Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ members = [
]

[workspace.package]
version = "0.1.52"
version = "0.1.53"
edition = "2024"
publish = false

Expand Down
2 changes: 2 additions & 0 deletions apps/gui/frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export interface AgencyZeroApi {
* all — the click lands and nothing happens.
*/
chooseDataDirectory(): Promise<string | null>;
/** A working directory for a project. Starts at home, not beside the store. */
chooseProjectDirectory(): Promise<string | null>;
/**
* Open the OS file picker, for the composer's Attach button. The chosen
* paths land in the prompt as text — the agents read file paths in prose,
Expand Down
1 change: 1 addition & 0 deletions apps/gui/frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const COMMAND_FOR: Partial<Record<keyof AgencyZeroApi, string>> = {
getDataLocation: "get_data_location",
setDataLocation: "set_data_location",
chooseDataDirectory: "choose_data_directory",
chooseProjectDirectory: "choose_project_directory",
chooseAttachments: "choose_attachments",
getWorkspaceRoot: "get_workspace_root",
createWorkspaceRoot: "create_workspace_root",
Expand Down
2 changes: 2 additions & 0 deletions apps/gui/frontend/src/api/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,8 @@ export function createMockApi(): AgencyZeroApi {
* they do not have.
*/
chooseDataDirectory: () => settle(null),
// No native panel in the preview; the typed path still works.
chooseProjectDirectory: () => settle(null),
// A fixed fixture path: the preview has no OS picker to open.
chooseAttachments: () => settle(["/tmp/mock-attachment.txt"]),

Expand Down
1 change: 1 addition & 0 deletions apps/gui/frontend/src/api/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export function createTauriApi(): AgencyZeroApi {
getDataLocation: () => call("get_data_location"),
setDataLocation: (path) => call("set_data_location", { path }),
chooseDataDirectory: () => call("choose_data_directory"),
chooseProjectDirectory: () => call("choose_project_directory"),
chooseAttachments: () => call("choose_attachments"),
getWorkspaceRoot: () => call("get_workspace_root"),
createWorkspaceRoot: () => call("create_workspace_root"),
Expand Down
60 changes: 44 additions & 16 deletions apps/gui/frontend/src/features/project/ProjectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,12 +300,26 @@ const IO_TONE: Record<string, string> = {
* in the composer, which is the note this section ends on.
*/
function SettingsSection(props: { project: Project }): JSX.Element {
const { state, actions } = useWorkspace();
const { state, actions, isLive } = useWorkspace();
const [adding, setAdding] = createSignal(false);
const [path, setPath] = createSignal("");

const moderatorDefault = () => state.settings?.moderator.enabled ?? true;

/** The native panel, then straight into the list: no second confirmation. */
async function pick(): Promise<void> {
try {
const picked = await actions.chooseProjectDirectory();
if (picked) {
await actions.addDir(props.project.id, picked);
setAdding(false);
setPath("");
}
} catch (cause) {
log.error(`could not choose a directory: ${describeError(cause)}`);
}
}

async function addDir(): Promise<void> {
const value = path().trim();
if (!value) return;
Expand Down Expand Up @@ -359,22 +373,36 @@ function SettingsSection(props: { project: Project }): JSX.Element {
}
>
{/*
A typed path rather than a native folder picker: opening one needs
the Tauri dialog plugin, which is not wired up on the Rust side yet.
Type a path or pick one. The picker matters more than it looks: a
typed path is how a project ends up pointed at a directory that is
not a checkout, and a project with no checkout can have no pull
requests discovered for it, silently.
*/}
<input
autofocus
value={path()}
placeholder="~/src/…"
aria-label="Working directory path"
onInput={(event) => setPath(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void addDir();
if (event.key === "Escape") setAdding(false);
}}
onBlur={() => void addDir()}
class="rounded-[9px] border border-primary/40 bg-base-300 px-2.5 py-[7px] font-mono text-[11.5px] text-az-body focus:outline-none"
/>
<div class="flex items-center gap-1.5">
<button
type="button"
onClick={() => void pick()}
disabled={!isLive("chooseProjectDirectory")}
aria-label="Choose a working directory"
title="Choose a folder"
class="shrink-0 cursor-pointer rounded-[9px] border border-primary/40 px-2 py-[7px] text-az-body transition-colors hover:border-primary hover:text-primary disabled:opacity-40"
>
<Icon name="folder-plus" class="text-[13px]" />
</button>
<input
autofocus
value={path()}
placeholder="~/src/…"
aria-label="Working directory path"
onInput={(event) => setPath(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void addDir();
if (event.key === "Escape") setAdding(false);
}}
onBlur={() => void addDir()}
class="min-w-0 flex-1 rounded-[9px] border border-primary/40 bg-base-300 px-2.5 py-[7px] font-mono text-[11.5px] text-az-body focus:outline-none"
/>
</div>
</Show>

<div class="my-0.5 h-px bg-az-hairline-soft" />
Expand Down
4 changes: 4 additions & 0 deletions apps/gui/frontend/src/stores/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1553,6 +1553,10 @@ function createWorkspace() {
const picked = await client().chooseDataDirectory();
if (picked) await actions.setDataLocation(picked);
},
/** The native folder panel, for a project's working directories. */
chooseProjectDirectory() {
return client().chooseProjectDirectory();
},
/** Open a link in the browser. See the Rust command for the scheme rule. */
openExternal(url: string) {
return client().openExternal(url);
Expand Down
32 changes: 32 additions & 0 deletions apps/gui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const IMPLEMENTED: &[&str] = &[
"get_data_location",
"set_data_location",
"choose_data_directory",
"choose_project_directory",
"get_workspace_root",
"create_workspace_root",
"list_projects",
Expand Down Expand Up @@ -388,6 +389,36 @@ async fn choose_data_directory(
Ok(picked.map(|path| path.to_string()))
}

/// Ask the OS for a working directory, for a project's Settings section.
///
/// Separate from [`choose_data_directory`] because it starts somewhere else:
/// a checkout lives under home, not beside the store. The panel asked for a
/// typed path with a note saying a picker needed the dialog plugin, which has
/// been wired since; a typed path is also how a project ends up pointed at a
/// directory that is not a checkout, which is exactly what stopped pull
/// requests being discovered.
#[tauri::command]
async fn choose_project_directory(app: AppHandle) -> Result<Option<String>, String> {
let mut dialog = app.dialog().file().set_title("Choose a working directory");
if let Some(home) = dirs_home() {
dialog = dialog.set_directory(home);
}
// The callback form, never the blocking one: see `choose_data_directory`.
let (tx, rx) = tokio::sync::oneshot::channel();
dialog.pick_folder(move |picked| {
let _ = tx.send(picked);
});
let picked = rx
.await
.map_err(|_| "the directory picker closed without answering".to_string())?;
Ok(picked.map(|path| path.to_string()))
}

/// The user's home, or nothing when the platform will not say.
fn dirs_home() -> Option<std::path::PathBuf> {
std::env::var_os("HOME").map(std::path::PathBuf::from)
}

/// Ask the OS for files, for the composer's Attach button.
///
/// The chosen paths land in the prompt as text, the agents take file paths
Expand Down Expand Up @@ -982,6 +1013,7 @@ fn main() {
get_data_location,
set_data_location,
choose_data_directory,
choose_project_directory,
get_workspace_root,
create_workspace_root,
projects::list_projects,
Expand Down
26 changes: 25 additions & 1 deletion apps/gui/src/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -767,10 +767,34 @@ pub async fn set_item_status(
status: String,
state: State<'_, AppState>,
) -> Result<ProjectItemDto, String> {
/*
* Every status the ladder can reach, including `questions` and `canceled`.
*
* `questions` was missing, and the marker's ladder walks through it, so a
* click on an `active` row asked for a status this command refused and the
* row did not move. From the outside that reads as the cycle stopping
* partway with no way to carry on or to correct a misclick, and nothing
* said why: the refusal reached the promise and not the panel.
*
* The list here and `ITEM_LADDER` in the frontend are the same vocabulary
* in two places, which is how they drifted. `ProjectStatus` is the source.
*/
if !matches!(
status.as_str(),
"new" | "pending" | "planning" | "active" | "shipped" | "finished"
"new"
| "pending"
| "planning"
| "active"
| "questions"
| "shipped"
| "finished"
| "canceled"
) {
crate::log!(
crate::log::Level::Error,
"items",
"refused status {status:?} for {id}: not one this app knows"
);
return Err(format!("not an item status: {status}"));
}
state
Expand Down
24 changes: 21 additions & 3 deletions apps/gui/src/prs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,14 @@ fn ci_word(state: Option<&str>) -> String {
pub fn refresh_project(app: AppHandle, project_id: String) {
tauri::async_runtime::spawn(async move {
let state = app.state::<AppState>();
let rows: Vec<PullRequestRow> = state
let known: Vec<PullRequestRow> = state
.tables
.pull_request
.select_by_project_id(project_id.clone())
.execute()
.unwrap_or_default()
.unwrap_or_default();
let rows: Vec<PullRequestRow> = known
.clone()
.into_iter()
// Endings, and rows nobody is looking at. A settled list costs
// nothing, which is what makes a short interval affordable.
Expand All @@ -258,7 +260,23 @@ pub fn refresh_project(app: AppHandle, project_id: String) {
* yet. That is what lets a pull request appear because it exists
* rather than because someone wrote its URL in a reply.
*/
let repos = repos_for(&state, &project_id, &rows).await;
/*
* Every row, not the open ones. A merged pull request still says which
* repository it belonged to, and reading only the open rows meant the
* repository was forgotten the moment the last one settled: discovery
* then had nothing to ask about and stopped, silently, exactly when
* the list looked finished.
*/
let repos = repos_for(&state, &project_id, &known).await;
if repos.is_empty() {
crate::log!(
crate::log::Level::Warn,
"prs",
"{project_id}: no repository to ask about. None of its directories is a git \
checkout and no pull request has been recorded, so none can be discovered. \
Add the checkout to the project's directories."
);
}
if repos.is_empty() {
return;
}
Expand Down
Loading