diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7495007e4..41d2ad476 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -222,6 +222,7 @@ pub fn run() { codex::codex_doctor, workspaces::list_workspaces, workspaces::add_workspace, + workspaces::add_clone, workspaces::add_worktree, workspaces::remove_workspace, workspaces::remove_worktree, diff --git a/src-tauri/src/local_usage.rs b/src-tauri/src/local_usage.rs index 67c8f4eb4..b529bbb31 100644 --- a/src-tauri/src/local_usage.rs +++ b/src-tauri/src/local_usage.rs @@ -234,6 +234,14 @@ fn scan_file( output: (output - prev.output).max(0), }; previous_totals = Some(UsageTotals { input, cached, output }); + } else { + // Some streams emit `last_token_usage` deltas between `total_token_usage` snapshots. + // Treat those as already-counted to avoid double-counting when the next total arrives. + let mut next = previous_totals.unwrap_or_default(); + next.input += delta.input; + next.cached += delta.cached; + next.output += delta.output; + previous_totals = Some(next); } if delta.input == 0 && delta.cached == 0 && delta.output == 0 { @@ -344,3 +352,75 @@ fn day_dir_for_key(root: &Path, day_key: &str) -> PathBuf { let day = parts.next().unwrap_or("01"); root.join(year).join(month).join(day) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use uuid::Uuid; + + fn write_temp_jsonl(lines: &[&str]) -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!( + "codexmonitor-local-usage-test-{}.jsonl", + Uuid::new_v4() + )); + let mut file = File::create(&path).expect("create temp jsonl"); + for line in lines { + writeln!(file, "{line}").expect("write jsonl line"); + } + path + } + + #[test] + fn scan_file_does_not_double_count_last_and_total_usage() { + let day_key = "2026-01-19"; + let path = write_temp_jsonl(&[ + r#"{"payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}}}"#, + r#"{"payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}}}"#, + ]); + + let mut daily: HashMap = HashMap::new(); + let mut model_totals: HashMap = HashMap::new(); + scan_file(&path, day_key, &mut daily, &mut model_totals).expect("scan file"); + + let totals = daily.get(day_key).copied().unwrap_or_default(); + assert_eq!(totals.input, 10); + assert_eq!(totals.output, 5); + } + + #[test] + fn scan_file_counts_last_deltas_before_total_snapshot_once() { + let day_key = "2026-01-19"; + let path = write_temp_jsonl(&[ + r#"{"payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}}}"#, + r#"{"payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":20,"cached_input_tokens":0,"output_tokens":10}}}}"#, + ]); + + let mut daily: HashMap = HashMap::new(); + let mut model_totals: HashMap = HashMap::new(); + scan_file(&path, day_key, &mut daily, &mut model_totals).expect("scan file"); + + let totals = daily.get(day_key).copied().unwrap_or_default(); + assert_eq!(totals.input, 20); + assert_eq!(totals.output, 10); + } + + #[test] + fn scan_file_does_not_double_count_last_between_total_snapshots() { + let day_key = "2026-01-19"; + let path = write_temp_jsonl(&[ + r#"{"payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}}}"#, + r#"{"payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":2,"cached_input_tokens":0,"output_tokens":1}}}}"#, + r#"{"payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":12,"cached_input_tokens":0,"output_tokens":6}}}}"#, + ]); + + let mut daily: HashMap = HashMap::new(); + let mut model_totals: HashMap = HashMap::new(); + scan_file(&path, day_key, &mut daily, &mut model_totals).expect("scan file"); + + let totals = daily.get(day_key).copied().unwrap_or_default(); + assert_eq!(totals.input, 12); + assert_eq!(totals.output, 6); + } +} diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index ce33c3f13..be9127f1c 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -213,6 +213,8 @@ pub(crate) struct WorkspaceGroup { pub(crate) name: String, #[serde(default, rename = "sortOrder")] pub(crate) sort_order: Option, + #[serde(default, rename = "copiesFolder")] + pub(crate) copies_folder: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] @@ -396,7 +398,9 @@ impl Default for AppSettings { #[cfg(test)] mod tests { - use super::{AppSettings, BackendMode, WorkspaceEntry, WorkspaceKind, WorkspaceSettings}; + use super::{ + AppSettings, BackendMode, WorkspaceEntry, WorkspaceGroup, WorkspaceKind, WorkspaceSettings, + }; #[test] fn app_settings_defaults_from_empty_json() { @@ -430,6 +434,33 @@ mod tests { assert!(settings.workspace_groups.is_empty()); } + #[test] + fn workspace_group_defaults_from_minimal_json() { + let group: WorkspaceGroup = + serde_json::from_str(r#"{"id":"g1","name":"Group"}"#).expect("group deserialize"); + assert!(group.sort_order.is_none()); + assert!(group.copies_folder.is_none()); + } + + #[test] + fn app_settings_round_trip_preserves_workspace_group_copies_folder() { + let mut settings = AppSettings::default(); + settings.workspace_groups = vec![WorkspaceGroup { + id: "g1".to_string(), + name: "Group".to_string(), + sort_order: Some(2), + copies_folder: Some("/tmp/group-copies".to_string()), + }]; + + let json = serde_json::to_string(&settings).expect("serialize settings"); + let decoded: AppSettings = serde_json::from_str(&json).expect("deserialize settings"); + assert_eq!(decoded.workspace_groups.len(), 1); + assert_eq!( + decoded.workspace_groups[0].copies_folder.as_deref(), + Some("/tmp/group-copies") + ); + } + #[test] fn workspace_entry_defaults_from_minimal_json() { let entry: WorkspaceEntry = serde_json::from_str( diff --git a/src-tauri/src/workspaces.rs b/src-tauri/src/workspaces.rs index 1b8d1f5ec..4ae763971 100644 --- a/src-tauri/src/workspaces.rs +++ b/src-tauri/src/workspaces.rs @@ -57,6 +57,23 @@ fn sanitize_worktree_name(branch: &str) -> String { } } +fn sanitize_clone_dir_name(name: &str) -> String { + let mut result = String::new(); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + result.push(ch); + } else { + result.push('-'); + } + } + let trimmed = result.trim_matches('-').to_string(); + if trimmed.is_empty() { + "copy".to_string() + } else { + trimmed + } +} + fn list_workspace_files_inner(root: &PathBuf, max_files: usize) -> Vec { let mut results = Vec::new(); let walker = WalkBuilder::new(root) @@ -211,6 +228,13 @@ async fn git_branch_exists(repo_path: &PathBuf, branch: &str) -> Result Option { + match run_git_command(repo_path, &["config", "--get", "remote.origin.url"]).await { + Ok(url) if !url.trim().is_empty() => Some(url), + _ => None, + } +} + fn unique_worktree_path(base_dir: &PathBuf, name: &str) -> PathBuf { let mut candidate = base_dir.join(name); if !candidate.exists() { @@ -226,6 +250,19 @@ fn unique_worktree_path(base_dir: &PathBuf, name: &str) -> PathBuf { candidate } +fn build_clone_destination_path(copies_folder: &PathBuf, copy_name: &str) -> PathBuf { + let safe_name = sanitize_clone_dir_name(copy_name); + unique_worktree_path(copies_folder, &safe_name) +} + +fn null_device_path() -> &'static str { + if cfg!(windows) { + "NUL" + } else { + "/dev/null" + } +} + #[tauri::command] pub(crate) async fn list_workspaces( state: State<'_, AppState>, @@ -279,12 +316,147 @@ pub(crate) async fn add_workspace( }; let codex_home = resolve_codex_home(&entry, None); let session = spawn_workspace_session(entry.clone(), default_bin, app, codex_home).await?; + + if let Err(error) = { + let mut workspaces = state.workspaces.lock().await; + workspaces.insert(entry.id.clone(), entry.clone()); + let list: Vec<_> = workspaces.values().cloned().collect(); + write_workspaces(&state.storage_path, &list) + } { + { + let mut workspaces = state.workspaces.lock().await; + workspaces.remove(&entry.id); + } + let mut child = session.child.lock().await; + let _ = child.kill().await; + return Err(error); + } + + state + .sessions + .lock() + .await + .insert(entry.id.clone(), session); + + Ok(WorkspaceInfo { + id: entry.id, + name: entry.name, + path: entry.path, + codex_bin: entry.codex_bin, + connected: true, + kind: entry.kind, + parent_id: entry.parent_id, + worktree: entry.worktree, + settings: entry.settings, + }) +} + +#[tauri::command] +pub(crate) async fn add_clone( + source_workspace_id: String, + copy_name: String, + copies_folder: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + let copy_name = copy_name.trim().to_string(); + if copy_name.is_empty() { + return Err("Copy name is required.".to_string()); + } + + let copies_folder = copies_folder.trim().to_string(); + if copies_folder.is_empty() { + return Err("Copies folder is required.".to_string()); + } + let copies_folder_path = PathBuf::from(&copies_folder); + std::fs::create_dir_all(&copies_folder_path) + .map_err(|e| format!("Failed to create copies folder: {e}"))?; + if !copies_folder_path.is_dir() { + return Err("Copies folder must be a directory.".to_string()); + } + + let (source_entry, inherited_group_id) = { + let workspaces = state.workspaces.lock().await; + let source_entry = workspaces + .get(&source_workspace_id) + .cloned() + .ok_or("source workspace not found")?; + let inherited_group_id = if source_entry.kind.is_worktree() { + source_entry + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .and_then(|parent| parent.settings.group_id.clone()) + } else { + source_entry.settings.group_id.clone() + }; + (source_entry, inherited_group_id) + }; + + let destination_path = build_clone_destination_path(&copies_folder_path, ©_name); + let destination_path_string = destination_path.to_string_lossy().to_string(); + + if let Err(error) = run_git_command( + &copies_folder_path, + &["clone", &source_entry.path, &destination_path_string], + ) + .await { + let _ = tokio::fs::remove_dir_all(&destination_path).await; + return Err(error); + } + + if let Some(origin_url) = git_get_origin_url(&PathBuf::from(&source_entry.path)).await { + let _ = run_git_command( + &destination_path, + &["remote", "set-url", "origin", &origin_url], + ) + .await; + } + + let entry = WorkspaceEntry { + id: Uuid::new_v4().to_string(), + name: copy_name.clone(), + path: destination_path_string, + codex_bin: source_entry.codex_bin.clone(), + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings { + group_id: inherited_group_id, + ..WorkspaceSettings::default() + }, + }; + + let default_bin = { + let settings = state.app_settings.lock().await; + settings.codex_bin.clone() + }; + let codex_home = resolve_codex_home(&entry, None); + let session = match spawn_workspace_session(entry.clone(), default_bin, app, codex_home).await { + Ok(session) => session, + Err(error) => { + let _ = tokio::fs::remove_dir_all(&destination_path).await; + return Err(error); + } + }; + + if let Err(error) = { let mut workspaces = state.workspaces.lock().await; workspaces.insert(entry.id.clone(), entry.clone()); let list: Vec<_> = workspaces.values().cloned().collect(); - write_workspaces(&state.storage_path, &list)?; + write_workspaces(&state.storage_path, &list) + } { + { + let mut workspaces = state.workspaces.lock().await; + workspaces.remove(&entry.id); + } + let mut child = session.child.lock().await; + let _ = child.kill().await; + let _ = tokio::fs::remove_dir_all(&destination_path).await; + return Err(error); } + state .sessions .lock() @@ -571,7 +743,7 @@ pub(crate) async fn apply_worktree_changes( "--no-color", "--no-index", "--", - "/dev/null", + null_device_path(), &path, ], ) @@ -765,7 +937,10 @@ mod tests { use std::collections::HashMap; use std::path::PathBuf; - use super::{apply_workspace_settings_update, sanitize_worktree_name, sort_workspaces}; + use super::{ + apply_workspace_settings_update, build_clone_destination_path, sanitize_clone_dir_name, + sanitize_worktree_name, sort_workspaces, + }; use crate::storage::{read_workspaces, write_workspaces}; use crate::types::{WorktreeInfo, WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings}; use uuid::Uuid; @@ -821,6 +996,44 @@ mod tests { assert_eq!(sanitize_worktree_name("feature--x"), "feature--x"); } + #[test] + fn sanitize_clone_dir_name_rewrites_specials() { + assert_eq!(sanitize_clone_dir_name("feature/new-thing"), "feature-new-thing"); + assert_eq!(sanitize_clone_dir_name("///"), "copy"); + assert_eq!(sanitize_clone_dir_name("--name--"), "name"); + } + + #[test] + fn sanitize_clone_dir_name_allows_safe_chars() { + assert_eq!(sanitize_clone_dir_name("release_1.2.3"), "release_1.2.3"); + assert_eq!(sanitize_clone_dir_name("feature--x"), "feature--x"); + } + + #[test] + fn build_clone_destination_path_sanitizes_and_uniquifies() { + let temp_dir = + std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); + let copies_folder = temp_dir.join("copies"); + std::fs::create_dir_all(&copies_folder).expect("create copies folder"); + + let first = build_clone_destination_path(&copies_folder, "feature/new-thing"); + assert!(first.starts_with(&copies_folder)); + assert_eq!( + first.file_name().and_then(|name| name.to_str()), + Some("feature-new-thing") + ); + + std::fs::create_dir_all(&first).expect("create first clone folder"); + + let second = build_clone_destination_path(&copies_folder, "feature/new-thing"); + assert!(second.starts_with(&copies_folder)); + assert_ne!(first, second); + assert_eq!( + second.file_name().and_then(|name| name.to_str()), + Some("feature-new-thing-2") + ); + } + #[test] fn sort_workspaces_orders_by_sort_then_name() { let mut items = vec![ diff --git a/src/App.tsx b/src/App.tsx index f27e476bb..4c7207e0f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import "./styles/plan.css"; import "./styles/about.css"; import "./styles/tabbar.css"; import "./styles/worktree-modal.css"; +import "./styles/clone-modal.css"; import "./styles/settings.css"; import "./styles/compact-base.css"; import "./styles/compact-phone.css"; @@ -26,6 +27,7 @@ import "./styles/compact-tablet.css"; import successSoundUrl from "./assets/success-notification.mp3"; import errorSoundUrl from "./assets/error-notification.mp3"; import { WorktreePrompt } from "./features/workspaces/components/WorktreePrompt"; +import { ClonePrompt } from "./features/workspaces/components/ClonePrompt"; import { RenameThreadPrompt } from "./features/threads/components/RenameThreadPrompt"; import { AboutView } from "./features/about/components/AboutView"; import { SettingsView } from "./features/settings/components/SettingsView"; @@ -76,6 +78,7 @@ import { useHoldToDictate } from "./features/dictation/hooks/useHoldToDictate"; import { useQueuedSend } from "./features/threads/hooks/useQueuedSend"; import { useRenameThreadPrompt } from "./features/threads/hooks/useRenameThreadPrompt"; import { useWorktreePrompt } from "./features/workspaces/hooks/useWorktreePrompt"; +import { useClonePrompt } from "./features/workspaces/hooks/useClonePrompt"; import { useUiScaleShortcuts } from "./features/layout/hooks/useUiScaleShortcuts"; import { useWorkspaceSelection } from "./features/workspaces/hooks/useWorkspaceSelection"; import { useLocalUsage } from "./features/home/hooks/useLocalUsage"; @@ -289,6 +292,7 @@ function MainApp() { activeWorkspaceId, setActiveWorkspaceId, addWorkspace, + addCloneAgent, addWorktreeAgent, connectWorkspace, markWorkspaceConnected, @@ -703,6 +707,59 @@ function MainApp() { }, }); + const resolveCloneProjectContext = useCallback( + (workspace: WorkspaceInfo) => { + const groupId = workspace.settings.groupId ?? null; + const group = groupId + ? appSettings.workspaceGroups.find((entry) => entry.id === groupId) + : null; + return { + groupId, + copiesFolder: group?.copiesFolder ?? null, + }; + }, + [appSettings.workspaceGroups], + ); + + const persistProjectCopiesFolder = useCallback( + async (groupId: string, copiesFolder: string) => { + await queueSaveSettings({ + ...appSettings, + workspaceGroups: appSettings.workspaceGroups.map((entry) => + entry.id === groupId ? { ...entry, copiesFolder } : entry, + ), + }); + }, + [appSettings, queueSaveSettings], + ); + + const { + clonePrompt, + openPrompt: openClonePrompt, + confirmPrompt: confirmClonePrompt, + cancelPrompt: cancelClonePrompt, + updateCopyName: updateCloneCopyName, + chooseCopiesFolder: chooseCloneCopiesFolder, + useSuggestedCopiesFolder: useSuggestedCloneCopiesFolder, + clearCopiesFolder: clearCloneCopiesFolder, + } = useClonePrompt({ + addCloneAgent, + connectWorkspace, + onSelectWorkspace: selectWorkspace, + resolveProjectContext: resolveCloneProjectContext, + persistProjectCopiesFolder, + onCompactActivate: isCompact ? () => setActiveTab("codex") : undefined, + onError: (message) => { + addDebugEntry({ + id: `${Date.now()}-client-add-clone-error`, + timestamp: Date.now(), + source: "error", + label: "clone/add error", + payload: message, + }); + }, + }); + const latestAgentRuns = useMemo(() => { const entries: Array<{ threadId: string; @@ -999,6 +1056,11 @@ function MainApp() { openWorktreePrompt(workspace); } + async function handleAddCloneAgent(workspace: (typeof workspaces)[number]) { + exitDiffView(); + openClonePrompt(workspace); + } + function handleSelectDiff(path: string) { setSelectedDiffPath(path); pendingDiffScrollRef.current = true; @@ -1211,6 +1273,7 @@ function MainApp() { }, onAddAgent: handleAddAgent, onAddWorktreeAgent: handleAddWorktreeAgent, + onAddCloneAgent: handleAddCloneAgent, onToggleWorkspaceCollapse: (workspaceId, collapsed) => { const target = workspaces.find((entry) => entry.id === workspaceId); if (!target) { @@ -1579,6 +1642,22 @@ function MainApp() { onConfirm={confirmWorktreePrompt} /> )} + {clonePrompt && ( + + )} {settingsOpen && ( void; onAddAgent: (workspace: WorkspaceInfo) => void; onAddWorktreeAgent: (workspace: WorkspaceInfo) => void; + onAddCloneAgent: (workspace: WorkspaceInfo) => void; onToggleWorkspaceCollapse: (workspaceId: string, collapsed: boolean) => void; onSelectThread: (workspaceId: string, threadId: string) => void; onDeleteThread: (workspaceId: string, threadId: string) => void; @@ -89,6 +90,7 @@ export function Sidebar({ onConnectWorkspace, onAddAgent, onAddWorktreeAgent, + onAddCloneAgent, onToggleWorkspaceCollapse, onSelectThread, onDeleteThread, @@ -315,13 +317,14 @@ export function Sidebar({ entry.id, getPinTimestamp, ); - const showThreads = !isCollapsed && threads.length > 0; + const nextCursor = + threadListCursorByWorkspace[entry.id] ?? null; + const showThreadList = + !isCollapsed && (threads.length > 0 || Boolean(nextCursor)); const isLoadingThreads = threadListLoadingByWorkspace[entry.id] ?? false; const showThreadLoader = !isCollapsed && isLoadingThreads && threads.length === 0; - const nextCursor = - threadListCursorByWorkspace[entry.id] ?? null; const isPaging = threadListPagingByWorkspace[entry.id] ?? false; const worktrees = worktreesByParent.get(entry.id) ?? []; const addMenuOpen = addMenuAnchor?.workspaceId === entry.id; @@ -371,6 +374,16 @@ export function Sidebar({ > New worktree agent + , document.body, )} @@ -399,7 +412,7 @@ export function Sidebar({ onLoadOlderThreads={onLoadOlderThreads} /> )} - {showThreads && ( + {showThreadList && ( )} - {isExpanded && nextCursor && ( + {nextCursor && (isExpanded || totalThreadRoots <= 3) && ( )} diff --git a/src/features/app/components/WorktreeSection.tsx b/src/features/app/components/WorktreeSection.tsx index 4a670b102..cba561605 100644 --- a/src/features/app/components/WorktreeSection.tsx +++ b/src/features/app/components/WorktreeSection.tsx @@ -89,8 +89,6 @@ export function WorktreeSection({ {worktrees.map((worktree) => { const worktreeThreads = threadsByWorkspace[worktree.id] ?? []; const worktreeCollapsed = worktree.settings.sidebarCollapsed; - const showWorktreeThreads = - !worktreeCollapsed && worktreeThreads.length > 0; const isLoadingWorktreeThreads = threadListLoadingByWorkspace[worktree.id] ?? false; const showWorktreeLoader = @@ -99,6 +97,9 @@ export function WorktreeSection({ worktreeThreads.length === 0; const worktreeNextCursor = threadListCursorByWorkspace[worktree.id] ?? null; + const showWorktreeThreadList = + !worktreeCollapsed && + (worktreeThreads.length > 0 || Boolean(worktreeNextCursor)); const isWorktreePaging = threadListPagingByWorkspace[worktree.id] ?? false; const isWorktreeExpanded = expandedWorkspaces.has(worktree.id); @@ -122,7 +123,7 @@ export function WorktreeSection({ onToggleWorkspaceCollapse={onToggleWorkspaceCollapse} onConnectWorkspace={onConnectWorkspace} > - {showWorktreeThreads && ( + {showWorktreeThreadList && ( { - await navigator.clipboard.writeText(threadId); + try { + await navigator.clipboard.writeText(threadId); + } catch { + // Clipboard failures are non-fatal here. + } }, }); const items = [renameItem]; diff --git a/src/features/git/hooks/useGitActions.ts b/src/features/git/hooks/useGitActions.ts index 7fbfc0076..5a191dc59 100644 --- a/src/features/git/hooks/useGitActions.ts +++ b/src/features/git/hooks/useGitActions.ts @@ -54,10 +54,18 @@ export function useGitActions({ if (!workspaceId) { return; } - await stageGitFileService(workspaceId, path); - refreshGitData(); + const actionWorkspaceId = workspaceId; + try { + await stageGitFileService(actionWorkspaceId, path); + } catch (error) { + onError?.(error); + } finally { + if (workspaceIdRef.current === actionWorkspaceId) { + refreshGitData(); + } + } }, - [refreshGitData, workspaceId], + [onError, refreshGitData, workspaceId], ); const unstageGitFile = useCallback( @@ -65,10 +73,18 @@ export function useGitActions({ if (!workspaceId) { return; } - await unstageGitFileService(workspaceId, path); - refreshGitData(); + const actionWorkspaceId = workspaceId; + try { + await unstageGitFileService(actionWorkspaceId, path); + } catch (error) { + onError?.(error); + } finally { + if (workspaceIdRef.current === actionWorkspaceId) { + refreshGitData(); + } + } }, - [refreshGitData, workspaceId], + [onError, refreshGitData, workspaceId], ); const revertGitFile = useCallback( @@ -76,10 +92,18 @@ export function useGitActions({ if (!workspaceId) { return; } - await revertGitFileService(workspaceId, path); - refreshGitData(); + const actionWorkspaceId = workspaceId; + try { + await revertGitFileService(actionWorkspaceId, path); + } catch (error) { + onError?.(error); + } finally { + if (workspaceIdRef.current === actionWorkspaceId) { + refreshGitData(); + } + } }, - [refreshGitData, workspaceId], + [onError, refreshGitData, workspaceId], ); const revertAllGitChanges = useCallback(async () => { diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index 3a4314047..5aa27f9f5 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -94,6 +94,7 @@ type LayoutNodesOptions = { onConnectWorkspace: (workspace: WorkspaceInfo) => Promise; onAddAgent: (workspace: WorkspaceInfo) => Promise; onAddWorktreeAgent: (workspace: WorkspaceInfo) => Promise; + onAddCloneAgent: (workspace: WorkspaceInfo) => Promise; onToggleWorkspaceCollapse: (workspaceId: string, collapsed: boolean) => void; onSelectThread: (workspaceId: string, threadId: string) => void; onDeleteThread: (workspaceId: string, threadId: string) => void; @@ -344,6 +345,7 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { onConnectWorkspace={options.onConnectWorkspace} onAddAgent={options.onAddAgent} onAddWorktreeAgent={options.onAddWorktreeAgent} + onAddCloneAgent={options.onAddCloneAgent} onToggleWorkspaceCollapse={options.onToggleWorkspaceCollapse} onSelectThread={options.onSelectThread} onDeleteThread={options.onDeleteThread} diff --git a/src/features/messages/hooks/useFileLinkOpener.ts b/src/features/messages/hooks/useFileLinkOpener.ts index c6e7de0f1..575c4e36c 100644 --- a/src/features/messages/hooks/useFileLinkOpener.ts +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -81,7 +81,7 @@ export function useFileLinkOpener(workspacePath?: string | null) { const resolvedPath = resolveFilePath(stripLineSuffix(rawPath), workspacePath); const openLabel = target.id === "finder" - ? "Open in Finder" + ? revealLabel() : target.appName ? `Open in ${target.appName}` : "Open Link"; @@ -92,18 +92,16 @@ export function useFileLinkOpener(workspacePath?: string | null) { await openFileLink(rawPath); }, }), - await MenuItem.new({ - text: "Open Link in New Window", - action: async () => { - await openFileLink(rawPath); - }, - }), - await MenuItem.new({ - text: revealLabel(), - action: async () => { - await revealItemInDir(resolvedPath); - }, - }), + ...(target.id === "finder" + ? [] + : [ + await MenuItem.new({ + text: revealLabel(), + action: async () => { + await revealItemInDir(resolvedPath); + }, + }), + ]), await MenuItem.new({ text: "Download Linked File", enabled: false, diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index b6b44aa6e..14152e463 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -368,6 +368,38 @@ export function SettingsView({ } }; + const updateGroupCopiesFolder = async ( + groupId: string, + copiesFolder: string | null, + ) => { + setGroupError(null); + try { + await onUpdateAppSettings({ + ...appSettings, + workspaceGroups: appSettings.workspaceGroups.map((entry) => + entry.id === groupId ? { ...entry, copiesFolder } : entry, + ), + }); + } catch (error) { + setGroupError(error instanceof Error ? error.message : String(error)); + } + }; + + const handleChooseGroupCopiesFolder = async (group: WorkspaceGroup) => { + const selection = await open({ multiple: false, directory: true }); + if (!selection || Array.isArray(selection)) { + return; + } + await updateGroupCopiesFolder(group.id, selection); + }; + + const handleClearGroupCopiesFolder = async (group: WorkspaceGroup) => { + if (!group.copiesFolder) { + return; + } + await updateGroupCopiesFolder(group.id, null); + }; + const handleDeleteGroup = async (group: WorkspaceGroup) => { const groupProjects = groupedWorkspaces.find((entry) => entry.id === group.id)?.workspaces ?? []; @@ -502,25 +534,61 @@ export function SettingsView({
{workspaceGroups.map((group, index) => (
- - setGroupDrafts((prev) => ({ - ...prev, - [group.id]: event.target.value, - })) - } - onBlur={() => { - void handleRenameGroup(group); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void handleRenameGroup(group); +
+ + setGroupDrafts((prev) => ({ + ...prev, + [group.id]: event.target.value, + })) } - }} - /> + onBlur={() => { + void handleRenameGroup(group); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleRenameGroup(group); + } + }} + /> +
+
+ Copies folder +
+
+
+ {group.copiesFolder ?? "Not set"} +
+ + +
+
+
+ +
+ {showSuggested && ( +
+
Suggested
+
+ + + +
+
+ )} + {error &&
{error}
} +
+ + +
+
+
+ ); +} diff --git a/src/features/workspaces/components/WorktreePrompt.tsx b/src/features/workspaces/components/WorktreePrompt.tsx index 775d26970..c75dd67bc 100644 --- a/src/features/workspaces/components/WorktreePrompt.tsx +++ b/src/features/workspaces/components/WorktreePrompt.tsx @@ -28,7 +28,14 @@ export function WorktreePrompt({ return (
-
+
{ + if (!isBusy) { + onCancel(); + } + }} + />
New worktree agent
@@ -46,9 +53,11 @@ export function WorktreePrompt({ onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); - onCancel(); + if (!isBusy) { + onCancel(); + } } - if (event.key === "Enter") { + if (event.key === "Enter" && !isBusy) { event.preventDefault(); onConfirm(); } diff --git a/src/features/workspaces/hooks/useClonePrompt.ts b/src/features/workspaces/hooks/useClonePrompt.ts new file mode 100644 index 000000000..29ff95573 --- /dev/null +++ b/src/features/workspaces/hooks/useClonePrompt.ts @@ -0,0 +1,250 @@ +import { useCallback, useMemo, useState } from "react"; +import type { WorkspaceInfo } from "../../../types"; +import { pickWorkspacePath } from "../../../services/tauri"; + +type ClonePromptState = { + workspace: WorkspaceInfo; + copyName: string; + copiesFolder: string; + initialCopiesFolder: string; + groupId: string | null; + suggestedCopiesFolder: string | null; + isSubmitting: boolean; + error: string | null; +} | null; + +type UseClonePromptOptions = { + addCloneAgent: ( + workspace: WorkspaceInfo, + copyName: string, + copiesFolder: string, + ) => Promise; + connectWorkspace: (workspace: WorkspaceInfo) => Promise; + onSelectWorkspace: (workspaceId: string) => void; + resolveProjectContext: ( + workspace: WorkspaceInfo, + ) => { groupId: string | null; copiesFolder: string | null }; + persistProjectCopiesFolder?: (groupId: string, copiesFolder: string) => Promise; + onCompactActivate?: () => void; + onError?: (message: string) => void; +}; + +type UseClonePromptResult = { + clonePrompt: ClonePromptState; + openPrompt: (workspace: WorkspaceInfo) => void; + confirmPrompt: () => Promise; + cancelPrompt: () => void; + updateCopyName: (value: string) => void; + chooseCopiesFolder: () => Promise; + useSuggestedCopiesFolder: () => void; + clearCopiesFolder: () => void; +}; + +function normalizePathSeparators(path: string) { + return path.replace(/\\/g, "/"); +} + +function dirname(path: string) { + const normalized = normalizePathSeparators(path).replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + if (index === -1) { + return ""; + } + return normalized.slice(0, index); +} + +function basename(path: string) { + const normalized = normalizePathSeparators(path).replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + if (index === -1) { + return normalized; + } + return normalized.slice(index + 1); +} + +function joinPath(parent: string, name: string) { + const normalized = normalizePathSeparators(parent).replace(/\/+$/, ""); + if (!normalized) { + return name; + } + return `${normalized}/${name}`; +} + +function suggestCopiesFolder(workspacePath: string) { + const parent = dirname(workspacePath); + const repoName = basename(workspacePath); + if (!parent || !repoName) { + return null; + } + return joinPath(parent, `${repoName}-copies`); +} + +function slugifyWorkspaceName(value: string) { + const slug = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || null; +} + +function defaultCopyName(workspace: WorkspaceInfo) { + const repoName = basename(workspace.path); + const slug = slugifyWorkspaceName(repoName)?.slice(0, 32) ?? null; + const suffix = Math.random().toString(36).slice(2, 6); + return slug ? `${slug}-${suffix}` : suffix; +} + +export function useClonePrompt({ + addCloneAgent, + connectWorkspace, + onSelectWorkspace, + resolveProjectContext, + persistProjectCopiesFolder, + onCompactActivate, + onError, +}: UseClonePromptOptions): UseClonePromptResult { + const [clonePrompt, setClonePrompt] = useState(null); + + const openPrompt = useCallback( + (workspace: WorkspaceInfo) => { + const { groupId, copiesFolder } = resolveProjectContext(workspace); + setClonePrompt({ + workspace, + copyName: defaultCopyName(workspace), + copiesFolder: copiesFolder ?? "", + initialCopiesFolder: copiesFolder ?? "", + groupId, + suggestedCopiesFolder: suggestCopiesFolder(workspace.path), + isSubmitting: false, + error: null, + }); + }, + [resolveProjectContext], + ); + + const updateCopyName = useCallback((value: string) => { + setClonePrompt((prev) => + prev ? { ...prev, copyName: value, error: null } : prev, + ); + }, []); + + const cancelPrompt = useCallback(() => { + setClonePrompt(null); + }, []); + + const chooseCopiesFolder = useCallback(async () => { + const selection = await pickWorkspacePath(); + if (!selection) { + return; + } + setClonePrompt((prev) => + prev ? { ...prev, copiesFolder: selection, error: null } : prev, + ); + }, []); + + const useSuggestedCopiesFolder = useCallback(() => { + setClonePrompt((prev) => { + if (!prev || !prev.suggestedCopiesFolder) { + return prev; + } + return { ...prev, copiesFolder: prev.suggestedCopiesFolder, error: null }; + }); + }, []); + + const clearCopiesFolder = useCallback(() => { + setClonePrompt((prev) => (prev ? { ...prev, copiesFolder: "", error: null } : prev)); + }, []); + + const canPersistCopiesFolder = useMemo(() => { + if (!clonePrompt) { + return false; + } + if (!clonePrompt.groupId || !persistProjectCopiesFolder) { + return false; + } + return clonePrompt.copiesFolder.trim().length > 0; + }, [clonePrompt, persistProjectCopiesFolder]); + + const confirmPrompt = useCallback(async () => { + if (!clonePrompt || clonePrompt.isSubmitting) { + return; + } + const copyName = clonePrompt.copyName.trim(); + const copiesFolder = clonePrompt.copiesFolder.trim(); + if (!copyName) { + setClonePrompt((prev) => + prev ? { ...prev, error: "Copy name is required." } : prev, + ); + return; + } + if (!copiesFolder) { + setClonePrompt((prev) => + prev ? { ...prev, error: "Copies folder is required." } : prev, + ); + return; + } + + setClonePrompt((prev) => + prev ? { ...prev, isSubmitting: true, error: null } : prev, + ); + try { + const cloneWorkspace = await addCloneAgent( + clonePrompt.workspace, + copyName, + copiesFolder, + ); + if (!cloneWorkspace) { + setClonePrompt(null); + return; + } + onSelectWorkspace(cloneWorkspace.id); + if (!cloneWorkspace.connected) { + await connectWorkspace(cloneWorkspace); + } + + if ( + canPersistCopiesFolder && + clonePrompt.groupId && + copiesFolder !== clonePrompt.initialCopiesFolder + ) { + try { + await persistProjectCopiesFolder?.(clonePrompt.groupId, copiesFolder); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + onError?.(message); + } + } + + onCompactActivate?.(); + setClonePrompt(null); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setClonePrompt((prev) => + prev ? { ...prev, isSubmitting: false, error: message } : prev, + ); + onError?.(message); + } + }, [ + addCloneAgent, + canPersistCopiesFolder, + clonePrompt, + connectWorkspace, + onCompactActivate, + onError, + onSelectWorkspace, + persistProjectCopiesFolder, + ]); + + return { + clonePrompt, + openPrompt, + confirmPrompt, + cancelPrompt, + updateCopyName, + chooseCopiesFolder, + useSuggestedCopiesFolder, + clearCopiesFolder, + }; +} diff --git a/src/features/workspaces/hooks/useWorkspaces.ts b/src/features/workspaces/hooks/useWorkspaces.ts index 0e592dddc..c1738aaeb 100644 --- a/src/features/workspaces/hooks/useWorkspaces.ts +++ b/src/features/workspaces/hooks/useWorkspaces.ts @@ -8,6 +8,7 @@ import type { } from "../../../types"; import { ask } from "@tauri-apps/plugin-dialog"; import { + addClone as addCloneService, addWorkspace as addWorkspaceService, addWorktree as addWorktreeService, connectWorkspace as connectWorkspaceService, @@ -260,6 +261,47 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { } } + async function addCloneAgent( + source: WorkspaceInfo, + copyName: string, + copiesFolder: string, + ) { + const trimmedName = copyName.trim(); + if (!trimmedName) { + return null; + } + const trimmedFolder = copiesFolder.trim(); + if (!trimmedFolder) { + throw new Error("Copies folder is required."); + } + onDebug?.({ + id: `${Date.now()}-client-add-clone`, + timestamp: Date.now(), + source: "client", + label: "clone/add", + payload: { + sourceWorkspaceId: source.id, + copyName: trimmedName, + copiesFolder: trimmedFolder, + }, + }); + try { + const workspace = await addCloneService(source.id, trimmedFolder, trimmedName); + setWorkspaces((prev) => [...prev, workspace]); + setActiveWorkspaceId(workspace.id); + return workspace; + } catch (error) { + onDebug?.({ + id: `${Date.now()}-client-add-clone-error`, + timestamp: Date.now(), + source: "error", + label: "clone/add error", + payload: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + async function connectWorkspace(entry: WorkspaceInfo) { onDebug?.({ id: `${Date.now()}-client-connect-workspace`, @@ -413,6 +455,7 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { id: createGroupId(), name: trimmed, sortOrder: nextSortOrder, + copiesFolder: null, }; await updateWorkspaceGroups([...currentGroups, nextGroup]); return nextGroup; @@ -640,6 +683,7 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { activeWorkspaceId, setActiveWorkspaceId, addWorkspace, + addCloneAgent, addWorktreeAgent, connectWorkspace, markWorkspaceConnected, diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 26774a574..e621c520e 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -55,6 +55,18 @@ export async function addWorkspace( return invoke("add_workspace", { path, codex_bin }); } +export async function addClone( + sourceWorkspaceId: string, + copiesFolder: string, + copyName: string, +): Promise { + return invoke("add_clone", { + sourceWorkspaceId, + copiesFolder, + copyName, + }); +} + export async function addWorktree( parentId: string, branch: string, diff --git a/src/styles/clone-modal.css b/src/styles/clone-modal.css new file mode 100644 index 000000000..2867db8f5 --- /dev/null +++ b/src/styles/clone-modal.css @@ -0,0 +1,127 @@ +.clone-modal { + position: fixed; + inset: 0; + z-index: 40; +} + +.clone-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.65); +} + +.clone-modal-card { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(520px, calc(100vw - 48px)); + background: rgba(8, 10, 16, 0.96); + border: 1px solid var(--border-subtle); + border-radius: 16px; + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); +} + +.clone-modal-title { + font-size: 16px; + font-weight: 600; + color: var(--text-strong); +} + +.clone-modal-subtitle { + font-size: 13px; + color: var(--text-subtle); +} + +.clone-modal-label { + font-size: 12px; + color: var(--text-faint); +} + +.clone-modal-input { + border-radius: 10px; + border: 1px solid var(--border-subtle); + background: var(--surface-card-muted); + color: var(--text-strong); + padding: 10px 12px; + font-size: 13px; + width: 100%; +} + +.clone-modal-input:focus { + outline: 2px solid rgba(77, 163, 255, 0.35); + outline-offset: 1px; +} + +.clone-modal-input--path { + resize: none; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.clone-modal-input--path::-webkit-scrollbar { + width: 0; + height: 0; +} + +.clone-modal-folder-row { + display: flex; + gap: 8px; + align-items: center; +} + +.clone-modal-suggested { + display: flex; + flex-direction: column; + gap: 6px; +} + +.clone-modal-suggested-label { + font-size: 11px; + color: var(--text-faint); +} + +.clone-modal-suggested-row { + display: flex; + gap: 8px; + align-items: center; +} + +.clone-modal-suggested-path { + flex: 1; + min-width: 0; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border-subtle); + background: var(--surface-card-muted); + color: var(--text-subtle); + font-size: 12px; +} + +.clone-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + +.clone-modal-error { + font-size: 12px; + color: #ff8f8f; + background: rgba(255, 79, 79, 0.12); + border: 1px solid rgba(255, 79, 79, 0.4); + padding: 8px 10px; + border-radius: 10px; +} + +.clone-modal-button { + padding: 6px 12px; + border-radius: 10px; + white-space: nowrap; +} diff --git a/src/styles/settings.css b/src/styles/settings.css index acd90ad8b..16b4bff93 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -283,7 +283,7 @@ .settings-group-row { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: 12px; padding: 10px 12px; @@ -292,6 +292,49 @@ background: var(--surface-card); } +.settings-group-fields { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.settings-group-copies { + display: flex; + flex-direction: column; + gap: 6px; +} + +.settings-group-copies-label { + font-size: 11px; + color: var(--text-faint); +} + +.settings-group-copies-row { + display: flex; + align-items: center; + gap: 8px; +} + +.settings-group-copies-path { + flex: 1; + min-width: 0; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid var(--border-muted); + background: var(--surface-control); + color: var(--text-strong); + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.settings-group-copies-path.empty { + color: var(--text-faint); +} + .settings-group-actions { display: inline-flex; align-items: center; diff --git a/src/styles/sidebar.css b/src/styles/sidebar.css index 009ccf9c3..29b8bcf13 100644 --- a/src/styles/sidebar.css +++ b/src/styles/sidebar.css @@ -196,7 +196,7 @@ .workspace-row { display: grid; - grid-template-columns: 1fr auto; + grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 8px 4px; border-radius: 0; @@ -257,6 +257,10 @@ .workspace-name { font-weight: 600; font-size: 14px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } @@ -264,6 +268,7 @@ font-size: 12px; color: var(--text-muted); align-self: center; + flex-shrink: 0; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border-quiet); @@ -305,12 +310,14 @@ align-items: center; gap: 8px; justify-content: space-between; + min-width: 0; } .workspace-title { display: flex; align-items: center; gap: 6px; + min-width: 0; } .workspace-toggle { @@ -602,7 +609,7 @@ .worktree-row { display: grid; - grid-template-columns: 1fr auto; + grid-template-columns: minmax(0, 1fr) auto; gap: 10px; padding: 6px 4px; border-radius: 6px; diff --git a/src/types.ts b/src/types.ts index 3ad56871c..aa2d28529 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,7 @@ export type WorkspaceGroup = { id: string; name: string; sortOrder?: number | null; + copiesFolder?: string | null; }; export type WorkspaceKind = "main" | "worktree"; diff --git a/src/utils/shortcuts.ts b/src/utils/shortcuts.ts index 58b5bde5d..1c33ff851 100644 --- a/src/utils/shortcuts.ts +++ b/src/utils/shortcuts.ts @@ -16,6 +16,7 @@ const MODIFIER_LABELS: Record = { const KEY_LABELS: Record = { " ": "Space", + space: "Space", escape: "Esc", arrowup: "↑", arrowdown: "↓", @@ -94,6 +95,10 @@ export function buildShortcutValue(event: KeyboardEvent): string | null { if (!key) { return null; } + const hasPrimaryModifier = event.metaKey || event.ctrlKey || event.altKey; + if (!hasPrimaryModifier) { + return null; + } const modifiers = []; if (event.metaKey) { modifiers.push("cmd");