From cf7d639fbca7c26297d892cf898e3a215629663d Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Fri, 21 Mar 2025 00:10:17 -0400 Subject: [PATCH] Migrate most callers of git-related worktree APIs to use the GitStore (#27225) This is a pure refactoring PR that goes through all the git-related APIs exposed by the worktree crate and minimizes their use outside that crate, migrating callers of those APIs to read from the GitStore instead. This is to prepare for evacuating git repository state from worktrees and making the GitStore the new source of truth. Other drive-by changes: - `project::git` is now `project::git_store`, for consistency with the other project stores - the project panel's test module has been split into its own file Release Notes: - N/A --------- Co-authored-by: Max Brunsfeld --- Cargo.lock | 1 + crates/assistant2/src/thread.rs | 73 +- crates/call/src/macos/room.rs | 14 +- crates/collab/src/tests/integration_tests.rs | 35 +- .../remote_editing_collaboration_tests.rs | 37 +- crates/editor/src/items.rs | 16 +- crates/editor/src/mouse_context_menu.rs | 15 +- crates/git_ui/src/branch_picker.rs | 2 +- crates/git_ui/src/git_panel.rs | 2 +- crates/git_ui/src/project_diff.rs | 2 +- crates/git_ui/src/repository_selector.rs | 2 +- crates/outline_panel/src/outline_panel.rs | 30 +- crates/project/Cargo.toml | 1 + crates/project/src/buffer_store.rs | 364 +- crates/project/src/connection_manager.rs | 12 +- crates/project/src/{git.rs => git_store.rs} | 529 +- crates/project/src/git_store/git_traversal.rs | 767 +++ crates/project/src/project.rs | 24 +- crates/project/src/worktree_store.rs | 9 - crates/project_panel/src/project_panel.rs | 5067 +---------------- .../project_panel/src/project_panel_tests.rs | 5013 ++++++++++++++++ crates/remote_server/src/headless_project.rs | 2 +- .../remote_server/src/remote_editing_tests.rs | 39 +- crates/title_bar/src/title_bar.rs | 17 +- crates/worktree/src/worktree.rs | 258 +- crates/worktree/src/worktree_tests.rs | 578 +- 26 files changed, 6480 insertions(+), 6429 deletions(-) rename crates/project/src/{git.rs => git_store.rs} (85%) create mode 100644 crates/project/src/git_store/git_traversal.rs create mode 100644 crates/project_panel/src/project_panel_tests.rs diff --git a/Cargo.lock b/Cargo.lock index e153e4147c..4beafa6703 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10590,6 +10590,7 @@ dependencies = [ "smol", "snippet", "snippet_provider", + "sum_tree", "task", "tempfile", "terminal", diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index e861a66f59..876662d386 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -17,7 +17,7 @@ use language_model::{ LanguageModelToolUseId, MaxMonthlySpendReachedError, MessageContent, PaymentRequiredError, Role, StopReason, TokenUsage, }; -use project::git::GitStoreCheckpoint; +use project::git_store::{GitStore, GitStoreCheckpoint}; use project::{Project, Worktree}; use prompt_store::{ AssistantSystemPromptContext, PromptBuilder, RulesFile, WorktreeInfoForSystemPrompt, @@ -1219,10 +1219,11 @@ impl Thread { project: Entity, cx: &mut Context, ) -> Task> { + let git_store = project.read(cx).git_store().clone(); let worktree_snapshots: Vec<_> = project .read(cx) .visible_worktrees(cx) - .map(|worktree| Self::worktree_snapshot(worktree, cx)) + .map(|worktree| Self::worktree_snapshot(worktree, git_store.clone(), cx)) .collect(); cx.spawn(async move |_, cx| { @@ -1251,7 +1252,11 @@ impl Thread { }) } - fn worktree_snapshot(worktree: Entity, cx: &App) -> Task { + fn worktree_snapshot( + worktree: Entity, + git_store: Entity, + cx: &App, + ) -> Task { cx.spawn(async move |cx| { // Get worktree path and snapshot let worktree_info = cx.update(|app_cx| { @@ -1268,42 +1273,40 @@ impl Thread { }; }; + let repo_info = git_store + .update(cx, |git_store, cx| { + git_store + .repositories() + .values() + .find(|repo| repo.read(cx).worktree_id == snapshot.id()) + .and_then(|repo| { + let repo = repo.read(cx); + Some((repo.branch().cloned(), repo.local_repository()?)) + }) + }) + .ok() + .flatten(); + // Extract git information - let git_state = match snapshot.repositories().first() { + let git_state = match repo_info { None => None, - Some(repo_entry) => { - // Get branch information - let current_branch = repo_entry.branch().map(|branch| branch.name.to_string()); + Some((branch, repo)) => { + let current_branch = branch.map(|branch| branch.name.to_string()); + let remote_url = repo.remote_url("origin"); + let head_sha = repo.head_sha(); - // Get repository info - let repo_result = worktree.read_with(cx, |worktree, _cx| { - if let project::Worktree::Local(local_worktree) = &worktree { - local_worktree.get_local_repo(repo_entry).map(|local_repo| { - let repo = local_repo.repo(); - (repo.remote_url("origin"), repo.head_sha(), repo.clone()) - }) - } else { - None - } - }); + // Get diff asynchronously + let diff = repo + .diff(git::repository::DiffType::HeadToWorktree, cx.clone()) + .await + .ok(); - match repo_result { - Ok(Some((remote_url, head_sha, repository))) => { - // Get diff asynchronously - let diff = repository - .diff(git::repository::DiffType::HeadToWorktree, cx.clone()) - .await - .ok(); - - Some(GitState { - remote_url, - head_sha, - current_branch, - diff, - }) - } - Err(_) | Ok(None) => None, - } + Some(GitState { + remote_url, + head_sha, + current_branch, + diff, + }) } }; diff --git a/crates/call/src/macos/room.rs b/crates/call/src/macos/room.rs index de5d4b927b..bfddd624fc 100644 --- a/crates/call/src/macos/room.rs +++ b/crates/call/src/macos/room.rs @@ -532,13 +532,15 @@ impl Room { id: worktree.id().to_proto(), scan_id: worktree.completed_scan_id() as u64, }); - for repository in worktree.repositories().iter() { - repositories.push(proto::RejoinRepository { - id: repository.work_directory_id().to_proto(), - scan_id: worktree.completed_scan_id() as u64, - }); - } } + for (entry_id, repository) in project.repositories(cx) { + let repository = repository.read(cx); + repositories.push(proto::RejoinRepository { + id: entry_id.to_proto(), + scan_id: repository.completed_scan_id as u64, + }); + } + rejoined_projects.push(proto::RejoinProject { id: project_id, worktrees, diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 5f0efd7ecf..fdfd51325f 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -2895,9 +2895,10 @@ async fn test_git_branch_name( let worktrees = project.visible_worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 1); let worktree = worktrees[0].clone(); - let root_entry = worktree.read(cx).snapshot().root_git_entry().unwrap(); + let snapshot = worktree.read(cx).snapshot(); + let repo = snapshot.repositories().first().unwrap(); assert_eq!( - root_entry.branch().map(|branch| branch.name.to_string()), + repo.branch().map(|branch| branch.name.to_string()), branch_name ); } @@ -6771,7 +6772,7 @@ async fn test_remote_git_branches( .map(ToString::to_string) .collect::>(); - let (project_a, worktree_id) = client_a.build_local_project("/project", cx_a).await; + let (project_a, _) = client_a.build_local_project("/project", cx_a).await; let project_id = active_call_a .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) @@ -6784,8 +6785,6 @@ async fn test_remote_git_branches( let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap()); - let root_path = ProjectPath::root_path(worktree_id); - let branches_b = cx_b .update(|cx| repo_b.update(cx, |repository, _| repository.branches())) .await @@ -6810,11 +6809,15 @@ async fn test_remote_git_branches( let host_branch = cx_a.update(|cx| { project_a.update(cx, |project, cx| { - project.worktree_store().update(cx, |worktree_store, cx| { - worktree_store - .current_branch(root_path.clone(), cx) - .unwrap() - }) + project + .repositories(cx) + .values() + .next() + .unwrap() + .read(cx) + .current_branch() + .unwrap() + .clone() }) }); @@ -6843,9 +6846,15 @@ async fn test_remote_git_branches( let host_branch = cx_a.update(|cx| { project_a.update(cx, |project, cx| { - project.worktree_store().update(cx, |worktree_store, cx| { - worktree_store.current_branch(root_path, cx).unwrap() - }) + project + .repositories(cx) + .values() + .next() + .unwrap() + .read(cx) + .current_branch() + .unwrap() + .clone() }) }); diff --git a/crates/collab/src/tests/remote_editing_collaboration_tests.rs b/crates/collab/src/tests/remote_editing_collaboration_tests.rs index fbc25cbf03..bd594b0fa1 100644 --- a/crates/collab/src/tests/remote_editing_collaboration_tests.rs +++ b/crates/collab/src/tests/remote_editing_collaboration_tests.rs @@ -258,7 +258,7 @@ async fn test_ssh_collaboration_git_branches( }); let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await; - let (project_a, worktree_id) = client_a + let (project_a, _) = client_a .build_ssh_project("/project", client_ssh, cx_a) .await; @@ -277,7 +277,6 @@ async fn test_ssh_collaboration_git_branches( executor.run_until_parked(); let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap()); - let root_path = ProjectPath::root_path(worktree_id); let branches_b = cx_b .update(|cx| repo_b.read(cx).branches()) @@ -303,13 +302,17 @@ async fn test_ssh_collaboration_git_branches( let server_branch = server_cx.update(|cx| { headless_project.update(cx, |headless_project, cx| { - headless_project - .worktree_store - .update(cx, |worktree_store, cx| { - worktree_store - .current_branch(root_path.clone(), cx) - .unwrap() - }) + headless_project.git_store.update(cx, |git_store, cx| { + git_store + .repositories() + .values() + .next() + .unwrap() + .read(cx) + .current_branch() + .unwrap() + .clone() + }) }) }); @@ -338,11 +341,17 @@ async fn test_ssh_collaboration_git_branches( let server_branch = server_cx.update(|cx| { headless_project.update(cx, |headless_project, cx| { - headless_project - .worktree_store - .update(cx, |worktree_store, cx| { - worktree_store.current_branch(root_path, cx).unwrap() - }) + headless_project.git_store.update(cx, |git_store, cx| { + git_store + .repositories() + .values() + .next() + .unwrap() + .read(cx) + .current_branch() + .unwrap() + .clone() + }) }) }); diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 39c041892f..13b993c9fd 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -629,18 +629,20 @@ impl Item for Editor { self.buffer() .read(cx) .as_singleton() - .and_then(|buffer| buffer.read(cx).project_path(cx)) - .and_then(|path| { + .and_then(|buffer| { + let buffer = buffer.read(cx); + let path = buffer.project_path(cx)?; + let buffer_id = buffer.remote_id(); let project = self.project.as_ref()?.read(cx); let entry = project.entry_for_path(&path, cx)?; - let git_status = project - .worktree_for_id(path.worktree_id, cx)? + let (repo, repo_path) = project + .git_store() .read(cx) - .snapshot() - .status_for_file(path.path)?; + .repository_and_path_for_buffer_id(buffer_id, cx)?; + let status = repo.read(cx).status_for_path(&repo_path)?.status; Some(entry_git_aware_label_color( - git_status.summary(), + status.summary(), entry.is_ignored, params.selected, )) diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 3390ba0450..8d77dda799 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -137,9 +137,9 @@ pub fn deploy_context_menu( menu } else { // Don't show the context menu if there isn't a project associated with this editor - if editor.project.is_none() { + let Some(project) = editor.project.clone() else { return; - } + }; let display_map = editor.selections.display_map(cx); let buffer = &editor.snapshot(window, cx).buffer_snapshot; @@ -159,10 +159,13 @@ pub fn deploy_context_menu( .all::(cx) .into_iter() .any(|s| !s.is_empty()); - let has_git_repo = editor.project.as_ref().map_or(false, |project| { - project.update(cx, |project, cx| { - project.get_first_worktree_root_repo(cx).is_some() - }) + let has_git_repo = anchor.buffer_id.is_some_and(|buffer_id| { + project + .read(cx) + .git_store() + .read(cx) + .repository_and_path_for_buffer_id(buffer_id, cx) + .is_some() }); ui::ContextMenu::build(window, cx, |menu, _window, _cx| { diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 2c15a667e8..9ba55f9ed9 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -8,7 +8,7 @@ use gpui::{ SharedString, Styled, Subscription, Task, Window, }; use picker::{Picker, PickerDelegate, PickerEditorPosition}; -use project::git::Repository; +use project::git_store::Repository; use std::sync::Arc; use time::OffsetDateTime; use time_format::format_local_timestamp; diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index f5f4e22ddb..d4cd4da52e 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -46,7 +46,7 @@ use panel::{ panel_icon_button, PanelHeader, }; use project::{ - git::{GitEvent, Repository}, + git_store::{GitEvent, Repository}, Fs, Project, ProjectPath, }; use serde::{Deserialize, Serialize}; diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 010d2cc5ea..630dc966c5 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -23,7 +23,7 @@ use gpui::{ use language::{Anchor, Buffer, Capability, OffsetRangeExt}; use multi_buffer::{MultiBuffer, PathKey}; use project::{ - git::{GitEvent, GitStore}, + git_store::{GitEvent, GitStore}, Project, ProjectPath, }; use std::any::{Any, TypeId}; diff --git a/crates/git_ui/src/repository_selector.rs b/crates/git_ui/src/repository_selector.rs index bfe4399535..d91f354039 100644 --- a/crates/git_ui/src/repository_selector.rs +++ b/crates/git_ui/src/repository_selector.rs @@ -4,7 +4,7 @@ use gpui::{ use itertools::Itertools; use picker::{Picker, PickerDelegate}; use project::{ - git::{GitStore, Repository}, + git_store::{GitStore, Repository}, Project, }; use std::sync::Arc; diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 3113087021..5530f745f9 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -40,7 +40,7 @@ use language::{BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings, ShowIndentGuides}; -use project::{File, Fs, Project, ProjectItem}; +use project::{File, Fs, GitEntry, GitTraversal, Project, ProjectItem}; use search::{BufferSearchBar, ProjectSearchView}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; @@ -60,7 +60,7 @@ use workspace::{ }, OpenInTerminal, WeakItemHandle, Workspace, }; -use worktree::{Entry, GitEntry, ProjectEntryId, WorktreeId}; +use worktree::{Entry, ProjectEntryId, WorktreeId}; actions!( outline_panel, @@ -2566,6 +2566,7 @@ impl OutlinePanel { let mut root_entries = HashSet::default(); let mut new_excerpts = HashMap::>::default(); let Ok(buffer_excerpts) = outline_panel.update(cx, |outline_panel, cx| { + let git_store = outline_panel.project.read(cx).git_store().clone(); new_collapsed_entries = outline_panel.collapsed_entries.clone(); new_unfolded_dirs = outline_panel.unfolded_dirs.clone(); let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx); @@ -2579,9 +2580,17 @@ impl OutlinePanel { let is_new = new_entries.contains(&excerpt_id) || !outline_panel.excerpts.contains_key(&buffer_id); let is_folded = active_editor.read(cx).is_buffer_folded(buffer_id, cx); + let status = git_store + .read(cx) + .repository_and_path_for_buffer_id(buffer_id, cx) + .and_then(|(repo, path)| { + Some(repo.read(cx).status_for_path(&path)?.status) + }); buffer_excerpts .entry(buffer_id) - .or_insert_with(|| (is_new, is_folded, Vec::new(), entry_id, worktree)) + .or_insert_with(|| { + (is_new, is_folded, Vec::new(), entry_id, worktree, status) + }) .2 .push(excerpt_id); @@ -2631,7 +2640,7 @@ impl OutlinePanel { >::default(); let mut external_excerpts = HashMap::default(); - for (buffer_id, (is_new, is_folded, excerpts, entry_id, worktree)) in + for (buffer_id, (is_new, is_folded, excerpts, entry_id, worktree, status)) in buffer_excerpts { if is_folded { @@ -2665,15 +2674,18 @@ impl OutlinePanel { match entry_id.and_then(|id| worktree.entry_for_id(id)).cloned() { Some(entry) => { let entry = GitEntry { - git_summary: worktree - .status_for_file(&entry.path) + git_summary: status .map(|status| status.summary()) .unwrap_or_default(), entry, }; - let mut traversal = worktree - .traverse_from_path(true, true, true, entry.path.as_ref()) - .with_git_statuses(); + let mut traversal = + GitTraversal::new(worktree.traverse_from_path( + true, + true, + true, + entry.path.as_ref(), + )); let mut entries_to_add = HashMap::default(); worktree_excerpts diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 01f3803846..fb86ff5f3c 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -73,6 +73,7 @@ shlex.workspace = true smol.workspace = true snippet.workspace = true snippet_provider.workspace = true +sum_tree.workspace = true task.workspace = true tempfile.workspace = true terminal.workspace = true diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index 6044052c93..409a15ca42 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -4,13 +4,11 @@ use crate::{ worktree_store::{WorktreeStore, WorktreeStoreEvent}, ProjectItem as _, ProjectPath, }; -use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry}; -use anyhow::{anyhow, bail, Context as _, Result}; +use anyhow::{anyhow, Context as _, Result}; use client::Client; use collections::{hash_map, HashMap, HashSet}; use fs::Fs; use futures::{channel::oneshot, future::Shared, Future, FutureExt as _, StreamExt}; -use git::{blame::Blame, repository::RepoPath}; use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity, }; @@ -25,16 +23,8 @@ use rpc::{ proto::{self, ToProto}, AnyProtoClient, ErrorExt as _, TypedEnvelope, }; -use serde::Deserialize; use smol::channel::Receiver; -use std::{ - io, - ops::Range, - path::{Path, PathBuf}, - pin::pin, - sync::Arc, - time::Instant, -}; +use std::{io, path::Path, pin::pin, sync::Arc, time::Instant}; use text::BufferId; use util::{debug_panic, maybe, ResultExt as _, TryFutureExt}; use worktree::{File, PathChange, ProjectEntryId, Worktree, WorktreeId}; @@ -750,9 +740,7 @@ impl BufferStore { client.add_entity_message_handler(Self::handle_buffer_saved); client.add_entity_message_handler(Self::handle_update_buffer_file); client.add_entity_request_handler(Self::handle_save_buffer); - client.add_entity_request_handler(Self::handle_blame_buffer); client.add_entity_request_handler(Self::handle_reload_buffers); - client.add_entity_request_handler(Self::handle_get_permalink_to_line); } /// Creates a buffer store, optionally retaining its buffers. @@ -938,172 +926,6 @@ impl BufferStore { }) } - pub fn blame_buffer( - &self, - buffer: &Entity, - version: Option, - cx: &App, - ) -> Task>> { - let buffer = buffer.read(cx); - let Some(file) = File::from_dyn(buffer.file()) else { - return Task::ready(Err(anyhow!("buffer has no file"))); - }; - - match file.worktree.clone().read(cx) { - Worktree::Local(worktree) => { - let worktree = worktree.snapshot(); - let blame_params = maybe!({ - let local_repo = match worktree.local_repo_for_path(&file.path) { - Some(repo_for_path) => repo_for_path, - None => return Ok(None), - }; - - let relative_path = local_repo - .relativize(&file.path) - .context("failed to relativize buffer path")?; - - let repo = local_repo.repo().clone(); - - let content = match version { - Some(version) => buffer.rope_for_version(&version).clone(), - None => buffer.as_rope().clone(), - }; - - anyhow::Ok(Some((repo, relative_path, content))) - }); - - cx.spawn(async move |cx| { - let Some((repo, relative_path, content)) = blame_params? else { - return Ok(None); - }; - repo.blame(relative_path.clone(), content, cx) - .await - .with_context(|| format!("Failed to blame {:?}", relative_path.0)) - .map(Some) - }) - } - Worktree::Remote(worktree) => { - let buffer_id = buffer.remote_id(); - let version = buffer.version(); - let project_id = worktree.project_id(); - let client = worktree.client(); - cx.spawn(async move |_| { - let response = client - .request(proto::BlameBuffer { - project_id, - buffer_id: buffer_id.into(), - version: serialize_version(&version), - }) - .await?; - Ok(deserialize_blame_buffer_response(response)) - }) - } - } - } - - pub fn get_permalink_to_line( - &self, - buffer: &Entity, - selection: Range, - cx: &App, - ) -> Task> { - let buffer = buffer.read(cx); - let Some(file) = File::from_dyn(buffer.file()) else { - return Task::ready(Err(anyhow!("buffer has no file"))); - }; - - match file.worktree.read(cx) { - Worktree::Local(worktree) => { - let worktree_path = worktree.abs_path().clone(); - let Some((repo_entry, repo)) = - worktree.repository_for_path(file.path()).and_then(|entry| { - let repo = worktree.get_local_repo(&entry)?.repo().clone(); - Some((entry, repo)) - }) - else { - // If we're not in a Git repo, check whether this is a Rust source - // file in the Cargo registry (presumably opened with go-to-definition - // from a normal Rust file). If so, we can put together a permalink - // using crate metadata. - if buffer - .language() - .is_none_or(|lang| lang.name() != "Rust".into()) - { - return Task::ready(Err(anyhow!("no permalink available"))); - } - let file_path = worktree_path.join(file.path()); - return cx.spawn(async move |cx| { - let provider_registry = - cx.update(GitHostingProviderRegistry::default_global)?; - get_permalink_in_rust_registry_src(provider_registry, file_path, selection) - .map_err(|_| anyhow!("no permalink available")) - }); - }; - - let path = match repo_entry.relativize(file.path()) { - Ok(RepoPath(path)) => path, - Err(e) => return Task::ready(Err(e)), - }; - - let remote = repo_entry - .branch() - .and_then(|b| b.upstream.as_ref()) - .and_then(|b| b.remote_name()) - .unwrap_or("origin") - .to_string(); - - cx.spawn(async move |cx| { - let origin_url = repo - .remote_url(&remote) - .ok_or_else(|| anyhow!("remote \"{remote}\" not found"))?; - - let sha = repo - .head_sha() - .ok_or_else(|| anyhow!("failed to read HEAD SHA"))?; - - let provider_registry = - cx.update(GitHostingProviderRegistry::default_global)?; - - let (provider, remote) = - parse_git_remote_url(provider_registry, &origin_url) - .ok_or_else(|| anyhow!("failed to parse Git remote URL"))?; - - let path = path - .to_str() - .ok_or_else(|| anyhow!("failed to convert path to string"))?; - - Ok(provider.build_permalink( - remote, - BuildPermalinkParams { - sha: &sha, - path, - selection: Some(selection), - }, - )) - }) - } - Worktree::Remote(worktree) => { - let buffer_id = buffer.remote_id(); - let project_id = worktree.project_id(); - let client = worktree.client(); - cx.spawn(async move |_| { - let response = client - .request(proto::GetPermalinkToLine { - project_id, - buffer_id: buffer_id.into(), - selection: Some(proto::Range { - start: selection.start as u64, - end: selection.end as u64, - }), - }) - .await?; - - url::Url::parse(&response.permalink).context("failed to parse permalink") - }) - } - } - } - fn add_buffer(&mut self, buffer_entity: Entity, cx: &mut Context) -> Result<()> { let buffer = buffer_entity.read(cx); let remote_id = buffer.remote_id(); @@ -1662,52 +1484,6 @@ impl BufferStore { }) } - pub async fn handle_blame_buffer( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result { - let buffer_id = BufferId::new(envelope.payload.buffer_id)?; - let version = deserialize_version(&envelope.payload.version); - let buffer = this.read_with(&cx, |this, _| this.get_existing(buffer_id))??; - buffer - .update(&mut cx, |buffer, _| { - buffer.wait_for_version(version.clone()) - })? - .await?; - let blame = this - .update(&mut cx, |this, cx| { - this.blame_buffer(&buffer, Some(version), cx) - })? - .await?; - Ok(serialize_blame_buffer_response(blame)) - } - - pub async fn handle_get_permalink_to_line( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result { - let buffer_id = BufferId::new(envelope.payload.buffer_id)?; - // let version = deserialize_version(&envelope.payload.version); - let selection = { - let proto_selection = envelope - .payload - .selection - .context("no selection to get permalink for defined")?; - proto_selection.start as u32..proto_selection.end as u32 - }; - let buffer = this.read_with(&cx, |this, _| this.get_existing(buffer_id))??; - let permalink = this - .update(&mut cx, |this, cx| { - this.get_permalink_to_line(&buffer, selection, cx) - })? - .await?; - Ok(proto::GetPermalinkToLineResponse { - permalink: permalink.to_string(), - }) - } - pub fn reload_buffers( &self, buffers: HashSet>, @@ -1930,139 +1706,3 @@ fn is_not_found_error(error: &anyhow::Error) -> bool { .downcast_ref::() .is_some_and(|err| err.kind() == io::ErrorKind::NotFound) } - -fn serialize_blame_buffer_response(blame: Option) -> proto::BlameBufferResponse { - let Some(blame) = blame else { - return proto::BlameBufferResponse { - blame_response: None, - }; - }; - - let entries = blame - .entries - .into_iter() - .map(|entry| proto::BlameEntry { - sha: entry.sha.as_bytes().into(), - start_line: entry.range.start, - end_line: entry.range.end, - original_line_number: entry.original_line_number, - author: entry.author.clone(), - author_mail: entry.author_mail.clone(), - author_time: entry.author_time, - author_tz: entry.author_tz.clone(), - committer: entry.committer_name.clone(), - committer_mail: entry.committer_email.clone(), - committer_time: entry.committer_time, - committer_tz: entry.committer_tz.clone(), - summary: entry.summary.clone(), - previous: entry.previous.clone(), - filename: entry.filename.clone(), - }) - .collect::>(); - - let messages = blame - .messages - .into_iter() - .map(|(oid, message)| proto::CommitMessage { - oid: oid.as_bytes().into(), - message, - }) - .collect::>(); - - proto::BlameBufferResponse { - blame_response: Some(proto::blame_buffer_response::BlameResponse { - entries, - messages, - remote_url: blame.remote_url, - }), - } -} - -fn deserialize_blame_buffer_response( - response: proto::BlameBufferResponse, -) -> Option { - let response = response.blame_response?; - let entries = response - .entries - .into_iter() - .filter_map(|entry| { - Some(git::blame::BlameEntry { - sha: git::Oid::from_bytes(&entry.sha).ok()?, - range: entry.start_line..entry.end_line, - original_line_number: entry.original_line_number, - committer_name: entry.committer, - committer_time: entry.committer_time, - committer_tz: entry.committer_tz, - committer_email: entry.committer_mail, - author: entry.author, - author_mail: entry.author_mail, - author_time: entry.author_time, - author_tz: entry.author_tz, - summary: entry.summary, - previous: entry.previous, - filename: entry.filename, - }) - }) - .collect::>(); - - let messages = response - .messages - .into_iter() - .filter_map(|message| Some((git::Oid::from_bytes(&message.oid).ok()?, message.message))) - .collect::>(); - - Some(Blame { - entries, - messages, - remote_url: response.remote_url, - }) -} - -fn get_permalink_in_rust_registry_src( - provider_registry: Arc, - path: PathBuf, - selection: Range, -) -> Result { - #[derive(Deserialize)] - struct CargoVcsGit { - sha1: String, - } - - #[derive(Deserialize)] - struct CargoVcsInfo { - git: CargoVcsGit, - path_in_vcs: String, - } - - #[derive(Deserialize)] - struct CargoPackage { - repository: String, - } - - #[derive(Deserialize)] - struct CargoToml { - package: CargoPackage, - } - - let Some((dir, cargo_vcs_info_json)) = path.ancestors().skip(1).find_map(|dir| { - let json = std::fs::read_to_string(dir.join(".cargo_vcs_info.json")).ok()?; - Some((dir, json)) - }) else { - bail!("No .cargo_vcs_info.json found in parent directories") - }; - let cargo_vcs_info = serde_json::from_str::(&cargo_vcs_info_json)?; - let cargo_toml = std::fs::read_to_string(dir.join("Cargo.toml"))?; - let manifest = toml::from_str::(&cargo_toml)?; - let (provider, remote) = parse_git_remote_url(provider_registry, &manifest.package.repository) - .ok_or_else(|| anyhow!("Failed to parse package.repository field of manifest"))?; - let path = PathBuf::from(cargo_vcs_info.path_in_vcs).join(path.strip_prefix(dir).unwrap()); - let permalink = provider.build_permalink( - remote, - BuildPermalinkParams { - sha: &cargo_vcs_info.git.sha1, - path: &path.to_string_lossy(), - selection: Some(selection), - }, - ); - Ok(permalink) -} diff --git a/crates/project/src/connection_manager.rs b/crates/project/src/connection_manager.rs index 72806cd977..6418fa1a8e 100644 --- a/crates/project/src/connection_manager.rs +++ b/crates/project/src/connection_manager.rs @@ -88,18 +88,18 @@ impl Manager { projects.insert(project_id, handle.clone()); let mut worktrees = Vec::new(); let mut repositories = Vec::new(); + for (id, repository) in project.repositories(cx) { + repositories.push(proto::RejoinRepository { + id: id.to_proto(), + scan_id: repository.read(cx).completed_scan_id as u64, + }); + } for worktree in project.worktrees(cx) { let worktree = worktree.read(cx); worktrees.push(proto::RejoinWorktree { id: worktree.id().to_proto(), scan_id: worktree.completed_scan_id() as u64, }); - for repository in worktree.repositories().iter() { - repositories.push(proto::RejoinRepository { - id: repository.work_directory_id().to_proto(), - scan_id: worktree.completed_scan_id() as u64, - }); - } } Some(proto::RejoinProject { id: project_id, diff --git a/crates/project/src/git.rs b/crates/project/src/git_store.rs similarity index 85% rename from crates/project/src/git.rs rename to crates/project/src/git_store.rs index bb802e9edf..ddd2edf189 100644 --- a/crates/project/src/git.rs +++ b/crates/project/src/git_store.rs @@ -1,9 +1,11 @@ +pub mod git_traversal; + use crate::{ buffer_store::{BufferStore, BufferStoreEvent}, worktree_store::{WorktreeStore, WorktreeStoreEvent}, Project, ProjectEnvironment, ProjectItem, ProjectPath, }; -use anyhow::{anyhow, Context as _, Result}; +use anyhow::{anyhow, bail, Context as _, Result}; use askpass::{AskPassDelegate, AskPassSession}; use buffer_diff::{BufferDiff, BufferDiffEvent}; use client::ProjectId; @@ -14,28 +16,35 @@ use futures::{ future::{self, OptionFuture, Shared}, FutureExt as _, StreamExt as _, }; -use git::repository::{DiffType, GitRepositoryCheckpoint}; use git::{ + blame::Blame, + parse_git_remote_url, repository::{ - Branch, CommitDetails, GitRepository, PushOptions, Remote, RemoteCommandOutput, RepoPath, - ResetMode, + Branch, CommitDetails, DiffType, GitRepository, GitRepositoryCheckpoint, PushOptions, + Remote, RemoteCommandOutput, RepoPath, ResetMode, }, status::FileStatus, + BuildPermalinkParams, GitHostingProviderRegistry, }; use gpui::{ App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Subscription, Task, WeakEntity, }; -use language::{Buffer, BufferEvent, Language, LanguageRegistry}; +use language::{ + proto::{deserialize_version, serialize_version}, + Buffer, BufferEvent, Language, LanguageRegistry, +}; use parking_lot::Mutex; use rpc::{ proto::{self, git_reset, ToProto, SSH_PROJECT_ID}, AnyProtoClient, TypedEnvelope, }; +use serde::Deserialize; use settings::WorktreeId; use std::{ collections::{hash_map, VecDeque}, future::Future, + ops::Range, path::{Path, PathBuf}, sync::Arc, }; @@ -49,6 +58,7 @@ use worktree::{ pub struct GitStore { state: GitStoreState, buffer_store: Entity, + _worktree_store: Entity, repositories: HashMap>, active_repo_id: Option, #[allow(clippy::type_complexity)] @@ -131,15 +141,16 @@ pub struct Repository { pub dot_git_abs_path: PathBuf, pub worktree_abs_path: Arc, pub is_from_single_file_worktree: bool, - pub git_repo: GitRepo, pub merge_message: Option, + pub completed_scan_id: usize, + git_repo: RepositoryState, job_sender: mpsc::UnboundedSender, askpass_delegates: Arc>>, latest_askpass_id: u64, } #[derive(Clone)] -pub enum GitRepo { +enum RepositoryState { Local(Arc), Remote { project_id: ProjectId, @@ -179,7 +190,7 @@ impl GitStore { cx: &mut Context, ) -> Self { Self::new( - worktree_store, + worktree_store.clone(), buffer_store, GitStoreState::Local { downstream_client: None, @@ -198,7 +209,7 @@ impl GitStore { cx: &mut Context, ) -> Self { Self::new( - worktree_store, + worktree_store.clone(), buffer_store, GitStoreState::Remote { upstream_client, @@ -216,7 +227,7 @@ impl GitStore { cx: &mut Context, ) -> Self { Self::new( - worktree_store, + worktree_store.clone(), buffer_store, GitStoreState::Ssh { upstream_client, @@ -229,20 +240,21 @@ impl GitStore { } fn new( - worktree_store: &Entity, + worktree_store: Entity, buffer_store: Entity, state: GitStoreState, cx: &mut Context, ) -> Self { let update_sender = Self::spawn_git_worker(cx); let _subscriptions = [ - cx.subscribe(worktree_store, Self::on_worktree_store_event), + cx.subscribe(&worktree_store, Self::on_worktree_store_event), cx.subscribe(&buffer_store, Self::on_buffer_store_event), ]; GitStore { state, buffer_store, + _worktree_store: worktree_store, repositories: HashMap::default(), active_repo_id: None, update_sender, @@ -276,6 +288,8 @@ impl GitStore { client.add_entity_request_handler(Self::handle_open_unstaged_diff); client.add_entity_request_handler(Self::handle_open_uncommitted_diff); client.add_entity_message_handler(Self::handle_update_diff_bases); + client.add_entity_request_handler(Self::handle_get_permalink_to_line); + client.add_entity_request_handler(Self::handle_blame_buffer); } pub fn is_local(&self) -> bool { @@ -511,6 +525,20 @@ impl GitStore { diff_state.read(cx).uncommitted_diff.as_ref()?.upgrade() } + pub fn project_path_git_status( + &self, + project_path: &ProjectPath, + cx: &App, + ) -> Option { + let (repo, repo_path) = self.repository_and_path_for_project_path(project_path, cx)?; + Some( + repo.read(cx) + .repository_entry + .status_for_path(&repo_path)? + .status, + ) + } + pub fn checkpoint(&self, cx: &App) -> Task> { let mut dot_git_abs_paths = Vec::new(); let mut checkpoints = Vec::new(); @@ -552,6 +580,172 @@ impl GitStore { }) } + pub fn blame_buffer( + &self, + buffer: &Entity, + version: Option, + cx: &App, + ) -> Task>> { + let buffer = buffer.read(cx); + let Some(file) = File::from_dyn(buffer.file()) else { + return Task::ready(Err(anyhow!("buffer has no file"))); + }; + + match file.worktree.clone().read(cx) { + Worktree::Local(worktree) => { + let worktree = worktree.snapshot(); + let blame_params = maybe!({ + let local_repo = match worktree.local_repo_for_path(&file.path) { + Some(repo_for_path) => repo_for_path, + None => return Ok(None), + }; + + let relative_path = local_repo + .relativize(&file.path) + .context("failed to relativize buffer path")?; + + let repo = local_repo.repo().clone(); + + let content = match version { + Some(version) => buffer.rope_for_version(&version).clone(), + None => buffer.as_rope().clone(), + }; + + anyhow::Ok(Some((repo, relative_path, content))) + }); + + cx.spawn(async move |cx| { + let Some((repo, relative_path, content)) = blame_params? else { + return Ok(None); + }; + repo.blame(relative_path.clone(), content, cx) + .await + .with_context(|| format!("Failed to blame {:?}", relative_path.0)) + .map(Some) + }) + } + Worktree::Remote(worktree) => { + let buffer_id = buffer.remote_id(); + let version = buffer.version(); + let project_id = worktree.project_id(); + let client = worktree.client(); + cx.spawn(async move |_| { + let response = client + .request(proto::BlameBuffer { + project_id, + buffer_id: buffer_id.into(), + version: serialize_version(&version), + }) + .await?; + Ok(deserialize_blame_buffer_response(response)) + }) + } + } + } + + pub fn get_permalink_to_line( + &self, + buffer: &Entity, + selection: Range, + cx: &App, + ) -> Task> { + let buffer = buffer.read(cx); + let Some(file) = File::from_dyn(buffer.file()) else { + return Task::ready(Err(anyhow!("buffer has no file"))); + }; + + match file.worktree.read(cx) { + Worktree::Local(worktree) => { + let worktree_path = worktree.abs_path().clone(); + let Some((repo_entry, repo)) = + worktree.repository_for_path(&file.path).and_then(|entry| { + let repo = worktree.get_local_repo(&entry)?.repo().clone(); + Some((entry, repo)) + }) + else { + // If we're not in a Git repo, check whether this is a Rust source + // file in the Cargo registry (presumably opened with go-to-definition + // from a normal Rust file). If so, we can put together a permalink + // using crate metadata. + if buffer + .language() + .is_none_or(|lang| lang.name() != "Rust".into()) + { + return Task::ready(Err(anyhow!("no permalink available"))); + } + let file_path = worktree_path.join(&file.path); + return cx.spawn(async move |cx| { + let provider_registry = + cx.update(GitHostingProviderRegistry::default_global)?; + get_permalink_in_rust_registry_src(provider_registry, file_path, selection) + .map_err(|_| anyhow!("no permalink available")) + }); + }; + + let path = match repo_entry.relativize(&file.path) { + Ok(RepoPath(path)) => path, + Err(e) => return Task::ready(Err(e)), + }; + + let remote = repo_entry + .branch() + .and_then(|b| b.upstream.as_ref()) + .and_then(|b| b.remote_name()) + .unwrap_or("origin") + .to_string(); + + cx.spawn(async move |cx| { + let origin_url = repo + .remote_url(&remote) + .ok_or_else(|| anyhow!("remote \"{remote}\" not found"))?; + + let sha = repo + .head_sha() + .ok_or_else(|| anyhow!("failed to read HEAD SHA"))?; + + let provider_registry = + cx.update(GitHostingProviderRegistry::default_global)?; + + let (provider, remote) = + parse_git_remote_url(provider_registry, &origin_url) + .ok_or_else(|| anyhow!("failed to parse Git remote URL"))?; + + let path = path + .to_str() + .ok_or_else(|| anyhow!("failed to convert path to string"))?; + + Ok(provider.build_permalink( + remote, + BuildPermalinkParams { + sha: &sha, + path, + selection: Some(selection), + }, + )) + }) + } + Worktree::Remote(worktree) => { + let buffer_id = buffer.remote_id(); + let project_id = worktree.project_id(); + let client = worktree.client(); + cx.spawn(async move |_| { + let response = client + .request(proto::GetPermalinkToLine { + project_id, + buffer_id: buffer_id.into(), + selection: Some(proto::Range { + start: selection.start as u64, + end: selection.end as u64, + }), + }) + .await?; + + url::Url::parse(&response.permalink).context("failed to parse permalink") + }) + } + } + } + fn downstream_client(&self) -> Option<(AnyProtoClient, ProjectId)> { match &self.state { GitStoreState::Local { @@ -611,12 +805,12 @@ impl GitStore { .and_then(|local_worktree| local_worktree.get_local_repo(repo_entry)) .map(|local_repo| { ( - GitRepo::Local(local_repo.repo().clone()), + RepositoryState::Local(local_repo.repo().clone()), local_repo.merge_message.clone(), ) }) .or_else(|| { - let git_repo = GitRepo::Remote { + let git_repo = RepositoryState::Remote { project_id: self.project_id()?, client: self .upstream_client() @@ -642,8 +836,9 @@ impl GitStore { let existing_repo = existing_repo.clone(); existing_repo.update(cx, |existing_repo, _| { existing_repo.repository_entry = repo_entry.clone(); - if matches!(git_repo, GitRepo::Local { .. }) { + if matches!(git_repo, RepositoryState::Local { .. }) { existing_repo.merge_message = merge_message; + existing_repo.completed_scan_id = worktree.completed_scan_id(); } }); existing_repo @@ -666,6 +861,7 @@ impl GitStore { job_sender: self.update_sender.clone(), merge_message, commit_message_buffer: None, + completed_scan_id: worktree.completed_scan_id(), }) }; new_repositories.insert(repo_entry.work_directory_id(), repo); @@ -992,13 +1188,21 @@ impl GitStore { Some(status.status) } - fn repository_and_path_for_buffer_id( + pub fn repository_and_path_for_buffer_id( &self, buffer_id: BufferId, cx: &App, ) -> Option<(Entity, RepoPath)> { let buffer = self.buffer_store.read(cx).get(buffer_id)?; - let path = buffer.read(cx).project_path(cx)?; + let project_path = buffer.read(cx).project_path(cx)?; + self.repository_and_path_for_project_path(&project_path, cx) + } + + pub fn repository_and_path_for_project_path( + &self, + path: &ProjectPath, + cx: &App, + ) -> Option<(Entity, RepoPath)> { let mut result: Option<(Entity, RepoPath)> = None; for repo_handle in self.repositories.values() { let repo = repo_handle.read(cx); @@ -1572,7 +1776,7 @@ impl GitStore { Ok(proto::GitDiffResponse { diff }) } - pub async fn handle_open_unstaged_diff( + async fn handle_open_unstaged_diff( this: Entity, request: TypedEnvelope, mut cx: AsyncApp, @@ -1596,7 +1800,7 @@ impl GitStore { Ok(proto::OpenUnstagedDiffResponse { staged_text }) } - pub async fn handle_open_uncommitted_diff( + async fn handle_open_uncommitted_diff( this: Entity, request: TypedEnvelope, mut cx: AsyncApp, @@ -1657,7 +1861,7 @@ impl GitStore { }) } - pub async fn handle_update_diff_bases( + async fn handle_update_diff_bases( this: Entity, request: TypedEnvelope, mut cx: AsyncApp, @@ -1675,6 +1879,56 @@ impl GitStore { }) } + async fn handle_blame_buffer( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let buffer_id = BufferId::new(envelope.payload.buffer_id)?; + let version = deserialize_version(&envelope.payload.version); + let buffer = this.read_with(&cx, |this, cx| { + this.buffer_store.read(cx).get_existing(buffer_id) + })??; + buffer + .update(&mut cx, |buffer, _| { + buffer.wait_for_version(version.clone()) + })? + .await?; + let blame = this + .update(&mut cx, |this, cx| { + this.blame_buffer(&buffer, Some(version), cx) + })? + .await?; + Ok(serialize_blame_buffer_response(blame)) + } + + async fn handle_get_permalink_to_line( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let buffer_id = BufferId::new(envelope.payload.buffer_id)?; + // let version = deserialize_version(&envelope.payload.version); + let selection = { + let proto_selection = envelope + .payload + .selection + .context("no selection to get permalink for defined")?; + proto_selection.start as u32..proto_selection.end as u32 + }; + let buffer = this.read_with(&cx, |this, cx| { + this.buffer_store.read(cx).get_existing(buffer_id) + })??; + let permalink = this + .update(&mut cx, |this, cx| { + this.get_permalink_to_line(&buffer, selection, cx) + })? + .await?; + Ok(proto::GetPermalinkToLineResponse { + permalink: permalink.to_string(), + }) + } + fn repository_for_request( this: &Entity, worktree_id: WorktreeId, @@ -2052,9 +2306,13 @@ impl Repository { self.repository_entry.branch() } + pub fn status_for_path(&self, path: &RepoPath) -> Option { + self.repository_entry.status_for_path(path) + } + fn send_job(&self, job: F) -> oneshot::Receiver where - F: FnOnce(GitRepo, AsyncApp) -> Fut + 'static, + F: FnOnce(RepositoryState, AsyncApp) -> Fut + 'static, Fut: Future + 'static, R: Send + 'static, { @@ -2063,7 +2321,7 @@ impl Repository { fn send_keyed_job(&self, key: Option, job: F) -> oneshot::Receiver where - F: FnOnce(GitRepo, AsyncApp) -> Fut + 'static, + F: FnOnce(RepositoryState, AsyncApp) -> Fut + 'static, Fut: Future + 'static, R: Send + 'static, { @@ -2178,6 +2436,13 @@ impl Repository { self.repository_entry.relativize(path).log_err() } + pub fn local_repository(&self) -> Option> { + match &self.git_repo { + RepositoryState::Local(git_repository) => Some(git_repository.clone()), + RepositoryState::Remote { .. } => None, + } + } + pub fn open_commit_buffer( &mut self, languages: Option>, @@ -2188,7 +2453,7 @@ impl Repository { return Task::ready(Ok(buffer)); } - if let GitRepo::Remote { + if let RepositoryState::Remote { project_id, client, worktree_id, @@ -2262,8 +2527,8 @@ impl Repository { self.send_job(|git_repo, _| async move { match git_repo { - GitRepo::Local(repo) => repo.checkout_files(commit, paths, env.await).await, - GitRepo::Remote { + RepositoryState::Local(repo) => repo.checkout_files(commit, paths, env.await).await, + RepositoryState::Remote { project_id, client, worktree_id, @@ -2298,11 +2563,11 @@ impl Repository { let env = self.worktree_environment(cx); self.send_job(|git_repo, _| async move { match git_repo { - GitRepo::Local(git_repo) => { + RepositoryState::Local(git_repo) => { let env = env.await; git_repo.reset(commit, reset_mode, env).await } - GitRepo::Remote { + RepositoryState::Remote { project_id, client, worktree_id, @@ -2330,8 +2595,8 @@ impl Repository { pub fn show(&self, commit: String) -> oneshot::Receiver> { self.send_job(|git_repo, cx| async move { match git_repo { - GitRepo::Local(git_repository) => git_repository.show(commit, cx).await, - GitRepo::Remote { + RepositoryState::Local(git_repository) => git_repository.show(commit, cx).await, + RepositoryState::Remote { project_id, client, worktree_id, @@ -2402,8 +2667,8 @@ impl Repository { this.update(cx, |this, _| { this.send_job(|git_repo, cx| async move { match git_repo { - GitRepo::Local(repo) => repo.stage_paths(entries, env, cx).await, - GitRepo::Remote { + RepositoryState::Local(repo) => repo.stage_paths(entries, env, cx).await, + RepositoryState::Remote { project_id, client, worktree_id, @@ -2473,8 +2738,8 @@ impl Repository { this.update(cx, |this, _| { this.send_job(|git_repo, cx| async move { match git_repo { - GitRepo::Local(repo) => repo.unstage_paths(entries, env, cx).await, - GitRepo::Remote { + RepositoryState::Local(repo) => repo.unstage_paths(entries, env, cx).await, + RepositoryState::Remote { project_id, client, worktree_id, @@ -2556,11 +2821,11 @@ impl Repository { let env = self.worktree_environment(cx); self.send_job(|git_repo, cx| async move { match git_repo { - GitRepo::Local(repo) => { + RepositoryState::Local(repo) => { let env = env.await; repo.commit(message, name_and_email, env, cx).await } - GitRepo::Remote { + RepositoryState::Remote { project_id, client, worktree_id, @@ -2597,12 +2862,12 @@ impl Repository { self.send_job(move |git_repo, cx| async move { match git_repo { - GitRepo::Local(git_repository) => { + RepositoryState::Local(git_repository) => { let askpass = AskPassSession::new(&executor, askpass).await?; let env = env.await; git_repository.fetch(askpass, env, cx).await } - GitRepo::Remote { + RepositoryState::Remote { project_id, client, worktree_id, @@ -2648,7 +2913,7 @@ impl Repository { self.send_job(move |git_repo, cx| async move { match git_repo { - GitRepo::Local(git_repository) => { + RepositoryState::Local(git_repository) => { let env = env.await; let askpass = AskPassSession::new(&executor, askpass).await?; git_repository @@ -2662,7 +2927,7 @@ impl Repository { ) .await } - GitRepo::Remote { + RepositoryState::Remote { project_id, client, worktree_id, @@ -2712,14 +2977,14 @@ impl Repository { self.send_job(move |git_repo, cx| async move { match git_repo { - GitRepo::Local(git_repository) => { + RepositoryState::Local(git_repository) => { let askpass = AskPassSession::new(&executor, askpass).await?; let env = env.await; git_repository .pull(branch.to_string(), remote.to_string(), askpass, env, cx) .await } - GitRepo::Remote { + RepositoryState::Remote { project_id, client, worktree_id, @@ -2763,8 +3028,10 @@ impl Repository { Some(GitJobKey::WriteIndex(path.clone())), |git_repo, cx| async { match git_repo { - GitRepo::Local(repo) => repo.set_index_text(path, content, env.await, cx).await, - GitRepo::Remote { + RepositoryState::Local(repo) => { + repo.set_index_text(path, content, env.await, cx).await + } + RepositoryState::Remote { project_id, client, worktree_id, @@ -2792,8 +3059,10 @@ impl Repository { ) -> oneshot::Receiver>> { self.send_job(|repo, cx| async move { match repo { - GitRepo::Local(git_repository) => git_repository.get_remotes(branch_name, cx).await, - GitRepo::Remote { + RepositoryState::Local(git_repository) => { + git_repository.get_remotes(branch_name, cx).await + } + RepositoryState::Remote { project_id, client, worktree_id, @@ -2822,15 +3091,19 @@ impl Repository { }) } + pub fn branch(&self) -> Option<&Branch> { + self.repository_entry.branch() + } + pub fn branches(&self) -> oneshot::Receiver>> { self.send_job(|repo, cx| async move { match repo { - GitRepo::Local(git_repository) => { + RepositoryState::Local(git_repository) => { let git_repository = git_repository.clone(); cx.background_spawn(async move { git_repository.branches().await }) .await } - GitRepo::Remote { + RepositoryState::Remote { project_id, client, worktree_id, @@ -2859,8 +3132,8 @@ impl Repository { pub fn diff(&self, diff_type: DiffType, _cx: &App) -> oneshot::Receiver> { self.send_job(|repo, cx| async move { match repo { - GitRepo::Local(git_repository) => git_repository.diff(diff_type, cx).await, - GitRepo::Remote { + RepositoryState::Local(git_repository) => git_repository.diff(diff_type, cx).await, + RepositoryState::Remote { project_id, client, worktree_id, @@ -2892,10 +3165,10 @@ impl Repository { pub fn create_branch(&self, branch_name: String) -> oneshot::Receiver> { self.send_job(|repo, cx| async move { match repo { - GitRepo::Local(git_repository) => { + RepositoryState::Local(git_repository) => { git_repository.create_branch(branch_name, cx).await } - GitRepo::Remote { + RepositoryState::Remote { project_id, client, worktree_id, @@ -2919,10 +3192,10 @@ impl Repository { pub fn change_branch(&self, branch_name: String) -> oneshot::Receiver> { self.send_job(|repo, cx| async move { match repo { - GitRepo::Local(git_repository) => { + RepositoryState::Local(git_repository) => { git_repository.change_branch(branch_name, cx).await } - GitRepo::Remote { + RepositoryState::Remote { project_id, client, worktree_id, @@ -2946,8 +3219,10 @@ impl Repository { pub fn check_for_pushed_commits(&self) -> oneshot::Receiver>> { self.send_job(|repo, cx| async move { match repo { - GitRepo::Local(git_repository) => git_repository.check_for_pushed_commit(cx).await, - GitRepo::Remote { + RepositoryState::Local(git_repository) => { + git_repository.check_for_pushed_commit(cx).await + } + RepositoryState::Remote { project_id, client, worktree_id, @@ -2972,8 +3247,8 @@ impl Repository { pub fn checkpoint(&self) -> oneshot::Receiver> { self.send_job(|repo, cx| async move { match repo { - GitRepo::Local(git_repository) => git_repository.checkpoint(cx).await, - GitRepo::Remote { .. } => Err(anyhow!("not implemented yet")), + RepositoryState::Local(git_repository) => git_repository.checkpoint(cx).await, + RepositoryState::Remote { .. } => Err(anyhow!("not implemented yet")), } }) } @@ -2984,11 +3259,147 @@ impl Repository { ) -> oneshot::Receiver> { self.send_job(move |repo, cx| async move { match repo { - GitRepo::Local(git_repository) => { + RepositoryState::Local(git_repository) => { git_repository.restore_checkpoint(checkpoint, cx).await } - GitRepo::Remote { .. } => Err(anyhow!("not implemented yet")), + RepositoryState::Remote { .. } => Err(anyhow!("not implemented yet")), } }) } } + +fn get_permalink_in_rust_registry_src( + provider_registry: Arc, + path: PathBuf, + selection: Range, +) -> Result { + #[derive(Deserialize)] + struct CargoVcsGit { + sha1: String, + } + + #[derive(Deserialize)] + struct CargoVcsInfo { + git: CargoVcsGit, + path_in_vcs: String, + } + + #[derive(Deserialize)] + struct CargoPackage { + repository: String, + } + + #[derive(Deserialize)] + struct CargoToml { + package: CargoPackage, + } + + let Some((dir, cargo_vcs_info_json)) = path.ancestors().skip(1).find_map(|dir| { + let json = std::fs::read_to_string(dir.join(".cargo_vcs_info.json")).ok()?; + Some((dir, json)) + }) else { + bail!("No .cargo_vcs_info.json found in parent directories") + }; + let cargo_vcs_info = serde_json::from_str::(&cargo_vcs_info_json)?; + let cargo_toml = std::fs::read_to_string(dir.join("Cargo.toml"))?; + let manifest = toml::from_str::(&cargo_toml)?; + let (provider, remote) = parse_git_remote_url(provider_registry, &manifest.package.repository) + .ok_or_else(|| anyhow!("Failed to parse package.repository field of manifest"))?; + let path = PathBuf::from(cargo_vcs_info.path_in_vcs).join(path.strip_prefix(dir).unwrap()); + let permalink = provider.build_permalink( + remote, + BuildPermalinkParams { + sha: &cargo_vcs_info.git.sha1, + path: &path.to_string_lossy(), + selection: Some(selection), + }, + ); + Ok(permalink) +} + +fn serialize_blame_buffer_response(blame: Option) -> proto::BlameBufferResponse { + let Some(blame) = blame else { + return proto::BlameBufferResponse { + blame_response: None, + }; + }; + + let entries = blame + .entries + .into_iter() + .map(|entry| proto::BlameEntry { + sha: entry.sha.as_bytes().into(), + start_line: entry.range.start, + end_line: entry.range.end, + original_line_number: entry.original_line_number, + author: entry.author.clone(), + author_mail: entry.author_mail.clone(), + author_time: entry.author_time, + author_tz: entry.author_tz.clone(), + committer: entry.committer_name.clone(), + committer_mail: entry.committer_email.clone(), + committer_time: entry.committer_time, + committer_tz: entry.committer_tz.clone(), + summary: entry.summary.clone(), + previous: entry.previous.clone(), + filename: entry.filename.clone(), + }) + .collect::>(); + + let messages = blame + .messages + .into_iter() + .map(|(oid, message)| proto::CommitMessage { + oid: oid.as_bytes().into(), + message, + }) + .collect::>(); + + proto::BlameBufferResponse { + blame_response: Some(proto::blame_buffer_response::BlameResponse { + entries, + messages, + remote_url: blame.remote_url, + }), + } +} + +fn deserialize_blame_buffer_response( + response: proto::BlameBufferResponse, +) -> Option { + let response = response.blame_response?; + let entries = response + .entries + .into_iter() + .filter_map(|entry| { + Some(git::blame::BlameEntry { + sha: git::Oid::from_bytes(&entry.sha).ok()?, + range: entry.start_line..entry.end_line, + original_line_number: entry.original_line_number, + committer_name: entry.committer, + committer_time: entry.committer_time, + committer_tz: entry.committer_tz, + committer_email: entry.committer_mail, + author: entry.author, + author_mail: entry.author_mail, + author_time: entry.author_time, + author_tz: entry.author_tz, + summary: entry.summary, + previous: entry.previous, + filename: entry.filename, + }) + }) + .collect::>(); + + let messages = response + .messages + .into_iter() + .filter_map(|message| Some((git::Oid::from_bytes(&message.oid).ok()?, message.message))) + .collect::>(); + + Some(Blame { + entries, + messages, + remote_url: response.remote_url, + }) +} diff --git a/crates/project/src/git_store/git_traversal.rs b/crates/project/src/git_store/git_traversal.rs new file mode 100644 index 0000000000..258939dc85 --- /dev/null +++ b/crates/project/src/git_store/git_traversal.rs @@ -0,0 +1,767 @@ +use git::status::GitSummary; +use std::{ops::Deref, path::Path}; +use sum_tree::Cursor; +use text::Bias; +use worktree::{Entry, PathProgress, PathTarget, RepositoryEntry, StatusEntry, Traversal}; + +/// Walks the worktree entries and their associated git statuses. +pub struct GitTraversal<'a> { + traversal: Traversal<'a>, + current_entry_summary: Option, + repo_location: Option<( + &'a RepositoryEntry, + Cursor<'a, StatusEntry, PathProgress<'a>>, + )>, +} + +impl<'a> GitTraversal<'a> { + pub fn new(traversal: Traversal<'a>) -> GitTraversal<'a> { + let mut this = GitTraversal { + traversal, + current_entry_summary: None, + repo_location: None, + }; + this.synchronize_statuses(true); + this + } + + fn synchronize_statuses(&mut self, reset: bool) { + self.current_entry_summary = None; + + let Some(entry) = self.entry() else { + return; + }; + + let Some(repo) = self.traversal.snapshot().repository_for_path(&entry.path) else { + self.repo_location = None; + return; + }; + + // Update our state if we changed repositories. + if reset + || self + .repo_location + .as_ref() + .map(|(prev_repo, _)| &prev_repo.work_directory) + != Some(&repo.work_directory) + { + self.repo_location = Some((repo, repo.statuses_by_path.cursor::(&()))); + } + + let Some((repo, statuses)) = &mut self.repo_location else { + return; + }; + + let repo_path = repo.relativize(&entry.path).unwrap(); + + if entry.is_dir() { + let mut statuses = statuses.clone(); + statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left, &()); + let summary = + statuses.summary(&PathTarget::Successor(repo_path.as_ref()), Bias::Left, &()); + + self.current_entry_summary = Some(summary); + } else if entry.is_file() { + // For a file entry, park the cursor on the corresponding status + if statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left, &()) { + // TODO: Investigate statuses.item() being None here. + self.current_entry_summary = statuses.item().map(|item| item.status.into()); + } else { + self.current_entry_summary = Some(GitSummary::UNCHANGED); + } + } + } + + pub fn advance(&mut self) -> bool { + self.advance_by(1) + } + + pub fn advance_by(&mut self, count: usize) -> bool { + let found = self.traversal.advance_by(count); + self.synchronize_statuses(false); + found + } + + pub fn advance_to_sibling(&mut self) -> bool { + let found = self.traversal.advance_to_sibling(); + self.synchronize_statuses(false); + found + } + + pub fn back_to_parent(&mut self) -> bool { + let found = self.traversal.back_to_parent(); + self.synchronize_statuses(true); + found + } + + pub fn start_offset(&self) -> usize { + self.traversal.start_offset() + } + + pub fn end_offset(&self) -> usize { + self.traversal.end_offset() + } + + pub fn entry(&self) -> Option> { + let entry = self.traversal.entry()?; + let git_summary = self.current_entry_summary.unwrap_or(GitSummary::UNCHANGED); + Some(GitEntryRef { entry, git_summary }) + } +} + +impl<'a> Iterator for GitTraversal<'a> { + type Item = GitEntryRef<'a>; + + fn next(&mut self) -> Option { + if let Some(item) = self.entry() { + self.advance(); + Some(item) + } else { + None + } + } +} + +pub struct ChildEntriesGitIter<'a> { + parent_path: &'a Path, + traversal: GitTraversal<'a>, +} + +impl<'a> ChildEntriesGitIter<'a> { + pub fn new(snapshot: &'a worktree::Snapshot, parent_path: &'a Path) -> Self { + let mut traversal = + GitTraversal::new(snapshot.traverse_from_path(true, true, true, parent_path)); + traversal.advance(); + ChildEntriesGitIter { + parent_path, + traversal, + } + } +} + +impl<'a> Iterator for ChildEntriesGitIter<'a> { + type Item = GitEntryRef<'a>; + + fn next(&mut self) -> Option { + if let Some(item) = self.traversal.entry() { + if item.path.starts_with(self.parent_path) { + self.traversal.advance_to_sibling(); + return Some(item); + } + } + None + } +} + +#[derive(Debug, Clone, Copy)] +pub struct GitEntryRef<'a> { + pub entry: &'a Entry, + pub git_summary: GitSummary, +} + +impl GitEntryRef<'_> { + pub fn to_owned(&self) -> GitEntry { + GitEntry { + entry: self.entry.clone(), + git_summary: self.git_summary, + } + } +} + +impl Deref for GitEntryRef<'_> { + type Target = Entry; + + fn deref(&self) -> &Self::Target { + &self.entry + } +} + +impl AsRef for GitEntryRef<'_> { + fn as_ref(&self) -> &Entry { + self.entry + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitEntry { + pub entry: Entry, + pub git_summary: GitSummary, +} + +impl GitEntry { + pub fn to_ref(&self) -> GitEntryRef { + GitEntryRef { + entry: &self.entry, + git_summary: self.git_summary, + } + } +} + +impl Deref for GitEntry { + type Target = Entry; + + fn deref(&self) -> &Self::Target { + &self.entry + } +} + +impl AsRef for GitEntry { + fn as_ref(&self) -> &Entry { + &self.entry + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use fs::FakeFs; + use git::status::{FileStatus, StatusCode, TrackedSummary, UnmergedStatus, UnmergedStatusCode}; + use gpui::TestAppContext; + use serde_json::json; + use settings::{Settings as _, SettingsStore}; + use util::path; + use worktree::{Worktree, WorktreeSettings}; + + const CONFLICT: FileStatus = FileStatus::Unmerged(UnmergedStatus { + first_head: UnmergedStatusCode::Updated, + second_head: UnmergedStatusCode::Updated, + }); + const ADDED: GitSummary = GitSummary { + index: TrackedSummary::ADDED, + count: 1, + ..GitSummary::UNCHANGED + }; + const MODIFIED: GitSummary = GitSummary { + index: TrackedSummary::MODIFIED, + count: 1, + ..GitSummary::UNCHANGED + }; + + #[gpui::test] + async fn test_git_traversal_with_one_repo(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "x": { + ".git": {}, + "x1.txt": "foo", + "x2.txt": "bar", + "y": { + ".git": {}, + "y1.txt": "baz", + "y2.txt": "qux" + }, + "z.txt": "sneaky..." + }, + "z": { + ".git": {}, + "z1.txt": "quux", + "z2.txt": "quuux" + } + }), + ) + .await; + + fs.set_status_for_repo( + Path::new(path!("/root/x/.git")), + &[ + (Path::new("x2.txt"), StatusCode::Modified.index()), + (Path::new("z.txt"), StatusCode::Added.index()), + ], + ); + fs.set_status_for_repo( + Path::new(path!("/root/x/y/.git")), + &[(Path::new("y1.txt"), CONFLICT)], + ); + fs.set_status_for_repo( + Path::new(path!("/root/z/.git")), + &[(Path::new("z2.txt"), StatusCode::Added.index())], + ); + + let tree = Worktree::local( + Path::new(path!("/root")), + true, + fs.clone(), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.executor().run_until_parked(); + + let snapshot = tree.read_with(cx, |tree, _| tree.snapshot()); + + let mut traversal = + GitTraversal::new(snapshot.traverse_from_path(true, false, true, Path::new("x"))); + + let entry = traversal.next().unwrap(); + assert_eq!(entry.path.as_ref(), Path::new("x/x1.txt")); + assert_eq!(entry.git_summary, GitSummary::UNCHANGED); + let entry = traversal.next().unwrap(); + assert_eq!(entry.path.as_ref(), Path::new("x/x2.txt")); + assert_eq!(entry.git_summary, MODIFIED); + let entry = traversal.next().unwrap(); + assert_eq!(entry.path.as_ref(), Path::new("x/y/y1.txt")); + assert_eq!(entry.git_summary, GitSummary::CONFLICT); + let entry = traversal.next().unwrap(); + assert_eq!(entry.path.as_ref(), Path::new("x/y/y2.txt")); + assert_eq!(entry.git_summary, GitSummary::UNCHANGED); + let entry = traversal.next().unwrap(); + assert_eq!(entry.path.as_ref(), Path::new("x/z.txt")); + assert_eq!(entry.git_summary, ADDED); + let entry = traversal.next().unwrap(); + assert_eq!(entry.path.as_ref(), Path::new("z/z1.txt")); + assert_eq!(entry.git_summary, GitSummary::UNCHANGED); + let entry = traversal.next().unwrap(); + assert_eq!(entry.path.as_ref(), Path::new("z/z2.txt")); + assert_eq!(entry.git_summary, ADDED); + } + + #[gpui::test] + async fn test_git_traversal_with_nested_repos(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "x": { + ".git": {}, + "x1.txt": "foo", + "x2.txt": "bar", + "y": { + ".git": {}, + "y1.txt": "baz", + "y2.txt": "qux" + }, + "z.txt": "sneaky..." + }, + "z": { + ".git": {}, + "z1.txt": "quux", + "z2.txt": "quuux" + } + }), + ) + .await; + + fs.set_status_for_repo( + Path::new(path!("/root/x/.git")), + &[ + (Path::new("x2.txt"), StatusCode::Modified.index()), + (Path::new("z.txt"), StatusCode::Added.index()), + ], + ); + fs.set_status_for_repo( + Path::new(path!("/root/x/y/.git")), + &[(Path::new("y1.txt"), CONFLICT)], + ); + + fs.set_status_for_repo( + Path::new(path!("/root/z/.git")), + &[(Path::new("z2.txt"), StatusCode::Added.index())], + ); + + let tree = Worktree::local( + Path::new(path!("/root")), + true, + fs.clone(), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.executor().run_until_parked(); + + let snapshot = tree.read_with(cx, |tree, _| tree.snapshot()); + + // Sanity check the propagation for x/y and z + check_git_statuses( + &snapshot, + &[ + (Path::new("x/y"), GitSummary::CONFLICT), + (Path::new("x/y/y1.txt"), GitSummary::CONFLICT), + (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED), + ], + ); + check_git_statuses( + &snapshot, + &[ + (Path::new("z"), ADDED), + (Path::new("z/z1.txt"), GitSummary::UNCHANGED), + (Path::new("z/z2.txt"), ADDED), + ], + ); + + // Test one of the fundamental cases of propagation blocking, the transition from one git repository to another + check_git_statuses( + &snapshot, + &[ + (Path::new("x"), MODIFIED + ADDED), + (Path::new("x/y"), GitSummary::CONFLICT), + (Path::new("x/y/y1.txt"), GitSummary::CONFLICT), + ], + ); + + // Sanity check everything around it + check_git_statuses( + &snapshot, + &[ + (Path::new("x"), MODIFIED + ADDED), + (Path::new("x/x1.txt"), GitSummary::UNCHANGED), + (Path::new("x/x2.txt"), MODIFIED), + (Path::new("x/y"), GitSummary::CONFLICT), + (Path::new("x/y/y1.txt"), GitSummary::CONFLICT), + (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED), + (Path::new("x/z.txt"), ADDED), + ], + ); + + // Test the other fundamental case, transitioning from git repository to non-git repository + check_git_statuses( + &snapshot, + &[ + (Path::new(""), GitSummary::UNCHANGED), + (Path::new("x"), MODIFIED + ADDED), + (Path::new("x/x1.txt"), GitSummary::UNCHANGED), + ], + ); + + // And all together now + check_git_statuses( + &snapshot, + &[ + (Path::new(""), GitSummary::UNCHANGED), + (Path::new("x"), MODIFIED + ADDED), + (Path::new("x/x1.txt"), GitSummary::UNCHANGED), + (Path::new("x/x2.txt"), MODIFIED), + (Path::new("x/y"), GitSummary::CONFLICT), + (Path::new("x/y/y1.txt"), GitSummary::CONFLICT), + (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED), + (Path::new("x/z.txt"), ADDED), + (Path::new("z"), ADDED), + (Path::new("z/z1.txt"), GitSummary::UNCHANGED), + (Path::new("z/z2.txt"), ADDED), + ], + ); + } + + #[gpui::test] + async fn test_git_traversal_simple(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".git": {}, + "a": { + "b": { + "c1.txt": "", + "c2.txt": "", + }, + "d": { + "e1.txt": "", + "e2.txt": "", + "e3.txt": "", + } + }, + "f": { + "no-status.txt": "" + }, + "g": { + "h1.txt": "", + "h2.txt": "" + }, + }), + ) + .await; + + fs.set_status_for_repo( + Path::new(path!("/root/.git")), + &[ + (Path::new("a/b/c1.txt"), StatusCode::Added.index()), + (Path::new("a/d/e2.txt"), StatusCode::Modified.index()), + (Path::new("g/h2.txt"), CONFLICT), + ], + ); + + let tree = Worktree::local( + Path::new(path!("/root")), + true, + fs.clone(), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + cx.executor().run_until_parked(); + + let snapshot = tree.read_with(cx, |tree, _| tree.snapshot()); + + check_git_statuses( + &snapshot, + &[ + (Path::new(""), GitSummary::CONFLICT + MODIFIED + ADDED), + (Path::new("g"), GitSummary::CONFLICT), + (Path::new("g/h2.txt"), GitSummary::CONFLICT), + ], + ); + + check_git_statuses( + &snapshot, + &[ + (Path::new(""), GitSummary::CONFLICT + ADDED + MODIFIED), + (Path::new("a"), ADDED + MODIFIED), + (Path::new("a/b"), ADDED), + (Path::new("a/b/c1.txt"), ADDED), + (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED), + (Path::new("a/d"), MODIFIED), + (Path::new("a/d/e2.txt"), MODIFIED), + (Path::new("f"), GitSummary::UNCHANGED), + (Path::new("f/no-status.txt"), GitSummary::UNCHANGED), + (Path::new("g"), GitSummary::CONFLICT), + (Path::new("g/h2.txt"), GitSummary::CONFLICT), + ], + ); + + check_git_statuses( + &snapshot, + &[ + (Path::new("a/b"), ADDED), + (Path::new("a/b/c1.txt"), ADDED), + (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED), + (Path::new("a/d"), MODIFIED), + (Path::new("a/d/e1.txt"), GitSummary::UNCHANGED), + (Path::new("a/d/e2.txt"), MODIFIED), + (Path::new("f"), GitSummary::UNCHANGED), + (Path::new("f/no-status.txt"), GitSummary::UNCHANGED), + (Path::new("g"), GitSummary::CONFLICT), + ], + ); + + check_git_statuses( + &snapshot, + &[ + (Path::new("a/b/c1.txt"), ADDED), + (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED), + (Path::new("a/d/e1.txt"), GitSummary::UNCHANGED), + (Path::new("a/d/e2.txt"), MODIFIED), + (Path::new("f/no-status.txt"), GitSummary::UNCHANGED), + ], + ); + } + + #[gpui::test] + async fn test_git_traversal_with_repos_under_project(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "x": { + ".git": {}, + "x1.txt": "foo", + "x2.txt": "bar" + }, + "y": { + ".git": {}, + "y1.txt": "baz", + "y2.txt": "qux" + }, + "z": { + ".git": {}, + "z1.txt": "quux", + "z2.txt": "quuux" + } + }), + ) + .await; + + fs.set_status_for_repo( + Path::new(path!("/root/x/.git")), + &[(Path::new("x1.txt"), StatusCode::Added.index())], + ); + fs.set_status_for_repo( + Path::new(path!("/root/y/.git")), + &[ + (Path::new("y1.txt"), CONFLICT), + (Path::new("y2.txt"), StatusCode::Modified.index()), + ], + ); + fs.set_status_for_repo( + Path::new(path!("/root/z/.git")), + &[(Path::new("z2.txt"), StatusCode::Modified.index())], + ); + + let tree = Worktree::local( + Path::new(path!("/root")), + true, + fs.clone(), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + cx.executor().run_until_parked(); + + let snapshot = tree.read_with(cx, |tree, _| tree.snapshot()); + + check_git_statuses( + &snapshot, + &[(Path::new("x"), ADDED), (Path::new("x/x1.txt"), ADDED)], + ); + + check_git_statuses( + &snapshot, + &[ + (Path::new("y"), GitSummary::CONFLICT + MODIFIED), + (Path::new("y/y1.txt"), GitSummary::CONFLICT), + (Path::new("y/y2.txt"), MODIFIED), + ], + ); + + check_git_statuses( + &snapshot, + &[ + (Path::new("z"), MODIFIED), + (Path::new("z/z2.txt"), MODIFIED), + ], + ); + + check_git_statuses( + &snapshot, + &[(Path::new("x"), ADDED), (Path::new("x/x1.txt"), ADDED)], + ); + + check_git_statuses( + &snapshot, + &[ + (Path::new("x"), ADDED), + (Path::new("x/x1.txt"), ADDED), + (Path::new("x/x2.txt"), GitSummary::UNCHANGED), + (Path::new("y"), GitSummary::CONFLICT + MODIFIED), + (Path::new("y/y1.txt"), GitSummary::CONFLICT), + (Path::new("y/y2.txt"), MODIFIED), + (Path::new("z"), MODIFIED), + (Path::new("z/z1.txt"), GitSummary::UNCHANGED), + (Path::new("z/z2.txt"), MODIFIED), + ], + ); + } + + fn init_test(cx: &mut gpui::TestAppContext) { + if std::env::var("RUST_LOG").is_ok() { + env_logger::try_init().ok(); + } + + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + WorktreeSettings::register(cx); + }); + } + + #[gpui::test] + async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) { + init_test(cx); + + // Create a worktree with a git directory. + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".git": {}, + "a.txt": "", + "b": { + "c.txt": "", + }, + }), + ) + .await; + fs.set_head_and_index_for_repo( + path!("/root/.git").as_ref(), + &[("a.txt".into(), "".into()), ("b/c.txt".into(), "".into())], + ); + cx.run_until_parked(); + + let tree = Worktree::local( + path!("/root").as_ref(), + true, + fs.clone(), + Default::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + cx.executor().run_until_parked(); + + let (old_entry_ids, old_mtimes) = tree.read_with(cx, |tree, _| { + ( + tree.entries(true, 0).map(|e| e.id).collect::>(), + tree.entries(true, 0).map(|e| e.mtime).collect::>(), + ) + }); + + // Regression test: after the directory is scanned, touch the git repo's + // working directory, bumping its mtime. That directory keeps its project + // entry id after the directories are re-scanned. + fs.touch_path(path!("/root")).await; + cx.executor().run_until_parked(); + + let (new_entry_ids, new_mtimes) = tree.read_with(cx, |tree, _| { + ( + tree.entries(true, 0).map(|e| e.id).collect::>(), + tree.entries(true, 0).map(|e| e.mtime).collect::>(), + ) + }); + assert_eq!(new_entry_ids, old_entry_ids); + assert_ne!(new_mtimes, old_mtimes); + + // Regression test: changes to the git repository should still be + // detected. + fs.set_head_for_repo( + path!("/root/.git").as_ref(), + &[ + ("a.txt".into(), "".into()), + ("b/c.txt".into(), "something-else".into()), + ], + ); + cx.executor().run_until_parked(); + cx.executor().advance_clock(Duration::from_secs(1)); + + let snapshot = tree.read_with(cx, |tree, _| tree.snapshot()); + + check_git_statuses( + &snapshot, + &[ + (Path::new(""), MODIFIED), + (Path::new("a.txt"), GitSummary::UNCHANGED), + (Path::new("b/c.txt"), MODIFIED), + ], + ); + } + + #[track_caller] + fn check_git_statuses( + snapshot: &worktree::Snapshot, + expected_statuses: &[(&Path, GitSummary)], + ) { + let mut traversal = + GitTraversal::new(snapshot.traverse_from_path(true, true, false, "".as_ref())); + let found_statuses = expected_statuses + .iter() + .map(|&(path, _)| { + let git_entry = traversal + .find(|git_entry| &*git_entry.path == path) + .unwrap_or_else(|| panic!("Traversal has no entry for {path:?}")); + (path, git_entry.git_summary) + }) + .collect::>(); + assert_eq!(found_statuses, expected_statuses); + } +} diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index a1d0fb6002..caea2eeffa 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -3,7 +3,7 @@ mod color_extractor; pub mod connection_manager; pub mod debounced_delay; pub mod debugger; -pub mod git; +pub mod git_store; pub mod image_store; pub mod lsp_command; pub mod lsp_store; @@ -24,11 +24,12 @@ mod direnv; mod environment; use buffer_diff::BufferDiff; pub use environment::{EnvironmentErrorMessage, ProjectEnvironmentEvent}; -use git::Repository; +use git_store::Repository; pub mod search_history; mod yarn; -use crate::git::GitStore; +use crate::git_store::GitStore; +pub use git_store::git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal}; use anyhow::{anyhow, Context as _, Result}; use buffer_store::{BufferStore, BufferStoreEvent}; @@ -55,7 +56,7 @@ use futures::{ pub use image_store::{ImageItem, ImageStore}; use image_store::{ImageItemEvent, ImageStoreEvent}; -use ::git::{blame::Blame, repository::GitRepository, status::FileStatus}; +use ::git::{blame::Blame, status::FileStatus}; use gpui::{ AnyEntity, App, AppContext, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter, Hsla, SharedString, Task, WeakEntity, Window, @@ -1768,8 +1769,9 @@ impl Project { project_path: &ProjectPath, cx: &App, ) -> Option { - self.worktree_for_id(project_path.worktree_id, cx) - .and_then(|worktree| worktree.read(cx).status_for_file(&project_path.path)) + self.git_store + .read(cx) + .project_path_git_status(project_path, cx) } pub fn visibility_for_paths( @@ -4049,19 +4051,13 @@ impl Project { ) } - pub fn get_first_worktree_root_repo(&self, cx: &App) -> Option> { - let worktree = self.visible_worktrees(cx).next()?.read(cx).as_local()?; - let root_entry = worktree.root_git_entry()?; - worktree.get_local_repo(&root_entry)?.repo().clone().into() - } - pub fn blame_buffer( &self, buffer: &Entity, version: Option, cx: &App, ) -> Task>> { - self.buffer_store.read(cx).blame_buffer(buffer, version, cx) + self.git_store.read(cx).blame_buffer(buffer, version, cx) } pub fn get_permalink_to_line( @@ -4070,7 +4066,7 @@ impl Project { selection: Range, cx: &App, ) -> Task> { - self.buffer_store + self.git_store .read(cx) .get_permalink_to_line(buffer, selection, cx) } diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index 3c2cfe547c..2f7ab40796 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -12,7 +12,6 @@ use futures::{ future::{BoxFuture, Shared}, FutureExt, SinkExt, }; -use git::repository::Branch; use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, EntityId, EventEmitter, Task, WeakEntity, }; @@ -134,14 +133,6 @@ impl WorktreeStore { .find(|worktree| worktree.read(cx).id() == id) } - pub fn current_branch(&self, repository: ProjectPath, cx: &App) -> Option { - self.worktree_for_id(repository.worktree_id, cx)? - .read(cx) - .git_entry(repository.path)? - .branch() - .cloned() - } - pub fn worktree_for_entry( &self, entry_id: ProjectEntryId, diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 6538a52bb0..e04b15850f 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -28,8 +28,8 @@ use indexmap::IndexMap; use language::DiagnosticSeverity; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use project::{ - relativize_path, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree, - WorktreeId, + git_store::git_traversal::ChildEntriesGitIter, relativize_path, Entry, EntryKind, Fs, GitEntry, + GitEntryRef, GitTraversal, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId, }; use project_panel_settings::{ ProjectPanelDockPosition, ProjectPanelSettings, ShowDiagnostics, ShowIndentGuides, @@ -62,7 +62,7 @@ use workspace::{ DraggedSelection, OpenInTerminal, OpenOptions, OpenVisible, PreviewTabsSettings, SelectedEntry, Workspace, }; -use worktree::{CreatedEntry, GitEntry, GitEntryRef}; +use worktree::CreatedEntry; const PROJECT_PANEL_KEY: &str = "ProjectPanel"; const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX; @@ -1564,10 +1564,8 @@ impl ProjectPanel { let parent_entry = worktree.entry_for_path(parent_path)?; // Remove all siblings that are being deleted except the last marked entry - let mut siblings: Vec<_> = worktree - .snapshot() - .child_entries(parent_path) - .with_git_statuses() + let snapshot = worktree.snapshot(); + let mut siblings: Vec<_> = ChildEntriesGitIter::new(&snapshot, parent_path) .filter(|sibling| { sibling.id == latest_entry.id || !marked_entries_in_worktree.contains(&&SelectedEntry { @@ -2631,7 +2629,7 @@ impl ProjectPanel { } let mut visible_worktree_entries = Vec::new(); - let mut entry_iter = snapshot.entries(true, 0).with_git_statuses(); + let mut entry_iter = GitTraversal::new(snapshot.entries(true, 0)); let mut auto_folded_ancestors = vec![]; while let Some(entry) = entry_iter.entry() { if auto_collapse_dirs && entry.kind.is_dir() { @@ -3284,7 +3282,7 @@ impl ProjectPanel { let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?; worktree.update(cx, |tree, _| { utils::ReversibleIterable::new( - tree.entries(true, 0usize).with_git_statuses(), + GitTraversal::new(tree.entries(true, 0usize)), reverse_search, ) .find_single_ended(|ele| predicate(*ele, worktree_id)) @@ -3318,9 +3316,12 @@ impl ProjectPanel { let root_entry = tree.root_entry()?; let tree_id = tree.id(); - let mut first_iter = tree - .traverse_from_path(true, true, true, entry.path.as_ref()) - .with_git_statuses(); + let mut first_iter = GitTraversal::new(tree.traverse_from_path( + true, + true, + true, + entry.path.as_ref(), + )); if reverse_search { first_iter.next(); @@ -3333,7 +3334,7 @@ impl ProjectPanel { .find(|ele| predicate(*ele, tree_id)) .map(|ele| ele.to_owned()); - let second_iter = tree.entries(true, 0usize).with_git_statuses(); + let second_iter = GitTraversal::new(tree.entries(true, 0usize)); let second = if reverse_search { second_iter @@ -4817,5042 +4818,4 @@ impl ClipboardEntry { } #[cfg(test)] -mod tests { - use super::*; - use collections::HashSet; - use gpui::{Empty, Entity, TestAppContext, VisualTestContext, WindowHandle}; - use pretty_assertions::assert_eq; - use project::{FakeFs, WorktreeSettings}; - use serde_json::json; - use settings::SettingsStore; - use std::path::{Path, PathBuf}; - use util::{path, separator}; - use workspace::{ - item::{Item, ProjectItem}, - register_project_item, AppState, - }; - - #[gpui::test] - async fn test_visible_list(cx: &mut gpui::TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/root1", - json!({ - ".dockerignore": "", - ".git": { - "HEAD": "", - }, - "a": { - "0": { "q": "", "r": "", "s": "" }, - "1": { "t": "", "u": "" }, - "2": { "v": "", "w": "", "x": "", "y": "" }, - }, - "b": { - "3": { "Q": "" }, - "4": { "R": "", "S": "", "T": "", "U": "" }, - }, - "C": { - "5": {}, - "6": { "V": "", "W": "" }, - "7": { "X": "" }, - "8": { "Y": {}, "Z": "" } - } - }), - ) - .await; - fs.insert_tree( - "/root2", - json!({ - "d": { - "9": "" - }, - "e": {} - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - "v root1", - " > .git", - " > a", - " > b", - " > C", - " .dockerignore", - "v root2", - " > d", - " > e", - ] - ); - - toggle_expand_dir(&panel, "root1/b", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - "v root1", - " > .git", - " > a", - " v b <== selected", - " > 3", - " > 4", - " > C", - " .dockerignore", - "v root2", - " > d", - " > e", - ] - ); - - assert_eq!( - visible_entries_as_strings(&panel, 6..9, cx), - &[ - // - " > C", - " .dockerignore", - "v root2", - ] - ); - } - - #[gpui::test] - async fn test_opening_file(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - path!("/src"), - json!({ - "test": { - "first.rs": "// First Rust file", - "second.rs": "// Second Rust file", - "third.rs": "// Third Rust file", - } - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - toggle_expand_dir(&panel, "src/test", cx); - select_path(&panel, "src/test/first.rs", cx); - panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v src", - " v test", - " first.rs <== selected <== marked", - " second.rs", - " third.rs" - ] - ); - ensure_single_file_is_opened(&workspace, "test/first.rs", cx); - - select_path(&panel, "src/test/second.rs", cx); - panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v src", - " v test", - " first.rs", - " second.rs <== selected <== marked", - " third.rs" - ] - ); - ensure_single_file_is_opened(&workspace, "test/second.rs", cx); - } - - #[gpui::test] - async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) { - init_test(cx); - cx.update(|cx| { - cx.update_global::(|store, cx| { - store.update_user_settings::(cx, |worktree_settings| { - worktree_settings.file_scan_exclusions = - Some(vec!["**/.git".to_string(), "**/4/**".to_string()]); - }); - }); - }); - - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - "/root1", - json!({ - ".dockerignore": "", - ".git": { - "HEAD": "", - }, - "a": { - "0": { "q": "", "r": "", "s": "" }, - "1": { "t": "", "u": "" }, - "2": { "v": "", "w": "", "x": "", "y": "" }, - }, - "b": { - "3": { "Q": "" }, - "4": { "R": "", "S": "", "T": "", "U": "" }, - }, - "C": { - "5": {}, - "6": { "V": "", "W": "" }, - "7": { "X": "" }, - "8": { "Y": {}, "Z": "" } - } - }), - ) - .await; - fs.insert_tree( - "/root2", - json!({ - "d": { - "4": "" - }, - "e": {} - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - "v root1", - " > a", - " > b", - " > C", - " .dockerignore", - "v root2", - " > d", - " > e", - ] - ); - - toggle_expand_dir(&panel, "root1/b", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - "v root1", - " > a", - " v b <== selected", - " > 3", - " > C", - " .dockerignore", - "v root2", - " > d", - " > e", - ] - ); - - toggle_expand_dir(&panel, "root2/d", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - "v root1", - " > a", - " v b", - " > 3", - " > C", - " .dockerignore", - "v root2", - " v d <== selected", - " > e", - ] - ); - - toggle_expand_dir(&panel, "root2/e", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - "v root1", - " > a", - " v b", - " > 3", - " > C", - " .dockerignore", - "v root2", - " v d", - " v e <== selected", - ] - ); - } - - #[gpui::test] - async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - path!("/root1"), - json!({ - "dir_1": { - "nested_dir_1": { - "nested_dir_2": { - "nested_dir_3": { - "file_a.java": "// File contents", - "file_b.java": "// File contents", - "file_c.java": "// File contents", - "nested_dir_4": { - "nested_dir_5": { - "file_d.java": "// File contents", - } - } - } - } - } - } - }), - ) - .await; - fs.insert_tree( - path!("/root2"), - json!({ - "dir_2": { - "file_1.java": "// File contents", - } - }), - ) - .await; - - let project = Project::test( - fs.clone(), - [path!("/root1").as_ref(), path!("/root2").as_ref()], - cx, - ) - .await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - cx.update(|_, cx| { - let settings = *ProjectPanelSettings::get_global(cx); - ProjectPanelSettings::override_global( - ProjectPanelSettings { - auto_fold_dirs: true, - ..settings - }, - cx, - ); - }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - separator!("v root1"), - separator!(" > dir_1/nested_dir_1/nested_dir_2/nested_dir_3"), - separator!("v root2"), - separator!(" > dir_2"), - ] - ); - - toggle_expand_dir( - &panel, - "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3", - cx, - ); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - separator!("v root1"), - separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected"), - separator!(" > nested_dir_4/nested_dir_5"), - separator!(" file_a.java"), - separator!(" file_b.java"), - separator!(" file_c.java"), - separator!("v root2"), - separator!(" > dir_2"), - ] - ); - - toggle_expand_dir( - &panel, - "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5", - cx, - ); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - separator!("v root1"), - separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"), - separator!(" v nested_dir_4/nested_dir_5 <== selected"), - separator!(" file_d.java"), - separator!(" file_a.java"), - separator!(" file_b.java"), - separator!(" file_c.java"), - separator!("v root2"), - separator!(" > dir_2"), - ] - ); - toggle_expand_dir(&panel, "root2/dir_2", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - separator!("v root1"), - separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"), - separator!(" v nested_dir_4/nested_dir_5"), - separator!(" file_d.java"), - separator!(" file_a.java"), - separator!(" file_b.java"), - separator!(" file_c.java"), - separator!("v root2"), - separator!(" v dir_2 <== selected"), - separator!(" file_1.java"), - ] - ); - } - - #[gpui::test(iterations = 30)] - async fn test_editing_files(cx: &mut gpui::TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/root1", - json!({ - ".dockerignore": "", - ".git": { - "HEAD": "", - }, - "a": { - "0": { "q": "", "r": "", "s": "" }, - "1": { "t": "", "u": "" }, - "2": { "v": "", "w": "", "x": "", "y": "" }, - }, - "b": { - "3": { "Q": "" }, - "4": { "R": "", "S": "", "T": "", "U": "" }, - }, - "C": { - "5": {}, - "6": { "V": "", "W": "" }, - "7": { "X": "" }, - "8": { "Y": {}, "Z": "" } - } - }), - ) - .await; - fs.insert_tree( - "/root2", - json!({ - "d": { - "9": "" - }, - "e": {} - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) - .unwrap(); - - select_path(&panel, "root1", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1 <== selected", - " > .git", - " > a", - " > b", - " > C", - " .dockerignore", - "v root2", - " > d", - " > e", - ] - ); - - // Add a file with the root folder selected. The filename editor is placed - // before the first file in the root folder. - panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); - panel.update_in(cx, |panel, window, cx| { - assert!(panel.filename_editor.read(cx).is_focused(window)); - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " > a", - " > b", - " > C", - " [EDITOR: ''] <== selected", - " .dockerignore", - "v root2", - " > d", - " > e", - ] - ); - - let confirm = panel.update_in(cx, |panel, window, cx| { - panel.filename_editor.update(cx, |editor, cx| { - editor.set_text("the-new-filename", window, cx) - }); - panel.confirm_edit(window, cx).unwrap() - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " > a", - " > b", - " > C", - " [PROCESSING: 'the-new-filename'] <== selected", - " .dockerignore", - "v root2", - " > d", - " > e", - ] - ); - - confirm.await.unwrap(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " > a", - " > b", - " > C", - " .dockerignore", - " the-new-filename <== selected <== marked", - "v root2", - " > d", - " > e", - ] - ); - - select_path(&panel, "root1/b", cx); - panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " > a", - " v b", - " > 3", - " > 4", - " [EDITOR: ''] <== selected", - " > C", - " .dockerignore", - " the-new-filename", - ] - ); - - panel - .update_in(cx, |panel, window, cx| { - panel.filename_editor.update(cx, |editor, cx| { - editor.set_text("another-filename.txt", window, cx) - }); - panel.confirm_edit(window, cx).unwrap() - }) - .await - .unwrap(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " > a", - " v b", - " > 3", - " > 4", - " another-filename.txt <== selected <== marked", - " > C", - " .dockerignore", - " the-new-filename", - ] - ); - - select_path(&panel, "root1/b/another-filename.txt", cx); - panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx)); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " > a", - " v b", - " > 3", - " > 4", - " [EDITOR: 'another-filename.txt'] <== selected <== marked", - " > C", - " .dockerignore", - " the-new-filename", - ] - ); - - let confirm = panel.update_in(cx, |panel, window, cx| { - panel.filename_editor.update(cx, |editor, cx| { - let file_name_selections = editor.selections.all::(cx); - assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}"); - let file_name_selection = &file_name_selections[0]; - assert_eq!(file_name_selection.start, 0, "Should select the file name from the start"); - assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension"); - - editor.set_text("a-different-filename.tar.gz", window, cx) - }); - panel.confirm_edit(window, cx).unwrap() - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " > a", - " v b", - " > 3", - " > 4", - " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked", - " > C", - " .dockerignore", - " the-new-filename", - ] - ); - - confirm.await.unwrap(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " > a", - " v b", - " > 3", - " > 4", - " a-different-filename.tar.gz <== selected", - " > C", - " .dockerignore", - " the-new-filename", - ] - ); - - panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx)); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " > a", - " v b", - " > 3", - " > 4", - " [EDITOR: 'a-different-filename.tar.gz'] <== selected", - " > C", - " .dockerignore", - " the-new-filename", - ] - ); - - panel.update_in(cx, |panel, window, cx| { - panel.filename_editor.update(cx, |editor, cx| { - let file_name_selections = editor.selections.all::(cx); - assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}"); - let file_name_selection = &file_name_selections[0]; - assert_eq!(file_name_selection.start, 0, "Should select the file name from the start"); - assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot.."); - - }); - panel.cancel(&menu::Cancel, window, cx) - }); - - panel.update_in(cx, |panel, window, cx| { - panel.new_directory(&NewDirectory, window, cx) - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " > a", - " v b", - " > 3", - " > 4", - " > [EDITOR: ''] <== selected", - " a-different-filename.tar.gz", - " > C", - " .dockerignore", - ] - ); - - let confirm = panel.update_in(cx, |panel, window, cx| { - panel - .filename_editor - .update(cx, |editor, cx| editor.set_text("new-dir", window, cx)); - panel.confirm_edit(window, cx).unwrap() - }); - panel.update_in(cx, |panel, window, cx| { - panel.select_next(&Default::default(), window, cx) - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " > a", - " v b", - " > 3", - " > 4", - " > [PROCESSING: 'new-dir']", - " a-different-filename.tar.gz <== selected", - " > C", - " .dockerignore", - ] - ); - - confirm.await.unwrap(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " > a", - " v b", - " > 3", - " > 4", - " > new-dir", - " a-different-filename.tar.gz <== selected", - " > C", - " .dockerignore", - ] - ); - - panel.update_in(cx, |panel, window, cx| { - panel.rename(&Default::default(), window, cx) - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " > a", - " v b", - " > 3", - " > 4", - " > new-dir", - " [EDITOR: 'a-different-filename.tar.gz'] <== selected", - " > C", - " .dockerignore", - ] - ); - - // Dismiss the rename editor when it loses focus. - workspace.update(cx, |_, window, _| window.blur()).unwrap(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " > a", - " v b", - " > 3", - " > 4", - " > new-dir", - " a-different-filename.tar.gz <== selected", - " > C", - " .dockerignore", - ] - ); - } - - #[gpui::test(iterations = 10)] - async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/root1", - json!({ - ".dockerignore": "", - ".git": { - "HEAD": "", - }, - "a": { - "0": { "q": "", "r": "", "s": "" }, - "1": { "t": "", "u": "" }, - "2": { "v": "", "w": "", "x": "", "y": "" }, - }, - "b": { - "3": { "Q": "" }, - "4": { "R": "", "S": "", "T": "", "U": "" }, - }, - "C": { - "5": {}, - "6": { "V": "", "W": "" }, - "7": { "X": "" }, - "8": { "Y": {}, "Z": "" } - } - }), - ) - .await; - fs.insert_tree( - "/root2", - json!({ - "d": { - "9": "" - }, - "e": {} - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) - .unwrap(); - - select_path(&panel, "root1", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1 <== selected", - " > .git", - " > a", - " > b", - " > C", - " .dockerignore", - "v root2", - " > d", - " > e", - ] - ); - - // Add a file with the root folder selected. The filename editor is placed - // before the first file in the root folder. - panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); - panel.update_in(cx, |panel, window, cx| { - assert!(panel.filename_editor.read(cx).is_focused(window)); - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " > a", - " > b", - " > C", - " [EDITOR: ''] <== selected", - " .dockerignore", - "v root2", - " > d", - " > e", - ] - ); - - let confirm = panel.update_in(cx, |panel, window, cx| { - panel.filename_editor.update(cx, |editor, cx| { - editor.set_text("/bdir1/dir2/the-new-filename", window, cx) - }); - panel.confirm_edit(window, cx).unwrap() - }); - - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " > a", - " > b", - " > C", - " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected", - " .dockerignore", - "v root2", - " > d", - " > e", - ] - ); - - confirm.await.unwrap(); - assert_eq!( - visible_entries_as_strings(&panel, 0..13, cx), - &[ - "v root1", - " > .git", - " > a", - " > b", - " v bdir1", - " v dir2", - " the-new-filename <== selected <== marked", - " > C", - " .dockerignore", - "v root2", - " > d", - " > e", - ] - ); - } - - #[gpui::test] - async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - path!("/root1"), - json!({ - ".dockerignore": "", - ".git": { - "HEAD": "", - }, - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) - .unwrap(); - - select_path(&panel, "root1", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &["v root1 <== selected", " > .git", " .dockerignore",] - ); - - // Add a file with the root folder selected. The filename editor is placed - // before the first file in the root folder. - panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); - panel.update_in(cx, |panel, window, cx| { - assert!(panel.filename_editor.read(cx).is_focused(window)); - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " [EDITOR: ''] <== selected", - " .dockerignore", - ] - ); - - let confirm = panel.update_in(cx, |panel, window, cx| { - // If we want to create a subdirectory, there should be no prefix slash. - panel - .filename_editor - .update(cx, |editor, cx| editor.set_text("new_dir/", window, cx)); - panel.confirm_edit(window, cx).unwrap() - }); - - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " [PROCESSING: 'new_dir/'] <== selected", - " .dockerignore", - ] - ); - - confirm.await.unwrap(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " v new_dir <== selected", - " .dockerignore", - ] - ); - - // Test filename with whitespace - select_path(&panel, "root1", cx); - panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); - let confirm = panel.update_in(cx, |panel, window, cx| { - // If we want to create a subdirectory, there should be no prefix slash. - panel - .filename_editor - .update(cx, |editor, cx| editor.set_text("new dir 2/", window, cx)); - panel.confirm_edit(window, cx).unwrap() - }); - confirm.await.unwrap(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " v new dir 2 <== selected", - " v new_dir", - " .dockerignore", - ] - ); - - // Test filename ends with "\" - #[cfg(target_os = "windows")] - { - select_path(&panel, "root1", cx); - panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); - let confirm = panel.update_in(cx, |panel, window, cx| { - // If we want to create a subdirectory, there should be no prefix slash. - panel - .filename_editor - .update(cx, |editor, cx| editor.set_text("new_dir_3\\", window, cx)); - panel.confirm_edit(window, cx).unwrap() - }); - confirm.await.unwrap(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v root1", - " > .git", - " v new dir 2", - " v new_dir", - " v new_dir_3 <== selected", - " .dockerignore", - ] - ); - } - } - - #[gpui::test] - async fn test_copy_paste(cx: &mut gpui::TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/root1", - json!({ - "one.two.txt": "", - "one.txt": "" - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - panel.update_in(cx, |panel, window, cx| { - panel.select_next(&Default::default(), window, cx); - panel.select_next(&Default::default(), window, cx); - }); - - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - // - "v root1", - " one.txt <== selected", - " one.two.txt", - ] - ); - - // Regression test - file name is created correctly when - // the copied file's name contains multiple dots. - panel.update_in(cx, |panel, window, cx| { - panel.copy(&Default::default(), window, cx); - panel.paste(&Default::default(), window, cx); - }); - cx.executor().run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - // - "v root1", - " one.txt", - " [EDITOR: 'one copy.txt'] <== selected <== marked", - " one.two.txt", - ] - ); - - panel.update_in(cx, |panel, window, cx| { - panel.filename_editor.update(cx, |editor, cx| { - let file_name_selections = editor.selections.all::(cx); - assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}"); - let file_name_selection = &file_name_selections[0]; - assert_eq!(file_name_selection.start, "one".len(), "Should select the file name disambiguation after the original file name"); - assert_eq!(file_name_selection.end, "one copy".len(), "Should select the file name disambiguation until the extension"); - }); - assert!(panel.confirm_edit(window, cx).is_none()); - }); - - panel.update_in(cx, |panel, window, cx| { - panel.paste(&Default::default(), window, cx); - }); - cx.executor().run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - // - "v root1", - " one.txt", - " one copy.txt", - " [EDITOR: 'one copy 1.txt'] <== selected <== marked", - " one.two.txt", - ] - ); - - panel.update_in(cx, |panel, window, cx| { - assert!(panel.confirm_edit(window, cx).is_none()) - }); - } - - #[gpui::test] - async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/root1", - json!({ - "one.txt": "", - "two.txt": "", - "three.txt": "", - "a": { - "0": { "q": "", "r": "", "s": "" }, - "1": { "t": "", "u": "" }, - "2": { "v": "", "w": "", "x": "", "y": "" }, - }, - }), - ) - .await; - - fs.insert_tree( - "/root2", - json!({ - "one.txt": "", - "two.txt": "", - "four.txt": "", - "b": { - "3": { "Q": "" }, - "4": { "R": "", "S": "", "T": "", "U": "" }, - }, - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - select_path(&panel, "root1/three.txt", cx); - panel.update_in(cx, |panel, window, cx| { - panel.cut(&Default::default(), window, cx); - }); - - select_path(&panel, "root2/one.txt", cx); - panel.update_in(cx, |panel, window, cx| { - panel.select_next(&Default::default(), window, cx); - panel.paste(&Default::default(), window, cx); - }); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - // - "v root1", - " > a", - " one.txt", - " two.txt", - "v root2", - " > b", - " four.txt", - " one.txt", - " three.txt <== selected <== marked", - " two.txt", - ] - ); - - select_path(&panel, "root1/a", cx); - panel.update_in(cx, |panel, window, cx| { - panel.cut(&Default::default(), window, cx); - }); - select_path(&panel, "root2/two.txt", cx); - panel.update_in(cx, |panel, window, cx| { - panel.select_next(&Default::default(), window, cx); - panel.paste(&Default::default(), window, cx); - }); - - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - // - "v root1", - " one.txt", - " two.txt", - "v root2", - " > a <== selected", - " > b", - " four.txt", - " one.txt", - " three.txt <== marked", - " two.txt", - ] - ); - } - - #[gpui::test] - async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/root1", - json!({ - "one.txt": "", - "two.txt": "", - "three.txt": "", - "a": { - "0": { "q": "", "r": "", "s": "" }, - "1": { "t": "", "u": "" }, - "2": { "v": "", "w": "", "x": "", "y": "" }, - }, - }), - ) - .await; - - fs.insert_tree( - "/root2", - json!({ - "one.txt": "", - "two.txt": "", - "four.txt": "", - "b": { - "3": { "Q": "" }, - "4": { "R": "", "S": "", "T": "", "U": "" }, - }, - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - select_path(&panel, "root1/three.txt", cx); - panel.update_in(cx, |panel, window, cx| { - panel.copy(&Default::default(), window, cx); - }); - - select_path(&panel, "root2/one.txt", cx); - panel.update_in(cx, |panel, window, cx| { - panel.select_next(&Default::default(), window, cx); - panel.paste(&Default::default(), window, cx); - }); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - // - "v root1", - " > a", - " one.txt", - " three.txt", - " two.txt", - "v root2", - " > b", - " four.txt", - " one.txt", - " three.txt <== selected <== marked", - " two.txt", - ] - ); - - select_path(&panel, "root1/three.txt", cx); - panel.update_in(cx, |panel, window, cx| { - panel.copy(&Default::default(), window, cx); - }); - select_path(&panel, "root2/two.txt", cx); - panel.update_in(cx, |panel, window, cx| { - panel.select_next(&Default::default(), window, cx); - panel.paste(&Default::default(), window, cx); - }); - - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - // - "v root1", - " > a", - " one.txt", - " three.txt", - " two.txt", - "v root2", - " > b", - " four.txt", - " one.txt", - " three.txt", - " [EDITOR: 'three copy.txt'] <== selected <== marked", - " two.txt", - ] - ); - - panel.update_in(cx, |panel, window, cx| { - panel.cancel(&menu::Cancel {}, window, cx) - }); - cx.executor().run_until_parked(); - - select_path(&panel, "root1/a", cx); - panel.update_in(cx, |panel, window, cx| { - panel.copy(&Default::default(), window, cx); - }); - select_path(&panel, "root2/two.txt", cx); - panel.update_in(cx, |panel, window, cx| { - panel.select_next(&Default::default(), window, cx); - panel.paste(&Default::default(), window, cx); - }); - - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - // - "v root1", - " > a", - " one.txt", - " three.txt", - " two.txt", - "v root2", - " > a <== selected", - " > b", - " four.txt", - " one.txt", - " three.txt", - " three copy.txt", - " two.txt", - ] - ); - } - - #[gpui::test] - async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/root", - json!({ - "a": { - "one.txt": "", - "two.txt": "", - "inner_dir": { - "three.txt": "", - "four.txt": "", - } - }, - "b": {} - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - select_path(&panel, "root/a", cx); - panel.update_in(cx, |panel, window, cx| { - panel.copy(&Default::default(), window, cx); - panel.select_next(&Default::default(), window, cx); - panel.paste(&Default::default(), window, cx); - }); - cx.executor().run_until_parked(); - - let pasted_dir = find_project_entry(&panel, "root/b/a", cx); - assert_ne!(pasted_dir, None, "Pasted directory should have an entry"); - - let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx); - assert_ne!( - pasted_dir_file, None, - "Pasted directory file should have an entry" - ); - - let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx); - assert_ne!( - pasted_dir_inner_dir, None, - "Directories inside pasted directory should have an entry" - ); - - toggle_expand_dir(&panel, "root/b/a", cx); - toggle_expand_dir(&panel, "root/b/a/inner_dir", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - // - "v root", - " > a", - " v b", - " v a", - " v inner_dir <== selected", - " four.txt", - " three.txt", - " one.txt", - " two.txt", - ] - ); - - select_path(&panel, "root", cx); - panel.update_in(cx, |panel, window, cx| { - panel.paste(&Default::default(), window, cx) - }); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - // - "v root", - " > a", - " > [EDITOR: 'a copy'] <== selected", - " v b", - " v a", - " v inner_dir", - " four.txt", - " three.txt", - " one.txt", - " two.txt" - ] - ); - - let confirm = panel.update_in(cx, |panel, window, cx| { - panel - .filename_editor - .update(cx, |editor, cx| editor.set_text("c", window, cx)); - panel.confirm_edit(window, cx).unwrap() - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - // - "v root", - " > a", - " > [PROCESSING: 'c'] <== selected", - " v b", - " v a", - " v inner_dir", - " four.txt", - " three.txt", - " one.txt", - " two.txt" - ] - ); - - confirm.await.unwrap(); - - panel.update_in(cx, |panel, window, cx| { - panel.paste(&Default::default(), window, cx) - }); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..50, cx), - &[ - // - "v root", - " > a", - " v b", - " v a", - " v inner_dir", - " four.txt", - " three.txt", - " one.txt", - " two.txt", - " v c", - " > a <== selected", - " > inner_dir", - " one.txt", - " two.txt", - ] - ); - } - - #[gpui::test] - async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/test", - json!({ - "dir1": { - "a.txt": "", - "b.txt": "", - }, - "dir2": {}, - "c.txt": "", - "d.txt": "", - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - toggle_expand_dir(&panel, "test/dir1", cx); - - cx.simulate_modifiers_change(gpui::Modifiers { - control: true, - ..Default::default() - }); - - select_path_with_mark(&panel, "test/dir1", cx); - select_path_with_mark(&panel, "test/c.txt", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v test", - " v dir1 <== marked", - " a.txt", - " b.txt", - " > dir2", - " c.txt <== selected <== marked", - " d.txt", - ], - "Initial state before copying dir1 and c.txt" - ); - - panel.update_in(cx, |panel, window, cx| { - panel.copy(&Default::default(), window, cx); - }); - select_path(&panel, "test/dir2", cx); - panel.update_in(cx, |panel, window, cx| { - panel.paste(&Default::default(), window, cx); - }); - cx.executor().run_until_parked(); - - toggle_expand_dir(&panel, "test/dir2/dir1", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v test", - " v dir1 <== marked", - " a.txt", - " b.txt", - " v dir2", - " v dir1 <== selected", - " a.txt", - " b.txt", - " c.txt", - " c.txt <== marked", - " d.txt", - ], - "Should copy dir1 as well as c.txt into dir2" - ); - - // Disambiguating multiple files should not open the rename editor. - select_path(&panel, "test/dir2", cx); - panel.update_in(cx, |panel, window, cx| { - panel.paste(&Default::default(), window, cx); - }); - cx.executor().run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v test", - " v dir1 <== marked", - " a.txt", - " b.txt", - " v dir2", - " v dir1", - " a.txt", - " b.txt", - " > dir1 copy <== selected", - " c.txt", - " c copy.txt", - " c.txt <== marked", - " d.txt", - ], - "Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor" - ); - } - - #[gpui::test] - async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/test", - json!({ - "dir1": { - "a.txt": "", - "b.txt": "", - }, - "dir2": {}, - "c.txt": "", - "d.txt": "", - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - toggle_expand_dir(&panel, "test/dir1", cx); - - cx.simulate_modifiers_change(gpui::Modifiers { - control: true, - ..Default::default() - }); - - select_path_with_mark(&panel, "test/dir1/a.txt", cx); - select_path_with_mark(&panel, "test/dir1", cx); - select_path_with_mark(&panel, "test/c.txt", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v test", - " v dir1 <== marked", - " a.txt <== marked", - " b.txt", - " > dir2", - " c.txt <== selected <== marked", - " d.txt", - ], - "Initial state before copying a.txt, dir1 and c.txt" - ); - - panel.update_in(cx, |panel, window, cx| { - panel.copy(&Default::default(), window, cx); - }); - select_path(&panel, "test/dir2", cx); - panel.update_in(cx, |panel, window, cx| { - panel.paste(&Default::default(), window, cx); - }); - cx.executor().run_until_parked(); - - toggle_expand_dir(&panel, "test/dir2/dir1", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v test", - " v dir1 <== marked", - " a.txt <== marked", - " b.txt", - " v dir2", - " v dir1 <== selected", - " a.txt", - " b.txt", - " c.txt", - " c.txt <== marked", - " d.txt", - ], - "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1." - ); - } - - #[gpui::test] - async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - path!("/src"), - json!({ - "test": { - "first.rs": "// First Rust file", - "second.rs": "// Second Rust file", - "third.rs": "// Third Rust file", - } - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - toggle_expand_dir(&panel, "src/test", cx); - select_path(&panel, "src/test/first.rs", cx); - panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v src", - " v test", - " first.rs <== selected <== marked", - " second.rs", - " third.rs" - ] - ); - ensure_single_file_is_opened(&workspace, "test/first.rs", cx); - - submit_deletion(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v src", - " v test", - " second.rs <== selected", - " third.rs" - ], - "Project panel should have no deleted file, no other file is selected in it" - ); - ensure_no_open_items_and_panes(&workspace, cx); - - panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v src", - " v test", - " second.rs <== selected <== marked", - " third.rs" - ] - ); - ensure_single_file_is_opened(&workspace, "test/second.rs", cx); - - workspace - .update(cx, |workspace, window, cx| { - let active_items = workspace - .panes() - .iter() - .filter_map(|pane| pane.read(cx).active_item()) - .collect::>(); - assert_eq!(active_items.len(), 1); - let open_editor = active_items - .into_iter() - .next() - .unwrap() - .downcast::() - .expect("Open item should be an editor"); - open_editor.update(cx, |editor, cx| { - editor.set_text("Another text!", window, cx) - }); - }) - .unwrap(); - submit_deletion_skipping_prompt(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &["v src", " v test", " third.rs <== selected"], - "Project panel should have no deleted file, with one last file remaining" - ); - ensure_no_open_items_and_panes(&workspace, cx); - } - - #[gpui::test] - async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/src", - json!({ - "test": { - "first.rs": "// First Rust file", - "second.rs": "// Second Rust file", - "third.rs": "// Third Rust file", - } - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) - .unwrap(); - - select_path(&panel, "src/", cx); - panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx)); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - // - "v src <== selected", - " > test" - ] - ); - panel.update_in(cx, |panel, window, cx| { - panel.new_directory(&NewDirectory, window, cx) - }); - panel.update_in(cx, |panel, window, cx| { - assert!(panel.filename_editor.read(cx).is_focused(window)); - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - // - "v src", - " > [EDITOR: ''] <== selected", - " > test" - ] - ); - panel.update_in(cx, |panel, window, cx| { - panel - .filename_editor - .update(cx, |editor, cx| editor.set_text("test", window, cx)); - assert!( - panel.confirm_edit(window, cx).is_none(), - "Should not allow to confirm on conflicting new directory name" - ) - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - // - "v src", - " > test" - ], - "File list should be unchanged after failed folder create confirmation" - ); - - select_path(&panel, "src/test/", cx); - panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx)); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - // - "v src", - " > test <== selected" - ] - ); - panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); - panel.update_in(cx, |panel, window, cx| { - assert!(panel.filename_editor.read(cx).is_focused(window)); - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v src", - " v test", - " [EDITOR: ''] <== selected", - " first.rs", - " second.rs", - " third.rs" - ] - ); - panel.update_in(cx, |panel, window, cx| { - panel - .filename_editor - .update(cx, |editor, cx| editor.set_text("first.rs", window, cx)); - assert!( - panel.confirm_edit(window, cx).is_none(), - "Should not allow to confirm on conflicting new file name" - ) - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v src", - " v test", - " first.rs", - " second.rs", - " third.rs" - ], - "File list should be unchanged after failed file create confirmation" - ); - - select_path(&panel, "src/test/first.rs", cx); - panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx)); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v src", - " v test", - " first.rs <== selected", - " second.rs", - " third.rs" - ], - ); - panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx)); - panel.update_in(cx, |panel, window, cx| { - assert!(panel.filename_editor.read(cx).is_focused(window)); - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v src", - " v test", - " [EDITOR: 'first.rs'] <== selected", - " second.rs", - " third.rs" - ] - ); - panel.update_in(cx, |panel, window, cx| { - panel - .filename_editor - .update(cx, |editor, cx| editor.set_text("second.rs", window, cx)); - assert!( - panel.confirm_edit(window, cx).is_none(), - "Should not allow to confirm on conflicting file rename" - ) - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v src", - " v test", - " first.rs <== selected", - " second.rs", - " third.rs" - ], - "File list should be unchanged after failed rename confirmation" - ); - } - - #[gpui::test] - async fn test_select_git_entry(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - path!("/root"), - json!({ - "tree1": { - ".git": {}, - "dir1": { - "modified1.txt": "1", - "unmodified1.txt": "1", - "modified2.txt": "1", - }, - "dir2": { - "modified3.txt": "1", - "unmodified2.txt": "1", - }, - "modified4.txt": "1", - "unmodified3.txt": "1", - }, - "tree2": { - ".git": {}, - "dir3": { - "modified5.txt": "1", - "unmodified4.txt": "1", - }, - "modified6.txt": "1", - "unmodified5.txt": "1", - } - }), - ) - .await; - - // Mark files as git modified - fs.set_git_content_for_repo( - path!("/root/tree1/.git").as_ref(), - &[ - ("dir1/modified1.txt".into(), "modified".into(), None), - ("dir1/modified2.txt".into(), "modified".into(), None), - ("modified4.txt".into(), "modified".into(), None), - ("dir2/modified3.txt".into(), "modified".into(), None), - ], - ); - fs.set_git_content_for_repo( - path!("/root/tree2/.git").as_ref(), - &[ - ("dir3/modified5.txt".into(), "modified".into(), None), - ("modified6.txt".into(), "modified".into(), None), - ], - ); - - let project = Project::test( - fs.clone(), - [path!("/root/tree1").as_ref(), path!("/root/tree2").as_ref()], - cx, - ) - .await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - // Check initial state - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v tree1", - " > .git", - " > dir1", - " > dir2", - " modified4.txt", - " unmodified3.txt", - "v tree2", - " > .git", - " > dir3", - " modified6.txt", - " unmodified5.txt" - ], - ); - - // Test selecting next modified entry - panel.update_in(cx, |panel, window, cx| { - panel.select_next_git_entry(&SelectNextGitEntry, window, cx); - }); - - assert_eq!( - visible_entries_as_strings(&panel, 0..6, cx), - &[ - "v tree1", - " > .git", - " v dir1", - " modified1.txt <== selected", - " modified2.txt", - " unmodified1.txt", - ], - ); - - panel.update_in(cx, |panel, window, cx| { - panel.select_next_git_entry(&SelectNextGitEntry, window, cx); - }); - - assert_eq!( - visible_entries_as_strings(&panel, 0..6, cx), - &[ - "v tree1", - " > .git", - " v dir1", - " modified1.txt", - " modified2.txt <== selected", - " unmodified1.txt", - ], - ); - - panel.update_in(cx, |panel, window, cx| { - panel.select_next_git_entry(&SelectNextGitEntry, window, cx); - }); - - assert_eq!( - visible_entries_as_strings(&panel, 6..9, cx), - &[ - " v dir2", - " modified3.txt <== selected", - " unmodified2.txt", - ], - ); - - panel.update_in(cx, |panel, window, cx| { - panel.select_next_git_entry(&SelectNextGitEntry, window, cx); - }); - - assert_eq!( - visible_entries_as_strings(&panel, 9..11, cx), - &[" modified4.txt <== selected", " unmodified3.txt",], - ); - - panel.update_in(cx, |panel, window, cx| { - panel.select_next_git_entry(&SelectNextGitEntry, window, cx); - }); - - assert_eq!( - visible_entries_as_strings(&panel, 13..16, cx), - &[ - " v dir3", - " modified5.txt <== selected", - " unmodified4.txt", - ], - ); - - panel.update_in(cx, |panel, window, cx| { - panel.select_next_git_entry(&SelectNextGitEntry, window, cx); - }); - - assert_eq!( - visible_entries_as_strings(&panel, 16..18, cx), - &[" modified6.txt <== selected", " unmodified5.txt",], - ); - - // Wraps around to first modified file - panel.update_in(cx, |panel, window, cx| { - panel.select_next_git_entry(&SelectNextGitEntry, window, cx); - }); - - assert_eq!( - visible_entries_as_strings(&panel, 0..18, cx), - &[ - "v tree1", - " > .git", - " v dir1", - " modified1.txt <== selected", - " modified2.txt", - " unmodified1.txt", - " v dir2", - " modified3.txt", - " unmodified2.txt", - " modified4.txt", - " unmodified3.txt", - "v tree2", - " > .git", - " v dir3", - " modified5.txt", - " unmodified4.txt", - " modified6.txt", - " unmodified5.txt", - ], - ); - - // Wraps around again to last modified file - panel.update_in(cx, |panel, window, cx| { - panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx); - }); - - assert_eq!( - visible_entries_as_strings(&panel, 16..18, cx), - &[" modified6.txt <== selected", " unmodified5.txt",], - ); - - panel.update_in(cx, |panel, window, cx| { - panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx); - }); - - assert_eq!( - visible_entries_as_strings(&panel, 13..16, cx), - &[ - " v dir3", - " modified5.txt <== selected", - " unmodified4.txt", - ], - ); - - panel.update_in(cx, |panel, window, cx| { - panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx); - }); - - assert_eq!( - visible_entries_as_strings(&panel, 9..11, cx), - &[" modified4.txt <== selected", " unmodified3.txt",], - ); - - panel.update_in(cx, |panel, window, cx| { - panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx); - }); - - assert_eq!( - visible_entries_as_strings(&panel, 6..9, cx), - &[ - " v dir2", - " modified3.txt <== selected", - " unmodified2.txt", - ], - ); - - panel.update_in(cx, |panel, window, cx| { - panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx); - }); - - assert_eq!( - visible_entries_as_strings(&panel, 0..6, cx), - &[ - "v tree1", - " > .git", - " v dir1", - " modified1.txt", - " modified2.txt <== selected", - " unmodified1.txt", - ], - ); - - panel.update_in(cx, |panel, window, cx| { - panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx); - }); - - assert_eq!( - visible_entries_as_strings(&panel, 0..6, cx), - &[ - "v tree1", - " > .git", - " v dir1", - " modified1.txt <== selected", - " modified2.txt", - " unmodified1.txt", - ], - ); - } - - #[gpui::test] - async fn test_select_directory(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/project_root", - json!({ - "dir_1": { - "nested_dir": { - "file_a.py": "# File contents", - } - }, - "file_1.py": "# File contents", - "dir_2": { - - }, - "dir_3": { - - }, - "file_2.py": "# File contents", - "dir_4": { - - }, - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); - cx.executor().run_until_parked(); - select_path(&panel, "project_root/dir_1", cx); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root", - " > dir_1 <== selected", - " > dir_2", - " > dir_3", - " > dir_4", - " file_1.py", - " file_2.py", - ] - ); - panel.update_in(cx, |panel, window, cx| { - panel.select_prev_directory(&SelectPrevDirectory, window, cx) - }); - - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root <== selected", - " > dir_1", - " > dir_2", - " > dir_3", - " > dir_4", - " file_1.py", - " file_2.py", - ] - ); - - panel.update_in(cx, |panel, window, cx| { - panel.select_prev_directory(&SelectPrevDirectory, window, cx) - }); - - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root", - " > dir_1", - " > dir_2", - " > dir_3", - " > dir_4 <== selected", - " file_1.py", - " file_2.py", - ] - ); - - panel.update_in(cx, |panel, window, cx| { - panel.select_next_directory(&SelectNextDirectory, window, cx) - }); - - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root <== selected", - " > dir_1", - " > dir_2", - " > dir_3", - " > dir_4", - " file_1.py", - " file_2.py", - ] - ); - } - #[gpui::test] - async fn test_select_first_last(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/project_root", - json!({ - "dir_1": { - "nested_dir": { - "file_a.py": "# File contents", - } - }, - "file_1.py": "# File contents", - "file_2.py": "# File contents", - "zdir_2": { - "nested_dir2": { - "file_b.py": "# File contents", - } - }, - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root", - " > dir_1", - " > zdir_2", - " file_1.py", - " file_2.py", - ] - ); - panel.update_in(cx, |panel, window, cx| { - panel.select_first(&SelectFirst, window, cx) - }); - - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root <== selected", - " > dir_1", - " > zdir_2", - " file_1.py", - " file_2.py", - ] - ); - - panel.update_in(cx, |panel, window, cx| { - panel.select_last(&SelectLast, window, cx) - }); - - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root", - " > dir_1", - " > zdir_2", - " file_1.py", - " file_2.py <== selected", - ] - ); - } - - #[gpui::test] - async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/project_root", - json!({ - "dir_1": { - "nested_dir": { - "file_a.py": "# File contents", - } - }, - "file_1.py": "# File contents", - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); - cx.executor().run_until_parked(); - select_path(&panel, "project_root/dir_1", cx); - panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); - select_path(&panel, "project_root/dir_1/nested_dir", cx); - panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); - panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root", - " v dir_1", - " > nested_dir <== selected", - " file_1.py", - ] - ); - } - - #[gpui::test] - async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/project_root", - json!({ - "dir_1": { - "nested_dir": { - "file_a.py": "# File contents", - "file_b.py": "# File contents", - "file_c.py": "# File contents", - }, - "file_1.py": "# File contents", - "file_2.py": "# File contents", - "file_3.py": "# File contents", - }, - "dir_2": { - "file_1.py": "# File contents", - "file_2.py": "# File contents", - "file_3.py": "# File contents", - } - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - panel.update_in(cx, |panel, window, cx| { - panel.collapse_all_entries(&CollapseAllEntries, window, cx) - }); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &["v project_root", " > dir_1", " > dir_2",] - ); - - // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries - toggle_expand_dir(&panel, "project_root/dir_1", cx); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root", - " v dir_1 <== selected", - " > nested_dir", - " file_1.py", - " file_2.py", - " file_3.py", - " > dir_2", - ] - ); - } - - #[gpui::test] - async fn test_new_file_move(cx: &mut gpui::TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.as_fake().insert_tree(path!("/root"), json!({})).await; - let project = Project::test(fs, [path!("/root").as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - // Make a new buffer with no backing file - workspace - .update(cx, |workspace, window, cx| { - Editor::new_file(workspace, &Default::default(), window, cx) - }) - .unwrap(); - - cx.executor().run_until_parked(); - - // "Save as" the buffer, creating a new backing file for it - let save_task = workspace - .update(cx, |workspace, window, cx| { - workspace.save_active_item(workspace::SaveIntent::Save, window, cx) - }) - .unwrap(); - - cx.executor().run_until_parked(); - cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new")))); - save_task.await.unwrap(); - - // Rename the file - select_path(&panel, "root/new", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &["v root", " new <== selected <== marked"] - ); - panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx)); - panel.update_in(cx, |panel, window, cx| { - panel - .filename_editor - .update(cx, |editor, cx| editor.set_text("newer", window, cx)); - }); - panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx)); - - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &["v root", " newer <== selected"] - ); - - workspace - .update(cx, |workspace, window, cx| { - workspace.save_active_item(workspace::SaveIntent::Save, window, cx) - }) - .unwrap() - .await - .unwrap(); - - cx.executor().run_until_parked(); - // assert that saving the file doesn't restore "new" - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &["v root", " newer <== selected"] - ); - } - - #[gpui::test] - #[cfg_attr(target_os = "windows", ignore)] - async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/root1", - json!({ - "dir1": { - "file1.txt": "content 1", - }, - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - toggle_expand_dir(&panel, "root1/dir1", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &["v root1", " v dir1 <== selected", " file1.txt",], - "Initial state with worktrees" - ); - - select_path(&panel, "root1", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &["v root1 <== selected", " v dir1", " file1.txt",], - ); - - // Rename root1 to new_root1 - panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx)); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v [EDITOR: 'root1'] <== selected", - " v dir1", - " file1.txt", - ], - ); - - let confirm = panel.update_in(cx, |panel, window, cx| { - panel - .filename_editor - .update(cx, |editor, cx| editor.set_text("new_root1", window, cx)); - panel.confirm_edit(window, cx).unwrap() - }); - confirm.await.unwrap(); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v new_root1 <== selected", - " v dir1", - " file1.txt", - ], - "Should update worktree name" - ); - - // Ensure internal paths have been updated - select_path(&panel, "new_root1/dir1/file1.txt", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v new_root1", - " v dir1", - " file1.txt <== selected", - ], - "Files in renamed worktree are selectable" - ); - } - - #[gpui::test] - async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/project_root", - json!({ - "dir_1": { - "nested_dir": { - "file_a.py": "# File contents", - } - }, - "file_1.py": "# File contents", - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let worktree_id = - cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id()); - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - cx.update(|window, cx| { - panel.update(cx, |this, cx| { - this.select_next(&Default::default(), window, cx); - this.expand_selected_entry(&Default::default(), window, cx); - this.expand_selected_entry(&Default::default(), window, cx); - this.select_next(&Default::default(), window, cx); - this.expand_selected_entry(&Default::default(), window, cx); - this.select_next(&Default::default(), window, cx); - }) - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root", - " v dir_1", - " v nested_dir", - " file_a.py <== selected", - " file_1.py", - ] - ); - let modifiers_with_shift = gpui::Modifiers { - shift: true, - ..Default::default() - }; - cx.simulate_modifiers_change(modifiers_with_shift); - cx.update(|window, cx| { - panel.update(cx, |this, cx| { - this.select_next(&Default::default(), window, cx); - }) - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root", - " v dir_1", - " v nested_dir", - " file_a.py", - " file_1.py <== selected <== marked", - ] - ); - cx.update(|window, cx| { - panel.update(cx, |this, cx| { - this.select_previous(&Default::default(), window, cx); - }) - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root", - " v dir_1", - " v nested_dir", - " file_a.py <== selected <== marked", - " file_1.py <== marked", - ] - ); - cx.update(|window, cx| { - panel.update(cx, |this, cx| { - let drag = DraggedSelection { - active_selection: this.selection.unwrap(), - marked_selections: Arc::new(this.marked_entries.clone()), - }; - let target_entry = this - .project - .read(cx) - .entry_for_path(&(worktree_id, "").into(), cx) - .unwrap(); - this.drag_onto(&drag, target_entry.id, false, window, cx); - }); - }); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root", - " v dir_1", - " v nested_dir", - " file_1.py <== marked", - " file_a.py <== selected <== marked", - ] - ); - // ESC clears out all marks - cx.update(|window, cx| { - panel.update(cx, |this, cx| { - this.cancel(&menu::Cancel, window, cx); - }) - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root", - " v dir_1", - " v nested_dir", - " file_1.py", - " file_a.py <== selected", - ] - ); - // ESC clears out all marks - cx.update(|window, cx| { - panel.update(cx, |this, cx| { - this.select_previous(&SelectPrevious, window, cx); - this.select_next(&SelectNext, window, cx); - }) - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root", - " v dir_1", - " v nested_dir", - " file_1.py <== marked", - " file_a.py <== selected <== marked", - ] - ); - cx.simulate_modifiers_change(Default::default()); - cx.update(|window, cx| { - panel.update(cx, |this, cx| { - this.cut(&Cut, window, cx); - this.select_previous(&SelectPrevious, window, cx); - this.select_previous(&SelectPrevious, window, cx); - - this.paste(&Paste, window, cx); - // this.expand_selected_entry(&ExpandSelectedEntry, cx); - }) - }); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root", - " v dir_1", - " v nested_dir", - " file_1.py <== marked", - " file_a.py <== selected <== marked", - ] - ); - cx.simulate_modifiers_change(modifiers_with_shift); - cx.update(|window, cx| { - panel.update(cx, |this, cx| { - this.expand_selected_entry(&Default::default(), window, cx); - this.select_next(&SelectNext, window, cx); - this.select_next(&SelectNext, window, cx); - }) - }); - submit_deletion(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - "v project_root", - " v dir_1", - " v nested_dir <== selected", - ] - ); - } - #[gpui::test] - async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - cx.update(|cx| { - cx.update_global::(|store, cx| { - store.update_user_settings::(cx, |worktree_settings| { - worktree_settings.file_scan_exclusions = Some(Vec::new()); - }); - store.update_user_settings::(cx, |project_panel_settings| { - project_panel_settings.auto_reveal_entries = Some(false) - }); - }) - }); - - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - "/project_root", - json!({ - ".git": {}, - ".gitignore": "**/gitignored_dir", - "dir_1": { - "file_1.py": "# File 1_1 contents", - "file_2.py": "# File 1_2 contents", - "file_3.py": "# File 1_3 contents", - "gitignored_dir": { - "file_a.py": "# File contents", - "file_b.py": "# File contents", - "file_c.py": "# File contents", - }, - }, - "dir_2": { - "file_1.py": "# File 2_1 contents", - "file_2.py": "# File 2_2 contents", - "file_3.py": "# File 2_3 contents", - } - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " > dir_1", - " > dir_2", - " .gitignore", - ] - ); - - let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx) - .expect("dir 1 file is not ignored and should have an entry"); - let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx) - .expect("dir 2 file is not ignored and should have an entry"); - let gitignored_dir_file = - find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx); - assert_eq!( - gitignored_dir_file, None, - "File in the gitignored dir should not have an entry before its dir is toggled" - ); - - toggle_expand_dir(&panel, "project_root/dir_1", cx); - toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " v dir_1", - " v gitignored_dir <== selected", - " file_a.py", - " file_b.py", - " file_c.py", - " file_1.py", - " file_2.py", - " file_3.py", - " > dir_2", - " .gitignore", - ], - "Should show gitignored dir file list in the project panel" - ); - let gitignored_dir_file = - find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx) - .expect("after gitignored dir got opened, a file entry should be present"); - - toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx); - toggle_expand_dir(&panel, "project_root/dir_1", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " > dir_1 <== selected", - " > dir_2", - " .gitignore", - ], - "Should hide all dir contents again and prepare for the auto reveal test" - ); - - for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] { - panel.update(cx, |panel, cx| { - panel.project.update(cx, |_, cx| { - cx.emit(project::Event::ActiveEntryChanged(Some(file_entry))) - }) - }); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " > dir_1 <== selected", - " > dir_2", - " .gitignore", - ], - "When no auto reveal is enabled, the selected entry should not be revealed in the project panel" - ); - } - - cx.update(|_, cx| { - cx.update_global::(|store, cx| { - store.update_user_settings::(cx, |project_panel_settings| { - project_panel_settings.auto_reveal_entries = Some(true) - }); - }) - }); - - panel.update(cx, |panel, cx| { - panel.project.update(cx, |_, cx| { - cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file))) - }) - }); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " v dir_1", - " > gitignored_dir", - " file_1.py <== selected <== marked", - " file_2.py", - " file_3.py", - " > dir_2", - " .gitignore", - ], - "When auto reveal is enabled, not ignored dir_1 entry should be revealed" - ); - - panel.update(cx, |panel, cx| { - panel.project.update(cx, |_, cx| { - cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file))) - }) - }); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " v dir_1", - " > gitignored_dir", - " file_1.py", - " file_2.py", - " file_3.py", - " v dir_2", - " file_1.py <== selected <== marked", - " file_2.py", - " file_3.py", - " .gitignore", - ], - "When auto reveal is enabled, not ignored dir_2 entry should be revealed" - ); - - panel.update(cx, |panel, cx| { - panel.project.update(cx, |_, cx| { - cx.emit(project::Event::ActiveEntryChanged(Some( - gitignored_dir_file, - ))) - }) - }); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " v dir_1", - " > gitignored_dir", - " file_1.py", - " file_2.py", - " file_3.py", - " v dir_2", - " file_1.py <== selected <== marked", - " file_2.py", - " file_3.py", - " .gitignore", - ], - "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel" - ); - - panel.update(cx, |panel, cx| { - panel.project.update(cx, |_, cx| { - cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file)) - }) - }); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " v dir_1", - " v gitignored_dir", - " file_a.py <== selected <== marked", - " file_b.py", - " file_c.py", - " file_1.py", - " file_2.py", - " file_3.py", - " v dir_2", - " file_1.py", - " file_2.py", - " file_3.py", - " .gitignore", - ], - "When a gitignored entry is explicitly revealed, it should be shown in the project tree" - ); - } - - #[gpui::test] - async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - cx.update(|cx| { - cx.update_global::(|store, cx| { - store.update_user_settings::(cx, |worktree_settings| { - worktree_settings.file_scan_exclusions = Some(Vec::new()); - worktree_settings.file_scan_inclusions = - Some(vec!["always_included_but_ignored_dir/*".to_string()]); - }); - store.update_user_settings::(cx, |project_panel_settings| { - project_panel_settings.auto_reveal_entries = Some(false) - }); - }) - }); - - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - "/project_root", - json!({ - ".git": {}, - ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir", - "dir_1": { - "file_1.py": "# File 1_1 contents", - "file_2.py": "# File 1_2 contents", - "file_3.py": "# File 1_3 contents", - "gitignored_dir": { - "file_a.py": "# File contents", - "file_b.py": "# File contents", - "file_c.py": "# File contents", - }, - }, - "dir_2": { - "file_1.py": "# File 2_1 contents", - "file_2.py": "# File 2_2 contents", - "file_3.py": "# File 2_3 contents", - }, - "always_included_but_ignored_dir": { - "file_a.py": "# File contents", - "file_b.py": "# File contents", - "file_c.py": "# File contents", - }, - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " > always_included_but_ignored_dir", - " > dir_1", - " > dir_2", - " .gitignore", - ] - ); - - let gitignored_dir_file = - find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx); - let always_included_but_ignored_dir_file = find_project_entry( - &panel, - "project_root/always_included_but_ignored_dir/file_a.py", - cx, - ) - .expect("file that is .gitignored but set to always be included should have an entry"); - assert_eq!( - gitignored_dir_file, None, - "File in the gitignored dir should not have an entry unless its directory is toggled" - ); - - toggle_expand_dir(&panel, "project_root/dir_1", cx); - cx.run_until_parked(); - cx.update(|_, cx| { - cx.update_global::(|store, cx| { - store.update_user_settings::(cx, |project_panel_settings| { - project_panel_settings.auto_reveal_entries = Some(true) - }); - }) - }); - - panel.update(cx, |panel, cx| { - panel.project.update(cx, |_, cx| { - cx.emit(project::Event::ActiveEntryChanged(Some( - always_included_but_ignored_dir_file, - ))) - }) - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " v always_included_but_ignored_dir", - " file_a.py <== selected <== marked", - " file_b.py", - " file_c.py", - " v dir_1", - " > gitignored_dir", - " file_1.py", - " file_2.py", - " file_3.py", - " > dir_2", - " .gitignore", - ], - "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel" - ); - } - - #[gpui::test] - async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - cx.update(|cx| { - cx.update_global::(|store, cx| { - store.update_user_settings::(cx, |worktree_settings| { - worktree_settings.file_scan_exclusions = Some(Vec::new()); - }); - store.update_user_settings::(cx, |project_panel_settings| { - project_panel_settings.auto_reveal_entries = Some(false) - }); - }) - }); - - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - "/project_root", - json!({ - ".git": {}, - ".gitignore": "**/gitignored_dir", - "dir_1": { - "file_1.py": "# File 1_1 contents", - "file_2.py": "# File 1_2 contents", - "file_3.py": "# File 1_3 contents", - "gitignored_dir": { - "file_a.py": "# File contents", - "file_b.py": "# File contents", - "file_c.py": "# File contents", - }, - }, - "dir_2": { - "file_1.py": "# File 2_1 contents", - "file_2.py": "# File 2_2 contents", - "file_3.py": "# File 2_3 contents", - } - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " > dir_1", - " > dir_2", - " .gitignore", - ] - ); - - let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx) - .expect("dir 1 file is not ignored and should have an entry"); - let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx) - .expect("dir 2 file is not ignored and should have an entry"); - let gitignored_dir_file = - find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx); - assert_eq!( - gitignored_dir_file, None, - "File in the gitignored dir should not have an entry before its dir is toggled" - ); - - toggle_expand_dir(&panel, "project_root/dir_1", cx); - toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " v dir_1", - " v gitignored_dir <== selected", - " file_a.py", - " file_b.py", - " file_c.py", - " file_1.py", - " file_2.py", - " file_3.py", - " > dir_2", - " .gitignore", - ], - "Should show gitignored dir file list in the project panel" - ); - let gitignored_dir_file = - find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx) - .expect("after gitignored dir got opened, a file entry should be present"); - - toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx); - toggle_expand_dir(&panel, "project_root/dir_1", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " > dir_1 <== selected", - " > dir_2", - " .gitignore", - ], - "Should hide all dir contents again and prepare for the explicit reveal test" - ); - - for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] { - panel.update(cx, |panel, cx| { - panel.project.update(cx, |_, cx| { - cx.emit(project::Event::ActiveEntryChanged(Some(file_entry))) - }) - }); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " > dir_1 <== selected", - " > dir_2", - " .gitignore", - ], - "When no auto reveal is enabled, the selected entry should not be revealed in the project panel" - ); - } - - panel.update(cx, |panel, cx| { - panel.project.update(cx, |_, cx| { - cx.emit(project::Event::RevealInProjectPanel(dir_1_file)) - }) - }); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " v dir_1", - " > gitignored_dir", - " file_1.py <== selected <== marked", - " file_2.py", - " file_3.py", - " > dir_2", - " .gitignore", - ], - "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel" - ); - - panel.update(cx, |panel, cx| { - panel.project.update(cx, |_, cx| { - cx.emit(project::Event::RevealInProjectPanel(dir_2_file)) - }) - }); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " v dir_1", - " > gitignored_dir", - " file_1.py", - " file_2.py", - " file_3.py", - " v dir_2", - " file_1.py <== selected <== marked", - " file_2.py", - " file_3.py", - " .gitignore", - ], - "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel" - ); - - panel.update(cx, |panel, cx| { - panel.project.update(cx, |_, cx| { - cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file)) - }) - }); - cx.run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v project_root", - " > .git", - " v dir_1", - " v gitignored_dir", - " file_a.py <== selected <== marked", - " file_b.py", - " file_c.py", - " file_1.py", - " file_2.py", - " file_3.py", - " v dir_2", - " file_1.py", - " file_2.py", - " file_3.py", - " .gitignore", - ], - "With no auto reveal, explicit reveal should show the gitignored entry in the project panel" - ); - } - - #[gpui::test] - async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) { - init_test(cx); - cx.update(|cx| { - cx.update_global::(|store, cx| { - store.update_user_settings::(cx, |project_settings| { - project_settings.file_scan_exclusions = - Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]); - }); - }); - }); - - cx.update(|cx| { - register_project_item::(cx); - }); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/root1", - json!({ - ".dockerignore": "", - ".git": { - "HEAD": "", - }, - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) - .unwrap(); - - select_path(&panel, "root1", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &["v root1 <== selected", " .dockerignore",] - ); - workspace - .update(cx, |workspace, _, cx| { - assert!( - workspace.active_item(cx).is_none(), - "Should have no active items in the beginning" - ); - }) - .unwrap(); - - let excluded_file_path = ".git/COMMIT_EDITMSG"; - let excluded_dir_path = "excluded_dir"; - - panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); - panel.update_in(cx, |panel, window, cx| { - assert!(panel.filename_editor.read(cx).is_focused(window)); - }); - panel - .update_in(cx, |panel, window, cx| { - panel.filename_editor.update(cx, |editor, cx| { - editor.set_text(excluded_file_path, window, cx) - }); - panel.confirm_edit(window, cx).unwrap() - }) - .await - .unwrap(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..13, cx), - &["v root1", " .dockerignore"], - "Excluded dir should not be shown after opening a file in it" - ); - panel.update_in(cx, |panel, window, cx| { - assert!( - !panel.filename_editor.read(cx).is_focused(window), - "Should have closed the file name editor" - ); - }); - workspace - .update(cx, |workspace, _, cx| { - let active_entry_path = workspace - .active_item(cx) - .expect("should have opened and activated the excluded item") - .act_as::(cx) - .expect( - "should have opened the corresponding project item for the excluded item", - ) - .read(cx) - .path - .clone(); - assert_eq!( - active_entry_path.path.as_ref(), - Path::new(excluded_file_path), - "Should open the excluded file" - ); - - assert!( - workspace.notification_ids().is_empty(), - "Should have no notifications after opening an excluded file" - ); - }) - .unwrap(); - assert!( - fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await, - "Should have created the excluded file" - ); - - select_path(&panel, "root1", cx); - panel.update_in(cx, |panel, window, cx| { - panel.new_directory(&NewDirectory, window, cx) - }); - panel.update_in(cx, |panel, window, cx| { - assert!(panel.filename_editor.read(cx).is_focused(window)); - }); - panel - .update_in(cx, |panel, window, cx| { - panel.filename_editor.update(cx, |editor, cx| { - editor.set_text(excluded_file_path, window, cx) - }); - panel.confirm_edit(window, cx).unwrap() - }) - .await - .unwrap(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..13, cx), - &["v root1", " .dockerignore"], - "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file" - ); - panel.update_in(cx, |panel, window, cx| { - assert!( - !panel.filename_editor.read(cx).is_focused(window), - "Should have closed the file name editor" - ); - }); - workspace - .update(cx, |workspace, _, cx| { - let notifications = workspace.notification_ids(); - assert_eq!( - notifications.len(), - 1, - "Should receive one notification with the error message" - ); - workspace.dismiss_notification(notifications.first().unwrap(), cx); - assert!(workspace.notification_ids().is_empty()); - }) - .unwrap(); - - select_path(&panel, "root1", cx); - panel.update_in(cx, |panel, window, cx| { - panel.new_directory(&NewDirectory, window, cx) - }); - panel.update_in(cx, |panel, window, cx| { - assert!(panel.filename_editor.read(cx).is_focused(window)); - }); - panel - .update_in(cx, |panel, window, cx| { - panel.filename_editor.update(cx, |editor, cx| { - editor.set_text(excluded_dir_path, window, cx) - }); - panel.confirm_edit(window, cx).unwrap() - }) - .await - .unwrap(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..13, cx), - &["v root1", " .dockerignore"], - "Should not change the project panel after trying to create an excluded directory" - ); - panel.update_in(cx, |panel, window, cx| { - assert!( - !panel.filename_editor.read(cx).is_focused(window), - "Should have closed the file name editor" - ); - }); - workspace - .update(cx, |workspace, _, cx| { - let notifications = workspace.notification_ids(); - assert_eq!( - notifications.len(), - 1, - "Should receive one notification explaining that no directory is actually shown" - ); - workspace.dismiss_notification(notifications.first().unwrap(), cx); - assert!(workspace.notification_ids().is_empty()); - }) - .unwrap(); - assert!( - fs.is_dir(Path::new("/root1/excluded_dir")).await, - "Should have created the excluded directory" - ); - } - - #[gpui::test] - async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/src", - json!({ - "test": { - "first.rs": "// First Rust file", - "second.rs": "// Second Rust file", - "third.rs": "// Third Rust file", - } - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) - .unwrap(); - - select_path(&panel, "src/", cx); - panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx)); - cx.executor().run_until_parked(); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - // - "v src <== selected", - " > test" - ] - ); - panel.update_in(cx, |panel, window, cx| { - panel.new_directory(&NewDirectory, window, cx) - }); - panel.update_in(cx, |panel, window, cx| { - assert!(panel.filename_editor.read(cx).is_focused(window)); - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - // - "v src", - " > [EDITOR: ''] <== selected", - " > test" - ] - ); - - panel.update_in(cx, |panel, window, cx| { - panel.cancel(&menu::Cancel, window, cx) - }); - assert_eq!( - visible_entries_as_strings(&panel, 0..10, cx), - &[ - // - "v src <== selected", - " > test" - ] - ); - } - - #[gpui::test] - async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/root", - json!({ - "dir1": { - "subdir1": {}, - "file1.txt": "", - "file2.txt": "", - }, - "dir2": { - "subdir2": {}, - "file3.txt": "", - "file4.txt": "", - }, - "file5.txt": "", - "file6.txt": "", - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - toggle_expand_dir(&panel, "root/dir1", cx); - toggle_expand_dir(&panel, "root/dir2", cx); - - // Test Case 1: Delete middle file in directory - select_path(&panel, "root/dir1/file1.txt", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v root", - " v dir1", - " > subdir1", - " file1.txt <== selected", - " file2.txt", - " v dir2", - " > subdir2", - " file3.txt", - " file4.txt", - " file5.txt", - " file6.txt", - ], - "Initial state before deleting middle file" - ); - - submit_deletion(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v root", - " v dir1", - " > subdir1", - " file2.txt <== selected", - " v dir2", - " > subdir2", - " file3.txt", - " file4.txt", - " file5.txt", - " file6.txt", - ], - "Should select next file after deleting middle file" - ); - - // Test Case 2: Delete last file in directory - submit_deletion(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v root", - " v dir1", - " > subdir1 <== selected", - " v dir2", - " > subdir2", - " file3.txt", - " file4.txt", - " file5.txt", - " file6.txt", - ], - "Should select next directory when last file is deleted" - ); - - // Test Case 3: Delete root level file - select_path(&panel, "root/file6.txt", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v root", - " v dir1", - " > subdir1", - " v dir2", - " > subdir2", - " file3.txt", - " file4.txt", - " file5.txt", - " file6.txt <== selected", - ], - "Initial state before deleting root level file" - ); - - submit_deletion(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v root", - " v dir1", - " > subdir1", - " v dir2", - " > subdir2", - " file3.txt", - " file4.txt", - " file5.txt <== selected", - ], - "Should select prev entry at root level" - ); - } - - #[gpui::test] - async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/root", - json!({ - "dir1": { - "subdir1": { - "a.txt": "", - "b.txt": "" - }, - "file1.txt": "", - }, - "dir2": { - "subdir2": { - "c.txt": "", - "d.txt": "" - }, - "file2.txt": "", - }, - "file3.txt": "", - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - toggle_expand_dir(&panel, "root/dir1", cx); - toggle_expand_dir(&panel, "root/dir1/subdir1", cx); - toggle_expand_dir(&panel, "root/dir2", cx); - toggle_expand_dir(&panel, "root/dir2/subdir2", cx); - - // Test Case 1: Select and delete nested directory with parent - cx.simulate_modifiers_change(gpui::Modifiers { - control: true, - ..Default::default() - }); - select_path_with_mark(&panel, "root/dir1/subdir1", cx); - select_path_with_mark(&panel, "root/dir1", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v root", - " v dir1 <== selected <== marked", - " v subdir1 <== marked", - " a.txt", - " b.txt", - " file1.txt", - " v dir2", - " v subdir2", - " c.txt", - " d.txt", - " file2.txt", - " file3.txt", - ], - "Initial state before deleting nested directory with parent" - ); - - submit_deletion(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v root", - " v dir2 <== selected", - " v subdir2", - " c.txt", - " d.txt", - " file2.txt", - " file3.txt", - ], - "Should select next directory after deleting directory with parent" - ); - - // Test Case 2: Select mixed files and directories across levels - select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx); - select_path_with_mark(&panel, "root/dir2/file2.txt", cx); - select_path_with_mark(&panel, "root/file3.txt", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v root", - " v dir2", - " v subdir2", - " c.txt <== marked", - " d.txt", - " file2.txt <== marked", - " file3.txt <== selected <== marked", - ], - "Initial state before deleting" - ); - - submit_deletion(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v root", - " v dir2 <== selected", - " v subdir2", - " d.txt", - ], - "Should select sibling directory" - ); - } - - #[gpui::test] - async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/root", - json!({ - "dir1": { - "subdir1": { - "a.txt": "", - "b.txt": "" - }, - "file1.txt": "", - }, - "dir2": { - "subdir2": { - "c.txt": "", - "d.txt": "" - }, - "file2.txt": "", - }, - "file3.txt": "", - "file4.txt": "", - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - toggle_expand_dir(&panel, "root/dir1", cx); - toggle_expand_dir(&panel, "root/dir1/subdir1", cx); - toggle_expand_dir(&panel, "root/dir2", cx); - toggle_expand_dir(&panel, "root/dir2/subdir2", cx); - - // Test Case 1: Select all root files and directories - cx.simulate_modifiers_change(gpui::Modifiers { - control: true, - ..Default::default() - }); - select_path_with_mark(&panel, "root/dir1", cx); - select_path_with_mark(&panel, "root/dir2", cx); - select_path_with_mark(&panel, "root/file3.txt", cx); - select_path_with_mark(&panel, "root/file4.txt", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v root", - " v dir1 <== marked", - " v subdir1", - " a.txt", - " b.txt", - " file1.txt", - " v dir2 <== marked", - " v subdir2", - " c.txt", - " d.txt", - " file2.txt", - " file3.txt <== marked", - " file4.txt <== selected <== marked", - ], - "State before deleting all contents" - ); - - submit_deletion(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &["v root <== selected"], - "Only empty root directory should remain after deleting all contents" - ); - } - - #[gpui::test] - async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/root", - json!({ - "dir1": { - "subdir1": { - "file_a.txt": "content a", - "file_b.txt": "content b", - }, - "subdir2": { - "file_c.txt": "content c", - }, - "file1.txt": "content 1", - }, - "dir2": { - "file2.txt": "content 2", - }, - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - toggle_expand_dir(&panel, "root/dir1", cx); - toggle_expand_dir(&panel, "root/dir1/subdir1", cx); - toggle_expand_dir(&panel, "root/dir2", cx); - cx.simulate_modifiers_change(gpui::Modifiers { - control: true, - ..Default::default() - }); - - // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory - select_path_with_mark(&panel, "root/dir1", cx); - select_path_with_mark(&panel, "root/dir1/subdir1", cx); - select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v root", - " v dir1 <== marked", - " v subdir1 <== marked", - " file_a.txt <== selected <== marked", - " file_b.txt", - " > subdir2", - " file1.txt", - " v dir2", - " file2.txt", - ], - "State with parent dir, subdir, and file selected" - ); - submit_deletion(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &["v root", " v dir2 <== selected", " file2.txt",], - "Only dir2 should remain after deletion" - ); - } - - #[gpui::test] - async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - // First worktree - fs.insert_tree( - "/root1", - json!({ - "dir1": { - "file1.txt": "content 1", - "file2.txt": "content 2", - }, - "dir2": { - "file3.txt": "content 3", - }, - }), - ) - .await; - - // Second worktree - fs.insert_tree( - "/root2", - json!({ - "dir3": { - "file4.txt": "content 4", - "file5.txt": "content 5", - }, - "file6.txt": "content 6", - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - // Expand all directories for testing - toggle_expand_dir(&panel, "root1/dir1", cx); - toggle_expand_dir(&panel, "root1/dir2", cx); - toggle_expand_dir(&panel, "root2/dir3", cx); - - // Test Case 1: Delete files across different worktrees - cx.simulate_modifiers_change(gpui::Modifiers { - control: true, - ..Default::default() - }); - select_path_with_mark(&panel, "root1/dir1/file1.txt", cx); - select_path_with_mark(&panel, "root2/dir3/file4.txt", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v root1", - " v dir1", - " file1.txt <== marked", - " file2.txt", - " v dir2", - " file3.txt", - "v root2", - " v dir3", - " file4.txt <== selected <== marked", - " file5.txt", - " file6.txt", - ], - "Initial state with files selected from different worktrees" - ); - - submit_deletion(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v root1", - " v dir1", - " file2.txt", - " v dir2", - " file3.txt", - "v root2", - " v dir3", - " file5.txt <== selected", - " file6.txt", - ], - "Should select next file in the last worktree after deletion" - ); - - // Test Case 2: Delete directories from different worktrees - select_path_with_mark(&panel, "root1/dir1", cx); - select_path_with_mark(&panel, "root2/dir3", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v root1", - " v dir1 <== marked", - " file2.txt", - " v dir2", - " file3.txt", - "v root2", - " v dir3 <== selected <== marked", - " file5.txt", - " file6.txt", - ], - "State with directories marked from different worktrees" - ); - - submit_deletion(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v root1", - " v dir2", - " file3.txt", - "v root2", - " file6.txt <== selected", - ], - "Should select remaining file in last worktree after directory deletion" - ); - - // Test Case 4: Delete all remaining files except roots - select_path_with_mark(&panel, "root1/dir2/file3.txt", cx); - select_path_with_mark(&panel, "root2/file6.txt", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v root1", - " v dir2", - " file3.txt <== marked", - "v root2", - " file6.txt <== selected <== marked", - ], - "State with all remaining files marked" - ); - - submit_deletion(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &["v root1", " v dir2", "v root2 <== selected"], - "Second parent root should be selected after deleting" - ); - } - - #[gpui::test] - async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/root", - json!({ - "dir1": { - "file1.txt": "", - "file2.txt": "", - "file3.txt": "", - }, - "dir2": { - "file4.txt": "", - "file5.txt": "", - }, - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - toggle_expand_dir(&panel, "root/dir1", cx); - toggle_expand_dir(&panel, "root/dir2", cx); - - cx.simulate_modifiers_change(gpui::Modifiers { - control: true, - ..Default::default() - }); - - select_path_with_mark(&panel, "root/dir1/file2.txt", cx); - select_path(&panel, "root/dir1/file1.txt", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v root", - " v dir1", - " file1.txt <== selected", - " file2.txt <== marked", - " file3.txt", - " v dir2", - " file4.txt", - " file5.txt", - ], - "Initial state with one marked entry and different selection" - ); - - // Delete should operate on the selected entry (file1.txt) - submit_deletion(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v root", - " v dir1", - " file2.txt <== selected <== marked", - " file3.txt", - " v dir2", - " file4.txt", - " file5.txt", - ], - "Should delete selected file, not marked file" - ); - - select_path_with_mark(&panel, "root/dir1/file3.txt", cx); - select_path_with_mark(&panel, "root/dir2/file4.txt", cx); - select_path(&panel, "root/dir2/file5.txt", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v root", - " v dir1", - " file2.txt <== marked", - " file3.txt <== marked", - " v dir2", - " file4.txt <== marked", - " file5.txt <== selected", - ], - "Initial state with multiple marked entries and different selection" - ); - - // Delete should operate on all marked entries, ignoring the selection - submit_deletion(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..15, cx), - &[ - "v root", - " v dir1", - " v dir2", - " file5.txt <== selected", - ], - "Should delete all marked files, leaving only the selected file" - ); - } - - #[gpui::test] - async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - "/root_b", - json!({ - "dir1": { - "file1.txt": "content 1", - "file2.txt": "content 2", - }, - }), - ) - .await; - - fs.insert_tree( - "/root_c", - json!({ - "dir2": {}, - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - toggle_expand_dir(&panel, "root_b/dir1", cx); - toggle_expand_dir(&panel, "root_c/dir2", cx); - - cx.simulate_modifiers_change(gpui::Modifiers { - control: true, - ..Default::default() - }); - select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx); - select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v root_b", - " v dir1", - " file1.txt <== marked", - " file2.txt <== selected <== marked", - "v root_c", - " v dir2", - ], - "Initial state with files marked in root_b" - ); - - submit_deletion(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - "v root_b", - " v dir1 <== selected", - "v root_c", - " v dir2", - ], - "After deletion in root_b as it's last deletion, selection should be in root_b" - ); - - select_path_with_mark(&panel, "root_c/dir2", cx); - - submit_deletion(&panel, cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &["v root_b", " v dir1", "v root_c <== selected",], - "After deleting from root_c, it should remain in root_c" - ); - } - - fn toggle_expand_dir( - panel: &Entity, - path: impl AsRef, - cx: &mut VisualTestContext, - ) { - let path = path.as_ref(); - panel.update_in(cx, |panel, window, cx| { - for worktree in panel.project.read(cx).worktrees(cx).collect::>() { - let worktree = worktree.read(cx); - if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) { - let entry_id = worktree.entry_for_path(relative_path).unwrap().id; - panel.toggle_expanded(entry_id, window, cx); - return; - } - } - panic!("no worktree for path {:?}", path); - }); - } - - #[gpui::test] - async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) { - init_test_with_editor(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - path!("/root"), - json!({ - ".gitignore": "**/ignored_dir\n**/ignored_nested", - "dir1": { - "empty1": { - "empty2": { - "empty3": { - "file.txt": "" - } - } - }, - "subdir1": { - "file1.txt": "", - "file2.txt": "", - "ignored_nested": { - "ignored_file.txt": "" - } - }, - "ignored_dir": { - "subdir": { - "deep_file.txt": "" - } - } - } - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - - // Test 1: When auto-fold is enabled - cx.update(|_, cx| { - let settings = *ProjectPanelSettings::get_global(cx); - ProjectPanelSettings::override_global( - ProjectPanelSettings { - auto_fold_dirs: true, - ..settings - }, - cx, - ); - }); - - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &["v root", " > dir1", " .gitignore",], - "Initial state should show collapsed root structure" - ); - - toggle_expand_dir(&panel, "root/dir1", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - separator!("v root"), - separator!(" v dir1 <== selected"), - separator!(" > empty1/empty2/empty3"), - separator!(" > ignored_dir"), - separator!(" > subdir1"), - separator!(" .gitignore"), - ], - "Should show first level with auto-folded dirs and ignored dir visible" - ); - - let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap(); - panel.update(cx, |panel, cx| { - let project = panel.project.read(cx); - let worktree = project.worktrees(cx).next().unwrap().read(cx); - panel.expand_all_for_entry(worktree.id(), entry_id, cx); - panel.update_visible_entries(None, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - separator!("v root"), - separator!(" v dir1 <== selected"), - separator!(" v empty1"), - separator!(" v empty2"), - separator!(" v empty3"), - separator!(" file.txt"), - separator!(" > ignored_dir"), - separator!(" v subdir1"), - separator!(" > ignored_nested"), - separator!(" file1.txt"), - separator!(" file2.txt"), - separator!(" .gitignore"), - ], - "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested" - ); - - // Test 2: When auto-fold is disabled - cx.update(|_, cx| { - let settings = *ProjectPanelSettings::get_global(cx); - ProjectPanelSettings::override_global( - ProjectPanelSettings { - auto_fold_dirs: false, - ..settings - }, - cx, - ); - }); - - panel.update_in(cx, |panel, window, cx| { - panel.collapse_all_entries(&CollapseAllEntries, window, cx); - }); - - toggle_expand_dir(&panel, "root/dir1", cx); - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - separator!("v root"), - separator!(" v dir1 <== selected"), - separator!(" > empty1"), - separator!(" > ignored_dir"), - separator!(" > subdir1"), - separator!(" .gitignore"), - ], - "With auto-fold disabled: should show all directories separately" - ); - - let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap(); - panel.update(cx, |panel, cx| { - let project = panel.project.read(cx); - let worktree = project.worktrees(cx).next().unwrap().read(cx); - panel.expand_all_for_entry(worktree.id(), entry_id, cx); - panel.update_visible_entries(None, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - separator!("v root"), - separator!(" v dir1 <== selected"), - separator!(" v empty1"), - separator!(" v empty2"), - separator!(" v empty3"), - separator!(" file.txt"), - separator!(" > ignored_dir"), - separator!(" v subdir1"), - separator!(" > ignored_nested"), - separator!(" file1.txt"), - separator!(" file2.txt"), - separator!(" .gitignore"), - ], - "After expand_all without auto-fold: should expand all dirs normally, \ - expand ignored_dir itself but not its subdirs, and not expand ignored_nested" - ); - - // Test 3: When explicitly called on ignored directory - let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap(); - panel.update(cx, |panel, cx| { - let project = panel.project.read(cx); - let worktree = project.worktrees(cx).next().unwrap().read(cx); - panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx); - panel.update_visible_entries(None, cx); - }); - cx.run_until_parked(); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - separator!("v root"), - separator!(" v dir1 <== selected"), - separator!(" v empty1"), - separator!(" v empty2"), - separator!(" v empty3"), - separator!(" file.txt"), - separator!(" v ignored_dir"), - separator!(" v subdir"), - separator!(" deep_file.txt"), - separator!(" v subdir1"), - separator!(" > ignored_nested"), - separator!(" file1.txt"), - separator!(" file2.txt"), - separator!(" .gitignore"), - ], - "After expand_all on ignored_dir: should expand all contents of the ignored directory" - ); - } - - #[gpui::test] - async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor().clone()); - fs.insert_tree( - path!("/root"), - json!({ - "dir1": { - "subdir1": { - "nested1": { - "file1.txt": "", - "file2.txt": "" - }, - }, - "subdir2": { - "file4.txt": "" - } - }, - "dir2": { - "single_file": { - "file5.txt": "" - } - } - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - - // Test 1: Basic collapsing - { - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - toggle_expand_dir(&panel, "root/dir1", cx); - toggle_expand_dir(&panel, "root/dir1/subdir1", cx); - toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx); - toggle_expand_dir(&panel, "root/dir1/subdir2", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - separator!("v root"), - separator!(" v dir1"), - separator!(" v subdir1"), - separator!(" v nested1"), - separator!(" file1.txt"), - separator!(" file2.txt"), - separator!(" v subdir2 <== selected"), - separator!(" file4.txt"), - separator!(" > dir2"), - ], - "Initial state with everything expanded" - ); - - let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap(); - panel.update(cx, |panel, cx| { - let project = panel.project.read(cx); - let worktree = project.worktrees(cx).next().unwrap().read(cx); - panel.collapse_all_for_entry(worktree.id(), entry_id, cx); - panel.update_visible_entries(None, cx); - }); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &["v root", " > dir1", " > dir2",], - "All subdirs under dir1 should be collapsed" - ); - } - - // Test 2: With auto-fold enabled - { - cx.update(|_, cx| { - let settings = *ProjectPanelSettings::get_global(cx); - ProjectPanelSettings::override_global( - ProjectPanelSettings { - auto_fold_dirs: true, - ..settings - }, - cx, - ); - }); - - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - toggle_expand_dir(&panel, "root/dir1", cx); - toggle_expand_dir(&panel, "root/dir1/subdir1", cx); - toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - separator!("v root"), - separator!(" v dir1"), - separator!(" v subdir1/nested1 <== selected"), - separator!(" file1.txt"), - separator!(" file2.txt"), - separator!(" > subdir2"), - separator!(" > dir2/single_file"), - ], - "Initial state with some dirs expanded" - ); - - let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap(); - panel.update(cx, |panel, cx| { - let project = panel.project.read(cx); - let worktree = project.worktrees(cx).next().unwrap().read(cx); - panel.collapse_all_for_entry(worktree.id(), entry_id, cx); - }); - - toggle_expand_dir(&panel, "root/dir1", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - separator!("v root"), - separator!(" v dir1 <== selected"), - separator!(" > subdir1/nested1"), - separator!(" > subdir2"), - separator!(" > dir2/single_file"), - ], - "Subdirs should be collapsed and folded with auto-fold enabled" - ); - } - - // Test 3: With auto-fold disabled - { - cx.update(|_, cx| { - let settings = *ProjectPanelSettings::get_global(cx); - ProjectPanelSettings::override_global( - ProjectPanelSettings { - auto_fold_dirs: false, - ..settings - }, - cx, - ); - }); - - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); - - toggle_expand_dir(&panel, "root/dir1", cx); - toggle_expand_dir(&panel, "root/dir1/subdir1", cx); - toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - separator!("v root"), - separator!(" v dir1"), - separator!(" v subdir1"), - separator!(" v nested1 <== selected"), - separator!(" file1.txt"), - separator!(" file2.txt"), - separator!(" > subdir2"), - separator!(" > dir2"), - ], - "Initial state with some dirs expanded and auto-fold disabled" - ); - - let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap(); - panel.update(cx, |panel, cx| { - let project = panel.project.read(cx); - let worktree = project.worktrees(cx).next().unwrap().read(cx); - panel.collapse_all_for_entry(worktree.id(), entry_id, cx); - }); - - toggle_expand_dir(&panel, "root/dir1", cx); - - assert_eq!( - visible_entries_as_strings(&panel, 0..20, cx), - &[ - separator!("v root"), - separator!(" v dir1 <== selected"), - separator!(" > subdir1"), - separator!(" > subdir2"), - separator!(" > dir2"), - ], - "Subdirs should be collapsed but not folded with auto-fold disabled" - ); - } - } - - fn select_path( - panel: &Entity, - path: impl AsRef, - cx: &mut VisualTestContext, - ) { - let path = path.as_ref(); - panel.update(cx, |panel, cx| { - for worktree in panel.project.read(cx).worktrees(cx).collect::>() { - let worktree = worktree.read(cx); - if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) { - let entry_id = worktree.entry_for_path(relative_path).unwrap().id; - panel.selection = Some(crate::SelectedEntry { - worktree_id: worktree.id(), - entry_id, - }); - return; - } - } - panic!("no worktree for path {:?}", path); - }); - } - - fn select_path_with_mark( - panel: &Entity, - path: impl AsRef, - cx: &mut VisualTestContext, - ) { - let path = path.as_ref(); - panel.update(cx, |panel, cx| { - for worktree in panel.project.read(cx).worktrees(cx).collect::>() { - let worktree = worktree.read(cx); - if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) { - let entry_id = worktree.entry_for_path(relative_path).unwrap().id; - let entry = crate::SelectedEntry { - worktree_id: worktree.id(), - entry_id, - }; - if !panel.marked_entries.contains(&entry) { - panel.marked_entries.insert(entry); - } - panel.selection = Some(entry); - return; - } - } - panic!("no worktree for path {:?}", path); - }); - } - - fn find_project_entry( - panel: &Entity, - path: impl AsRef, - cx: &mut VisualTestContext, - ) -> Option { - let path = path.as_ref(); - panel.update(cx, |panel, cx| { - for worktree in panel.project.read(cx).worktrees(cx).collect::>() { - let worktree = worktree.read(cx); - if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) { - return worktree.entry_for_path(relative_path).map(|entry| entry.id); - } - } - panic!("no worktree for path {path:?}"); - }) - } - - fn visible_entries_as_strings( - panel: &Entity, - range: Range, - cx: &mut VisualTestContext, - ) -> Vec { - let mut result = Vec::new(); - let mut project_entries = HashSet::default(); - let mut has_editor = false; - - panel.update_in(cx, |panel, window, cx| { - panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| { - if details.is_editing { - assert!(!has_editor, "duplicate editor entry"); - has_editor = true; - } else { - assert!( - project_entries.insert(project_entry), - "duplicate project entry {:?} {:?}", - project_entry, - details - ); - } - - let indent = " ".repeat(details.depth); - let icon = if details.kind.is_dir() { - if details.is_expanded { - "v " - } else { - "> " - } - } else { - " " - }; - let name = if details.is_editing { - format!("[EDITOR: '{}']", details.filename) - } else if details.is_processing { - format!("[PROCESSING: '{}']", details.filename) - } else { - details.filename.clone() - }; - let selected = if details.is_selected { - " <== selected" - } else { - "" - }; - let marked = if details.is_marked { - " <== marked" - } else { - "" - }; - - result.push(format!("{indent}{icon}{name}{selected}{marked}")); - }); - }); - - result - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - init_settings(cx); - theme::init(theme::LoadThemes::JustBase, cx); - language::init(cx); - editor::init_settings(cx); - crate::init(cx); - workspace::init_settings(cx); - client::init_settings(cx); - Project::init_settings(cx); - - cx.update_global::(|store, cx| { - store.update_user_settings::(cx, |project_panel_settings| { - project_panel_settings.auto_fold_dirs = Some(false); - }); - store.update_user_settings::(cx, |worktree_settings| { - worktree_settings.file_scan_exclusions = Some(Vec::new()); - }); - }); - }); - } - - fn init_test_with_editor(cx: &mut TestAppContext) { - cx.update(|cx| { - let app_state = AppState::test(cx); - theme::init(theme::LoadThemes::JustBase, cx); - init_settings(cx); - language::init(cx); - editor::init(cx); - crate::init(cx); - workspace::init(app_state.clone(), cx); - Project::init_settings(cx); - - cx.update_global::(|store, cx| { - store.update_user_settings::(cx, |project_panel_settings| { - project_panel_settings.auto_fold_dirs = Some(false); - }); - store.update_user_settings::(cx, |worktree_settings| { - worktree_settings.file_scan_exclusions = Some(Vec::new()); - }); - }); - }); - } - - fn ensure_single_file_is_opened( - window: &WindowHandle, - expected_path: &str, - cx: &mut TestAppContext, - ) { - window - .update(cx, |workspace, _, cx| { - let worktrees = workspace.worktrees(cx).collect::>(); - assert_eq!(worktrees.len(), 1); - let worktree_id = worktrees[0].read(cx).id(); - - let open_project_paths = workspace - .panes() - .iter() - .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx)) - .collect::>(); - assert_eq!( - open_project_paths, - vec![ProjectPath { - worktree_id, - path: Arc::from(Path::new(expected_path)) - }], - "Should have opened file, selected in project panel" - ); - }) - .unwrap(); - } - - fn submit_deletion(panel: &Entity, cx: &mut VisualTestContext) { - assert!( - !cx.has_pending_prompt(), - "Should have no prompts before the deletion" - ); - panel.update_in(cx, |panel, window, cx| { - panel.delete(&Delete { skip_prompt: false }, window, cx) - }); - assert!( - cx.has_pending_prompt(), - "Should have a prompt after the deletion" - ); - cx.simulate_prompt_answer("Delete"); - assert!( - !cx.has_pending_prompt(), - "Should have no prompts after prompt was replied to" - ); - cx.executor().run_until_parked(); - } - - fn submit_deletion_skipping_prompt(panel: &Entity, cx: &mut VisualTestContext) { - assert!( - !cx.has_pending_prompt(), - "Should have no prompts before the deletion" - ); - panel.update_in(cx, |panel, window, cx| { - panel.delete(&Delete { skip_prompt: true }, window, cx) - }); - assert!(!cx.has_pending_prompt(), "Should have received no prompts"); - cx.executor().run_until_parked(); - } - - fn ensure_no_open_items_and_panes( - workspace: &WindowHandle, - cx: &mut VisualTestContext, - ) { - assert!( - !cx.has_pending_prompt(), - "Should have no prompts after deletion operation closes the file" - ); - workspace - .read_with(cx, |workspace, cx| { - let open_project_paths = workspace - .panes() - .iter() - .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx)) - .collect::>(); - assert!( - open_project_paths.is_empty(), - "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}" - ); - }) - .unwrap(); - } - - struct TestProjectItemView { - focus_handle: FocusHandle, - path: ProjectPath, - } - - struct TestProjectItem { - path: ProjectPath, - } - - impl project::ProjectItem for TestProjectItem { - fn try_open( - _project: &Entity, - path: &ProjectPath, - cx: &mut App, - ) -> Option>>> { - let path = path.clone(); - Some(cx.spawn(async move |cx| cx.new(|_| Self { path }))) - } - - fn entry_id(&self, _: &App) -> Option { - None - } - - fn project_path(&self, _: &App) -> Option { - Some(self.path.clone()) - } - - fn is_dirty(&self) -> bool { - false - } - } - - impl ProjectItem for TestProjectItemView { - type Item = TestProjectItem; - - fn for_project_item( - _: Entity, - project_item: Entity, - _: &mut Window, - cx: &mut Context, - ) -> Self - where - Self: Sized, - { - Self { - path: project_item.update(cx, |project_item, _| project_item.path.clone()), - focus_handle: cx.focus_handle(), - } - } - } - - impl Item for TestProjectItemView { - type Event = (); - } - - impl EventEmitter<()> for TestProjectItemView {} - - impl Focusable for TestProjectItemView { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } - } - - impl Render for TestProjectItemView { - fn render(&mut self, _window: &mut Window, _: &mut Context) -> impl IntoElement { - Empty - } - } -} +mod project_panel_tests; diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs new file mode 100644 index 0000000000..4edb2e62e8 --- /dev/null +++ b/crates/project_panel/src/project_panel_tests.rs @@ -0,0 +1,5013 @@ +use super::*; +use collections::HashSet; +use gpui::{Empty, Entity, TestAppContext, VisualTestContext, WindowHandle}; +use pretty_assertions::assert_eq; +use project::{FakeFs, WorktreeSettings}; +use serde_json::json; +use settings::SettingsStore; +use std::path::{Path, PathBuf}; +use util::{path, separator}; +use workspace::{ + item::{Item, ProjectItem}, + register_project_item, AppState, +}; + +#[gpui::test] +async fn test_visible_list(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root1", + json!({ + ".dockerignore": "", + ".git": { + "HEAD": "", + }, + "a": { + "0": { "q": "", "r": "", "s": "" }, + "1": { "t": "", "u": "" }, + "2": { "v": "", "w": "", "x": "", "y": "" }, + }, + "b": { + "3": { "Q": "" }, + "4": { "R": "", "S": "", "T": "", "U": "" }, + }, + "C": { + "5": {}, + "6": { "V": "", "W": "" }, + "7": { "X": "" }, + "8": { "Y": {}, "Z": "" } + } + }), + ) + .await; + fs.insert_tree( + "/root2", + json!({ + "d": { + "9": "" + }, + "e": {} + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root1", + " > .git", + " > a", + " > b", + " > C", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + toggle_expand_dir(&panel, "root1/b", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root1", + " > .git", + " > a", + " v b <== selected", + " > 3", + " > 4", + " > C", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + assert_eq!( + visible_entries_as_strings(&panel, 6..9, cx), + &[ + // + " > C", + " .dockerignore", + "v root2", + ] + ); +} + +#[gpui::test] +async fn test_opening_file(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + path!("/src"), + json!({ + "test": { + "first.rs": "// First Rust file", + "second.rs": "// Second Rust file", + "third.rs": "// Third Rust file", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "src/test", cx); + select_path(&panel, "src/test/first.rs", cx); + panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " first.rs <== selected <== marked", + " second.rs", + " third.rs" + ] + ); + ensure_single_file_is_opened(&workspace, "test/first.rs", cx); + + select_path(&panel, "src/test/second.rs", cx); + panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " first.rs", + " second.rs <== selected <== marked", + " third.rs" + ] + ); + ensure_single_file_is_opened(&workspace, "test/second.rs", cx); +} + +#[gpui::test] +async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |worktree_settings| { + worktree_settings.file_scan_exclusions = + Some(vec!["**/.git".to_string(), "**/4/**".to_string()]); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root1", + json!({ + ".dockerignore": "", + ".git": { + "HEAD": "", + }, + "a": { + "0": { "q": "", "r": "", "s": "" }, + "1": { "t": "", "u": "" }, + "2": { "v": "", "w": "", "x": "", "y": "" }, + }, + "b": { + "3": { "Q": "" }, + "4": { "R": "", "S": "", "T": "", "U": "" }, + }, + "C": { + "5": {}, + "6": { "V": "", "W": "" }, + "7": { "X": "" }, + "8": { "Y": {}, "Z": "" } + } + }), + ) + .await; + fs.insert_tree( + "/root2", + json!({ + "d": { + "4": "" + }, + "e": {} + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root1", + " > a", + " > b", + " > C", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + toggle_expand_dir(&panel, "root1/b", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root1", + " > a", + " v b <== selected", + " > 3", + " > C", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + toggle_expand_dir(&panel, "root2/d", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root1", + " > a", + " v b", + " > 3", + " > C", + " .dockerignore", + "v root2", + " v d <== selected", + " > e", + ] + ); + + toggle_expand_dir(&panel, "root2/e", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + "v root1", + " > a", + " v b", + " > 3", + " > C", + " .dockerignore", + "v root2", + " v d", + " v e <== selected", + ] + ); +} + +#[gpui::test] +async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + path!("/root1"), + json!({ + "dir_1": { + "nested_dir_1": { + "nested_dir_2": { + "nested_dir_3": { + "file_a.java": "// File contents", + "file_b.java": "// File contents", + "file_c.java": "// File contents", + "nested_dir_4": { + "nested_dir_5": { + "file_d.java": "// File contents", + } + } + } + } + } + } + }), + ) + .await; + fs.insert_tree( + path!("/root2"), + json!({ + "dir_2": { + "file_1.java": "// File contents", + } + }), + ) + .await; + + let project = Project::test( + fs.clone(), + [path!("/root1").as_ref(), path!("/root2").as_ref()], + cx, + ) + .await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + auto_fold_dirs: true, + ..settings + }, + cx, + ); + }); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + separator!("v root1"), + separator!(" > dir_1/nested_dir_1/nested_dir_2/nested_dir_3"), + separator!("v root2"), + separator!(" > dir_2"), + ] + ); + + toggle_expand_dir( + &panel, + "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3", + cx, + ); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + separator!("v root1"), + separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected"), + separator!(" > nested_dir_4/nested_dir_5"), + separator!(" file_a.java"), + separator!(" file_b.java"), + separator!(" file_c.java"), + separator!("v root2"), + separator!(" > dir_2"), + ] + ); + + toggle_expand_dir( + &panel, + "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5", + cx, + ); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + separator!("v root1"), + separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"), + separator!(" v nested_dir_4/nested_dir_5 <== selected"), + separator!(" file_d.java"), + separator!(" file_a.java"), + separator!(" file_b.java"), + separator!(" file_c.java"), + separator!("v root2"), + separator!(" > dir_2"), + ] + ); + toggle_expand_dir(&panel, "root2/dir_2", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + separator!("v root1"), + separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"), + separator!(" v nested_dir_4/nested_dir_5"), + separator!(" file_d.java"), + separator!(" file_a.java"), + separator!(" file_b.java"), + separator!(" file_c.java"), + separator!("v root2"), + separator!(" v dir_2 <== selected"), + separator!(" file_1.java"), + ] + ); +} + +#[gpui::test(iterations = 30)] +async fn test_editing_files(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root1", + json!({ + ".dockerignore": "", + ".git": { + "HEAD": "", + }, + "a": { + "0": { "q": "", "r": "", "s": "" }, + "1": { "t": "", "u": "" }, + "2": { "v": "", "w": "", "x": "", "y": "" }, + }, + "b": { + "3": { "Q": "" }, + "4": { "R": "", "S": "", "T": "", "U": "" }, + }, + "C": { + "5": {}, + "6": { "V": "", "W": "" }, + "7": { "X": "" }, + "8": { "Y": {}, "Z": "" } + } + }), + ) + .await; + fs.insert_tree( + "/root2", + json!({ + "d": { + "9": "" + }, + "e": {} + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }) + .unwrap(); + + select_path(&panel, "root1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1 <== selected", + " > .git", + " > a", + " > b", + " > C", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + // Add a file with the root folder selected. The filename editor is placed + // before the first file in the root folder. + panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); + panel.update_in(cx, |panel, window, cx| { + assert!(panel.filename_editor.read(cx).is_focused(window)); + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " > b", + " > C", + " [EDITOR: ''] <== selected", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + let confirm = panel.update_in(cx, |panel, window, cx| { + panel.filename_editor.update(cx, |editor, cx| { + editor.set_text("the-new-filename", window, cx) + }); + panel.confirm_edit(window, cx).unwrap() + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " > b", + " > C", + " [PROCESSING: 'the-new-filename'] <== selected", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + confirm.await.unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " > b", + " > C", + " .dockerignore", + " the-new-filename <== selected <== marked", + "v root2", + " > d", + " > e", + ] + ); + + select_path(&panel, "root1/b", cx); + panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " [EDITOR: ''] <== selected", + " > C", + " .dockerignore", + " the-new-filename", + ] + ); + + panel + .update_in(cx, |panel, window, cx| { + panel.filename_editor.update(cx, |editor, cx| { + editor.set_text("another-filename.txt", window, cx) + }); + panel.confirm_edit(window, cx).unwrap() + }) + .await + .unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " another-filename.txt <== selected <== marked", + " > C", + " .dockerignore", + " the-new-filename", + ] + ); + + select_path(&panel, "root1/b/another-filename.txt", cx); + panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx)); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " [EDITOR: 'another-filename.txt'] <== selected <== marked", + " > C", + " .dockerignore", + " the-new-filename", + ] + ); + + let confirm = panel.update_in(cx, |panel, window, cx| { + panel.filename_editor.update(cx, |editor, cx| { + let file_name_selections = editor.selections.all::(cx); + assert_eq!( + file_name_selections.len(), + 1, + "File editing should have a single selection, but got: {file_name_selections:?}" + ); + let file_name_selection = &file_name_selections[0]; + assert_eq!( + file_name_selection.start, 0, + "Should select the file name from the start" + ); + assert_eq!( + file_name_selection.end, + "another-filename".len(), + "Should not select file extension" + ); + + editor.set_text("a-different-filename.tar.gz", window, cx) + }); + panel.confirm_edit(window, cx).unwrap() + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked", + " > C", + " .dockerignore", + " the-new-filename", + ] + ); + + confirm.await.unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " a-different-filename.tar.gz <== selected", + " > C", + " .dockerignore", + " the-new-filename", + ] + ); + + panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx)); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " [EDITOR: 'a-different-filename.tar.gz'] <== selected", + " > C", + " .dockerignore", + " the-new-filename", + ] + ); + + panel.update_in(cx, |panel, window, cx| { + panel.filename_editor.update(cx, |editor, cx| { + let file_name_selections = editor.selections.all::(cx); + assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}"); + let file_name_selection = &file_name_selections[0]; + assert_eq!(file_name_selection.start, 0, "Should select the file name from the start"); + assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot.."); + + }); + panel.cancel(&menu::Cancel, window, cx) + }); + + panel.update_in(cx, |panel, window, cx| { + panel.new_directory(&NewDirectory, window, cx) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " > [EDITOR: ''] <== selected", + " a-different-filename.tar.gz", + " > C", + " .dockerignore", + ] + ); + + let confirm = panel.update_in(cx, |panel, window, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("new-dir", window, cx)); + panel.confirm_edit(window, cx).unwrap() + }); + panel.update_in(cx, |panel, window, cx| { + panel.select_next(&Default::default(), window, cx) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " > [PROCESSING: 'new-dir']", + " a-different-filename.tar.gz <== selected", + " > C", + " .dockerignore", + ] + ); + + confirm.await.unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " > new-dir", + " a-different-filename.tar.gz <== selected", + " > C", + " .dockerignore", + ] + ); + + panel.update_in(cx, |panel, window, cx| { + panel.rename(&Default::default(), window, cx) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " > new-dir", + " [EDITOR: 'a-different-filename.tar.gz'] <== selected", + " > C", + " .dockerignore", + ] + ); + + // Dismiss the rename editor when it loses focus. + workspace.update(cx, |_, window, _| window.blur()).unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " v b", + " > 3", + " > 4", + " > new-dir", + " a-different-filename.tar.gz <== selected", + " > C", + " .dockerignore", + ] + ); +} + +#[gpui::test(iterations = 10)] +async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root1", + json!({ + ".dockerignore": "", + ".git": { + "HEAD": "", + }, + "a": { + "0": { "q": "", "r": "", "s": "" }, + "1": { "t": "", "u": "" }, + "2": { "v": "", "w": "", "x": "", "y": "" }, + }, + "b": { + "3": { "Q": "" }, + "4": { "R": "", "S": "", "T": "", "U": "" }, + }, + "C": { + "5": {}, + "6": { "V": "", "W": "" }, + "7": { "X": "" }, + "8": { "Y": {}, "Z": "" } + } + }), + ) + .await; + fs.insert_tree( + "/root2", + json!({ + "d": { + "9": "" + }, + "e": {} + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }) + .unwrap(); + + select_path(&panel, "root1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1 <== selected", + " > .git", + " > a", + " > b", + " > C", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + // Add a file with the root folder selected. The filename editor is placed + // before the first file in the root folder. + panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); + panel.update_in(cx, |panel, window, cx| { + assert!(panel.filename_editor.read(cx).is_focused(window)); + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " > b", + " > C", + " [EDITOR: ''] <== selected", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + let confirm = panel.update_in(cx, |panel, window, cx| { + panel.filename_editor.update(cx, |editor, cx| { + editor.set_text("/bdir1/dir2/the-new-filename", window, cx) + }); + panel.confirm_edit(window, cx).unwrap() + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " > a", + " > b", + " > C", + " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); + + confirm.await.unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..13, cx), + &[ + "v root1", + " > .git", + " > a", + " > b", + " v bdir1", + " v dir2", + " the-new-filename <== selected <== marked", + " > C", + " .dockerignore", + "v root2", + " > d", + " > e", + ] + ); +} + +#[gpui::test] +async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + path!("/root1"), + json!({ + ".dockerignore": "", + ".git": { + "HEAD": "", + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }) + .unwrap(); + + select_path(&panel, "root1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v root1 <== selected", " > .git", " .dockerignore",] + ); + + // Add a file with the root folder selected. The filename editor is placed + // before the first file in the root folder. + panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); + panel.update_in(cx, |panel, window, cx| { + assert!(panel.filename_editor.read(cx).is_focused(window)); + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " [EDITOR: ''] <== selected", + " .dockerignore", + ] + ); + + let confirm = panel.update_in(cx, |panel, window, cx| { + // If we want to create a subdirectory, there should be no prefix slash. + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("new_dir/", window, cx)); + panel.confirm_edit(window, cx).unwrap() + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " [PROCESSING: 'new_dir/'] <== selected", + " .dockerignore", + ] + ); + + confirm.await.unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " v new_dir <== selected", + " .dockerignore", + ] + ); + + // Test filename with whitespace + select_path(&panel, "root1", cx); + panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); + let confirm = panel.update_in(cx, |panel, window, cx| { + // If we want to create a subdirectory, there should be no prefix slash. + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("new dir 2/", window, cx)); + panel.confirm_edit(window, cx).unwrap() + }); + confirm.await.unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " v new dir 2 <== selected", + " v new_dir", + " .dockerignore", + ] + ); + + // Test filename ends with "\" + #[cfg(target_os = "windows")] + { + select_path(&panel, "root1", cx); + panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); + let confirm = panel.update_in(cx, |panel, window, cx| { + // If we want to create a subdirectory, there should be no prefix slash. + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("new_dir_3\\", window, cx)); + panel.confirm_edit(window, cx).unwrap() + }); + confirm.await.unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v root1", + " > .git", + " v new dir 2", + " v new_dir", + " v new_dir_3 <== selected", + " .dockerignore", + ] + ); + } +} + +#[gpui::test] +async fn test_copy_paste(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root1", + json!({ + "one.two.txt": "", + "one.txt": "" + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + panel.update_in(cx, |panel, window, cx| { + panel.select_next(&Default::default(), window, cx); + panel.select_next(&Default::default(), window, cx); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + // + "v root1", + " one.txt <== selected", + " one.two.txt", + ] + ); + + // Regression test - file name is created correctly when + // the copied file's name contains multiple dots. + panel.update_in(cx, |panel, window, cx| { + panel.copy(&Default::default(), window, cx); + panel.paste(&Default::default(), window, cx); + }); + cx.executor().run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + // + "v root1", + " one.txt", + " [EDITOR: 'one copy.txt'] <== selected <== marked", + " one.two.txt", + ] + ); + + panel.update_in(cx, |panel, window, cx| { + panel.filename_editor.update(cx, |editor, cx| { + let file_name_selections = editor.selections.all::(cx); + assert_eq!( + file_name_selections.len(), + 1, + "File editing should have a single selection, but got: {file_name_selections:?}" + ); + let file_name_selection = &file_name_selections[0]; + assert_eq!( + file_name_selection.start, + "one".len(), + "Should select the file name disambiguation after the original file name" + ); + assert_eq!( + file_name_selection.end, + "one copy".len(), + "Should select the file name disambiguation until the extension" + ); + }); + assert!(panel.confirm_edit(window, cx).is_none()); + }); + + panel.update_in(cx, |panel, window, cx| { + panel.paste(&Default::default(), window, cx); + }); + cx.executor().run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + // + "v root1", + " one.txt", + " one copy.txt", + " [EDITOR: 'one copy 1.txt'] <== selected <== marked", + " one.two.txt", + ] + ); + + panel.update_in(cx, |panel, window, cx| { + assert!(panel.confirm_edit(window, cx).is_none()) + }); +} + +#[gpui::test] +async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root1", + json!({ + "one.txt": "", + "two.txt": "", + "three.txt": "", + "a": { + "0": { "q": "", "r": "", "s": "" }, + "1": { "t": "", "u": "" }, + "2": { "v": "", "w": "", "x": "", "y": "" }, + }, + }), + ) + .await; + + fs.insert_tree( + "/root2", + json!({ + "one.txt": "", + "two.txt": "", + "four.txt": "", + "b": { + "3": { "Q": "" }, + "4": { "R": "", "S": "", "T": "", "U": "" }, + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + select_path(&panel, "root1/three.txt", cx); + panel.update_in(cx, |panel, window, cx| { + panel.cut(&Default::default(), window, cx); + }); + + select_path(&panel, "root2/one.txt", cx); + panel.update_in(cx, |panel, window, cx| { + panel.select_next(&Default::default(), window, cx); + panel.paste(&Default::default(), window, cx); + }); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + // + "v root1", + " > a", + " one.txt", + " two.txt", + "v root2", + " > b", + " four.txt", + " one.txt", + " three.txt <== selected <== marked", + " two.txt", + ] + ); + + select_path(&panel, "root1/a", cx); + panel.update_in(cx, |panel, window, cx| { + panel.cut(&Default::default(), window, cx); + }); + select_path(&panel, "root2/two.txt", cx); + panel.update_in(cx, |panel, window, cx| { + panel.select_next(&Default::default(), window, cx); + panel.paste(&Default::default(), window, cx); + }); + + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + // + "v root1", + " one.txt", + " two.txt", + "v root2", + " > a <== selected", + " > b", + " four.txt", + " one.txt", + " three.txt <== marked", + " two.txt", + ] + ); +} + +#[gpui::test] +async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root1", + json!({ + "one.txt": "", + "two.txt": "", + "three.txt": "", + "a": { + "0": { "q": "", "r": "", "s": "" }, + "1": { "t": "", "u": "" }, + "2": { "v": "", "w": "", "x": "", "y": "" }, + }, + }), + ) + .await; + + fs.insert_tree( + "/root2", + json!({ + "one.txt": "", + "two.txt": "", + "four.txt": "", + "b": { + "3": { "Q": "" }, + "4": { "R": "", "S": "", "T": "", "U": "" }, + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + select_path(&panel, "root1/three.txt", cx); + panel.update_in(cx, |panel, window, cx| { + panel.copy(&Default::default(), window, cx); + }); + + select_path(&panel, "root2/one.txt", cx); + panel.update_in(cx, |panel, window, cx| { + panel.select_next(&Default::default(), window, cx); + panel.paste(&Default::default(), window, cx); + }); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + // + "v root1", + " > a", + " one.txt", + " three.txt", + " two.txt", + "v root2", + " > b", + " four.txt", + " one.txt", + " three.txt <== selected <== marked", + " two.txt", + ] + ); + + select_path(&panel, "root1/three.txt", cx); + panel.update_in(cx, |panel, window, cx| { + panel.copy(&Default::default(), window, cx); + }); + select_path(&panel, "root2/two.txt", cx); + panel.update_in(cx, |panel, window, cx| { + panel.select_next(&Default::default(), window, cx); + panel.paste(&Default::default(), window, cx); + }); + + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + // + "v root1", + " > a", + " one.txt", + " three.txt", + " two.txt", + "v root2", + " > b", + " four.txt", + " one.txt", + " three.txt", + " [EDITOR: 'three copy.txt'] <== selected <== marked", + " two.txt", + ] + ); + + panel.update_in(cx, |panel, window, cx| { + panel.cancel(&menu::Cancel {}, window, cx) + }); + cx.executor().run_until_parked(); + + select_path(&panel, "root1/a", cx); + panel.update_in(cx, |panel, window, cx| { + panel.copy(&Default::default(), window, cx); + }); + select_path(&panel, "root2/two.txt", cx); + panel.update_in(cx, |panel, window, cx| { + panel.select_next(&Default::default(), window, cx); + panel.paste(&Default::default(), window, cx); + }); + + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + // + "v root1", + " > a", + " one.txt", + " three.txt", + " two.txt", + "v root2", + " > a <== selected", + " > b", + " four.txt", + " one.txt", + " three.txt", + " three copy.txt", + " two.txt", + ] + ); +} + +#[gpui::test] +async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root", + json!({ + "a": { + "one.txt": "", + "two.txt": "", + "inner_dir": { + "three.txt": "", + "four.txt": "", + } + }, + "b": {} + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + select_path(&panel, "root/a", cx); + panel.update_in(cx, |panel, window, cx| { + panel.copy(&Default::default(), window, cx); + panel.select_next(&Default::default(), window, cx); + panel.paste(&Default::default(), window, cx); + }); + cx.executor().run_until_parked(); + + let pasted_dir = find_project_entry(&panel, "root/b/a", cx); + assert_ne!(pasted_dir, None, "Pasted directory should have an entry"); + + let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx); + assert_ne!( + pasted_dir_file, None, + "Pasted directory file should have an entry" + ); + + let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx); + assert_ne!( + pasted_dir_inner_dir, None, + "Directories inside pasted directory should have an entry" + ); + + toggle_expand_dir(&panel, "root/b/a", cx); + toggle_expand_dir(&panel, "root/b/a/inner_dir", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + // + "v root", + " > a", + " v b", + " v a", + " v inner_dir <== selected", + " four.txt", + " three.txt", + " one.txt", + " two.txt", + ] + ); + + select_path(&panel, "root", cx); + panel.update_in(cx, |panel, window, cx| { + panel.paste(&Default::default(), window, cx) + }); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + // + "v root", + " > a", + " > [EDITOR: 'a copy'] <== selected", + " v b", + " v a", + " v inner_dir", + " four.txt", + " three.txt", + " one.txt", + " two.txt" + ] + ); + + let confirm = panel.update_in(cx, |panel, window, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("c", window, cx)); + panel.confirm_edit(window, cx).unwrap() + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + // + "v root", + " > a", + " > [PROCESSING: 'c'] <== selected", + " v b", + " v a", + " v inner_dir", + " four.txt", + " three.txt", + " one.txt", + " two.txt" + ] + ); + + confirm.await.unwrap(); + + panel.update_in(cx, |panel, window, cx| { + panel.paste(&Default::default(), window, cx) + }); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..50, cx), + &[ + // + "v root", + " > a", + " v b", + " v a", + " v inner_dir", + " four.txt", + " three.txt", + " one.txt", + " two.txt", + " v c", + " > a <== selected", + " > inner_dir", + " one.txt", + " two.txt", + ] + ); +} + +#[gpui::test] +async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/test", + json!({ + "dir1": { + "a.txt": "", + "b.txt": "", + }, + "dir2": {}, + "c.txt": "", + "d.txt": "", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "test/dir1", cx); + + cx.simulate_modifiers_change(gpui::Modifiers { + control: true, + ..Default::default() + }); + + select_path_with_mark(&panel, "test/dir1", cx); + select_path_with_mark(&panel, "test/c.txt", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v test", + " v dir1 <== marked", + " a.txt", + " b.txt", + " > dir2", + " c.txt <== selected <== marked", + " d.txt", + ], + "Initial state before copying dir1 and c.txt" + ); + + panel.update_in(cx, |panel, window, cx| { + panel.copy(&Default::default(), window, cx); + }); + select_path(&panel, "test/dir2", cx); + panel.update_in(cx, |panel, window, cx| { + panel.paste(&Default::default(), window, cx); + }); + cx.executor().run_until_parked(); + + toggle_expand_dir(&panel, "test/dir2/dir1", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v test", + " v dir1 <== marked", + " a.txt", + " b.txt", + " v dir2", + " v dir1 <== selected", + " a.txt", + " b.txt", + " c.txt", + " c.txt <== marked", + " d.txt", + ], + "Should copy dir1 as well as c.txt into dir2" + ); + + // Disambiguating multiple files should not open the rename editor. + select_path(&panel, "test/dir2", cx); + panel.update_in(cx, |panel, window, cx| { + panel.paste(&Default::default(), window, cx); + }); + cx.executor().run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v test", + " v dir1 <== marked", + " a.txt", + " b.txt", + " v dir2", + " v dir1", + " a.txt", + " b.txt", + " > dir1 copy <== selected", + " c.txt", + " c copy.txt", + " c.txt <== marked", + " d.txt", + ], + "Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor" + ); +} + +#[gpui::test] +async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/test", + json!({ + "dir1": { + "a.txt": "", + "b.txt": "", + }, + "dir2": {}, + "c.txt": "", + "d.txt": "", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "test/dir1", cx); + + cx.simulate_modifiers_change(gpui::Modifiers { + control: true, + ..Default::default() + }); + + select_path_with_mark(&panel, "test/dir1/a.txt", cx); + select_path_with_mark(&panel, "test/dir1", cx); + select_path_with_mark(&panel, "test/c.txt", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v test", + " v dir1 <== marked", + " a.txt <== marked", + " b.txt", + " > dir2", + " c.txt <== selected <== marked", + " d.txt", + ], + "Initial state before copying a.txt, dir1 and c.txt" + ); + + panel.update_in(cx, |panel, window, cx| { + panel.copy(&Default::default(), window, cx); + }); + select_path(&panel, "test/dir2", cx); + panel.update_in(cx, |panel, window, cx| { + panel.paste(&Default::default(), window, cx); + }); + cx.executor().run_until_parked(); + + toggle_expand_dir(&panel, "test/dir2/dir1", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v test", + " v dir1 <== marked", + " a.txt <== marked", + " b.txt", + " v dir2", + " v dir1 <== selected", + " a.txt", + " b.txt", + " c.txt", + " c.txt <== marked", + " d.txt", + ], + "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1." + ); +} + +#[gpui::test] +async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + path!("/src"), + json!({ + "test": { + "first.rs": "// First Rust file", + "second.rs": "// Second Rust file", + "third.rs": "// Third Rust file", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "src/test", cx); + select_path(&panel, "src/test/first.rs", cx); + panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " first.rs <== selected <== marked", + " second.rs", + " third.rs" + ] + ); + ensure_single_file_is_opened(&workspace, "test/first.rs", cx); + + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " second.rs <== selected", + " third.rs" + ], + "Project panel should have no deleted file, no other file is selected in it" + ); + ensure_no_open_items_and_panes(&workspace, cx); + + panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " second.rs <== selected <== marked", + " third.rs" + ] + ); + ensure_single_file_is_opened(&workspace, "test/second.rs", cx); + + workspace + .update(cx, |workspace, window, cx| { + let active_items = workspace + .panes() + .iter() + .filter_map(|pane| pane.read(cx).active_item()) + .collect::>(); + assert_eq!(active_items.len(), 1); + let open_editor = active_items + .into_iter() + .next() + .unwrap() + .downcast::() + .expect("Open item should be an editor"); + open_editor.update(cx, |editor, cx| { + editor.set_text("Another text!", window, cx) + }); + }) + .unwrap(); + submit_deletion_skipping_prompt(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v src", " v test", " third.rs <== selected"], + "Project panel should have no deleted file, with one last file remaining" + ); + ensure_no_open_items_and_panes(&workspace, cx); +} + +#[gpui::test] +async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/src", + json!({ + "test": { + "first.rs": "// First Rust file", + "second.rs": "// Second Rust file", + "third.rs": "// Third Rust file", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }) + .unwrap(); + + select_path(&panel, "src/", cx); + panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx)); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + // + "v src <== selected", + " > test" + ] + ); + panel.update_in(cx, |panel, window, cx| { + panel.new_directory(&NewDirectory, window, cx) + }); + panel.update_in(cx, |panel, window, cx| { + assert!(panel.filename_editor.read(cx).is_focused(window)); + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + // + "v src", + " > [EDITOR: ''] <== selected", + " > test" + ] + ); + panel.update_in(cx, |panel, window, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("test", window, cx)); + assert!( + panel.confirm_edit(window, cx).is_none(), + "Should not allow to confirm on conflicting new directory name" + ) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + // + "v src", + " > test" + ], + "File list should be unchanged after failed folder create confirmation" + ); + + select_path(&panel, "src/test/", cx); + panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx)); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + // + "v src", + " > test <== selected" + ] + ); + panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); + panel.update_in(cx, |panel, window, cx| { + assert!(panel.filename_editor.read(cx).is_focused(window)); + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " [EDITOR: ''] <== selected", + " first.rs", + " second.rs", + " third.rs" + ] + ); + panel.update_in(cx, |panel, window, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("first.rs", window, cx)); + assert!( + panel.confirm_edit(window, cx).is_none(), + "Should not allow to confirm on conflicting new file name" + ) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " first.rs", + " second.rs", + " third.rs" + ], + "File list should be unchanged after failed file create confirmation" + ); + + select_path(&panel, "src/test/first.rs", cx); + panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx)); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " first.rs <== selected", + " second.rs", + " third.rs" + ], + ); + panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx)); + panel.update_in(cx, |panel, window, cx| { + assert!(panel.filename_editor.read(cx).is_focused(window)); + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " [EDITOR: 'first.rs'] <== selected", + " second.rs", + " third.rs" + ] + ); + panel.update_in(cx, |panel, window, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("second.rs", window, cx)); + assert!( + panel.confirm_edit(window, cx).is_none(), + "Should not allow to confirm on conflicting file rename" + ) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v src", + " v test", + " first.rs <== selected", + " second.rs", + " third.rs" + ], + "File list should be unchanged after failed rename confirmation" + ); +} + +#[gpui::test] +async fn test_select_git_entry(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + path!("/root"), + json!({ + "tree1": { + ".git": {}, + "dir1": { + "modified1.txt": "1", + "unmodified1.txt": "1", + "modified2.txt": "1", + }, + "dir2": { + "modified3.txt": "1", + "unmodified2.txt": "1", + }, + "modified4.txt": "1", + "unmodified3.txt": "1", + }, + "tree2": { + ".git": {}, + "dir3": { + "modified5.txt": "1", + "unmodified4.txt": "1", + }, + "modified6.txt": "1", + "unmodified5.txt": "1", + } + }), + ) + .await; + + // Mark files as git modified + fs.set_git_content_for_repo( + path!("/root/tree1/.git").as_ref(), + &[ + ("dir1/modified1.txt".into(), "modified".into(), None), + ("dir1/modified2.txt".into(), "modified".into(), None), + ("modified4.txt".into(), "modified".into(), None), + ("dir2/modified3.txt".into(), "modified".into(), None), + ], + ); + fs.set_git_content_for_repo( + path!("/root/tree2/.git").as_ref(), + &[ + ("dir3/modified5.txt".into(), "modified".into(), None), + ("modified6.txt".into(), "modified".into(), None), + ], + ); + + let project = Project::test( + fs.clone(), + [path!("/root/tree1").as_ref(), path!("/root/tree2").as_ref()], + cx, + ) + .await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + // Check initial state + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v tree1", + " > .git", + " > dir1", + " > dir2", + " modified4.txt", + " unmodified3.txt", + "v tree2", + " > .git", + " > dir3", + " modified6.txt", + " unmodified5.txt" + ], + ); + + // Test selecting next modified entry + panel.update_in(cx, |panel, window, cx| { + panel.select_next_git_entry(&SelectNextGitEntry, window, cx); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..6, cx), + &[ + "v tree1", + " > .git", + " v dir1", + " modified1.txt <== selected", + " modified2.txt", + " unmodified1.txt", + ], + ); + + panel.update_in(cx, |panel, window, cx| { + panel.select_next_git_entry(&SelectNextGitEntry, window, cx); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..6, cx), + &[ + "v tree1", + " > .git", + " v dir1", + " modified1.txt", + " modified2.txt <== selected", + " unmodified1.txt", + ], + ); + + panel.update_in(cx, |panel, window, cx| { + panel.select_next_git_entry(&SelectNextGitEntry, window, cx); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 6..9, cx), + &[ + " v dir2", + " modified3.txt <== selected", + " unmodified2.txt", + ], + ); + + panel.update_in(cx, |panel, window, cx| { + panel.select_next_git_entry(&SelectNextGitEntry, window, cx); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 9..11, cx), + &[" modified4.txt <== selected", " unmodified3.txt",], + ); + + panel.update_in(cx, |panel, window, cx| { + panel.select_next_git_entry(&SelectNextGitEntry, window, cx); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 13..16, cx), + &[ + " v dir3", + " modified5.txt <== selected", + " unmodified4.txt", + ], + ); + + panel.update_in(cx, |panel, window, cx| { + panel.select_next_git_entry(&SelectNextGitEntry, window, cx); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 16..18, cx), + &[" modified6.txt <== selected", " unmodified5.txt",], + ); + + // Wraps around to first modified file + panel.update_in(cx, |panel, window, cx| { + panel.select_next_git_entry(&SelectNextGitEntry, window, cx); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..18, cx), + &[ + "v tree1", + " > .git", + " v dir1", + " modified1.txt <== selected", + " modified2.txt", + " unmodified1.txt", + " v dir2", + " modified3.txt", + " unmodified2.txt", + " modified4.txt", + " unmodified3.txt", + "v tree2", + " > .git", + " v dir3", + " modified5.txt", + " unmodified4.txt", + " modified6.txt", + " unmodified5.txt", + ], + ); + + // Wraps around again to last modified file + panel.update_in(cx, |panel, window, cx| { + panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 16..18, cx), + &[" modified6.txt <== selected", " unmodified5.txt",], + ); + + panel.update_in(cx, |panel, window, cx| { + panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 13..16, cx), + &[ + " v dir3", + " modified5.txt <== selected", + " unmodified4.txt", + ], + ); + + panel.update_in(cx, |panel, window, cx| { + panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 9..11, cx), + &[" modified4.txt <== selected", " unmodified3.txt",], + ); + + panel.update_in(cx, |panel, window, cx| { + panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 6..9, cx), + &[ + " v dir2", + " modified3.txt <== selected", + " unmodified2.txt", + ], + ); + + panel.update_in(cx, |panel, window, cx| { + panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..6, cx), + &[ + "v tree1", + " > .git", + " v dir1", + " modified1.txt", + " modified2.txt <== selected", + " unmodified1.txt", + ], + ); + + panel.update_in(cx, |panel, window, cx| { + panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..6, cx), + &[ + "v tree1", + " > .git", + " v dir1", + " modified1.txt <== selected", + " modified2.txt", + " unmodified1.txt", + ], + ); +} + +#[gpui::test] +async fn test_select_directory(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/project_root", + json!({ + "dir_1": { + "nested_dir": { + "file_a.py": "# File contents", + } + }, + "file_1.py": "# File contents", + "dir_2": { + + }, + "dir_3": { + + }, + "file_2.py": "# File contents", + "dir_4": { + + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); + cx.executor().run_until_parked(); + select_path(&panel, "project_root/dir_1", cx); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " > dir_1 <== selected", + " > dir_2", + " > dir_3", + " > dir_4", + " file_1.py", + " file_2.py", + ] + ); + panel.update_in(cx, |panel, window, cx| { + panel.select_prev_directory(&SelectPrevDirectory, window, cx) + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root <== selected", + " > dir_1", + " > dir_2", + " > dir_3", + " > dir_4", + " file_1.py", + " file_2.py", + ] + ); + + panel.update_in(cx, |panel, window, cx| { + panel.select_prev_directory(&SelectPrevDirectory, window, cx) + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " > dir_1", + " > dir_2", + " > dir_3", + " > dir_4 <== selected", + " file_1.py", + " file_2.py", + ] + ); + + panel.update_in(cx, |panel, window, cx| { + panel.select_next_directory(&SelectNextDirectory, window, cx) + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root <== selected", + " > dir_1", + " > dir_2", + " > dir_3", + " > dir_4", + " file_1.py", + " file_2.py", + ] + ); +} +#[gpui::test] +async fn test_select_first_last(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/project_root", + json!({ + "dir_1": { + "nested_dir": { + "file_a.py": "# File contents", + } + }, + "file_1.py": "# File contents", + "file_2.py": "# File contents", + "zdir_2": { + "nested_dir2": { + "file_b.py": "# File contents", + } + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " > dir_1", + " > zdir_2", + " file_1.py", + " file_2.py", + ] + ); + panel.update_in(cx, |panel, window, cx| { + panel.select_first(&SelectFirst, window, cx) + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root <== selected", + " > dir_1", + " > zdir_2", + " file_1.py", + " file_2.py", + ] + ); + + panel.update_in(cx, |panel, window, cx| { + panel.select_last(&SelectLast, window, cx) + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " > dir_1", + " > zdir_2", + " file_1.py", + " file_2.py <== selected", + ] + ); +} + +#[gpui::test] +async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/project_root", + json!({ + "dir_1": { + "nested_dir": { + "file_a.py": "# File contents", + } + }, + "file_1.py": "# File contents", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); + cx.executor().run_until_parked(); + select_path(&panel, "project_root/dir_1", cx); + panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); + select_path(&panel, "project_root/dir_1/nested_dir", cx); + panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); + panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1", + " > nested_dir <== selected", + " file_1.py", + ] + ); +} + +#[gpui::test] +async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/project_root", + json!({ + "dir_1": { + "nested_dir": { + "file_a.py": "# File contents", + "file_b.py": "# File contents", + "file_c.py": "# File contents", + }, + "file_1.py": "# File contents", + "file_2.py": "# File contents", + "file_3.py": "# File contents", + }, + "dir_2": { + "file_1.py": "# File contents", + "file_2.py": "# File contents", + "file_3.py": "# File contents", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + panel.update_in(cx, |panel, window, cx| { + panel.collapse_all_entries(&CollapseAllEntries, window, cx) + }); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v project_root", " > dir_1", " > dir_2",] + ); + + // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries + toggle_expand_dir(&panel, "project_root/dir_1", cx); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1 <== selected", + " > nested_dir", + " file_1.py", + " file_2.py", + " file_3.py", + " > dir_2", + ] + ); +} + +#[gpui::test] +async fn test_new_file_move(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.as_fake().insert_tree(path!("/root"), json!({})).await; + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + // Make a new buffer with no backing file + workspace + .update(cx, |workspace, window, cx| { + Editor::new_file(workspace, &Default::default(), window, cx) + }) + .unwrap(); + + cx.executor().run_until_parked(); + + // "Save as" the buffer, creating a new backing file for it + let save_task = workspace + .update(cx, |workspace, window, cx| { + workspace.save_active_item(workspace::SaveIntent::Save, window, cx) + }) + .unwrap(); + + cx.executor().run_until_parked(); + cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new")))); + save_task.await.unwrap(); + + // Rename the file + select_path(&panel, "root/new", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v root", " new <== selected <== marked"] + ); + panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx)); + panel.update_in(cx, |panel, window, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("newer", window, cx)); + }); + panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx)); + + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v root", " newer <== selected"] + ); + + workspace + .update(cx, |workspace, window, cx| { + workspace.save_active_item(workspace::SaveIntent::Save, window, cx) + }) + .unwrap() + .await + .unwrap(); + + cx.executor().run_until_parked(); + // assert that saving the file doesn't restore "new" + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v root", " newer <== selected"] + ); +} + +#[gpui::test] +#[cfg_attr(target_os = "windows", ignore)] +async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root1", + json!({ + "dir1": { + "file1.txt": "content 1", + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "root1/dir1", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &["v root1", " v dir1 <== selected", " file1.txt",], + "Initial state with worktrees" + ); + + select_path(&panel, "root1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &["v root1 <== selected", " v dir1", " file1.txt",], + ); + + // Rename root1 to new_root1 + panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx)); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v [EDITOR: 'root1'] <== selected", + " v dir1", + " file1.txt", + ], + ); + + let confirm = panel.update_in(cx, |panel, window, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("new_root1", window, cx)); + panel.confirm_edit(window, cx).unwrap() + }); + confirm.await.unwrap(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v new_root1 <== selected", + " v dir1", + " file1.txt", + ], + "Should update worktree name" + ); + + // Ensure internal paths have been updated + select_path(&panel, "new_root1/dir1/file1.txt", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v new_root1", + " v dir1", + " file1.txt <== selected", + ], + "Files in renamed worktree are selectable" + ); +} + +#[gpui::test] +async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/project_root", + json!({ + "dir_1": { + "nested_dir": { + "file_a.py": "# File contents", + } + }, + "file_1.py": "# File contents", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; + let worktree_id = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id()); + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + cx.update(|window, cx| { + panel.update(cx, |this, cx| { + this.select_next(&Default::default(), window, cx); + this.expand_selected_entry(&Default::default(), window, cx); + this.expand_selected_entry(&Default::default(), window, cx); + this.select_next(&Default::default(), window, cx); + this.expand_selected_entry(&Default::default(), window, cx); + this.select_next(&Default::default(), window, cx); + }) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1", + " v nested_dir", + " file_a.py <== selected", + " file_1.py", + ] + ); + let modifiers_with_shift = gpui::Modifiers { + shift: true, + ..Default::default() + }; + cx.simulate_modifiers_change(modifiers_with_shift); + cx.update(|window, cx| { + panel.update(cx, |this, cx| { + this.select_next(&Default::default(), window, cx); + }) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1", + " v nested_dir", + " file_a.py", + " file_1.py <== selected <== marked", + ] + ); + cx.update(|window, cx| { + panel.update(cx, |this, cx| { + this.select_previous(&Default::default(), window, cx); + }) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1", + " v nested_dir", + " file_a.py <== selected <== marked", + " file_1.py <== marked", + ] + ); + cx.update(|window, cx| { + panel.update(cx, |this, cx| { + let drag = DraggedSelection { + active_selection: this.selection.unwrap(), + marked_selections: Arc::new(this.marked_entries.clone()), + }; + let target_entry = this + .project + .read(cx) + .entry_for_path(&(worktree_id, "").into(), cx) + .unwrap(); + this.drag_onto(&drag, target_entry.id, false, window, cx); + }); + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1", + " v nested_dir", + " file_1.py <== marked", + " file_a.py <== selected <== marked", + ] + ); + // ESC clears out all marks + cx.update(|window, cx| { + panel.update(cx, |this, cx| { + this.cancel(&menu::Cancel, window, cx); + }) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1", + " v nested_dir", + " file_1.py", + " file_a.py <== selected", + ] + ); + // ESC clears out all marks + cx.update(|window, cx| { + panel.update(cx, |this, cx| { + this.select_previous(&SelectPrevious, window, cx); + this.select_next(&SelectNext, window, cx); + }) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1", + " v nested_dir", + " file_1.py <== marked", + " file_a.py <== selected <== marked", + ] + ); + cx.simulate_modifiers_change(Default::default()); + cx.update(|window, cx| { + panel.update(cx, |this, cx| { + this.cut(&Cut, window, cx); + this.select_previous(&SelectPrevious, window, cx); + this.select_previous(&SelectPrevious, window, cx); + + this.paste(&Paste, window, cx); + // this.expand_selected_entry(&ExpandSelectedEntry, cx); + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1", + " v nested_dir", + " file_1.py <== marked", + " file_a.py <== selected <== marked", + ] + ); + cx.simulate_modifiers_change(modifiers_with_shift); + cx.update(|window, cx| { + panel.update(cx, |this, cx| { + this.expand_selected_entry(&Default::default(), window, cx); + this.select_next(&SelectNext, window, cx); + this.select_next(&SelectNext, window, cx); + }) + }); + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " v dir_1", + " v nested_dir <== selected", + ] + ); +} +#[gpui::test] +async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |worktree_settings| { + worktree_settings.file_scan_exclusions = Some(Vec::new()); + }); + store.update_user_settings::(cx, |project_panel_settings| { + project_panel_settings.auto_reveal_entries = Some(false) + }); + }) + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/project_root", + json!({ + ".git": {}, + ".gitignore": "**/gitignored_dir", + "dir_1": { + "file_1.py": "# File 1_1 contents", + "file_2.py": "# File 1_2 contents", + "file_3.py": "# File 1_3 contents", + "gitignored_dir": { + "file_a.py": "# File contents", + "file_b.py": "# File contents", + "file_c.py": "# File contents", + }, + }, + "dir_2": { + "file_1.py": "# File 2_1 contents", + "file_2.py": "# File 2_2 contents", + "file_3.py": "# File 2_3 contents", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1", + " > dir_2", + " .gitignore", + ] + ); + + let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx) + .expect("dir 1 file is not ignored and should have an entry"); + let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx) + .expect("dir 2 file is not ignored and should have an entry"); + let gitignored_dir_file = + find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx); + assert_eq!( + gitignored_dir_file, None, + "File in the gitignored dir should not have an entry before its dir is toggled" + ); + + toggle_expand_dir(&panel, "project_root/dir_1", cx); + toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " v gitignored_dir <== selected", + " file_a.py", + " file_b.py", + " file_c.py", + " file_1.py", + " file_2.py", + " file_3.py", + " > dir_2", + " .gitignore", + ], + "Should show gitignored dir file list in the project panel" + ); + let gitignored_dir_file = + find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx) + .expect("after gitignored dir got opened, a file entry should be present"); + + toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx); + toggle_expand_dir(&panel, "project_root/dir_1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1 <== selected", + " > dir_2", + " .gitignore", + ], + "Should hide all dir contents again and prepare for the auto reveal test" + ); + + for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] { + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some(file_entry))) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1 <== selected", + " > dir_2", + " .gitignore", + ], + "When no auto reveal is enabled, the selected entry should not be revealed in the project panel" + ); + } + + cx.update(|_, cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_panel_settings| { + project_panel_settings.auto_reveal_entries = Some(true) + }); + }) + }); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file))) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " > gitignored_dir", + " file_1.py <== selected <== marked", + " file_2.py", + " file_3.py", + " > dir_2", + " .gitignore", + ], + "When auto reveal is enabled, not ignored dir_1 entry should be revealed" + ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file))) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " > gitignored_dir", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py <== selected <== marked", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "When auto reveal is enabled, not ignored dir_2 entry should be revealed" + ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some( + gitignored_dir_file, + ))) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " > gitignored_dir", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py <== selected <== marked", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel" + ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file)) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " v gitignored_dir", + " file_a.py <== selected <== marked", + " file_b.py", + " file_c.py", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "When a gitignored entry is explicitly revealed, it should be shown in the project tree" + ); +} + +#[gpui::test] +async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |worktree_settings| { + worktree_settings.file_scan_exclusions = Some(Vec::new()); + worktree_settings.file_scan_inclusions = + Some(vec!["always_included_but_ignored_dir/*".to_string()]); + }); + store.update_user_settings::(cx, |project_panel_settings| { + project_panel_settings.auto_reveal_entries = Some(false) + }); + }) + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/project_root", + json!({ + ".git": {}, + ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir", + "dir_1": { + "file_1.py": "# File 1_1 contents", + "file_2.py": "# File 1_2 contents", + "file_3.py": "# File 1_3 contents", + "gitignored_dir": { + "file_a.py": "# File contents", + "file_b.py": "# File contents", + "file_c.py": "# File contents", + }, + }, + "dir_2": { + "file_1.py": "# File 2_1 contents", + "file_2.py": "# File 2_2 contents", + "file_3.py": "# File 2_3 contents", + }, + "always_included_but_ignored_dir": { + "file_a.py": "# File contents", + "file_b.py": "# File contents", + "file_c.py": "# File contents", + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > always_included_but_ignored_dir", + " > dir_1", + " > dir_2", + " .gitignore", + ] + ); + + let gitignored_dir_file = + find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx); + let always_included_but_ignored_dir_file = find_project_entry( + &panel, + "project_root/always_included_but_ignored_dir/file_a.py", + cx, + ) + .expect("file that is .gitignored but set to always be included should have an entry"); + assert_eq!( + gitignored_dir_file, None, + "File in the gitignored dir should not have an entry unless its directory is toggled" + ); + + toggle_expand_dir(&panel, "project_root/dir_1", cx); + cx.run_until_parked(); + cx.update(|_, cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_panel_settings| { + project_panel_settings.auto_reveal_entries = Some(true) + }); + }) + }); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some( + always_included_but_ignored_dir_file, + ))) + }) + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v always_included_but_ignored_dir", + " file_a.py <== selected <== marked", + " file_b.py", + " file_c.py", + " v dir_1", + " > gitignored_dir", + " file_1.py", + " file_2.py", + " file_3.py", + " > dir_2", + " .gitignore", + ], + "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel" + ); +} + +#[gpui::test] +async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |worktree_settings| { + worktree_settings.file_scan_exclusions = Some(Vec::new()); + }); + store.update_user_settings::(cx, |project_panel_settings| { + project_panel_settings.auto_reveal_entries = Some(false) + }); + }) + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/project_root", + json!({ + ".git": {}, + ".gitignore": "**/gitignored_dir", + "dir_1": { + "file_1.py": "# File 1_1 contents", + "file_2.py": "# File 1_2 contents", + "file_3.py": "# File 1_3 contents", + "gitignored_dir": { + "file_a.py": "# File contents", + "file_b.py": "# File contents", + "file_c.py": "# File contents", + }, + }, + "dir_2": { + "file_1.py": "# File 2_1 contents", + "file_2.py": "# File 2_2 contents", + "file_3.py": "# File 2_3 contents", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1", + " > dir_2", + " .gitignore", + ] + ); + + let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx) + .expect("dir 1 file is not ignored and should have an entry"); + let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx) + .expect("dir 2 file is not ignored and should have an entry"); + let gitignored_dir_file = + find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx); + assert_eq!( + gitignored_dir_file, None, + "File in the gitignored dir should not have an entry before its dir is toggled" + ); + + toggle_expand_dir(&panel, "project_root/dir_1", cx); + toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " v gitignored_dir <== selected", + " file_a.py", + " file_b.py", + " file_c.py", + " file_1.py", + " file_2.py", + " file_3.py", + " > dir_2", + " .gitignore", + ], + "Should show gitignored dir file list in the project panel" + ); + let gitignored_dir_file = + find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx) + .expect("after gitignored dir got opened, a file entry should be present"); + + toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx); + toggle_expand_dir(&panel, "project_root/dir_1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1 <== selected", + " > dir_2", + " .gitignore", + ], + "Should hide all dir contents again and prepare for the explicit reveal test" + ); + + for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] { + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some(file_entry))) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1 <== selected", + " > dir_2", + " .gitignore", + ], + "When no auto reveal is enabled, the selected entry should not be revealed in the project panel" + ); + } + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(dir_1_file)) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " > gitignored_dir", + " file_1.py <== selected <== marked", + " file_2.py", + " file_3.py", + " > dir_2", + " .gitignore", + ], + "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel" + ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(dir_2_file)) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " > gitignored_dir", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py <== selected <== marked", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel" + ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file)) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " v gitignored_dir", + " file_a.py <== selected <== marked", + " file_b.py", + " file_c.py", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "With no auto reveal, explicit reveal should show the gitignored entry in the project panel" + ); +} + +#[gpui::test] +async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = + Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]); + }); + }); + }); + + cx.update(|cx| { + register_project_item::(cx); + }); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root1", + json!({ + ".dockerignore": "", + ".git": { + "HEAD": "", + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }) + .unwrap(); + + select_path(&panel, "root1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v root1 <== selected", " .dockerignore",] + ); + workspace + .update(cx, |workspace, _, cx| { + assert!( + workspace.active_item(cx).is_none(), + "Should have no active items in the beginning" + ); + }) + .unwrap(); + + let excluded_file_path = ".git/COMMIT_EDITMSG"; + let excluded_dir_path = "excluded_dir"; + + panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); + panel.update_in(cx, |panel, window, cx| { + assert!(panel.filename_editor.read(cx).is_focused(window)); + }); + panel + .update_in(cx, |panel, window, cx| { + panel.filename_editor.update(cx, |editor, cx| { + editor.set_text(excluded_file_path, window, cx) + }); + panel.confirm_edit(window, cx).unwrap() + }) + .await + .unwrap(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..13, cx), + &["v root1", " .dockerignore"], + "Excluded dir should not be shown after opening a file in it" + ); + panel.update_in(cx, |panel, window, cx| { + assert!( + !panel.filename_editor.read(cx).is_focused(window), + "Should have closed the file name editor" + ); + }); + workspace + .update(cx, |workspace, _, cx| { + let active_entry_path = workspace + .active_item(cx) + .expect("should have opened and activated the excluded item") + .act_as::(cx) + .expect("should have opened the corresponding project item for the excluded item") + .read(cx) + .path + .clone(); + assert_eq!( + active_entry_path.path.as_ref(), + Path::new(excluded_file_path), + "Should open the excluded file" + ); + + assert!( + workspace.notification_ids().is_empty(), + "Should have no notifications after opening an excluded file" + ); + }) + .unwrap(); + assert!( + fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await, + "Should have created the excluded file" + ); + + select_path(&panel, "root1", cx); + panel.update_in(cx, |panel, window, cx| { + panel.new_directory(&NewDirectory, window, cx) + }); + panel.update_in(cx, |panel, window, cx| { + assert!(panel.filename_editor.read(cx).is_focused(window)); + }); + panel + .update_in(cx, |panel, window, cx| { + panel.filename_editor.update(cx, |editor, cx| { + editor.set_text(excluded_file_path, window, cx) + }); + panel.confirm_edit(window, cx).unwrap() + }) + .await + .unwrap(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..13, cx), + &["v root1", " .dockerignore"], + "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file" + ); + panel.update_in(cx, |panel, window, cx| { + assert!( + !panel.filename_editor.read(cx).is_focused(window), + "Should have closed the file name editor" + ); + }); + workspace + .update(cx, |workspace, _, cx| { + let notifications = workspace.notification_ids(); + assert_eq!( + notifications.len(), + 1, + "Should receive one notification with the error message" + ); + workspace.dismiss_notification(notifications.first().unwrap(), cx); + assert!(workspace.notification_ids().is_empty()); + }) + .unwrap(); + + select_path(&panel, "root1", cx); + panel.update_in(cx, |panel, window, cx| { + panel.new_directory(&NewDirectory, window, cx) + }); + panel.update_in(cx, |panel, window, cx| { + assert!(panel.filename_editor.read(cx).is_focused(window)); + }); + panel + .update_in(cx, |panel, window, cx| { + panel.filename_editor.update(cx, |editor, cx| { + editor.set_text(excluded_dir_path, window, cx) + }); + panel.confirm_edit(window, cx).unwrap() + }) + .await + .unwrap(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..13, cx), + &["v root1", " .dockerignore"], + "Should not change the project panel after trying to create an excluded directory" + ); + panel.update_in(cx, |panel, window, cx| { + assert!( + !panel.filename_editor.read(cx).is_focused(window), + "Should have closed the file name editor" + ); + }); + workspace + .update(cx, |workspace, _, cx| { + let notifications = workspace.notification_ids(); + assert_eq!( + notifications.len(), + 1, + "Should receive one notification explaining that no directory is actually shown" + ); + workspace.dismiss_notification(notifications.first().unwrap(), cx); + assert!(workspace.notification_ids().is_empty()); + }) + .unwrap(); + assert!( + fs.is_dir(Path::new("/root1/excluded_dir")).await, + "Should have created the excluded directory" + ); +} + +#[gpui::test] +async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/src", + json!({ + "test": { + "first.rs": "// First Rust file", + "second.rs": "// Second Rust file", + "third.rs": "// Third Rust file", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace + .update(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }) + .unwrap(); + + select_path(&panel, "src/", cx); + panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx)); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + // + "v src <== selected", + " > test" + ] + ); + panel.update_in(cx, |panel, window, cx| { + panel.new_directory(&NewDirectory, window, cx) + }); + panel.update_in(cx, |panel, window, cx| { + assert!(panel.filename_editor.read(cx).is_focused(window)); + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + // + "v src", + " > [EDITOR: ''] <== selected", + " > test" + ] + ); + + panel.update_in(cx, |panel, window, cx| { + panel.cancel(&menu::Cancel, window, cx) + }); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + // + "v src <== selected", + " > test" + ] + ); +} + +#[gpui::test] +async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root", + json!({ + "dir1": { + "subdir1": {}, + "file1.txt": "", + "file2.txt": "", + }, + "dir2": { + "subdir2": {}, + "file3.txt": "", + "file4.txt": "", + }, + "file5.txt": "", + "file6.txt": "", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "root/dir1", cx); + toggle_expand_dir(&panel, "root/dir2", cx); + + // Test Case 1: Delete middle file in directory + select_path(&panel, "root/dir1/file1.txt", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v root", + " v dir1", + " > subdir1", + " file1.txt <== selected", + " file2.txt", + " v dir2", + " > subdir2", + " file3.txt", + " file4.txt", + " file5.txt", + " file6.txt", + ], + "Initial state before deleting middle file" + ); + + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v root", + " v dir1", + " > subdir1", + " file2.txt <== selected", + " v dir2", + " > subdir2", + " file3.txt", + " file4.txt", + " file5.txt", + " file6.txt", + ], + "Should select next file after deleting middle file" + ); + + // Test Case 2: Delete last file in directory + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v root", + " v dir1", + " > subdir1 <== selected", + " v dir2", + " > subdir2", + " file3.txt", + " file4.txt", + " file5.txt", + " file6.txt", + ], + "Should select next directory when last file is deleted" + ); + + // Test Case 3: Delete root level file + select_path(&panel, "root/file6.txt", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v root", + " v dir1", + " > subdir1", + " v dir2", + " > subdir2", + " file3.txt", + " file4.txt", + " file5.txt", + " file6.txt <== selected", + ], + "Initial state before deleting root level file" + ); + + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v root", + " v dir1", + " > subdir1", + " v dir2", + " > subdir2", + " file3.txt", + " file4.txt", + " file5.txt <== selected", + ], + "Should select prev entry at root level" + ); +} + +#[gpui::test] +async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root", + json!({ + "dir1": { + "subdir1": { + "a.txt": "", + "b.txt": "" + }, + "file1.txt": "", + }, + "dir2": { + "subdir2": { + "c.txt": "", + "d.txt": "" + }, + "file2.txt": "", + }, + "file3.txt": "", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "root/dir1", cx); + toggle_expand_dir(&panel, "root/dir1/subdir1", cx); + toggle_expand_dir(&panel, "root/dir2", cx); + toggle_expand_dir(&panel, "root/dir2/subdir2", cx); + + // Test Case 1: Select and delete nested directory with parent + cx.simulate_modifiers_change(gpui::Modifiers { + control: true, + ..Default::default() + }); + select_path_with_mark(&panel, "root/dir1/subdir1", cx); + select_path_with_mark(&panel, "root/dir1", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v root", + " v dir1 <== selected <== marked", + " v subdir1 <== marked", + " a.txt", + " b.txt", + " file1.txt", + " v dir2", + " v subdir2", + " c.txt", + " d.txt", + " file2.txt", + " file3.txt", + ], + "Initial state before deleting nested directory with parent" + ); + + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v root", + " v dir2 <== selected", + " v subdir2", + " c.txt", + " d.txt", + " file2.txt", + " file3.txt", + ], + "Should select next directory after deleting directory with parent" + ); + + // Test Case 2: Select mixed files and directories across levels + select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx); + select_path_with_mark(&panel, "root/dir2/file2.txt", cx); + select_path_with_mark(&panel, "root/file3.txt", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v root", + " v dir2", + " v subdir2", + " c.txt <== marked", + " d.txt", + " file2.txt <== marked", + " file3.txt <== selected <== marked", + ], + "Initial state before deleting" + ); + + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v root", + " v dir2 <== selected", + " v subdir2", + " d.txt", + ], + "Should select sibling directory" + ); +} + +#[gpui::test] +async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root", + json!({ + "dir1": { + "subdir1": { + "a.txt": "", + "b.txt": "" + }, + "file1.txt": "", + }, + "dir2": { + "subdir2": { + "c.txt": "", + "d.txt": "" + }, + "file2.txt": "", + }, + "file3.txt": "", + "file4.txt": "", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "root/dir1", cx); + toggle_expand_dir(&panel, "root/dir1/subdir1", cx); + toggle_expand_dir(&panel, "root/dir2", cx); + toggle_expand_dir(&panel, "root/dir2/subdir2", cx); + + // Test Case 1: Select all root files and directories + cx.simulate_modifiers_change(gpui::Modifiers { + control: true, + ..Default::default() + }); + select_path_with_mark(&panel, "root/dir1", cx); + select_path_with_mark(&panel, "root/dir2", cx); + select_path_with_mark(&panel, "root/file3.txt", cx); + select_path_with_mark(&panel, "root/file4.txt", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root", + " v dir1 <== marked", + " v subdir1", + " a.txt", + " b.txt", + " file1.txt", + " v dir2 <== marked", + " v subdir2", + " c.txt", + " d.txt", + " file2.txt", + " file3.txt <== marked", + " file4.txt <== selected <== marked", + ], + "State before deleting all contents" + ); + + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &["v root <== selected"], + "Only empty root directory should remain after deleting all contents" + ); +} + +#[gpui::test] +async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root", + json!({ + "dir1": { + "subdir1": { + "file_a.txt": "content a", + "file_b.txt": "content b", + }, + "subdir2": { + "file_c.txt": "content c", + }, + "file1.txt": "content 1", + }, + "dir2": { + "file2.txt": "content 2", + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "root/dir1", cx); + toggle_expand_dir(&panel, "root/dir1/subdir1", cx); + toggle_expand_dir(&panel, "root/dir2", cx); + cx.simulate_modifiers_change(gpui::Modifiers { + control: true, + ..Default::default() + }); + + // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory + select_path_with_mark(&panel, "root/dir1", cx); + select_path_with_mark(&panel, "root/dir1/subdir1", cx); + select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root", + " v dir1 <== marked", + " v subdir1 <== marked", + " file_a.txt <== selected <== marked", + " file_b.txt", + " > subdir2", + " file1.txt", + " v dir2", + " file2.txt", + ], + "State with parent dir, subdir, and file selected" + ); + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &["v root", " v dir2 <== selected", " file2.txt",], + "Only dir2 should remain after deletion" + ); +} + +#[gpui::test] +async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + // First worktree + fs.insert_tree( + "/root1", + json!({ + "dir1": { + "file1.txt": "content 1", + "file2.txt": "content 2", + }, + "dir2": { + "file3.txt": "content 3", + }, + }), + ) + .await; + + // Second worktree + fs.insert_tree( + "/root2", + json!({ + "dir3": { + "file4.txt": "content 4", + "file5.txt": "content 5", + }, + "file6.txt": "content 6", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + // Expand all directories for testing + toggle_expand_dir(&panel, "root1/dir1", cx); + toggle_expand_dir(&panel, "root1/dir2", cx); + toggle_expand_dir(&panel, "root2/dir3", cx); + + // Test Case 1: Delete files across different worktrees + cx.simulate_modifiers_change(gpui::Modifiers { + control: true, + ..Default::default() + }); + select_path_with_mark(&panel, "root1/dir1/file1.txt", cx); + select_path_with_mark(&panel, "root2/dir3/file4.txt", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root1", + " v dir1", + " file1.txt <== marked", + " file2.txt", + " v dir2", + " file3.txt", + "v root2", + " v dir3", + " file4.txt <== selected <== marked", + " file5.txt", + " file6.txt", + ], + "Initial state with files selected from different worktrees" + ); + + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root1", + " v dir1", + " file2.txt", + " v dir2", + " file3.txt", + "v root2", + " v dir3", + " file5.txt <== selected", + " file6.txt", + ], + "Should select next file in the last worktree after deletion" + ); + + // Test Case 2: Delete directories from different worktrees + select_path_with_mark(&panel, "root1/dir1", cx); + select_path_with_mark(&panel, "root2/dir3", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root1", + " v dir1 <== marked", + " file2.txt", + " v dir2", + " file3.txt", + "v root2", + " v dir3 <== selected <== marked", + " file5.txt", + " file6.txt", + ], + "State with directories marked from different worktrees" + ); + + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root1", + " v dir2", + " file3.txt", + "v root2", + " file6.txt <== selected", + ], + "Should select remaining file in last worktree after directory deletion" + ); + + // Test Case 4: Delete all remaining files except roots + select_path_with_mark(&panel, "root1/dir2/file3.txt", cx); + select_path_with_mark(&panel, "root2/file6.txt", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root1", + " v dir2", + " file3.txt <== marked", + "v root2", + " file6.txt <== selected <== marked", + ], + "State with all remaining files marked" + ); + + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &["v root1", " v dir2", "v root2 <== selected"], + "Second parent root should be selected after deleting" + ); +} + +#[gpui::test] +async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root", + json!({ + "dir1": { + "file1.txt": "", + "file2.txt": "", + "file3.txt": "", + }, + "dir2": { + "file4.txt": "", + "file5.txt": "", + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "root/dir1", cx); + toggle_expand_dir(&panel, "root/dir2", cx); + + cx.simulate_modifiers_change(gpui::Modifiers { + control: true, + ..Default::default() + }); + + select_path_with_mark(&panel, "root/dir1/file2.txt", cx); + select_path(&panel, "root/dir1/file1.txt", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v root", + " v dir1", + " file1.txt <== selected", + " file2.txt <== marked", + " file3.txt", + " v dir2", + " file4.txt", + " file5.txt", + ], + "Initial state with one marked entry and different selection" + ); + + // Delete should operate on the selected entry (file1.txt) + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v root", + " v dir1", + " file2.txt <== selected <== marked", + " file3.txt", + " v dir2", + " file4.txt", + " file5.txt", + ], + "Should delete selected file, not marked file" + ); + + select_path_with_mark(&panel, "root/dir1/file3.txt", cx); + select_path_with_mark(&panel, "root/dir2/file4.txt", cx); + select_path(&panel, "root/dir2/file5.txt", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v root", + " v dir1", + " file2.txt <== marked", + " file3.txt <== marked", + " v dir2", + " file4.txt <== marked", + " file5.txt <== selected", + ], + "Initial state with multiple marked entries and different selection" + ); + + // Delete should operate on all marked entries, ignoring the selection + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v root", + " v dir1", + " v dir2", + " file5.txt <== selected", + ], + "Should delete all marked files, leaving only the selected file" + ); +} + +#[gpui::test] +async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root_b", + json!({ + "dir1": { + "file1.txt": "content 1", + "file2.txt": "content 2", + }, + }), + ) + .await; + + fs.insert_tree( + "/root_c", + json!({ + "dir2": {}, + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "root_b/dir1", cx); + toggle_expand_dir(&panel, "root_c/dir2", cx); + + cx.simulate_modifiers_change(gpui::Modifiers { + control: true, + ..Default::default() + }); + select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx); + select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root_b", + " v dir1", + " file1.txt <== marked", + " file2.txt <== selected <== marked", + "v root_c", + " v dir2", + ], + "Initial state with files marked in root_b" + ); + + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v root_b", + " v dir1 <== selected", + "v root_c", + " v dir2", + ], + "After deletion in root_b as it's last deletion, selection should be in root_b" + ); + + select_path_with_mark(&panel, "root_c/dir2", cx); + + submit_deletion(&panel, cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &["v root_b", " v dir1", "v root_c <== selected",], + "After deleting from root_c, it should remain in root_c" + ); +} + +fn toggle_expand_dir( + panel: &Entity, + path: impl AsRef, + cx: &mut VisualTestContext, +) { + let path = path.as_ref(); + panel.update_in(cx, |panel, window, cx| { + for worktree in panel.project.read(cx).worktrees(cx).collect::>() { + let worktree = worktree.read(cx); + if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) { + let entry_id = worktree.entry_for_path(relative_path).unwrap().id; + panel.toggle_expanded(entry_id, window, cx); + return; + } + } + panic!("no worktree for path {:?}", path); + }); +} + +#[gpui::test] +async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".gitignore": "**/ignored_dir\n**/ignored_nested", + "dir1": { + "empty1": { + "empty2": { + "empty3": { + "file.txt": "" + } + } + }, + "subdir1": { + "file1.txt": "", + "file2.txt": "", + "ignored_nested": { + "ignored_file.txt": "" + } + }, + "ignored_dir": { + "subdir": { + "deep_file.txt": "" + } + } + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + // Test 1: When auto-fold is enabled + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + auto_fold_dirs: true, + ..settings + }, + cx, + ); + }); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &["v root", " > dir1", " .gitignore",], + "Initial state should show collapsed root structure" + ); + + toggle_expand_dir(&panel, "root/dir1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + separator!("v root"), + separator!(" v dir1 <== selected"), + separator!(" > empty1/empty2/empty3"), + separator!(" > ignored_dir"), + separator!(" > subdir1"), + separator!(" .gitignore"), + ], + "Should show first level with auto-folded dirs and ignored dir visible" + ); + + let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap(); + panel.update(cx, |panel, cx| { + let project = panel.project.read(cx); + let worktree = project.worktrees(cx).next().unwrap().read(cx); + panel.expand_all_for_entry(worktree.id(), entry_id, cx); + panel.update_visible_entries(None, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + separator!("v root"), + separator!(" v dir1 <== selected"), + separator!(" v empty1"), + separator!(" v empty2"), + separator!(" v empty3"), + separator!(" file.txt"), + separator!(" > ignored_dir"), + separator!(" v subdir1"), + separator!(" > ignored_nested"), + separator!(" file1.txt"), + separator!(" file2.txt"), + separator!(" .gitignore"), + ], + "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested" + ); + + // Test 2: When auto-fold is disabled + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + auto_fold_dirs: false, + ..settings + }, + cx, + ); + }); + + panel.update_in(cx, |panel, window, cx| { + panel.collapse_all_entries(&CollapseAllEntries, window, cx); + }); + + toggle_expand_dir(&panel, "root/dir1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + separator!("v root"), + separator!(" v dir1 <== selected"), + separator!(" > empty1"), + separator!(" > ignored_dir"), + separator!(" > subdir1"), + separator!(" .gitignore"), + ], + "With auto-fold disabled: should show all directories separately" + ); + + let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap(); + panel.update(cx, |panel, cx| { + let project = panel.project.read(cx); + let worktree = project.worktrees(cx).next().unwrap().read(cx); + panel.expand_all_for_entry(worktree.id(), entry_id, cx); + panel.update_visible_entries(None, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + separator!("v root"), + separator!(" v dir1 <== selected"), + separator!(" v empty1"), + separator!(" v empty2"), + separator!(" v empty3"), + separator!(" file.txt"), + separator!(" > ignored_dir"), + separator!(" v subdir1"), + separator!(" > ignored_nested"), + separator!(" file1.txt"), + separator!(" file2.txt"), + separator!(" .gitignore"), + ], + "After expand_all without auto-fold: should expand all dirs normally, \ + expand ignored_dir itself but not its subdirs, and not expand ignored_nested" + ); + + // Test 3: When explicitly called on ignored directory + let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap(); + panel.update(cx, |panel, cx| { + let project = panel.project.read(cx); + let worktree = project.worktrees(cx).next().unwrap().read(cx); + panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx); + panel.update_visible_entries(None, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + separator!("v root"), + separator!(" v dir1 <== selected"), + separator!(" v empty1"), + separator!(" v empty2"), + separator!(" v empty3"), + separator!(" file.txt"), + separator!(" v ignored_dir"), + separator!(" v subdir"), + separator!(" deep_file.txt"), + separator!(" v subdir1"), + separator!(" > ignored_nested"), + separator!(" file1.txt"), + separator!(" file2.txt"), + separator!(" .gitignore"), + ], + "After expand_all on ignored_dir: should expand all contents of the ignored directory" + ); +} + +#[gpui::test] +async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + path!("/root"), + json!({ + "dir1": { + "subdir1": { + "nested1": { + "file1.txt": "", + "file2.txt": "" + }, + }, + "subdir2": { + "file4.txt": "" + } + }, + "dir2": { + "single_file": { + "file5.txt": "" + } + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + // Test 1: Basic collapsing + { + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "root/dir1", cx); + toggle_expand_dir(&panel, "root/dir1/subdir1", cx); + toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx); + toggle_expand_dir(&panel, "root/dir1/subdir2", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + separator!("v root"), + separator!(" v dir1"), + separator!(" v subdir1"), + separator!(" v nested1"), + separator!(" file1.txt"), + separator!(" file2.txt"), + separator!(" v subdir2 <== selected"), + separator!(" file4.txt"), + separator!(" > dir2"), + ], + "Initial state with everything expanded" + ); + + let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap(); + panel.update(cx, |panel, cx| { + let project = panel.project.read(cx); + let worktree = project.worktrees(cx).next().unwrap().read(cx); + panel.collapse_all_for_entry(worktree.id(), entry_id, cx); + panel.update_visible_entries(None, cx); + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &["v root", " > dir1", " > dir2",], + "All subdirs under dir1 should be collapsed" + ); + } + + // Test 2: With auto-fold enabled + { + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + auto_fold_dirs: true, + ..settings + }, + cx, + ); + }); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "root/dir1", cx); + toggle_expand_dir(&panel, "root/dir1/subdir1", cx); + toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + separator!("v root"), + separator!(" v dir1"), + separator!(" v subdir1/nested1 <== selected"), + separator!(" file1.txt"), + separator!(" file2.txt"), + separator!(" > subdir2"), + separator!(" > dir2/single_file"), + ], + "Initial state with some dirs expanded" + ); + + let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap(); + panel.update(cx, |panel, cx| { + let project = panel.project.read(cx); + let worktree = project.worktrees(cx).next().unwrap().read(cx); + panel.collapse_all_for_entry(worktree.id(), entry_id, cx); + }); + + toggle_expand_dir(&panel, "root/dir1", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + separator!("v root"), + separator!(" v dir1 <== selected"), + separator!(" > subdir1/nested1"), + separator!(" > subdir2"), + separator!(" > dir2/single_file"), + ], + "Subdirs should be collapsed and folded with auto-fold enabled" + ); + } + + // Test 3: With auto-fold disabled + { + cx.update(|_, cx| { + let settings = *ProjectPanelSettings::get_global(cx); + ProjectPanelSettings::override_global( + ProjectPanelSettings { + auto_fold_dirs: false, + ..settings + }, + cx, + ); + }); + + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "root/dir1", cx); + toggle_expand_dir(&panel, "root/dir1/subdir1", cx); + toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + separator!("v root"), + separator!(" v dir1"), + separator!(" v subdir1"), + separator!(" v nested1 <== selected"), + separator!(" file1.txt"), + separator!(" file2.txt"), + separator!(" > subdir2"), + separator!(" > dir2"), + ], + "Initial state with some dirs expanded and auto-fold disabled" + ); + + let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap(); + panel.update(cx, |panel, cx| { + let project = panel.project.read(cx); + let worktree = project.worktrees(cx).next().unwrap().read(cx); + panel.collapse_all_for_entry(worktree.id(), entry_id, cx); + }); + + toggle_expand_dir(&panel, "root/dir1", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + separator!("v root"), + separator!(" v dir1 <== selected"), + separator!(" > subdir1"), + separator!(" > subdir2"), + separator!(" > dir2"), + ], + "Subdirs should be collapsed but not folded with auto-fold disabled" + ); + } +} + +fn select_path(panel: &Entity, path: impl AsRef, cx: &mut VisualTestContext) { + let path = path.as_ref(); + panel.update(cx, |panel, cx| { + for worktree in panel.project.read(cx).worktrees(cx).collect::>() { + let worktree = worktree.read(cx); + if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) { + let entry_id = worktree.entry_for_path(relative_path).unwrap().id; + panel.selection = Some(crate::SelectedEntry { + worktree_id: worktree.id(), + entry_id, + }); + return; + } + } + panic!("no worktree for path {:?}", path); + }); +} + +fn select_path_with_mark( + panel: &Entity, + path: impl AsRef, + cx: &mut VisualTestContext, +) { + let path = path.as_ref(); + panel.update(cx, |panel, cx| { + for worktree in panel.project.read(cx).worktrees(cx).collect::>() { + let worktree = worktree.read(cx); + if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) { + let entry_id = worktree.entry_for_path(relative_path).unwrap().id; + let entry = crate::SelectedEntry { + worktree_id: worktree.id(), + entry_id, + }; + if !panel.marked_entries.contains(&entry) { + panel.marked_entries.insert(entry); + } + panel.selection = Some(entry); + return; + } + } + panic!("no worktree for path {:?}", path); + }); +} + +fn find_project_entry( + panel: &Entity, + path: impl AsRef, + cx: &mut VisualTestContext, +) -> Option { + let path = path.as_ref(); + panel.update(cx, |panel, cx| { + for worktree in panel.project.read(cx).worktrees(cx).collect::>() { + let worktree = worktree.read(cx); + if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) { + return worktree.entry_for_path(relative_path).map(|entry| entry.id); + } + } + panic!("no worktree for path {path:?}"); + }) +} + +fn visible_entries_as_strings( + panel: &Entity, + range: Range, + cx: &mut VisualTestContext, +) -> Vec { + let mut result = Vec::new(); + let mut project_entries = HashSet::default(); + let mut has_editor = false; + + panel.update_in(cx, |panel, window, cx| { + panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| { + if details.is_editing { + assert!(!has_editor, "duplicate editor entry"); + has_editor = true; + } else { + assert!( + project_entries.insert(project_entry), + "duplicate project entry {:?} {:?}", + project_entry, + details + ); + } + + let indent = " ".repeat(details.depth); + let icon = if details.kind.is_dir() { + if details.is_expanded { + "v " + } else { + "> " + } + } else { + " " + }; + let name = if details.is_editing { + format!("[EDITOR: '{}']", details.filename) + } else if details.is_processing { + format!("[PROCESSING: '{}']", details.filename) + } else { + details.filename.clone() + }; + let selected = if details.is_selected { + " <== selected" + } else { + "" + }; + let marked = if details.is_marked { + " <== marked" + } else { + "" + }; + + result.push(format!("{indent}{icon}{name}{selected}{marked}")); + }); + }); + + result +} + +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + init_settings(cx); + theme::init(theme::LoadThemes::JustBase, cx); + language::init(cx); + editor::init_settings(cx); + crate::init(cx); + workspace::init_settings(cx); + client::init_settings(cx); + Project::init_settings(cx); + + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_panel_settings| { + project_panel_settings.auto_fold_dirs = Some(false); + }); + store.update_user_settings::(cx, |worktree_settings| { + worktree_settings.file_scan_exclusions = Some(Vec::new()); + }); + }); + }); +} + +fn init_test_with_editor(cx: &mut TestAppContext) { + cx.update(|cx| { + let app_state = AppState::test(cx); + theme::init(theme::LoadThemes::JustBase, cx); + init_settings(cx); + language::init(cx); + editor::init(cx); + crate::init(cx); + workspace::init(app_state.clone(), cx); + Project::init_settings(cx); + + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_panel_settings| { + project_panel_settings.auto_fold_dirs = Some(false); + }); + store.update_user_settings::(cx, |worktree_settings| { + worktree_settings.file_scan_exclusions = Some(Vec::new()); + }); + }); + }); +} + +fn ensure_single_file_is_opened( + window: &WindowHandle, + expected_path: &str, + cx: &mut TestAppContext, +) { + window + .update(cx, |workspace, _, cx| { + let worktrees = workspace.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + let worktree_id = worktrees[0].read(cx).id(); + + let open_project_paths = workspace + .panes() + .iter() + .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx)) + .collect::>(); + assert_eq!( + open_project_paths, + vec![ProjectPath { + worktree_id, + path: Arc::from(Path::new(expected_path)) + }], + "Should have opened file, selected in project panel" + ); + }) + .unwrap(); +} + +fn submit_deletion(panel: &Entity, cx: &mut VisualTestContext) { + assert!( + !cx.has_pending_prompt(), + "Should have no prompts before the deletion" + ); + panel.update_in(cx, |panel, window, cx| { + panel.delete(&Delete { skip_prompt: false }, window, cx) + }); + assert!( + cx.has_pending_prompt(), + "Should have a prompt after the deletion" + ); + cx.simulate_prompt_answer("Delete"); + assert!( + !cx.has_pending_prompt(), + "Should have no prompts after prompt was replied to" + ); + cx.executor().run_until_parked(); +} + +fn submit_deletion_skipping_prompt(panel: &Entity, cx: &mut VisualTestContext) { + assert!( + !cx.has_pending_prompt(), + "Should have no prompts before the deletion" + ); + panel.update_in(cx, |panel, window, cx| { + panel.delete(&Delete { skip_prompt: true }, window, cx) + }); + assert!(!cx.has_pending_prompt(), "Should have received no prompts"); + cx.executor().run_until_parked(); +} + +fn ensure_no_open_items_and_panes(workspace: &WindowHandle, cx: &mut VisualTestContext) { + assert!( + !cx.has_pending_prompt(), + "Should have no prompts after deletion operation closes the file" + ); + workspace + .read_with(cx, |workspace, cx| { + let open_project_paths = workspace + .panes() + .iter() + .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx)) + .collect::>(); + assert!( + open_project_paths.is_empty(), + "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}" + ); + }) + .unwrap(); +} + +struct TestProjectItemView { + focus_handle: FocusHandle, + path: ProjectPath, +} + +struct TestProjectItem { + path: ProjectPath, +} + +impl project::ProjectItem for TestProjectItem { + fn try_open( + _project: &Entity, + path: &ProjectPath, + cx: &mut App, + ) -> Option>>> { + let path = path.clone(); + Some(cx.spawn(async move |cx| cx.new(|_| Self { path }))) + } + + fn entry_id(&self, _: &App) -> Option { + None + } + + fn project_path(&self, _: &App) -> Option { + Some(self.path.clone()) + } + + fn is_dirty(&self) -> bool { + false + } +} + +impl ProjectItem for TestProjectItemView { + type Item = TestProjectItem; + + fn for_project_item( + _: Entity, + project_item: Entity, + _: &mut Window, + cx: &mut Context, + ) -> Self + where + Self: Sized, + { + Self { + path: project_item.update(cx, |project_item, _| project_item.path.clone()), + focus_handle: cx.focus_handle(), + } + } +} + +impl Item for TestProjectItemView { + type Event = (); +} + +impl EventEmitter<()> for TestProjectItemView {} + +impl Focusable for TestProjectItemView { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for TestProjectItemView { + fn render(&mut self, _window: &mut Window, _: &mut Context) -> impl IntoElement { + Empty + } +} diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 0d38c1b7ab..3802d08c8c 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -10,7 +10,7 @@ use node_runtime::NodeRuntime; use project::{ buffer_store::{BufferStore, BufferStoreEvent}, debugger::{breakpoint_store::BreakpointStore, dap_store::DapStore}, - git::GitStore, + git_store::GitStore, project_settings::SettingsObserver, search::SearchQuery, task_store::TaskStore, diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index bc95326e7c..e15ea20c90 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -1336,15 +1336,12 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA .collect::>(); fs.insert_branches(Path::new(path!("/code/project1/.git")), &branches); - let (worktree, _) = project + let (_worktree, _) = project .update(cx, |project, cx| { project.find_or_create_worktree(path!("/code/project1"), true, cx) }) .await .unwrap(); - - let worktree_id = cx.update(|cx| worktree.read(cx).id()); - let root_path = ProjectPath::root_path(worktree_id); // Give the worktree a bit of time to index the file system cx.run_until_parked(); @@ -1374,13 +1371,17 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA let server_branch = server_cx.update(|cx| { headless_project.update(cx, |headless_project, cx| { - headless_project - .worktree_store - .update(cx, |worktree_store, cx| { - worktree_store - .current_branch(root_path.clone(), cx) - .unwrap() - }) + headless_project.git_store.update(cx, |git_store, cx| { + git_store + .repositories() + .values() + .next() + .unwrap() + .read(cx) + .current_branch() + .unwrap() + .clone() + }) }) }); @@ -1409,11 +1410,17 @@ async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestA let server_branch = server_cx.update(|cx| { headless_project.update(cx, |headless_project, cx| { - headless_project - .worktree_store - .update(cx, |worktree_store, cx| { - worktree_store.current_branch(root_path, cx).unwrap() - }) + headless_project.git_store.update(cx, |git_store, cx| { + git_store + .repositories() + .values() + .next() + .unwrap() + .read(cx) + .current_branch() + .unwrap() + .clone() + }) }) }); diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index f71c230dfd..57dee9e273 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -513,21 +513,10 @@ impl TitleBar { } pub fn render_project_branch(&self, cx: &mut Context) -> Option { - let entry = { - let mut names_and_branches = - self.project.read(cx).visible_worktrees(cx).map(|worktree| { - let worktree = worktree.read(cx); - worktree.root_git_entry() - }); - - names_and_branches.next().flatten() - }; + let repository = self.project.read(cx).active_repository(cx)?; let workspace = self.workspace.upgrade()?; - let branch_name = entry - .as_ref() - .and_then(|entry| entry.branch()) - .map(|branch| branch.name.clone()) - .map(|branch| util::truncate_and_trailoff(&branch, MAX_BRANCH_NAME_LENGTH))?; + let branch_name = repository.read(cx).current_branch()?.name.clone(); + let branch_name = util::truncate_and_trailoff(&branch_name, MAX_BRANCH_NAME_LENGTH); Some( Button::new("project_branch_trigger", branch_name) .color(Color::Muted) diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 85e9dd1a9c..3d06d17e60 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -66,9 +66,7 @@ use std::{ }, time::{Duration, Instant}, }; -use sum_tree::{ - Bias, Cursor, Edit, KeyedItem, SeekTarget, SumTree, Summary, TreeMap, TreeSet, Unit, -}; +use sum_tree::{Bias, Edit, KeyedItem, SeekTarget, SumTree, Summary, TreeMap, TreeSet, Unit}; use text::{LineEnding, Rope}; use util::{ paths::{home_dir, PathMatcher, SanitizedPath}, @@ -197,7 +195,7 @@ pub struct RepositoryEntry { /// With this setup, this field would contain 2 entries, like so: /// - my_sub_folder_1/project_root/changed_file_1 /// - my_sub_folder_2/changed_file_2 - pub(crate) statuses_by_path: SumTree, + pub statuses_by_path: SumTree, work_directory_id: ProjectEntryId, pub work_directory: WorkDirectory, work_directory_abs_path: PathBuf, @@ -2700,6 +2698,7 @@ impl Snapshot { Some(removed_entry.path) } + #[cfg(any(test, feature = "test-support"))] pub fn status_for_file(&self, path: impl AsRef) -> Option { let path = path.as_ref(); self.repository_for_path(path).and_then(|repo| { @@ -2955,19 +2954,12 @@ impl Snapshot { self.traverse_from_offset(true, true, include_ignored, start) } - #[cfg(any(feature = "test-support", test))] - pub fn git_status(&self, work_dir: &Path) -> Option> { - self.repositories - .get(&PathKey(work_dir.into()), &()) - .map(|repo| repo.status().collect()) - } - pub fn repositories(&self) -> &SumTree { &self.repositories } /// Get the repository whose work directory corresponds to the given path. - pub(crate) fn repository(&self, work_directory: PathKey) -> Option { + fn repository(&self, work_directory: PathKey) -> Option { self.repositories.get(&work_directory, &()).cloned() } @@ -2982,13 +2974,14 @@ impl Snapshot { /// Given an ordered iterator of entries, returns an iterator of those entries, /// along with their containing git repository. + #[cfg(test)] #[track_caller] - pub fn entries_with_repositories<'a>( + fn entries_with_repositories<'a>( &'a self, entries: impl 'a + Iterator, ) -> impl 'a + Iterator)> { let mut containing_repos = Vec::<&RepositoryEntry>::new(); - let mut repositories = self.repositories().iter().peekable(); + let mut repositories = self.repositories.iter().peekable(); entries.map(move |entry| { while let Some(repository) = containing_repos.last() { if repository.directory_contains(&entry.path) { @@ -3062,22 +3055,6 @@ impl Snapshot { &self.root_name } - pub fn root_git_entry(&self) -> Option { - self.repositories - .get(&PathKey(Path::new("").into()), &()) - .map(|entry| entry.to_owned()) - } - - pub fn git_entry(&self, work_directory_path: Arc) -> Option { - self.repositories - .get(&PathKey(work_directory_path), &()) - .map(|entry| entry.to_owned()) - } - - pub fn git_entries(&self) -> impl Iterator { - self.repositories.iter() - } - pub fn scan_id(&self) -> usize { self.scan_id } @@ -4087,8 +4064,8 @@ impl TryFrom for StatusEntry { } #[derive(Clone, Debug)] -struct PathProgress<'a> { - max_path: &'a Path, +pub struct PathProgress<'a> { + pub max_path: &'a Path, } #[derive(Clone, Debug)] @@ -6036,8 +6013,8 @@ impl WorktreeModelHandle for Entity { let tree = self.clone(); let (fs, root_path, mut git_dir_scan_id) = self.update(cx, |tree, _| { let tree = tree.as_local().unwrap(); - let root_entry = tree.root_git_entry().unwrap(); - let local_repo_entry = tree.get_local_repo(&root_entry).unwrap(); + let repository = tree.repositories.first().unwrap(); + let local_repo_entry = tree.get_local_repo(&repository).unwrap(); ( tree.fs.clone(), local_repo_entry.dot_git_dir_abs_path.clone(), @@ -6046,11 +6023,11 @@ impl WorktreeModelHandle for Entity { }); let scan_id_increased = |tree: &mut Worktree, git_dir_scan_id: &mut usize| { - let root_entry = tree.root_git_entry().unwrap(); + let repository = tree.repositories.first().unwrap(); let local_repo_entry = tree .as_local() .unwrap() - .get_local_repo(&root_entry) + .get_local_repo(&repository) .unwrap(); if local_repo_entry.git_dir_scan_id > *git_dir_scan_id { @@ -6139,171 +6116,6 @@ impl Default for TraversalProgress<'_> { } } -#[derive(Debug, Clone, Copy)] -pub struct GitEntryRef<'a> { - pub entry: &'a Entry, - pub git_summary: GitSummary, -} - -impl GitEntryRef<'_> { - pub fn to_owned(&self) -> GitEntry { - GitEntry { - entry: self.entry.clone(), - git_summary: self.git_summary, - } - } -} - -impl Deref for GitEntryRef<'_> { - type Target = Entry; - - fn deref(&self) -> &Self::Target { - &self.entry - } -} - -impl AsRef for GitEntryRef<'_> { - fn as_ref(&self) -> &Entry { - self.entry - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct GitEntry { - pub entry: Entry, - pub git_summary: GitSummary, -} - -impl GitEntry { - pub fn to_ref(&self) -> GitEntryRef { - GitEntryRef { - entry: &self.entry, - git_summary: self.git_summary, - } - } -} - -impl Deref for GitEntry { - type Target = Entry; - - fn deref(&self) -> &Self::Target { - &self.entry - } -} - -impl AsRef for GitEntry { - fn as_ref(&self) -> &Entry { - &self.entry - } -} - -/// Walks the worktree entries and their associated git statuses. -pub struct GitTraversal<'a> { - traversal: Traversal<'a>, - current_entry_summary: Option, - repo_location: Option<( - &'a RepositoryEntry, - Cursor<'a, StatusEntry, PathProgress<'a>>, - )>, -} - -impl<'a> GitTraversal<'a> { - fn synchronize_statuses(&mut self, reset: bool) { - self.current_entry_summary = None; - - let Some(entry) = self.traversal.cursor.item() else { - return; - }; - - let Some(repo) = self.traversal.snapshot.repository_for_path(&entry.path) else { - self.repo_location = None; - return; - }; - - // Update our state if we changed repositories. - if reset - || self - .repo_location - .as_ref() - .map(|(prev_repo, _)| &prev_repo.work_directory) - != Some(&repo.work_directory) - { - self.repo_location = Some((repo, repo.statuses_by_path.cursor::(&()))); - } - - let Some((repo, statuses)) = &mut self.repo_location else { - return; - }; - - let repo_path = repo.relativize(&entry.path).unwrap(); - - if entry.is_dir() { - let mut statuses = statuses.clone(); - statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left, &()); - let summary = - statuses.summary(&PathTarget::Successor(repo_path.as_ref()), Bias::Left, &()); - - self.current_entry_summary = Some(summary); - } else if entry.is_file() { - // For a file entry, park the cursor on the corresponding status - if statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left, &()) { - // TODO: Investigate statuses.item() being None here. - self.current_entry_summary = statuses.item().map(|item| item.status.into()); - } else { - self.current_entry_summary = Some(GitSummary::UNCHANGED); - } - } - } - - pub fn advance(&mut self) -> bool { - self.advance_by(1) - } - - pub fn advance_by(&mut self, count: usize) -> bool { - let found = self.traversal.advance_by(count); - self.synchronize_statuses(false); - found - } - - pub fn advance_to_sibling(&mut self) -> bool { - let found = self.traversal.advance_to_sibling(); - self.synchronize_statuses(false); - found - } - - pub fn back_to_parent(&mut self) -> bool { - let found = self.traversal.back_to_parent(); - self.synchronize_statuses(true); - found - } - - pub fn start_offset(&self) -> usize { - self.traversal.start_offset() - } - - pub fn end_offset(&self) -> usize { - self.traversal.end_offset() - } - - pub fn entry(&self) -> Option> { - let entry = self.traversal.cursor.item()?; - let git_summary = self.current_entry_summary.unwrap_or(GitSummary::UNCHANGED); - Some(GitEntryRef { entry, git_summary }) - } -} - -impl<'a> Iterator for GitTraversal<'a> { - type Item = GitEntryRef<'a>; - fn next(&mut self) -> Option { - if let Some(item) = self.entry() { - self.advance(); - Some(item) - } else { - None - } - } -} - #[derive(Debug)] pub struct Traversal<'a> { snapshot: &'a Snapshot, @@ -6336,16 +6148,6 @@ impl<'a> Traversal<'a> { traversal } - pub fn with_git_statuses(self) -> GitTraversal<'a> { - let mut this = GitTraversal { - traversal: self, - current_entry_summary: None, - repo_location: None, - }; - this.synchronize_statuses(true); - this - } - pub fn advance(&mut self) -> bool { self.advance_by(1) } @@ -6391,6 +6193,10 @@ impl<'a> Traversal<'a> { self.cursor.item() } + pub fn snapshot(&self) -> &'a Snapshot { + self.snapshot + } + pub fn start_offset(&self) -> usize { self.cursor .start() @@ -6418,7 +6224,7 @@ impl<'a> Iterator for Traversal<'a> { } #[derive(Debug, Clone, Copy)] -enum PathTarget<'a> { +pub enum PathTarget<'a> { Path(&'a Path), Successor(&'a Path), } @@ -6517,20 +6323,6 @@ pub struct ChildEntriesIter<'a> { traversal: Traversal<'a>, } -impl<'a> ChildEntriesIter<'a> { - pub fn with_git_statuses(self) -> ChildEntriesGitIter<'a> { - ChildEntriesGitIter { - parent_path: self.parent_path, - traversal: self.traversal.with_git_statuses(), - } - } -} - -pub struct ChildEntriesGitIter<'a> { - parent_path: &'a Path, - traversal: GitTraversal<'a>, -} - impl<'a> Iterator for ChildEntriesIter<'a> { type Item = &'a Entry; @@ -6545,20 +6337,6 @@ impl<'a> Iterator for ChildEntriesIter<'a> { } } -impl<'a> Iterator for ChildEntriesGitIter<'a> { - type Item = GitEntryRef<'a>; - - fn next(&mut self) -> Option { - if let Some(item) = self.traversal.entry() { - if item.path.starts_with(self.parent_path) { - self.traversal.advance_to_sibling(); - return Some(item); - } - } - None - } -} - impl<'a> From<&'a Entry> for proto::Entry { fn from(entry: &'a Entry) -> Self { Self { diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index 5e0e249364..276adbdccd 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -1,15 +1,12 @@ use crate::{ - worktree_settings::WorktreeSettings, Entry, EntryKind, Event, PathChange, Snapshot, - WorkDirectory, Worktree, WorktreeModelHandle, + worktree_settings::WorktreeSettings, Entry, EntryKind, Event, PathChange, WorkDirectory, + Worktree, WorktreeModelHandle, }; use anyhow::Result; use fs::{FakeFs, Fs, RealFs, RemoveOptions}; use git::{ repository::RepoPath, - status::{ - FileStatus, GitSummary, StatusCode, TrackedStatus, TrackedSummary, UnmergedStatus, - UnmergedStatusCode, - }, + status::{FileStatus, StatusCode, TrackedStatus}, GITIGNORE, }; use git2::RepositoryInitOptions; @@ -27,7 +24,6 @@ use std::{ mem, path::{Path, PathBuf}, sync::Arc, - time::Duration, }; use util::{path, test::TempTree, ResultExt}; @@ -1472,86 +1468,6 @@ async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) { ); } -#[gpui::test] -async fn test_bump_mtime_of_git_repo_workdir(cx: &mut TestAppContext) { - init_test(cx); - - // Create a worktree with a git directory. - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/root"), - json!({ - ".git": {}, - "a.txt": "", - "b": { - "c.txt": "", - }, - }), - ) - .await; - fs.set_head_and_index_for_repo( - path!("/root/.git").as_ref(), - &[("a.txt".into(), "".into()), ("b/c.txt".into(), "".into())], - ); - cx.run_until_parked(); - - let tree = Worktree::local( - path!("/root").as_ref(), - true, - fs.clone(), - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - cx.executor().run_until_parked(); - - let (old_entry_ids, old_mtimes) = tree.read_with(cx, |tree, _| { - ( - tree.entries(true, 0).map(|e| e.id).collect::>(), - tree.entries(true, 0).map(|e| e.mtime).collect::>(), - ) - }); - - // Regression test: after the directory is scanned, touch the git repo's - // working directory, bumping its mtime. That directory keeps its project - // entry id after the directories are re-scanned. - fs.touch_path(path!("/root")).await; - cx.executor().run_until_parked(); - - let (new_entry_ids, new_mtimes) = tree.read_with(cx, |tree, _| { - ( - tree.entries(true, 0).map(|e| e.id).collect::>(), - tree.entries(true, 0).map(|e| e.mtime).collect::>(), - ) - }); - assert_eq!(new_entry_ids, old_entry_ids); - assert_ne!(new_mtimes, old_mtimes); - - // Regression test: changes to the git repository should still be - // detected. - fs.set_head_for_repo( - path!("/root/.git").as_ref(), - &[ - ("a.txt".into(), "".into()), - ("b/c.txt".into(), "something-else".into()), - ], - ); - cx.executor().run_until_parked(); - cx.executor().advance_clock(Duration::from_secs(1)); - - let snapshot = tree.read_with(cx, |tree, _| tree.snapshot()); - - check_git_statuses( - &snapshot, - &[ - (Path::new(""), MODIFIED), - (Path::new("a.txt"), GitSummary::UNCHANGED), - (Path::new("b/c.txt"), MODIFIED), - ], - ); -} - #[gpui::test] async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { init_test(cx); @@ -2196,11 +2112,6 @@ fn random_filename(rng: &mut impl Rng) -> String { .collect() } -const CONFLICT: FileStatus = FileStatus::Unmerged(UnmergedStatus { - first_head: UnmergedStatusCode::Updated, - second_head: UnmergedStatusCode::Updated, -}); - // NOTE: // This test always fails on Windows, because on Windows, unlike on Unix, you can't rename // a directory which some program has already open. @@ -2244,7 +2155,7 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) { cx.read(|cx| { let tree = tree.read(cx); - let repo = tree.repositories().iter().next().unwrap(); + let repo = tree.repositories.iter().next().unwrap(); assert_eq!( repo.work_directory, WorkDirectory::in_project("projects/project1") @@ -2268,7 +2179,7 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) { cx.read(|cx| { let tree = tree.read(cx); - let repo = tree.repositories().iter().next().unwrap(); + let repo = tree.repositories.iter().next().unwrap(); assert_eq!( repo.work_directory, WorkDirectory::in_project("projects/project2") @@ -2529,8 +2440,8 @@ async fn test_file_status(cx: &mut TestAppContext) { // Check that the right git state is observed on startup tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - assert_eq!(snapshot.repositories().iter().count(), 1); - let repo_entry = snapshot.repositories().iter().next().unwrap(); + assert_eq!(snapshot.repositories.iter().count(), 1); + let repo_entry = snapshot.repositories.iter().next().unwrap(); assert_eq!( repo_entry.work_directory, WorkDirectory::in_project("project") @@ -2705,7 +2616,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { // Check that the right git state is observed on startup tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - let repo = snapshot.repositories().iter().next().unwrap(); + let repo = snapshot.repositories.iter().next().unwrap(); let entries = repo.status().collect::>(); assert_eq!(entries.len(), 3); @@ -2727,7 +2638,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - let repository = snapshot.repositories().iter().next().unwrap(); + let repository = snapshot.repositories.iter().next().unwrap(); let entries = repository.status().collect::>(); std::assert_eq!(entries.len(), 4, "entries: {entries:?}"); @@ -2760,7 +2671,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - let repo = snapshot.repositories().iter().next().unwrap(); + let repo = snapshot.repositories.iter().next().unwrap(); let entries = repo.status().collect::>(); // Deleting an untracked entry, b.txt, should leave no status @@ -2814,7 +2725,7 @@ async fn test_git_status_postprocessing(cx: &mut TestAppContext) { tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - let repo = snapshot.repositories().iter().next().unwrap(); + let repo = snapshot.repositories.iter().next().unwrap(); let entries = repo.status().collect::>(); // `sub` doesn't appear in our computed statuses. @@ -2883,8 +2794,8 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) { // Ensure that the git status is loaded correctly tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - assert_eq!(snapshot.repositories().iter().count(), 1); - let repo = snapshot.repositories().iter().next().unwrap(); + assert_eq!(snapshot.repositories.iter().count(), 1); + let repo = snapshot.repositories.iter().next().unwrap(); assert_eq!( repo.work_directory.canonicalize(), WorkDirectory::AboveProject { @@ -2913,442 +2824,13 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) { tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - assert!(snapshot.repositories().iter().next().is_some()); + assert!(snapshot.repositories.iter().next().is_some()); assert_eq!(snapshot.status_for_file("c.txt"), None); assert_eq!(snapshot.status_for_file("d/e.txt"), None); }); } -#[gpui::test] -async fn test_traverse_with_git_status(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/root"), - json!({ - "x": { - ".git": {}, - "x1.txt": "foo", - "x2.txt": "bar", - "y": { - ".git": {}, - "y1.txt": "baz", - "y2.txt": "qux" - }, - "z.txt": "sneaky..." - }, - "z": { - ".git": {}, - "z1.txt": "quux", - "z2.txt": "quuux" - } - }), - ) - .await; - - fs.set_status_for_repo( - Path::new(path!("/root/x/.git")), - &[ - (Path::new("x2.txt"), StatusCode::Modified.index()), - (Path::new("z.txt"), StatusCode::Added.index()), - ], - ); - fs.set_status_for_repo( - Path::new(path!("/root/x/y/.git")), - &[(Path::new("y1.txt"), CONFLICT)], - ); - fs.set_status_for_repo( - Path::new(path!("/root/z/.git")), - &[(Path::new("z2.txt"), StatusCode::Added.index())], - ); - - let tree = Worktree::local( - Path::new(path!("/root")), - true, - fs.clone(), - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - - tree.flush_fs_events(cx).await; - cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) - .await; - cx.executor().run_until_parked(); - - let snapshot = tree.read_with(cx, |tree, _| tree.snapshot()); - - let mut traversal = snapshot - .traverse_from_path(true, false, true, Path::new("x")) - .with_git_statuses(); - - let entry = traversal.next().unwrap(); - assert_eq!(entry.path.as_ref(), Path::new("x/x1.txt")); - assert_eq!(entry.git_summary, GitSummary::UNCHANGED); - let entry = traversal.next().unwrap(); - assert_eq!(entry.path.as_ref(), Path::new("x/x2.txt")); - assert_eq!(entry.git_summary, MODIFIED); - let entry = traversal.next().unwrap(); - assert_eq!(entry.path.as_ref(), Path::new("x/y/y1.txt")); - assert_eq!(entry.git_summary, GitSummary::CONFLICT); - let entry = traversal.next().unwrap(); - assert_eq!(entry.path.as_ref(), Path::new("x/y/y2.txt")); - assert_eq!(entry.git_summary, GitSummary::UNCHANGED); - let entry = traversal.next().unwrap(); - assert_eq!(entry.path.as_ref(), Path::new("x/z.txt")); - assert_eq!(entry.git_summary, ADDED); - let entry = traversal.next().unwrap(); - assert_eq!(entry.path.as_ref(), Path::new("z/z1.txt")); - assert_eq!(entry.git_summary, GitSummary::UNCHANGED); - let entry = traversal.next().unwrap(); - assert_eq!(entry.path.as_ref(), Path::new("z/z2.txt")); - assert_eq!(entry.git_summary, ADDED); -} - -#[gpui::test] -async fn test_propagate_git_statuses(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/root"), - json!({ - ".git": {}, - "a": { - "b": { - "c1.txt": "", - "c2.txt": "", - }, - "d": { - "e1.txt": "", - "e2.txt": "", - "e3.txt": "", - } - }, - "f": { - "no-status.txt": "" - }, - "g": { - "h1.txt": "", - "h2.txt": "" - }, - }), - ) - .await; - - fs.set_status_for_repo( - Path::new(path!("/root/.git")), - &[ - (Path::new("a/b/c1.txt"), StatusCode::Added.index()), - (Path::new("a/d/e2.txt"), StatusCode::Modified.index()), - (Path::new("g/h2.txt"), CONFLICT), - ], - ); - - let tree = Worktree::local( - Path::new(path!("/root")), - true, - fs.clone(), - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - - cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) - .await; - - cx.executor().run_until_parked(); - let snapshot = tree.read_with(cx, |tree, _| tree.snapshot()); - - check_git_statuses( - &snapshot, - &[ - (Path::new(""), GitSummary::CONFLICT + MODIFIED + ADDED), - (Path::new("g"), GitSummary::CONFLICT), - (Path::new("g/h2.txt"), GitSummary::CONFLICT), - ], - ); - - check_git_statuses( - &snapshot, - &[ - (Path::new(""), GitSummary::CONFLICT + ADDED + MODIFIED), - (Path::new("a"), ADDED + MODIFIED), - (Path::new("a/b"), ADDED), - (Path::new("a/b/c1.txt"), ADDED), - (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED), - (Path::new("a/d"), MODIFIED), - (Path::new("a/d/e2.txt"), MODIFIED), - (Path::new("f"), GitSummary::UNCHANGED), - (Path::new("f/no-status.txt"), GitSummary::UNCHANGED), - (Path::new("g"), GitSummary::CONFLICT), - (Path::new("g/h2.txt"), GitSummary::CONFLICT), - ], - ); - - check_git_statuses( - &snapshot, - &[ - (Path::new("a/b"), ADDED), - (Path::new("a/b/c1.txt"), ADDED), - (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED), - (Path::new("a/d"), MODIFIED), - (Path::new("a/d/e1.txt"), GitSummary::UNCHANGED), - (Path::new("a/d/e2.txt"), MODIFIED), - (Path::new("f"), GitSummary::UNCHANGED), - (Path::new("f/no-status.txt"), GitSummary::UNCHANGED), - (Path::new("g"), GitSummary::CONFLICT), - ], - ); - - check_git_statuses( - &snapshot, - &[ - (Path::new("a/b/c1.txt"), ADDED), - (Path::new("a/b/c2.txt"), GitSummary::UNCHANGED), - (Path::new("a/d/e1.txt"), GitSummary::UNCHANGED), - (Path::new("a/d/e2.txt"), MODIFIED), - (Path::new("f/no-status.txt"), GitSummary::UNCHANGED), - ], - ); -} - -#[gpui::test] -async fn test_propagate_statuses_for_repos_under_project(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/root"), - json!({ - "x": { - ".git": {}, - "x1.txt": "foo", - "x2.txt": "bar" - }, - "y": { - ".git": {}, - "y1.txt": "baz", - "y2.txt": "qux" - }, - "z": { - ".git": {}, - "z1.txt": "quux", - "z2.txt": "quuux" - } - }), - ) - .await; - - fs.set_status_for_repo( - Path::new(path!("/root/x/.git")), - &[(Path::new("x1.txt"), StatusCode::Added.index())], - ); - fs.set_status_for_repo( - Path::new(path!("/root/y/.git")), - &[ - (Path::new("y1.txt"), CONFLICT), - (Path::new("y2.txt"), StatusCode::Modified.index()), - ], - ); - fs.set_status_for_repo( - Path::new(path!("/root/z/.git")), - &[(Path::new("z2.txt"), StatusCode::Modified.index())], - ); - - let tree = Worktree::local( - Path::new(path!("/root")), - true, - fs.clone(), - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - - tree.flush_fs_events(cx).await; - cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) - .await; - cx.executor().run_until_parked(); - - let snapshot = tree.read_with(cx, |tree, _| tree.snapshot()); - - check_git_statuses( - &snapshot, - &[(Path::new("x"), ADDED), (Path::new("x/x1.txt"), ADDED)], - ); - - check_git_statuses( - &snapshot, - &[ - (Path::new("y"), GitSummary::CONFLICT + MODIFIED), - (Path::new("y/y1.txt"), GitSummary::CONFLICT), - (Path::new("y/y2.txt"), MODIFIED), - ], - ); - - check_git_statuses( - &snapshot, - &[ - (Path::new("z"), MODIFIED), - (Path::new("z/z2.txt"), MODIFIED), - ], - ); - - check_git_statuses( - &snapshot, - &[(Path::new("x"), ADDED), (Path::new("x/x1.txt"), ADDED)], - ); - - check_git_statuses( - &snapshot, - &[ - (Path::new("x"), ADDED), - (Path::new("x/x1.txt"), ADDED), - (Path::new("x/x2.txt"), GitSummary::UNCHANGED), - (Path::new("y"), GitSummary::CONFLICT + MODIFIED), - (Path::new("y/y1.txt"), GitSummary::CONFLICT), - (Path::new("y/y2.txt"), MODIFIED), - (Path::new("z"), MODIFIED), - (Path::new("z/z1.txt"), GitSummary::UNCHANGED), - (Path::new("z/z2.txt"), MODIFIED), - ], - ); -} - -#[gpui::test] -async fn test_propagate_statuses_for_nested_repos(cx: &mut TestAppContext) { - init_test(cx); - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/root"), - json!({ - "x": { - ".git": {}, - "x1.txt": "foo", - "x2.txt": "bar", - "y": { - ".git": {}, - "y1.txt": "baz", - "y2.txt": "qux" - }, - "z.txt": "sneaky..." - }, - "z": { - ".git": {}, - "z1.txt": "quux", - "z2.txt": "quuux" - } - }), - ) - .await; - - fs.set_status_for_repo( - Path::new(path!("/root/x/.git")), - &[ - (Path::new("x2.txt"), StatusCode::Modified.index()), - (Path::new("z.txt"), StatusCode::Added.index()), - ], - ); - fs.set_status_for_repo( - Path::new(path!("/root/x/y/.git")), - &[(Path::new("y1.txt"), CONFLICT)], - ); - - fs.set_status_for_repo( - Path::new(path!("/root/z/.git")), - &[(Path::new("z2.txt"), StatusCode::Added.index())], - ); - - let tree = Worktree::local( - Path::new(path!("/root")), - true, - fs.clone(), - Default::default(), - &mut cx.to_async(), - ) - .await - .unwrap(); - - tree.flush_fs_events(cx).await; - cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) - .await; - cx.executor().run_until_parked(); - - let snapshot = tree.read_with(cx, |tree, _| tree.snapshot()); - - // Sanity check the propagation for x/y and z - check_git_statuses( - &snapshot, - &[ - (Path::new("x/y"), GitSummary::CONFLICT), - (Path::new("x/y/y1.txt"), GitSummary::CONFLICT), - (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED), - ], - ); - check_git_statuses( - &snapshot, - &[ - (Path::new("z"), ADDED), - (Path::new("z/z1.txt"), GitSummary::UNCHANGED), - (Path::new("z/z2.txt"), ADDED), - ], - ); - - // Test one of the fundamental cases of propagation blocking, the transition from one git repository to another - check_git_statuses( - &snapshot, - &[ - (Path::new("x"), MODIFIED + ADDED), - (Path::new("x/y"), GitSummary::CONFLICT), - (Path::new("x/y/y1.txt"), GitSummary::CONFLICT), - ], - ); - - // Sanity check everything around it - check_git_statuses( - &snapshot, - &[ - (Path::new("x"), MODIFIED + ADDED), - (Path::new("x/x1.txt"), GitSummary::UNCHANGED), - (Path::new("x/x2.txt"), MODIFIED), - (Path::new("x/y"), GitSummary::CONFLICT), - (Path::new("x/y/y1.txt"), GitSummary::CONFLICT), - (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED), - (Path::new("x/z.txt"), ADDED), - ], - ); - - // Test the other fundamental case, transitioning from git repository to non-git repository - check_git_statuses( - &snapshot, - &[ - (Path::new(""), GitSummary::UNCHANGED), - (Path::new("x"), MODIFIED + ADDED), - (Path::new("x/x1.txt"), GitSummary::UNCHANGED), - ], - ); - - // And all together now - check_git_statuses( - &snapshot, - &[ - (Path::new(""), GitSummary::UNCHANGED), - (Path::new("x"), MODIFIED + ADDED), - (Path::new("x/x1.txt"), GitSummary::UNCHANGED), - (Path::new("x/x2.txt"), MODIFIED), - (Path::new("x/y"), GitSummary::CONFLICT), - (Path::new("x/y/y1.txt"), GitSummary::CONFLICT), - (Path::new("x/y/y2.txt"), GitSummary::UNCHANGED), - (Path::new("x/z.txt"), ADDED), - (Path::new("z"), ADDED), - (Path::new("z/z1.txt"), GitSummary::UNCHANGED), - (Path::new("z/z2.txt"), ADDED), - ], - ); -} - #[gpui::test] async fn test_conflicted_cherry_pick(cx: &mut TestAppContext) { init_test(cx); @@ -3403,7 +2885,7 @@ async fn test_conflicted_cherry_pick(cx: &mut TestAppContext) { ); tree.flush_fs_events(cx).await; let conflicts = tree.update(cx, |tree, _| { - let entry = tree.git_entries().nth(0).expect("No git entry").clone(); + let entry = tree.repositories.first().expect("No git entry").clone(); entry .current_merge_conflicts .iter() @@ -3420,7 +2902,7 @@ async fn test_conflicted_cherry_pick(cx: &mut TestAppContext) { pretty_assertions::assert_eq!(git_status(&repo), collections::HashMap::default()); tree.flush_fs_events(cx).await; let conflicts = tree.update(cx, |tree, _| { - let entry = tree.git_entries().nth(0).expect("No git entry").clone(); + let entry = tree.repositories.first().expect("No git entry").clone(); entry .current_merge_conflicts .iter() @@ -3490,34 +2972,6 @@ fn test_unrelativize() { ); } -#[track_caller] -fn check_git_statuses(snapshot: &Snapshot, expected_statuses: &[(&Path, GitSummary)]) { - let mut traversal = snapshot - .traverse_from_path(true, true, false, "".as_ref()) - .with_git_statuses(); - let found_statuses = expected_statuses - .iter() - .map(|&(path, _)| { - let git_entry = traversal - .find(|git_entry| &*git_entry.path == path) - .unwrap_or_else(|| panic!("Traversal has no entry for {path:?}")); - (path, git_entry.git_summary) - }) - .collect::>(); - assert_eq!(found_statuses, expected_statuses); -} - -const ADDED: GitSummary = GitSummary { - index: TrackedSummary::ADDED, - count: 1, - ..GitSummary::UNCHANGED -}; -const MODIFIED: GitSummary = GitSummary { - index: TrackedSummary::MODIFIED, - count: 1, - ..GitSummary::UNCHANGED -}; - #[track_caller] fn git_init(path: &Path) -> git2::Repository { let mut init_opts = RepositoryInitOptions::new();