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