diff --git a/crates/services/src/import/mod.rs b/crates/services/src/import/mod.rs index 820f4610b..59a33c448 100644 --- a/crates/services/src/import/mod.rs +++ b/crates/services/src/import/mod.rs @@ -318,7 +318,9 @@ fn collect_files( }; for entry in entries.flatten() { let path = entry.path(); - if path.is_dir() { + // Not followed through symlinks: a link back to an ancestor would + // make the walk branch without end. + if entry.file_type().is_ok_and(|kind| kind.is_dir()) { visit(&path, predicate, files); } else if predicate(&path) { files.push(path); diff --git a/crates/services/src/import/scan.rs b/crates/services/src/import/scan.rs index a621f6b5f..832761581 100644 --- a/crates/services/src/import/scan.rs +++ b/crates/services/src/import/scan.rs @@ -130,17 +130,22 @@ pub fn scan_recent_dirs(roots: &ExternalRoots, exclude_roots: &[PathBuf]) -> Vec let mut groups = HashMap::>::new(); for (cwd, thread) in found { - if !cwd.is_dir() - || exclude_roots - .iter() - .any(|excluded| same_path(&cwd, excluded)) - { - continue; - } groups.entry(cwd).or_default().push(thread); } + // Filesystem checks run once per directory, not per thread: each one can + // block on a slow or unreachable mount. + let excluded: Vec<_> = exclude_roots + .iter() + .map(|root| root.canonicalize().unwrap_or_else(|_| root.clone())) + .collect(); let mut dirs: Vec<_> = groups .into_iter() + .filter(|(cwd, _)| { + cwd.is_dir() && { + let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.clone()); + !excluded.contains(&cwd) + } + }) .map(|(path, mut threads)| { threads.sort_by_key(|thread| std::cmp::Reverse(thread.last_active_ms)); RecentDir { @@ -209,10 +214,3 @@ fn desktop_metadata(root: &Path) -> HashMap { } metadata } - -fn same_path(left: &Path, right: &Path) -> bool { - match (left.canonicalize(), right.canonicalize()) { - (Ok(left), Ok(right)) => left == right, - _ => left == right, - } -} diff --git a/crates/services/src/import/tests.rs b/crates/services/src/import/tests.rs index 2e9e0b86e..6530aa636 100644 --- a/crates/services/src/import/tests.rs +++ b/crates/services/src/import/tests.rs @@ -196,6 +196,14 @@ fn scanner_groups_attributes_orders_and_excludes() { })], ); + // A link back to an ancestor must not list the sessions below it again. + #[cfg(unix)] + std::os::unix::fs::symlink( + roots.codex_session_roots[0].join("2026"), + roots.codex_session_roots[0].join("2026/01/loop"), + ) + .unwrap(); + let recent = scan_recent_dirs(&roots, std::slice::from_ref(&excluded_cwd)); assert_eq!(recent.len(), 2); assert_eq!(recent[0].path, desktop_cwd); diff --git a/crates/ui/src/add_project_dialog.rs b/crates/ui/src/add_project_dialog.rs index b529d37ca..66c8c05f8 100644 --- a/crates/ui/src/add_project_dialog.rs +++ b/crates/ui/src/add_project_dialog.rs @@ -31,10 +31,10 @@ use tcode_protocol::{CommandResponse, ExternalImportState, ExternalThread, Recen const RECENT_LIMIT: usize = 15; const RECENT_ROW_HEIGHT_ESTIMATE: f32 = 64.; const RECENT_VIEWPORT_MAX_HEIGHT: f32 = 390.; -/// Everything above the recents viewport inside the dialog: title, the path row -/// and the footer. Subtracted so the list scrolls instead of pushing the Open -/// button off a short window. -const RECENT_VIEWPORT_CHROME: f32 = 260.; +/// Everything around the recents viewport inside the dialog: title, the path +/// row, the recents heading and the footer. Subtracted so the list scrolls +/// instead of pushing the Open button off a short window. +const RECENT_VIEWPORT_CHROME: f32 = 280.; enum RecentState { Loading, @@ -364,12 +364,6 @@ impl Render for AddProjectDialog { let can_browse = self.can_browse(cx); v_flex() .gap_4() - .child( - v_flex() - .gap_2() - .child(div().text_size(px(13.)).font_semibold().child(recent_label)) - .child(self.render_recent(window, cx)), - ) .child( v_flex() .gap_1() @@ -410,6 +404,22 @@ impl Render for AddProjectDialog { ) }), ) + .child( + v_flex() + .gap_2() + .child( + v_flex() + .gap_0p5() + .child(div().text_size(px(13.)).font_semibold().child(recent_label)) + .child( + div() + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground) + .child(crate::tr!("sidebar.recent_import_hint")), + ), + ) + .child(self.render_recent(window, cx)), + ) } } @@ -583,9 +593,14 @@ fn tool_counts(threads: &[ExternalThread]) -> String { ] .into_iter() .filter_map(|source| { - counts - .get(&source) - .map(|count| format!("{} ×{count}", source.display_name())) + counts.get(&source).map(|&count| { + let tool = source.display_name(); + if count == 1 { + crate::tr!("sidebar.recent_tool_threads_one", tool = tool).into_owned() + } else { + crate::tr!("sidebar.recent_tool_threads", tool = tool, count = count).into_owned() + } + }) }) .collect::>() .join(" · ") diff --git a/locales/en.yml b/locales/en.yml index 6f31c7768..c44587e68 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -126,9 +126,12 @@ sidebar: add_project: "Add project" select_project: "Select project directory" recent_activity: "Recently active" - open_recent: "Open recent project %{name}" + open_recent: "Open recent project %{name} and import its conversations" recent_loading: "Looking for recent activity…" recent_empty: "No recent activity found" + recent_import_hint: "Choosing a project here also imports the conversations listed under it." + recent_tool_threads_one: "%{tool}: 1 conversation" + recent_tool_threads: "%{tool}: %{count} conversations" path_placeholder: "Enter an absolute directory path" host_path_placeholder: "Absolute path on %{host}" host_path_hint: "This path is on %{host}, not on this device." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 735ce779d..a9eb8eefc 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -126,9 +126,12 @@ sidebar: add_project: "添加项目" select_project: "选择项目目录" recent_activity: "最近活跃" - open_recent: "打开最近项目 %{name}" + open_recent: "打开最近项目 %{name} 并导入其对话" recent_loading: "正在查找最近活跃的目录…" recent_empty: "未发现最近活跃的目录" + recent_import_hint: "点击此处的项目会同时导入其下列出的对话。" + recent_tool_threads_one: "%{tool}:1 条对话" + recent_tool_threads: "%{tool}:%{count} 条对话" path_placeholder: "输入绝对目录路径" host_path_placeholder: "%{host} 上的绝对路径" host_path_hint: "该路径在 %{host} 上解析,而不是这台设备。"