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 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 fn activation_priority(&self) -> u32 {
4257 0
4258 }
4259}
4260
4261impl FocusableView for ProjectPanel {
4262 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
4263 self.focus_handle.clone()
4264 }
4265}
4266
4267impl ClipboardEntry {
4268 fn is_cut(&self) -> bool {
4269 matches!(self, Self::Cut { .. })
4270 }
4271
4272 fn items(&self) -> &BTreeSet<SelectedEntry> {
4273 match self {
4274 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
4275 }
4276 }
4277}
4278
4279#[cfg(test)]
4280mod tests {
4281 use super::*;
4282 use collections::HashSet;
4283 use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
4284 use pretty_assertions::assert_eq;
4285 use project::{FakeFs, WorktreeSettings};
4286 use serde_json::json;
4287 use settings::SettingsStore;
4288 use std::path::{Path, PathBuf};
4289 use workspace::{
4290 item::{Item, ProjectItem},
4291 register_project_item, AppState,
4292 };
4293
4294 #[gpui::test]
4295 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
4296 init_test(cx);
4297
4298 let fs = FakeFs::new(cx.executor().clone());
4299 fs.insert_tree(
4300 "/root1",
4301 json!({
4302 ".dockerignore": "",
4303 ".git": {
4304 "HEAD": "",
4305 },
4306 "a": {
4307 "0": { "q": "", "r": "", "s": "" },
4308 "1": { "t": "", "u": "" },
4309 "2": { "v": "", "w": "", "x": "", "y": "" },
4310 },
4311 "b": {
4312 "3": { "Q": "" },
4313 "4": { "R": "", "S": "", "T": "", "U": "" },
4314 },
4315 "C": {
4316 "5": {},
4317 "6": { "V": "", "W": "" },
4318 "7": { "X": "" },
4319 "8": { "Y": {}, "Z": "" }
4320 }
4321 }),
4322 )
4323 .await;
4324 fs.insert_tree(
4325 "/root2",
4326 json!({
4327 "d": {
4328 "9": ""
4329 },
4330 "e": {}
4331 }),
4332 )
4333 .await;
4334
4335 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4336 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4337 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4338 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4339 assert_eq!(
4340 visible_entries_as_strings(&panel, 0..50, cx),
4341 &[
4342 "v root1",
4343 " > .git",
4344 " > a",
4345 " > b",
4346 " > C",
4347 " .dockerignore",
4348 "v root2",
4349 " > d",
4350 " > e",
4351 ]
4352 );
4353
4354 toggle_expand_dir(&panel, "root1/b", cx);
4355 assert_eq!(
4356 visible_entries_as_strings(&panel, 0..50, cx),
4357 &[
4358 "v root1",
4359 " > .git",
4360 " > a",
4361 " v b <== selected",
4362 " > 3",
4363 " > 4",
4364 " > C",
4365 " .dockerignore",
4366 "v root2",
4367 " > d",
4368 " > e",
4369 ]
4370 );
4371
4372 assert_eq!(
4373 visible_entries_as_strings(&panel, 6..9, cx),
4374 &[
4375 //
4376 " > C",
4377 " .dockerignore",
4378 "v root2",
4379 ]
4380 );
4381 }
4382
4383 #[gpui::test]
4384 async fn test_opening_file(cx: &mut gpui::TestAppContext) {
4385 init_test_with_editor(cx);
4386
4387 let fs = FakeFs::new(cx.executor().clone());
4388 fs.insert_tree(
4389 "/src",
4390 json!({
4391 "test": {
4392 "first.rs": "// First Rust file",
4393 "second.rs": "// Second Rust file",
4394 "third.rs": "// Third Rust file",
4395 }
4396 }),
4397 )
4398 .await;
4399
4400 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4401 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4402 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4403 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4404
4405 toggle_expand_dir(&panel, "src/test", cx);
4406 select_path(&panel, "src/test/first.rs", cx);
4407 panel.update(cx, |panel, cx| panel.open(&Open, cx));
4408 cx.executor().run_until_parked();
4409 assert_eq!(
4410 visible_entries_as_strings(&panel, 0..10, cx),
4411 &[
4412 "v src",
4413 " v test",
4414 " first.rs <== selected <== marked",
4415 " second.rs",
4416 " third.rs"
4417 ]
4418 );
4419 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
4420
4421 select_path(&panel, "src/test/second.rs", cx);
4422 panel.update(cx, |panel, cx| panel.open(&Open, cx));
4423 cx.executor().run_until_parked();
4424 assert_eq!(
4425 visible_entries_as_strings(&panel, 0..10, cx),
4426 &[
4427 "v src",
4428 " v test",
4429 " first.rs",
4430 " second.rs <== selected <== marked",
4431 " third.rs"
4432 ]
4433 );
4434 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
4435 }
4436
4437 #[gpui::test]
4438 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
4439 init_test(cx);
4440 cx.update(|cx| {
4441 cx.update_global::<SettingsStore, _>(|store, cx| {
4442 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4443 worktree_settings.file_scan_exclusions =
4444 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
4445 });
4446 });
4447 });
4448
4449 let fs = FakeFs::new(cx.background_executor.clone());
4450 fs.insert_tree(
4451 "/root1",
4452 json!({
4453 ".dockerignore": "",
4454 ".git": {
4455 "HEAD": "",
4456 },
4457 "a": {
4458 "0": { "q": "", "r": "", "s": "" },
4459 "1": { "t": "", "u": "" },
4460 "2": { "v": "", "w": "", "x": "", "y": "" },
4461 },
4462 "b": {
4463 "3": { "Q": "" },
4464 "4": { "R": "", "S": "", "T": "", "U": "" },
4465 },
4466 "C": {
4467 "5": {},
4468 "6": { "V": "", "W": "" },
4469 "7": { "X": "" },
4470 "8": { "Y": {}, "Z": "" }
4471 }
4472 }),
4473 )
4474 .await;
4475 fs.insert_tree(
4476 "/root2",
4477 json!({
4478 "d": {
4479 "4": ""
4480 },
4481 "e": {}
4482 }),
4483 )
4484 .await;
4485
4486 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4487 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4488 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4489 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4490 assert_eq!(
4491 visible_entries_as_strings(&panel, 0..50, cx),
4492 &[
4493 "v root1",
4494 " > a",
4495 " > b",
4496 " > C",
4497 " .dockerignore",
4498 "v root2",
4499 " > d",
4500 " > e",
4501 ]
4502 );
4503
4504 toggle_expand_dir(&panel, "root1/b", cx);
4505 assert_eq!(
4506 visible_entries_as_strings(&panel, 0..50, cx),
4507 &[
4508 "v root1",
4509 " > a",
4510 " v b <== selected",
4511 " > 3",
4512 " > C",
4513 " .dockerignore",
4514 "v root2",
4515 " > d",
4516 " > e",
4517 ]
4518 );
4519
4520 toggle_expand_dir(&panel, "root2/d", cx);
4521 assert_eq!(
4522 visible_entries_as_strings(&panel, 0..50, cx),
4523 &[
4524 "v root1",
4525 " > a",
4526 " v b",
4527 " > 3",
4528 " > C",
4529 " .dockerignore",
4530 "v root2",
4531 " v d <== selected",
4532 " > e",
4533 ]
4534 );
4535
4536 toggle_expand_dir(&panel, "root2/e", cx);
4537 assert_eq!(
4538 visible_entries_as_strings(&panel, 0..50, cx),
4539 &[
4540 "v root1",
4541 " > a",
4542 " v b",
4543 " > 3",
4544 " > C",
4545 " .dockerignore",
4546 "v root2",
4547 " v d",
4548 " v e <== selected",
4549 ]
4550 );
4551 }
4552
4553 #[gpui::test]
4554 async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
4555 init_test(cx);
4556
4557 let fs = FakeFs::new(cx.executor().clone());
4558 fs.insert_tree(
4559 "/root1",
4560 json!({
4561 "dir_1": {
4562 "nested_dir_1": {
4563 "nested_dir_2": {
4564 "nested_dir_3": {
4565 "file_a.java": "// File contents",
4566 "file_b.java": "// File contents",
4567 "file_c.java": "// File contents",
4568 "nested_dir_4": {
4569 "nested_dir_5": {
4570 "file_d.java": "// File contents",
4571 }
4572 }
4573 }
4574 }
4575 }
4576 }
4577 }),
4578 )
4579 .await;
4580 fs.insert_tree(
4581 "/root2",
4582 json!({
4583 "dir_2": {
4584 "file_1.java": "// File contents",
4585 }
4586 }),
4587 )
4588 .await;
4589
4590 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4591 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4592 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4593 cx.update(|cx| {
4594 let settings = *ProjectPanelSettings::get_global(cx);
4595 ProjectPanelSettings::override_global(
4596 ProjectPanelSettings {
4597 auto_fold_dirs: true,
4598 ..settings
4599 },
4600 cx,
4601 );
4602 });
4603 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4604 assert_eq!(
4605 visible_entries_as_strings(&panel, 0..10, cx),
4606 &[
4607 "v root1",
4608 " > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4609 "v root2",
4610 " > dir_2",
4611 ]
4612 );
4613
4614 toggle_expand_dir(
4615 &panel,
4616 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4617 cx,
4618 );
4619 assert_eq!(
4620 visible_entries_as_strings(&panel, 0..10, cx),
4621 &[
4622 "v root1",
4623 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
4624 " > nested_dir_4/nested_dir_5",
4625 " file_a.java",
4626 " file_b.java",
4627 " file_c.java",
4628 "v root2",
4629 " > dir_2",
4630 ]
4631 );
4632
4633 toggle_expand_dir(
4634 &panel,
4635 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
4636 cx,
4637 );
4638 assert_eq!(
4639 visible_entries_as_strings(&panel, 0..10, cx),
4640 &[
4641 "v root1",
4642 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4643 " v nested_dir_4/nested_dir_5 <== selected",
4644 " file_d.java",
4645 " file_a.java",
4646 " file_b.java",
4647 " file_c.java",
4648 "v root2",
4649 " > dir_2",
4650 ]
4651 );
4652 toggle_expand_dir(&panel, "root2/dir_2", cx);
4653 assert_eq!(
4654 visible_entries_as_strings(&panel, 0..10, cx),
4655 &[
4656 "v root1",
4657 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4658 " v nested_dir_4/nested_dir_5",
4659 " file_d.java",
4660 " file_a.java",
4661 " file_b.java",
4662 " file_c.java",
4663 "v root2",
4664 " v dir_2 <== selected",
4665 " file_1.java",
4666 ]
4667 );
4668 }
4669
4670 #[gpui::test(iterations = 30)]
4671 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
4672 init_test(cx);
4673
4674 let fs = FakeFs::new(cx.executor().clone());
4675 fs.insert_tree(
4676 "/root1",
4677 json!({
4678 ".dockerignore": "",
4679 ".git": {
4680 "HEAD": "",
4681 },
4682 "a": {
4683 "0": { "q": "", "r": "", "s": "" },
4684 "1": { "t": "", "u": "" },
4685 "2": { "v": "", "w": "", "x": "", "y": "" },
4686 },
4687 "b": {
4688 "3": { "Q": "" },
4689 "4": { "R": "", "S": "", "T": "", "U": "" },
4690 },
4691 "C": {
4692 "5": {},
4693 "6": { "V": "", "W": "" },
4694 "7": { "X": "" },
4695 "8": { "Y": {}, "Z": "" }
4696 }
4697 }),
4698 )
4699 .await;
4700 fs.insert_tree(
4701 "/root2",
4702 json!({
4703 "d": {
4704 "9": ""
4705 },
4706 "e": {}
4707 }),
4708 )
4709 .await;
4710
4711 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4712 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4713 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4714 let panel = workspace
4715 .update(cx, |workspace, cx| {
4716 let panel = ProjectPanel::new(workspace, cx);
4717 workspace.add_panel(panel.clone(), cx);
4718 panel
4719 })
4720 .unwrap();
4721
4722 select_path(&panel, "root1", cx);
4723 assert_eq!(
4724 visible_entries_as_strings(&panel, 0..10, cx),
4725 &[
4726 "v root1 <== selected",
4727 " > .git",
4728 " > a",
4729 " > b",
4730 " > C",
4731 " .dockerignore",
4732 "v root2",
4733 " > d",
4734 " > e",
4735 ]
4736 );
4737
4738 // Add a file with the root folder selected. The filename editor is placed
4739 // before the first file in the root folder.
4740 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4741 panel.update(cx, |panel, cx| {
4742 assert!(panel.filename_editor.read(cx).is_focused(cx));
4743 });
4744 assert_eq!(
4745 visible_entries_as_strings(&panel, 0..10, cx),
4746 &[
4747 "v root1",
4748 " > .git",
4749 " > a",
4750 " > b",
4751 " > C",
4752 " [EDITOR: ''] <== selected",
4753 " .dockerignore",
4754 "v root2",
4755 " > d",
4756 " > e",
4757 ]
4758 );
4759
4760 let confirm = panel.update(cx, |panel, cx| {
4761 panel
4762 .filename_editor
4763 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
4764 panel.confirm_edit(cx).unwrap()
4765 });
4766 assert_eq!(
4767 visible_entries_as_strings(&panel, 0..10, cx),
4768 &[
4769 "v root1",
4770 " > .git",
4771 " > a",
4772 " > b",
4773 " > C",
4774 " [PROCESSING: 'the-new-filename'] <== selected",
4775 " .dockerignore",
4776 "v root2",
4777 " > d",
4778 " > e",
4779 ]
4780 );
4781
4782 confirm.await.unwrap();
4783 assert_eq!(
4784 visible_entries_as_strings(&panel, 0..10, cx),
4785 &[
4786 "v root1",
4787 " > .git",
4788 " > a",
4789 " > b",
4790 " > C",
4791 " .dockerignore",
4792 " the-new-filename <== selected <== marked",
4793 "v root2",
4794 " > d",
4795 " > e",
4796 ]
4797 );
4798
4799 select_path(&panel, "root1/b", cx);
4800 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4801 assert_eq!(
4802 visible_entries_as_strings(&panel, 0..10, cx),
4803 &[
4804 "v root1",
4805 " > .git",
4806 " > a",
4807 " v b",
4808 " > 3",
4809 " > 4",
4810 " [EDITOR: ''] <== selected",
4811 " > C",
4812 " .dockerignore",
4813 " the-new-filename",
4814 ]
4815 );
4816
4817 panel
4818 .update(cx, |panel, cx| {
4819 panel
4820 .filename_editor
4821 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
4822 panel.confirm_edit(cx).unwrap()
4823 })
4824 .await
4825 .unwrap();
4826 assert_eq!(
4827 visible_entries_as_strings(&panel, 0..10, cx),
4828 &[
4829 "v root1",
4830 " > .git",
4831 " > a",
4832 " v b",
4833 " > 3",
4834 " > 4",
4835 " another-filename.txt <== selected <== marked",
4836 " > C",
4837 " .dockerignore",
4838 " the-new-filename",
4839 ]
4840 );
4841
4842 select_path(&panel, "root1/b/another-filename.txt", cx);
4843 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4844 assert_eq!(
4845 visible_entries_as_strings(&panel, 0..10, cx),
4846 &[
4847 "v root1",
4848 " > .git",
4849 " > a",
4850 " v b",
4851 " > 3",
4852 " > 4",
4853 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
4854 " > C",
4855 " .dockerignore",
4856 " the-new-filename",
4857 ]
4858 );
4859
4860 let confirm = panel.update(cx, |panel, cx| {
4861 panel.filename_editor.update(cx, |editor, cx| {
4862 let file_name_selections = editor.selections.all::<usize>(cx);
4863 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
4864 let file_name_selection = &file_name_selections[0];
4865 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
4866 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
4867
4868 editor.set_text("a-different-filename.tar.gz", cx)
4869 });
4870 panel.confirm_edit(cx).unwrap()
4871 });
4872 assert_eq!(
4873 visible_entries_as_strings(&panel, 0..10, cx),
4874 &[
4875 "v root1",
4876 " > .git",
4877 " > a",
4878 " v b",
4879 " > 3",
4880 " > 4",
4881 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
4882 " > C",
4883 " .dockerignore",
4884 " the-new-filename",
4885 ]
4886 );
4887
4888 confirm.await.unwrap();
4889 assert_eq!(
4890 visible_entries_as_strings(&panel, 0..10, cx),
4891 &[
4892 "v root1",
4893 " > .git",
4894 " > a",
4895 " v b",
4896 " > 3",
4897 " > 4",
4898 " a-different-filename.tar.gz <== selected",
4899 " > C",
4900 " .dockerignore",
4901 " the-new-filename",
4902 ]
4903 );
4904
4905 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4906 assert_eq!(
4907 visible_entries_as_strings(&panel, 0..10, cx),
4908 &[
4909 "v root1",
4910 " > .git",
4911 " > a",
4912 " v b",
4913 " > 3",
4914 " > 4",
4915 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
4916 " > C",
4917 " .dockerignore",
4918 " the-new-filename",
4919 ]
4920 );
4921
4922 panel.update(cx, |panel, cx| {
4923 panel.filename_editor.update(cx, |editor, cx| {
4924 let file_name_selections = editor.selections.all::<usize>(cx);
4925 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
4926 let file_name_selection = &file_name_selections[0];
4927 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
4928 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..");
4929
4930 });
4931 panel.cancel(&menu::Cancel, cx)
4932 });
4933
4934 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4935 assert_eq!(
4936 visible_entries_as_strings(&panel, 0..10, cx),
4937 &[
4938 "v root1",
4939 " > .git",
4940 " > a",
4941 " v b",
4942 " > 3",
4943 " > 4",
4944 " > [EDITOR: ''] <== selected",
4945 " a-different-filename.tar.gz",
4946 " > C",
4947 " .dockerignore",
4948 ]
4949 );
4950
4951 let confirm = panel.update(cx, |panel, cx| {
4952 panel
4953 .filename_editor
4954 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
4955 panel.confirm_edit(cx).unwrap()
4956 });
4957 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
4958 assert_eq!(
4959 visible_entries_as_strings(&panel, 0..10, cx),
4960 &[
4961 "v root1",
4962 " > .git",
4963 " > a",
4964 " v b",
4965 " > 3",
4966 " > 4",
4967 " > [PROCESSING: 'new-dir']",
4968 " a-different-filename.tar.gz <== selected",
4969 " > C",
4970 " .dockerignore",
4971 ]
4972 );
4973
4974 confirm.await.unwrap();
4975 assert_eq!(
4976 visible_entries_as_strings(&panel, 0..10, cx),
4977 &[
4978 "v root1",
4979 " > .git",
4980 " > a",
4981 " v b",
4982 " > 3",
4983 " > 4",
4984 " > new-dir",
4985 " a-different-filename.tar.gz <== selected",
4986 " > C",
4987 " .dockerignore",
4988 ]
4989 );
4990
4991 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
4992 assert_eq!(
4993 visible_entries_as_strings(&panel, 0..10, cx),
4994 &[
4995 "v root1",
4996 " > .git",
4997 " > a",
4998 " v b",
4999 " > 3",
5000 " > 4",
5001 " > new-dir",
5002 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
5003 " > C",
5004 " .dockerignore",
5005 ]
5006 );
5007
5008 // Dismiss the rename editor when it loses focus.
5009 workspace.update(cx, |_, cx| cx.blur()).unwrap();
5010 assert_eq!(
5011 visible_entries_as_strings(&panel, 0..10, cx),
5012 &[
5013 "v root1",
5014 " > .git",
5015 " > a",
5016 " v b",
5017 " > 3",
5018 " > 4",
5019 " > new-dir",
5020 " a-different-filename.tar.gz <== selected",
5021 " > C",
5022 " .dockerignore",
5023 ]
5024 );
5025 }
5026
5027 #[gpui::test(iterations = 10)]
5028 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
5029 init_test(cx);
5030
5031 let fs = FakeFs::new(cx.executor().clone());
5032 fs.insert_tree(
5033 "/root1",
5034 json!({
5035 ".dockerignore": "",
5036 ".git": {
5037 "HEAD": "",
5038 },
5039 "a": {
5040 "0": { "q": "", "r": "", "s": "" },
5041 "1": { "t": "", "u": "" },
5042 "2": { "v": "", "w": "", "x": "", "y": "" },
5043 },
5044 "b": {
5045 "3": { "Q": "" },
5046 "4": { "R": "", "S": "", "T": "", "U": "" },
5047 },
5048 "C": {
5049 "5": {},
5050 "6": { "V": "", "W": "" },
5051 "7": { "X": "" },
5052 "8": { "Y": {}, "Z": "" }
5053 }
5054 }),
5055 )
5056 .await;
5057 fs.insert_tree(
5058 "/root2",
5059 json!({
5060 "d": {
5061 "9": ""
5062 },
5063 "e": {}
5064 }),
5065 )
5066 .await;
5067
5068 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5069 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5070 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5071 let panel = workspace
5072 .update(cx, |workspace, cx| {
5073 let panel = ProjectPanel::new(workspace, cx);
5074 workspace.add_panel(panel.clone(), cx);
5075 panel
5076 })
5077 .unwrap();
5078
5079 select_path(&panel, "root1", cx);
5080 assert_eq!(
5081 visible_entries_as_strings(&panel, 0..10, cx),
5082 &[
5083 "v root1 <== selected",
5084 " > .git",
5085 " > a",
5086 " > b",
5087 " > C",
5088 " .dockerignore",
5089 "v root2",
5090 " > d",
5091 " > e",
5092 ]
5093 );
5094
5095 // Add a file with the root folder selected. The filename editor is placed
5096 // before the first file in the root folder.
5097 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5098 panel.update(cx, |panel, cx| {
5099 assert!(panel.filename_editor.read(cx).is_focused(cx));
5100 });
5101 assert_eq!(
5102 visible_entries_as_strings(&panel, 0..10, cx),
5103 &[
5104 "v root1",
5105 " > .git",
5106 " > a",
5107 " > b",
5108 " > C",
5109 " [EDITOR: ''] <== selected",
5110 " .dockerignore",
5111 "v root2",
5112 " > d",
5113 " > e",
5114 ]
5115 );
5116
5117 let confirm = panel.update(cx, |panel, cx| {
5118 panel.filename_editor.update(cx, |editor, cx| {
5119 editor.set_text("/bdir1/dir2/the-new-filename", cx)
5120 });
5121 panel.confirm_edit(cx).unwrap()
5122 });
5123
5124 assert_eq!(
5125 visible_entries_as_strings(&panel, 0..10, cx),
5126 &[
5127 "v root1",
5128 " > .git",
5129 " > a",
5130 " > b",
5131 " > C",
5132 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
5133 " .dockerignore",
5134 "v root2",
5135 " > d",
5136 " > e",
5137 ]
5138 );
5139
5140 confirm.await.unwrap();
5141 assert_eq!(
5142 visible_entries_as_strings(&panel, 0..13, cx),
5143 &[
5144 "v root1",
5145 " > .git",
5146 " > a",
5147 " > b",
5148 " v bdir1",
5149 " v dir2",
5150 " the-new-filename <== selected <== marked",
5151 " > C",
5152 " .dockerignore",
5153 "v root2",
5154 " > d",
5155 " > e",
5156 ]
5157 );
5158 }
5159
5160 #[gpui::test]
5161 async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
5162 init_test(cx);
5163
5164 let fs = FakeFs::new(cx.executor().clone());
5165 fs.insert_tree(
5166 "/root1",
5167 json!({
5168 ".dockerignore": "",
5169 ".git": {
5170 "HEAD": "",
5171 },
5172 }),
5173 )
5174 .await;
5175
5176 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5177 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5178 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5179 let panel = workspace
5180 .update(cx, |workspace, cx| {
5181 let panel = ProjectPanel::new(workspace, cx);
5182 workspace.add_panel(panel.clone(), cx);
5183 panel
5184 })
5185 .unwrap();
5186
5187 select_path(&panel, "root1", cx);
5188 assert_eq!(
5189 visible_entries_as_strings(&panel, 0..10, cx),
5190 &["v root1 <== selected", " > .git", " .dockerignore",]
5191 );
5192
5193 // Add a file with the root folder selected. The filename editor is placed
5194 // before the first file in the root folder.
5195 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5196 panel.update(cx, |panel, cx| {
5197 assert!(panel.filename_editor.read(cx).is_focused(cx));
5198 });
5199 assert_eq!(
5200 visible_entries_as_strings(&panel, 0..10, cx),
5201 &[
5202 "v root1",
5203 " > .git",
5204 " [EDITOR: ''] <== selected",
5205 " .dockerignore",
5206 ]
5207 );
5208
5209 let confirm = panel.update(cx, |panel, cx| {
5210 panel
5211 .filename_editor
5212 .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
5213 panel.confirm_edit(cx).unwrap()
5214 });
5215
5216 assert_eq!(
5217 visible_entries_as_strings(&panel, 0..10, cx),
5218 &[
5219 "v root1",
5220 " > .git",
5221 " [PROCESSING: '/new_dir/'] <== selected",
5222 " .dockerignore",
5223 ]
5224 );
5225
5226 confirm.await.unwrap();
5227 assert_eq!(
5228 visible_entries_as_strings(&panel, 0..13, cx),
5229 &[
5230 "v root1",
5231 " > .git",
5232 " v new_dir <== selected",
5233 " .dockerignore",
5234 ]
5235 );
5236 }
5237
5238 #[gpui::test]
5239 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
5240 init_test(cx);
5241
5242 let fs = FakeFs::new(cx.executor().clone());
5243 fs.insert_tree(
5244 "/root1",
5245 json!({
5246 "one.two.txt": "",
5247 "one.txt": ""
5248 }),
5249 )
5250 .await;
5251
5252 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5253 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5254 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5255 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5256
5257 panel.update(cx, |panel, cx| {
5258 panel.select_next(&Default::default(), cx);
5259 panel.select_next(&Default::default(), cx);
5260 });
5261
5262 assert_eq!(
5263 visible_entries_as_strings(&panel, 0..50, cx),
5264 &[
5265 //
5266 "v root1",
5267 " one.txt <== selected",
5268 " one.two.txt",
5269 ]
5270 );
5271
5272 // Regression test - file name is created correctly when
5273 // the copied file's name contains multiple dots.
5274 panel.update(cx, |panel, cx| {
5275 panel.copy(&Default::default(), cx);
5276 panel.paste(&Default::default(), cx);
5277 });
5278 cx.executor().run_until_parked();
5279
5280 assert_eq!(
5281 visible_entries_as_strings(&panel, 0..50, cx),
5282 &[
5283 //
5284 "v root1",
5285 " one.txt",
5286 " one copy.txt <== selected",
5287 " one.two.txt",
5288 ]
5289 );
5290
5291 panel.update(cx, |panel, cx| {
5292 panel.paste(&Default::default(), cx);
5293 });
5294 cx.executor().run_until_parked();
5295
5296 assert_eq!(
5297 visible_entries_as_strings(&panel, 0..50, cx),
5298 &[
5299 //
5300 "v root1",
5301 " one.txt",
5302 " one copy.txt",
5303 " one copy 1.txt <== selected",
5304 " one.two.txt",
5305 ]
5306 );
5307 }
5308
5309 #[gpui::test]
5310 async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5311 init_test(cx);
5312
5313 let fs = FakeFs::new(cx.executor().clone());
5314 fs.insert_tree(
5315 "/root1",
5316 json!({
5317 "one.txt": "",
5318 "two.txt": "",
5319 "three.txt": "",
5320 "a": {
5321 "0": { "q": "", "r": "", "s": "" },
5322 "1": { "t": "", "u": "" },
5323 "2": { "v": "", "w": "", "x": "", "y": "" },
5324 },
5325 }),
5326 )
5327 .await;
5328
5329 fs.insert_tree(
5330 "/root2",
5331 json!({
5332 "one.txt": "",
5333 "two.txt": "",
5334 "four.txt": "",
5335 "b": {
5336 "3": { "Q": "" },
5337 "4": { "R": "", "S": "", "T": "", "U": "" },
5338 },
5339 }),
5340 )
5341 .await;
5342
5343 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5344 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5345 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5346 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5347
5348 select_path(&panel, "root1/three.txt", cx);
5349 panel.update(cx, |panel, cx| {
5350 panel.cut(&Default::default(), cx);
5351 });
5352
5353 select_path(&panel, "root2/one.txt", cx);
5354 panel.update(cx, |panel, cx| {
5355 panel.select_next(&Default::default(), cx);
5356 panel.paste(&Default::default(), cx);
5357 });
5358 cx.executor().run_until_parked();
5359 assert_eq!(
5360 visible_entries_as_strings(&panel, 0..50, cx),
5361 &[
5362 //
5363 "v root1",
5364 " > a",
5365 " one.txt",
5366 " two.txt",
5367 "v root2",
5368 " > b",
5369 " four.txt",
5370 " one.txt",
5371 " three.txt <== selected",
5372 " two.txt",
5373 ]
5374 );
5375
5376 select_path(&panel, "root1/a", cx);
5377 panel.update(cx, |panel, cx| {
5378 panel.cut(&Default::default(), cx);
5379 });
5380 select_path(&panel, "root2/two.txt", cx);
5381 panel.update(cx, |panel, cx| {
5382 panel.select_next(&Default::default(), cx);
5383 panel.paste(&Default::default(), cx);
5384 });
5385
5386 cx.executor().run_until_parked();
5387 assert_eq!(
5388 visible_entries_as_strings(&panel, 0..50, cx),
5389 &[
5390 //
5391 "v root1",
5392 " one.txt",
5393 " two.txt",
5394 "v root2",
5395 " > a <== selected",
5396 " > b",
5397 " four.txt",
5398 " one.txt",
5399 " three.txt",
5400 " two.txt",
5401 ]
5402 );
5403 }
5404
5405 #[gpui::test]
5406 async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5407 init_test(cx);
5408
5409 let fs = FakeFs::new(cx.executor().clone());
5410 fs.insert_tree(
5411 "/root1",
5412 json!({
5413 "one.txt": "",
5414 "two.txt": "",
5415 "three.txt": "",
5416 "a": {
5417 "0": { "q": "", "r": "", "s": "" },
5418 "1": { "t": "", "u": "" },
5419 "2": { "v": "", "w": "", "x": "", "y": "" },
5420 },
5421 }),
5422 )
5423 .await;
5424
5425 fs.insert_tree(
5426 "/root2",
5427 json!({
5428 "one.txt": "",
5429 "two.txt": "",
5430 "four.txt": "",
5431 "b": {
5432 "3": { "Q": "" },
5433 "4": { "R": "", "S": "", "T": "", "U": "" },
5434 },
5435 }),
5436 )
5437 .await;
5438
5439 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5440 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5441 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5442 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5443
5444 select_path(&panel, "root1/three.txt", cx);
5445 panel.update(cx, |panel, cx| {
5446 panel.copy(&Default::default(), cx);
5447 });
5448
5449 select_path(&panel, "root2/one.txt", cx);
5450 panel.update(cx, |panel, cx| {
5451 panel.select_next(&Default::default(), cx);
5452 panel.paste(&Default::default(), cx);
5453 });
5454 cx.executor().run_until_parked();
5455 assert_eq!(
5456 visible_entries_as_strings(&panel, 0..50, cx),
5457 &[
5458 //
5459 "v root1",
5460 " > a",
5461 " one.txt",
5462 " three.txt",
5463 " two.txt",
5464 "v root2",
5465 " > b",
5466 " four.txt",
5467 " one.txt",
5468 " three.txt <== selected",
5469 " two.txt",
5470 ]
5471 );
5472
5473 select_path(&panel, "root1/three.txt", cx);
5474 panel.update(cx, |panel, cx| {
5475 panel.copy(&Default::default(), cx);
5476 });
5477 select_path(&panel, "root2/two.txt", cx);
5478 panel.update(cx, |panel, cx| {
5479 panel.select_next(&Default::default(), cx);
5480 panel.paste(&Default::default(), cx);
5481 });
5482
5483 cx.executor().run_until_parked();
5484 assert_eq!(
5485 visible_entries_as_strings(&panel, 0..50, cx),
5486 &[
5487 //
5488 "v root1",
5489 " > a",
5490 " one.txt",
5491 " three.txt",
5492 " two.txt",
5493 "v root2",
5494 " > b",
5495 " four.txt",
5496 " one.txt",
5497 " three.txt",
5498 " three copy.txt <== selected",
5499 " two.txt",
5500 ]
5501 );
5502
5503 select_path(&panel, "root1/a", cx);
5504 panel.update(cx, |panel, cx| {
5505 panel.copy(&Default::default(), cx);
5506 });
5507 select_path(&panel, "root2/two.txt", cx);
5508 panel.update(cx, |panel, cx| {
5509 panel.select_next(&Default::default(), cx);
5510 panel.paste(&Default::default(), cx);
5511 });
5512
5513 cx.executor().run_until_parked();
5514 assert_eq!(
5515 visible_entries_as_strings(&panel, 0..50, cx),
5516 &[
5517 //
5518 "v root1",
5519 " > a",
5520 " one.txt",
5521 " three.txt",
5522 " two.txt",
5523 "v root2",
5524 " > a <== selected",
5525 " > b",
5526 " four.txt",
5527 " one.txt",
5528 " three.txt",
5529 " three copy.txt",
5530 " two.txt",
5531 ]
5532 );
5533 }
5534
5535 #[gpui::test]
5536 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
5537 init_test(cx);
5538
5539 let fs = FakeFs::new(cx.executor().clone());
5540 fs.insert_tree(
5541 "/root",
5542 json!({
5543 "a": {
5544 "one.txt": "",
5545 "two.txt": "",
5546 "inner_dir": {
5547 "three.txt": "",
5548 "four.txt": "",
5549 }
5550 },
5551 "b": {}
5552 }),
5553 )
5554 .await;
5555
5556 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5557 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5558 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5559 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5560
5561 select_path(&panel, "root/a", cx);
5562 panel.update(cx, |panel, cx| {
5563 panel.copy(&Default::default(), cx);
5564 panel.select_next(&Default::default(), cx);
5565 panel.paste(&Default::default(), cx);
5566 });
5567 cx.executor().run_until_parked();
5568
5569 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
5570 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
5571
5572 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
5573 assert_ne!(
5574 pasted_dir_file, None,
5575 "Pasted directory file should have an entry"
5576 );
5577
5578 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
5579 assert_ne!(
5580 pasted_dir_inner_dir, None,
5581 "Directories inside pasted directory should have an entry"
5582 );
5583
5584 toggle_expand_dir(&panel, "root/b/a", cx);
5585 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
5586
5587 assert_eq!(
5588 visible_entries_as_strings(&panel, 0..50, cx),
5589 &[
5590 //
5591 "v root",
5592 " > a",
5593 " v b",
5594 " v a",
5595 " v inner_dir <== selected",
5596 " four.txt",
5597 " three.txt",
5598 " one.txt",
5599 " two.txt",
5600 ]
5601 );
5602
5603 select_path(&panel, "root", cx);
5604 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
5605 cx.executor().run_until_parked();
5606 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
5607 cx.executor().run_until_parked();
5608 assert_eq!(
5609 visible_entries_as_strings(&panel, 0..50, cx),
5610 &[
5611 //
5612 "v root",
5613 " > a",
5614 " v a copy",
5615 " > a <== selected",
5616 " > inner_dir",
5617 " one.txt",
5618 " two.txt",
5619 " v b",
5620 " v a",
5621 " v inner_dir",
5622 " four.txt",
5623 " three.txt",
5624 " one.txt",
5625 " two.txt"
5626 ]
5627 );
5628 }
5629
5630 #[gpui::test]
5631 async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
5632 init_test(cx);
5633
5634 let fs = FakeFs::new(cx.executor().clone());
5635 fs.insert_tree(
5636 "/test",
5637 json!({
5638 "dir1": {
5639 "a.txt": "",
5640 "b.txt": "",
5641 },
5642 "dir2": {},
5643 "c.txt": "",
5644 "d.txt": "",
5645 }),
5646 )
5647 .await;
5648
5649 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
5650 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5651 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5652 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5653
5654 toggle_expand_dir(&panel, "test/dir1", cx);
5655
5656 cx.simulate_modifiers_change(gpui::Modifiers {
5657 control: true,
5658 ..Default::default()
5659 });
5660
5661 select_path_with_mark(&panel, "test/dir1", cx);
5662 select_path_with_mark(&panel, "test/c.txt", cx);
5663
5664 assert_eq!(
5665 visible_entries_as_strings(&panel, 0..15, cx),
5666 &[
5667 "v test",
5668 " v dir1 <== marked",
5669 " a.txt",
5670 " b.txt",
5671 " > dir2",
5672 " c.txt <== selected <== marked",
5673 " d.txt",
5674 ],
5675 "Initial state before copying dir1 and c.txt"
5676 );
5677
5678 panel.update(cx, |panel, cx| {
5679 panel.copy(&Default::default(), cx);
5680 });
5681 select_path(&panel, "test/dir2", cx);
5682 panel.update(cx, |panel, cx| {
5683 panel.paste(&Default::default(), cx);
5684 });
5685 cx.executor().run_until_parked();
5686
5687 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
5688
5689 assert_eq!(
5690 visible_entries_as_strings(&panel, 0..15, cx),
5691 &[
5692 "v test",
5693 " v dir1 <== marked",
5694 " a.txt",
5695 " b.txt",
5696 " v dir2",
5697 " v dir1 <== selected",
5698 " a.txt",
5699 " b.txt",
5700 " c.txt",
5701 " c.txt <== marked",
5702 " d.txt",
5703 ],
5704 "Should copy dir1 as well as c.txt into dir2"
5705 );
5706 }
5707
5708 #[gpui::test]
5709 async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
5710 init_test(cx);
5711
5712 let fs = FakeFs::new(cx.executor().clone());
5713 fs.insert_tree(
5714 "/test",
5715 json!({
5716 "dir1": {
5717 "a.txt": "",
5718 "b.txt": "",
5719 },
5720 "dir2": {},
5721 "c.txt": "",
5722 "d.txt": "",
5723 }),
5724 )
5725 .await;
5726
5727 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
5728 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5729 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5730 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5731
5732 toggle_expand_dir(&panel, "test/dir1", cx);
5733
5734 cx.simulate_modifiers_change(gpui::Modifiers {
5735 control: true,
5736 ..Default::default()
5737 });
5738
5739 select_path_with_mark(&panel, "test/dir1/a.txt", cx);
5740 select_path_with_mark(&panel, "test/dir1", cx);
5741 select_path_with_mark(&panel, "test/c.txt", cx);
5742
5743 assert_eq!(
5744 visible_entries_as_strings(&panel, 0..15, cx),
5745 &[
5746 "v test",
5747 " v dir1 <== marked",
5748 " a.txt <== marked",
5749 " b.txt",
5750 " > dir2",
5751 " c.txt <== selected <== marked",
5752 " d.txt",
5753 ],
5754 "Initial state before copying a.txt, dir1 and c.txt"
5755 );
5756
5757 panel.update(cx, |panel, cx| {
5758 panel.copy(&Default::default(), cx);
5759 });
5760 select_path(&panel, "test/dir2", cx);
5761 panel.update(cx, |panel, cx| {
5762 panel.paste(&Default::default(), cx);
5763 });
5764 cx.executor().run_until_parked();
5765
5766 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
5767
5768 assert_eq!(
5769 visible_entries_as_strings(&panel, 0..20, cx),
5770 &[
5771 "v test",
5772 " v dir1 <== marked",
5773 " a.txt <== marked",
5774 " b.txt",
5775 " v dir2",
5776 " v dir1 <== selected",
5777 " a.txt",
5778 " b.txt",
5779 " c.txt",
5780 " c.txt <== marked",
5781 " d.txt",
5782 ],
5783 "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
5784 );
5785 }
5786
5787 #[gpui::test]
5788 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
5789 init_test_with_editor(cx);
5790
5791 let fs = FakeFs::new(cx.executor().clone());
5792 fs.insert_tree(
5793 "/src",
5794 json!({
5795 "test": {
5796 "first.rs": "// First Rust file",
5797 "second.rs": "// Second Rust file",
5798 "third.rs": "// Third Rust file",
5799 }
5800 }),
5801 )
5802 .await;
5803
5804 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5805 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5806 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5807 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5808
5809 toggle_expand_dir(&panel, "src/test", cx);
5810 select_path(&panel, "src/test/first.rs", cx);
5811 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5812 cx.executor().run_until_parked();
5813 assert_eq!(
5814 visible_entries_as_strings(&panel, 0..10, cx),
5815 &[
5816 "v src",
5817 " v test",
5818 " first.rs <== selected <== marked",
5819 " second.rs",
5820 " third.rs"
5821 ]
5822 );
5823 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
5824
5825 submit_deletion(&panel, cx);
5826 assert_eq!(
5827 visible_entries_as_strings(&panel, 0..10, cx),
5828 &[
5829 "v src",
5830 " v test",
5831 " second.rs <== selected",
5832 " third.rs"
5833 ],
5834 "Project panel should have no deleted file, no other file is selected in it"
5835 );
5836 ensure_no_open_items_and_panes(&workspace, cx);
5837
5838 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5839 cx.executor().run_until_parked();
5840 assert_eq!(
5841 visible_entries_as_strings(&panel, 0..10, cx),
5842 &[
5843 "v src",
5844 " v test",
5845 " second.rs <== selected <== marked",
5846 " third.rs"
5847 ]
5848 );
5849 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
5850
5851 workspace
5852 .update(cx, |workspace, cx| {
5853 let active_items = workspace
5854 .panes()
5855 .iter()
5856 .filter_map(|pane| pane.read(cx).active_item())
5857 .collect::<Vec<_>>();
5858 assert_eq!(active_items.len(), 1);
5859 let open_editor = active_items
5860 .into_iter()
5861 .next()
5862 .unwrap()
5863 .downcast::<Editor>()
5864 .expect("Open item should be an editor");
5865 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
5866 })
5867 .unwrap();
5868 submit_deletion_skipping_prompt(&panel, cx);
5869 assert_eq!(
5870 visible_entries_as_strings(&panel, 0..10, cx),
5871 &["v src", " v test", " third.rs <== selected"],
5872 "Project panel should have no deleted file, with one last file remaining"
5873 );
5874 ensure_no_open_items_and_panes(&workspace, cx);
5875 }
5876
5877 #[gpui::test]
5878 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
5879 init_test_with_editor(cx);
5880
5881 let fs = FakeFs::new(cx.executor().clone());
5882 fs.insert_tree(
5883 "/src",
5884 json!({
5885 "test": {
5886 "first.rs": "// First Rust file",
5887 "second.rs": "// Second Rust file",
5888 "third.rs": "// Third Rust file",
5889 }
5890 }),
5891 )
5892 .await;
5893
5894 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5895 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5896 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5897 let panel = workspace
5898 .update(cx, |workspace, cx| {
5899 let panel = ProjectPanel::new(workspace, cx);
5900 workspace.add_panel(panel.clone(), cx);
5901 panel
5902 })
5903 .unwrap();
5904
5905 select_path(&panel, "src/", cx);
5906 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
5907 cx.executor().run_until_parked();
5908 assert_eq!(
5909 visible_entries_as_strings(&panel, 0..10, cx),
5910 &[
5911 //
5912 "v src <== selected",
5913 " > test"
5914 ]
5915 );
5916 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
5917 panel.update(cx, |panel, cx| {
5918 assert!(panel.filename_editor.read(cx).is_focused(cx));
5919 });
5920 assert_eq!(
5921 visible_entries_as_strings(&panel, 0..10, cx),
5922 &[
5923 //
5924 "v src",
5925 " > [EDITOR: ''] <== selected",
5926 " > test"
5927 ]
5928 );
5929 panel.update(cx, |panel, cx| {
5930 panel
5931 .filename_editor
5932 .update(cx, |editor, cx| editor.set_text("test", cx));
5933 assert!(
5934 panel.confirm_edit(cx).is_none(),
5935 "Should not allow to confirm on conflicting new directory name"
5936 )
5937 });
5938 assert_eq!(
5939 visible_entries_as_strings(&panel, 0..10, cx),
5940 &[
5941 //
5942 "v src",
5943 " > test"
5944 ],
5945 "File list should be unchanged after failed folder create confirmation"
5946 );
5947
5948 select_path(&panel, "src/test/", cx);
5949 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
5950 cx.executor().run_until_parked();
5951 assert_eq!(
5952 visible_entries_as_strings(&panel, 0..10, cx),
5953 &[
5954 //
5955 "v src",
5956 " > test <== selected"
5957 ]
5958 );
5959 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5960 panel.update(cx, |panel, cx| {
5961 assert!(panel.filename_editor.read(cx).is_focused(cx));
5962 });
5963 assert_eq!(
5964 visible_entries_as_strings(&panel, 0..10, cx),
5965 &[
5966 "v src",
5967 " v test",
5968 " [EDITOR: ''] <== selected",
5969 " first.rs",
5970 " second.rs",
5971 " third.rs"
5972 ]
5973 );
5974 panel.update(cx, |panel, cx| {
5975 panel
5976 .filename_editor
5977 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
5978 assert!(
5979 panel.confirm_edit(cx).is_none(),
5980 "Should not allow to confirm on conflicting new file name"
5981 )
5982 });
5983 assert_eq!(
5984 visible_entries_as_strings(&panel, 0..10, cx),
5985 &[
5986 "v src",
5987 " v test",
5988 " first.rs",
5989 " second.rs",
5990 " third.rs"
5991 ],
5992 "File list should be unchanged after failed file create confirmation"
5993 );
5994
5995 select_path(&panel, "src/test/first.rs", cx);
5996 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
5997 cx.executor().run_until_parked();
5998 assert_eq!(
5999 visible_entries_as_strings(&panel, 0..10, cx),
6000 &[
6001 "v src",
6002 " v test",
6003 " first.rs <== selected",
6004 " second.rs",
6005 " third.rs"
6006 ],
6007 );
6008 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
6009 panel.update(cx, |panel, cx| {
6010 assert!(panel.filename_editor.read(cx).is_focused(cx));
6011 });
6012 assert_eq!(
6013 visible_entries_as_strings(&panel, 0..10, cx),
6014 &[
6015 "v src",
6016 " v test",
6017 " [EDITOR: 'first.rs'] <== selected",
6018 " second.rs",
6019 " third.rs"
6020 ]
6021 );
6022 panel.update(cx, |panel, cx| {
6023 panel
6024 .filename_editor
6025 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
6026 assert!(
6027 panel.confirm_edit(cx).is_none(),
6028 "Should not allow to confirm on conflicting file rename"
6029 )
6030 });
6031 assert_eq!(
6032 visible_entries_as_strings(&panel, 0..10, cx),
6033 &[
6034 "v src",
6035 " v test",
6036 " first.rs <== selected",
6037 " second.rs",
6038 " third.rs"
6039 ],
6040 "File list should be unchanged after failed rename confirmation"
6041 );
6042 }
6043
6044 #[gpui::test]
6045 async fn test_select_directory(cx: &mut gpui::TestAppContext) {
6046 init_test_with_editor(cx);
6047
6048 let fs = FakeFs::new(cx.executor().clone());
6049 fs.insert_tree(
6050 "/project_root",
6051 json!({
6052 "dir_1": {
6053 "nested_dir": {
6054 "file_a.py": "# File contents",
6055 }
6056 },
6057 "file_1.py": "# File contents",
6058 "dir_2": {
6059
6060 },
6061 "dir_3": {
6062
6063 },
6064 "file_2.py": "# File contents",
6065 "dir_4": {
6066
6067 },
6068 }),
6069 )
6070 .await;
6071
6072 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6073 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6074 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6075 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6076
6077 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6078 cx.executor().run_until_parked();
6079 select_path(&panel, "project_root/dir_1", cx);
6080 cx.executor().run_until_parked();
6081 assert_eq!(
6082 visible_entries_as_strings(&panel, 0..10, cx),
6083 &[
6084 "v project_root",
6085 " > dir_1 <== selected",
6086 " > dir_2",
6087 " > dir_3",
6088 " > dir_4",
6089 " file_1.py",
6090 " file_2.py",
6091 ]
6092 );
6093 panel.update(cx, |panel, cx| {
6094 panel.select_prev_directory(&SelectPrevDirectory, cx)
6095 });
6096
6097 assert_eq!(
6098 visible_entries_as_strings(&panel, 0..10, cx),
6099 &[
6100 "v project_root <== selected",
6101 " > dir_1",
6102 " > dir_2",
6103 " > dir_3",
6104 " > dir_4",
6105 " file_1.py",
6106 " file_2.py",
6107 ]
6108 );
6109
6110 panel.update(cx, |panel, cx| {
6111 panel.select_prev_directory(&SelectPrevDirectory, cx)
6112 });
6113
6114 assert_eq!(
6115 visible_entries_as_strings(&panel, 0..10, cx),
6116 &[
6117 "v project_root",
6118 " > dir_1",
6119 " > dir_2",
6120 " > dir_3",
6121 " > dir_4 <== selected",
6122 " file_1.py",
6123 " file_2.py",
6124 ]
6125 );
6126
6127 panel.update(cx, |panel, cx| {
6128 panel.select_next_directory(&SelectNextDirectory, cx)
6129 });
6130
6131 assert_eq!(
6132 visible_entries_as_strings(&panel, 0..10, cx),
6133 &[
6134 "v project_root <== selected",
6135 " > dir_1",
6136 " > dir_2",
6137 " > dir_3",
6138 " > dir_4",
6139 " file_1.py",
6140 " file_2.py",
6141 ]
6142 );
6143 }
6144
6145 #[gpui::test]
6146 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
6147 init_test_with_editor(cx);
6148
6149 let fs = FakeFs::new(cx.executor().clone());
6150 fs.insert_tree(
6151 "/project_root",
6152 json!({
6153 "dir_1": {
6154 "nested_dir": {
6155 "file_a.py": "# File contents",
6156 }
6157 },
6158 "file_1.py": "# File contents",
6159 }),
6160 )
6161 .await;
6162
6163 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6164 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6165 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6166 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6167
6168 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6169 cx.executor().run_until_parked();
6170 select_path(&panel, "project_root/dir_1", cx);
6171 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6172 select_path(&panel, "project_root/dir_1/nested_dir", cx);
6173 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6174 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6175 cx.executor().run_until_parked();
6176 assert_eq!(
6177 visible_entries_as_strings(&panel, 0..10, cx),
6178 &[
6179 "v project_root",
6180 " v dir_1",
6181 " > nested_dir <== selected",
6182 " file_1.py",
6183 ]
6184 );
6185 }
6186
6187 #[gpui::test]
6188 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
6189 init_test_with_editor(cx);
6190
6191 let fs = FakeFs::new(cx.executor().clone());
6192 fs.insert_tree(
6193 "/project_root",
6194 json!({
6195 "dir_1": {
6196 "nested_dir": {
6197 "file_a.py": "# File contents",
6198 "file_b.py": "# File contents",
6199 "file_c.py": "# File contents",
6200 },
6201 "file_1.py": "# File contents",
6202 "file_2.py": "# File contents",
6203 "file_3.py": "# File contents",
6204 },
6205 "dir_2": {
6206 "file_1.py": "# File contents",
6207 "file_2.py": "# File contents",
6208 "file_3.py": "# File contents",
6209 }
6210 }),
6211 )
6212 .await;
6213
6214 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6215 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6216 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6217 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6218
6219 panel.update(cx, |panel, cx| {
6220 panel.collapse_all_entries(&CollapseAllEntries, cx)
6221 });
6222 cx.executor().run_until_parked();
6223 assert_eq!(
6224 visible_entries_as_strings(&panel, 0..10, cx),
6225 &["v project_root", " > dir_1", " > dir_2",]
6226 );
6227
6228 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
6229 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6230 cx.executor().run_until_parked();
6231 assert_eq!(
6232 visible_entries_as_strings(&panel, 0..10, cx),
6233 &[
6234 "v project_root",
6235 " v dir_1 <== selected",
6236 " > nested_dir",
6237 " file_1.py",
6238 " file_2.py",
6239 " file_3.py",
6240 " > dir_2",
6241 ]
6242 );
6243 }
6244
6245 #[gpui::test]
6246 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
6247 init_test(cx);
6248
6249 let fs = FakeFs::new(cx.executor().clone());
6250 fs.as_fake().insert_tree("/root", json!({})).await;
6251 let project = Project::test(fs, ["/root".as_ref()], cx).await;
6252 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6253 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6254 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6255
6256 // Make a new buffer with no backing file
6257 workspace
6258 .update(cx, |workspace, cx| {
6259 Editor::new_file(workspace, &Default::default(), cx)
6260 })
6261 .unwrap();
6262
6263 cx.executor().run_until_parked();
6264
6265 // "Save as" the buffer, creating a new backing file for it
6266 let save_task = workspace
6267 .update(cx, |workspace, cx| {
6268 workspace.save_active_item(workspace::SaveIntent::Save, cx)
6269 })
6270 .unwrap();
6271
6272 cx.executor().run_until_parked();
6273 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
6274 save_task.await.unwrap();
6275
6276 // Rename the file
6277 select_path(&panel, "root/new", cx);
6278 assert_eq!(
6279 visible_entries_as_strings(&panel, 0..10, cx),
6280 &["v root", " new <== selected"]
6281 );
6282 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
6283 panel.update(cx, |panel, cx| {
6284 panel
6285 .filename_editor
6286 .update(cx, |editor, cx| editor.set_text("newer", cx));
6287 });
6288 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6289
6290 cx.executor().run_until_parked();
6291 assert_eq!(
6292 visible_entries_as_strings(&panel, 0..10, cx),
6293 &["v root", " newer <== selected"]
6294 );
6295
6296 workspace
6297 .update(cx, |workspace, cx| {
6298 workspace.save_active_item(workspace::SaveIntent::Save, cx)
6299 })
6300 .unwrap()
6301 .await
6302 .unwrap();
6303
6304 cx.executor().run_until_parked();
6305 // assert that saving the file doesn't restore "new"
6306 assert_eq!(
6307 visible_entries_as_strings(&panel, 0..10, cx),
6308 &["v root", " newer <== selected"]
6309 );
6310 }
6311
6312 #[gpui::test]
6313 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
6314 init_test_with_editor(cx);
6315 let fs = FakeFs::new(cx.executor().clone());
6316 fs.insert_tree(
6317 "/project_root",
6318 json!({
6319 "dir_1": {
6320 "nested_dir": {
6321 "file_a.py": "# File contents",
6322 }
6323 },
6324 "file_1.py": "# File contents",
6325 }),
6326 )
6327 .await;
6328
6329 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6330 let worktree_id =
6331 cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
6332 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6333 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6334 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6335 cx.update(|cx| {
6336 panel.update(cx, |this, cx| {
6337 this.select_next(&Default::default(), cx);
6338 this.expand_selected_entry(&Default::default(), cx);
6339 this.expand_selected_entry(&Default::default(), cx);
6340 this.select_next(&Default::default(), cx);
6341 this.expand_selected_entry(&Default::default(), cx);
6342 this.select_next(&Default::default(), cx);
6343 })
6344 });
6345 assert_eq!(
6346 visible_entries_as_strings(&panel, 0..10, cx),
6347 &[
6348 "v project_root",
6349 " v dir_1",
6350 " v nested_dir",
6351 " file_a.py <== selected",
6352 " file_1.py",
6353 ]
6354 );
6355 let modifiers_with_shift = gpui::Modifiers {
6356 shift: true,
6357 ..Default::default()
6358 };
6359 cx.simulate_modifiers_change(modifiers_with_shift);
6360 cx.update(|cx| {
6361 panel.update(cx, |this, cx| {
6362 this.select_next(&Default::default(), cx);
6363 })
6364 });
6365 assert_eq!(
6366 visible_entries_as_strings(&panel, 0..10, cx),
6367 &[
6368 "v project_root",
6369 " v dir_1",
6370 " v nested_dir",
6371 " file_a.py",
6372 " file_1.py <== selected <== marked",
6373 ]
6374 );
6375 cx.update(|cx| {
6376 panel.update(cx, |this, cx| {
6377 this.select_prev(&Default::default(), cx);
6378 })
6379 });
6380 assert_eq!(
6381 visible_entries_as_strings(&panel, 0..10, cx),
6382 &[
6383 "v project_root",
6384 " v dir_1",
6385 " v nested_dir",
6386 " file_a.py <== selected <== marked",
6387 " file_1.py <== marked",
6388 ]
6389 );
6390 cx.update(|cx| {
6391 panel.update(cx, |this, cx| {
6392 let drag = DraggedSelection {
6393 active_selection: this.selection.unwrap(),
6394 marked_selections: Arc::new(this.marked_entries.clone()),
6395 };
6396 let target_entry = this
6397 .project
6398 .read(cx)
6399 .entry_for_path(&(worktree_id, "").into(), cx)
6400 .unwrap();
6401 this.drag_onto(&drag, target_entry.id, false, cx);
6402 });
6403 });
6404 cx.run_until_parked();
6405 assert_eq!(
6406 visible_entries_as_strings(&panel, 0..10, cx),
6407 &[
6408 "v project_root",
6409 " v dir_1",
6410 " v nested_dir",
6411 " file_1.py <== marked",
6412 " file_a.py <== selected <== marked",
6413 ]
6414 );
6415 // ESC clears out all marks
6416 cx.update(|cx| {
6417 panel.update(cx, |this, cx| {
6418 this.cancel(&menu::Cancel, cx);
6419 })
6420 });
6421 assert_eq!(
6422 visible_entries_as_strings(&panel, 0..10, cx),
6423 &[
6424 "v project_root",
6425 " v dir_1",
6426 " v nested_dir",
6427 " file_1.py",
6428 " file_a.py <== selected",
6429 ]
6430 );
6431 // ESC clears out all marks
6432 cx.update(|cx| {
6433 panel.update(cx, |this, cx| {
6434 this.select_prev(&SelectPrev, cx);
6435 this.select_next(&SelectNext, cx);
6436 })
6437 });
6438 assert_eq!(
6439 visible_entries_as_strings(&panel, 0..10, cx),
6440 &[
6441 "v project_root",
6442 " v dir_1",
6443 " v nested_dir",
6444 " file_1.py <== marked",
6445 " file_a.py <== selected <== marked",
6446 ]
6447 );
6448 cx.simulate_modifiers_change(Default::default());
6449 cx.update(|cx| {
6450 panel.update(cx, |this, cx| {
6451 this.cut(&Cut, cx);
6452 this.select_prev(&SelectPrev, cx);
6453 this.select_prev(&SelectPrev, cx);
6454
6455 this.paste(&Paste, cx);
6456 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
6457 })
6458 });
6459 cx.run_until_parked();
6460 assert_eq!(
6461 visible_entries_as_strings(&panel, 0..10, cx),
6462 &[
6463 "v project_root",
6464 " v dir_1",
6465 " v nested_dir",
6466 " file_1.py <== marked",
6467 " file_a.py <== selected <== marked",
6468 ]
6469 );
6470 cx.simulate_modifiers_change(modifiers_with_shift);
6471 cx.update(|cx| {
6472 panel.update(cx, |this, cx| {
6473 this.expand_selected_entry(&Default::default(), cx);
6474 this.select_next(&SelectNext, cx);
6475 this.select_next(&SelectNext, cx);
6476 })
6477 });
6478 submit_deletion(&panel, cx);
6479 assert_eq!(
6480 visible_entries_as_strings(&panel, 0..10, cx),
6481 &[
6482 "v project_root",
6483 " v dir_1",
6484 " v nested_dir <== selected",
6485 ]
6486 );
6487 }
6488 #[gpui::test]
6489 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
6490 init_test_with_editor(cx);
6491 cx.update(|cx| {
6492 cx.update_global::<SettingsStore, _>(|store, cx| {
6493 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6494 worktree_settings.file_scan_exclusions = Some(Vec::new());
6495 });
6496 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6497 project_panel_settings.auto_reveal_entries = Some(false)
6498 });
6499 })
6500 });
6501
6502 let fs = FakeFs::new(cx.background_executor.clone());
6503 fs.insert_tree(
6504 "/project_root",
6505 json!({
6506 ".git": {},
6507 ".gitignore": "**/gitignored_dir",
6508 "dir_1": {
6509 "file_1.py": "# File 1_1 contents",
6510 "file_2.py": "# File 1_2 contents",
6511 "file_3.py": "# File 1_3 contents",
6512 "gitignored_dir": {
6513 "file_a.py": "# File contents",
6514 "file_b.py": "# File contents",
6515 "file_c.py": "# File contents",
6516 },
6517 },
6518 "dir_2": {
6519 "file_1.py": "# File 2_1 contents",
6520 "file_2.py": "# File 2_2 contents",
6521 "file_3.py": "# File 2_3 contents",
6522 }
6523 }),
6524 )
6525 .await;
6526
6527 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6528 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6529 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6530 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6531
6532 assert_eq!(
6533 visible_entries_as_strings(&panel, 0..20, cx),
6534 &[
6535 "v project_root",
6536 " > .git",
6537 " > dir_1",
6538 " > dir_2",
6539 " .gitignore",
6540 ]
6541 );
6542
6543 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
6544 .expect("dir 1 file is not ignored and should have an entry");
6545 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
6546 .expect("dir 2 file is not ignored and should have an entry");
6547 let gitignored_dir_file =
6548 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
6549 assert_eq!(
6550 gitignored_dir_file, None,
6551 "File in the gitignored dir should not have an entry before its dir is toggled"
6552 );
6553
6554 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6555 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6556 cx.executor().run_until_parked();
6557 assert_eq!(
6558 visible_entries_as_strings(&panel, 0..20, cx),
6559 &[
6560 "v project_root",
6561 " > .git",
6562 " v dir_1",
6563 " v gitignored_dir <== selected",
6564 " file_a.py",
6565 " file_b.py",
6566 " file_c.py",
6567 " file_1.py",
6568 " file_2.py",
6569 " file_3.py",
6570 " > dir_2",
6571 " .gitignore",
6572 ],
6573 "Should show gitignored dir file list in the project panel"
6574 );
6575 let gitignored_dir_file =
6576 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
6577 .expect("after gitignored dir got opened, a file entry should be present");
6578
6579 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6580 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6581 assert_eq!(
6582 visible_entries_as_strings(&panel, 0..20, cx),
6583 &[
6584 "v project_root",
6585 " > .git",
6586 " > dir_1 <== selected",
6587 " > dir_2",
6588 " .gitignore",
6589 ],
6590 "Should hide all dir contents again and prepare for the auto reveal test"
6591 );
6592
6593 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
6594 panel.update(cx, |panel, cx| {
6595 panel.project.update(cx, |_, cx| {
6596 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
6597 })
6598 });
6599 cx.run_until_parked();
6600 assert_eq!(
6601 visible_entries_as_strings(&panel, 0..20, cx),
6602 &[
6603 "v project_root",
6604 " > .git",
6605 " > dir_1 <== selected",
6606 " > dir_2",
6607 " .gitignore",
6608 ],
6609 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
6610 );
6611 }
6612
6613 cx.update(|cx| {
6614 cx.update_global::<SettingsStore, _>(|store, cx| {
6615 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6616 project_panel_settings.auto_reveal_entries = Some(true)
6617 });
6618 })
6619 });
6620
6621 panel.update(cx, |panel, cx| {
6622 panel.project.update(cx, |_, cx| {
6623 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
6624 })
6625 });
6626 cx.run_until_parked();
6627 assert_eq!(
6628 visible_entries_as_strings(&panel, 0..20, cx),
6629 &[
6630 "v project_root",
6631 " > .git",
6632 " v dir_1",
6633 " > gitignored_dir",
6634 " file_1.py <== selected",
6635 " file_2.py",
6636 " file_3.py",
6637 " > dir_2",
6638 " .gitignore",
6639 ],
6640 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
6641 );
6642
6643 panel.update(cx, |panel, cx| {
6644 panel.project.update(cx, |_, cx| {
6645 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
6646 })
6647 });
6648 cx.run_until_parked();
6649 assert_eq!(
6650 visible_entries_as_strings(&panel, 0..20, cx),
6651 &[
6652 "v project_root",
6653 " > .git",
6654 " v dir_1",
6655 " > gitignored_dir",
6656 " file_1.py",
6657 " file_2.py",
6658 " file_3.py",
6659 " v dir_2",
6660 " file_1.py <== selected",
6661 " file_2.py",
6662 " file_3.py",
6663 " .gitignore",
6664 ],
6665 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
6666 );
6667
6668 panel.update(cx, |panel, cx| {
6669 panel.project.update(cx, |_, cx| {
6670 cx.emit(project::Event::ActiveEntryChanged(Some(
6671 gitignored_dir_file,
6672 )))
6673 })
6674 });
6675 cx.run_until_parked();
6676 assert_eq!(
6677 visible_entries_as_strings(&panel, 0..20, cx),
6678 &[
6679 "v project_root",
6680 " > .git",
6681 " v dir_1",
6682 " > gitignored_dir",
6683 " file_1.py",
6684 " file_2.py",
6685 " file_3.py",
6686 " v dir_2",
6687 " file_1.py <== selected",
6688 " file_2.py",
6689 " file_3.py",
6690 " .gitignore",
6691 ],
6692 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
6693 );
6694
6695 panel.update(cx, |panel, cx| {
6696 panel.project.update(cx, |_, cx| {
6697 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
6698 })
6699 });
6700 cx.run_until_parked();
6701 assert_eq!(
6702 visible_entries_as_strings(&panel, 0..20, cx),
6703 &[
6704 "v project_root",
6705 " > .git",
6706 " v dir_1",
6707 " v gitignored_dir",
6708 " file_a.py <== selected",
6709 " file_b.py",
6710 " file_c.py",
6711 " file_1.py",
6712 " file_2.py",
6713 " file_3.py",
6714 " v dir_2",
6715 " file_1.py",
6716 " file_2.py",
6717 " file_3.py",
6718 " .gitignore",
6719 ],
6720 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
6721 );
6722 }
6723
6724 #[gpui::test]
6725 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
6726 init_test_with_editor(cx);
6727 cx.update(|cx| {
6728 cx.update_global::<SettingsStore, _>(|store, cx| {
6729 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6730 worktree_settings.file_scan_exclusions = Some(Vec::new());
6731 });
6732 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6733 project_panel_settings.auto_reveal_entries = Some(false)
6734 });
6735 })
6736 });
6737
6738 let fs = FakeFs::new(cx.background_executor.clone());
6739 fs.insert_tree(
6740 "/project_root",
6741 json!({
6742 ".git": {},
6743 ".gitignore": "**/gitignored_dir",
6744 "dir_1": {
6745 "file_1.py": "# File 1_1 contents",
6746 "file_2.py": "# File 1_2 contents",
6747 "file_3.py": "# File 1_3 contents",
6748 "gitignored_dir": {
6749 "file_a.py": "# File contents",
6750 "file_b.py": "# File contents",
6751 "file_c.py": "# File contents",
6752 },
6753 },
6754 "dir_2": {
6755 "file_1.py": "# File 2_1 contents",
6756 "file_2.py": "# File 2_2 contents",
6757 "file_3.py": "# File 2_3 contents",
6758 }
6759 }),
6760 )
6761 .await;
6762
6763 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6764 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6765 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6766 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6767
6768 assert_eq!(
6769 visible_entries_as_strings(&panel, 0..20, cx),
6770 &[
6771 "v project_root",
6772 " > .git",
6773 " > dir_1",
6774 " > dir_2",
6775 " .gitignore",
6776 ]
6777 );
6778
6779 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
6780 .expect("dir 1 file is not ignored and should have an entry");
6781 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
6782 .expect("dir 2 file is not ignored and should have an entry");
6783 let gitignored_dir_file =
6784 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
6785 assert_eq!(
6786 gitignored_dir_file, None,
6787 "File in the gitignored dir should not have an entry before its dir is toggled"
6788 );
6789
6790 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6791 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6792 cx.run_until_parked();
6793 assert_eq!(
6794 visible_entries_as_strings(&panel, 0..20, cx),
6795 &[
6796 "v project_root",
6797 " > .git",
6798 " v dir_1",
6799 " v gitignored_dir <== selected",
6800 " file_a.py",
6801 " file_b.py",
6802 " file_c.py",
6803 " file_1.py",
6804 " file_2.py",
6805 " file_3.py",
6806 " > dir_2",
6807 " .gitignore",
6808 ],
6809 "Should show gitignored dir file list in the project panel"
6810 );
6811 let gitignored_dir_file =
6812 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
6813 .expect("after gitignored dir got opened, a file entry should be present");
6814
6815 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6816 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6817 assert_eq!(
6818 visible_entries_as_strings(&panel, 0..20, cx),
6819 &[
6820 "v project_root",
6821 " > .git",
6822 " > dir_1 <== selected",
6823 " > dir_2",
6824 " .gitignore",
6825 ],
6826 "Should hide all dir contents again and prepare for the explicit reveal test"
6827 );
6828
6829 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
6830 panel.update(cx, |panel, cx| {
6831 panel.project.update(cx, |_, cx| {
6832 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
6833 })
6834 });
6835 cx.run_until_parked();
6836 assert_eq!(
6837 visible_entries_as_strings(&panel, 0..20, cx),
6838 &[
6839 "v project_root",
6840 " > .git",
6841 " > dir_1 <== selected",
6842 " > dir_2",
6843 " .gitignore",
6844 ],
6845 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
6846 );
6847 }
6848
6849 panel.update(cx, |panel, cx| {
6850 panel.project.update(cx, |_, cx| {
6851 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
6852 })
6853 });
6854 cx.run_until_parked();
6855 assert_eq!(
6856 visible_entries_as_strings(&panel, 0..20, cx),
6857 &[
6858 "v project_root",
6859 " > .git",
6860 " v dir_1",
6861 " > gitignored_dir",
6862 " file_1.py <== selected",
6863 " file_2.py",
6864 " file_3.py",
6865 " > dir_2",
6866 " .gitignore",
6867 ],
6868 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
6869 );
6870
6871 panel.update(cx, |panel, cx| {
6872 panel.project.update(cx, |_, cx| {
6873 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
6874 })
6875 });
6876 cx.run_until_parked();
6877 assert_eq!(
6878 visible_entries_as_strings(&panel, 0..20, cx),
6879 &[
6880 "v project_root",
6881 " > .git",
6882 " v dir_1",
6883 " > gitignored_dir",
6884 " file_1.py",
6885 " file_2.py",
6886 " file_3.py",
6887 " v dir_2",
6888 " file_1.py <== selected",
6889 " file_2.py",
6890 " file_3.py",
6891 " .gitignore",
6892 ],
6893 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
6894 );
6895
6896 panel.update(cx, |panel, cx| {
6897 panel.project.update(cx, |_, cx| {
6898 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
6899 })
6900 });
6901 cx.run_until_parked();
6902 assert_eq!(
6903 visible_entries_as_strings(&panel, 0..20, cx),
6904 &[
6905 "v project_root",
6906 " > .git",
6907 " v dir_1",
6908 " v gitignored_dir",
6909 " file_a.py <== selected",
6910 " file_b.py",
6911 " file_c.py",
6912 " file_1.py",
6913 " file_2.py",
6914 " file_3.py",
6915 " v dir_2",
6916 " file_1.py",
6917 " file_2.py",
6918 " file_3.py",
6919 " .gitignore",
6920 ],
6921 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
6922 );
6923 }
6924
6925 #[gpui::test]
6926 async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
6927 init_test(cx);
6928 cx.update(|cx| {
6929 cx.update_global::<SettingsStore, _>(|store, cx| {
6930 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
6931 project_settings.file_scan_exclusions =
6932 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
6933 });
6934 });
6935 });
6936
6937 cx.update(|cx| {
6938 register_project_item::<TestProjectItemView>(cx);
6939 });
6940
6941 let fs = FakeFs::new(cx.executor().clone());
6942 fs.insert_tree(
6943 "/root1",
6944 json!({
6945 ".dockerignore": "",
6946 ".git": {
6947 "HEAD": "",
6948 },
6949 }),
6950 )
6951 .await;
6952
6953 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
6954 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6955 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6956 let panel = workspace
6957 .update(cx, |workspace, cx| {
6958 let panel = ProjectPanel::new(workspace, cx);
6959 workspace.add_panel(panel.clone(), cx);
6960 panel
6961 })
6962 .unwrap();
6963
6964 select_path(&panel, "root1", cx);
6965 assert_eq!(
6966 visible_entries_as_strings(&panel, 0..10, cx),
6967 &["v root1 <== selected", " .dockerignore",]
6968 );
6969 workspace
6970 .update(cx, |workspace, cx| {
6971 assert!(
6972 workspace.active_item(cx).is_none(),
6973 "Should have no active items in the beginning"
6974 );
6975 })
6976 .unwrap();
6977
6978 let excluded_file_path = ".git/COMMIT_EDITMSG";
6979 let excluded_dir_path = "excluded_dir";
6980
6981 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
6982 panel.update(cx, |panel, cx| {
6983 assert!(panel.filename_editor.read(cx).is_focused(cx));
6984 });
6985 panel
6986 .update(cx, |panel, cx| {
6987 panel
6988 .filename_editor
6989 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
6990 panel.confirm_edit(cx).unwrap()
6991 })
6992 .await
6993 .unwrap();
6994
6995 assert_eq!(
6996 visible_entries_as_strings(&panel, 0..13, cx),
6997 &["v root1", " .dockerignore"],
6998 "Excluded dir should not be shown after opening a file in it"
6999 );
7000 panel.update(cx, |panel, cx| {
7001 assert!(
7002 !panel.filename_editor.read(cx).is_focused(cx),
7003 "Should have closed the file name editor"
7004 );
7005 });
7006 workspace
7007 .update(cx, |workspace, cx| {
7008 let active_entry_path = workspace
7009 .active_item(cx)
7010 .expect("should have opened and activated the excluded item")
7011 .act_as::<TestProjectItemView>(cx)
7012 .expect(
7013 "should have opened the corresponding project item for the excluded item",
7014 )
7015 .read(cx)
7016 .path
7017 .clone();
7018 assert_eq!(
7019 active_entry_path.path.as_ref(),
7020 Path::new(excluded_file_path),
7021 "Should open the excluded file"
7022 );
7023
7024 assert!(
7025 workspace.notification_ids().is_empty(),
7026 "Should have no notifications after opening an excluded file"
7027 );
7028 })
7029 .unwrap();
7030 assert!(
7031 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
7032 "Should have created the excluded file"
7033 );
7034
7035 select_path(&panel, "root1", cx);
7036 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7037 panel.update(cx, |panel, cx| {
7038 assert!(panel.filename_editor.read(cx).is_focused(cx));
7039 });
7040 panel
7041 .update(cx, |panel, cx| {
7042 panel
7043 .filename_editor
7044 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
7045 panel.confirm_edit(cx).unwrap()
7046 })
7047 .await
7048 .unwrap();
7049
7050 assert_eq!(
7051 visible_entries_as_strings(&panel, 0..13, cx),
7052 &["v root1", " .dockerignore"],
7053 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
7054 );
7055 panel.update(cx, |panel, cx| {
7056 assert!(
7057 !panel.filename_editor.read(cx).is_focused(cx),
7058 "Should have closed the file name editor"
7059 );
7060 });
7061 workspace
7062 .update(cx, |workspace, cx| {
7063 let notifications = workspace.notification_ids();
7064 assert_eq!(
7065 notifications.len(),
7066 1,
7067 "Should receive one notification with the error message"
7068 );
7069 workspace.dismiss_notification(notifications.first().unwrap(), cx);
7070 assert!(workspace.notification_ids().is_empty());
7071 })
7072 .unwrap();
7073
7074 select_path(&panel, "root1", cx);
7075 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7076 panel.update(cx, |panel, cx| {
7077 assert!(panel.filename_editor.read(cx).is_focused(cx));
7078 });
7079 panel
7080 .update(cx, |panel, cx| {
7081 panel
7082 .filename_editor
7083 .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
7084 panel.confirm_edit(cx).unwrap()
7085 })
7086 .await
7087 .unwrap();
7088
7089 assert_eq!(
7090 visible_entries_as_strings(&panel, 0..13, cx),
7091 &["v root1", " .dockerignore"],
7092 "Should not change the project panel after trying to create an excluded directory"
7093 );
7094 panel.update(cx, |panel, cx| {
7095 assert!(
7096 !panel.filename_editor.read(cx).is_focused(cx),
7097 "Should have closed the file name editor"
7098 );
7099 });
7100 workspace
7101 .update(cx, |workspace, cx| {
7102 let notifications = workspace.notification_ids();
7103 assert_eq!(
7104 notifications.len(),
7105 1,
7106 "Should receive one notification explaining that no directory is actually shown"
7107 );
7108 workspace.dismiss_notification(notifications.first().unwrap(), cx);
7109 assert!(workspace.notification_ids().is_empty());
7110 })
7111 .unwrap();
7112 assert!(
7113 fs.is_dir(Path::new("/root1/excluded_dir")).await,
7114 "Should have created the excluded directory"
7115 );
7116 }
7117
7118 #[gpui::test]
7119 async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
7120 init_test_with_editor(cx);
7121
7122 let fs = FakeFs::new(cx.executor().clone());
7123 fs.insert_tree(
7124 "/src",
7125 json!({
7126 "test": {
7127 "first.rs": "// First Rust file",
7128 "second.rs": "// Second Rust file",
7129 "third.rs": "// Third Rust file",
7130 }
7131 }),
7132 )
7133 .await;
7134
7135 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
7136 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7137 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7138 let panel = workspace
7139 .update(cx, |workspace, cx| {
7140 let panel = ProjectPanel::new(workspace, cx);
7141 workspace.add_panel(panel.clone(), cx);
7142 panel
7143 })
7144 .unwrap();
7145
7146 select_path(&panel, "src/", cx);
7147 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
7148 cx.executor().run_until_parked();
7149 assert_eq!(
7150 visible_entries_as_strings(&panel, 0..10, cx),
7151 &[
7152 //
7153 "v src <== selected",
7154 " > test"
7155 ]
7156 );
7157 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7158 panel.update(cx, |panel, cx| {
7159 assert!(panel.filename_editor.read(cx).is_focused(cx));
7160 });
7161 assert_eq!(
7162 visible_entries_as_strings(&panel, 0..10, cx),
7163 &[
7164 //
7165 "v src",
7166 " > [EDITOR: ''] <== selected",
7167 " > test"
7168 ]
7169 );
7170
7171 panel.update(cx, |panel, cx| panel.cancel(&menu::Cancel, cx));
7172 assert_eq!(
7173 visible_entries_as_strings(&panel, 0..10, cx),
7174 &[
7175 //
7176 "v src <== selected",
7177 " > test"
7178 ]
7179 );
7180 }
7181
7182 #[gpui::test]
7183 async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
7184 init_test_with_editor(cx);
7185
7186 let fs = FakeFs::new(cx.executor().clone());
7187 fs.insert_tree(
7188 "/root",
7189 json!({
7190 "dir1": {
7191 "subdir1": {},
7192 "file1.txt": "",
7193 "file2.txt": "",
7194 },
7195 "dir2": {
7196 "subdir2": {},
7197 "file3.txt": "",
7198 "file4.txt": "",
7199 },
7200 "file5.txt": "",
7201 "file6.txt": "",
7202 }),
7203 )
7204 .await;
7205
7206 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7207 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7208 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7209 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7210
7211 toggle_expand_dir(&panel, "root/dir1", cx);
7212 toggle_expand_dir(&panel, "root/dir2", cx);
7213
7214 // Test Case 1: Delete middle file in directory
7215 select_path(&panel, "root/dir1/file1.txt", cx);
7216 assert_eq!(
7217 visible_entries_as_strings(&panel, 0..15, cx),
7218 &[
7219 "v root",
7220 " v dir1",
7221 " > subdir1",
7222 " file1.txt <== selected",
7223 " file2.txt",
7224 " v dir2",
7225 " > subdir2",
7226 " file3.txt",
7227 " file4.txt",
7228 " file5.txt",
7229 " file6.txt",
7230 ],
7231 "Initial state before deleting middle file"
7232 );
7233
7234 submit_deletion(&panel, cx);
7235 assert_eq!(
7236 visible_entries_as_strings(&panel, 0..15, cx),
7237 &[
7238 "v root",
7239 " v dir1",
7240 " > subdir1",
7241 " file2.txt <== selected",
7242 " v dir2",
7243 " > subdir2",
7244 " file3.txt",
7245 " file4.txt",
7246 " file5.txt",
7247 " file6.txt",
7248 ],
7249 "Should select next file after deleting middle file"
7250 );
7251
7252 // Test Case 2: Delete last file in directory
7253 submit_deletion(&panel, cx);
7254 assert_eq!(
7255 visible_entries_as_strings(&panel, 0..15, cx),
7256 &[
7257 "v root",
7258 " v dir1",
7259 " > subdir1 <== selected",
7260 " v dir2",
7261 " > subdir2",
7262 " file3.txt",
7263 " file4.txt",
7264 " file5.txt",
7265 " file6.txt",
7266 ],
7267 "Should select next directory when last file is deleted"
7268 );
7269
7270 // Test Case 3: Delete root level file
7271 select_path(&panel, "root/file6.txt", cx);
7272 assert_eq!(
7273 visible_entries_as_strings(&panel, 0..15, cx),
7274 &[
7275 "v root",
7276 " v dir1",
7277 " > subdir1",
7278 " v dir2",
7279 " > subdir2",
7280 " file3.txt",
7281 " file4.txt",
7282 " file5.txt",
7283 " file6.txt <== selected",
7284 ],
7285 "Initial state before deleting root level file"
7286 );
7287
7288 submit_deletion(&panel, cx);
7289 assert_eq!(
7290 visible_entries_as_strings(&panel, 0..15, cx),
7291 &[
7292 "v root",
7293 " v dir1",
7294 " > subdir1",
7295 " v dir2",
7296 " > subdir2",
7297 " file3.txt",
7298 " file4.txt",
7299 " file5.txt <== selected",
7300 ],
7301 "Should select prev entry at root level"
7302 );
7303 }
7304
7305 #[gpui::test]
7306 async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
7307 init_test_with_editor(cx);
7308
7309 let fs = FakeFs::new(cx.executor().clone());
7310 fs.insert_tree(
7311 "/root",
7312 json!({
7313 "dir1": {
7314 "subdir1": {
7315 "a.txt": "",
7316 "b.txt": ""
7317 },
7318 "file1.txt": "",
7319 },
7320 "dir2": {
7321 "subdir2": {
7322 "c.txt": "",
7323 "d.txt": ""
7324 },
7325 "file2.txt": "",
7326 },
7327 "file3.txt": "",
7328 }),
7329 )
7330 .await;
7331
7332 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7333 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7334 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7335 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7336
7337 toggle_expand_dir(&panel, "root/dir1", cx);
7338 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7339 toggle_expand_dir(&panel, "root/dir2", cx);
7340 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
7341
7342 // Test Case 1: Select and delete nested directory with parent
7343 cx.simulate_modifiers_change(gpui::Modifiers {
7344 control: true,
7345 ..Default::default()
7346 });
7347 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
7348 select_path_with_mark(&panel, "root/dir1", cx);
7349
7350 assert_eq!(
7351 visible_entries_as_strings(&panel, 0..15, cx),
7352 &[
7353 "v root",
7354 " v dir1 <== selected <== marked",
7355 " v subdir1 <== marked",
7356 " a.txt",
7357 " b.txt",
7358 " file1.txt",
7359 " v dir2",
7360 " v subdir2",
7361 " c.txt",
7362 " d.txt",
7363 " file2.txt",
7364 " file3.txt",
7365 ],
7366 "Initial state before deleting nested directory with parent"
7367 );
7368
7369 submit_deletion(&panel, cx);
7370 assert_eq!(
7371 visible_entries_as_strings(&panel, 0..15, cx),
7372 &[
7373 "v root",
7374 " v dir2 <== selected",
7375 " v subdir2",
7376 " c.txt",
7377 " d.txt",
7378 " file2.txt",
7379 " file3.txt",
7380 ],
7381 "Should select next directory after deleting directory with parent"
7382 );
7383
7384 // Test Case 2: Select mixed files and directories across levels
7385 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
7386 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
7387 select_path_with_mark(&panel, "root/file3.txt", cx);
7388
7389 assert_eq!(
7390 visible_entries_as_strings(&panel, 0..15, cx),
7391 &[
7392 "v root",
7393 " v dir2",
7394 " v subdir2",
7395 " c.txt <== marked",
7396 " d.txt",
7397 " file2.txt <== marked",
7398 " file3.txt <== selected <== marked",
7399 ],
7400 "Initial state before deleting"
7401 );
7402
7403 submit_deletion(&panel, cx);
7404 assert_eq!(
7405 visible_entries_as_strings(&panel, 0..15, cx),
7406 &[
7407 "v root",
7408 " v dir2 <== selected",
7409 " v subdir2",
7410 " d.txt",
7411 ],
7412 "Should select sibling directory"
7413 );
7414 }
7415
7416 #[gpui::test]
7417 async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
7418 init_test_with_editor(cx);
7419
7420 let fs = FakeFs::new(cx.executor().clone());
7421 fs.insert_tree(
7422 "/root",
7423 json!({
7424 "dir1": {
7425 "subdir1": {
7426 "a.txt": "",
7427 "b.txt": ""
7428 },
7429 "file1.txt": "",
7430 },
7431 "dir2": {
7432 "subdir2": {
7433 "c.txt": "",
7434 "d.txt": ""
7435 },
7436 "file2.txt": "",
7437 },
7438 "file3.txt": "",
7439 "file4.txt": "",
7440 }),
7441 )
7442 .await;
7443
7444 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7445 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7446 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7447 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7448
7449 toggle_expand_dir(&panel, "root/dir1", cx);
7450 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7451 toggle_expand_dir(&panel, "root/dir2", cx);
7452 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
7453
7454 // Test Case 1: Select all root files and directories
7455 cx.simulate_modifiers_change(gpui::Modifiers {
7456 control: true,
7457 ..Default::default()
7458 });
7459 select_path_with_mark(&panel, "root/dir1", cx);
7460 select_path_with_mark(&panel, "root/dir2", cx);
7461 select_path_with_mark(&panel, "root/file3.txt", cx);
7462 select_path_with_mark(&panel, "root/file4.txt", cx);
7463 assert_eq!(
7464 visible_entries_as_strings(&panel, 0..20, cx),
7465 &[
7466 "v root",
7467 " v dir1 <== marked",
7468 " v subdir1",
7469 " a.txt",
7470 " b.txt",
7471 " file1.txt",
7472 " v dir2 <== marked",
7473 " v subdir2",
7474 " c.txt",
7475 " d.txt",
7476 " file2.txt",
7477 " file3.txt <== marked",
7478 " file4.txt <== selected <== marked",
7479 ],
7480 "State before deleting all contents"
7481 );
7482
7483 submit_deletion(&panel, cx);
7484 assert_eq!(
7485 visible_entries_as_strings(&panel, 0..20, cx),
7486 &["v root <== selected"],
7487 "Only empty root directory should remain after deleting all contents"
7488 );
7489 }
7490
7491 #[gpui::test]
7492 async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
7493 init_test_with_editor(cx);
7494
7495 let fs = FakeFs::new(cx.executor().clone());
7496 fs.insert_tree(
7497 "/root",
7498 json!({
7499 "dir1": {
7500 "subdir1": {
7501 "file_a.txt": "content a",
7502 "file_b.txt": "content b",
7503 },
7504 "subdir2": {
7505 "file_c.txt": "content c",
7506 },
7507 "file1.txt": "content 1",
7508 },
7509 "dir2": {
7510 "file2.txt": "content 2",
7511 },
7512 }),
7513 )
7514 .await;
7515
7516 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7517 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7518 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7519 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7520
7521 toggle_expand_dir(&panel, "root/dir1", cx);
7522 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7523 toggle_expand_dir(&panel, "root/dir2", cx);
7524 cx.simulate_modifiers_change(gpui::Modifiers {
7525 control: true,
7526 ..Default::default()
7527 });
7528
7529 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
7530 select_path_with_mark(&panel, "root/dir1", cx);
7531 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
7532 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
7533
7534 assert_eq!(
7535 visible_entries_as_strings(&panel, 0..20, cx),
7536 &[
7537 "v root",
7538 " v dir1 <== marked",
7539 " v subdir1 <== marked",
7540 " file_a.txt <== selected <== marked",
7541 " file_b.txt",
7542 " > subdir2",
7543 " file1.txt",
7544 " v dir2",
7545 " file2.txt",
7546 ],
7547 "State with parent dir, subdir, and file selected"
7548 );
7549 submit_deletion(&panel, cx);
7550 assert_eq!(
7551 visible_entries_as_strings(&panel, 0..20, cx),
7552 &["v root", " v dir2 <== selected", " file2.txt",],
7553 "Only dir2 should remain after deletion"
7554 );
7555 }
7556
7557 #[gpui::test]
7558 async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
7559 init_test_with_editor(cx);
7560
7561 let fs = FakeFs::new(cx.executor().clone());
7562 // First worktree
7563 fs.insert_tree(
7564 "/root1",
7565 json!({
7566 "dir1": {
7567 "file1.txt": "content 1",
7568 "file2.txt": "content 2",
7569 },
7570 "dir2": {
7571 "file3.txt": "content 3",
7572 },
7573 }),
7574 )
7575 .await;
7576
7577 // Second worktree
7578 fs.insert_tree(
7579 "/root2",
7580 json!({
7581 "dir3": {
7582 "file4.txt": "content 4",
7583 "file5.txt": "content 5",
7584 },
7585 "file6.txt": "content 6",
7586 }),
7587 )
7588 .await;
7589
7590 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7591 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7592 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7593 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7594
7595 // Expand all directories for testing
7596 toggle_expand_dir(&panel, "root1/dir1", cx);
7597 toggle_expand_dir(&panel, "root1/dir2", cx);
7598 toggle_expand_dir(&panel, "root2/dir3", cx);
7599
7600 // Test Case 1: Delete files across different worktrees
7601 cx.simulate_modifiers_change(gpui::Modifiers {
7602 control: true,
7603 ..Default::default()
7604 });
7605 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
7606 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
7607
7608 assert_eq!(
7609 visible_entries_as_strings(&panel, 0..20, cx),
7610 &[
7611 "v root1",
7612 " v dir1",
7613 " file1.txt <== marked",
7614 " file2.txt",
7615 " v dir2",
7616 " file3.txt",
7617 "v root2",
7618 " v dir3",
7619 " file4.txt <== selected <== marked",
7620 " file5.txt",
7621 " file6.txt",
7622 ],
7623 "Initial state with files selected from different worktrees"
7624 );
7625
7626 submit_deletion(&panel, cx);
7627 assert_eq!(
7628 visible_entries_as_strings(&panel, 0..20, cx),
7629 &[
7630 "v root1",
7631 " v dir1",
7632 " file2.txt",
7633 " v dir2",
7634 " file3.txt",
7635 "v root2",
7636 " v dir3",
7637 " file5.txt <== selected",
7638 " file6.txt",
7639 ],
7640 "Should select next file in the last worktree after deletion"
7641 );
7642
7643 // Test Case 2: Delete directories from different worktrees
7644 select_path_with_mark(&panel, "root1/dir1", cx);
7645 select_path_with_mark(&panel, "root2/dir3", cx);
7646
7647 assert_eq!(
7648 visible_entries_as_strings(&panel, 0..20, cx),
7649 &[
7650 "v root1",
7651 " v dir1 <== marked",
7652 " file2.txt",
7653 " v dir2",
7654 " file3.txt",
7655 "v root2",
7656 " v dir3 <== selected <== marked",
7657 " file5.txt",
7658 " file6.txt",
7659 ],
7660 "State with directories marked from different worktrees"
7661 );
7662
7663 submit_deletion(&panel, cx);
7664 assert_eq!(
7665 visible_entries_as_strings(&panel, 0..20, cx),
7666 &[
7667 "v root1",
7668 " v dir2",
7669 " file3.txt",
7670 "v root2",
7671 " file6.txt <== selected",
7672 ],
7673 "Should select remaining file in last worktree after directory deletion"
7674 );
7675
7676 // Test Case 4: Delete all remaining files except roots
7677 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
7678 select_path_with_mark(&panel, "root2/file6.txt", cx);
7679
7680 assert_eq!(
7681 visible_entries_as_strings(&panel, 0..20, cx),
7682 &[
7683 "v root1",
7684 " v dir2",
7685 " file3.txt <== marked",
7686 "v root2",
7687 " file6.txt <== selected <== marked",
7688 ],
7689 "State with all remaining files marked"
7690 );
7691
7692 submit_deletion(&panel, cx);
7693 assert_eq!(
7694 visible_entries_as_strings(&panel, 0..20, cx),
7695 &["v root1", " v dir2", "v root2 <== selected"],
7696 "Second parent root should be selected after deleting"
7697 );
7698 }
7699
7700 #[gpui::test]
7701 async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
7702 init_test_with_editor(cx);
7703
7704 let fs = FakeFs::new(cx.executor().clone());
7705 fs.insert_tree(
7706 "/root_b",
7707 json!({
7708 "dir1": {
7709 "file1.txt": "content 1",
7710 "file2.txt": "content 2",
7711 },
7712 }),
7713 )
7714 .await;
7715
7716 fs.insert_tree(
7717 "/root_c",
7718 json!({
7719 "dir2": {},
7720 }),
7721 )
7722 .await;
7723
7724 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
7725 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7726 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7727 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7728
7729 toggle_expand_dir(&panel, "root_b/dir1", cx);
7730 toggle_expand_dir(&panel, "root_c/dir2", cx);
7731
7732 cx.simulate_modifiers_change(gpui::Modifiers {
7733 control: true,
7734 ..Default::default()
7735 });
7736 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
7737 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
7738
7739 assert_eq!(
7740 visible_entries_as_strings(&panel, 0..20, cx),
7741 &[
7742 "v root_b",
7743 " v dir1",
7744 " file1.txt <== marked",
7745 " file2.txt <== selected <== marked",
7746 "v root_c",
7747 " v dir2",
7748 ],
7749 "Initial state with files marked in root_b"
7750 );
7751
7752 submit_deletion(&panel, cx);
7753 assert_eq!(
7754 visible_entries_as_strings(&panel, 0..20, cx),
7755 &[
7756 "v root_b",
7757 " v dir1 <== selected",
7758 "v root_c",
7759 " v dir2",
7760 ],
7761 "After deletion in root_b as it's last deletion, selection should be in root_b"
7762 );
7763
7764 select_path_with_mark(&panel, "root_c/dir2", cx);
7765
7766 submit_deletion(&panel, cx);
7767 assert_eq!(
7768 visible_entries_as_strings(&panel, 0..20, cx),
7769 &["v root_b", " v dir1", "v root_c <== selected",],
7770 "After deleting from root_c, it should remain in root_c"
7771 );
7772 }
7773
7774 fn toggle_expand_dir(
7775 panel: &View<ProjectPanel>,
7776 path: impl AsRef<Path>,
7777 cx: &mut VisualTestContext,
7778 ) {
7779 let path = path.as_ref();
7780 panel.update(cx, |panel, cx| {
7781 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7782 let worktree = worktree.read(cx);
7783 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7784 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
7785 panel.toggle_expanded(entry_id, cx);
7786 return;
7787 }
7788 }
7789 panic!("no worktree for path {:?}", path);
7790 });
7791 }
7792
7793 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
7794 let path = path.as_ref();
7795 panel.update(cx, |panel, cx| {
7796 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7797 let worktree = worktree.read(cx);
7798 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7799 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
7800 panel.selection = Some(crate::SelectedEntry {
7801 worktree_id: worktree.id(),
7802 entry_id,
7803 });
7804 return;
7805 }
7806 }
7807 panic!("no worktree for path {:?}", path);
7808 });
7809 }
7810
7811 fn select_path_with_mark(
7812 panel: &View<ProjectPanel>,
7813 path: impl AsRef<Path>,
7814 cx: &mut VisualTestContext,
7815 ) {
7816 let path = path.as_ref();
7817 panel.update(cx, |panel, cx| {
7818 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7819 let worktree = worktree.read(cx);
7820 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7821 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
7822 let entry = crate::SelectedEntry {
7823 worktree_id: worktree.id(),
7824 entry_id,
7825 };
7826 if !panel.marked_entries.contains(&entry) {
7827 panel.marked_entries.insert(entry);
7828 }
7829 panel.selection = Some(entry);
7830 return;
7831 }
7832 }
7833 panic!("no worktree for path {:?}", path);
7834 });
7835 }
7836
7837 fn find_project_entry(
7838 panel: &View<ProjectPanel>,
7839 path: impl AsRef<Path>,
7840 cx: &mut VisualTestContext,
7841 ) -> Option<ProjectEntryId> {
7842 let path = path.as_ref();
7843 panel.update(cx, |panel, cx| {
7844 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7845 let worktree = worktree.read(cx);
7846 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7847 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
7848 }
7849 }
7850 panic!("no worktree for path {path:?}");
7851 })
7852 }
7853
7854 fn visible_entries_as_strings(
7855 panel: &View<ProjectPanel>,
7856 range: Range<usize>,
7857 cx: &mut VisualTestContext,
7858 ) -> Vec<String> {
7859 let mut result = Vec::new();
7860 let mut project_entries = HashSet::default();
7861 let mut has_editor = false;
7862
7863 panel.update(cx, |panel, cx| {
7864 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
7865 if details.is_editing {
7866 assert!(!has_editor, "duplicate editor entry");
7867 has_editor = true;
7868 } else {
7869 assert!(
7870 project_entries.insert(project_entry),
7871 "duplicate project entry {:?} {:?}",
7872 project_entry,
7873 details
7874 );
7875 }
7876
7877 let indent = " ".repeat(details.depth);
7878 let icon = if details.kind.is_dir() {
7879 if details.is_expanded {
7880 "v "
7881 } else {
7882 "> "
7883 }
7884 } else {
7885 " "
7886 };
7887 let name = if details.is_editing {
7888 format!("[EDITOR: '{}']", details.filename)
7889 } else if details.is_processing {
7890 format!("[PROCESSING: '{}']", details.filename)
7891 } else {
7892 details.filename.clone()
7893 };
7894 let selected = if details.is_selected {
7895 " <== selected"
7896 } else {
7897 ""
7898 };
7899 let marked = if details.is_marked {
7900 " <== marked"
7901 } else {
7902 ""
7903 };
7904
7905 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
7906 });
7907 });
7908
7909 result
7910 }
7911
7912 fn init_test(cx: &mut TestAppContext) {
7913 cx.update(|cx| {
7914 let settings_store = SettingsStore::test(cx);
7915 cx.set_global(settings_store);
7916 init_settings(cx);
7917 theme::init(theme::LoadThemes::JustBase, cx);
7918 language::init(cx);
7919 editor::init_settings(cx);
7920 crate::init((), cx);
7921 workspace::init_settings(cx);
7922 client::init_settings(cx);
7923 Project::init_settings(cx);
7924
7925 cx.update_global::<SettingsStore, _>(|store, cx| {
7926 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7927 project_panel_settings.auto_fold_dirs = Some(false);
7928 });
7929 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7930 worktree_settings.file_scan_exclusions = Some(Vec::new());
7931 });
7932 });
7933 });
7934 }
7935
7936 fn init_test_with_editor(cx: &mut TestAppContext) {
7937 cx.update(|cx| {
7938 let app_state = AppState::test(cx);
7939 theme::init(theme::LoadThemes::JustBase, cx);
7940 init_settings(cx);
7941 language::init(cx);
7942 editor::init(cx);
7943 crate::init((), cx);
7944 workspace::init(app_state.clone(), cx);
7945 Project::init_settings(cx);
7946
7947 cx.update_global::<SettingsStore, _>(|store, cx| {
7948 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7949 project_panel_settings.auto_fold_dirs = Some(false);
7950 });
7951 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7952 worktree_settings.file_scan_exclusions = Some(Vec::new());
7953 });
7954 });
7955 });
7956 }
7957
7958 fn ensure_single_file_is_opened(
7959 window: &WindowHandle<Workspace>,
7960 expected_path: &str,
7961 cx: &mut TestAppContext,
7962 ) {
7963 window
7964 .update(cx, |workspace, cx| {
7965 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
7966 assert_eq!(worktrees.len(), 1);
7967 let worktree_id = worktrees[0].read(cx).id();
7968
7969 let open_project_paths = workspace
7970 .panes()
7971 .iter()
7972 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
7973 .collect::<Vec<_>>();
7974 assert_eq!(
7975 open_project_paths,
7976 vec![ProjectPath {
7977 worktree_id,
7978 path: Arc::from(Path::new(expected_path))
7979 }],
7980 "Should have opened file, selected in project panel"
7981 );
7982 })
7983 .unwrap();
7984 }
7985
7986 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
7987 assert!(
7988 !cx.has_pending_prompt(),
7989 "Should have no prompts before the deletion"
7990 );
7991 panel.update(cx, |panel, cx| {
7992 panel.delete(&Delete { skip_prompt: false }, cx)
7993 });
7994 assert!(
7995 cx.has_pending_prompt(),
7996 "Should have a prompt after the deletion"
7997 );
7998 cx.simulate_prompt_answer(0);
7999 assert!(
8000 !cx.has_pending_prompt(),
8001 "Should have no prompts after prompt was replied to"
8002 );
8003 cx.executor().run_until_parked();
8004 }
8005
8006 fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
8007 assert!(
8008 !cx.has_pending_prompt(),
8009 "Should have no prompts before the deletion"
8010 );
8011 panel.update(cx, |panel, cx| {
8012 panel.delete(&Delete { skip_prompt: true }, cx)
8013 });
8014 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
8015 cx.executor().run_until_parked();
8016 }
8017
8018 fn ensure_no_open_items_and_panes(
8019 workspace: &WindowHandle<Workspace>,
8020 cx: &mut VisualTestContext,
8021 ) {
8022 assert!(
8023 !cx.has_pending_prompt(),
8024 "Should have no prompts after deletion operation closes the file"
8025 );
8026 workspace
8027 .read_with(cx, |workspace, cx| {
8028 let open_project_paths = workspace
8029 .panes()
8030 .iter()
8031 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
8032 .collect::<Vec<_>>();
8033 assert!(
8034 open_project_paths.is_empty(),
8035 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
8036 );
8037 })
8038 .unwrap();
8039 }
8040
8041 struct TestProjectItemView {
8042 focus_handle: FocusHandle,
8043 path: ProjectPath,
8044 }
8045
8046 struct TestProjectItem {
8047 path: ProjectPath,
8048 }
8049
8050 impl project::ProjectItem for TestProjectItem {
8051 fn try_open(
8052 _project: &Model<Project>,
8053 path: &ProjectPath,
8054 cx: &mut AppContext,
8055 ) -> Option<Task<gpui::Result<Model<Self>>>> {
8056 let path = path.clone();
8057 Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
8058 }
8059
8060 fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
8061 None
8062 }
8063
8064 fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
8065 Some(self.path.clone())
8066 }
8067
8068 fn is_dirty(&self) -> bool {
8069 false
8070 }
8071 }
8072
8073 impl ProjectItem for TestProjectItemView {
8074 type Item = TestProjectItem;
8075
8076 fn for_project_item(
8077 _: Model<Project>,
8078 project_item: Model<Self::Item>,
8079 cx: &mut ViewContext<Self>,
8080 ) -> Self
8081 where
8082 Self: Sized,
8083 {
8084 Self {
8085 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
8086 focus_handle: cx.focus_handle(),
8087 }
8088 }
8089 }
8090
8091 impl Item for TestProjectItemView {
8092 type Event = ();
8093 }
8094
8095 impl EventEmitter<()> for TestProjectItemView {}
8096
8097 impl FocusableView for TestProjectItemView {
8098 fn focus_handle(&self, _: &AppContext) -> FocusHandle {
8099 self.focus_handle.clone()
8100 }
8101 }
8102
8103 impl Render for TestProjectItemView {
8104 fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
8105 Empty
8106 }
8107 }
8108}