From 41bc501233c8453fa80e1cd180c2bbc90e00801c Mon Sep 17 00:00:00 2001 From: David Veszelovszki Date: Tue, 21 Jul 2026 03:23:25 +0200 Subject: [PATCH 1/4] Ask Cmdr: review and apply natural-language bulk renames --- apps/desktop/src-tauri/DETAILS.md | 3 + apps/desktop/src-tauri/src/agent/DETAILS.md | 3 + .../src-tauri/src/agent/chat/DETAILS.md | 6 +- .../src-tauri/src/agent/chat/context.rs | 2 +- .../src-tauri/src/agent/chat/runtime.rs | 28 +- .../src-tauri/src/agent/chat/runtime/tests.rs | 46 +- .../src-tauri/src/agent/chat/system_prompt.rs | 23 +- .../src-tauri/src/agent/llm/DETAILS.md | 3 +- .../src-tauri/src/agent/llm/genai_impl.rs | 11 + apps/desktop/src-tauri/src/agent/llm/types.rs | 14 +- apps/desktop/src-tauri/src/agent/mod.rs | 1 + .../src-tauri/src/agent/tools/CLAUDE.md | 8 +- .../src-tauri/src/agent/tools/DETAILS.md | 3 + apps/desktop/src-tauri/src/agent/tools/mod.rs | 1 + .../src/agent/tools/propose/CLAUDE.md | 3 + .../src/agent/tools/propose/DETAILS.md | 5 + .../src-tauri/src/agent/tools/propose/mod.rs | 4 + .../src/agent/tools/propose/rename.rs | 782 ++++++++++++++++++ .../src-tauri/src/agent/tools/read/mod.rs | 1 + .../src/agent/tools/read/pane_listing.rs | 234 ++++++ .../src-tauri/src/agent/tools/read/state.rs | 48 ++ .../desktop/src-tauri/src/agent/tools/view.rs | 27 +- .../desktop/src-tauri/src/commands/DETAILS.md | 4 + apps/desktop/src-tauri/src/commands/agent.rs | 154 +++- .../src/file_system/listing/DETAILS.md | 3 + .../src/file_system/listing/caching.rs | 14 + .../src-tauri/src/file_system/listing/mod.rs | 4 +- .../file_system/write_operations/DETAILS.md | 2 +- .../src/file_system/write_operations/mod.rs | 3 +- .../file_system/write_operations/rename.rs | 16 +- .../write_operations/rename/bulk.rs | 685 +++++++++++++++ .../write_operations/rename/bulk/tests.rs | 174 ++++ .../write_operations/rename/tests.rs | 62 +- apps/desktop/src-tauri/src/ipc.rs | 3 + apps/desktop/src-tauri/src/ipc_collectors.rs | 3 + apps/desktop/src-tauri/src/mcp/DETAILS.md | 3 +- .../src/mcp/tests/tool_registry_tests.rs | 4 +- .../src-tauri/src/mcp/tool_registry/mod.rs | 16 + .../src/lib/ask-cmdr/AskCmdrMessage.svelte | 33 +- .../src/lib/ask-cmdr/AskCmdrToolLine.svelte | 3 + .../BulkRenameReviewDialog.a11y.test.ts | 173 ++++ .../ask-cmdr/BulkRenameReviewDialog.svelte | 174 ++++ apps/desktop/src/lib/ask-cmdr/DETAILS.md | 15 + .../src/lib/ask-cmdr/ask-cmdr-labels.ts | 2 + .../lib/ask-cmdr/ask-cmdr-trigger.svelte.ts | 210 ++++- .../src/lib/ask-cmdr/ask-cmdr-trigger.test.ts | 84 ++ .../src/lib/file-explorer/pane/CLAUDE.md | 4 +- .../pane/DualPaneExplorer.svelte | 1 + .../pane/DualPaneExplorer.test.ts | 28 + apps/desktop/src/lib/intl/DETAILS.md | 3 + apps/desktop/src/lib/intl/keys.gen.ts | 16 + .../src/lib/intl/messages/de/askCmdr.json | 64 ++ .../src/lib/intl/messages/en/askCmdr.json | 69 ++ .../src/lib/intl/messages/es/askCmdr.json | 64 ++ .../src/lib/intl/messages/fr/askCmdr.json | 64 ++ .../src/lib/intl/messages/hu/askCmdr.json | 64 ++ .../src/lib/intl/messages/nl/askCmdr.json | 64 ++ .../src/lib/intl/messages/pt/askCmdr.json | 64 ++ .../src/lib/intl/messages/sv/askCmdr.json | 64 ++ .../src/lib/intl/messages/vi/askCmdr.json | 64 ++ .../src/lib/intl/messages/zh/askCmdr.json | 64 ++ apps/desktop/src/lib/ipc/DETAILS.md | 4 + apps/desktop/src/lib/ipc/bindings.ts | 44 + .../lib/onboarding/CloudProviderSetup.test.ts | 6 + apps/desktop/src/lib/onboarding/DETAILS.md | 3 +- apps/desktop/src/lib/settings/DETAILS.md | 9 +- .../src/lib/settings/cloud-providers.test.ts | 9 + .../src/lib/settings/cloud-providers.ts | 12 +- .../desktop/src/lib/tauri-commands/DETAILS.md | 4 + .../src/lib/tauri-commands/ask-cmdr.ts | 24 + apps/desktop/src/lib/tauri-commands/index.ts | 3 + apps/desktop/src/lib/ui/DETAILS.md | 3 + apps/desktop/src/lib/ui/dialog-registry.ts | 1 + apps/desktop/src/routes/(main)/+page.svelte | 4 + apps/desktop/src/routes/(main)/DETAILS.md | 3 +- docs/specs/index.md | 5 + .../natural-language-bulk-rename-plan.md | 246 ++++++ 77 files changed, 4037 insertions(+), 140 deletions(-) create mode 100644 apps/desktop/src-tauri/src/agent/tools/propose/CLAUDE.md create mode 100644 apps/desktop/src-tauri/src/agent/tools/propose/DETAILS.md create mode 100644 apps/desktop/src-tauri/src/agent/tools/propose/mod.rs create mode 100644 apps/desktop/src-tauri/src/agent/tools/propose/rename.rs create mode 100644 apps/desktop/src-tauri/src/agent/tools/read/pane_listing.rs create mode 100644 apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk.rs create mode 100644 apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk/tests.rs create mode 100644 apps/desktop/src/lib/ask-cmdr/BulkRenameReviewDialog.a11y.test.ts create mode 100644 apps/desktop/src/lib/ask-cmdr/BulkRenameReviewDialog.svelte create mode 100644 docs/specs/natural-language-bulk-rename-plan.md diff --git a/apps/desktop/src-tauri/DETAILS.md b/apps/desktop/src-tauri/DETAILS.md index 5a882d208..7500e928c 100644 --- a/apps/desktop/src-tauri/DETAILS.md +++ b/apps/desktop/src-tauri/DETAILS.md @@ -31,3 +31,6 @@ re-evaluate on the next rc and bump all three together or not at all. `bindings.ts` is generated: change this behavior at the `builder()` call site and regenerate with `pnpm bindings:regen`, never by hand-editing the output. + +The Ask Cmdr bulk-rename review commands register in the same builder and type collector as every other typed command. +Their authority and filesystem behavior live with the agent and write-operation modules, not at this registration edge. diff --git a/apps/desktop/src-tauri/src/agent/DETAILS.md b/apps/desktop/src-tauri/src/agent/DETAILS.md index 219d99a08..06751908f 100644 --- a/apps/desktop/src-tauri/src/agent/DETAILS.md +++ b/apps/desktop/src-tauri/src/agent/DETAILS.md @@ -34,6 +34,9 @@ Construction plan: [`docs/specs/ask-cmdr-plan.md`](../../../../../docs/specs/ask ## The agent can propose; only the user can approve +`RenameProposalStore` is managed with the agent runtime. It holds short-lived immutable rename proposals by opaque id; +the tool can stage one, but no agent path can approve or apply it. + **The invariant.** The agent can propose. Only the user can approve. Approval originates in the frontend as a user action. There is no tool, and never will be a tool, that approves a proposal. Without that, `Propose` is `Write` with extra steps. diff --git a/apps/desktop/src-tauri/src/agent/chat/DETAILS.md b/apps/desktop/src-tauri/src/agent/chat/DETAILS.md index 5e187c903..45e59ffe6 100644 --- a/apps/desktop/src-tauri/src/agent/chat/DETAILS.md +++ b/apps/desktop/src-tauri/src/agent/chat/DETAILS.md @@ -52,7 +52,8 @@ shows the soft-cap nudge — summarize-on-overflow is deferred (spec §3). ## Constants table (§10; initial values, tune with use) - `MAX_TOOL_TURNS = 8` — per message; the loop stops before the 9th tool respond fires. -- `MAX_WALL_TIME = 60s` — per message wall-clock ceiling across the whole loop. +- `MAX_WALL_TIME = 120s` — per message wall-clock ceiling across the whole loop; leaves room for reasoning-heavy + OpenAI-compatible models while the tool-turn cap still prevents a runaway loop. - `CONTEXT_TOKEN_BUDGET = 8_000` — target assembled-prompt size (spec's 6–10k band). - `ELIDE_TOOL_RESULTS_AFTER_TURNS = 3` — tool results this many turns back (or more) elide. - `THREAD_SOFT_CAP_MESSAGES = 40` — past this the UI nudges "start a fresh one?". @@ -94,6 +95,9 @@ state is unambiguous: ### Model-change events +`ProposalReady` is a display-only stream event. The runtime emits it only after the proposal dispatcher staged +immutable rows in `RenameProposalStore`; chat history persists the concise tool result, not proposal authority. + A settings change can switch a thread's effective model mid-conversation; the thread logs it honestly as a UI-facing event row (`store::ConversationEvent::ModelChanged`) so the user sees which replies used which model. Two cooperating paths, one comparison diff --git a/apps/desktop/src-tauri/src/agent/chat/context.rs b/apps/desktop/src-tauri/src/agent/chat/context.rs index 8c6de23cc..e1b95483d 100644 --- a/apps/desktop/src-tauri/src/agent/chat/context.rs +++ b/apps/desktop/src-tauri/src/agent/chat/context.rs @@ -34,7 +34,7 @@ pub const MAX_TOOL_TURNS: usize = 8; /// Per user message wall-clock ceiling across the whole tool loop. Initial value; /// tune with use. -pub const MAX_WALL_TIME: Duration = Duration::from_secs(60); +pub const MAX_WALL_TIME: Duration = Duration::from_secs(120); /// Target assembled-prompt size per call, in estimated tokens (spec's 6-10k band). /// Assembly elides older tool results until it fits, never touching assistant prose. diff --git a/apps/desktop/src-tauri/src/agent/chat/runtime.rs b/apps/desktop/src-tauri/src/agent/chat/runtime.rs index 55680a66d..4785ca4a2 100644 --- a/apps/desktop/src-tauri/src/agent/chat/runtime.rs +++ b/apps/desktop/src-tauri/src/agent/chat/runtime.rs @@ -52,6 +52,7 @@ use crate::ignore_poison::IgnorePoison; use super::context::{self, ContextEnvelope, MAX_TOOL_TURNS, MAX_WALL_TIME, PrefixInputs}; use super::system_prompt::SYSTEM_PROMPT; +use crate::agent::tools::propose::rename::RenameProposalSnapshot; const LOG_TARGET: &str = "agent::chat"; @@ -85,6 +86,9 @@ pub enum AgentChatEvent { /// A tool call finished dispatching. `ok` is false for a refusal or a handler /// problem (inspected from OUR OWN typed result shape, not external wording). ToolCallFinished { call_id: String, ok: bool }, + /// A server-owned rename proposal is ready for the review surface. The + /// snapshot is display-only; later approval uses only opaque row ids. + ProposalReady { proposal: RenameProposalSnapshot }, /// The turn produced its final answer. Carries the persisted assistant message id. Done { message_id: i64, @@ -155,7 +159,12 @@ fn emit(sink: &ChatEventSink, event: AgentChatEvent) { /// read-only dispatch view ([`AppHandleDispatcher`]); tests inject a scripted double, /// so [`run_turn`] needs no Tauri app. pub trait ToolDispatcher: Send + Sync { - fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, AgentToolResult>; + fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, ToolDispatchOutcome>; +} + +pub struct ToolDispatchOutcome { + pub result: AgentToolResult, + pub proposal: Option, } /// The production dispatcher: every call goes through `agent::tools::view::dispatch`, @@ -171,8 +180,15 @@ impl AppHandleDispatcher { } impl ToolDispatcher for AppHandleDispatcher { - fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, AgentToolResult> { - async move { crate::agent::tools::view::dispatch(&self.app, call).await }.boxed() + fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, ToolDispatchOutcome> { + async move { + let outcome = crate::agent::tools::view::dispatch(&self.app, call).await; + ToolDispatchOutcome { + result: outcome.result, + proposal: outcome.proposal, + } + } + .boxed() } } @@ -431,7 +447,8 @@ pub async fn run_turn( tool_turns += 1; for part in &message.parts { let AgentPart::ToolCall(call) = part else { continue }; - let result = dispatcher.dispatch(call).await; + let dispatch = dispatcher.dispatch(call).await; + let result = dispatch.result; emit( sink, AgentChatEvent::ToolCallFinished { @@ -456,6 +473,9 @@ pub async fn run_turn( ) { return persist_failed(sink, e); } + if let Some(proposal) = dispatch.proposal { + emit(sink, AgentChatEvent::ProposalReady { proposal }); + } transcript.push(tool_message); } } diff --git a/apps/desktop/src-tauri/src/agent/chat/runtime/tests.rs b/apps/desktop/src-tauri/src/agent/chat/runtime/tests.rs index bd3dc49e0..1dcce54c3 100644 --- a/apps/desktop/src-tauri/src/agent/chat/runtime/tests.rs +++ b/apps/desktop/src-tauri/src/agent/chat/runtime/tests.rs @@ -218,12 +218,15 @@ fn program_to_deltas(program: Program) -> Vec> struct OkDispatcher; impl ToolDispatcher for OkDispatcher { - fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, AgentToolResult> { + fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, ToolDispatchOutcome> { async move { - AgentToolResult { - call_id: call.call_id.clone(), - content: json!({ "looked_at": call.tool.as_wire_name() }), - elided: false, + ToolDispatchOutcome { + result: AgentToolResult { + call_id: call.call_id.clone(), + content: json!({ "looked_at": call.tool.as_wire_name() }), + elided: false, + }, + proposal: None, } } .boxed() @@ -237,13 +240,16 @@ struct SleepingDispatcher { } impl ToolDispatcher for SleepingDispatcher { - fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, AgentToolResult> { + fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, ToolDispatchOutcome> { async move { tokio::time::sleep(Duration::from_secs(self.secs)).await; - AgentToolResult { - call_id: call.call_id.clone(), - content: json!({ "ok": true }), - elided: false, + ToolDispatchOutcome { + result: AgentToolResult { + call_id: call.call_id.clone(), + content: json!({ "ok": true }), + elided: false, + }, + proposal: None, } } .boxed() @@ -256,13 +262,16 @@ struct CancellingDispatcher { } impl ToolDispatcher for CancellingDispatcher { - fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, AgentToolResult> { + fn dispatch<'a>(&'a self, call: &'a AgentToolCall) -> BoxFuture<'a, ToolDispatchOutcome> { async move { self.token.cancel(); - AgentToolResult { - call_id: call.call_id.clone(), - content: json!({ "ok": true }), - elided: false, + ToolDispatchOutcome { + result: AgentToolResult { + call_id: call.call_id.clone(), + content: json!({ "ok": true }), + elided: false, + }, + proposal: None, } } .boxed() @@ -354,11 +363,12 @@ async fn max_wall_time_halts_the_loop() { let llm = ProgrammableLlm::new(programs); let (tx, _rx) = unbounded_channel(); - // Each dispatch burns 61 virtual seconds, so the wall-time ceiling trips after one - // tool round. + // One dispatch crosses the configured wall-time ceiling, so it trips after one tool round. let result = run_turn( &llm, - &SleepingDispatcher { secs: 61 }, + &SleepingDispatcher { + secs: MAX_WALL_TIME.as_secs() + 1, + }, &conn, &[], ¶ms(id, Some("slow please")), diff --git a/apps/desktop/src-tauri/src/agent/chat/system_prompt.rs b/apps/desktop/src-tauri/src/agent/chat/system_prompt.rs index 051dbaa33..ad8c78edd 100644 --- a/apps/desktop/src-tauri/src/agent/chat/system_prompt.rs +++ b/apps/desktop/src-tauri/src/agent/chat/system_prompt.rs @@ -22,12 +22,18 @@ You help the user understand their files by looking at what Cmdr already knows: drive index (sizes, listings, recency), the importance of folders, the operation log, \ and the live app state (panes, cursor, selection, volumes). -You are read-only. You can look and speak, never act. You have no tool that changes, \ -moves, deletes, or renames anything, and no tool that reads the contents of a file. \ +You can look and speak, and you can prepare a rename plan for the user to review. You never \ +act: you have no tool that changes, moves, deletes, or renames anything, and no tool that reads the contents of a file. \ Only names, paths, and metadata reach you, never file contents. If the user asks you \ -to change something or read a file's contents, tell them plainly that this version of \ -Ask Cmdr can only look and answer, and point them at the app's own commands for the \ -action. +to change something, explain that only they can approve a prepared rename plan, or point them at the app's own commands. + +For a natural-language rename request, call list_pane_files first. It returns the focused \ +selection when one exists, otherwise the focused folder, plus the exact volume ID for the plan. Treat \ +that pane listing as ready to use: do not wait for a drive scan or image indexing to finish. The \ +propose_rename_plan tool is always available. Use image_facts only when image contents would \ +improve the names; if it is unavailable or incomplete, continue with names, dates, and other \ +available metadata. Preserve each file extension unless the user explicitly asks otherwise. Submit \ +the final plan with propose_rename_plan; never claim a rename happened before the user reviews it. Be honest about coverage. The tools tell you when their answer is partial: an index \ that is still scanning or stale, a size that is a lower bound, an unmounted or \ @@ -53,13 +59,16 @@ mod tests { #[test] fn prompt_states_the_read_only_self_description() { - assert!(SYSTEM_PROMPT.contains("read-only"), "must describe itself as read-only"); + assert!( + SYSTEM_PROMPT.contains("prepare a rename plan"), + "must describe its proposal-only power" + ); assert!( SYSTEM_PROMPT.contains("never file contents"), "must state file contents never reach it (the privacy line)" ); assert!( - SYSTEM_PROMPT.contains("look and speak, never act"), + SYSTEM_PROMPT.contains("never act"), "must state it can look and speak but never act" ); } diff --git a/apps/desktop/src-tauri/src/agent/llm/DETAILS.md b/apps/desktop/src-tauri/src/agent/llm/DETAILS.md index a1fd748be..a17d06682 100644 --- a/apps/desktop/src-tauri/src/agent/llm/DETAILS.md +++ b/apps/desktop/src-tauri/src/agent/llm/DETAILS.md @@ -93,7 +93,8 @@ typed "tool not available" result. The read-only guarantee is that `Unrecognized in `agent_tool_view()`, so dispatch refuses it — a typed view-membership check, not a string match. The known variants are the read-only families (`AppState`, `ListDir`, `LargestDirs`, `ImportantFolders`, `FolderImportance`, `ListVolumes`, `OperationsList`, `OperationsGet`, `SearchPhotos`, `ImageFacts`), pinned 1:1 to `agent_tool_view()` by a structural test in `agent/tools`; -`ToolId::KNOWN` excludes `Unrecognized` by design. +`ToolId::KNOWN` excludes `Unrecognized` by design. `ProposeRenamePlan` is the one proposing variant: it stages a +reviewable plan and remains subject to the same typed-view gate, never an approval or write route. ## Tests diff --git a/apps/desktop/src-tauri/src/agent/llm/genai_impl.rs b/apps/desktop/src-tauri/src/agent/llm/genai_impl.rs index 5ae37b804..ca4e5e2e3 100644 --- a/apps/desktop/src-tauri/src/agent/llm/genai_impl.rs +++ b/apps/desktop/src-tauri/src/agent/llm/genai_impl.rs @@ -33,6 +33,10 @@ use super::{AgentDeltaStream, AgentLlm}; const LOG_TARGET: &str = "agent::llm"; +/// Per-call output room for reasoning-heavy OpenAI-compatible models. Reasoning tokens +/// share this budget with visible text and tool calls. +const AGENT_MAX_OUTPUT_TOKENS: u32 = 12_000; + /// The genai-backed agent LLM. Wraps a configured [`AiBackend`] (the interactive /// model slot; the slot is resolved in the command layer) and reuses its adapter routing and /// stream-cancel model. @@ -112,6 +116,7 @@ fn build_request(system: &str, tools: &[ToolDeclaration], messages: &[AgentMessa /// plus the per-provider reasoning posture. fn build_options(adapter: AdapterKind) -> ChatOptions { let options = ChatOptions::default() + .with_max_tokens(AGENT_MAX_OUTPUT_TOKENS) .with_capture_content(true) .with_capture_tool_calls(true) .with_capture_usage(true) @@ -563,6 +568,12 @@ mod tests { ); } + #[test] + fn agent_calls_leave_room_for_reasoning_and_a_tool_call() { + let options = build_options(AdapterKind::OpenAI); + assert_eq!(options.max_tokens, Some(AGENT_MAX_OUTPUT_TOKENS)); + } + #[test] fn ai_error_maps_to_typed_agent_error() { // Status-classified upstream (by HTTP status), mapped variant-to-variant diff --git a/apps/desktop/src-tauri/src/agent/llm/types.rs b/apps/desktop/src-tauri/src/agent/llm/types.rs index a30b3d977..361a3c0c5 100644 --- a/apps/desktop/src-tauri/src/agent/llm/types.rs +++ b/apps/desktop/src-tauri/src/agent/llm/types.rs @@ -148,6 +148,9 @@ pub enum ToolId { /// One directory's immediate children plus its recursive size stats, from the /// drive index. ListDir, + /// Up to 200 entries from the focused pane's current backend listing cache, + /// scoped to the selection when present. + ListPaneFiles, /// The largest subdirectories under a path, by recursive size (batches dir /// stats and sorts). LargestDirs, @@ -169,6 +172,9 @@ pub enum ToolId { /// What the media index stored for images the agent already has: the full recognized /// text plus tags, per path (shared with the ai-client view). Text-only, no image bytes. ImageFacts, + /// Stages a same-folder rename plan for the user to review. It never applies + /// a rename and has no approval path. + ProposeRenamePlan, /// A tool name the agent does not recognize (hallucinated, a typo, or a /// write/non-view tool). Carries the raw name for the transparent UI and the /// typed "tool not available" result; always refused by dispatch. @@ -179,9 +185,10 @@ impl ToolId { /// Every known read-only variant, in wire order. Excludes [`ToolId::Unrecognized`] /// by design (it's the refusal case, never a view entry). The 1:1 structural test /// asserts these map exactly onto `agent_tool_view()`. - pub const KNOWN: [ToolId; 10] = [ + pub const KNOWN: [ToolId; 12] = [ ToolId::AppState, ToolId::ListDir, + ToolId::ListPaneFiles, ToolId::LargestDirs, ToolId::ImportantFolders, ToolId::FolderImportance, @@ -190,6 +197,7 @@ impl ToolId { ToolId::OperationsGet, ToolId::SearchPhotos, ToolId::ImageFacts, + ToolId::ProposeRenamePlan, ]; /// The wire name for this tool: the genai `fn_name`, the DB token, and the IPC @@ -198,6 +206,7 @@ impl ToolId { match self { ToolId::AppState => "app_state", ToolId::ListDir => "list_dir", + ToolId::ListPaneFiles => "list_pane_files", ToolId::LargestDirs => "largest_dirs", ToolId::ImportantFolders => "important_folders", ToolId::FolderImportance => "folder_importance", @@ -206,6 +215,7 @@ impl ToolId { ToolId::OperationsGet => "operations_get", ToolId::SearchPhotos => "search_photos", ToolId::ImageFacts => "image_facts", + ToolId::ProposeRenamePlan => "propose_rename_plan", ToolId::Unrecognized(name) => name.as_str(), } } @@ -218,6 +228,7 @@ impl ToolId { match name { "app_state" => ToolId::AppState, "list_dir" => ToolId::ListDir, + "list_pane_files" => ToolId::ListPaneFiles, "largest_dirs" => ToolId::LargestDirs, "important_folders" => ToolId::ImportantFolders, "folder_importance" => ToolId::FolderImportance, @@ -226,6 +237,7 @@ impl ToolId { "operations_get" => ToolId::OperationsGet, "search_photos" => ToolId::SearchPhotos, "image_facts" => ToolId::ImageFacts, + "propose_rename_plan" => ToolId::ProposeRenamePlan, other => ToolId::Unrecognized(other.to_string()), } } diff --git a/apps/desktop/src-tauri/src/agent/mod.rs b/apps/desktop/src-tauri/src/agent/mod.rs index 86e90591c..e802aa373 100644 --- a/apps/desktop/src-tauri/src/agent/mod.rs +++ b/apps/desktop/src-tauri/src/agent/mod.rs @@ -75,6 +75,7 @@ pub fn start(app: &AppHandle) { app.manage(AgentDb { db_path: db_path.clone(), }); + app.manage(tools::propose::rename::RenameProposalStore::default()); // Register the chat runtime against the same DB so the IPC command is a // thin pass-through (`app.state::()`). chat::runtime::register(app, db_path.clone()); diff --git a/apps/desktop/src-tauri/src/agent/tools/CLAUDE.md b/apps/desktop/src-tauri/src/agent/tools/CLAUDE.md index 9dcbfc133..65c23f0ea 100644 --- a/apps/desktop/src-tauri/src/agent/tools/CLAUDE.md +++ b/apps/desktop/src-tauri/src/agent/tools/CLAUDE.md @@ -7,11 +7,13 @@ list): [DETAILS.md](DETAILS.md). ## Module map -- `read/`: one file per family — `state` (`app_state`), `listing` (`list_dir` + `largest_dirs`), `importance` +- `read/`: one file per family — `state` (`app_state`), `pane_listing` (`list_pane_files`), `listing` (`list_dir` + + `largest_dirs`), `importance` (`important_folders` + `folder_importance`), `volumes` (`list_volumes`). The `operations_list` / `operations_get` and `search_photos` / `image_facts` (photo search and image lookup) tools are shared with the ai-client view, so their handlers live in `mcp/executor/` (`operation_log.rs`, `photos.rs`, `image_facts.rs`), not here. - `view.rs`: the gated dispatch — `dispatch` + `refuse_unavailable` (the read-only choke point). +- `propose/`: server-owned, immutable proposal staging. Its tools validate cached state only and never apply a change. - `mod.rs`: `agent_tool_declarations()` (registry view → `ToolDeclaration`s). ## Must-knows @@ -33,8 +35,8 @@ list): [DETAILS.md](DETAILS.md). `Access::Write`. A `Propose` tool stages a proposal and opens a review surface: it mutates nothing, it can't self-approve (no tool approves a proposal, ever), and it must cap its payload the way `image_facts` caps at 200 paths. Adding one also means adding its name to `EXPECTED_PROPOSE_TOOL_NAMES` by hand. Depth: [DETAILS.md](DETAILS.md). -- **Handlers read Rust-side stores + SQLite only — never a live `statfs`/`readdir` on a mount.** The index and - importance DBs answer everything, so a dead NAS can't hang a tool (the whole point of reading the cache). +- **Handlers read Rust-side stores, pane caches, + SQLite only — never a live `statfs`/`readdir` on a mount.** The + existing pane listing, index, and importance DBs answer everything, so a dead NAS can't hang a tool. - **The registry couples `mcp` ↔ `agent`.** The `mcp_tools!` entries reference `crate::agent::tools::read::*` handler + schema paths, and `agent::tools` calls back into `crate::mcp::{execute_tool, agent_tool_view, tool_access, Consumer, Access, ToolError, ToolResult}` (re-exported from `mcp` for exactly this). Same-crate cycle, intended (D49: one diff --git a/apps/desktop/src-tauri/src/agent/tools/DETAILS.md b/apps/desktop/src-tauri/src/agent/tools/DETAILS.md index 5e2552188..c6ddaf078 100644 --- a/apps/desktop/src-tauri/src/agent/tools/DETAILS.md +++ b/apps/desktop/src-tauri/src/agent/tools/DETAILS.md @@ -29,6 +29,9 @@ and returns a typed serde shape as the tool-result JSON the model reads. Every t - **`app_state`** (`read/state.rs`) — both panes (path, cursor item, selection count, view/sort) plus the volume list. Built from `PaneStateStore` (`get_focused_pane` returns the SIDE; the path comes from that side's state) + `snapshot_volumes`. Not the private `build_state_yaml` — typed data, not parsed YAML. +- **`list_pane_files`** (`read/pane_listing.rs`) — up to 200 compact rows from the focused pane's existing Rust + listing cache. It uses the current selection when present, otherwise the whole folder, and returns the exact volume + ID plus one shared parent path for `propose_rename_plan`. It never queries the index or starts a filesystem listing. - **`list_dir`** (`read/listing.rs`) — a directory's immediate children (`indexing::list_dir_children`, a new path-based helper added beside `get_dir_stats`) plus its recursive size stats (`get_dir_stats`) and a `Coverage` block. `Ok(None)` children ⇒ typed "not in index" / "no index", distinguished by whether the volume is indexed. diff --git a/apps/desktop/src-tauri/src/agent/tools/mod.rs b/apps/desktop/src-tauri/src/agent/tools/mod.rs index d4174b7d9..9f5d640e3 100644 --- a/apps/desktop/src-tauri/src/agent/tools/mod.rs +++ b/apps/desktop/src-tauri/src/agent/tools/mod.rs @@ -13,6 +13,7 @@ //! See `CLAUDE.md` for the must-knows (reuse the core; the honesty contract; the //! Unrecognized-out-of-view invariant) and `DETAILS.md` for the tool catalog. +pub mod propose; pub mod read; pub mod view; diff --git a/apps/desktop/src-tauri/src/agent/tools/propose/CLAUDE.md b/apps/desktop/src-tauri/src/agent/tools/propose/CLAUDE.md new file mode 100644 index 000000000..3440c69fe --- /dev/null +++ b/apps/desktop/src-tauri/src/agent/tools/propose/CLAUDE.md @@ -0,0 +1,3 @@ +# Rename proposals + +`rename.rs` stages Ask Cmdr's immutable, in-memory rename plans. It validates only cached pane and index state: never probe a live mount, follow a symlink, or rename anything here. The agent can propose; only the frontend can approve. Details: [DETAILS.md](DETAILS.md). diff --git a/apps/desktop/src-tauri/src/agent/tools/propose/DETAILS.md b/apps/desktop/src-tauri/src/agent/tools/propose/DETAILS.md new file mode 100644 index 000000000..085ac96dd --- /dev/null +++ b/apps/desktop/src-tauri/src/agent/tools/propose/DETAILS.md @@ -0,0 +1,5 @@ +# Rename proposal details + +The store is feature-local because its opaque ids and immutable rows are the authority boundary for review and apply commands. Entries expire in memory and are deliberately not persisted in chat history. A successful preflight records both the exact allowed row-id set and server-only source fingerprints; Apply atomically consumes that pair, so a dialog cannot replay an already-started plan or substitute a different subset. + +Proposal validation reads the `PaneStateStore` cache and index registration only. It does not call live filesystem APIs: a dead mount must not hang an agent turn, and symlinks remain links rather than targets. diff --git a/apps/desktop/src-tauri/src/agent/tools/propose/mod.rs b/apps/desktop/src-tauri/src/agent/tools/propose/mod.rs new file mode 100644 index 000000000..e9971db5a --- /dev/null +++ b/apps/desktop/src-tauri/src/agent/tools/propose/mod.rs @@ -0,0 +1,4 @@ +//! Agent-only proposals. A proposal is immutable server-owned data: the agent +//! can stage it, while a later frontend action decides what to approve. + +pub mod rename; diff --git a/apps/desktop/src-tauri/src/agent/tools/propose/rename.rs b/apps/desktop/src-tauri/src/agent/tools/propose/rename.rs new file mode 100644 index 000000000..624a72afa --- /dev/null +++ b/apps/desktop/src-tauri/src/agent/tools/propose/rename.rs @@ -0,0 +1,782 @@ +//! The `propose_rename_plan` tool. It stages a bounded, cache-validated rename +//! proposal without touching the filesystem. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use tauri::{AppHandle, Manager, Runtime}; +use uuid::Uuid; + +use crate::agent::llm::types::AgentToolResult; +use crate::file_system::validation::validate_filename; +use crate::ignore_poison::IgnorePoison; +use crate::mcp::pane_state::{PaneFileEntry, PaneState, PaneStateStore}; +use crate::mcp::{ToolError, ToolResult}; + +const MAX_RENAMES: usize = 200; +const PROPOSAL_TTL: Duration = Duration::from_secs(15 * 60); + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RenamePlanInput { + renames: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RenameInput { + source_path: String, + volume_id: String, + destination_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenameProposal { + pub proposal_id: String, + pub rows: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RenameProposalRow { + pub row_id: String, + pub source_path: String, + pub volume_id: String, + pub destination_name: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameProposalSnapshot { + pub proposal_id: String, + pub rows: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameProposalRowSnapshot { + pub row_id: String, + pub source_name: String, + pub destination_name: String, +} + +impl RenameProposal { + pub fn snapshot(&self) -> RenameProposalSnapshot { + RenameProposalSnapshot { + proposal_id: self.proposal_id.clone(), + rows: self + .rows + .iter() + .map(|row| RenameProposalRowSnapshot { + row_id: row.row_id.clone(), + source_name: Path::new(&row.source_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&row.source_path) + .to_string(), + destination_name: row.destination_name.clone(), + }) + .collect(), + } + } +} + +struct StoredProposal { + proposal: RenameProposal, + expires_at: Instant, + accepted_preflight: Option, +} + +/// The exact user-approved subset that passed the latest preflight. The apply +/// command consumes this later; the frontend never receives fingerprints. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcceptedPreflight { + pub allowed_row_ids: Vec, + pub fingerprints: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RenameSourceFingerprint { + Local { + row_id: String, + device: u64, + inode: u64, + size: u64, + modified_nanos: Option, + }, + Remote { + row_id: String, + normalized_path: String, + size: Option, + modified: Option, + }, +} + +#[derive(Default)] +pub struct RenameProposalStore { + proposals: Mutex>, +} + +impl RenameProposalStore { + pub fn stage(&self, proposal: RenameProposal) -> RenameProposalSnapshot { + let snapshot = proposal.snapshot(); + let mut proposals = self.proposals.lock_ignore_poison(); + proposals.retain(|_, stored| stored.expires_at > Instant::now()); + proposals.insert( + proposal.proposal_id.clone(), + StoredProposal { + proposal, + expires_at: Instant::now() + PROPOSAL_TTL, + accepted_preflight: None, + }, + ); + snapshot + } + + /// Gets an immutable proposal for repeated review-time checks. Expired + /// records are removed and indistinguishable from a missing id to callers. + pub fn get(&self, proposal_id: &str) -> Option { + let mut proposals = self.proposals.lock_ignore_poison(); + let is_live = proposals + .get(proposal_id) + .is_some_and(|stored| stored.expires_at > Instant::now()); + if !is_live { + proposals.remove(proposal_id); + return None; + } + proposals.get(proposal_id).map(|stored| stored.proposal.clone()) + } + + /// Discards a proposal after an explicit user cancellation or terminal apply. + pub fn consume(&self, proposal_id: &str) -> Option { + let stored = self.proposals.lock_ignore_poison().remove(proposal_id)?; + (stored.expires_at > Instant::now()).then_some(stored.proposal) + } + + pub fn record_accepted_preflight(&self, proposal_id: &str, accepted: AcceptedPreflight) -> bool { + let mut proposals = self.proposals.lock_ignore_poison(); + let Some(stored) = proposals.get_mut(proposal_id) else { + return false; + }; + if stored.expires_at <= Instant::now() { + proposals.remove(proposal_id); + return false; + } + stored.accepted_preflight = Some(accepted); + true + } + + pub fn accepted_preflight(&self, proposal_id: &str, allowed_row_ids: &[String]) -> Option { + let proposal = self.get(proposal_id)?; + let proposals = self.proposals.lock_ignore_poison(); + let stored = proposals.get(&proposal.proposal_id)?; + let accepted = stored.accepted_preflight.clone()?; + (accepted.allowed_row_ids == allowed_row_ids).then_some(accepted) + } + + /// Atomically consumes the exact user-approved subset after a successful + /// preflight. Once apply begins, the proposal cannot be replayed or altered. + pub fn take_accepted_preflight( + &self, + proposal_id: &str, + allowed_row_ids: &[String], + ) -> Option<(RenameProposal, AcceptedPreflight)> { + let mut proposals = self.proposals.lock_ignore_poison(); + let stored = proposals.get(proposal_id)?; + if stored.expires_at <= Instant::now() { + proposals.remove(proposal_id); + return None; + } + if stored + .accepted_preflight + .as_ref() + .is_none_or(|accepted| accepted.allowed_row_ids != allowed_row_ids) + { + return None; + } + let stored = proposals.remove(proposal_id)?; + Some((stored.proposal, stored.accepted_preflight?)) + } +} + +/// A row's user-action-time validation result. It deliberately contains no +/// path or destination authority: the frontend retains only opaque row ids. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct BulkRenamePreflight { + pub status: BulkRenamePreflightStatus, + pub rows: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub enum BulkRenamePreflightStatus { + Ready, + Blocked, + Expired, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct BulkRenamePreflightRow { + pub row_id: String, + pub status: BulkRenameRowStatus, + pub reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub enum BulkRenameRowStatus { + Ready, + Blocked, +} + +#[derive(Debug, Clone, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +#[derive(PartialEq, Eq)] +pub enum BulkRenameBlockReason { + UnknownRow, + DuplicateDestination, + SourceMissing, + SourceChanged, + TargetExists, + VolumeUnavailable, +} + +pub struct RenameDispatchOutcome { + pub result: AgentToolResult, + pub proposal: Option, +} + +pub fn propose_rename_plan_schema() -> Value { + serde_json::json!({ + "type": "object", + "properties": { "renames": { "type": "array", "items": { "type": "object", "properties": { + "sourcePath": { "type": "string" }, "volumeId": { "type": "string" }, "destinationName": { "type": "string" } + }, "required": ["sourcePath", "volumeId", "destinationName"], "additionalProperties": false }, "maxItems": MAX_RENAMES } }, + "required": ["renames"], "additionalProperties": false + }) +} + +pub async fn dispatch(app: &AppHandle, call_id: &str, params: &Value) -> RenameDispatchOutcome { + match build_proposal(app, params) { + Ok(proposal) => { + let snapshot = app.state::().stage(proposal); + RenameDispatchOutcome { + result: AgentToolResult { + call_id: call_id.to_string(), + content: serde_json::json!({ "readyForReview": true, "count": snapshot.rows.len() }), + elided: false, + }, + proposal: Some(snapshot), + } + } + Err(error) => RenameDispatchOutcome { + result: AgentToolResult { + call_id: call_id.to_string(), + content: serde_json::json!({ "problem": error.message }), + elided: false, + }, + proposal: None, + }, + } +} + +pub async fn execute_propose_rename_plan(app: &AppHandle, params: &Value) -> ToolResult { + let outcome = dispatch(app, "registry", params).await; + Ok(outcome.result.content) +} + +/// Revalidates the server-owned subset a user currently allows. The caller may +/// send opaque row ids only; paths and destination names remain in the proposal +/// store. This performs no mutation and records fingerprints only when every +/// allowed row is safe to apply. +pub async fn preflight( + app: &AppHandle, + proposal_id: String, + allowed_row_ids: Vec, +) -> BulkRenamePreflight { + let Some(store) = app.try_state::() else { + return expired_preflight(); + }; + let Some(proposal) = store.get(&proposal_id) else { + return expired_preflight(); + }; + let outcome = if proposal + .rows + .first() + .is_some_and(|row| volume_uses_local_paths(&row.volume_id)) + { + let blocking_proposal = proposal.clone(); + let blocking_allowed_row_ids = allowed_row_ids.clone(); + match tokio::task::spawn_blocking(move || preflight_local(&blocking_proposal, &blocking_allowed_row_ids)).await + { + Ok(outcome) => outcome, + Err(_) => unavailable_preflight(&proposal, &allowed_row_ids), + } + } else { + preflight_remote(&proposal, &allowed_row_ids).await + }; + if outcome.status == BulkRenamePreflightStatus::Ready + && !store.record_accepted_preflight( + &proposal_id, + AcceptedPreflight { + allowed_row_ids, + fingerprints: outcome.fingerprints, + }, + ) + { + return expired_preflight(); + } + outcome.response +} + +struct PreflightOutcome { + response: BulkRenamePreflight, + fingerprints: Vec, + status: BulkRenamePreflightStatus, +} + +fn expired_preflight() -> BulkRenamePreflight { + BulkRenamePreflight { + status: BulkRenamePreflightStatus::Expired, + rows: Vec::new(), + } +} + +fn volume_uses_local_paths(volume_id: &str) -> bool { + volume_id == "root" +} + +fn preflight_local(proposal: &RenameProposal, allowed_row_ids: &[String]) -> PreflightOutcome { + let mut rows = initial_rows(proposal, allowed_row_ids); + let allowed = allowed_rows(proposal, allowed_row_ids, &mut rows); + mark_duplicate_destinations(&allowed, &mut rows); + let allowed_sources: HashSet<&str> = allowed.iter().map(|row| row.source_path.as_str()).collect(); + let mut fingerprints = Vec::new(); + + for row in allowed { + let Some(status) = rows.get_mut(&row.row_id) else { + continue; + }; + if status.status == BulkRenameRowStatus::Blocked { + continue; + } + let source = PathBuf::from(&row.source_path); + let source_meta = match std::fs::symlink_metadata(&source) { + Ok(metadata) if !metadata.file_type().is_dir() => metadata, + _ => { + block(status, BulkRenameBlockReason::SourceMissing); + continue; + } + }; + let destination = source.parent().unwrap_or(Path::new("")).join(&row.destination_name); + if !allowed_sources.contains(destination.to_string_lossy().as_ref()) { + match std::fs::symlink_metadata(&destination) { + Ok(destination_meta) if !same_local_file(&source_meta, &destination_meta) => { + block(status, BulkRenameBlockReason::TargetExists); + continue; + } + Ok(_) | Err(_) => {} + } + } + fingerprints.push(local_fingerprint(&row.row_id, &source_meta)); + } + finish_preflight(rows, fingerprints) +} + +async fn preflight_remote(proposal: &RenameProposal, allowed_row_ids: &[String]) -> PreflightOutcome { + let mut rows = initial_rows(proposal, allowed_row_ids); + let allowed = allowed_rows(proposal, allowed_row_ids, &mut rows); + mark_duplicate_destinations(&allowed, &mut rows); + let allowed_sources: HashSet<&str> = allowed.iter().map(|row| row.source_path.as_str()).collect(); + let mut fingerprints = Vec::new(); + let Some(volume_id) = proposal.rows.first().map(|row| row.volume_id.as_str()) else { + return finish_preflight(rows, fingerprints); + }; + let Some(volume) = crate::file_system::get_volume_manager().get(volume_id) else { + for status in rows.values_mut() { + if status.status == BulkRenameRowStatus::Ready { + block(status, BulkRenameBlockReason::VolumeUnavailable); + } + } + return finish_preflight(rows, fingerprints); + }; + + for row in allowed { + let Some(status) = rows.get_mut(&row.row_id) else { + continue; + }; + if status.status == BulkRenameRowStatus::Blocked { + continue; + } + let source = Path::new(&row.source_path); + let source_meta = match volume.get_metadata(source).await { + Ok(metadata) if !metadata.is_directory => metadata, + _ => { + block(status, BulkRenameBlockReason::SourceMissing); + continue; + } + }; + let destination = source.parent().unwrap_or(Path::new("")).join(&row.destination_name); + if !allowed_sources.contains(destination.to_string_lossy().as_ref()) + && volume.get_metadata(&destination).await.is_ok() + { + block(status, BulkRenameBlockReason::TargetExists); + continue; + } + fingerprints.push(RenameSourceFingerprint::Remote { + row_id: row.row_id.clone(), + normalized_path: crate::indexing::store::normalize_for_comparison(&row.source_path), + size: source_meta.size, + modified: source_meta.modified_at.map(|modified| modified as i64), + }); + } + finish_preflight(rows, fingerprints) +} + +fn initial_rows(proposal: &RenameProposal, allowed_row_ids: &[String]) -> HashMap { + let known: HashSet<&str> = proposal.rows.iter().map(|row| row.row_id.as_str()).collect(); + allowed_row_ids + .iter() + .map(|row_id| { + let row = BulkRenamePreflightRow { + row_id: row_id.clone(), + status: if known.contains(row_id.as_str()) { + BulkRenameRowStatus::Ready + } else { + BulkRenameRowStatus::Blocked + }, + reason: (!known.contains(row_id.as_str())).then_some(BulkRenameBlockReason::UnknownRow), + }; + (row_id.clone(), row) + }) + .collect() +} + +fn allowed_rows<'a>( + proposal: &'a RenameProposal, + allowed_row_ids: &[String], + statuses: &mut HashMap, +) -> Vec<&'a RenameProposalRow> { + let mut seen = HashSet::new(); + allowed_row_ids + .iter() + .filter_map(|row_id| { + if !seen.insert(row_id.as_str()) { + if let Some(status) = statuses.get_mut(row_id) { + block(status, BulkRenameBlockReason::UnknownRow); + } + return None; + } + proposal.rows.iter().find(|row| row.row_id == *row_id) + }) + .collect() +} + +fn mark_duplicate_destinations(rows: &[&RenameProposalRow], statuses: &mut HashMap) { + let mut grouped: HashMap> = HashMap::new(); + for row in rows { + let destination = Path::new(&row.source_path) + .parent() + .unwrap_or(Path::new("")) + .join(&row.destination_name); + grouped + .entry(crate::indexing::store::normalize_for_comparison( + &destination.to_string_lossy(), + )) + .or_default() + .push(&row.row_id); + } + for row_ids in grouped.values().filter(|row_ids| row_ids.len() > 1) { + for row_id in row_ids { + if let Some(status) = statuses.get_mut(*row_id) { + block(status, BulkRenameBlockReason::DuplicateDestination); + } + } + } +} + +fn block(row: &mut BulkRenamePreflightRow, reason: BulkRenameBlockReason) { + row.status = BulkRenameRowStatus::Blocked; + row.reason = Some(reason); +} + +fn finish_preflight( + rows: HashMap, + fingerprints: Vec, +) -> PreflightOutcome { + let mut rows: Vec<_> = rows.into_values().collect(); + rows.sort_unstable_by(|a, b| a.row_id.cmp(&b.row_id)); + let status = if rows.iter().any(|row| row.status == BulkRenameRowStatus::Blocked) { + BulkRenamePreflightStatus::Blocked + } else { + BulkRenamePreflightStatus::Ready + }; + PreflightOutcome { + response: BulkRenamePreflight { + status: status.clone(), + rows, + }, + fingerprints, + status, + } +} + +fn unavailable_preflight(proposal: &RenameProposal, allowed_row_ids: &[String]) -> PreflightOutcome { + let mut rows = initial_rows(proposal, allowed_row_ids); + for row in rows.values_mut() { + if row.status == BulkRenameRowStatus::Ready { + block(row, BulkRenameBlockReason::VolumeUnavailable); + } + } + finish_preflight(rows, Vec::new()) +} + +#[cfg(unix)] +fn same_local_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(not(unix))] +fn same_local_file(_left: &std::fs::Metadata, _right: &std::fs::Metadata) -> bool { + false +} + +#[cfg(unix)] +fn local_fingerprint(row_id: &str, metadata: &std::fs::Metadata) -> RenameSourceFingerprint { + use std::os::unix::fs::MetadataExt; + RenameSourceFingerprint::Local { + row_id: row_id.to_string(), + device: metadata.dev(), + inode: metadata.ino(), + size: metadata.len(), + modified_nanos: metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|time| time.as_nanos()), + } +} + +#[cfg(not(unix))] +fn local_fingerprint(row_id: &str, metadata: &std::fs::Metadata) -> RenameSourceFingerprint { + RenameSourceFingerprint::Local { + row_id: row_id.to_string(), + device: 0, + inode: 0, + size: metadata.len(), + modified_nanos: metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|time| time.as_nanos()), + } +} + +fn build_proposal(app: &AppHandle, params: &Value) -> Result { + let input: RenamePlanInput = serde_json::from_value(params.clone()).map_err(|_| { + ToolError::invalid_params("Provide a rename plan with sourcePath, volumeId, and destinationName for every row.") + })?; + if input.renames.is_empty() { + return Err(ToolError::invalid_params("A rename plan needs at least one row.")); + } + if input.renames.len() > MAX_RENAMES { + return Err(ToolError::invalid_params( + "A rename plan can contain up to 200 items.".to_string(), + )); + } + let store = app + .try_state::() + .ok_or_else(|| ToolError::internal("Pane state isn't available yet"))?; + let state = focused_state(&store); + let volume_id = state + .volume_id + .clone() + .ok_or_else(|| ToolError::invalid_params("The focused pane has no volume id yet."))?; + let scope = scoped_files(&state)?; + let mut source_paths = HashSet::new(); + let mut destination_names = HashSet::new(); + let mut rows = Vec::with_capacity(input.renames.len()); + for rename in input.renames { + if rename.volume_id != volume_id { + return Err(ToolError::invalid_params( + "Every rename must use the focused pane's volume id.", + )); + } + if !source_paths.insert(rename.source_path.clone()) { + return Err(ToolError::invalid_params( + "A source file can appear only once in a rename plan.", + )); + } + validate_destination_name(&rename.destination_name)?; + let destination_key = crate::indexing::store::normalize_for_comparison(&rename.destination_name); + if !destination_names.insert(destination_key) { + return Err(ToolError::invalid_params( + "Destination names must be unique on this volume.", + )); + } + let entry = scope + .get(rename.source_path.as_str()) + .ok_or_else(|| ToolError::invalid_params("Every source must be in the focused pane's effective scope."))?; + if entry.is_directory { + return Err(ToolError::invalid_params( + "Rename plans can contain files, not folders.", + )); + } + if crate::file_system::volume::backends::archive::archive_boundary_candidate(Path::new(&rename.source_path)) + .is_some() + { + return Err(ToolError::invalid_params( + "Rename plans can't include files inside an archive.", + )); + } + rows.push(RenameProposalRow { + row_id: Uuid::new_v4().to_string(), + source_path: rename.source_path, + volume_id: volume_id.clone(), + destination_name: rename.destination_name, + }); + } + Ok(RenameProposal { + proposal_id: Uuid::new_v4().to_string(), + rows, + }) +} + +fn focused_state(store: &PaneStateStore) -> PaneState { + if store.get_focused_pane() == "right" { + store.get_right() + } else { + store.get_left() + } +} + +fn scoped_files(state: &PaneState) -> Result, ToolError> { + let indexes: Vec = if state.selected_indices.is_empty() { + (0..state.files.len()).collect() + } else { + state.selected_indices.clone() + }; + let mut files = HashMap::with_capacity(indexes.len()); + for index in indexes { + let entry = state.files.get(index).ok_or_else(|| { + ToolError::invalid_params( + "The selected files are not fully loaded. Ask the user to narrow or reload the selection.", + ) + })?; + files.insert(entry.path.as_str(), entry); + } + Ok(files) +} + +fn validate_destination_name(name: &str) -> Result<(), ToolError> { + if name == "." || name == ".." || name.contains('/') || name.contains('\\') { + return Err(ToolError::invalid_params( + "Each destinationName must be one filename, not a path.", + )); + } + validate_filename(name).map_err(|_| ToolError::invalid_params("Each destinationName must be a valid filename.")) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn destination_names_reject_paths_and_dot_entries() { + for name in ["", ".", "..", "folder/name.png", "folder\\name.png"] { + assert!(validate_destination_name(name).is_err(), "{name}"); + } + assert!(validate_destination_name("2026-07-20 - Receipt.png").is_ok()); + } + #[test] + fn store_returns_an_immutable_snapshot_and_consumes_once() { + let store = RenameProposalStore::default(); + let proposal = RenameProposal { + proposal_id: "proposal".into(), + rows: vec![RenameProposalRow { + row_id: "row".into(), + source_path: "/x/a.png".into(), + volume_id: "root".into(), + destination_name: "b.png".into(), + }], + }; + let snapshot = store.stage(proposal); + assert_eq!(snapshot.rows[0].source_name, "a.png"); + assert!(store.get("proposal").is_some()); + assert!(store.get("proposal").is_some()); + assert!(store.consume("proposal").is_some()); + assert!(store.get("proposal").is_none()); + } + + #[test] + fn current_folder_entries_are_a_valid_rename_scope_while_listing_updates() { + let state = PaneState { + total_files: 2, + files: vec![PaneFileEntry { + name: "a.png".into(), + path: "/ignored/a.png".into(), + is_directory: false, + size: None, + recursive_size: None, + modified: None, + recursive_size_pending: None, + tags: vec![], + }], + ..Default::default() + }; + assert_eq!(scoped_files(&state).expect("current entries are usable").len(), 1); + } + + #[test] + fn duplicate_final_targets_block_every_row_in_the_group() { + let first = RenameProposalRow { + row_id: "first".into(), + source_path: "/ignored/a.png".into(), + volume_id: "root".into(), + destination_name: "same.png".into(), + }; + let second = RenameProposalRow { + row_id: "second".into(), + source_path: "/ignored/b.png".into(), + volume_id: "root".into(), + destination_name: "same.png".into(), + }; + let mut statuses = initial_rows( + &RenameProposal { + proposal_id: "proposal".into(), + rows: vec![first.clone(), second.clone()], + }, + &["first".into(), "second".into()], + ); + mark_duplicate_destinations(&[&first, &second], &mut statuses); + assert!( + statuses + .values() + .all(|row| row.reason == Some(BulkRenameBlockReason::DuplicateDestination)) + ); + } + + #[test] + fn accepted_preflight_requires_the_exact_allowed_subset() { + let store = RenameProposalStore::default(); + store.stage(RenameProposal { + proposal_id: "proposal".into(), + rows: vec![], + }); + assert!(store.record_accepted_preflight( + "proposal", + AcceptedPreflight { + allowed_row_ids: vec!["row".into()], + fingerprints: vec![], + }, + )); + assert!(store.accepted_preflight("proposal", &["row".into()]).is_some()); + assert!(store.accepted_preflight("proposal", &["other".into()]).is_none()); + } +} diff --git a/apps/desktop/src-tauri/src/agent/tools/read/mod.rs b/apps/desktop/src-tauri/src/agent/tools/read/mod.rs index 344604f98..9ff3a4f39 100644 --- a/apps/desktop/src-tauri/src/agent/tools/read/mod.rs +++ b/apps/desktop/src-tauri/src/agent/tools/read/mod.rs @@ -16,6 +16,7 @@ pub mod importance; pub mod listing; +pub mod pane_listing; pub mod state; pub mod volumes; diff --git a/apps/desktop/src-tauri/src/agent/tools/read/pane_listing.rs b/apps/desktop/src-tauri/src/agent/tools/read/pane_listing.rs new file mode 100644 index 000000000..b9a6caf24 --- /dev/null +++ b/apps/desktop/src-tauri/src/agent/tools/read/pane_listing.rs @@ -0,0 +1,234 @@ +//! Compact access to the focused pane's existing backend listing cache. + +use std::collections::HashSet; +use std::path::Path; + +use serde::Serialize; +use serde_json::Value; +use tauri::{AppHandle, Manager, Runtime}; + +use crate::file_system::listing::{FileEntry, get_cached_listing}; +use crate::mcp::pane_state::PaneState; +use crate::mcp::{PaneStateStore, ToolError, ToolResult}; + +const MAX_PANE_FILES: usize = 200; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct PaneListingSource { + path: String, + name: String, + is_directory: bool, + is_symlink: bool, + size: Option, + modified: Option, +} + +impl From for PaneListingSource { + fn from(entry: FileEntry) -> Self { + Self { + path: entry.path, + name: entry.name, + is_directory: entry.is_directory, + is_symlink: entry.is_symlink, + size: entry.size, + modified: entry.modified_at, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PaneListingEntry { + pub name: String, + #[serde(skip_serializing_if = "crate::agent::tools::read::is_false")] + pub is_directory: bool, + #[serde(skip_serializing_if = "crate::agent::tools::read::is_false")] + pub is_symlink: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub modified: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum PaneListingScope { + Selection, + Folder, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PaneListingResult { + pub pane: String, + pub path: String, + pub volume_id: String, + pub scope: PaneListingScope, + pub total: usize, + pub returned: usize, + pub truncated: bool, + pub entries: Vec, +} + +pub(crate) fn build_pane_listing( + pane: String, + state: &PaneState, + cached_entries: Vec, +) -> Result { + let volume_id = state + .volume_id + .clone() + .ok_or_else(|| ToolError::internal("The focused pane's volume isn't available yet"))?; + let (scope, entries) = if state.selected_indices.is_empty() { + (PaneListingScope::Folder, cached_entries) + } else { + let selected_paths = state + .selected_indices + .iter() + .map(|&index| state.files.get(index).map(|entry| entry.path.as_str())) + .collect::>>() + .ok_or_else(|| ToolError::internal("Some selected rows aren't available in the pane cache"))?; + let entries: Vec<_> = cached_entries + .into_iter() + .filter(|entry| selected_paths.contains(entry.path.as_str())) + .collect(); + if entries.len() != selected_paths.len() { + return Err(ToolError::internal( + "Some selected rows no longer match the focused pane's listing", + )); + } + (PaneListingScope::Selection, entries) + }; + let total = entries.len(); + let entries: Vec<_> = entries + .into_iter() + .take(MAX_PANE_FILES) + .map(|entry| PaneListingEntry { + name: entry.name, + is_directory: entry.is_directory, + is_symlink: entry.is_symlink, + size: entry.size, + modified: entry.modified, + }) + .collect(); + let returned = entries.len(); + Ok(PaneListingResult { + pane, + path: state.path.clone(), + volume_id, + scope, + total, + returned, + truncated: total > returned, + entries, + }) +} + +/// `list_pane_files` always reads the currently focused pane and takes no parameters. +pub fn list_pane_files_schema() -> Value { + serde_json::json!({ "type": "object", "properties": {}, "additionalProperties": false }) +} + +pub async fn execute_list_pane_files(app: &AppHandle, _params: &Value) -> ToolResult { + let store = app + .try_state::() + .ok_or_else(|| ToolError::internal("Pane state isn't available yet"))?; + let pane = store.get_focused_pane(); + let state = match pane.as_str() { + "left" => store.get_left(), + "right" => store.get_right(), + _ => return Err(ToolError::internal("The focused pane isn't available yet")), + }; + let volume_id = state + .volume_id + .as_deref() + .ok_or_else(|| ToolError::internal("The focused pane's volume isn't available yet"))?; + let entries = get_cached_listing(volume_id, Path::new(&state.path)) + .ok_or_else(|| ToolError::internal("The focused pane's listing isn't available yet"))? + .into_iter() + .map(PaneListingSource::from) + .collect(); + let result = build_pane_listing(pane, &state, entries)?; + serde_json::to_value(result).map_err(|error| ToolError::internal(error.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::pane_state::PaneFileEntry; + + fn source(index: usize) -> PaneListingSource { + PaneListingSource { + path: format!("/shots/shot-{index}.png"), + name: format!("shot-{index}.png"), + is_directory: false, + is_symlink: false, + size: Some(index as u64), + modified: Some(1_700_000_000 + index as u64), + } + } + + fn pane_file(index: usize) -> PaneFileEntry { + PaneFileEntry { + name: format!("shot-{index}.png"), + path: format!("/shots/shot-{index}.png"), + is_directory: false, + size: Some(index as u64), + recursive_size: None, + modified: None, + recursive_size_pending: None, + tags: vec![], + } + } + + #[test] + fn folder_scope_is_compact_capped_and_carries_volume_id() { + let state = PaneState { + path: "/shots".to_string(), + volume_id: Some("root".to_string()), + files: (0..205).map(pane_file).collect(), + total_files: 205, + ..Default::default() + }; + let result = build_pane_listing("right".to_string(), &state, (0..205).map(source).collect()).unwrap(); + assert_eq!(result.volume_id, "root"); + assert_eq!(result.scope, PaneListingScope::Folder); + assert_eq!(result.total, 205); + assert_eq!(result.returned, 200); + assert!(result.truncated); + assert_eq!(result.entries[0].name, "shot-0.png"); + } + + #[test] + fn selection_scope_returns_only_selected_cached_rows() { + let state = PaneState { + path: "/shots".to_string(), + volume_id: Some("root".to_string()), + files: (0..5).map(pane_file).collect(), + selected_indices: vec![1, 3], + ..Default::default() + }; + let result = build_pane_listing("left".to_string(), &state, (0..5).map(source).collect()).unwrap(); + assert_eq!(result.scope, PaneListingScope::Selection); + assert_eq!( + result + .entries + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(), + ["shot-1.png", "shot-3.png"] + ); + } + + #[test] + fn selection_scope_refuses_to_silently_drop_a_stale_selected_row() { + let state = PaneState { + path: "/shots".to_string(), + volume_id: Some("root".to_string()), + files: (0..5).map(pane_file).collect(), + selected_indices: vec![1, 3], + ..Default::default() + }; + assert!(build_pane_listing("left".to_string(), &state, vec![source(1)]).is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/agent/tools/read/state.rs b/apps/desktop/src-tauri/src/agent/tools/read/state.rs index a4e14e212..0afd65c97 100644 --- a/apps/desktop/src-tauri/src/agent/tools/read/state.rs +++ b/apps/desktop/src-tauri/src/agent/tools/read/state.rs @@ -22,6 +22,10 @@ use crate::mcp::{ToolError, ToolResult}; pub struct PaneSnapshot { /// The pane's current folder. pub path: String, + /// The backing volume for this pane. Rename proposals use this exact id to keep + /// image-facts reads and the staged plan on the focused volume. + #[serde(skip_serializing_if = "Option::is_none")] + pub volume_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub volume_name: Option, /// The item under the cursor, or `None` when the pane is empty or the cursor @@ -30,6 +34,11 @@ pub struct PaneSnapshot { pub cursor_item: Option, /// How many items are selected right now. pub selected_count: usize, + /// The exact selected entries when every selected index is in the cached pane + /// window. `None` means a selected row is outside that window, so a tool must + /// refuse a scoped proposal instead of silently dropping that row. + #[serde(skip_serializing_if = "Option::is_none")] + pub selected_entries: Option>, /// Total items in the folder (may exceed the loaded window on a huge dir). pub total_files: usize, pub view_mode: String, @@ -40,6 +49,16 @@ pub struct PaneSnapshot { pub show_hidden: bool, } +/// The subset of a selected pane entry that a proposal needs. It intentionally +/// mirrors cached pane state and never probes the live filesystem. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SelectedEntrySnapshot { + pub name: String, + pub path: String, + pub is_directory: bool, +} + /// The whole app-state snapshot. #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -54,11 +73,24 @@ pub struct AppStateSnapshot { /// Flatten one pane's live state. Pure, so cursor/selection reporting is testable /// without an app handle. pub(crate) fn pane_snapshot(state: &PaneState) -> PaneSnapshot { + let selected_entries = state + .selected_indices + .iter() + .map(|&index| { + state.files.get(index).map(|entry| SelectedEntrySnapshot { + name: entry.name.clone(), + path: entry.path.clone(), + is_directory: entry.is_directory, + }) + }) + .collect::>>(); PaneSnapshot { path: state.path.clone(), + volume_id: state.volume_id.clone(), volume_name: state.volume_name.clone(), cursor_item: state.files.get(state.cursor_index).map(|f| f.name.clone()), selected_count: state.selected_indices.len(), + selected_entries, total_files: state.total_files, view_mode: state.view_mode.clone(), sort_field: state.sort_field.clone(), @@ -136,6 +168,22 @@ mod tests { assert_eq!(snap.selected_count, 2); assert_eq!(snap.path, "/Users/x/Documents"); assert_eq!(snap.volume_name.as_deref(), Some("Macintosh HD")); + assert!(snap.selected_entries.is_some()); + } + + #[test] + fn selection_outside_the_cached_window_is_explicitly_unrepresentable() { + let state = PaneState { + path: "/big".to_string(), + files: vec![file("only-loaded")], + selected_indices: vec![0, 5_000], + total_files: 1_000_000, + ..Default::default() + }; + + let snapshot = pane_snapshot(&state); + assert_eq!(snapshot.selected_count, 2); + assert_eq!(snapshot.selected_entries, None); } #[test] diff --git a/apps/desktop/src-tauri/src/agent/tools/view.rs b/apps/desktop/src-tauri/src/agent/tools/view.rs index 959a3f140..74df6af44 100644 --- a/apps/desktop/src-tauri/src/agent/tools/view.rs +++ b/apps/desktop/src-tauri/src/agent/tools/view.rs @@ -16,6 +16,7 @@ use serde_json::json; use tauri::{AppHandle, Runtime}; use crate::agent::llm::types::{AgentToolCall, AgentToolResult, ToolId}; +use crate::agent::tools::propose::rename::{RenameDispatchOutcome, RenameProposalSnapshot}; use crate::mcp::{Access, Consumer, execute_tool, tool_access}; /// The access axis of the gate: whether a registry access class may dispatch through the @@ -40,14 +41,12 @@ pub fn refuse_unavailable(call_id: &str, tool: &ToolId) -> Option Option(app: &AppHandle, call: &AgentToolCall) -> AgentToolResult { +pub struct DispatchOutcome { + pub result: AgentToolResult, + pub proposal: Option, +} + +pub async fn dispatch(app: &AppHandle, call: &AgentToolCall) -> DispatchOutcome { if let Some(refusal) = refuse_unavailable(&call.call_id, &call.tool) { - return refusal; + return DispatchOutcome { + result: refusal, + proposal: None, + }; } - match execute_tool(app, Consumer::Agent, call.tool.as_wire_name(), &call.arguments).await { + if call.tool == ToolId::ProposeRenamePlan { + let RenameDispatchOutcome { result, proposal } = + crate::agent::tools::propose::rename::dispatch(app, &call.call_id, &call.arguments).await; + return DispatchOutcome { result, proposal }; + } + let result = match execute_tool(app, Consumer::Agent, call.tool.as_wire_name(), &call.arguments).await { Ok(content) => AgentToolResult { call_id: call.call_id.clone(), content, @@ -73,7 +85,8 @@ pub async fn dispatch(app: &AppHandle, call: &AgentToolCall) -> A content: json!({ "problem": err.message }), elided: false, }, - } + }; + DispatchOutcome { result, proposal: None } } #[cfg(test)] diff --git a/apps/desktop/src-tauri/src/commands/DETAILS.md b/apps/desktop/src-tauri/src/commands/DETAILS.md index 1a5ad578e..d5d3d17b7 100644 --- a/apps/desktop/src-tauri/src/commands/DETAILS.md +++ b/apps/desktop/src-tauri/src/commands/DETAILS.md @@ -131,6 +131,10 @@ Per-file function inventory and decision rationale. `CLAUDE.md` holds the must-k ## Decisions +`agent.rs` adapts Ask Cmdr's channel-only stream events. Its `ProposalReady` snapshot is display-only; rename review +commands accept only opaque proposal and row ids. `apply_bulk_rename` consumes an exact accepted preflight then delegates +the batch to `write_operations::start_bulk_rename`; it never accepts frontend paths or model approval. + **One commands file per domain, no business logic in commands.** Tauri command functions are the IPC boundary (deserialization, state extraction, error mapping). Mixing business logic here makes it untestable (Tauri commands need a running app to invoke); thin pass-throughs keep the real logic in independently unit-testable subsystem modules. diff --git a/apps/desktop/src-tauri/src/commands/agent.rs b/apps/desktop/src-tauri/src/commands/agent.rs index ac61d05bd..1fedb1fd1 100644 --- a/apps/desktop/src-tauri/src/commands/agent.rs +++ b/apps/desktop/src-tauri/src/commands/agent.rs @@ -32,8 +32,9 @@ //! that id; [`ask_cmdr_cancel`] trips it. use std::collections::HashMap; -use std::path::Path; -use std::sync::{LazyLock, Mutex}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::Duration; use chrono::{FixedOffset, Local}; use serde::{Deserialize, Serialize}; @@ -53,13 +54,19 @@ use crate::agent::llm::fake::FakeAgentLlm; use crate::agent::llm::genai_impl::GenaiAgentLlm; use crate::agent::llm::types::{AgentPart, AgentRole, AgentStopReason, AgentUsage, ProviderTag}; use crate::agent::store::{self, ConversationRow, ConversationSearchHit, StoredMessage}; +use crate::agent::tools::propose::rename::{ + BulkRenamePreflight, BulkRenamePreflightStatus, RenameProposalStore, RenameSourceFingerprint, +}; use crate::ai::client::AiBackend; use crate::ai::llm_log::LlmLogContext; +use crate::commands::util::IpcError; use crate::ignore_poison::IgnorePoison; use crate::mcp::PaneStateStore; use crate::mcp::resources::volumes::{VolumeSummary, snapshot_volumes}; const LOG_TARGET: &str = "agent::ipc"; +const BULK_RENAME_PREFLIGHT_TIMEOUT: Duration = Duration::from_secs(5); +const BULK_RENAME_APPLY_TIMEOUT: Duration = Duration::from_secs(5); // ── The wire event enum (Channel; Serialize only, not specta) ────────────────── @@ -87,6 +94,11 @@ pub enum AskCmdrStreamEvent { ToolCallStarted { call_id: String, tool: String }, /// A tool call finished dispatching (`ok = false` for a refusal or handler problem). ToolCallFinished { call_id: String, ok: bool }, + /// Display-only rename rows for the review surface. The frontend must send + /// only opaque ids back when a later user action approves them. + ProposalReady { + proposal: crate::agent::tools::propose::rename::RenameProposalSnapshot, + }, /// The turn produced its final answer, carrying the persisted assistant id. Done { message_id: i64, @@ -199,6 +211,7 @@ fn to_wire_event(event: AgentChatEvent) -> AskCmdrStreamEvent { tool: tool.as_wire_name().to_string(), }, AgentChatEvent::ToolCallFinished { call_id, ok } => AskCmdrStreamEvent::ToolCallFinished { call_id, ok }, + AgentChatEvent::ProposalReady { proposal } => AskCmdrStreamEvent::ProposalReady { proposal }, AgentChatEvent::Done { message_id, seq, @@ -803,6 +816,143 @@ pub fn ask_cmdr_cancel(conversation_id: i64) { } } +/// Revalidates the user-selected subset of a server-owned rename proposal. The +/// frontend supplies opaque ids only, never source paths or destination names. +#[tauri::command] +#[specta::specta] +pub async fn preflight_bulk_rename( + app: AppHandle, + proposal_id: String, + allowed_row_ids: Vec, +) -> Result { + tokio::time::timeout( + BULK_RENAME_PREFLIGHT_TIMEOUT, + crate::agent::tools::propose::rename::preflight(&app, proposal_id, allowed_row_ids), + ) + .await + .map_err(|_| IpcError::timeout()) +} + +/// Starts the user-approved subset of a server-owned rename plan. Paths and +/// names never cross this IPC boundary: the frontend submits only opaque ids. +#[tauri::command] +#[specta::specta] +pub async fn apply_bulk_rename( + app: AppHandle, + proposal_id: String, + allowed_row_ids: Vec, +) -> Result { + let Some(store) = app.try_state::() else { + return Err(IpcError::from_err( + "This rename review has expired. Ask Cmdr to prepare it again.", + )); + }; + + // The normal dialog path always arrives with this exact preflight. A stale + // client retries the bounded authoritative preflight instead of trusting old + // rows or accepting a different subset. + if store.accepted_preflight(&proposal_id, &allowed_row_ids).is_none() { + let preflight = tokio::time::timeout( + BULK_RENAME_APPLY_TIMEOUT, + crate::agent::tools::propose::rename::preflight(&app, proposal_id.clone(), allowed_row_ids.clone()), + ) + .await + .map_err(|_| IpcError::timeout())?; + if preflight.status != BulkRenamePreflightStatus::Ready { + return Err(IpcError::from_err("Review the rename plan again before applying it.")); + } + } + + let Some((proposal, accepted)) = store.take_accepted_preflight(&proposal_id, &allowed_row_ids) else { + return Err(IpcError::from_err( + "This rename review has expired. Ask Cmdr to prepare it again.", + )); + }; + let Some(volume_id) = proposal.rows.first().map(|row| row.volume_id.clone()) else { + return Err(IpcError::from_err("This rename plan has no rows to apply.")); + }; + if proposal.rows.iter().any(|row| row.volume_id != volume_id) { + return Err(IpcError::from_err("A rename plan must stay on one volume.")); + } + + let fingerprints: HashMap<_, _> = accepted + .fingerprints + .into_iter() + .map(|fingerprint| (fingerprint_row_id(&fingerprint).to_string(), fingerprint)) + .collect(); + let mut rows = Vec::with_capacity(allowed_row_ids.len()); + for row_id in &allowed_row_ids { + let Some(proposal_row) = proposal.rows.iter().find(|row| &row.row_id == row_id) else { + return Err(IpcError::from_err("Review the rename plan again before applying it.")); + }; + let Some(fingerprint) = fingerprints.get(row_id) else { + return Err(IpcError::from_err("Review the rename plan again before applying it.")); + }; + rows.push(crate::file_system::write_operations::BulkRenameRow { + row_id: row_id.clone(), + source: PathBuf::from(&proposal_row.source_path), + destination: Path::new(&proposal_row.source_path) + .parent() + .unwrap_or_else(|| Path::new("")) + .join(&proposal_row.destination_name), + expected_fingerprint: map_bulk_rename_fingerprint(fingerprint), + }); + } + + crate::file_system::write_operations::start_bulk_rename( + Arc::new(crate::file_system::write_operations::TauriEventSink::new(app)), + volume_id, + rows, + crate::operation_log::types::Initiator::Agent, + ) + .map_err(IpcError::from_err) +} + +fn fingerprint_row_id(fingerprint: &RenameSourceFingerprint) -> &str { + match fingerprint { + RenameSourceFingerprint::Local { row_id, .. } | RenameSourceFingerprint::Remote { row_id, .. } => row_id, + } +} + +fn map_bulk_rename_fingerprint( + fingerprint: &RenameSourceFingerprint, +) -> crate::file_system::write_operations::BulkRenameFingerprint { + match fingerprint { + RenameSourceFingerprint::Local { + device, + inode, + size, + modified_nanos, + .. + } => crate::file_system::write_operations::BulkRenameFingerprint::Local { + device: *device, + inode: *inode, + size: *size, + modified_nanos: *modified_nanos, + }, + RenameSourceFingerprint::Remote { + normalized_path, + size, + modified, + .. + } => crate::file_system::write_operations::BulkRenameFingerprint::Remote { + normalized_path: normalized_path.clone(), + size: *size, + modified: *modified, + }, + } +} + +/// Discards a staged proposal after the user closes its review. There is no +/// agent-controlled approval route: only this user action consumes the plan. +#[tauri::command] +#[specta::specta] +pub fn cancel_bulk_rename_proposal(app: AppHandle, proposal_id: String) { + if let Some(store) = app.try_state::() { + store.consume(&proposal_id); + } +} + /// One conversation's header plus a page of its display messages (oldest first). `None` /// when the thread is absent or the store never opened. #[tauri::command] diff --git a/apps/desktop/src-tauri/src/file_system/listing/DETAILS.md b/apps/desktop/src-tauri/src/file_system/listing/DETAILS.md index 8e7a12938..c35deaae3 100644 --- a/apps/desktop/src-tauri/src/file_system/listing/DETAILS.md +++ b/apps/desktop/src-tauri/src/file_system/listing/DETAILS.md @@ -28,6 +28,9 @@ Frontend Backend - **`LISTING_CACHE`**: global `RwLock>`, keyed by `listing_id` (UUID per navigation). - **`CachedListing`**: `{ volume_id, path, entries, sort_by, sort_order, directory_sort_mode, sequence, created_at, last_accessed_ms }`. +- **Focused-pane reads**: `get_cached_listing(volume_id, path)` clones the newest matching cached listing without + requiring watcher coverage. Agent reads use it for the already-open pane, including SMB and MTP, and never start a + new filesystem listing. - **`caching::snapshot_listings()`**: lightweight summary of every active listing (id, volume, path, entry count, age). Used by `cmdr://state` so error reports surface orphan listings (started but not bound to a pane). - **Concurrency**: multiple listings coexist (different panes, rapid navigation), each with a unique ID. diff --git a/apps/desktop/src-tauri/src/file_system/listing/caching.rs b/apps/desktop/src-tauri/src/file_system/listing/caching.rs index 51719382f..fa61b0ba8 100644 --- a/apps/desktop/src-tauri/src/file_system/listing/caching.rs +++ b/apps/desktop/src-tauri/src/file_system/listing/caching.rs @@ -277,6 +277,20 @@ pub fn find_listings_for_path_on_volume( .collect() } +/// Returns the newest cached pane listing for a `(volume_id, path)` pair. +/// +/// Unlike [`try_get_watched_listing`], this doesn't require a watcher: SMB, MTP, and +/// other virtual panes still have a UI listing cache. No filesystem call happens here. +pub(crate) fn get_cached_listing(volume_id: &str, path: &Path) -> Option> { + let cache = LISTING_CACHE.read().ok()?; + let listing = cache + .values() + .filter(|listing| listing.volume_id == volume_id && listing.path == path) + .max_by_key(|listing| (listing.sequence.load(Ordering::Relaxed), listing.created_at))?; + listing.touch(); + Some(listing.entries.clone()) +} + /// Finds all cached listings belonging to a volume, regardless of path. /// /// Used by `FullRefresh` when the SMB watcher emits `STATUS_NOTIFY_ENUM_DIR` for diff --git a/apps/desktop/src-tauri/src/file_system/listing/mod.rs b/apps/desktop/src-tauri/src/file_system/listing/mod.rs index 231b3c2d8..b3c52ac3b 100644 --- a/apps/desktop/src-tauri/src/file_system/listing/mod.rs +++ b/apps/desktop/src-tauri/src/file_system/listing/mod.rs @@ -29,8 +29,8 @@ pub use operations::{get_files_at_indices, get_paths_at_indices}; // Internal re-exports for file_system module internals (pub(crate) for crate-internal use) pub(crate) use caching::{ - ModifyResult, find_listings_for_path, get_listing_volume_id_and_path, has_entry, increment_sequence, - insert_entry_sorted, remove_entry_by_path, start_orphan_listing_reaper, update_entry_sorted, + ModifyResult, find_listings_for_path, get_cached_listing, get_listing_volume_id_and_path, has_entry, + increment_sequence, insert_entry_sorted, remove_entry_by_path, start_orphan_listing_reaper, update_entry_sorted, }; // Notification API for volume mutations #[cfg(any(target_os = "macos", target_os = "linux"))] diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/DETAILS.md b/apps/desktop/src-tauri/src/file_system/write_operations/DETAILS.md index 5633cbe9e..fe537fd69 100644 --- a/apps/desktop/src-tauri/src/file_system/write_operations/DETAILS.md +++ b/apps/desktop/src-tauri/src/file_system/write_operations/DETAILS.md @@ -32,7 +32,7 @@ Pre-flight scans reuse cached listings when the source volume reports an active - **`operation_intent.rs`**: The two per-operation state machines. `OperationIntent` (the `Running → RollingBack/Stopped` cancellation/rollback machine, with `load_intent` / `is_cancelled`) and `PauseGate` (pause/resume parking: a sync condvar for `spawn_blocking` drivers plus an async `Notify` for volume drivers). - **`scan_cache.rs`**: Scan-preview caching. `ScanPreviewState`, `CachedScanResult`, the `SCAN_PREVIEW_STATE` / `SCAN_PREVIEW_RESULTS` caches, the scan-result TTL safety net (`insert_scan_result` / `release_scan_result` / `expired_scan_result_ids`, `SCAN_RESULT_TTL`), and the `FileInfo` / `ScanResult` carriers. - **`validation.rs`**: Source/destination validation: `validate_sources`, `ensure_destination_dir` (the local copy/move destination gate — creates the destination and any missing ancestors via `create_dir_all` when absent, so a transfer into a brand-new folder just works; rejects a path that exists but isn't a directory; runs AFTER `validate_destination_not_inside_source` so it never creates a folder inside a source), `validate_destination_writable` (via `libc::access`), `validate_disk_space` (NSURL API on macOS, `statvfs` on Linux), `validate_not_same_location`, `validate_destination_not_inside_source` (resolves a not-yet-created dest via its nearest existing ancestor, `canonicalize_or_nearest_ancestor`), `validate_path_length`. Identity/filesystem checks: `is_same_file` (inode+device), `is_same_filesystem` (device IDs), `path_exists_or_is_symlink` (dangling-symlink-aware), `is_symlink_loop`. The volume-aware pipelines have the same recursive dest-create behavior: `copy_volumes_with_progress` / `move_volumes_with_progress` (cross-volume) and `move_within_same_volume_with_progress` (same-volume rename) each call `Volume::create_directory_all(dest)` before transferring, so a copy/move into a brand-new nested folder auto-creates it on EVERY backend (local, SMB, MTP, in-memory), matching `ensure_destination_dir`. The cross-volume/copy gate runs AFTER the dest-inside-source guard (same order as local). See `volume/DETAILS.md` § "Recursive destination create". -- **`rename.rs`**: Rename validation + the managed rename mutation. The read-only, UNMANAGED validity/permission checks (`check_rename_validity_impl`, `check_rename_permission_sync`, run per-keystroke / on-commit, never touch the manager) plus `rename_managed`, a managed instant op that runs the rename inside `manager::run_instant` (registers a `Running` record + marks its volume busy for its sub-second duration, reserves no lane, returns its `Result` inline). The command layer (`commands/rename.rs`) is a thin pass-through into here. See [Managed instant ops](#managed-instant-ops-run_instant). +- **`rename.rs`**: Rename validation and the single-file managed instant mutation. `check_rename_validity_impl` / `check_rename_permission_sync` are read-only, unmanaged per-keystroke checks; `rename_managed` is the regular single-file `run_instant` route. **`rename/bulk.rs`**: Ask Cmdr's reviewed batch rename driver. `start_bulk_rename` receives only backend-owned rows accepted by preflight, runs through `spawn_managed` as one lane-queued operation, stages each source through a unique same-directory temporary name, journals one header with one final outcome per row, and restores unfinished temporary names on cancellation. The Ask Cmdr command is the only caller; it never receives paths or names from the frontend. See [Managed instant ops](#managed-instant-ops-run_instant). - **`create.rs`**: New-folder / new-file creation. `create_directory_managed` / `create_file_managed` run the mutation inside `manager::run_instant` (busy-mark + brief `Running` record, no lane, returns the new path inline; no inner timeout — the command's outer 5 s timeout drops the future on a hang and the guard releases the busy set). Co-locates the synthetic listing-cache diff (`emit_synthetic_entry_diff` / `should_emit_synthetic_diff`) that updates the pane when a new entry appears, for local-FS-backed volumes. The command layer (`commands/file_system/write_ops.rs`) is a thin pass-through. See [Managed instant ops](#managed-instant-ops-run_instant). - **`conflict.rs`**: Conflict resolution. The two-bucket `ApplyToAll` latch model (`apply_to_all_effective` / `apply_to_all_record`). `resolve_conflict` (`tokio::sync::oneshot` channel wait for Stop mode), `reduce_conditional_resolution`, `apply_resolution`, `find_unique_name` (O_EXCL reservation). The ` (N)` name formatting lives in ONE pure helper, `numbered_name(stem, ext, counter)` (`counter 0` = bare, `1..` = ` (N)`); `find_unique_name` and the clipboard-paste writer both go through it so the two numbering paths can't drift. Conflict-event/info builders: `build_conflict_event`, `calculate_dest_path`, `create_conflict_info`, `sample_conflicts`. - **`paste_clipboard.rs`**: `write_payload_to_dir` — the backend half of "paste clipboard content as a file" (issue #35). Takes an already-read `ClipboardPayload` + a `&Path` dir (decoupled from NSPasteboard / the IPC edge, so it's `TempDir`-testable). Maps payload→content (`ext` + `PastedKind` + bytes; markdown sniff for `.md` vs `.txt`), then writes `pasted.` via a `numbered_name` retry loop: candidate → `Volume::create_file` (O_EXCL create+write) → on the TYPED `VolumeError::AlreadyExists`, bump the counter. No pre-scan-then-write TOCTOU, and it works on any writable volume. Reuses `create::should_emit_synthetic_diff` + `emit_synthetic_entry_diff` (both `pub(super)`) so the new file lands in the pane and the FE cursor-lands like mkfile. `Nothing` payload → `Ok(None)` (the typed no-op). The command (`commands/clipboard.rs::paste_clipboard_as_file`) reads the raw flavors on the main thread, picks/converts off-main (`spawn_blocking`), and calls this under a **30 s** write timeout — a longer tier than the 5 s empty-mkfile write, because the payload can be a large image written to a slow network volume. **Partial-file-on-timeout edge (accepted):** if a very large paste to a very slow volume exceeds 30 s, the write future is dropped and a partial `pasted.` may remain (the user sees a timeout and can retry / delete). This is bounded, rare (local writes never approach 30 s; on a local FS `create_file`'s `spawn_blocking` isn't even cancellable, so the file actually completes), and only affects slow network volumes. If it ever matters, route paste-as-file through the managed transfer engine for cancellation + no-partial guarantees. Pasteboard read + flavor precedence: [`clipboard/DETAILS.md`](../../clipboard/DETAILS.md) § Paste clipboard content as a file. diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/mod.rs b/apps/desktop/src-tauri/src/file_system/write_operations/mod.rs index cdcfc71ce..b105844b4 100644 --- a/apps/desktop/src-tauri/src/file_system/write_operations/mod.rs +++ b/apps/desktop/src-tauri/src/file_system/write_operations/mod.rs @@ -108,7 +108,8 @@ pub(crate) use create::{create_directory_managed, create_file_managed}; #[cfg(target_os = "macos")] pub(crate) use paste_clipboard::write_payload_to_dir; pub(crate) use rename::{ - RenameValidityResult, check_rename_permission_sync, check_rename_validity_impl, rename_managed, + BulkRenameFingerprint, BulkRenameRow, RenameValidityResult, check_rename_permission_sync, + check_rename_validity_impl, rename_managed, start_bulk_rename, }; // External busy-volume seam for the drag-out fulfillment service (see // `state.rs` § "External busy-volume seam"). `pub(crate)` so only in-crate diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/rename.rs b/apps/desktop/src-tauri/src/file_system/write_operations/rename.rs index fe2d225f6..4b9ac9f2f 100644 --- a/apps/desktop/src-tauri/src/file_system/write_operations/rename.rs +++ b/apps/desktop/src-tauri/src/file_system/write_operations/rename.rs @@ -23,6 +23,11 @@ use super::manager::{self, OperationDescriptor, OperationSummaryText}; use super::types::WriteOperationType; use crate::file_system::volume::backends::archive; use crate::file_system::volume::backends::archive::mutator::Changeset; +use crate::operation_log::types::{Initiator, OpKind}; + +mod bulk; + +pub(crate) use bulk::{BulkRenameFingerprint, BulkRenameRow, start_bulk_rename}; /// Result of a rename validity check. #[derive(Debug, Clone, serde::Serialize, specta::Type)] @@ -70,7 +75,7 @@ pub(crate) async fn rename_managed( to: PathBuf, force: bool, volume_id: String, - initiator: crate::operation_log::types::Initiator, + initiator: Initiator, ) -> Result<(), String> { // Renaming a path INSIDE an archive is a zip mutation: route it to the // managed archive-edit driver. The `.zip` file itself is a regular file — @@ -108,14 +113,7 @@ pub(crate) async fn rename_managed( None => false, } }; - super::journal::open_volume_op( - &op_id, - crate::operation_log::types::OpKind::Rename, - initiator, - &journal_volume_id, - None, - 1, - ); + super::journal::open_volume_op(&op_id, OpKind::Rename, initiator, &journal_volume_id, None, 1); let journal_snapshot = Some((from.clone(), to.clone(), local_meta, volume_is_dir)); let result = manager::manager() diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk.rs b/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk.rs new file mode 100644 index 000000000..ca0c0d933 --- /dev/null +++ b/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk.rs @@ -0,0 +1,685 @@ +//! Managed batch rename for Ask Cmdr's reviewed rename proposals. +//! +//! This module owns the server-side rows and collision-safe driver. It stages +//! sources through same-directory temporary names so a chain, cycle, swap, or +//! case-only rename never overwrites a reviewed source. + +use std::collections::HashSet; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::AtomicU8; +use std::time::Duration; + +use uuid::Uuid; + +use super::super::event_sinks::OperationEventSink; +use super::super::manager::{self, OperationDescriptor, OperationSummaryText}; +use super::super::state::{WriteOperationState, WriteSettledGuard, is_cancelled, update_operation_status}; +use super::super::types::{ + WriteCancelledEvent, WriteCompleteEvent, WriteOperationStartResult, WriteOperationType, WriteProgressEvent, + WriteSourceItemDoneEvent, +}; +use crate::file_system::volume::{LaneKey, Volume}; +use crate::operation_log::types::{EntryType, ExecutionStatus, Initiator, ItemOutcome, OpKind}; + +/// Server-owned source identity captured by the rename-review preflight. The +/// frontend never creates this data; the Ask Cmdr command maps its accepted +/// preflight directly into this write-engine input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BulkRenameFingerprint { + Local { + device: u64, + inode: u64, + size: u64, + modified_nanos: Option, + }, + Remote { + normalized_path: String, + size: Option, + modified: Option, + }, +} + +/// One immutable row that the user allowed and preflight accepted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BulkRenameRow { + pub row_id: String, + pub source: PathBuf, + pub destination: PathBuf, + pub expected_fingerprint: BulkRenameFingerprint, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BulkRenameOutcome { + Done, + Skipped, + Failed, +} + +impl BulkRenameOutcome { + #[cfg(test)] + fn is_done(self) -> bool { + self == Self::Done + } + + fn journal_outcome(self) -> ItemOutcome { + match self { + Self::Done => ItemOutcome::Done, + Self::Skipped => ItemOutcome::Skipped, + Self::Failed => ItemOutcome::Failed, + } + } +} + +/// Starts one queued, same-volume batch rename. The caller has already resolved +/// the proposal id and exact accepted-preflight subset; this layer receives only +/// immutable backend rows, never frontend paths or names. +pub(crate) fn start_bulk_rename( + events: Arc, + volume_id: String, + rows: Vec, + initiator: Initiator, +) -> Result { + if rows.is_empty() { + return Err("Choose at least one rename to apply.".to_string()); + } + if rows.iter().any(|row| row.source.parent() != row.destination.parent()) { + return Err("A rename plan can only change names in the same folder.".to_string()); + } + + // `root` is the only backend that owns raw local paths here. Every mounted + // volume, including a locally mounted removable drive, stays on its Volume + // route so its listing and connection semantics remain authoritative. + let uses_local_paths = volume_id == "root"; + let (lanes, volume_ids, settled_volume) = if uses_local_paths { + (vec![LaneKey::new("root")], Vec::new(), None) + } else { + let volume = crate::file_system::get_volume_manager() + .get(&volume_id) + .ok_or_else(|| "The rename volume is no longer available.".to_string())?; + ( + vec![volume.lane_key()], + vec![volume_id.clone()], + Some(volume.name().to_string()), + ) + }; + + let operation_id = Uuid::new_v4().to_string(); + let summary = OperationSummaryText { + source: Some(format!("{} files", rows.len())), + destination: None, + }; + let descriptor = OperationDescriptor { + operation_id: operation_id.clone(), + operation_type: WriteOperationType::Rename, + lanes, + volume_ids, + summary, + }; + let state = Arc::new(WriteOperationState::new(Duration::from_millis(200))); + let events_for_task = Arc::clone(&events); + let operation_id_for_task = operation_id.clone(); + let rows_for_task = rows.clone(); + let volume_id_for_task = volume_id.clone(); + let state_for_task = Arc::clone(&state); + let deferred = move || -> Pin + Send>> { + Box::pin(async move { + let task_guard = manager::ManagedTaskGuard::new(operation_id_for_task.clone()); + let _settled_guard = WriteSettledGuard::new( + Arc::clone(&events_for_task), + operation_id_for_task.clone(), + WriteOperationType::Rename, + settled_volume, + ); + super::super::journal::open_volume_op( + &operation_id_for_task, + OpKind::Rename, + initiator, + &volume_id_for_task, + Some(&volume_id_for_task), + rows_for_task.len() as u64, + ); + + let run = if uses_local_paths { + let rows = rows_for_task.clone(); + let intent = Arc::clone(&state_for_task.intent); + match tokio::task::spawn_blocking(move || bulk_rename_local(&rows, &intent)).await { + Ok(result) => result, + Err(join_error) => BulkRenameRun::failed(rows_for_task.len(), join_error.to_string()), + } + } else { + bulk_rename_remote(&rows_for_task, &volume_id_for_task, &state_for_task.intent).await + }; + + if uses_local_paths { + for (row, outcome) in rows_for_task.iter().zip(run.outcomes.iter()) { + if *outcome == BulkRenameOutcome::Done && row.source != row.destination { + super::notify_rename_in_listing(&volume_id_for_task, &row.source, &row.destination).await; + } + } + } + + record_bulk_rename_outcomes( + &operation_id_for_task, + &volume_id_for_task, + &rows_for_task, + &run.outcomes, + ); + emit_bulk_rename_progress( + events_for_task.as_ref(), + &state_for_task, + &operation_id_for_task, + &rows_for_task, + &run.outcomes, + ); + if run.cancelled { + events_for_task.emit_cancelled(WriteCancelledEvent { + operation_id: operation_id_for_task.clone(), + operation_type: WriteOperationType::Rename, + files_processed: run.processed(), + rolled_back: false, + }); + super::super::journal::finalize_op(&operation_id_for_task, OpKind::Rename, ExecutionStatus::Canceled); + } else { + events_for_task.emit_complete(WriteCompleteEvent { + operation_id: operation_id_for_task.clone(), + operation_type: WriteOperationType::Rename, + files_processed: rows_for_task.len(), + files_skipped: run.skipped(), + bytes_processed: 0, + }); + super::super::journal::finalize_op(&operation_id_for_task, OpKind::Rename, ExecutionStatus::Done); + } + + task_guard.disarm(); + manager::manager().on_settled(&operation_id_for_task); + }) + }; + + manager::manager().spawn_managed(descriptor, state, Box::new(deferred)); + Ok(WriteOperationStartResult { + operation_id, + operation_type: WriteOperationType::Rename, + }) +} + +#[derive(Debug)] +struct BulkRenameRun { + outcomes: Vec, + cancelled: bool, +} + +impl BulkRenameRun { + fn failed(row_count: usize, _detail: String) -> Self { + Self { + outcomes: vec![BulkRenameOutcome::Failed; row_count], + cancelled: false, + } + } + + fn skipped(&self) -> usize { + self.outcomes + .iter() + .filter(|outcome| **outcome != BulkRenameOutcome::Done) + .count() + } + + fn processed(&self) -> usize { + self.outcomes + .iter() + .filter(|outcome| **outcome == BulkRenameOutcome::Done) + .count() + } +} + +/// Local batch engine used on the blocking pool. Every currently valid source +/// moves to a unique sibling temporary name before any final destination is +/// occupied, which makes chains, swaps, cycles, and case-only changes safe. +fn bulk_rename_local(rows: &[BulkRenameRow], intent: &AtomicU8) -> BulkRenameRun { + let mut outcomes = vec![BulkRenameOutcome::Skipped; rows.len()]; + let mut active: Vec = rows + .iter() + .map(|row| local_fingerprint(&row.source).is_some_and(|actual| actual == row.expected_fingerprint)) + .collect(); + settle_local_conflicts(rows, &mut active); + let mut temporary_paths = vec![None; rows.len()]; + + for (index, row) in rows.iter().enumerate() { + if !active[index] || row.source == row.destination { + continue; + } + if is_cancelled(intent) { + restore_local_temporaries(rows, &temporary_paths, &outcomes); + return BulkRenameRun { + outcomes, + cancelled: true, + }; + } + if !local_fingerprint(&row.source).is_some_and(|actual| actual == row.expected_fingerprint) { + active[index] = false; + continue; + } + let Some(temporary) = unique_temporary_path(&row.source, &row.row_id) else { + outcomes[index] = BulkRenameOutcome::Failed; + active[index] = false; + continue; + }; + note_rename_write(&row.source, &temporary); + if std::fs::rename(&row.source, &temporary).is_ok() { + temporary_paths[index] = Some(temporary); + } else { + outcomes[index] = BulkRenameOutcome::Failed; + active[index] = false; + } + } + + for (index, row) in rows.iter().enumerate() { + if !active[index] { + continue; + } + if row.source == row.destination { + outcomes[index] = BulkRenameOutcome::Done; + continue; + } + if is_cancelled(intent) { + restore_local_temporaries(rows, &temporary_paths, &outcomes); + return BulkRenameRun { + outcomes, + cancelled: true, + }; + } + let Some(temporary) = temporary_paths[index].as_ref() else { + continue; + }; + if !local_fingerprint(temporary).is_some_and(|actual| actual == row.expected_fingerprint) { + outcomes[index] = BulkRenameOutcome::Failed; + continue; + } + if destination_occupied_by_other_local(&row.destination, temporary) { + outcomes[index] = BulkRenameOutcome::Skipped; + continue; + } + note_rename_write(temporary, &row.destination); + outcomes[index] = if std::fs::rename(temporary, &row.destination).is_ok() { + BulkRenameOutcome::Done + } else { + BulkRenameOutcome::Failed + }; + } + restore_local_temporaries(rows, &temporary_paths, &outcomes); + BulkRenameRun { + outcomes, + cancelled: false, + } +} + +async fn bulk_rename_remote(rows: &[BulkRenameRow], volume_id: &str, intent: &AtomicU8) -> BulkRenameRun { + let Some(volume) = crate::file_system::get_volume_manager().get(volume_id) else { + return BulkRenameRun::failed(rows.len(), "volume unavailable".to_string()); + }; + let mut outcomes = vec![BulkRenameOutcome::Skipped; rows.len()]; + let mut active = Vec::with_capacity(rows.len()); + for row in rows { + active.push(remote_fingerprint_matches(volume.as_ref(), &row.source, &row.expected_fingerprint).await); + } + settle_remote_conflicts(rows, &mut active, volume.as_ref()).await; + let mut temporary_paths = vec![None; rows.len()]; + + for (index, row) in rows.iter().enumerate() { + if !active[index] || row.source == row.destination { + continue; + } + if is_cancelled(intent) { + restore_remote_temporaries(volume.as_ref(), rows, &temporary_paths, &outcomes).await; + return BulkRenameRun { + outcomes, + cancelled: true, + }; + } + if !remote_fingerprint_matches(volume.as_ref(), &row.source, &row.expected_fingerprint).await { + active[index] = false; + continue; + } + let Some(temporary) = unique_remote_temporary_path(volume.as_ref(), &row.source, &row.row_id).await else { + outcomes[index] = BulkRenameOutcome::Failed; + active[index] = false; + continue; + }; + note_rename_write(&row.source, &temporary); + if volume.rename(&row.source, &temporary, false).await.is_ok() { + temporary_paths[index] = Some(temporary); + } else { + outcomes[index] = BulkRenameOutcome::Failed; + active[index] = false; + } + } + + for (index, row) in rows.iter().enumerate() { + if !active[index] { + continue; + } + if row.source == row.destination { + outcomes[index] = BulkRenameOutcome::Done; + continue; + } + if is_cancelled(intent) { + restore_remote_temporaries(volume.as_ref(), rows, &temporary_paths, &outcomes).await; + return BulkRenameRun { + outcomes, + cancelled: true, + }; + } + let Some(temporary) = temporary_paths[index].as_ref() else { + continue; + }; + if !remote_fingerprint_matches_at_temporary_path(volume.as_ref(), temporary, &row.expected_fingerprint).await + || volume.get_metadata(&row.destination).await.is_ok() + { + outcomes[index] = BulkRenameOutcome::Skipped; + continue; + } + note_rename_write(temporary, &row.destination); + outcomes[index] = if volume.rename(temporary, &row.destination, false).await.is_ok() { + BulkRenameOutcome::Done + } else { + BulkRenameOutcome::Failed + }; + } + restore_remote_temporaries(volume.as_ref(), rows, &temporary_paths, &outcomes).await; + BulkRenameRun { + outcomes, + cancelled: false, + } +} + +fn settle_local_conflicts(rows: &[BulkRenameRow], active: &mut [bool]) { + loop { + let sources: HashSet = rows + .iter() + .zip(active.iter()) + .filter(|(_, active)| **active) + .map(|(row, _)| normalized_path(&row.source)) + .collect(); + let mut changed = false; + for (row, active) in rows.iter().zip(active.iter_mut()) { + if !*active || row.source == row.destination { + continue; + } + if !sources.contains(&normalized_path(&row.destination)) + && let Ok(destination_meta) = std::fs::symlink_metadata(&row.destination) + { + let destination_is_source = std::fs::symlink_metadata(&row.source) + .is_ok_and(|source_meta| same_local_file(&source_meta, &destination_meta)); + if !destination_is_source { + *active = false; + changed = true; + } + } + } + if !changed { + return; + } + } +} + +async fn settle_remote_conflicts(rows: &[BulkRenameRow], active: &mut [bool], volume: &dyn Volume) { + loop { + let sources: HashSet = rows + .iter() + .zip(active.iter()) + .filter(|(_, active)| **active) + .map(|(row, _)| normalized_path(&row.source)) + .collect(); + let mut changed = false; + for (row, active) in rows.iter().zip(active.iter_mut()) { + if !*active || row.source == row.destination { + continue; + } + if !sources.contains(&normalized_path(&row.destination)) + && volume.get_metadata(&row.destination).await.is_ok() + { + *active = false; + changed = true; + } + } + if !changed { + return; + } + } +} + +fn normalized_path(path: &Path) -> String { + crate::indexing::store::normalize_for_comparison(&path.to_string_lossy()) +} + +fn unique_temporary_path(source: &Path, row_id: &str) -> Option { + let parent = source.parent()?; + for _ in 0..16 { + let candidate = parent.join(format!(".cmdr-bulk-rename-{row_id}-{}", Uuid::new_v4())); + if std::fs::symlink_metadata(&candidate).is_err() { + return Some(candidate); + } + } + None +} + +async fn unique_remote_temporary_path(volume: &dyn Volume, source: &Path, row_id: &str) -> Option { + let parent = source.parent()?; + for _ in 0..16 { + let candidate = parent.join(format!(".cmdr-bulk-rename-{row_id}-{}", Uuid::new_v4())); + if volume.get_metadata(&candidate).await.is_err() { + return Some(candidate); + } + } + None +} + +fn destination_occupied_by_other_local(destination: &Path, temporary: &Path) -> bool { + match ( + std::fs::symlink_metadata(destination), + std::fs::symlink_metadata(temporary), + ) { + (Ok(destination_meta), Ok(temporary_meta)) => !same_local_file(&destination_meta, &temporary_meta), + (Ok(_), Err(_)) => true, + _ => false, + } +} + +/// A cancellation never leaves Cmdr's private staging names visible. Restoring +/// a temporary path to its original source is safe recovery, not an unreviewed +/// rollback: no final user-visible rename has occurred for that row. +fn restore_local_temporaries( + rows: &[BulkRenameRow], + temporary_paths: &[Option], + outcomes: &[BulkRenameOutcome], +) { + for ((row, temporary), outcome) in rows.iter().zip(temporary_paths).zip(outcomes) { + if *outcome != BulkRenameOutcome::Done + && let Some(temporary) = temporary + && std::fs::symlink_metadata(&row.source).is_err() + { + note_rename_write(temporary, &row.source); + let _ = std::fs::rename(temporary, &row.source); + } + } +} + +async fn restore_remote_temporaries( + volume: &dyn Volume, + rows: &[BulkRenameRow], + temporary_paths: &[Option], + outcomes: &[BulkRenameOutcome], +) { + for ((row, temporary), outcome) in rows.iter().zip(temporary_paths).zip(outcomes) { + if *outcome != BulkRenameOutcome::Done + && let Some(temporary) = temporary + && volume.get_metadata(&row.source).await.is_err() + { + note_rename_write(temporary, &row.source); + let _ = volume.rename(temporary, &row.source, false).await; + } + } +} + +fn note_rename_write(from: &Path, to: &Path) { + crate::downloads::note_pending_write_for_cmdr(from); + crate::downloads::note_pending_write_for_cmdr(to); +} + +fn local_fingerprint(path: &Path) -> Option { + let metadata = std::fs::symlink_metadata(path).ok()?; + if metadata.file_type().is_dir() { + return None; + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + Some(BulkRenameFingerprint::Local { + device: metadata.dev(), + inode: metadata.ino(), + size: metadata.len(), + modified_nanos: metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|time| time.as_nanos()), + }) + } + #[cfg(not(unix))] + { + Some(BulkRenameFingerprint::Local { + device: 0, + inode: 0, + size: metadata.len(), + modified_nanos: metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|time| time.as_nanos()), + }) + } +} + +#[cfg(unix)] +fn same_local_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(not(unix))] +fn same_local_file(_left: &std::fs::Metadata, _right: &std::fs::Metadata) -> bool { + false +} + +async fn remote_fingerprint_matches(volume: &dyn Volume, path: &Path, expected: &BulkRenameFingerprint) -> bool { + let BulkRenameFingerprint::Remote { + normalized_path, + size, + modified, + } = expected + else { + return false; + }; + if normalized_path != &crate::indexing::store::normalize_for_comparison(&path.to_string_lossy()) { + return false; + } + let Ok(metadata) = volume.get_metadata(path).await else { + return false; + }; + !metadata.is_directory && metadata.size == *size && metadata.modified_at.map(|value| value as i64) == *modified +} + +async fn remote_fingerprint_matches_at_temporary_path( + volume: &dyn Volume, + path: &Path, + expected: &BulkRenameFingerprint, +) -> bool { + let BulkRenameFingerprint::Remote { size, modified, .. } = expected else { + return false; + }; + let Ok(metadata) = volume.get_metadata(path).await else { + return false; + }; + !metadata.is_directory && metadata.size == *size && metadata.modified_at.map(|value| value as i64) == *modified +} + +fn record_bulk_rename_outcomes( + operation_id: &str, + volume_id: &str, + rows: &[BulkRenameRow], + outcomes: &[BulkRenameOutcome], +) { + for (row, outcome) in rows.iter().zip(outcomes.iter().copied()) { + let size = match &row.expected_fingerprint { + BulkRenameFingerprint::Local { size, .. } => Some(*size as i64), + BulkRenameFingerprint::Remote { size, .. } => size.map(|size| size as i64), + }; + super::super::journal::record_volume_leaf( + operation_id, + EntryType::File, + volume_id, + &row.source, + Some((volume_id, &row.destination)), + size, + None, + false, + outcome.journal_outcome(), + ); + } +} + +fn emit_bulk_rename_progress( + events: &dyn OperationEventSink, + state: &WriteOperationState, + operation_id: &str, + rows: &[BulkRenameRow], + outcomes: &[BulkRenameOutcome], +) { + for (index, (row, outcome)) in rows.iter().zip(outcomes.iter()).enumerate() { + update_operation_status( + operation_id, + super::super::types::WriteOperationPhase::Copying, + Some( + row.destination + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(), + ), + index + 1, + rows.len(), + 0, + 0, + ); + state.emit_progress_via_sink( + events, + WriteProgressEvent::new( + operation_id.to_string(), + WriteOperationType::Rename, + super::super::types::WriteOperationPhase::Copying, + Some( + row.destination + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(), + ), + index + 1, + rows.len(), + 0, + 0, + ), + ); + if *outcome == BulkRenameOutcome::Done { + events.emit_source_item_done(WriteSourceItemDoneEvent { + operation_id: operation_id.to_string(), + source_path: row.source.to_string_lossy().to_string(), + }); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk/tests.rs b/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk/tests.rs new file mode 100644 index 000000000..e8b1a192a --- /dev/null +++ b/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk/tests.rs @@ -0,0 +1,174 @@ +use super::*; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicU8; +use uuid::Uuid; + +use super::super::super::operation_intent::OperationIntent; + +fn create_test_dir(name: &str) -> PathBuf { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .expect("src-tauri manifest has the repository root as its third ancestor"); + let dir = repo_root + .join("_ignored") + .join(format!("cmdr_bulk_rename_test_{name}_{}", Uuid::new_v4())); + fs::create_dir_all(&dir).expect("create test directory"); + dir +} + +fn local_row(row_id: &str, source: PathBuf, destination: PathBuf) -> BulkRenameRow { + let expected_fingerprint = local_fingerprint(&source).expect("fingerprint fixture source"); + BulkRenameRow { + row_id: row_id.to_string(), + source, + destination, + expected_fingerprint, + } +} + +fn assert_no_staging_paths(dir: &Path) { + let staging_paths: Vec<_> = fs::read_dir(dir) + .expect("read fixture directory") + .flatten() + .filter(|entry| entry.file_name().to_string_lossy().starts_with(".cmdr-bulk-rename-")) + .collect(); + assert!(staging_paths.is_empty(), "unexpected staging paths: {staging_paths:?}"); +} + +#[test] +fn bulk_local_rename_preserves_chains_and_cycles() { + let tmp = create_test_dir("chain_cycle"); + let chain_a = tmp.join("chain-a.txt"); + let chain_b = tmp.join("chain-b.txt"); + let chain_c = tmp.join("chain-c.txt"); + let chain_d = tmp.join("chain-d.txt"); + let cycle_a = tmp.join("cycle-a.txt"); + let cycle_b = tmp.join("cycle-b.txt"); + let cycle_c = tmp.join("cycle-c.txt"); + for (path, contents) in [ + (&chain_a, "chain a"), + (&chain_b, "chain b"), + (&chain_c, "chain c"), + (&cycle_a, "cycle a"), + (&cycle_b, "cycle b"), + (&cycle_c, "cycle c"), + ] { + fs::write(path, contents).expect("write fixture"); + } + + let rows = vec![ + local_row("chain-a", chain_a.clone(), chain_b.clone()), + local_row("chain-b", chain_b.clone(), chain_c.clone()), + local_row("chain-c", chain_c.clone(), chain_d.clone()), + local_row("cycle-a", cycle_a.clone(), cycle_b.clone()), + local_row("cycle-b", cycle_b.clone(), cycle_c.clone()), + local_row("cycle-c", cycle_c.clone(), cycle_a.clone()), + ]; + + let run = bulk_rename_local(&rows, &AtomicU8::new(OperationIntent::Running as u8)); + + assert_eq!(fs::read_to_string(&chain_b).expect("read chain b"), "chain a"); + assert_eq!(fs::read_to_string(&chain_c).expect("read chain c"), "chain b"); + assert_eq!(fs::read_to_string(&chain_d).expect("read chain d"), "chain c"); + assert_eq!(fs::read_to_string(&cycle_a).expect("read cycle a"), "cycle c"); + assert_eq!(fs::read_to_string(&cycle_b).expect("read cycle b"), "cycle a"); + assert_eq!(fs::read_to_string(&cycle_c).expect("read cycle c"), "cycle b"); + assert!( + run.outcomes.iter().all(|outcome| outcome.is_done()), + "unexpected outcomes: {:?}", + run.outcomes + ); + assert_no_staging_paths(&tmp); + let _ = fs::remove_dir_all(&tmp); +} + +#[test] +fn bulk_local_rename_preserves_swaps_and_case_only_names() { + let tmp = create_test_dir("swap_case_only"); + let first = tmp.join("first.txt"); + let second = tmp.join("second.txt"); + let case_source = tmp.join("screenshot.png"); + fs::write(&first, "first").expect("write first fixture"); + fs::write(&second, "second").expect("write second fixture"); + fs::write(&case_source, "image").expect("write case fixture"); + + let rows = vec![ + local_row("first", first.clone(), second.clone()), + local_row("second", second.clone(), first.clone()), + local_row("case", case_source.clone(), tmp.join("Screenshot.png")), + ]; + + let run = bulk_rename_local(&rows, &AtomicU8::new(OperationIntent::Running as u8)); + + assert_eq!(fs::read_to_string(&first).expect("read swapped first"), "second"); + assert_eq!(fs::read_to_string(&second).expect("read swapped second"), "first"); + assert_eq!( + fs::read_to_string(tmp.join("Screenshot.png")).expect("read case-only rename"), + "image" + ); + assert!( + run.outcomes.iter().all(|outcome| outcome.is_done()), + "unexpected outcomes: {:?}", + run.outcomes + ); + assert_no_staging_paths(&tmp); + let _ = fs::remove_dir_all(&tmp); +} + +#[test] +fn bulk_local_rename_skips_a_source_that_changed_after_preflight() { + let tmp = create_test_dir("changed_source"); + let source = tmp.join("before.txt"); + let destination = tmp.join("after.txt"); + fs::write(&source, "reviewed").expect("write fixture"); + let row = local_row("changed", source.clone(), destination.clone()); + fs::write(&source, "changed after review").expect("change fixture after fingerprint"); + + let run = bulk_rename_local(&[row], &AtomicU8::new(OperationIntent::Running as u8)); + + assert_eq!(run.outcomes, vec![BulkRenameOutcome::Skipped]); + assert_eq!( + fs::read_to_string(&source).expect("read changed source"), + "changed after review" + ); + assert!(!destination.exists(), "a changed source must not be renamed"); + assert_no_staging_paths(&tmp); + let _ = fs::remove_dir_all(&tmp); +} + +#[test] +fn bulk_local_rename_honours_cancel_before_staging() { + let tmp = create_test_dir("cancel_before_staging"); + let source = tmp.join("before.txt"); + let destination = tmp.join("after.txt"); + fs::write(&source, "reviewed").expect("write fixture"); + let row = local_row("cancelled", source.clone(), destination.clone()); + + let run = bulk_rename_local(&[row], &AtomicU8::new(OperationIntent::Stopped as u8)); + + assert!(run.cancelled, "cancel must stop the batch driver"); + assert_eq!(run.outcomes, vec![BulkRenameOutcome::Skipped]); + assert_eq!(fs::read_to_string(&source).expect("read preserved source"), "reviewed"); + assert!(!destination.exists(), "cancel must not apply a final rename"); + assert_no_staging_paths(&tmp); + let _ = fs::remove_dir_all(&tmp); +} + +#[test] +fn restoring_cancelled_local_staging_path_recovers_the_source_name() { + let tmp = create_test_dir("cancel_restore"); + let source = tmp.join("before.txt"); + let destination = tmp.join("after.txt"); + fs::write(&source, "reviewed").expect("write fixture"); + let row = local_row("restore", source.clone(), destination); + let temporary = unique_temporary_path(&source, &row.row_id).expect("temporary path"); + fs::rename(&source, &temporary).expect("stage fixture source"); + + restore_local_temporaries(&[row], &[Some(temporary)], &[BulkRenameOutcome::Skipped]); + + assert_eq!(fs::read_to_string(&source).expect("read restored source"), "reviewed"); + assert_no_staging_paths(&tmp); + let _ = fs::remove_dir_all(&tmp); +} diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/rename/tests.rs b/apps/desktop/src-tauri/src/file_system/write_operations/rename/tests.rs index 490aa16be..3121d0003 100644 --- a/apps/desktop/src-tauri/src/file_system/write_operations/rename/tests.rs +++ b/apps/desktop/src-tauri/src/file_system/write_operations/rename/tests.rs @@ -60,14 +60,7 @@ async fn rename_managed_local_success() { let old = tmp.join("old.txt"); let new = tmp.join("new.txt"); fs::write(&old, "content").unwrap(); - let result = rename_managed( - old.clone(), - new.clone(), - false, - "root".to_string(), - crate::operation_log::types::Initiator::User, - ) - .await; + let result = rename_managed(old.clone(), new.clone(), false, "root".to_string(), Initiator::User).await; assert!(result.is_ok()); assert!(!old.exists()); assert_eq!(fs::read_to_string(&new).unwrap(), "content"); @@ -82,14 +75,7 @@ async fn rename_managed_renames_a_zip_file_itself() { let old = tmp.join("bundle.zip"); let new = tmp.join("renamed.zip"); fs::write(&old, b"PK\x03\x04rest").unwrap(); - let result = rename_managed( - old.clone(), - new.clone(), - false, - "root".to_string(), - crate::operation_log::types::Initiator::User, - ) - .await; + let result = rename_managed(old.clone(), new.clone(), false, "root".to_string(), Initiator::User).await; assert!(result.is_ok(), "renaming the .zip file itself must succeed: {result:?}"); assert!(!old.exists()); assert!(new.exists()); @@ -103,14 +89,7 @@ async fn rename_managed_local_conflict_without_force_is_transparent() { let new = tmp.join("new.txt"); fs::write(&old, "old").unwrap(); fs::write(&new, "new").unwrap(); - let result = rename_managed( - old.clone(), - new.clone(), - false, - "root".to_string(), - crate::operation_log::types::Initiator::User, - ) - .await; + let result = rename_managed(old.clone(), new.clone(), false, "root".to_string(), Initiator::User).await; assert!(result.is_err()); // allowed-error-string-match: the module returns a String; message is the signal assert!(result.unwrap_err().contains("already exists")); @@ -125,14 +104,7 @@ async fn rename_managed_local_force_overwrites() { let new = tmp.join("new.txt"); fs::write(&old, "new content").unwrap(); fs::write(&new, "old content").unwrap(); - let result = rename_managed( - old.clone(), - new.clone(), - true, - "root".to_string(), - crate::operation_log::types::Initiator::User, - ) - .await; + let result = rename_managed(old.clone(), new.clone(), true, "root".to_string(), Initiator::User).await; assert!(result.is_ok()); assert!(!old.exists()); assert_eq!(fs::read_to_string(&new).unwrap(), "new content"); @@ -221,7 +193,7 @@ async fn rename_managed_marks_nonroot_volume_busy_during_op() { PathBuf::from("/new"), false, vid, - crate::operation_log::types::Initiator::User, + Initiator::User, ) .await }); @@ -263,15 +235,9 @@ async fn rename_managed_routes_an_in_archive_rename_to_the_edit_driver() { // rather than the old flat "isn't available yet" refusal or an instant rename. let from = zip.join("old.txt"); let to = zip.join("new.txt"); - let err = rename_managed( - from, - to, - false, - "root".to_string(), - crate::operation_log::types::Initiator::User, - ) - .await - .expect_err("routing needs an app handle the unit test doesn't wire"); + let err = rename_managed(from, to, false, "root".to_string(), Initiator::User) + .await + .expect_err("routing needs an app handle the unit test doesn't wire"); // allowed-error-string-match: the fn returns a String; the "archive" wording is // how we tell the routing fork fired from a natural rename failure. assert!( @@ -282,15 +248,9 @@ async fn rename_managed_routes_an_in_archive_rename_to_the_edit_driver() { // A cross-boundary rename (OUT of the archive) is refused as a move, a // deterministic routing decision that needs no app handle. let outside = tmp.join("out.txt"); - let cross = rename_managed( - zip.join("old.txt"), - outside, - false, - "root".to_string(), - crate::operation_log::types::Initiator::User, - ) - .await - .expect_err("a cross-boundary rename is refused"); + let cross = rename_managed(zip.join("old.txt"), outside, false, "root".to_string(), Initiator::User) + .await + .expect_err("a cross-boundary rename is refused"); assert!( cross.to_lowercase().contains("move"), "a cross-boundary rename should suggest a move instead, got: {cross}" diff --git a/apps/desktop/src-tauri/src/ipc.rs b/apps/desktop/src-tauri/src/ipc.rs index fc68f3314..3f75eec10 100644 --- a/apps/desktop/src-tauri/src/ipc.rs +++ b/apps/desktop/src-tauri/src/ipc.rs @@ -647,6 +647,9 @@ pub fn builder() -> Builder { // it rides raw invoke on the frontend and is absent from ipc_collectors. crate::commands::agent::ask_cmdr_send_message, crate::commands::agent::ask_cmdr_cancel, + crate::commands::agent::preflight_bulk_rename, + crate::commands::agent::apply_bulk_rename, + crate::commands::agent::cancel_bulk_rename_proposal, crate::commands::agent::ask_cmdr_record_model_change, crate::commands::agent::ask_cmdr_get_conversation, crate::commands::agent::ask_cmdr_list_conversations, diff --git a/apps/desktop/src-tauri/src/ipc_collectors.rs b/apps/desktop/src-tauri/src/ipc_collectors.rs index 50943d6ea..f06cb8e5e 100644 --- a/apps/desktop/src-tauri/src/ipc_collectors.rs +++ b/apps/desktop/src-tauri/src/ipc_collectors.rs @@ -264,6 +264,9 @@ pub(crate) fn collect_cross_platform_types(types: &mut Types) -> Vec { crate::commands::operation_log::get_operation_log_detail, // ask_cmdr_send_message is Channel-based (not specta); registered only in ipc.rs. crate::commands::agent::ask_cmdr_cancel, + crate::commands::agent::preflight_bulk_rename, + crate::commands::agent::apply_bulk_rename, + crate::commands::agent::cancel_bulk_rename_proposal, crate::commands::agent::ask_cmdr_record_model_change, crate::commands::agent::ask_cmdr_get_conversation, crate::commands::agent::ask_cmdr_list_conversations, diff --git a/apps/desktop/src-tauri/src/mcp/DETAILS.md b/apps/desktop/src-tauri/src/mcp/DETAILS.md index 4479dcb6e..ae368e4ce 100644 --- a/apps/desktop/src-tauri/src/mcp/DETAILS.md +++ b/apps/desktop/src-tauri/src/mcp/DETAILS.md @@ -103,7 +103,8 @@ Three things make it hold rather than merely be intended: - **A hand-authored allowlist, not inference.** No structural check can prove a handler doesn't mutate, so `EXPECTED_PROPOSE_TOOL_NAMES` (in `tests/tool_registry_tests.rs`) lists every `Propose` tool by name, and `test_propose_tools_are_an_explicit_allowlist` fails if a registry entry is tagged `Propose` without being listed. - Widening the agent's power is therefore a deliberate act a human signs off, having read the handler. Empty today. + `propose_rename_plan` is the current entry. Widening the agent's power remains a deliberate act a human signs off, + having read the handler. - **No proposal reaches the confirmation bypass.** `autoConfirm` (plus `queue`'s `rollback` and `dialog`'s `action: "confirm"`) lets a token-holding MCP client skip the user's confirmation dialog — exactly the approval a proposal must never grant itself. `test_no_agent_tool_reaches_the_confirmation_bypass` asserts every agent-view tool diff --git a/apps/desktop/src-tauri/src/mcp/tests/tool_registry_tests.rs b/apps/desktop/src-tauri/src/mcp/tests/tool_registry_tests.rs index 7a43706b6..e90325cda 100644 --- a/apps/desktop/src-tauri/src/mcp/tests/tool_registry_tests.rs +++ b/apps/desktop/src-tauri/src/mcp/tests/tool_registry_tests.rs @@ -655,6 +655,7 @@ fn test_requires_token_arg_logic() { const EXPECTED_AGENT_TOOL_NAMES: &[&str] = &[ "app_state", "list_dir", + "list_pane_files", "largest_dirs", "important_folders", "folder_importance", @@ -663,6 +664,7 @@ const EXPECTED_AGENT_TOOL_NAMES: &[&str] = &[ "operations_get", "search_photos", "image_facts", + "propose_rename_plan", ]; /// Set-equality: the agent view equals exactly its authored `consumers:[agent]` entries. This is @@ -684,7 +686,7 @@ fn test_agent_tool_view_is_exactly_expected_set() { /// A `Propose` tool must also cap its payload the way `image_facts` caps at 200 paths — a proposal /// the user can't review is a proposal they can only rubber-stamp. That contract can't be enforced /// generically; see `mcp/DETAILS.md` § Consumer and access views. -const EXPECTED_PROPOSE_TOOL_NAMES: &[&str] = &[]; +const EXPECTED_PROPOSE_TOOL_NAMES: &[&str] = &["propose_rename_plan"]; /// The agent can propose; only the user can approve. Structurally: every tool in the agent's view /// is `Access::Read` or `Access::Propose`, and NEVER `Access::Write`. This is the guarantee diff --git a/apps/desktop/src-tauri/src/mcp/tool_registry/mod.rs b/apps/desktop/src-tauri/src/mcp/tool_registry/mod.rs index be8205bf3..341d9a686 100644 --- a/apps/desktop/src-tauri/src/mcp/tool_registry/mod.rs +++ b/apps/desktop/src-tauri/src/mcp/tool_registry/mod.rs @@ -652,6 +652,14 @@ mcp_tools! { access: Access::Read, run: app_params crate::agent::tools::read::state::execute_app_state }, + "propose_rename_plan" => { + desc: "Prepare a same-folder image-file rename plan for the user to review. It stages no filesystem change, never approves a proposal, and accepts at most 200 rows.", + schema: crate::agent::tools::propose::rename::propose_rename_plan_schema(), + gate: TokenGate::Open, + consumers: &[Consumer::Agent], + access: Access::Propose, + run: app_params crate::agent::tools::propose::rename::execute_propose_rename_plan + }, "list_dir" => { desc: "List a directory's immediate children (names, folder/file, size, modified) plus its recursive size totals, from the drive index. Reports index freshness honestly (fresh / scanning / stale) and returns a typed 'no index' when the volume isn't indexed, never a wrong zero. Reads the index only — it never touches the disk.", schema: crate::agent::tools::read::listing::list_dir_schema(), @@ -660,6 +668,14 @@ mcp_tools! { access: Access::Read, run: app_params crate::agent::tools::read::listing::execute_list_dir }, + "list_pane_files" => { + desc: "List up to 200 entries from the focused pane's current backend listing cache, using the selection when one exists and otherwise the folder. Returns the exact volume ID and shared parent path needed for a rename proposal. It never reads the drive index or filesystem.", + schema: crate::agent::tools::read::pane_listing::list_pane_files_schema(), + gate: TokenGate::Open, + consumers: &[Consumer::Agent], + access: Access::Read, + run: app_params crate::agent::tools::read::pane_listing::execute_list_pane_files + }, "largest_dirs" => { desc: "Find the largest subdirectories directly under a path, by recursive size (largest first). Batches directory-size lookups over the index and sorts them. Reports freshness and whether each size is an exact total or a lower bound.", schema: crate::agent::tools::read::listing::largest_dirs_schema(), diff --git a/apps/desktop/src/lib/ask-cmdr/AskCmdrMessage.svelte b/apps/desktop/src/lib/ask-cmdr/AskCmdrMessage.svelte index 442f009de..7b360ed82 100644 --- a/apps/desktop/src/lib/ask-cmdr/AskCmdrMessage.svelte +++ b/apps/desktop/src/lib/ask-cmdr/AskCmdrMessage.svelte @@ -25,7 +25,7 @@ {#if message.kind === 'user'}
-
{message.text}
+
{message.text}
{#if message.attachments.length > 0}
{#each message.attachments as attachment (attachment.path)} @@ -44,14 +44,14 @@ {/each}
{/if} - {#if message.thinking} -
- - {tString('askCmdr.thinking')} + {#if message.thinking || message.stalled} +
+ + {message.stalled ? tString('askCmdr.stalled') : tString('askCmdr.thinking')}
{/if} {#if message.text} -
+
{@html renderAssistantMarkdown(message.text)}{#if message.streaming} ({ + state: { + renameReview: null as { + proposalId: string + rows: Array<{ + rowId: string + sourceName: string + destinationName: string + allowed: boolean + blockedReason: string | null + }> + preflighting: boolean + expired: boolean + requestVersion: number + } | null, + }, + actions: { + apply: vi.fn<() => Promise>(), + allowAll: vi.fn(), + cancel: vi.fn(), + denyAll: vi.fn(), + setAllowed: vi.fn(), + }, +})) + +vi.mock('./ask-cmdr-trigger.svelte', () => ({ + applyRenameReview: async () => { + await actions.apply() + }, + allowAllRenameRows: () => { + actions.allowAll() + }, + askCmdrState: state, + cancelRenameReview: () => { + actions.cancel() + }, + denyAllRenameRows: () => { + actions.denyAll() + }, + setRenameRowAllowed: (rowId: string, allowed: boolean) => { + actions.setAllowed(rowId, allowed) + }, +})) + +vi.mock('$lib/tauri-commands', () => ({ + notifyDialogOpened: vi.fn(() => Promise.resolve()), + notifyDialogClosed: vi.fn(() => Promise.resolve()), +})) + +import BulkRenameReviewDialog from './BulkRenameReviewDialog.svelte' + +function review(overrides: Partial> = {}) { + return { + proposalId: 'opaque-proposal-id', + rows: [ + { + rowId: 'opaque-row-one', + sourceName: 'before-one.png', + destinationName: 'after-one.png', + allowed: true, + blockedReason: null, + }, + { + rowId: 'opaque-row-two', + sourceName: 'before-two.png', + destinationName: 'after-two.png', + allowed: true, + blockedReason: null, + }, + { + rowId: 'opaque-row-blocked', + sourceName: 'occupied.png', + destinationName: 'after-three.png', + allowed: false, + blockedReason: 'targetExists', + }, + ], + preflighting: false, + expired: false, + requestVersion: 0, + ...overrides, + } +} + +function mountDialog(): HTMLElement { + const target = document.createElement('div') + document.body.appendChild(target) + mount(BulkRenameReviewDialog, { target, props: {} }) + return target +} + +function requiredElement(target: ParentNode, selector: string): HTMLElement { + const element = target.querySelector(selector) + if (element === null) throw new Error(`Expected ${selector}`) + return element +} + +function requiredButton(target: ParentNode, selector: string): HTMLButtonElement { + const element = target.querySelector(selector) + if (element === null) throw new Error(`Expected ${selector}`) + return element +} + +function requiredInput(target: ParentNode, selector: string): HTMLInputElement { + const element = target.querySelector(selector) + if (element === null) throw new Error(`Expected ${selector}`) + return element +} + +beforeEach(() => { + state.renameReview = review() + actions.apply.mockReset() + actions.allowAll.mockReset() + actions.cancel.mockReset() + actions.denyAll.mockReset() + actions.setAllowed.mockReset() + document.body.replaceChildren() +}) + +describe('BulkRenameReviewDialog', () => { + it('announces reviewable and blocked rows without accessibility violations', async () => { + const target = mountDialog() + await tick() + + expect(requiredElement(target, '[role="status"]').textContent).toContain('2 renames allowed; 1 blocked') + expect(requiredButton(target, 'button[aria-label="Rename 2 files"]').disabled).toBe(false) + expect(requiredInput(target, 'input[aria-label="Deny: before-one.png"]').checked).toBe(true) + expect(requiredInput(target, 'input[aria-label="Allow: occupied.png"]').disabled).toBe(true) + await expectNoA11yViolations(target) + }) + + it('sends only user decisions to the trigger callbacks', async () => { + const target = mountDialog() + await tick() + + requiredInput(target, 'input[aria-label="Deny: before-one.png"]').click() + const bulkButtons = target.querySelectorAll('.bulk-actions button') + if (bulkButtons.length < 2) throw new Error('Expected bulk rename action buttons') + const allowAll = bulkButtons.item(0) + const denyAll = bulkButtons.item(1) + allowAll.click() + denyAll.click() + requiredButton(target, 'button[aria-label="Rename 2 files"]').click() + requiredButton(target, '.modal-footer button:not([aria-label])').click() + + expect(actions.setAllowed).toHaveBeenCalledWith('opaque-row-one', false) + expect(actions.allowAll).toHaveBeenCalledOnce() + expect(actions.denyAll).toHaveBeenCalledOnce() + expect(actions.apply).toHaveBeenCalledOnce() + expect(actions.cancel).toHaveBeenCalledOnce() + }) + + it('disables and labels Apply when no valid row remains allowed', async () => { + state.renameReview = review({ + rows: review().rows.map((row) => ({ ...row, allowed: false })), + }) + const target = mountDialog() + await tick() + + expect(requiredButton(target, 'button[aria-label="Rename 0 files"]').disabled).toBe(true) + }) +}) diff --git a/apps/desktop/src/lib/ask-cmdr/BulkRenameReviewDialog.svelte b/apps/desktop/src/lib/ask-cmdr/BulkRenameReviewDialog.svelte new file mode 100644 index 000000000..602f47ba7 --- /dev/null +++ b/apps/desktop/src/lib/ask-cmdr/BulkRenameReviewDialog.svelte @@ -0,0 +1,174 @@ + + +{#if review} + + {#snippet title()}{tString('askCmdr.renameReview.title')}{/snippet} + +
+

{tString('askCmdr.renameReview.description')}

+ {#if review.expired} +

{tString('askCmdr.renameReview.expired')}

+ {:else} +
+ + + + {tString('askCmdr.renameReview.status', { allowed: allowedCount, blocked: blockedCount })} + +
+
+ + + + + + + + + + {#each review.rows as row (row.rowId)} + + + + + + {/each} + +
{tString('askCmdr.renameReview.allow')}{tString('askCmdr.renameReview.originalName')}{tString('askCmdr.renameReview.newName')}
+ { toggleRow(row.rowId, event.currentTarget.checked); }} + /> + {row.sourceName} + {row.destinationName} + {#if row.blockedReason} + {tString('askCmdr.renameReview.blocked')} + {/if} +
+
+ {/if} +
+ + {#snippet footer()} + + + {/snippet} +
+{/if} + + diff --git a/apps/desktop/src/lib/ask-cmdr/DETAILS.md b/apps/desktop/src/lib/ask-cmdr/DETAILS.md index dabb935fc..f8c205b28 100644 --- a/apps/desktop/src/lib/ask-cmdr/DETAILS.md +++ b/apps/desktop/src/lib/ask-cmdr/DETAILS.md @@ -40,6 +40,10 @@ stays simple: - `modelChanged` → insert a `{ kind: 'modelChange' }` timeline line BEFORE the current user bubble (the switch happened between the turns; the backend already persisted the event row). +Every terminal path uses the same assistant finalizer. It clears thinking/stalled state and removes tool rows that never +received `toolCallFinished`, while retaining completed tool history. This also covers local cancellation, the +progress-watchdog timeout, and a send invocation that rejects before stream events can report a typed failure. + **Model-change events, live path.** `settings-applier.ts` calls the trigger's `noteModelSettingChanged()` on the four model-affecting settings (`ai.provider` / `ai.cloudProvider` / `ai.cloudProviderConfigs` / `askCmdr.interactiveModel`), which debounces 1 s (outlasting the settings store's 500 ms disk flush, which the backend re-reads, and the model text @@ -139,8 +143,19 @@ Opening the rail grows the MAIN window by the rail's width instead of squeezing rail mounts on open); `markRailFocused` on composer focus; Escape → `returnFocusToPane` (`.dual-pane-explorer.focus()`). +## Rename review apply + +`BulkRenameReviewDialog` owns the user's allow/deny decisions. Its Apply action sends only the staged proposal id and +the currently allowed row ids to `apply_bulk_rename`; it cannot supply a path, destination name, fingerprint, or +approval from the model. The backend requires that exact subset to have passed the latest preflight, rechecks it if the +client is stale, consumes the proposal once, then returns a queued operation id. The dialog closes only after that +operation has started. + ## The E2E fake-LLM path +The stream also carries a display-only `proposalReady` rename-plan snapshot. The review dialog owns it in the next +feature slice; until then the rail deliberately does not treat the event as approval or a filesystem action. + The app has no real AI provider under E2E, so `commands/agent.rs::resolve_agent_llm` routes the send through a scripted `FakeAgentLlm` when `CMDR_E2E_ASK_CMDR_FAKE=1` (set for the whole E2E run by the `desktop-svelte-e2e-playwright` check). It streams a fixed "Hi! I'm the test assistant." so `ask-cmdr.spec.ts` can assert send-and-render deterministically, diff --git a/apps/desktop/src/lib/ask-cmdr/ask-cmdr-labels.ts b/apps/desktop/src/lib/ask-cmdr/ask-cmdr-labels.ts index a241a9197..83b78d602 100644 --- a/apps/desktop/src/lib/ask-cmdr/ask-cmdr-labels.ts +++ b/apps/desktop/src/lib/ask-cmdr/ask-cmdr-labels.ts @@ -12,6 +12,7 @@ import type { MessageKey } from '$lib/intl/keys.gen' const TOOL_LABEL_KEYS: Record = { app_state: { doing: 'askCmdr.tool.appState.doing', done: 'askCmdr.tool.appState.done' }, list_dir: { doing: 'askCmdr.tool.listDir.doing', done: 'askCmdr.tool.listDir.done' }, + list_pane_files: { doing: 'askCmdr.tool.listDir.doing', done: 'askCmdr.tool.listDir.done' }, largest_dirs: { doing: 'askCmdr.tool.largestDirs.doing', done: 'askCmdr.tool.largestDirs.done' }, important_folders: { doing: 'askCmdr.tool.importantFolders.doing', done: 'askCmdr.tool.importantFolders.done' }, folder_importance: { doing: 'askCmdr.tool.folderImportance.doing', done: 'askCmdr.tool.folderImportance.done' }, @@ -20,6 +21,7 @@ const TOOL_LABEL_KEYS: Record = operations_get: { doing: 'askCmdr.tool.operationsGet.doing', done: 'askCmdr.tool.operationsGet.done' }, search_photos: { doing: 'askCmdr.tool.searchPhotos.doing', done: 'askCmdr.tool.searchPhotos.done' }, image_facts: { doing: 'askCmdr.tool.imageFacts.doing', done: 'askCmdr.tool.imageFacts.done' }, + propose_rename_plan: { doing: 'askCmdr.tool.proposeRenamePlan.doing', done: 'askCmdr.tool.proposeRenamePlan.done' }, } const UNKNOWN_TOOL_KEYS = { doing: 'askCmdr.tool.unknown.doing', done: 'askCmdr.tool.unknown.done' } as const diff --git a/apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.svelte.ts b/apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.svelte.ts index 1ff14b26d..44a31ff1a 100644 --- a/apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.svelte.ts +++ b/apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.svelte.ts @@ -18,10 +18,13 @@ import { getAppLogger } from '$lib/logging/logger' import { consentState, refreshConsent } from './ask-cmdr-consent.svelte' import { growMainWindowForRail, shrinkMainWindowForRail } from './rail-window' import { + applyBulkRename, cancelAskCmdr, + cancelBulkRenameProposal, getAskCmdrConversation, listAskCmdrConversations, recordAskCmdrModelChange, + preflightBulkRename, sendAskCmdrMessage, type AskCmdrErrorKind, type AskCmdrStreamEvent, @@ -40,6 +43,10 @@ export const THREAD_SOFT_CAP_MESSAGES = 40 * page is usually the whole thread; paging is the insurance for a long one. Loading is * tail-first (newest page), with "load earlier" prepending older pages. */ export const MESSAGE_PAGE = 50 +const STALL_AFTER_MS = 30_000 +const STOP_AFTER_MS = 90_000 +let stallTimer: ReturnType | null = null +let stopTimer: ReturnType | null = null /** One tool call the assistant made, as the collapsible "looked at X" line shows it. */ export interface RailToolCall { @@ -57,7 +64,15 @@ export interface RailToolCall { * stored blocks). */ export type RailMessage = | { kind: 'user'; id: number | null; text: string; attachments: AttachmentRef[] } - | { kind: 'assistant'; id: number | null; text: string; tools: RailToolCall[]; thinking: boolean; streaming: boolean } + | { + kind: 'assistant' + id: number | null + text: string + tools: RailToolCall[] + thinking: boolean + stalled?: boolean + streaming: boolean + } | { kind: 'error' errorKind: AskCmdrErrorKind @@ -68,6 +83,22 @@ export type RailMessage = /** A timeline line marking that the thread's effective model changed between turns. */ | { kind: 'modelChange'; model: string } +export interface BulkRenameReviewRow { + rowId: string + sourceName: string + destinationName: string + allowed: boolean + blockedReason: string | null +} + +export interface BulkRenameReview { + proposalId: string + rows: BulkRenameReviewRow[] + preflighting: boolean + expired: boolean + requestVersion: number +} + interface AskCmdrState { open: boolean /** Rail width in px (clamped 280-520), persisted. */ @@ -85,6 +116,7 @@ interface AskCmdrState { loadingOlder: boolean /** Files/folders staged in the composer for the next send (path + kind only). */ attachments: AttachmentRef[] + renameReview: BulkRenameReview | null } export const RAIL_MIN_WIDTH = 280 @@ -102,6 +134,7 @@ export const askCmdrState = $state({ historyCount: 0, loadingOlder: false, attachments: [], + renameReview: null, }) /** True once the thread grows past the soft cap (drives the "start a fresh one?" nudge). */ @@ -190,12 +223,14 @@ export function newChat(): void { askCmdrState.messageTotal = 0 askCmdrState.historyCount = 0 askCmdrState.attachments = [] + discardRenameReview() } /** Switch the rail to an existing thread and load its most recent page. */ export async function switchToThread(id: number): Promise { if (askCmdrState.streaming) stopStreaming() askCmdrState.attachments = [] + discardRenameReview() await loadConversation(id) } @@ -333,6 +368,7 @@ export function sendMessage(text: string): void { const attachments = askCmdrState.attachments askCmdrState.messages.push({ kind: 'user', id: null, text: trimmed, attachments }) askCmdrState.streaming = true + resetProgressWatchdog() askCmdrState.attachments = [] void sendAskCmdrMessage(askCmdrState.conversationId, trimmed, attachments, handleStreamEvent).then( (id) => { @@ -340,6 +376,7 @@ export function sendMessage(text: string): void { }, (e: unknown) => { log.warn('sending a message failed: {error}', { error: String(e) }) + if (askCmdrState.streaming) applyFailed('provider', String(e)) }, ) } @@ -368,6 +405,7 @@ export function stopStreaming(): void { if (askCmdrState.conversationId !== null) void cancelAskCmdr(askCmdrState.conversationId) finalizeAssistant() askCmdrState.streaming = false + clearProgressWatchdog() } function handleStreamEvent(event: AskCmdrStreamEvent): void { @@ -381,7 +419,22 @@ function handleStreamEvent(event: AskCmdrStreamEvent): void { applyUserPersisted(event.messageId) return case 'assistantStarted': - askCmdrState.messages.push({ kind: 'assistant', id: null, text: '', tools: [], thinking: false, streaming: true }) + { + const assistant = currentAssistant() + if (assistant) { + assistant.streaming = true + } else { + askCmdrState.messages.push({ + kind: 'assistant', + id: null, + text: '', + tools: [], + thinking: false, + stalled: false, + streaming: true, + }) + } + } return case 'textDelta': applyTextDelta(event.text) @@ -395,6 +448,9 @@ function handleStreamEvent(event: AskCmdrStreamEvent): void { case 'toolCallFinished': applyToolFinished(event.callId, event.ok) return + case 'proposalReady': + openRenameReview(event.proposal) + return case 'done': applyDone(event.messageId) return @@ -416,6 +472,8 @@ function applyTextDelta(text: string): void { if (assistant) { assistant.text += text assistant.thinking = false + assistant.stalled = false + resetProgressWatchdog() } } @@ -426,30 +484,55 @@ function applyThinking(): void { function applyToolStarted(callId: string, tool: string): void { currentAssistant()?.tools.push({ callId, tool, running: true, ok: true, path: null }) + resetProgressWatchdog() } function applyToolFinished(callId: string, ok: boolean): void { - const tool = currentAssistant()?.tools.find((t) => t.callId === callId) + const tool = askCmdrState.messages + .findLast( + (message): message is Extract => + message.kind === 'assistant' && message.tools.some((candidate) => candidate.callId === callId), + ) + ?.tools.find((candidate) => candidate.callId === callId) if (tool) { tool.running = false tool.ok = ok } + resetProgressWatchdog() } function applyDone(messageId: number): void { - const assistant = currentAssistant() - if (assistant) { - assistant.streaming = false - assistant.thinking = false - assistant.id = messageId - } + finalizeAssistant(messageId) askCmdrState.streaming = false + clearProgressWatchdog() } function applyFailed(kind: AskCmdrErrorKind, detail: string | null): void { finalizeAssistant() askCmdrState.messages.push({ kind: 'error', errorKind: kind, detail: detail ?? undefined }) askCmdrState.streaming = false + clearProgressWatchdog() +} + +function resetProgressWatchdog(): void { + clearProgressWatchdog() + if (!askCmdrState.streaming) return + stallTimer = setTimeout(() => { + const assistant = currentAssistant() + if (assistant?.streaming) assistant.stalled = true + }, STALL_AFTER_MS) + stopTimer = setTimeout(() => { + if (!askCmdrState.streaming) return + stopStreaming() + askCmdrState.messages.push({ kind: 'error', errorKind: 'timeout' }) + }, STOP_AFTER_MS) +} + +function clearProgressWatchdog(): void { + if (stallTimer) clearTimeout(stallTimer) + if (stopTimer) clearTimeout(stopTimer) + stallTimer = null + stopTimer = null } /** The model changed between the previous turn and this one, so the line belongs BEFORE @@ -461,6 +544,106 @@ function applyModelChanged(model: string): void { else askCmdrState.messages.push(item) } +function openRenameReview(proposal: Extract['proposal']): void { + discardRenameReview() + askCmdrState.renameReview = { + proposalId: proposal.proposalId, + rows: proposal.rows.map((row) => ({ ...row, allowed: true, blockedReason: null })), + preflighting: false, + expired: false, + requestVersion: 0, + } + void refreshRenamePreflight() +} + +/** Change one row's user decision, then revalidate the exact allowed subset. */ +export function setRenameRowAllowed(rowId: string, allowed: boolean): void { + const review = askCmdrState.renameReview + const row = review?.rows.find((candidate) => candidate.rowId === rowId) + if (!review || !row || (row.blockedReason && allowed)) return + row.allowed = allowed + void refreshRenamePreflight() +} + +/** Allow every row the latest preflight did not block. */ +export function allowAllRenameRows(): void { + const review = askCmdrState.renameReview + if (!review) return + for (const row of review.rows) { + if (!row.blockedReason) row.allowed = true + } + void refreshRenamePreflight() +} + +/** Deny every row. This sends no filesystem request and creates no operation. */ +export function denyAllRenameRows(): void { + const review = askCmdrState.renameReview + if (!review) return + for (const row of review.rows) row.allowed = false + void refreshRenamePreflight() +} + +/** Cancel closes the review and consumes its server-owned proposal. */ +export function cancelRenameReview(): void { + const review = askCmdrState.renameReview + if (!review) return + askCmdrState.renameReview = null + void cancelBulkRenameProposal(review.proposalId) +} + +/** Starts the one managed operation for the rows the user currently allows. */ +export async function applyRenameReview(): Promise { + const review = askCmdrState.renameReview + if (!review || review.preflighting || review.expired) return + const allowedRowIds = review.rows.filter((row) => row.allowed && !row.blockedReason).map((row) => row.rowId) + if (allowedRowIds.length === 0) return + review.preflighting = true + try { + await applyBulkRename(review.proposalId, allowedRowIds) + if (askCmdrState.renameReview?.proposalId === review.proposalId) askCmdrState.renameReview = null + } catch (e) { + const current = askCmdrState.renameReview + if (!current || current.proposalId !== review.proposalId) return + current.preflighting = false + log.warn('starting the rename plan failed: {error}', { error: String(e) }) + void refreshRenamePreflight() + } +} + +function discardRenameReview(): void { + const review = askCmdrState.renameReview + if (!review) return + askCmdrState.renameReview = null + void cancelBulkRenameProposal(review.proposalId) +} + +async function refreshRenamePreflight(): Promise { + const review = askCmdrState.renameReview + if (!review) return + const version = review.requestVersion + 1 + review.requestVersion = version + review.preflighting = true + const allowedRowIds = review.rows.filter((row) => row.allowed).map((row) => row.rowId) + try { + const result = await preflightBulkRename(review.proposalId, allowedRowIds) + const current = askCmdrState.renameReview + if (!current || current.proposalId !== review.proposalId || current.requestVersion !== version) return + current.preflighting = false + current.expired = result.status === 'expired' + if (current.expired) return + for (const row of current.rows) { + const backend = result.rows.find((candidate) => candidate.rowId === row.rowId) + row.blockedReason = backend?.status === 'blocked' ? backend.reason : null + if (row.blockedReason) row.allowed = false + } + } catch (e) { + const current = askCmdrState.renameReview + if (!current || current.proposalId !== review.proposalId || current.requestVersion !== version) return + current.preflighting = false + log.warn('checking the rename plan failed: {error}', { error: String(e) }) + } +} + /** How long to wait after a model-affecting settings change before asking the backend to * record it: outlasts the settings store's 500 ms debounced disk flush (the backend * re-reads `settings.json`) and collapses the model text field's keystrokes. */ @@ -511,13 +694,16 @@ function lastUserMessage(): Extract | null { return null } -/** Finalize the streaming assistant bubble: stop its cursor, and drop it if it never - * produced anything (an empty bubble left by a failure or a cancel before any output). */ -function finalizeAssistant(): void { +/** Finalize the streaming assistant bubble: retire unfinished activity, stop its cursor, + * and drop it if it never produced anything. Finished tool history stays visible. */ +function finalizeAssistant(messageId?: number): void { const assistant = currentAssistant() if (!assistant) return assistant.streaming = false assistant.thinking = false + assistant.stalled = false + assistant.tools = assistant.tools.filter((tool) => !tool.running) + if (messageId !== undefined) assistant.id = messageId if (assistant.text.length === 0 && assistant.tools.length === 0) { askCmdrState.messages.pop() } diff --git a/apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.test.ts b/apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.test.ts index a4062d6a7..b79023698 100644 --- a/apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.test.ts +++ b/apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.test.ts @@ -150,6 +150,26 @@ describe('sendMessage + streaming', () => { expect(assistantAt(1).tools[0]).toMatchObject({ running: false, ok: true }) }) + it('a successful turn retires unfinished activity while keeping completed tool history', () => { + sendMessage('inspect this folder') + fire({ type: 'assistantStarted' }) + fire({ type: 'toolCallStarted', callId: 'finished', tool: 'list_dir' }) + fire({ type: 'toolCallFinished', callId: 'finished', ok: true }) + fire({ type: 'toolCallStarted', callId: 'unfinished', tool: 'list_volumes' }) + fire({ type: 'reasoningTick' }) + + fire({ + type: 'done', + messageId: 42, + seq: 2, + stop: 'completed', + usage: { promptTokens: 1, completionTokens: 2 }, + }) + + expect(assistantAt(1).tools.map((tool) => tool.callId)).toEqual(['finished']) + expect(assistantAt(1)).toMatchObject({ thinking: false, stalled: false, streaming: false }) + }) + it('a typed failure ends streaming and shows an honest notice', () => { sendMessage('hi') fire({ type: 'assistantStarted' }) @@ -160,6 +180,38 @@ describe('sendMessage + streaming', () => { expect(last).toEqual({ kind: 'error', errorKind: 'rateLimited', detail: undefined }) }) + it('a typed failure retires unfinished tool activity', () => { + sendMessage('inspect this folder') + fire({ type: 'assistantStarted' }) + fire({ type: 'toolCallStarted', callId: 'c1', tool: 'list_dir' }) + fire({ type: 'textDelta', text: 'I found part of it.' }) + + fire({ type: 'failed', kind: 'budgetExhausted', detail: null }) + + expect(assistantAt(1).tools).toEqual([]) + expect(assistantAt(1)).toMatchObject({ thinking: false, streaming: false }) + expect(askCmdrState.messages.at(-1)).toEqual({ + kind: 'error', + errorKind: 'budgetExhausted', + detail: undefined, + }) + }) + + it('a rejected send settles the turn as a provider failure', async () => { + sendMock.mockRejectedValueOnce(new Error('connection closed')) + sendMessage('hello') + + await vi.waitFor(() => { + expect(askCmdrState.streaming).toBe(false) + }) + + expect(askCmdrState.messages.at(-1)).toEqual({ + kind: 'error', + errorKind: 'provider', + detail: 'Error: connection closed', + }) + }) + it("a failure with provider detail keeps the provider's wording for display", () => { sendMessage('hi') fire({ type: 'assistantStarted' }) @@ -273,6 +325,38 @@ describe('stopStreaming', () => { expect(assistantAt(1).streaming).toBe(false) expect(assistantAt(1).text).toBe('partial') }) + + it('removes unfinished tool activity when the user cancels', () => { + sendMessage('long one') + fire({ type: 'started', conversationId: 3 }) + fire({ type: 'assistantStarted' }) + fire({ type: 'textDelta', text: 'partial' }) + fire({ type: 'toolCallStarted', callId: 'c1', tool: 'list_dir' }) + + stopStreaming() + + expect(assistantAt(1).tools).toEqual([]) + expect(assistantAt(1)).toMatchObject({ thinking: false, streaming: false }) + }) + + it('removes unfinished tool activity when the progress watchdog times out', async () => { + vi.useFakeTimers() + try { + sendMessage('long one') + fire({ type: 'started', conversationId: 3 }) + fire({ type: 'assistantStarted' }) + fire({ type: 'textDelta', text: 'partial' }) + fire({ type: 'toolCallStarted', callId: 'c1', tool: 'list_dir' }) + + await vi.advanceTimersByTimeAsync(90_000) + + expect(assistantAt(1).tools).toEqual([]) + expect(assistantAt(1)).toMatchObject({ thinking: false, stalled: false, streaming: false }) + expect(askCmdrState.messages.at(-1)).toEqual({ kind: 'error', errorKind: 'timeout' }) + } finally { + vi.useRealTimers() + } + }) }) describe('openRail bootstrap + newChat + hydrate', () => { diff --git a/apps/desktop/src/lib/file-explorer/pane/CLAUDE.md b/apps/desktop/src/lib/file-explorer/pane/CLAUDE.md index ede397170..d1880f809 100644 --- a/apps/desktop/src/lib/file-explorer/pane/CLAUDE.md +++ b/apps/desktop/src/lib/file-explorer/pane/CLAUDE.md @@ -15,8 +15,8 @@ Per-pane orchestrator: cursor, focus, tabs, selection, type-to-jump, dialogs, dr ## Must-knows -- **Exactly one pane is focused.** `focusedPane` lives in the explorer store; `setFocusedPane` is its ONLY mutator. - Pane-switch (Tab) clears type-to-jump and rename mode on both. +- **One pane is always focused.** Only `setFocusedPane` mutates it. Pane-switch clears type-to-jump and rename. Startup + calls `updateFocusedPane`; otherwise Rust's left default misdirects Ask Cmdr/MCP. - **Explorer-store fields are module-private with exactly one mutator each.** Assigning any store property outside `explorer-state.svelte.ts` is a lint error (`cmdr/no-explorer-state-writes`). `cursorIndex`, selection, and listing UI state stay LOCAL to `FilePane` (perf P3). diff --git a/apps/desktop/src/lib/file-explorer/pane/DualPaneExplorer.svelte b/apps/desktop/src/lib/file-explorer/pane/DualPaneExplorer.svelte index 548adc62e..001af7533 100644 --- a/apps/desktop/src/lib/file-explorer/pane/DualPaneExplorer.svelte +++ b/apps/desktop/src/lib/file-explorer/pane/DualPaneExplorer.svelte @@ -618,6 +618,7 @@ explorerState.setTabMgr('left', persistedState.leftTabMgr) explorerState.setTabMgr('right', persistedState.rightTabMgr) explorerState.setFocusedPane(persistedState.focusedPane) + await updateFocusedPane(persistedState.focusedPane) explorerState.setShowHiddenFiles(persistedState.showHiddenFiles) explorerState.setLeftPaneWidthPercent(persistedState.leftPaneWidthPercent) diff --git a/apps/desktop/src/lib/file-explorer/pane/DualPaneExplorer.test.ts b/apps/desktop/src/lib/file-explorer/pane/DualPaneExplorer.test.ts index d4cacd621..7e411b7f0 100644 --- a/apps/desktop/src/lib/file-explorer/pane/DualPaneExplorer.test.ts +++ b/apps/desktop/src/lib/file-explorer/pane/DualPaneExplorer.test.ts @@ -182,6 +182,34 @@ describe('DualPaneExplorer', () => { expect(target.textContent).toContain('Loading') }) + + it('syncs the restored focused pane to the backend at startup', async () => { + const { loadAppStatus } = await import('$lib/app-status-store') + const { updateFocusedPane } = await import('$lib/tauri-commands') + vi.mocked(loadAppStatus).mockResolvedValue({ + leftPath: '~/Downloads', + rightPath: '~/_ignored/screenshots', + focusedPane: 'right', + leftVolumeId: 'root', + rightVolumeId: 'root', + leftSortBy: 'name', + rightSortBy: 'name', + leftViewMode: 'brief', + rightViewMode: 'brief', + leftPaneWidthPercent: 50, + askCmdrRailOpen: false, + askCmdrRailWidth: 340, + }) + vi.mocked(updateFocusedPane).mockClear() + + const target = document.createElement('div') + mount(DualPaneExplorer, { target }) + for (let i = 0; i < 10; i++) await tick() + await new Promise((resolve) => setTimeout(resolve, 10)) + await tick() + + expect(updateFocusedPane).toHaveBeenCalledWith('right') + }) }) describe('Sorting integration', () => { diff --git a/apps/desktop/src/lib/intl/DETAILS.md b/apps/desktop/src/lib/intl/DETAILS.md index 75d9c4dcb..877b6b60e 100644 --- a/apps/desktop/src/lib/intl/DETAILS.md +++ b/apps/desktop/src/lib/intl/DETAILS.md @@ -79,6 +79,9 @@ checks guard the rest: `desktop-message-keys-fresh` (regenerate-and-diff `keys.g content, `title`/`label`/`placeholder`/ `aria-label` props, `.svelte` text nodes; an area allowlist widened per migrated area). +Ask Cmdr tool labels are literal-keyed in `ask-cmdr-labels.ts`, so proposal status remains localized without dynamic +message-key construction. New English keys require translated catalog entries and a regenerated `keys.gen.ts`. + ## The locale-aware formatting layer ## What this layer owns vs. doesn't diff --git a/apps/desktop/src/lib/intl/keys.gen.ts b/apps/desktop/src/lib/intl/keys.gen.ts index 75e71f88f..1636772a3 100644 --- a/apps/desktop/src/lib/intl/keys.gen.ts +++ b/apps/desktop/src/lib/intl/keys.gen.ts @@ -144,6 +144,19 @@ export type MessageKey = | 'askCmdr.event.modelChanged' | 'askCmdr.loadEarlier' | 'askCmdr.newChat' + | 'askCmdr.renameReview.allow' + | 'askCmdr.renameReview.allowAll' + | 'askCmdr.renameReview.blocked' + | 'askCmdr.renameReview.cancel' + | 'askCmdr.renameReview.deny' + | 'askCmdr.renameReview.denyAll' + | 'askCmdr.renameReview.description' + | 'askCmdr.renameReview.expired' + | 'askCmdr.renameReview.newName' + | 'askCmdr.renameReview.originalName' + | 'askCmdr.renameReview.rename' + | 'askCmdr.renameReview.status' + | 'askCmdr.renameReview.title' | 'askCmdr.sessions.archive' | 'askCmdr.sessions.archivedBadge' | 'askCmdr.sessions.back' @@ -162,6 +175,7 @@ export type MessageKey = | 'askCmdr.sessions.unarchive' | 'askCmdr.softCap.action' | 'askCmdr.softCap.message' + | 'askCmdr.stalled' | 'askCmdr.thinking' | 'askCmdr.threads.open' | 'askCmdr.title' @@ -183,6 +197,8 @@ export type MessageKey = | 'askCmdr.tool.operationsGet.done' | 'askCmdr.tool.operationsList.doing' | 'askCmdr.tool.operationsList.done' + | 'askCmdr.tool.proposeRenamePlan.doing' + | 'askCmdr.tool.proposeRenamePlan.done' | 'askCmdr.tool.refused' | 'askCmdr.tool.searchPhotos.doing' | 'askCmdr.tool.searchPhotos.done' diff --git a/apps/desktop/src/lib/intl/messages/de/askCmdr.json b/apps/desktop/src/lib/intl/messages/de/askCmdr.json index 672091e2d..f7dfcb80f 100644 --- a/apps/desktop/src/lib/intl/messages/de/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/de/askCmdr.json @@ -36,6 +36,10 @@ "@askCmdr.thinking": { "sourceHash": "a02f1ce" }, + "askCmdr.stalled": "Das dauert länger als üblich. Du kannst weiter warten oder stoppen.", + "@askCmdr.stalled": { + "sourceHash": "c171aab" + }, "askCmdr.softCap.message": "Dieser Chat wird lang. Ein neuer hält die Antworten schnell und fokussiert.", "@askCmdr.softCap.message": { "sourceHash": "60d2c54" @@ -124,6 +128,14 @@ "@askCmdr.tool.imageFacts.done": { "sourceHash": "fc24aac" }, + "askCmdr.tool.proposeRenamePlan.doing": "Einen Umbenennungsplan vorbereiten", + "@askCmdr.tool.proposeRenamePlan.doing": { + "sourceHash": "540f1d9" + }, + "askCmdr.tool.proposeRenamePlan.done": "Einen Umbenennungsplan vorbereitet", + "@askCmdr.tool.proposeRenamePlan.done": { + "sourceHash": "a271302" + }, "askCmdr.tool.unknown.doing": "Wird ausgeführt", "@askCmdr.tool.unknown.doing": { "sourceHash": "a92f044" @@ -337,5 +349,57 @@ "askCmdr.cost.label": "Nutzung dieses Chats", "@askCmdr.cost.label": { "sourceHash": "3b0dd81" + }, + "askCmdr.renameReview.title": "Dateiumbenennungen prüfen", + "@askCmdr.renameReview.title": { + "sourceHash": "f923506" + }, + "askCmdr.renameReview.description": "Cmdr benennt nur die Dateien um, die du erlaubst.", + "@askCmdr.renameReview.description": { + "sourceHash": "de95869" + }, + "askCmdr.renameReview.originalName": "Aktueller Name", + "@askCmdr.renameReview.originalName": { + "sourceHash": "0619bd6" + }, + "askCmdr.renameReview.newName": "Neuer Name", + "@askCmdr.renameReview.newName": { + "sourceHash": "04eea2a" + }, + "askCmdr.renameReview.allow": "Erlauben", + "@askCmdr.renameReview.allow": { + "sourceHash": "e213c16" + }, + "askCmdr.renameReview.deny": "Ablehnen", + "@askCmdr.renameReview.deny": { + "sourceHash": "05a2d73" + }, + "askCmdr.renameReview.allowAll": "Alle erlauben", + "@askCmdr.renameReview.allowAll": { + "sourceHash": "56ac845" + }, + "askCmdr.renameReview.denyAll": "Alle ablehnen", + "@askCmdr.renameReview.denyAll": { + "sourceHash": "5a41803" + }, + "askCmdr.renameReview.cancel": "Abbrechen", + "@askCmdr.renameReview.cancel": { + "sourceHash": "19766ed" + }, + "askCmdr.renameReview.rename": "{count, plural, one {# Datei umbenennen} other {# Dateien umbenennen}}", + "@askCmdr.renameReview.rename": { + "sourceHash": "d4b5c18" + }, + "askCmdr.renameReview.blocked": "Diese Umbenennung braucht Aufmerksamkeit, bevor sie fortgesetzt werden kann.", + "@askCmdr.renameReview.blocked": { + "sourceHash": "868541c" + }, + "askCmdr.renameReview.expired": "Diese Prüfung ist abgelaufen. Bitte Ask Cmdr, sie erneut vorzubereiten.", + "@askCmdr.renameReview.expired": { + "sourceHash": "4e6482a" + }, + "askCmdr.renameReview.status": "{allowed, plural, one {# Umbenennung erlaubt} other {# Umbenennungen erlaubt}}; {blocked, plural, one {# blockiert} other {# blockiert}}", + "@askCmdr.renameReview.status": { + "sourceHash": "9f70789" } } diff --git a/apps/desktop/src/lib/intl/messages/en/askCmdr.json b/apps/desktop/src/lib/intl/messages/en/askCmdr.json index edffc3587..bc0e316f9 100644 --- a/apps/desktop/src/lib/intl/messages/en/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/en/askCmdr.json @@ -35,6 +35,10 @@ "@askCmdr.thinking": { "description": "Shown while the assistant is reasoning before it starts replying. Ends with an ellipsis character (…)." }, + "askCmdr.stalled": "This is taking longer than usual. You can keep waiting or stop.", + "@askCmdr.stalled": { + "description": "Ask Cmdr status shown after 30 seconds with no visible assistant text or tool progress. It reassures the user and points to the existing Stop control; avoid the words error and failed." + }, "askCmdr.softCap.message": "This chat is getting long. A fresh one keeps replies fast and focused.", "@askCmdr.softCap.message": { "description": "A gentle nudge shown once an Ask Cmdr conversation grows past its soft length cap. Friendly, not a hard limit." @@ -123,6 +127,14 @@ "@askCmdr.tool.imageFacts.done": { "description": "Past-tense label on the same tool line once the assistant finished reading what Cmdr recognized inside those photos. Pairs with askCmdr.tool.imageFacts.doing. The apostrophe is U+2019." }, + "askCmdr.tool.proposeRenamePlan.doing": "Preparing a rename plan", + "@askCmdr.tool.proposeRenamePlan.doing": { + "description": "Present-tense label in the Ask Cmdr chat tool line while it prepares a file rename proposal for the user to review. The tool does not rename anything." + }, + "askCmdr.tool.proposeRenamePlan.done": "Prepared a rename plan", + "@askCmdr.tool.proposeRenamePlan.done": { + "description": "Past-tense label on the Ask Cmdr chat tool line after it prepared a file rename proposal. The user still reviews and approves rows separately. Pairs with askCmdr.tool.proposeRenamePlan.doing." + }, "askCmdr.tool.unknown.doing": "Working", "@askCmdr.tool.unknown.doing": { "description": "Present-tense fallback status for a tool the rail doesn't have a specific label for." @@ -341,5 +353,62 @@ "askCmdr.cost.label": "This chat’s usage", "@askCmdr.cost.label": { "description": "Accessible label for the chat's cost footer region (token count and estimated cost). The apostrophe is U+2019." + }, + "askCmdr.renameReview.title": "Review file renames", + "@askCmdr.renameReview.title": { + "description": "Title of the modal where the user reviews an Ask Cmdr rename proposal before files change." + }, + "askCmdr.renameReview.description": "Cmdr will rename only the files you allow.", + "@askCmdr.renameReview.description": { + "description": "Short explanation under the bulk rename review title. Reassures the user that denied rows will not change." + }, + "askCmdr.renameReview.originalName": "Current name", + "@askCmdr.renameReview.originalName": { + "description": "Column heading for the original file name in the bulk rename review table." + }, + "askCmdr.renameReview.newName": "New name", + "@askCmdr.renameReview.newName": { + "description": "Column heading for the proposed file name in the bulk rename review table." + }, + "askCmdr.renameReview.allow": "Allow", + "@askCmdr.renameReview.allow": { + "description": "Button that lets one proposed file rename proceed." + }, + "askCmdr.renameReview.deny": "Deny", + "@askCmdr.renameReview.deny": { + "description": "Button that prevents one proposed file rename from proceeding." + }, + "askCmdr.renameReview.allowAll": "Allow all", + "@askCmdr.renameReview.allowAll": { + "description": "Button that allows every currently reviewable rename in the proposal." + }, + "askCmdr.renameReview.denyAll": "Deny all", + "@askCmdr.renameReview.denyAll": { + "description": "Button that denies every rename in the proposal." + }, + "askCmdr.renameReview.cancel": "Cancel", + "@askCmdr.renameReview.cancel": { + "description": "Button that closes the bulk rename review and discards its proposal without renaming files." + }, + "askCmdr.renameReview.rename": "Rename {count, plural, one {# file} other {# files}}", + "@askCmdr.renameReview.rename": { + "description": "Disabled-until-apply primary action label in the bulk rename review. count is the number of allowed rows.", + "placeholders": { "count": { "content": "The number of allowed rename rows." } } + }, + "askCmdr.renameReview.blocked": "This rename needs attention before it can continue.", + "@askCmdr.renameReview.blocked": { + "description": "Generic per-row message when backend preflight blocks a proposed rename." + }, + "askCmdr.renameReview.expired": "This review expired. Ask Cmdr to prepare it again.", + "@askCmdr.renameReview.expired": { + "description": "Message when the short-lived server-side rename proposal has expired." + }, + "askCmdr.renameReview.status": "{allowed, plural, one {# rename allowed} other {# renames allowed}}; {blocked, plural, one {# blocked} other {# blocked}}", + "@askCmdr.renameReview.status": { + "description": "Screen-reader status summary for the bulk rename review list. allowed and blocked are raw counts.", + "placeholders": { + "allowed": { "content": "The number of currently allowed rows." }, + "blocked": { "content": "The number of rows blocked by preflight." } + } } } diff --git a/apps/desktop/src/lib/intl/messages/es/askCmdr.json b/apps/desktop/src/lib/intl/messages/es/askCmdr.json index 3c7389dae..e15f7407c 100644 --- a/apps/desktop/src/lib/intl/messages/es/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/es/askCmdr.json @@ -36,6 +36,10 @@ "@askCmdr.thinking": { "sourceHash": "a02f1ce" }, + "askCmdr.stalled": "Esto está tardando más de lo habitual. Puedes seguir esperando o detenerlo.", + "@askCmdr.stalled": { + "sourceHash": "c171aab" + }, "askCmdr.softCap.message": "Este chat se está alargando. Uno nuevo mantiene las respuestas rápidas y concisas.", "@askCmdr.softCap.message": { "sourceHash": "60d2c54" @@ -124,6 +128,14 @@ "@askCmdr.tool.imageFacts.done": { "sourceHash": "fc24aac" }, + "askCmdr.tool.proposeRenamePlan.doing": "Preparando un plan de cambio de nombre", + "@askCmdr.tool.proposeRenamePlan.doing": { + "sourceHash": "540f1d9" + }, + "askCmdr.tool.proposeRenamePlan.done": "Preparó un plan de cambio de nombre", + "@askCmdr.tool.proposeRenamePlan.done": { + "sourceHash": "a271302" + }, "askCmdr.tool.unknown.doing": "Trabajando", "@askCmdr.tool.unknown.doing": { "sourceHash": "a92f044" @@ -337,5 +349,57 @@ "askCmdr.cost.label": "El uso de este chat", "@askCmdr.cost.label": { "sourceHash": "3b0dd81" + }, + "askCmdr.renameReview.title": "Revisar cambios de nombre", + "@askCmdr.renameReview.title": { + "sourceHash": "f923506" + }, + "askCmdr.renameReview.description": "Cmdr cambiará el nombre solo de los archivos que permitas.", + "@askCmdr.renameReview.description": { + "sourceHash": "de95869" + }, + "askCmdr.renameReview.originalName": "Nombre actual", + "@askCmdr.renameReview.originalName": { + "sourceHash": "0619bd6" + }, + "askCmdr.renameReview.newName": "Nombre nuevo", + "@askCmdr.renameReview.newName": { + "sourceHash": "04eea2a" + }, + "askCmdr.renameReview.allow": "Permitir", + "@askCmdr.renameReview.allow": { + "sourceHash": "e213c16" + }, + "askCmdr.renameReview.deny": "Denegar", + "@askCmdr.renameReview.deny": { + "sourceHash": "05a2d73" + }, + "askCmdr.renameReview.allowAll": "Permitir todos", + "@askCmdr.renameReview.allowAll": { + "sourceHash": "56ac845" + }, + "askCmdr.renameReview.denyAll": "Denegar todos", + "@askCmdr.renameReview.denyAll": { + "sourceHash": "5a41803" + }, + "askCmdr.renameReview.cancel": "Cancelar", + "@askCmdr.renameReview.cancel": { + "sourceHash": "19766ed" + }, + "askCmdr.renameReview.rename": "{count, plural, one {Cambiar nombre de # archivo} many {Cambiar nombre de # archivos} other {Cambiar nombre de # archivos}}", + "@askCmdr.renameReview.rename": { + "sourceHash": "d4b5c18" + }, + "askCmdr.renameReview.blocked": "Este cambio de nombre necesita atención antes de continuar.", + "@askCmdr.renameReview.blocked": { + "sourceHash": "868541c" + }, + "askCmdr.renameReview.expired": "Esta revisión caducó. Pide a Ask Cmdr que la prepare de nuevo.", + "@askCmdr.renameReview.expired": { + "sourceHash": "4e6482a" + }, + "askCmdr.renameReview.status": "{allowed, plural, one {# cambio de nombre permitido} many {# cambios de nombre permitidos} other {# cambios de nombre permitidos}}; {blocked, plural, one {# bloqueado} many {# bloqueados} other {# bloqueados}}", + "@askCmdr.renameReview.status": { + "sourceHash": "9f70789" } } diff --git a/apps/desktop/src/lib/intl/messages/fr/askCmdr.json b/apps/desktop/src/lib/intl/messages/fr/askCmdr.json index 2c8b3a090..8a6171715 100644 --- a/apps/desktop/src/lib/intl/messages/fr/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/fr/askCmdr.json @@ -36,6 +36,10 @@ "@askCmdr.thinking": { "sourceHash": "a02f1ce" }, + "askCmdr.stalled": "Cela prend plus de temps que d''habitude. Vous pouvez continuer à attendre ou l''arrêter.", + "@askCmdr.stalled": { + "sourceHash": "c171aab" + }, "askCmdr.softCap.message": "Cette conversation devient longue. Une nouvelle conversation garde les réponses rapides et pertinentes.", "@askCmdr.softCap.message": { "sourceHash": "60d2c54" @@ -124,6 +128,14 @@ "@askCmdr.tool.imageFacts.done": { "sourceHash": "fc24aac" }, + "askCmdr.tool.proposeRenamePlan.doing": "Préparation d’un plan de renommage", + "@askCmdr.tool.proposeRenamePlan.doing": { + "sourceHash": "540f1d9" + }, + "askCmdr.tool.proposeRenamePlan.done": "Plan de renommage préparé", + "@askCmdr.tool.proposeRenamePlan.done": { + "sourceHash": "a271302" + }, "askCmdr.tool.unknown.doing": "Travail en cours", "@askCmdr.tool.unknown.doing": { "sourceHash": "a92f044" @@ -335,5 +347,57 @@ "askCmdr.cost.label": "Utilisation de cette conversation", "@askCmdr.cost.label": { "sourceHash": "3b0dd81" + }, + "askCmdr.renameReview.title": "Vérifier les renommages", + "@askCmdr.renameReview.title": { + "sourceHash": "f923506" + }, + "askCmdr.renameReview.description": "Cmdr renommera uniquement les fichiers que vous autorisez.", + "@askCmdr.renameReview.description": { + "sourceHash": "de95869" + }, + "askCmdr.renameReview.originalName": "Nom actuel", + "@askCmdr.renameReview.originalName": { + "sourceHash": "0619bd6" + }, + "askCmdr.renameReview.newName": "Nouveau nom", + "@askCmdr.renameReview.newName": { + "sourceHash": "04eea2a" + }, + "askCmdr.renameReview.allow": "Autoriser", + "@askCmdr.renameReview.allow": { + "sourceHash": "e213c16" + }, + "askCmdr.renameReview.deny": "Refuser", + "@askCmdr.renameReview.deny": { + "sourceHash": "05a2d73" + }, + "askCmdr.renameReview.allowAll": "Tout autoriser", + "@askCmdr.renameReview.allowAll": { + "sourceHash": "56ac845" + }, + "askCmdr.renameReview.denyAll": "Tout refuser", + "@askCmdr.renameReview.denyAll": { + "sourceHash": "5a41803" + }, + "askCmdr.renameReview.cancel": "Annuler", + "@askCmdr.renameReview.cancel": { + "sourceHash": "19766ed" + }, + "askCmdr.renameReview.rename": "{count, plural, one {Renommer # fichier} many {Renommer # fichiers} other {Renommer # fichiers}}", + "@askCmdr.renameReview.rename": { + "sourceHash": "d4b5c18" + }, + "askCmdr.renameReview.blocked": "Ce renommage nécessite une attention avant de continuer.", + "@askCmdr.renameReview.blocked": { + "sourceHash": "868541c" + }, + "askCmdr.renameReview.expired": "Cette vérification a expiré. Demandez à Ask Cmdr de la préparer à nouveau.", + "@askCmdr.renameReview.expired": { + "sourceHash": "4e6482a" + }, + "askCmdr.renameReview.status": "{allowed, plural, one {# renommage autorisé} many {# renommages autorisés} other {# renommages autorisés}}; {blocked, plural, one {# bloqué} many {# bloqués} other {# bloqués}}", + "@askCmdr.renameReview.status": { + "sourceHash": "9f70789" } } diff --git a/apps/desktop/src/lib/intl/messages/hu/askCmdr.json b/apps/desktop/src/lib/intl/messages/hu/askCmdr.json index f1ca36ed2..687ab2075 100644 --- a/apps/desktop/src/lib/intl/messages/hu/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/hu/askCmdr.json @@ -36,6 +36,10 @@ "@askCmdr.thinking": { "sourceHash": "a02f1ce" }, + "askCmdr.stalled": "Ez a szokásosnál tovább tart. Tovább várhatsz, vagy leállíthatod.", + "@askCmdr.stalled": { + "sourceHash": "c171aab" + }, "askCmdr.softCap.message": "Ez a csevegés egyre hosszabb lesz. Egy új csevegésben gyorsabbak és célratörőbbek maradnak a válaszok.", "@askCmdr.softCap.message": { "sourceHash": "60d2c54" @@ -124,6 +128,14 @@ "@askCmdr.tool.imageFacts.done": { "sourceHash": "fc24aac" }, + "askCmdr.tool.proposeRenamePlan.doing": "Átnevezési terv előkészítése", + "@askCmdr.tool.proposeRenamePlan.doing": { + "sourceHash": "540f1d9" + }, + "askCmdr.tool.proposeRenamePlan.done": "Átnevezési terv elkészült", + "@askCmdr.tool.proposeRenamePlan.done": { + "sourceHash": "a271302" + }, "askCmdr.tool.unknown.doing": "Folyamatban", "@askCmdr.tool.unknown.doing": { "sourceHash": "a92f044" @@ -335,5 +347,57 @@ "askCmdr.cost.label": "E csevegés felhasználása", "@askCmdr.cost.label": { "sourceHash": "3b0dd81" + }, + "askCmdr.renameReview.title": "Átnevezések áttekintése", + "@askCmdr.renameReview.title": { + "sourceHash": "f923506" + }, + "askCmdr.renameReview.description": "A Cmdr csak az általad engedélyezett fájlokat nevezi át.", + "@askCmdr.renameReview.description": { + "sourceHash": "de95869" + }, + "askCmdr.renameReview.originalName": "Jelenlegi név", + "@askCmdr.renameReview.originalName": { + "sourceHash": "0619bd6" + }, + "askCmdr.renameReview.newName": "Új név", + "@askCmdr.renameReview.newName": { + "sourceHash": "04eea2a" + }, + "askCmdr.renameReview.allow": "Engedélyezés", + "@askCmdr.renameReview.allow": { + "sourceHash": "e213c16" + }, + "askCmdr.renameReview.deny": "Elutasítás", + "@askCmdr.renameReview.deny": { + "sourceHash": "05a2d73" + }, + "askCmdr.renameReview.allowAll": "Összes engedélyezése", + "@askCmdr.renameReview.allowAll": { + "sourceHash": "56ac845" + }, + "askCmdr.renameReview.denyAll": "Összes elutasítása", + "@askCmdr.renameReview.denyAll": { + "sourceHash": "5a41803" + }, + "askCmdr.renameReview.cancel": "Mégse", + "@askCmdr.renameReview.cancel": { + "sourceHash": "19766ed" + }, + "askCmdr.renameReview.rename": "{count, plural, one {# fájl átnevezése} other {# fájl átnevezése}}", + "@askCmdr.renameReview.rename": { + "sourceHash": "d4b5c18" + }, + "askCmdr.renameReview.blocked": "Ez az átnevezés figyelmet igényel a folytatás előtt.", + "@askCmdr.renameReview.blocked": { + "sourceHash": "868541c" + }, + "askCmdr.renameReview.expired": "Ez az áttekintés lejárt. Kérd meg az Ask Cmdr-t, hogy készítse el újra.", + "@askCmdr.renameReview.expired": { + "sourceHash": "4e6482a" + }, + "askCmdr.renameReview.status": "{allowed, plural, one {# átnevezés engedélyezve} other {# átnevezés engedélyezve}}; {blocked, plural, one {# blokkolva} other {# blokkolva}}", + "@askCmdr.renameReview.status": { + "sourceHash": "9f70789" } } diff --git a/apps/desktop/src/lib/intl/messages/nl/askCmdr.json b/apps/desktop/src/lib/intl/messages/nl/askCmdr.json index efa918560..fd16c0e8c 100644 --- a/apps/desktop/src/lib/intl/messages/nl/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/nl/askCmdr.json @@ -37,6 +37,10 @@ "@askCmdr.thinking": { "sourceHash": "a02f1ce" }, + "askCmdr.stalled": "Dit duurt langer dan gewoonlijk. Je kunt blijven wachten of stoppen.", + "@askCmdr.stalled": { + "sourceHash": "c171aab" + }, "askCmdr.softCap.message": "Deze chat wordt lang. Een nieuwe chat houdt de antwoorden snel en gefocust.", "@askCmdr.softCap.message": { "sourceHash": "60d2c54" @@ -125,6 +129,14 @@ "@askCmdr.tool.imageFacts.done": { "sourceHash": "fc24aac" }, + "askCmdr.tool.proposeRenamePlan.doing": "Een hernoemingsplan voorbereiden", + "@askCmdr.tool.proposeRenamePlan.doing": { + "sourceHash": "540f1d9" + }, + "askCmdr.tool.proposeRenamePlan.done": "Een hernoemingsplan voorbereid", + "@askCmdr.tool.proposeRenamePlan.done": { + "sourceHash": "a271302" + }, "askCmdr.tool.unknown.doing": "Bezig", "@askCmdr.tool.unknown.doing": { "sourceHash": "a92f044" @@ -339,5 +351,57 @@ "askCmdr.cost.label": "Gebruik van deze chat", "@askCmdr.cost.label": { "sourceHash": "3b0dd81" + }, + "askCmdr.renameReview.title": "Hernoemingen beoordelen", + "@askCmdr.renameReview.title": { + "sourceHash": "f923506" + }, + "askCmdr.renameReview.description": "Cmdr hernoemt alleen de bestanden die je toestaat.", + "@askCmdr.renameReview.description": { + "sourceHash": "de95869" + }, + "askCmdr.renameReview.originalName": "Huidige naam", + "@askCmdr.renameReview.originalName": { + "sourceHash": "0619bd6" + }, + "askCmdr.renameReview.newName": "Nieuwe naam", + "@askCmdr.renameReview.newName": { + "sourceHash": "04eea2a" + }, + "askCmdr.renameReview.allow": "Toestaan", + "@askCmdr.renameReview.allow": { + "sourceHash": "e213c16" + }, + "askCmdr.renameReview.deny": "Weigeren", + "@askCmdr.renameReview.deny": { + "sourceHash": "05a2d73" + }, + "askCmdr.renameReview.allowAll": "Alles toestaan", + "@askCmdr.renameReview.allowAll": { + "sourceHash": "56ac845" + }, + "askCmdr.renameReview.denyAll": "Alles weigeren", + "@askCmdr.renameReview.denyAll": { + "sourceHash": "5a41803" + }, + "askCmdr.renameReview.cancel": "Annuleren", + "@askCmdr.renameReview.cancel": { + "sourceHash": "19766ed" + }, + "askCmdr.renameReview.rename": "{count, plural, one {# bestand hernoemen} other {# bestanden hernoemen}}", + "@askCmdr.renameReview.rename": { + "sourceHash": "d4b5c18" + }, + "askCmdr.renameReview.blocked": "Deze hernoeming heeft aandacht nodig voordat je verdergaat.", + "@askCmdr.renameReview.blocked": { + "sourceHash": "868541c" + }, + "askCmdr.renameReview.expired": "Deze beoordeling is verlopen. Vraag Ask Cmdr om deze opnieuw voor te bereiden.", + "@askCmdr.renameReview.expired": { + "sourceHash": "4e6482a" + }, + "askCmdr.renameReview.status": "{allowed, plural, one {# hernoeming toegestaan} other {# hernoemingen toegestaan}}; {blocked, plural, one {# geblokkeerd} other {# geblokkeerd}}", + "@askCmdr.renameReview.status": { + "sourceHash": "9f70789" } } diff --git a/apps/desktop/src/lib/intl/messages/pt/askCmdr.json b/apps/desktop/src/lib/intl/messages/pt/askCmdr.json index 6da195d48..27c70ce05 100644 --- a/apps/desktop/src/lib/intl/messages/pt/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/pt/askCmdr.json @@ -36,6 +36,10 @@ "@askCmdr.thinking": { "sourceHash": "a02f1ce" }, + "askCmdr.stalled": "Isso está demorando mais do que o normal. Você pode continuar esperando ou parar.", + "@askCmdr.stalled": { + "sourceHash": "c171aab" + }, "askCmdr.softCap.message": "Este chat está ficando longo. Um novo mantém as respostas rápidas e focadas.", "@askCmdr.softCap.message": { "sourceHash": "60d2c54" @@ -124,6 +128,14 @@ "@askCmdr.tool.imageFacts.done": { "sourceHash": "fc24aac" }, + "askCmdr.tool.proposeRenamePlan.doing": "Preparando um plano de renomeação", + "@askCmdr.tool.proposeRenamePlan.doing": { + "sourceHash": "540f1d9" + }, + "askCmdr.tool.proposeRenamePlan.done": "Preparou um plano de renomeação", + "@askCmdr.tool.proposeRenamePlan.done": { + "sourceHash": "a271302" + }, "askCmdr.tool.unknown.doing": "Trabalhando", "@askCmdr.tool.unknown.doing": { "sourceHash": "a92f044" @@ -337,5 +349,57 @@ "askCmdr.cost.label": "Uso deste chat", "@askCmdr.cost.label": { "sourceHash": "3b0dd81" + }, + "askCmdr.renameReview.title": "Rever alterações de nome", + "@askCmdr.renameReview.title": { + "sourceHash": "f923506" + }, + "askCmdr.renameReview.description": "O Cmdr só vai alterar o nome dos ficheiros que permitir.", + "@askCmdr.renameReview.description": { + "sourceHash": "de95869" + }, + "askCmdr.renameReview.originalName": "Nome atual", + "@askCmdr.renameReview.originalName": { + "sourceHash": "0619bd6" + }, + "askCmdr.renameReview.newName": "Nome novo", + "@askCmdr.renameReview.newName": { + "sourceHash": "04eea2a" + }, + "askCmdr.renameReview.allow": "Permitir", + "@askCmdr.renameReview.allow": { + "sourceHash": "e213c16" + }, + "askCmdr.renameReview.deny": "Recusar", + "@askCmdr.renameReview.deny": { + "sourceHash": "05a2d73" + }, + "askCmdr.renameReview.allowAll": "Permitir todos", + "@askCmdr.renameReview.allowAll": { + "sourceHash": "56ac845" + }, + "askCmdr.renameReview.denyAll": "Recusar todos", + "@askCmdr.renameReview.denyAll": { + "sourceHash": "5a41803" + }, + "askCmdr.renameReview.cancel": "Cancelar", + "@askCmdr.renameReview.cancel": { + "sourceHash": "19766ed" + }, + "askCmdr.renameReview.rename": "{count, plural, one {Alterar o nome de # ficheiro} many {Alterar o nome de # ficheiros} other {Alterar o nome de # ficheiros}}", + "@askCmdr.renameReview.rename": { + "sourceHash": "d4b5c18" + }, + "askCmdr.renameReview.blocked": "Esta alteração de nome precisa de atenção antes de continuar.", + "@askCmdr.renameReview.blocked": { + "sourceHash": "868541c" + }, + "askCmdr.renameReview.expired": "Esta revisão expirou. Peça ao Ask Cmdr para a preparar novamente.", + "@askCmdr.renameReview.expired": { + "sourceHash": "4e6482a" + }, + "askCmdr.renameReview.status": "{allowed, plural, one {# alteração de nome permitida} many {# alterações de nome permitidas} other {# alterações de nome permitidas}}; {blocked, plural, one {# bloqueada} many {# bloqueadas} other {# bloqueadas}}", + "@askCmdr.renameReview.status": { + "sourceHash": "9f70789" } } diff --git a/apps/desktop/src/lib/intl/messages/sv/askCmdr.json b/apps/desktop/src/lib/intl/messages/sv/askCmdr.json index 114481db0..02ddc65d7 100644 --- a/apps/desktop/src/lib/intl/messages/sv/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/sv/askCmdr.json @@ -36,6 +36,10 @@ "@askCmdr.thinking": { "sourceHash": "a02f1ce" }, + "askCmdr.stalled": "Det tar längre tid än vanligt. Du kan fortsätta vänta eller stoppa.", + "@askCmdr.stalled": { + "sourceHash": "c171aab" + }, "askCmdr.softCap.message": "Den här chatten börjar bli lång. En ny håller svaren snabba och fokuserade.", "@askCmdr.softCap.message": { "sourceHash": "60d2c54" @@ -124,6 +128,14 @@ "@askCmdr.tool.imageFacts.done": { "sourceHash": "fc24aac" }, + "askCmdr.tool.proposeRenamePlan.doing": "Förbereder en namnbytesplan", + "@askCmdr.tool.proposeRenamePlan.doing": { + "sourceHash": "540f1d9" + }, + "askCmdr.tool.proposeRenamePlan.done": "Förberedde en namnbytesplan", + "@askCmdr.tool.proposeRenamePlan.done": { + "sourceHash": "a271302" + }, "askCmdr.tool.unknown.doing": "Arbetar", "@askCmdr.tool.unknown.doing": { "sourceHash": "a92f044" @@ -336,5 +348,57 @@ "askCmdr.cost.label": "Den här chattens användning", "@askCmdr.cost.label": { "sourceHash": "3b0dd81" + }, + "askCmdr.renameReview.title": "Granska filbyten", + "@askCmdr.renameReview.title": { + "sourceHash": "f923506" + }, + "askCmdr.renameReview.description": "Cmdr byter bara namn på filerna som du tillåter.", + "@askCmdr.renameReview.description": { + "sourceHash": "de95869" + }, + "askCmdr.renameReview.originalName": "Nuvarande namn", + "@askCmdr.renameReview.originalName": { + "sourceHash": "0619bd6" + }, + "askCmdr.renameReview.newName": "Nytt namn", + "@askCmdr.renameReview.newName": { + "sourceHash": "04eea2a" + }, + "askCmdr.renameReview.allow": "Tillåt", + "@askCmdr.renameReview.allow": { + "sourceHash": "e213c16" + }, + "askCmdr.renameReview.deny": "Neka", + "@askCmdr.renameReview.deny": { + "sourceHash": "05a2d73" + }, + "askCmdr.renameReview.allowAll": "Tillåt alla", + "@askCmdr.renameReview.allowAll": { + "sourceHash": "56ac845" + }, + "askCmdr.renameReview.denyAll": "Neka alla", + "@askCmdr.renameReview.denyAll": { + "sourceHash": "5a41803" + }, + "askCmdr.renameReview.cancel": "Avbryt", + "@askCmdr.renameReview.cancel": { + "sourceHash": "19766ed" + }, + "askCmdr.renameReview.rename": "{count, plural, one {Byt namn på # fil} other {Byt namn på # filer}}", + "@askCmdr.renameReview.rename": { + "sourceHash": "d4b5c18" + }, + "askCmdr.renameReview.blocked": "Det här namnbytet behöver uppmärksamhet innan det kan fortsätta.", + "@askCmdr.renameReview.blocked": { + "sourceHash": "868541c" + }, + "askCmdr.renameReview.expired": "Den här granskningen har gått ut. Be Ask Cmdr att förbereda den igen.", + "@askCmdr.renameReview.expired": { + "sourceHash": "4e6482a" + }, + "askCmdr.renameReview.status": "{allowed, plural, one {# namnbyte tillåtet} other {# namnbyten tillåtna}}; {blocked, plural, one {# blockerat} other {# blockerade}}", + "@askCmdr.renameReview.status": { + "sourceHash": "9f70789" } } diff --git a/apps/desktop/src/lib/intl/messages/vi/askCmdr.json b/apps/desktop/src/lib/intl/messages/vi/askCmdr.json index 9d9d98173..ea31d0ee1 100644 --- a/apps/desktop/src/lib/intl/messages/vi/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/vi/askCmdr.json @@ -36,6 +36,10 @@ "@askCmdr.thinking": { "sourceHash": "a02f1ce" }, + "askCmdr.stalled": "Việc này mất nhiều thời gian hơn bình thường. Bạn có thể tiếp tục chờ hoặc dừng lại.", + "@askCmdr.stalled": { + "sourceHash": "c171aab" + }, "askCmdr.softCap.message": "Cuộc trò chuyện này đang dài dần. Một cuộc trò chuyện mới sẽ giúp câu trả lời nhanh và tập trung hơn.", "@askCmdr.softCap.message": { "sourceHash": "60d2c54" @@ -124,6 +128,14 @@ "@askCmdr.tool.imageFacts.done": { "sourceHash": "fc24aac" }, + "askCmdr.tool.proposeRenamePlan.doing": "Đang chuẩn bị kế hoạch đổi tên", + "@askCmdr.tool.proposeRenamePlan.doing": { + "sourceHash": "540f1d9" + }, + "askCmdr.tool.proposeRenamePlan.done": "Đã chuẩn bị kế hoạch đổi tên", + "@askCmdr.tool.proposeRenamePlan.done": { + "sourceHash": "a271302" + }, "askCmdr.tool.unknown.doing": "Đang xử lý", "@askCmdr.tool.unknown.doing": { "sourceHash": "a92f044" @@ -335,5 +347,57 @@ "askCmdr.cost.label": "Mức sử dụng của cuộc trò chuyện này", "@askCmdr.cost.label": { "sourceHash": "3b0dd81" + }, + "askCmdr.renameReview.title": "Xem lại việc đổi tên tệp", + "@askCmdr.renameReview.title": { + "sourceHash": "f923506" + }, + "askCmdr.renameReview.description": "Cmdr chỉ đổi tên những tệp bạn cho phép.", + "@askCmdr.renameReview.description": { + "sourceHash": "de95869" + }, + "askCmdr.renameReview.originalName": "Tên hiện tại", + "@askCmdr.renameReview.originalName": { + "sourceHash": "0619bd6" + }, + "askCmdr.renameReview.newName": "Tên mới", + "@askCmdr.renameReview.newName": { + "sourceHash": "04eea2a" + }, + "askCmdr.renameReview.allow": "Cho phép", + "@askCmdr.renameReview.allow": { + "sourceHash": "e213c16" + }, + "askCmdr.renameReview.deny": "Từ chối", + "@askCmdr.renameReview.deny": { + "sourceHash": "05a2d73" + }, + "askCmdr.renameReview.allowAll": "Cho phép tất cả", + "@askCmdr.renameReview.allowAll": { + "sourceHash": "56ac845" + }, + "askCmdr.renameReview.denyAll": "Từ chối tất cả", + "@askCmdr.renameReview.denyAll": { + "sourceHash": "5a41803" + }, + "askCmdr.renameReview.cancel": "Hủy", + "@askCmdr.renameReview.cancel": { + "sourceHash": "19766ed" + }, + "askCmdr.renameReview.rename": "Đổi tên {count, plural, other {# tệp}}", + "@askCmdr.renameReview.rename": { + "sourceHash": "d4b5c18" + }, + "askCmdr.renameReview.blocked": "Việc đổi tên này cần được chú ý trước khi tiếp tục.", + "@askCmdr.renameReview.blocked": { + "sourceHash": "868541c" + }, + "askCmdr.renameReview.expired": "Lần xem lại này đã hết hạn. Hãy yêu cầu Ask Cmdr chuẩn bị lại.", + "@askCmdr.renameReview.expired": { + "sourceHash": "4e6482a" + }, + "askCmdr.renameReview.status": "{allowed, plural, other {# lần đổi tên được cho phép}}; {blocked, plural, other {# mục bị chặn}}", + "@askCmdr.renameReview.status": { + "sourceHash": "9f70789" } } diff --git a/apps/desktop/src/lib/intl/messages/zh/askCmdr.json b/apps/desktop/src/lib/intl/messages/zh/askCmdr.json index 6b1c4c469..0f470a153 100644 --- a/apps/desktop/src/lib/intl/messages/zh/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/zh/askCmdr.json @@ -36,6 +36,10 @@ "@askCmdr.thinking": { "sourceHash": "a02f1ce" }, + "askCmdr.stalled": "这次耗时比平时更长。你可以继续等待或停止。", + "@askCmdr.stalled": { + "sourceHash": "c171aab" + }, "askCmdr.softCap.message": "这段聊天已经有点长了,开一段新的能让回复更快、更专注。", "@askCmdr.softCap.message": { "sourceHash": "60d2c54" @@ -124,6 +128,14 @@ "@askCmdr.tool.imageFacts.done": { "sourceHash": "fc24aac" }, + "askCmdr.tool.proposeRenamePlan.doing": "正在准备重命名计划", + "@askCmdr.tool.proposeRenamePlan.doing": { + "sourceHash": "540f1d9" + }, + "askCmdr.tool.proposeRenamePlan.done": "已准备好重命名计划", + "@askCmdr.tool.proposeRenamePlan.done": { + "sourceHash": "a271302" + }, "askCmdr.tool.unknown.doing": "正在处理", "@askCmdr.tool.unknown.doing": { "sourceHash": "a92f044" @@ -335,5 +347,57 @@ "askCmdr.cost.label": "这次聊天的用量", "@askCmdr.cost.label": { "sourceHash": "3b0dd81" + }, + "askCmdr.renameReview.title": "检查文件重命名", + "@askCmdr.renameReview.title": { + "sourceHash": "f923506" + }, + "askCmdr.renameReview.description": "Cmdr 只会重命名你允许的文件。", + "@askCmdr.renameReview.description": { + "sourceHash": "de95869" + }, + "askCmdr.renameReview.originalName": "当前名称", + "@askCmdr.renameReview.originalName": { + "sourceHash": "0619bd6" + }, + "askCmdr.renameReview.newName": "新名称", + "@askCmdr.renameReview.newName": { + "sourceHash": "04eea2a" + }, + "askCmdr.renameReview.allow": "允许", + "@askCmdr.renameReview.allow": { + "sourceHash": "e213c16" + }, + "askCmdr.renameReview.deny": "拒绝", + "@askCmdr.renameReview.deny": { + "sourceHash": "05a2d73" + }, + "askCmdr.renameReview.allowAll": "全部允许", + "@askCmdr.renameReview.allowAll": { + "sourceHash": "56ac845" + }, + "askCmdr.renameReview.denyAll": "全部拒绝", + "@askCmdr.renameReview.denyAll": { + "sourceHash": "5a41803" + }, + "askCmdr.renameReview.cancel": "取消", + "@askCmdr.renameReview.cancel": { + "sourceHash": "19766ed" + }, + "askCmdr.renameReview.rename": "重命名 {count, plural, other {# 个文件}}", + "@askCmdr.renameReview.rename": { + "sourceHash": "d4b5c18" + }, + "askCmdr.renameReview.blocked": "此重命名需要处理后才能继续。", + "@askCmdr.renameReview.blocked": { + "sourceHash": "868541c" + }, + "askCmdr.renameReview.expired": "此审核已过期。请让 Ask Cmdr 再次准备。", + "@askCmdr.renameReview.expired": { + "sourceHash": "4e6482a" + }, + "askCmdr.renameReview.status": "{allowed, plural, other {已允许 # 项重命名}};{blocked, plural, other {已阻止 # 项}}", + "@askCmdr.renameReview.status": { + "sourceHash": "9f70789" } } diff --git a/apps/desktop/src/lib/ipc/DETAILS.md b/apps/desktop/src/lib/ipc/DETAILS.md index 6a15a48fd..512f0d774 100644 --- a/apps/desktop/src/lib/ipc/DETAILS.md +++ b/apps/desktop/src/lib/ipc/DETAILS.md @@ -37,6 +37,10 @@ For commands that return `Result` on the Rust side, the TS wrapper returns `{ status: 'ok', data: T } | { status: 'error', error: E }`. Most call sites unwrap via `throwIpcError` from `$lib/tauri-commands/ipc-types`. +The Ask Cmdr bulk-rename preflight, apply, and cancel commands follow this generated-binding path. The streaming +`proposalReady` event remains part of Ask Cmdr's Channel-only stream, so its display-only wire shape stays hand-mirrored +in `tauri-commands/ask-cmdr.ts`. + ## Typed events Events are wired through the same `tauri-specta` machinery as commands. Every typeable event is a Rust struct deriving diff --git a/apps/desktop/src/lib/ipc/bindings.ts b/apps/desktop/src/lib/ipc/bindings.ts index 3c46db61f..88661a08d 100644 --- a/apps/desktop/src/lib/ipc/bindings.ts +++ b/apps/desktop/src/lib/ipc/bindings.ts @@ -2124,6 +2124,23 @@ export const commands = { * no-op. A clean stop at the next tool boundary or stream chunk, not a hard abort. */ askCmdrCancel: (conversationId: number) => __TAURI_INVOKE('ask_cmdr_cancel', { conversationId }), + /** + * Revalidates the user-selected subset of a server-owned rename proposal. The + * frontend supplies opaque ids only, never source paths or destination names. + */ + preflightBulkRename: (proposalId: string, allowedRowIds: string[]) => + typedError(__TAURI_INVOKE('preflight_bulk_rename', { proposalId, allowedRowIds })), + /** + * Starts the user-approved subset of a server-owned rename plan. Paths and + * names never cross this IPC boundary: the frontend submits only opaque ids. + */ + applyBulkRename: (proposalId: string, allowedRowIds: string[]) => + typedError(__TAURI_INVOKE('apply_bulk_rename', { proposalId, allowedRowIds })), + /** + * Discards a staged proposal after the user closes its review. There is no + * agent-controlled approval route: only this user action consumes the plan. + */ + cancelBulkRenameProposal: (proposalId: string) => __TAURI_INVOKE('cancel_bulk_rename_proposal', { proposalId }), /** * A settings change may have switched the model for an open thread: record it as a * conversation event once any in-flight turn finishes (the turn keeps its already-resolved @@ -3387,6 +3404,33 @@ export type BetaSignupResult = */ | { kind: 'softFailure' } +export type BulkRenameBlockReason = + | 'unknownRow' + | 'duplicateDestination' + | 'sourceMissing' + | 'sourceChanged' + | 'targetExists' + | 'volumeUnavailable' + +/** + * A row's user-action-time validation result. It deliberately contains no + * path or destination authority: the frontend retains only opaque row ids. + */ +export type BulkRenamePreflight = { + status: BulkRenamePreflightStatus + rows: BulkRenamePreflightRow[] +} + +export type BulkRenamePreflightRow = { + rowId: string + status: BulkRenameRowStatus + reason: BulkRenameBlockReason | null +} + +export type BulkRenamePreflightStatus = 'ready' | 'blocked' | 'expired' + +export type BulkRenameRowStatus = 'ready' | 'blocked' + /** * Logical-pixel rectangle. `f64` mirrors what Tauri's `LogicalPosition` / * `LogicalSize` use on the wire. diff --git a/apps/desktop/src/lib/onboarding/CloudProviderSetup.test.ts b/apps/desktop/src/lib/onboarding/CloudProviderSetup.test.ts index aa861260a..ca7429562 100644 --- a/apps/desktop/src/lib/onboarding/CloudProviderSetup.test.ts +++ b/apps/desktop/src/lib/onboarding/CloudProviderSetup.test.ts @@ -187,6 +187,12 @@ describe('CloudProviderSetup', () => { mountSetup('custom') await settle() if (!mounted) throw new Error('not mounted') + const keyInput = mounted.target.querySelector('input[aria-label="API key"]') + if (!keyInput) throw new Error('API key input missing') + keyInput.value = 'custom-test-key' + keyInput.dispatchEvent(new Event('input', { bubbles: true })) + await advanceTimers(400) + checkAiConnection.mockClear() const baseUrlInput = mounted.target.querySelector('#onboarding-cloud-base-url') if (!baseUrlInput) throw new Error('base URL input missing') baseUrlInput.value = 'https://example.test/v1' diff --git a/apps/desktop/src/lib/onboarding/DETAILS.md b/apps/desktop/src/lib/onboarding/DETAILS.md index 80e54db54..a8439284a 100644 --- a/apps/desktop/src/lib/onboarding/DETAILS.md +++ b/apps/desktop/src/lib/onboarding/DETAILS.md @@ -23,7 +23,8 @@ finishes onboarding. - **`CloudProviderPicker.svelte`**: Step 2 left column: scrollable listbox of all 15 cloud providers. Single tab stop via `aria-activedescendant` (no roving focus); Arrow / Home / End / type-to-jump move the active option. - **`CloudProviderSetup.svelte`**: Step 2 right column: per-provider numbered tutorial with API-key persist + - auto-check + model combobox. + auto-check + model combobox. Providers with editable OpenAI-compatible endpoints, including Custom, still require a + stored API key before the endpoint check runs. - **`StepBeta.svelte`**: Step 3 (Open beta, non-skippable): personal open-beta intro (feedback channels: in-app, GitHub, Discord, book-a-call) + anonymous-analytics disclosure + `analytics.enabled` opt-out switch + optional `analytics.email` contact field. Footer = "Start using Cmdr!" (finish here) + "One more optional setup step" diff --git a/apps/desktop/src/lib/settings/DETAILS.md b/apps/desktop/src/lib/settings/DETAILS.md index 2d7dcfb7b..b17d59378 100644 --- a/apps/desktop/src/lib/settings/DETAILS.md +++ b/apps/desktop/src/lib/settings/DETAILS.md @@ -243,10 +243,11 @@ file map, the 50-50 split-layout rule, and the `SettingPasswordInput` store-driv ### Other files -- **cloud-providers.ts**: Cloud provider preset definitions (OpenAI, Anthropic, Groq, etc.) and per-provider config - helpers (`getProviderConfigs`, `setProviderConfig`, `resolveCloudConfig`). Used by `AiSection` and the startup flow in - `+layout.svelte` to resolve the effective base URL and model. The API key is fetched separately from the OS secret - store via `getAiApiKey(providerId)` before calling `configureAi`. +- **cloud-providers.ts**: Cloud provider preset definitions (OpenAI, Anthropic, Qwen, Groq, etc.) and per-provider + config helpers (`getProviderConfigs`, `setProviderConfig`, `resolveCloudConfig`). Used by `AiSection` and the startup + flow in `+layout.svelte` to resolve the effective base URL and model. Custom is an API-key-backed OpenAI-compatible + provider with an editable base URL; Qwen presets DashScope's compatible endpoint. API keys are fetched separately from + the OS secret store via `getAiApiKey(providerId)` before calling `configureAi`. - **settings-search.ts**: Fuzzy search over setting definitions; returns ranked matches with highlight ranges - **settings-applier.ts**: Listens for setting changes and applies side effects (CSS vars, backend config sync). The `passthroughBackendHandlers` table includes three entries for `ai.provider` / `ai.cloudProvider` / diff --git a/apps/desktop/src/lib/settings/cloud-providers.test.ts b/apps/desktop/src/lib/settings/cloud-providers.test.ts index 7bd1a646d..ddcf597d0 100644 --- a/apps/desktop/src/lib/settings/cloud-providers.test.ts +++ b/apps/desktop/src/lib/settings/cloud-providers.test.ts @@ -12,6 +12,7 @@ describe('cloudProviderPresets', () => { const ids = cloudProviderPresets.map((p) => p.id) expect(ids).toContain('openai') expect(ids).toContain('anthropic') + expect(ids).toContain('qwen') expect(ids).toContain('ollama') expect(ids).toContain('custom') }) @@ -43,6 +44,14 @@ describe('getCloudProvider', () => { it('returns undefined for unknown ID', () => { expect(getCloudProvider('nonexistent')).toBeUndefined() }) + + it('configures Qwen and Custom as API-key-backed providers', () => { + expect(getCloudProvider('qwen')).toMatchObject({ + baseUrl: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + requiresApiKey: true, + }) + expect(getCloudProvider('custom')?.requiresApiKey).toBe(true) + }) }) describe('getProviderConfigs', () => { diff --git a/apps/desktop/src/lib/settings/cloud-providers.ts b/apps/desktop/src/lib/settings/cloud-providers.ts index 942f9d33f..bcfb7bfc3 100644 --- a/apps/desktop/src/lib/settings/cloud-providers.ts +++ b/apps/desktop/src/lib/settings/cloud-providers.ts @@ -100,6 +100,16 @@ export const cloudProviderPresets: CloudProviderPreset[] = [ isLocal: false, description: 'Strong coding and reasoning at low cost.', }, + { + id: 'qwen', + name: 'Qwen', + baseUrl: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + defaultModel: 'qwen-plus', + requiresApiKey: true, + supportsModelList: true, + isLocal: false, + description: 'Qwen models through Alibaba Cloud’s OpenAI-compatible API.', + }, { id: 'xai', name: 'xAI', @@ -155,7 +165,7 @@ export const cloudProviderPresets: CloudProviderPreset[] = [ name: 'Custom', baseUrl: '', defaultModel: '', - requiresApiKey: false, + requiresApiKey: true, supportsModelList: true, isLocal: false, description: 'Any OpenAI-compatible API endpoint.', diff --git a/apps/desktop/src/lib/tauri-commands/DETAILS.md b/apps/desktop/src/lib/tauri-commands/DETAILS.md index 33e5cc4d9..9cd973593 100644 --- a/apps/desktop/src/lib/tauri-commands/DETAILS.md +++ b/apps/desktop/src/lib/tauri-commands/DETAILS.md @@ -95,6 +95,10 @@ commands, and notable non-obvious placements. ## Notable non-obvious placements +`ask-cmdr.ts` hand-mirrors the backend channel event union. `proposalReady` carries names for display only; the rename +review's `preflightBulkRename` and `applyBulkRename` calls send opaque proposal and row ids, never reconstructed paths, +destinations, or fingerprints. + - `formatBytes` and `formatDuration` are co-located in `write-operations.ts` with no IPC calls. - `listen` and `UnlistenFn` from `@tauri-apps/api/event` are re-exported through `write-operations.ts`. - `getSyncStatus` and font metrics (`storeFontMetrics`, `hasFontMetrics`) live in `file-listing.ts` because they diff --git a/apps/desktop/src/lib/tauri-commands/ask-cmdr.ts b/apps/desktop/src/lib/tauri-commands/ask-cmdr.ts index 1fde620b0..87d805171 100644 --- a/apps/desktop/src/lib/tauri-commands/ask-cmdr.ts +++ b/apps/desktop/src/lib/tauri-commands/ask-cmdr.ts @@ -70,6 +70,10 @@ export type AskCmdrStreamEvent = | { type: 'reasoningTick' } | { type: 'toolCallStarted'; callId: string; tool: string } | { type: 'toolCallFinished'; callId: string; ok: boolean } + | { + type: 'proposalReady' + proposal: { proposalId: string; rows: Array<{ rowId: string; sourceName: string; destinationName: string }> } + } | { type: 'done'; messageId: number; seq: number; stop: StopReason; usage: AskCmdrUsage } /** `detail` is the source error's own wording for display under the typed headline * (a retired model slug, a quota reset time); never branch on it. */ @@ -105,6 +109,26 @@ export async function cancelAskCmdr(conversationId: number): Promise { await commands.askCmdrCancel(conversationId) } +/** Rechecks the user-selected rows of a server-owned rename proposal. Only opaque ids + * cross the IPC boundary: source paths and destination names stay in the backend. */ +export async function preflightBulkRename(proposalId: string, allowedRowIds: string[]) { + const res = await commands.preflightBulkRename(proposalId, allowedRowIds) + if (res.status === 'error') throwIpcError(res.error) + return res.data +} + +/** Starts the one queued operation for the exact rows the user allowed after preflight. */ +export async function applyBulkRename(proposalId: string, allowedRowIds: string[]) { + const res = await commands.applyBulkRename(proposalId, allowedRowIds) + if (res.status === 'error') throwIpcError(res.error) + return res.data +} + +/** Discard a staged rename proposal after the user closes its review. */ +export async function cancelBulkRenameProposal(proposalId: string): Promise { + await commands.cancelBulkRenameProposal(proposalId) +} + /** Record that a settings change switched a thread's effective model. Resolves once any * in-flight turn finished (the backend queues on the thread's single-flight lock), with * the persisted event's display view — or `null` when nothing changed for this thread. */ diff --git a/apps/desktop/src/lib/tauri-commands/index.ts b/apps/desktop/src/lib/tauri-commands/index.ts index 4b4ab5ea3..e63e9cd88 100644 --- a/apps/desktop/src/lib/tauri-commands/index.ts +++ b/apps/desktop/src/lib/tauri-commands/index.ts @@ -313,6 +313,9 @@ export type { OperationRow, OperationItemView, OperationLogDetail } from './oper export { sendAskCmdrMessage, cancelAskCmdr, + applyBulkRename, + preflightBulkRename, + cancelBulkRenameProposal, recordAskCmdrModelChange, getAskCmdrConversation, listAskCmdrConversations, diff --git a/apps/desktop/src/lib/ui/DETAILS.md b/apps/desktop/src/lib/ui/DETAILS.md index 1dcd001f1..ddef7a859 100644 --- a/apps/desktop/src/lib/ui/DETAILS.md +++ b/apps/desktop/src/lib/ui/DETAILS.md @@ -120,6 +120,9 @@ To add a new dialog: 1. Add an entry to `SOFT_DIALOG_REGISTRY` in `dialog-registry.ts`. 2. Pass the new id as `dialogId` to `ModalDialog`. MCP tracking is then automatic. +`bulk-rename-review` is an Ask Cmdr-owned modal mounted beside the rail, rather than a pane command dialog. Its review +rows are display-only; the frontend returns opaque proposal and row ids to the backend for preflight or apply. + ### Generic close (`dialog-close-registry.ts`) The MCP `dialog` tool's generic `close` action closes any registered soft dialog by id. `dialog-close-registry.ts` holds diff --git a/apps/desktop/src/lib/ui/dialog-registry.ts b/apps/desktop/src/lib/ui/dialog-registry.ts index 578ba6d0c..73c9ba4ec 100644 --- a/apps/desktop/src/lib/ui/dialog-registry.ts +++ b/apps/desktop/src/lib/ui/dialog-registry.ts @@ -31,6 +31,7 @@ export const SOFT_DIALOG_REGISTRY = [ { id: 'delete-ai-model', description: 'Confirmation before deleting the local AI model' }, { id: 'search', description: 'Whole-drive file search' }, { id: 'go-to-path', description: 'Jump the focused pane to a typed or recent path' }, + { id: 'bulk-rename-review', description: 'Reviews an Ask Cmdr rename proposal before any files change' }, { id: 'connect-to-server', description: 'Manual SMB server address entry' }, { id: 'viewer-copy-confirm', description: 'Confirms copying a 10 to 100 MB selection from the file viewer' }, { id: 'viewer-copy-refuse', description: 'Tells the user a > 100 MB viewer selection is too large to copy' }, diff --git a/apps/desktop/src/routes/(main)/+page.svelte b/apps/desktop/src/routes/(main)/+page.svelte index f4352adc8..7b0911dc3 100644 --- a/apps/desktop/src/routes/(main)/+page.svelte +++ b/apps/desktop/src/routes/(main)/+page.svelte @@ -18,6 +18,7 @@ import OperationLogDialog from '$lib/operation-log/OperationLogDialog.svelte' import { operationLogState } from '$lib/operation-log/operation-log-trigger.svelte' import AskCmdrRail from '$lib/ask-cmdr/AskCmdrRail.svelte' + import BulkRenameReviewDialog from '$lib/ask-cmdr/BulkRenameReviewDialog.svelte' import { askCmdrState } from '$lib/ask-cmdr/ask-cmdr-trigger.svelte' import { goToPath } from '$lib/go-to-path/go-to-path' import { @@ -848,6 +849,9 @@ {/if}
+ {#if askCmdrState.renameReview} + + {/if} {/if} diff --git a/apps/desktop/src/routes/(main)/DETAILS.md b/apps/desktop/src/routes/(main)/DETAILS.md index 6abeb84b3..0863ed3ba 100644 --- a/apps/desktop/src/routes/(main)/DETAILS.md +++ b/apps/desktop/src/routes/(main)/DETAILS.md @@ -9,7 +9,8 @@ Depth and rationale for the app orchestrator. `CLAUDE.md` holds the must-knows; - **`+page.svelte`**: app shell: mounts `DualPaneExplorer`, owns top-level dialog visibility (`$state`) and the `explorerRef` handle, owns the keydown / context-menu handlers and onboarding / licensing gating, and orchestrates the extracted listener setup (`setupTauriEventListeners` calls into `listener-setup.ts`, then wires MCP + the event - bridges). + bridges). It also mounts Ask Cmdr's bulk-rename review beside the rail; the rail owns its user decisions and proposal + lifetime. - **`listener-setup.ts`**: the menu, MCP-dialog, and window-focus Tauri listener wiring, extracted out of the component to keep it focused on reactive `$state`. A plain `.ts` (no runes), so it can't hold `$state` directly: state crosses the boundary through `ListenerSetupContext` (getter functions for reads, setter callbacks for writes), which keeps the diff --git a/docs/specs/index.md b/docs/specs/index.md index 1a8ec32d4..2ae3d5825 100644 --- a/docs/specs/index.md +++ b/docs/specs/index.md @@ -6,6 +6,11 @@ this folder is and when it gets wiped. Shipped specs get wiped once their durabl ## In progress +- [ ] 2026-07-20 [natural-language-bulk-rename-plan.md](natural-language-bulk-rename-plan.md) - Let Ask Cmdr turn a + natural-language request into an image-index-informed, same-folder batch rename proposal: the first + `Access::Propose` tool stages at most 200 canonical rows, an accessible review dialog makes every per-row decision + user-owned, and one managed batch operation revalidates, handles rename cycles safely, updates listings, and + journals every row for later rollback. No agent tool can approve or write. - [ ] 2026-07-20 [sealed-subtrees-plan.md](sealed-subtrees-plan.md) - Bound the cost of pathological high-churn directories without lying about folder sizes. Motivated by a measured 7-minute, 1 GB cold-start stall caused by one directory (Google DriveFS `fetch_temp`, 1.14M empty files). M1 ships alone: a two-teeth child-count guard in diff --git a/docs/specs/natural-language-bulk-rename-plan.md b/docs/specs/natural-language-bulk-rename-plan.md new file mode 100644 index 000000000..ec63b0dd2 --- /dev/null +++ b/docs/specs/natural-language-bulk-rename-plan.md @@ -0,0 +1,246 @@ +# Natural-language bulk rename plan + +## Outcome + +Let someone ask Ask Cmdr to rename a set of image files in natural language, then review and approve each exact rename +before Cmdr changes anything. The first use case is screenshots in a folder such as `~/Downloads`, using the local image +index's OCR text and Vision tags to produce useful names. + +The model proposes. The user approves. Applying approved rows uses Cmdr's managed write-operation engine and records one +durable rename operation for later rollback work. + +## Scope + +- **Inputs**: the focused pane's selected files, when there is a selection, otherwise its direct file children. The user + asks in the Ask Cmdr rail in ordinary language. +- **Intelligence**: the agent enumerates the candidate files and uses `image_facts` only when OCR or image tags would + improve the names. It proposes a structured batch of new names and preserves file extensions unless the user + explicitly asks otherwise. +- **Review**: a modal lists original and proposed names in a scrollable, keyboard-operable table. Each row can be + allowed or denied; Allow all and Deny all act on the visible proposal. Cancel discards the whole proposal. OK applies + only allowed rows. +- **Writes**: same-folder file renames only. No move, folder creation, deletion, overwrite, reorganization, or agent + approval capability. +- **Limits**: at most 200 proposed rows, matching `image_facts`' path cap. The agent asks the user to narrow or split a + larger request. + +## Non-goals + +- Arbitrary write tools, tool-driven approval, or an agent-controlled apply path. +- Recursive bulk rename, folders, non-image naming without image-index facts, or a generic proposal framework. +- Editing generated bindings by hand, changing the proposal-access structural tests, or implementing rollback UI. The + existing operation journal captures the operation now; its later rollback surface consumes it. + +## User flow + +1. The user selects files, or opens a folder, opens Ask Cmdr, and describes the desired naming convention. +2. The agent reads `app_state` to learn the focused pane and effective scope. `app_state` gains the focused pane's + `volumeId` and exact selected file entries/paths, not merely its selection count, so the agent can correctly act on a + selection. If a selected index is outside the cached pane entries, the scope is unrepresentable and the tool refuses + rather than silently omitting it. +3. The agent gets the in-scope direct children through `list_dir` when no selection is present. The current-folder + listing is usable immediately; the agent does not wait for a drive scan to finish. +4. `image_facts` is optional enrichment. If image indexing is off or a file has no facts yet, the agent continues with + names, dates, and other available metadata instead of withholding a rename proposal. +5. The agent calls `propose_rename_plan` with the source path, volume id, and a destination file name for every row. The + tool validates and stages a proposal but performs no filesystem mutation. +6. Ask Cmdr receives a typed `ProposalReady { proposalId, snapshot }` stream event and opens the review dialog. The rail + retains the normal tool status line and the agent can explain what it used to derive the names. +7. The user allows or denies rows. The dialog sends only the opaque proposal id and allowed row ids to one bounded + backend preflight command whenever that allowed set changes. It marks malformed, missing-source, + duplicate-destination, and external-target conflicts with an actionable reason. Valid case-only renames and cycles + stay reviewable. +8. OK creates one managed bulk-rename operation. It sends only the opaque proposal id and allowed row ids, revalidates + immediately before each write, updates the listing, records one operation-log header with per-file entries, and + reports completion or the exact rows that did not run. + +## Proposal contract + +### Tool registration + +Add one `mcp_tools!` registry entry: + +- **Name**: `propose_rename_plan`. +- **Consumers**: `Agent` only. +- **Access**: `Access::Propose`. +- **Handler home**: `agent/tools/propose/rename.rs`, alongside its schema and typed input/result models. +- **Agent wiring**: add the `ToolId` variant, wire name, `ToolId::KNOWN` entry, tool label in `ask-cmdr-labels.ts`, and + the literal name in `EXPECTED_PROPOSE_TOOL_NAMES`. Do not change the structural test's shape or weaken its assertions. +- **Refusal copy**: update `agent/tools/view.rs` from "Ask Cmdr is read-only" to explain that Ask Cmdr can prepare + changes for review but cannot make them itself. + +The existing dispatch gate already permits `Access::Read | Access::Propose` and rejects `Write`. Keep that gate and the +agent's lack of any approval tool unchanged. + +### Agent instructions + +Add a narrowly scoped rule to the system/tool instructions. For a natural-language image rename request, the agent must +use only the focused scope; get exact selected entries from `app_state`, or list the focused directory when there is no +selection; treat that listing as ready without waiting for indexing; use `image_facts` only when it improves the names; +preserve extensions unless the user explicitly requests otherwise; and submit a final plan only through +`propose_rename_plan`. The proposal tool is always available and is never gated on indexing state. + +### Input and handler validation + +The input is a single `renames` array, capped at 200: + +```text +{ + renames: [{ sourcePath, volumeId, destinationName }] +} +``` + +`destinationName` is a filename, never a path. The handler rejects separators, `.` and `..`, blank names, invalid +filenames, duplicate sources, duplicate destinations under Cmdr's platform comparator, and an empty proposal. It also +rejects a row unless all of these hold: + +- Its source is a non-directory file in the effective scope captured from `PaneStateStore`: the current selection when + one exists, otherwise a direct child of the currently focused folder. v1 renames a selected symlink as the link, not + its target, because pane state does not prove a richer file kind without touching the live filesystem. +- Its destination remains in that source's parent directory. +- Its volume matches the source's focused-pane `volumeId`. +- Archive-internal paths return a typed refusal. Image indexing state never affects proposal availability. + +Proposal-time validation is entirely cache/state based. "Canonical" means normalized, validated data in a stable typed +shape, never `std::fs::canonicalize`: agent tools do not touch live mounts, follow symlinks, or probe the filesystem. +The handler returns a typed `imageIndexingOff` outcome instead of staging anything when indexing is disabled. It does +not call rename APIs, create temporary files, or probe unbounded directories. + +### Proposal lifetime and authority + +Add a feature-local in-memory `RenameProposalStore`, keyed by an unguessable proposal id. It stores the immutable +accepted rows and their opaque row ids. This is not a generic proposal framework. + +- **Staging**: the proposal handler produces a dispatch outcome containing a concise model-facing `AgentToolResult` and + an internal `RenameProposal` snapshot. The runtime persists only the former in the chat transcript, puts the latter in + `RenameProposalStore`, and emits `ProposalReady` from its turn sink. +- **Frontend boundary**: the event snapshot is display-only. Cancel consumes and discards the proposal. Preflight and + Apply accept only `{ proposalId, allowedRowIds }`; paths, names, volumes, and validation fields never round-trip from + the frontend as authority. The store records the latest successful preflight's exact allowed-id set and its + fingerprints; Apply accepts that exact set only, otherwise it reruns full preflight. +- **Expiry**: close the proposal on Cancel or terminal Apply, and expire abandoned proposals after a short bounded + lifetime. An expired id returns a typed "review expired, ask Cmdr to prepare it again" result. + +### Agent-to-frontend handoff + +Extend the existing Ask Cmdr runtime and IPC stream with `ProposalReady { proposalId, snapshot }`. `ToolDispatcher` must +return the split dispatch outcome above, because today's handler returns only `ToolResult` and cannot emit a chat event +itself. `run_turn` emits the event only after it has stored the immutable proposal. The event is the review-surface +trigger, not an approval or a write. + +Keep the model-facing `AgentToolResult` concise: it confirms that a named number of rows is ready for review and carries +per-row validation refusals where useful. It does not contain the canonical payload because tool results are persisted +and returned to the provider. The frontend receives its display snapshot through the stream event, not by parsing +assistant prose or a tool-result string. + +## Review dialog + +Create `BulkRenameReviewDialog` under the Ask Cmdr feature and mount it beside `AskCmdrRail`, not in the pane-local +`DialogManager`: the proposal belongs to an agent conversation rather than one explorer command. + +- **Header**: say how many files the review covers and state that Cmdr will rename only the allowed rows. +- **Rows**: include a checkbox or Allow/Deny control, original name, proposed name, and a validation status. Long names + truncate visually but expose their complete value to assistive technology. +- **Bulk controls**: Allow all and Deny all change every currently reviewable row. Blocked rows cannot be allowed until + their condition is resolved; the initial state is allowed only for valid rows. +- **Keyboard and a11y**: focus enters the row list, Space toggles the focused row, bulk buttons and footer buttons have + visible focus, and the list announces its allowed/blocked counts. +- **Footer**: Cancel closes and forgets the staged proposal. OK is disabled with no allowed valid rows. Its label states + the number of files it will rename. +- **Copy**: all user-facing strings go through the Ask Cmdr i18n catalog with translator descriptions. Use the shared + `ModalDialog`, `Button`, and existing list/scroll tokens; support dark mode and reduced motion. +- **Dialog contract**: add `bulk-rename-review` to `SOFT_DIALOG_REGISTRY` and pass that `dialogId` to `ModalDialog` so + focus trapping, Escape, app-wide dialog state, and MCP dialog inspection remain intact. + +The dialog owns the user's decisions. Neither the agent response nor a later tool call can change an allowed/denied +state or reopen a cancelled proposal. + +### Backend preflight + +Add one typed, asynchronous, timeout-guarded `preflight_bulk_rename(proposalId, allowedRowIds)` command. It reads the +server-owned proposal, checks all allowed rows together on the appropriate blocking/volume path, and returns one typed +status per row. The dialog reruns it whenever an allowed decision changes, because the rename graph can change with that +subset. The frontend has no filesystem authority and must not call the one-row rename-validity command 200 times. + +Preflight builds a rename graph over the allowed rows: + +- Duplicate final targets are blocked. +- A target owned by another allowed source is a valid dependency, including a cycle. +- A same-source case-only rename is valid and requires a temporary name. +- A target that already exists outside the allowed source set is blocked, with no overwrite option. + +It captures a user-action-time source fingerprint for Apply: local files use device/inode plus metadata; SMB uses its +normalized volume-relative path plus current metadata, because SMB has no portable inode contract. Apply verifies the +matching backend-specific fingerprint and file kind again before renaming. + +## Managed apply + +Add a focused batch operation in `file_system/write_operations/rename.rs`, rather than calling `rename_managed` once per +row from the frontend. One batch must have one operation id, one operation-manager lifecycle, and one operation-log +header with `item_count = allowed rows`; each source-to-destination pair is an item row. + +The operation: + +1. Receives only `proposalId` and allowed row ids through a typed Tauri command with `Initiator::Agent`. The command is + a normal user-initiated command, asynchronous and timeout-guarded like other filesystem commands. +2. Looks up the server-owned proposal and the accepted preflight fingerprint, then revalidates source identity, filename + validity, volume membership, conflicts, and duplicate targets. It refuses conflict overwrite. Any row that has + changed since review is skipped and reported, never guessed at. +3. Creates `WriteOperationState`, an operation event sink, and one queued `OperationDescriptor`; use + `manager::spawn_managed`, not `rename_managed` / `run_instant`. The operation returns its id immediately, reserves + its volume lane when admitted, reports row-count progress, and honours cancellation between filesystem calls. +4. Renames in a collision-safe order. For cycles, swaps, and case-only paths it first assigns unique, same-directory + temporary names, then performs final names. On a mid-operation stop, it records completed and skipped rows honestly; + it does not attempt an unreviewed rollback. +5. Opens one operation-log header on admission with `item_count = allowed rows`, writes one item row per final outcome, + and reuses the existing local/volume rename routes, Downloads watcher write-ignore handling, listing notifications, + busy state, and journal helpers. It never writes through a raw filesystem shortcut. + +The first shipped scope is indexed local volumes and opted-in indexed SMB volumes. MTP cannot produce an image-facts +proposal because it has no background media index. Archive-internal paths are refused explicitly in this feature's first +version rather than accidentally invoking archive mutation semantics. + +## Implementation milestones + +1. **Scope and proposal tool**: add selected-entry plus volume-id reporting to `app_state`; implement the typed + `Propose` tool, feature-local proposal store, split dispatch outcome, registry/`ToolId`/label wiring, 200-row cap, + scope and image-index gates, agent instructions, and the narrow `ProposalReady` stream event. Update the + proposal-name fixture only. +2. **Review UI and preflight**: add proposal state to Ask Cmdr, the registered accessible review dialog, i18n messages, + row/bulk decisions, expiry/cancellation handling, and the bounded backend preflight command with its typed statuses. +3. **Batch write operation**: add the typed id-only apply command and `spawn_managed` batch rename with collision-safe + sequencing, backend-specific revalidation, progress/cancellation, operation-log rows, and list refresh. Wire the + dialog's OK action to it. +4. **Verification**: exercise the red-to-green unit seams, proposal-store lifecycle tests, graph tests for chains, + swaps, cycles, case-only paths, targets appearing after review, and cancellation; frontend dialog/a11y tests; and a + focused desktop E2E that uses a fake image-facts-backed plan. Run + `cargo mutants --file src/file_system/write_operations/rename.rs` or the then-current scoped equivalent after the + substantial write-engine change, `pnpm check -q` after each milestone, and `pnpm check --include-slow -q` before + handoff. + +## Acceptance scenarios + +- A selected group of screenshots receives distinct, valid same-folder names based on their OCR/tags; the user can deny + one row and only the allowed rows change. +- With no selection, the agent considers only direct files in the focused folder, not descendants or the other pane. +- With Image indexing off, a filename- or date-based proposal still opens; the agent only omits image-derived detail. +- A plan with 201 rows, a destination containing a path separator, a duplicate target, or a source outside scope is + refused before review. +- A target that appears after review, a changed local or SMB source, a case-only rename, a chain, and an A-to-B/B-to-A + swap are all safe and transparently represented. +- A selected file that has scrolled out of the pane's cached entries is refused rather than omitted from a plan. +- Cancel and Deny all produce no write operation. No agent tool can approve or apply a proposal. +- The operation log shows the successful bulk rename as one operation with one row per file, ready for the existing + rollback work when that UI ships. + +## Resolved choices + +- **Why an agent-only `Propose` tool**: it keeps the proposal on the agent boundary and prevents the existing external + AI-client MCP surface from gaining a new mutation-adjacent capability. +- **Why a modal, not chat buttons**: reviewing filenames needs a scrollable, columnar surface and independent row + controls. The chat remains the place to state intent and explain the plan. +- **Why a dedicated batch operation**: repeated single-item `rename_managed` calls would create fragmented operation-log + records, cannot safely solve rename cycles as a group, and gives poor cancellation/progress semantics. +- **Why proposal ids are server-owned**: a review dialog must never turn the frontend into a second plan author. Opaque + ids plus allowed row ids preserve the exact plan the agent proposed while leaving every approval decision with the + user. From 2223e7f797aa500adaa3fdae023adcde52e48f59 Mon Sep 17 00:00:00 2001 From: David Veszelovszki Date: Tue, 21 Jul 2026 12:48:02 +0200 Subject: [PATCH 2/4] Ask Cmdr: harden reviewed bulk renames --- README.md | 20 + .../src-tauri/src/agent/chat/system_prompt.rs | 14 +- .../src/agent/tools/propose/DETAILS.md | 6 + .../src/agent/tools/propose/rename.rs | 242 +++++++- .../file_system/write_operations/DETAILS.md | 8 +- .../write_operations/rename/bulk.rs | 518 ++++++++++++------ .../write_operations/rename/bulk/tests.rs | 130 ++++- .../src-tauri/src/mcp/tool_registry/mod.rs | 8 - .../BulkRenameReviewDialog.a11y.test.ts | 58 +- .../ask-cmdr/BulkRenameReviewDialog.svelte | 73 +++ apps/desktop/src/lib/ask-cmdr/DETAILS.md | 14 + .../lib/ask-cmdr/ask-cmdr-trigger.svelte.ts | 22 +- .../src/lib/ask-cmdr/ask-cmdr-trigger.test.ts | 104 ++++ apps/desktop/src/lib/intl/keys.gen.ts | 8 + .../src/lib/intl/messages/de/askCmdr.json | 32 ++ .../src/lib/intl/messages/en/askCmdr.json | 32 ++ .../src/lib/intl/messages/es/askCmdr.json | 32 ++ .../src/lib/intl/messages/fr/askCmdr.json | 34 ++ .../src/lib/intl/messages/hu/askCmdr.json | 32 ++ .../src/lib/intl/messages/nl/askCmdr.json | 32 ++ .../src/lib/intl/messages/pt/askCmdr.json | 32 ++ .../src/lib/intl/messages/sv/askCmdr.json | 32 ++ .../src/lib/intl/messages/vi/askCmdr.json | 32 ++ .../src/lib/intl/messages/zh/askCmdr.json | 32 ++ apps/desktop/src/lib/ipc/bindings.ts | 3 + docs/hackathon-submission.md | 153 ++++++ docs/specs/index.md | 4 + ...-language-bulk-rename-hardening-handoff.md | 268 +++++++++ .../natural-language-bulk-rename-plan.md | 7 +- 29 files changed, 1782 insertions(+), 200 deletions(-) create mode 100644 docs/hackathon-submission.md create mode 100644 docs/specs/natural-language-bulk-rename-hardening-handoff.md diff --git a/README.md b/README.md index b2a54ccd0..a60e76afb 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,26 @@ For work projects, you'll need a license: Purchase at [getcmdr.com/pricing](https://getcmdr.com/pricing). +### OpenAI Build Week submission + +For OpenAI Build Week, I worked with Codex to build a natural-language bulk-rename feature incl. renaming images based +on their content, and a UI for humans to review and apply the rename suggestions. I worked in one long, continuous +session, mainly using Terra (high) and some Sol (medium), from feature planning through +[PR #39](https://github.com/vdavid/cmdr/pull/39). I brought my existing codebase and guardrails, picked the scope, the +plan review process, the UX direction, and what to accept during manual QA. Codex turned those decisions into the +spec/pland then the impl. It delegated plan reviews, translations, and pieces of the implementation to subagents, +diagnosed failures from logs, wrote tests, then prepared the PR and docs. The bulk of the submission-window work is the +natural-language bulk rename feature, but it also needed an MCP extension, a tweak to add an OpenAI-compatible provider, +streaming-state, copyability, localization, and operation-safety changes in the PR, so it came out pretty complex. Mind +that Codex did not build the rest of Cmdr itself: the file manager, the built-in chat, image index, MCP infra, and ops +engine all existed before Build Week. The evidence and collaboration record are in +[docs/hackathon-submission.md](docs/hackathon-submission.md). + +To run the submission without building from source, download Cmdr v0.35.0 from [getcmdr.com](https://getcmdr.com), +configure an AI provider, open a folder or select files, and ask Cmdr to rename them. Review the proposed rows, change +the Allow/Deny choices, and apply the accepted subset; use disposable files for evaluation. The exact submitted code, +scope, and validation record are in [PR #39](https://github.com/vdavid/cmdr/pull/39). + ### Source code The source becomes [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html) after 3 years (rolling per release). Until diff --git a/apps/desktop/src-tauri/src/agent/chat/system_prompt.rs b/apps/desktop/src-tauri/src/agent/chat/system_prompt.rs index ad8c78edd..6b75849d8 100644 --- a/apps/desktop/src-tauri/src/agent/chat/system_prompt.rs +++ b/apps/desktop/src-tauri/src/agent/chat/system_prompt.rs @@ -32,7 +32,9 @@ selection when one exists, otherwise the focused folder, plus the exact volume I that pane listing as ready to use: do not wait for a drive scan or image indexing to finish. The \ propose_rename_plan tool is always available. Use image_facts only when image contents would \ improve the names; if it is unavailable or incomplete, continue with names, dates, and other \ -available metadata. Preserve each file extension unless the user explicitly asks otherwise. Submit \ +available metadata. If list_pane_files returns truncated: true, your reply must say you looked at only \ +returned of total files, using those numbers, and must never imply you covered the full selection or folder. \ +Preserve each file extension unless the user explicitly asks otherwise. Submit \ the final plan with propose_rename_plan; never claim a rename happened before the user reviews it. Be honest about coverage. The tools tell you when their answer is partial: an index \ @@ -85,6 +87,16 @@ mod tests { ); } + #[test] + fn prompt_requires_exact_pane_listing_truncation_disclosure() { + assert!( + SYSTEM_PROMPT.contains("truncated: true") + && SYSTEM_PROMPT.contains("returned of total") + && SYSTEM_PROMPT.contains("must never imply you covered the full"), + "a capped pane listing must be disclosed with exact returned and total counts" + ); + } + #[test] fn prompt_forbids_the_error_and_failed_words() { // The prompt instructs the model to avoid "error"/"failed"; it may quote the diff --git a/apps/desktop/src-tauri/src/agent/tools/propose/DETAILS.md b/apps/desktop/src-tauri/src/agent/tools/propose/DETAILS.md index 085ac96dd..d285b7b5c 100644 --- a/apps/desktop/src-tauri/src/agent/tools/propose/DETAILS.md +++ b/apps/desktop/src-tauri/src/agent/tools/propose/DETAILS.md @@ -3,3 +3,9 @@ The store is feature-local because its opaque ids and immutable rows are the authority boundary for review and apply commands. Entries expire in memory and are deliberately not persisted in chat history. A successful preflight records both the exact allowed row-id set and server-only source fingerprints; Apply atomically consumes that pair, so a dialog cannot replay an already-started plan or substitute a different subset. Proposal validation reads the `PaneStateStore` cache and index registration only. It does not call live filesystem APIs: a dead mount must not hang an agent turn, and symlinks remain links rather than targets. + +Preflight owns row warnings as well as blockers. It compares the final filename extension case-insensitively and marks +extension additions, removals, and changes without blocking them. A renamed dotfile still has no extension; a trailing +dot is an empty extension and therefore differs from no extension. The same warning list carries dependency-cycle +metadata. Preflight peels acyclic dependencies from free destinations and marks only rows left in closed multi-file +cycles, so the frontend renders backend findings instead of re-deriving filename or graph semantics. diff --git a/apps/desktop/src-tauri/src/agent/tools/propose/rename.rs b/apps/desktop/src-tauri/src/agent/tools/propose/rename.rs index 624a72afa..e676ec9f1 100644 --- a/apps/desktop/src-tauri/src/agent/tools/propose/rename.rs +++ b/apps/desktop/src-tauri/src/agent/tools/propose/rename.rs @@ -226,6 +226,7 @@ pub struct BulkRenamePreflightRow { pub row_id: String, pub status: BulkRenameRowStatus, pub reason: Option, + pub warnings: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, specta::Type)] @@ -247,6 +248,13 @@ pub enum BulkRenameBlockReason { VolumeUnavailable, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub enum BulkRenameWarning { + ExtensionChanged, + Cycle, +} + pub struct RenameDispatchOutcome { pub result: AgentToolResult, pub proposal: Option, @@ -359,7 +367,7 @@ fn preflight_local(proposal: &RenameProposal, allowed_row_ids: &[String]) -> Pre let allowed_sources: HashSet<&str> = allowed.iter().map(|row| row.source_path.as_str()).collect(); let mut fingerprints = Vec::new(); - for row in allowed { + for row in &allowed { let Some(status) = rows.get_mut(&row.row_id) else { continue; }; @@ -386,6 +394,7 @@ fn preflight_local(proposal: &RenameProposal, allowed_row_ids: &[String]) -> Pre } fingerprints.push(local_fingerprint(&row.row_id, &source_meta)); } + mark_cycle_warnings(&allowed, &mut rows); finish_preflight(rows, fingerprints) } @@ -407,7 +416,7 @@ async fn preflight_remote(proposal: &RenameProposal, allowed_row_ids: &[String]) return finish_preflight(rows, fingerprints); }; - for row in allowed { + for row in &allowed { let Some(status) = rows.get_mut(&row.row_id) else { continue; }; @@ -436,6 +445,7 @@ async fn preflight_remote(proposal: &RenameProposal, allowed_row_ids: &[String]) modified: source_meta.modified_at.map(|modified| modified as i64), }); } + mark_cycle_warnings(&allowed, &mut rows); finish_preflight(rows, fingerprints) } @@ -452,12 +462,45 @@ fn initial_rows(proposal: &RenameProposal, allowed_row_ids: &[String]) -> HashMa BulkRenameRowStatus::Blocked }, reason: (!known.contains(row_id.as_str())).then_some(BulkRenameBlockReason::UnknownRow), + warnings: proposal + .rows + .iter() + .find(|row| row.row_id == *row_id) + .map_or_else(Vec::new, |row| rename_warnings(&row.source_path, &row.destination_name)), }; (row_id.clone(), row) }) .collect() } +fn rename_warnings(source_path: &str, destination_name: &str) -> Vec { + let source_name = Path::new(source_path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(source_path); + if extensions_match(source_name, destination_name) { + Vec::new() + } else { + vec![BulkRenameWarning::ExtensionChanged] + } +} + +fn extensions_match(source_name: &str, destination_name: &str) -> bool { + match ( + Path::new(source_name).extension(), + Path::new(destination_name).extension(), + ) { + (Some(source), Some(destination)) => { + let (Some(source), Some(destination)) = (source.to_str(), destination.to_str()) else { + return source == destination; + }; + source.eq_ignore_ascii_case(destination) + } + (None, None) => true, + (Some(_), None) | (None, Some(_)) => false, + } +} + fn allowed_rows<'a>( proposal: &'a RenameProposal, allowed_row_ids: &[String], @@ -501,6 +544,56 @@ fn mark_duplicate_destinations(rows: &[&RenameProposalRow], statuses: &mut HashM } } +/// Marks the rows left after repeatedly peeling free destinations. Preflight +/// has already rejected duplicate destinations, so every remaining component +/// is a closed rename cycle. Case-only self-edges are staging requirements, not +/// multi-file cycles, and get no cycle warning. +fn mark_cycle_warnings(rows: &[&RenameProposalRow], statuses: &mut HashMap) { + let mut remaining: HashSet<&str> = rows + .iter() + .filter(|row| { + statuses + .get(&row.row_id) + .is_some_and(|status| status.status == BulkRenameRowStatus::Ready) + }) + .map(|row| row.row_id.as_str()) + .collect(); + loop { + let source_keys: HashSet = rows + .iter() + .filter(|row| remaining.contains(row.row_id.as_str())) + .map(|row| crate::indexing::store::normalize_for_comparison(&row.source_path)) + .collect(); + let free: Vec<&str> = rows + .iter() + .filter(|row| remaining.contains(row.row_id.as_str())) + .filter(|row| { + let source = crate::indexing::store::normalize_for_comparison(&row.source_path); + let destination = Path::new(&row.source_path) + .parent() + .unwrap_or(Path::new("")) + .join(&row.destination_name); + let destination = crate::indexing::store::normalize_for_comparison(&destination.to_string_lossy()); + source == destination || !source_keys.contains(&destination) + }) + .map(|row| row.row_id.as_str()) + .collect(); + if free.is_empty() { + break; + } + for row_id in free { + remaining.remove(row_id); + } + } + for row_id in remaining { + if let Some(status) = statuses.get_mut(row_id) + && !status.warnings.contains(&BulkRenameWarning::Cycle) + { + status.warnings.push(BulkRenameWarning::Cycle); + } + } +} + fn block(row: &mut BulkRenamePreflightRow, reason: BulkRenameBlockReason) { row.status = BulkRenameRowStatus::Blocked; row.reason = Some(reason); @@ -621,12 +714,15 @@ fn build_proposal(app: &AppHandle, params: &Value) -> Result(app: &AppHandle, params: &Value) -> Result bool { + if !volume_uses_local_paths(volume_id) || std::fs::symlink_metadata(source_path).is_ok() { + return false; + } + let source = Path::new(source_path); + source.parent() == Some(Path::new(&state.path)) && source.file_name().is_some() +} + fn focused_state(store: &PaneStateStore) -> PaneState { if store.get_focused_pane() == "right" { store.get_right() @@ -687,6 +795,126 @@ fn validate_destination_name(name: &str) -> Result<(), ToolError> { #[cfg(test)] mod tests { use super::*; + + #[test] + fn cycle_warnings_mark_only_closed_dependency_components() { + let proposal = RenameProposal { + proposal_id: "proposal".into(), + rows: vec![ + proposal_row("chain-a", "/x/a", "b"), + proposal_row("chain-b", "/x/b", "free"), + proposal_row("cycle-a", "/x/c", "d"), + proposal_row("cycle-b", "/x/d", "c"), + ], + }; + let allowed_ids: Vec = proposal.rows.iter().map(|row| row.row_id.clone()).collect(); + let mut statuses = initial_rows(&proposal, &allowed_ids); + let allowed = allowed_rows(&proposal, &allowed_ids, &mut statuses); + + mark_cycle_warnings(&allowed, &mut statuses); + + assert!(statuses["chain-a"].warnings.is_empty()); + assert!(statuses["chain-b"].warnings.is_empty()); + assert_eq!(statuses["cycle-a"].warnings, vec![BulkRenameWarning::Cycle]); + assert_eq!(statuses["cycle-b"].warnings, vec![BulkRenameWarning::Cycle]); + } + + fn proposal_row(row_id: &str, source_path: &str, destination_name: &str) -> RenameProposalRow { + RenameProposalRow { + row_id: row_id.into(), + source_path: source_path.into(), + volume_id: "root".into(), + destination_name: destination_name.into(), + } + } + + #[test] + fn extension_warnings_cover_changes_additions_removals_and_filename_edges() { + for (source, destination) in [ + ("photo.png", "photo.jpg"), + ("photo.png", "photo"), + ("README", "README.md"), + (".env", ".env.txt"), + ("archive.tar.gz", "archive.tar.zip"), + ("trailing.", "trailing"), + ] { + assert_eq!( + rename_warnings(source, destination), + vec![BulkRenameWarning::ExtensionChanged], + "expected an extension warning for {source:?} -> {destination:?}" + ); + } + + for (source, destination) in [ + ("photo.png", "renamed.png"), + ("photo.PNG", "renamed.png"), + (".env", ".config"), + ("archive.tar.gz", "renamed.gz"), + ] { + assert!( + rename_warnings(source, destination).is_empty(), + "did not expect an extension warning for {source:?} -> {destination:?}" + ); + } + } + + #[test] + fn local_preflight_blocks_a_source_that_no_longer_exists() { + let temp = tempfile::tempdir().expect("temp directory"); + let missing = temp.path().join("missing.png"); + let proposal = RenameProposal { + proposal_id: "proposal".into(), + rows: vec![proposal_row( + "row", + missing.to_str().expect("UTF-8 temp path"), + "renamed.png", + )], + }; + + let outcome = preflight_local(&proposal, &["row".into()]); + + assert_eq!(outcome.status, BulkRenamePreflightStatus::Blocked); + assert_eq!( + outcome.response.rows[0].reason, + Some(BulkRenameBlockReason::SourceMissing) + ); + assert!(outcome.fingerprints.is_empty()); + } + + #[test] + fn only_a_missing_direct_child_can_enter_review_without_a_pane_entry() { + let temp = tempfile::tempdir().expect("temp directory"); + let state = PaneState { + path: temp.path().to_string_lossy().into_owned(), + ..PaneState::default() + }; + let missing = temp.path().join("imagined.png"); + let nested = temp.path().join("nested").join("imagined.png"); + let existing = temp.path().join("existing.png"); + std::fs::write(&existing, b"present").expect("write fixture"); + + assert!(missing_local_child( + &state, + "root", + missing.to_str().expect("UTF-8 path") + )); + assert!(!missing_local_child( + &state, + "root", + nested.to_str().expect("UTF-8 path") + )); + assert!(!missing_local_child( + &state, + "root", + existing.to_str().expect("UTF-8 path") + )); + assert!(!missing_local_child( + &state, + "mtp-device", + missing.to_str().expect("UTF-8 path") + )); + } + #[test] fn destination_names_reject_paths_and_dot_entries() { for name in ["", ".", "..", "folder/name.png", "folder\\name.png"] { diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/DETAILS.md b/apps/desktop/src-tauri/src/file_system/write_operations/DETAILS.md index fe537fd69..e1fb3ccd2 100644 --- a/apps/desktop/src-tauri/src/file_system/write_operations/DETAILS.md +++ b/apps/desktop/src-tauri/src/file_system/write_operations/DETAILS.md @@ -17,6 +17,12 @@ Subdirs: Implements the four destructive file operations as background tasks that stream Tauri events to the frontend. Every operation is cancellable, reports byte-level progress, and handles edge cases: symlink loops, same-inode overwrites, network mounts, cross-filesystem moves, and name/path length limits. +Ask Cmdr bulk renames use the same managed-operation lifecycle. For local final destinations, +`rename_local_exclusive` is the mandatory commit primitive: macOS uses `renamex_np(RENAME_EXCL)` and Linux uses +`renameat2(RENAME_NOREPLACE)`. The dialog's target-exists preflight improves review UX, but it cannot prove that a name +stays empty; only the exclusive kernel operation closes the check-to-rename race without replacing another process's +new file. + Pre-flight scans reuse cached listings when the source volume reports an active watcher, avoiding redundant `list_directory` calls. The freshness contract and per-backend debounce windows are documented in `../volume/CLAUDE.md` and `../listing/caching.rs::try_get_watched_listing`. ## Files (top level) @@ -32,7 +38,7 @@ Pre-flight scans reuse cached listings when the source volume reports an active - **`operation_intent.rs`**: The two per-operation state machines. `OperationIntent` (the `Running → RollingBack/Stopped` cancellation/rollback machine, with `load_intent` / `is_cancelled`) and `PauseGate` (pause/resume parking: a sync condvar for `spawn_blocking` drivers plus an async `Notify` for volume drivers). - **`scan_cache.rs`**: Scan-preview caching. `ScanPreviewState`, `CachedScanResult`, the `SCAN_PREVIEW_STATE` / `SCAN_PREVIEW_RESULTS` caches, the scan-result TTL safety net (`insert_scan_result` / `release_scan_result` / `expired_scan_result_ids`, `SCAN_RESULT_TTL`), and the `FileInfo` / `ScanResult` carriers. - **`validation.rs`**: Source/destination validation: `validate_sources`, `ensure_destination_dir` (the local copy/move destination gate — creates the destination and any missing ancestors via `create_dir_all` when absent, so a transfer into a brand-new folder just works; rejects a path that exists but isn't a directory; runs AFTER `validate_destination_not_inside_source` so it never creates a folder inside a source), `validate_destination_writable` (via `libc::access`), `validate_disk_space` (NSURL API on macOS, `statvfs` on Linux), `validate_not_same_location`, `validate_destination_not_inside_source` (resolves a not-yet-created dest via its nearest existing ancestor, `canonicalize_or_nearest_ancestor`), `validate_path_length`. Identity/filesystem checks: `is_same_file` (inode+device), `is_same_filesystem` (device IDs), `path_exists_or_is_symlink` (dangling-symlink-aware), `is_symlink_loop`. The volume-aware pipelines have the same recursive dest-create behavior: `copy_volumes_with_progress` / `move_volumes_with_progress` (cross-volume) and `move_within_same_volume_with_progress` (same-volume rename) each call `Volume::create_directory_all(dest)` before transferring, so a copy/move into a brand-new nested folder auto-creates it on EVERY backend (local, SMB, MTP, in-memory), matching `ensure_destination_dir`. The cross-volume/copy gate runs AFTER the dest-inside-source guard (same order as local). See `volume/DETAILS.md` § "Recursive destination create". -- **`rename.rs`**: Rename validation and the single-file managed instant mutation. `check_rename_validity_impl` / `check_rename_permission_sync` are read-only, unmanaged per-keystroke checks; `rename_managed` is the regular single-file `run_instant` route. **`rename/bulk.rs`**: Ask Cmdr's reviewed batch rename driver. `start_bulk_rename` receives only backend-owned rows accepted by preflight, runs through `spawn_managed` as one lane-queued operation, stages each source through a unique same-directory temporary name, journals one header with one final outcome per row, and restores unfinished temporary names on cancellation. The Ask Cmdr command is the only caller; it never receives paths or names from the frontend. See [Managed instant ops](#managed-instant-ops-run_instant). +- **`rename.rs`**: Rename validation and the single-file managed instant mutation. `check_rename_validity_impl` / `check_rename_permission_sync` are read-only, unmanaged per-keystroke checks; `rename_managed` is the regular single-file `run_instant` route. **`rename/bulk.rs`**: Ask Cmdr's reviewed batch rename driver. `start_bulk_rename` receives only backend-owned rows accepted by preflight and runs through `spawn_managed` as one lane-queued operation. Its dependency planner renames independent rows directly, peels acyclic chains from their free destination, uses one same-directory temporary per cycle, and retains one temporary for a case-only rename on a case-insensitive filesystem. Local and remote drivers share the plan, so remote rename-as-copy backends do not duplicate every transfer. Cancellation happens between components; a started cycle finishes or reverses before the driver observes cancellation again. The operation journals one header and one final outcome per row. The Ask Cmdr command is the only caller; it never receives paths or names from the frontend. See [Managed instant ops](#managed-instant-ops-run_instant). - **`create.rs`**: New-folder / new-file creation. `create_directory_managed` / `create_file_managed` run the mutation inside `manager::run_instant` (busy-mark + brief `Running` record, no lane, returns the new path inline; no inner timeout — the command's outer 5 s timeout drops the future on a hang and the guard releases the busy set). Co-locates the synthetic listing-cache diff (`emit_synthetic_entry_diff` / `should_emit_synthetic_diff`) that updates the pane when a new entry appears, for local-FS-backed volumes. The command layer (`commands/file_system/write_ops.rs`) is a thin pass-through. See [Managed instant ops](#managed-instant-ops-run_instant). - **`conflict.rs`**: Conflict resolution. The two-bucket `ApplyToAll` latch model (`apply_to_all_effective` / `apply_to_all_record`). `resolve_conflict` (`tokio::sync::oneshot` channel wait for Stop mode), `reduce_conditional_resolution`, `apply_resolution`, `find_unique_name` (O_EXCL reservation). The ` (N)` name formatting lives in ONE pure helper, `numbered_name(stem, ext, counter)` (`counter 0` = bare, `1..` = ` (N)`); `find_unique_name` and the clipboard-paste writer both go through it so the two numbering paths can't drift. Conflict-event/info builders: `build_conflict_event`, `calculate_dest_path`, `create_conflict_info`, `sample_conflicts`. - **`paste_clipboard.rs`**: `write_payload_to_dir` — the backend half of "paste clipboard content as a file" (issue #35). Takes an already-read `ClipboardPayload` + a `&Path` dir (decoupled from NSPasteboard / the IPC edge, so it's `TempDir`-testable). Maps payload→content (`ext` + `PastedKind` + bytes; markdown sniff for `.md` vs `.txt`), then writes `pasted.` via a `numbered_name` retry loop: candidate → `Volume::create_file` (O_EXCL create+write) → on the TYPED `VolumeError::AlreadyExists`, bump the counter. No pre-scan-then-write TOCTOU, and it works on any writable volume. Reuses `create::should_emit_synthetic_diff` + `emit_synthetic_entry_diff` (both `pub(super)`) so the new file lands in the pane and the FE cursor-lands like mkfile. `Nothing` payload → `Ok(None)` (the typed no-op). The command (`commands/clipboard.rs::paste_clipboard_as_file`) reads the raw flavors on the main thread, picks/converts off-main (`spawn_blocking`), and calls this under a **30 s** write timeout — a longer tier than the 5 s empty-mkfile write, because the payload can be a large image written to a slow network volume. **Partial-file-on-timeout edge (accepted):** if a very large paste to a very slow volume exceeds 30 s, the write future is dropped and a partial `pasted.` may remain (the user sees a timeout and can retry / delete). This is bounded, rare (local writes never approach 30 s; on a local FS `create_file`'s `spawn_blocking` isn't even cancellable, so the file actually completes), and only affects slow network volumes. If it ever matters, route paste-as-file through the managed transfer engine for cancellation + no-partial guarantees. Pasteboard read + flavor precedence: [`clipboard/DETAILS.md`](../../clipboard/DETAILS.md) § Paste clipboard content as a file. diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk.rs b/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk.rs index ca0c0d933..c61f0b0bf 100644 --- a/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk.rs +++ b/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk.rs @@ -1,11 +1,12 @@ //! Managed batch rename for Ask Cmdr's reviewed rename proposals. //! -//! This module owns the server-side rows and collision-safe driver. It stages -//! sources through same-directory temporary names so a chain, cycle, swap, or -//! case-only rename never overwrites a reviewed source. +//! This module owns the server-side rows and collision-safe driver. Independent +//! rows and chains rename directly in dependency order. Each cycle and each +//! case-only rename uses one same-directory temporary name. use std::collections::HashSet; use std::future::Future; +use std::io; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::Arc; @@ -24,6 +25,59 @@ use super::super::types::{ use crate::file_system::volume::{LaneKey, Volume}; use crate::operation_log::types::{EntryType, ExecutionStatus, Initiator, ItemOutcome, OpKind}; +/// Atomically renames a local file only when `destination` is unoccupied. +/// +/// The review-time existence check is advisory. This syscall-level exclusion +/// is the write boundary that prevents a destination created after review from +/// being silently replaced. +#[cfg(target_os = "macos")] +pub(super) fn rename_local_exclusive(source: &Path, destination: &Path) -> io::Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let source = CString::new(source.as_os_str().as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "source path contains a null byte"))?; + let destination = CString::new(destination.as_os_str().as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "destination path contains a null byte"))?; + // SAFETY: Both pointers come from live `CString`s and remain valid for the + // duration of the call. `RENAME_EXCL` asks the kernel to combine the + // destination-absence check and rename into one operation. + let result = unsafe { libc::renamex_np(source.as_ptr(), destination.as_ptr(), libc::RENAME_EXCL) }; + if result == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +#[cfg(target_os = "linux")] +pub(super) fn rename_local_exclusive(source: &Path, destination: &Path) -> io::Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let source = CString::new(source.as_os_str().as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "source path contains a null byte"))?; + let destination = CString::new(destination.as_os_str().as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "destination path contains a null byte"))?; + // SAFETY: Both pointers come from live `CString`s and remain valid for the + // duration of the call. `RENAME_NOREPLACE` provides Linux's equivalent + // atomic no-overwrite contract. + let result = unsafe { + libc::renameat2( + libc::AT_FDCWD, + source.as_ptr(), + libc::AT_FDCWD, + destination.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + /// Server-owned source identity captured by the rename-review preflight. The /// frontend never creates this data; the Ask Cmdr command maps its accepted /// preflight directly into this write-engine input. @@ -234,81 +288,110 @@ impl BulkRenameRun { } } -/// Local batch engine used on the blocking pool. Every currently valid source -/// moves to a unique sibling temporary name before any final destination is -/// occupied, which makes chains, swaps, cycles, and case-only changes safe. -fn bulk_rename_local(rows: &[BulkRenameRow], intent: &AtomicU8) -> BulkRenameRun { - let mut outcomes = vec![BulkRenameOutcome::Skipped; rows.len()]; - let mut active: Vec = rows +/// One collision-safe unit in a batch rename. Direct steps consume a free +/// destination. A cycle rotates through one temporary name, while a case-only +/// change uses one because the volume may treat both spellings as the same key. +#[derive(Debug, Clone, PartialEq, Eq)] +enum RenamePlanStep { + Direct(usize), + Cycle(Vec), + CaseOnly(usize), +} + +/// Orders active rows without filesystem access. The rename graph is +/// functional after preflight: every source and destination has at most one +/// owner. Removing rows whose destination is currently free peels all acyclic +/// chains in execution order; the remaining components are cycles. +fn build_execution_plan(rows: &[BulkRenameRow], active: &[bool]) -> Vec { + let mut remaining: HashSet = rows .iter() - .map(|row| local_fingerprint(&row.source).is_some_and(|actual| actual == row.expected_fingerprint)) + .enumerate() + .filter(|(index, row)| active[*index] && row.source != row.destination) + .map(|(index, _)| index) .collect(); - settle_local_conflicts(rows, &mut active); - let mut temporary_paths = vec![None; rows.len()]; + let mut plan = Vec::with_capacity(remaining.len()); - for (index, row) in rows.iter().enumerate() { - if !active[index] || row.source == row.destination { - continue; + loop { + let source_to_index: std::collections::HashMap = remaining + .iter() + .map(|index| (normalized_path(&rows[*index].source), *index)) + .collect(); + let mut ready: Vec = remaining + .iter() + .copied() + .filter(|index| { + let source = normalized_path(&rows[*index].source); + let destination = normalized_path(&rows[*index].destination); + source == destination || !source_to_index.contains_key(&destination) + }) + .collect(); + ready.sort_unstable(); + if ready.is_empty() { + break; } - if is_cancelled(intent) { - restore_local_temporaries(rows, &temporary_paths, &outcomes); - return BulkRenameRun { - outcomes, - cancelled: true, - }; + for index in ready { + if !remaining.remove(&index) { + continue; + } + if normalized_path(&rows[index].source) == normalized_path(&rows[index].destination) { + plan.push(RenamePlanStep::CaseOnly(index)); + } else { + plan.push(RenamePlanStep::Direct(index)); + } } - if !local_fingerprint(&row.source).is_some_and(|actual| actual == row.expected_fingerprint) { - active[index] = false; - continue; + } + + while let Some(start) = remaining.iter().min().copied() { + let source_to_index: std::collections::HashMap = remaining + .iter() + .map(|index| (normalized_path(&rows[*index].source), *index)) + .collect(); + let mut cycle = vec![start]; + let mut current = start; + loop { + let destination = normalized_path(&rows[current].destination); + let next = source_to_index[&destination]; + if next == start { + break; + } + cycle.push(next); + current = next; } - let Some(temporary) = unique_temporary_path(&row.source, &row.row_id) else { - outcomes[index] = BulkRenameOutcome::Failed; - active[index] = false; - continue; - }; - note_rename_write(&row.source, &temporary); - if std::fs::rename(&row.source, &temporary).is_ok() { - temporary_paths[index] = Some(temporary); - } else { - outcomes[index] = BulkRenameOutcome::Failed; - active[index] = false; + for index in &cycle { + remaining.remove(index); } + plan.push(RenamePlanStep::Cycle(cycle)); } + plan +} - for (index, row) in rows.iter().enumerate() { - if !active[index] { - continue; - } +/// Local batch engine used on the blocking pool. Acyclic rows move directly in +/// dependency order. Only cycles and case-only changes use a sibling temporary. +fn bulk_rename_local(rows: &[BulkRenameRow], intent: &AtomicU8) -> BulkRenameRun { + let mut outcomes = vec![BulkRenameOutcome::Skipped; rows.len()]; + let mut active: Vec = rows + .iter() + .map(|row| local_fingerprint(&row.source).is_some_and(|actual| actual == row.expected_fingerprint)) + .collect(); + settle_local_conflicts(rows, &mut active); + for (index, row) in rows.iter().enumerate().filter(|(index, _)| active[*index]) { if row.source == row.destination { outcomes[index] = BulkRenameOutcome::Done; - continue; } + } + for step in build_execution_plan(rows, &active) { if is_cancelled(intent) { - restore_local_temporaries(rows, &temporary_paths, &outcomes); return BulkRenameRun { outcomes, cancelled: true, }; } - let Some(temporary) = temporary_paths[index].as_ref() else { - continue; - }; - if !local_fingerprint(temporary).is_some_and(|actual| actual == row.expected_fingerprint) { - outcomes[index] = BulkRenameOutcome::Failed; - continue; - } - if destination_occupied_by_other_local(&row.destination, temporary) { - outcomes[index] = BulkRenameOutcome::Skipped; - continue; + match step { + RenamePlanStep::Direct(index) => rename_local_direct(&rows[index], &mut outcomes[index]), + RenamePlanStep::CaseOnly(index) => rename_local_case_only(&rows[index], &mut outcomes[index]), + RenamePlanStep::Cycle(indices) => rename_local_cycle(rows, &indices, &mut outcomes), } - note_rename_write(temporary, &row.destination); - outcomes[index] = if std::fs::rename(temporary, &row.destination).is_ok() { - BulkRenameOutcome::Done - } else { - BulkRenameOutcome::Failed - }; } - restore_local_temporaries(rows, &temporary_paths, &outcomes); BulkRenameRun { outcomes, cancelled: false, @@ -325,75 +408,233 @@ async fn bulk_rename_remote(rows: &[BulkRenameRow], volume_id: &str, intent: &At active.push(remote_fingerprint_matches(volume.as_ref(), &row.source, &row.expected_fingerprint).await); } settle_remote_conflicts(rows, &mut active, volume.as_ref()).await; - let mut temporary_paths = vec![None; rows.len()]; - - for (index, row) in rows.iter().enumerate() { - if !active[index] || row.source == row.destination { - continue; + for (index, row) in rows.iter().enumerate().filter(|(index, _)| active[*index]) { + if row.source == row.destination { + outcomes[index] = BulkRenameOutcome::Done; } + } + for step in build_execution_plan(rows, &active) { if is_cancelled(intent) { - restore_remote_temporaries(volume.as_ref(), rows, &temporary_paths, &outcomes).await; return BulkRenameRun { outcomes, cancelled: true, }; } - if !remote_fingerprint_matches(volume.as_ref(), &row.source, &row.expected_fingerprint).await { - active[index] = false; - continue; - } - let Some(temporary) = unique_remote_temporary_path(volume.as_ref(), &row.source, &row.row_id).await else { - outcomes[index] = BulkRenameOutcome::Failed; - active[index] = false; - continue; - }; - note_rename_write(&row.source, &temporary); - if volume.rename(&row.source, &temporary, false).await.is_ok() { - temporary_paths[index] = Some(temporary); - } else { - outcomes[index] = BulkRenameOutcome::Failed; - active[index] = false; + match step { + RenamePlanStep::Direct(index) => { + rename_remote_direct(volume.as_ref(), &rows[index], &mut outcomes[index]).await; + } + RenamePlanStep::CaseOnly(index) => { + rename_remote_case_only(volume.as_ref(), &rows[index], &mut outcomes[index]).await; + } + RenamePlanStep::Cycle(indices) => { + rename_remote_cycle(volume.as_ref(), rows, &indices, &mut outcomes).await; + } } } + BulkRenameRun { + outcomes, + cancelled: false, + } +} - for (index, row) in rows.iter().enumerate() { - if !active[index] { - continue; - } - if row.source == row.destination { - outcomes[index] = BulkRenameOutcome::Done; - continue; - } - if is_cancelled(intent) { - restore_remote_temporaries(volume.as_ref(), rows, &temporary_paths, &outcomes).await; - return BulkRenameRun { - outcomes, - cancelled: true, - }; +fn rename_local_direct(row: &BulkRenameRow, outcome: &mut BulkRenameOutcome) { + if !local_fingerprint(&row.source).is_some_and(|actual| actual == row.expected_fingerprint) { + return; + } + note_rename_write(&row.source, &row.destination); + *outcome = match rename_local_exclusive(&row.source, &row.destination) { + Ok(()) => BulkRenameOutcome::Done, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => BulkRenameOutcome::Skipped, + Err(_) => BulkRenameOutcome::Failed, + }; +} + +fn rename_local_case_only(row: &BulkRenameRow, outcome: &mut BulkRenameOutcome) { + if !local_fingerprint(&row.source).is_some_and(|actual| actual == row.expected_fingerprint) { + return; + } + let Some(temporary) = unique_temporary_path(&row.source, &row.row_id) else { + *outcome = BulkRenameOutcome::Failed; + return; + }; + note_rename_write(&row.source, &temporary); + if rename_local_exclusive(&row.source, &temporary).is_err() { + *outcome = BulkRenameOutcome::Failed; + return; + } + note_rename_write(&temporary, &row.destination); + *outcome = match rename_local_exclusive(&temporary, &row.destination) { + Ok(()) => BulkRenameOutcome::Done, + Err(error) => { + note_rename_write(&temporary, &row.source); + let _ = rename_local_exclusive(&temporary, &row.source); + if error.kind() == io::ErrorKind::AlreadyExists { + BulkRenameOutcome::Skipped + } else { + BulkRenameOutcome::Failed + } } - let Some(temporary) = temporary_paths[index].as_ref() else { - continue; - }; - if !remote_fingerprint_matches_at_temporary_path(volume.as_ref(), temporary, &row.expected_fingerprint).await - || volume.get_metadata(&row.destination).await.is_ok() - { - outcomes[index] = BulkRenameOutcome::Skipped; - continue; + }; +} + +/// Rotates a closed dependency component with one temporary. Once staging has +/// started, the bounded component finishes or rolls back before cancellation is +/// observed again, so Cmdr never intentionally strands a private temp name. +fn rename_local_cycle(rows: &[BulkRenameRow], indices: &[usize], outcomes: &mut [BulkRenameOutcome]) { + if indices.iter().any(|index| { + !local_fingerprint(&rows[*index].source).is_some_and(|actual| actual == rows[*index].expected_fingerprint) + }) { + return; + } + let first = indices[0]; + let Some(temporary) = unique_temporary_path(&rows[first].source, &rows[first].row_id) else { + outcomes[first] = BulkRenameOutcome::Failed; + return; + }; + note_rename_write(&rows[first].source, &temporary); + if rename_local_exclusive(&rows[first].source, &temporary).is_err() { + outcomes[first] = BulkRenameOutcome::Failed; + return; + } + + let mut moved = Vec::with_capacity(indices.len() - 1); + for index in indices.iter().skip(1).rev().copied() { + let row = &rows[index]; + note_rename_write(&row.source, &row.destination); + if rename_local_exclusive(&row.source, &row.destination).is_err() { + restore_local_cycle(rows, first, &temporary, &moved); + outcomes[index] = BulkRenameOutcome::Failed; + return; } - note_rename_write(temporary, &row.destination); - outcomes[index] = if volume.rename(temporary, &row.destination, false).await.is_ok() { - BulkRenameOutcome::Done + moved.push(index); + } + note_rename_write(&temporary, &rows[first].destination); + if rename_local_exclusive(&temporary, &rows[first].destination).is_err() { + restore_local_cycle(rows, first, &temporary, &moved); + outcomes[first] = BulkRenameOutcome::Failed; + return; + } + for index in indices { + outcomes[*index] = BulkRenameOutcome::Done; + } +} + +fn restore_local_cycle(rows: &[BulkRenameRow], first: usize, temporary: &Path, moved: &[usize]) { + for index in moved.iter().rev().copied() { + note_rename_write(&rows[index].destination, &rows[index].source); + let _ = rename_local_exclusive(&rows[index].destination, &rows[index].source); + } + note_rename_write(temporary, &rows[first].source); + let _ = rename_local_exclusive(temporary, &rows[first].source); +} + +async fn rename_remote_direct(volume: &dyn Volume, row: &BulkRenameRow, outcome: &mut BulkRenameOutcome) { + if !remote_fingerprint_matches(volume, &row.source, &row.expected_fingerprint).await { + return; + } + note_rename_write(&row.source, &row.destination); + *outcome = if volume.rename(&row.source, &row.destination, false).await.is_ok() { + BulkRenameOutcome::Done + } else if volume.get_metadata(&row.destination).await.is_ok() { + BulkRenameOutcome::Skipped + } else { + BulkRenameOutcome::Failed + }; +} + +async fn rename_remote_case_only(volume: &dyn Volume, row: &BulkRenameRow, outcome: &mut BulkRenameOutcome) { + if !remote_fingerprint_matches(volume, &row.source, &row.expected_fingerprint).await { + return; + } + let Some(temporary) = unique_remote_temporary_path(volume, &row.source, &row.row_id).await else { + *outcome = BulkRenameOutcome::Failed; + return; + }; + note_rename_write(&row.source, &temporary); + if volume.rename(&row.source, &temporary, false).await.is_err() { + *outcome = BulkRenameOutcome::Failed; + return; + } + note_rename_write(&temporary, &row.destination); + if volume.rename(&temporary, &row.destination, false).await.is_ok() { + *outcome = BulkRenameOutcome::Done; + } else { + note_rename_write(&temporary, &row.source); + let destination_exists = volume.get_metadata(&row.destination).await.is_ok(); + let _ = volume.rename(&temporary, &row.source, false).await; + *outcome = if destination_exists { + BulkRenameOutcome::Skipped } else { BulkRenameOutcome::Failed }; } - restore_remote_temporaries(volume.as_ref(), rows, &temporary_paths, &outcomes).await; - BulkRenameRun { - outcomes, - cancelled: false, +} + +async fn rename_remote_cycle( + volume: &dyn Volume, + rows: &[BulkRenameRow], + indices: &[usize], + outcomes: &mut [BulkRenameOutcome], +) { + for index in indices { + if !remote_fingerprint_matches(volume, &rows[*index].source, &rows[*index].expected_fingerprint).await { + return; + } + } + let first = indices[0]; + let Some(temporary) = unique_remote_temporary_path(volume, &rows[first].source, &rows[first].row_id).await else { + outcomes[first] = BulkRenameOutcome::Failed; + return; + }; + note_rename_write(&rows[first].source, &temporary); + if volume.rename(&rows[first].source, &temporary, false).await.is_err() { + outcomes[first] = BulkRenameOutcome::Failed; + return; + } + let mut moved = Vec::with_capacity(indices.len() - 1); + for index in indices.iter().skip(1).rev().copied() { + let row = &rows[index]; + note_rename_write(&row.source, &row.destination); + if volume.rename(&row.source, &row.destination, false).await.is_err() { + restore_remote_cycle(volume, rows, first, &temporary, &moved).await; + outcomes[index] = BulkRenameOutcome::Failed; + return; + } + moved.push(index); + } + note_rename_write(&temporary, &rows[first].destination); + if volume + .rename(&temporary, &rows[first].destination, false) + .await + .is_err() + { + restore_remote_cycle(volume, rows, first, &temporary, &moved).await; + outcomes[first] = BulkRenameOutcome::Failed; + return; + } + for index in indices { + outcomes[*index] = BulkRenameOutcome::Done; } } +async fn restore_remote_cycle( + volume: &dyn Volume, + rows: &[BulkRenameRow], + first: usize, + temporary: &Path, + moved: &[usize], +) { + for index in moved.iter().rev().copied() { + note_rename_write(&rows[index].destination, &rows[index].source); + let _ = volume + .rename(&rows[index].destination, &rows[index].source, false) + .await; + } + note_rename_write(temporary, &rows[first].source); + let _ = volume.rename(temporary, &rows[first].source, false).await; +} + fn settle_local_conflicts(rows: &[BulkRenameRow], active: &mut [bool]) { loop { let sources: HashSet = rows @@ -476,53 +717,6 @@ async fn unique_remote_temporary_path(volume: &dyn Volume, source: &Path, row_id None } -fn destination_occupied_by_other_local(destination: &Path, temporary: &Path) -> bool { - match ( - std::fs::symlink_metadata(destination), - std::fs::symlink_metadata(temporary), - ) { - (Ok(destination_meta), Ok(temporary_meta)) => !same_local_file(&destination_meta, &temporary_meta), - (Ok(_), Err(_)) => true, - _ => false, - } -} - -/// A cancellation never leaves Cmdr's private staging names visible. Restoring -/// a temporary path to its original source is safe recovery, not an unreviewed -/// rollback: no final user-visible rename has occurred for that row. -fn restore_local_temporaries( - rows: &[BulkRenameRow], - temporary_paths: &[Option], - outcomes: &[BulkRenameOutcome], -) { - for ((row, temporary), outcome) in rows.iter().zip(temporary_paths).zip(outcomes) { - if *outcome != BulkRenameOutcome::Done - && let Some(temporary) = temporary - && std::fs::symlink_metadata(&row.source).is_err() - { - note_rename_write(temporary, &row.source); - let _ = std::fs::rename(temporary, &row.source); - } - } -} - -async fn restore_remote_temporaries( - volume: &dyn Volume, - rows: &[BulkRenameRow], - temporary_paths: &[Option], - outcomes: &[BulkRenameOutcome], -) { - for ((row, temporary), outcome) in rows.iter().zip(temporary_paths).zip(outcomes) { - if *outcome != BulkRenameOutcome::Done - && let Some(temporary) = temporary - && volume.get_metadata(&row.source).await.is_err() - { - note_rename_write(temporary, &row.source); - let _ = volume.rename(temporary, &row.source, false).await; - } - } -} - fn note_rename_write(from: &Path, to: &Path) { crate::downloads::note_pending_write_for_cmdr(from); crate::downloads::note_pending_write_for_cmdr(to); @@ -591,20 +785,6 @@ async fn remote_fingerprint_matches(volume: &dyn Volume, path: &Path, expected: !metadata.is_directory && metadata.size == *size && metadata.modified_at.map(|value| value as i64) == *modified } -async fn remote_fingerprint_matches_at_temporary_path( - volume: &dyn Volume, - path: &Path, - expected: &BulkRenameFingerprint, -) -> bool { - let BulkRenameFingerprint::Remote { size, modified, .. } = expected else { - return false; - }; - let Ok(metadata) = volume.get_metadata(path).await else { - return false; - }; - !metadata.is_directory && metadata.size == *size && metadata.modified_at.map(|value| value as i64) == *modified -} - fn record_bulk_rename_outcomes( operation_id: &str, volume_id: &str, diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk/tests.rs b/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk/tests.rs index e8b1a192a..9c273419d 100644 --- a/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk/tests.rs +++ b/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk/tests.rs @@ -37,6 +37,68 @@ fn assert_no_staging_paths(dir: &Path) { assert!(staging_paths.is_empty(), "unexpected staging paths: {staging_paths:?}"); } +fn planning_row(source: &str, destination: &str) -> BulkRenameRow { + BulkRenameRow { + row_id: source.to_string(), + source: PathBuf::from(source), + destination: PathBuf::from(destination), + expected_fingerprint: BulkRenameFingerprint::Local { + device: 0, + inode: 0, + size: 0, + modified_nanos: None, + }, + } +} + +#[test] +fn execution_plan_renames_independent_rows_directly_without_temporaries() { + let rows = vec![planning_row("a", "renamed-a"), planning_row("b", "renamed-b")]; + + assert_eq!( + build_execution_plan(&rows, &[true, true]), + vec![RenamePlanStep::Direct(0), RenamePlanStep::Direct(1)] + ); +} + +#[test] +fn execution_plan_orders_chains_from_the_free_destination_without_temporaries() { + let rows = vec![planning_row("a", "b"), planning_row("b", "c"), planning_row("c", "d")]; + + assert_eq!( + build_execution_plan(&rows, &[true, true, true]), + vec![ + RenamePlanStep::Direct(2), + RenamePlanStep::Direct(1), + RenamePlanStep::Direct(0), + ] + ); +} + +#[test] +fn execution_plan_uses_one_temporary_step_per_cycle() { + let rows = vec![ + planning_row("a", "b"), + planning_row("b", "c"), + planning_row("c", "a"), + planning_row("x", "y"), + planning_row("y", "x"), + ]; + + assert_eq!( + build_execution_plan(&rows, &[true, true, true, true, true]), + vec![RenamePlanStep::Cycle(vec![0, 1, 2]), RenamePlanStep::Cycle(vec![3, 4]),] + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn execution_plan_keeps_a_temporary_step_for_case_only_renames() { + let rows = vec![planning_row("screenshot.png", "Screenshot.png")]; + + assert_eq!(build_execution_plan(&rows, &[true]), vec![RenamePlanStep::CaseOnly(0)]); +} + #[test] fn bulk_local_rename_preserves_chains_and_cycles() { let tmp = create_test_dir("chain_cycle"); @@ -157,18 +219,74 @@ fn bulk_local_rename_honours_cancel_before_staging() { } #[test] -fn restoring_cancelled_local_staging_path_recovers_the_source_name() { - let tmp = create_test_dir("cancel_restore"); +fn case_only_staging_recovers_the_source_when_the_destination_is_occupied() { + let tmp = create_test_dir("case_restore"); let source = tmp.join("before.txt"); let destination = tmp.join("after.txt"); fs::write(&source, "reviewed").expect("write fixture"); - let row = local_row("restore", source.clone(), destination); - let temporary = unique_temporary_path(&source, &row.row_id).expect("temporary path"); - fs::rename(&source, &temporary).expect("stage fixture source"); + fs::write(&destination, "external").expect("write occupied destination"); + let row = local_row("restore", source.clone(), destination.clone()); + let mut outcome = BulkRenameOutcome::Skipped; - restore_local_temporaries(&[row], &[Some(temporary)], &[BulkRenameOutcome::Skipped]); + rename_local_case_only(&row, &mut outcome); + assert_eq!(outcome, BulkRenameOutcome::Skipped); assert_eq!(fs::read_to_string(&source).expect("read restored source"), "reviewed"); + assert_eq!(fs::read_to_string(&destination).expect("read destination"), "external"); + assert_no_staging_paths(&tmp); + let _ = fs::remove_dir_all(&tmp); +} + +#[test] +fn exclusive_local_rename_moves_into_an_empty_name() { + let tmp = create_test_dir("exclusive_empty"); + let source = tmp.join("before.txt"); + let destination = tmp.join("after.txt"); + fs::write(&source, "reviewed").expect("write fixture"); + + rename_local_exclusive(&source, &destination).expect("exclusive rename into empty name"); + + assert!(!source.exists(), "the source name must be released"); + assert_eq!(fs::read_to_string(&destination).expect("read destination"), "reviewed"); + let _ = fs::remove_dir_all(&tmp); +} + +#[test] +fn exclusive_local_rename_never_replaces_an_existing_destination() { + let tmp = create_test_dir("exclusive_conflict"); + let source = tmp.join("before.txt"); + let destination = tmp.join("after.txt"); + fs::write(&source, "reviewed").expect("write source fixture"); + fs::write(&destination, "appeared after preflight").expect("write destination fixture"); + + let result = rename_local_exclusive(&source, &destination); + + assert!(result.is_err(), "an occupied destination must reject the rename"); + assert_eq!(fs::read_to_string(&source).expect("read preserved source"), "reviewed"); + assert_eq!( + fs::read_to_string(&destination).expect("read preserved destination"), + "appeared after preflight" + ); + let _ = fs::remove_dir_all(&tmp); +} + +#[test] +fn bulk_local_rename_preserves_a_destination_created_after_review() { + let tmp = create_test_dir("late_destination"); + let source = tmp.join("before.txt"); + let destination = tmp.join("after.txt"); + fs::write(&source, "reviewed").expect("write source fixture"); + let row = local_row("late-conflict", source.clone(), destination.clone()); + fs::write(&destination, "appeared after review").expect("write late destination"); + + let run = bulk_rename_local(&[row], &AtomicU8::new(OperationIntent::Running as u8)); + + assert_ne!(run.outcomes, vec![BulkRenameOutcome::Done]); + assert_eq!(fs::read_to_string(&source).expect("read preserved source"), "reviewed"); + assert_eq!( + fs::read_to_string(&destination).expect("read preserved destination"), + "appeared after review" + ); assert_no_staging_paths(&tmp); let _ = fs::remove_dir_all(&tmp); } diff --git a/apps/desktop/src-tauri/src/mcp/tool_registry/mod.rs b/apps/desktop/src-tauri/src/mcp/tool_registry/mod.rs index 341d9a686..aac3f2460 100644 --- a/apps/desktop/src-tauri/src/mcp/tool_registry/mod.rs +++ b/apps/desktop/src-tauri/src/mcp/tool_registry/mod.rs @@ -81,14 +81,6 @@ pub enum Access { /// proposal. A `Propose` tool is authored by hand into the test allowlist /// (`EXPECTED_PROPOSE_TOOL_NAMES`), because no structural check can prove a handler doesn't /// mutate. - /// - /// No registry entry is tagged `Propose` yet, so `#![deny(unused)]` would reject the variant. - /// The tier ships before its first tool on purpose: the boundary is reviewed on its own, not - /// bundled into a feature. Drop this `allow` when the first `Propose` tool is authored. - #[allow( - dead_code, - reason = "no Propose tool is authored yet; the tier lands before its first tool" - )] Propose, /// Mutates the filesystem OR app state (nav, cursor, selection, tabs, dialogs, settings, /// connect/eject, file ops, rollback-cancel); when in doubt a tool is `Write`. Never reachable diff --git a/apps/desktop/src/lib/ask-cmdr/BulkRenameReviewDialog.a11y.test.ts b/apps/desktop/src/lib/ask-cmdr/BulkRenameReviewDialog.a11y.test.ts index 05cfdf939..97562a658 100644 --- a/apps/desktop/src/lib/ask-cmdr/BulkRenameReviewDialog.a11y.test.ts +++ b/apps/desktop/src/lib/ask-cmdr/BulkRenameReviewDialog.a11y.test.ts @@ -9,7 +9,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { mount, tick } from 'svelte' import { expectNoA11yViolations } from '$lib/test-a11y' -const { state, actions } = vi.hoisted(() => ({ +const { state, actions, watcher } = vi.hoisted(() => ({ state: { renameReview: null as { proposalId: string @@ -19,6 +19,7 @@ const { state, actions } = vi.hoisted(() => ({ destinationName: string allowed: boolean blockedReason: string | null + warnings: Array<'extensionChanged' | 'cycle'> }> preflighting: boolean expired: boolean @@ -31,6 +32,10 @@ const { state, actions } = vi.hoisted(() => ({ cancel: vi.fn(), denyAll: vi.fn(), setAllowed: vi.fn(), + listingChanged: vi.fn(), + }, + watcher: { + handler: null as ((diff: { changes: unknown[] }) => void) | null, }, })) @@ -51,16 +56,25 @@ vi.mock('./ask-cmdr-trigger.svelte', () => ({ setRenameRowAllowed: (rowId: string, allowed: boolean) => { actions.setAllowed(rowId, allowed) }, + renameReviewListingChanged: (changes: unknown[]) => { + actions.listingChanged(changes) + }, })) vi.mock('$lib/tauri-commands', () => ({ notifyDialogOpened: vi.fn(() => Promise.resolve()), notifyDialogClosed: vi.fn(() => Promise.resolve()), + onDirectoryDiff: vi.fn((handler: (diff: { changes: unknown[] }) => void) => { + watcher.handler = handler + return Promise.resolve(vi.fn()) + }), })) import BulkRenameReviewDialog from './BulkRenameReviewDialog.svelte' -function review(overrides: Partial> = {}) { +function review( + overrides: Partial> = {}, +): NonNullable { return { proposalId: 'opaque-proposal-id', rows: [ @@ -70,6 +84,7 @@ function review(overrides: Partial> = {}) destinationName: 'after-one.png', allowed: true, blockedReason: null, + warnings: ['extensionChanged'], }, { rowId: 'opaque-row-two', @@ -77,6 +92,7 @@ function review(overrides: Partial> = {}) destinationName: 'after-two.png', allowed: true, blockedReason: null, + warnings: ['cycle'], }, { rowId: 'opaque-row-blocked', @@ -84,6 +100,15 @@ function review(overrides: Partial> = {}) destinationName: 'after-three.png', allowed: false, blockedReason: 'targetExists', + warnings: [], + }, + { + rowId: 'opaque-row-missing', + sourceName: 'imagined.png', + destinationName: 'after-four.png', + allowed: false, + blockedReason: 'sourceMissing', + warnings: [], }, ], preflighting: false, @@ -125,6 +150,8 @@ beforeEach(() => { actions.cancel.mockReset() actions.denyAll.mockReset() actions.setAllowed.mockReset() + actions.listingChanged.mockReset() + watcher.handler = null document.body.replaceChildren() }) @@ -133,10 +160,25 @@ describe('BulkRenameReviewDialog', () => { const target = mountDialog() await tick() - expect(requiredElement(target, '[role="status"]').textContent).toContain('2 renames allowed; 1 blocked') + expect(requiredElement(target, '[role="status"]').textContent).toContain('2 renames allowed; 2 blocked') expect(requiredButton(target, 'button[aria-label="Rename 2 files"]').disabled).toBe(false) expect(requiredInput(target, 'input[aria-label="Deny: before-one.png"]').checked).toBe(true) expect(requiredInput(target, 'input[aria-label="Allow: occupied.png"]').disabled).toBe(true) + const overwriteBadge = requiredElement(target, '[data-warning="overwrite"]') + expect(overwriteBadge.textContent).toContain('(overwrite!)') + expect(overwriteBadge.getAttribute('aria-label')).toContain("isn't part of this rename plan") + const missingBadge = requiredElement(target, '[data-warning="source-missing"]') + expect(missingBadge.textContent).toContain("(doesn't exist)") + expect(missingBadge.getAttribute('aria-label')).toContain('no longer exists') + expect(requiredInput(target, 'input[aria-label="Allow: imagined.png"]').disabled).toBe(true) + const extensionBadge = requiredElement(target, '[data-rename-warning="extensionChanged"]') + expect(extensionBadge.textContent).toBe('(extension)') + expect(extensionBadge.getAttribute('aria-label')).toBe( + 'Extension changed. The file contents will not be converted.', + ) + const cycleBadge = requiredElement(target, '[data-rename-warning="cycle"]') + expect(cycleBadge.textContent).toBe('(cycle)') + expect(cycleBadge.getAttribute('aria-label')).toContain('one temporary name') await expectNoA11yViolations(target) }) @@ -161,6 +203,16 @@ describe('BulkRenameReviewDialog', () => { expect(actions.cancel).toHaveBeenCalledOnce() }) + it('forwards pane file-watcher changes for live preflight', async () => { + mountDialog() + await tick() + const changes = [{ type: 'add', entry: { name: 'after-three.png' } }] + + watcher.handler?.({ changes }) + + expect(actions.listingChanged).toHaveBeenCalledWith(changes) + }) + it('disables and labels Apply when no valid row remains allowed', async () => { state.renameReview = review({ rows: review().rows.map((row) => ({ ...row, allowed: false })), diff --git a/apps/desktop/src/lib/ask-cmdr/BulkRenameReviewDialog.svelte b/apps/desktop/src/lib/ask-cmdr/BulkRenameReviewDialog.svelte index 602f47ba7..ad9954785 100644 --- a/apps/desktop/src/lib/ask-cmdr/BulkRenameReviewDialog.svelte +++ b/apps/desktop/src/lib/ask-cmdr/BulkRenameReviewDialog.svelte @@ -2,12 +2,16 @@ import ModalDialog from '$lib/ui/ModalDialog.svelte' import Button from '$lib/ui/Button.svelte' import { tString } from '$lib/intl/messages.svelte' + import { onDirectoryDiff } from '$lib/tauri-commands' + import { onMount } from 'svelte' + import { tooltip } from '$lib/tooltip/tooltip' import { applyRenameReview, allowAllRenameRows, askCmdrState, cancelRenameReview, denyAllRenameRows, + renameReviewListingChanged, setRenameRowAllowed, } from './ask-cmdr-trigger.svelte' @@ -19,6 +23,15 @@ function toggleRow(rowId: string, checked: boolean): void { setRenameRowAllowed(rowId, checked) } + + onMount(() => { + const listener = onDirectoryDiff((diff) => { + void renameReviewListingChanged(diff.changes) + }) + return () => { + void listener.then((unlisten) => { unlisten(); }).catch(() => {}) + } + }) {#if review} @@ -72,6 +85,42 @@ {row.sourceName} {row.destinationName} + {#if row.warnings.includes('extensionChanged')} + {tString('askCmdr.renameReview.extensionBadge')} + {/if} + {#if row.warnings.includes('cycle')} + {tString('askCmdr.renameReview.cycleBadge')} + {/if} + {#if row.blockedReason === 'targetExists'} + {tString('askCmdr.renameReview.overwriteBadge')} + {/if} + {#if row.blockedReason === 'sourceMissing'} + {tString('askCmdr.renameReview.sourceMissingBadge')} + {/if} {#if row.blockedReason} {tString('askCmdr.renameReview.blocked')} {/if} @@ -171,4 +220,28 @@ margin-top: var(--spacing-xxs); color: var(--color-text-secondary); } + + .warning-badge { + display: inline-flex; + width: fit-content; + margin-top: var(--spacing-xxs); + padding: 0 var(--spacing-xs); + border-radius: var(--radius-sm); + color: var(--color-warning-text); + background: var(--color-warning-bg); + font-size: var(--font-size-xs); + white-space: nowrap; + } + + .danger-badge { + display: inline-flex; + width: fit-content; + margin-top: var(--spacing-xxs); + padding: 0 var(--spacing-xs); + border-radius: var(--radius-sm); + color: var(--color-error-text); + background: var(--color-error-bg); + font-size: var(--font-size-xs); + white-space: nowrap; + } diff --git a/apps/desktop/src/lib/ask-cmdr/DETAILS.md b/apps/desktop/src/lib/ask-cmdr/DETAILS.md index f8c205b28..4d8c8634b 100644 --- a/apps/desktop/src/lib/ask-cmdr/DETAILS.md +++ b/apps/desktop/src/lib/ask-cmdr/DETAILS.md @@ -151,6 +151,20 @@ approval from the model. The backend requires that exact subset to have passed t client is stale, consumes the proposal once, then returns a queued operation id. The dialog closes only after that operation has started. +Backend preflight verifies every source still exists and blocks any missing source, including one removed after the +dialog opened. The dialog deselects that row and shows a red, accessible warning; a matching watcher event rechecks it +if the source returns. Preflight also supplies additive row warnings. The dialog keeps extension changes allowed but +marks them with an accessible yellow badge explaining that a rename does not convert file contents; dependency cycles +use the same warning channel. Its cycle tooltip explains that Cmdr uses one temporary name while rotating the files. +Blockers remain separate and automatically clear the row's Allow decision. + +The dialog subscribes to the same `directory-diff` stream that updates the file panes. A change whose filename matches a +proposal source or destination reruns the authoritative preflight for every displayed row, including denied and +previously blocked rows. This keeps target-exists and source-missing warnings live and lets a row recover when an +external process removes the clash or restores a missing source; matching names are only an IPC filter, never filesystem +authority. `TargetExists` and `SourceMissing` rows are deselected and show specific red warnings, while the write +engine's exclusive final rename remains the data-safety boundary. + ## The E2E fake-LLM path The stream also carries a display-only `proposalReady` rename-plan snapshot. The review dialog owns it in the next diff --git a/apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.svelte.ts b/apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.svelte.ts index 44a31ff1a..d5b4df72c 100644 --- a/apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.svelte.ts +++ b/apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.svelte.ts @@ -15,6 +15,7 @@ import { saveAppStatus } from '$lib/app-status-store' import { explorerState } from '$lib/file-explorer/pane/explorer-state.svelte' import { getAppLogger } from '$lib/logging/logger' +import { SvelteSet } from 'svelte/reactivity' import { consentState, refreshConsent } from './ask-cmdr-consent.svelte' import { growMainWindowForRail, shrinkMainWindowForRail } from './rail-window' import { @@ -89,6 +90,7 @@ export interface BulkRenameReviewRow { destinationName: string allowed: boolean blockedReason: string | null + warnings: Array<'extensionChanged' | 'cycle'> } export interface BulkRenameReview { @@ -548,7 +550,7 @@ function openRenameReview(proposal: Extract ({ ...row, allowed: true, blockedReason: null })), + rows: proposal.rows.map((row) => ({ ...row, allowed: true, blockedReason: null, warnings: [] })), preflighting: false, expired: false, requestVersion: 0, @@ -583,6 +585,18 @@ export function denyAllRenameRows(): void { void refreshRenamePreflight() } +/** Revalidates a review when the pane's existing file watcher reports a name + * that participates in the proposal. The backend remains authoritative; this + * name filter only avoids unrelated watcher traffic causing extra IPC. */ +export async function renameReviewListingChanged( + changes: ReadonlyArray<{ type?: string; entry: { name: string } }>, +): Promise { + const review = askCmdrState.renameReview + if (!review) return + const reviewedNames = new SvelteSet(review.rows.flatMap((row) => [row.sourceName, row.destinationName])) + if (changes.some((change) => reviewedNames.has(change.entry.name))) await refreshRenamePreflight() +} + /** Cancel closes the review and consumes its server-owned proposal. */ export function cancelRenameReview(): void { const review = askCmdrState.renameReview @@ -623,7 +637,10 @@ async function refreshRenamePreflight(): Promise { const version = review.requestVersion + 1 review.requestVersion = version review.preflighting = true - const allowedRowIds = review.rows.filter((row) => row.allowed).map((row) => row.rowId) + // Validate every displayed row, including denied and previously blocked rows. + // Otherwise a target that disappears after blocking its row could never make + // that row reviewable again. Apply still submits only the user's allowed ids. + const allowedRowIds = review.rows.map((row) => row.rowId) try { const result = await preflightBulkRename(review.proposalId, allowedRowIds) const current = askCmdrState.renameReview @@ -634,6 +651,7 @@ async function refreshRenamePreflight(): Promise { for (const row of current.rows) { const backend = result.rows.find((candidate) => candidate.rowId === row.rowId) row.blockedReason = backend?.status === 'blocked' ? backend.reason : null + if (backend) row.warnings = backend.warnings if (row.blockedReason) row.allowed = false } } catch (e) { diff --git a/apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.test.ts b/apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.test.ts index b79023698..0c49fafd4 100644 --- a/apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.test.ts +++ b/apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.test.ts @@ -17,6 +17,7 @@ const recordMock = vi.fn<(id: number) => Promise>() const saveMock = vi.fn() const growWindowMock = vi.fn<(w: number) => Promise>() const shrinkWindowMock = vi.fn<(w: number) => Promise>() +const preflightRenameMock = vi.fn<(...args: unknown[]) => Promise>() vi.mock('$lib/tauri-commands', () => ({ sendAskCmdrMessage: (c: number | null, t: string, a: unknown[], o: (e: AskCmdrStreamEvent) => void) => @@ -25,6 +26,9 @@ vi.mock('$lib/tauri-commands', () => ({ listAskCmdrConversations: (...a: unknown[]) => listMock(...a), getAskCmdrConversation: (...a: unknown[]) => getMock(...a), recordAskCmdrModelChange: (id: number) => recordMock(id), + preflightBulkRename: (...args: unknown[]) => preflightRenameMock(...args), + cancelBulkRenameProposal: vi.fn(() => Promise.resolve()), + applyBulkRename: vi.fn(() => Promise.resolve()), })) vi.mock('$lib/app-status-store', () => ({ saveAppStatus: (s: unknown) => { @@ -60,6 +64,7 @@ import { closeRail, newChat, noteModelSettingChanged, + renameReviewListingChanged, openRail, pathFromArguments, RAIL_MAX_WIDTH, @@ -91,6 +96,8 @@ beforeEach(() => { recordMock.mockReset() growWindowMock.mockReset() shrinkWindowMock.mockReset() + preflightRenameMock.mockReset() + preflightRenameMock.mockResolvedValue({ status: 'ready', rows: [] }) growWindowMock.mockResolvedValue() shrinkWindowMock.mockResolvedValue() listMock.mockResolvedValue([]) @@ -239,6 +246,103 @@ describe('sendMessage + streaming', () => { }) }) +describe('rename review listing updates', () => { + it('rechecks a proposed target when the pane watcher reports it appeared', async () => { + preflightRenameMock.mockResolvedValue({ + status: 'blocked', + rows: [{ rowId: 'row-1', status: 'blocked', reason: 'targetExists' }], + }) + sendMessage('rename it') + fire({ + type: 'proposalReady', + proposal: { + proposalId: 'proposal-1', + rows: [{ rowId: 'row-1', sourceName: 'before.png', destinationName: 'after.png' }], + }, + }) + await vi.waitFor(() => { + expect(preflightRenameMock).toHaveBeenCalledTimes(1) + }) + preflightRenameMock.mockClear() + + await renameReviewListingChanged([{ type: 'add', entry: { name: 'after.png' } }]) + + await vi.waitFor(() => { + expect(preflightRenameMock).toHaveBeenCalledWith('proposal-1', ['row-1']) + }) + expect(askCmdrState.renameReview?.rows[0]).toMatchObject({ + allowed: false, + blockedReason: 'targetExists', + }) + + preflightRenameMock.mockResolvedValue({ + status: 'ready', + rows: [{ rowId: 'row-1', status: 'ready', reason: null, warnings: [] }], + }) + await renameReviewListingChanged([{ type: 'remove', entry: { name: 'after.png' } }]) + + await vi.waitFor(() => { + expect(askCmdrState.renameReview?.rows[0]?.blockedReason).toBeNull() + }) + expect(askCmdrState.renameReview?.rows[0]?.allowed).toBe(false) + }) + + it('ignores watcher changes unrelated to the reviewed names', async () => { + sendMessage('rename it') + fire({ + type: 'proposalReady', + proposal: { + proposalId: 'proposal-1', + rows: [{ rowId: 'row-1', sourceName: 'before.png', destinationName: 'after.png' }], + }, + }) + await vi.waitFor(() => { + expect(preflightRenameMock).toHaveBeenCalledTimes(1) + }) + preflightRenameMock.mockClear() + + await renameReviewListingChanged([{ type: 'modify', entry: { name: 'other.png' } }]) + + await Promise.resolve() + expect(preflightRenameMock).not.toHaveBeenCalled() + }) + + it('deselects a missing source and rechecks it when the pane watcher reports its return', async () => { + preflightRenameMock.mockResolvedValue({ + status: 'blocked', + rows: [{ rowId: 'row-1', status: 'blocked', reason: 'sourceMissing', warnings: [] }], + }) + sendMessage('rename it') + fire({ + type: 'proposalReady', + proposal: { + proposalId: 'proposal-1', + rows: [{ rowId: 'row-1', sourceName: 'before.png', destinationName: 'after.png' }], + }, + }) + + await vi.waitFor(() => { + expect(askCmdrState.renameReview?.rows[0]).toMatchObject({ + allowed: false, + blockedReason: 'sourceMissing', + }) + }) + preflightRenameMock.mockResolvedValue({ + status: 'ready', + rows: [{ rowId: 'row-1', status: 'ready', reason: null, warnings: [] }], + }) + preflightRenameMock.mockClear() + + await renameReviewListingChanged([{ type: 'add', entry: { name: 'before.png' } }]) + + await vi.waitFor(() => { + expect(askCmdrState.renameReview?.rows[0]?.blockedReason).toBeNull() + }) + expect(preflightRenameMock).toHaveBeenCalledWith('proposal-1', ['row-1']) + expect(askCmdrState.renameReview?.rows[0]?.allowed).toBe(false) + }) +}) + describe('model settings changes', () => { it('records an event for the active thread and appends the line (debounced)', async () => { vi.useFakeTimers() diff --git a/apps/desktop/src/lib/intl/keys.gen.ts b/apps/desktop/src/lib/intl/keys.gen.ts index 1636772a3..0d2f0fee1 100644 --- a/apps/desktop/src/lib/intl/keys.gen.ts +++ b/apps/desktop/src/lib/intl/keys.gen.ts @@ -148,13 +148,21 @@ export type MessageKey = | 'askCmdr.renameReview.allowAll' | 'askCmdr.renameReview.blocked' | 'askCmdr.renameReview.cancel' + | 'askCmdr.renameReview.cycleBadge' + | 'askCmdr.renameReview.cycleTooltip' | 'askCmdr.renameReview.deny' | 'askCmdr.renameReview.denyAll' | 'askCmdr.renameReview.description' | 'askCmdr.renameReview.expired' + | 'askCmdr.renameReview.extensionBadge' + | 'askCmdr.renameReview.extensionTooltip' | 'askCmdr.renameReview.newName' | 'askCmdr.renameReview.originalName' + | 'askCmdr.renameReview.overwriteBadge' + | 'askCmdr.renameReview.overwriteTooltip' | 'askCmdr.renameReview.rename' + | 'askCmdr.renameReview.sourceMissingBadge' + | 'askCmdr.renameReview.sourceMissingTooltip' | 'askCmdr.renameReview.status' | 'askCmdr.renameReview.title' | 'askCmdr.sessions.archive' diff --git a/apps/desktop/src/lib/intl/messages/de/askCmdr.json b/apps/desktop/src/lib/intl/messages/de/askCmdr.json index f7dfcb80f..77a40039b 100644 --- a/apps/desktop/src/lib/intl/messages/de/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/de/askCmdr.json @@ -394,6 +394,38 @@ "@askCmdr.renameReview.blocked": { "sourceHash": "868541c" }, + "askCmdr.renameReview.overwriteBadge": "(überschreiben!)", + "@askCmdr.renameReview.overwriteBadge": { + "sourceHash": "762fd52" + }, + "askCmdr.renameReview.overwriteTooltip": "Eine andere Datei verwendet diesen Namen und gehört nicht zu diesem Umbenennungsplan. Diese Umbenennung ist zu ihrem Schutz blockiert.", + "@askCmdr.renameReview.overwriteTooltip": { + "sourceHash": "f3448fc" + }, + "askCmdr.renameReview.sourceMissingBadge": "(existiert nicht)", + "@askCmdr.renameReview.sourceMissingBadge": { + "sourceHash": "fbb0106" + }, + "askCmdr.renameReview.sourceMissingTooltip": "Diese Quelldatei existiert nicht mehr. Sie wurde möglicherweise außerhalb von Cmdr gelöscht oder umbenannt.", + "@askCmdr.renameReview.sourceMissingTooltip": { + "sourceHash": "acd6306" + }, + "askCmdr.renameReview.extensionBadge": "(Dateiendung)", + "@askCmdr.renameReview.extensionBadge": { + "sourceHash": "bcc0b2c" + }, + "askCmdr.renameReview.extensionTooltip": "Dateiendung geändert. Der Dateiinhalt wird nicht konvertiert.", + "@askCmdr.renameReview.extensionTooltip": { + "sourceHash": "7da4864" + }, + "askCmdr.renameReview.cycleBadge": "(Zyklus)", + "@askCmdr.renameReview.cycleBadge": { + "sourceHash": "5a2772b" + }, + "askCmdr.renameReview.cycleTooltip": "Umbenennungszyklus. Cmdr verwendet einen temporären Namen, während diese Dateien zyklisch umbenannt werden.", + "@askCmdr.renameReview.cycleTooltip": { + "sourceHash": "d76a0af" + }, "askCmdr.renameReview.expired": "Diese Prüfung ist abgelaufen. Bitte Ask Cmdr, sie erneut vorzubereiten.", "@askCmdr.renameReview.expired": { "sourceHash": "4e6482a" diff --git a/apps/desktop/src/lib/intl/messages/en/askCmdr.json b/apps/desktop/src/lib/intl/messages/en/askCmdr.json index bc0e316f9..ca6c8f810 100644 --- a/apps/desktop/src/lib/intl/messages/en/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/en/askCmdr.json @@ -399,6 +399,38 @@ "@askCmdr.renameReview.blocked": { "description": "Generic per-row message when backend preflight blocks a proposed rename." }, + "askCmdr.renameReview.overwriteBadge": "(overwrite!)", + "@askCmdr.renameReview.overwriteBadge": { + "description": "Compact red warning badge beside a proposed filename when another file already occupies that name. Keep the parentheses and exclamation mark." + }, + "askCmdr.renameReview.overwriteTooltip": "Another file already uses this name and isn''t part of this rename plan. This rename is blocked to protect it.", + "@askCmdr.renameReview.overwriteTooltip": { + "description": "Accessible tooltip for the overwrite warning badge. Explains that the existing file is outside the rename plan and will not be replaced." + }, + "askCmdr.renameReview.sourceMissingBadge": "(doesn''t exist)", + "@askCmdr.renameReview.sourceMissingBadge": { + "description": "Compact red warning badge beside a bulk rename row whose proposed source file does not exist. Keep the parentheses." + }, + "askCmdr.renameReview.sourceMissingTooltip": "This source file no longer exists. It may have been deleted or renamed outside Cmdr.", + "@askCmdr.renameReview.sourceMissingTooltip": { + "description": "Accessible tooltip for the missing-source warning badge. Explains that the row is blocked because its original file is gone." + }, + "askCmdr.renameReview.extensionBadge": "(extension)", + "@askCmdr.renameReview.extensionBadge": { + "description": "Compact yellow warning badge beside a proposed filename when the rename changes, adds, or removes its filename extension. Keep the parentheses." + }, + "askCmdr.renameReview.extensionTooltip": "Extension changed. The file contents will not be converted.", + "@askCmdr.renameReview.extensionTooltip": { + "description": "Accessible tooltip for the extension-change warning badge in the bulk rename review. Explains that renaming an extension does not convert the file format." + }, + "askCmdr.renameReview.cycleBadge": "(cycle)", + "@askCmdr.renameReview.cycleBadge": { + "description": "Compact yellow warning badge beside each proposed rename that belongs to a dependency cycle. Keep the parentheses." + }, + "askCmdr.renameReview.cycleTooltip": "Rename cycle. Cmdr will use one temporary name while rotating these files.", + "@askCmdr.renameReview.cycleTooltip": { + "description": "Accessible tooltip for the rename-cycle warning badge. Explains that Cmdr needs one temporary filename to rotate the files safely." + }, "askCmdr.renameReview.expired": "This review expired. Ask Cmdr to prepare it again.", "@askCmdr.renameReview.expired": { "description": "Message when the short-lived server-side rename proposal has expired." diff --git a/apps/desktop/src/lib/intl/messages/es/askCmdr.json b/apps/desktop/src/lib/intl/messages/es/askCmdr.json index e15f7407c..d6639b356 100644 --- a/apps/desktop/src/lib/intl/messages/es/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/es/askCmdr.json @@ -394,6 +394,38 @@ "@askCmdr.renameReview.blocked": { "sourceHash": "868541c" }, + "askCmdr.renameReview.overwriteBadge": "(¡sobrescribir!)", + "@askCmdr.renameReview.overwriteBadge": { + "sourceHash": "762fd52" + }, + "askCmdr.renameReview.overwriteTooltip": "Otro archivo ya usa este nombre y no forma parte de este plan de cambio de nombre. Este cambio está bloqueado para protegerlo.", + "@askCmdr.renameReview.overwriteTooltip": { + "sourceHash": "f3448fc" + }, + "askCmdr.renameReview.sourceMissingBadge": "(no existe)", + "@askCmdr.renameReview.sourceMissingBadge": { + "sourceHash": "fbb0106" + }, + "askCmdr.renameReview.sourceMissingTooltip": "Este archivo de origen ya no existe. Puede que se haya eliminado o renombrado fuera de Cmdr.", + "@askCmdr.renameReview.sourceMissingTooltip": { + "sourceHash": "acd6306" + }, + "askCmdr.renameReview.extensionBadge": "(extensión)", + "@askCmdr.renameReview.extensionBadge": { + "sourceHash": "bcc0b2c" + }, + "askCmdr.renameReview.extensionTooltip": "Extensión cambiada. El contenido del archivo no se convertirá.", + "@askCmdr.renameReview.extensionTooltip": { + "sourceHash": "7da4864" + }, + "askCmdr.renameReview.cycleBadge": "(ciclo)", + "@askCmdr.renameReview.cycleBadge": { + "sourceHash": "5a2772b" + }, + "askCmdr.renameReview.cycleTooltip": "Ciclo de cambios de nombre. Cmdr usará un nombre temporal mientras rota estos archivos.", + "@askCmdr.renameReview.cycleTooltip": { + "sourceHash": "d76a0af" + }, "askCmdr.renameReview.expired": "Esta revisión caducó. Pide a Ask Cmdr que la prepare de nuevo.", "@askCmdr.renameReview.expired": { "sourceHash": "4e6482a" diff --git a/apps/desktop/src/lib/intl/messages/fr/askCmdr.json b/apps/desktop/src/lib/intl/messages/fr/askCmdr.json index 8a6171715..28aa10436 100644 --- a/apps/desktop/src/lib/intl/messages/fr/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/fr/askCmdr.json @@ -392,6 +392,40 @@ "@askCmdr.renameReview.blocked": { "sourceHash": "868541c" }, + "askCmdr.renameReview.overwriteBadge": "(écrasement !)", + "@askCmdr.renameReview.overwriteBadge": { + "sourceHash": "762fd52" + }, + "askCmdr.renameReview.overwriteTooltip": "Un autre fichier utilise déjà ce nom et ne fait pas partie de ce plan de renommage. Ce renommage est bloqué pour le protéger.", + "@askCmdr.renameReview.overwriteTooltip": { + "sourceHash": "f3448fc" + }, + "askCmdr.renameReview.sourceMissingBadge": "(n’existe pas)", + "@askCmdr.renameReview.sourceMissingBadge": { + "sourceHash": "fbb0106" + }, + "askCmdr.renameReview.sourceMissingTooltip": "Ce fichier source n’existe plus. Il a peut-être été supprimé ou renommé en dehors de Cmdr.", + "@askCmdr.renameReview.sourceMissingTooltip": { + "sourceHash": "acd6306" + }, + "askCmdr.renameReview.extensionBadge": "(extension)", + "@askCmdr.renameReview.extensionBadge": { + "sourceHash": "bcc0b2c", + "sameAsSourceJustification": "Le mot « extension » est identique en français et correspond au terme utilisé par macOS." + }, + "askCmdr.renameReview.extensionTooltip": "Extension modifiée. Le contenu du fichier ne sera pas converti.", + "@askCmdr.renameReview.extensionTooltip": { + "sourceHash": "7da4864" + }, + "askCmdr.renameReview.cycleBadge": "(cycle)", + "@askCmdr.renameReview.cycleBadge": { + "sourceHash": "5a2772b", + "sameAsSourceJustification": "Le mot « cycle » est identique en français et décrit le même enchaînement circulaire." + }, + "askCmdr.renameReview.cycleTooltip": "Cycle de renommage. Cmdr utilisera un nom temporaire pendant la rotation de ces fichiers.", + "@askCmdr.renameReview.cycleTooltip": { + "sourceHash": "d76a0af" + }, "askCmdr.renameReview.expired": "Cette vérification a expiré. Demandez à Ask Cmdr de la préparer à nouveau.", "@askCmdr.renameReview.expired": { "sourceHash": "4e6482a" diff --git a/apps/desktop/src/lib/intl/messages/hu/askCmdr.json b/apps/desktop/src/lib/intl/messages/hu/askCmdr.json index 687ab2075..18ec9b457 100644 --- a/apps/desktop/src/lib/intl/messages/hu/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/hu/askCmdr.json @@ -392,6 +392,38 @@ "@askCmdr.renameReview.blocked": { "sourceHash": "868541c" }, + "askCmdr.renameReview.overwriteBadge": "(felülírás!)", + "@askCmdr.renameReview.overwriteBadge": { + "sourceHash": "762fd52" + }, + "askCmdr.renameReview.overwriteTooltip": "Ezt a nevet már egy másik fájl használja, amely nem része ennek az átnevezési tervnek. A fájl védelme érdekében ez az átnevezés blokkolva van.", + "@askCmdr.renameReview.overwriteTooltip": { + "sourceHash": "f3448fc" + }, + "askCmdr.renameReview.sourceMissingBadge": "(nem létezik)", + "@askCmdr.renameReview.sourceMissingBadge": { + "sourceHash": "fbb0106" + }, + "askCmdr.renameReview.sourceMissingTooltip": "Ez a forrásfájl már nem létezik. Lehet, hogy a Cmdren kívül törölték vagy átnevezték.", + "@askCmdr.renameReview.sourceMissingTooltip": { + "sourceHash": "acd6306" + }, + "askCmdr.renameReview.extensionBadge": "(kiterjesztés)", + "@askCmdr.renameReview.extensionBadge": { + "sourceHash": "bcc0b2c" + }, + "askCmdr.renameReview.extensionTooltip": "A kiterjesztés megváltozott. A fájl tartalma nem lesz átalakítva.", + "@askCmdr.renameReview.extensionTooltip": { + "sourceHash": "7da4864" + }, + "askCmdr.renameReview.cycleBadge": "(ciklus)", + "@askCmdr.renameReview.cycleBadge": { + "sourceHash": "5a2772b" + }, + "askCmdr.renameReview.cycleTooltip": "Átnevezési ciklus. A fájlok körbeforgatásához a Cmdr egy ideiglenes nevet fog használni.", + "@askCmdr.renameReview.cycleTooltip": { + "sourceHash": "d76a0af" + }, "askCmdr.renameReview.expired": "Ez az áttekintés lejárt. Kérd meg az Ask Cmdr-t, hogy készítse el újra.", "@askCmdr.renameReview.expired": { "sourceHash": "4e6482a" diff --git a/apps/desktop/src/lib/intl/messages/nl/askCmdr.json b/apps/desktop/src/lib/intl/messages/nl/askCmdr.json index fd16c0e8c..1fca80bd3 100644 --- a/apps/desktop/src/lib/intl/messages/nl/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/nl/askCmdr.json @@ -396,6 +396,38 @@ "@askCmdr.renameReview.blocked": { "sourceHash": "868541c" }, + "askCmdr.renameReview.overwriteBadge": "(overschrijven!)", + "@askCmdr.renameReview.overwriteBadge": { + "sourceHash": "762fd52" + }, + "askCmdr.renameReview.overwriteTooltip": "Een ander bestand gebruikt deze naam al en maakt geen deel uit van dit hernoemingsplan. Deze hernoeming is geblokkeerd om het bestand te beschermen.", + "@askCmdr.renameReview.overwriteTooltip": { + "sourceHash": "f3448fc" + }, + "askCmdr.renameReview.sourceMissingBadge": "(bestaat niet)", + "@askCmdr.renameReview.sourceMissingBadge": { + "sourceHash": "fbb0106" + }, + "askCmdr.renameReview.sourceMissingTooltip": "Dit bronbestand bestaat niet meer. Het is mogelijk buiten Cmdr verwijderd of hernoemd.", + "@askCmdr.renameReview.sourceMissingTooltip": { + "sourceHash": "acd6306" + }, + "askCmdr.renameReview.extensionBadge": "(extensie)", + "@askCmdr.renameReview.extensionBadge": { + "sourceHash": "bcc0b2c" + }, + "askCmdr.renameReview.extensionTooltip": "Extensie gewijzigd. De bestandsinhoud wordt niet geconverteerd.", + "@askCmdr.renameReview.extensionTooltip": { + "sourceHash": "7da4864" + }, + "askCmdr.renameReview.cycleBadge": "(cyclus)", + "@askCmdr.renameReview.cycleBadge": { + "sourceHash": "5a2772b" + }, + "askCmdr.renameReview.cycleTooltip": "Hernoemingscyclus. Cmdr gebruikt één tijdelijke naam terwijl deze bestanden worden geroteerd.", + "@askCmdr.renameReview.cycleTooltip": { + "sourceHash": "d76a0af" + }, "askCmdr.renameReview.expired": "Deze beoordeling is verlopen. Vraag Ask Cmdr om deze opnieuw voor te bereiden.", "@askCmdr.renameReview.expired": { "sourceHash": "4e6482a" diff --git a/apps/desktop/src/lib/intl/messages/pt/askCmdr.json b/apps/desktop/src/lib/intl/messages/pt/askCmdr.json index 27c70ce05..e6f5e9126 100644 --- a/apps/desktop/src/lib/intl/messages/pt/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/pt/askCmdr.json @@ -394,6 +394,38 @@ "@askCmdr.renameReview.blocked": { "sourceHash": "868541c" }, + "askCmdr.renameReview.overwriteBadge": "(sobrescrever!)", + "@askCmdr.renameReview.overwriteBadge": { + "sourceHash": "762fd52" + }, + "askCmdr.renameReview.overwriteTooltip": "Outro arquivo já usa esse nome e não faz parte deste plano de renomeação. Esta renomeação está bloqueada para proteger o arquivo.", + "@askCmdr.renameReview.overwriteTooltip": { + "sourceHash": "f3448fc" + }, + "askCmdr.renameReview.sourceMissingBadge": "(não existe)", + "@askCmdr.renameReview.sourceMissingBadge": { + "sourceHash": "fbb0106" + }, + "askCmdr.renameReview.sourceMissingTooltip": "Este arquivo de origem não existe mais. Ele pode ter sido apagado ou renomeado fora do Cmdr.", + "@askCmdr.renameReview.sourceMissingTooltip": { + "sourceHash": "acd6306" + }, + "askCmdr.renameReview.extensionBadge": "(extensão)", + "@askCmdr.renameReview.extensionBadge": { + "sourceHash": "bcc0b2c" + }, + "askCmdr.renameReview.extensionTooltip": "Extensão alterada. O conteúdo do arquivo não será convertido.", + "@askCmdr.renameReview.extensionTooltip": { + "sourceHash": "7da4864" + }, + "askCmdr.renameReview.cycleBadge": "(ciclo)", + "@askCmdr.renameReview.cycleBadge": { + "sourceHash": "5a2772b" + }, + "askCmdr.renameReview.cycleTooltip": "Ciclo de renomeação. O Cmdr usará um nome temporário enquanto gira esses arquivos.", + "@askCmdr.renameReview.cycleTooltip": { + "sourceHash": "d76a0af" + }, "askCmdr.renameReview.expired": "Esta revisão expirou. Peça ao Ask Cmdr para a preparar novamente.", "@askCmdr.renameReview.expired": { "sourceHash": "4e6482a" diff --git a/apps/desktop/src/lib/intl/messages/sv/askCmdr.json b/apps/desktop/src/lib/intl/messages/sv/askCmdr.json index 02ddc65d7..03bdca331 100644 --- a/apps/desktop/src/lib/intl/messages/sv/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/sv/askCmdr.json @@ -393,6 +393,38 @@ "@askCmdr.renameReview.blocked": { "sourceHash": "868541c" }, + "askCmdr.renameReview.overwriteBadge": "(skriv över!)", + "@askCmdr.renameReview.overwriteBadge": { + "sourceHash": "762fd52" + }, + "askCmdr.renameReview.overwriteTooltip": "En annan fil använder redan namnet och ingår inte i den här namnbytesplanen. Namnbytet blockeras för att skydda filen.", + "@askCmdr.renameReview.overwriteTooltip": { + "sourceHash": "f3448fc" + }, + "askCmdr.renameReview.sourceMissingBadge": "(finns inte)", + "@askCmdr.renameReview.sourceMissingBadge": { + "sourceHash": "fbb0106" + }, + "askCmdr.renameReview.sourceMissingTooltip": "Den här källfilen finns inte längre. Den kan ha raderats eller bytt namn utanför Cmdr.", + "@askCmdr.renameReview.sourceMissingTooltip": { + "sourceHash": "acd6306" + }, + "askCmdr.renameReview.extensionBadge": "(filtillägg)", + "@askCmdr.renameReview.extensionBadge": { + "sourceHash": "bcc0b2c" + }, + "askCmdr.renameReview.extensionTooltip": "Filtillägget har ändrats. Filens innehåll konverteras inte.", + "@askCmdr.renameReview.extensionTooltip": { + "sourceHash": "7da4864" + }, + "askCmdr.renameReview.cycleBadge": "(cykel)", + "@askCmdr.renameReview.cycleBadge": { + "sourceHash": "5a2772b" + }, + "askCmdr.renameReview.cycleTooltip": "Namnbytescykel. Cmdr använder ett tillfälligt namn medan filerna roteras.", + "@askCmdr.renameReview.cycleTooltip": { + "sourceHash": "d76a0af" + }, "askCmdr.renameReview.expired": "Den här granskningen har gått ut. Be Ask Cmdr att förbereda den igen.", "@askCmdr.renameReview.expired": { "sourceHash": "4e6482a" diff --git a/apps/desktop/src/lib/intl/messages/vi/askCmdr.json b/apps/desktop/src/lib/intl/messages/vi/askCmdr.json index ea31d0ee1..fb81abf0f 100644 --- a/apps/desktop/src/lib/intl/messages/vi/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/vi/askCmdr.json @@ -392,6 +392,38 @@ "@askCmdr.renameReview.blocked": { "sourceHash": "868541c" }, + "askCmdr.renameReview.overwriteBadge": "(ghi đè!)", + "@askCmdr.renameReview.overwriteBadge": { + "sourceHash": "762fd52" + }, + "askCmdr.renameReview.overwriteTooltip": "Một tệp khác đã dùng tên này và không nằm trong kế hoạch đổi tên. Lần đổi tên này bị chặn để bảo vệ tệp đó.", + "@askCmdr.renameReview.overwriteTooltip": { + "sourceHash": "f3448fc" + }, + "askCmdr.renameReview.sourceMissingBadge": "(không tồn tại)", + "@askCmdr.renameReview.sourceMissingBadge": { + "sourceHash": "fbb0106" + }, + "askCmdr.renameReview.sourceMissingTooltip": "Tệp nguồn này không còn tồn tại. Tệp có thể đã bị xóa hoặc đổi tên bên ngoài Cmdr.", + "@askCmdr.renameReview.sourceMissingTooltip": { + "sourceHash": "acd6306" + }, + "askCmdr.renameReview.extensionBadge": "(đuôi tệp)", + "@askCmdr.renameReview.extensionBadge": { + "sourceHash": "bcc0b2c" + }, + "askCmdr.renameReview.extensionTooltip": "Đuôi tệp đã thay đổi. Nội dung tệp sẽ không được chuyển đổi.", + "@askCmdr.renameReview.extensionTooltip": { + "sourceHash": "7da4864" + }, + "askCmdr.renameReview.cycleBadge": "(chu trình)", + "@askCmdr.renameReview.cycleBadge": { + "sourceHash": "5a2772b" + }, + "askCmdr.renameReview.cycleTooltip": "Chu trình đổi tên. Cmdr sẽ dùng một tên tạm thời khi xoay vòng các tệp này.", + "@askCmdr.renameReview.cycleTooltip": { + "sourceHash": "d76a0af" + }, "askCmdr.renameReview.expired": "Lần xem lại này đã hết hạn. Hãy yêu cầu Ask Cmdr chuẩn bị lại.", "@askCmdr.renameReview.expired": { "sourceHash": "4e6482a" diff --git a/apps/desktop/src/lib/intl/messages/zh/askCmdr.json b/apps/desktop/src/lib/intl/messages/zh/askCmdr.json index 0f470a153..471a1ec46 100644 --- a/apps/desktop/src/lib/intl/messages/zh/askCmdr.json +++ b/apps/desktop/src/lib/intl/messages/zh/askCmdr.json @@ -392,6 +392,38 @@ "@askCmdr.renameReview.blocked": { "sourceHash": "868541c" }, + "askCmdr.renameReview.overwriteBadge": "(覆盖!)", + "@askCmdr.renameReview.overwriteBadge": { + "sourceHash": "762fd52" + }, + "askCmdr.renameReview.overwriteTooltip": "另一个文件已使用这个名称,且不在此重命名计划中。为保护该文件,此项重命名已被阻止。", + "@askCmdr.renameReview.overwriteTooltip": { + "sourceHash": "f3448fc" + }, + "askCmdr.renameReview.sourceMissingBadge": "(不存在)", + "@askCmdr.renameReview.sourceMissingBadge": { + "sourceHash": "fbb0106" + }, + "askCmdr.renameReview.sourceMissingTooltip": "此源文件已不存在。它可能已在 Cmdr 外被删除或重命名。", + "@askCmdr.renameReview.sourceMissingTooltip": { + "sourceHash": "acd6306" + }, + "askCmdr.renameReview.extensionBadge": "(扩展名)", + "@askCmdr.renameReview.extensionBadge": { + "sourceHash": "bcc0b2c" + }, + "askCmdr.renameReview.extensionTooltip": "扩展名已更改。文件内容不会转换。", + "@askCmdr.renameReview.extensionTooltip": { + "sourceHash": "7da4864" + }, + "askCmdr.renameReview.cycleBadge": "(循环)", + "@askCmdr.renameReview.cycleBadge": { + "sourceHash": "5a2772b" + }, + "askCmdr.renameReview.cycleTooltip": "重命名循环。轮换这些文件时,Cmdr 会使用一个临时名称。", + "@askCmdr.renameReview.cycleTooltip": { + "sourceHash": "d76a0af" + }, "askCmdr.renameReview.expired": "此审核已过期。请让 Ask Cmdr 再次准备。", "@askCmdr.renameReview.expired": { "sourceHash": "4e6482a" diff --git a/apps/desktop/src/lib/ipc/bindings.ts b/apps/desktop/src/lib/ipc/bindings.ts index 88661a08d..4568210b9 100644 --- a/apps/desktop/src/lib/ipc/bindings.ts +++ b/apps/desktop/src/lib/ipc/bindings.ts @@ -3425,12 +3425,15 @@ export type BulkRenamePreflightRow = { rowId: string status: BulkRenameRowStatus reason: BulkRenameBlockReason | null + warnings: BulkRenameWarning[] } export type BulkRenamePreflightStatus = 'ready' | 'blocked' | 'expired' export type BulkRenameRowStatus = 'ready' | 'blocked' +export type BulkRenameWarning = 'extensionChanged' | 'cycle' + /** * Logical-pixel rectangle. `f64` mirrors what Tauri's `LogicalPosition` / * `LogicalSize` use on the wire. diff --git a/docs/hackathon-submission.md b/docs/hackathon-submission.md new file mode 100644 index 000000000..a2c9190a5 --- /dev/null +++ b/docs/hackathon-submission.md @@ -0,0 +1,153 @@ +# OpenAI Build Week submission + +This document records what changed for OpenAI Build Week, what existed beforehand, and how I, David Veszelovszki, worked +with Codex. The submitted implementation is [PR #39](https://github.com/vdavid/cmdr/pull/39). + +## Collaboration record + +- Codex session: `019f7f8e-e1da-7f23-b5e8-a25f0dde683e` +- Models and reasoning: primarily Terra at high reasoning, with some Sol at medium for delegated work +- Working method: I described the product intent and constraints, Codex drafted a specification, delegated review + passes, implemented the accepted plan in a worktree, launched the app, read runtime and model logs during manual QA, + fixed the findings, ran the repository checks, pushed the branch, and opened the PR. +- Review loop: I exercised the running app on disposable files, reported screenshots and observed behavior, and decided + which UX changes to make. Codex traced each report through logs and code, proposed engineering responses, and + implemented the decisions I accepted. + +### Decision ownership + +- **Natural-language bulk rename scope**: David +- **Renames only, with no moves, new folders, or reorganization**: David +- **Agent proposes and only a frontend user action approves**: David +- **Use the existing `Access::Propose` boundary without weakening its structural guarantees**: David +- **Cap a proposal at 200 rows**: David +- **Keep ordinary renames independent of image indexing while gating OCR/image-content reads**: David, after QA +- **Use a focused-pane cache tool that also returns the volume ID**: Codex, accepted by David +- **Use backend-owned proposal IDs, source fingerprints, preflight results, and accepted subsets**: Codex +- **Stage sources through same-directory temporary names for chains, cycles, swaps, and case-only renames**: Codex +- **Add per-row Allow/Deny, Allow all/Deny all, Cancel, and Apply controls**: David's requested flow, implemented by + Codex +- **Add Qwen and an API-key-backed Custom provider to Settings and onboarding**: David's requested provider UX, + implemented by Codex +- **Raise output and wall-time budgets while keeping the tool-turn guard**: Codex, based on Qwen logs +- **Retire unfinished activity rows on every terminal stream outcome**: David's QA finding, implementation designed by + Codex +- **Final UI acceptance and the decision to submit**: David + +Codex made code-level and test-level decisions within David's product and safety constraints. David retained product +scope, data-authority, UX, and release decisions throughout the session. + +## What existed before the submission window + +Cmdr was already a two-pane macOS file manager. The pre-existing app included file navigation and operations, Ask Cmdr +chat, the local image index and OCR facts, the MCP registry, the `Read`/`Write`/`Propose` access model, the operation +engine and journal, localization infrastructure, settings and onboarding, and cloud/local AI provider support. Codex did +not create those parts for Build Week. + +At the PR base: + +- The structural `Access::Propose` boundary existed, but + [`EXPECTED_PROPOSE_TOOL_NAMES` was empty](https://github.com/vdavid/cmdr/blob/79b58b21c75eaed12855c19a8bc27c7bcf2cc703/apps/desktop/src-tauri/src/mcp/tests/tool_registry_tests.rs#L687). + There was no proposal tool that staged a user-reviewable action. +- There was no `propose_rename_plan`, `list_pane_files`, bulk-rename review dialog, or managed batch-rename driver. +- Ask Cmdr could call existing read tools such as folder listing and image facts, but it could not submit a rename plan. +- The existing operation engine handled file operations and individual renames, but not an approved proposal containing + collision-safe rename chains, cycles, swaps, or case-only changes. +- The provider registry had no Qwen preset, and + [Custom did not require an API key](https://github.com/vdavid/cmdr/blob/79b58b21c75eaed12855c19a8bc27c7bcf2cc703/apps/desktop/src/lib/settings/cloud-providers.ts#L154-L160). + +The base commit and PR head provide the comparison boundary: + +```text +prior app: 79b58b21c75eaed12855c19a8bc27c7bcf2cc703 +submission: 41bc501233c8453fa80e1cd180c2bbc90e00801c +comparison: https://github.com/vdavid/cmdr/compare/79b58b21c75eaed12855c19a8bc27c7bcf2cc703...41bc501233c8453fa80e1cd180c2bbc90e00801c +``` + +## What is new in the submission window + +The submission-window scope is exactly [PR #39](https://github.com/vdavid/cmdr/pull/39): + +1. **Natural-language bulk rename** + - Adds the agent-only `list_pane_files` read tool, sourced from the focused pane's existing backend listing cache. + - Returns selection/folder scope, shared path, exact volume ID, counts, truncation state, and up to 200 compact rows. + - Adds `propose_rename_plan`, the first hand-approved `Access::Propose` tool. + - Keeps proposals limited to same-folder filename changes and gives the agent no approval or write tool. + - Stores proposal identity and reviewed source facts on the backend, preflights the user's accepted subset, and + routes it through the existing managed operation engine. + - Adds a review dialog with per-row and bulk Allow/Deny controls, Cancel, and Apply. + - Adds collision-safe local and volume-backed execution, including chains, cycles, swaps, case-only renames, + cancellation recovery, stale-source checks, operation events, and journal records. + +2. **Focused-pane and model-loop fixes needed by the workflow** + - Synchronizes the persisted focused pane to the backend during startup. + - Updates the agent prompt to use the focused-pane tool and to allow filename/date-based plans without image + indexing. + - Gives reasoning-heavy OpenAI-compatible models a 12,000-token output allowance and a 120-second tool-loop deadline, + while retaining the eight-tool-turn guard. + - Preserves provider reasoning data across tool continuations. + +3. **Ask Cmdr stream and chat UI fixes found during QA** + - Settles unfinished tool and thinking rows on success, rejection, cancellation, watchdog timeout, or budget limits. + - Adds stalled-stream feedback and stop behavior. + - Aligns and spaces activity rows consistently. + - Makes chat text selectable and copyable without rerender loops or duplicate clipboard content. + +4. **Provider access required to test the feature** + - Adds Qwen with DashScope's OpenAI-compatible endpoint. + - Makes Custom an API-key-backed OpenAI-compatible provider with an editable base URL. + - Exposes the same setup in Settings and onboarding while retaining OS secret-store handling for keys. + +5. **Tests, translations, generated bindings, and subsystem documentation** + - Adds backend proposal, preflight, batch-rename, registry, pane-context, model-budget, and runtime tests. + - Adds frontend review-dialog, accessibility, lifecycle, focused-pane, and provider tests. + - Adds the new UI copy in every supported locale and updates generated localization and IPC bindings. + - Adds the implementation spec and colocated architecture/guardrail documentation. + +The PR changes 77 files with 4,037 insertions and 140 deletions. Those counts include tests, translations, generated +bindings, and documentation, not only runtime code. + +## Commit breakdown + +- **[`41bc50123`](https://github.com/vdavid/cmdr/commit/41bc501233c8453fa80e1cd180c2bbc90e00801c)**: Implements PR #39: + the proposal/read tools, review UI, preflight and managed bulk-rename execution, focused-pane synchronization, + model-loop and stream cleanup, selectable chat text, Qwen/Custom provider setup, translations, tests, generated + bindings, and documentation. + +Any later Build Week UI or hardening commits should be added to this list and identified separately from the `41bc50123` +feature commit. + +## Run and evaluate without building + +1. Download Cmdr v0.35.0 from [getcmdr.com](https://getcmdr.com) and install it on macOS. +2. In onboarding or Settings → AI, configure an available provider. Qwen can use DashScope; Custom accepts an + OpenAI-compatible base URL and API key. +3. Create a disposable folder containing files you are willing to rename, then open that folder in a Cmdr pane. You can + also select a subset of its files. +4. Open Ask Cmdr and enter an intent such as: + `Rename these screenshots to YYYY-MM-DD Screenshot{N}.png, with N restarting each day.` +5. Inspect the proposal dialog. Exercise individual Allow/Deny choices and Allow all/Deny all. Cancel should change + nothing. +6. Apply an accepted subset and verify that only those rows were renamed. The operation appears through Cmdr's existing + operation infrastructure. +7. For an indexing-independence check, disable image indexing and request a rename based only on filenames or dates. The + proposal remains available. Requests that need OCR or image contents still require image indexing. + +The evaluator does not need Rust, Node.js, pnpm, Tauri tooling, or a source checkout. + +## Validation recorded before submission + +- Full Svelte suite: 6,811 tests passed. +- Normal repository lane: 73 checks passed. +- Local Rust suite passed. +- Final fast lane: 51 checks passed. +- `git diff --check` was clean. +- The slow run passed all 273 Linux E2E tests, with seven duration warnings. +- The desktop Playwright shard passed 128 of 129 tests. One pre-existing mixed copy-conflict scenario exceeded its + dialog-close timeout. +- The Linux Rust run initially passed 4,142 of 4,143 tests and exposed a case-only identity issue on a macOS-backed + Docker mount. The conflict check was corrected afterward; at my request, the slow Linux and E2E suites were not rerun, + to save time. + +Manual QA took place only on disposable files under the repository's ignored test area. The feature did not use live +personal files for rename testing. diff --git a/docs/specs/index.md b/docs/specs/index.md index 2ae3d5825..ab8ef8ec7 100644 --- a/docs/specs/index.md +++ b/docs/specs/index.md @@ -11,6 +11,10 @@ this folder is and when it gets wiped. Shipped specs get wiped once their durabl `Access::Propose` tool stages at most 200 canonical rows, an accessible review dialog makes every per-row decision user-owned, and one managed batch operation revalidates, handles rename cycles safely, updates listings, and journals every row for later rollback. No agent tool can approve or write. +- [ ] 2026-07-21 + [natural-language-bulk-rename-hardening-handoff.md](natural-language-bulk-rename-hardening-handoff.md) - + Continuation state for the atomic no-overwrite, dependency-planning, live conflict/source, and review-warning + hardening pass, including completed work, unresolved tests, remaining findings, and safety invariants. - [ ] 2026-07-20 [sealed-subtrees-plan.md](sealed-subtrees-plan.md) - Bound the cost of pathological high-churn directories without lying about folder sizes. Motivated by a measured 7-minute, 1 GB cold-start stall caused by one directory (Google DriveFS `fetch_temp`, 1.14M empty files). M1 ships alone: a two-teeth child-count guard in diff --git a/docs/specs/natural-language-bulk-rename-hardening-handoff.md b/docs/specs/natural-language-bulk-rename-hardening-handoff.md new file mode 100644 index 000000000..7d9d126ff --- /dev/null +++ b/docs/specs/natural-language-bulk-rename-hardening-handoff.md @@ -0,0 +1,268 @@ +# Natural-language bulk rename hardening handoff + +Status captured on 2026-07-21 because the Codex session was nearly out of credits. Continue only in +`/Users/veszelovszki/projects-git/vdavid/cmdr-natural-language-bulk-rename` on branch +`codex/natural-language-bulk-rename`. Do not touch the main working copy. Main was restored clean before this handoff. + +PR: [#39](https://github.com/vdavid/cmdr/pull/39) + +Last pushed feature commit: `41bc501233c8453fa80e1cd180c2bbc90e00801c` + +Codex session: `019f7f8e-e1da-7f23-b5e8-a25f0dde683e` + +## Instructions for the next agent + +Before touching a folder, manually read `~/.claude/CLAUDE.md`, `~/.claude/rules/*.md`, repo `.claude/*.md`, and every +colocated `CLAUDE.md` from the repository root down to that folder. This harness does not auto-load them. Preserve the +user's edits to `README.md` and `docs/hackathon-submission.md`. Use `pnpm check`, not raw Cargo or Vitest commands. Test +renames only in isolated `_ignored` fixtures. + +The worktree contains uncommitted edits from findings 1–4. Do not reset or discard them. Findings 5–7 were deliberately +not started when this handoff was first captured. The scoped follow-up below completed findings 6 and 7; finding 5 +remains untouched. + +## Scoped follow-up completed after capture + +- Replaced the reactive-path plain `Set` in `renameReviewListingChanged` with `SvelteSet`. +- Made the watcher callback await the existing versioned preflight refresh. The overwrite-protection regression now + observes the authoritative result without sleeps or weakened assertions. +- Added a hard system-prompt rule: when `list_pane_files` returns `truncated: true`, the reply must state it inspected + only `returned` of `total` files using those exact counts and must not imply full coverage. +- Added a focused prompt test for that rule. +- Removed the obsolete `#[allow(dead_code)]` from `Access::Propose`. +- Linked this handoff from `docs/specs/index.md`. +- Validation after these changes: `pnpm check --fast -q` passed 51 checks with the existing file-length warning; + `pnpm check svelte-tests --fresh -q` passed the full Svelte lane, including all 6,815 tests. + +## Current worktree state + +Changed or added files at capture time: + +- `README.md` +- `docs/hackathon-submission.md` +- `docs/specs/natural-language-bulk-rename-plan.md` +- `apps/desktop/src-tauri/src/agent/tools/propose/DETAILS.md` +- `apps/desktop/src-tauri/src/agent/tools/propose/rename.rs` +- `apps/desktop/src-tauri/src/file_system/write_operations/DETAILS.md` +- `apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk.rs` +- `apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk/tests.rs` +- `apps/desktop/src/lib/ask-cmdr/BulkRenameReviewDialog.a11y.test.ts` +- `apps/desktop/src/lib/ask-cmdr/BulkRenameReviewDialog.svelte` +- `apps/desktop/src/lib/ask-cmdr/DETAILS.md` +- `apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.svelte.ts` +- `apps/desktop/src/lib/ask-cmdr/ask-cmdr-trigger.test.ts` +- `apps/desktop/src/lib/intl/keys.gen.ts` +- `apps/desktop/src/lib/intl/messages/{de,en,es,fr,hu,nl,pt,sv,vi,zh}/askCmdr.json` +- `apps/desktop/src/lib/ipc/bindings.ts` + +The implementation diff for this hardening wave was about 1,337 insertions and 191 deletions before this handoff file. + +## Finding 1: atomic no-overwrite and live destination clashes + +User finding: the previous check followed by `std::fs::rename` could destroy a destination created in the gap. + +Implemented: + +- Added `rename_local_exclusive`. +- macOS uses `renamex_np(..., RENAME_EXCL)`. +- Linux uses `renameat2(..., RENAME_NOREPLACE)`. +- The new dependency planner uses the exclusive helper for local final, staged, cycle, and recovery renames. +- Tests cover an empty destination, an occupied destination preserving both files, and a destination appearing after + review. +- The review dialog subscribes to the existing `directory-diff` event stream. +- It filters changes to proposal source/destination names and reruns authoritative preflight for displayed rows. +- `TargetExists` auto-deselects the row and renders a red `(overwrite!)` badge with an accessible tooltip. +- A row can recover live if the clash disappears, but remains deselected so the user must opt in again. + +Reported validation: + +- Focused Rust tests passed. +- Formatting, i18n parity, and ICU checks passed. +- Later agents regenerated bindings and translated the new strings. + +Review carefully: + +- Confirm every local final mutation path uses `rename_local_exclusive`; no check-then-plain-rename final path should + remain. +- Confirm recovery never clobbers a newly created source/destination. The current planner is intended to use exclusive + recovery and report failure rather than destroy data. + +## Finding 2: dependency-aware execution and cycle warnings + +User finding: staging every row doubled rename operations, especially costly on MTP and future object stores. + +Implemented by the dependency-planner agent: + +- A shared pure planner peels destinations that are not current sources. +- Independent rows execute directly with zero temporary names. +- Acyclic chains execute in reverse dependency/topological order with zero temporary names. +- Each remaining cycle rotates through exactly one temporary name. +- Case-only renames retain one temporary step where required. +- Local and remote execution use the same plan. Remote direct/chain rows use `Volume::rename(..., false)`, avoiding a + second copy/delete-style rename. +- Cancellation is checked between components. Once a cycle starts, it completes or reverses before cancellation is + observed. +- Backend preflight emits additive `BulkRenameWarning::Cycle` metadata. +- The dialog shows a yellow, focusable `(cycle)` badge with an accessible tooltip explaining that one temporary name is + used. +- Copy exists in all supported locales, bindings, DETAILS docs, and the feature spec. + +Tests added: + +- Independent plan uses no temporary step. +- Chain ordering uses no temporary step. +- Two separate cycles use one temporary step each. +- Case-only behavior uses one temporary step. +- Cancellation/recovery behavior. +- Cycle warning classification and dialog accessibility. +- Existing real chain, cycle, and swap tests reportedly pass. + +Reported validation: + +- Clippy and rustfmt passed. +- `git diff --check` passed. +- Rust: 4,357 of 4,359 passed, including all new rename tests. Reported unrelated results were a manager panic-lane + failure and an encoding timeout. +- Svelte: 6,803 passed during that agent's run. The cycle assertion passed; three failures were attributed to concurrent + source/watcher integration and an unrelated transfer test. + +Review carefully: + +- Audit the planner rather than trusting the report. Check dependency direction, multiple disconnected components, + duplicate destinations, case-folded names, partial preflight subsets, cancellation during direct chains, remote + failure halfway through a component, and recovery when a destination appears concurrently. +- Verify cycle metadata is computed from the exact allowed/preflight subset, not rejected rows. +- Confirm remote implementations actually provide no-overwrite behavior for `force = false`; if a backend cannot, the + operation must fail safely and document the limitation. + +## Finding 3: extension-change warning + +User finding: extension changes were visually indistinguishable from ordinary renames. + +Implemented: + +- Added additive `BulkRenamePreflightRow.warnings: Vec`. +- Added `BulkRenameWarning::ExtensionChanged` alongside `Cycle`. +- Extension comparison uses Rust `Path::extension` on the final suffix and compares ASCII-case-insensitively. +- Warns for changed, added, removed, and trailing-dot extensions. +- Does not warn for stem-only changes, extension case-only changes, dotfile-to-dotfile changes, or an unchanged final + suffix such as one `.tar.gz` name to another `.gz` name. +- Extension changes remain allowed and selected. +- The dialog shows a yellow, focusable `(extension)` badge with a tooltip stating that renaming does not convert file + contents. +- Generated TypeScript bindings include `warnings` and `BulkRenameWarning`. +- Extension, overwrite, and cycle strings were translated across all nine non-English locales. +- Proposal and Ask Cmdr DETAILS docs were updated. + +Reported validation: + +- Rust tests passed. +- Binding export passed. +- Eight i18n checks passed. +- `svelte-check` and stylelint passed. +- `git diff --check` passed. +- Full Svelte run: 6,804 passed with two concurrent-integration failures described below. + +Review carefully: + +- Confirm the intended policy for compound extensions. Current semantics inspect only the final suffix, so + `archive.tar.gz` to `archive.zip` warns, while `one.tar.gz` to `two.gz` does not. +- Confirm case-only extension changes such as `.PNG` to `.png` should remain warning-free. + +## Finding 4: missing sources and live watcher state + +User finding: hallucinated or externally deleted sources need a visible blocker and live update. + +Implemented before the agent was stopped: + +- Existing authoritative local and remote preflight returns `SourceMissing` when metadata/fingerprint lookup fails. +- Missing rows are auto-deselected by generic blocker handling. +- Added a red `(doesn't exist)` badge with an accessible tooltip. +- The same `directory-diff` subscription rechecks matching source/destination names when a source disappears or returns. +- A recovered row remains deselected, matching target-clash recovery behavior. +- Proposal construction was widened only for a nonexistent direct child of the focused local folder, allowing an agent + hallucination to reach review as `SourceMissing`. +- Existing out-of-scope files, nested/path-escape names, directories, remote hallucinations, and existing files outside + pane scope remain rejected. + +Tests added: + +- Rust: a missing local source yields `SourceMissing` and no fingerprint. +- Rust: only nonexistent direct local children may enter review without a pane entry. +- Svelte state: watcher removal blocks/deselects; watcher return rechecks and clears the blocker. +- Dialog/a11y: badge text, tooltip, disabled checkbox, and blocked count. + +Reported validation: + +- `pnpm check -m --fast -q`: 51 passed with the existing file-length warning. +- Rust: 4,359 of 4,360 passed; the new source test passed. The reported failure was + `file_system::write_operations::manager::tests::panicking_op_releases_its_lane_without_spawning_next`. +- The full Svelte suite was not rerun after the final source badge implementation. + +Known integration problem: + +- A trigger watcher test sometimes expects the second mocked `targetExists` preflight result but observes the initial + `ready` result. This is likely asynchronous mock response ordering/race, not yet reviewed. Fix the test or state flow + based on actual semantics, not by adding arbitrary sleeps. + +Policy question: + +- Remote hallucinated source rows are still rejected before review because synchronous proposal construction cannot + safely prove that an absent remote name is a direct child. Decide whether this is acceptable or whether proposal + construction should become authoritative/async for remote paths. + +## Svelte integration status + +The watcher race is resolved. `renameReviewListingChanged` now returns and awaits the versioned preflight refresh, and +the dialog listener explicitly discards that promise. Tests await the returned promise, so they assert the applied +backend result rather than only the start of the IPC call. + +The post-fix Svelte lane passed all 6,815 tests. Earlier concurrent-run failures did not recur. + +## Remaining finding deliberately not started + +### Finding 5: operation-log provenance + +Current code at `apps/desktop/src-tauri/src/commands/agent.rs` around line 906 passes only `Initiator::Agent`. The user +requires the operation log to record both facts: the agent proposed the plan and the user approved the selected rows. + +Design this as structured provenance, not a misleading replacement with `User` and not a display-only string hack. +Inspect the operation-log schema, serialization, migration requirements, UI presentation, rollback/history consumers, +and existing `Initiator` invariants. Add tests proving both facts survive persistence and appear in the “see what you +did” surface. Update docs. + +### Finding 6: mandatory truncation disclosure (completed) + +The system prompt now requires exact `returned` of `total` disclosure whenever `truncated` is true and forbids implying +full coverage. A focused prompt test pins all three requirements. A backend rejection of claimed full-folder coverage +was not added; this scoped round changed only the requested prompt contract. + +### Finding 7: obsolete dead-code allowance (completed) + +The stale allowance and its no-longer-true explanatory comment were removed from `Access::Propose`. + +## Integration and review checklist + +1. Read all guidance files listed at the top. +2. Inspect `git diff` in full. Three agents edited shared proposal, dialog, trigger, bindings, locale, and docs files. +3. Resolve the watcher test race without sleeps. +4. Run focused proposal/bulk-rename Rust tests and focused Ask Cmdr Svelte tests. +5. Audit the exclusive-rename FFI declarations, flags, errno mapping, and platform cfgs. +6. Audit the dependency planner and failure recovery for data loss before running any manual rename test. +7. If manual testing is needed, use only a newly created directory under `_ignored`. +8. Run binding generation/checks and all i18n checks. +9. Run `pnpm check --fast -q`, then the normal relevant lanes. Do not rerun broad slow/E2E suites unless the user asks. +10. Update this handoff, the implementation spec, DETAILS docs, README submission text, and + `docs/hackathon-submission.md` to reflect final commits and validation. +11. Commit and push to `codex/natural-language-bulk-rename`, then update PR #39. Do not modify main. + +## Safety invariants that must remain true + +- The agent can propose but cannot approve or write. +- Approval is a frontend user action over backend-owned proposal rows. +- No final rename may overwrite an unreviewed or concurrently created destination. +- Missing or changed sources remain blocked. +- Rejected rows never enter the execution plan. +- Cancellation and recovery must not clobber new filesystem entries. +- Renames stay within one parent folder; no moves, new folders, or reorganization. +- Image indexing is required only for image-content/OCR reads, not filename/date-only rename proposals. diff --git a/docs/specs/natural-language-bulk-rename-plan.md b/docs/specs/natural-language-bulk-rename-plan.md index ec63b0dd2..509cbffa4 100644 --- a/docs/specs/natural-language-bulk-rename-plan.md +++ b/docs/specs/natural-language-bulk-rename-plan.md @@ -189,9 +189,10 @@ The operation: 3. Creates `WriteOperationState`, an operation event sink, and one queued `OperationDescriptor`; use `manager::spawn_managed`, not `rename_managed` / `run_instant`. The operation returns its id immediately, reserves its volume lane when admitted, reports row-count progress, and honours cancellation between filesystem calls. -4. Renames in a collision-safe order. For cycles, swaps, and case-only paths it first assigns unique, same-directory - temporary names, then performs final names. On a mid-operation stop, it records completed and skipped rows honestly; - it does not attempt an unreviewed rollback. +4. Renames in a collision-safe dependency order. Independent rows rename directly; acyclic chains run backward from + their free destination; each cycle uses one unique, same-directory temporary; and case-only paths retain one + temporary on case-insensitive filesystems. The review marks cycle rows and explains the temporary rotation. On a + mid-operation stop, it records completed and skipped rows honestly; it does not attempt an unreviewed rollback. 5. Opens one operation-log header on admission with `item_count = allowed rows`, writes one item row per final outcome, and reuses the existing local/volume rename routes, Downloads watcher write-ignore handling, listing notifications, busy state, and journal helpers. It never writes through a raw filesystem shortcut. From ccf540b811206c3a5505b0813bb2af91da7498fe Mon Sep 17 00:00:00 2001 From: David Veszelovszki Date: Tue, 21 Jul 2026 14:34:42 +0200 Subject: [PATCH 3/4] File operations: close local rename and rollback safety gaps --- .../src/file_system/volume/DETAILS.md | 4 ++ .../file_system/volume/backends/DETAILS.md | 3 + .../volume/backends/local_posix.rs | 55 ++++++++++++++- .../src/file_system/volume/backends/mod.rs | 1 + .../src-tauri/src/file_system/volume/mod.rs | 1 + .../file_system/write_operations/DETAILS.md | 9 ++- .../write_operations/rename/bulk.rs | 68 ++----------------- .../src-tauri/src/operation_log/DETAILS.md | 4 ++ .../src-tauri/src/operation_log/rollback.rs | 3 + .../src-tauri/src/operation_log/store/mod.rs | 5 +- .../src/operation_log/writer/tests.rs | 15 +++- ...-language-bulk-rename-hardening-handoff.md | 11 +++ 12 files changed, 103 insertions(+), 76 deletions(-) diff --git a/apps/desktop/src-tauri/src/file_system/volume/DETAILS.md b/apps/desktop/src-tauri/src/file_system/volume/DETAILS.md index d94665c3e..72c02fcee 100644 --- a/apps/desktop/src-tauri/src/file_system/volume/DETAILS.md +++ b/apps/desktop/src-tauri/src/file_system/volume/DETAILS.md @@ -36,6 +36,10 @@ VolumeManager (registry) `VolumeScanner` and `VolumeWatcher` are separate sub-traits returned by `Volume::scanner()` and `Volume::watcher()`. Only `LocalPosixVolume` implements both today. +Every non-forced `LocalPosixVolume::rename` is atomic-no-overwrite. macOS uses `renamex_np(RENAME_EXCL)` and Linux uses +`renameat2(RENAME_NOREPLACE)`, covering the boot volume, attached local volumes, and cloud folders registered under +their own volume IDs. A separate metadata check followed by plain `rename` is not an acceptable substitute. + ## Trait capability model Optional methods default to `Err(VolumeError::NotSupported)` or `false`, so new volume types can be added incrementally. Key capability flags: diff --git a/apps/desktop/src-tauri/src/file_system/volume/backends/DETAILS.md b/apps/desktop/src-tauri/src/file_system/volume/backends/DETAILS.md index 0a7d863d6..9039b3832 100644 --- a/apps/desktop/src-tauri/src/file_system/volume/backends/DETAILS.md +++ b/apps/desktop/src-tauri/src/file_system/volume/backends/DETAILS.md @@ -205,3 +205,6 @@ Tests: `smb_watcher/archive_refresh_test.rs` (a Modified `.zip` event refreshes the single-shot integration tests can't see (credit leak, FD leak, memory growth, slowdown). Default mode: `CMDR_SOAK_ITERATIONS=100` (≈5 s against Docker). Long mode: `CMDR_SOAK_DURATION_SECS=1800` (30 min, via `./scripts/soak-smb.sh`). CI has a `workflow_dispatch`-only job in `slow-checks.yml`. +`LocalPosixVolume` routes every non-forced rename through the shared atomic-exclusive primitive. This applies equally +to `/`, attached disks, Dropbox, iCloud, and other local POSIX roots registered with non-root volume IDs. Forced +renames retain normal POSIX replacement semantics because the caller explicitly authorized replacement. diff --git a/apps/desktop/src-tauri/src/file_system/volume/backends/local_posix.rs b/apps/desktop/src-tauri/src/file_system/volume/backends/local_posix.rs index 16aea3fc3..bbf4bccc2 100644 --- a/apps/desktop/src-tauri/src/file_system/volume/backends/local_posix.rs +++ b/apps/desktop/src-tauri/src/file_system/volume/backends/local_posix.rs @@ -12,6 +12,7 @@ use crate::indexing::scanner::{self, ScanConfig, ScanError, ScanHandle, ScanSumm use crate::indexing::watcher::{DriveWatcher, FsChangeEvent, WatcherError}; use crate::indexing::writer::IndexWriter; use std::future::Future; +use std::io; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::atomic::AtomicBool; @@ -19,6 +20,53 @@ use tokio::sync::mpsc; use tokio::task::spawn_blocking; use walkdir::WalkDir; +/// Atomically renames a local path only when `destination` is unoccupied. +#[cfg(target_os = "macos")] +pub(crate) fn rename_local_exclusive(source: &Path, destination: &Path) -> io::Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let source = CString::new(source.as_os_str().as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "source path contains a null byte"))?; + let destination = CString::new(destination.as_os_str().as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "destination path contains a null byte"))?; + // SAFETY: Both live C strings remain valid for the call. RENAME_EXCL makes + // destination absence and the rename one kernel operation. + let result = unsafe { libc::renamex_np(source.as_ptr(), destination.as_ptr(), libc::RENAME_EXCL) }; + if result == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +#[cfg(target_os = "linux")] +pub(crate) fn rename_local_exclusive(source: &Path, destination: &Path) -> io::Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let source = CString::new(source.as_os_str().as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "source path contains a null byte"))?; + let destination = CString::new(destination.as_os_str().as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "destination path contains a null byte"))?; + // SAFETY: Both live C strings remain valid for the call. RENAME_NOREPLACE + // is Linux's atomic no-overwrite contract. + let result = unsafe { + libc::renameat2( + libc::AT_FDCWD, + source.as_ptr(), + libc::AT_FDCWD, + destination.as_ptr(), + libc::RENAME_NOREPLACE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + /// A volume backed by the local POSIX file system. /// /// This implementation wraps the real filesystem, with a configurable root path. @@ -381,10 +429,11 @@ impl Volume for LocalPosixVolume { } Box::pin(async move { spawn_blocking(move || { - if !force && from_abs != to_abs && std::fs::symlink_metadata(&to_abs).is_ok() { - return Err(VolumeError::AlreadyExists(to_abs.display().to_string())); + if !force && from_abs != to_abs { + rename_local_exclusive(&from_abs, &to_abs)?; + } else { + std::fs::rename(&from_abs, &to_abs)?; } - std::fs::rename(&from_abs, &to_abs)?; Ok(()) }) .await diff --git a/apps/desktop/src-tauri/src/file_system/volume/backends/mod.rs b/apps/desktop/src-tauri/src/file_system/volume/backends/mod.rs index 250c15689..900445d3a 100644 --- a/apps/desktop/src-tauri/src/file_system/volume/backends/mod.rs +++ b/apps/desktop/src-tauri/src/file_system/volume/backends/mod.rs @@ -27,6 +27,7 @@ pub use local_posix::LocalPosixVolume; /// `statvfs` on Linux). Re-exported so the indexing module can read the scanned /// volume's used bytes for tier-2 scan progress without re-implementing statfs. pub(crate) use local_posix::get_space_info_for_path; +pub(crate) use local_posix::rename_local_exclusive; #[cfg(any(target_os = "macos", target_os = "linux"))] pub use mtp::MtpVolume; #[cfg(any(target_os = "macos", target_os = "linux"))] diff --git a/apps/desktop/src-tauri/src/file_system/volume/mod.rs b/apps/desktop/src-tauri/src/file_system/volume/mod.rs index fa96088ed..5be53cea0 100644 --- a/apps/desktop/src-tauri/src/file_system/volume/mod.rs +++ b/apps/desktop/src-tauri/src/file_system/volume/mod.rs @@ -991,6 +991,7 @@ pub mod eject; pub mod friendly_error; pub(crate) mod manager; +pub(crate) use backends::rename_local_exclusive; pub use backends::{InMemoryVolume, LocalPosixVolume}; #[cfg(any(target_os = "macos", target_os = "linux"))] pub use backends::{MtpVolume, SmbVolume}; diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/DETAILS.md b/apps/desktop/src-tauri/src/file_system/write_operations/DETAILS.md index e1fb3ccd2..d0ff80b1c 100644 --- a/apps/desktop/src-tauri/src/file_system/write_operations/DETAILS.md +++ b/apps/desktop/src-tauri/src/file_system/write_operations/DETAILS.md @@ -17,11 +17,10 @@ Subdirs: Implements the four destructive file operations as background tasks that stream Tauri events to the frontend. Every operation is cancellable, reports byte-level progress, and handles edge cases: symlink loops, same-inode overwrites, network mounts, cross-filesystem moves, and name/path length limits. -Ask Cmdr bulk renames use the same managed-operation lifecycle. For local final destinations, -`rename_local_exclusive` is the mandatory commit primitive: macOS uses `renamex_np(RENAME_EXCL)` and Linux uses -`renameat2(RENAME_NOREPLACE)`. The dialog's target-exists preflight improves review UX, but it cannot prove that a name -stays empty; only the exclusive kernel operation closes the check-to-rename race without replacing another process's -new file. +Ask Cmdr bulk renames use the same managed-operation lifecycle. `LocalPosixVolume` makes every non-forced local rename +atomic-no-overwrite, including attached disks and cloud folders registered under non-root volume IDs: macOS uses +`renamex_np(RENAME_EXCL)` and Linux uses `renameat2(RENAME_NOREPLACE)`. Root bulk-renames share that primitive directly. +The dialog's target-exists preflight improves review UX, but only the kernel operation closes the check-to-rename race. Pre-flight scans reuse cached listings when the source volume reports an active watcher, avoiding redundant `list_directory` calls. The freshness contract and per-backend debounce windows are documented in `../volume/CLAUDE.md` and `../listing/caching.rs::try_get_watched_listing`. diff --git a/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk.rs b/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk.rs index c61f0b0bf..864878a61 100644 --- a/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk.rs +++ b/apps/desktop/src-tauri/src/file_system/write_operations/rename/bulk.rs @@ -22,62 +22,9 @@ use super::super::types::{ WriteCancelledEvent, WriteCompleteEvent, WriteOperationStartResult, WriteOperationType, WriteProgressEvent, WriteSourceItemDoneEvent, }; -use crate::file_system::volume::{LaneKey, Volume}; +use crate::file_system::volume::{LaneKey, Volume, rename_local_exclusive}; use crate::operation_log::types::{EntryType, ExecutionStatus, Initiator, ItemOutcome, OpKind}; -/// Atomically renames a local file only when `destination` is unoccupied. -/// -/// The review-time existence check is advisory. This syscall-level exclusion -/// is the write boundary that prevents a destination created after review from -/// being silently replaced. -#[cfg(target_os = "macos")] -pub(super) fn rename_local_exclusive(source: &Path, destination: &Path) -> io::Result<()> { - use std::ffi::CString; - use std::os::unix::ffi::OsStrExt; - - let source = CString::new(source.as_os_str().as_bytes()) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "source path contains a null byte"))?; - let destination = CString::new(destination.as_os_str().as_bytes()) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "destination path contains a null byte"))?; - // SAFETY: Both pointers come from live `CString`s and remain valid for the - // duration of the call. `RENAME_EXCL` asks the kernel to combine the - // destination-absence check and rename into one operation. - let result = unsafe { libc::renamex_np(source.as_ptr(), destination.as_ptr(), libc::RENAME_EXCL) }; - if result == 0 { - Ok(()) - } else { - Err(io::Error::last_os_error()) - } -} - -#[cfg(target_os = "linux")] -pub(super) fn rename_local_exclusive(source: &Path, destination: &Path) -> io::Result<()> { - use std::ffi::CString; - use std::os::unix::ffi::OsStrExt; - - let source = CString::new(source.as_os_str().as_bytes()) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "source path contains a null byte"))?; - let destination = CString::new(destination.as_os_str().as_bytes()) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "destination path contains a null byte"))?; - // SAFETY: Both pointers come from live `CString`s and remain valid for the - // duration of the call. `RENAME_NOREPLACE` provides Linux's equivalent - // atomic no-overwrite contract. - let result = unsafe { - libc::renameat2( - libc::AT_FDCWD, - source.as_ptr(), - libc::AT_FDCWD, - destination.as_ptr(), - libc::RENAME_NOREPLACE, - ) - }; - if result == 0 { - Ok(()) - } else { - Err(io::Error::last_os_error()) - } -} - /// Server-owned source identity captured by the rename-review preflight. The /// frontend never creates this data; the Ask Cmdr command maps its accepted /// preflight directly into this write-engine input. @@ -117,14 +64,6 @@ impl BulkRenameOutcome { fn is_done(self) -> bool { self == Self::Done } - - fn journal_outcome(self) -> ItemOutcome { - match self { - Self::Done => ItemOutcome::Done, - Self::Skipped => ItemOutcome::Skipped, - Self::Failed => ItemOutcome::Failed, - } - } } /// Starts one queued, same-volume batch rename. The caller has already resolved @@ -792,6 +731,9 @@ fn record_bulk_rename_outcomes( outcomes: &[BulkRenameOutcome], ) { for (row, outcome) in rows.iter().zip(outcomes.iter().copied()) { + if outcome != BulkRenameOutcome::Done { + continue; + } let size = match &row.expected_fingerprint { BulkRenameFingerprint::Local { size, .. } => Some(*size as i64), BulkRenameFingerprint::Remote { size, .. } => size.map(|size| size as i64), @@ -805,7 +747,7 @@ fn record_bulk_rename_outcomes( size, None, false, - outcome.journal_outcome(), + ItemOutcome::Done, ); } } diff --git a/apps/desktop/src-tauri/src/operation_log/DETAILS.md b/apps/desktop/src-tauri/src/operation_log/DETAILS.md index ce5e75f8c..03cee46a4 100644 --- a/apps/desktop/src-tauri/src/operation_log/DETAILS.md +++ b/apps/desktop/src-tauri/src/operation_log/DETAILS.md @@ -316,6 +316,10 @@ cursor, `before_seq` = the smallest seq of the prior page), so a 1M-item op neve a dir removes only once its contents are gone, and pure `seq DESC` would hit a deep dir before the files it holds. Dirs are a small fraction of an op (interning shares them), so buffering just the dir rows stays bounded. +Rollback eligibility is Done-only at both boundaries: mutation code journals rollback leaves only for committed rows, +and `read_rollback_units_page` filters `outcome = done`. The executor also rejects any non-Done unit defensively. A +skipped or unsuccessful planned rename must never make an untouched destination eligible for rename-back. + ### The `rolling_back` state machine + startup reconcile (Finding 7 + 3) `rollback_operation` (the entry) reads the op, gates it (`check_rollbackable`: `UnknownOperation` / `AlreadyRollingBack` diff --git a/apps/desktop/src-tauri/src/operation_log/rollback.rs b/apps/desktop/src-tauri/src/operation_log/rollback.rs index b1c09f530..fdbf92a06 100644 --- a/apps/desktop/src-tauri/src/operation_log/rollback.rs +++ b/apps/desktop/src-tauri/src/operation_log/rollback.rs @@ -643,6 +643,9 @@ async fn remove_dir_if_empty(vm: &VolumeManager, unit: &RollbackUnit) -> ItemRes } async fn restore_move(vm: &VolumeManager, unit: &RollbackUnit) -> ItemResult { + if unit.outcome != ItemOutcome::Done { + return ItemResult::Skipped(SkipReason::Failed); + } // Restore moves the item back FROM where it landed (dest) TO its original // (source). Both must be present in the row (move/trash/rename all record a // dest); a row without one is a journal shape bug — skip safe. diff --git a/apps/desktop/src-tauri/src/operation_log/store/mod.rs b/apps/desktop/src-tauri/src/operation_log/store/mod.rs index 21a1a4bc4..34350107c 100644 --- a/apps/desktop/src-tauri/src/operation_log/store/mod.rs +++ b/apps/desktop/src-tauri/src/operation_log/store/mod.rs @@ -488,13 +488,14 @@ pub fn read_rollback_units_page( ) -> Result, OperationLogStoreError> { let sql = format!( "SELECT {ITEM_COLUMNS} FROM operation_items \ - WHERE op_id = ?1 AND row_role = ?2 AND seq < ?3 \ - ORDER BY seq DESC LIMIT ?4" + WHERE op_id = ?1 AND row_role = ?2 AND outcome = ?3 AND seq < ?4 \ + ORDER BY seq DESC LIMIT ?5" ); let mut stmt = conn.prepare_cached(&sql)?; let mut rows = stmt.query(rusqlite::params![ op_id, RowRole::RollbackUnit.as_token(), + ItemOutcome::Done.as_token(), before_seq, limit ])?; diff --git a/apps/desktop/src-tauri/src/operation_log/writer/tests.rs b/apps/desktop/src-tauri/src/operation_log/writer/tests.rs index d39193b96..bb6893b39 100644 --- a/apps/desktop/src-tauri/src/operation_log/writer/tests.rs +++ b/apps/desktop/src-tauri/src/operation_log/writer/tests.rs @@ -266,7 +266,8 @@ fn rollback_units_page_streams_reverse_and_excludes_search_leaves() { execution_status: ExecutionStatus::Running, }) .expect("open"); - // Three rollback units (seq 0,1,2) on two volumes, plus one search-only leaf. + // Three completed rollback units (seq 0,1,2), one skipped rollback unit, + // plus one search-only leaf. let unit = |seq: i64, name: &str| JournalItem { seq, entry_type: EntryType::File, @@ -286,8 +287,12 @@ fn rollback_units_page_streams_reverse_and_excludes_search_leaves() { row_role: RowRole::SearchOnly, ..unit(3, "inner.txt") }; + let skipped = JournalItem { + outcome: ItemOutcome::Skipped, + ..unit(4, "untouched.txt") + }; writer - .record_items("op", vec![unit(0, "a"), unit(1, "b"), unit(2, "c"), leaf]) + .record_items("op", vec![unit(0, "a"), unit(1, "b"), unit(2, "c"), leaf, skipped]) .expect("record"); writer.flush_blocking().expect("flush"); @@ -310,7 +315,11 @@ fn rollback_units_page_streams_reverse_and_excludes_search_leaves() { .into_iter() .map(|u| u.seq) .collect(); - assert_eq!(all, vec![2, 1, 0], "search_only leaf excluded from every page"); + assert_eq!( + all, + vec![2, 1, 0], + "search-only and non-Done rollback rows are excluded from every page" + ); writer.shutdown(); } diff --git a/docs/specs/natural-language-bulk-rename-hardening-handoff.md b/docs/specs/natural-language-bulk-rename-hardening-handoff.md index 7d9d126ff..af56d754e 100644 --- a/docs/specs/natural-language-bulk-rename-hardening-handoff.md +++ b/docs/specs/natural-language-bulk-rename-hardening-handoff.md @@ -34,6 +34,17 @@ remains untouched. - Validation after these changes: `pnpm check --fast -q` passed 51 checks with the existing file-length warning; `pnpm check svelte-tests --fresh -q` passed the full Svelte lane, including all 6,815 tests. +## Data-safety follow-up completed + +- Moved the atomic-exclusive local rename primitive into the shared local POSIX backend and made + `LocalPosixVolume::rename(..., force = false)` use it. This closes the no-clobber race for attached volumes and local + cloud roots with non-root IDs, not only the boot volume. Forced renames retain explicit replacement behavior. +- Bulk rename now journals rollback leaves only for `Done` rows. +- Rollback paging filters to `outcome = done`, and `restore_move` defensively skips any non-Done unit that reaches it. +- Added a store regression proving skipped rollback units are excluded. +- Updated volume, backend, write-operation, and operation-log DETAILS docs. +- Validation: the full local Rust lane passed after the changes. + ## Current worktree state Changed or added files at capture time: From a821773bc24fea7bf63e5d7598208ce3fdb62486 Mon Sep 17 00:00:00 2001 From: David Veszelovszki Date: Tue, 21 Jul 2026 15:08:35 +0200 Subject: [PATCH 4/4] Linux: iterate mounted-volume keys directly --- apps/desktop/src-tauri/src/volumes_linux/watcher.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/volumes_linux/watcher.rs b/apps/desktop/src-tauri/src/volumes_linux/watcher.rs index a9cbef271..a3d492f44 100644 --- a/apps/desktop/src-tauri/src/volumes_linux/watcher.rs +++ b/apps/desktop/src-tauri/src/volumes_linux/watcher.rs @@ -115,7 +115,7 @@ fn check_for_mount_changes() { } // Unmounted - for (path, _fstype) in known_guard.iter() { + for path in known_guard.keys() { if !current.contains_key(path) { debug!("Volume unmounted: {}", path); emit_volume_unmounted(path);