From 52f6df3d667e9f0c4edbf2d7416e5dc29fa2b07d Mon Sep 17 00:00:00 2001 From: zhoupengwu Date: Wed, 11 Mar 2026 18:42:14 +0800 Subject: [PATCH 1/3] feat(settings): Custom workspace worktree folder can be configured Add custom worktree folder settings at the workspace level, overriding global settings. Add a worktree folder input box and browse button for each project in the settings interface. Update the backend logic to prioritize workspace settings, followed by global settings, and finally the default location. --- .gitignore | 1 + .../src/shared/workspaces_core/worktree.rs | 15 ++++- src-tauri/src/types.rs | 5 ++ .../sections/SettingsEnvironmentsSection.tsx | 59 +++++++++++++++++- .../hooks/useSettingsEnvironmentsSection.ts | 61 +++++++++++-------- src/types.ts | 2 + 6 files changed, 116 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index 05b62d7c2..cebd4d089 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Logs logs *.log +**-lock.** npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/src-tauri/src/shared/workspaces_core/worktree.rs b/src-tauri/src/shared/workspaces_core/worktree.rs index 58d808e12..bd22d5ad6 100644 --- a/src-tauri/src/shared/workspaces_core/worktree.rs +++ b/src-tauri/src/shared/workspaces_core/worktree.rs @@ -134,7 +134,20 @@ where return Err("Cannot create a worktree from another worktree.".to_string()); } - let worktree_root = data_dir.join("worktrees").join(&parent_entry.id); + // Determine worktree root: per-workspace setting > global setting > default + let worktree_root = if let Some(custom_folder) = &parent_entry.settings.worktrees_folder { + PathBuf::from(custom_folder) + } else { + let global_folder = { + let settings = app_settings.lock().await; + settings.global_worktrees_folder.clone() + }; + if let Some(global_folder) = global_folder { + PathBuf::from(global_folder).join(&parent_entry.id) + } else { + data_dir.join("worktrees").join(&parent_entry.id) + } + }; std::fs::create_dir_all(&worktree_root) .map_err(|err| format!("Failed to create worktree directory: {err}"))?; diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index cbb8afbaa..fdc37af43 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -329,6 +329,8 @@ pub(crate) struct WorkspaceSettings { pub(crate) launch_scripts: Option>, #[serde(default, rename = "worktreeSetupScript")] pub(crate) worktree_setup_script: Option, + #[serde(default, rename = "worktreesFolder")] + pub(crate) worktrees_folder: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -635,6 +637,8 @@ pub(crate) struct AppSettings { pub(crate) composer_code_block_copy_use_modifier: bool, #[serde(default = "default_workspace_groups", rename = "workspaceGroups")] pub(crate) workspace_groups: Vec, + #[serde(default, rename = "globalWorktreesFolder")] + pub(crate) global_worktrees_folder: Option, #[serde(default = "default_open_app_targets", rename = "openAppTargets")] pub(crate) open_app_targets: Vec, #[serde(default = "default_selected_open_app_id", rename = "selectedOpenAppId")] @@ -1182,6 +1186,7 @@ impl Default for AppSettings { composer_list_continuation: default_composer_list_continuation(), composer_code_block_copy_use_modifier: default_composer_code_block_copy_use_modifier(), workspace_groups: default_workspace_groups(), + global_worktrees_folder: None, open_app_targets: default_open_app_targets(), selected_open_app_id: default_selected_open_app_id(), } diff --git a/src/features/settings/components/sections/SettingsEnvironmentsSection.tsx b/src/features/settings/components/sections/SettingsEnvironmentsSection.tsx index a04312c67..8f6b253cb 100644 --- a/src/features/settings/components/sections/SettingsEnvironmentsSection.tsx +++ b/src/features/settings/components/sections/SettingsEnvironmentsSection.tsx @@ -11,8 +11,12 @@ type SettingsEnvironmentsSectionProps = { environmentDraftScript: string; environmentSavedScript: string | null; environmentDirty: boolean; + worktreesFolderDraft: string; + worktreesFolderSaved: string | null; + worktreesFolderDirty: boolean; onSetEnvironmentWorkspaceId: Dispatch>; onSetEnvironmentDraftScript: Dispatch>; + onSetWorktreesFolderDraft: Dispatch>; onSaveEnvironmentSetup: () => Promise; }; @@ -24,14 +28,20 @@ export function SettingsEnvironmentsSection({ environmentDraftScript, environmentSavedScript, environmentDirty, + worktreesFolderDraft, + worktreesFolderSaved, + worktreesFolderDirty, onSetEnvironmentWorkspaceId, onSetEnvironmentDraftScript, + onSetWorktreesFolderDraft, onSaveEnvironmentSetup, }: SettingsEnvironmentsSectionProps) { + const hasAnyChanges = environmentDirty || worktreesFolderDirty; + return ( {mainWorkspaces.length === 0 ? (
No projects yet.
@@ -116,12 +126,57 @@ export function SettingsEnvironmentsSection({ onClick={() => { void onSaveEnvironmentSetup(); }} - disabled={environmentSaving || !environmentDirty} + disabled={environmentSaving || !hasAnyChanges} > {environmentSaving ? "Saving..." : "Save"} + +
+ +
+ Custom location for worktrees. Leave empty to use the default location. +
+
+ onSetWorktreesFolderDraft(event.target.value)} + placeholder="/path/to/worktrees" + disabled={environmentSaving} + /> + +
+
)}
diff --git a/src/features/settings/hooks/useSettingsEnvironmentsSection.ts b/src/features/settings/hooks/useSettingsEnvironmentsSection.ts index 118ba5251..9d04f10ec 100644 --- a/src/features/settings/hooks/useSettingsEnvironmentsSection.ts +++ b/src/features/settings/hooks/useSettingsEnvironmentsSection.ts @@ -19,8 +19,12 @@ export type SettingsEnvironmentsSectionProps = { environmentDraftScript: string; environmentSavedScript: string | null; environmentDirty: boolean; + worktreesFolderDraft: string; + worktreesFolderSaved: string | null; + worktreesFolderDirty: boolean; onSetEnvironmentWorkspaceId: Dispatch>; onSetEnvironmentDraftScript: Dispatch>; + onSetWorktreesFolderDraft: Dispatch>; onSaveEnvironmentSetup: () => Promise; }; @@ -28,28 +32,20 @@ export const useSettingsEnvironmentsSection = ({ mainWorkspaces, onUpdateWorkspaceSettings, }: UseSettingsEnvironmentsSectionArgs): SettingsEnvironmentsSectionProps => { - const [environmentWorkspaceId, setEnvironmentWorkspaceId] = useState( - null, - ); + const [environmentWorkspaceId, setEnvironmentWorkspaceId] = useState(null); const [environmentDraftScript, setEnvironmentDraftScript] = useState(""); - const [environmentSavedScript, setEnvironmentSavedScript] = useState( - null, - ); - const [environmentLoadedWorkspaceId, setEnvironmentLoadedWorkspaceId] = useState< - string | null - >(null); + const [environmentSavedScript, setEnvironmentSavedScript] = useState(null); + const [environmentLoadedWorkspaceId, setEnvironmentLoadedWorkspaceId] = useState(null); const [environmentError, setEnvironmentError] = useState(null); const [environmentSaving, setEnvironmentSaving] = useState(false); + const [worktreesFolderDraft, setWorktreesFolderDraft] = useState(""); + const [worktreesFolderSaved, setWorktreesFolderSaved] = useState(null); const environmentWorkspace = useMemo(() => { - if (mainWorkspaces.length === 0) { - return null; - } + if (mainWorkspaces.length === 0) return null; if (environmentWorkspaceId) { const found = mainWorkspaces.find((workspace) => workspace.id === environmentWorkspaceId); - if (found) { - return found; - } + if (found) return found; } return mainWorkspaces[0] ?? null; }, [environmentWorkspaceId, mainWorkspaces]); @@ -58,11 +54,16 @@ export const useSettingsEnvironmentsSection = ({ return normalizeWorktreeSetupScript(environmentWorkspace?.settings.worktreeSetupScript); }, [environmentWorkspace?.settings.worktreeSetupScript]); + const worktreesFolderFromWorkspace = useMemo(() => { + return environmentWorkspace?.settings.worktreesFolder ?? null; + }, [environmentWorkspace?.settings.worktreesFolder]); + const environmentDraftNormalized = useMemo(() => { return normalizeWorktreeSetupScript(environmentDraftScript); }, [environmentDraftScript]); const environmentDirty = environmentDraftNormalized !== environmentSavedScript; + const worktreesFolderDirty = (worktreesFolderDraft.trim() || null) !== worktreesFolderSaved; useEffect(() => { if (!environmentWorkspace) { @@ -72,53 +73,61 @@ export const useSettingsEnvironmentsSection = ({ setEnvironmentDraftScript(""); setEnvironmentError(null); setEnvironmentSaving(false); + setWorktreesFolderDraft(""); + setWorktreesFolderSaved(null); return; } - if (environmentWorkspaceId !== environmentWorkspace.id) { setEnvironmentWorkspaceId(environmentWorkspace.id); } }, [environmentWorkspace, environmentWorkspaceId]); useEffect(() => { - if (!environmentWorkspace) { - return; - } - + if (!environmentWorkspace) return; if (environmentLoadedWorkspaceId !== environmentWorkspace.id) { setEnvironmentLoadedWorkspaceId(environmentWorkspace.id); setEnvironmentSavedScript(environmentSavedScriptFromWorkspace); setEnvironmentDraftScript(environmentSavedScriptFromWorkspace ?? ""); + setWorktreesFolderSaved(worktreesFolderFromWorkspace); + setWorktreesFolderDraft(worktreesFolderFromWorkspace ?? ""); setEnvironmentError(null); return; } - if (!environmentDirty && environmentSavedScript !== environmentSavedScriptFromWorkspace) { setEnvironmentSavedScript(environmentSavedScriptFromWorkspace); setEnvironmentDraftScript(environmentSavedScriptFromWorkspace ?? ""); setEnvironmentError(null); } + if (!worktreesFolderDirty && worktreesFolderSaved !== worktreesFolderFromWorkspace) { + setWorktreesFolderSaved(worktreesFolderFromWorkspace); + setWorktreesFolderDraft(worktreesFolderFromWorkspace ?? ""); + } }, [ environmentDirty, environmentLoadedWorkspaceId, environmentSavedScript, environmentSavedScriptFromWorkspace, environmentWorkspace, + worktreesFolderDirty, + worktreesFolderFromWorkspace, + worktreesFolderSaved, ]); const handleSaveEnvironmentSetup = async () => { - if (!environmentWorkspace || environmentSaving) { - return; - } + if (!environmentWorkspace || environmentSaving) return; const nextScript = environmentDraftNormalized; + const nextFolder = worktreesFolderDraft.trim() || null; setEnvironmentSaving(true); setEnvironmentError(null); try { await onUpdateWorkspaceSettings(environmentWorkspace.id, { worktreeSetupScript: nextScript, + worktreesFolder: nextFolder, }); setEnvironmentSavedScript(nextScript); setEnvironmentDraftScript(nextScript ?? ""); + setWorktreesFolderSaved(nextFolder); + setWorktreesFolderDraft(nextFolder ?? ""); } catch (error) { setEnvironmentError(error instanceof Error ? error.message : String(error)); } finally { @@ -134,8 +143,12 @@ export const useSettingsEnvironmentsSection = ({ environmentDraftScript, environmentSavedScript, environmentDirty, + worktreesFolderDraft, + worktreesFolderSaved, + worktreesFolderDirty, onSetEnvironmentWorkspaceId: setEnvironmentWorkspaceId, onSetEnvironmentDraftScript: setEnvironmentDraftScript, + onSetWorktreesFolderDraft: setWorktreesFolderDraft, onSaveEnvironmentSetup: handleSaveEnvironmentSetup, }; }; diff --git a/src/types.ts b/src/types.ts index aa68a1eb0..c980474e1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,7 @@ export type WorkspaceSettings = { launchScript?: string | null; launchScripts?: LaunchScriptEntry[] | null; worktreeSetupScript?: string | null; + worktreesFolder?: string | null; }; export type LaunchScriptIconId = @@ -303,6 +304,7 @@ export type AppSettings = { composerListContinuation: boolean; composerCodeBlockCopyUseModifier: boolean; workspaceGroups: WorkspaceGroup[]; + globalWorktreesFolder: string | null; openAppTargets: OpenAppTarget[]; selectedOpenAppId: string; }; From dda908daf7b515ff1cea1e538f1ca20de156d6ce Mon Sep 17 00:00:00 2001 From: zhoupengwu Date: Wed, 11 Mar 2026 18:55:46 +0800 Subject: [PATCH 2/3] fix(worktree): When renaming a worktree, use the correct path to the worktrees folder When renaming a worktree, the priority logic now follows the same as add_worktree_core: first use the workspace-specific settings, then use the global settings, and finally fallback to the default path. This ensures that the renaming operation uses the same storage location as the adding operation. --- .../src/shared/workspaces_core/worktree.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/shared/workspaces_core/worktree.rs b/src-tauri/src/shared/workspaces_core/worktree.rs index bd22d5ad6..fe86207d4 100644 --- a/src-tauri/src/shared/workspaces_core/worktree.rs +++ b/src-tauri/src/shared/workspaces_core/worktree.rs @@ -346,7 +346,7 @@ pub(crate) async fn rename_worktree_core< data_dir: &PathBuf, workspaces: &Mutex>, sessions: &Mutex>>, - _app_settings: &Mutex, + app_settings: &Mutex, storage_path: &PathBuf, resolve_git_root: FResolveGitRoot, unique_branch_name: FUniqueBranch, @@ -408,7 +408,21 @@ where run_git_command(&parent_root, &["branch", "-m", &old_branch, &final_branch]).await?; - let worktree_root = data_dir.join("worktrees").join(&parent.id); + // Use the same priority logic as add_worktree_core: + // per-workspace setting > global setting > default + let worktree_root = if let Some(custom_folder) = &parent.settings.worktrees_folder { + PathBuf::from(custom_folder) + } else { + let global_folder = { + let settings = app_settings.lock().await; + settings.global_worktrees_folder.clone() + }; + if let Some(global_folder) = global_folder { + PathBuf::from(global_folder).join(&parent.id) + } else { + data_dir.join("worktrees").join(&parent.id) + } + }; std::fs::create_dir_all(&worktree_root) .map_err(|err| format!("Failed to create worktree directory: {err}"))?; From 40035c7bf0a91927e9ecc4b9eae48d156be981b6 Mon Sep 17 00:00:00 2001 From: zhoupengwu Date: Thu, 12 Mar 2026 20:16:43 +0800 Subject: [PATCH 3/3] fix(worktree):Before renaming, verify the root directory of the working tree. Fixed the issue where the validity of the parent repository's working tree directory was not verified during renaming of the working tree. Now, the directory will be checked for existence and accessibility first, and the operation will fail early if it is invalid, to avoid inconsistencies in the state if the working tree fails to move after the branch has been renamed. New test cases have been added to verify this scenario. --- .../src/shared/workspaces_core/worktree.rs | 82 ++++++++++++----- src-tauri/src/workspaces/tests.rs | 92 ++++++++++++++++++- 2 files changed, 149 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/shared/workspaces_core/worktree.rs b/src-tauri/src/shared/workspaces_core/worktree.rs index fe86207d4..222111596 100644 --- a/src-tauri/src/shared/workspaces_core/worktree.rs +++ b/src-tauri/src/shared/workspaces_core/worktree.rs @@ -406,8 +406,6 @@ where return Err("Branch name is unchanged.".to_string()); } - run_git_command(&parent_root, &["branch", "-m", &old_branch, &final_branch]).await?; - // Use the same priority logic as add_worktree_core: // per-workspace setting > global setting > default let worktree_root = if let Some(custom_folder) = &parent.settings.worktrees_folder { @@ -430,10 +428,15 @@ where let current_path = PathBuf::from(&entry.path); let next_path = unique_worktree_path_for_rename(&worktree_root, &safe_name, ¤t_path)?; let next_path_string = next_path.to_string_lossy().to_string(); - if next_path_string != entry.path { + let old_path_string = entry.path.clone(); + + run_git_command(&parent_root, &["branch", "-m", &old_branch, &final_branch]).await?; + + let mut moved_worktree = false; + if next_path_string != old_path_string { if let Err(error) = run_git_command( &parent_root, - &["worktree", "move", &entry.path, &next_path_string], + &["worktree", "move", &old_path_string, &next_path_string], ) .await { @@ -441,33 +444,64 @@ where run_git_command(&parent_root, &["branch", "-m", &final_branch, &old_branch]).await; return Err(error); } + moved_worktree = true; } - let (entry_snapshot, list) = { + let update_result: Result<(WorkspaceEntry, WorkspaceEntry, Vec), String> = { let mut workspaces = workspaces.lock().await; - let entry = match workspaces.get_mut(&id) { - Some(entry) => entry, - None => return Err("workspace not found".to_string()), - }; - if entry.name.trim() == old_branch { - entry.name = final_branch.clone(); - } - entry.path = next_path_string.clone(); - match entry.worktree.as_mut() { - Some(worktree) => { - worktree.branch = final_branch.clone(); + if let Some(entry) = workspaces.get_mut(&id) { + let old_snapshot = entry.clone(); + if entry.name.trim() == old_branch { + entry.name = final_branch.clone(); } - None => { - entry.worktree = Some(WorktreeInfo { - branch: final_branch.clone(), - }); + entry.path = next_path_string.clone(); + match entry.worktree.as_mut() { + Some(worktree) => { + worktree.branch = final_branch.clone(); + } + None => { + entry.worktree = Some(WorktreeInfo { + branch: final_branch.clone(), + }); + } } + let snapshot = entry.clone(); + let list: Vec<_> = workspaces.values().cloned().collect(); + Ok((old_snapshot, snapshot, list)) + } else { + Err("workspace not found".to_string()) } - let snapshot = entry.clone(); - let list: Vec<_> = workspaces.values().cloned().collect(); - (snapshot, list) }; - write_workspaces(storage_path, &list)?; + let (old_snapshot, entry_snapshot, list) = match update_result { + Ok(value) => value, + Err(error) => { + if moved_worktree { + let _ = run_git_command( + &parent_root, + &["worktree", "move", &next_path_string, &old_path_string], + ) + .await; + } + let _ = + run_git_command(&parent_root, &["branch", "-m", &final_branch, &old_branch]).await; + return Err(error); + } + }; + if let Err(error) = write_workspaces(storage_path, &list) { + if moved_worktree { + let _ = run_git_command( + &parent_root, + &["worktree", "move", &next_path_string, &old_path_string], + ) + .await; + } + let _ = run_git_command(&parent_root, &["branch", "-m", &final_branch, &old_branch]).await; + let mut workspaces = workspaces.lock().await; + if let Some(entry) = workspaces.get_mut(&id) { + *entry = old_snapshot; + } + return Err(error); + } if let Some(session) = sessions.lock().await.get(&entry_snapshot.id).cloned() { session diff --git a/src-tauri/src/workspaces/tests.rs b/src-tauri/src/workspaces/tests.rs index 04fe3ff19..01311ae70 100644 --- a/src-tauri/src/workspaces/tests.rs +++ b/src-tauri/src/workspaces/tests.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::future::Future; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex as StdMutex}; use super::settings::{apply_workspace_settings_update, sort_workspaces}; use super::worktree::{ @@ -56,6 +56,7 @@ fn workspace_with_id_and_kind( launch_script: None, launch_scripts: None, worktree_setup_script: None, + worktrees_folder: None, }, } } @@ -390,6 +391,95 @@ fn rename_worktree_updates_name_when_unmodified() { }); } +#[test] +fn rename_worktree_validates_worktree_root_before_branch_rename() { + run_async(async { + let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); + let repo_path = temp_dir.join("repo"); + std::fs::create_dir_all(&repo_path).expect("create repo path"); + let worktree_path = temp_dir.join("worktrees").join("parent").join("old"); + std::fs::create_dir_all(&worktree_path).expect("create worktree path"); + + let invalid_root = temp_dir.join("not-a-directory"); + std::fs::write(&invalid_root, "x").expect("create invalid root file"); + + let mut parent_settings = WorkspaceSettings::default(); + parent_settings.worktrees_folder = Some(invalid_root.to_string_lossy().to_string()); + let parent = WorkspaceEntry { + id: "parent".to_string(), + name: "Parent".to_string(), + path: repo_path.to_string_lossy().to_string(), + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: parent_settings, + }; + let worktree = WorkspaceEntry { + id: "wt-3".to_string(), + name: "feature/old".to_string(), + path: worktree_path.to_string_lossy().to_string(), + kind: WorkspaceKind::Worktree, + parent_id: Some(parent.id.clone()), + worktree: Some(WorktreeInfo { + branch: "feature/old".to_string(), + }), + settings: WorkspaceSettings::default(), + }; + let workspaces = Mutex::new(HashMap::from([ + (parent.id.clone(), parent.clone()), + (worktree.id.clone(), worktree.clone()), + ])); + let sessions: Mutex>> = Mutex::new(HashMap::new()); + let app_settings = Mutex::new(AppSettings::default()); + let storage_path = temp_dir.join("workspaces.json"); + + let calls: Arc>>> = Arc::new(StdMutex::new(Vec::new())); + let result = rename_worktree_core( + worktree.id.clone(), + "feature/new".to_string(), + &temp_dir, + &workspaces, + &sessions, + &app_settings, + &storage_path, + |_| Ok(repo_path.clone()), + |_root, branch| { + let branch = branch.to_string(); + async move { Ok(branch) } + }, + |value| sanitize_worktree_name(value), + |_, _, current| Ok(current.to_path_buf()), + |_root, args| { + let calls = calls.clone(); + let args: Vec = args.iter().map(|value| value.to_string()).collect(); + async move { + calls + .lock() + .expect("lock") + .push(args); + Ok(()) + } + }, + |_entry, _default_bin, _codex_args, _codex_home| async move { + Err("spawn not expected".to_string()) + }, + ) + .await; + + let error = result.expect_err("expected invalid worktree root to fail"); + assert!(error.contains("Failed to create worktree directory")); + assert!(calls.lock().expect("lock").is_empty()); + + let stored = workspaces.lock().await; + let entry = stored.get(&worktree.id).expect("stored entry"); + assert_eq!( + entry.worktree.as_ref().map(|worktree| worktree.branch.as_str()), + Some("feature/old") + ); + assert_eq!(entry.path, worktree.path); + }); +} + #[test] fn remove_workspace_succeeds_when_parent_repo_folder_is_missing() { run_async(async {