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();