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