diff --git a/src-tauri/src/shared/settings_core.rs b/src-tauri/src/shared/settings_core.rs index 211fcf666..e4d949075 100644 --- a/src-tauri/src/shared/settings_core.rs +++ b/src-tauri/src/shared/settings_core.rs @@ -5,6 +5,7 @@ use tokio::sync::Mutex; use crate::codex::config as codex_config; use crate::storage::write_settings; use crate::types::AppSettings; +use crate::utils::normalize_windows_namespace_path; fn normalize_personality(value: &str) -> Option<&'static str> { match value.trim() { @@ -40,10 +41,13 @@ pub(crate) async fn get_app_settings_core(app_settings: &Mutex) -> } pub(crate) async fn update_app_settings_core( - settings: AppSettings, + mut settings: AppSettings, app_settings: &Mutex, settings_path: &PathBuf, ) -> Result { + settings.global_worktrees_folder = settings + .global_worktrees_folder + .map(|path| normalize_windows_namespace_path(&path)); let _ = codex_config::write_collaboration_modes_enabled(settings.collaboration_modes_enabled); let _ = codex_config::write_steer_enabled(settings.steer_enabled); let _ = codex_config::write_unified_exec_enabled(settings.unified_exec_enabled); diff --git a/src-tauri/src/shared/workspaces_core/crud_persistence.rs b/src-tauri/src/shared/workspaces_core/crud_persistence.rs index 7df26ce98..633b078f0 100644 --- a/src-tauri/src/shared/workspaces_core/crud_persistence.rs +++ b/src-tauri/src/shared/workspaces_core/crud_persistence.rs @@ -13,9 +13,12 @@ use crate::shared::process_core::kill_child_process_tree; use crate::shared::{git_core, worktree_core}; use crate::storage::write_workspaces; use crate::types::{AppSettings, WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings}; +use crate::utils::normalize_windows_namespace_path; use super::connect::{kill_session_by_id, take_live_shared_session, workspace_session_spawn_lock}; -use super::helpers::{normalize_setup_script, normalize_workspace_path_input}; +use super::helpers::{ + normalize_setup_script, normalize_workspace_path_input, workspace_path_to_string, +}; pub(crate) async fn add_workspace_core( path: String, @@ -33,7 +36,7 @@ where if !normalized_path.is_dir() { return Err("Workspace path must be a folder.".to_string()); } - let path = normalized_path.to_string_lossy().to_string(); + let path = workspace_path_to_string(&normalized_path); let name = PathBuf::from(&path) .file_name() @@ -154,6 +157,7 @@ where let destination_path = worktree_core::build_clone_destination_path(&copies_folder_path, ©_name); let destination_path_string = destination_path.to_string_lossy().to_string(); + let stored_destination_path = workspace_path_to_string(&destination_path); if let Err(error) = git_core::run_git_command( &copies_folder_path, @@ -189,7 +193,7 @@ where let entry = WorkspaceEntry { id: Uuid::new_v4().to_string(), name: copy_name, - path: destination_path_string, + path: stored_destination_path, kind: WorkspaceKind::Main, parent_id: None, worktree: None, @@ -342,6 +346,7 @@ where } let clone_path_string = clone_path.to_string_lossy().to_string(); + let stored_clone_path = workspace_path_to_string(&clone_path); if let Err(error) = git_core::run_git_command(&destination_parent, &["clone", &url, &clone_path_string]).await { @@ -357,7 +362,7 @@ where let entry = WorkspaceEntry { id: Uuid::new_v4().to_string(), name: workspace_name, - path: clone_path_string, + path: stored_clone_path, kind: WorkspaceKind::Main, parent_id: None, worktree: None, @@ -553,6 +558,9 @@ where FutSpawn: Future, String>>, { settings.worktree_setup_script = normalize_setup_script(settings.worktree_setup_script); + settings.worktrees_folder = settings + .worktrees_folder + .map(|path| normalize_windows_namespace_path(&path)); let (entry_snapshot, previous_worktree_setup_script, child_entries) = { let mut workspaces = workspaces.lock().await; diff --git a/src-tauri/src/shared/workspaces_core/helpers.rs b/src-tauri/src/shared/workspaces_core/helpers.rs index f4f67ddc0..da4e00de0 100644 --- a/src-tauri/src/shared/workspaces_core/helpers.rs +++ b/src-tauri/src/shared/workspaces_core/helpers.rs @@ -6,6 +6,7 @@ use tokio::sync::Mutex; use crate::backend::app_server::WorkspaceSession; use crate::types::{WorkspaceEntry, WorkspaceInfo}; +use crate::utils::normalize_windows_namespace_path; pub(crate) const WORKTREE_SETUP_MARKERS_DIR: &str = "worktree-setup"; pub(crate) const WORKTREE_SETUP_MARKER_EXT: &str = "ran"; @@ -81,6 +82,10 @@ pub(crate) fn normalize_workspace_path_input(path: &str) -> PathBuf { PathBuf::from(trimmed) } +pub(crate) fn workspace_path_to_string(path: &PathBuf) -> String { + normalize_windows_namespace_path(&path.to_string_lossy()) +} + pub(crate) async fn list_workspaces_core( workspaces: &Mutex>, sessions: &Mutex>>, @@ -147,7 +152,8 @@ pub(super) fn sort_workspaces(workspaces: &mut [WorkspaceInfo]) { #[cfg(test)] mod tests { use super::{ - copy_agents_md_from_parent_to_worktree, normalize_workspace_path_input, AGENTS_MD_FILE_NAME, + copy_agents_md_from_parent_to_worktree, normalize_workspace_path_input, + workspace_path_to_string, AGENTS_MD_FILE_NAME, }; use std::path::PathBuf; use std::sync::Mutex; @@ -217,4 +223,12 @@ mod tests { None => std::env::remove_var("HOME"), } } + + #[test] + fn workspace_path_to_string_strips_windows_namespace_prefixes() { + assert_eq!( + workspace_path_to_string(&PathBuf::from(r"\\?\I:\gpt-projects\CodexMonitor")), + r"I:\gpt-projects\CodexMonitor" + ); + } } diff --git a/src-tauri/src/shared/workspaces_core/worktree.rs b/src-tauri/src/shared/workspaces_core/worktree.rs index 222111596..824e4aecd 100644 --- a/src-tauri/src/shared/workspaces_core/worktree.rs +++ b/src-tauri/src/shared/workspaces_core/worktree.rs @@ -17,8 +17,8 @@ use crate::types::{ use super::connect::{kill_session_by_id, take_live_shared_session, workspace_session_spawn_lock}; use super::helpers::{ - copy_agents_md_from_parent_to_worktree, normalize_setup_script, worktree_setup_marker_path, - AGENTS_MD_FILE_NAME, + copy_agents_md_from_parent_to_worktree, normalize_setup_script, workspace_path_to_string, + worktree_setup_marker_path, AGENTS_MD_FILE_NAME, }; pub(crate) async fn worktree_setup_status_core( @@ -136,14 +136,15 @@ where // 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) + PathBuf::from(workspace_path_to_string(&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) + PathBuf::from(workspace_path_to_string(&PathBuf::from(global_folder))) + .join(&parent_entry.id) } else { data_dir.join("worktrees").join(&parent_entry.id) } @@ -154,6 +155,7 @@ where let safe_name = sanitize_worktree_name(&branch); let worktree_path = unique_worktree_path(&worktree_root, &safe_name)?; let worktree_path_string = worktree_path.to_string_lossy().to_string(); + let stored_worktree_path = workspace_path_to_string(&worktree_path); let repo_path = PathBuf::from(&parent_entry.path); let branch_exists = git_branch_exists(&repo_path, &branch).await?; @@ -206,7 +208,7 @@ where let entry = WorkspaceEntry { id: Uuid::new_v4().to_string(), name: name.clone().unwrap_or_else(|| branch.clone()), - path: worktree_path_string, + path: stored_worktree_path, kind: WorkspaceKind::Worktree, parent_id: Some(parent_entry.id.clone()), worktree: Some(WorktreeInfo { branch }), @@ -409,14 +411,14 @@ where // 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) + PathBuf::from(workspace_path_to_string(&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) + PathBuf::from(workspace_path_to_string(&PathBuf::from(global_folder))).join(&parent.id) } else { data_dir.join("worktrees").join(&parent.id) } @@ -428,12 +430,13 @@ 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(); + let stored_next_path = workspace_path_to_string(&next_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 stored_next_path != old_path_string { if let Err(error) = run_git_command( &parent_root, &["worktree", "move", &old_path_string, &next_path_string], @@ -454,7 +457,7 @@ where if entry.name.trim() == old_branch { entry.name = final_branch.clone(); } - entry.path = next_path_string.clone(); + entry.path = stored_next_path.clone(); match entry.worktree.as_mut() { Some(worktree) => { worktree.branch = final_branch.clone(); diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index c29b9058c..efdb35d4c 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -1,15 +1,141 @@ use std::collections::HashMap; use std::path::PathBuf; -use crate::types::{AppSettings, WorkspaceEntry}; +use crate::types::{AppSettings, WorkspaceEntry, WorkspaceSettings}; use serde_json::Value; +fn normalize_windows_namespace_path(path: &str) -> String { + if path.is_empty() { + return String::new(); + } + + fn strip_prefix_ascii_case<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { + value + .get(..prefix.len()) + .filter(|candidate| candidate.eq_ignore_ascii_case(prefix)) + .map(|_| &value[prefix.len()..]) + } + + fn starts_with_drive_path(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && (bytes[2] == b'\\' || bytes[2] == b'/') + } + + if let Some(rest) = strip_prefix_ascii_case(path, r"\\?\UNC\") { + return format!(r"\\{rest}"); + } + if let Some(rest) = strip_prefix_ascii_case(path, "//?/UNC/") { + return format!("//{rest}"); + } + if let Some(rest) = + strip_prefix_ascii_case(path, r"\\?\").filter(|rest| starts_with_drive_path(rest)) + { + return rest.to_string(); + } + if let Some(rest) = + strip_prefix_ascii_case(path, "//?/").filter(|rest| starts_with_drive_path(rest)) + { + return rest.to_string(); + } + if let Some(rest) = + strip_prefix_ascii_case(path, r"\\.\").filter(|rest| starts_with_drive_path(rest)) + { + return rest.to_string(); + } + if let Some(rest) = + strip_prefix_ascii_case(path, "//./").filter(|rest| starts_with_drive_path(rest)) + { + return rest.to_string(); + } + + path.to_string() +} + +fn normalize_optional_windows_namespace_path(path: Option) -> (Option, bool) { + match path { + Some(path) => { + let normalized = normalize_windows_namespace_path(&path); + let changed = normalized != path; + (Some(normalized), changed) + } + None => (None, false), + } +} + +fn normalize_workspace_settings(settings: WorkspaceSettings) -> (WorkspaceSettings, bool) { + let (worktrees_folder, changed) = + normalize_optional_windows_namespace_path(settings.worktrees_folder.clone()); + ( + WorkspaceSettings { + worktrees_folder, + ..settings + }, + changed, + ) +} + +fn normalize_workspace_entry(entry: WorkspaceEntry) -> (WorkspaceEntry, bool) { + let normalized_path = normalize_windows_namespace_path(&entry.path); + let (settings, settings_changed) = normalize_workspace_settings(entry.settings.clone()); + let changed = normalized_path != entry.path || settings_changed; + ( + WorkspaceEntry { + path: normalized_path, + settings, + ..entry + }, + changed, + ) +} + +fn normalize_workspace_entries(entries: I) -> (Vec, bool) +where + I: IntoIterator, +{ + let mut changed = false; + let normalized = entries + .into_iter() + .map(|entry| { + let (entry, entry_changed) = normalize_workspace_entry(entry); + changed |= entry_changed; + entry + }) + .collect(); + (normalized, changed) +} + +fn normalize_app_settings(settings: AppSettings) -> (AppSettings, bool) { + let (global_worktrees_folder, changed) = + normalize_optional_windows_namespace_path(settings.global_worktrees_folder.clone()); + ( + AppSettings { + global_worktrees_folder, + ..settings + }, + changed, + ) +} + +fn try_rewrite_settings_with_normalized_paths(path: &PathBuf, settings: &AppSettings) { + if let Err(error) = write_settings(path, settings) { + eprintln!( + "read_settings: failed to persist normalized settings paths to {}: {}", + path.display(), + error + ); + } +} + pub(crate) fn read_workspaces(path: &PathBuf) -> Result, String> { if !path.exists() { return Ok(HashMap::new()); } let data = std::fs::read_to_string(path).map_err(|e| e.to_string())?; let list: Vec = serde_json::from_str(&data).map_err(|e| e.to_string())?; + let (list, _) = normalize_workspace_entries(list); Ok(list .into_iter() .map(|entry| (entry.id.clone(), entry)) @@ -20,7 +146,8 @@ pub(crate) fn write_workspaces(path: &PathBuf, entries: &[WorkspaceEntry]) -> Re if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } - let data = serde_json::to_string_pretty(entries).map_err(|e| e.to_string())?; + let (entries, _) = normalize_workspace_entries(entries.iter().cloned()); + let data = serde_json::to_string_pretty(&entries).map_err(|e| e.to_string())?; std::fs::write(path, data).map_err(|e| e.to_string()) } @@ -32,11 +159,13 @@ pub(crate) fn read_settings(path: &PathBuf) -> Result { let mut value: Value = serde_json::from_str(&data).map_err(|e| e.to_string())?; migrate_follow_up_message_behavior(&mut value); match serde_json::from_value(value.clone()) { - Ok(settings) => Ok(settings), + Ok(settings) => Ok(finalize_loaded_settings(path, settings)), Err(_) => { sanitize_remote_settings_for_tcp_only(&mut value); migrate_follow_up_message_behavior(&mut value); - serde_json::from_value(value).map_err(|e| e.to_string()) + serde_json::from_value(value) + .map(|settings| finalize_loaded_settings(path, settings)) + .map_err(|e| e.to_string()) } } } @@ -45,10 +174,19 @@ pub(crate) fn write_settings(path: &PathBuf, settings: &AppSettings) -> Result<( if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } - let data = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?; + let (settings, _) = normalize_app_settings(settings.clone()); + let data = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; std::fs::write(path, data).map_err(|e| e.to_string()) } +fn finalize_loaded_settings(path: &PathBuf, settings: AppSettings) -> AppSettings { + let (settings, changed) = normalize_app_settings(settings); + if changed { + try_rewrite_settings_with_normalized_paths(path, &settings); + } + settings +} + fn sanitize_remote_settings_for_tcp_only(value: &mut Value) { let Value::Object(root) = value else { return; @@ -94,8 +232,8 @@ fn migrate_follow_up_message_behavior(value: &mut Value) { #[cfg(test)] mod tests { - use super::{read_settings, read_workspaces, write_workspaces}; - use crate::types::{WorkspaceEntry, WorkspaceKind, WorkspaceSettings}; + use super::{read_settings, read_workspaces, write_settings, write_workspaces}; + use crate::types::{AppSettings, WorkspaceEntry, WorkspaceKind, WorkspaceSettings}; use uuid::Uuid; #[test] @@ -130,7 +268,7 @@ mod tests { } #[test] - fn write_read_workspaces_preserves_windows_namespace_paths() { + fn write_read_workspaces_sanitizes_windows_namespace_paths() { let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); std::fs::create_dir_all(&temp_dir).expect("create temp dir"); let path = temp_dir.join("workspaces.json"); @@ -149,7 +287,40 @@ mod tests { let read = read_workspaces(&path).expect("read workspaces"); let stored = read.get("w1").expect("stored workspace"); - assert_eq!(stored.path, r"\\?\I:\gpt-projects\json-composer"); + assert_eq!(stored.path, r"I:\gpt-projects\json-composer"); + } + + #[test] + fn read_workspaces_sanitizes_namespace_paths_without_rewriting_file() { + let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("workspaces.json"); + + std::fs::write( + &path, + r#"[ + { + "id": "w1", + "name": "Workspace", + "path": "\\\\?\\I:\\gpt-projects\\json-composer", + "kind": "main", + "parentId": null, + "worktree": null, + "settings": {} + } +]"#, + ) + .expect("write workspaces"); + + let read = read_workspaces(&path).expect("read workspaces"); + let stored = read.get("w1").expect("stored workspace"); + assert_eq!(stored.path, r"I:\gpt-projects\json-composer"); + + let persisted = std::fs::read_to_string(&path).expect("read persisted workspaces"); + let persisted_entries: Vec = + serde_json::from_str(&persisted).expect("deserialize persisted workspaces"); + assert_eq!(persisted_entries.len(), 1); + assert_eq!(persisted_entries[0].path, r"\\?\I:\gpt-projects\json-composer"); } #[test] @@ -232,6 +403,53 @@ mod tests { assert_eq!(settings.follow_up_message_behavior, "queue"); } + #[test] + fn write_read_settings_sanitizes_global_worktrees_folder_namespace_paths() { + let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("settings.json"); + + let mut settings = AppSettings::default(); + settings.global_worktrees_folder = Some(r"\\?\I:\gpt-projects\worktrees".to_string()); + + write_settings(&path, &settings).expect("write settings"); + let read = read_settings(&path).expect("read settings"); + assert_eq!( + read.global_worktrees_folder.as_deref(), + Some(r"I:\gpt-projects\worktrees") + ); + } + + #[test] + fn read_settings_rewrites_global_worktrees_folder_namespace_paths() { + let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("settings.json"); + + std::fs::write( + &path, + r#"{ + "globalWorktreesFolder": "\\\\?\\I:\\gpt-projects\\worktrees", + "theme": "dark" +}"#, + ) + .expect("write settings"); + + let settings = read_settings(&path).expect("read settings"); + assert_eq!( + settings.global_worktrees_folder.as_deref(), + Some(r"I:\gpt-projects\worktrees") + ); + + let rewritten = std::fs::read_to_string(&path).expect("read rewritten settings"); + let rewritten_settings: AppSettings = + serde_json::from_str(&rewritten).expect("deserialize rewritten settings"); + assert_eq!( + rewritten_settings.global_worktrees_folder.as_deref(), + Some(r"I:\gpt-projects\worktrees") + ); + } + #[test] fn read_settings_keeps_existing_follow_up_behavior() { let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); diff --git a/src-tauri/src/workspaces/tests.rs b/src-tauri/src/workspaces/tests.rs index 01311ae70..84208dfd1 100644 --- a/src-tauri/src/workspaces/tests.rs +++ b/src-tauri/src/workspaces/tests.rs @@ -6,10 +6,12 @@ use std::sync::{Arc, Mutex as StdMutex}; use super::settings::{apply_workspace_settings_update, sort_workspaces}; use super::worktree::{ build_clone_destination_path, sanitize_clone_dir_name, sanitize_worktree_name, + unique_worktree_path_for_rename, }; use crate::backend::app_server::WorkspaceSession; use crate::shared::workspaces_core::{ remove_workspace_core, remove_worktree_core, rename_worktree_core, + update_workspace_settings_core, }; use crate::storage::{read_workspaces, write_workspaces}; use crate::types::{ @@ -218,6 +220,7 @@ fn update_workspace_settings_persists_sort_and_group() { settings.git_root = Some("/tmp".to_string()); settings.launch_script = Some("npm run dev".to_string()); settings.worktree_setup_script = Some("pnpm install".to_string()); + settings.worktrees_folder = Some(r"\\?\I:\gpt-projects\worktrees".to_string()); let updated = apply_workspace_settings_update(&mut workspaces, &id, settings.clone()).expect("update"); @@ -233,6 +236,10 @@ fn update_workspace_settings_persists_sort_and_group() { updated.settings.worktree_setup_script.as_deref(), Some("pnpm install"), ); + assert_eq!( + updated.settings.worktrees_folder.as_deref(), + Some(r"\\?\I:\gpt-projects\worktrees"), + ); let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); std::fs::create_dir_all(&temp_dir).expect("create temp dir"); @@ -254,6 +261,10 @@ fn update_workspace_settings_persists_sort_and_group() { stored.settings.worktree_setup_script.as_deref(), Some("pnpm install"), ); + assert_eq!( + stored.settings.worktrees_folder.as_deref(), + Some(r"I:\gpt-projects\worktrees"), + ); } #[test] @@ -453,10 +464,7 @@ fn rename_worktree_validates_worktree_root_before_branch_rename() { let calls = calls.clone(); let args: Vec = args.iter().map(|value| value.to_string()).collect(); async move { - calls - .lock() - .expect("lock") - .push(args); + calls.lock().expect("lock").push(args); Ok(()) } }, @@ -473,13 +481,148 @@ fn rename_worktree_validates_worktree_root_before_branch_rename() { 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()), + entry + .worktree + .as_ref() + .map(|worktree| worktree.branch.as_str()), Some("feature/old") ); assert_eq!(entry.path, worktree.path); }); } +#[test] +#[cfg(target_os = "windows")] +fn update_workspace_settings_core_sanitizes_namespace_worktrees_folder() { + run_async(async { + let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let storage_path = temp_dir.join("workspaces.json"); + + let id = "workspace-1".to_string(); + let entry = WorkspaceEntry { + id: id.clone(), + name: "Workspace".to_string(), + path: temp_dir.join("repo").to_string_lossy().to_string(), + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + }; + let workspaces = Mutex::new(HashMap::from([(id.clone(), entry)])); + let sessions: Mutex>> = Mutex::new(HashMap::new()); + let app_settings = Mutex::new(AppSettings::default()); + + let mut settings = WorkspaceSettings::default(); + settings.worktrees_folder = Some(r"\\?\I:\gpt-projects\worktrees".to_string()); + + let updated = update_workspace_settings_core( + id.clone(), + settings, + &workspaces, + &sessions, + &app_settings, + &storage_path, + apply_workspace_settings_update, + |_entry, _default_bin, _codex_args, _codex_home| async move { + Err("spawn not expected".to_string()) + }, + ) + .await + .expect("update workspace settings"); + + assert_eq!( + updated.settings.worktrees_folder.as_deref(), + Some(r"I:\gpt-projects\worktrees") + ); + }); +} + +#[test] +#[cfg(target_os = "windows")] +fn rename_worktree_ignores_namespace_only_difference_in_worktree_root() { + 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_root = temp_dir.join("worktrees").join("parent"); + std::fs::create_dir_all(&worktree_root).expect("create worktree root"); + let current_path = worktree_root.join("feature-new"); + std::fs::create_dir_all(¤t_path).expect("create current worktree path"); + + let mut parent_settings = WorkspaceSettings::default(); + parent_settings.worktrees_folder = + Some(format!(r"\\?\{}", worktree_root.to_string_lossy())); + 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-namespace".to_string(), + name: "feature/new".to_string(), + path: current_path.to_string_lossy().to_string(), + kind: WorkspaceKind::Worktree, + parent_id: Some(parent.id.clone()), + worktree: Some(WorktreeInfo { + branch: "feature/new".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 updated = 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), + |base_dir, name, current| unique_worktree_path_for_rename(base_dir, name, current), + |_root, args| { + let args: Vec = args.iter().map(|value| value.to_string()).collect(); + let calls = calls.clone(); + 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 + .expect("rename worktree"); + + let recorded_calls = calls.lock().expect("lock"); + assert!(recorded_calls + .iter() + .all( + |args| !(args.get(0).map(|value| value.as_str()) == Some("worktree") + && args.get(1).map(|value| value.as_str()) == Some("move")) + )); + assert_eq!(updated.path, current_path.to_string_lossy().to_string()); + }); +} + #[test] fn remove_workspace_succeeds_when_parent_repo_folder_is_missing() { run_async(async { diff --git a/src/features/workspaces/hooks/useWorkspaceCrud.ts b/src/features/workspaces/hooks/useWorkspaceCrud.ts index f2fe84bd5..07158f821 100644 --- a/src/features/workspaces/hooks/useWorkspaceCrud.ts +++ b/src/features/workspaces/hooks/useWorkspaceCrud.ts @@ -2,6 +2,7 @@ import { useCallback } from "react"; import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import * as Sentry from "@sentry/react"; import type { DebugEntry, WorkspaceInfo, WorkspaceSettings } from "../../../types"; +import { normalizeRootPath } from "../../threads/utils/threadNormalize"; import { addWorkspace as addWorkspaceService, addWorkspaceFromGitUrl as addWorkspaceFromGitUrlService, @@ -35,7 +36,7 @@ export type AddWorkspacesFromPathsResult = { }; function normalizeWorkspacePathKey(value: string) { - return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); + return normalizeRootPath(value.trim()); } function inferHomePrefixes(paths: string[]): string[] { diff --git a/src/features/workspaces/hooks/useWorkspaces.test.tsx b/src/features/workspaces/hooks/useWorkspaces.test.tsx index d922afe39..dd06b4166 100644 --- a/src/features/workspaces/hooks/useWorkspaces.test.tsx +++ b/src/features/workspaces/hooks/useWorkspaces.test.tsx @@ -357,6 +357,82 @@ describe("useWorkspaces.addWorkspacesFromPaths", () => { expect(addResult!.failures).toHaveLength(0); }); + it("treats Windows namespace paths as duplicates of existing workspace roots", async () => { + const listWorkspacesMock = vi.mocked(listWorkspaces); + const isWorkspacePathDirMock = vi.mocked(isWorkspacePathDir); + const addWorkspaceMock = vi.mocked(addWorkspace); + + listWorkspacesMock.mockResolvedValue([ + { + ...workspaceOne, + id: "existing-win", + path: "I:\\gpt-projects\\CodexMonitor", + }, + ]); + isWorkspacePathDirMock.mockResolvedValue(true); + + const { result } = renderHook(() => useWorkspaces()); + + await act(async () => { + await Promise.resolve(); + }); + + let addResult: Awaited>; + await act(async () => { + addResult = await result.current.addWorkspacesFromPaths([ + "\\\\?\\I:\\gpt-projects\\CodexMonitor", + ]); + }); + + expect(isWorkspacePathDirMock).toHaveBeenCalledWith( + "\\\\?\\I:\\gpt-projects\\CodexMonitor", + ); + expect(addWorkspaceMock).not.toHaveBeenCalled(); + expect(addResult!.added).toHaveLength(0); + expect(addResult!.skippedExisting).toEqual(["\\\\?\\I:\\gpt-projects\\CodexMonitor"]); + expect(addResult!.skippedInvalid).toHaveLength(0); + expect(addResult!.failures).toHaveLength(0); + }); + + it("treats Windows UNC namespace paths as duplicates of existing workspace roots", async () => { + const listWorkspacesMock = vi.mocked(listWorkspaces); + const isWorkspacePathDirMock = vi.mocked(isWorkspacePathDir); + const addWorkspaceMock = vi.mocked(addWorkspace); + + listWorkspacesMock.mockResolvedValue([ + { + ...workspaceOne, + id: "existing-unc", + path: "\\\\SERVER\\Share\\CodexMonitor", + }, + ]); + isWorkspacePathDirMock.mockResolvedValue(true); + + const { result } = renderHook(() => useWorkspaces()); + + await act(async () => { + await Promise.resolve(); + }); + + let addResult: Awaited>; + await act(async () => { + addResult = await result.current.addWorkspacesFromPaths([ + "\\\\?\\UNC\\SERVER\\Share\\CodexMonitor", + ]); + }); + + expect(isWorkspacePathDirMock).toHaveBeenCalledWith( + "\\\\?\\UNC\\SERVER\\Share\\CodexMonitor", + ); + expect(addWorkspaceMock).not.toHaveBeenCalled(); + expect(addResult!.added).toHaveLength(0); + expect(addResult!.skippedExisting).toEqual([ + "\\\\?\\UNC\\SERVER\\Share\\CodexMonitor", + ]); + expect(addResult!.skippedInvalid).toHaveLength(0); + expect(addResult!.failures).toHaveLength(0); + }); + it("tries raw tilde paths before inferred home-prefix expansion", async () => { const listWorkspacesMock = vi.mocked(listWorkspaces); const isWorkspacePathDirMock = vi.mocked(isWorkspacePathDir);