Make file scanner less eager in non-git-tracked directory trees (#62583)
Fixes https://github.com/zed-industries/zed/issues/35780 Collab schema migration PR: https://github.com/zed-industries/cloud/pull/3422 The corresponding database schema migration has been created in the Cloud repo and applied to the production database. Before, Zed scanned each and every entry in the tree down from the directory it was opened in, except gitignored files and scan exclusions. The approach is unchanged, if Zed detects it was open inside a git repository: e.g. the directory open in Zed contains `.git` directory. For the rest of the projects, 2 optimizations are made: * Limit the depth of file scan traversal. Now, `file_scan_depth` (default `5`) restricts Zed from traversing any directory that has same number or more segments in its file path. Such directories behave similar to gitignored directories: their contents is not available in file finder, project search and project panel, but can be lazily traversed when the directory is expanded (e.g. project panel expands it or a nested file is open by path via terminal, etc.) To indicate that to the users, a status entry is shown firs time the limitation is hit in the project: <img width="858" height="133" alt="image" src="https://github.com/user-attachments/assets/7da6cfbb-98b4-4cc3-bf2a-8902a9597a15" /> * During the scan, any git repositories that are not direct children of the directory open in Zed (depth >= 2), are traversed and indexed normally, but their git metadata is never fetched eagerly. Only when Zed opens a buffer from that repo the git metadata is fetched and applied. All that combined now uses a way more moderate amount of CPU and RAM when opening `~`: <img width="1717" height="368" alt="Screenshot 2026-08-13 at 17 43 53" src="https://github.com/user-attachments/assets/ec83e2a9-f7cc-452b-8eb7-af158284ca4e" /> File scan inclusions and exclusions are considered still for such projects. Set `file_scan_depth` to `0` to enable old behavior. The setting is supported in the project settings, so custom values can be set based on the project's structure. --- Release Notes: - Fixed Zed using a lot of memory and CPU in large, non-git-tracked, directory trees
This commit is contained in:
1 parent
fc58ad2226
commit
851bf3d499
29 files changed
+1933
-84
No files matched your search
@@ -1646,6 +1646,14 @@
|
||||
// that are overly broad can slow down Zed's file scanning. `file_scan_exclusions` takes
|
||||
// precedence over these inclusions.
|
||||
"file_scan_inclusions": [".env*"],
|
||||
// Maximum directory depth to eagerly index outside of git repositories;
|
||||
// contents of directories at this depth or deeper are indexed on demand.
|
||||
// Repositories rooted shallower than this depth are always indexed fully.
|
||||
// In projects that are not rooted at a git repository, repositories directly
|
||||
// inside a root folder activate their git features immediately; deeper ones
|
||||
// activate on first use.
|
||||
// `0` means no limit and activates all git repositories immediately.
|
||||
"file_scan_depth": 5,
|
||||
// When to scan content of linked directories.
|
||||
// May take 2 values:
|
||||
// 1. Only scan symlinked directories when they've been expanded in the workspace:
|
||||
|
||||
@@ -12,6 +12,9 @@ workspace = true
|
||||
path = "src/activity_indicator.rs"
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
auto_update.workspace = true
|
||||
|
||||
@@ -4,7 +4,7 @@ use extension_host::{ExtensionOperation, ExtensionStore};
|
||||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
App, Context, Entity, EventEmitter, InteractiveElement as _, ParentElement as _, Render,
|
||||
SharedString, Styled, Window, actions,
|
||||
SharedString, Styled, Task, Window, actions,
|
||||
};
|
||||
use language::{
|
||||
BinaryStatus, LanguageRegistry, LanguageServerId, LanguageServerName,
|
||||
@@ -27,6 +27,7 @@ use util::truncate_and_trailoff;
|
||||
use workspace::{StatusItemView, Workspace, item::ItemHandle};
|
||||
|
||||
const GIT_OPERATION_DELAY: Duration = Duration::from_millis(0);
|
||||
pub const DEFERRED_SCAN_MESSAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
actions!(
|
||||
activity_indicator,
|
||||
@@ -48,6 +49,18 @@ pub struct ActivityIndicator {
|
||||
project: Entity<Project>,
|
||||
context_menu_handle: PopoverMenuHandle<ContextMenu>,
|
||||
fs_jobs: Vec<fs::JobInfo>,
|
||||
deferred_scan_message: DeferredScanMessage,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
enum DeferredScanMessage {
|
||||
#[default]
|
||||
Undetected,
|
||||
Pending,
|
||||
Shown {
|
||||
_dismiss_timer: Task<()>,
|
||||
},
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -215,12 +228,28 @@ impl ActivityIndicator {
|
||||
)
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
cx.subscribe(
|
||||
&project,
|
||||
|this, _, event: &project::Event, cx| match event {
|
||||
project::Event::WorktreeAdded(_)
|
||||
| project::Event::WorktreeRemoved(_)
|
||||
| project::Event::WorktreeUpdatedEntries(..) => {
|
||||
this.update_deferred_scan_status(cx);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
let mut this = Self {
|
||||
statuses: Vec::new(),
|
||||
project: project.clone(),
|
||||
context_menu_handle: PopoverMenuHandle::default(),
|
||||
fs_jobs: Vec::new(),
|
||||
}
|
||||
deferred_scan_message: DeferredScanMessage::default(),
|
||||
};
|
||||
this.update_deferred_scan_status(cx);
|
||||
this
|
||||
});
|
||||
|
||||
cx.subscribe_in(&this, window, move |_, _, event, window, cx| match event {
|
||||
@@ -334,7 +363,45 @@ impl ActivityIndicator {
|
||||
self.project.read(cx).peek_environment_error(cx)
|
||||
}
|
||||
|
||||
fn update_deferred_scan_status(&mut self, cx: &mut Context<Self>) {
|
||||
let has_deferred_scan_dirs = self.project.read(cx).visible_worktrees(cx).any(|worktree| {
|
||||
let worktree = worktree.read(cx);
|
||||
worktree.deferred_scan_dir_count() > 0
|
||||
&& worktree.as_local().is_none_or(|local_worktree| {
|
||||
local_worktree.settings().file_scan_depth.is_some()
|
||||
})
|
||||
});
|
||||
match &self.deferred_scan_message {
|
||||
DeferredScanMessage::Undetected if has_deferred_scan_dirs => {
|
||||
self.deferred_scan_message = DeferredScanMessage::Pending;
|
||||
cx.notify();
|
||||
}
|
||||
DeferredScanMessage::Pending | DeferredScanMessage::Shown { .. }
|
||||
if !has_deferred_scan_dirs =>
|
||||
{
|
||||
self.deferred_scan_message = DeferredScanMessage::Undetected;
|
||||
cx.notify();
|
||||
}
|
||||
DeferredScanMessage::Dismissed if !has_deferred_scan_dirs => {
|
||||
self.deferred_scan_message = DeferredScanMessage::Undetected;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn message_to_render(&mut self, cx: &mut Context<Self>) -> Option<String> {
|
||||
self.content_to_render(cx).map(|content| content.message)
|
||||
}
|
||||
|
||||
fn content_to_render(&mut self, cx: &mut Context<Self>) -> Option<Content> {
|
||||
if let Some(content) = self.primary_content(cx) {
|
||||
return Some(content);
|
||||
}
|
||||
self.deferred_scan_content(cx)
|
||||
}
|
||||
|
||||
fn primary_content(&mut self, cx: &mut Context<Self>) -> Option<Content> {
|
||||
// Show if any direnv calls failed
|
||||
if let Some(message) = self.pending_environment_error(cx) {
|
||||
return Some(Content {
|
||||
@@ -639,6 +706,38 @@ impl ActivityIndicator {
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn deferred_scan_content(&mut self, cx: &mut Context<Self>) -> Option<Content> {
|
||||
if !matches!(
|
||||
self.deferred_scan_message,
|
||||
DeferredScanMessage::Pending | DeferredScanMessage::Shown { .. }
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
if matches!(self.deferred_scan_message, DeferredScanMessage::Pending) {
|
||||
self.deferred_scan_message = DeferredScanMessage::Shown {
|
||||
_dismiss_timer: cx.spawn(async move |this, cx| {
|
||||
cx.background_executor()
|
||||
.timer(DEFERRED_SCAN_MESSAGE_TIMEOUT)
|
||||
.await;
|
||||
this.update(cx, |this, cx| {
|
||||
this.deferred_scan_message = DeferredScanMessage::Dismissed;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}),
|
||||
};
|
||||
}
|
||||
Some(Content {
|
||||
icon: ActivityIcon::Icon(IconName::Info),
|
||||
message: "Partial file index".to_string(),
|
||||
tooltip_message: Some("Directories outside of git repositories and deeper than the `file_scan_depth` setting will be indexed on demand.".to_string()),
|
||||
on_click: Some(Arc::new(|this, _, cx| {
|
||||
this.deferred_scan_message = DeferredScanMessage::Dismissed;
|
||||
cx.notify();
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<Event> for ActivityIndicator {}
|
||||
|
||||
@@ -84,6 +84,7 @@ CREATE TABLE "worktree_entries" (
|
||||
"is_hidden" BOOL NOT NULL,
|
||||
"git_status" INTEGER,
|
||||
"is_fifo" BOOL NOT NULL,
|
||||
"is_unloaded" BOOL NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (project_id, worktree_id, id),
|
||||
FOREIGN KEY (project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
@@ -459,7 +459,8 @@ CREATE TABLE public.worktree_entries (
|
||||
is_external boolean DEFAULT false NOT NULL,
|
||||
is_fifo boolean DEFAULT false NOT NULL,
|
||||
canonical_path text,
|
||||
is_hidden boolean DEFAULT false NOT NULL
|
||||
is_hidden boolean DEFAULT false NOT NULL,
|
||||
is_unloaded boolean DEFAULT false NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.worktree_settings_files (
|
||||
|
||||
@@ -294,6 +294,7 @@ impl Database {
|
||||
is_hidden: ActiveValue::set(entry.is_hidden),
|
||||
scan_id: ActiveValue::set(update.scan_id as i64),
|
||||
is_fifo: ActiveValue::set(entry.is_fifo),
|
||||
is_unloaded: ActiveValue::set(entry.is_unloaded),
|
||||
}
|
||||
}))
|
||||
.on_conflict(
|
||||
@@ -312,6 +313,7 @@ impl Database {
|
||||
worktree_entry::Column::IsIgnored,
|
||||
worktree_entry::Column::IsHidden,
|
||||
worktree_entry::Column::ScanId,
|
||||
worktree_entry::Column::IsUnloaded,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
@@ -809,6 +811,7 @@ impl Database {
|
||||
// on number of files only. That shouldn't be a huge deal in practice.
|
||||
size: None,
|
||||
is_fifo: db_entry.is_fifo,
|
||||
is_unloaded: db_entry.is_unloaded,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,6 +679,7 @@ impl Database {
|
||||
// on number of files only. That shouldn't be a huge deal in practice.
|
||||
size: None,
|
||||
is_fifo: db_entry.is_fifo,
|
||||
is_unloaded: db_entry.is_unloaded,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ pub struct Model {
|
||||
pub scan_id: i64,
|
||||
pub is_fifo: bool,
|
||||
pub canonical_path: Option<String>,
|
||||
pub is_unloaded: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
@@ -2539,6 +2539,65 @@ async fn test_propagate_saves_and_fs_changes(
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test(iterations = 10)]
|
||||
async fn test_unloaded_entries_sync_to_guests(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
server
|
||||
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
|
||||
.await;
|
||||
let active_call_a = cx_a.read(ActiveCall::global);
|
||||
|
||||
cx_a.update(|cx| {
|
||||
SettingsStore::update_global(cx, |store, cx| {
|
||||
store.update_user_settings(cx, |settings| {
|
||||
settings.project.worktree.file_scan_depth = Some(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
client_a
|
||||
.fs()
|
||||
.insert_tree(
|
||||
path!("/a"),
|
||||
json!({
|
||||
"junk": {
|
||||
"x": {
|
||||
"deep.txt": ""
|
||||
}
|
||||
},
|
||||
"top.txt": ""
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (project_a, _) = client_a.build_local_project(path!("/a"), cx_a).await;
|
||||
let project_id = active_call_a
|
||||
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
executor.run_until_parked();
|
||||
|
||||
let project_b = client_b.join_remote_project(project_id, cx_b).await;
|
||||
executor.run_until_parked();
|
||||
|
||||
let worktree_b = project_b.read_with(cx_b, |p, cx| p.worktrees(cx).next().unwrap());
|
||||
worktree_b.read_with(cx_b, |tree, _| {
|
||||
assert_eq!(
|
||||
tree.entry_for_path(rel_path("junk"))
|
||||
.map(|entry| entry.kind),
|
||||
Some(worktree::EntryKind::UnloadedDir)
|
||||
);
|
||||
assert_eq!(tree.entry_for_path(rel_path("junk/x")), None);
|
||||
assert_eq!(tree.deferred_scan_dir_count(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test(iterations = 10)]
|
||||
async fn test_git_diff_base_change(
|
||||
executor: BackgroundExecutor,
|
||||
|
||||
@@ -6881,6 +6881,12 @@ outline: struct OutlineEntryExcerpt
|
||||
project_search::init(cx);
|
||||
buffer_search::init(cx);
|
||||
super::init(cx);
|
||||
|
||||
SettingsStore::update_global(cx, |store, cx| {
|
||||
store.update_user_settings(cx, |settings| {
|
||||
settings.project.worktree.file_scan_depth = Some(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use client::Client;
|
||||
use collections::{HashMap, HashSet, hash_map};
|
||||
use futures::{Future, FutureExt as _, channel::oneshot, future::Shared};
|
||||
use futures::{Future, FutureExt as _, StreamExt as _, channel::oneshot, future::Shared};
|
||||
use gpui::{
|
||||
App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, TaskExt,
|
||||
WeakEntity,
|
||||
@@ -514,6 +514,38 @@ impl LocalBufferStore {
|
||||
return None;
|
||||
};
|
||||
|
||||
if snapshot.entry_for_id(entry_id).is_none()
|
||||
&& snapshot.entry_for_path(path.as_ref()).is_none()
|
||||
&& Self::unloaded_ancestor_hides_path(snapshot, path)
|
||||
{
|
||||
let mut refresh = worktree
|
||||
.read(cx)
|
||||
.as_local()?
|
||||
.refresh_entries_for_paths(vec![path.clone()]);
|
||||
cx.spawn({
|
||||
let path = path.clone();
|
||||
let worktree = worktree.clone();
|
||||
async move |this, cx| {
|
||||
refresh.next().await;
|
||||
this.update(cx, |this, cx| {
|
||||
let snapshot = worktree.read(cx).snapshot();
|
||||
if Self::unloaded_ancestor_hides_path(&snapshot, &path) {
|
||||
log::warn!(
|
||||
"buffer path {path:?} is still hidden by an unloaded directory after a refresh"
|
||||
);
|
||||
} else {
|
||||
Self::local_worktree_entry_changed(
|
||||
this, entry_id, &path, &worktree, &snapshot, cx,
|
||||
);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
return None;
|
||||
}
|
||||
|
||||
let events = buffer.update(cx, |buffer, cx| {
|
||||
let file = buffer.file()?;
|
||||
let old_file = File::from_dyn(Some(file))?;
|
||||
@@ -607,6 +639,13 @@ impl LocalBufferStore {
|
||||
None
|
||||
}
|
||||
|
||||
fn unloaded_ancestor_hides_path(snapshot: &worktree::Snapshot, path: &RelPath) -> bool {
|
||||
path.ancestors()
|
||||
.skip(1)
|
||||
.find_map(|ancestor| snapshot.entry_for_path(ancestor))
|
||||
.is_some_and(|entry| entry.kind == worktree::EntryKind::UnloadedDir)
|
||||
}
|
||||
|
||||
fn save_buffer(
|
||||
&self,
|
||||
buffer: Entity<Buffer>,
|
||||
|
||||
+342
-52
@@ -64,7 +64,7 @@ use rpc::{
|
||||
proto::{self, git_reset, split_repository_update},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use settings::{GitDiffBaseSetting, Settings, SettingsStore, WorktreeId};
|
||||
use settings::{GitDiffBaseSetting, Settings, SettingsLocation, SettingsStore, WorktreeId};
|
||||
use smallvec::SmallVec;
|
||||
use smol::future::yield_now;
|
||||
use std::{
|
||||
@@ -92,7 +92,7 @@ use util::{
|
||||
};
|
||||
use worktree::{
|
||||
File, PathChange, PathKey, PathProgress, PathSummary, PathTarget, ProjectEntryId,
|
||||
UpdatedGitRepositoriesSet, UpdatedGitRepository, Worktree,
|
||||
UpdatedGitRepositoriesSet, UpdatedGitRepository, Worktree, WorktreeSettings,
|
||||
};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
@@ -101,6 +101,7 @@ pub struct GitStore {
|
||||
buffer_store: Entity<BufferStore>,
|
||||
worktree_store: Entity<WorktreeStore>,
|
||||
repositories: HashMap<RepositoryId, Entity<Repository>>,
|
||||
parked_repositories: Vec<ParkedRepository>,
|
||||
diff_base: GitDiffBaseSetting,
|
||||
display_diffs: HashMap<RepositoryId, DisplayDiff>,
|
||||
worktree_ids: HashMap<RepositoryId, HashSet<WorktreeId>>,
|
||||
@@ -114,6 +115,24 @@ pub struct GitStore {
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
const MIN_PARKED_REPOSITORY_DEPTH: usize = 2;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ParkedRepository {
|
||||
worktree_id: WorktreeId,
|
||||
work_directory_abs_path: Arc<Path>,
|
||||
dot_git_abs_path: Arc<Path>,
|
||||
repository_dir_abs_path: Arc<Path>,
|
||||
common_dir_abs_path: Arc<Path>,
|
||||
}
|
||||
|
||||
impl ParkedRepository {
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn work_directory_abs_path(&self) -> &Path {
|
||||
&self.work_directory_abs_path
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DisplayDiff {
|
||||
buffers: Option<(Entity<diff_buffer_list::DiffBufferList>, Subscription)>,
|
||||
@@ -772,6 +791,7 @@ impl GitStore {
|
||||
if setting != this.diff_base {
|
||||
this.set_diff_base(setting, cx);
|
||||
}
|
||||
this.activate_parked_repositories_where_parking_disabled(cx);
|
||||
}));
|
||||
|
||||
let diff_base_setting = ProjectSettings::get_global(cx).git.diff_base;
|
||||
@@ -780,6 +800,7 @@ impl GitStore {
|
||||
buffer_store,
|
||||
worktree_store,
|
||||
repositories: HashMap::default(),
|
||||
parked_repositories: Vec::new(),
|
||||
diff_base: diff_base_setting,
|
||||
display_diffs: HashMap::default(),
|
||||
worktree_ids: HashMap::default(),
|
||||
@@ -2128,7 +2149,6 @@ impl GitStore {
|
||||
let GitStoreState::Local {
|
||||
project_environment,
|
||||
downstream,
|
||||
next_repository_id,
|
||||
fs,
|
||||
..
|
||||
} = &self.state
|
||||
@@ -2168,7 +2188,6 @@ impl GitStore {
|
||||
self.update_repositories_from_worktree(
|
||||
*worktree_id,
|
||||
project_environment.clone(),
|
||||
next_repository_id.clone(),
|
||||
downstream
|
||||
.as_ref()
|
||||
.map(|downstream| downstream.updates_tx.clone()),
|
||||
@@ -2179,6 +2198,8 @@ impl GitStore {
|
||||
self.local_worktree_git_repos_changed(worktree, changed_repos, cx);
|
||||
}
|
||||
WorktreeStoreEvent::WorktreeRemoved(_entity_id, worktree_id) => {
|
||||
self.parked_repositories
|
||||
.retain(|parked| parked.worktree_id != *worktree_id);
|
||||
let repos_without_worktree: Vec<RepositoryId> = self
|
||||
.worktree_ids
|
||||
.iter_mut()
|
||||
@@ -2290,7 +2311,6 @@ impl GitStore {
|
||||
&mut self,
|
||||
worktree_id: WorktreeId,
|
||||
project_environment: Entity<ProjectEnvironment>,
|
||||
next_repository_id: Arc<AtomicU64>,
|
||||
updates_tx: Option<mpsc::UnboundedSender<DownstreamUpdate>>,
|
||||
updated_git_repositories: UpdatedGitRepositoriesSet,
|
||||
fs: Arc<dyn Fs>,
|
||||
@@ -2306,22 +2326,37 @@ impl GitStore {
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
for update in updated_git_repositories.iter() {
|
||||
if let Some((id, existing)) = self.repositories.iter().find(|(_, repo)| {
|
||||
let existing_work_directory_abs_path = &repo.read(cx).work_directory_abs_path;
|
||||
Some(existing_work_directory_abs_path)
|
||||
== update.old_work_directory_abs_path.as_ref()
|
||||
|| Some(existing_work_directory_abs_path)
|
||||
== update.new_work_directory_abs_path.as_ref()
|
||||
}) {
|
||||
let repo_id = *id;
|
||||
let mut sorted_updates = updated_git_repositories.iter().collect::<Vec<_>>();
|
||||
sorted_updates.sort_by_key(|update| {
|
||||
update
|
||||
.new_work_directory_abs_path
|
||||
.as_ref()
|
||||
.map(|work_directory_abs_path| work_directory_abs_path.components().count())
|
||||
});
|
||||
for update in sorted_updates {
|
||||
self.retarget_parked_repositories(update);
|
||||
|
||||
if let Some((repo_id, existing)) = self
|
||||
.repositories
|
||||
.iter()
|
||||
.find(|(_, repo)| {
|
||||
let existing_work_directory_abs_path = &repo.read(cx).work_directory_abs_path;
|
||||
Some(existing_work_directory_abs_path)
|
||||
== update.old_work_directory_abs_path.as_ref()
|
||||
|| Some(existing_work_directory_abs_path)
|
||||
== update.new_work_directory_abs_path.as_ref()
|
||||
})
|
||||
.map(|(id, repo)| (*id, repo.clone()))
|
||||
{
|
||||
if let Some(new_work_directory_abs_path) =
|
||||
update.new_work_directory_abs_path.clone()
|
||||
{
|
||||
let mut merged_worktree_ids = self.take_parked_at(&new_work_directory_abs_path);
|
||||
merged_worktree_ids.insert(worktree_id);
|
||||
self.worktree_ids
|
||||
.entry(repo_id)
|
||||
.or_insert_with(HashSet::new)
|
||||
.insert(worktree_id);
|
||||
.or_default()
|
||||
.extend(merged_worktree_ids);
|
||||
let path_changed = update.old_work_directory_abs_path.as_ref()
|
||||
!= update.new_work_directory_abs_path.as_ref();
|
||||
if path_changed
|
||||
@@ -2365,42 +2400,30 @@ impl GitStore {
|
||||
..
|
||||
} = update
|
||||
{
|
||||
let id = RepositoryId(next_repository_id.fetch_add(1, atomic::Ordering::Release));
|
||||
let git_store = cx.weak_entity();
|
||||
let repo = cx.new(|cx| {
|
||||
let mut repo = Repository::local(
|
||||
id,
|
||||
work_directory_abs_path.clone(),
|
||||
repository_dir_abs_path.clone(),
|
||||
common_dir_abs_path.clone(),
|
||||
dot_git_abs_path.clone(),
|
||||
project_environment.downgrade(),
|
||||
fs.clone(),
|
||||
is_trusted,
|
||||
git_store,
|
||||
cx,
|
||||
);
|
||||
if let Some(updates_tx) = updates_tx.as_ref() {
|
||||
// trigger an empty `UpdateRepository` to ensure remote active_repo_id is set correctly
|
||||
updates_tx
|
||||
.unbounded_send(DownstreamUpdate::UpdateRepository(repo.snapshot()))
|
||||
.ok();
|
||||
}
|
||||
repo.schedule_scan(updates_tx.clone(), cx);
|
||||
repo
|
||||
});
|
||||
self._subscriptions
|
||||
.push(cx.subscribe(&repo, Self::on_repository_event));
|
||||
self._subscriptions
|
||||
.push(cx.subscribe(&repo, Self::on_jobs_updated));
|
||||
self.repositories.insert(id, repo);
|
||||
self.worktree_ids.insert(id, HashSet::from([worktree_id]));
|
||||
cx.emit(GitStoreEvent::RepositoryAdded);
|
||||
self.refresh_diff_base_for_repo(id, cx);
|
||||
self.active_repo_id.get_or_insert_with(|| {
|
||||
cx.emit(GitStoreEvent::ActiveRepositoryChanged(Some(id)));
|
||||
id
|
||||
});
|
||||
let Some(worktree) = self
|
||||
.worktree_store
|
||||
.read(cx)
|
||||
.worktree_for_id(worktree_id, cx)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let parked_repository = ParkedRepository {
|
||||
worktree_id,
|
||||
work_directory_abs_path: work_directory_abs_path.clone(),
|
||||
dot_git_abs_path: dot_git_abs_path.clone(),
|
||||
repository_dir_abs_path: repository_dir_abs_path.clone(),
|
||||
common_dir_abs_path: common_dir_abs_path.clone(),
|
||||
};
|
||||
if self.should_park_repository(&parked_repository, &worktree, cx) {
|
||||
self.parked_repositories.retain(|parked| {
|
||||
parked.worktree_id != parked_repository.worktree_id
|
||||
|| parked.work_directory_abs_path
|
||||
!= parked_repository.work_directory_abs_path
|
||||
});
|
||||
self.parked_repositories.push(parked_repository);
|
||||
} else {
|
||||
self.register_local_repository(parked_repository, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2419,6 +2442,271 @@ impl GitStore {
|
||||
}
|
||||
}
|
||||
|
||||
fn retarget_parked_repositories(&mut self, update: &UpdatedGitRepository) {
|
||||
match (
|
||||
&update.old_work_directory_abs_path,
|
||||
&update.new_work_directory_abs_path,
|
||||
) {
|
||||
(Some(old_work_directory_abs_path), Some(new_work_directory_abs_path))
|
||||
if old_work_directory_abs_path != new_work_directory_abs_path =>
|
||||
{
|
||||
if let (
|
||||
Some(dot_git_abs_path),
|
||||
Some(repository_dir_abs_path),
|
||||
Some(common_dir_abs_path),
|
||||
) = (
|
||||
&update.dot_git_abs_path,
|
||||
&update.repository_dir_abs_path,
|
||||
&update.common_dir_abs_path,
|
||||
) {
|
||||
for worktree_id in self.take_parked_at(old_work_directory_abs_path) {
|
||||
let already_parked = self.parked_repositories.iter().any(|parked| {
|
||||
parked.worktree_id == worktree_id
|
||||
&& parked.work_directory_abs_path == *new_work_directory_abs_path
|
||||
});
|
||||
if !already_parked {
|
||||
self.parked_repositories.push(ParkedRepository {
|
||||
worktree_id,
|
||||
work_directory_abs_path: new_work_directory_abs_path.clone(),
|
||||
dot_git_abs_path: dot_git_abs_path.clone(),
|
||||
repository_dir_abs_path: repository_dir_abs_path.clone(),
|
||||
common_dir_abs_path: common_dir_abs_path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.parked_repositories.retain(|parked| {
|
||||
&parked.work_directory_abs_path != old_work_directory_abs_path
|
||||
});
|
||||
}
|
||||
}
|
||||
(Some(old_work_directory_abs_path), None) => {
|
||||
self.parked_repositories.retain(|parked| {
|
||||
&parked.work_directory_abs_path != old_work_directory_abs_path
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_park_repository(
|
||||
&self,
|
||||
repository: &ParkedRepository,
|
||||
worktree: &Entity<Worktree>,
|
||||
cx: &App,
|
||||
) -> bool {
|
||||
if !Self::parking_enabled(repository.worktree_id, cx) {
|
||||
return false;
|
||||
}
|
||||
let worktree = worktree.read(cx);
|
||||
if worktree.root_repo_common_dir().is_some() {
|
||||
return false;
|
||||
}
|
||||
let deep_enough = match repository
|
||||
.work_directory_abs_path
|
||||
.strip_prefix(worktree.abs_path())
|
||||
{
|
||||
Ok(relative_path) => relative_path.components().count() >= MIN_PARKED_REPOSITORY_DEPTH,
|
||||
Err(_) => false,
|
||||
};
|
||||
deep_enough
|
||||
&& !self.repositories.values().any(|active_repository| {
|
||||
repository
|
||||
.work_directory_abs_path
|
||||
.starts_with(&active_repository.read(cx).work_directory_abs_path)
|
||||
})
|
||||
&& !self.buffer_store.read(cx).buffers().any(|buffer| {
|
||||
buffer
|
||||
.read(cx)
|
||||
.project_path(cx)
|
||||
.and_then(|project_path| {
|
||||
self.worktree_store.read(cx).absolutize(&project_path, cx)
|
||||
})
|
||||
.is_some_and(|abs_path| {
|
||||
abs_path.starts_with(&repository.work_directory_abs_path)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn parking_enabled(worktree_id: WorktreeId, cx: &App) -> bool {
|
||||
let settings_location = SettingsLocation {
|
||||
worktree_id,
|
||||
path: RelPath::empty(),
|
||||
};
|
||||
WorktreeSettings::get(Some(settings_location), cx)
|
||||
.file_scan_depth
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn take_parked_at(&mut self, work_directory_abs_path: &Path) -> HashSet<WorktreeId> {
|
||||
let mut worktree_ids = HashSet::default();
|
||||
self.parked_repositories.retain(|parked| {
|
||||
if parked.work_directory_abs_path.as_ref() == work_directory_abs_path {
|
||||
worktree_ids.insert(parked.worktree_id);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
worktree_ids
|
||||
}
|
||||
|
||||
fn activate_parked_repositories(
|
||||
&mut self,
|
||||
mut should_activate: impl FnMut(&ParkedRepository) -> bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.parked_repositories.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (to_activate, to_keep) = mem::take(&mut self.parked_repositories)
|
||||
.into_iter()
|
||||
.partition::<Vec<_>, _>(|parked| should_activate(parked));
|
||||
self.parked_repositories = to_keep;
|
||||
for parked in to_activate {
|
||||
self.register_local_repository(parked, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn activate_parked_repositories_where_parking_disabled(&mut self, cx: &mut Context<Self>) {
|
||||
let activatable_worktree_ids = self
|
||||
.parked_repositories
|
||||
.iter()
|
||||
.map(|parked| parked.worktree_id)
|
||||
.filter(|worktree_id| !Self::parking_enabled(*worktree_id, cx))
|
||||
.collect::<HashSet<_>>();
|
||||
if activatable_worktree_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.activate_parked_repositories(
|
||||
|parked| activatable_worktree_ids.contains(&parked.worktree_id),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn register_local_repository(&mut self, repository: ParkedRepository, cx: &mut Context<Self>) {
|
||||
if !matches!(self.state, GitStoreState::Local { .. }) {
|
||||
return;
|
||||
}
|
||||
let mut worktree_ids = self.take_parked_at(&repository.work_directory_abs_path);
|
||||
worktree_ids.insert(repository.worktree_id);
|
||||
if let Some((&existing_id, _)) = self.repositories.iter().find(|(_, existing)| {
|
||||
existing.read(cx).work_directory_abs_path == repository.work_directory_abs_path
|
||||
}) {
|
||||
self.worktree_ids
|
||||
.entry(existing_id)
|
||||
.or_default()
|
||||
.extend(worktree_ids);
|
||||
self.activate_parked_repositories_under(&repository.work_directory_abs_path, cx);
|
||||
return;
|
||||
}
|
||||
let GitStoreState::Local {
|
||||
next_repository_id,
|
||||
downstream,
|
||||
project_environment,
|
||||
fs,
|
||||
..
|
||||
} = &self.state
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let updates_tx = downstream
|
||||
.as_ref()
|
||||
.map(|downstream| downstream.updates_tx.clone());
|
||||
let project_environment = project_environment.downgrade();
|
||||
let fs = fs.clone();
|
||||
let is_trusted = TrustedWorktrees::try_get_global(cx)
|
||||
.map(|trusted_worktrees| {
|
||||
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
|
||||
trusted_worktrees.can_trust(&self.worktree_store, repository.worktree_id, cx)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let id = RepositoryId(next_repository_id.fetch_add(1, atomic::Ordering::Release));
|
||||
let git_store = cx.weak_entity();
|
||||
let repo = cx.new(|cx| {
|
||||
let mut repo = Repository::local(
|
||||
id,
|
||||
repository.work_directory_abs_path.clone(),
|
||||
repository.repository_dir_abs_path.clone(),
|
||||
repository.common_dir_abs_path.clone(),
|
||||
repository.dot_git_abs_path.clone(),
|
||||
project_environment,
|
||||
fs,
|
||||
is_trusted,
|
||||
git_store,
|
||||
cx,
|
||||
);
|
||||
if let Some(updates_tx) = updates_tx.as_ref() {
|
||||
// trigger an empty `UpdateRepository` to ensure remote active_repo_id is set correctly
|
||||
updates_tx
|
||||
.unbounded_send(DownstreamUpdate::UpdateRepository(repo.snapshot()))
|
||||
.ok();
|
||||
}
|
||||
repo.schedule_scan(updates_tx, cx);
|
||||
repo
|
||||
});
|
||||
self._subscriptions
|
||||
.push(cx.subscribe(&repo, Self::on_repository_event));
|
||||
self._subscriptions
|
||||
.push(cx.subscribe(&repo, Self::on_jobs_updated));
|
||||
self.repositories.insert(id, repo);
|
||||
self.worktree_ids.insert(id, worktree_ids);
|
||||
cx.emit(GitStoreEvent::RepositoryAdded);
|
||||
self.refresh_diff_base_for_repo(id, cx);
|
||||
self.active_repo_id.get_or_insert_with(|| {
|
||||
cx.emit(GitStoreEvent::ActiveRepositoryChanged(Some(id)));
|
||||
id
|
||||
});
|
||||
self.activate_parked_repositories_under(&repository.work_directory_abs_path, cx);
|
||||
}
|
||||
|
||||
fn activate_parked_repositories_under(
|
||||
&mut self,
|
||||
work_directory_abs_path: &Path,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.activate_parked_repositories(
|
||||
|parked| {
|
||||
parked
|
||||
.work_directory_abs_path
|
||||
.starts_with(work_directory_abs_path)
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn parked_repositories(&self) -> &[ParkedRepository] {
|
||||
&self.parked_repositories
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn activate_all_parked_repositories(&mut self, cx: &mut Context<Self>) {
|
||||
self.activate_parked_repositories(|_| true, cx);
|
||||
}
|
||||
|
||||
fn activate_parked_repositories_for_buffer(
|
||||
&mut self,
|
||||
buffer: &Entity<Buffer>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.parked_repositories.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(abs_path) = buffer
|
||||
.read(cx)
|
||||
.project_path(cx)
|
||||
.and_then(|project_path| self.worktree_store.read(cx).absolutize(&project_path, cx))
|
||||
{
|
||||
self.activate_parked_repositories(
|
||||
|parked| abs_path.starts_with(&parked.work_directory_abs_path),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_trusted_worktrees_event(
|
||||
&mut self,
|
||||
_: Entity<TrustedWorktreesStore>,
|
||||
@@ -2460,6 +2748,7 @@ impl GitStore {
|
||||
) {
|
||||
match event {
|
||||
BufferStoreEvent::BufferAdded(buffer) => {
|
||||
self.activate_parked_repositories_for_buffer(buffer, cx);
|
||||
cx.subscribe(buffer, |this, buffer, event, cx| {
|
||||
if let BufferEvent::LanguageChanged(_) = event {
|
||||
let buffer_id = buffer.read(cx).remote_id();
|
||||
@@ -2486,6 +2775,7 @@ impl GitStore {
|
||||
}
|
||||
}
|
||||
BufferStoreEvent::BufferChangedFilePath { buffer, .. } => {
|
||||
self.activate_parked_repositories_for_buffer(buffer, cx);
|
||||
// Whenever a buffer's file path changes, it's possible that the
|
||||
// new path is actually a path that is being tracked by a git
|
||||
// repository. In that case, we'll want to update the buffer's
|
||||
|
||||
@@ -1796,3 +1796,598 @@ mod resolve_worktree_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod repository_activation_tests {
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use fs::{FakeFs, Fs, RemoveOptions};
|
||||
use gpui::{BorrowAppContext, Entity, TestAppContext};
|
||||
use serde_json::json;
|
||||
use settings::{LocalSettingsKind, LocalSettingsPath, SettingsStore};
|
||||
use util::{
|
||||
path,
|
||||
rel_path::{RelPath, rel_path},
|
||||
};
|
||||
|
||||
use crate::{Project, ProjectPath};
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_deep_repositories_park_in_unmanaged_root(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let (_fs, project) = build_project(
|
||||
cx,
|
||||
json!({
|
||||
"repo1": {
|
||||
".git": {},
|
||||
"a.txt": ""
|
||||
},
|
||||
"nested": {
|
||||
"deep": {
|
||||
"repo2": {
|
||||
".git": {},
|
||||
"src": {
|
||||
"b.txt": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_repositories(
|
||||
&project,
|
||||
cx,
|
||||
&[path!("/root/repo1")],
|
||||
&[path!("/root/nested/deep/repo2")],
|
||||
);
|
||||
project.read_with(cx, |project, cx| {
|
||||
let worktree = project.worktrees(cx).next().unwrap().read(cx);
|
||||
assert_eq!(
|
||||
worktree
|
||||
.entry_for_path(rel_path("nested/deep/repo2/src/b.txt"))
|
||||
.map(|entry| entry.path.as_ref()),
|
||||
Some(rel_path("nested/deep/repo2/src/b.txt"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_managed_root_never_parks(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let (_fs, project) = build_project(
|
||||
cx,
|
||||
json!({
|
||||
".git": {},
|
||||
"vendor": {
|
||||
"x": {
|
||||
".git": {},
|
||||
"a.txt": ""
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_repositories(
|
||||
&project,
|
||||
cx,
|
||||
&[path!("/root"), path!("/root/vendor/x")],
|
||||
&[],
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_parked_repository_activates_on_buffer_open(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let (_fs, project) = build_project(
|
||||
cx,
|
||||
json!({
|
||||
"nested": {
|
||||
"outer": {
|
||||
".git": {},
|
||||
"inner": {
|
||||
".git": {},
|
||||
"a.txt": ""
|
||||
},
|
||||
"b.txt": ""
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_repositories(
|
||||
&project,
|
||||
cx,
|
||||
&[],
|
||||
&[
|
||||
path!("/root/nested/outer"),
|
||||
path!("/root/nested/outer/inner"),
|
||||
],
|
||||
);
|
||||
|
||||
let _buffer = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_local_buffer(path!("/root/nested/outer/inner/a.txt"), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
assert_repositories(
|
||||
&project,
|
||||
cx,
|
||||
&[
|
||||
path!("/root/nested/outer"),
|
||||
path!("/root/nested/outer/inner"),
|
||||
],
|
||||
&[],
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_parked_repository_activates_on_save_as(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let (_fs, project) = build_project(
|
||||
cx,
|
||||
json!({
|
||||
"a": {
|
||||
"repo": {
|
||||
".git": {},
|
||||
"x.txt": ""
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_repositories(&project, cx, &[], &[path!("/root/a/repo")]);
|
||||
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| project.create_buffer(None, false, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let worktree_id = project.read_with(cx, |project, cx| {
|
||||
project.worktrees(cx).next().unwrap().read(cx).id()
|
||||
});
|
||||
project
|
||||
.update(cx, |project, cx| {
|
||||
project.save_buffer_as(
|
||||
buffer,
|
||||
ProjectPath {
|
||||
worktree_id,
|
||||
path: rel_path("a/repo/new.txt").into(),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
assert_repositories(&project, cx, &[path!("/root/a/repo")], &[]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_buffer_open_beyond_scan_horizon_activates_repository(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
set_file_scan_depth(cx, 2);
|
||||
let (_fs, project) = build_project(
|
||||
cx,
|
||||
json!({
|
||||
"a": {
|
||||
"b": {
|
||||
"repo": {
|
||||
".git": {},
|
||||
"src": {
|
||||
"deep": {
|
||||
"f.txt": ""
|
||||
},
|
||||
"g.txt": ""
|
||||
},
|
||||
"docs": {
|
||||
"h.txt": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_repositories(&project, cx, &[], &[]);
|
||||
project.read_with(cx, |project, cx| {
|
||||
let worktree = project.worktrees(cx).next().unwrap().read(cx);
|
||||
assert_eq!(worktree.entry_for_path(rel_path("a/b/repo")), None);
|
||||
});
|
||||
|
||||
let _buffer = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_local_buffer(path!("/root/a/b/repo/src/deep/f.txt"), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
assert_repositories(&project, cx, &[path!("/root/a/b/repo")], &[]);
|
||||
project.read_with(cx, |project, cx| {
|
||||
let worktree = project.worktrees(cx).next().unwrap().read(cx);
|
||||
for path in [
|
||||
"a/b/repo/src/deep/f.txt",
|
||||
"a/b/repo/src/g.txt",
|
||||
"a/b/repo/docs/h.txt",
|
||||
] {
|
||||
assert_eq!(
|
||||
worktree
|
||||
.entry_for_path(rel_path(path))
|
||||
.map(|entry| entry.path.as_ref()),
|
||||
Some(rel_path(path))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_project_settings_disable_parking(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let (_fs, project) = build_project(
|
||||
cx,
|
||||
json!({
|
||||
"a": {
|
||||
"repo": {
|
||||
".git": {},
|
||||
"x.txt": ""
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_repositories(&project, cx, &[], &[path!("/root/a/repo")]);
|
||||
|
||||
let worktree_id = project.read_with(cx, |project, cx| {
|
||||
project.worktrees(cx).next().unwrap().read(cx).id()
|
||||
});
|
||||
cx.update(|cx| {
|
||||
cx.update_global::<SettingsStore, _>(|store, cx| {
|
||||
store
|
||||
.set_local_settings(
|
||||
worktree_id,
|
||||
LocalSettingsPath::InWorktree(Arc::from(RelPath::empty())),
|
||||
LocalSettingsKind::Settings,
|
||||
Some(r#"{ "file_scan_depth": 0 }"#),
|
||||
cx,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
});
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
assert_repositories(&project, cx, &[path!("/root/a/repo")], &[]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_open_buffer_prevents_parking_late_discovered_repository(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let (fs, project) = build_project(
|
||||
cx,
|
||||
json!({
|
||||
"nested": {
|
||||
"repo": {
|
||||
"a.txt": ""
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let _buffer = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_local_buffer(path!("/root/nested/repo/a.txt"), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
fs.create_dir(Path::new(path!("/root/nested/repo/.git")))
|
||||
.await
|
||||
.unwrap();
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
assert_repositories(&project, cx, &[path!("/root/nested/repo")], &[]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_parked_repository_dropped_when_dot_git_removed(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let (fs, project) = build_project(
|
||||
cx,
|
||||
json!({
|
||||
"nested": {
|
||||
"repo": {
|
||||
".git": {},
|
||||
"a.txt": ""
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_repositories(&project, cx, &[], &[path!("/root/nested/repo")]);
|
||||
|
||||
fs.remove_dir(
|
||||
Path::new(path!("/root/nested/repo/.git")),
|
||||
RemoveOptions {
|
||||
recursive: true,
|
||||
ignore_if_not_exists: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
assert_repositories(&project, cx, &[], &[]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_repository_shared_between_two_worktrees(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let (_fs, project) = build_project(
|
||||
cx,
|
||||
json!({
|
||||
"nested": {
|
||||
"repo": {
|
||||
".git": {},
|
||||
"a.txt": ""
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_repositories(&project, cx, &[], &[path!("/root/nested/repo")]);
|
||||
|
||||
let worktree = project
|
||||
.update(cx, |project, cx| {
|
||||
project.worktree_store().update(cx, |worktree_store, cx| {
|
||||
worktree_store.create_worktree(path!("/root/nested/repo"), true, cx)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
worktree
|
||||
.read_with(cx, |worktree, _| {
|
||||
worktree.as_local().unwrap().scan_complete()
|
||||
})
|
||||
.await;
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
assert_repositories(&project, cx, &[path!("/root/nested/repo")], &[]);
|
||||
|
||||
project.update(cx, |project, cx| {
|
||||
project.git_store().update(cx, |git_store, cx| {
|
||||
git_store.activate_all_parked_repositories(cx);
|
||||
});
|
||||
});
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
assert_repositories(&project, cx, &[path!("/root/nested/repo")], &[]);
|
||||
|
||||
let second_worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
|
||||
project.update(cx, |project, cx| {
|
||||
project.worktree_store().update(cx, |worktree_store, cx| {
|
||||
worktree_store.remove_worktree(second_worktree_id, cx);
|
||||
})
|
||||
});
|
||||
drop(worktree);
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
assert_repositories(&project, cx, &[path!("/root/nested/repo")], &[]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_nested_repository_inside_active_repository_not_parked(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let (_fs, project) = build_project(
|
||||
cx,
|
||||
json!({
|
||||
"x": {
|
||||
".git": {},
|
||||
"vendor": {
|
||||
"sub": {
|
||||
".git": {},
|
||||
"f.txt": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_repositories(
|
||||
&project,
|
||||
cx,
|
||||
&[path!("/root/x"), path!("/root/x/vendor/sub")],
|
||||
&[],
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_active_repository_survives_file_scan_depth_tightening(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
set_file_scan_depth(cx, 0);
|
||||
let (_fs, project) = build_project(
|
||||
cx,
|
||||
json!({
|
||||
"a": {
|
||||
"repo": {
|
||||
".git": {},
|
||||
"x.txt": ""
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_repositories(&project, cx, &[path!("/root/a/repo")], &[]);
|
||||
|
||||
set_file_scan_depth(cx, 1);
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
assert_repositories(&project, cx, &[path!("/root/a/repo")], &[]);
|
||||
project.read_with(cx, |project, cx| {
|
||||
let worktree = project.worktrees(cx).next().unwrap().read(cx);
|
||||
assert_eq!(worktree.entry_for_path(rel_path("a/repo/x.txt")), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_open_buffer_survives_file_scan_depth_tightening(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let (_fs, project) = build_project(
|
||||
cx,
|
||||
json!({
|
||||
"junk": {
|
||||
"a": {
|
||||
"b": {
|
||||
"deep.txt": "content",
|
||||
"sibling.txt": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_local_buffer(path!("/root/junk/a/b/deep.txt"), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
let disk_state_before =
|
||||
buffer.read_with(cx, |buffer, _| buffer.file().unwrap().disk_state());
|
||||
|
||||
set_file_scan_depth(cx, 1);
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
buffer.read_with(cx, |buffer, _| {
|
||||
assert_eq!(buffer.file().unwrap().disk_state(), disk_state_before);
|
||||
});
|
||||
project.read_with(cx, |project, cx| {
|
||||
let worktree = project.worktrees(cx).next().unwrap().read(cx);
|
||||
assert_eq!(
|
||||
worktree
|
||||
.entry_for_path(rel_path("junk/a/b/deep.txt"))
|
||||
.map(|entry| entry.path.as_ref()),
|
||||
Some(rel_path("junk/a/b/deep.txt"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_git_init_at_root_activates_parked_repositories(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let (fs, project) = build_project(
|
||||
cx,
|
||||
json!({
|
||||
"nested": {
|
||||
"repo": {
|
||||
".git": {},
|
||||
"a.txt": ""
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_repositories(&project, cx, &[], &[path!("/root/nested/repo")]);
|
||||
|
||||
fs.create_dir(Path::new(path!("/root/.git"))).await.unwrap();
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
assert_repositories(
|
||||
&project,
|
||||
cx,
|
||||
&[path!("/root"), path!("/root/nested/repo")],
|
||||
&[],
|
||||
);
|
||||
}
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) {
|
||||
zlog::init_test();
|
||||
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
});
|
||||
}
|
||||
|
||||
async fn build_project(
|
||||
cx: &mut TestAppContext,
|
||||
tree: serde_json::Value,
|
||||
) -> (Arc<FakeFs>, Entity<Project>) {
|
||||
let fs = FakeFs::new(cx.background_executor.clone());
|
||||
fs.insert_tree(path!("/root"), tree).await;
|
||||
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
|
||||
cx.executor().run_until_parked();
|
||||
(fs, project)
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_repositories(
|
||||
project: &Entity<Project>,
|
||||
cx: &mut TestAppContext,
|
||||
expected_active: &[&str],
|
||||
expected_parked: &[&str],
|
||||
) {
|
||||
let (mut active, mut parked) = project.read_with(cx, |project, cx| {
|
||||
(
|
||||
project
|
||||
.repositories(cx)
|
||||
.values()
|
||||
.map(|repository| repository.read(cx).work_directory_abs_path.to_path_buf())
|
||||
.collect::<Vec<_>>(),
|
||||
project
|
||||
.git_store()
|
||||
.read(cx)
|
||||
.parked_repositories()
|
||||
.iter()
|
||||
.map(|parked| parked.work_directory_abs_path().to_path_buf())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
});
|
||||
active.sort();
|
||||
parked.sort();
|
||||
assert_eq!(
|
||||
active,
|
||||
expected_active
|
||||
.iter()
|
||||
.map(PathBuf::from)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
parked,
|
||||
expected_parked
|
||||
.iter()
|
||||
.map(PathBuf::from)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
fn set_file_scan_depth(cx: &mut TestAppContext, depth: u32) {
|
||||
cx.update(|cx| {
|
||||
cx.update_global::<SettingsStore, _>(|store, cx| {
|
||||
store.update_user_settings(cx, |settings| {
|
||||
settings.project.worktree.file_scan_depth = Some(depth);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14020,6 +14020,16 @@ async fn test_rename_work_directory(cx: &mut gpui::TestAppContext) {
|
||||
.update(cx, |project, cx| project.git_scans_complete(cx))
|
||||
.await;
|
||||
cx.executor().run_until_parked();
|
||||
let _buffer = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_local_buffer(root_path.join("projects/project1/a"), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
project
|
||||
.update(cx, |project, cx| project.git_scans_complete(cx))
|
||||
.await;
|
||||
cx.executor().run_until_parked();
|
||||
|
||||
let repository = project.read_with(cx, |project, cx| {
|
||||
project.repositories(cx).values().next().unwrap().clone()
|
||||
|
||||
@@ -11315,6 +11315,7 @@ pub(crate) fn init_test(cx: &mut TestAppContext) {
|
||||
.get_or_insert_default()
|
||||
.auto_fold_dirs = Some(false);
|
||||
settings.project.worktree.file_scan_exclusions = Some(Vec::new());
|
||||
settings.project.worktree.file_scan_depth = Some(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11334,7 +11335,8 @@ fn init_test_with_editor(cx: &mut TestAppContext) {
|
||||
.project_panel
|
||||
.get_or_insert_default()
|
||||
.auto_fold_dirs = Some(false);
|
||||
settings.project.worktree.file_scan_exclusions = Some(Vec::new())
|
||||
settings.project.worktree.file_scan_exclusions = Some(Vec::new());
|
||||
settings.project.worktree.file_scan_depth = Some(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11355,7 +11357,8 @@ fn init_test_with_git_ui(cx: &mut TestAppContext) {
|
||||
.project_panel
|
||||
.get_or_insert_default()
|
||||
.auto_fold_dirs = Some(false);
|
||||
settings.project.worktree.file_scan_exclusions = Some(Vec::new())
|
||||
settings.project.worktree.file_scan_exclusions = Some(Vec::new());
|
||||
settings.project.worktree.file_scan_depth = Some(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ message Entry {
|
||||
optional uint64 size = 11;
|
||||
optional string canonical_path = 12;
|
||||
bool is_hidden = 13;
|
||||
bool is_unloaded = 14;
|
||||
}
|
||||
|
||||
message AddWorktree {
|
||||
|
||||
@@ -1092,6 +1092,7 @@ impl VsCodeSettings {
|
||||
fn worktree_settings_content(&self) -> WorktreeSettingsContent {
|
||||
WorktreeSettingsContent {
|
||||
prevent_sharing_in_public_channels: false,
|
||||
file_scan_depth: None,
|
||||
file_scan_exclusions: self
|
||||
.read_value("files.watcherExclude")
|
||||
.and_then(|v| v.as_array())
|
||||
|
||||
@@ -149,6 +149,17 @@ pub struct WorktreeSettingsContent {
|
||||
/// Default: expanded
|
||||
pub scan_symlinks: Option<ScanSymlinksSetting>,
|
||||
|
||||
/// Maximum directory depth to eagerly index outside of git repositories;
|
||||
/// contents of directories at this depth or deeper are indexed on demand.
|
||||
/// Repositories rooted shallower than this depth are always indexed fully.
|
||||
/// In projects that are not rooted at a git repository, repositories directly
|
||||
/// inside a root folder activate their git features immediately; deeper ones
|
||||
/// activate on first use.
|
||||
/// `0` means no limit and activates all git repositories immediately.
|
||||
///
|
||||
/// Default: 5
|
||||
pub file_scan_depth: Option<u32>,
|
||||
|
||||
/// Treat the files matching these globs as `.env` files.
|
||||
/// Default: ["**/.env*", "**/*.pem", "**/*.key", "**/*.cert", "**/*.crt", "**/secrets.yml"]
|
||||
pub private_files: Option<ExtendingVec<String>>,
|
||||
|
||||
@@ -3744,7 +3744,7 @@ fn search_and_files_page() -> SettingsPage {
|
||||
]
|
||||
}
|
||||
|
||||
fn file_scan_section() -> [SettingsPageItem; 6] {
|
||||
fn file_scan_section() -> [SettingsPageItem; 7] {
|
||||
[
|
||||
SettingsPageItem::SectionHeader("File Scan"),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
@@ -3793,6 +3793,22 @@ fn search_and_files_page() -> SettingsPage {
|
||||
metadata: None,
|
||||
files: USER,
|
||||
}),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "File Scan Depth",
|
||||
description: "Maximum directory depth to eagerly index outside of git repositories; contents of directories at this depth or deeper are indexed on demand. Repositories rooted shallower than this depth are always indexed fully. In projects that are not rooted at a git repository, repositories directly inside a root folder activate their git features immediately; deeper ones activate on first use. 0 means no limit and activates all git repositories immediately",
|
||||
field: Box::new(SettingField {
|
||||
organization_override: None,
|
||||
json_path: Some("file_scan_depth"),
|
||||
pick: |settings_content| {
|
||||
settings_content.project.worktree.file_scan_depth.as_ref()
|
||||
},
|
||||
write: |settings_content, value, _| {
|
||||
settings_content.project.worktree.file_scan_depth = value;
|
||||
},
|
||||
}),
|
||||
metadata: None,
|
||||
files: USER | PROJECT,
|
||||
}),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Scan Symbolic Links",
|
||||
description: "When to scan content of linked directories",
|
||||
|
||||
@@ -1130,6 +1130,7 @@ async fn test_remote_project_root_dir_changes_update_groups(cx: &mut TestAppCont
|
||||
is_fifo: false,
|
||||
size: None,
|
||||
canonical_path: None,
|
||||
is_unloaded: false,
|
||||
}],
|
||||
removed_entries: vec![],
|
||||
scan_id: 1,
|
||||
|
||||
@@ -2815,6 +2815,10 @@ impl Snapshot {
|
||||
self.entries_by_path.summary().non_ignored_file_count
|
||||
}
|
||||
|
||||
pub fn deferred_scan_dir_count(&self) -> usize {
|
||||
self.entries_by_path.summary().deferred_scan_dir_count
|
||||
}
|
||||
|
||||
fn traverse_from_offset(
|
||||
&self,
|
||||
include_files: bool,
|
||||
@@ -3157,10 +3161,23 @@ impl LocalSnapshot {
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn expanded_entries(&self) -> impl Iterator<Item = &Entry> {
|
||||
self.entries_by_path
|
||||
.cursor::<()>(())
|
||||
.filter(|entry| entry.kind == EntryKind::Dir && (entry.is_external || entry.is_ignored))
|
||||
pub fn expanded_entries(&self, file_scan_depth: Option<u32>) -> impl Iterator<Item = &Entry> {
|
||||
let file_scan_depth = if self.root_repo_common_dir.is_some() {
|
||||
None
|
||||
} else {
|
||||
file_scan_depth
|
||||
};
|
||||
self.entries_by_path.cursor::<()>(()).filter(move |entry| {
|
||||
entry.kind == EntryKind::Dir
|
||||
&& (entry.is_external
|
||||
|| entry.is_ignored
|
||||
|| (!entry.is_always_included
|
||||
&& is_beyond_scan_depth(file_scan_depth, &entry.path)
|
||||
&& !self
|
||||
.git_repositories
|
||||
.values()
|
||||
.any(|repo| repo.work_directory.directory_contains(&entry.path))))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
@@ -3415,9 +3432,11 @@ impl BackgroundScannerState {
|
||||
watcher: &dyn Watcher,
|
||||
preserve_repository_watches: bool,
|
||||
) {
|
||||
// When the caller preserves repository watches, it intends to re-scan
|
||||
// this subtree and keep its git repositories; pruning them here would
|
||||
// transiently drop and then re-create them with fresh `RepositoryId`s.
|
||||
// When the caller preserves repository watches, the removal must not
|
||||
// prune git repositories: either the subtree is about to be re-scanned,
|
||||
// or its entries are being unloaded by depth deferral while the
|
||||
// repositories stay active. Pruning here would transiently drop and
|
||||
// then re-create them with fresh `RepositoryId`s.
|
||||
let prune_repositories = !preserve_repository_watches;
|
||||
let removed_descendant_abs_paths = self.remove_path_from_snapshot(path, prune_repositories);
|
||||
self.unwatch_path(
|
||||
@@ -4216,6 +4235,12 @@ impl sum_tree::Item for Entry {
|
||||
non_ignored_count,
|
||||
file_count,
|
||||
non_ignored_file_count,
|
||||
deferred_scan_dir_count: usize::from(
|
||||
self.kind == EntryKind::UnloadedDir
|
||||
&& !self.is_ignored
|
||||
&& !self.is_external
|
||||
&& !self.path.is_empty(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4235,6 +4260,7 @@ pub struct EntrySummary {
|
||||
non_ignored_count: usize,
|
||||
file_count: usize,
|
||||
non_ignored_file_count: usize,
|
||||
deferred_scan_dir_count: usize,
|
||||
}
|
||||
|
||||
impl Default for EntrySummary {
|
||||
@@ -4245,6 +4271,7 @@ impl Default for EntrySummary {
|
||||
non_ignored_count: 0,
|
||||
file_count: 0,
|
||||
non_ignored_file_count: 0,
|
||||
deferred_scan_dir_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4260,6 +4287,7 @@ impl sum_tree::ContextLessSummary for EntrySummary {
|
||||
self.non_ignored_count += rhs.non_ignored_count;
|
||||
self.file_count += rhs.file_count;
|
||||
self.non_ignored_file_count += rhs.non_ignored_file_count;
|
||||
self.deferred_scan_dir_count += rhs.deferred_scan_dir_count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4486,6 +4514,17 @@ impl BackgroundScanner {
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.snapshot.completed_scan_id = state.snapshot.scan_id;
|
||||
if state.scanning_enabled {
|
||||
let deferred_scan_dir_count = state.snapshot.deferred_scan_dir_count();
|
||||
if deferred_scan_dir_count > 0
|
||||
&& let Some(file_scan_depth) = self.settings.file_scan_depth
|
||||
{
|
||||
log::info!(
|
||||
"deferred indexing of {deferred_scan_dir_count} directories in {:?} that are outside of git repositories and deeper than file_scan_depth={file_scan_depth}",
|
||||
root_abs_path.as_path(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.send_status_update(false, SmallVec::new(), &[]).await;
|
||||
@@ -5547,10 +5586,20 @@ impl BackgroundScanner {
|
||||
for entry in &mut new_entries {
|
||||
state.reuse_entry_id(entry);
|
||||
if entry.is_dir() {
|
||||
if !self.should_scan_directory(&state, entry) {
|
||||
if !self.should_scan_directory(&state, entry, ignore_stack.repo_root.is_some()) {
|
||||
log::debug!("defer scanning directory {:?}", entry.path);
|
||||
entry.kind = EntryKind::UnloadedDir;
|
||||
new_jobs[job_ix] = None;
|
||||
if !entry.is_ignored
|
||||
&& !entry.is_external
|
||||
&& state.snapshot.child_entries(&entry.path).next().is_some()
|
||||
{
|
||||
state.remove_path_from_snapshot_and_unwatch(
|
||||
&entry.path,
|
||||
self.watcher.as_ref(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
job_ix += 1;
|
||||
}
|
||||
@@ -5713,10 +5762,13 @@ 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 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)))
|
||||
if self.should_scan_directory(
|
||||
&state,
|
||||
&fs_entry,
|
||||
ignore_stack.repo_root.is_some(),
|
||||
) || (self.track_git_repositories
|
||||
&& fs_entry.path.is_empty()
|
||||
&& abs_path.file_name() == Some(OsStr::new(DOT_GIT)))
|
||||
{
|
||||
state
|
||||
.enqueue_scan_dir(
|
||||
@@ -5999,9 +6051,7 @@ impl BackgroundScanner {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(Some(metadata)) = self.fs.metadata(&job.abs_path.join(DOT_GIT)).await
|
||||
&& metadata.is_dir
|
||||
{
|
||||
if let Ok(Some(_)) = self.fs.metadata(&job.abs_path.join(DOT_GIT)).await {
|
||||
ignore_stack.repo_root = Some(job.abs_path.clone());
|
||||
}
|
||||
|
||||
@@ -6017,10 +6067,16 @@ impl BackgroundScanner {
|
||||
ignore_stack.clone()
|
||||
};
|
||||
|
||||
// Scan any directories that were previously ignored and weren't previously scanned.
|
||||
if was_ignored && !entry.is_ignored && entry.kind.is_unloaded() {
|
||||
// Scan any unloaded directories that became scannable: no longer
|
||||
// ignored, or newly inside a repository that exempts them from
|
||||
// the scan depth limit.
|
||||
if !entry.is_ignored
|
||||
&& entry.kind.is_unloaded()
|
||||
&& (was_ignored || ignore_stack.repo_root.is_some())
|
||||
{
|
||||
let state = self.state.lock().await;
|
||||
if self.should_scan_directory(&state, &entry) {
|
||||
if self.should_scan_directory(&state, &entry, ignore_stack.repo_root.is_some())
|
||||
{
|
||||
state
|
||||
.enqueue_scan_dir(
|
||||
abs_path.clone(),
|
||||
@@ -6191,11 +6247,18 @@ impl BackgroundScanner {
|
||||
!self.share_private_files && self.settings.is_path_private(path)
|
||||
}
|
||||
|
||||
fn should_scan_directory(&self, state: &BackgroundScannerState, entry: &Entry) -> bool {
|
||||
fn should_scan_directory(
|
||||
&self,
|
||||
state: &BackgroundScannerState,
|
||||
entry: &Entry,
|
||||
in_repo: bool,
|
||||
) -> bool {
|
||||
let beyond_scan_depth =
|
||||
!in_repo && is_beyond_scan_depth(self.settings.file_scan_depth, &entry.path);
|
||||
let scannable = state.scanning_enabled
|
||||
&& (!entry.is_external
|
||||
|| self.settings.scan_symlinks == settings::ScanSymlinksSetting::Always)
|
||||
&& (!entry.is_ignored || entry.is_always_included);
|
||||
&& (!(entry.is_ignored || beyond_scan_depth) || entry.is_always_included);
|
||||
|
||||
scannable
|
||||
|| entry.path.file_name() == Some(DOT_GIT)
|
||||
@@ -6439,6 +6502,10 @@ fn build_diff(
|
||||
changes.into()
|
||||
}
|
||||
|
||||
fn is_beyond_scan_depth(file_scan_depth: Option<u32>, path: &RelPath) -> bool {
|
||||
file_scan_depth.is_some_and(|depth| path.components().count() >= depth as usize)
|
||||
}
|
||||
|
||||
fn swap_to_front(child_paths: &mut Vec<PathBuf>, file: &str) {
|
||||
let position = child_paths
|
||||
.iter()
|
||||
@@ -6976,6 +7043,7 @@ impl<'a> From<&'a Entry> for proto::Entry {
|
||||
.canonical_path
|
||||
.as_ref()
|
||||
.map(|path| path.to_string_lossy().into_owned()),
|
||||
is_unloaded: entry.kind == EntryKind::UnloadedDir,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6987,7 +7055,11 @@ impl TryFrom<(&CharBag, &PathMatcher, proto::Entry)> for Entry {
|
||||
(root_char_bag, always_included, entry): (&CharBag, &PathMatcher, proto::Entry),
|
||||
) -> Result<Self> {
|
||||
let kind = if entry.is_dir {
|
||||
EntryKind::Dir
|
||||
if entry.is_unloaded {
|
||||
EntryKind::UnloadedDir
|
||||
} else {
|
||||
EntryKind::Dir
|
||||
}
|
||||
} else {
|
||||
EntryKind::File
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct WorktreeSettings {
|
||||
/// determine whether to terminate worktree scanning for a given dir.
|
||||
pub parent_dir_scan_inclusions: PathMatcher,
|
||||
pub scan_symlinks: ScanSymlinksSetting,
|
||||
pub file_scan_depth: Option<u32>,
|
||||
pub private_files: PathMatcher,
|
||||
pub hidden_files: PathMatcher,
|
||||
pub read_only_files: PathMatcher,
|
||||
@@ -98,6 +99,7 @@ impl Settings for WorktreeSettings {
|
||||
.log_err()
|
||||
.unwrap_or_default(),
|
||||
scan_symlinks,
|
||||
file_scan_depth: worktree.file_scan_depth.filter(|depth| *depth > 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ fn make_settings_with_read_only(patterns: &[&str]) -> WorktreeSettings {
|
||||
)
|
||||
.unwrap(),
|
||||
scan_symlinks: Default::default(),
|
||||
file_scan_depth: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ use anyhow::Result;
|
||||
use encoding_rs;
|
||||
use fs::{FakeFs, Fs, PathEventKind, RealFs, RemoveOptions};
|
||||
use git::{DOT_GIT, GITIGNORE, REPO_EXCLUDE};
|
||||
use gpui::{AppContext as _, BackgroundExecutor, BorrowAppContext, Context, Task, TestAppContext};
|
||||
use gpui::{
|
||||
AppContext as _, BackgroundExecutor, BorrowAppContext, Context, Entity, Task, TestAppContext,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use postage::stream::Stream;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -13,7 +15,7 @@ use rpc::{AnyProtoClient, NoopProtoClient, proto};
|
||||
use worktree::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandle};
|
||||
|
||||
use serde_json::json;
|
||||
use settings::{SettingsStore, WorktreeId};
|
||||
use settings::{LocalSettingsKind, LocalSettingsPath, SettingsStore, WorktreeId};
|
||||
use std::{
|
||||
cell::Cell,
|
||||
env,
|
||||
@@ -3230,8 +3232,11 @@ async fn test_random_worktree_changes(cx: &mut TestAppContext, mut rng: StdRng)
|
||||
|
||||
let snapshot = worktree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot());
|
||||
snapshot.check_invariants(true);
|
||||
let file_scan_depth = worktree.read_with(cx, |tree, _| {
|
||||
tree.as_local().unwrap().settings().file_scan_depth
|
||||
});
|
||||
let expanded_paths = snapshot
|
||||
.expanded_entries()
|
||||
.expanded_entries(file_scan_depth)
|
||||
.map(|e| e.path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -5954,6 +5959,7 @@ async fn test_remote_worktree_without_git_emits_root_repo_event_after_first_upda
|
||||
is_fifo: false,
|
||||
size: None,
|
||||
canonical_path: None,
|
||||
is_unloaded: false,
|
||||
}],
|
||||
removed_entries: vec![],
|
||||
scan_id: 1,
|
||||
@@ -6048,6 +6054,7 @@ async fn test_remote_worktree_with_git_emits_root_repo_event_when_repo_info_arri
|
||||
is_fifo: false,
|
||||
size: None,
|
||||
canonical_path: None,
|
||||
is_unloaded: false,
|
||||
}],
|
||||
removed_entries: vec![],
|
||||
scan_id: 1,
|
||||
@@ -6511,3 +6518,483 @@ fn test_repo_exclude_does_not_match_outside_its_work_directory() {
|
||||
"patterns must not apply outside the repository's work directory"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_file_scan_depth_outside_repo(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
set_file_scan_depth(cx, Some(2));
|
||||
|
||||
let fs = FakeFs::new(cx.background_executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
"junk": {
|
||||
"a": {
|
||||
"b": {
|
||||
"c": {
|
||||
"deep.txt": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo": {
|
||||
".git": {},
|
||||
"src": {
|
||||
"nested": {
|
||||
"deep": {
|
||||
"code.rs": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"top.txt": ""
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
||||
|
||||
tree.read_with(cx, |tree, _| {
|
||||
assert_eq!(
|
||||
tree.entries(true, 0)
|
||||
.map(|entry| entry.path.as_ref())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
rel_path(""),
|
||||
rel_path("junk"),
|
||||
rel_path("junk/a"),
|
||||
rel_path("repo"),
|
||||
rel_path("repo/src"),
|
||||
rel_path("repo/src/nested"),
|
||||
rel_path("repo/src/nested/deep"),
|
||||
rel_path("repo/src/nested/deep/code.rs"),
|
||||
rel_path("top.txt"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
tree.entry_for_path(rel_path("junk/a")).unwrap().kind,
|
||||
EntryKind::UnloadedDir
|
||||
);
|
||||
assert!(!tree.entry_for_path(rel_path("junk/a")).unwrap().is_ignored);
|
||||
assert_eq!(tree.deferred_scan_dir_count(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_file_scan_depth_settings_change(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
set_file_scan_depth(cx, Some(1));
|
||||
|
||||
let fs = FakeFs::new(cx.background_executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
"junk": {
|
||||
"a": {
|
||||
"b": {
|
||||
"deep.txt": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
||||
|
||||
tree.read_with(cx, |tree, _| {
|
||||
assert_eq!(tree.entry_for_path(rel_path("junk/a/b/deep.txt")), None);
|
||||
assert_eq!(tree.deferred_scan_dir_count(), 1);
|
||||
});
|
||||
|
||||
set_file_scan_depth(cx, Some(0));
|
||||
cx.run_until_parked();
|
||||
cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
|
||||
.await;
|
||||
|
||||
tree.read_with(cx, |tree, _| {
|
||||
assert_eq!(
|
||||
tree.entry_for_path(rel_path("junk/a/b/deep.txt"))
|
||||
.map(|entry| entry.path.as_ref()),
|
||||
Some(rel_path("junk/a/b/deep.txt"))
|
||||
);
|
||||
assert_eq!(tree.deferred_scan_dir_count(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_file_scan_depth_inside_repo(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
set_file_scan_depth(cx, Some(1));
|
||||
|
||||
let fs = FakeFs::new(cx.background_executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
".git": {},
|
||||
"a": {
|
||||
"b": {
|
||||
"c": {
|
||||
"deep.txt": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
||||
|
||||
tree.read_with(cx, |tree, _| {
|
||||
assert_eq!(
|
||||
tree.entry_for_path(rel_path("a/b/c/deep.txt"))
|
||||
.map(|entry| entry.path.as_ref()),
|
||||
Some(rel_path("a/b/c/deep.txt"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_file_scan_depth_inside_ancestor_repo(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
set_file_scan_depth(cx, Some(1));
|
||||
|
||||
let fs = FakeFs::new(cx.background_executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
".git": {},
|
||||
"sub": {
|
||||
"a": {
|
||||
"b": {
|
||||
"c": {
|
||||
"deep.txt": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let tree = build_worktree(fs.clone(), path!("/root/sub"), cx).await;
|
||||
|
||||
tree.read_with(cx, |tree, _| {
|
||||
assert_eq!(
|
||||
tree.entry_for_path(rel_path("a/b/c/deep.txt"))
|
||||
.map(|entry| entry.path.as_ref()),
|
||||
Some(rel_path("a/b/c/deep.txt"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_file_scan_depth_pierced_by_inclusions(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
cx.update(|cx| {
|
||||
cx.update_global::<SettingsStore, _>(|store, cx| {
|
||||
store.update_user_settings(cx, |settings| {
|
||||
settings.project.worktree.file_scan_depth = Some(1);
|
||||
settings.project.worktree.file_scan_inclusions =
|
||||
Some(vec!["junk/a/b/c/deep.txt".to_string()]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.background_executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
"junk": {
|
||||
"a": {
|
||||
"b": {
|
||||
"c": {
|
||||
"deep.txt": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"x": {}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
||||
|
||||
tree.read_with(cx, |tree, _| {
|
||||
assert_eq!(
|
||||
tree.entry_for_path(rel_path("junk/a/b/c/deep.txt"))
|
||||
.map(|entry| entry.path.as_ref()),
|
||||
Some(rel_path("junk/a/b/c/deep.txt"))
|
||||
);
|
||||
assert_eq!(
|
||||
tree.entry_for_path(rel_path("other")).unwrap().kind,
|
||||
EntryKind::UnloadedDir
|
||||
);
|
||||
assert_eq!(tree.entry_for_path(rel_path("other/x")), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_file_scan_depth_expansion(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
set_file_scan_depth(cx, Some(1));
|
||||
|
||||
let fs = FakeFs::new(cx.background_executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
"junk": {
|
||||
"a": {
|
||||
"file.txt": "",
|
||||
"b": {
|
||||
"c.txt": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
||||
|
||||
tree.read_with(cx, |tree, _| {
|
||||
assert_eq!(
|
||||
tree.entry_for_path(rel_path("junk")).unwrap().kind,
|
||||
EntryKind::UnloadedDir
|
||||
);
|
||||
assert_eq!(tree.entry_for_path(rel_path("junk/a")), None);
|
||||
});
|
||||
|
||||
tree.read_with(cx, |tree, _| {
|
||||
tree.as_local()
|
||||
.unwrap()
|
||||
.refresh_entries_for_paths(vec![rel_path("junk/a").into()])
|
||||
})
|
||||
.recv()
|
||||
.await;
|
||||
|
||||
tree.read_with(cx, |tree, _| {
|
||||
assert_eq!(
|
||||
tree.entries(true, 0)
|
||||
.map(|entry| (entry.path.as_ref(), entry.kind))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
(rel_path(""), EntryKind::Dir),
|
||||
(rel_path("junk"), EntryKind::Dir),
|
||||
(rel_path("junk/a"), EntryKind::Dir),
|
||||
(rel_path("junk/a/b"), EntryKind::UnloadedDir),
|
||||
(rel_path("junk/a/file.txt"), EntryKind::File),
|
||||
]
|
||||
);
|
||||
assert_eq!(tree.deferred_scan_dir_count(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_file_scan_depth_fs_event_below_horizon(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
set_file_scan_depth(cx, Some(1));
|
||||
|
||||
let fs = FakeFs::new(cx.background_executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
"junk": {
|
||||
"a": {}
|
||||
},
|
||||
"top.txt": ""
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
||||
|
||||
fs.insert_file(Path::new(path!("/root/junk/a/new.txt")), b"".to_vec())
|
||||
.await;
|
||||
tree.flush_fs_events(cx).await;
|
||||
cx.run_until_parked();
|
||||
|
||||
tree.read_with(cx, |tree, _| {
|
||||
assert_eq!(
|
||||
tree.entries(true, 0)
|
||||
.map(|entry| (entry.path.as_ref(), entry.kind))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
(rel_path(""), EntryKind::Dir),
|
||||
(rel_path("junk"), EntryKind::UnloadedDir),
|
||||
(rel_path("top.txt"), EntryKind::File),
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_file_scan_depth_settings_tightening(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
set_file_scan_depth(cx, Some(0));
|
||||
|
||||
let fs = FakeFs::new(cx.background_executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
"junk": {
|
||||
"a": {
|
||||
"b": {
|
||||
"deep.txt": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
||||
|
||||
tree.read_with(cx, |tree, _| {
|
||||
assert_eq!(
|
||||
tree.entry_for_path(rel_path("junk/a/b/deep.txt"))
|
||||
.map(|entry| entry.path.as_ref()),
|
||||
Some(rel_path("junk/a/b/deep.txt"))
|
||||
);
|
||||
assert_eq!(tree.deferred_scan_dir_count(), 0);
|
||||
});
|
||||
|
||||
set_file_scan_depth(cx, Some(1));
|
||||
cx.run_until_parked();
|
||||
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.kind))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
(rel_path(""), EntryKind::Dir),
|
||||
(rel_path("junk"), EntryKind::UnloadedDir),
|
||||
]
|
||||
);
|
||||
assert_eq!(tree.deferred_scan_dir_count(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_file_scan_depth_from_project_settings(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let worktree_id = WorktreeId::from_proto(0);
|
||||
cx.update(|cx| {
|
||||
cx.update_global::<SettingsStore, _>(|store, cx| {
|
||||
store
|
||||
.set_local_settings(
|
||||
worktree_id,
|
||||
LocalSettingsPath::InWorktree(Arc::from(RelPath::empty())),
|
||||
LocalSettingsKind::Settings,
|
||||
Some(r#"{ "file_scan_depth": 1 }"#),
|
||||
cx,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.background_executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
"junk": {
|
||||
"a": {
|
||||
"b": {
|
||||
"deep.txt": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
||||
|
||||
tree.read_with(cx, |tree, _| {
|
||||
assert_eq!(
|
||||
tree.entry_for_path(rel_path("junk")).unwrap().kind,
|
||||
EntryKind::UnloadedDir
|
||||
);
|
||||
assert_eq!(tree.entry_for_path(rel_path("junk/a")), None);
|
||||
assert_eq!(tree.deferred_scan_dir_count(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_file_scan_depth_git_init_above_deferred_dirs(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
set_file_scan_depth(cx, Some(2));
|
||||
|
||||
let fs = FakeFs::new(cx.background_executor.clone());
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
"project": {
|
||||
"src": {
|
||||
"nested": {
|
||||
"deep.txt": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let tree = build_worktree(fs.clone(), path!("/root"), cx).await;
|
||||
|
||||
tree.read_with(cx, |tree, _| {
|
||||
assert_eq!(
|
||||
tree.entry_for_path(rel_path("project/src")).unwrap().kind,
|
||||
EntryKind::UnloadedDir
|
||||
);
|
||||
assert_eq!(tree.deferred_scan_dir_count(), 1);
|
||||
});
|
||||
|
||||
fs.create_dir(Path::new(path!("/root/project/.git")))
|
||||
.await
|
||||
.unwrap();
|
||||
tree.flush_fs_events(cx).await;
|
||||
cx.run_until_parked();
|
||||
|
||||
tree.read_with(cx, |tree, _| {
|
||||
assert_eq!(
|
||||
tree.entry_for_path(rel_path("project/src/nested/deep.txt"))
|
||||
.map(|entry| entry.path.as_ref()),
|
||||
Some(rel_path("project/src/nested/deep.txt"))
|
||||
);
|
||||
assert_eq!(tree.deferred_scan_dir_count(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
async fn build_worktree(fs: Arc<FakeFs>, root: &str, cx: &mut TestAppContext) -> Entity<Worktree> {
|
||||
let tree = Worktree::local(
|
||||
Path::new(root),
|
||||
true,
|
||||
fs,
|
||||
Arc::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
|
||||
}
|
||||
|
||||
fn set_file_scan_depth(cx: &mut TestAppContext, depth: Option<u32>) {
|
||||
cx.update(|cx| {
|
||||
cx.update_global::<SettingsStore, _>(|store, cx| {
|
||||
store.update_user_settings(cx, |settings| {
|
||||
settings.project.worktree.file_scan_depth = depth;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -262,6 +262,7 @@ image.workspace = true
|
||||
pkg-config = "0.3.22"
|
||||
|
||||
[dev-dependencies]
|
||||
activity_indicator = { workspace = true, features = ["test-support"] }
|
||||
call = { workspace = true, features = ["test-support"] }
|
||||
editor = { workspace = true, features = ["test-support"] }
|
||||
fs = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -2964,6 +2964,87 @@ mod tests {
|
||||
futures::future::join_all(all_tasks).await;
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_partial_file_index_status_bar_message(cx: &mut TestAppContext) {
|
||||
let app_state = init_test(cx);
|
||||
set_file_scan_depth(cx, 1);
|
||||
|
||||
let fs = app_state.fs.as_fake();
|
||||
fs.insert_tree(
|
||||
path!("/root"),
|
||||
json!({
|
||||
"junk": {
|
||||
"a": {
|
||||
"b": {
|
||||
"deep.txt": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"top.txt": ""
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
|
||||
let (multi_workspace, cx) =
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||
let workspace =
|
||||
multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone());
|
||||
let languages = project.read_with(cx, |project, _| project.languages().clone());
|
||||
let indicator = workspace.update_in(cx, |workspace, window, cx| {
|
||||
activity_indicator::ActivityIndicator::new(workspace, languages, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
indicator.update(cx, |indicator, cx| {
|
||||
assert_eq!(
|
||||
indicator.message_to_render(cx),
|
||||
Some("Partial file index".to_string())
|
||||
);
|
||||
});
|
||||
|
||||
set_file_scan_depth(cx, 0);
|
||||
cx.run_until_parked();
|
||||
|
||||
indicator.update(cx, |indicator, cx| {
|
||||
assert_eq!(indicator.message_to_render(cx), None);
|
||||
});
|
||||
|
||||
set_file_scan_depth(cx, 1);
|
||||
cx.run_until_parked();
|
||||
|
||||
indicator.update(cx, |indicator, cx| {
|
||||
assert_eq!(
|
||||
indicator.message_to_render(cx),
|
||||
Some("Partial file index".to_string())
|
||||
);
|
||||
});
|
||||
|
||||
cx.executor().advance_clock(
|
||||
activity_indicator::DEFERRED_SCAN_MESSAGE_TIMEOUT + Duration::from_secs(1),
|
||||
);
|
||||
cx.run_until_parked();
|
||||
|
||||
indicator.update(cx, |indicator, cx| {
|
||||
assert_eq!(indicator.message_to_render(cx), None);
|
||||
});
|
||||
|
||||
fs.insert_tree(
|
||||
path!("/root/other"),
|
||||
json!({
|
||||
"x": {
|
||||
"y": ""
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
cx.run_until_parked();
|
||||
|
||||
indicator.update(cx, |indicator, cx| {
|
||||
assert_eq!(indicator.message_to_render(cx), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_open_non_existing_file(cx: &mut TestAppContext) {
|
||||
let app_state = init_test(cx);
|
||||
@@ -6043,6 +6124,16 @@ mod tests {
|
||||
init_test_with_state(cx, cx.update(AppState::test))
|
||||
}
|
||||
|
||||
fn set_file_scan_depth(cx: &mut TestAppContext, depth: u32) {
|
||||
cx.update(|cx| {
|
||||
cx.update_global::<SettingsStore, _>(|store, cx| {
|
||||
store.update_user_settings(cx, |settings| {
|
||||
settings.project.worktree.file_scan_depth = Some(depth);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn init_test_with_state(
|
||||
cx: &mut TestAppContext,
|
||||
mut app_state: Arc<AppState>,
|
||||
|
||||
@@ -9,6 +9,19 @@ Zed has built-in Git support that lets you manage version control without leavin
|
||||
|
||||
For operations that Zed doesn't support natively, you can use the integrated terminal.
|
||||
|
||||
## Repository Activation
|
||||
|
||||
When a project is rooted at a git repository, or at a subdirectory of one, all repositories in it are active immediately.
|
||||
|
||||
When a project is not rooted at a repository (a home directory, a folder of projects), repositories directly inside the project root are also active immediately, and deeper ones activate when a file inside them is opened, in local and remote projects alike.
|
||||
|
||||
Inactive repositories are fully indexed and searchable; only git features (status, diffs, branches) wait for activation.
|
||||
|
||||
This behavior is tied to the [`file_scan_depth`](./reference/all-settings.md#file-scan-depth) setting, but not to its value: any non-zero `file_scan_depth` defers activation for repositories that are not directly inside a project root folder, and repositories rooted at or deeper than the limit are not even discovered until their directories are indexed on demand.
|
||||
In multi-folder projects, depth and activation are measured from each root folder separately.
|
||||
|
||||
Setting `file_scan_depth` to `0` turns deferred activation off: every discovered repository activates immediately.
|
||||
|
||||
## Git Panel
|
||||
|
||||
The Git Panel shows the state of your working tree and Git's staging area.
|
||||
|
||||
@@ -123,6 +123,8 @@ be disabled with the `project_panel.drag_and_drop` setting.
|
||||
|
||||
When `project_panel.git_status` is enabled (the default), file and directory names are tinted
|
||||
to reflect their git status—modified, added, deleted, untracked, or conflicting.
|
||||
In projects that are not rooted at a git repository, status is shown only for
|
||||
[active repositories](./git.md#repository-activation).
|
||||
|
||||
Setting `project_panel.git_status_indicator` to `true` (disabled by default) adds a letter badge next
|
||||
to each name: **M** (modified), **A** (added), **D** (deleted), **U**
|
||||
|
||||
@@ -2151,6 +2151,37 @@ Note, specifying `file_scan_exclusions` in settings.json will override the defau
|
||||
}
|
||||
```
|
||||
|
||||
## File Scan Depth
|
||||
|
||||
- Setting: `file_scan_depth`
|
||||
- Description: Maximum directory depth that Zed eagerly indexes outside of git repositories. Directories at this depth or deeper are indexed on demand: when expanded in the project panel or when a file inside them is opened. Contents of directories that were not indexed yet are invisible to the file finder and project search. When directories get deferred, the status bar of the affected window shows a brief "Partial file index" message. Set to `0` to always index everything eagerly and activate all git repositories immediately.
|
||||
- Default: `5`
|
||||
|
||||
```json [settings]
|
||||
{
|
||||
"file_scan_depth": 0
|
||||
}
|
||||
```
|
||||
|
||||
How the limit applies, case by case:
|
||||
|
||||
| Case | Behavior |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| Project rooted at a git repository, or at a subdirectory of one | Indexed fully, the limit never applies |
|
||||
| Git repository rooted shallower than the limit (e.g. a repo under `~/`) | Its whole subtree is indexed fully, no matter how deep |
|
||||
| Git repository rooted at or deeper than the limit | Not discovered eagerly; opening any file inside it registers it and indexes its whole subtree from then on |
|
||||
| Non-git tree shallower than the limit | Indexed fully, nothing changes |
|
||||
| Non-git tree deeper than the limit (e.g. `~/`, `/`, large datasets) | Indexed up to the limit, the rest on demand; unindexed contents are invisible to the file finder and search |
|
||||
| Gitignored directories | Indexed on demand regardless of this setting, as always |
|
||||
| `file_scan_inclusions` matches | Always indexed, regardless of depth |
|
||||
| `file_scan_exclusions` matches | Never indexed, regardless of depth |
|
||||
|
||||
Directories loaded on demand stay indexed, but are deferred again after a restart or after a settings change triggers a worktree rescan.
|
||||
|
||||
`file_scan_depth` bounds where git repositories can be discovered; [repository activation](../git.md#repository-activation) decides what a discovered repository costs.
|
||||
Note that any non-zero value defers git activation for repositories that are not directly inside a project root folder, no matter how large the value is; raising the limit indexes more directories eagerly but does not activate deeper repositories any earlier.
|
||||
In multi-folder projects, depth is measured from each root folder separately.
|
||||
|
||||
## Scan Symbolic Links
|
||||
|
||||
- Description: When to scan content of linked directories.
|
||||
|
||||
Reference in new issue
Block a user