diff --git a/assets/settings/default.json b/assets/settings/default.json index 37c06960555011..bd32deca2ce7e8 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1559,6 +1559,13 @@ // that are overly broad can slow down Zed's file scanning. `file_scan_exclusions` takes // precedence over these inclusions. "file_scan_inclusions": [".env*"], + // When to scan content of linked directories. + // May take 2 values: + // 1. Only scan symlinked directories when they've been expanded in the workspace: + // "scan_symlinks": "expanded" + // 2. Always scan symlinked directories: + // "scan_symlinks": "always" + "scan_symlinks": "expanded", // Globs to match files that will be considered "hidden". These files can be hidden from the // project panel by toggling the "hide_hidden" setting. "hidden_files": ["**/.*"], diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 59dcdfbccf65c2..e4053bd2b75711 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -1085,6 +1085,7 @@ impl VsCodeSettings { .collect::>() }) .filter(|r| !r.is_empty()), + scan_symlinks: None, private_files: None, hidden_files: None, read_only_files: self diff --git a/crates/settings_content/src/project.rs b/crates/settings_content/src/project.rs index fbeede37871eeb..3d64cda282e920 100644 --- a/crates/settings_content/src/project.rs +++ b/crates/settings_content/src/project.rs @@ -87,6 +87,30 @@ pub struct ProjectSettingsContent { pub disable_ai: Option, } +/// When to scan content of linked directories. +#[derive( + Copy, + Clone, + Default, + Debug, + Serialize, + Deserialize, + PartialEq, + Eq, + JsonSchema, + MergeFrom, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum ScanSymlinksSetting { + /// Always scan symlinked directories + Always, + /// Only scan symlinked directories when they've been expanded in the workspace + #[default] + Expanded, +} + #[with_fallible_options] #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct WorktreeSettingsContent { @@ -120,6 +144,11 @@ pub struct WorktreeSettingsContent { /// ] pub file_scan_inclusions: Option>, + /// When to scan content of linked directories. + /// + /// Default: expanded + pub scan_symlinks: Option, + /// Treat the files matching these globs as `.env` files. /// Default: ["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/secrets.yml"] pub private_files: Option>, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 1a38337fb492fb..e2c69647a686d0 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -3424,7 +3424,7 @@ fn search_and_files_page() -> SettingsPage { ] } - fn file_scan_section() -> [SettingsPageItem; 5] { + fn file_scan_section() -> [SettingsPageItem; 6] { [ SettingsPageItem::SectionHeader("File Scan"), SettingsPageItem::SettingItem(SettingItem { @@ -3471,6 +3471,21 @@ fn search_and_files_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Scan Symbolic Links", + description: "When to scan content of linked directories", + field: Box::new(SettingField { + json_path: Some("scan_symlinks"), + pick: |settings_content| { + settings_content.project.worktree.scan_symlinks.as_ref() + }, + write: |settings_content, value, _| { + settings_content.project.worktree.scan_symlinks = value; + }, + }), + metadata: None, + files: USER, + }), SettingsPageItem::SettingItem(SettingItem { title: "Restore File State", description: "Restore previous file state when reopening.", diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index eae0e60166e566..d3c831b425331b 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -558,6 +558,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_editable_number_field) .add_basic_renderer::(render_ollama_model_picker) .add_basic_renderer::(render_dropdown) diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 9e6f58e0042562..13a65f6d9446c1 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -5,7 +5,7 @@ use ::ignore::gitignore::{Gitignore, GitignoreBuilder}; use anyhow::{Context as _, Result, anyhow}; use chardetng::EncodingDetector; use clock::ReplicaId; -use collections::{HashMap, HashSet, VecDeque}; +use collections::{BTreeMap, HashMap, HashSet, VecDeque}; use encoding_rs::Encoding; use fs::{ Fs, MTime, PathEvent, PathEventKind, RemoveOptions, TrashedEntry, Watcher, copy_recursive, @@ -257,6 +257,10 @@ pub struct LocalSnapshot { /// The file handle of the worktree root /// (so we can find it after it's been moved) root_file_handle: Option>, + /// Maps canonical absolute paths of externally watched symlinked directories + /// to their relative paths within the worktree, used to translate FSEvents + /// canonical-path events back to worktree-relative paths. + external_canonical_to_relative: BTreeMap, Arc>, } struct BackgroundScannerState { @@ -430,6 +434,7 @@ impl Worktree { global_gitignore: Default::default(), repo_exclude_by_work_dir_abs_path: Default::default(), git_repositories: Default::default(), + external_canonical_to_relative: Default::default(), snapshot: Snapshot::new( worktree_id, abs_path @@ -2987,22 +2992,6 @@ impl LocalSnapshot { } impl BackgroundScannerState { - fn should_scan_directory(&self, entry: &Entry) -> bool { - (self.scanning_enabled && !entry.is_external && (!entry.is_ignored || entry.is_always_included)) - || entry.path.file_name() == Some(DOT_GIT) - || entry.path.file_name() == Some(local_settings_folder_name()) - || entry.path.file_name() == Some(local_vscode_folder_name()) - || self.scanned_dirs.contains(&entry.id) // If we've ever scanned it, keep scanning - || self - .paths_to_scan - .iter() - .any(|p| p.starts_with(&entry.path)) - || self - .path_prefixes_to_scan - .iter() - .any(|p| entry.path.starts_with(p)) - } - async fn enqueue_scan_dir( &self, abs_path: Arc, @@ -3219,6 +3208,17 @@ impl BackgroundScannerState { watcher.remove(&removed_dir_abs_path).log_err(); } + self.snapshot + .external_canonical_to_relative + .retain(|canonical, relative| { + if relative.starts_with(path) { + watcher.remove(canonical.as_ref()).log_err(); + false + } else { + true + } + }); + #[cfg(feature = "test-support")] self.snapshot.check_invariants(false); } @@ -4508,6 +4508,24 @@ impl BackgroundScanner { && let Ok(path) = RelPath::new(path, PathStyle::local()) { path + } else if let Some(path) = snapshot.external_canonical_to_relative.iter().find_map( + |(canonical, relative)| { + abs_path + .as_path() + .strip_prefix(canonical.as_ref()) + .ok() + .and_then(|suffix| { + RelPath::new(suffix, PathStyle::local()) + .ok() + .map(|suffix_rel| { + std::borrow::Cow::Owned( + relative.join(&suffix_rel).to_rel_path_buf(), + ) + }) + }) + }, + ) { + path } else { skip_ix(&mut ranges_to_drop, ix); continue; @@ -5002,13 +5020,12 @@ impl BackgroundScanner { } let mut state = self.state.lock().await; - // Identify any subdirectories that should not be scanned. let mut job_ix = 0; for entry in &mut new_entries { state.reuse_entry_id(entry); if entry.is_dir() { - if state.should_scan_directory(entry) { + if self.should_scan_directory(&state, entry) { job_ix += 1; } else { log::debug!("defer scanning directory {:?}", entry.path); @@ -5025,17 +5042,49 @@ impl BackgroundScanner { } state.populate_dir(job.path.clone(), new_entries, new_ignore); + // For external entries, watch the canonical (resolved) path so OS-level + // FS events on the real filesystem location are observed. The same + // canonical path is stored in both `external_canonical_to_relative` + // (for translating canonical-path FS events back to worktree-relative + // paths) and `watched_dir_abs_paths_by_entry_id` (used by `remove_path` + // to know which abs path to unwatch), so both cleanup paths agree on + // the path the watcher was actually registered on. + // + // `canonicalize` is an async filesystem operation that may suspend, so + // the lock must not be held across the await point below. + drop(state); + let watched_abs_path: Option> = if job.is_external { + self.fs + .canonicalize(job.abs_path.as_ref()) + .await + .ok() + .map(|canonical| { + let canonical: Arc = canonical.into(); + self.watcher.add(&canonical).log_err(); + canonical + }) + } else { + self.watcher.add(job.abs_path.as_ref()).log_err(); + Some(job.abs_path.clone()) + }; - self.watcher.add(job.abs_path.as_ref()).log_err(); - - let entry_id = state - .snapshot - .entry_for_path(&job.path) - .map(|entry| entry.id); - if let Some(entry_id) = entry_id { - state - .watched_dir_abs_paths_by_entry_id - .insert(entry_id, job.abs_path.clone()); + let mut state = self.state.lock().await; + if let Some(watched_abs_path) = &watched_abs_path { + if job.is_external { + state + .snapshot + .external_canonical_to_relative + .insert(watched_abs_path.clone(), job.path.clone()); + } + if let Some(entry_id) = state + .snapshot + .entry_for_path(&job.path) + .map(|entry| entry.id) + { + state + .watched_dir_abs_paths_by_entry_id + .insert(entry_id, watched_abs_path.clone()); + } } for new_job in new_jobs.into_iter().flatten() { @@ -5138,7 +5187,7 @@ impl BackgroundScanner { fs_entry.is_hidden = self.settings.is_path_hidden(path); if let (Some(scan_queue_tx), true) = (&scan_queue_tx, is_dir) { - if state.should_scan_directory(&fs_entry) + if self.should_scan_directory(&state, &fs_entry) || (self.track_git_repositories && fs_entry.path.is_empty() && abs_path.file_name() == Some(OsStr::new(DOT_GIT))) @@ -5432,7 +5481,7 @@ impl BackgroundScanner { // Scan any directories that were previously ignored and weren't previously scanned. if was_ignored && !entry.is_ignored && entry.kind.is_unloaded() { let state = self.state.lock().await; - if state.should_scan_directory(&entry) { + if self.should_scan_directory(&state, &entry) { state .enqueue_scan_dir( abs_path.clone(), @@ -5591,6 +5640,27 @@ impl BackgroundScanner { !self.share_private_files && self.settings.is_path_private(path) } + fn should_scan_directory(&self, state: &BackgroundScannerState, entry: &Entry) -> bool { + let scannable = state.scanning_enabled + && (!entry.is_external + || self.settings.scan_symlinks == settings::ScanSymlinksSetting::Always) + && (!entry.is_ignored || entry.is_always_included); + + scannable + || entry.path.file_name() == Some(DOT_GIT) + || entry.path.file_name() == Some(local_settings_folder_name()) + || entry.path.file_name() == Some(local_vscode_folder_name()) + || state.scanned_dirs.contains(&entry.id) // If we've ever scanned it, keep scanning + || state + .paths_to_scan + .iter() + .any(|p| p.starts_with(&entry.path)) + || state + .path_prefixes_to_scan + .iter() + .any(|p| entry.path.starts_with(p)) + } + async fn next_scan_request(&self) -> Result { let mut request = self.scan_requests_rx.recv().await?; while let Ok(next_request) = self.scan_requests_rx.try_recv() { diff --git a/crates/worktree/src/worktree_settings.rs b/crates/worktree/src/worktree_settings.rs index 90fe5ba724b286..c33ca45c4053bb 100644 --- a/crates/worktree/src/worktree_settings.rs +++ b/crates/worktree/src/worktree_settings.rs @@ -1,7 +1,7 @@ use std::path::Path; use anyhow::Context as _; -use settings::{RegisterSetting, Settings}; +use settings::{RegisterSetting, ScanSymlinksSetting, Settings}; use util::{ ResultExt, paths::{PathMatcher, PathStyle}, @@ -17,6 +17,7 @@ pub struct WorktreeSettings { /// This field contains all ancestors of the `file_scan_inclusions`. It's used to /// determine whether to terminate worktree scanning for a given dir. pub parent_dir_scan_inclusions: PathMatcher, + pub scan_symlinks: ScanSymlinksSetting, pub private_files: PathMatcher, pub hidden_files: PathMatcher, pub read_only_files: PathMatcher, @@ -63,6 +64,7 @@ impl Settings for WorktreeSettings { let private_files = worktree.private_files.unwrap().0; let hidden_files = worktree.hidden_files.unwrap(); let read_only_files = worktree.read_only_files.unwrap_or_default(); + let scan_symlinks = worktree.scan_symlinks.unwrap(); let parsed_file_scan_inclusions: Vec = file_scan_inclusions .iter() .flat_map(|glob| { @@ -95,6 +97,7 @@ impl Settings for WorktreeSettings { read_only_files: path_matchers(read_only_files, "read_only_files") .log_err() .unwrap_or_default(), + scan_symlinks, } } } diff --git a/crates/worktree/tests/integration/worktree_settings_tests.rs b/crates/worktree/tests/integration/worktree_settings_tests.rs index 0a47766f35f480..20dc75819c5883 100644 --- a/crates/worktree/tests/integration/worktree_settings_tests.rs +++ b/crates/worktree/tests/integration/worktree_settings_tests.rs @@ -18,6 +18,7 @@ fn make_settings_with_read_only(patterns: &[&str]) -> WorktreeSettings { PathStyle::local(), ) .unwrap(), + scan_symlinks: Default::default(), } } diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs index b98f4517696aec..661454c9b6a29e 100644 --- a/crates/worktree/tests/integration/worktree_tests.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -584,6 +584,544 @@ async fn test_symlinked_dir_inside_project(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_scan_symlinks_always(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.scan_symlinks = + Some(settings::ScanSymlinksSetting::Always); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "dir1": { + "deps": { + // symlink target placed here by create_symlink below + }, + "src": { + "a.rs": "", + }, + }, + "dir2": { + "src": { + "b.rs": "", + } + } + }), + ) + .await; + + fs.create_symlink("/root/dir1/deps/dep-dir2".as_ref(), "../../dir2".into()) + .await + .unwrap(); + + let tree = Worktree::local( + Path::new("/root/dir1"), + true, + fs.clone(), + Default::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + // With scan_symlinks = Always, the symlinked directory's contents should be + // fully visible on the first scan without any manual expansion. + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true, 0) + .map(|entry| (entry.path.as_ref(), entry.is_external)) + .collect::>(), + vec![ + (rel_path(""), false), + (rel_path("deps"), false), + (rel_path("deps/dep-dir2"), true), + (rel_path("deps/dep-dir2/src"), true), + (rel_path("deps/dep-dir2/src/b.rs"), true), + (rel_path("src"), false), + (rel_path("src/a.rs"), false), + ] + ); + }); +} + +#[gpui::test] +async fn test_scan_symlinks_expanded(cx: &mut TestAppContext) { + init_test(cx); + + // scan_symlinks defaults to Expanded — no settings change needed. + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "dir1": { + "deps": { + // symlink target placed here by create_symlink below + }, + "src": { + "a.rs": "", + }, + }, + "dir2": { + "src": { + "b.rs": "", + } + } + }), + ) + .await; + + fs.create_symlink("/root/dir1/deps/dep-dir2".as_ref(), "../../dir2".into()) + .await + .unwrap(); + + let tree = Worktree::local( + Path::new("/root/dir1"), + true, + fs.clone(), + Default::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + // With the default scan_symlinks = Expanded, the symlinked directory + // should appear as an UnloadedDir entry with no children visible. + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true, 0) + .map(|entry| (entry.path.as_ref(), entry.is_external)) + .collect::>(), + vec![ + (rel_path(""), false), + (rel_path("deps"), false), + (rel_path("deps/dep-dir2"), true), + (rel_path("src"), false), + (rel_path("src/a.rs"), false), + ] + ); + + assert_eq!( + tree.entry_for_path(rel_path("deps/dep-dir2")).unwrap().kind, + EntryKind::UnloadedDir + ); + }); + + // Manually expand the symlinked directory. + tree.read_with(cx, |tree, _| { + tree.as_local() + .unwrap() + .refresh_entries_for_paths(vec![rel_path("deps/dep-dir2").into()]) + }) + .recv() + .await; + + // After expansion, dep-dir2's immediate children are visible. Subdirectories + // within it are present but not yet scanned. + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true, 0) + .map(|entry| (entry.path.as_ref(), entry.is_external)) + .collect::>(), + vec![ + (rel_path(""), false), + (rel_path("deps"), false), + (rel_path("deps/dep-dir2"), true), + (rel_path("deps/dep-dir2/src"), true), + (rel_path("src"), false), + (rel_path("src/a.rs"), false), + ] + ); + + assert_eq!( + tree.entry_for_path(rel_path("deps/dep-dir2/src")) + .unwrap() + .kind, + EntryKind::UnloadedDir + ); + }); + + // Expand the subdirectory inside the symlinked directory. + tree.read_with(cx, |tree, _| { + tree.as_local() + .unwrap() + .refresh_entries_for_paths(vec![rel_path("deps/dep-dir2/src").into()]) + }) + .recv() + .await; + + // After expanding the subdirectory, its files are visible. + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true, 0) + .map(|entry| (entry.path.as_ref(), entry.is_external)) + .collect::>(), + vec![ + (rel_path(""), false), + (rel_path("deps"), false), + (rel_path("deps/dep-dir2"), true), + (rel_path("deps/dep-dir2/src"), true), + (rel_path("deps/dep-dir2/src/b.rs"), true), + (rel_path("src"), false), + (rel_path("src/a.rs"), false), + ] + ); + }); +} + +#[gpui::test(iterations = 10)] +async fn test_circular_symlinks_always(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.scan_symlinks = + Some(settings::ScanSymlinksSetting::Always); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "project": { + "lib": { + "a": { + "a.txt": "" + } + }, + "deps": {} + }, + "outside": { + "data.txt": "" + } + }), + ) + .await; + + fs.create_symlink("/root/project/deps/ext".as_ref(), "../../outside".into()) + .await + .unwrap(); + fs.create_symlink("/root/outside/back".as_ref(), "../../project".into()) + .await + .unwrap(); + + let tree = Worktree::local( + Path::new("/root/project"), + true, + fs.clone(), + Default::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + tree.read_with(cx, |tree, _| { + let entries: Vec<_> = tree + .entries(true, 0) + .map(|entry| (entry.path.as_ref(), entry.is_external)) + .collect(); + + assert_eq!( + entries, + vec![ + (rel_path(""), false), + (rel_path("deps"), false), + (rel_path("deps/ext"), true), + (rel_path("deps/ext/data.txt"), true), + (rel_path("lib"), false), + (rel_path("lib/a"), false), + (rel_path("lib/a/a.txt"), false), + ] + ); + }); +} + +#[gpui::test] +async fn test_scan_symlinks_always_respects_gitignore(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.scan_symlinks = + Some(settings::ScanSymlinksSetting::Always); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "project": { + ".gitignore": "ignored-dep\n", + "deps": {} + }, + "external-included": { + "src": { + "included.rs": "" + } + }, + "external-ignored": { + "src": { + "ignored.rs": "" + } + } + }), + ) + .await; + + fs.create_symlink( + "/root/project/deps/included-dep".as_ref(), + "../../external-included".into(), + ) + .await + .unwrap(); + fs.create_symlink( + "/root/project/deps/ignored-dep".as_ref(), + "../../external-ignored".into(), + ) + .await + .unwrap(); + + let tree = Worktree::local( + Path::new("/root/project"), + true, + fs.clone(), + Default::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true, 0) + .map(|entry| (entry.path.as_ref(), entry.is_external, entry.is_ignored)) + .collect::>(), + vec![ + (rel_path(""), false, false), + (rel_path(".gitignore"), false, false), + (rel_path("deps"), false, false), + (rel_path("deps/ignored-dep"), true, true), + (rel_path("deps/included-dep"), true, false), + (rel_path("deps/included-dep/src"), true, false), + (rel_path("deps/included-dep/src/included.rs"), true, false), + ] + ); + + assert_eq!( + tree.entry_for_path(rel_path("deps/ignored-dep")) + .unwrap() + .kind, + EntryKind::UnloadedDir + ); + }); +} + +// Real-fs counterparts to the FakeFs scan_symlinks tests above. FakeFs does not +// model `fs::canonicalize` against a real filesystem, so platform-specific +// canonicalization or readdir behavior is not covered by the FakeFs tests. +// These tests use a real temp directory and a real symlink to exercise the +// production path on the host platform. +#[cfg(unix)] +#[gpui::test] +async fn test_real_fs_scan_symlinks_always(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + init_test(cx); + + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.scan_symlinks = + Some(settings::ScanSymlinksSetting::Always); + }); + }); + }); + + let temp_root = TempTree::new(json!({ + "project": { + "deps": {}, + "src": { + "a.rs": "", + }, + }, + "external": { + "src": { + "b.rs": "", + }, + }, + })); + + // Relative symlink: from temp_root/project/deps/, `../../external` resolves + // to temp_root/external — outside the worktree root at temp_root/project. + std::os::unix::fs::symlink( + "../../external", + temp_root.path().join("project/deps/dep-external"), + ) + .unwrap(); + + let project_root = temp_root.path().join("project"); + let tree = Worktree::local( + project_root.as_path(), + true, + Arc::new(RealFs::new(None, cx.executor())), + Default::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true, 0) + .map(|entry| (entry.path.as_ref(), entry.is_external)) + .collect::>(), + vec![ + (rel_path(""), false), + (rel_path("deps"), false), + (rel_path("deps/dep-external"), true), + (rel_path("deps/dep-external/src"), true), + (rel_path("deps/dep-external/src/b.rs"), true), + (rel_path("src"), false), + (rel_path("src/a.rs"), false), + ] + ); + }); +} + +#[cfg(unix)] +#[gpui::test] +async fn test_real_fs_scan_symlinks_expanded(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + init_test(cx); + + // scan_symlinks defaults to Expanded — no settings change needed. + + let temp_root = TempTree::new(json!({ + "project": { + "deps": {}, + "src": { + "a.rs": "", + }, + }, + "external": { + "src": { + "b.rs": "", + }, + }, + })); + + std::os::unix::fs::symlink( + "../../external", + temp_root.path().join("project/deps/dep-external"), + ) + .unwrap(); + + let project_root = temp_root.path().join("project"); + let tree = Worktree::local( + project_root.as_path(), + true, + Arc::new(RealFs::new(None, cx.executor())), + Default::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + // Before expansion, the symlinked directory should appear as an UnloadedDir + // with no children visible. + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true, 0) + .map(|entry| (entry.path.as_ref(), entry.is_external)) + .collect::>(), + vec![ + (rel_path(""), false), + (rel_path("deps"), false), + (rel_path("deps/dep-external"), true), + (rel_path("src"), false), + (rel_path("src/a.rs"), false), + ] + ); + + assert_eq!( + tree.entry_for_path(rel_path("deps/dep-external")) + .unwrap() + .kind, + EntryKind::UnloadedDir + ); + }); + + // Manually expand the symlinked directory. This is the case #51382 was + // added to fix; if this assertion fails it's a regression of that fix on + // real filesystems. + tree.read_with(cx, |tree, _| { + tree.as_local() + .unwrap() + .refresh_entries_for_paths(vec![rel_path("deps/dep-external").into()]) + }) + .recv() + .await; + + tree.read_with(cx, |tree, _| { + assert_eq!( + tree.entries(true, 0) + .map(|entry| (entry.path.as_ref(), entry.is_external)) + .collect::>(), + vec![ + (rel_path(""), false), + (rel_path("deps"), false), + (rel_path("deps/dep-external"), true), + (rel_path("deps/dep-external/src"), true), + (rel_path("src"), false), + (rel_path("src/a.rs"), false), + ] + ); + }); +} + #[cfg(target_os = "macos")] #[gpui::test] async fn test_renaming_case_only(cx: &mut TestAppContext) { diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index d339ffc4333018..999315195401c7 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -2055,6 +2055,32 @@ Note, specifying `file_scan_exclusions` in settings.json will override the defau } ``` +## Scan Symbolic Links + +- Description: When to scan content of linked directories. +- Setting: `scan_symlinks` +- Default: `expanded` + +**Options** + +1. Only scan symlinked directories when they've been expanded in the workspace (default): + +```json [settings] +{ + "scan_symlinks": "expanded" +} +``` + +2. Always scan symlinked directories: + +```json [settings] +{ + "scan_symlinks": "always" +} +``` + +When set to `expanded`, symbolic links are only scanned after you explicitly expand them in the project panel. When set to `always`, Zed follows all symbolic links and scans their contents when indexing the project, unless they match gitignore rules. The `always` option may have performance implications for projects with many or deeply nested symlinks. + ## File Types - Setting: `file_types`