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