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