Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions assets/settings/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": ["**/.*"],
Expand Down
1 change: 1 addition & 0 deletions crates/settings/src/vscode_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,7 @@ impl VsCodeSettings {
.collect::<Vec<_>>()
})
.filter(|r| !r.is_empty()),
scan_symlinks: None,
private_files: None,
hidden_files: None,
read_only_files: self
Expand Down
29 changes: 29 additions & 0 deletions crates/settings_content/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,30 @@ pub struct ProjectSettingsContent {
pub disable_ai: Option<SaturatingBool>,
}

/// 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 {
Expand Down Expand Up @@ -120,6 +144,11 @@ pub struct WorktreeSettingsContent {
/// ]
pub file_scan_inclusions: Option<Vec<String>>,

/// When to scan content of linked directories.
///
/// Default: expanded
pub scan_symlinks: Option<ScanSymlinksSetting>,

/// Treat the files matching these globs as `.env` files.
/// Default: ["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/secrets.yml"]
pub private_files: Option<ExtendingVec<String>>,
Expand Down
17 changes: 16 additions & 1 deletion crates/settings_ui/src/page_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions crates/settings_ui/src/settings_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,7 @@ fn init_renderers(cx: &mut App) {
.add_basic_renderer::<settings::RelativeLineNumbers>(render_dropdown)
.add_basic_renderer::<settings::WindowDecorations>(render_dropdown)
.add_basic_renderer::<settings::WindowButtonLayoutContentDiscriminants>(render_dropdown)
.add_basic_renderer::<settings::ScanSymlinksSetting>(render_dropdown)
.add_basic_renderer::<settings::FontSize>(render_editable_number_field)
.add_basic_renderer::<settings::OllamaModelName>(render_ollama_model_picker)
.add_basic_renderer::<settings::SemanticTokens>(render_dropdown)
Expand Down
132 changes: 101 additions & 31 deletions crates/worktree/src/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Arc<dyn fs::FileHandle>>,
/// 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<Path>, Arc<RelPath>>,
}

struct BackgroundScannerState {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Path>,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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<Arc<Path>> = if job.is_external {
self.fs
.canonicalize(job.abs_path.as_ref())
.await
.ok()
.map(|canonical| {
let canonical: Arc<Path> = 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() {
Expand Down Expand Up @@ -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)))
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<ScanRequest> {
let mut request = self.scan_requests_rx.recv().await?;
while let Ok(next_request) = self.scan_requests_rx.try_recv() {
Expand Down
5 changes: 4 additions & 1 deletion crates/worktree/src/worktree_settings.rs
Original file line number Diff line number Diff line change
@@ -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},
Expand All @@ -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,
Expand Down Expand Up @@ -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<String> = file_scan_inclusions
.iter()
.flat_map(|glob| {
Expand Down Expand Up @@ -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,
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ fn make_settings_with_read_only(patterns: &[&str]) -> WorktreeSettings {
PathStyle::local(),
)
.unwrap(),
scan_symlinks: Default::default(),
}
}

Expand Down
Loading
Loading