diff --git a/Cargo.lock b/Cargo.lock index ad71e25..0f51a09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,25 +142,25 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "az-agent" -version = "0.1.52" +version = "0.1.53" dependencies = [ "az-core", ] [[package]] name = "az-agent-proxy" -version = "0.1.52" +version = "0.1.53" dependencies = [ "az-core", ] [[package]] name = "az-core" -version = "0.1.52" +version = "0.1.53" [[package]] name = "az-gui" -version = "0.1.52" +version = "0.1.53" dependencies = [ "agent-abstraction", "az-core", @@ -183,7 +183,7 @@ dependencies = [ [[package]] name = "az-mcp-proxy" -version = "0.1.52" +version = "0.1.53" dependencies = [ "az-core", ] @@ -5551,7 +5551,7 @@ dependencies = [ [[package]] name = "wt-migrate" -version = "0.1.52" +version = "0.1.53" dependencies = [ "derive_more 2.1.1", "eyre", @@ -5565,7 +5565,7 @@ dependencies = [ [[package]] name = "wt-tools" -version = "0.1.52" +version = "0.1.53" dependencies = [ "derive_more 2.1.1", "dirs", diff --git a/Cargo.toml b/Cargo.toml index db39960..0c93950 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.1.52" +version = "0.1.53" edition = "2024" publish = false diff --git a/apps/gui/frontend/src/api/client.ts b/apps/gui/frontend/src/api/client.ts index 920028b..61092e8 100644 --- a/apps/gui/frontend/src/api/client.ts +++ b/apps/gui/frontend/src/api/client.ts @@ -145,6 +145,8 @@ export interface AgencyZeroApi { * all — the click lands and nothing happens. */ chooseDataDirectory(): Promise; + /** A working directory for a project. Starts at home, not beside the store. */ + chooseProjectDirectory(): Promise; /** * 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, diff --git a/apps/gui/frontend/src/api/index.ts b/apps/gui/frontend/src/api/index.ts index b18eb66..3e77c9a 100644 --- a/apps/gui/frontend/src/api/index.ts +++ b/apps/gui/frontend/src/api/index.ts @@ -47,6 +47,7 @@ const COMMAND_FOR: Partial> = { 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", diff --git a/apps/gui/frontend/src/api/mock.ts b/apps/gui/frontend/src/api/mock.ts index c0772e4..e35a14c 100644 --- a/apps/gui/frontend/src/api/mock.ts +++ b/apps/gui/frontend/src/api/mock.ts @@ -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"]), diff --git a/apps/gui/frontend/src/api/tauri.ts b/apps/gui/frontend/src/api/tauri.ts index 5032057..49620d1 100644 --- a/apps/gui/frontend/src/api/tauri.ts +++ b/apps/gui/frontend/src/api/tauri.ts @@ -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"), diff --git a/apps/gui/frontend/src/features/project/ProjectPanel.tsx b/apps/gui/frontend/src/features/project/ProjectPanel.tsx index 5b65a61..0656bac 100644 --- a/apps/gui/frontend/src/features/project/ProjectPanel.tsx +++ b/apps/gui/frontend/src/features/project/ProjectPanel.tsx @@ -300,12 +300,26 @@ const IO_TONE: Record = { * 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 { + 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 { const value = path().trim(); if (!value) return; @@ -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. */} - 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" - /> +
+ + 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" + /> +
diff --git a/apps/gui/frontend/src/stores/workspace.tsx b/apps/gui/frontend/src/stores/workspace.tsx index a514fff..9b5a055 100644 --- a/apps/gui/frontend/src/stores/workspace.tsx +++ b/apps/gui/frontend/src/stores/workspace.tsx @@ -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); diff --git a/apps/gui/src/main.rs b/apps/gui/src/main.rs index f67e9c7..06fb5f6 100644 --- a/apps/gui/src/main.rs +++ b/apps/gui/src/main.rs @@ -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", @@ -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, 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::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 @@ -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, diff --git a/apps/gui/src/projects.rs b/apps/gui/src/projects.rs index d61e90f..2af2e6c 100644 --- a/apps/gui/src/projects.rs +++ b/apps/gui/src/projects.rs @@ -767,10 +767,34 @@ pub async fn set_item_status( status: String, state: State<'_, AppState>, ) -> Result { + /* + * 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 diff --git a/apps/gui/src/prs.rs b/apps/gui/src/prs.rs index 585e02d..28c84ca 100644 --- a/apps/gui/src/prs.rs +++ b/apps/gui/src/prs.rs @@ -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::(); - let rows: Vec = state + let known: Vec = state .tables .pull_request .select_by_project_id(project_id.clone()) .execute() - .unwrap_or_default() + .unwrap_or_default(); + let rows: Vec = known + .clone() .into_iter() // Endings, and rows nobody is looking at. A settled list costs // nothing, which is what makes a short interval affordable. @@ -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; }