1mod project_panel_settings;
2mod utils;
3
4use anyhow::{anyhow, Context as _, Result};
5use client::{ErrorCode, ErrorExt};
6use collections::{hash_map, BTreeSet, HashMap};
7use command_palette_hooks::CommandPaletteFilter;
8use db::kvp::KEY_VALUE_STORE;
9use editor::{
10 items::{
11 entry_diagnostic_aware_icon_decoration_and_color,
12 entry_diagnostic_aware_icon_name_and_color, entry_git_aware_label_color,
13 },
14 scroll::{Autoscroll, ScrollbarAutoHide},
15 Editor, EditorEvent, EditorSettings, ShowScrollbar,
16};
17use file_icons::FileIcons;
18use git::status::GitSummary;
19use gpui::{
20 actions, anchored, deferred, div, impl_actions, point, px, size, uniform_list, Action,
21 AnyElement, App, AssetSource, AsyncWindowContext, Bounds, ClipboardItem, Context, DismissEvent,
22 Div, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, Hsla,
23 InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton,
24 MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, ScrollStrategy, Stateful,
25 Styled, Subscription, Task, UniformListScrollHandle, WeakEntity, Window,
26};
27use indexmap::IndexMap;
28use language::DiagnosticSeverity;
29use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
30use project::{
31 relativize_path, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree,
32 WorktreeId,
33};
34use project_panel_settings::{
35 ProjectPanelDockPosition, ProjectPanelSettings, ShowDiagnostics, ShowIndentGuides,
36};
37use schemars::JsonSchema;
38use serde::{Deserialize, Serialize};
39use settings::{Settings, SettingsStore};
40use smallvec::SmallVec;
41use std::any::TypeId;
42use std::{
43 cell::OnceCell,
44 cmp,
45 collections::HashSet,
46 ffi::OsStr,
47 ops::Range,
48 path::{Path, PathBuf},
49 sync::Arc,
50 time::Duration,
51};
52use theme::ThemeSettings;
53use ui::{
54 prelude::*, v_flex, ContextMenu, DecoratedIcon, Icon, IconDecoration, IconDecorationKind,
55 IndentGuideColors, IndentGuideLayout, KeyBinding, Label, ListItem, ListItemSpacing, Scrollbar,
56 ScrollbarState, Tooltip,
57};
58use util::{maybe, paths::compare_paths, ResultExt, TakeUntilExt, TryFutureExt};
59use workspace::{
60 dock::{DockPosition, Panel, PanelEvent},
61 notifications::{DetachAndPromptErr, NotifyTaskExt},
62 DraggedSelection, OpenInTerminal, PreviewTabsSettings, SelectedEntry, Workspace,
63};
64use worktree::{CreatedEntry, GitEntry, GitEntryRef};
65
66const PROJECT_PANEL_KEY: &str = "ProjectPanel";
67const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
68
69pub struct ProjectPanel {
70 project: Entity<Project>,
71 fs: Arc<dyn Fs>,
72 focus_handle: FocusHandle,
73 scroll_handle: UniformListScrollHandle,
74 // An update loop that keeps incrementing/decrementing scroll offset while there is a dragged entry that's
75 // hovered over the start/end of a list.
76 hover_scroll_task: Option<Task<()>>,
77 visible_entries: Vec<(WorktreeId, Vec<GitEntry>, OnceCell<HashSet<Arc<Path>>>)>,
78 /// Maps from leaf project entry ID to the currently selected ancestor.
79 /// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several
80 /// project entries (and all non-leaf nodes are guaranteed to be directories).
81 ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
82 folded_directory_drag_target: Option<FoldedDirectoryDragTarget>,
83 last_worktree_root_id: Option<ProjectEntryId>,
84 last_selection_drag_over_entry: Option<ProjectEntryId>,
85 last_external_paths_drag_over_entry: Option<ProjectEntryId>,
86 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
87 unfolded_dir_ids: HashSet<ProjectEntryId>,
88 // Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
89 selection: Option<SelectedEntry>,
90 marked_entries: BTreeSet<SelectedEntry>,
91 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
92 edit_state: Option<EditState>,
93 filename_editor: Entity<Editor>,
94 clipboard: Option<ClipboardEntry>,
95 _dragged_entry_destination: Option<Arc<Path>>,
96 workspace: WeakEntity<Workspace>,
97 width: Option<Pixels>,
98 pending_serialization: Task<Option<()>>,
99 show_scrollbar: bool,
100 vertical_scrollbar_state: ScrollbarState,
101 horizontal_scrollbar_state: ScrollbarState,
102 hide_scrollbar_task: Option<Task<()>>,
103 diagnostics: HashMap<(WorktreeId, PathBuf), DiagnosticSeverity>,
104 max_width_item_index: Option<usize>,
105 // We keep track of the mouse down state on entries so we don't flash the UI
106 // in case a user clicks to open a file.
107 mouse_down: bool,
108 hover_expand_task: Option<Task<()>>,
109}
110
111#[derive(Copy, Clone, Debug)]
112struct FoldedDirectoryDragTarget {
113 entry_id: ProjectEntryId,
114 index: usize,
115 /// Whether we are dragging over the delimiter rather than the component itself.
116 is_delimiter_target: bool,
117}
118
119#[derive(Clone, Debug)]
120struct EditState {
121 worktree_id: WorktreeId,
122 entry_id: ProjectEntryId,
123 leaf_entry_id: Option<ProjectEntryId>,
124 is_dir: bool,
125 depth: usize,
126 processing_filename: Option<String>,
127 previously_focused: Option<SelectedEntry>,
128}
129
130impl EditState {
131 fn is_new_entry(&self) -> bool {
132 self.leaf_entry_id.is_none()
133 }
134}
135
136#[derive(Clone, Debug)]
137enum ClipboardEntry {
138 Copied(BTreeSet<SelectedEntry>),
139 Cut(BTreeSet<SelectedEntry>),
140}
141
142#[derive(Debug, PartialEq, Eq, Clone)]
143struct EntryDetails {
144 filename: String,
145 icon: Option<SharedString>,
146 path: Arc<Path>,
147 depth: usize,
148 kind: EntryKind,
149 is_ignored: bool,
150 is_expanded: bool,
151 is_selected: bool,
152 is_marked: bool,
153 is_editing: bool,
154 is_processing: bool,
155 is_cut: bool,
156 filename_text_color: Color,
157 diagnostic_severity: Option<DiagnosticSeverity>,
158 git_status: GitSummary,
159 is_private: bool,
160 worktree_id: WorktreeId,
161 canonical_path: Option<Box<Path>>,
162}
163
164#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)]
165#[serde(deny_unknown_fields)]
166struct Delete {
167 #[serde(default)]
168 pub skip_prompt: bool,
169}
170
171#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)]
172#[serde(deny_unknown_fields)]
173struct Trash {
174 #[serde(default)]
175 pub skip_prompt: bool,
176}
177
178impl_actions!(project_panel, [Delete, Trash]);
179
180actions!(
181 project_panel,
182 [
183 ExpandSelectedEntry,
184 CollapseSelectedEntry,
185 CollapseAllEntries,
186 NewDirectory,
187 NewFile,
188 Copy,
189 CopyPath,
190 CopyRelativePath,
191 Duplicate,
192 RevealInFileManager,
193 RemoveFromProject,
194 OpenWithSystem,
195 Cut,
196 Paste,
197 Rename,
198 Open,
199 OpenPermanent,
200 ToggleFocus,
201 NewSearchInDirectory,
202 UnfoldDirectory,
203 FoldDirectory,
204 SelectParent,
205 SelectNextGitEntry,
206 SelectPrevGitEntry,
207 SelectNextDiagnostic,
208 SelectPrevDiagnostic,
209 SelectNextDirectory,
210 SelectPrevDirectory,
211 ]
212);
213
214#[derive(Debug, Default)]
215struct FoldedAncestors {
216 current_ancestor_depth: usize,
217 ancestors: Vec<ProjectEntryId>,
218}
219
220impl FoldedAncestors {
221 fn max_ancestor_depth(&self) -> usize {
222 self.ancestors.len()
223 }
224}
225
226pub fn init_settings(cx: &mut App) {
227 ProjectPanelSettings::register(cx);
228}
229
230pub fn init(assets: impl AssetSource, cx: &mut App) {
231 init_settings(cx);
232 file_icons::init(assets, cx);
233
234 cx.observe_new(|workspace: &mut Workspace, _, _| {
235 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
236 workspace.toggle_panel_focus::<ProjectPanel>(window, cx);
237 });
238 })
239 .detach();
240}
241
242#[derive(Debug)]
243pub enum Event {
244 OpenedEntry {
245 entry_id: ProjectEntryId,
246 focus_opened_item: bool,
247 allow_preview: bool,
248 },
249 SplitEntry {
250 entry_id: ProjectEntryId,
251 },
252 Focus,
253}
254
255#[derive(Serialize, Deserialize)]
256struct SerializedProjectPanel {
257 width: Option<Pixels>,
258}
259
260struct DraggedProjectEntryView {
261 selection: SelectedEntry,
262 details: EntryDetails,
263 click_offset: Point<Pixels>,
264 selections: Arc<BTreeSet<SelectedEntry>>,
265}
266
267struct ItemColors {
268 default: Hsla,
269 hover: Hsla,
270 drag_over: Hsla,
271 marked_active: Hsla,
272 focused: Hsla,
273}
274
275fn get_item_color(cx: &App) -> ItemColors {
276 let colors = cx.theme().colors();
277
278 ItemColors {
279 default: colors.panel_background,
280 hover: colors.ghost_element_hover,
281 drag_over: colors.drop_target_background,
282 marked_active: colors.element_selected,
283 focused: colors.panel_focused_border,
284 }
285}
286
287impl ProjectPanel {
288 fn new(
289 workspace: &mut Workspace,
290 window: &mut Window,
291 cx: &mut Context<Workspace>,
292 ) -> Entity<Self> {
293 let project = workspace.project().clone();
294 let project_panel = cx.new(|cx| {
295 let focus_handle = cx.focus_handle();
296 cx.on_focus(&focus_handle, window, Self::focus_in).detach();
297 cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
298 this.focus_out(window, cx);
299 this.hide_scrollbar(window, cx);
300 })
301 .detach();
302 cx.subscribe(&project, |this, project, event, cx| match event {
303 project::Event::ActiveEntryChanged(Some(entry_id)) => {
304 if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
305 this.reveal_entry(project.clone(), *entry_id, true, cx);
306 }
307 }
308 project::Event::RevealInProjectPanel(entry_id) => {
309 this.reveal_entry(project.clone(), *entry_id, false, cx);
310 cx.emit(PanelEvent::Activate);
311 }
312 project::Event::ActivateProjectPanel => {
313 cx.emit(PanelEvent::Activate);
314 }
315 project::Event::DiskBasedDiagnosticsFinished { .. }
316 | project::Event::DiagnosticsUpdated { .. } => {
317 if ProjectPanelSettings::get_global(cx).show_diagnostics != ShowDiagnostics::Off
318 {
319 this.update_diagnostics(cx);
320 cx.notify();
321 }
322 }
323 project::Event::WorktreeRemoved(id) => {
324 this.expanded_dir_ids.remove(id);
325 this.update_visible_entries(None, cx);
326 cx.notify();
327 }
328 project::Event::WorktreeUpdatedGitRepositories(_)
329 | project::Event::WorktreeUpdatedEntries(_, _)
330 | project::Event::WorktreeAdded(_)
331 | project::Event::WorktreeOrderChanged => {
332 this.update_visible_entries(None, cx);
333 cx.notify();
334 }
335 project::Event::ExpandedAllForEntry(worktree_id, entry_id) => {
336 if let Some((worktree, expanded_dir_ids)) = project
337 .read(cx)
338 .worktree_for_id(*worktree_id, cx)
339 .zip(this.expanded_dir_ids.get_mut(&worktree_id))
340 {
341 let worktree = worktree.read(cx);
342
343 let Some(entry) = worktree.entry_for_id(*entry_id) else {
344 return;
345 };
346 let include_ignored_dirs = !entry.is_ignored;
347
348 let mut dirs_to_expand = vec![*entry_id];
349 while let Some(current_id) = dirs_to_expand.pop() {
350 let Some(current_entry) = worktree.entry_for_id(current_id) else {
351 continue;
352 };
353 for child in worktree.child_entries(¤t_entry.path) {
354 if !child.is_dir() || (include_ignored_dirs && child.is_ignored) {
355 continue;
356 }
357
358 dirs_to_expand.push(child.id);
359
360 if let Err(ix) = expanded_dir_ids.binary_search(&child.id) {
361 expanded_dir_ids.insert(ix, child.id);
362 }
363 this.unfolded_dir_ids.insert(child.id);
364 }
365 }
366 this.update_visible_entries(None, cx);
367 cx.notify();
368 }
369 }
370 _ => {}
371 })
372 .detach();
373
374 let trash_action = [TypeId::of::<Trash>()];
375 let is_remote = project.read(cx).is_via_collab();
376
377 if is_remote {
378 CommandPaletteFilter::update_global(cx, |filter, _cx| {
379 filter.hide_action_types(&trash_action);
380 });
381 }
382
383 let filename_editor = cx.new(|cx| Editor::single_line(window, cx));
384
385 cx.subscribe(
386 &filename_editor,
387 |project_panel, _, editor_event, cx| match editor_event {
388 EditorEvent::BufferEdited | EditorEvent::SelectionsChanged { .. } => {
389 project_panel.autoscroll(cx);
390 }
391 EditorEvent::Blurred => {
392 if project_panel
393 .edit_state
394 .as_ref()
395 .map_or(false, |state| state.processing_filename.is_none())
396 {
397 project_panel.edit_state = None;
398 project_panel.update_visible_entries(None, cx);
399 cx.notify();
400 }
401 }
402 _ => {}
403 },
404 )
405 .detach();
406
407 cx.observe_global::<FileIcons>(|_, cx| {
408 cx.notify();
409 })
410 .detach();
411
412 let mut project_panel_settings = *ProjectPanelSettings::get_global(cx);
413 cx.observe_global::<SettingsStore>(move |this, cx| {
414 let new_settings = *ProjectPanelSettings::get_global(cx);
415 if project_panel_settings != new_settings {
416 project_panel_settings = new_settings;
417 this.update_diagnostics(cx);
418 cx.notify();
419 }
420 })
421 .detach();
422
423 let scroll_handle = UniformListScrollHandle::new();
424 let mut this = Self {
425 project: project.clone(),
426 hover_scroll_task: None,
427 fs: workspace.app_state().fs.clone(),
428 focus_handle,
429 visible_entries: Default::default(),
430 ancestors: Default::default(),
431 folded_directory_drag_target: None,
432 last_worktree_root_id: Default::default(),
433 last_external_paths_drag_over_entry: None,
434 last_selection_drag_over_entry: None,
435 expanded_dir_ids: Default::default(),
436 unfolded_dir_ids: Default::default(),
437 selection: None,
438 marked_entries: Default::default(),
439 edit_state: None,
440 context_menu: None,
441 filename_editor,
442 clipboard: None,
443 _dragged_entry_destination: None,
444 workspace: workspace.weak_handle(),
445 width: None,
446 pending_serialization: Task::ready(None),
447 show_scrollbar: !Self::should_autohide_scrollbar(cx),
448 hide_scrollbar_task: None,
449 vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
450 .parent_entity(&cx.entity()),
451 horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
452 .parent_entity(&cx.entity()),
453 max_width_item_index: None,
454 diagnostics: Default::default(),
455 scroll_handle,
456 mouse_down: false,
457 hover_expand_task: None,
458 };
459 this.update_visible_entries(None, cx);
460
461 this
462 });
463
464 cx.subscribe_in(&project_panel, window, {
465 let project_panel = project_panel.downgrade();
466 move |workspace, _, event, window, cx| match event {
467 &Event::OpenedEntry {
468 entry_id,
469 focus_opened_item,
470 allow_preview,
471 } => {
472 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
473 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
474 let file_path = entry.path.clone();
475 let worktree_id = worktree.read(cx).id();
476 let entry_id = entry.id;
477 let is_via_ssh = project.read(cx).is_via_ssh();
478
479 workspace
480 .open_path_preview(
481 ProjectPath {
482 worktree_id,
483 path: file_path.clone(),
484 },
485 None,
486 focus_opened_item,
487 allow_preview,
488 window, cx,
489 )
490 .detach_and_prompt_err("Failed to open file", window, cx, move |e, _, _| {
491 match e.error_code() {
492 ErrorCode::Disconnected => if is_via_ssh {
493 Some("Disconnected from SSH host".to_string())
494 } else {
495 Some("Disconnected from remote project".to_string())
496 },
497 ErrorCode::UnsharedItem => Some(format!(
498 "{} is not shared by the host. This could be because it has been marked as `private`",
499 file_path.display()
500 )),
501 _ => None,
502 }
503 });
504
505 if let Some(project_panel) = project_panel.upgrade() {
506 // Always select and mark the entry, regardless of whether it is opened or not.
507 project_panel.update(cx, |project_panel, _| {
508 let entry = SelectedEntry { worktree_id, entry_id };
509 project_panel.marked_entries.clear();
510 project_panel.marked_entries.insert(entry);
511 project_panel.selection = Some(entry);
512 });
513 if !focus_opened_item {
514 let focus_handle = project_panel.read(cx).focus_handle.clone();
515 window.focus(&focus_handle);
516 }
517 }
518 }
519 }
520 }
521 &Event::SplitEntry { entry_id } => {
522 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
523 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
524 workspace
525 .split_path(
526 ProjectPath {
527 worktree_id: worktree.read(cx).id(),
528 path: entry.path.clone(),
529 },
530 window, cx,
531 )
532 .detach_and_log_err(cx);
533 }
534 }
535 }
536
537 _ => {}
538 }
539 })
540 .detach();
541
542 project_panel
543 }
544
545 pub async fn load(
546 workspace: WeakEntity<Workspace>,
547 mut cx: AsyncWindowContext,
548 ) -> Result<Entity<Self>> {
549 let serialized_panel = cx
550 .background_executor()
551 .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
552 .await
553 .map_err(|e| anyhow!("Failed to load project panel: {}", e))
554 .log_err()
555 .flatten()
556 .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
557 .transpose()
558 .log_err()
559 .flatten();
560
561 workspace.update_in(&mut cx, |workspace, window, cx| {
562 let panel = ProjectPanel::new(workspace, window, cx);
563 if let Some(serialized_panel) = serialized_panel {
564 panel.update(cx, |panel, cx| {
565 panel.width = serialized_panel.width.map(|px| px.round());
566 cx.notify();
567 });
568 }
569 panel
570 })
571 }
572
573 fn update_diagnostics(&mut self, cx: &mut Context<Self>) {
574 let mut diagnostics: HashMap<(WorktreeId, PathBuf), DiagnosticSeverity> =
575 Default::default();
576 let show_diagnostics_setting = ProjectPanelSettings::get_global(cx).show_diagnostics;
577
578 if show_diagnostics_setting != ShowDiagnostics::Off {
579 self.project
580 .read(cx)
581 .diagnostic_summaries(false, cx)
582 .filter_map(|(path, _, diagnostic_summary)| {
583 if diagnostic_summary.error_count > 0 {
584 Some((path, DiagnosticSeverity::ERROR))
585 } else if show_diagnostics_setting == ShowDiagnostics::All
586 && diagnostic_summary.warning_count > 0
587 {
588 Some((path, DiagnosticSeverity::WARNING))
589 } else {
590 None
591 }
592 })
593 .for_each(|(project_path, diagnostic_severity)| {
594 let mut path_buffer = PathBuf::new();
595 Self::update_strongest_diagnostic_severity(
596 &mut diagnostics,
597 &project_path,
598 path_buffer.clone(),
599 diagnostic_severity,
600 );
601
602 for component in project_path.path.components() {
603 path_buffer.push(component);
604 Self::update_strongest_diagnostic_severity(
605 &mut diagnostics,
606 &project_path,
607 path_buffer.clone(),
608 diagnostic_severity,
609 );
610 }
611 });
612 }
613 self.diagnostics = diagnostics;
614 }
615
616 fn update_strongest_diagnostic_severity(
617 diagnostics: &mut HashMap<(WorktreeId, PathBuf), DiagnosticSeverity>,
618 project_path: &ProjectPath,
619 path_buffer: PathBuf,
620 diagnostic_severity: DiagnosticSeverity,
621 ) {
622 diagnostics
623 .entry((project_path.worktree_id, path_buffer.clone()))
624 .and_modify(|strongest_diagnostic_severity| {
625 *strongest_diagnostic_severity =
626 cmp::min(*strongest_diagnostic_severity, diagnostic_severity);
627 })
628 .or_insert(diagnostic_severity);
629 }
630
631 fn serialize(&mut self, cx: &mut Context<Self>) {
632 let width = self.width;
633 self.pending_serialization = cx.background_executor().spawn(
634 async move {
635 KEY_VALUE_STORE
636 .write_kvp(
637 PROJECT_PANEL_KEY.into(),
638 serde_json::to_string(&SerializedProjectPanel { width })?,
639 )
640 .await?;
641 anyhow::Ok(())
642 }
643 .log_err(),
644 );
645 }
646
647 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
648 if !self.focus_handle.contains_focused(window, cx) {
649 cx.emit(Event::Focus);
650 }
651 }
652
653 fn focus_out(&mut self, window: &mut Window, cx: &mut Context<Self>) {
654 if !self.focus_handle.is_focused(window) {
655 self.confirm(&Confirm, window, cx);
656 }
657 }
658
659 fn deploy_context_menu(
660 &mut self,
661 position: Point<Pixels>,
662 entry_id: ProjectEntryId,
663 window: &mut Window,
664 cx: &mut Context<Self>,
665 ) {
666 let project = self.project.read(cx);
667
668 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
669 id
670 } else {
671 return;
672 };
673
674 self.selection = Some(SelectedEntry {
675 worktree_id,
676 entry_id,
677 });
678
679 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
680 let auto_fold_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
681 let worktree = worktree.read(cx);
682 let is_root = Some(entry) == worktree.root_entry();
683 let is_dir = entry.is_dir();
684 let is_foldable = auto_fold_dirs && self.is_foldable(entry, worktree);
685 let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree);
686 let is_read_only = project.is_read_only(cx);
687 let is_remote = project.is_via_collab();
688 let is_local = project.is_local();
689
690 let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
691 menu.context(self.focus_handle.clone()).map(|menu| {
692 if is_read_only {
693 menu.when(is_dir, |menu| {
694 menu.action("Search Inside", Box::new(NewSearchInDirectory))
695 })
696 } else {
697 menu.action("New File", Box::new(NewFile))
698 .action("New Folder", Box::new(NewDirectory))
699 .separator()
700 .when(is_local && cfg!(target_os = "macos"), |menu| {
701 menu.action("Reveal in Finder", Box::new(RevealInFileManager))
702 })
703 .when(is_local && cfg!(not(target_os = "macos")), |menu| {
704 menu.action("Reveal in File Manager", Box::new(RevealInFileManager))
705 })
706 .when(is_local, |menu| {
707 menu.action("Open in Default App", Box::new(OpenWithSystem))
708 })
709 .action("Open in Terminal", Box::new(OpenInTerminal))
710 .when(is_dir, |menu| {
711 menu.separator()
712 .action("Find in Folder…", Box::new(NewSearchInDirectory))
713 })
714 .when(is_unfoldable, |menu| {
715 menu.action("Unfold Directory", Box::new(UnfoldDirectory))
716 })
717 .when(is_foldable, |menu| {
718 menu.action("Fold Directory", Box::new(FoldDirectory))
719 })
720 .separator()
721 .action("Cut", Box::new(Cut))
722 .action("Copy", Box::new(Copy))
723 .action("Duplicate", Box::new(Duplicate))
724 // TODO: Paste should always be visible, cbut disabled when clipboard is empty
725 .map(|menu| {
726 if self.clipboard.as_ref().is_some() {
727 menu.action("Paste", Box::new(Paste))
728 } else {
729 menu.disabled_action("Paste", Box::new(Paste))
730 }
731 })
732 .separator()
733 .action("Copy Path", Box::new(CopyPath))
734 .action("Copy Relative Path", Box::new(CopyRelativePath))
735 .separator()
736 .action("Rename", Box::new(Rename))
737 .when(!is_root & !is_remote, |menu| {
738 menu.action("Trash", Box::new(Trash { skip_prompt: false }))
739 })
740 .when(!is_root, |menu| {
741 menu.action("Delete", Box::new(Delete { skip_prompt: false }))
742 })
743 .when(!is_remote & is_root, |menu| {
744 menu.separator()
745 .action(
746 "Add Folder to Project…",
747 Box::new(workspace::AddFolderToProject),
748 )
749 .action("Remove from Project", Box::new(RemoveFromProject))
750 })
751 .when(is_root, |menu| {
752 menu.separator()
753 .action("Collapse All", Box::new(CollapseAllEntries))
754 })
755 }
756 })
757 });
758
759 window.focus(&context_menu.focus_handle(cx));
760 let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
761 this.context_menu.take();
762 cx.notify();
763 });
764 self.context_menu = Some((context_menu, position, subscription));
765 }
766
767 cx.notify();
768 }
769
770 fn is_unfoldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
771 if !entry.is_dir() || self.unfolded_dir_ids.contains(&entry.id) {
772 return false;
773 }
774
775 if let Some(parent_path) = entry.path.parent() {
776 let snapshot = worktree.snapshot();
777 let mut child_entries = snapshot.child_entries(parent_path);
778 if let Some(child) = child_entries.next() {
779 if child_entries.next().is_none() {
780 return child.kind.is_dir();
781 }
782 }
783 };
784 false
785 }
786
787 fn is_foldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
788 if entry.is_dir() {
789 let snapshot = worktree.snapshot();
790
791 let mut child_entries = snapshot.child_entries(&entry.path);
792 if let Some(child) = child_entries.next() {
793 if child_entries.next().is_none() {
794 return child.kind.is_dir();
795 }
796 }
797 }
798 false
799 }
800
801 fn expand_selected_entry(
802 &mut self,
803 _: &ExpandSelectedEntry,
804 window: &mut Window,
805 cx: &mut Context<Self>,
806 ) {
807 if let Some((worktree, entry)) = self.selected_entry(cx) {
808 if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
809 if folded_ancestors.current_ancestor_depth > 0 {
810 folded_ancestors.current_ancestor_depth -= 1;
811 cx.notify();
812 return;
813 }
814 }
815 if entry.is_dir() {
816 let worktree_id = worktree.id();
817 let entry_id = entry.id;
818 let expanded_dir_ids =
819 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
820 expanded_dir_ids
821 } else {
822 return;
823 };
824
825 match expanded_dir_ids.binary_search(&entry_id) {
826 Ok(_) => self.select_next(&SelectNext, window, cx),
827 Err(ix) => {
828 self.project.update(cx, |project, cx| {
829 project.expand_entry(worktree_id, entry_id, cx);
830 });
831
832 expanded_dir_ids.insert(ix, entry_id);
833 self.update_visible_entries(None, cx);
834 cx.notify();
835 }
836 }
837 }
838 }
839 }
840
841 fn collapse_selected_entry(
842 &mut self,
843 _: &CollapseSelectedEntry,
844 _: &mut Window,
845 cx: &mut Context<Self>,
846 ) {
847 let Some((worktree, entry)) = self.selected_entry_handle(cx) else {
848 return;
849 };
850 self.collapse_entry(entry.clone(), worktree, cx)
851 }
852
853 fn collapse_entry(&mut self, entry: Entry, worktree: Entity<Worktree>, cx: &mut Context<Self>) {
854 let worktree = worktree.read(cx);
855 if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
856 if folded_ancestors.current_ancestor_depth + 1 < folded_ancestors.max_ancestor_depth() {
857 folded_ancestors.current_ancestor_depth += 1;
858 cx.notify();
859 return;
860 }
861 }
862 let worktree_id = worktree.id();
863 let expanded_dir_ids =
864 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
865 expanded_dir_ids
866 } else {
867 return;
868 };
869
870 let mut entry = &entry;
871 loop {
872 let entry_id = entry.id;
873 match expanded_dir_ids.binary_search(&entry_id) {
874 Ok(ix) => {
875 expanded_dir_ids.remove(ix);
876 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
877 cx.notify();
878 break;
879 }
880 Err(_) => {
881 if let Some(parent_entry) =
882 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
883 {
884 entry = parent_entry;
885 } else {
886 break;
887 }
888 }
889 }
890 }
891 }
892
893 pub fn collapse_all_entries(
894 &mut self,
895 _: &CollapseAllEntries,
896 _: &mut Window,
897 cx: &mut Context<Self>,
898 ) {
899 // By keeping entries for fully collapsed worktrees, we avoid expanding them within update_visible_entries
900 // (which is it's default behavior when there's no entry for a worktree in expanded_dir_ids).
901 self.expanded_dir_ids
902 .retain(|_, expanded_entries| expanded_entries.is_empty());
903 self.update_visible_entries(None, cx);
904 cx.notify();
905 }
906
907 fn toggle_expanded(
908 &mut self,
909 entry_id: ProjectEntryId,
910 window: &mut Window,
911 cx: &mut Context<Self>,
912 ) {
913 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
914 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
915 self.project.update(cx, |project, cx| {
916 match expanded_dir_ids.binary_search(&entry_id) {
917 Ok(ix) => {
918 expanded_dir_ids.remove(ix);
919 }
920 Err(ix) => {
921 project.expand_entry(worktree_id, entry_id, cx);
922 expanded_dir_ids.insert(ix, entry_id);
923 }
924 }
925 });
926 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
927 window.focus(&self.focus_handle);
928 cx.notify();
929 }
930 }
931 }
932
933 fn toggle_expand_all(
934 &mut self,
935 entry_id: ProjectEntryId,
936 window: &mut Window,
937 cx: &mut Context<Self>,
938 ) {
939 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
940 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
941 match expanded_dir_ids.binary_search(&entry_id) {
942 Ok(_ix) => {
943 self.collapse_all_for_entry(worktree_id, entry_id, cx);
944 }
945 Err(_ix) => {
946 self.expand_all_for_entry(worktree_id, entry_id, cx);
947 }
948 }
949 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
950 window.focus(&self.focus_handle);
951 cx.notify();
952 }
953 }
954 }
955
956 fn expand_all_for_entry(
957 &mut self,
958 worktree_id: WorktreeId,
959 entry_id: ProjectEntryId,
960 cx: &mut Context<Self>,
961 ) {
962 self.project.update(cx, |project, cx| {
963 if let Some((worktree, expanded_dir_ids)) = project
964 .worktree_for_id(worktree_id, cx)
965 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
966 {
967 if let Some(task) = project.expand_all_for_entry(worktree_id, entry_id, cx) {
968 task.detach();
969 }
970
971 let worktree = worktree.read(cx);
972
973 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
974 loop {
975 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
976 expanded_dir_ids.insert(ix, entry.id);
977 }
978
979 if let Some(parent_entry) =
980 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
981 {
982 entry = parent_entry;
983 } else {
984 break;
985 }
986 }
987 }
988 }
989 });
990 }
991
992 fn collapse_all_for_entry(
993 &mut self,
994 worktree_id: WorktreeId,
995 entry_id: ProjectEntryId,
996 cx: &mut Context<Self>,
997 ) {
998 self.project.update(cx, |project, cx| {
999 if let Some((worktree, expanded_dir_ids)) = project
1000 .worktree_for_id(worktree_id, cx)
1001 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1002 {
1003 let worktree = worktree.read(cx);
1004 let mut dirs_to_collapse = vec![entry_id];
1005 let auto_fold_enabled = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
1006 while let Some(current_id) = dirs_to_collapse.pop() {
1007 let Some(current_entry) = worktree.entry_for_id(current_id) else {
1008 continue;
1009 };
1010 if let Ok(ix) = expanded_dir_ids.binary_search(¤t_id) {
1011 expanded_dir_ids.remove(ix);
1012 }
1013 if auto_fold_enabled {
1014 self.unfolded_dir_ids.remove(¤t_id);
1015 }
1016 for child in worktree.child_entries(¤t_entry.path) {
1017 if child.is_dir() {
1018 dirs_to_collapse.push(child.id);
1019 }
1020 }
1021 }
1022 }
1023 });
1024 }
1025
1026 fn select_prev(&mut self, _: &SelectPrev, window: &mut Window, cx: &mut Context<Self>) {
1027 if let Some(edit_state) = &self.edit_state {
1028 if edit_state.processing_filename.is_none() {
1029 self.filename_editor.update(cx, |editor, cx| {
1030 editor.move_to_beginning_of_line(
1031 &editor::actions::MoveToBeginningOfLine {
1032 stop_at_soft_wraps: false,
1033 },
1034 window,
1035 cx,
1036 );
1037 });
1038 return;
1039 }
1040 }
1041 if let Some(selection) = self.selection {
1042 let (mut worktree_ix, mut entry_ix, _) =
1043 self.index_for_selection(selection).unwrap_or_default();
1044 if entry_ix > 0 {
1045 entry_ix -= 1;
1046 } else if worktree_ix > 0 {
1047 worktree_ix -= 1;
1048 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
1049 } else {
1050 return;
1051 }
1052
1053 let (worktree_id, worktree_entries, _) = &self.visible_entries[worktree_ix];
1054 let selection = SelectedEntry {
1055 worktree_id: *worktree_id,
1056 entry_id: worktree_entries[entry_ix].id,
1057 };
1058 self.selection = Some(selection);
1059 if window.modifiers().shift {
1060 self.marked_entries.insert(selection);
1061 }
1062 self.autoscroll(cx);
1063 cx.notify();
1064 } else {
1065 self.select_first(&SelectFirst {}, window, cx);
1066 }
1067 }
1068
1069 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
1070 if let Some(task) = self.confirm_edit(window, cx) {
1071 task.detach_and_notify_err(window, cx);
1072 }
1073 }
1074
1075 fn open(&mut self, _: &Open, window: &mut Window, cx: &mut Context<Self>) {
1076 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
1077 self.open_internal(true, !preview_tabs_enabled, window, cx);
1078 }
1079
1080 fn open_permanent(&mut self, _: &OpenPermanent, window: &mut Window, cx: &mut Context<Self>) {
1081 self.open_internal(false, true, window, cx);
1082 }
1083
1084 fn open_internal(
1085 &mut self,
1086 allow_preview: bool,
1087 focus_opened_item: bool,
1088 window: &mut Window,
1089 cx: &mut Context<Self>,
1090 ) {
1091 if let Some((_, entry)) = self.selected_entry(cx) {
1092 if entry.is_file() {
1093 self.open_entry(entry.id, focus_opened_item, allow_preview, cx);
1094 } else {
1095 self.toggle_expanded(entry.id, window, cx);
1096 }
1097 }
1098 }
1099
1100 fn confirm_edit(
1101 &mut self,
1102 window: &mut Window,
1103 cx: &mut Context<Self>,
1104 ) -> Option<Task<Result<()>>> {
1105 let edit_state = self.edit_state.as_mut()?;
1106 window.focus(&self.focus_handle);
1107
1108 let worktree_id = edit_state.worktree_id;
1109 let is_new_entry = edit_state.is_new_entry();
1110 let filename = self.filename_editor.read(cx).text(cx);
1111 #[cfg(not(target_os = "windows"))]
1112 let filename_indicates_dir = filename.ends_with("/");
1113 // On Windows, path separator could be either `/` or `\`.
1114 #[cfg(target_os = "windows")]
1115 let filename_indicates_dir = filename.ends_with("/") || filename.ends_with("\\");
1116 edit_state.is_dir =
1117 edit_state.is_dir || (edit_state.is_new_entry() && filename_indicates_dir);
1118 let is_dir = edit_state.is_dir;
1119 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
1120 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
1121
1122 let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
1123 let edit_task;
1124 let edited_entry_id;
1125 if is_new_entry {
1126 self.selection = Some(SelectedEntry {
1127 worktree_id,
1128 entry_id: NEW_ENTRY_ID,
1129 });
1130 let new_path = entry.path.join(filename.trim_start_matches('/'));
1131 if path_already_exists(new_path.as_path()) {
1132 return None;
1133 }
1134
1135 edited_entry_id = NEW_ENTRY_ID;
1136 edit_task = self.project.update(cx, |project, cx| {
1137 project.create_entry((worktree_id, &new_path), is_dir, cx)
1138 });
1139 } else {
1140 let new_path = if let Some(parent) = entry.path.clone().parent() {
1141 parent.join(&filename)
1142 } else {
1143 filename.clone().into()
1144 };
1145 if path_already_exists(new_path.as_path()) {
1146 return None;
1147 }
1148 edited_entry_id = entry.id;
1149 edit_task = self.project.update(cx, |project, cx| {
1150 project.rename_entry(entry.id, new_path.as_path(), cx)
1151 });
1152 };
1153
1154 edit_state.processing_filename = Some(filename);
1155 cx.notify();
1156
1157 Some(cx.spawn_in(window, |project_panel, mut cx| async move {
1158 let new_entry = edit_task.await;
1159 project_panel.update(&mut cx, |project_panel, cx| {
1160 project_panel.edit_state = None;
1161 cx.notify();
1162 })?;
1163
1164 match new_entry {
1165 Err(e) => {
1166 project_panel.update(&mut cx, |project_panel, cx| {
1167 project_panel.marked_entries.clear();
1168 project_panel.update_visible_entries(None, cx);
1169 }).ok();
1170 Err(e)?;
1171 }
1172 Ok(CreatedEntry::Included(new_entry)) => {
1173 project_panel.update(&mut cx, |project_panel, cx| {
1174 if let Some(selection) = &mut project_panel.selection {
1175 if selection.entry_id == edited_entry_id {
1176 selection.worktree_id = worktree_id;
1177 selection.entry_id = new_entry.id;
1178 project_panel.marked_entries.clear();
1179 project_panel.expand_to_selection(cx);
1180 }
1181 }
1182 project_panel.update_visible_entries(None, cx);
1183 if is_new_entry && !is_dir {
1184 project_panel.open_entry(new_entry.id, true, false, cx);
1185 }
1186 cx.notify();
1187 })?;
1188 }
1189 Ok(CreatedEntry::Excluded { abs_path }) => {
1190 if let Some(open_task) = project_panel
1191 .update_in(&mut cx, |project_panel, window, cx| {
1192 project_panel.marked_entries.clear();
1193 project_panel.update_visible_entries(None, cx);
1194
1195 if is_dir {
1196 project_panel.project.update(cx, |_, cx| {
1197 cx.emit(project::Event::Toast {
1198 notification_id: "excluded-directory".into(),
1199 message: format!("Created an excluded directory at {abs_path:?}.\nAlter `file_scan_exclusions` in the settings to show it in the panel")
1200 })
1201 });
1202 None
1203 } else {
1204 project_panel
1205 .workspace
1206 .update(cx, |workspace, cx| {
1207 workspace.open_abs_path(abs_path, true, window, cx)
1208 })
1209 .ok()
1210 }
1211 })
1212 .ok()
1213 .flatten()
1214 {
1215 let _ = open_task.await?;
1216 }
1217 }
1218 }
1219 Ok(())
1220 }))
1221 }
1222
1223 fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
1224 let previous_edit_state = self.edit_state.take();
1225 self.update_visible_entries(None, cx);
1226 self.marked_entries.clear();
1227
1228 if let Some(previously_focused) =
1229 previous_edit_state.and_then(|edit_state| edit_state.previously_focused)
1230 {
1231 self.selection = Some(previously_focused);
1232 self.autoscroll(cx);
1233 }
1234
1235 window.focus(&self.focus_handle);
1236 cx.notify();
1237 }
1238
1239 fn open_entry(
1240 &mut self,
1241 entry_id: ProjectEntryId,
1242 focus_opened_item: bool,
1243 allow_preview: bool,
1244
1245 cx: &mut Context<Self>,
1246 ) {
1247 cx.emit(Event::OpenedEntry {
1248 entry_id,
1249 focus_opened_item,
1250 allow_preview,
1251 });
1252 }
1253
1254 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut Context<Self>) {
1255 cx.emit(Event::SplitEntry { entry_id });
1256 }
1257
1258 fn new_file(&mut self, _: &NewFile, window: &mut Window, cx: &mut Context<Self>) {
1259 self.add_entry(false, window, cx)
1260 }
1261
1262 fn new_directory(&mut self, _: &NewDirectory, window: &mut Window, cx: &mut Context<Self>) {
1263 self.add_entry(true, window, cx)
1264 }
1265
1266 fn add_entry(&mut self, is_dir: bool, window: &mut Window, cx: &mut Context<Self>) {
1267 if let Some(SelectedEntry {
1268 worktree_id,
1269 entry_id,
1270 }) = self.selection
1271 {
1272 let directory_id;
1273 let new_entry_id = self.resolve_entry(entry_id);
1274 if let Some((worktree, expanded_dir_ids)) = self
1275 .project
1276 .read(cx)
1277 .worktree_for_id(worktree_id, cx)
1278 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1279 {
1280 let worktree = worktree.read(cx);
1281 if let Some(mut entry) = worktree.entry_for_id(new_entry_id) {
1282 loop {
1283 if entry.is_dir() {
1284 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1285 expanded_dir_ids.insert(ix, entry.id);
1286 }
1287 directory_id = entry.id;
1288 break;
1289 } else {
1290 if let Some(parent_path) = entry.path.parent() {
1291 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
1292 entry = parent_entry;
1293 continue;
1294 }
1295 }
1296 return;
1297 }
1298 }
1299 } else {
1300 return;
1301 };
1302 } else {
1303 return;
1304 };
1305 self.marked_entries.clear();
1306 self.edit_state = Some(EditState {
1307 worktree_id,
1308 entry_id: directory_id,
1309 leaf_entry_id: None,
1310 is_dir,
1311 processing_filename: None,
1312 previously_focused: self.selection,
1313 depth: 0,
1314 });
1315 self.filename_editor.update(cx, |editor, cx| {
1316 editor.clear(window, cx);
1317 window.focus(&editor.focus_handle(cx));
1318 });
1319 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
1320 self.autoscroll(cx);
1321 cx.notify();
1322 }
1323 }
1324
1325 fn unflatten_entry_id(&self, leaf_entry_id: ProjectEntryId) -> ProjectEntryId {
1326 if let Some(ancestors) = self.ancestors.get(&leaf_entry_id) {
1327 ancestors
1328 .ancestors
1329 .get(ancestors.current_ancestor_depth)
1330 .copied()
1331 .unwrap_or(leaf_entry_id)
1332 } else {
1333 leaf_entry_id
1334 }
1335 }
1336
1337 fn rename_impl(
1338 &mut self,
1339 selection: Option<Range<usize>>,
1340 window: &mut Window,
1341 cx: &mut Context<Self>,
1342 ) {
1343 if let Some(SelectedEntry {
1344 worktree_id,
1345 entry_id,
1346 }) = self.selection
1347 {
1348 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
1349 let sub_entry_id = self.unflatten_entry_id(entry_id);
1350 if let Some(entry) = worktree.read(cx).entry_for_id(sub_entry_id) {
1351 self.edit_state = Some(EditState {
1352 worktree_id,
1353 entry_id: sub_entry_id,
1354 leaf_entry_id: Some(entry_id),
1355 is_dir: entry.is_dir(),
1356 processing_filename: None,
1357 previously_focused: None,
1358 depth: 0,
1359 });
1360 let file_name = entry
1361 .path
1362 .file_name()
1363 .map(|s| s.to_string_lossy())
1364 .unwrap_or_default()
1365 .to_string();
1366 let selection = selection.unwrap_or_else(|| {
1367 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
1368 let selection_end =
1369 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
1370 0..selection_end
1371 });
1372 self.filename_editor.update(cx, |editor, cx| {
1373 editor.set_text(file_name, window, cx);
1374 editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
1375 s.select_ranges([selection])
1376 });
1377 window.focus(&editor.focus_handle(cx));
1378 });
1379 self.update_visible_entries(None, cx);
1380 self.autoscroll(cx);
1381 cx.notify();
1382 }
1383 }
1384 }
1385 }
1386
1387 fn rename(&mut self, _: &Rename, window: &mut Window, cx: &mut Context<Self>) {
1388 self.rename_impl(None, window, cx);
1389 }
1390
1391 fn trash(&mut self, action: &Trash, window: &mut Window, cx: &mut Context<Self>) {
1392 self.remove(true, action.skip_prompt, window, cx);
1393 }
1394
1395 fn delete(&mut self, action: &Delete, window: &mut Window, cx: &mut Context<Self>) {
1396 self.remove(false, action.skip_prompt, window, cx);
1397 }
1398
1399 fn remove(
1400 &mut self,
1401 trash: bool,
1402 skip_prompt: bool,
1403 window: &mut Window,
1404 cx: &mut Context<ProjectPanel>,
1405 ) {
1406 maybe!({
1407 let items_to_delete = self.disjoint_entries(cx);
1408 if items_to_delete.is_empty() {
1409 return None;
1410 }
1411 let project = self.project.read(cx);
1412
1413 let mut dirty_buffers = 0;
1414 let file_paths = items_to_delete
1415 .iter()
1416 .filter_map(|selection| {
1417 let project_path = project.path_for_entry(selection.entry_id, cx)?;
1418 dirty_buffers +=
1419 project.dirty_buffers(cx).any(|path| path == project_path) as usize;
1420 Some((
1421 selection.entry_id,
1422 project_path
1423 .path
1424 .file_name()?
1425 .to_string_lossy()
1426 .into_owned(),
1427 ))
1428 })
1429 .collect::<Vec<_>>();
1430 if file_paths.is_empty() {
1431 return None;
1432 }
1433 let answer = if !skip_prompt {
1434 let operation = if trash { "Trash" } else { "Delete" };
1435 let prompt = match file_paths.first() {
1436 Some((_, path)) if file_paths.len() == 1 => {
1437 let unsaved_warning = if dirty_buffers > 0 {
1438 "\n\nIt has unsaved changes, which will be lost."
1439 } else {
1440 ""
1441 };
1442
1443 format!("{operation} {path}?{unsaved_warning}")
1444 }
1445 _ => {
1446 const CUTOFF_POINT: usize = 10;
1447 let names = if file_paths.len() > CUTOFF_POINT {
1448 let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
1449 let mut paths = file_paths
1450 .iter()
1451 .map(|(_, path)| path.clone())
1452 .take(CUTOFF_POINT)
1453 .collect::<Vec<_>>();
1454 paths.truncate(CUTOFF_POINT);
1455 if truncated_path_counts == 1 {
1456 paths.push(".. 1 file not shown".into());
1457 } else {
1458 paths.push(format!(".. {} files not shown", truncated_path_counts));
1459 }
1460 paths
1461 } else {
1462 file_paths.iter().map(|(_, path)| path.clone()).collect()
1463 };
1464 let unsaved_warning = if dirty_buffers == 0 {
1465 String::new()
1466 } else if dirty_buffers == 1 {
1467 "\n\n1 of these has unsaved changes, which will be lost.".to_string()
1468 } else {
1469 format!("\n\n{dirty_buffers} of these have unsaved changes, which will be lost.")
1470 };
1471
1472 format!(
1473 "Do you want to {} the following {} files?\n{}{unsaved_warning}",
1474 operation.to_lowercase(),
1475 file_paths.len(),
1476 names.join("\n")
1477 )
1478 }
1479 };
1480 Some(window.prompt(PromptLevel::Info, &prompt, None, &[operation, "Cancel"], cx))
1481 } else {
1482 None
1483 };
1484 let next_selection = self.find_next_selection_after_deletion(items_to_delete, cx);
1485 cx.spawn_in(window, |panel, mut cx| async move {
1486 if let Some(answer) = answer {
1487 if answer.await != Ok(0) {
1488 return anyhow::Ok(());
1489 }
1490 }
1491 for (entry_id, _) in file_paths {
1492 panel
1493 .update(&mut cx, |panel, cx| {
1494 panel
1495 .project
1496 .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
1497 .context("no such entry")
1498 })??
1499 .await?;
1500 }
1501 panel.update_in(&mut cx, |panel, window, cx| {
1502 if let Some(next_selection) = next_selection {
1503 panel.selection = Some(next_selection);
1504 panel.autoscroll(cx);
1505 } else {
1506 panel.select_last(&SelectLast {}, window, cx);
1507 }
1508 })?;
1509 Ok(())
1510 })
1511 .detach_and_log_err(cx);
1512 Some(())
1513 });
1514 }
1515
1516 fn find_next_selection_after_deletion(
1517 &self,
1518 sanitized_entries: BTreeSet<SelectedEntry>,
1519 cx: &mut Context<Self>,
1520 ) -> Option<SelectedEntry> {
1521 if sanitized_entries.is_empty() {
1522 return None;
1523 }
1524
1525 let project = self.project.read(cx);
1526 let (worktree_id, worktree) = sanitized_entries
1527 .iter()
1528 .map(|entry| entry.worktree_id)
1529 .filter_map(|id| project.worktree_for_id(id, cx).map(|w| (id, w.read(cx))))
1530 .max_by(|(_, a), (_, b)| a.root_name().cmp(b.root_name()))?;
1531
1532 let marked_entries_in_worktree = sanitized_entries
1533 .iter()
1534 .filter(|e| e.worktree_id == worktree_id)
1535 .collect::<HashSet<_>>();
1536 let latest_entry = marked_entries_in_worktree
1537 .iter()
1538 .max_by(|a, b| {
1539 match (
1540 worktree.entry_for_id(a.entry_id),
1541 worktree.entry_for_id(b.entry_id),
1542 ) {
1543 (Some(a), Some(b)) => {
1544 compare_paths((&a.path, a.is_file()), (&b.path, b.is_file()))
1545 }
1546 _ => cmp::Ordering::Equal,
1547 }
1548 })
1549 .and_then(|e| worktree.entry_for_id(e.entry_id))?;
1550
1551 let parent_path = latest_entry.path.parent()?;
1552 let parent_entry = worktree.entry_for_path(parent_path)?;
1553
1554 // Remove all siblings that are being deleted except the last marked entry
1555 let mut siblings: Vec<_> = worktree
1556 .snapshot()
1557 .child_entries(parent_path)
1558 .with_git_statuses()
1559 .filter(|sibling| {
1560 sibling.id == latest_entry.id
1561 || !marked_entries_in_worktree.contains(&&SelectedEntry {
1562 worktree_id,
1563 entry_id: sibling.id,
1564 })
1565 })
1566 .map(|entry| entry.to_owned())
1567 .collect();
1568
1569 project::sort_worktree_entries(&mut siblings);
1570 let sibling_entry_index = siblings
1571 .iter()
1572 .position(|sibling| sibling.id == latest_entry.id)?;
1573
1574 if let Some(next_sibling) = sibling_entry_index
1575 .checked_add(1)
1576 .and_then(|i| siblings.get(i))
1577 {
1578 return Some(SelectedEntry {
1579 worktree_id,
1580 entry_id: next_sibling.id,
1581 });
1582 }
1583 if let Some(prev_sibling) = sibling_entry_index
1584 .checked_sub(1)
1585 .and_then(|i| siblings.get(i))
1586 {
1587 return Some(SelectedEntry {
1588 worktree_id,
1589 entry_id: prev_sibling.id,
1590 });
1591 }
1592 // No neighbour sibling found, fall back to parent
1593 Some(SelectedEntry {
1594 worktree_id,
1595 entry_id: parent_entry.id,
1596 })
1597 }
1598
1599 fn unfold_directory(&mut self, _: &UnfoldDirectory, _: &mut Window, cx: &mut Context<Self>) {
1600 if let Some((worktree, entry)) = self.selected_entry(cx) {
1601 self.unfolded_dir_ids.insert(entry.id);
1602
1603 let snapshot = worktree.snapshot();
1604 let mut parent_path = entry.path.parent();
1605 while let Some(path) = parent_path {
1606 if let Some(parent_entry) = worktree.entry_for_path(path) {
1607 let mut children_iter = snapshot.child_entries(path);
1608
1609 if children_iter.by_ref().take(2).count() > 1 {
1610 break;
1611 }
1612
1613 self.unfolded_dir_ids.insert(parent_entry.id);
1614 parent_path = path.parent();
1615 } else {
1616 break;
1617 }
1618 }
1619
1620 self.update_visible_entries(None, cx);
1621 self.autoscroll(cx);
1622 cx.notify();
1623 }
1624 }
1625
1626 fn fold_directory(&mut self, _: &FoldDirectory, _: &mut Window, cx: &mut Context<Self>) {
1627 if let Some((worktree, entry)) = self.selected_entry(cx) {
1628 self.unfolded_dir_ids.remove(&entry.id);
1629
1630 let snapshot = worktree.snapshot();
1631 let mut path = &*entry.path;
1632 loop {
1633 let mut child_entries_iter = snapshot.child_entries(path);
1634 if let Some(child) = child_entries_iter.next() {
1635 if child_entries_iter.next().is_none() && child.is_dir() {
1636 self.unfolded_dir_ids.remove(&child.id);
1637 path = &*child.path;
1638 } else {
1639 break;
1640 }
1641 } else {
1642 break;
1643 }
1644 }
1645
1646 self.update_visible_entries(None, cx);
1647 self.autoscroll(cx);
1648 cx.notify();
1649 }
1650 }
1651
1652 fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
1653 if let Some(edit_state) = &self.edit_state {
1654 if edit_state.processing_filename.is_none() {
1655 self.filename_editor.update(cx, |editor, cx| {
1656 editor.move_to_end_of_line(
1657 &editor::actions::MoveToEndOfLine {
1658 stop_at_soft_wraps: false,
1659 },
1660 window,
1661 cx,
1662 );
1663 });
1664 return;
1665 }
1666 }
1667 if let Some(selection) = self.selection {
1668 let (mut worktree_ix, mut entry_ix, _) =
1669 self.index_for_selection(selection).unwrap_or_default();
1670 if let Some((_, worktree_entries, _)) = self.visible_entries.get(worktree_ix) {
1671 if entry_ix + 1 < worktree_entries.len() {
1672 entry_ix += 1;
1673 } else {
1674 worktree_ix += 1;
1675 entry_ix = 0;
1676 }
1677 }
1678
1679 if let Some((worktree_id, worktree_entries, _)) = self.visible_entries.get(worktree_ix)
1680 {
1681 if let Some(entry) = worktree_entries.get(entry_ix) {
1682 let selection = SelectedEntry {
1683 worktree_id: *worktree_id,
1684 entry_id: entry.id,
1685 };
1686 self.selection = Some(selection);
1687 if window.modifiers().shift {
1688 self.marked_entries.insert(selection);
1689 }
1690
1691 self.autoscroll(cx);
1692 cx.notify();
1693 }
1694 }
1695 } else {
1696 self.select_first(&SelectFirst {}, window, cx);
1697 }
1698 }
1699
1700 fn select_prev_diagnostic(
1701 &mut self,
1702 _: &SelectPrevDiagnostic,
1703 _: &mut Window,
1704 cx: &mut Context<Self>,
1705 ) {
1706 let selection = self.find_entry(
1707 self.selection.as_ref(),
1708 true,
1709 |entry, worktree_id| {
1710 (self.selection.is_none()
1711 || self.selection.is_some_and(|selection| {
1712 if selection.worktree_id == worktree_id {
1713 selection.entry_id != entry.id
1714 } else {
1715 true
1716 }
1717 }))
1718 && entry.is_file()
1719 && self
1720 .diagnostics
1721 .contains_key(&(worktree_id, entry.path.to_path_buf()))
1722 },
1723 cx,
1724 );
1725
1726 if let Some(selection) = selection {
1727 self.selection = Some(selection);
1728 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1729 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1730 self.autoscroll(cx);
1731 cx.notify();
1732 }
1733 }
1734
1735 fn select_next_diagnostic(
1736 &mut self,
1737 _: &SelectNextDiagnostic,
1738 _: &mut Window,
1739 cx: &mut Context<Self>,
1740 ) {
1741 let selection = self.find_entry(
1742 self.selection.as_ref(),
1743 false,
1744 |entry, worktree_id| {
1745 (self.selection.is_none()
1746 || self.selection.is_some_and(|selection| {
1747 if selection.worktree_id == worktree_id {
1748 selection.entry_id != entry.id
1749 } else {
1750 true
1751 }
1752 }))
1753 && entry.is_file()
1754 && self
1755 .diagnostics
1756 .contains_key(&(worktree_id, entry.path.to_path_buf()))
1757 },
1758 cx,
1759 );
1760
1761 if let Some(selection) = selection {
1762 self.selection = Some(selection);
1763 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1764 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1765 self.autoscroll(cx);
1766 cx.notify();
1767 }
1768 }
1769
1770 fn select_prev_git_entry(
1771 &mut self,
1772 _: &SelectPrevGitEntry,
1773 _: &mut Window,
1774 cx: &mut Context<Self>,
1775 ) {
1776 let selection = self.find_entry(
1777 self.selection.as_ref(),
1778 true,
1779 |entry, worktree_id| {
1780 (self.selection.is_none()
1781 || self.selection.is_some_and(|selection| {
1782 if selection.worktree_id == worktree_id {
1783 selection.entry_id != entry.id
1784 } else {
1785 true
1786 }
1787 }))
1788 && entry.is_file()
1789 && entry.git_summary.index.modified + entry.git_summary.worktree.modified > 0
1790 },
1791 cx,
1792 );
1793
1794 if let Some(selection) = selection {
1795 self.selection = Some(selection);
1796 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1797 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1798 self.autoscroll(cx);
1799 cx.notify();
1800 }
1801 }
1802
1803 fn select_prev_directory(
1804 &mut self,
1805 _: &SelectPrevDirectory,
1806 _: &mut Window,
1807 cx: &mut Context<Self>,
1808 ) {
1809 let selection = self.find_visible_entry(
1810 self.selection.as_ref(),
1811 true,
1812 |entry, worktree_id| {
1813 (self.selection.is_none()
1814 || self.selection.is_some_and(|selection| {
1815 if selection.worktree_id == worktree_id {
1816 selection.entry_id != entry.id
1817 } else {
1818 true
1819 }
1820 }))
1821 && entry.is_dir()
1822 },
1823 cx,
1824 );
1825
1826 if let Some(selection) = selection {
1827 self.selection = Some(selection);
1828 self.autoscroll(cx);
1829 cx.notify();
1830 }
1831 }
1832
1833 fn select_next_directory(
1834 &mut self,
1835 _: &SelectNextDirectory,
1836 _: &mut Window,
1837 cx: &mut Context<Self>,
1838 ) {
1839 let selection = self.find_visible_entry(
1840 self.selection.as_ref(),
1841 false,
1842 |entry, worktree_id| {
1843 (self.selection.is_none()
1844 || self.selection.is_some_and(|selection| {
1845 if selection.worktree_id == worktree_id {
1846 selection.entry_id != entry.id
1847 } else {
1848 true
1849 }
1850 }))
1851 && entry.is_dir()
1852 },
1853 cx,
1854 );
1855
1856 if let Some(selection) = selection {
1857 self.selection = Some(selection);
1858 self.autoscroll(cx);
1859 cx.notify();
1860 }
1861 }
1862
1863 fn select_next_git_entry(
1864 &mut self,
1865 _: &SelectNextGitEntry,
1866 _: &mut Window,
1867 cx: &mut Context<Self>,
1868 ) {
1869 let selection = self.find_entry(
1870 self.selection.as_ref(),
1871 false,
1872 |entry, worktree_id| {
1873 (self.selection.is_none()
1874 || self.selection.is_some_and(|selection| {
1875 if selection.worktree_id == worktree_id {
1876 selection.entry_id != entry.id
1877 } else {
1878 true
1879 }
1880 }))
1881 && entry.is_file()
1882 && entry.git_summary.index.modified + entry.git_summary.worktree.modified > 0
1883 },
1884 cx,
1885 );
1886
1887 if let Some(selection) = selection {
1888 self.selection = Some(selection);
1889 self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1890 self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1891 self.autoscroll(cx);
1892 cx.notify();
1893 }
1894 }
1895
1896 fn select_parent(&mut self, _: &SelectParent, window: &mut Window, cx: &mut Context<Self>) {
1897 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
1898 if let Some(parent) = entry.path.parent() {
1899 let worktree = worktree.read(cx);
1900 if let Some(parent_entry) = worktree.entry_for_path(parent) {
1901 self.selection = Some(SelectedEntry {
1902 worktree_id: worktree.id(),
1903 entry_id: parent_entry.id,
1904 });
1905 self.autoscroll(cx);
1906 cx.notify();
1907 }
1908 }
1909 } else {
1910 self.select_first(&SelectFirst {}, window, cx);
1911 }
1912 }
1913
1914 fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
1915 let worktree = self
1916 .visible_entries
1917 .first()
1918 .and_then(|(worktree_id, _, _)| {
1919 self.project.read(cx).worktree_for_id(*worktree_id, cx)
1920 });
1921 if let Some(worktree) = worktree {
1922 let worktree = worktree.read(cx);
1923 let worktree_id = worktree.id();
1924 if let Some(root_entry) = worktree.root_entry() {
1925 let selection = SelectedEntry {
1926 worktree_id,
1927 entry_id: root_entry.id,
1928 };
1929 self.selection = Some(selection);
1930 if window.modifiers().shift {
1931 self.marked_entries.insert(selection);
1932 }
1933 self.autoscroll(cx);
1934 cx.notify();
1935 }
1936 }
1937 }
1938
1939 fn select_last(&mut self, _: &SelectLast, _: &mut Window, cx: &mut Context<Self>) {
1940 let worktree = self.visible_entries.last().and_then(|(worktree_id, _, _)| {
1941 self.project.read(cx).worktree_for_id(*worktree_id, cx)
1942 });
1943 if let Some(worktree) = worktree {
1944 let worktree = worktree.read(cx);
1945 let worktree_id = worktree.id();
1946 if let Some(last_entry) = worktree.entries(true, 0).last() {
1947 self.selection = Some(SelectedEntry {
1948 worktree_id,
1949 entry_id: last_entry.id,
1950 });
1951 self.autoscroll(cx);
1952 cx.notify();
1953 }
1954 }
1955 }
1956
1957 fn autoscroll(&mut self, cx: &mut Context<Self>) {
1958 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
1959 self.scroll_handle
1960 .scroll_to_item(index, ScrollStrategy::Center);
1961 cx.notify();
1962 }
1963 }
1964
1965 fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context<Self>) {
1966 let entries = self.disjoint_entries(cx);
1967 if !entries.is_empty() {
1968 self.clipboard = Some(ClipboardEntry::Cut(entries));
1969 cx.notify();
1970 }
1971 }
1972
1973 fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
1974 let entries = self.disjoint_entries(cx);
1975 if !entries.is_empty() {
1976 self.clipboard = Some(ClipboardEntry::Copied(entries));
1977 cx.notify();
1978 }
1979 }
1980
1981 fn create_paste_path(
1982 &self,
1983 source: &SelectedEntry,
1984 (worktree, target_entry): (Entity<Worktree>, &Entry),
1985 cx: &App,
1986 ) -> Option<(PathBuf, Option<Range<usize>>)> {
1987 let mut new_path = target_entry.path.to_path_buf();
1988 // If we're pasting into a file, or a directory into itself, go up one level.
1989 if target_entry.is_file() || (target_entry.is_dir() && target_entry.id == source.entry_id) {
1990 new_path.pop();
1991 }
1992 let clipboard_entry_file_name = self
1993 .project
1994 .read(cx)
1995 .path_for_entry(source.entry_id, cx)?
1996 .path
1997 .file_name()?
1998 .to_os_string();
1999 new_path.push(&clipboard_entry_file_name);
2000 let extension = new_path.extension().map(|e| e.to_os_string());
2001 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
2002 let file_name_len = file_name_without_extension.to_string_lossy().len();
2003 let mut disambiguation_range = None;
2004 let mut ix = 0;
2005 {
2006 let worktree = worktree.read(cx);
2007 while worktree.entry_for_path(&new_path).is_some() {
2008 new_path.pop();
2009
2010 let mut new_file_name = file_name_without_extension.to_os_string();
2011
2012 let disambiguation = " copy";
2013 let mut disambiguation_len = disambiguation.len();
2014
2015 new_file_name.push(disambiguation);
2016
2017 if ix > 0 {
2018 let extra_disambiguation = format!(" {}", ix);
2019 disambiguation_len += extra_disambiguation.len();
2020
2021 new_file_name.push(extra_disambiguation);
2022 }
2023 if let Some(extension) = extension.as_ref() {
2024 new_file_name.push(".");
2025 new_file_name.push(extension);
2026 }
2027
2028 new_path.push(new_file_name);
2029 disambiguation_range = Some(file_name_len..(file_name_len + disambiguation_len));
2030 ix += 1;
2031 }
2032 }
2033 Some((new_path, disambiguation_range))
2034 }
2035
2036 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
2037 maybe!({
2038 let (worktree, entry) = self.selected_entry_handle(cx)?;
2039 let entry = entry.clone();
2040 let worktree_id = worktree.read(cx).id();
2041 let clipboard_entries = self
2042 .clipboard
2043 .as_ref()
2044 .filter(|clipboard| !clipboard.items().is_empty())?;
2045 enum PasteTask {
2046 Rename(Task<Result<CreatedEntry>>),
2047 Copy(Task<Result<Option<Entry>>>),
2048 }
2049 let mut paste_entry_tasks: IndexMap<(ProjectEntryId, bool), PasteTask> =
2050 IndexMap::default();
2051 let mut disambiguation_range = None;
2052 let clip_is_cut = clipboard_entries.is_cut();
2053 for clipboard_entry in clipboard_entries.items() {
2054 let (new_path, new_disambiguation_range) =
2055 self.create_paste_path(clipboard_entry, self.selected_sub_entry(cx)?, cx)?;
2056 let clip_entry_id = clipboard_entry.entry_id;
2057 let is_same_worktree = clipboard_entry.worktree_id == worktree_id;
2058 let relative_worktree_source_path = if !is_same_worktree {
2059 let target_base_path = worktree.read(cx).abs_path();
2060 let clipboard_project_path =
2061 self.project.read(cx).path_for_entry(clip_entry_id, cx)?;
2062 let clipboard_abs_path = self
2063 .project
2064 .read(cx)
2065 .absolute_path(&clipboard_project_path, cx)?;
2066 Some(relativize_path(
2067 &target_base_path,
2068 clipboard_abs_path.as_path(),
2069 ))
2070 } else {
2071 None
2072 };
2073 let task = if clip_is_cut && is_same_worktree {
2074 let task = self.project.update(cx, |project, cx| {
2075 project.rename_entry(clip_entry_id, new_path, cx)
2076 });
2077 PasteTask::Rename(task)
2078 } else {
2079 let entry_id = if is_same_worktree {
2080 clip_entry_id
2081 } else {
2082 entry.id
2083 };
2084 let task = self.project.update(cx, |project, cx| {
2085 project.copy_entry(entry_id, relative_worktree_source_path, new_path, cx)
2086 });
2087 PasteTask::Copy(task)
2088 };
2089 let needs_delete = !is_same_worktree && clip_is_cut;
2090 paste_entry_tasks.insert((clip_entry_id, needs_delete), task);
2091 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
2092 }
2093
2094 let item_count = paste_entry_tasks.len();
2095
2096 cx.spawn_in(window, |project_panel, mut cx| async move {
2097 let mut last_succeed = None;
2098 let mut need_delete_ids = Vec::new();
2099 for ((entry_id, need_delete), task) in paste_entry_tasks.into_iter() {
2100 match task {
2101 PasteTask::Rename(task) => {
2102 if let Some(CreatedEntry::Included(entry)) = task.await.log_err() {
2103 last_succeed = Some(entry.id);
2104 }
2105 }
2106 PasteTask::Copy(task) => {
2107 if let Some(Some(entry)) = task.await.log_err() {
2108 last_succeed = Some(entry.id);
2109 if need_delete {
2110 need_delete_ids.push(entry_id);
2111 }
2112 }
2113 }
2114 }
2115 }
2116 // remove entry for cut in difference worktree
2117 for entry_id in need_delete_ids {
2118 project_panel
2119 .update(&mut cx, |project_panel, cx| {
2120 project_panel
2121 .project
2122 .update(cx, |project, cx| project.delete_entry(entry_id, true, cx))
2123 .ok_or_else(|| anyhow!("no such entry"))
2124 })??
2125 .await?;
2126 }
2127 // update selection
2128 if let Some(entry_id) = last_succeed {
2129 project_panel
2130 .update_in(&mut cx, |project_panel, window, cx| {
2131 project_panel.selection = Some(SelectedEntry {
2132 worktree_id,
2133 entry_id,
2134 });
2135
2136 // if only one entry was pasted and it was disambiguated, open the rename editor
2137 if item_count == 1 && disambiguation_range.is_some() {
2138 project_panel.rename_impl(disambiguation_range, window, cx);
2139 }
2140 })
2141 .ok();
2142 }
2143
2144 anyhow::Ok(())
2145 })
2146 .detach_and_log_err(cx);
2147
2148 self.expand_entry(worktree_id, entry.id, cx);
2149 Some(())
2150 });
2151 }
2152
2153 fn duplicate(&mut self, _: &Duplicate, window: &mut Window, cx: &mut Context<Self>) {
2154 self.copy(&Copy {}, window, cx);
2155 self.paste(&Paste {}, window, cx);
2156 }
2157
2158 fn copy_path(&mut self, _: &CopyPath, _: &mut Window, cx: &mut Context<Self>) {
2159 let abs_file_paths = {
2160 let project = self.project.read(cx);
2161 self.effective_entries()
2162 .into_iter()
2163 .filter_map(|entry| {
2164 let entry_path = project.path_for_entry(entry.entry_id, cx)?.path;
2165 Some(
2166 project
2167 .worktree_for_id(entry.worktree_id, cx)?
2168 .read(cx)
2169 .abs_path()
2170 .join(entry_path)
2171 .to_string_lossy()
2172 .to_string(),
2173 )
2174 })
2175 .collect::<Vec<_>>()
2176 };
2177 if !abs_file_paths.is_empty() {
2178 cx.write_to_clipboard(ClipboardItem::new_string(abs_file_paths.join("\n")));
2179 }
2180 }
2181
2182 fn copy_relative_path(&mut self, _: &CopyRelativePath, _: &mut Window, cx: &mut Context<Self>) {
2183 let file_paths = {
2184 let project = self.project.read(cx);
2185 self.effective_entries()
2186 .into_iter()
2187 .filter_map(|entry| {
2188 Some(
2189 project
2190 .path_for_entry(entry.entry_id, cx)?
2191 .path
2192 .to_string_lossy()
2193 .to_string(),
2194 )
2195 })
2196 .collect::<Vec<_>>()
2197 };
2198 if !file_paths.is_empty() {
2199 cx.write_to_clipboard(ClipboardItem::new_string(file_paths.join("\n")));
2200 }
2201 }
2202
2203 fn reveal_in_finder(
2204 &mut self,
2205 _: &RevealInFileManager,
2206 _: &mut Window,
2207 cx: &mut Context<Self>,
2208 ) {
2209 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2210 cx.reveal_path(&worktree.read(cx).abs_path().join(&entry.path));
2211 }
2212 }
2213
2214 fn remove_from_project(
2215 &mut self,
2216 _: &RemoveFromProject,
2217 _window: &mut Window,
2218 cx: &mut Context<Self>,
2219 ) {
2220 for entry in self.effective_entries().iter() {
2221 let worktree_id = entry.worktree_id;
2222 self.project
2223 .update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
2224 }
2225 }
2226
2227 fn open_system(&mut self, _: &OpenWithSystem, _: &mut Window, cx: &mut Context<Self>) {
2228 if let Some((worktree, entry)) = self.selected_entry(cx) {
2229 let abs_path = worktree.abs_path().join(&entry.path);
2230 cx.open_with_system(&abs_path);
2231 }
2232 }
2233
2234 fn open_in_terminal(
2235 &mut self,
2236 _: &OpenInTerminal,
2237 window: &mut Window,
2238 cx: &mut Context<Self>,
2239 ) {
2240 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2241 let abs_path = match &entry.canonical_path {
2242 Some(canonical_path) => Some(canonical_path.to_path_buf()),
2243 None => worktree.read(cx).absolutize(&entry.path).ok(),
2244 };
2245
2246 let working_directory = if entry.is_dir() {
2247 abs_path
2248 } else {
2249 abs_path.and_then(|path| Some(path.parent()?.to_path_buf()))
2250 };
2251 if let Some(working_directory) = working_directory {
2252 window.dispatch_action(
2253 workspace::OpenTerminal { working_directory }.boxed_clone(),
2254 cx,
2255 )
2256 }
2257 }
2258 }
2259
2260 pub fn new_search_in_directory(
2261 &mut self,
2262 _: &NewSearchInDirectory,
2263 window: &mut Window,
2264 cx: &mut Context<Self>,
2265 ) {
2266 if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2267 let dir_path = if entry.is_dir() {
2268 entry.path.clone()
2269 } else {
2270 // entry is a file, use its parent directory
2271 match entry.path.parent() {
2272 Some(parent) => Arc::from(parent),
2273 None => {
2274 // File at root, open search with empty filter
2275 self.workspace
2276 .update(cx, |workspace, cx| {
2277 search::ProjectSearchView::new_search_in_directory(
2278 workspace,
2279 Path::new(""),
2280 window,
2281 cx,
2282 );
2283 })
2284 .ok();
2285 return;
2286 }
2287 }
2288 };
2289
2290 let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
2291 let dir_path = if include_root {
2292 let mut full_path = PathBuf::from(worktree.read(cx).root_name());
2293 full_path.push(&dir_path);
2294 Arc::from(full_path)
2295 } else {
2296 dir_path
2297 };
2298
2299 self.workspace
2300 .update(cx, |workspace, cx| {
2301 search::ProjectSearchView::new_search_in_directory(
2302 workspace, &dir_path, window, cx,
2303 );
2304 })
2305 .ok();
2306 }
2307 }
2308
2309 fn move_entry(
2310 &mut self,
2311 entry_to_move: ProjectEntryId,
2312 destination: ProjectEntryId,
2313 destination_is_file: bool,
2314 cx: &mut Context<Self>,
2315 ) {
2316 if self
2317 .project
2318 .read(cx)
2319 .entry_is_worktree_root(entry_to_move, cx)
2320 {
2321 self.move_worktree_root(entry_to_move, destination, cx)
2322 } else {
2323 self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx)
2324 }
2325 }
2326
2327 fn move_worktree_root(
2328 &mut self,
2329 entry_to_move: ProjectEntryId,
2330 destination: ProjectEntryId,
2331 cx: &mut Context<Self>,
2332 ) {
2333 self.project.update(cx, |project, cx| {
2334 let Some(worktree_to_move) = project.worktree_for_entry(entry_to_move, cx) else {
2335 return;
2336 };
2337 let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else {
2338 return;
2339 };
2340
2341 let worktree_id = worktree_to_move.read(cx).id();
2342 let destination_id = destination_worktree.read(cx).id();
2343
2344 project
2345 .move_worktree(worktree_id, destination_id, cx)
2346 .log_err();
2347 });
2348 }
2349
2350 fn move_worktree_entry(
2351 &mut self,
2352 entry_to_move: ProjectEntryId,
2353 destination: ProjectEntryId,
2354 destination_is_file: bool,
2355 cx: &mut Context<Self>,
2356 ) {
2357 if entry_to_move == destination {
2358 return;
2359 }
2360
2361 let destination_worktree = self.project.update(cx, |project, cx| {
2362 let entry_path = project.path_for_entry(entry_to_move, cx)?;
2363 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
2364
2365 let mut destination_path = destination_entry_path.as_ref();
2366 if destination_is_file {
2367 destination_path = destination_path.parent()?;
2368 }
2369
2370 let mut new_path = destination_path.to_path_buf();
2371 new_path.push(entry_path.path.file_name()?);
2372 if new_path != entry_path.path.as_ref() {
2373 let task = project.rename_entry(entry_to_move, new_path, cx);
2374 cx.foreground_executor().spawn(task).detach_and_log_err(cx);
2375 }
2376
2377 project.worktree_id_for_entry(destination, cx)
2378 });
2379
2380 if let Some(destination_worktree) = destination_worktree {
2381 self.expand_entry(destination_worktree, destination, cx);
2382 }
2383 }
2384
2385 fn index_for_selection(&self, selection: SelectedEntry) -> Option<(usize, usize, usize)> {
2386 let mut entry_index = 0;
2387 let mut visible_entries_index = 0;
2388 for (worktree_index, (worktree_id, worktree_entries, _)) in
2389 self.visible_entries.iter().enumerate()
2390 {
2391 if *worktree_id == selection.worktree_id {
2392 for entry in worktree_entries {
2393 if entry.id == selection.entry_id {
2394 return Some((worktree_index, entry_index, visible_entries_index));
2395 } else {
2396 visible_entries_index += 1;
2397 entry_index += 1;
2398 }
2399 }
2400 break;
2401 } else {
2402 visible_entries_index += worktree_entries.len();
2403 }
2404 }
2405 None
2406 }
2407
2408 fn disjoint_entries(&self, cx: &App) -> BTreeSet<SelectedEntry> {
2409 let marked_entries = self.effective_entries();
2410 let mut sanitized_entries = BTreeSet::new();
2411 if marked_entries.is_empty() {
2412 return sanitized_entries;
2413 }
2414
2415 let project = self.project.read(cx);
2416 let marked_entries_by_worktree: HashMap<WorktreeId, Vec<SelectedEntry>> = marked_entries
2417 .into_iter()
2418 .filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx))
2419 .fold(HashMap::default(), |mut map, entry| {
2420 map.entry(entry.worktree_id).or_default().push(entry);
2421 map
2422 });
2423
2424 for (worktree_id, marked_entries) in marked_entries_by_worktree {
2425 if let Some(worktree) = project.worktree_for_id(worktree_id, cx) {
2426 let worktree = worktree.read(cx);
2427 let marked_dir_paths = marked_entries
2428 .iter()
2429 .filter_map(|entry| {
2430 worktree.entry_for_id(entry.entry_id).and_then(|entry| {
2431 if entry.is_dir() {
2432 Some(entry.path.as_ref())
2433 } else {
2434 None
2435 }
2436 })
2437 })
2438 .collect::<BTreeSet<_>>();
2439
2440 sanitized_entries.extend(marked_entries.into_iter().filter(|entry| {
2441 let Some(entry_info) = worktree.entry_for_id(entry.entry_id) else {
2442 return false;
2443 };
2444 let entry_path = entry_info.path.as_ref();
2445 let inside_marked_dir = marked_dir_paths.iter().any(|&marked_dir_path| {
2446 entry_path != marked_dir_path && entry_path.starts_with(marked_dir_path)
2447 });
2448 !inside_marked_dir
2449 }));
2450 }
2451 }
2452
2453 sanitized_entries
2454 }
2455
2456 fn effective_entries(&self) -> BTreeSet<SelectedEntry> {
2457 if let Some(selection) = self.selection {
2458 let selection = SelectedEntry {
2459 entry_id: self.resolve_entry(selection.entry_id),
2460 worktree_id: selection.worktree_id,
2461 };
2462
2463 // Default to using just the selected item when nothing is marked.
2464 if self.marked_entries.is_empty() {
2465 return BTreeSet::from([selection]);
2466 }
2467
2468 // Allow operating on the selected item even when something else is marked,
2469 // making it easier to perform one-off actions without clearing a mark.
2470 if self.marked_entries.len() == 1 && !self.marked_entries.contains(&selection) {
2471 return BTreeSet::from([selection]);
2472 }
2473 }
2474
2475 // Return only marked entries since we've already handled special cases where
2476 // only selection should take precedence. At this point, marked entries may or
2477 // may not include the current selection, which is intentional.
2478 self.marked_entries
2479 .iter()
2480 .map(|entry| SelectedEntry {
2481 entry_id: self.resolve_entry(entry.entry_id),
2482 worktree_id: entry.worktree_id,
2483 })
2484 .collect::<BTreeSet<_>>()
2485 }
2486
2487 /// Finds the currently selected subentry for a given leaf entry id. If a given entry
2488 /// has no ancestors, the project entry ID that's passed in is returned as-is.
2489 fn resolve_entry(&self, id: ProjectEntryId) -> ProjectEntryId {
2490 self.ancestors
2491 .get(&id)
2492 .and_then(|ancestors| {
2493 if ancestors.current_ancestor_depth == 0 {
2494 return None;
2495 }
2496 ancestors.ancestors.get(ancestors.current_ancestor_depth)
2497 })
2498 .copied()
2499 .unwrap_or(id)
2500 }
2501
2502 pub fn selected_entry<'a>(&self, cx: &'a App) -> Option<(&'a Worktree, &'a project::Entry)> {
2503 let (worktree, entry) = self.selected_entry_handle(cx)?;
2504 Some((worktree.read(cx), entry))
2505 }
2506
2507 /// Compared to selected_entry, this function resolves to the currently
2508 /// selected subentry if dir auto-folding is enabled.
2509 fn selected_sub_entry<'a>(
2510 &self,
2511 cx: &'a App,
2512 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
2513 let (worktree, mut entry) = self.selected_entry_handle(cx)?;
2514
2515 let resolved_id = self.resolve_entry(entry.id);
2516 if resolved_id != entry.id {
2517 let worktree = worktree.read(cx);
2518 entry = worktree.entry_for_id(resolved_id)?;
2519 }
2520 Some((worktree, entry))
2521 }
2522 fn selected_entry_handle<'a>(
2523 &self,
2524 cx: &'a App,
2525 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
2526 let selection = self.selection?;
2527 let project = self.project.read(cx);
2528 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
2529 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
2530 Some((worktree, entry))
2531 }
2532
2533 fn expand_to_selection(&mut self, cx: &mut Context<Self>) -> Option<()> {
2534 let (worktree, entry) = self.selected_entry(cx)?;
2535 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
2536
2537 for path in entry.path.ancestors() {
2538 let Some(entry) = worktree.entry_for_path(path) else {
2539 continue;
2540 };
2541 if entry.is_dir() {
2542 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
2543 expanded_dir_ids.insert(idx, entry.id);
2544 }
2545 }
2546 }
2547
2548 Some(())
2549 }
2550
2551 fn update_visible_entries(
2552 &mut self,
2553 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
2554 cx: &mut Context<Self>,
2555 ) {
2556 let auto_collapse_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
2557 let project = self.project.read(cx);
2558 self.last_worktree_root_id = project
2559 .visible_worktrees(cx)
2560 .next_back()
2561 .and_then(|worktree| worktree.read(cx).root_entry())
2562 .map(|entry| entry.id);
2563
2564 let old_ancestors = std::mem::take(&mut self.ancestors);
2565 self.visible_entries.clear();
2566 let mut max_width_item = None;
2567 for worktree in project.visible_worktrees(cx) {
2568 let snapshot = worktree.read(cx).snapshot();
2569 let worktree_id = snapshot.id();
2570
2571 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
2572 hash_map::Entry::Occupied(e) => e.into_mut(),
2573 hash_map::Entry::Vacant(e) => {
2574 // The first time a worktree's root entry becomes available,
2575 // mark that root entry as expanded.
2576 if let Some(entry) = snapshot.root_entry() {
2577 e.insert(vec![entry.id]).as_slice()
2578 } else {
2579 &[]
2580 }
2581 }
2582 };
2583
2584 let mut new_entry_parent_id = None;
2585 let mut new_entry_kind = EntryKind::Dir;
2586 if let Some(edit_state) = &self.edit_state {
2587 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry() {
2588 new_entry_parent_id = Some(edit_state.entry_id);
2589 new_entry_kind = if edit_state.is_dir {
2590 EntryKind::Dir
2591 } else {
2592 EntryKind::File
2593 };
2594 }
2595 }
2596
2597 let mut visible_worktree_entries = Vec::new();
2598 let mut entry_iter = snapshot.entries(true, 0).with_git_statuses();
2599 let mut auto_folded_ancestors = vec![];
2600 while let Some(entry) = entry_iter.entry() {
2601 if auto_collapse_dirs && entry.kind.is_dir() {
2602 auto_folded_ancestors.push(entry.id);
2603 if !self.unfolded_dir_ids.contains(&entry.id) {
2604 if let Some(root_path) = snapshot.root_entry() {
2605 let mut child_entries = snapshot.child_entries(&entry.path);
2606 if let Some(child) = child_entries.next() {
2607 if entry.path != root_path.path
2608 && child_entries.next().is_none()
2609 && child.kind.is_dir()
2610 {
2611 entry_iter.advance();
2612
2613 continue;
2614 }
2615 }
2616 }
2617 }
2618 let depth = old_ancestors
2619 .get(&entry.id)
2620 .map(|ancestor| ancestor.current_ancestor_depth)
2621 .unwrap_or_default()
2622 .min(auto_folded_ancestors.len());
2623 if let Some(edit_state) = &mut self.edit_state {
2624 if edit_state.entry_id == entry.id {
2625 edit_state.depth = depth;
2626 }
2627 }
2628 let mut ancestors = std::mem::take(&mut auto_folded_ancestors);
2629 if ancestors.len() > 1 {
2630 ancestors.reverse();
2631 self.ancestors.insert(
2632 entry.id,
2633 FoldedAncestors {
2634 current_ancestor_depth: depth,
2635 ancestors,
2636 },
2637 );
2638 }
2639 }
2640 auto_folded_ancestors.clear();
2641 visible_worktree_entries.push(entry.to_owned());
2642 let precedes_new_entry = if let Some(new_entry_id) = new_entry_parent_id {
2643 entry.id == new_entry_id || {
2644 self.ancestors.get(&entry.id).map_or(false, |entries| {
2645 entries
2646 .ancestors
2647 .iter()
2648 .any(|entry_id| *entry_id == new_entry_id)
2649 })
2650 }
2651 } else {
2652 false
2653 };
2654 if precedes_new_entry {
2655 visible_worktree_entries.push(GitEntry {
2656 entry: Entry {
2657 id: NEW_ENTRY_ID,
2658 kind: new_entry_kind,
2659 path: entry.path.join("\0").into(),
2660 inode: 0,
2661 mtime: entry.mtime,
2662 size: entry.size,
2663 is_ignored: entry.is_ignored,
2664 is_external: false,
2665 is_private: false,
2666 is_always_included: entry.is_always_included,
2667 canonical_path: entry.canonical_path.clone(),
2668 char_bag: entry.char_bag,
2669 is_fifo: entry.is_fifo,
2670 },
2671 git_summary: entry.git_summary,
2672 });
2673 }
2674 let worktree_abs_path = worktree.read(cx).abs_path();
2675 let (depth, path) = if Some(entry.entry) == worktree.read(cx).root_entry() {
2676 let Some(path_name) = worktree_abs_path
2677 .file_name()
2678 .with_context(|| {
2679 format!("Worktree abs path has no file name, root entry: {entry:?}")
2680 })
2681 .log_err()
2682 else {
2683 continue;
2684 };
2685 let path = Arc::from(Path::new(path_name));
2686 let depth = 0;
2687 (depth, path)
2688 } else if entry.is_file() {
2689 let Some(path_name) = entry
2690 .path
2691 .file_name()
2692 .with_context(|| format!("Non-root entry has no file name: {entry:?}"))
2693 .log_err()
2694 else {
2695 continue;
2696 };
2697 let path = Arc::from(Path::new(path_name));
2698 let depth = entry.path.ancestors().count() - 1;
2699 (depth, path)
2700 } else {
2701 let path = self
2702 .ancestors
2703 .get(&entry.id)
2704 .and_then(|ancestors| {
2705 let outermost_ancestor = ancestors.ancestors.last()?;
2706 let root_folded_entry = worktree
2707 .read(cx)
2708 .entry_for_id(*outermost_ancestor)?
2709 .path
2710 .as_ref();
2711 entry
2712 .path
2713 .strip_prefix(root_folded_entry)
2714 .ok()
2715 .and_then(|suffix| {
2716 let full_path = Path::new(root_folded_entry.file_name()?);
2717 Some(Arc::<Path>::from(full_path.join(suffix)))
2718 })
2719 })
2720 .or_else(|| entry.path.file_name().map(Path::new).map(Arc::from))
2721 .unwrap_or_else(|| entry.path.clone());
2722 let depth = path.components().count();
2723 (depth, path)
2724 };
2725 let width_estimate = item_width_estimate(
2726 depth,
2727 path.to_string_lossy().chars().count(),
2728 entry.canonical_path.is_some(),
2729 );
2730
2731 match max_width_item.as_mut() {
2732 Some((id, worktree_id, width)) => {
2733 if *width < width_estimate {
2734 *id = entry.id;
2735 *worktree_id = worktree.read(cx).id();
2736 *width = width_estimate;
2737 }
2738 }
2739 None => {
2740 max_width_item = Some((entry.id, worktree.read(cx).id(), width_estimate))
2741 }
2742 }
2743
2744 if expanded_dir_ids.binary_search(&entry.id).is_err()
2745 && entry_iter.advance_to_sibling()
2746 {
2747 continue;
2748 }
2749 entry_iter.advance();
2750 }
2751
2752 project::sort_worktree_entries(&mut visible_worktree_entries);
2753
2754 self.visible_entries
2755 .push((worktree_id, visible_worktree_entries, OnceCell::new()));
2756 }
2757
2758 if let Some((project_entry_id, worktree_id, _)) = max_width_item {
2759 let mut visited_worktrees_length = 0;
2760 let index = self.visible_entries.iter().find_map(|(id, entries, _)| {
2761 if worktree_id == *id {
2762 entries
2763 .iter()
2764 .position(|entry| entry.id == project_entry_id)
2765 } else {
2766 visited_worktrees_length += entries.len();
2767 None
2768 }
2769 });
2770 if let Some(index) = index {
2771 self.max_width_item_index = Some(visited_worktrees_length + index);
2772 }
2773 }
2774 if let Some((worktree_id, entry_id)) = new_selected_entry {
2775 self.selection = Some(SelectedEntry {
2776 worktree_id,
2777 entry_id,
2778 });
2779 }
2780 }
2781
2782 fn expand_entry(
2783 &mut self,
2784 worktree_id: WorktreeId,
2785 entry_id: ProjectEntryId,
2786 cx: &mut Context<Self>,
2787 ) {
2788 self.project.update(cx, |project, cx| {
2789 if let Some((worktree, expanded_dir_ids)) = project
2790 .worktree_for_id(worktree_id, cx)
2791 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
2792 {
2793 project.expand_entry(worktree_id, entry_id, cx);
2794 let worktree = worktree.read(cx);
2795
2796 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
2797 loop {
2798 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
2799 expanded_dir_ids.insert(ix, entry.id);
2800 }
2801
2802 if let Some(parent_entry) =
2803 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
2804 {
2805 entry = parent_entry;
2806 } else {
2807 break;
2808 }
2809 }
2810 }
2811 }
2812 });
2813 }
2814
2815 fn drop_external_files(
2816 &mut self,
2817 paths: &[PathBuf],
2818 entry_id: ProjectEntryId,
2819 window: &mut Window,
2820 cx: &mut Context<Self>,
2821 ) {
2822 let mut paths: Vec<Arc<Path>> = paths.iter().map(|path| Arc::from(path.clone())).collect();
2823
2824 let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
2825
2826 let Some((target_directory, worktree)) = maybe!({
2827 let worktree = self.project.read(cx).worktree_for_entry(entry_id, cx)?;
2828 let entry = worktree.read(cx).entry_for_id(entry_id)?;
2829 let path = worktree.read(cx).absolutize(&entry.path).ok()?;
2830 let target_directory = if path.is_dir() {
2831 path
2832 } else {
2833 path.parent()?.to_path_buf()
2834 };
2835 Some((target_directory, worktree))
2836 }) else {
2837 return;
2838 };
2839
2840 let mut paths_to_replace = Vec::new();
2841 for path in &paths {
2842 if let Some(name) = path.file_name() {
2843 let mut target_path = target_directory.clone();
2844 target_path.push(name);
2845 if target_path.exists() {
2846 paths_to_replace.push((name.to_string_lossy().to_string(), path.clone()));
2847 }
2848 }
2849 }
2850
2851 cx.spawn_in(window, |this, mut cx| {
2852 async move {
2853 for (filename, original_path) in &paths_to_replace {
2854 let answer = cx.update(|window, cx| {
2855 window
2856 .prompt(
2857 PromptLevel::Info,
2858 format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
2859 None,
2860 &["Replace", "Cancel"],
2861 cx,
2862 )
2863 })?.await?;
2864
2865 if answer == 1 {
2866 if let Some(item_idx) = paths.iter().position(|p| p == original_path) {
2867 paths.remove(item_idx);
2868 }
2869 }
2870 }
2871
2872 if paths.is_empty() {
2873 return Ok(());
2874 }
2875
2876 let task = worktree.update(&mut cx, |worktree, cx| {
2877 worktree.copy_external_entries(target_directory, paths, true, cx)
2878 })?;
2879
2880 let opened_entries = task.await?;
2881 this.update(&mut cx, |this, cx| {
2882 if open_file_after_drop && !opened_entries.is_empty() {
2883 this.open_entry(opened_entries[0], true, false, cx);
2884 }
2885 })
2886 }
2887 .log_err()
2888 })
2889 .detach();
2890 }
2891
2892 fn drag_onto(
2893 &mut self,
2894 selections: &DraggedSelection,
2895 target_entry_id: ProjectEntryId,
2896 is_file: bool,
2897 window: &mut Window,
2898 cx: &mut Context<Self>,
2899 ) {
2900 let should_copy = window.modifiers().alt;
2901 if should_copy {
2902 let _ = maybe!({
2903 let project = self.project.read(cx);
2904 let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
2905 let worktree_id = target_worktree.read(cx).id();
2906 let target_entry = target_worktree
2907 .read(cx)
2908 .entry_for_id(target_entry_id)?
2909 .clone();
2910
2911 let mut copy_tasks = Vec::new();
2912 let mut disambiguation_range = None;
2913 for selection in selections.items() {
2914 let (new_path, new_disambiguation_range) = self.create_paste_path(
2915 selection,
2916 (target_worktree.clone(), &target_entry),
2917 cx,
2918 )?;
2919
2920 let task = self.project.update(cx, |project, cx| {
2921 project.copy_entry(selection.entry_id, None, new_path, cx)
2922 });
2923 copy_tasks.push(task);
2924 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
2925 }
2926
2927 let item_count = copy_tasks.len();
2928
2929 cx.spawn_in(window, |project_panel, mut cx| async move {
2930 let mut last_succeed = None;
2931 for task in copy_tasks.into_iter() {
2932 if let Some(Some(entry)) = task.await.log_err() {
2933 last_succeed = Some(entry.id);
2934 }
2935 }
2936 // update selection
2937 if let Some(entry_id) = last_succeed {
2938 project_panel
2939 .update_in(&mut cx, |project_panel, window, cx| {
2940 project_panel.selection = Some(SelectedEntry {
2941 worktree_id,
2942 entry_id,
2943 });
2944
2945 // if only one entry was dragged and it was disambiguated, open the rename editor
2946 if item_count == 1 && disambiguation_range.is_some() {
2947 project_panel.rename_impl(disambiguation_range, window, cx);
2948 }
2949 })
2950 .ok();
2951 }
2952 })
2953 .detach();
2954 Some(())
2955 });
2956 } else {
2957 for selection in selections.items() {
2958 self.move_entry(selection.entry_id, target_entry_id, is_file, cx);
2959 }
2960 }
2961 }
2962
2963 fn index_for_entry(
2964 &self,
2965 entry_id: ProjectEntryId,
2966 worktree_id: WorktreeId,
2967 ) -> Option<(usize, usize, usize)> {
2968 let mut worktree_ix = 0;
2969 let mut total_ix = 0;
2970 for (current_worktree_id, visible_worktree_entries, _) in &self.visible_entries {
2971 if worktree_id != *current_worktree_id {
2972 total_ix += visible_worktree_entries.len();
2973 worktree_ix += 1;
2974 continue;
2975 }
2976
2977 return visible_worktree_entries
2978 .iter()
2979 .enumerate()
2980 .find(|(_, entry)| entry.id == entry_id)
2981 .map(|(ix, _)| (worktree_ix, ix, total_ix + ix));
2982 }
2983 None
2984 }
2985
2986 fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, GitEntryRef)> {
2987 let mut offset = 0;
2988 for (worktree_id, visible_worktree_entries, _) in &self.visible_entries {
2989 if visible_worktree_entries.len() > offset + index {
2990 return visible_worktree_entries
2991 .get(index)
2992 .map(|entry| (*worktree_id, entry.to_ref()));
2993 }
2994 offset += visible_worktree_entries.len();
2995 }
2996 None
2997 }
2998
2999 fn iter_visible_entries(
3000 &self,
3001 range: Range<usize>,
3002 window: &mut Window,
3003 cx: &mut Context<ProjectPanel>,
3004 mut callback: impl FnMut(&Entry, &HashSet<Arc<Path>>, &mut Window, &mut Context<ProjectPanel>),
3005 ) {
3006 let mut ix = 0;
3007 for (_, visible_worktree_entries, entries_paths) in &self.visible_entries {
3008 if ix >= range.end {
3009 return;
3010 }
3011
3012 if ix + visible_worktree_entries.len() <= range.start {
3013 ix += visible_worktree_entries.len();
3014 continue;
3015 }
3016
3017 let end_ix = range.end.min(ix + visible_worktree_entries.len());
3018 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
3019 let entries = entries_paths.get_or_init(|| {
3020 visible_worktree_entries
3021 .iter()
3022 .map(|e| (e.path.clone()))
3023 .collect()
3024 });
3025 for entry in visible_worktree_entries[entry_range].iter() {
3026 callback(&entry, entries, window, cx);
3027 }
3028 ix = end_ix;
3029 }
3030 }
3031
3032 fn for_each_visible_entry(
3033 &self,
3034 range: Range<usize>,
3035 window: &mut Window,
3036 cx: &mut Context<ProjectPanel>,
3037 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut Window, &mut Context<ProjectPanel>),
3038 ) {
3039 let mut ix = 0;
3040 for (worktree_id, visible_worktree_entries, entries_paths) in &self.visible_entries {
3041 if ix >= range.end {
3042 return;
3043 }
3044
3045 if ix + visible_worktree_entries.len() <= range.start {
3046 ix += visible_worktree_entries.len();
3047 continue;
3048 }
3049
3050 let end_ix = range.end.min(ix + visible_worktree_entries.len());
3051 let (git_status_setting, show_file_icons, show_folder_icons) = {
3052 let settings = ProjectPanelSettings::get_global(cx);
3053 (
3054 settings.git_status,
3055 settings.file_icons,
3056 settings.folder_icons,
3057 )
3058 };
3059 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
3060 let snapshot = worktree.read(cx).snapshot();
3061 let root_name = OsStr::new(snapshot.root_name());
3062 let expanded_entry_ids = self
3063 .expanded_dir_ids
3064 .get(&snapshot.id())
3065 .map(Vec::as_slice)
3066 .unwrap_or(&[]);
3067
3068 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
3069 let entries = entries_paths.get_or_init(|| {
3070 visible_worktree_entries
3071 .iter()
3072 .map(|e| (e.path.clone()))
3073 .collect()
3074 });
3075 for entry in visible_worktree_entries[entry_range].iter() {
3076 let status = git_status_setting
3077 .then_some(entry.git_summary)
3078 .unwrap_or_default();
3079 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
3080 let icon = match entry.kind {
3081 EntryKind::File => {
3082 if show_file_icons {
3083 FileIcons::get_icon(&entry.path, cx)
3084 } else {
3085 None
3086 }
3087 }
3088 _ => {
3089 if show_folder_icons {
3090 FileIcons::get_folder_icon(is_expanded, cx)
3091 } else {
3092 FileIcons::get_chevron_icon(is_expanded, cx)
3093 }
3094 }
3095 };
3096
3097 let (depth, difference) =
3098 ProjectPanel::calculate_depth_and_difference(&entry, entries);
3099
3100 let filename = match difference {
3101 diff if diff > 1 => entry
3102 .path
3103 .iter()
3104 .skip(entry.path.components().count() - diff)
3105 .collect::<PathBuf>()
3106 .to_str()
3107 .unwrap_or_default()
3108 .to_string(),
3109 _ => entry
3110 .path
3111 .file_name()
3112 .map(|name| name.to_string_lossy().into_owned())
3113 .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
3114 };
3115 let selection = SelectedEntry {
3116 worktree_id: snapshot.id(),
3117 entry_id: entry.id,
3118 };
3119
3120 let is_marked = self.marked_entries.contains(&selection);
3121
3122 let diagnostic_severity = self
3123 .diagnostics
3124 .get(&(*worktree_id, entry.path.to_path_buf()))
3125 .cloned();
3126
3127 let filename_text_color =
3128 entry_git_aware_label_color(status, entry.is_ignored, is_marked);
3129
3130 let mut details = EntryDetails {
3131 filename,
3132 icon,
3133 path: entry.path.clone(),
3134 depth,
3135 kind: entry.kind,
3136 is_ignored: entry.is_ignored,
3137 is_expanded,
3138 is_selected: self.selection == Some(selection),
3139 is_marked,
3140 is_editing: false,
3141 is_processing: false,
3142 is_cut: self
3143 .clipboard
3144 .as_ref()
3145 .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
3146 filename_text_color,
3147 diagnostic_severity,
3148 git_status: status,
3149 is_private: entry.is_private,
3150 worktree_id: *worktree_id,
3151 canonical_path: entry.canonical_path.clone(),
3152 };
3153
3154 if let Some(edit_state) = &self.edit_state {
3155 let is_edited_entry = if edit_state.is_new_entry() {
3156 entry.id == NEW_ENTRY_ID
3157 } else {
3158 entry.id == edit_state.entry_id
3159 || self
3160 .ancestors
3161 .get(&entry.id)
3162 .is_some_and(|auto_folded_dirs| {
3163 auto_folded_dirs
3164 .ancestors
3165 .iter()
3166 .any(|entry_id| *entry_id == edit_state.entry_id)
3167 })
3168 };
3169
3170 if is_edited_entry {
3171 if let Some(processing_filename) = &edit_state.processing_filename {
3172 details.is_processing = true;
3173 if let Some(ancestors) = edit_state
3174 .leaf_entry_id
3175 .and_then(|entry| self.ancestors.get(&entry))
3176 {
3177 let position = ancestors.ancestors.iter().position(|entry_id| *entry_id == edit_state.entry_id).expect("Edited sub-entry should be an ancestor of selected leaf entry") + 1;
3178 let all_components = ancestors.ancestors.len();
3179
3180 let prefix_components = all_components - position;
3181 let suffix_components = position.checked_sub(1);
3182 let mut previous_components =
3183 Path::new(&details.filename).components();
3184 let mut new_path = previous_components
3185 .by_ref()
3186 .take(prefix_components)
3187 .collect::<PathBuf>();
3188 if let Some(last_component) =
3189 Path::new(processing_filename).components().last()
3190 {
3191 new_path.push(last_component);
3192 previous_components.next();
3193 }
3194
3195 if let Some(_) = suffix_components {
3196 new_path.push(previous_components);
3197 }
3198 if let Some(str) = new_path.to_str() {
3199 details.filename.clear();
3200 details.filename.push_str(str);
3201 }
3202 } else {
3203 details.filename.clear();
3204 details.filename.push_str(processing_filename);
3205 }
3206 } else {
3207 if edit_state.is_new_entry() {
3208 details.filename.clear();
3209 }
3210 details.is_editing = true;
3211 }
3212 }
3213 }
3214
3215 callback(entry.id, details, window, cx);
3216 }
3217 }
3218 ix = end_ix;
3219 }
3220 }
3221
3222 fn find_entry_in_worktree(
3223 &self,
3224 worktree_id: WorktreeId,
3225 reverse_search: bool,
3226 only_visible_entries: bool,
3227 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3228 cx: &mut Context<Self>,
3229 ) -> Option<GitEntry> {
3230 if only_visible_entries {
3231 let entries = self
3232 .visible_entries
3233 .iter()
3234 .find_map(|(tree_id, entries, _)| {
3235 if worktree_id == *tree_id {
3236 Some(entries)
3237 } else {
3238 None
3239 }
3240 })?
3241 .clone();
3242
3243 return utils::ReversibleIterable::new(entries.iter(), reverse_search)
3244 .find(|ele| predicate(ele.to_ref(), worktree_id))
3245 .cloned();
3246 }
3247
3248 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
3249 worktree.update(cx, |tree, _| {
3250 utils::ReversibleIterable::new(
3251 tree.entries(true, 0usize).with_git_statuses(),
3252 reverse_search,
3253 )
3254 .find_single_ended(|ele| predicate(*ele, worktree_id))
3255 .map(|ele| ele.to_owned())
3256 })
3257 }
3258
3259 fn find_entry(
3260 &self,
3261 start: Option<&SelectedEntry>,
3262 reverse_search: bool,
3263 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3264 cx: &mut Context<Self>,
3265 ) -> Option<SelectedEntry> {
3266 let mut worktree_ids: Vec<_> = self
3267 .visible_entries
3268 .iter()
3269 .map(|(worktree_id, _, _)| *worktree_id)
3270 .collect();
3271
3272 let mut last_found: Option<SelectedEntry> = None;
3273
3274 if let Some(start) = start {
3275 let worktree = self
3276 .project
3277 .read(cx)
3278 .worktree_for_id(start.worktree_id, cx)?;
3279
3280 let search = worktree.update(cx, |tree, _| {
3281 let entry = tree.entry_for_id(start.entry_id)?;
3282 let root_entry = tree.root_entry()?;
3283 let tree_id = tree.id();
3284
3285 let mut first_iter = tree
3286 .traverse_from_path(true, true, true, entry.path.as_ref())
3287 .with_git_statuses();
3288
3289 if reverse_search {
3290 first_iter.next();
3291 }
3292
3293 let first = first_iter
3294 .enumerate()
3295 .take_until(|(count, entry)| entry.entry == root_entry && *count != 0usize)
3296 .map(|(_, entry)| entry)
3297 .find(|ele| predicate(*ele, tree_id))
3298 .map(|ele| ele.to_owned());
3299
3300 let second_iter = tree.entries(true, 0usize).with_git_statuses();
3301
3302 let second = if reverse_search {
3303 second_iter
3304 .take_until(|ele| ele.id == start.entry_id)
3305 .filter(|ele| predicate(*ele, tree_id))
3306 .last()
3307 .map(|ele| ele.to_owned())
3308 } else {
3309 second_iter
3310 .take_while(|ele| ele.id != start.entry_id)
3311 .filter(|ele| predicate(*ele, tree_id))
3312 .last()
3313 .map(|ele| ele.to_owned())
3314 };
3315
3316 if reverse_search {
3317 Some((second, first))
3318 } else {
3319 Some((first, second))
3320 }
3321 });
3322
3323 if let Some((first, second)) = search {
3324 let first = first.map(|entry| SelectedEntry {
3325 worktree_id: start.worktree_id,
3326 entry_id: entry.id,
3327 });
3328
3329 let second = second.map(|entry| SelectedEntry {
3330 worktree_id: start.worktree_id,
3331 entry_id: entry.id,
3332 });
3333
3334 if first.is_some() {
3335 return first;
3336 }
3337 last_found = second;
3338
3339 let idx = worktree_ids
3340 .iter()
3341 .enumerate()
3342 .find(|(_, ele)| **ele == start.worktree_id)
3343 .map(|(idx, _)| idx);
3344
3345 if let Some(idx) = idx {
3346 worktree_ids.rotate_left(idx + 1usize);
3347 worktree_ids.pop();
3348 }
3349 }
3350 }
3351
3352 for tree_id in worktree_ids.into_iter() {
3353 if let Some(found) =
3354 self.find_entry_in_worktree(tree_id, reverse_search, false, &predicate, cx)
3355 {
3356 return Some(SelectedEntry {
3357 worktree_id: tree_id,
3358 entry_id: found.id,
3359 });
3360 }
3361 }
3362
3363 last_found
3364 }
3365
3366 fn find_visible_entry(
3367 &self,
3368 start: Option<&SelectedEntry>,
3369 reverse_search: bool,
3370 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3371 cx: &mut Context<Self>,
3372 ) -> Option<SelectedEntry> {
3373 let mut worktree_ids: Vec<_> = self
3374 .visible_entries
3375 .iter()
3376 .map(|(worktree_id, _, _)| *worktree_id)
3377 .collect();
3378
3379 let mut last_found: Option<SelectedEntry> = None;
3380
3381 if let Some(start) = start {
3382 let entries = self
3383 .visible_entries
3384 .iter()
3385 .find(|(worktree_id, _, _)| *worktree_id == start.worktree_id)
3386 .map(|(_, entries, _)| entries)?;
3387
3388 let mut start_idx = entries
3389 .iter()
3390 .enumerate()
3391 .find(|(_, ele)| ele.id == start.entry_id)
3392 .map(|(idx, _)| idx)?;
3393
3394 if reverse_search {
3395 start_idx = start_idx.saturating_add(1usize);
3396 }
3397
3398 let (left, right) = entries.split_at_checked(start_idx)?;
3399
3400 let (first_iter, second_iter) = if reverse_search {
3401 (
3402 utils::ReversibleIterable::new(left.iter(), reverse_search),
3403 utils::ReversibleIterable::new(right.iter(), reverse_search),
3404 )
3405 } else {
3406 (
3407 utils::ReversibleIterable::new(right.iter(), reverse_search),
3408 utils::ReversibleIterable::new(left.iter(), reverse_search),
3409 )
3410 };
3411
3412 let first_search = first_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3413 let second_search = second_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3414
3415 if first_search.is_some() {
3416 return first_search.map(|entry| SelectedEntry {
3417 worktree_id: start.worktree_id,
3418 entry_id: entry.id,
3419 });
3420 }
3421
3422 last_found = second_search.map(|entry| SelectedEntry {
3423 worktree_id: start.worktree_id,
3424 entry_id: entry.id,
3425 });
3426
3427 let idx = worktree_ids
3428 .iter()
3429 .enumerate()
3430 .find(|(_, ele)| **ele == start.worktree_id)
3431 .map(|(idx, _)| idx);
3432
3433 if let Some(idx) = idx {
3434 worktree_ids.rotate_left(idx + 1usize);
3435 worktree_ids.pop();
3436 }
3437 }
3438
3439 for tree_id in worktree_ids.into_iter() {
3440 if let Some(found) =
3441 self.find_entry_in_worktree(tree_id, reverse_search, true, &predicate, cx)
3442 {
3443 return Some(SelectedEntry {
3444 worktree_id: tree_id,
3445 entry_id: found.id,
3446 });
3447 }
3448 }
3449
3450 last_found
3451 }
3452
3453 fn calculate_depth_and_difference(
3454 entry: &Entry,
3455 visible_worktree_entries: &HashSet<Arc<Path>>,
3456 ) -> (usize, usize) {
3457 let (depth, difference) = entry
3458 .path
3459 .ancestors()
3460 .skip(1) // Skip the entry itself
3461 .find_map(|ancestor| {
3462 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
3463 let entry_path_components_count = entry.path.components().count();
3464 let parent_path_components_count = parent_entry.components().count();
3465 let difference = entry_path_components_count - parent_path_components_count;
3466 let depth = parent_entry
3467 .ancestors()
3468 .skip(1)
3469 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
3470 .count();
3471 Some((depth + 1, difference))
3472 } else {
3473 None
3474 }
3475 })
3476 .unwrap_or((0, 0));
3477
3478 (depth, difference)
3479 }
3480
3481 fn render_entry(
3482 &self,
3483 entry_id: ProjectEntryId,
3484 details: EntryDetails,
3485 window: &mut Window,
3486 cx: &mut Context<Self>,
3487 ) -> Stateful<Div> {
3488 const GROUP_NAME: &str = "project_entry";
3489
3490 let kind = details.kind;
3491 let settings = ProjectPanelSettings::get_global(cx);
3492 let show_editor = details.is_editing && !details.is_processing;
3493
3494 let selection = SelectedEntry {
3495 worktree_id: details.worktree_id,
3496 entry_id,
3497 };
3498
3499 let is_marked = self.marked_entries.contains(&selection);
3500 let is_active = self
3501 .selection
3502 .map_or(false, |selection| selection.entry_id == entry_id);
3503
3504 let file_name = details.filename.clone();
3505
3506 let mut icon = details.icon.clone();
3507 if settings.file_icons && show_editor && details.kind.is_file() {
3508 let filename = self.filename_editor.read(cx).text(cx);
3509 if filename.len() > 2 {
3510 icon = FileIcons::get_icon(Path::new(&filename), cx);
3511 }
3512 }
3513
3514 let filename_text_color = details.filename_text_color;
3515 let diagnostic_severity = details.diagnostic_severity;
3516 let item_colors = get_item_color(cx);
3517
3518 let canonical_path = details
3519 .canonical_path
3520 .as_ref()
3521 .map(|f| f.to_string_lossy().to_string());
3522 let path = details.path.clone();
3523
3524 let depth = details.depth;
3525 let worktree_id = details.worktree_id;
3526 let selections = Arc::new(self.marked_entries.clone());
3527 let is_local = self.project.read(cx).is_local();
3528
3529 let dragged_selection = DraggedSelection {
3530 active_selection: selection,
3531 marked_selections: selections,
3532 };
3533
3534 let bg_color = if is_marked || is_active {
3535 item_colors.marked_active
3536 } else {
3537 item_colors.default
3538 };
3539
3540 let bg_hover_color = if self.mouse_down || is_marked || is_active {
3541 item_colors.marked_active
3542 } else if !is_active {
3543 item_colors.hover
3544 } else {
3545 item_colors.default
3546 };
3547
3548 let border_color =
3549 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
3550 item_colors.focused
3551 } else {
3552 bg_color
3553 };
3554
3555 let border_hover_color =
3556 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
3557 item_colors.focused
3558 } else {
3559 bg_hover_color
3560 };
3561
3562 let folded_directory_drag_target = self.folded_directory_drag_target;
3563
3564 div()
3565 .id(entry_id.to_proto() as usize)
3566 .group(GROUP_NAME)
3567 .cursor_pointer()
3568 .rounded_none()
3569 .bg(bg_color)
3570 .border_1()
3571 .border_r_2()
3572 .border_color(border_color)
3573 .hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
3574 .when(is_local, |div| {
3575 div.on_drag_move::<ExternalPaths>(cx.listener(
3576 move |this, event: &DragMoveEvent<ExternalPaths>, _, cx| {
3577 if event.bounds.contains(&event.event.position) {
3578 if this.last_external_paths_drag_over_entry == Some(entry_id) {
3579 return;
3580 }
3581 this.last_external_paths_drag_over_entry = Some(entry_id);
3582 this.marked_entries.clear();
3583
3584 let Some((worktree, path, entry)) = maybe!({
3585 let worktree = this
3586 .project
3587 .read(cx)
3588 .worktree_for_id(selection.worktree_id, cx)?;
3589 let worktree = worktree.read(cx);
3590 let abs_path = worktree.absolutize(&path).log_err()?;
3591 let path = if abs_path.is_dir() {
3592 path.as_ref()
3593 } else {
3594 path.parent()?
3595 };
3596 let entry = worktree.entry_for_path(path)?;
3597 Some((worktree, path, entry))
3598 }) else {
3599 return;
3600 };
3601
3602 this.marked_entries.insert(SelectedEntry {
3603 entry_id: entry.id,
3604 worktree_id: worktree.id(),
3605 });
3606
3607 for entry in worktree.child_entries(path) {
3608 this.marked_entries.insert(SelectedEntry {
3609 entry_id: entry.id,
3610 worktree_id: worktree.id(),
3611 });
3612 }
3613
3614 cx.notify();
3615 }
3616 },
3617 ))
3618 .on_drop(cx.listener(
3619 move |this, external_paths: &ExternalPaths, window, cx| {
3620 this.hover_scroll_task.take();
3621 this.last_external_paths_drag_over_entry = None;
3622 this.marked_entries.clear();
3623 this.drop_external_files(external_paths.paths(), entry_id, window, cx);
3624 cx.stop_propagation();
3625 },
3626 ))
3627 })
3628 .on_drag_move::<DraggedSelection>(cx.listener(
3629 move |this, event: &DragMoveEvent<DraggedSelection>, window, cx| {
3630 if event.bounds.contains(&event.event.position) {
3631 if this.last_selection_drag_over_entry == Some(entry_id) {
3632 return;
3633 }
3634 this.last_selection_drag_over_entry = Some(entry_id);
3635 this.hover_expand_task.take();
3636
3637 if !kind.is_dir()
3638 || this
3639 .expanded_dir_ids
3640 .get(&details.worktree_id)
3641 .map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
3642 {
3643 return;
3644 }
3645
3646 let bounds = event.bounds;
3647 this.hover_expand_task =
3648 Some(cx.spawn_in(window, |this, mut cx| async move {
3649 cx.background_executor()
3650 .timer(Duration::from_millis(500))
3651 .await;
3652 this.update_in(&mut cx, |this, window, cx| {
3653 this.hover_expand_task.take();
3654 if this.last_selection_drag_over_entry == Some(entry_id)
3655 && bounds.contains(&window.mouse_position())
3656 {
3657 this.expand_entry(worktree_id, entry_id, cx);
3658 this.update_visible_entries(
3659 Some((worktree_id, entry_id)),
3660 cx,
3661 );
3662 cx.notify();
3663 }
3664 })
3665 .ok();
3666 }));
3667 }
3668 },
3669 ))
3670 .on_drag(
3671 dragged_selection,
3672 move |selection, click_offset, _window, cx| {
3673 cx.new(|_| DraggedProjectEntryView {
3674 details: details.clone(),
3675 click_offset,
3676 selection: selection.active_selection,
3677 selections: selection.marked_selections.clone(),
3678 })
3679 },
3680 )
3681 .drag_over::<DraggedSelection>(move |style, _, _, _| {
3682 if folded_directory_drag_target.is_some() {
3683 return style;
3684 }
3685 style.bg(item_colors.drag_over)
3686 })
3687 .on_drop(
3688 cx.listener(move |this, selections: &DraggedSelection, window, cx| {
3689 this.hover_scroll_task.take();
3690 this.hover_expand_task.take();
3691 if folded_directory_drag_target.is_some() {
3692 return;
3693 }
3694 this.drag_onto(selections, entry_id, kind.is_file(), window, cx);
3695 }),
3696 )
3697 .on_mouse_down(
3698 MouseButton::Left,
3699 cx.listener(move |this, _, _, cx| {
3700 this.mouse_down = true;
3701 cx.propagate();
3702 }),
3703 )
3704 .on_click(
3705 cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
3706 if event.down.button == MouseButton::Right
3707 || event.down.first_mouse
3708 || show_editor
3709 {
3710 return;
3711 }
3712 if event.down.button == MouseButton::Left {
3713 this.mouse_down = false;
3714 }
3715 cx.stop_propagation();
3716
3717 if let Some(selection) = this.selection.filter(|_| event.modifiers().shift) {
3718 let current_selection = this.index_for_selection(selection);
3719 let clicked_entry = SelectedEntry {
3720 entry_id,
3721 worktree_id,
3722 };
3723 let target_selection = this.index_for_selection(clicked_entry);
3724 if let Some(((_, _, source_index), (_, _, target_index))) =
3725 current_selection.zip(target_selection)
3726 {
3727 let range_start = source_index.min(target_index);
3728 let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
3729 let mut new_selections = BTreeSet::new();
3730 this.for_each_visible_entry(
3731 range_start..range_end,
3732 window,
3733 cx,
3734 |entry_id, details, _, _| {
3735 new_selections.insert(SelectedEntry {
3736 entry_id,
3737 worktree_id: details.worktree_id,
3738 });
3739 },
3740 );
3741
3742 this.marked_entries = this
3743 .marked_entries
3744 .union(&new_selections)
3745 .cloned()
3746 .collect();
3747
3748 this.selection = Some(clicked_entry);
3749 this.marked_entries.insert(clicked_entry);
3750 }
3751 } else if event.modifiers().secondary() {
3752 if event.down.click_count > 1 {
3753 this.split_entry(entry_id, cx);
3754 } else {
3755 this.selection = Some(selection);
3756 if !this.marked_entries.insert(selection) {
3757 this.marked_entries.remove(&selection);
3758 }
3759 }
3760 } else if kind.is_dir() {
3761 this.marked_entries.clear();
3762 if event.modifiers().alt {
3763 this.toggle_expand_all(entry_id, window, cx);
3764 } else {
3765 this.toggle_expanded(entry_id, window, cx);
3766 }
3767 } else {
3768 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
3769 let click_count = event.up.click_count;
3770 let focus_opened_item = !preview_tabs_enabled || click_count > 1;
3771 let allow_preview = preview_tabs_enabled && click_count == 1;
3772 this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
3773 }
3774 }),
3775 )
3776 .child(
3777 ListItem::new(entry_id.to_proto() as usize)
3778 .indent_level(depth)
3779 .indent_step_size(px(settings.indent_size))
3780 .spacing(match settings.entry_spacing {
3781 project_panel_settings::EntrySpacing::Comfortable => ListItemSpacing::Dense,
3782 project_panel_settings::EntrySpacing::Standard => {
3783 ListItemSpacing::ExtraDense
3784 }
3785 })
3786 .selectable(false)
3787 .when_some(canonical_path, |this, path| {
3788 this.end_slot::<AnyElement>(
3789 div()
3790 .id("symlink_icon")
3791 .pr_3()
3792 .tooltip(move |window, cx| {
3793 Tooltip::with_meta(
3794 path.to_string(),
3795 None,
3796 "Symbolic Link",
3797 window,
3798 cx,
3799 )
3800 })
3801 .child(
3802 Icon::new(IconName::ArrowUpRight)
3803 .size(IconSize::Indicator)
3804 .color(filename_text_color),
3805 )
3806 .into_any_element(),
3807 )
3808 })
3809 .child(if let Some(icon) = &icon {
3810 if let Some((_, decoration_color)) =
3811 entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
3812 {
3813 let is_warning = diagnostic_severity
3814 .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
3815 .unwrap_or(false);
3816 div().child(
3817 DecoratedIcon::new(
3818 Icon::from_path(icon.clone()).color(Color::Muted),
3819 Some(
3820 IconDecoration::new(
3821 if kind.is_file() {
3822 if is_warning {
3823 IconDecorationKind::Triangle
3824 } else {
3825 IconDecorationKind::X
3826 }
3827 } else {
3828 IconDecorationKind::Dot
3829 },
3830 bg_color,
3831 cx,
3832 )
3833 .group_name(Some(GROUP_NAME.into()))
3834 .knockout_hover_color(bg_hover_color)
3835 .color(decoration_color.color(cx))
3836 .position(Point {
3837 x: px(-2.),
3838 y: px(-2.),
3839 }),
3840 ),
3841 )
3842 .into_any_element(),
3843 )
3844 } else {
3845 h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
3846 }
3847 } else {
3848 if let Some((icon_name, color)) =
3849 entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
3850 {
3851 h_flex()
3852 .size(IconSize::default().rems())
3853 .child(Icon::new(icon_name).color(color).size(IconSize::Small))
3854 } else {
3855 h_flex()
3856 .size(IconSize::default().rems())
3857 .invisible()
3858 .flex_none()
3859 }
3860 })
3861 .child(
3862 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
3863 h_flex().h_6().w_full().child(editor.clone())
3864 } else {
3865 h_flex().h_6().map(|mut this| {
3866 if let Some(folded_ancestors) = self.ancestors.get(&entry_id) {
3867 let components = Path::new(&file_name)
3868 .components()
3869 .map(|comp| {
3870 let comp_str =
3871 comp.as_os_str().to_string_lossy().into_owned();
3872 comp_str
3873 })
3874 .collect::<Vec<_>>();
3875
3876 let components_len = components.len();
3877 let active_index = components_len
3878 - 1
3879 - folded_ancestors.current_ancestor_depth;
3880 const DELIMITER: SharedString =
3881 SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
3882 for (index, component) in components.into_iter().enumerate() {
3883 if index != 0 {
3884 let delimiter_target_index = index - 1;
3885 let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - delimiter_target_index).cloned();
3886 this = this.child(
3887 div()
3888 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
3889 this.hover_scroll_task.take();
3890 this.folded_directory_drag_target = None;
3891 if let Some(target_entry_id) = target_entry_id {
3892 this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
3893 }
3894 }))
3895 .on_drag_move(cx.listener(
3896 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
3897 if event.bounds.contains(&event.event.position) {
3898 this.folded_directory_drag_target = Some(
3899 FoldedDirectoryDragTarget {
3900 entry_id,
3901 index: delimiter_target_index,
3902 is_delimiter_target: true,
3903 }
3904 );
3905 } else {
3906 let is_current_target = this.folded_directory_drag_target
3907 .map_or(false, |target|
3908 target.entry_id == entry_id &&
3909 target.index == delimiter_target_index &&
3910 target.is_delimiter_target
3911 );
3912 if is_current_target {
3913 this.folded_directory_drag_target = None;
3914 }
3915 }
3916
3917 },
3918 ))
3919 .child(
3920 Label::new(DELIMITER.clone())
3921 .single_line()
3922 .color(filename_text_color)
3923 )
3924 );
3925 }
3926 let id = SharedString::from(format!(
3927 "project_panel_path_component_{}_{index}",
3928 entry_id.to_usize()
3929 ));
3930 let label = div()
3931 .id(id)
3932 .on_click(cx.listener(move |this, _, _, cx| {
3933 if index != active_index {
3934 if let Some(folds) =
3935 this.ancestors.get_mut(&entry_id)
3936 {
3937 folds.current_ancestor_depth =
3938 components_len - 1 - index;
3939 cx.notify();
3940 }
3941 }
3942 }))
3943 .when(index != components_len - 1, |div|{
3944 let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned();
3945 div
3946 .on_drag_move(cx.listener(
3947 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
3948 if event.bounds.contains(&event.event.position) {
3949 this.folded_directory_drag_target = Some(
3950 FoldedDirectoryDragTarget {
3951 entry_id,
3952 index,
3953 is_delimiter_target: false,
3954 }
3955 );
3956 } else {
3957 let is_current_target = this.folded_directory_drag_target
3958 .as_ref()
3959 .map_or(false, |target|
3960 target.entry_id == entry_id &&
3961 target.index == index &&
3962 !target.is_delimiter_target
3963 );
3964 if is_current_target {
3965 this.folded_directory_drag_target = None;
3966 }
3967 }
3968 },
3969 ))
3970 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| {
3971 this.hover_scroll_task.take();
3972 this.folded_directory_drag_target = None;
3973 if let Some(target_entry_id) = target_entry_id {
3974 this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
3975 }
3976 }))
3977 .when(folded_directory_drag_target.map_or(false, |target|
3978 target.entry_id == entry_id &&
3979 target.index == index
3980 ), |this| {
3981 this.bg(item_colors.drag_over)
3982 })
3983 })
3984 .child(
3985 Label::new(component)
3986 .single_line()
3987 .color(filename_text_color)
3988 .when(
3989 index == active_index
3990 && (is_active || is_marked),
3991 |this| this.underline(true),
3992 ),
3993 );
3994
3995 this = this.child(label);
3996 }
3997
3998 this
3999 } else {
4000 this.child(
4001 Label::new(file_name)
4002 .single_line()
4003 .color(filename_text_color),
4004 )
4005 }
4006 })
4007 }
4008 .ml_1(),
4009 )
4010 .on_secondary_mouse_down(cx.listener(
4011 move |this, event: &MouseDownEvent, window, cx| {
4012 // Stop propagation to prevent the catch-all context menu for the project
4013 // panel from being deployed.
4014 cx.stop_propagation();
4015 // Some context menu actions apply to all marked entries. If the user
4016 // right-clicks on an entry that is not marked, they may not realize the
4017 // action applies to multiple entries. To avoid inadvertent changes, all
4018 // entries are unmarked.
4019 if !this.marked_entries.contains(&selection) {
4020 this.marked_entries.clear();
4021 }
4022 this.deploy_context_menu(event.position, entry_id, window, cx);
4023 },
4024 ))
4025 .overflow_x(),
4026 )
4027 }
4028
4029 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
4030 if !Self::should_show_scrollbar(cx)
4031 || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
4032 {
4033 return None;
4034 }
4035 Some(
4036 div()
4037 .occlude()
4038 .id("project-panel-vertical-scroll")
4039 .on_mouse_move(cx.listener(|_, _, _, cx| {
4040 cx.notify();
4041 cx.stop_propagation()
4042 }))
4043 .on_hover(|_, _, cx| {
4044 cx.stop_propagation();
4045 })
4046 .on_any_mouse_down(|_, _, cx| {
4047 cx.stop_propagation();
4048 })
4049 .on_mouse_up(
4050 MouseButton::Left,
4051 cx.listener(|this, _, window, cx| {
4052 if !this.vertical_scrollbar_state.is_dragging()
4053 && !this.focus_handle.contains_focused(window, cx)
4054 {
4055 this.hide_scrollbar(window, cx);
4056 cx.notify();
4057 }
4058
4059 cx.stop_propagation();
4060 }),
4061 )
4062 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4063 cx.notify();
4064 }))
4065 .h_full()
4066 .absolute()
4067 .right_1()
4068 .top_1()
4069 .bottom_1()
4070 .w(px(12.))
4071 .cursor_default()
4072 .children(Scrollbar::vertical(
4073 // percentage as f32..end_offset as f32,
4074 self.vertical_scrollbar_state.clone(),
4075 )),
4076 )
4077 }
4078
4079 fn render_horizontal_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
4080 if !Self::should_show_scrollbar(cx)
4081 || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
4082 {
4083 return None;
4084 }
4085
4086 let scroll_handle = self.scroll_handle.0.borrow();
4087 let longest_item_width = scroll_handle
4088 .last_item_size
4089 .filter(|size| size.contents.width > size.item.width)?
4090 .contents
4091 .width
4092 .0 as f64;
4093 if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
4094 return None;
4095 }
4096
4097 Some(
4098 div()
4099 .occlude()
4100 .id("project-panel-horizontal-scroll")
4101 .on_mouse_move(cx.listener(|_, _, _, cx| {
4102 cx.notify();
4103 cx.stop_propagation()
4104 }))
4105 .on_hover(|_, _, cx| {
4106 cx.stop_propagation();
4107 })
4108 .on_any_mouse_down(|_, _, cx| {
4109 cx.stop_propagation();
4110 })
4111 .on_mouse_up(
4112 MouseButton::Left,
4113 cx.listener(|this, _, window, cx| {
4114 if !this.horizontal_scrollbar_state.is_dragging()
4115 && !this.focus_handle.contains_focused(window, cx)
4116 {
4117 this.hide_scrollbar(window, cx);
4118 cx.notify();
4119 }
4120
4121 cx.stop_propagation();
4122 }),
4123 )
4124 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4125 cx.notify();
4126 }))
4127 .w_full()
4128 .absolute()
4129 .right_1()
4130 .left_1()
4131 .bottom_1()
4132 .h(px(12.))
4133 .cursor_default()
4134 .when(self.width.is_some(), |this| {
4135 this.children(Scrollbar::horizontal(
4136 self.horizontal_scrollbar_state.clone(),
4137 ))
4138 }),
4139 )
4140 }
4141
4142 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
4143 let mut dispatch_context = KeyContext::new_with_defaults();
4144 dispatch_context.add("ProjectPanel");
4145 dispatch_context.add("menu");
4146
4147 let identifier = if self.filename_editor.focus_handle(cx).is_focused(window) {
4148 "editing"
4149 } else {
4150 "not_editing"
4151 };
4152
4153 dispatch_context.add(identifier);
4154 dispatch_context
4155 }
4156
4157 fn should_show_scrollbar(cx: &App) -> bool {
4158 let show = ProjectPanelSettings::get_global(cx)
4159 .scrollbar
4160 .show
4161 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4162 match show {
4163 ShowScrollbar::Auto => true,
4164 ShowScrollbar::System => true,
4165 ShowScrollbar::Always => true,
4166 ShowScrollbar::Never => false,
4167 }
4168 }
4169
4170 fn should_autohide_scrollbar(cx: &App) -> bool {
4171 let show = ProjectPanelSettings::get_global(cx)
4172 .scrollbar
4173 .show
4174 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4175 match show {
4176 ShowScrollbar::Auto => true,
4177 ShowScrollbar::System => cx
4178 .try_global::<ScrollbarAutoHide>()
4179 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
4180 ShowScrollbar::Always => false,
4181 ShowScrollbar::Never => true,
4182 }
4183 }
4184
4185 fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4186 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
4187 if !Self::should_autohide_scrollbar(cx) {
4188 return;
4189 }
4190 self.hide_scrollbar_task = Some(cx.spawn_in(window, |panel, mut cx| async move {
4191 cx.background_executor()
4192 .timer(SCROLLBAR_SHOW_INTERVAL)
4193 .await;
4194 panel
4195 .update(&mut cx, |panel, cx| {
4196 panel.show_scrollbar = false;
4197 cx.notify();
4198 })
4199 .log_err();
4200 }))
4201 }
4202
4203 fn reveal_entry(
4204 &mut self,
4205 project: Entity<Project>,
4206 entry_id: ProjectEntryId,
4207 skip_ignored: bool,
4208 cx: &mut Context<Self>,
4209 ) {
4210 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
4211 let worktree = worktree.read(cx);
4212 if skip_ignored
4213 && worktree
4214 .entry_for_id(entry_id)
4215 .map_or(true, |entry| entry.is_ignored)
4216 {
4217 return;
4218 }
4219
4220 let worktree_id = worktree.id();
4221 self.expand_entry(worktree_id, entry_id, cx);
4222 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
4223
4224 if self.marked_entries.len() == 1
4225 && self
4226 .marked_entries
4227 .first()
4228 .filter(|entry| entry.entry_id == entry_id)
4229 .is_none()
4230 {
4231 self.marked_entries.clear();
4232 }
4233 self.autoscroll(cx);
4234 cx.notify();
4235 }
4236 }
4237
4238 fn find_active_indent_guide(
4239 &self,
4240 indent_guides: &[IndentGuideLayout],
4241 cx: &App,
4242 ) -> Option<usize> {
4243 let (worktree, entry) = self.selected_entry(cx)?;
4244
4245 // Find the parent entry of the indent guide, this will either be the
4246 // expanded folder we have selected, or the parent of the currently
4247 // selected file/collapsed directory
4248 let mut entry = entry;
4249 loop {
4250 let is_expanded_dir = entry.is_dir()
4251 && self
4252 .expanded_dir_ids
4253 .get(&worktree.id())
4254 .map(|ids| ids.binary_search(&entry.id).is_ok())
4255 .unwrap_or(false);
4256 if is_expanded_dir {
4257 break;
4258 }
4259 entry = worktree.entry_for_path(&entry.path.parent()?)?;
4260 }
4261
4262 let (active_indent_range, depth) = {
4263 let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
4264 let child_paths = &self.visible_entries[worktree_ix].1;
4265 let mut child_count = 0;
4266 let depth = entry.path.ancestors().count();
4267 while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
4268 if entry.path.ancestors().count() <= depth {
4269 break;
4270 }
4271 child_count += 1;
4272 }
4273
4274 let start = ix + 1;
4275 let end = start + child_count;
4276
4277 let (_, entries, paths) = &self.visible_entries[worktree_ix];
4278 let visible_worktree_entries =
4279 paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect());
4280
4281 // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
4282 let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
4283 (start..end, depth)
4284 };
4285
4286 let candidates = indent_guides
4287 .iter()
4288 .enumerate()
4289 .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
4290
4291 for (i, indent) in candidates {
4292 // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
4293 if active_indent_range.start <= indent.offset.y + indent.length
4294 && indent.offset.y <= active_indent_range.end
4295 {
4296 return Some(i);
4297 }
4298 }
4299 None
4300 }
4301}
4302
4303fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
4304 const ICON_SIZE_FACTOR: usize = 2;
4305 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
4306 if is_symlink {
4307 item_width += ICON_SIZE_FACTOR;
4308 }
4309 item_width
4310}
4311
4312impl Render for ProjectPanel {
4313 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4314 let has_worktree = !self.visible_entries.is_empty();
4315 let project = self.project.read(cx);
4316 let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
4317 let show_indent_guides =
4318 ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
4319 let is_local = project.is_local();
4320
4321 if has_worktree {
4322 let item_count = self
4323 .visible_entries
4324 .iter()
4325 .map(|(_, worktree_entries, _)| worktree_entries.len())
4326 .sum();
4327
4328 fn handle_drag_move_scroll<T: 'static>(
4329 this: &mut ProjectPanel,
4330 e: &DragMoveEvent<T>,
4331 window: &mut Window,
4332 cx: &mut Context<ProjectPanel>,
4333 ) {
4334 if !e.bounds.contains(&e.event.position) {
4335 return;
4336 }
4337 this.hover_scroll_task.take();
4338 let panel_height = e.bounds.size.height;
4339 if panel_height <= px(0.) {
4340 return;
4341 }
4342
4343 let event_offset = e.event.position.y - e.bounds.origin.y;
4344 // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
4345 let hovered_region_offset = event_offset / panel_height;
4346
4347 // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
4348 // These pixels offsets were picked arbitrarily.
4349 let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
4350 8.
4351 } else if hovered_region_offset <= 0.15 {
4352 5.
4353 } else if hovered_region_offset >= 0.95 {
4354 -8.
4355 } else if hovered_region_offset >= 0.85 {
4356 -5.
4357 } else {
4358 return;
4359 };
4360 let adjustment = point(px(0.), px(vertical_scroll_offset));
4361 this.hover_scroll_task =
4362 Some(cx.spawn_in(window, move |this, mut cx| async move {
4363 loop {
4364 let should_stop_scrolling = this
4365 .update(&mut cx, |this, cx| {
4366 this.hover_scroll_task.as_ref()?;
4367 let handle = this.scroll_handle.0.borrow_mut();
4368 let offset = handle.base_handle.offset();
4369
4370 handle.base_handle.set_offset(offset + adjustment);
4371 cx.notify();
4372 Some(())
4373 })
4374 .ok()
4375 .flatten()
4376 .is_some();
4377 if should_stop_scrolling {
4378 return;
4379 }
4380 cx.background_executor()
4381 .timer(Duration::from_millis(16))
4382 .await;
4383 }
4384 }));
4385 }
4386 h_flex()
4387 .id("project-panel")
4388 .group("project-panel")
4389 .on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
4390 .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
4391 .size_full()
4392 .relative()
4393 .on_hover(cx.listener(|this, hovered, window, cx| {
4394 if *hovered {
4395 this.show_scrollbar = true;
4396 this.hide_scrollbar_task.take();
4397 cx.notify();
4398 } else if !this.focus_handle.contains_focused(window, cx) {
4399 this.hide_scrollbar(window, cx);
4400 }
4401 }))
4402 .on_click(cx.listener(|this, _event, _, cx| {
4403 cx.stop_propagation();
4404 this.selection = None;
4405 this.marked_entries.clear();
4406 }))
4407 .key_context(self.dispatch_context(window, cx))
4408 .on_action(cx.listener(Self::select_next))
4409 .on_action(cx.listener(Self::select_prev))
4410 .on_action(cx.listener(Self::select_first))
4411 .on_action(cx.listener(Self::select_last))
4412 .on_action(cx.listener(Self::select_parent))
4413 .on_action(cx.listener(Self::select_next_git_entry))
4414 .on_action(cx.listener(Self::select_prev_git_entry))
4415 .on_action(cx.listener(Self::select_next_diagnostic))
4416 .on_action(cx.listener(Self::select_prev_diagnostic))
4417 .on_action(cx.listener(Self::select_next_directory))
4418 .on_action(cx.listener(Self::select_prev_directory))
4419 .on_action(cx.listener(Self::expand_selected_entry))
4420 .on_action(cx.listener(Self::collapse_selected_entry))
4421 .on_action(cx.listener(Self::collapse_all_entries))
4422 .on_action(cx.listener(Self::open))
4423 .on_action(cx.listener(Self::open_permanent))
4424 .on_action(cx.listener(Self::confirm))
4425 .on_action(cx.listener(Self::cancel))
4426 .on_action(cx.listener(Self::copy_path))
4427 .on_action(cx.listener(Self::copy_relative_path))
4428 .on_action(cx.listener(Self::new_search_in_directory))
4429 .on_action(cx.listener(Self::unfold_directory))
4430 .on_action(cx.listener(Self::fold_directory))
4431 .on_action(cx.listener(Self::remove_from_project))
4432 .when(!project.is_read_only(cx), |el| {
4433 el.on_action(cx.listener(Self::new_file))
4434 .on_action(cx.listener(Self::new_directory))
4435 .on_action(cx.listener(Self::rename))
4436 .on_action(cx.listener(Self::delete))
4437 .on_action(cx.listener(Self::trash))
4438 .on_action(cx.listener(Self::cut))
4439 .on_action(cx.listener(Self::copy))
4440 .on_action(cx.listener(Self::paste))
4441 .on_action(cx.listener(Self::duplicate))
4442 .on_click(cx.listener(|this, event: &gpui::ClickEvent, window, cx| {
4443 if event.up.click_count > 1 {
4444 if let Some(entry_id) = this.last_worktree_root_id {
4445 let project = this.project.read(cx);
4446
4447 let worktree_id = if let Some(worktree) =
4448 project.worktree_for_entry(entry_id, cx)
4449 {
4450 worktree.read(cx).id()
4451 } else {
4452 return;
4453 };
4454
4455 this.selection = Some(SelectedEntry {
4456 worktree_id,
4457 entry_id,
4458 });
4459
4460 this.new_file(&NewFile, window, cx);
4461 }
4462 }
4463 }))
4464 })
4465 .when(project.is_local(), |el| {
4466 el.on_action(cx.listener(Self::reveal_in_finder))
4467 .on_action(cx.listener(Self::open_system))
4468 .on_action(cx.listener(Self::open_in_terminal))
4469 })
4470 .when(project.is_via_ssh(), |el| {
4471 el.on_action(cx.listener(Self::open_in_terminal))
4472 })
4473 .on_mouse_down(
4474 MouseButton::Right,
4475 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
4476 // When deploying the context menu anywhere below the last project entry,
4477 // act as if the user clicked the root of the last worktree.
4478 if let Some(entry_id) = this.last_worktree_root_id {
4479 this.deploy_context_menu(event.position, entry_id, window, cx);
4480 }
4481 }),
4482 )
4483 .track_focus(&self.focus_handle(cx))
4484 .child(
4485 uniform_list(cx.entity().clone(), "entries", item_count, {
4486 |this, range, window, cx| {
4487 let mut items = Vec::with_capacity(range.end - range.start);
4488 this.for_each_visible_entry(
4489 range,
4490 window,
4491 cx,
4492 |id, details, window, cx| {
4493 items.push(this.render_entry(id, details, window, cx));
4494 },
4495 );
4496 items
4497 }
4498 })
4499 .when(show_indent_guides, |list| {
4500 list.with_decoration(
4501 ui::indent_guides(
4502 cx.entity().clone(),
4503 px(indent_size),
4504 IndentGuideColors::panel(cx),
4505 |this, range, window, cx| {
4506 let mut items =
4507 SmallVec::with_capacity(range.end - range.start);
4508 this.iter_visible_entries(
4509 range,
4510 window,
4511 cx,
4512 |entry, entries, _, _| {
4513 let (depth, _) = Self::calculate_depth_and_difference(
4514 entry, entries,
4515 );
4516 items.push(depth);
4517 },
4518 );
4519 items
4520 },
4521 )
4522 .on_click(cx.listener(
4523 |this, active_indent_guide: &IndentGuideLayout, window, cx| {
4524 if window.modifiers().secondary() {
4525 let ix = active_indent_guide.offset.y;
4526 let Some((target_entry, worktree)) = maybe!({
4527 let (worktree_id, entry) = this.entry_at_index(ix)?;
4528 let worktree = this
4529 .project
4530 .read(cx)
4531 .worktree_for_id(worktree_id, cx)?;
4532 let target_entry = worktree
4533 .read(cx)
4534 .entry_for_path(&entry.path.parent()?)?;
4535 Some((target_entry, worktree))
4536 }) else {
4537 return;
4538 };
4539
4540 this.collapse_entry(target_entry.clone(), worktree, cx);
4541 }
4542 },
4543 ))
4544 .with_render_fn(
4545 cx.entity().clone(),
4546 move |this, params, _, cx| {
4547 const LEFT_OFFSET: f32 = 14.;
4548 const PADDING_Y: f32 = 4.;
4549 const HITBOX_OVERDRAW: f32 = 3.;
4550
4551 let active_indent_guide_index =
4552 this.find_active_indent_guide(¶ms.indent_guides, cx);
4553
4554 let indent_size = params.indent_size;
4555 let item_height = params.item_height;
4556
4557 params
4558 .indent_guides
4559 .into_iter()
4560 .enumerate()
4561 .map(|(idx, layout)| {
4562 let offset = if layout.continues_offscreen {
4563 px(0.)
4564 } else {
4565 px(PADDING_Y)
4566 };
4567 let bounds = Bounds::new(
4568 point(
4569 px(layout.offset.x as f32) * indent_size
4570 + px(LEFT_OFFSET),
4571 px(layout.offset.y as f32) * item_height
4572 + offset,
4573 ),
4574 size(
4575 px(1.),
4576 px(layout.length as f32) * item_height
4577 - px(offset.0 * 2.),
4578 ),
4579 );
4580 ui::RenderedIndentGuide {
4581 bounds,
4582 layout,
4583 is_active: Some(idx) == active_indent_guide_index,
4584 hitbox: Some(Bounds::new(
4585 point(
4586 bounds.origin.x - px(HITBOX_OVERDRAW),
4587 bounds.origin.y,
4588 ),
4589 size(
4590 bounds.size.width
4591 + px(2. * HITBOX_OVERDRAW),
4592 bounds.size.height,
4593 ),
4594 )),
4595 }
4596 })
4597 .collect()
4598 },
4599 ),
4600 )
4601 })
4602 .size_full()
4603 .with_sizing_behavior(ListSizingBehavior::Infer)
4604 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4605 .with_width_from_item(self.max_width_item_index)
4606 .track_scroll(self.scroll_handle.clone()),
4607 )
4608 .children(self.render_vertical_scrollbar(cx))
4609 .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
4610 this.pb_4().child(scrollbar)
4611 })
4612 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4613 deferred(
4614 anchored()
4615 .position(*position)
4616 .anchor(gpui::Corner::TopLeft)
4617 .child(menu.clone()),
4618 )
4619 .with_priority(1)
4620 }))
4621 } else {
4622 v_flex()
4623 .id("empty-project_panel")
4624 .size_full()
4625 .p_4()
4626 .track_focus(&self.focus_handle(cx))
4627 .child(
4628 Button::new("open_project", "Open a project")
4629 .full_width()
4630 .key_binding(KeyBinding::for_action(&workspace::Open, window))
4631 .on_click(cx.listener(|this, _, window, cx| {
4632 this.workspace
4633 .update(cx, |_, cx| {
4634 window.dispatch_action(Box::new(workspace::Open), cx)
4635 })
4636 .log_err();
4637 })),
4638 )
4639 .when(is_local, |div| {
4640 div.drag_over::<ExternalPaths>(|style, _, _, cx| {
4641 style.bg(cx.theme().colors().drop_target_background)
4642 })
4643 .on_drop(cx.listener(
4644 move |this, external_paths: &ExternalPaths, window, cx| {
4645 this.last_external_paths_drag_over_entry = None;
4646 this.marked_entries.clear();
4647 this.hover_scroll_task.take();
4648 if let Some(task) = this
4649 .workspace
4650 .update(cx, |workspace, cx| {
4651 workspace.open_workspace_for_paths(
4652 true,
4653 external_paths.paths().to_owned(),
4654 window,
4655 cx,
4656 )
4657 })
4658 .log_err()
4659 {
4660 task.detach_and_log_err(cx);
4661 }
4662 cx.stop_propagation();
4663 },
4664 ))
4665 })
4666 }
4667 }
4668}
4669
4670impl Render for DraggedProjectEntryView {
4671 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4672 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
4673 h_flex()
4674 .font(ui_font)
4675 .pl(self.click_offset.x + px(12.))
4676 .pt(self.click_offset.y + px(12.))
4677 .child(
4678 div()
4679 .flex()
4680 .gap_1()
4681 .items_center()
4682 .py_1()
4683 .px_2()
4684 .rounded_lg()
4685 .bg(cx.theme().colors().background)
4686 .map(|this| {
4687 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
4688 this.child(Label::new(format!("{} entries", self.selections.len())))
4689 } else {
4690 this.child(if let Some(icon) = &self.details.icon {
4691 div().child(Icon::from_path(icon.clone()))
4692 } else {
4693 div()
4694 })
4695 .child(Label::new(self.details.filename.clone()))
4696 }
4697 }),
4698 )
4699 }
4700}
4701
4702impl EventEmitter<Event> for ProjectPanel {}
4703
4704impl EventEmitter<PanelEvent> for ProjectPanel {}
4705
4706impl Panel for ProjectPanel {
4707 fn position(&self, _: &Window, cx: &App) -> DockPosition {
4708 match ProjectPanelSettings::get_global(cx).dock {
4709 ProjectPanelDockPosition::Left => DockPosition::Left,
4710 ProjectPanelDockPosition::Right => DockPosition::Right,
4711 }
4712 }
4713
4714 fn position_is_valid(&self, position: DockPosition) -> bool {
4715 matches!(position, DockPosition::Left | DockPosition::Right)
4716 }
4717
4718 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
4719 settings::update_settings_file::<ProjectPanelSettings>(
4720 self.fs.clone(),
4721 cx,
4722 move |settings, _| {
4723 let dock = match position {
4724 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
4725 DockPosition::Right => ProjectPanelDockPosition::Right,
4726 };
4727 settings.dock = Some(dock);
4728 },
4729 );
4730 }
4731
4732 fn size(&self, _: &Window, cx: &App) -> Pixels {
4733 self.width
4734 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
4735 }
4736
4737 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
4738 self.width = size;
4739 self.serialize(cx);
4740 cx.notify();
4741 }
4742
4743 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
4744 ProjectPanelSettings::get_global(cx)
4745 .button
4746 .then_some(IconName::FileTree)
4747 }
4748
4749 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
4750 Some("Project Panel")
4751 }
4752
4753 fn toggle_action(&self) -> Box<dyn Action> {
4754 Box::new(ToggleFocus)
4755 }
4756
4757 fn persistent_name() -> &'static str {
4758 "Project Panel"
4759 }
4760
4761 fn starts_open(&self, _: &Window, cx: &App) -> bool {
4762 let project = &self.project.read(cx);
4763 project.visible_worktrees(cx).any(|tree| {
4764 tree.read(cx)
4765 .root_entry()
4766 .map_or(false, |entry| entry.is_dir())
4767 })
4768 }
4769
4770 fn activation_priority(&self) -> u32 {
4771 0
4772 }
4773}
4774
4775impl Focusable for ProjectPanel {
4776 fn focus_handle(&self, _cx: &App) -> FocusHandle {
4777 self.focus_handle.clone()
4778 }
4779}
4780
4781impl ClipboardEntry {
4782 fn is_cut(&self) -> bool {
4783 matches!(self, Self::Cut { .. })
4784 }
4785
4786 fn items(&self) -> &BTreeSet<SelectedEntry> {
4787 match self {
4788 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
4789 }
4790 }
4791}
4792
4793#[cfg(test)]
4794mod tests {
4795 use super::*;
4796 use collections::HashSet;
4797 use gpui::{Empty, Entity, TestAppContext, VisualTestContext, WindowHandle};
4798 use pretty_assertions::assert_eq;
4799 use project::{FakeFs, WorktreeSettings};
4800 use serde_json::json;
4801 use settings::SettingsStore;
4802 use std::path::{Path, PathBuf};
4803 use util::{path, separator};
4804 use workspace::{
4805 item::{Item, ProjectItem},
4806 register_project_item, AppState,
4807 };
4808
4809 #[gpui::test]
4810 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
4811 init_test(cx);
4812
4813 let fs = FakeFs::new(cx.executor().clone());
4814 fs.insert_tree(
4815 "/root1",
4816 json!({
4817 ".dockerignore": "",
4818 ".git": {
4819 "HEAD": "",
4820 },
4821 "a": {
4822 "0": { "q": "", "r": "", "s": "" },
4823 "1": { "t": "", "u": "" },
4824 "2": { "v": "", "w": "", "x": "", "y": "" },
4825 },
4826 "b": {
4827 "3": { "Q": "" },
4828 "4": { "R": "", "S": "", "T": "", "U": "" },
4829 },
4830 "C": {
4831 "5": {},
4832 "6": { "V": "", "W": "" },
4833 "7": { "X": "" },
4834 "8": { "Y": {}, "Z": "" }
4835 }
4836 }),
4837 )
4838 .await;
4839 fs.insert_tree(
4840 "/root2",
4841 json!({
4842 "d": {
4843 "9": ""
4844 },
4845 "e": {}
4846 }),
4847 )
4848 .await;
4849
4850 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4851 let workspace =
4852 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4853 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4854 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4855 assert_eq!(
4856 visible_entries_as_strings(&panel, 0..50, cx),
4857 &[
4858 "v root1",
4859 " > .git",
4860 " > a",
4861 " > b",
4862 " > C",
4863 " .dockerignore",
4864 "v root2",
4865 " > d",
4866 " > e",
4867 ]
4868 );
4869
4870 toggle_expand_dir(&panel, "root1/b", cx);
4871 assert_eq!(
4872 visible_entries_as_strings(&panel, 0..50, cx),
4873 &[
4874 "v root1",
4875 " > .git",
4876 " > a",
4877 " v b <== selected",
4878 " > 3",
4879 " > 4",
4880 " > C",
4881 " .dockerignore",
4882 "v root2",
4883 " > d",
4884 " > e",
4885 ]
4886 );
4887
4888 assert_eq!(
4889 visible_entries_as_strings(&panel, 6..9, cx),
4890 &[
4891 //
4892 " > C",
4893 " .dockerignore",
4894 "v root2",
4895 ]
4896 );
4897 }
4898
4899 #[gpui::test]
4900 async fn test_opening_file(cx: &mut gpui::TestAppContext) {
4901 init_test_with_editor(cx);
4902
4903 let fs = FakeFs::new(cx.executor().clone());
4904 fs.insert_tree(
4905 path!("/src"),
4906 json!({
4907 "test": {
4908 "first.rs": "// First Rust file",
4909 "second.rs": "// Second Rust file",
4910 "third.rs": "// Third Rust file",
4911 }
4912 }),
4913 )
4914 .await;
4915
4916 let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
4917 let workspace =
4918 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4919 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4920 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4921
4922 toggle_expand_dir(&panel, "src/test", cx);
4923 select_path(&panel, "src/test/first.rs", cx);
4924 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
4925 cx.executor().run_until_parked();
4926 assert_eq!(
4927 visible_entries_as_strings(&panel, 0..10, cx),
4928 &[
4929 "v src",
4930 " v test",
4931 " first.rs <== selected <== marked",
4932 " second.rs",
4933 " third.rs"
4934 ]
4935 );
4936 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
4937
4938 select_path(&panel, "src/test/second.rs", cx);
4939 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
4940 cx.executor().run_until_parked();
4941 assert_eq!(
4942 visible_entries_as_strings(&panel, 0..10, cx),
4943 &[
4944 "v src",
4945 " v test",
4946 " first.rs",
4947 " second.rs <== selected <== marked",
4948 " third.rs"
4949 ]
4950 );
4951 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
4952 }
4953
4954 #[gpui::test]
4955 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
4956 init_test(cx);
4957 cx.update(|cx| {
4958 cx.update_global::<SettingsStore, _>(|store, cx| {
4959 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4960 worktree_settings.file_scan_exclusions =
4961 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
4962 });
4963 });
4964 });
4965
4966 let fs = FakeFs::new(cx.background_executor.clone());
4967 fs.insert_tree(
4968 "/root1",
4969 json!({
4970 ".dockerignore": "",
4971 ".git": {
4972 "HEAD": "",
4973 },
4974 "a": {
4975 "0": { "q": "", "r": "", "s": "" },
4976 "1": { "t": "", "u": "" },
4977 "2": { "v": "", "w": "", "x": "", "y": "" },
4978 },
4979 "b": {
4980 "3": { "Q": "" },
4981 "4": { "R": "", "S": "", "T": "", "U": "" },
4982 },
4983 "C": {
4984 "5": {},
4985 "6": { "V": "", "W": "" },
4986 "7": { "X": "" },
4987 "8": { "Y": {}, "Z": "" }
4988 }
4989 }),
4990 )
4991 .await;
4992 fs.insert_tree(
4993 "/root2",
4994 json!({
4995 "d": {
4996 "4": ""
4997 },
4998 "e": {}
4999 }),
5000 )
5001 .await;
5002
5003 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5004 let workspace =
5005 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5006 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5007 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5008 assert_eq!(
5009 visible_entries_as_strings(&panel, 0..50, cx),
5010 &[
5011 "v root1",
5012 " > a",
5013 " > b",
5014 " > C",
5015 " .dockerignore",
5016 "v root2",
5017 " > d",
5018 " > e",
5019 ]
5020 );
5021
5022 toggle_expand_dir(&panel, "root1/b", cx);
5023 assert_eq!(
5024 visible_entries_as_strings(&panel, 0..50, cx),
5025 &[
5026 "v root1",
5027 " > a",
5028 " v b <== selected",
5029 " > 3",
5030 " > C",
5031 " .dockerignore",
5032 "v root2",
5033 " > d",
5034 " > e",
5035 ]
5036 );
5037
5038 toggle_expand_dir(&panel, "root2/d", cx);
5039 assert_eq!(
5040 visible_entries_as_strings(&panel, 0..50, cx),
5041 &[
5042 "v root1",
5043 " > a",
5044 " v b",
5045 " > 3",
5046 " > C",
5047 " .dockerignore",
5048 "v root2",
5049 " v d <== selected",
5050 " > e",
5051 ]
5052 );
5053
5054 toggle_expand_dir(&panel, "root2/e", cx);
5055 assert_eq!(
5056 visible_entries_as_strings(&panel, 0..50, cx),
5057 &[
5058 "v root1",
5059 " > a",
5060 " v b",
5061 " > 3",
5062 " > C",
5063 " .dockerignore",
5064 "v root2",
5065 " v d",
5066 " v e <== selected",
5067 ]
5068 );
5069 }
5070
5071 #[gpui::test]
5072 async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
5073 init_test(cx);
5074
5075 let fs = FakeFs::new(cx.executor().clone());
5076 fs.insert_tree(
5077 path!("/root1"),
5078 json!({
5079 "dir_1": {
5080 "nested_dir_1": {
5081 "nested_dir_2": {
5082 "nested_dir_3": {
5083 "file_a.java": "// File contents",
5084 "file_b.java": "// File contents",
5085 "file_c.java": "// File contents",
5086 "nested_dir_4": {
5087 "nested_dir_5": {
5088 "file_d.java": "// File contents",
5089 }
5090 }
5091 }
5092 }
5093 }
5094 }
5095 }),
5096 )
5097 .await;
5098 fs.insert_tree(
5099 path!("/root2"),
5100 json!({
5101 "dir_2": {
5102 "file_1.java": "// File contents",
5103 }
5104 }),
5105 )
5106 .await;
5107
5108 let project = Project::test(
5109 fs.clone(),
5110 [path!("/root1").as_ref(), path!("/root2").as_ref()],
5111 cx,
5112 )
5113 .await;
5114 let workspace =
5115 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5116 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5117 cx.update(|_, cx| {
5118 let settings = *ProjectPanelSettings::get_global(cx);
5119 ProjectPanelSettings::override_global(
5120 ProjectPanelSettings {
5121 auto_fold_dirs: true,
5122 ..settings
5123 },
5124 cx,
5125 );
5126 });
5127 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5128 assert_eq!(
5129 visible_entries_as_strings(&panel, 0..10, cx),
5130 &[
5131 separator!("v root1"),
5132 separator!(" > dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
5133 separator!("v root2"),
5134 separator!(" > dir_2"),
5135 ]
5136 );
5137
5138 toggle_expand_dir(
5139 &panel,
5140 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
5141 cx,
5142 );
5143 assert_eq!(
5144 visible_entries_as_strings(&panel, 0..10, cx),
5145 &[
5146 separator!("v root1"),
5147 separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected"),
5148 separator!(" > nested_dir_4/nested_dir_5"),
5149 separator!(" file_a.java"),
5150 separator!(" file_b.java"),
5151 separator!(" file_c.java"),
5152 separator!("v root2"),
5153 separator!(" > dir_2"),
5154 ]
5155 );
5156
5157 toggle_expand_dir(
5158 &panel,
5159 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
5160 cx,
5161 );
5162 assert_eq!(
5163 visible_entries_as_strings(&panel, 0..10, cx),
5164 &[
5165 separator!("v root1"),
5166 separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
5167 separator!(" v nested_dir_4/nested_dir_5 <== selected"),
5168 separator!(" file_d.java"),
5169 separator!(" file_a.java"),
5170 separator!(" file_b.java"),
5171 separator!(" file_c.java"),
5172 separator!("v root2"),
5173 separator!(" > dir_2"),
5174 ]
5175 );
5176 toggle_expand_dir(&panel, "root2/dir_2", cx);
5177 assert_eq!(
5178 visible_entries_as_strings(&panel, 0..10, cx),
5179 &[
5180 separator!("v root1"),
5181 separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
5182 separator!(" v nested_dir_4/nested_dir_5"),
5183 separator!(" file_d.java"),
5184 separator!(" file_a.java"),
5185 separator!(" file_b.java"),
5186 separator!(" file_c.java"),
5187 separator!("v root2"),
5188 separator!(" v dir_2 <== selected"),
5189 separator!(" file_1.java"),
5190 ]
5191 );
5192 }
5193
5194 #[gpui::test(iterations = 30)]
5195 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
5196 init_test(cx);
5197
5198 let fs = FakeFs::new(cx.executor().clone());
5199 fs.insert_tree(
5200 "/root1",
5201 json!({
5202 ".dockerignore": "",
5203 ".git": {
5204 "HEAD": "",
5205 },
5206 "a": {
5207 "0": { "q": "", "r": "", "s": "" },
5208 "1": { "t": "", "u": "" },
5209 "2": { "v": "", "w": "", "x": "", "y": "" },
5210 },
5211 "b": {
5212 "3": { "Q": "" },
5213 "4": { "R": "", "S": "", "T": "", "U": "" },
5214 },
5215 "C": {
5216 "5": {},
5217 "6": { "V": "", "W": "" },
5218 "7": { "X": "" },
5219 "8": { "Y": {}, "Z": "" }
5220 }
5221 }),
5222 )
5223 .await;
5224 fs.insert_tree(
5225 "/root2",
5226 json!({
5227 "d": {
5228 "9": ""
5229 },
5230 "e": {}
5231 }),
5232 )
5233 .await;
5234
5235 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5236 let workspace =
5237 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5238 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5239 let panel = workspace
5240 .update(cx, |workspace, window, cx| {
5241 let panel = ProjectPanel::new(workspace, window, cx);
5242 workspace.add_panel(panel.clone(), window, cx);
5243 panel
5244 })
5245 .unwrap();
5246
5247 select_path(&panel, "root1", cx);
5248 assert_eq!(
5249 visible_entries_as_strings(&panel, 0..10, cx),
5250 &[
5251 "v root1 <== selected",
5252 " > .git",
5253 " > a",
5254 " > b",
5255 " > C",
5256 " .dockerignore",
5257 "v root2",
5258 " > d",
5259 " > e",
5260 ]
5261 );
5262
5263 // Add a file with the root folder selected. The filename editor is placed
5264 // before the first file in the root folder.
5265 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5266 panel.update_in(cx, |panel, window, cx| {
5267 assert!(panel.filename_editor.read(cx).is_focused(window));
5268 });
5269 assert_eq!(
5270 visible_entries_as_strings(&panel, 0..10, cx),
5271 &[
5272 "v root1",
5273 " > .git",
5274 " > a",
5275 " > b",
5276 " > C",
5277 " [EDITOR: ''] <== selected",
5278 " .dockerignore",
5279 "v root2",
5280 " > d",
5281 " > e",
5282 ]
5283 );
5284
5285 let confirm = panel.update_in(cx, |panel, window, cx| {
5286 panel.filename_editor.update(cx, |editor, cx| {
5287 editor.set_text("the-new-filename", window, cx)
5288 });
5289 panel.confirm_edit(window, cx).unwrap()
5290 });
5291 assert_eq!(
5292 visible_entries_as_strings(&panel, 0..10, cx),
5293 &[
5294 "v root1",
5295 " > .git",
5296 " > a",
5297 " > b",
5298 " > C",
5299 " [PROCESSING: 'the-new-filename'] <== selected",
5300 " .dockerignore",
5301 "v root2",
5302 " > d",
5303 " > e",
5304 ]
5305 );
5306
5307 confirm.await.unwrap();
5308 assert_eq!(
5309 visible_entries_as_strings(&panel, 0..10, cx),
5310 &[
5311 "v root1",
5312 " > .git",
5313 " > a",
5314 " > b",
5315 " > C",
5316 " .dockerignore",
5317 " the-new-filename <== selected <== marked",
5318 "v root2",
5319 " > d",
5320 " > e",
5321 ]
5322 );
5323
5324 select_path(&panel, "root1/b", cx);
5325 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5326 assert_eq!(
5327 visible_entries_as_strings(&panel, 0..10, cx),
5328 &[
5329 "v root1",
5330 " > .git",
5331 " > a",
5332 " v b",
5333 " > 3",
5334 " > 4",
5335 " [EDITOR: ''] <== selected",
5336 " > C",
5337 " .dockerignore",
5338 " the-new-filename",
5339 ]
5340 );
5341
5342 panel
5343 .update_in(cx, |panel, window, cx| {
5344 panel.filename_editor.update(cx, |editor, cx| {
5345 editor.set_text("another-filename.txt", window, cx)
5346 });
5347 panel.confirm_edit(window, cx).unwrap()
5348 })
5349 .await
5350 .unwrap();
5351 assert_eq!(
5352 visible_entries_as_strings(&panel, 0..10, cx),
5353 &[
5354 "v root1",
5355 " > .git",
5356 " > a",
5357 " v b",
5358 " > 3",
5359 " > 4",
5360 " another-filename.txt <== selected <== marked",
5361 " > C",
5362 " .dockerignore",
5363 " the-new-filename",
5364 ]
5365 );
5366
5367 select_path(&panel, "root1/b/another-filename.txt", cx);
5368 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
5369 assert_eq!(
5370 visible_entries_as_strings(&panel, 0..10, cx),
5371 &[
5372 "v root1",
5373 " > .git",
5374 " > a",
5375 " v b",
5376 " > 3",
5377 " > 4",
5378 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
5379 " > C",
5380 " .dockerignore",
5381 " the-new-filename",
5382 ]
5383 );
5384
5385 let confirm = panel.update_in(cx, |panel, window, cx| {
5386 panel.filename_editor.update(cx, |editor, cx| {
5387 let file_name_selections = editor.selections.all::<usize>(cx);
5388 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5389 let file_name_selection = &file_name_selections[0];
5390 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
5391 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
5392
5393 editor.set_text("a-different-filename.tar.gz", window, cx)
5394 });
5395 panel.confirm_edit(window, cx).unwrap()
5396 });
5397 assert_eq!(
5398 visible_entries_as_strings(&panel, 0..10, cx),
5399 &[
5400 "v root1",
5401 " > .git",
5402 " > a",
5403 " v b",
5404 " > 3",
5405 " > 4",
5406 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
5407 " > C",
5408 " .dockerignore",
5409 " the-new-filename",
5410 ]
5411 );
5412
5413 confirm.await.unwrap();
5414 assert_eq!(
5415 visible_entries_as_strings(&panel, 0..10, cx),
5416 &[
5417 "v root1",
5418 " > .git",
5419 " > a",
5420 " v b",
5421 " > 3",
5422 " > 4",
5423 " a-different-filename.tar.gz <== selected",
5424 " > C",
5425 " .dockerignore",
5426 " the-new-filename",
5427 ]
5428 );
5429
5430 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
5431 assert_eq!(
5432 visible_entries_as_strings(&panel, 0..10, cx),
5433 &[
5434 "v root1",
5435 " > .git",
5436 " > a",
5437 " v b",
5438 " > 3",
5439 " > 4",
5440 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
5441 " > C",
5442 " .dockerignore",
5443 " the-new-filename",
5444 ]
5445 );
5446
5447 panel.update_in(cx, |panel, window, cx| {
5448 panel.filename_editor.update(cx, |editor, cx| {
5449 let file_name_selections = editor.selections.all::<usize>(cx);
5450 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5451 let file_name_selection = &file_name_selections[0];
5452 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
5453 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..");
5454
5455 });
5456 panel.cancel(&menu::Cancel, window, cx)
5457 });
5458
5459 panel.update_in(cx, |panel, window, cx| {
5460 panel.new_directory(&NewDirectory, window, cx)
5461 });
5462 assert_eq!(
5463 visible_entries_as_strings(&panel, 0..10, cx),
5464 &[
5465 "v root1",
5466 " > .git",
5467 " > a",
5468 " v b",
5469 " > 3",
5470 " > 4",
5471 " > [EDITOR: ''] <== selected",
5472 " a-different-filename.tar.gz",
5473 " > C",
5474 " .dockerignore",
5475 ]
5476 );
5477
5478 let confirm = panel.update_in(cx, |panel, window, cx| {
5479 panel
5480 .filename_editor
5481 .update(cx, |editor, cx| editor.set_text("new-dir", window, cx));
5482 panel.confirm_edit(window, cx).unwrap()
5483 });
5484 panel.update_in(cx, |panel, window, cx| {
5485 panel.select_next(&Default::default(), window, cx)
5486 });
5487 assert_eq!(
5488 visible_entries_as_strings(&panel, 0..10, cx),
5489 &[
5490 "v root1",
5491 " > .git",
5492 " > a",
5493 " v b",
5494 " > 3",
5495 " > 4",
5496 " > [PROCESSING: 'new-dir']",
5497 " a-different-filename.tar.gz <== selected",
5498 " > C",
5499 " .dockerignore",
5500 ]
5501 );
5502
5503 confirm.await.unwrap();
5504 assert_eq!(
5505 visible_entries_as_strings(&panel, 0..10, cx),
5506 &[
5507 "v root1",
5508 " > .git",
5509 " > a",
5510 " v b",
5511 " > 3",
5512 " > 4",
5513 " > new-dir",
5514 " a-different-filename.tar.gz <== selected",
5515 " > C",
5516 " .dockerignore",
5517 ]
5518 );
5519
5520 panel.update_in(cx, |panel, window, cx| {
5521 panel.rename(&Default::default(), window, cx)
5522 });
5523 assert_eq!(
5524 visible_entries_as_strings(&panel, 0..10, cx),
5525 &[
5526 "v root1",
5527 " > .git",
5528 " > a",
5529 " v b",
5530 " > 3",
5531 " > 4",
5532 " > new-dir",
5533 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
5534 " > C",
5535 " .dockerignore",
5536 ]
5537 );
5538
5539 // Dismiss the rename editor when it loses focus.
5540 workspace.update(cx, |_, window, _| window.blur()).unwrap();
5541 assert_eq!(
5542 visible_entries_as_strings(&panel, 0..10, cx),
5543 &[
5544 "v root1",
5545 " > .git",
5546 " > a",
5547 " v b",
5548 " > 3",
5549 " > 4",
5550 " > new-dir",
5551 " a-different-filename.tar.gz <== selected",
5552 " > C",
5553 " .dockerignore",
5554 ]
5555 );
5556 }
5557
5558 #[gpui::test(iterations = 10)]
5559 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
5560 init_test(cx);
5561
5562 let fs = FakeFs::new(cx.executor().clone());
5563 fs.insert_tree(
5564 "/root1",
5565 json!({
5566 ".dockerignore": "",
5567 ".git": {
5568 "HEAD": "",
5569 },
5570 "a": {
5571 "0": { "q": "", "r": "", "s": "" },
5572 "1": { "t": "", "u": "" },
5573 "2": { "v": "", "w": "", "x": "", "y": "" },
5574 },
5575 "b": {
5576 "3": { "Q": "" },
5577 "4": { "R": "", "S": "", "T": "", "U": "" },
5578 },
5579 "C": {
5580 "5": {},
5581 "6": { "V": "", "W": "" },
5582 "7": { "X": "" },
5583 "8": { "Y": {}, "Z": "" }
5584 }
5585 }),
5586 )
5587 .await;
5588 fs.insert_tree(
5589 "/root2",
5590 json!({
5591 "d": {
5592 "9": ""
5593 },
5594 "e": {}
5595 }),
5596 )
5597 .await;
5598
5599 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5600 let workspace =
5601 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5602 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5603 let panel = workspace
5604 .update(cx, |workspace, window, cx| {
5605 let panel = ProjectPanel::new(workspace, window, cx);
5606 workspace.add_panel(panel.clone(), window, cx);
5607 panel
5608 })
5609 .unwrap();
5610
5611 select_path(&panel, "root1", cx);
5612 assert_eq!(
5613 visible_entries_as_strings(&panel, 0..10, cx),
5614 &[
5615 "v root1 <== selected",
5616 " > .git",
5617 " > a",
5618 " > b",
5619 " > C",
5620 " .dockerignore",
5621 "v root2",
5622 " > d",
5623 " > e",
5624 ]
5625 );
5626
5627 // Add a file with the root folder selected. The filename editor is placed
5628 // before the first file in the root folder.
5629 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5630 panel.update_in(cx, |panel, window, cx| {
5631 assert!(panel.filename_editor.read(cx).is_focused(window));
5632 });
5633 assert_eq!(
5634 visible_entries_as_strings(&panel, 0..10, cx),
5635 &[
5636 "v root1",
5637 " > .git",
5638 " > a",
5639 " > b",
5640 " > C",
5641 " [EDITOR: ''] <== selected",
5642 " .dockerignore",
5643 "v root2",
5644 " > d",
5645 " > e",
5646 ]
5647 );
5648
5649 let confirm = panel.update_in(cx, |panel, window, cx| {
5650 panel.filename_editor.update(cx, |editor, cx| {
5651 editor.set_text("/bdir1/dir2/the-new-filename", window, cx)
5652 });
5653 panel.confirm_edit(window, cx).unwrap()
5654 });
5655
5656 assert_eq!(
5657 visible_entries_as_strings(&panel, 0..10, cx),
5658 &[
5659 "v root1",
5660 " > .git",
5661 " > a",
5662 " > b",
5663 " > C",
5664 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
5665 " .dockerignore",
5666 "v root2",
5667 " > d",
5668 " > e",
5669 ]
5670 );
5671
5672 confirm.await.unwrap();
5673 assert_eq!(
5674 visible_entries_as_strings(&panel, 0..13, cx),
5675 &[
5676 "v root1",
5677 " > .git",
5678 " > a",
5679 " > b",
5680 " v bdir1",
5681 " v dir2",
5682 " the-new-filename <== selected <== marked",
5683 " > C",
5684 " .dockerignore",
5685 "v root2",
5686 " > d",
5687 " > e",
5688 ]
5689 );
5690 }
5691
5692 #[gpui::test]
5693 async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
5694 init_test(cx);
5695
5696 let fs = FakeFs::new(cx.executor().clone());
5697 fs.insert_tree(
5698 path!("/root1"),
5699 json!({
5700 ".dockerignore": "",
5701 ".git": {
5702 "HEAD": "",
5703 },
5704 }),
5705 )
5706 .await;
5707
5708 let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
5709 let workspace =
5710 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5711 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5712 let panel = workspace
5713 .update(cx, |workspace, window, cx| {
5714 let panel = ProjectPanel::new(workspace, window, cx);
5715 workspace.add_panel(panel.clone(), window, cx);
5716 panel
5717 })
5718 .unwrap();
5719
5720 select_path(&panel, "root1", cx);
5721 assert_eq!(
5722 visible_entries_as_strings(&panel, 0..10, cx),
5723 &["v root1 <== selected", " > .git", " .dockerignore",]
5724 );
5725
5726 // Add a file with the root folder selected. The filename editor is placed
5727 // before the first file in the root folder.
5728 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5729 panel.update_in(cx, |panel, window, cx| {
5730 assert!(panel.filename_editor.read(cx).is_focused(window));
5731 });
5732 assert_eq!(
5733 visible_entries_as_strings(&panel, 0..10, cx),
5734 &[
5735 "v root1",
5736 " > .git",
5737 " [EDITOR: ''] <== selected",
5738 " .dockerignore",
5739 ]
5740 );
5741
5742 let confirm = panel.update_in(cx, |panel, window, cx| {
5743 // If we want to create a subdirectory, there should be no prefix slash.
5744 panel
5745 .filename_editor
5746 .update(cx, |editor, cx| editor.set_text("new_dir/", window, cx));
5747 panel.confirm_edit(window, cx).unwrap()
5748 });
5749
5750 assert_eq!(
5751 visible_entries_as_strings(&panel, 0..10, cx),
5752 &[
5753 "v root1",
5754 " > .git",
5755 " [PROCESSING: 'new_dir/'] <== selected",
5756 " .dockerignore",
5757 ]
5758 );
5759
5760 confirm.await.unwrap();
5761 assert_eq!(
5762 visible_entries_as_strings(&panel, 0..10, cx),
5763 &[
5764 "v root1",
5765 " > .git",
5766 " v new_dir <== selected",
5767 " .dockerignore",
5768 ]
5769 );
5770
5771 // Test filename with whitespace
5772 select_path(&panel, "root1", cx);
5773 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5774 let confirm = panel.update_in(cx, |panel, window, cx| {
5775 // If we want to create a subdirectory, there should be no prefix slash.
5776 panel
5777 .filename_editor
5778 .update(cx, |editor, cx| editor.set_text("new dir 2/", window, cx));
5779 panel.confirm_edit(window, cx).unwrap()
5780 });
5781 confirm.await.unwrap();
5782 assert_eq!(
5783 visible_entries_as_strings(&panel, 0..10, cx),
5784 &[
5785 "v root1",
5786 " > .git",
5787 " v new dir 2 <== selected",
5788 " v new_dir",
5789 " .dockerignore",
5790 ]
5791 );
5792
5793 // Test filename ends with "\"
5794 #[cfg(target_os = "windows")]
5795 {
5796 select_path(&panel, "root1", cx);
5797 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5798 let confirm = panel.update_in(cx, |panel, window, cx| {
5799 // If we want to create a subdirectory, there should be no prefix slash.
5800 panel
5801 .filename_editor
5802 .update(cx, |editor, cx| editor.set_text("new_dir_3\\", window, cx));
5803 panel.confirm_edit(window, cx).unwrap()
5804 });
5805 confirm.await.unwrap();
5806 assert_eq!(
5807 visible_entries_as_strings(&panel, 0..10, cx),
5808 &[
5809 "v root1",
5810 " > .git",
5811 " v new dir 2",
5812 " v new_dir",
5813 " v new_dir_3 <== selected",
5814 " .dockerignore",
5815 ]
5816 );
5817 }
5818 }
5819
5820 #[gpui::test]
5821 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
5822 init_test(cx);
5823
5824 let fs = FakeFs::new(cx.executor().clone());
5825 fs.insert_tree(
5826 "/root1",
5827 json!({
5828 "one.two.txt": "",
5829 "one.txt": ""
5830 }),
5831 )
5832 .await;
5833
5834 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5835 let workspace =
5836 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5837 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5838 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5839
5840 panel.update_in(cx, |panel, window, cx| {
5841 panel.select_next(&Default::default(), window, cx);
5842 panel.select_next(&Default::default(), window, cx);
5843 });
5844
5845 assert_eq!(
5846 visible_entries_as_strings(&panel, 0..50, cx),
5847 &[
5848 //
5849 "v root1",
5850 " one.txt <== selected",
5851 " one.two.txt",
5852 ]
5853 );
5854
5855 // Regression test - file name is created correctly when
5856 // the copied file's name contains multiple dots.
5857 panel.update_in(cx, |panel, window, cx| {
5858 panel.copy(&Default::default(), window, cx);
5859 panel.paste(&Default::default(), window, cx);
5860 });
5861 cx.executor().run_until_parked();
5862
5863 assert_eq!(
5864 visible_entries_as_strings(&panel, 0..50, cx),
5865 &[
5866 //
5867 "v root1",
5868 " one.txt",
5869 " [EDITOR: 'one copy.txt'] <== selected",
5870 " one.two.txt",
5871 ]
5872 );
5873
5874 panel.update_in(cx, |panel, window, cx| {
5875 panel.filename_editor.update(cx, |editor, cx| {
5876 let file_name_selections = editor.selections.all::<usize>(cx);
5877 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5878 let file_name_selection = &file_name_selections[0];
5879 assert_eq!(file_name_selection.start, "one".len(), "Should select the file name disambiguation after the original file name");
5880 assert_eq!(file_name_selection.end, "one copy".len(), "Should select the file name disambiguation until the extension");
5881 });
5882 assert!(panel.confirm_edit(window, cx).is_none());
5883 });
5884
5885 panel.update_in(cx, |panel, window, cx| {
5886 panel.paste(&Default::default(), window, cx);
5887 });
5888 cx.executor().run_until_parked();
5889
5890 assert_eq!(
5891 visible_entries_as_strings(&panel, 0..50, cx),
5892 &[
5893 //
5894 "v root1",
5895 " one.txt",
5896 " one copy.txt",
5897 " [EDITOR: 'one copy 1.txt'] <== selected",
5898 " one.two.txt",
5899 ]
5900 );
5901
5902 panel.update_in(cx, |panel, window, cx| {
5903 assert!(panel.confirm_edit(window, cx).is_none())
5904 });
5905 }
5906
5907 #[gpui::test]
5908 async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5909 init_test(cx);
5910
5911 let fs = FakeFs::new(cx.executor().clone());
5912 fs.insert_tree(
5913 "/root1",
5914 json!({
5915 "one.txt": "",
5916 "two.txt": "",
5917 "three.txt": "",
5918 "a": {
5919 "0": { "q": "", "r": "", "s": "" },
5920 "1": { "t": "", "u": "" },
5921 "2": { "v": "", "w": "", "x": "", "y": "" },
5922 },
5923 }),
5924 )
5925 .await;
5926
5927 fs.insert_tree(
5928 "/root2",
5929 json!({
5930 "one.txt": "",
5931 "two.txt": "",
5932 "four.txt": "",
5933 "b": {
5934 "3": { "Q": "" },
5935 "4": { "R": "", "S": "", "T": "", "U": "" },
5936 },
5937 }),
5938 )
5939 .await;
5940
5941 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5942 let workspace =
5943 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5944 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5945 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5946
5947 select_path(&panel, "root1/three.txt", cx);
5948 panel.update_in(cx, |panel, window, cx| {
5949 panel.cut(&Default::default(), window, cx);
5950 });
5951
5952 select_path(&panel, "root2/one.txt", cx);
5953 panel.update_in(cx, |panel, window, cx| {
5954 panel.select_next(&Default::default(), window, cx);
5955 panel.paste(&Default::default(), window, cx);
5956 });
5957 cx.executor().run_until_parked();
5958 assert_eq!(
5959 visible_entries_as_strings(&panel, 0..50, cx),
5960 &[
5961 //
5962 "v root1",
5963 " > a",
5964 " one.txt",
5965 " two.txt",
5966 "v root2",
5967 " > b",
5968 " four.txt",
5969 " one.txt",
5970 " three.txt <== selected",
5971 " two.txt",
5972 ]
5973 );
5974
5975 select_path(&panel, "root1/a", cx);
5976 panel.update_in(cx, |panel, window, cx| {
5977 panel.cut(&Default::default(), window, cx);
5978 });
5979 select_path(&panel, "root2/two.txt", cx);
5980 panel.update_in(cx, |panel, window, cx| {
5981 panel.select_next(&Default::default(), window, cx);
5982 panel.paste(&Default::default(), window, cx);
5983 });
5984
5985 cx.executor().run_until_parked();
5986 assert_eq!(
5987 visible_entries_as_strings(&panel, 0..50, cx),
5988 &[
5989 //
5990 "v root1",
5991 " one.txt",
5992 " two.txt",
5993 "v root2",
5994 " > a <== selected",
5995 " > b",
5996 " four.txt",
5997 " one.txt",
5998 " three.txt",
5999 " two.txt",
6000 ]
6001 );
6002 }
6003
6004 #[gpui::test]
6005 async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
6006 init_test(cx);
6007
6008 let fs = FakeFs::new(cx.executor().clone());
6009 fs.insert_tree(
6010 "/root1",
6011 json!({
6012 "one.txt": "",
6013 "two.txt": "",
6014 "three.txt": "",
6015 "a": {
6016 "0": { "q": "", "r": "", "s": "" },
6017 "1": { "t": "", "u": "" },
6018 "2": { "v": "", "w": "", "x": "", "y": "" },
6019 },
6020 }),
6021 )
6022 .await;
6023
6024 fs.insert_tree(
6025 "/root2",
6026 json!({
6027 "one.txt": "",
6028 "two.txt": "",
6029 "four.txt": "",
6030 "b": {
6031 "3": { "Q": "" },
6032 "4": { "R": "", "S": "", "T": "", "U": "" },
6033 },
6034 }),
6035 )
6036 .await;
6037
6038 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6039 let workspace =
6040 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6041 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6042 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6043
6044 select_path(&panel, "root1/three.txt", cx);
6045 panel.update_in(cx, |panel, window, cx| {
6046 panel.copy(&Default::default(), window, cx);
6047 });
6048
6049 select_path(&panel, "root2/one.txt", cx);
6050 panel.update_in(cx, |panel, window, cx| {
6051 panel.select_next(&Default::default(), window, cx);
6052 panel.paste(&Default::default(), window, cx);
6053 });
6054 cx.executor().run_until_parked();
6055 assert_eq!(
6056 visible_entries_as_strings(&panel, 0..50, cx),
6057 &[
6058 //
6059 "v root1",
6060 " > a",
6061 " one.txt",
6062 " three.txt",
6063 " two.txt",
6064 "v root2",
6065 " > b",
6066 " four.txt",
6067 " one.txt",
6068 " three.txt <== selected",
6069 " two.txt",
6070 ]
6071 );
6072
6073 select_path(&panel, "root1/three.txt", cx);
6074 panel.update_in(cx, |panel, window, cx| {
6075 panel.copy(&Default::default(), window, cx);
6076 });
6077 select_path(&panel, "root2/two.txt", cx);
6078 panel.update_in(cx, |panel, window, cx| {
6079 panel.select_next(&Default::default(), window, cx);
6080 panel.paste(&Default::default(), window, cx);
6081 });
6082
6083 cx.executor().run_until_parked();
6084 assert_eq!(
6085 visible_entries_as_strings(&panel, 0..50, cx),
6086 &[
6087 //
6088 "v root1",
6089 " > a",
6090 " one.txt",
6091 " three.txt",
6092 " two.txt",
6093 "v root2",
6094 " > b",
6095 " four.txt",
6096 " one.txt",
6097 " three.txt",
6098 " [EDITOR: 'three copy.txt'] <== selected",
6099 " two.txt",
6100 ]
6101 );
6102
6103 panel.update_in(cx, |panel, window, cx| {
6104 panel.cancel(&menu::Cancel {}, window, cx)
6105 });
6106 cx.executor().run_until_parked();
6107
6108 select_path(&panel, "root1/a", cx);
6109 panel.update_in(cx, |panel, window, cx| {
6110 panel.copy(&Default::default(), window, cx);
6111 });
6112 select_path(&panel, "root2/two.txt", cx);
6113 panel.update_in(cx, |panel, window, cx| {
6114 panel.select_next(&Default::default(), window, cx);
6115 panel.paste(&Default::default(), window, cx);
6116 });
6117
6118 cx.executor().run_until_parked();
6119 assert_eq!(
6120 visible_entries_as_strings(&panel, 0..50, cx),
6121 &[
6122 //
6123 "v root1",
6124 " > a",
6125 " one.txt",
6126 " three.txt",
6127 " two.txt",
6128 "v root2",
6129 " > a <== selected",
6130 " > b",
6131 " four.txt",
6132 " one.txt",
6133 " three.txt",
6134 " three copy.txt",
6135 " two.txt",
6136 ]
6137 );
6138 }
6139
6140 #[gpui::test]
6141 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
6142 init_test(cx);
6143
6144 let fs = FakeFs::new(cx.executor().clone());
6145 fs.insert_tree(
6146 "/root",
6147 json!({
6148 "a": {
6149 "one.txt": "",
6150 "two.txt": "",
6151 "inner_dir": {
6152 "three.txt": "",
6153 "four.txt": "",
6154 }
6155 },
6156 "b": {}
6157 }),
6158 )
6159 .await;
6160
6161 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6162 let workspace =
6163 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6164 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6165 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6166
6167 select_path(&panel, "root/a", cx);
6168 panel.update_in(cx, |panel, window, cx| {
6169 panel.copy(&Default::default(), window, cx);
6170 panel.select_next(&Default::default(), window, cx);
6171 panel.paste(&Default::default(), window, cx);
6172 });
6173 cx.executor().run_until_parked();
6174
6175 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
6176 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
6177
6178 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
6179 assert_ne!(
6180 pasted_dir_file, None,
6181 "Pasted directory file should have an entry"
6182 );
6183
6184 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
6185 assert_ne!(
6186 pasted_dir_inner_dir, None,
6187 "Directories inside pasted directory should have an entry"
6188 );
6189
6190 toggle_expand_dir(&panel, "root/b/a", cx);
6191 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
6192
6193 assert_eq!(
6194 visible_entries_as_strings(&panel, 0..50, cx),
6195 &[
6196 //
6197 "v root",
6198 " > a",
6199 " v b",
6200 " v a",
6201 " v inner_dir <== selected",
6202 " four.txt",
6203 " three.txt",
6204 " one.txt",
6205 " two.txt",
6206 ]
6207 );
6208
6209 select_path(&panel, "root", cx);
6210 panel.update_in(cx, |panel, window, cx| {
6211 panel.paste(&Default::default(), window, cx)
6212 });
6213 cx.executor().run_until_parked();
6214 assert_eq!(
6215 visible_entries_as_strings(&panel, 0..50, cx),
6216 &[
6217 //
6218 "v root",
6219 " > a",
6220 " > [EDITOR: 'a copy'] <== selected",
6221 " v b",
6222 " v a",
6223 " v inner_dir",
6224 " four.txt",
6225 " three.txt",
6226 " one.txt",
6227 " two.txt"
6228 ]
6229 );
6230
6231 let confirm = panel.update_in(cx, |panel, window, cx| {
6232 panel
6233 .filename_editor
6234 .update(cx, |editor, cx| editor.set_text("c", window, cx));
6235 panel.confirm_edit(window, cx).unwrap()
6236 });
6237 assert_eq!(
6238 visible_entries_as_strings(&panel, 0..50, cx),
6239 &[
6240 //
6241 "v root",
6242 " > a",
6243 " > [PROCESSING: 'c'] <== selected",
6244 " v b",
6245 " v a",
6246 " v inner_dir",
6247 " four.txt",
6248 " three.txt",
6249 " one.txt",
6250 " two.txt"
6251 ]
6252 );
6253
6254 confirm.await.unwrap();
6255
6256 panel.update_in(cx, |panel, window, cx| {
6257 panel.paste(&Default::default(), window, cx)
6258 });
6259 cx.executor().run_until_parked();
6260 assert_eq!(
6261 visible_entries_as_strings(&panel, 0..50, cx),
6262 &[
6263 //
6264 "v root",
6265 " > a",
6266 " v b",
6267 " v a",
6268 " v inner_dir",
6269 " four.txt",
6270 " three.txt",
6271 " one.txt",
6272 " two.txt",
6273 " v c",
6274 " > a <== selected",
6275 " > inner_dir",
6276 " one.txt",
6277 " two.txt",
6278 ]
6279 );
6280 }
6281
6282 #[gpui::test]
6283 async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
6284 init_test(cx);
6285
6286 let fs = FakeFs::new(cx.executor().clone());
6287 fs.insert_tree(
6288 "/test",
6289 json!({
6290 "dir1": {
6291 "a.txt": "",
6292 "b.txt": "",
6293 },
6294 "dir2": {},
6295 "c.txt": "",
6296 "d.txt": "",
6297 }),
6298 )
6299 .await;
6300
6301 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
6302 let workspace =
6303 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6304 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6305 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6306
6307 toggle_expand_dir(&panel, "test/dir1", cx);
6308
6309 cx.simulate_modifiers_change(gpui::Modifiers {
6310 control: true,
6311 ..Default::default()
6312 });
6313
6314 select_path_with_mark(&panel, "test/dir1", cx);
6315 select_path_with_mark(&panel, "test/c.txt", cx);
6316
6317 assert_eq!(
6318 visible_entries_as_strings(&panel, 0..15, cx),
6319 &[
6320 "v test",
6321 " v dir1 <== marked",
6322 " a.txt",
6323 " b.txt",
6324 " > dir2",
6325 " c.txt <== selected <== marked",
6326 " d.txt",
6327 ],
6328 "Initial state before copying dir1 and c.txt"
6329 );
6330
6331 panel.update_in(cx, |panel, window, cx| {
6332 panel.copy(&Default::default(), window, cx);
6333 });
6334 select_path(&panel, "test/dir2", cx);
6335 panel.update_in(cx, |panel, window, cx| {
6336 panel.paste(&Default::default(), window, cx);
6337 });
6338 cx.executor().run_until_parked();
6339
6340 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
6341
6342 assert_eq!(
6343 visible_entries_as_strings(&panel, 0..15, cx),
6344 &[
6345 "v test",
6346 " v dir1 <== marked",
6347 " a.txt",
6348 " b.txt",
6349 " v dir2",
6350 " v dir1 <== selected",
6351 " a.txt",
6352 " b.txt",
6353 " c.txt",
6354 " c.txt <== marked",
6355 " d.txt",
6356 ],
6357 "Should copy dir1 as well as c.txt into dir2"
6358 );
6359
6360 // Disambiguating multiple files should not open the rename editor.
6361 select_path(&panel, "test/dir2", cx);
6362 panel.update_in(cx, |panel, window, cx| {
6363 panel.paste(&Default::default(), window, cx);
6364 });
6365 cx.executor().run_until_parked();
6366
6367 assert_eq!(
6368 visible_entries_as_strings(&panel, 0..15, cx),
6369 &[
6370 "v test",
6371 " v dir1 <== marked",
6372 " a.txt",
6373 " b.txt",
6374 " v dir2",
6375 " v dir1",
6376 " a.txt",
6377 " b.txt",
6378 " > dir1 copy <== selected",
6379 " c.txt",
6380 " c copy.txt",
6381 " c.txt <== marked",
6382 " d.txt",
6383 ],
6384 "Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor"
6385 );
6386 }
6387
6388 #[gpui::test]
6389 async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
6390 init_test(cx);
6391
6392 let fs = FakeFs::new(cx.executor().clone());
6393 fs.insert_tree(
6394 "/test",
6395 json!({
6396 "dir1": {
6397 "a.txt": "",
6398 "b.txt": "",
6399 },
6400 "dir2": {},
6401 "c.txt": "",
6402 "d.txt": "",
6403 }),
6404 )
6405 .await;
6406
6407 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
6408 let workspace =
6409 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6410 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6411 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6412
6413 toggle_expand_dir(&panel, "test/dir1", cx);
6414
6415 cx.simulate_modifiers_change(gpui::Modifiers {
6416 control: true,
6417 ..Default::default()
6418 });
6419
6420 select_path_with_mark(&panel, "test/dir1/a.txt", cx);
6421 select_path_with_mark(&panel, "test/dir1", cx);
6422 select_path_with_mark(&panel, "test/c.txt", cx);
6423
6424 assert_eq!(
6425 visible_entries_as_strings(&panel, 0..15, cx),
6426 &[
6427 "v test",
6428 " v dir1 <== marked",
6429 " a.txt <== marked",
6430 " b.txt",
6431 " > dir2",
6432 " c.txt <== selected <== marked",
6433 " d.txt",
6434 ],
6435 "Initial state before copying a.txt, dir1 and c.txt"
6436 );
6437
6438 panel.update_in(cx, |panel, window, cx| {
6439 panel.copy(&Default::default(), window, cx);
6440 });
6441 select_path(&panel, "test/dir2", cx);
6442 panel.update_in(cx, |panel, window, cx| {
6443 panel.paste(&Default::default(), window, cx);
6444 });
6445 cx.executor().run_until_parked();
6446
6447 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
6448
6449 assert_eq!(
6450 visible_entries_as_strings(&panel, 0..20, cx),
6451 &[
6452 "v test",
6453 " v dir1 <== marked",
6454 " a.txt <== marked",
6455 " b.txt",
6456 " v dir2",
6457 " v dir1 <== selected",
6458 " a.txt",
6459 " b.txt",
6460 " c.txt",
6461 " c.txt <== marked",
6462 " d.txt",
6463 ],
6464 "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
6465 );
6466 }
6467
6468 #[gpui::test]
6469 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
6470 init_test_with_editor(cx);
6471
6472 let fs = FakeFs::new(cx.executor().clone());
6473 fs.insert_tree(
6474 path!("/src"),
6475 json!({
6476 "test": {
6477 "first.rs": "// First Rust file",
6478 "second.rs": "// Second Rust file",
6479 "third.rs": "// Third Rust file",
6480 }
6481 }),
6482 )
6483 .await;
6484
6485 let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
6486 let workspace =
6487 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6488 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6489 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6490
6491 toggle_expand_dir(&panel, "src/test", cx);
6492 select_path(&panel, "src/test/first.rs", cx);
6493 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
6494 cx.executor().run_until_parked();
6495 assert_eq!(
6496 visible_entries_as_strings(&panel, 0..10, cx),
6497 &[
6498 "v src",
6499 " v test",
6500 " first.rs <== selected <== marked",
6501 " second.rs",
6502 " third.rs"
6503 ]
6504 );
6505 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
6506
6507 submit_deletion(&panel, cx);
6508 assert_eq!(
6509 visible_entries_as_strings(&panel, 0..10, cx),
6510 &[
6511 "v src",
6512 " v test",
6513 " second.rs <== selected",
6514 " third.rs"
6515 ],
6516 "Project panel should have no deleted file, no other file is selected in it"
6517 );
6518 ensure_no_open_items_and_panes(&workspace, cx);
6519
6520 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
6521 cx.executor().run_until_parked();
6522 assert_eq!(
6523 visible_entries_as_strings(&panel, 0..10, cx),
6524 &[
6525 "v src",
6526 " v test",
6527 " second.rs <== selected <== marked",
6528 " third.rs"
6529 ]
6530 );
6531 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
6532
6533 workspace
6534 .update(cx, |workspace, window, cx| {
6535 let active_items = workspace
6536 .panes()
6537 .iter()
6538 .filter_map(|pane| pane.read(cx).active_item())
6539 .collect::<Vec<_>>();
6540 assert_eq!(active_items.len(), 1);
6541 let open_editor = active_items
6542 .into_iter()
6543 .next()
6544 .unwrap()
6545 .downcast::<Editor>()
6546 .expect("Open item should be an editor");
6547 open_editor.update(cx, |editor, cx| {
6548 editor.set_text("Another text!", window, cx)
6549 });
6550 })
6551 .unwrap();
6552 submit_deletion_skipping_prompt(&panel, cx);
6553 assert_eq!(
6554 visible_entries_as_strings(&panel, 0..10, cx),
6555 &["v src", " v test", " third.rs <== selected"],
6556 "Project panel should have no deleted file, with one last file remaining"
6557 );
6558 ensure_no_open_items_and_panes(&workspace, cx);
6559 }
6560
6561 #[gpui::test]
6562 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
6563 init_test_with_editor(cx);
6564
6565 let fs = FakeFs::new(cx.executor().clone());
6566 fs.insert_tree(
6567 "/src",
6568 json!({
6569 "test": {
6570 "first.rs": "// First Rust file",
6571 "second.rs": "// Second Rust file",
6572 "third.rs": "// Third Rust file",
6573 }
6574 }),
6575 )
6576 .await;
6577
6578 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
6579 let workspace =
6580 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6581 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6582 let panel = workspace
6583 .update(cx, |workspace, window, cx| {
6584 let panel = ProjectPanel::new(workspace, window, cx);
6585 workspace.add_panel(panel.clone(), window, cx);
6586 panel
6587 })
6588 .unwrap();
6589
6590 select_path(&panel, "src/", cx);
6591 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
6592 cx.executor().run_until_parked();
6593 assert_eq!(
6594 visible_entries_as_strings(&panel, 0..10, cx),
6595 &[
6596 //
6597 "v src <== selected",
6598 " > test"
6599 ]
6600 );
6601 panel.update_in(cx, |panel, window, cx| {
6602 panel.new_directory(&NewDirectory, window, cx)
6603 });
6604 panel.update_in(cx, |panel, window, cx| {
6605 assert!(panel.filename_editor.read(cx).is_focused(window));
6606 });
6607 assert_eq!(
6608 visible_entries_as_strings(&panel, 0..10, cx),
6609 &[
6610 //
6611 "v src",
6612 " > [EDITOR: ''] <== selected",
6613 " > test"
6614 ]
6615 );
6616 panel.update_in(cx, |panel, window, cx| {
6617 panel
6618 .filename_editor
6619 .update(cx, |editor, cx| editor.set_text("test", window, cx));
6620 assert!(
6621 panel.confirm_edit(window, cx).is_none(),
6622 "Should not allow to confirm on conflicting new directory name"
6623 )
6624 });
6625 assert_eq!(
6626 visible_entries_as_strings(&panel, 0..10, cx),
6627 &[
6628 //
6629 "v src",
6630 " > test"
6631 ],
6632 "File list should be unchanged after failed folder create confirmation"
6633 );
6634
6635 select_path(&panel, "src/test/", cx);
6636 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
6637 cx.executor().run_until_parked();
6638 assert_eq!(
6639 visible_entries_as_strings(&panel, 0..10, cx),
6640 &[
6641 //
6642 "v src",
6643 " > test <== selected"
6644 ]
6645 );
6646 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
6647 panel.update_in(cx, |panel, window, cx| {
6648 assert!(panel.filename_editor.read(cx).is_focused(window));
6649 });
6650 assert_eq!(
6651 visible_entries_as_strings(&panel, 0..10, cx),
6652 &[
6653 "v src",
6654 " v test",
6655 " [EDITOR: ''] <== selected",
6656 " first.rs",
6657 " second.rs",
6658 " third.rs"
6659 ]
6660 );
6661 panel.update_in(cx, |panel, window, cx| {
6662 panel
6663 .filename_editor
6664 .update(cx, |editor, cx| editor.set_text("first.rs", window, cx));
6665 assert!(
6666 panel.confirm_edit(window, cx).is_none(),
6667 "Should not allow to confirm on conflicting new file name"
6668 )
6669 });
6670 assert_eq!(
6671 visible_entries_as_strings(&panel, 0..10, cx),
6672 &[
6673 "v src",
6674 " v test",
6675 " first.rs",
6676 " second.rs",
6677 " third.rs"
6678 ],
6679 "File list should be unchanged after failed file create confirmation"
6680 );
6681
6682 select_path(&panel, "src/test/first.rs", cx);
6683 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
6684 cx.executor().run_until_parked();
6685 assert_eq!(
6686 visible_entries_as_strings(&panel, 0..10, cx),
6687 &[
6688 "v src",
6689 " v test",
6690 " first.rs <== selected",
6691 " second.rs",
6692 " third.rs"
6693 ],
6694 );
6695 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
6696 panel.update_in(cx, |panel, window, cx| {
6697 assert!(panel.filename_editor.read(cx).is_focused(window));
6698 });
6699 assert_eq!(
6700 visible_entries_as_strings(&panel, 0..10, cx),
6701 &[
6702 "v src",
6703 " v test",
6704 " [EDITOR: 'first.rs'] <== selected",
6705 " second.rs",
6706 " third.rs"
6707 ]
6708 );
6709 panel.update_in(cx, |panel, window, cx| {
6710 panel
6711 .filename_editor
6712 .update(cx, |editor, cx| editor.set_text("second.rs", window, cx));
6713 assert!(
6714 panel.confirm_edit(window, cx).is_none(),
6715 "Should not allow to confirm on conflicting file rename"
6716 )
6717 });
6718 assert_eq!(
6719 visible_entries_as_strings(&panel, 0..10, cx),
6720 &[
6721 "v src",
6722 " v test",
6723 " first.rs <== selected",
6724 " second.rs",
6725 " third.rs"
6726 ],
6727 "File list should be unchanged after failed rename confirmation"
6728 );
6729 }
6730
6731 #[gpui::test]
6732 async fn test_select_git_entry(cx: &mut gpui::TestAppContext) {
6733 use git::status::{FileStatus, StatusCode, TrackedStatus};
6734 use std::path::Path;
6735
6736 init_test_with_editor(cx);
6737
6738 let fs = FakeFs::new(cx.executor().clone());
6739 fs.insert_tree(
6740 "/root",
6741 json!({
6742 "tree1": {
6743 ".git": {},
6744 "dir1": {
6745 "modified1.txt": "",
6746 "unmodified1.txt": "",
6747 "modified2.txt": "",
6748 },
6749 "dir2": {
6750 "modified3.txt": "",
6751 "unmodified2.txt": "",
6752 },
6753 "modified4.txt": "",
6754 "unmodified3.txt": "",
6755 },
6756 "tree2": {
6757 ".git": {},
6758 "dir3": {
6759 "modified5.txt": "",
6760 "unmodified4.txt": "",
6761 },
6762 "modified6.txt": "",
6763 "unmodified5.txt": "",
6764 }
6765 }),
6766 )
6767 .await;
6768
6769 // Mark files as git modified
6770 let tree1_modified_files = [
6771 "dir1/modified1.txt",
6772 "dir1/modified2.txt",
6773 "modified4.txt",
6774 "dir2/modified3.txt",
6775 ];
6776
6777 let tree2_modified_files = ["dir3/modified5.txt", "modified6.txt"];
6778
6779 let root1_dot_git = Path::new("/root/tree1/.git");
6780 let root2_dot_git = Path::new("/root/tree2/.git");
6781 let set_value = FileStatus::Tracked(TrackedStatus {
6782 index_status: StatusCode::Modified,
6783 worktree_status: StatusCode::Modified,
6784 });
6785
6786 fs.with_git_state(&root1_dot_git, true, |git_repo_state| {
6787 for file_path in tree1_modified_files {
6788 git_repo_state.statuses.insert(file_path.into(), set_value);
6789 }
6790 });
6791
6792 fs.with_git_state(&root2_dot_git, true, |git_repo_state| {
6793 for file_path in tree2_modified_files {
6794 git_repo_state.statuses.insert(file_path.into(), set_value);
6795 }
6796 });
6797
6798 let project = Project::test(
6799 fs.clone(),
6800 ["/root/tree1".as_ref(), "/root/tree2".as_ref()],
6801 cx,
6802 )
6803 .await;
6804 let workspace =
6805 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6806 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6807 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6808
6809 // Check initial state
6810 assert_eq!(
6811 visible_entries_as_strings(&panel, 0..15, cx),
6812 &[
6813 "v tree1",
6814 " > .git",
6815 " > dir1",
6816 " > dir2",
6817 " modified4.txt",
6818 " unmodified3.txt",
6819 "v tree2",
6820 " > .git",
6821 " > dir3",
6822 " modified6.txt",
6823 " unmodified5.txt"
6824 ],
6825 );
6826
6827 // Test selecting next modified entry
6828 panel.update_in(cx, |panel, window, cx| {
6829 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6830 });
6831
6832 assert_eq!(
6833 visible_entries_as_strings(&panel, 0..6, cx),
6834 &[
6835 "v tree1",
6836 " > .git",
6837 " v dir1",
6838 " modified1.txt <== selected",
6839 " modified2.txt",
6840 " unmodified1.txt",
6841 ],
6842 );
6843
6844 panel.update_in(cx, |panel, window, cx| {
6845 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6846 });
6847
6848 assert_eq!(
6849 visible_entries_as_strings(&panel, 0..6, cx),
6850 &[
6851 "v tree1",
6852 " > .git",
6853 " v dir1",
6854 " modified1.txt",
6855 " modified2.txt <== selected",
6856 " unmodified1.txt",
6857 ],
6858 );
6859
6860 panel.update_in(cx, |panel, window, cx| {
6861 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6862 });
6863
6864 assert_eq!(
6865 visible_entries_as_strings(&panel, 6..9, cx),
6866 &[
6867 " v dir2",
6868 " modified3.txt <== selected",
6869 " unmodified2.txt",
6870 ],
6871 );
6872
6873 panel.update_in(cx, |panel, window, cx| {
6874 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6875 });
6876
6877 assert_eq!(
6878 visible_entries_as_strings(&panel, 9..11, cx),
6879 &[" modified4.txt <== selected", " unmodified3.txt",],
6880 );
6881
6882 panel.update_in(cx, |panel, window, cx| {
6883 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6884 });
6885
6886 assert_eq!(
6887 visible_entries_as_strings(&panel, 13..16, cx),
6888 &[
6889 " v dir3",
6890 " modified5.txt <== selected",
6891 " unmodified4.txt",
6892 ],
6893 );
6894
6895 panel.update_in(cx, |panel, window, cx| {
6896 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6897 });
6898
6899 assert_eq!(
6900 visible_entries_as_strings(&panel, 16..18, cx),
6901 &[" modified6.txt <== selected", " unmodified5.txt",],
6902 );
6903
6904 // Wraps around to first modified file
6905 panel.update_in(cx, |panel, window, cx| {
6906 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6907 });
6908
6909 assert_eq!(
6910 visible_entries_as_strings(&panel, 0..18, cx),
6911 &[
6912 "v tree1",
6913 " > .git",
6914 " v dir1",
6915 " modified1.txt <== selected",
6916 " modified2.txt",
6917 " unmodified1.txt",
6918 " v dir2",
6919 " modified3.txt",
6920 " unmodified2.txt",
6921 " modified4.txt",
6922 " unmodified3.txt",
6923 "v tree2",
6924 " > .git",
6925 " v dir3",
6926 " modified5.txt",
6927 " unmodified4.txt",
6928 " modified6.txt",
6929 " unmodified5.txt",
6930 ],
6931 );
6932
6933 // Wraps around again to last modified file
6934 panel.update_in(cx, |panel, window, cx| {
6935 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6936 });
6937
6938 assert_eq!(
6939 visible_entries_as_strings(&panel, 16..18, cx),
6940 &[" modified6.txt <== selected", " unmodified5.txt",],
6941 );
6942
6943 panel.update_in(cx, |panel, window, cx| {
6944 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6945 });
6946
6947 assert_eq!(
6948 visible_entries_as_strings(&panel, 13..16, cx),
6949 &[
6950 " v dir3",
6951 " modified5.txt <== selected",
6952 " unmodified4.txt",
6953 ],
6954 );
6955
6956 panel.update_in(cx, |panel, window, cx| {
6957 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6958 });
6959
6960 assert_eq!(
6961 visible_entries_as_strings(&panel, 9..11, cx),
6962 &[" modified4.txt <== selected", " unmodified3.txt",],
6963 );
6964
6965 panel.update_in(cx, |panel, window, cx| {
6966 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6967 });
6968
6969 assert_eq!(
6970 visible_entries_as_strings(&panel, 6..9, cx),
6971 &[
6972 " v dir2",
6973 " modified3.txt <== selected",
6974 " unmodified2.txt",
6975 ],
6976 );
6977
6978 panel.update_in(cx, |panel, window, cx| {
6979 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6980 });
6981
6982 assert_eq!(
6983 visible_entries_as_strings(&panel, 0..6, cx),
6984 &[
6985 "v tree1",
6986 " > .git",
6987 " v dir1",
6988 " modified1.txt",
6989 " modified2.txt <== selected",
6990 " unmodified1.txt",
6991 ],
6992 );
6993
6994 panel.update_in(cx, |panel, window, cx| {
6995 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6996 });
6997
6998 assert_eq!(
6999 visible_entries_as_strings(&panel, 0..6, cx),
7000 &[
7001 "v tree1",
7002 " > .git",
7003 " v dir1",
7004 " modified1.txt <== selected",
7005 " modified2.txt",
7006 " unmodified1.txt",
7007 ],
7008 );
7009 }
7010
7011 #[gpui::test]
7012 async fn test_select_directory(cx: &mut gpui::TestAppContext) {
7013 init_test_with_editor(cx);
7014
7015 let fs = FakeFs::new(cx.executor().clone());
7016 fs.insert_tree(
7017 "/project_root",
7018 json!({
7019 "dir_1": {
7020 "nested_dir": {
7021 "file_a.py": "# File contents",
7022 }
7023 },
7024 "file_1.py": "# File contents",
7025 "dir_2": {
7026
7027 },
7028 "dir_3": {
7029
7030 },
7031 "file_2.py": "# File contents",
7032 "dir_4": {
7033
7034 },
7035 }),
7036 )
7037 .await;
7038
7039 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7040 let workspace =
7041 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7042 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7043 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7044
7045 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7046 cx.executor().run_until_parked();
7047 select_path(&panel, "project_root/dir_1", cx);
7048 cx.executor().run_until_parked();
7049 assert_eq!(
7050 visible_entries_as_strings(&panel, 0..10, cx),
7051 &[
7052 "v project_root",
7053 " > dir_1 <== selected",
7054 " > dir_2",
7055 " > dir_3",
7056 " > dir_4",
7057 " file_1.py",
7058 " file_2.py",
7059 ]
7060 );
7061 panel.update_in(cx, |panel, window, cx| {
7062 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
7063 });
7064
7065 assert_eq!(
7066 visible_entries_as_strings(&panel, 0..10, cx),
7067 &[
7068 "v project_root <== selected",
7069 " > dir_1",
7070 " > dir_2",
7071 " > dir_3",
7072 " > dir_4",
7073 " file_1.py",
7074 " file_2.py",
7075 ]
7076 );
7077
7078 panel.update_in(cx, |panel, window, cx| {
7079 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
7080 });
7081
7082 assert_eq!(
7083 visible_entries_as_strings(&panel, 0..10, cx),
7084 &[
7085 "v project_root",
7086 " > dir_1",
7087 " > dir_2",
7088 " > dir_3",
7089 " > dir_4 <== selected",
7090 " file_1.py",
7091 " file_2.py",
7092 ]
7093 );
7094
7095 panel.update_in(cx, |panel, window, cx| {
7096 panel.select_next_directory(&SelectNextDirectory, window, cx)
7097 });
7098
7099 assert_eq!(
7100 visible_entries_as_strings(&panel, 0..10, cx),
7101 &[
7102 "v project_root <== selected",
7103 " > dir_1",
7104 " > dir_2",
7105 " > dir_3",
7106 " > dir_4",
7107 " file_1.py",
7108 " file_2.py",
7109 ]
7110 );
7111 }
7112
7113 #[gpui::test]
7114 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
7115 init_test_with_editor(cx);
7116
7117 let fs = FakeFs::new(cx.executor().clone());
7118 fs.insert_tree(
7119 "/project_root",
7120 json!({
7121 "dir_1": {
7122 "nested_dir": {
7123 "file_a.py": "# File contents",
7124 }
7125 },
7126 "file_1.py": "# File contents",
7127 }),
7128 )
7129 .await;
7130
7131 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7132 let workspace =
7133 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7134 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7135 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7136
7137 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7138 cx.executor().run_until_parked();
7139 select_path(&panel, "project_root/dir_1", cx);
7140 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7141 select_path(&panel, "project_root/dir_1/nested_dir", cx);
7142 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7143 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7144 cx.executor().run_until_parked();
7145 assert_eq!(
7146 visible_entries_as_strings(&panel, 0..10, cx),
7147 &[
7148 "v project_root",
7149 " v dir_1",
7150 " > nested_dir <== selected",
7151 " file_1.py",
7152 ]
7153 );
7154 }
7155
7156 #[gpui::test]
7157 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
7158 init_test_with_editor(cx);
7159
7160 let fs = FakeFs::new(cx.executor().clone());
7161 fs.insert_tree(
7162 "/project_root",
7163 json!({
7164 "dir_1": {
7165 "nested_dir": {
7166 "file_a.py": "# File contents",
7167 "file_b.py": "# File contents",
7168 "file_c.py": "# File contents",
7169 },
7170 "file_1.py": "# File contents",
7171 "file_2.py": "# File contents",
7172 "file_3.py": "# File contents",
7173 },
7174 "dir_2": {
7175 "file_1.py": "# File contents",
7176 "file_2.py": "# File contents",
7177 "file_3.py": "# File contents",
7178 }
7179 }),
7180 )
7181 .await;
7182
7183 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7184 let workspace =
7185 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7186 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7187 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7188
7189 panel.update_in(cx, |panel, window, cx| {
7190 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
7191 });
7192 cx.executor().run_until_parked();
7193 assert_eq!(
7194 visible_entries_as_strings(&panel, 0..10, cx),
7195 &["v project_root", " > dir_1", " > dir_2",]
7196 );
7197
7198 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
7199 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7200 cx.executor().run_until_parked();
7201 assert_eq!(
7202 visible_entries_as_strings(&panel, 0..10, cx),
7203 &[
7204 "v project_root",
7205 " v dir_1 <== selected",
7206 " > nested_dir",
7207 " file_1.py",
7208 " file_2.py",
7209 " file_3.py",
7210 " > dir_2",
7211 ]
7212 );
7213 }
7214
7215 #[gpui::test]
7216 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
7217 init_test(cx);
7218
7219 let fs = FakeFs::new(cx.executor().clone());
7220 fs.as_fake().insert_tree("/root", json!({})).await;
7221 let project = Project::test(fs, ["/root".as_ref()], cx).await;
7222 let workspace =
7223 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7224 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7225 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7226
7227 // Make a new buffer with no backing file
7228 workspace
7229 .update(cx, |workspace, window, cx| {
7230 Editor::new_file(workspace, &Default::default(), window, cx)
7231 })
7232 .unwrap();
7233
7234 cx.executor().run_until_parked();
7235
7236 // "Save as" the buffer, creating a new backing file for it
7237 let save_task = workspace
7238 .update(cx, |workspace, window, cx| {
7239 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
7240 })
7241 .unwrap();
7242
7243 cx.executor().run_until_parked();
7244 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
7245 save_task.await.unwrap();
7246
7247 // Rename the file
7248 select_path(&panel, "root/new", cx);
7249 assert_eq!(
7250 visible_entries_as_strings(&panel, 0..10, cx),
7251 &["v root", " new <== selected"]
7252 );
7253 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
7254 panel.update_in(cx, |panel, window, cx| {
7255 panel
7256 .filename_editor
7257 .update(cx, |editor, cx| editor.set_text("newer", window, cx));
7258 });
7259 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
7260
7261 cx.executor().run_until_parked();
7262 assert_eq!(
7263 visible_entries_as_strings(&panel, 0..10, cx),
7264 &["v root", " newer <== selected"]
7265 );
7266
7267 workspace
7268 .update(cx, |workspace, window, cx| {
7269 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
7270 })
7271 .unwrap()
7272 .await
7273 .unwrap();
7274
7275 cx.executor().run_until_parked();
7276 // assert that saving the file doesn't restore "new"
7277 assert_eq!(
7278 visible_entries_as_strings(&panel, 0..10, cx),
7279 &["v root", " newer <== selected"]
7280 );
7281 }
7282
7283 #[gpui::test]
7284 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
7285 init_test_with_editor(cx);
7286 let fs = FakeFs::new(cx.executor().clone());
7287 fs.insert_tree(
7288 "/project_root",
7289 json!({
7290 "dir_1": {
7291 "nested_dir": {
7292 "file_a.py": "# File contents",
7293 }
7294 },
7295 "file_1.py": "# File contents",
7296 }),
7297 )
7298 .await;
7299
7300 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7301 let worktree_id =
7302 cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
7303 let workspace =
7304 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7305 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7306 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7307 cx.update(|window, cx| {
7308 panel.update(cx, |this, cx| {
7309 this.select_next(&Default::default(), window, cx);
7310 this.expand_selected_entry(&Default::default(), window, cx);
7311 this.expand_selected_entry(&Default::default(), window, cx);
7312 this.select_next(&Default::default(), window, cx);
7313 this.expand_selected_entry(&Default::default(), window, cx);
7314 this.select_next(&Default::default(), window, cx);
7315 })
7316 });
7317 assert_eq!(
7318 visible_entries_as_strings(&panel, 0..10, cx),
7319 &[
7320 "v project_root",
7321 " v dir_1",
7322 " v nested_dir",
7323 " file_a.py <== selected",
7324 " file_1.py",
7325 ]
7326 );
7327 let modifiers_with_shift = gpui::Modifiers {
7328 shift: true,
7329 ..Default::default()
7330 };
7331 cx.simulate_modifiers_change(modifiers_with_shift);
7332 cx.update(|window, cx| {
7333 panel.update(cx, |this, cx| {
7334 this.select_next(&Default::default(), window, cx);
7335 })
7336 });
7337 assert_eq!(
7338 visible_entries_as_strings(&panel, 0..10, cx),
7339 &[
7340 "v project_root",
7341 " v dir_1",
7342 " v nested_dir",
7343 " file_a.py",
7344 " file_1.py <== selected <== marked",
7345 ]
7346 );
7347 cx.update(|window, cx| {
7348 panel.update(cx, |this, cx| {
7349 this.select_prev(&Default::default(), window, cx);
7350 })
7351 });
7352 assert_eq!(
7353 visible_entries_as_strings(&panel, 0..10, cx),
7354 &[
7355 "v project_root",
7356 " v dir_1",
7357 " v nested_dir",
7358 " file_a.py <== selected <== marked",
7359 " file_1.py <== marked",
7360 ]
7361 );
7362 cx.update(|window, cx| {
7363 panel.update(cx, |this, cx| {
7364 let drag = DraggedSelection {
7365 active_selection: this.selection.unwrap(),
7366 marked_selections: Arc::new(this.marked_entries.clone()),
7367 };
7368 let target_entry = this
7369 .project
7370 .read(cx)
7371 .entry_for_path(&(worktree_id, "").into(), cx)
7372 .unwrap();
7373 this.drag_onto(&drag, target_entry.id, false, window, cx);
7374 });
7375 });
7376 cx.run_until_parked();
7377 assert_eq!(
7378 visible_entries_as_strings(&panel, 0..10, cx),
7379 &[
7380 "v project_root",
7381 " v dir_1",
7382 " v nested_dir",
7383 " file_1.py <== marked",
7384 " file_a.py <== selected <== marked",
7385 ]
7386 );
7387 // ESC clears out all marks
7388 cx.update(|window, cx| {
7389 panel.update(cx, |this, cx| {
7390 this.cancel(&menu::Cancel, window, cx);
7391 })
7392 });
7393 assert_eq!(
7394 visible_entries_as_strings(&panel, 0..10, cx),
7395 &[
7396 "v project_root",
7397 " v dir_1",
7398 " v nested_dir",
7399 " file_1.py",
7400 " file_a.py <== selected",
7401 ]
7402 );
7403 // ESC clears out all marks
7404 cx.update(|window, cx| {
7405 panel.update(cx, |this, cx| {
7406 this.select_prev(&SelectPrev, window, cx);
7407 this.select_next(&SelectNext, window, cx);
7408 })
7409 });
7410 assert_eq!(
7411 visible_entries_as_strings(&panel, 0..10, cx),
7412 &[
7413 "v project_root",
7414 " v dir_1",
7415 " v nested_dir",
7416 " file_1.py <== marked",
7417 " file_a.py <== selected <== marked",
7418 ]
7419 );
7420 cx.simulate_modifiers_change(Default::default());
7421 cx.update(|window, cx| {
7422 panel.update(cx, |this, cx| {
7423 this.cut(&Cut, window, cx);
7424 this.select_prev(&SelectPrev, window, cx);
7425 this.select_prev(&SelectPrev, window, cx);
7426
7427 this.paste(&Paste, window, cx);
7428 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
7429 })
7430 });
7431 cx.run_until_parked();
7432 assert_eq!(
7433 visible_entries_as_strings(&panel, 0..10, cx),
7434 &[
7435 "v project_root",
7436 " v dir_1",
7437 " v nested_dir",
7438 " file_1.py <== marked",
7439 " file_a.py <== selected <== marked",
7440 ]
7441 );
7442 cx.simulate_modifiers_change(modifiers_with_shift);
7443 cx.update(|window, cx| {
7444 panel.update(cx, |this, cx| {
7445 this.expand_selected_entry(&Default::default(), window, cx);
7446 this.select_next(&SelectNext, window, cx);
7447 this.select_next(&SelectNext, window, cx);
7448 })
7449 });
7450 submit_deletion(&panel, cx);
7451 assert_eq!(
7452 visible_entries_as_strings(&panel, 0..10, cx),
7453 &[
7454 "v project_root",
7455 " v dir_1",
7456 " v nested_dir <== selected",
7457 ]
7458 );
7459 }
7460 #[gpui::test]
7461 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
7462 init_test_with_editor(cx);
7463 cx.update(|cx| {
7464 cx.update_global::<SettingsStore, _>(|store, cx| {
7465 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7466 worktree_settings.file_scan_exclusions = Some(Vec::new());
7467 });
7468 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7469 project_panel_settings.auto_reveal_entries = Some(false)
7470 });
7471 })
7472 });
7473
7474 let fs = FakeFs::new(cx.background_executor.clone());
7475 fs.insert_tree(
7476 "/project_root",
7477 json!({
7478 ".git": {},
7479 ".gitignore": "**/gitignored_dir",
7480 "dir_1": {
7481 "file_1.py": "# File 1_1 contents",
7482 "file_2.py": "# File 1_2 contents",
7483 "file_3.py": "# File 1_3 contents",
7484 "gitignored_dir": {
7485 "file_a.py": "# File contents",
7486 "file_b.py": "# File contents",
7487 "file_c.py": "# File contents",
7488 },
7489 },
7490 "dir_2": {
7491 "file_1.py": "# File 2_1 contents",
7492 "file_2.py": "# File 2_2 contents",
7493 "file_3.py": "# File 2_3 contents",
7494 }
7495 }),
7496 )
7497 .await;
7498
7499 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7500 let workspace =
7501 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7502 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7503 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7504
7505 assert_eq!(
7506 visible_entries_as_strings(&panel, 0..20, cx),
7507 &[
7508 "v project_root",
7509 " > .git",
7510 " > dir_1",
7511 " > dir_2",
7512 " .gitignore",
7513 ]
7514 );
7515
7516 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
7517 .expect("dir 1 file is not ignored and should have an entry");
7518 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
7519 .expect("dir 2 file is not ignored and should have an entry");
7520 let gitignored_dir_file =
7521 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
7522 assert_eq!(
7523 gitignored_dir_file, None,
7524 "File in the gitignored dir should not have an entry before its dir is toggled"
7525 );
7526
7527 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7528 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7529 cx.executor().run_until_parked();
7530 assert_eq!(
7531 visible_entries_as_strings(&panel, 0..20, cx),
7532 &[
7533 "v project_root",
7534 " > .git",
7535 " v dir_1",
7536 " v gitignored_dir <== selected",
7537 " file_a.py",
7538 " file_b.py",
7539 " file_c.py",
7540 " file_1.py",
7541 " file_2.py",
7542 " file_3.py",
7543 " > dir_2",
7544 " .gitignore",
7545 ],
7546 "Should show gitignored dir file list in the project panel"
7547 );
7548 let gitignored_dir_file =
7549 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
7550 .expect("after gitignored dir got opened, a file entry should be present");
7551
7552 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7553 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7554 assert_eq!(
7555 visible_entries_as_strings(&panel, 0..20, cx),
7556 &[
7557 "v project_root",
7558 " > .git",
7559 " > dir_1 <== selected",
7560 " > dir_2",
7561 " .gitignore",
7562 ],
7563 "Should hide all dir contents again and prepare for the auto reveal test"
7564 );
7565
7566 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
7567 panel.update(cx, |panel, cx| {
7568 panel.project.update(cx, |_, cx| {
7569 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
7570 })
7571 });
7572 cx.run_until_parked();
7573 assert_eq!(
7574 visible_entries_as_strings(&panel, 0..20, cx),
7575 &[
7576 "v project_root",
7577 " > .git",
7578 " > dir_1 <== selected",
7579 " > dir_2",
7580 " .gitignore",
7581 ],
7582 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
7583 );
7584 }
7585
7586 cx.update(|_, cx| {
7587 cx.update_global::<SettingsStore, _>(|store, cx| {
7588 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7589 project_panel_settings.auto_reveal_entries = Some(true)
7590 });
7591 })
7592 });
7593
7594 panel.update(cx, |panel, cx| {
7595 panel.project.update(cx, |_, cx| {
7596 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
7597 })
7598 });
7599 cx.run_until_parked();
7600 assert_eq!(
7601 visible_entries_as_strings(&panel, 0..20, cx),
7602 &[
7603 "v project_root",
7604 " > .git",
7605 " v dir_1",
7606 " > gitignored_dir",
7607 " file_1.py <== selected",
7608 " file_2.py",
7609 " file_3.py",
7610 " > dir_2",
7611 " .gitignore",
7612 ],
7613 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
7614 );
7615
7616 panel.update(cx, |panel, cx| {
7617 panel.project.update(cx, |_, cx| {
7618 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
7619 })
7620 });
7621 cx.run_until_parked();
7622 assert_eq!(
7623 visible_entries_as_strings(&panel, 0..20, cx),
7624 &[
7625 "v project_root",
7626 " > .git",
7627 " v dir_1",
7628 " > gitignored_dir",
7629 " file_1.py",
7630 " file_2.py",
7631 " file_3.py",
7632 " v dir_2",
7633 " file_1.py <== selected",
7634 " file_2.py",
7635 " file_3.py",
7636 " .gitignore",
7637 ],
7638 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
7639 );
7640
7641 panel.update(cx, |panel, cx| {
7642 panel.project.update(cx, |_, cx| {
7643 cx.emit(project::Event::ActiveEntryChanged(Some(
7644 gitignored_dir_file,
7645 )))
7646 })
7647 });
7648 cx.run_until_parked();
7649 assert_eq!(
7650 visible_entries_as_strings(&panel, 0..20, cx),
7651 &[
7652 "v project_root",
7653 " > .git",
7654 " v dir_1",
7655 " > gitignored_dir",
7656 " file_1.py",
7657 " file_2.py",
7658 " file_3.py",
7659 " v dir_2",
7660 " file_1.py <== selected",
7661 " file_2.py",
7662 " file_3.py",
7663 " .gitignore",
7664 ],
7665 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
7666 );
7667
7668 panel.update(cx, |panel, cx| {
7669 panel.project.update(cx, |_, cx| {
7670 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
7671 })
7672 });
7673 cx.run_until_parked();
7674 assert_eq!(
7675 visible_entries_as_strings(&panel, 0..20, cx),
7676 &[
7677 "v project_root",
7678 " > .git",
7679 " v dir_1",
7680 " v gitignored_dir",
7681 " file_a.py <== selected",
7682 " file_b.py",
7683 " file_c.py",
7684 " file_1.py",
7685 " file_2.py",
7686 " file_3.py",
7687 " v dir_2",
7688 " file_1.py",
7689 " file_2.py",
7690 " file_3.py",
7691 " .gitignore",
7692 ],
7693 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
7694 );
7695 }
7696
7697 #[gpui::test]
7698 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
7699 init_test_with_editor(cx);
7700 cx.update(|cx| {
7701 cx.update_global::<SettingsStore, _>(|store, cx| {
7702 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7703 worktree_settings.file_scan_exclusions = Some(Vec::new());
7704 });
7705 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7706 project_panel_settings.auto_reveal_entries = Some(false)
7707 });
7708 })
7709 });
7710
7711 let fs = FakeFs::new(cx.background_executor.clone());
7712 fs.insert_tree(
7713 "/project_root",
7714 json!({
7715 ".git": {},
7716 ".gitignore": "**/gitignored_dir",
7717 "dir_1": {
7718 "file_1.py": "# File 1_1 contents",
7719 "file_2.py": "# File 1_2 contents",
7720 "file_3.py": "# File 1_3 contents",
7721 "gitignored_dir": {
7722 "file_a.py": "# File contents",
7723 "file_b.py": "# File contents",
7724 "file_c.py": "# File contents",
7725 },
7726 },
7727 "dir_2": {
7728 "file_1.py": "# File 2_1 contents",
7729 "file_2.py": "# File 2_2 contents",
7730 "file_3.py": "# File 2_3 contents",
7731 }
7732 }),
7733 )
7734 .await;
7735
7736 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7737 let workspace =
7738 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7739 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7740 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7741
7742 assert_eq!(
7743 visible_entries_as_strings(&panel, 0..20, cx),
7744 &[
7745 "v project_root",
7746 " > .git",
7747 " > dir_1",
7748 " > dir_2",
7749 " .gitignore",
7750 ]
7751 );
7752
7753 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
7754 .expect("dir 1 file is not ignored and should have an entry");
7755 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
7756 .expect("dir 2 file is not ignored and should have an entry");
7757 let gitignored_dir_file =
7758 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
7759 assert_eq!(
7760 gitignored_dir_file, None,
7761 "File in the gitignored dir should not have an entry before its dir is toggled"
7762 );
7763
7764 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7765 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7766 cx.run_until_parked();
7767 assert_eq!(
7768 visible_entries_as_strings(&panel, 0..20, cx),
7769 &[
7770 "v project_root",
7771 " > .git",
7772 " v dir_1",
7773 " v gitignored_dir <== selected",
7774 " file_a.py",
7775 " file_b.py",
7776 " file_c.py",
7777 " file_1.py",
7778 " file_2.py",
7779 " file_3.py",
7780 " > dir_2",
7781 " .gitignore",
7782 ],
7783 "Should show gitignored dir file list in the project panel"
7784 );
7785 let gitignored_dir_file =
7786 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
7787 .expect("after gitignored dir got opened, a file entry should be present");
7788
7789 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7790 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7791 assert_eq!(
7792 visible_entries_as_strings(&panel, 0..20, cx),
7793 &[
7794 "v project_root",
7795 " > .git",
7796 " > dir_1 <== selected",
7797 " > dir_2",
7798 " .gitignore",
7799 ],
7800 "Should hide all dir contents again and prepare for the explicit reveal test"
7801 );
7802
7803 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
7804 panel.update(cx, |panel, cx| {
7805 panel.project.update(cx, |_, cx| {
7806 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
7807 })
7808 });
7809 cx.run_until_parked();
7810 assert_eq!(
7811 visible_entries_as_strings(&panel, 0..20, cx),
7812 &[
7813 "v project_root",
7814 " > .git",
7815 " > dir_1 <== selected",
7816 " > dir_2",
7817 " .gitignore",
7818 ],
7819 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
7820 );
7821 }
7822
7823 panel.update(cx, |panel, cx| {
7824 panel.project.update(cx, |_, cx| {
7825 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
7826 })
7827 });
7828 cx.run_until_parked();
7829 assert_eq!(
7830 visible_entries_as_strings(&panel, 0..20, cx),
7831 &[
7832 "v project_root",
7833 " > .git",
7834 " v dir_1",
7835 " > gitignored_dir",
7836 " file_1.py <== selected",
7837 " file_2.py",
7838 " file_3.py",
7839 " > dir_2",
7840 " .gitignore",
7841 ],
7842 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
7843 );
7844
7845 panel.update(cx, |panel, cx| {
7846 panel.project.update(cx, |_, cx| {
7847 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
7848 })
7849 });
7850 cx.run_until_parked();
7851 assert_eq!(
7852 visible_entries_as_strings(&panel, 0..20, cx),
7853 &[
7854 "v project_root",
7855 " > .git",
7856 " v dir_1",
7857 " > gitignored_dir",
7858 " file_1.py",
7859 " file_2.py",
7860 " file_3.py",
7861 " v dir_2",
7862 " file_1.py <== selected",
7863 " file_2.py",
7864 " file_3.py",
7865 " .gitignore",
7866 ],
7867 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
7868 );
7869
7870 panel.update(cx, |panel, cx| {
7871 panel.project.update(cx, |_, cx| {
7872 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
7873 })
7874 });
7875 cx.run_until_parked();
7876 assert_eq!(
7877 visible_entries_as_strings(&panel, 0..20, cx),
7878 &[
7879 "v project_root",
7880 " > .git",
7881 " v dir_1",
7882 " v gitignored_dir",
7883 " file_a.py <== selected",
7884 " file_b.py",
7885 " file_c.py",
7886 " file_1.py",
7887 " file_2.py",
7888 " file_3.py",
7889 " v dir_2",
7890 " file_1.py",
7891 " file_2.py",
7892 " file_3.py",
7893 " .gitignore",
7894 ],
7895 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
7896 );
7897 }
7898
7899 #[gpui::test]
7900 async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
7901 init_test(cx);
7902 cx.update(|cx| {
7903 cx.update_global::<SettingsStore, _>(|store, cx| {
7904 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
7905 project_settings.file_scan_exclusions =
7906 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
7907 });
7908 });
7909 });
7910
7911 cx.update(|cx| {
7912 register_project_item::<TestProjectItemView>(cx);
7913 });
7914
7915 let fs = FakeFs::new(cx.executor().clone());
7916 fs.insert_tree(
7917 "/root1",
7918 json!({
7919 ".dockerignore": "",
7920 ".git": {
7921 "HEAD": "",
7922 },
7923 }),
7924 )
7925 .await;
7926
7927 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
7928 let workspace =
7929 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7930 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7931 let panel = workspace
7932 .update(cx, |workspace, window, cx| {
7933 let panel = ProjectPanel::new(workspace, window, cx);
7934 workspace.add_panel(panel.clone(), window, cx);
7935 panel
7936 })
7937 .unwrap();
7938
7939 select_path(&panel, "root1", cx);
7940 assert_eq!(
7941 visible_entries_as_strings(&panel, 0..10, cx),
7942 &["v root1 <== selected", " .dockerignore",]
7943 );
7944 workspace
7945 .update(cx, |workspace, _, cx| {
7946 assert!(
7947 workspace.active_item(cx).is_none(),
7948 "Should have no active items in the beginning"
7949 );
7950 })
7951 .unwrap();
7952
7953 let excluded_file_path = ".git/COMMIT_EDITMSG";
7954 let excluded_dir_path = "excluded_dir";
7955
7956 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
7957 panel.update_in(cx, |panel, window, cx| {
7958 assert!(panel.filename_editor.read(cx).is_focused(window));
7959 });
7960 panel
7961 .update_in(cx, |panel, window, cx| {
7962 panel.filename_editor.update(cx, |editor, cx| {
7963 editor.set_text(excluded_file_path, window, cx)
7964 });
7965 panel.confirm_edit(window, cx).unwrap()
7966 })
7967 .await
7968 .unwrap();
7969
7970 assert_eq!(
7971 visible_entries_as_strings(&panel, 0..13, cx),
7972 &["v root1", " .dockerignore"],
7973 "Excluded dir should not be shown after opening a file in it"
7974 );
7975 panel.update_in(cx, |panel, window, cx| {
7976 assert!(
7977 !panel.filename_editor.read(cx).is_focused(window),
7978 "Should have closed the file name editor"
7979 );
7980 });
7981 workspace
7982 .update(cx, |workspace, _, cx| {
7983 let active_entry_path = workspace
7984 .active_item(cx)
7985 .expect("should have opened and activated the excluded item")
7986 .act_as::<TestProjectItemView>(cx)
7987 .expect(
7988 "should have opened the corresponding project item for the excluded item",
7989 )
7990 .read(cx)
7991 .path
7992 .clone();
7993 assert_eq!(
7994 active_entry_path.path.as_ref(),
7995 Path::new(excluded_file_path),
7996 "Should open the excluded file"
7997 );
7998
7999 assert!(
8000 workspace.notification_ids().is_empty(),
8001 "Should have no notifications after opening an excluded file"
8002 );
8003 })
8004 .unwrap();
8005 assert!(
8006 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
8007 "Should have created the excluded file"
8008 );
8009
8010 select_path(&panel, "root1", cx);
8011 panel.update_in(cx, |panel, window, cx| {
8012 panel.new_directory(&NewDirectory, window, cx)
8013 });
8014 panel.update_in(cx, |panel, window, cx| {
8015 assert!(panel.filename_editor.read(cx).is_focused(window));
8016 });
8017 panel
8018 .update_in(cx, |panel, window, cx| {
8019 panel.filename_editor.update(cx, |editor, cx| {
8020 editor.set_text(excluded_file_path, window, cx)
8021 });
8022 panel.confirm_edit(window, cx).unwrap()
8023 })
8024 .await
8025 .unwrap();
8026
8027 assert_eq!(
8028 visible_entries_as_strings(&panel, 0..13, cx),
8029 &["v root1", " .dockerignore"],
8030 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
8031 );
8032 panel.update_in(cx, |panel, window, cx| {
8033 assert!(
8034 !panel.filename_editor.read(cx).is_focused(window),
8035 "Should have closed the file name editor"
8036 );
8037 });
8038 workspace
8039 .update(cx, |workspace, _, cx| {
8040 let notifications = workspace.notification_ids();
8041 assert_eq!(
8042 notifications.len(),
8043 1,
8044 "Should receive one notification with the error message"
8045 );
8046 workspace.dismiss_notification(notifications.first().unwrap(), cx);
8047 assert!(workspace.notification_ids().is_empty());
8048 })
8049 .unwrap();
8050
8051 select_path(&panel, "root1", cx);
8052 panel.update_in(cx, |panel, window, cx| {
8053 panel.new_directory(&NewDirectory, window, cx)
8054 });
8055 panel.update_in(cx, |panel, window, cx| {
8056 assert!(panel.filename_editor.read(cx).is_focused(window));
8057 });
8058 panel
8059 .update_in(cx, |panel, window, cx| {
8060 panel.filename_editor.update(cx, |editor, cx| {
8061 editor.set_text(excluded_dir_path, window, cx)
8062 });
8063 panel.confirm_edit(window, cx).unwrap()
8064 })
8065 .await
8066 .unwrap();
8067
8068 assert_eq!(
8069 visible_entries_as_strings(&panel, 0..13, cx),
8070 &["v root1", " .dockerignore"],
8071 "Should not change the project panel after trying to create an excluded directory"
8072 );
8073 panel.update_in(cx, |panel, window, cx| {
8074 assert!(
8075 !panel.filename_editor.read(cx).is_focused(window),
8076 "Should have closed the file name editor"
8077 );
8078 });
8079 workspace
8080 .update(cx, |workspace, _, cx| {
8081 let notifications = workspace.notification_ids();
8082 assert_eq!(
8083 notifications.len(),
8084 1,
8085 "Should receive one notification explaining that no directory is actually shown"
8086 );
8087 workspace.dismiss_notification(notifications.first().unwrap(), cx);
8088 assert!(workspace.notification_ids().is_empty());
8089 })
8090 .unwrap();
8091 assert!(
8092 fs.is_dir(Path::new("/root1/excluded_dir")).await,
8093 "Should have created the excluded directory"
8094 );
8095 }
8096
8097 #[gpui::test]
8098 async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
8099 init_test_with_editor(cx);
8100
8101 let fs = FakeFs::new(cx.executor().clone());
8102 fs.insert_tree(
8103 "/src",
8104 json!({
8105 "test": {
8106 "first.rs": "// First Rust file",
8107 "second.rs": "// Second Rust file",
8108 "third.rs": "// Third Rust file",
8109 }
8110 }),
8111 )
8112 .await;
8113
8114 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
8115 let workspace =
8116 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8117 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8118 let panel = workspace
8119 .update(cx, |workspace, window, cx| {
8120 let panel = ProjectPanel::new(workspace, window, cx);
8121 workspace.add_panel(panel.clone(), window, cx);
8122 panel
8123 })
8124 .unwrap();
8125
8126 select_path(&panel, "src/", cx);
8127 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
8128 cx.executor().run_until_parked();
8129 assert_eq!(
8130 visible_entries_as_strings(&panel, 0..10, cx),
8131 &[
8132 //
8133 "v src <== selected",
8134 " > test"
8135 ]
8136 );
8137 panel.update_in(cx, |panel, window, cx| {
8138 panel.new_directory(&NewDirectory, window, cx)
8139 });
8140 panel.update_in(cx, |panel, window, cx| {
8141 assert!(panel.filename_editor.read(cx).is_focused(window));
8142 });
8143 assert_eq!(
8144 visible_entries_as_strings(&panel, 0..10, cx),
8145 &[
8146 //
8147 "v src",
8148 " > [EDITOR: ''] <== selected",
8149 " > test"
8150 ]
8151 );
8152
8153 panel.update_in(cx, |panel, window, cx| {
8154 panel.cancel(&menu::Cancel, window, cx)
8155 });
8156 assert_eq!(
8157 visible_entries_as_strings(&panel, 0..10, cx),
8158 &[
8159 //
8160 "v src <== selected",
8161 " > test"
8162 ]
8163 );
8164 }
8165
8166 #[gpui::test]
8167 async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
8168 init_test_with_editor(cx);
8169
8170 let fs = FakeFs::new(cx.executor().clone());
8171 fs.insert_tree(
8172 "/root",
8173 json!({
8174 "dir1": {
8175 "subdir1": {},
8176 "file1.txt": "",
8177 "file2.txt": "",
8178 },
8179 "dir2": {
8180 "subdir2": {},
8181 "file3.txt": "",
8182 "file4.txt": "",
8183 },
8184 "file5.txt": "",
8185 "file6.txt": "",
8186 }),
8187 )
8188 .await;
8189
8190 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8191 let workspace =
8192 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8193 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8194 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8195
8196 toggle_expand_dir(&panel, "root/dir1", cx);
8197 toggle_expand_dir(&panel, "root/dir2", cx);
8198
8199 // Test Case 1: Delete middle file in directory
8200 select_path(&panel, "root/dir1/file1.txt", cx);
8201 assert_eq!(
8202 visible_entries_as_strings(&panel, 0..15, cx),
8203 &[
8204 "v root",
8205 " v dir1",
8206 " > subdir1",
8207 " file1.txt <== selected",
8208 " file2.txt",
8209 " v dir2",
8210 " > subdir2",
8211 " file3.txt",
8212 " file4.txt",
8213 " file5.txt",
8214 " file6.txt",
8215 ],
8216 "Initial state before deleting middle file"
8217 );
8218
8219 submit_deletion(&panel, cx);
8220 assert_eq!(
8221 visible_entries_as_strings(&panel, 0..15, cx),
8222 &[
8223 "v root",
8224 " v dir1",
8225 " > subdir1",
8226 " file2.txt <== selected",
8227 " v dir2",
8228 " > subdir2",
8229 " file3.txt",
8230 " file4.txt",
8231 " file5.txt",
8232 " file6.txt",
8233 ],
8234 "Should select next file after deleting middle file"
8235 );
8236
8237 // Test Case 2: Delete last file in directory
8238 submit_deletion(&panel, cx);
8239 assert_eq!(
8240 visible_entries_as_strings(&panel, 0..15, cx),
8241 &[
8242 "v root",
8243 " v dir1",
8244 " > subdir1 <== selected",
8245 " v dir2",
8246 " > subdir2",
8247 " file3.txt",
8248 " file4.txt",
8249 " file5.txt",
8250 " file6.txt",
8251 ],
8252 "Should select next directory when last file is deleted"
8253 );
8254
8255 // Test Case 3: Delete root level file
8256 select_path(&panel, "root/file6.txt", cx);
8257 assert_eq!(
8258 visible_entries_as_strings(&panel, 0..15, cx),
8259 &[
8260 "v root",
8261 " v dir1",
8262 " > subdir1",
8263 " v dir2",
8264 " > subdir2",
8265 " file3.txt",
8266 " file4.txt",
8267 " file5.txt",
8268 " file6.txt <== selected",
8269 ],
8270 "Initial state before deleting root level file"
8271 );
8272
8273 submit_deletion(&panel, cx);
8274 assert_eq!(
8275 visible_entries_as_strings(&panel, 0..15, cx),
8276 &[
8277 "v root",
8278 " v dir1",
8279 " > subdir1",
8280 " v dir2",
8281 " > subdir2",
8282 " file3.txt",
8283 " file4.txt",
8284 " file5.txt <== selected",
8285 ],
8286 "Should select prev entry at root level"
8287 );
8288 }
8289
8290 #[gpui::test]
8291 async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
8292 init_test_with_editor(cx);
8293
8294 let fs = FakeFs::new(cx.executor().clone());
8295 fs.insert_tree(
8296 "/root",
8297 json!({
8298 "dir1": {
8299 "subdir1": {
8300 "a.txt": "",
8301 "b.txt": ""
8302 },
8303 "file1.txt": "",
8304 },
8305 "dir2": {
8306 "subdir2": {
8307 "c.txt": "",
8308 "d.txt": ""
8309 },
8310 "file2.txt": "",
8311 },
8312 "file3.txt": "",
8313 }),
8314 )
8315 .await;
8316
8317 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8318 let workspace =
8319 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8320 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8321 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8322
8323 toggle_expand_dir(&panel, "root/dir1", cx);
8324 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8325 toggle_expand_dir(&panel, "root/dir2", cx);
8326 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
8327
8328 // Test Case 1: Select and delete nested directory with parent
8329 cx.simulate_modifiers_change(gpui::Modifiers {
8330 control: true,
8331 ..Default::default()
8332 });
8333 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
8334 select_path_with_mark(&panel, "root/dir1", cx);
8335
8336 assert_eq!(
8337 visible_entries_as_strings(&panel, 0..15, cx),
8338 &[
8339 "v root",
8340 " v dir1 <== selected <== marked",
8341 " v subdir1 <== marked",
8342 " a.txt",
8343 " b.txt",
8344 " file1.txt",
8345 " v dir2",
8346 " v subdir2",
8347 " c.txt",
8348 " d.txt",
8349 " file2.txt",
8350 " file3.txt",
8351 ],
8352 "Initial state before deleting nested directory with parent"
8353 );
8354
8355 submit_deletion(&panel, cx);
8356 assert_eq!(
8357 visible_entries_as_strings(&panel, 0..15, cx),
8358 &[
8359 "v root",
8360 " v dir2 <== selected",
8361 " v subdir2",
8362 " c.txt",
8363 " d.txt",
8364 " file2.txt",
8365 " file3.txt",
8366 ],
8367 "Should select next directory after deleting directory with parent"
8368 );
8369
8370 // Test Case 2: Select mixed files and directories across levels
8371 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
8372 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
8373 select_path_with_mark(&panel, "root/file3.txt", cx);
8374
8375 assert_eq!(
8376 visible_entries_as_strings(&panel, 0..15, cx),
8377 &[
8378 "v root",
8379 " v dir2",
8380 " v subdir2",
8381 " c.txt <== marked",
8382 " d.txt",
8383 " file2.txt <== marked",
8384 " file3.txt <== selected <== marked",
8385 ],
8386 "Initial state before deleting"
8387 );
8388
8389 submit_deletion(&panel, cx);
8390 assert_eq!(
8391 visible_entries_as_strings(&panel, 0..15, cx),
8392 &[
8393 "v root",
8394 " v dir2 <== selected",
8395 " v subdir2",
8396 " d.txt",
8397 ],
8398 "Should select sibling directory"
8399 );
8400 }
8401
8402 #[gpui::test]
8403 async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
8404 init_test_with_editor(cx);
8405
8406 let fs = FakeFs::new(cx.executor().clone());
8407 fs.insert_tree(
8408 "/root",
8409 json!({
8410 "dir1": {
8411 "subdir1": {
8412 "a.txt": "",
8413 "b.txt": ""
8414 },
8415 "file1.txt": "",
8416 },
8417 "dir2": {
8418 "subdir2": {
8419 "c.txt": "",
8420 "d.txt": ""
8421 },
8422 "file2.txt": "",
8423 },
8424 "file3.txt": "",
8425 "file4.txt": "",
8426 }),
8427 )
8428 .await;
8429
8430 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8431 let workspace =
8432 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8433 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8434 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8435
8436 toggle_expand_dir(&panel, "root/dir1", cx);
8437 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8438 toggle_expand_dir(&panel, "root/dir2", cx);
8439 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
8440
8441 // Test Case 1: Select all root files and directories
8442 cx.simulate_modifiers_change(gpui::Modifiers {
8443 control: true,
8444 ..Default::default()
8445 });
8446 select_path_with_mark(&panel, "root/dir1", cx);
8447 select_path_with_mark(&panel, "root/dir2", cx);
8448 select_path_with_mark(&panel, "root/file3.txt", cx);
8449 select_path_with_mark(&panel, "root/file4.txt", cx);
8450 assert_eq!(
8451 visible_entries_as_strings(&panel, 0..20, cx),
8452 &[
8453 "v root",
8454 " v dir1 <== marked",
8455 " v subdir1",
8456 " a.txt",
8457 " b.txt",
8458 " file1.txt",
8459 " v dir2 <== marked",
8460 " v subdir2",
8461 " c.txt",
8462 " d.txt",
8463 " file2.txt",
8464 " file3.txt <== marked",
8465 " file4.txt <== selected <== marked",
8466 ],
8467 "State before deleting all contents"
8468 );
8469
8470 submit_deletion(&panel, cx);
8471 assert_eq!(
8472 visible_entries_as_strings(&panel, 0..20, cx),
8473 &["v root <== selected"],
8474 "Only empty root directory should remain after deleting all contents"
8475 );
8476 }
8477
8478 #[gpui::test]
8479 async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
8480 init_test_with_editor(cx);
8481
8482 let fs = FakeFs::new(cx.executor().clone());
8483 fs.insert_tree(
8484 "/root",
8485 json!({
8486 "dir1": {
8487 "subdir1": {
8488 "file_a.txt": "content a",
8489 "file_b.txt": "content b",
8490 },
8491 "subdir2": {
8492 "file_c.txt": "content c",
8493 },
8494 "file1.txt": "content 1",
8495 },
8496 "dir2": {
8497 "file2.txt": "content 2",
8498 },
8499 }),
8500 )
8501 .await;
8502
8503 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8504 let workspace =
8505 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8506 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8507 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8508
8509 toggle_expand_dir(&panel, "root/dir1", cx);
8510 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8511 toggle_expand_dir(&panel, "root/dir2", cx);
8512 cx.simulate_modifiers_change(gpui::Modifiers {
8513 control: true,
8514 ..Default::default()
8515 });
8516
8517 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
8518 select_path_with_mark(&panel, "root/dir1", cx);
8519 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
8520 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
8521
8522 assert_eq!(
8523 visible_entries_as_strings(&panel, 0..20, cx),
8524 &[
8525 "v root",
8526 " v dir1 <== marked",
8527 " v subdir1 <== marked",
8528 " file_a.txt <== selected <== marked",
8529 " file_b.txt",
8530 " > subdir2",
8531 " file1.txt",
8532 " v dir2",
8533 " file2.txt",
8534 ],
8535 "State with parent dir, subdir, and file selected"
8536 );
8537 submit_deletion(&panel, cx);
8538 assert_eq!(
8539 visible_entries_as_strings(&panel, 0..20, cx),
8540 &["v root", " v dir2 <== selected", " file2.txt",],
8541 "Only dir2 should remain after deletion"
8542 );
8543 }
8544
8545 #[gpui::test]
8546 async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
8547 init_test_with_editor(cx);
8548
8549 let fs = FakeFs::new(cx.executor().clone());
8550 // First worktree
8551 fs.insert_tree(
8552 "/root1",
8553 json!({
8554 "dir1": {
8555 "file1.txt": "content 1",
8556 "file2.txt": "content 2",
8557 },
8558 "dir2": {
8559 "file3.txt": "content 3",
8560 },
8561 }),
8562 )
8563 .await;
8564
8565 // Second worktree
8566 fs.insert_tree(
8567 "/root2",
8568 json!({
8569 "dir3": {
8570 "file4.txt": "content 4",
8571 "file5.txt": "content 5",
8572 },
8573 "file6.txt": "content 6",
8574 }),
8575 )
8576 .await;
8577
8578 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8579 let workspace =
8580 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8581 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8582 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8583
8584 // Expand all directories for testing
8585 toggle_expand_dir(&panel, "root1/dir1", cx);
8586 toggle_expand_dir(&panel, "root1/dir2", cx);
8587 toggle_expand_dir(&panel, "root2/dir3", cx);
8588
8589 // Test Case 1: Delete files across different worktrees
8590 cx.simulate_modifiers_change(gpui::Modifiers {
8591 control: true,
8592 ..Default::default()
8593 });
8594 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
8595 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
8596
8597 assert_eq!(
8598 visible_entries_as_strings(&panel, 0..20, cx),
8599 &[
8600 "v root1",
8601 " v dir1",
8602 " file1.txt <== marked",
8603 " file2.txt",
8604 " v dir2",
8605 " file3.txt",
8606 "v root2",
8607 " v dir3",
8608 " file4.txt <== selected <== marked",
8609 " file5.txt",
8610 " file6.txt",
8611 ],
8612 "Initial state with files selected from different worktrees"
8613 );
8614
8615 submit_deletion(&panel, cx);
8616 assert_eq!(
8617 visible_entries_as_strings(&panel, 0..20, cx),
8618 &[
8619 "v root1",
8620 " v dir1",
8621 " file2.txt",
8622 " v dir2",
8623 " file3.txt",
8624 "v root2",
8625 " v dir3",
8626 " file5.txt <== selected",
8627 " file6.txt",
8628 ],
8629 "Should select next file in the last worktree after deletion"
8630 );
8631
8632 // Test Case 2: Delete directories from different worktrees
8633 select_path_with_mark(&panel, "root1/dir1", cx);
8634 select_path_with_mark(&panel, "root2/dir3", cx);
8635
8636 assert_eq!(
8637 visible_entries_as_strings(&panel, 0..20, cx),
8638 &[
8639 "v root1",
8640 " v dir1 <== marked",
8641 " file2.txt",
8642 " v dir2",
8643 " file3.txt",
8644 "v root2",
8645 " v dir3 <== selected <== marked",
8646 " file5.txt",
8647 " file6.txt",
8648 ],
8649 "State with directories marked from different worktrees"
8650 );
8651
8652 submit_deletion(&panel, cx);
8653 assert_eq!(
8654 visible_entries_as_strings(&panel, 0..20, cx),
8655 &[
8656 "v root1",
8657 " v dir2",
8658 " file3.txt",
8659 "v root2",
8660 " file6.txt <== selected",
8661 ],
8662 "Should select remaining file in last worktree after directory deletion"
8663 );
8664
8665 // Test Case 4: Delete all remaining files except roots
8666 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
8667 select_path_with_mark(&panel, "root2/file6.txt", cx);
8668
8669 assert_eq!(
8670 visible_entries_as_strings(&panel, 0..20, cx),
8671 &[
8672 "v root1",
8673 " v dir2",
8674 " file3.txt <== marked",
8675 "v root2",
8676 " file6.txt <== selected <== marked",
8677 ],
8678 "State with all remaining files marked"
8679 );
8680
8681 submit_deletion(&panel, cx);
8682 assert_eq!(
8683 visible_entries_as_strings(&panel, 0..20, cx),
8684 &["v root1", " v dir2", "v root2 <== selected"],
8685 "Second parent root should be selected after deleting"
8686 );
8687 }
8688
8689 #[gpui::test]
8690 async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
8691 init_test_with_editor(cx);
8692
8693 let fs = FakeFs::new(cx.executor().clone());
8694 fs.insert_tree(
8695 "/root",
8696 json!({
8697 "dir1": {
8698 "file1.txt": "",
8699 "file2.txt": "",
8700 "file3.txt": "",
8701 },
8702 "dir2": {
8703 "file4.txt": "",
8704 "file5.txt": "",
8705 },
8706 }),
8707 )
8708 .await;
8709
8710 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8711 let workspace =
8712 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8713 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8714 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8715
8716 toggle_expand_dir(&panel, "root/dir1", cx);
8717 toggle_expand_dir(&panel, "root/dir2", cx);
8718
8719 cx.simulate_modifiers_change(gpui::Modifiers {
8720 control: true,
8721 ..Default::default()
8722 });
8723
8724 select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
8725 select_path(&panel, "root/dir1/file1.txt", cx);
8726
8727 assert_eq!(
8728 visible_entries_as_strings(&panel, 0..15, cx),
8729 &[
8730 "v root",
8731 " v dir1",
8732 " file1.txt <== selected",
8733 " file2.txt <== marked",
8734 " file3.txt",
8735 " v dir2",
8736 " file4.txt",
8737 " file5.txt",
8738 ],
8739 "Initial state with one marked entry and different selection"
8740 );
8741
8742 // Delete should operate on the selected entry (file1.txt)
8743 submit_deletion(&panel, cx);
8744 assert_eq!(
8745 visible_entries_as_strings(&panel, 0..15, cx),
8746 &[
8747 "v root",
8748 " v dir1",
8749 " file2.txt <== selected <== marked",
8750 " file3.txt",
8751 " v dir2",
8752 " file4.txt",
8753 " file5.txt",
8754 ],
8755 "Should delete selected file, not marked file"
8756 );
8757
8758 select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
8759 select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
8760 select_path(&panel, "root/dir2/file5.txt", cx);
8761
8762 assert_eq!(
8763 visible_entries_as_strings(&panel, 0..15, cx),
8764 &[
8765 "v root",
8766 " v dir1",
8767 " file2.txt <== marked",
8768 " file3.txt <== marked",
8769 " v dir2",
8770 " file4.txt <== marked",
8771 " file5.txt <== selected",
8772 ],
8773 "Initial state with multiple marked entries and different selection"
8774 );
8775
8776 // Delete should operate on all marked entries, ignoring the selection
8777 submit_deletion(&panel, cx);
8778 assert_eq!(
8779 visible_entries_as_strings(&panel, 0..15, cx),
8780 &[
8781 "v root",
8782 " v dir1",
8783 " v dir2",
8784 " file5.txt <== selected",
8785 ],
8786 "Should delete all marked files, leaving only the selected file"
8787 );
8788 }
8789
8790 #[gpui::test]
8791 async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
8792 init_test_with_editor(cx);
8793
8794 let fs = FakeFs::new(cx.executor().clone());
8795 fs.insert_tree(
8796 "/root_b",
8797 json!({
8798 "dir1": {
8799 "file1.txt": "content 1",
8800 "file2.txt": "content 2",
8801 },
8802 }),
8803 )
8804 .await;
8805
8806 fs.insert_tree(
8807 "/root_c",
8808 json!({
8809 "dir2": {},
8810 }),
8811 )
8812 .await;
8813
8814 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
8815 let workspace =
8816 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8817 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8818 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8819
8820 toggle_expand_dir(&panel, "root_b/dir1", cx);
8821 toggle_expand_dir(&panel, "root_c/dir2", cx);
8822
8823 cx.simulate_modifiers_change(gpui::Modifiers {
8824 control: true,
8825 ..Default::default()
8826 });
8827 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
8828 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
8829
8830 assert_eq!(
8831 visible_entries_as_strings(&panel, 0..20, cx),
8832 &[
8833 "v root_b",
8834 " v dir1",
8835 " file1.txt <== marked",
8836 " file2.txt <== selected <== marked",
8837 "v root_c",
8838 " v dir2",
8839 ],
8840 "Initial state with files marked in root_b"
8841 );
8842
8843 submit_deletion(&panel, cx);
8844 assert_eq!(
8845 visible_entries_as_strings(&panel, 0..20, cx),
8846 &[
8847 "v root_b",
8848 " v dir1 <== selected",
8849 "v root_c",
8850 " v dir2",
8851 ],
8852 "After deletion in root_b as it's last deletion, selection should be in root_b"
8853 );
8854
8855 select_path_with_mark(&panel, "root_c/dir2", cx);
8856
8857 submit_deletion(&panel, cx);
8858 assert_eq!(
8859 visible_entries_as_strings(&panel, 0..20, cx),
8860 &["v root_b", " v dir1", "v root_c <== selected",],
8861 "After deleting from root_c, it should remain in root_c"
8862 );
8863 }
8864
8865 fn toggle_expand_dir(
8866 panel: &Entity<ProjectPanel>,
8867 path: impl AsRef<Path>,
8868 cx: &mut VisualTestContext,
8869 ) {
8870 let path = path.as_ref();
8871 panel.update_in(cx, |panel, window, cx| {
8872 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8873 let worktree = worktree.read(cx);
8874 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8875 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8876 panel.toggle_expanded(entry_id, window, cx);
8877 return;
8878 }
8879 }
8880 panic!("no worktree for path {:?}", path);
8881 });
8882 }
8883
8884 #[gpui::test]
8885 async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
8886 init_test_with_editor(cx);
8887
8888 let fs = FakeFs::new(cx.executor().clone());
8889 fs.insert_tree(
8890 path!("/root"),
8891 json!({
8892 ".gitignore": "**/ignored_dir\n**/ignored_nested",
8893 "dir1": {
8894 "empty1": {
8895 "empty2": {
8896 "empty3": {
8897 "file.txt": ""
8898 }
8899 }
8900 },
8901 "subdir1": {
8902 "file1.txt": "",
8903 "file2.txt": "",
8904 "ignored_nested": {
8905 "ignored_file.txt": ""
8906 }
8907 },
8908 "ignored_dir": {
8909 "subdir": {
8910 "deep_file.txt": ""
8911 }
8912 }
8913 }
8914 }),
8915 )
8916 .await;
8917
8918 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
8919 let workspace =
8920 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8921 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8922
8923 // Test 1: When auto-fold is enabled
8924 cx.update(|_, cx| {
8925 let settings = *ProjectPanelSettings::get_global(cx);
8926 ProjectPanelSettings::override_global(
8927 ProjectPanelSettings {
8928 auto_fold_dirs: true,
8929 ..settings
8930 },
8931 cx,
8932 );
8933 });
8934
8935 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8936
8937 assert_eq!(
8938 visible_entries_as_strings(&panel, 0..20, cx),
8939 &["v root", " > dir1", " .gitignore",],
8940 "Initial state should show collapsed root structure"
8941 );
8942
8943 toggle_expand_dir(&panel, "root/dir1", cx);
8944 assert_eq!(
8945 visible_entries_as_strings(&panel, 0..20, cx),
8946 &[
8947 separator!("v root"),
8948 separator!(" v dir1 <== selected"),
8949 separator!(" > empty1/empty2/empty3"),
8950 separator!(" > ignored_dir"),
8951 separator!(" > subdir1"),
8952 separator!(" .gitignore"),
8953 ],
8954 "Should show first level with auto-folded dirs and ignored dir visible"
8955 );
8956
8957 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
8958 panel.update(cx, |panel, cx| {
8959 let project = panel.project.read(cx);
8960 let worktree = project.worktrees(cx).next().unwrap().read(cx);
8961 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
8962 panel.update_visible_entries(None, cx);
8963 });
8964 cx.run_until_parked();
8965
8966 assert_eq!(
8967 visible_entries_as_strings(&panel, 0..20, cx),
8968 &[
8969 separator!("v root"),
8970 separator!(" v dir1 <== selected"),
8971 separator!(" v empty1"),
8972 separator!(" v empty2"),
8973 separator!(" v empty3"),
8974 separator!(" file.txt"),
8975 separator!(" > ignored_dir"),
8976 separator!(" v subdir1"),
8977 separator!(" > ignored_nested"),
8978 separator!(" file1.txt"),
8979 separator!(" file2.txt"),
8980 separator!(" .gitignore"),
8981 ],
8982 "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
8983 );
8984
8985 // Test 2: When auto-fold is disabled
8986 cx.update(|_, cx| {
8987 let settings = *ProjectPanelSettings::get_global(cx);
8988 ProjectPanelSettings::override_global(
8989 ProjectPanelSettings {
8990 auto_fold_dirs: false,
8991 ..settings
8992 },
8993 cx,
8994 );
8995 });
8996
8997 panel.update_in(cx, |panel, window, cx| {
8998 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
8999 });
9000
9001 toggle_expand_dir(&panel, "root/dir1", cx);
9002 assert_eq!(
9003 visible_entries_as_strings(&panel, 0..20, cx),
9004 &[
9005 separator!("v root"),
9006 separator!(" v dir1 <== selected"),
9007 separator!(" > empty1"),
9008 separator!(" > ignored_dir"),
9009 separator!(" > subdir1"),
9010 separator!(" .gitignore"),
9011 ],
9012 "With auto-fold disabled: should show all directories separately"
9013 );
9014
9015 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9016 panel.update(cx, |panel, cx| {
9017 let project = panel.project.read(cx);
9018 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9019 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
9020 panel.update_visible_entries(None, cx);
9021 });
9022 cx.run_until_parked();
9023
9024 assert_eq!(
9025 visible_entries_as_strings(&panel, 0..20, cx),
9026 &[
9027 separator!("v root"),
9028 separator!(" v dir1 <== selected"),
9029 separator!(" v empty1"),
9030 separator!(" v empty2"),
9031 separator!(" v empty3"),
9032 separator!(" file.txt"),
9033 separator!(" > ignored_dir"),
9034 separator!(" v subdir1"),
9035 separator!(" > ignored_nested"),
9036 separator!(" file1.txt"),
9037 separator!(" file2.txt"),
9038 separator!(" .gitignore"),
9039 ],
9040 "After expand_all without auto-fold: should expand all dirs normally, \
9041 expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
9042 );
9043
9044 // Test 3: When explicitly called on ignored directory
9045 let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
9046 panel.update(cx, |panel, cx| {
9047 let project = panel.project.read(cx);
9048 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9049 panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
9050 panel.update_visible_entries(None, cx);
9051 });
9052 cx.run_until_parked();
9053
9054 assert_eq!(
9055 visible_entries_as_strings(&panel, 0..20, cx),
9056 &[
9057 separator!("v root"),
9058 separator!(" v dir1 <== selected"),
9059 separator!(" v empty1"),
9060 separator!(" v empty2"),
9061 separator!(" v empty3"),
9062 separator!(" file.txt"),
9063 separator!(" v ignored_dir"),
9064 separator!(" v subdir"),
9065 separator!(" deep_file.txt"),
9066 separator!(" v subdir1"),
9067 separator!(" > ignored_nested"),
9068 separator!(" file1.txt"),
9069 separator!(" file2.txt"),
9070 separator!(" .gitignore"),
9071 ],
9072 "After expand_all on ignored_dir: should expand all contents of the ignored directory"
9073 );
9074 }
9075
9076 #[gpui::test]
9077 async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
9078 init_test(cx);
9079
9080 let fs = FakeFs::new(cx.executor().clone());
9081 fs.insert_tree(
9082 path!("/root"),
9083 json!({
9084 "dir1": {
9085 "subdir1": {
9086 "nested1": {
9087 "file1.txt": "",
9088 "file2.txt": ""
9089 },
9090 },
9091 "subdir2": {
9092 "file4.txt": ""
9093 }
9094 },
9095 "dir2": {
9096 "single_file": {
9097 "file5.txt": ""
9098 }
9099 }
9100 }),
9101 )
9102 .await;
9103
9104 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
9105 let workspace =
9106 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
9107 let cx = &mut VisualTestContext::from_window(*workspace, cx);
9108
9109 // Test 1: Basic collapsing
9110 {
9111 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9112
9113 toggle_expand_dir(&panel, "root/dir1", cx);
9114 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
9115 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
9116 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
9117
9118 assert_eq!(
9119 visible_entries_as_strings(&panel, 0..20, cx),
9120 &[
9121 separator!("v root"),
9122 separator!(" v dir1"),
9123 separator!(" v subdir1"),
9124 separator!(" v nested1"),
9125 separator!(" file1.txt"),
9126 separator!(" file2.txt"),
9127 separator!(" v subdir2 <== selected"),
9128 separator!(" file4.txt"),
9129 separator!(" > dir2"),
9130 ],
9131 "Initial state with everything expanded"
9132 );
9133
9134 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9135 panel.update(cx, |panel, cx| {
9136 let project = panel.project.read(cx);
9137 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9138 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
9139 panel.update_visible_entries(None, cx);
9140 });
9141
9142 assert_eq!(
9143 visible_entries_as_strings(&panel, 0..20, cx),
9144 &["v root", " > dir1", " > dir2",],
9145 "All subdirs under dir1 should be collapsed"
9146 );
9147 }
9148
9149 // Test 2: With auto-fold enabled
9150 {
9151 cx.update(|_, cx| {
9152 let settings = *ProjectPanelSettings::get_global(cx);
9153 ProjectPanelSettings::override_global(
9154 ProjectPanelSettings {
9155 auto_fold_dirs: true,
9156 ..settings
9157 },
9158 cx,
9159 );
9160 });
9161
9162 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9163
9164 toggle_expand_dir(&panel, "root/dir1", cx);
9165 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
9166 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
9167
9168 assert_eq!(
9169 visible_entries_as_strings(&panel, 0..20, cx),
9170 &[
9171 separator!("v root"),
9172 separator!(" v dir1"),
9173 separator!(" v subdir1/nested1 <== selected"),
9174 separator!(" file1.txt"),
9175 separator!(" file2.txt"),
9176 separator!(" > subdir2"),
9177 separator!(" > dir2/single_file"),
9178 ],
9179 "Initial state with some dirs expanded"
9180 );
9181
9182 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9183 panel.update(cx, |panel, cx| {
9184 let project = panel.project.read(cx);
9185 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9186 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
9187 });
9188
9189 toggle_expand_dir(&panel, "root/dir1", cx);
9190
9191 assert_eq!(
9192 visible_entries_as_strings(&panel, 0..20, cx),
9193 &[
9194 separator!("v root"),
9195 separator!(" v dir1 <== selected"),
9196 separator!(" > subdir1/nested1"),
9197 separator!(" > subdir2"),
9198 separator!(" > dir2/single_file"),
9199 ],
9200 "Subdirs should be collapsed and folded with auto-fold enabled"
9201 );
9202 }
9203
9204 // Test 3: With auto-fold disabled
9205 {
9206 cx.update(|_, cx| {
9207 let settings = *ProjectPanelSettings::get_global(cx);
9208 ProjectPanelSettings::override_global(
9209 ProjectPanelSettings {
9210 auto_fold_dirs: false,
9211 ..settings
9212 },
9213 cx,
9214 );
9215 });
9216
9217 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9218
9219 toggle_expand_dir(&panel, "root/dir1", cx);
9220 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
9221 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
9222
9223 assert_eq!(
9224 visible_entries_as_strings(&panel, 0..20, cx),
9225 &[
9226 separator!("v root"),
9227 separator!(" v dir1"),
9228 separator!(" v subdir1"),
9229 separator!(" v nested1 <== selected"),
9230 separator!(" file1.txt"),
9231 separator!(" file2.txt"),
9232 separator!(" > subdir2"),
9233 separator!(" > dir2"),
9234 ],
9235 "Initial state with some dirs expanded and auto-fold disabled"
9236 );
9237
9238 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9239 panel.update(cx, |panel, cx| {
9240 let project = panel.project.read(cx);
9241 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9242 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
9243 });
9244
9245 toggle_expand_dir(&panel, "root/dir1", cx);
9246
9247 assert_eq!(
9248 visible_entries_as_strings(&panel, 0..20, cx),
9249 &[
9250 separator!("v root"),
9251 separator!(" v dir1 <== selected"),
9252 separator!(" > subdir1"),
9253 separator!(" > subdir2"),
9254 separator!(" > dir2"),
9255 ],
9256 "Subdirs should be collapsed but not folded with auto-fold disabled"
9257 );
9258 }
9259 }
9260
9261 fn select_path(
9262 panel: &Entity<ProjectPanel>,
9263 path: impl AsRef<Path>,
9264 cx: &mut VisualTestContext,
9265 ) {
9266 let path = path.as_ref();
9267 panel.update(cx, |panel, cx| {
9268 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9269 let worktree = worktree.read(cx);
9270 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9271 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9272 panel.selection = Some(crate::SelectedEntry {
9273 worktree_id: worktree.id(),
9274 entry_id,
9275 });
9276 return;
9277 }
9278 }
9279 panic!("no worktree for path {:?}", path);
9280 });
9281 }
9282
9283 fn select_path_with_mark(
9284 panel: &Entity<ProjectPanel>,
9285 path: impl AsRef<Path>,
9286 cx: &mut VisualTestContext,
9287 ) {
9288 let path = path.as_ref();
9289 panel.update(cx, |panel, cx| {
9290 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9291 let worktree = worktree.read(cx);
9292 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9293 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9294 let entry = crate::SelectedEntry {
9295 worktree_id: worktree.id(),
9296 entry_id,
9297 };
9298 if !panel.marked_entries.contains(&entry) {
9299 panel.marked_entries.insert(entry);
9300 }
9301 panel.selection = Some(entry);
9302 return;
9303 }
9304 }
9305 panic!("no worktree for path {:?}", path);
9306 });
9307 }
9308
9309 fn find_project_entry(
9310 panel: &Entity<ProjectPanel>,
9311 path: impl AsRef<Path>,
9312 cx: &mut VisualTestContext,
9313 ) -> Option<ProjectEntryId> {
9314 let path = path.as_ref();
9315 panel.update(cx, |panel, cx| {
9316 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9317 let worktree = worktree.read(cx);
9318 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9319 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
9320 }
9321 }
9322 panic!("no worktree for path {path:?}");
9323 })
9324 }
9325
9326 fn visible_entries_as_strings(
9327 panel: &Entity<ProjectPanel>,
9328 range: Range<usize>,
9329 cx: &mut VisualTestContext,
9330 ) -> Vec<String> {
9331 let mut result = Vec::new();
9332 let mut project_entries = HashSet::default();
9333 let mut has_editor = false;
9334
9335 panel.update_in(cx, |panel, window, cx| {
9336 panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
9337 if details.is_editing {
9338 assert!(!has_editor, "duplicate editor entry");
9339 has_editor = true;
9340 } else {
9341 assert!(
9342 project_entries.insert(project_entry),
9343 "duplicate project entry {:?} {:?}",
9344 project_entry,
9345 details
9346 );
9347 }
9348
9349 let indent = " ".repeat(details.depth);
9350 let icon = if details.kind.is_dir() {
9351 if details.is_expanded {
9352 "v "
9353 } else {
9354 "> "
9355 }
9356 } else {
9357 " "
9358 };
9359 let name = if details.is_editing {
9360 format!("[EDITOR: '{}']", details.filename)
9361 } else if details.is_processing {
9362 format!("[PROCESSING: '{}']", details.filename)
9363 } else {
9364 details.filename.clone()
9365 };
9366 let selected = if details.is_selected {
9367 " <== selected"
9368 } else {
9369 ""
9370 };
9371 let marked = if details.is_marked {
9372 " <== marked"
9373 } else {
9374 ""
9375 };
9376
9377 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
9378 });
9379 });
9380
9381 result
9382 }
9383
9384 fn init_test(cx: &mut TestAppContext) {
9385 cx.update(|cx| {
9386 let settings_store = SettingsStore::test(cx);
9387 cx.set_global(settings_store);
9388 init_settings(cx);
9389 theme::init(theme::LoadThemes::JustBase, cx);
9390 language::init(cx);
9391 editor::init_settings(cx);
9392 crate::init((), cx);
9393 workspace::init_settings(cx);
9394 client::init_settings(cx);
9395 Project::init_settings(cx);
9396
9397 cx.update_global::<SettingsStore, _>(|store, cx| {
9398 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
9399 project_panel_settings.auto_fold_dirs = Some(false);
9400 });
9401 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
9402 worktree_settings.file_scan_exclusions = Some(Vec::new());
9403 });
9404 });
9405 });
9406 }
9407
9408 fn init_test_with_editor(cx: &mut TestAppContext) {
9409 cx.update(|cx| {
9410 let app_state = AppState::test(cx);
9411 theme::init(theme::LoadThemes::JustBase, cx);
9412 init_settings(cx);
9413 language::init(cx);
9414 editor::init(cx);
9415 crate::init((), cx);
9416 workspace::init(app_state.clone(), cx);
9417 Project::init_settings(cx);
9418
9419 cx.update_global::<SettingsStore, _>(|store, cx| {
9420 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
9421 project_panel_settings.auto_fold_dirs = Some(false);
9422 });
9423 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
9424 worktree_settings.file_scan_exclusions = Some(Vec::new());
9425 });
9426 });
9427 });
9428 }
9429
9430 fn ensure_single_file_is_opened(
9431 window: &WindowHandle<Workspace>,
9432 expected_path: &str,
9433 cx: &mut TestAppContext,
9434 ) {
9435 window
9436 .update(cx, |workspace, _, cx| {
9437 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
9438 assert_eq!(worktrees.len(), 1);
9439 let worktree_id = worktrees[0].read(cx).id();
9440
9441 let open_project_paths = workspace
9442 .panes()
9443 .iter()
9444 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9445 .collect::<Vec<_>>();
9446 assert_eq!(
9447 open_project_paths,
9448 vec![ProjectPath {
9449 worktree_id,
9450 path: Arc::from(Path::new(expected_path))
9451 }],
9452 "Should have opened file, selected in project panel"
9453 );
9454 })
9455 .unwrap();
9456 }
9457
9458 fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9459 assert!(
9460 !cx.has_pending_prompt(),
9461 "Should have no prompts before the deletion"
9462 );
9463 panel.update_in(cx, |panel, window, cx| {
9464 panel.delete(&Delete { skip_prompt: false }, window, cx)
9465 });
9466 assert!(
9467 cx.has_pending_prompt(),
9468 "Should have a prompt after the deletion"
9469 );
9470 cx.simulate_prompt_answer(0);
9471 assert!(
9472 !cx.has_pending_prompt(),
9473 "Should have no prompts after prompt was replied to"
9474 );
9475 cx.executor().run_until_parked();
9476 }
9477
9478 fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9479 assert!(
9480 !cx.has_pending_prompt(),
9481 "Should have no prompts before the deletion"
9482 );
9483 panel.update_in(cx, |panel, window, cx| {
9484 panel.delete(&Delete { skip_prompt: true }, window, cx)
9485 });
9486 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
9487 cx.executor().run_until_parked();
9488 }
9489
9490 fn ensure_no_open_items_and_panes(
9491 workspace: &WindowHandle<Workspace>,
9492 cx: &mut VisualTestContext,
9493 ) {
9494 assert!(
9495 !cx.has_pending_prompt(),
9496 "Should have no prompts after deletion operation closes the file"
9497 );
9498 workspace
9499 .read_with(cx, |workspace, cx| {
9500 let open_project_paths = workspace
9501 .panes()
9502 .iter()
9503 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9504 .collect::<Vec<_>>();
9505 assert!(
9506 open_project_paths.is_empty(),
9507 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
9508 );
9509 })
9510 .unwrap();
9511 }
9512
9513 struct TestProjectItemView {
9514 focus_handle: FocusHandle,
9515 path: ProjectPath,
9516 }
9517
9518 struct TestProjectItem {
9519 path: ProjectPath,
9520 }
9521
9522 impl project::ProjectItem for TestProjectItem {
9523 fn try_open(
9524 _project: &Entity<Project>,
9525 path: &ProjectPath,
9526 cx: &mut App,
9527 ) -> Option<Task<gpui::Result<Entity<Self>>>> {
9528 let path = path.clone();
9529 Some(cx.spawn(|mut cx| async move { cx.new(|_| Self { path }) }))
9530 }
9531
9532 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
9533 None
9534 }
9535
9536 fn project_path(&self, _: &App) -> Option<ProjectPath> {
9537 Some(self.path.clone())
9538 }
9539
9540 fn is_dirty(&self) -> bool {
9541 false
9542 }
9543 }
9544
9545 impl ProjectItem for TestProjectItemView {
9546 type Item = TestProjectItem;
9547
9548 fn for_project_item(
9549 _: Entity<Project>,
9550 project_item: Entity<Self::Item>,
9551 _: &mut Window,
9552 cx: &mut Context<Self>,
9553 ) -> Self
9554 where
9555 Self: Sized,
9556 {
9557 Self {
9558 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
9559 focus_handle: cx.focus_handle(),
9560 }
9561 }
9562 }
9563
9564 impl Item for TestProjectItemView {
9565 type Event = ();
9566 }
9567
9568 impl EventEmitter<()> for TestProjectItemView {}
9569
9570 impl Focusable for TestProjectItemView {
9571 fn focus_handle(&self, _: &App) -> FocusHandle {
9572 self.focus_handle.clone()
9573 }
9574 }
9575
9576 impl Render for TestProjectItemView {
9577 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
9578 Empty
9579 }
9580 }
9581}