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 {
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 .when(!is_marked && !is_active, |div| {
3228 div.hover(|style| style.bg(bg_hover_color))
3229 })
3230 .when(is_local, |div| {
3231 div.on_drag_move::<ExternalPaths>(cx.listener(
3232 move |this, event: &DragMoveEvent<ExternalPaths>, cx| {
3233 if event.bounds.contains(&event.event.position) {
3234 if this.last_external_paths_drag_over_entry == Some(entry_id) {
3235 return;
3236 }
3237 this.last_external_paths_drag_over_entry = Some(entry_id);
3238 this.marked_entries.clear();
3239
3240 let Some((worktree, path, entry)) = maybe!({
3241 let worktree = this
3242 .project
3243 .read(cx)
3244 .worktree_for_id(selection.worktree_id, cx)?;
3245 let worktree = worktree.read(cx);
3246 let abs_path = worktree.absolutize(&path).log_err()?;
3247 let path = if abs_path.is_dir() {
3248 path.as_ref()
3249 } else {
3250 path.parent()?
3251 };
3252 let entry = worktree.entry_for_path(path)?;
3253 Some((worktree, path, entry))
3254 }) else {
3255 return;
3256 };
3257
3258 this.marked_entries.insert(SelectedEntry {
3259 entry_id: entry.id,
3260 worktree_id: worktree.id(),
3261 });
3262
3263 for entry in worktree.child_entries(path) {
3264 this.marked_entries.insert(SelectedEntry {
3265 entry_id: entry.id,
3266 worktree_id: worktree.id(),
3267 });
3268 }
3269
3270 cx.notify();
3271 }
3272 },
3273 ))
3274 .on_drop(cx.listener(
3275 move |this, external_paths: &ExternalPaths, cx| {
3276 this.hover_scroll_task.take();
3277 this.last_external_paths_drag_over_entry = None;
3278 this.marked_entries.clear();
3279 this.drop_external_files(external_paths.paths(), entry_id, cx);
3280 cx.stop_propagation();
3281 },
3282 ))
3283 })
3284 .on_drag(dragged_selection, move |selection, click_offset, cx| {
3285 cx.new_view(|_| DraggedProjectEntryView {
3286 details: details.clone(),
3287 width,
3288 click_offset,
3289 selection: selection.active_selection,
3290 selections: selection.marked_selections.clone(),
3291 })
3292 })
3293 .drag_over::<DraggedSelection>(move |style, _, _| style.bg(item_colors.drag_over))
3294 .on_drop(cx.listener(move |this, selections: &DraggedSelection, cx| {
3295 this.hover_scroll_task.take();
3296 this.drag_onto(selections, entry_id, kind.is_file(), cx);
3297 }))
3298 .on_mouse_down(
3299 MouseButton::Left,
3300 cx.listener(move |this, _, cx| {
3301 this.mouse_down = true;
3302 cx.propagate();
3303 }),
3304 )
3305 .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
3306 if event.down.button == MouseButton::Right || event.down.first_mouse || show_editor
3307 {
3308 return;
3309 }
3310 if event.down.button == MouseButton::Left {
3311 this.mouse_down = false;
3312 }
3313 cx.stop_propagation();
3314
3315 if let Some(selection) = this.selection.filter(|_| event.down.modifiers.shift) {
3316 let current_selection = this.index_for_selection(selection);
3317 let clicked_entry = SelectedEntry {
3318 entry_id,
3319 worktree_id,
3320 };
3321 let target_selection = this.index_for_selection(clicked_entry);
3322 if let Some(((_, _, source_index), (_, _, target_index))) =
3323 current_selection.zip(target_selection)
3324 {
3325 let range_start = source_index.min(target_index);
3326 let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
3327 let mut new_selections = BTreeSet::new();
3328 this.for_each_visible_entry(
3329 range_start..range_end,
3330 cx,
3331 |entry_id, details, _| {
3332 new_selections.insert(SelectedEntry {
3333 entry_id,
3334 worktree_id: details.worktree_id,
3335 });
3336 },
3337 );
3338
3339 this.marked_entries = this
3340 .marked_entries
3341 .union(&new_selections)
3342 .cloned()
3343 .collect();
3344
3345 this.selection = Some(clicked_entry);
3346 this.marked_entries.insert(clicked_entry);
3347 }
3348 } else if event.down.modifiers.secondary() {
3349 if event.down.click_count > 1 {
3350 this.split_entry(entry_id, cx);
3351 } else if !this.marked_entries.insert(selection) {
3352 this.marked_entries.remove(&selection);
3353 }
3354 } else if kind.is_dir() {
3355 this.marked_entries.clear();
3356 this.toggle_expanded(entry_id, cx);
3357 } else {
3358 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
3359 let click_count = event.up.click_count;
3360 let focus_opened_item = !preview_tabs_enabled || click_count > 1;
3361 let allow_preview = preview_tabs_enabled && click_count == 1;
3362 this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
3363 }
3364 }))
3365 .child(
3366 ListItem::new(entry_id.to_proto() as usize)
3367 .indent_level(depth)
3368 .indent_step_size(px(settings.indent_size))
3369 .selectable(false)
3370 .when_some(canonical_path, |this, path| {
3371 this.end_slot::<AnyElement>(
3372 div()
3373 .id("symlink_icon")
3374 .pr_3()
3375 .tooltip(move |cx| {
3376 Tooltip::with_meta(path.to_string(), None, "Symbolic Link", cx)
3377 })
3378 .child(
3379 Icon::new(IconName::ArrowUpRight)
3380 .size(IconSize::Indicator)
3381 .color(filename_text_color),
3382 )
3383 .into_any_element(),
3384 )
3385 })
3386 .child(if let Some(icon) = &icon {
3387 // Check if there's a diagnostic severity and get the decoration color
3388 if let Some((_, decoration_color)) =
3389 entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
3390 {
3391 // Determine if the diagnostic is a warning
3392 let is_warning = diagnostic_severity
3393 .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
3394 .unwrap_or(false);
3395 div().child(
3396 DecoratedIcon::new(
3397 Icon::from_path(icon.clone()).color(Color::Muted),
3398 Some(
3399 IconDecoration::new(
3400 if kind.is_file() {
3401 if is_warning {
3402 IconDecorationKind::Triangle
3403 } else {
3404 IconDecorationKind::X
3405 }
3406 } else {
3407 IconDecorationKind::Dot
3408 },
3409 default_color,
3410 cx,
3411 )
3412 .group_name(Some(GROUP_NAME.into()))
3413 .knockout_hover_color(bg_hover_color)
3414 .color(decoration_color.color(cx))
3415 .position(Point {
3416 x: px(-2.),
3417 y: px(-2.),
3418 }),
3419 ),
3420 )
3421 .into_any_element(),
3422 )
3423 } else {
3424 h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
3425 }
3426 } else {
3427 if let Some((icon_name, color)) =
3428 entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
3429 {
3430 h_flex()
3431 .size(IconSize::default().rems())
3432 .child(Icon::new(icon_name).color(color).size(IconSize::Small))
3433 } else {
3434 h_flex()
3435 .size(IconSize::default().rems())
3436 .invisible()
3437 .flex_none()
3438 }
3439 })
3440 .child(
3441 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
3442 h_flex().h_6().w_full().child(editor.clone())
3443 } else {
3444 h_flex().h_6().map(|mut this| {
3445 if let Some(folded_ancestors) = self.ancestors.get(&entry_id) {
3446 let components = Path::new(&file_name)
3447 .components()
3448 .map(|comp| {
3449 let comp_str =
3450 comp.as_os_str().to_string_lossy().into_owned();
3451 comp_str
3452 })
3453 .collect::<Vec<_>>();
3454
3455 let components_len = components.len();
3456 let active_index = components_len
3457 - 1
3458 - folded_ancestors.current_ancestor_depth;
3459 const DELIMITER: SharedString =
3460 SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
3461 for (index, component) in components.into_iter().enumerate() {
3462 if index != 0 {
3463 this = this.child(
3464 Label::new(DELIMITER.clone())
3465 .single_line()
3466 .color(filename_text_color),
3467 );
3468 }
3469 let id = SharedString::from(format!(
3470 "project_panel_path_component_{}_{index}",
3471 entry_id.to_usize()
3472 ));
3473 let label = div()
3474 .id(id)
3475 .on_click(cx.listener(move |this, _, cx| {
3476 if index != active_index {
3477 if let Some(folds) =
3478 this.ancestors.get_mut(&entry_id)
3479 {
3480 folds.current_ancestor_depth =
3481 components_len - 1 - index;
3482 cx.notify();
3483 }
3484 }
3485 }))
3486 .child(
3487 Label::new(component)
3488 .single_line()
3489 .color(filename_text_color)
3490 .when(
3491 index == active_index
3492 && (is_active || is_marked),
3493 |this| this.underline(true),
3494 ),
3495 );
3496
3497 this = this.child(label);
3498 }
3499
3500 this
3501 } else {
3502 this.child(
3503 Label::new(file_name)
3504 .single_line()
3505 .color(filename_text_color),
3506 )
3507 }
3508 })
3509 }
3510 .ml_1(),
3511 )
3512 .on_secondary_mouse_down(cx.listener(
3513 move |this, event: &MouseDownEvent, cx| {
3514 // Stop propagation to prevent the catch-all context menu for the project
3515 // panel from being deployed.
3516 cx.stop_propagation();
3517 // Some context menu actions apply to all marked entries. If the user
3518 // right-clicks on an entry that is not marked, they may not realize the
3519 // action applies to multiple entries. To avoid inadvertent changes, all
3520 // entries are unmarked.
3521 if !this.marked_entries.contains(&selection) {
3522 this.marked_entries.clear();
3523 }
3524 this.deploy_context_menu(event.position, entry_id, cx);
3525 },
3526 ))
3527 .overflow_x(),
3528 )
3529 }
3530
3531 fn render_vertical_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
3532 if !Self::should_show_scrollbar(cx)
3533 || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
3534 {
3535 return None;
3536 }
3537 Some(
3538 div()
3539 .occlude()
3540 .id("project-panel-vertical-scroll")
3541 .on_mouse_move(cx.listener(|_, _, cx| {
3542 cx.notify();
3543 cx.stop_propagation()
3544 }))
3545 .on_hover(|_, cx| {
3546 cx.stop_propagation();
3547 })
3548 .on_any_mouse_down(|_, cx| {
3549 cx.stop_propagation();
3550 })
3551 .on_mouse_up(
3552 MouseButton::Left,
3553 cx.listener(|this, _, cx| {
3554 if !this.vertical_scrollbar_state.is_dragging()
3555 && !this.focus_handle.contains_focused(cx)
3556 {
3557 this.hide_scrollbar(cx);
3558 cx.notify();
3559 }
3560
3561 cx.stop_propagation();
3562 }),
3563 )
3564 .on_scroll_wheel(cx.listener(|_, _, cx| {
3565 cx.notify();
3566 }))
3567 .h_full()
3568 .absolute()
3569 .right_1()
3570 .top_1()
3571 .bottom_1()
3572 .w(px(12.))
3573 .cursor_default()
3574 .children(Scrollbar::vertical(
3575 // percentage as f32..end_offset as f32,
3576 self.vertical_scrollbar_state.clone(),
3577 )),
3578 )
3579 }
3580
3581 fn render_horizontal_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
3582 if !Self::should_show_scrollbar(cx)
3583 || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
3584 {
3585 return None;
3586 }
3587
3588 let scroll_handle = self.scroll_handle.0.borrow();
3589 let longest_item_width = scroll_handle
3590 .last_item_size
3591 .filter(|size| size.contents.width > size.item.width)?
3592 .contents
3593 .width
3594 .0 as f64;
3595 if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
3596 return None;
3597 }
3598
3599 Some(
3600 div()
3601 .occlude()
3602 .id("project-panel-horizontal-scroll")
3603 .on_mouse_move(cx.listener(|_, _, cx| {
3604 cx.notify();
3605 cx.stop_propagation()
3606 }))
3607 .on_hover(|_, cx| {
3608 cx.stop_propagation();
3609 })
3610 .on_any_mouse_down(|_, cx| {
3611 cx.stop_propagation();
3612 })
3613 .on_mouse_up(
3614 MouseButton::Left,
3615 cx.listener(|this, _, cx| {
3616 if !this.horizontal_scrollbar_state.is_dragging()
3617 && !this.focus_handle.contains_focused(cx)
3618 {
3619 this.hide_scrollbar(cx);
3620 cx.notify();
3621 }
3622
3623 cx.stop_propagation();
3624 }),
3625 )
3626 .on_scroll_wheel(cx.listener(|_, _, cx| {
3627 cx.notify();
3628 }))
3629 .w_full()
3630 .absolute()
3631 .right_1()
3632 .left_1()
3633 .bottom_1()
3634 .h(px(12.))
3635 .cursor_default()
3636 .when(self.width.is_some(), |this| {
3637 this.children(Scrollbar::horizontal(
3638 self.horizontal_scrollbar_state.clone(),
3639 ))
3640 }),
3641 )
3642 }
3643
3644 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
3645 let mut dispatch_context = KeyContext::new_with_defaults();
3646 dispatch_context.add("ProjectPanel");
3647 dispatch_context.add("menu");
3648
3649 let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
3650 "editing"
3651 } else {
3652 "not_editing"
3653 };
3654
3655 dispatch_context.add(identifier);
3656 dispatch_context
3657 }
3658
3659 fn should_show_scrollbar(cx: &AppContext) -> bool {
3660 let show = ProjectPanelSettings::get_global(cx)
3661 .scrollbar
3662 .show
3663 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
3664 match show {
3665 ShowScrollbar::Auto => true,
3666 ShowScrollbar::System => true,
3667 ShowScrollbar::Always => true,
3668 ShowScrollbar::Never => false,
3669 }
3670 }
3671
3672 fn should_autohide_scrollbar(cx: &AppContext) -> bool {
3673 let show = ProjectPanelSettings::get_global(cx)
3674 .scrollbar
3675 .show
3676 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
3677 match show {
3678 ShowScrollbar::Auto => true,
3679 ShowScrollbar::System => cx
3680 .try_global::<ScrollbarAutoHide>()
3681 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
3682 ShowScrollbar::Always => false,
3683 ShowScrollbar::Never => true,
3684 }
3685 }
3686
3687 fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
3688 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
3689 if !Self::should_autohide_scrollbar(cx) {
3690 return;
3691 }
3692 self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
3693 cx.background_executor()
3694 .timer(SCROLLBAR_SHOW_INTERVAL)
3695 .await;
3696 panel
3697 .update(&mut cx, |panel, cx| {
3698 panel.show_scrollbar = false;
3699 cx.notify();
3700 })
3701 .log_err();
3702 }))
3703 }
3704
3705 fn reveal_entry(
3706 &mut self,
3707 project: Model<Project>,
3708 entry_id: ProjectEntryId,
3709 skip_ignored: bool,
3710 cx: &mut ViewContext<'_, Self>,
3711 ) {
3712 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
3713 let worktree = worktree.read(cx);
3714 if skip_ignored
3715 && worktree
3716 .entry_for_id(entry_id)
3717 .map_or(true, |entry| entry.is_ignored)
3718 {
3719 return;
3720 }
3721
3722 let worktree_id = worktree.id();
3723 self.expand_entry(worktree_id, entry_id, cx);
3724 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
3725
3726 if self.marked_entries.len() == 1
3727 && self
3728 .marked_entries
3729 .first()
3730 .filter(|entry| entry.entry_id == entry_id)
3731 .is_none()
3732 {
3733 self.marked_entries.clear();
3734 }
3735 self.autoscroll(cx);
3736 cx.notify();
3737 }
3738 }
3739
3740 fn find_active_indent_guide(
3741 &self,
3742 indent_guides: &[IndentGuideLayout],
3743 cx: &AppContext,
3744 ) -> Option<usize> {
3745 let (worktree, entry) = self.selected_entry(cx)?;
3746
3747 // Find the parent entry of the indent guide, this will either be the
3748 // expanded folder we have selected, or the parent of the currently
3749 // selected file/collapsed directory
3750 let mut entry = entry;
3751 loop {
3752 let is_expanded_dir = entry.is_dir()
3753 && self
3754 .expanded_dir_ids
3755 .get(&worktree.id())
3756 .map(|ids| ids.binary_search(&entry.id).is_ok())
3757 .unwrap_or(false);
3758 if is_expanded_dir {
3759 break;
3760 }
3761 entry = worktree.entry_for_path(&entry.path.parent()?)?;
3762 }
3763
3764 let (active_indent_range, depth) = {
3765 let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
3766 let child_paths = &self.visible_entries[worktree_ix].1;
3767 let mut child_count = 0;
3768 let depth = entry.path.ancestors().count();
3769 while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
3770 if entry.path.ancestors().count() <= depth {
3771 break;
3772 }
3773 child_count += 1;
3774 }
3775
3776 let start = ix + 1;
3777 let end = start + child_count;
3778
3779 let (_, entries, paths) = &self.visible_entries[worktree_ix];
3780 let visible_worktree_entries =
3781 paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect());
3782
3783 // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
3784 let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
3785 (start..end, depth)
3786 };
3787
3788 let candidates = indent_guides
3789 .iter()
3790 .enumerate()
3791 .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
3792
3793 for (i, indent) in candidates {
3794 // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
3795 if active_indent_range.start <= indent.offset.y + indent.length
3796 && indent.offset.y <= active_indent_range.end
3797 {
3798 return Some(i);
3799 }
3800 }
3801 None
3802 }
3803}
3804
3805fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
3806 const ICON_SIZE_FACTOR: usize = 2;
3807 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
3808 if is_symlink {
3809 item_width += ICON_SIZE_FACTOR;
3810 }
3811 item_width
3812}
3813
3814impl Render for ProjectPanel {
3815 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
3816 let has_worktree = !self.visible_entries.is_empty();
3817 let project = self.project.read(cx);
3818 let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
3819 let show_indent_guides =
3820 ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
3821 let is_local = project.is_local();
3822
3823 if has_worktree {
3824 let item_count = self
3825 .visible_entries
3826 .iter()
3827 .map(|(_, worktree_entries, _)| worktree_entries.len())
3828 .sum();
3829
3830 fn handle_drag_move_scroll<T: 'static>(
3831 this: &mut ProjectPanel,
3832 e: &DragMoveEvent<T>,
3833 cx: &mut ViewContext<ProjectPanel>,
3834 ) {
3835 if !e.bounds.contains(&e.event.position) {
3836 return;
3837 }
3838 this.hover_scroll_task.take();
3839 let panel_height = e.bounds.size.height;
3840 if panel_height <= px(0.) {
3841 return;
3842 }
3843
3844 let event_offset = e.event.position.y - e.bounds.origin.y;
3845 // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
3846 let hovered_region_offset = event_offset / panel_height;
3847
3848 // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
3849 // These pixels offsets were picked arbitrarily.
3850 let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
3851 8.
3852 } else if hovered_region_offset <= 0.15 {
3853 5.
3854 } else if hovered_region_offset >= 0.95 {
3855 -8.
3856 } else if hovered_region_offset >= 0.85 {
3857 -5.
3858 } else {
3859 return;
3860 };
3861 let adjustment = point(px(0.), px(vertical_scroll_offset));
3862 this.hover_scroll_task = Some(cx.spawn(move |this, mut cx| async move {
3863 loop {
3864 let should_stop_scrolling = this
3865 .update(&mut cx, |this, cx| {
3866 this.hover_scroll_task.as_ref()?;
3867 let handle = this.scroll_handle.0.borrow_mut();
3868 let offset = handle.base_handle.offset();
3869
3870 handle.base_handle.set_offset(offset + adjustment);
3871 cx.notify();
3872 Some(())
3873 })
3874 .ok()
3875 .flatten()
3876 .is_some();
3877 if should_stop_scrolling {
3878 return;
3879 }
3880 cx.background_executor()
3881 .timer(Duration::from_millis(16))
3882 .await;
3883 }
3884 }));
3885 }
3886 h_flex()
3887 .id("project-panel")
3888 .group("project-panel")
3889 .on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
3890 .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
3891 .size_full()
3892 .relative()
3893 .on_hover(cx.listener(|this, hovered, cx| {
3894 if *hovered {
3895 this.show_scrollbar = true;
3896 this.hide_scrollbar_task.take();
3897 cx.notify();
3898 } else if !this.focus_handle.contains_focused(cx) {
3899 this.hide_scrollbar(cx);
3900 }
3901 }))
3902 .on_click(cx.listener(|this, _event, cx| {
3903 cx.stop_propagation();
3904 this.selection = None;
3905 this.marked_entries.clear();
3906 }))
3907 .key_context(self.dispatch_context(cx))
3908 .on_action(cx.listener(Self::select_next))
3909 .on_action(cx.listener(Self::select_prev))
3910 .on_action(cx.listener(Self::select_first))
3911 .on_action(cx.listener(Self::select_last))
3912 .on_action(cx.listener(Self::select_parent))
3913 .on_action(cx.listener(Self::select_next_git_entry))
3914 .on_action(cx.listener(Self::select_prev_git_entry))
3915 .on_action(cx.listener(Self::select_next_diagnostic))
3916 .on_action(cx.listener(Self::select_prev_diagnostic))
3917 .on_action(cx.listener(Self::select_next_directory))
3918 .on_action(cx.listener(Self::select_prev_directory))
3919 .on_action(cx.listener(Self::expand_selected_entry))
3920 .on_action(cx.listener(Self::collapse_selected_entry))
3921 .on_action(cx.listener(Self::collapse_all_entries))
3922 .on_action(cx.listener(Self::open))
3923 .on_action(cx.listener(Self::open_permanent))
3924 .on_action(cx.listener(Self::confirm))
3925 .on_action(cx.listener(Self::cancel))
3926 .on_action(cx.listener(Self::copy_path))
3927 .on_action(cx.listener(Self::copy_relative_path))
3928 .on_action(cx.listener(Self::new_search_in_directory))
3929 .on_action(cx.listener(Self::unfold_directory))
3930 .on_action(cx.listener(Self::fold_directory))
3931 .on_action(cx.listener(Self::remove_from_project))
3932 .when(!project.is_read_only(cx), |el| {
3933 el.on_action(cx.listener(Self::new_file))
3934 .on_action(cx.listener(Self::new_directory))
3935 .on_action(cx.listener(Self::rename))
3936 .on_action(cx.listener(Self::delete))
3937 .on_action(cx.listener(Self::trash))
3938 .on_action(cx.listener(Self::cut))
3939 .on_action(cx.listener(Self::copy))
3940 .on_action(cx.listener(Self::paste))
3941 .on_action(cx.listener(Self::duplicate))
3942 .on_click(cx.listener(|this, event: &gpui::ClickEvent, cx| {
3943 if event.up.click_count > 1 {
3944 if let Some(entry_id) = this.last_worktree_root_id {
3945 let project = this.project.read(cx);
3946
3947 let worktree_id = if let Some(worktree) =
3948 project.worktree_for_entry(entry_id, cx)
3949 {
3950 worktree.read(cx).id()
3951 } else {
3952 return;
3953 };
3954
3955 this.selection = Some(SelectedEntry {
3956 worktree_id,
3957 entry_id,
3958 });
3959
3960 this.new_file(&NewFile, cx);
3961 }
3962 }
3963 }))
3964 })
3965 .when(project.is_local(), |el| {
3966 el.on_action(cx.listener(Self::reveal_in_finder))
3967 .on_action(cx.listener(Self::open_system))
3968 .on_action(cx.listener(Self::open_in_terminal))
3969 })
3970 .when(project.is_via_ssh(), |el| {
3971 el.on_action(cx.listener(Self::open_in_terminal))
3972 })
3973 .on_mouse_down(
3974 MouseButton::Right,
3975 cx.listener(move |this, event: &MouseDownEvent, cx| {
3976 // When deploying the context menu anywhere below the last project entry,
3977 // act as if the user clicked the root of the last worktree.
3978 if let Some(entry_id) = this.last_worktree_root_id {
3979 this.deploy_context_menu(event.position, entry_id, cx);
3980 }
3981 }),
3982 )
3983 .track_focus(&self.focus_handle(cx))
3984 .child(
3985 uniform_list(cx.view().clone(), "entries", item_count, {
3986 |this, range, cx| {
3987 let mut items = Vec::with_capacity(range.end - range.start);
3988 this.for_each_visible_entry(range, cx, |id, details, cx| {
3989 items.push(this.render_entry(id, details, cx));
3990 });
3991 items
3992 }
3993 })
3994 .when(show_indent_guides, |list| {
3995 list.with_decoration(
3996 ui::indent_guides(
3997 cx.view().clone(),
3998 px(indent_size),
3999 IndentGuideColors::panel(cx),
4000 |this, range, cx| {
4001 let mut items =
4002 SmallVec::with_capacity(range.end - range.start);
4003 this.iter_visible_entries(range, cx, |entry, entries, _| {
4004 let (depth, _) =
4005 Self::calculate_depth_and_difference(entry, entries);
4006 items.push(depth);
4007 });
4008 items
4009 },
4010 )
4011 .on_click(cx.listener(
4012 |this, active_indent_guide: &IndentGuideLayout, cx| {
4013 if cx.modifiers().secondary() {
4014 let ix = active_indent_guide.offset.y;
4015 let Some((target_entry, worktree)) = maybe!({
4016 let (worktree_id, entry) = this.entry_at_index(ix)?;
4017 let worktree = this
4018 .project
4019 .read(cx)
4020 .worktree_for_id(worktree_id, cx)?;
4021 let target_entry = worktree
4022 .read(cx)
4023 .entry_for_path(&entry.path.parent()?)?;
4024 Some((target_entry, worktree))
4025 }) else {
4026 return;
4027 };
4028
4029 this.collapse_entry(target_entry.clone(), worktree, cx);
4030 }
4031 },
4032 ))
4033 .with_render_fn(
4034 cx.view().clone(),
4035 move |this, params, cx| {
4036 const LEFT_OFFSET: f32 = 14.;
4037 const PADDING_Y: f32 = 4.;
4038 const HITBOX_OVERDRAW: f32 = 3.;
4039
4040 let active_indent_guide_index =
4041 this.find_active_indent_guide(¶ms.indent_guides, cx);
4042
4043 let indent_size = params.indent_size;
4044 let item_height = params.item_height;
4045
4046 params
4047 .indent_guides
4048 .into_iter()
4049 .enumerate()
4050 .map(|(idx, layout)| {
4051 let offset = if layout.continues_offscreen {
4052 px(0.)
4053 } else {
4054 px(PADDING_Y)
4055 };
4056 let bounds = Bounds::new(
4057 point(
4058 px(layout.offset.x as f32) * indent_size
4059 + px(LEFT_OFFSET),
4060 px(layout.offset.y as f32) * item_height
4061 + offset,
4062 ),
4063 size(
4064 px(1.),
4065 px(layout.length as f32) * item_height
4066 - px(offset.0 * 2.),
4067 ),
4068 );
4069 ui::RenderedIndentGuide {
4070 bounds,
4071 layout,
4072 is_active: Some(idx) == active_indent_guide_index,
4073 hitbox: Some(Bounds::new(
4074 point(
4075 bounds.origin.x - px(HITBOX_OVERDRAW),
4076 bounds.origin.y,
4077 ),
4078 size(
4079 bounds.size.width
4080 + px(2. * HITBOX_OVERDRAW),
4081 bounds.size.height,
4082 ),
4083 )),
4084 }
4085 })
4086 .collect()
4087 },
4088 ),
4089 )
4090 })
4091 .size_full()
4092 .with_sizing_behavior(ListSizingBehavior::Infer)
4093 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4094 .with_width_from_item(self.max_width_item_index)
4095 .track_scroll(self.scroll_handle.clone()),
4096 )
4097 .children(self.render_vertical_scrollbar(cx))
4098 .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
4099 this.pb_4().child(scrollbar)
4100 })
4101 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4102 deferred(
4103 anchored()
4104 .position(*position)
4105 .anchor(gpui::Corner::TopLeft)
4106 .child(menu.clone()),
4107 )
4108 .with_priority(1)
4109 }))
4110 } else {
4111 v_flex()
4112 .id("empty-project_panel")
4113 .size_full()
4114 .p_4()
4115 .track_focus(&self.focus_handle(cx))
4116 .child(
4117 Button::new("open_project", "Open a project")
4118 .full_width()
4119 .key_binding(KeyBinding::for_action(&workspace::Open, cx))
4120 .on_click(cx.listener(|this, _, cx| {
4121 this.workspace
4122 .update(cx, |_, cx| cx.dispatch_action(Box::new(workspace::Open)))
4123 .log_err();
4124 })),
4125 )
4126 .when(is_local, |div| {
4127 div.drag_over::<ExternalPaths>(|style, _, cx| {
4128 style.bg(cx.theme().colors().drop_target_background)
4129 })
4130 .on_drop(cx.listener(
4131 move |this, external_paths: &ExternalPaths, cx| {
4132 this.last_external_paths_drag_over_entry = None;
4133 this.marked_entries.clear();
4134 this.hover_scroll_task.take();
4135 if let Some(task) = this
4136 .workspace
4137 .update(cx, |workspace, cx| {
4138 workspace.open_workspace_for_paths(
4139 true,
4140 external_paths.paths().to_owned(),
4141 cx,
4142 )
4143 })
4144 .log_err()
4145 {
4146 task.detach_and_log_err(cx);
4147 }
4148 cx.stop_propagation();
4149 },
4150 ))
4151 })
4152 }
4153 }
4154}
4155
4156impl Render for DraggedProjectEntryView {
4157 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
4158 let settings = ProjectPanelSettings::get_global(cx);
4159 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
4160
4161 h_flex().font(ui_font).map(|this| {
4162 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
4163 this.flex_none()
4164 .w(self.width)
4165 .child(div().w(self.click_offset.x))
4166 .child(
4167 div()
4168 .p_1()
4169 .rounded_xl()
4170 .bg(cx.theme().colors().background)
4171 .child(Label::new(format!("{} entries", self.selections.len()))),
4172 )
4173 } else {
4174 this.w(self.width).bg(cx.theme().colors().background).child(
4175 ListItem::new(self.selection.entry_id.to_proto() as usize)
4176 .indent_level(self.details.depth)
4177 .indent_step_size(px(settings.indent_size))
4178 .child(if let Some(icon) = &self.details.icon {
4179 div().child(Icon::from_path(icon.clone()))
4180 } else {
4181 div()
4182 })
4183 .child(Label::new(self.details.filename.clone())),
4184 )
4185 }
4186 })
4187 }
4188}
4189
4190impl EventEmitter<Event> for ProjectPanel {}
4191
4192impl EventEmitter<PanelEvent> for ProjectPanel {}
4193
4194impl Panel for ProjectPanel {
4195 fn position(&self, cx: &WindowContext) -> DockPosition {
4196 match ProjectPanelSettings::get_global(cx).dock {
4197 ProjectPanelDockPosition::Left => DockPosition::Left,
4198 ProjectPanelDockPosition::Right => DockPosition::Right,
4199 }
4200 }
4201
4202 fn position_is_valid(&self, position: DockPosition) -> bool {
4203 matches!(position, DockPosition::Left | DockPosition::Right)
4204 }
4205
4206 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
4207 settings::update_settings_file::<ProjectPanelSettings>(
4208 self.fs.clone(),
4209 cx,
4210 move |settings, _| {
4211 let dock = match position {
4212 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
4213 DockPosition::Right => ProjectPanelDockPosition::Right,
4214 };
4215 settings.dock = Some(dock);
4216 },
4217 );
4218 }
4219
4220 fn size(&self, cx: &WindowContext) -> Pixels {
4221 self.width
4222 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
4223 }
4224
4225 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
4226 self.width = size;
4227 self.serialize(cx);
4228 cx.notify();
4229 }
4230
4231 fn icon(&self, cx: &WindowContext) -> Option<IconName> {
4232 ProjectPanelSettings::get_global(cx)
4233 .button
4234 .then_some(IconName::FileTree)
4235 }
4236
4237 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
4238 Some("Project Panel")
4239 }
4240
4241 fn toggle_action(&self) -> Box<dyn Action> {
4242 Box::new(ToggleFocus)
4243 }
4244
4245 fn persistent_name() -> &'static str {
4246 "Project Panel"
4247 }
4248
4249 fn starts_open(&self, cx: &WindowContext) -> bool {
4250 let project = &self.project.read(cx);
4251 project.visible_worktrees(cx).any(|tree| {
4252 tree.read(cx)
4253 .root_entry()
4254 .map_or(false, |entry| entry.is_dir())
4255 })
4256 }
4257}
4258
4259impl FocusableView for ProjectPanel {
4260 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
4261 self.focus_handle.clone()
4262 }
4263}
4264
4265impl ClipboardEntry {
4266 fn is_cut(&self) -> bool {
4267 matches!(self, Self::Cut { .. })
4268 }
4269
4270 fn items(&self) -> &BTreeSet<SelectedEntry> {
4271 match self {
4272 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
4273 }
4274 }
4275}
4276
4277#[cfg(test)]
4278mod tests {
4279 use super::*;
4280 use collections::HashSet;
4281 use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
4282 use pretty_assertions::assert_eq;
4283 use project::{FakeFs, WorktreeSettings};
4284 use serde_json::json;
4285 use settings::SettingsStore;
4286 use std::path::{Path, PathBuf};
4287 use workspace::{
4288 item::{Item, ProjectItem},
4289 register_project_item, AppState,
4290 };
4291
4292 #[gpui::test]
4293 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
4294 init_test(cx);
4295
4296 let fs = FakeFs::new(cx.executor().clone());
4297 fs.insert_tree(
4298 "/root1",
4299 json!({
4300 ".dockerignore": "",
4301 ".git": {
4302 "HEAD": "",
4303 },
4304 "a": {
4305 "0": { "q": "", "r": "", "s": "" },
4306 "1": { "t": "", "u": "" },
4307 "2": { "v": "", "w": "", "x": "", "y": "" },
4308 },
4309 "b": {
4310 "3": { "Q": "" },
4311 "4": { "R": "", "S": "", "T": "", "U": "" },
4312 },
4313 "C": {
4314 "5": {},
4315 "6": { "V": "", "W": "" },
4316 "7": { "X": "" },
4317 "8": { "Y": {}, "Z": "" }
4318 }
4319 }),
4320 )
4321 .await;
4322 fs.insert_tree(
4323 "/root2",
4324 json!({
4325 "d": {
4326 "9": ""
4327 },
4328 "e": {}
4329 }),
4330 )
4331 .await;
4332
4333 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4334 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4335 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4336 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4337 assert_eq!(
4338 visible_entries_as_strings(&panel, 0..50, cx),
4339 &[
4340 "v root1",
4341 " > .git",
4342 " > a",
4343 " > b",
4344 " > C",
4345 " .dockerignore",
4346 "v root2",
4347 " > d",
4348 " > e",
4349 ]
4350 );
4351
4352 toggle_expand_dir(&panel, "root1/b", cx);
4353 assert_eq!(
4354 visible_entries_as_strings(&panel, 0..50, cx),
4355 &[
4356 "v root1",
4357 " > .git",
4358 " > a",
4359 " v b <== selected",
4360 " > 3",
4361 " > 4",
4362 " > C",
4363 " .dockerignore",
4364 "v root2",
4365 " > d",
4366 " > e",
4367 ]
4368 );
4369
4370 assert_eq!(
4371 visible_entries_as_strings(&panel, 6..9, cx),
4372 &[
4373 //
4374 " > C",
4375 " .dockerignore",
4376 "v root2",
4377 ]
4378 );
4379 }
4380
4381 #[gpui::test]
4382 async fn test_opening_file(cx: &mut gpui::TestAppContext) {
4383 init_test_with_editor(cx);
4384
4385 let fs = FakeFs::new(cx.executor().clone());
4386 fs.insert_tree(
4387 "/src",
4388 json!({
4389 "test": {
4390 "first.rs": "// First Rust file",
4391 "second.rs": "// Second Rust file",
4392 "third.rs": "// Third Rust file",
4393 }
4394 }),
4395 )
4396 .await;
4397
4398 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4399 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4400 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4401 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4402
4403 toggle_expand_dir(&panel, "src/test", cx);
4404 select_path(&panel, "src/test/first.rs", cx);
4405 panel.update(cx, |panel, cx| panel.open(&Open, cx));
4406 cx.executor().run_until_parked();
4407 assert_eq!(
4408 visible_entries_as_strings(&panel, 0..10, cx),
4409 &[
4410 "v src",
4411 " v test",
4412 " first.rs <== selected <== marked",
4413 " second.rs",
4414 " third.rs"
4415 ]
4416 );
4417 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
4418
4419 select_path(&panel, "src/test/second.rs", cx);
4420 panel.update(cx, |panel, cx| panel.open(&Open, cx));
4421 cx.executor().run_until_parked();
4422 assert_eq!(
4423 visible_entries_as_strings(&panel, 0..10, cx),
4424 &[
4425 "v src",
4426 " v test",
4427 " first.rs",
4428 " second.rs <== selected <== marked",
4429 " third.rs"
4430 ]
4431 );
4432 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
4433 }
4434
4435 #[gpui::test]
4436 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
4437 init_test(cx);
4438 cx.update(|cx| {
4439 cx.update_global::<SettingsStore, _>(|store, cx| {
4440 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4441 worktree_settings.file_scan_exclusions =
4442 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
4443 });
4444 });
4445 });
4446
4447 let fs = FakeFs::new(cx.background_executor.clone());
4448 fs.insert_tree(
4449 "/root1",
4450 json!({
4451 ".dockerignore": "",
4452 ".git": {
4453 "HEAD": "",
4454 },
4455 "a": {
4456 "0": { "q": "", "r": "", "s": "" },
4457 "1": { "t": "", "u": "" },
4458 "2": { "v": "", "w": "", "x": "", "y": "" },
4459 },
4460 "b": {
4461 "3": { "Q": "" },
4462 "4": { "R": "", "S": "", "T": "", "U": "" },
4463 },
4464 "C": {
4465 "5": {},
4466 "6": { "V": "", "W": "" },
4467 "7": { "X": "" },
4468 "8": { "Y": {}, "Z": "" }
4469 }
4470 }),
4471 )
4472 .await;
4473 fs.insert_tree(
4474 "/root2",
4475 json!({
4476 "d": {
4477 "4": ""
4478 },
4479 "e": {}
4480 }),
4481 )
4482 .await;
4483
4484 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4485 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4486 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4487 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4488 assert_eq!(
4489 visible_entries_as_strings(&panel, 0..50, cx),
4490 &[
4491 "v root1",
4492 " > a",
4493 " > b",
4494 " > C",
4495 " .dockerignore",
4496 "v root2",
4497 " > d",
4498 " > e",
4499 ]
4500 );
4501
4502 toggle_expand_dir(&panel, "root1/b", cx);
4503 assert_eq!(
4504 visible_entries_as_strings(&panel, 0..50, cx),
4505 &[
4506 "v root1",
4507 " > a",
4508 " v b <== selected",
4509 " > 3",
4510 " > C",
4511 " .dockerignore",
4512 "v root2",
4513 " > d",
4514 " > e",
4515 ]
4516 );
4517
4518 toggle_expand_dir(&panel, "root2/d", cx);
4519 assert_eq!(
4520 visible_entries_as_strings(&panel, 0..50, cx),
4521 &[
4522 "v root1",
4523 " > a",
4524 " v b",
4525 " > 3",
4526 " > C",
4527 " .dockerignore",
4528 "v root2",
4529 " v d <== selected",
4530 " > e",
4531 ]
4532 );
4533
4534 toggle_expand_dir(&panel, "root2/e", cx);
4535 assert_eq!(
4536 visible_entries_as_strings(&panel, 0..50, cx),
4537 &[
4538 "v root1",
4539 " > a",
4540 " v b",
4541 " > 3",
4542 " > C",
4543 " .dockerignore",
4544 "v root2",
4545 " v d",
4546 " v e <== selected",
4547 ]
4548 );
4549 }
4550
4551 #[gpui::test]
4552 async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
4553 init_test(cx);
4554
4555 let fs = FakeFs::new(cx.executor().clone());
4556 fs.insert_tree(
4557 "/root1",
4558 json!({
4559 "dir_1": {
4560 "nested_dir_1": {
4561 "nested_dir_2": {
4562 "nested_dir_3": {
4563 "file_a.java": "// File contents",
4564 "file_b.java": "// File contents",
4565 "file_c.java": "// File contents",
4566 "nested_dir_4": {
4567 "nested_dir_5": {
4568 "file_d.java": "// File contents",
4569 }
4570 }
4571 }
4572 }
4573 }
4574 }
4575 }),
4576 )
4577 .await;
4578 fs.insert_tree(
4579 "/root2",
4580 json!({
4581 "dir_2": {
4582 "file_1.java": "// File contents",
4583 }
4584 }),
4585 )
4586 .await;
4587
4588 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4589 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4590 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4591 cx.update(|cx| {
4592 let settings = *ProjectPanelSettings::get_global(cx);
4593 ProjectPanelSettings::override_global(
4594 ProjectPanelSettings {
4595 auto_fold_dirs: true,
4596 ..settings
4597 },
4598 cx,
4599 );
4600 });
4601 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4602 assert_eq!(
4603 visible_entries_as_strings(&panel, 0..10, cx),
4604 &[
4605 "v root1",
4606 " > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4607 "v root2",
4608 " > dir_2",
4609 ]
4610 );
4611
4612 toggle_expand_dir(
4613 &panel,
4614 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4615 cx,
4616 );
4617 assert_eq!(
4618 visible_entries_as_strings(&panel, 0..10, cx),
4619 &[
4620 "v root1",
4621 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
4622 " > nested_dir_4/nested_dir_5",
4623 " file_a.java",
4624 " file_b.java",
4625 " file_c.java",
4626 "v root2",
4627 " > dir_2",
4628 ]
4629 );
4630
4631 toggle_expand_dir(
4632 &panel,
4633 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
4634 cx,
4635 );
4636 assert_eq!(
4637 visible_entries_as_strings(&panel, 0..10, cx),
4638 &[
4639 "v root1",
4640 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4641 " v nested_dir_4/nested_dir_5 <== selected",
4642 " file_d.java",
4643 " file_a.java",
4644 " file_b.java",
4645 " file_c.java",
4646 "v root2",
4647 " > dir_2",
4648 ]
4649 );
4650 toggle_expand_dir(&panel, "root2/dir_2", cx);
4651 assert_eq!(
4652 visible_entries_as_strings(&panel, 0..10, cx),
4653 &[
4654 "v root1",
4655 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4656 " v nested_dir_4/nested_dir_5",
4657 " file_d.java",
4658 " file_a.java",
4659 " file_b.java",
4660 " file_c.java",
4661 "v root2",
4662 " v dir_2 <== selected",
4663 " file_1.java",
4664 ]
4665 );
4666 }
4667
4668 #[gpui::test(iterations = 30)]
4669 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
4670 init_test(cx);
4671
4672 let fs = FakeFs::new(cx.executor().clone());
4673 fs.insert_tree(
4674 "/root1",
4675 json!({
4676 ".dockerignore": "",
4677 ".git": {
4678 "HEAD": "",
4679 },
4680 "a": {
4681 "0": { "q": "", "r": "", "s": "" },
4682 "1": { "t": "", "u": "" },
4683 "2": { "v": "", "w": "", "x": "", "y": "" },
4684 },
4685 "b": {
4686 "3": { "Q": "" },
4687 "4": { "R": "", "S": "", "T": "", "U": "" },
4688 },
4689 "C": {
4690 "5": {},
4691 "6": { "V": "", "W": "" },
4692 "7": { "X": "" },
4693 "8": { "Y": {}, "Z": "" }
4694 }
4695 }),
4696 )
4697 .await;
4698 fs.insert_tree(
4699 "/root2",
4700 json!({
4701 "d": {
4702 "9": ""
4703 },
4704 "e": {}
4705 }),
4706 )
4707 .await;
4708
4709 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4710 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4711 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4712 let panel = workspace
4713 .update(cx, |workspace, cx| {
4714 let panel = ProjectPanel::new(workspace, cx);
4715 workspace.add_panel(panel.clone(), cx);
4716 panel
4717 })
4718 .unwrap();
4719
4720 select_path(&panel, "root1", cx);
4721 assert_eq!(
4722 visible_entries_as_strings(&panel, 0..10, cx),
4723 &[
4724 "v root1 <== selected",
4725 " > .git",
4726 " > a",
4727 " > b",
4728 " > C",
4729 " .dockerignore",
4730 "v root2",
4731 " > d",
4732 " > e",
4733 ]
4734 );
4735
4736 // Add a file with the root folder selected. The filename editor is placed
4737 // before the first file in the root folder.
4738 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4739 panel.update(cx, |panel, cx| {
4740 assert!(panel.filename_editor.read(cx).is_focused(cx));
4741 });
4742 assert_eq!(
4743 visible_entries_as_strings(&panel, 0..10, cx),
4744 &[
4745 "v root1",
4746 " > .git",
4747 " > a",
4748 " > b",
4749 " > C",
4750 " [EDITOR: ''] <== selected",
4751 " .dockerignore",
4752 "v root2",
4753 " > d",
4754 " > e",
4755 ]
4756 );
4757
4758 let confirm = panel.update(cx, |panel, cx| {
4759 panel
4760 .filename_editor
4761 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
4762 panel.confirm_edit(cx).unwrap()
4763 });
4764 assert_eq!(
4765 visible_entries_as_strings(&panel, 0..10, cx),
4766 &[
4767 "v root1",
4768 " > .git",
4769 " > a",
4770 " > b",
4771 " > C",
4772 " [PROCESSING: 'the-new-filename'] <== selected",
4773 " .dockerignore",
4774 "v root2",
4775 " > d",
4776 " > e",
4777 ]
4778 );
4779
4780 confirm.await.unwrap();
4781 assert_eq!(
4782 visible_entries_as_strings(&panel, 0..10, cx),
4783 &[
4784 "v root1",
4785 " > .git",
4786 " > a",
4787 " > b",
4788 " > C",
4789 " .dockerignore",
4790 " the-new-filename <== selected <== marked",
4791 "v root2",
4792 " > d",
4793 " > e",
4794 ]
4795 );
4796
4797 select_path(&panel, "root1/b", cx);
4798 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4799 assert_eq!(
4800 visible_entries_as_strings(&panel, 0..10, cx),
4801 &[
4802 "v root1",
4803 " > .git",
4804 " > a",
4805 " v b",
4806 " > 3",
4807 " > 4",
4808 " [EDITOR: ''] <== selected",
4809 " > C",
4810 " .dockerignore",
4811 " the-new-filename",
4812 ]
4813 );
4814
4815 panel
4816 .update(cx, |panel, cx| {
4817 panel
4818 .filename_editor
4819 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
4820 panel.confirm_edit(cx).unwrap()
4821 })
4822 .await
4823 .unwrap();
4824 assert_eq!(
4825 visible_entries_as_strings(&panel, 0..10, cx),
4826 &[
4827 "v root1",
4828 " > .git",
4829 " > a",
4830 " v b",
4831 " > 3",
4832 " > 4",
4833 " another-filename.txt <== selected <== marked",
4834 " > C",
4835 " .dockerignore",
4836 " the-new-filename",
4837 ]
4838 );
4839
4840 select_path(&panel, "root1/b/another-filename.txt", cx);
4841 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4842 assert_eq!(
4843 visible_entries_as_strings(&panel, 0..10, cx),
4844 &[
4845 "v root1",
4846 " > .git",
4847 " > a",
4848 " v b",
4849 " > 3",
4850 " > 4",
4851 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
4852 " > C",
4853 " .dockerignore",
4854 " the-new-filename",
4855 ]
4856 );
4857
4858 let confirm = panel.update(cx, |panel, cx| {
4859 panel.filename_editor.update(cx, |editor, cx| {
4860 let file_name_selections = editor.selections.all::<usize>(cx);
4861 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
4862 let file_name_selection = &file_name_selections[0];
4863 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
4864 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
4865
4866 editor.set_text("a-different-filename.tar.gz", cx)
4867 });
4868 panel.confirm_edit(cx).unwrap()
4869 });
4870 assert_eq!(
4871 visible_entries_as_strings(&panel, 0..10, cx),
4872 &[
4873 "v root1",
4874 " > .git",
4875 " > a",
4876 " v b",
4877 " > 3",
4878 " > 4",
4879 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
4880 " > C",
4881 " .dockerignore",
4882 " the-new-filename",
4883 ]
4884 );
4885
4886 confirm.await.unwrap();
4887 assert_eq!(
4888 visible_entries_as_strings(&panel, 0..10, cx),
4889 &[
4890 "v root1",
4891 " > .git",
4892 " > a",
4893 " v b",
4894 " > 3",
4895 " > 4",
4896 " a-different-filename.tar.gz <== selected",
4897 " > C",
4898 " .dockerignore",
4899 " the-new-filename",
4900 ]
4901 );
4902
4903 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4904 assert_eq!(
4905 visible_entries_as_strings(&panel, 0..10, cx),
4906 &[
4907 "v root1",
4908 " > .git",
4909 " > a",
4910 " v b",
4911 " > 3",
4912 " > 4",
4913 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
4914 " > C",
4915 " .dockerignore",
4916 " the-new-filename",
4917 ]
4918 );
4919
4920 panel.update(cx, |panel, cx| {
4921 panel.filename_editor.update(cx, |editor, cx| {
4922 let file_name_selections = editor.selections.all::<usize>(cx);
4923 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
4924 let file_name_selection = &file_name_selections[0];
4925 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
4926 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..");
4927
4928 });
4929 panel.cancel(&menu::Cancel, cx)
4930 });
4931
4932 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4933 assert_eq!(
4934 visible_entries_as_strings(&panel, 0..10, cx),
4935 &[
4936 "v root1",
4937 " > .git",
4938 " > a",
4939 " v b",
4940 " > 3",
4941 " > 4",
4942 " > [EDITOR: ''] <== selected",
4943 " a-different-filename.tar.gz",
4944 " > C",
4945 " .dockerignore",
4946 ]
4947 );
4948
4949 let confirm = panel.update(cx, |panel, cx| {
4950 panel
4951 .filename_editor
4952 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
4953 panel.confirm_edit(cx).unwrap()
4954 });
4955 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
4956 assert_eq!(
4957 visible_entries_as_strings(&panel, 0..10, cx),
4958 &[
4959 "v root1",
4960 " > .git",
4961 " > a",
4962 " v b",
4963 " > 3",
4964 " > 4",
4965 " > [PROCESSING: 'new-dir']",
4966 " a-different-filename.tar.gz <== selected",
4967 " > C",
4968 " .dockerignore",
4969 ]
4970 );
4971
4972 confirm.await.unwrap();
4973 assert_eq!(
4974 visible_entries_as_strings(&panel, 0..10, cx),
4975 &[
4976 "v root1",
4977 " > .git",
4978 " > a",
4979 " v b",
4980 " > 3",
4981 " > 4",
4982 " > new-dir",
4983 " a-different-filename.tar.gz <== selected",
4984 " > C",
4985 " .dockerignore",
4986 ]
4987 );
4988
4989 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
4990 assert_eq!(
4991 visible_entries_as_strings(&panel, 0..10, cx),
4992 &[
4993 "v root1",
4994 " > .git",
4995 " > a",
4996 " v b",
4997 " > 3",
4998 " > 4",
4999 " > new-dir",
5000 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
5001 " > C",
5002 " .dockerignore",
5003 ]
5004 );
5005
5006 // Dismiss the rename editor when it loses focus.
5007 workspace.update(cx, |_, cx| cx.blur()).unwrap();
5008 assert_eq!(
5009 visible_entries_as_strings(&panel, 0..10, cx),
5010 &[
5011 "v root1",
5012 " > .git",
5013 " > a",
5014 " v b",
5015 " > 3",
5016 " > 4",
5017 " > new-dir",
5018 " a-different-filename.tar.gz <== selected",
5019 " > C",
5020 " .dockerignore",
5021 ]
5022 );
5023 }
5024
5025 #[gpui::test(iterations = 10)]
5026 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
5027 init_test(cx);
5028
5029 let fs = FakeFs::new(cx.executor().clone());
5030 fs.insert_tree(
5031 "/root1",
5032 json!({
5033 ".dockerignore": "",
5034 ".git": {
5035 "HEAD": "",
5036 },
5037 "a": {
5038 "0": { "q": "", "r": "", "s": "" },
5039 "1": { "t": "", "u": "" },
5040 "2": { "v": "", "w": "", "x": "", "y": "" },
5041 },
5042 "b": {
5043 "3": { "Q": "" },
5044 "4": { "R": "", "S": "", "T": "", "U": "" },
5045 },
5046 "C": {
5047 "5": {},
5048 "6": { "V": "", "W": "" },
5049 "7": { "X": "" },
5050 "8": { "Y": {}, "Z": "" }
5051 }
5052 }),
5053 )
5054 .await;
5055 fs.insert_tree(
5056 "/root2",
5057 json!({
5058 "d": {
5059 "9": ""
5060 },
5061 "e": {}
5062 }),
5063 )
5064 .await;
5065
5066 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5067 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5068 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5069 let panel = workspace
5070 .update(cx, |workspace, cx| {
5071 let panel = ProjectPanel::new(workspace, cx);
5072 workspace.add_panel(panel.clone(), cx);
5073 panel
5074 })
5075 .unwrap();
5076
5077 select_path(&panel, "root1", cx);
5078 assert_eq!(
5079 visible_entries_as_strings(&panel, 0..10, cx),
5080 &[
5081 "v root1 <== selected",
5082 " > .git",
5083 " > a",
5084 " > b",
5085 " > C",
5086 " .dockerignore",
5087 "v root2",
5088 " > d",
5089 " > e",
5090 ]
5091 );
5092
5093 // Add a file with the root folder selected. The filename editor is placed
5094 // before the first file in the root folder.
5095 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5096 panel.update(cx, |panel, cx| {
5097 assert!(panel.filename_editor.read(cx).is_focused(cx));
5098 });
5099 assert_eq!(
5100 visible_entries_as_strings(&panel, 0..10, cx),
5101 &[
5102 "v root1",
5103 " > .git",
5104 " > a",
5105 " > b",
5106 " > C",
5107 " [EDITOR: ''] <== selected",
5108 " .dockerignore",
5109 "v root2",
5110 " > d",
5111 " > e",
5112 ]
5113 );
5114
5115 let confirm = panel.update(cx, |panel, cx| {
5116 panel.filename_editor.update(cx, |editor, cx| {
5117 editor.set_text("/bdir1/dir2/the-new-filename", cx)
5118 });
5119 panel.confirm_edit(cx).unwrap()
5120 });
5121
5122 assert_eq!(
5123 visible_entries_as_strings(&panel, 0..10, cx),
5124 &[
5125 "v root1",
5126 " > .git",
5127 " > a",
5128 " > b",
5129 " > C",
5130 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
5131 " .dockerignore",
5132 "v root2",
5133 " > d",
5134 " > e",
5135 ]
5136 );
5137
5138 confirm.await.unwrap();
5139 assert_eq!(
5140 visible_entries_as_strings(&panel, 0..13, cx),
5141 &[
5142 "v root1",
5143 " > .git",
5144 " > a",
5145 " > b",
5146 " v bdir1",
5147 " v dir2",
5148 " the-new-filename <== selected <== marked",
5149 " > C",
5150 " .dockerignore",
5151 "v root2",
5152 " > d",
5153 " > e",
5154 ]
5155 );
5156 }
5157
5158 #[gpui::test]
5159 async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
5160 init_test(cx);
5161
5162 let fs = FakeFs::new(cx.executor().clone());
5163 fs.insert_tree(
5164 "/root1",
5165 json!({
5166 ".dockerignore": "",
5167 ".git": {
5168 "HEAD": "",
5169 },
5170 }),
5171 )
5172 .await;
5173
5174 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5175 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5176 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5177 let panel = workspace
5178 .update(cx, |workspace, cx| {
5179 let panel = ProjectPanel::new(workspace, cx);
5180 workspace.add_panel(panel.clone(), cx);
5181 panel
5182 })
5183 .unwrap();
5184
5185 select_path(&panel, "root1", cx);
5186 assert_eq!(
5187 visible_entries_as_strings(&panel, 0..10, cx),
5188 &["v root1 <== selected", " > .git", " .dockerignore",]
5189 );
5190
5191 // Add a file with the root folder selected. The filename editor is placed
5192 // before the first file in the root folder.
5193 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5194 panel.update(cx, |panel, cx| {
5195 assert!(panel.filename_editor.read(cx).is_focused(cx));
5196 });
5197 assert_eq!(
5198 visible_entries_as_strings(&panel, 0..10, cx),
5199 &[
5200 "v root1",
5201 " > .git",
5202 " [EDITOR: ''] <== selected",
5203 " .dockerignore",
5204 ]
5205 );
5206
5207 let confirm = panel.update(cx, |panel, cx| {
5208 panel
5209 .filename_editor
5210 .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
5211 panel.confirm_edit(cx).unwrap()
5212 });
5213
5214 assert_eq!(
5215 visible_entries_as_strings(&panel, 0..10, cx),
5216 &[
5217 "v root1",
5218 " > .git",
5219 " [PROCESSING: '/new_dir/'] <== selected",
5220 " .dockerignore",
5221 ]
5222 );
5223
5224 confirm.await.unwrap();
5225 assert_eq!(
5226 visible_entries_as_strings(&panel, 0..13, cx),
5227 &[
5228 "v root1",
5229 " > .git",
5230 " v new_dir <== selected",
5231 " .dockerignore",
5232 ]
5233 );
5234 }
5235
5236 #[gpui::test]
5237 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
5238 init_test(cx);
5239
5240 let fs = FakeFs::new(cx.executor().clone());
5241 fs.insert_tree(
5242 "/root1",
5243 json!({
5244 "one.two.txt": "",
5245 "one.txt": ""
5246 }),
5247 )
5248 .await;
5249
5250 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5251 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5252 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5253 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5254
5255 panel.update(cx, |panel, cx| {
5256 panel.select_next(&Default::default(), cx);
5257 panel.select_next(&Default::default(), cx);
5258 });
5259
5260 assert_eq!(
5261 visible_entries_as_strings(&panel, 0..50, cx),
5262 &[
5263 //
5264 "v root1",
5265 " one.txt <== selected",
5266 " one.two.txt",
5267 ]
5268 );
5269
5270 // Regression test - file name is created correctly when
5271 // the copied file's name contains multiple dots.
5272 panel.update(cx, |panel, cx| {
5273 panel.copy(&Default::default(), cx);
5274 panel.paste(&Default::default(), cx);
5275 });
5276 cx.executor().run_until_parked();
5277
5278 assert_eq!(
5279 visible_entries_as_strings(&panel, 0..50, cx),
5280 &[
5281 //
5282 "v root1",
5283 " one.txt",
5284 " one copy.txt <== selected",
5285 " one.two.txt",
5286 ]
5287 );
5288
5289 panel.update(cx, |panel, cx| {
5290 panel.paste(&Default::default(), cx);
5291 });
5292 cx.executor().run_until_parked();
5293
5294 assert_eq!(
5295 visible_entries_as_strings(&panel, 0..50, cx),
5296 &[
5297 //
5298 "v root1",
5299 " one.txt",
5300 " one copy.txt",
5301 " one copy 1.txt <== selected",
5302 " one.two.txt",
5303 ]
5304 );
5305 }
5306
5307 #[gpui::test]
5308 async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5309 init_test(cx);
5310
5311 let fs = FakeFs::new(cx.executor().clone());
5312 fs.insert_tree(
5313 "/root1",
5314 json!({
5315 "one.txt": "",
5316 "two.txt": "",
5317 "three.txt": "",
5318 "a": {
5319 "0": { "q": "", "r": "", "s": "" },
5320 "1": { "t": "", "u": "" },
5321 "2": { "v": "", "w": "", "x": "", "y": "" },
5322 },
5323 }),
5324 )
5325 .await;
5326
5327 fs.insert_tree(
5328 "/root2",
5329 json!({
5330 "one.txt": "",
5331 "two.txt": "",
5332 "four.txt": "",
5333 "b": {
5334 "3": { "Q": "" },
5335 "4": { "R": "", "S": "", "T": "", "U": "" },
5336 },
5337 }),
5338 )
5339 .await;
5340
5341 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5342 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5343 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5344 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5345
5346 select_path(&panel, "root1/three.txt", cx);
5347 panel.update(cx, |panel, cx| {
5348 panel.cut(&Default::default(), cx);
5349 });
5350
5351 select_path(&panel, "root2/one.txt", cx);
5352 panel.update(cx, |panel, cx| {
5353 panel.select_next(&Default::default(), cx);
5354 panel.paste(&Default::default(), cx);
5355 });
5356 cx.executor().run_until_parked();
5357 assert_eq!(
5358 visible_entries_as_strings(&panel, 0..50, cx),
5359 &[
5360 //
5361 "v root1",
5362 " > a",
5363 " one.txt",
5364 " two.txt",
5365 "v root2",
5366 " > b",
5367 " four.txt",
5368 " one.txt",
5369 " three.txt <== selected",
5370 " two.txt",
5371 ]
5372 );
5373
5374 select_path(&panel, "root1/a", cx);
5375 panel.update(cx, |panel, cx| {
5376 panel.cut(&Default::default(), cx);
5377 });
5378 select_path(&panel, "root2/two.txt", cx);
5379 panel.update(cx, |panel, cx| {
5380 panel.select_next(&Default::default(), cx);
5381 panel.paste(&Default::default(), cx);
5382 });
5383
5384 cx.executor().run_until_parked();
5385 assert_eq!(
5386 visible_entries_as_strings(&panel, 0..50, cx),
5387 &[
5388 //
5389 "v root1",
5390 " one.txt",
5391 " two.txt",
5392 "v root2",
5393 " > a <== selected",
5394 " > b",
5395 " four.txt",
5396 " one.txt",
5397 " three.txt",
5398 " two.txt",
5399 ]
5400 );
5401 }
5402
5403 #[gpui::test]
5404 async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5405 init_test(cx);
5406
5407 let fs = FakeFs::new(cx.executor().clone());
5408 fs.insert_tree(
5409 "/root1",
5410 json!({
5411 "one.txt": "",
5412 "two.txt": "",
5413 "three.txt": "",
5414 "a": {
5415 "0": { "q": "", "r": "", "s": "" },
5416 "1": { "t": "", "u": "" },
5417 "2": { "v": "", "w": "", "x": "", "y": "" },
5418 },
5419 }),
5420 )
5421 .await;
5422
5423 fs.insert_tree(
5424 "/root2",
5425 json!({
5426 "one.txt": "",
5427 "two.txt": "",
5428 "four.txt": "",
5429 "b": {
5430 "3": { "Q": "" },
5431 "4": { "R": "", "S": "", "T": "", "U": "" },
5432 },
5433 }),
5434 )
5435 .await;
5436
5437 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5438 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5439 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5440 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5441
5442 select_path(&panel, "root1/three.txt", cx);
5443 panel.update(cx, |panel, cx| {
5444 panel.copy(&Default::default(), cx);
5445 });
5446
5447 select_path(&panel, "root2/one.txt", cx);
5448 panel.update(cx, |panel, cx| {
5449 panel.select_next(&Default::default(), cx);
5450 panel.paste(&Default::default(), cx);
5451 });
5452 cx.executor().run_until_parked();
5453 assert_eq!(
5454 visible_entries_as_strings(&panel, 0..50, cx),
5455 &[
5456 //
5457 "v root1",
5458 " > a",
5459 " one.txt",
5460 " three.txt",
5461 " two.txt",
5462 "v root2",
5463 " > b",
5464 " four.txt",
5465 " one.txt",
5466 " three.txt <== selected",
5467 " two.txt",
5468 ]
5469 );
5470
5471 select_path(&panel, "root1/three.txt", cx);
5472 panel.update(cx, |panel, cx| {
5473 panel.copy(&Default::default(), cx);
5474 });
5475 select_path(&panel, "root2/two.txt", cx);
5476 panel.update(cx, |panel, cx| {
5477 panel.select_next(&Default::default(), cx);
5478 panel.paste(&Default::default(), cx);
5479 });
5480
5481 cx.executor().run_until_parked();
5482 assert_eq!(
5483 visible_entries_as_strings(&panel, 0..50, cx),
5484 &[
5485 //
5486 "v root1",
5487 " > a",
5488 " one.txt",
5489 " three.txt",
5490 " two.txt",
5491 "v root2",
5492 " > b",
5493 " four.txt",
5494 " one.txt",
5495 " three.txt",
5496 " three copy.txt <== selected",
5497 " two.txt",
5498 ]
5499 );
5500
5501 select_path(&panel, "root1/a", cx);
5502 panel.update(cx, |panel, cx| {
5503 panel.copy(&Default::default(), cx);
5504 });
5505 select_path(&panel, "root2/two.txt", cx);
5506 panel.update(cx, |panel, cx| {
5507 panel.select_next(&Default::default(), cx);
5508 panel.paste(&Default::default(), cx);
5509 });
5510
5511 cx.executor().run_until_parked();
5512 assert_eq!(
5513 visible_entries_as_strings(&panel, 0..50, cx),
5514 &[
5515 //
5516 "v root1",
5517 " > a",
5518 " one.txt",
5519 " three.txt",
5520 " two.txt",
5521 "v root2",
5522 " > a <== selected",
5523 " > b",
5524 " four.txt",
5525 " one.txt",
5526 " three.txt",
5527 " three copy.txt",
5528 " two.txt",
5529 ]
5530 );
5531 }
5532
5533 #[gpui::test]
5534 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
5535 init_test(cx);
5536
5537 let fs = FakeFs::new(cx.executor().clone());
5538 fs.insert_tree(
5539 "/root",
5540 json!({
5541 "a": {
5542 "one.txt": "",
5543 "two.txt": "",
5544 "inner_dir": {
5545 "three.txt": "",
5546 "four.txt": "",
5547 }
5548 },
5549 "b": {}
5550 }),
5551 )
5552 .await;
5553
5554 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5555 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5556 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5557 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5558
5559 select_path(&panel, "root/a", cx);
5560 panel.update(cx, |panel, cx| {
5561 panel.copy(&Default::default(), cx);
5562 panel.select_next(&Default::default(), cx);
5563 panel.paste(&Default::default(), cx);
5564 });
5565 cx.executor().run_until_parked();
5566
5567 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
5568 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
5569
5570 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
5571 assert_ne!(
5572 pasted_dir_file, None,
5573 "Pasted directory file should have an entry"
5574 );
5575
5576 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
5577 assert_ne!(
5578 pasted_dir_inner_dir, None,
5579 "Directories inside pasted directory should have an entry"
5580 );
5581
5582 toggle_expand_dir(&panel, "root/b/a", cx);
5583 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
5584
5585 assert_eq!(
5586 visible_entries_as_strings(&panel, 0..50, cx),
5587 &[
5588 //
5589 "v root",
5590 " > a",
5591 " v b",
5592 " v a",
5593 " v inner_dir <== selected",
5594 " four.txt",
5595 " three.txt",
5596 " one.txt",
5597 " two.txt",
5598 ]
5599 );
5600
5601 select_path(&panel, "root", cx);
5602 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
5603 cx.executor().run_until_parked();
5604 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
5605 cx.executor().run_until_parked();
5606 assert_eq!(
5607 visible_entries_as_strings(&panel, 0..50, cx),
5608 &[
5609 //
5610 "v root",
5611 " > a",
5612 " v a copy",
5613 " > a <== selected",
5614 " > inner_dir",
5615 " one.txt",
5616 " two.txt",
5617 " v b",
5618 " v a",
5619 " v inner_dir",
5620 " four.txt",
5621 " three.txt",
5622 " one.txt",
5623 " two.txt"
5624 ]
5625 );
5626 }
5627
5628 #[gpui::test]
5629 async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
5630 init_test(cx);
5631
5632 let fs = FakeFs::new(cx.executor().clone());
5633 fs.insert_tree(
5634 "/test",
5635 json!({
5636 "dir1": {
5637 "a.txt": "",
5638 "b.txt": "",
5639 },
5640 "dir2": {},
5641 "c.txt": "",
5642 "d.txt": "",
5643 }),
5644 )
5645 .await;
5646
5647 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
5648 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5649 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5650 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5651
5652 toggle_expand_dir(&panel, "test/dir1", cx);
5653
5654 cx.simulate_modifiers_change(gpui::Modifiers {
5655 control: true,
5656 ..Default::default()
5657 });
5658
5659 select_path_with_mark(&panel, "test/dir1", cx);
5660 select_path_with_mark(&panel, "test/c.txt", cx);
5661
5662 assert_eq!(
5663 visible_entries_as_strings(&panel, 0..15, cx),
5664 &[
5665 "v test",
5666 " v dir1 <== marked",
5667 " a.txt",
5668 " b.txt",
5669 " > dir2",
5670 " c.txt <== selected <== marked",
5671 " d.txt",
5672 ],
5673 "Initial state before copying dir1 and c.txt"
5674 );
5675
5676 panel.update(cx, |panel, cx| {
5677 panel.copy(&Default::default(), cx);
5678 });
5679 select_path(&panel, "test/dir2", cx);
5680 panel.update(cx, |panel, cx| {
5681 panel.paste(&Default::default(), cx);
5682 });
5683 cx.executor().run_until_parked();
5684
5685 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
5686
5687 assert_eq!(
5688 visible_entries_as_strings(&panel, 0..15, cx),
5689 &[
5690 "v test",
5691 " v dir1 <== marked",
5692 " a.txt",
5693 " b.txt",
5694 " v dir2",
5695 " v dir1 <== selected",
5696 " a.txt",
5697 " b.txt",
5698 " c.txt",
5699 " c.txt <== marked",
5700 " d.txt",
5701 ],
5702 "Should copy dir1 as well as c.txt into dir2"
5703 );
5704 }
5705
5706 #[gpui::test]
5707 async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
5708 init_test(cx);
5709
5710 let fs = FakeFs::new(cx.executor().clone());
5711 fs.insert_tree(
5712 "/test",
5713 json!({
5714 "dir1": {
5715 "a.txt": "",
5716 "b.txt": "",
5717 },
5718 "dir2": {},
5719 "c.txt": "",
5720 "d.txt": "",
5721 }),
5722 )
5723 .await;
5724
5725 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
5726 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5727 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5728 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5729
5730 toggle_expand_dir(&panel, "test/dir1", cx);
5731
5732 cx.simulate_modifiers_change(gpui::Modifiers {
5733 control: true,
5734 ..Default::default()
5735 });
5736
5737 select_path_with_mark(&panel, "test/dir1/a.txt", cx);
5738 select_path_with_mark(&panel, "test/dir1", cx);
5739 select_path_with_mark(&panel, "test/c.txt", cx);
5740
5741 assert_eq!(
5742 visible_entries_as_strings(&panel, 0..15, cx),
5743 &[
5744 "v test",
5745 " v dir1 <== marked",
5746 " a.txt <== marked",
5747 " b.txt",
5748 " > dir2",
5749 " c.txt <== selected <== marked",
5750 " d.txt",
5751 ],
5752 "Initial state before copying a.txt, dir1 and c.txt"
5753 );
5754
5755 panel.update(cx, |panel, cx| {
5756 panel.copy(&Default::default(), cx);
5757 });
5758 select_path(&panel, "test/dir2", cx);
5759 panel.update(cx, |panel, cx| {
5760 panel.paste(&Default::default(), cx);
5761 });
5762 cx.executor().run_until_parked();
5763
5764 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
5765
5766 assert_eq!(
5767 visible_entries_as_strings(&panel, 0..20, cx),
5768 &[
5769 "v test",
5770 " v dir1 <== marked",
5771 " a.txt <== marked",
5772 " b.txt",
5773 " v dir2",
5774 " v dir1 <== selected",
5775 " a.txt",
5776 " b.txt",
5777 " c.txt",
5778 " c.txt <== marked",
5779 " d.txt",
5780 ],
5781 "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
5782 );
5783 }
5784
5785 #[gpui::test]
5786 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
5787 init_test_with_editor(cx);
5788
5789 let fs = FakeFs::new(cx.executor().clone());
5790 fs.insert_tree(
5791 "/src",
5792 json!({
5793 "test": {
5794 "first.rs": "// First Rust file",
5795 "second.rs": "// Second Rust file",
5796 "third.rs": "// Third Rust file",
5797 }
5798 }),
5799 )
5800 .await;
5801
5802 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5803 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5804 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5805 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5806
5807 toggle_expand_dir(&panel, "src/test", cx);
5808 select_path(&panel, "src/test/first.rs", cx);
5809 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5810 cx.executor().run_until_parked();
5811 assert_eq!(
5812 visible_entries_as_strings(&panel, 0..10, cx),
5813 &[
5814 "v src",
5815 " v test",
5816 " first.rs <== selected <== marked",
5817 " second.rs",
5818 " third.rs"
5819 ]
5820 );
5821 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
5822
5823 submit_deletion(&panel, cx);
5824 assert_eq!(
5825 visible_entries_as_strings(&panel, 0..10, cx),
5826 &[
5827 "v src",
5828 " v test",
5829 " second.rs <== selected",
5830 " third.rs"
5831 ],
5832 "Project panel should have no deleted file, no other file is selected in it"
5833 );
5834 ensure_no_open_items_and_panes(&workspace, cx);
5835
5836 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5837 cx.executor().run_until_parked();
5838 assert_eq!(
5839 visible_entries_as_strings(&panel, 0..10, cx),
5840 &[
5841 "v src",
5842 " v test",
5843 " second.rs <== selected <== marked",
5844 " third.rs"
5845 ]
5846 );
5847 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
5848
5849 workspace
5850 .update(cx, |workspace, cx| {
5851 let active_items = workspace
5852 .panes()
5853 .iter()
5854 .filter_map(|pane| pane.read(cx).active_item())
5855 .collect::<Vec<_>>();
5856 assert_eq!(active_items.len(), 1);
5857 let open_editor = active_items
5858 .into_iter()
5859 .next()
5860 .unwrap()
5861 .downcast::<Editor>()
5862 .expect("Open item should be an editor");
5863 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
5864 })
5865 .unwrap();
5866 submit_deletion_skipping_prompt(&panel, cx);
5867 assert_eq!(
5868 visible_entries_as_strings(&panel, 0..10, cx),
5869 &["v src", " v test", " third.rs <== selected"],
5870 "Project panel should have no deleted file, with one last file remaining"
5871 );
5872 ensure_no_open_items_and_panes(&workspace, cx);
5873 }
5874
5875 #[gpui::test]
5876 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
5877 init_test_with_editor(cx);
5878
5879 let fs = FakeFs::new(cx.executor().clone());
5880 fs.insert_tree(
5881 "/src",
5882 json!({
5883 "test": {
5884 "first.rs": "// First Rust file",
5885 "second.rs": "// Second Rust file",
5886 "third.rs": "// Third Rust file",
5887 }
5888 }),
5889 )
5890 .await;
5891
5892 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5893 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5894 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5895 let panel = workspace
5896 .update(cx, |workspace, cx| {
5897 let panel = ProjectPanel::new(workspace, cx);
5898 workspace.add_panel(panel.clone(), cx);
5899 panel
5900 })
5901 .unwrap();
5902
5903 select_path(&panel, "src/", cx);
5904 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
5905 cx.executor().run_until_parked();
5906 assert_eq!(
5907 visible_entries_as_strings(&panel, 0..10, cx),
5908 &[
5909 //
5910 "v src <== selected",
5911 " > test"
5912 ]
5913 );
5914 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
5915 panel.update(cx, |panel, cx| {
5916 assert!(panel.filename_editor.read(cx).is_focused(cx));
5917 });
5918 assert_eq!(
5919 visible_entries_as_strings(&panel, 0..10, cx),
5920 &[
5921 //
5922 "v src",
5923 " > [EDITOR: ''] <== selected",
5924 " > test"
5925 ]
5926 );
5927 panel.update(cx, |panel, cx| {
5928 panel
5929 .filename_editor
5930 .update(cx, |editor, cx| editor.set_text("test", cx));
5931 assert!(
5932 panel.confirm_edit(cx).is_none(),
5933 "Should not allow to confirm on conflicting new directory name"
5934 )
5935 });
5936 assert_eq!(
5937 visible_entries_as_strings(&panel, 0..10, cx),
5938 &[
5939 //
5940 "v src",
5941 " > test"
5942 ],
5943 "File list should be unchanged after failed folder create confirmation"
5944 );
5945
5946 select_path(&panel, "src/test/", cx);
5947 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
5948 cx.executor().run_until_parked();
5949 assert_eq!(
5950 visible_entries_as_strings(&panel, 0..10, cx),
5951 &[
5952 //
5953 "v src",
5954 " > test <== selected"
5955 ]
5956 );
5957 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5958 panel.update(cx, |panel, cx| {
5959 assert!(panel.filename_editor.read(cx).is_focused(cx));
5960 });
5961 assert_eq!(
5962 visible_entries_as_strings(&panel, 0..10, cx),
5963 &[
5964 "v src",
5965 " v test",
5966 " [EDITOR: ''] <== selected",
5967 " first.rs",
5968 " second.rs",
5969 " third.rs"
5970 ]
5971 );
5972 panel.update(cx, |panel, cx| {
5973 panel
5974 .filename_editor
5975 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
5976 assert!(
5977 panel.confirm_edit(cx).is_none(),
5978 "Should not allow to confirm on conflicting new file name"
5979 )
5980 });
5981 assert_eq!(
5982 visible_entries_as_strings(&panel, 0..10, cx),
5983 &[
5984 "v src",
5985 " v test",
5986 " first.rs",
5987 " second.rs",
5988 " third.rs"
5989 ],
5990 "File list should be unchanged after failed file create confirmation"
5991 );
5992
5993 select_path(&panel, "src/test/first.rs", cx);
5994 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
5995 cx.executor().run_until_parked();
5996 assert_eq!(
5997 visible_entries_as_strings(&panel, 0..10, cx),
5998 &[
5999 "v src",
6000 " v test",
6001 " first.rs <== selected",
6002 " second.rs",
6003 " third.rs"
6004 ],
6005 );
6006 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
6007 panel.update(cx, |panel, cx| {
6008 assert!(panel.filename_editor.read(cx).is_focused(cx));
6009 });
6010 assert_eq!(
6011 visible_entries_as_strings(&panel, 0..10, cx),
6012 &[
6013 "v src",
6014 " v test",
6015 " [EDITOR: 'first.rs'] <== selected",
6016 " second.rs",
6017 " third.rs"
6018 ]
6019 );
6020 panel.update(cx, |panel, cx| {
6021 panel
6022 .filename_editor
6023 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
6024 assert!(
6025 panel.confirm_edit(cx).is_none(),
6026 "Should not allow to confirm on conflicting file rename"
6027 )
6028 });
6029 assert_eq!(
6030 visible_entries_as_strings(&panel, 0..10, cx),
6031 &[
6032 "v src",
6033 " v test",
6034 " first.rs <== selected",
6035 " second.rs",
6036 " third.rs"
6037 ],
6038 "File list should be unchanged after failed rename confirmation"
6039 );
6040 }
6041
6042 #[gpui::test]
6043 async fn test_select_directory(cx: &mut gpui::TestAppContext) {
6044 init_test_with_editor(cx);
6045
6046 let fs = FakeFs::new(cx.executor().clone());
6047 fs.insert_tree(
6048 "/project_root",
6049 json!({
6050 "dir_1": {
6051 "nested_dir": {
6052 "file_a.py": "# File contents",
6053 }
6054 },
6055 "file_1.py": "# File contents",
6056 "dir_2": {
6057
6058 },
6059 "dir_3": {
6060
6061 },
6062 "file_2.py": "# File contents",
6063 "dir_4": {
6064
6065 },
6066 }),
6067 )
6068 .await;
6069
6070 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6071 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6072 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6073 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6074
6075 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6076 cx.executor().run_until_parked();
6077 select_path(&panel, "project_root/dir_1", cx);
6078 cx.executor().run_until_parked();
6079 assert_eq!(
6080 visible_entries_as_strings(&panel, 0..10, cx),
6081 &[
6082 "v project_root",
6083 " > dir_1 <== selected",
6084 " > dir_2",
6085 " > dir_3",
6086 " > dir_4",
6087 " file_1.py",
6088 " file_2.py",
6089 ]
6090 );
6091 panel.update(cx, |panel, cx| {
6092 panel.select_prev_directory(&SelectPrevDirectory, cx)
6093 });
6094
6095 assert_eq!(
6096 visible_entries_as_strings(&panel, 0..10, cx),
6097 &[
6098 "v project_root <== selected",
6099 " > dir_1",
6100 " > dir_2",
6101 " > dir_3",
6102 " > dir_4",
6103 " file_1.py",
6104 " file_2.py",
6105 ]
6106 );
6107
6108 panel.update(cx, |panel, cx| {
6109 panel.select_prev_directory(&SelectPrevDirectory, cx)
6110 });
6111
6112 assert_eq!(
6113 visible_entries_as_strings(&panel, 0..10, cx),
6114 &[
6115 "v project_root",
6116 " > dir_1",
6117 " > dir_2",
6118 " > dir_3",
6119 " > dir_4 <== selected",
6120 " file_1.py",
6121 " file_2.py",
6122 ]
6123 );
6124
6125 panel.update(cx, |panel, cx| {
6126 panel.select_next_directory(&SelectNextDirectory, cx)
6127 });
6128
6129 assert_eq!(
6130 visible_entries_as_strings(&panel, 0..10, cx),
6131 &[
6132 "v project_root <== selected",
6133 " > dir_1",
6134 " > dir_2",
6135 " > dir_3",
6136 " > dir_4",
6137 " file_1.py",
6138 " file_2.py",
6139 ]
6140 );
6141 }
6142
6143 #[gpui::test]
6144 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
6145 init_test_with_editor(cx);
6146
6147 let fs = FakeFs::new(cx.executor().clone());
6148 fs.insert_tree(
6149 "/project_root",
6150 json!({
6151 "dir_1": {
6152 "nested_dir": {
6153 "file_a.py": "# File contents",
6154 }
6155 },
6156 "file_1.py": "# File contents",
6157 }),
6158 )
6159 .await;
6160
6161 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6162 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6163 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6164 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6165
6166 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6167 cx.executor().run_until_parked();
6168 select_path(&panel, "project_root/dir_1", cx);
6169 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6170 select_path(&panel, "project_root/dir_1/nested_dir", cx);
6171 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6172 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6173 cx.executor().run_until_parked();
6174 assert_eq!(
6175 visible_entries_as_strings(&panel, 0..10, cx),
6176 &[
6177 "v project_root",
6178 " v dir_1",
6179 " > nested_dir <== selected",
6180 " file_1.py",
6181 ]
6182 );
6183 }
6184
6185 #[gpui::test]
6186 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
6187 init_test_with_editor(cx);
6188
6189 let fs = FakeFs::new(cx.executor().clone());
6190 fs.insert_tree(
6191 "/project_root",
6192 json!({
6193 "dir_1": {
6194 "nested_dir": {
6195 "file_a.py": "# File contents",
6196 "file_b.py": "# File contents",
6197 "file_c.py": "# File contents",
6198 },
6199 "file_1.py": "# File contents",
6200 "file_2.py": "# File contents",
6201 "file_3.py": "# File contents",
6202 },
6203 "dir_2": {
6204 "file_1.py": "# File contents",
6205 "file_2.py": "# File contents",
6206 "file_3.py": "# File contents",
6207 }
6208 }),
6209 )
6210 .await;
6211
6212 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6213 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6214 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6215 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6216
6217 panel.update(cx, |panel, cx| {
6218 panel.collapse_all_entries(&CollapseAllEntries, cx)
6219 });
6220 cx.executor().run_until_parked();
6221 assert_eq!(
6222 visible_entries_as_strings(&panel, 0..10, cx),
6223 &["v project_root", " > dir_1", " > dir_2",]
6224 );
6225
6226 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
6227 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6228 cx.executor().run_until_parked();
6229 assert_eq!(
6230 visible_entries_as_strings(&panel, 0..10, cx),
6231 &[
6232 "v project_root",
6233 " v dir_1 <== selected",
6234 " > nested_dir",
6235 " file_1.py",
6236 " file_2.py",
6237 " file_3.py",
6238 " > dir_2",
6239 ]
6240 );
6241 }
6242
6243 #[gpui::test]
6244 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
6245 init_test(cx);
6246
6247 let fs = FakeFs::new(cx.executor().clone());
6248 fs.as_fake().insert_tree("/root", json!({})).await;
6249 let project = Project::test(fs, ["/root".as_ref()], cx).await;
6250 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6251 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6252 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6253
6254 // Make a new buffer with no backing file
6255 workspace
6256 .update(cx, |workspace, cx| {
6257 Editor::new_file(workspace, &Default::default(), cx)
6258 })
6259 .unwrap();
6260
6261 cx.executor().run_until_parked();
6262
6263 // "Save as" the buffer, creating a new backing file for it
6264 let save_task = workspace
6265 .update(cx, |workspace, cx| {
6266 workspace.save_active_item(workspace::SaveIntent::Save, cx)
6267 })
6268 .unwrap();
6269
6270 cx.executor().run_until_parked();
6271 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
6272 save_task.await.unwrap();
6273
6274 // Rename the file
6275 select_path(&panel, "root/new", cx);
6276 assert_eq!(
6277 visible_entries_as_strings(&panel, 0..10, cx),
6278 &["v root", " new <== selected"]
6279 );
6280 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
6281 panel.update(cx, |panel, cx| {
6282 panel
6283 .filename_editor
6284 .update(cx, |editor, cx| editor.set_text("newer", cx));
6285 });
6286 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6287
6288 cx.executor().run_until_parked();
6289 assert_eq!(
6290 visible_entries_as_strings(&panel, 0..10, cx),
6291 &["v root", " newer <== selected"]
6292 );
6293
6294 workspace
6295 .update(cx, |workspace, cx| {
6296 workspace.save_active_item(workspace::SaveIntent::Save, cx)
6297 })
6298 .unwrap()
6299 .await
6300 .unwrap();
6301
6302 cx.executor().run_until_parked();
6303 // assert that saving the file doesn't restore "new"
6304 assert_eq!(
6305 visible_entries_as_strings(&panel, 0..10, cx),
6306 &["v root", " newer <== selected"]
6307 );
6308 }
6309
6310 #[gpui::test]
6311 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
6312 init_test_with_editor(cx);
6313 let fs = FakeFs::new(cx.executor().clone());
6314 fs.insert_tree(
6315 "/project_root",
6316 json!({
6317 "dir_1": {
6318 "nested_dir": {
6319 "file_a.py": "# File contents",
6320 }
6321 },
6322 "file_1.py": "# File contents",
6323 }),
6324 )
6325 .await;
6326
6327 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6328 let worktree_id =
6329 cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
6330 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6331 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6332 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6333 cx.update(|cx| {
6334 panel.update(cx, |this, cx| {
6335 this.select_next(&Default::default(), cx);
6336 this.expand_selected_entry(&Default::default(), cx);
6337 this.expand_selected_entry(&Default::default(), cx);
6338 this.select_next(&Default::default(), cx);
6339 this.expand_selected_entry(&Default::default(), cx);
6340 this.select_next(&Default::default(), cx);
6341 })
6342 });
6343 assert_eq!(
6344 visible_entries_as_strings(&panel, 0..10, cx),
6345 &[
6346 "v project_root",
6347 " v dir_1",
6348 " v nested_dir",
6349 " file_a.py <== selected",
6350 " file_1.py",
6351 ]
6352 );
6353 let modifiers_with_shift = gpui::Modifiers {
6354 shift: true,
6355 ..Default::default()
6356 };
6357 cx.simulate_modifiers_change(modifiers_with_shift);
6358 cx.update(|cx| {
6359 panel.update(cx, |this, cx| {
6360 this.select_next(&Default::default(), cx);
6361 })
6362 });
6363 assert_eq!(
6364 visible_entries_as_strings(&panel, 0..10, cx),
6365 &[
6366 "v project_root",
6367 " v dir_1",
6368 " v nested_dir",
6369 " file_a.py",
6370 " file_1.py <== selected <== marked",
6371 ]
6372 );
6373 cx.update(|cx| {
6374 panel.update(cx, |this, cx| {
6375 this.select_prev(&Default::default(), cx);
6376 })
6377 });
6378 assert_eq!(
6379 visible_entries_as_strings(&panel, 0..10, cx),
6380 &[
6381 "v project_root",
6382 " v dir_1",
6383 " v nested_dir",
6384 " file_a.py <== selected <== marked",
6385 " file_1.py <== marked",
6386 ]
6387 );
6388 cx.update(|cx| {
6389 panel.update(cx, |this, cx| {
6390 let drag = DraggedSelection {
6391 active_selection: this.selection.unwrap(),
6392 marked_selections: Arc::new(this.marked_entries.clone()),
6393 };
6394 let target_entry = this
6395 .project
6396 .read(cx)
6397 .entry_for_path(&(worktree_id, "").into(), cx)
6398 .unwrap();
6399 this.drag_onto(&drag, target_entry.id, false, cx);
6400 });
6401 });
6402 cx.run_until_parked();
6403 assert_eq!(
6404 visible_entries_as_strings(&panel, 0..10, cx),
6405 &[
6406 "v project_root",
6407 " v dir_1",
6408 " v nested_dir",
6409 " file_1.py <== marked",
6410 " file_a.py <== selected <== marked",
6411 ]
6412 );
6413 // ESC clears out all marks
6414 cx.update(|cx| {
6415 panel.update(cx, |this, cx| {
6416 this.cancel(&menu::Cancel, cx);
6417 })
6418 });
6419 assert_eq!(
6420 visible_entries_as_strings(&panel, 0..10, cx),
6421 &[
6422 "v project_root",
6423 " v dir_1",
6424 " v nested_dir",
6425 " file_1.py",
6426 " file_a.py <== selected",
6427 ]
6428 );
6429 // ESC clears out all marks
6430 cx.update(|cx| {
6431 panel.update(cx, |this, cx| {
6432 this.select_prev(&SelectPrev, cx);
6433 this.select_next(&SelectNext, cx);
6434 })
6435 });
6436 assert_eq!(
6437 visible_entries_as_strings(&panel, 0..10, cx),
6438 &[
6439 "v project_root",
6440 " v dir_1",
6441 " v nested_dir",
6442 " file_1.py <== marked",
6443 " file_a.py <== selected <== marked",
6444 ]
6445 );
6446 cx.simulate_modifiers_change(Default::default());
6447 cx.update(|cx| {
6448 panel.update(cx, |this, cx| {
6449 this.cut(&Cut, cx);
6450 this.select_prev(&SelectPrev, cx);
6451 this.select_prev(&SelectPrev, cx);
6452
6453 this.paste(&Paste, cx);
6454 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
6455 })
6456 });
6457 cx.run_until_parked();
6458 assert_eq!(
6459 visible_entries_as_strings(&panel, 0..10, cx),
6460 &[
6461 "v project_root",
6462 " v dir_1",
6463 " v nested_dir",
6464 " file_1.py <== marked",
6465 " file_a.py <== selected <== marked",
6466 ]
6467 );
6468 cx.simulate_modifiers_change(modifiers_with_shift);
6469 cx.update(|cx| {
6470 panel.update(cx, |this, cx| {
6471 this.expand_selected_entry(&Default::default(), cx);
6472 this.select_next(&SelectNext, cx);
6473 this.select_next(&SelectNext, cx);
6474 })
6475 });
6476 submit_deletion(&panel, cx);
6477 assert_eq!(
6478 visible_entries_as_strings(&panel, 0..10, cx),
6479 &[
6480 "v project_root",
6481 " v dir_1",
6482 " v nested_dir <== selected",
6483 ]
6484 );
6485 }
6486 #[gpui::test]
6487 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
6488 init_test_with_editor(cx);
6489 cx.update(|cx| {
6490 cx.update_global::<SettingsStore, _>(|store, cx| {
6491 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6492 worktree_settings.file_scan_exclusions = Some(Vec::new());
6493 });
6494 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6495 project_panel_settings.auto_reveal_entries = Some(false)
6496 });
6497 })
6498 });
6499
6500 let fs = FakeFs::new(cx.background_executor.clone());
6501 fs.insert_tree(
6502 "/project_root",
6503 json!({
6504 ".git": {},
6505 ".gitignore": "**/gitignored_dir",
6506 "dir_1": {
6507 "file_1.py": "# File 1_1 contents",
6508 "file_2.py": "# File 1_2 contents",
6509 "file_3.py": "# File 1_3 contents",
6510 "gitignored_dir": {
6511 "file_a.py": "# File contents",
6512 "file_b.py": "# File contents",
6513 "file_c.py": "# File contents",
6514 },
6515 },
6516 "dir_2": {
6517 "file_1.py": "# File 2_1 contents",
6518 "file_2.py": "# File 2_2 contents",
6519 "file_3.py": "# File 2_3 contents",
6520 }
6521 }),
6522 )
6523 .await;
6524
6525 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6526 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6527 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6528 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6529
6530 assert_eq!(
6531 visible_entries_as_strings(&panel, 0..20, cx),
6532 &[
6533 "v project_root",
6534 " > .git",
6535 " > dir_1",
6536 " > dir_2",
6537 " .gitignore",
6538 ]
6539 );
6540
6541 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
6542 .expect("dir 1 file is not ignored and should have an entry");
6543 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
6544 .expect("dir 2 file is not ignored and should have an entry");
6545 let gitignored_dir_file =
6546 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
6547 assert_eq!(
6548 gitignored_dir_file, None,
6549 "File in the gitignored dir should not have an entry before its dir is toggled"
6550 );
6551
6552 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6553 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6554 cx.executor().run_until_parked();
6555 assert_eq!(
6556 visible_entries_as_strings(&panel, 0..20, cx),
6557 &[
6558 "v project_root",
6559 " > .git",
6560 " v dir_1",
6561 " v gitignored_dir <== selected",
6562 " file_a.py",
6563 " file_b.py",
6564 " file_c.py",
6565 " file_1.py",
6566 " file_2.py",
6567 " file_3.py",
6568 " > dir_2",
6569 " .gitignore",
6570 ],
6571 "Should show gitignored dir file list in the project panel"
6572 );
6573 let gitignored_dir_file =
6574 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
6575 .expect("after gitignored dir got opened, a file entry should be present");
6576
6577 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6578 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6579 assert_eq!(
6580 visible_entries_as_strings(&panel, 0..20, cx),
6581 &[
6582 "v project_root",
6583 " > .git",
6584 " > dir_1 <== selected",
6585 " > dir_2",
6586 " .gitignore",
6587 ],
6588 "Should hide all dir contents again and prepare for the auto reveal test"
6589 );
6590
6591 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
6592 panel.update(cx, |panel, cx| {
6593 panel.project.update(cx, |_, cx| {
6594 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
6595 })
6596 });
6597 cx.run_until_parked();
6598 assert_eq!(
6599 visible_entries_as_strings(&panel, 0..20, cx),
6600 &[
6601 "v project_root",
6602 " > .git",
6603 " > dir_1 <== selected",
6604 " > dir_2",
6605 " .gitignore",
6606 ],
6607 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
6608 );
6609 }
6610
6611 cx.update(|cx| {
6612 cx.update_global::<SettingsStore, _>(|store, cx| {
6613 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6614 project_panel_settings.auto_reveal_entries = Some(true)
6615 });
6616 })
6617 });
6618
6619 panel.update(cx, |panel, cx| {
6620 panel.project.update(cx, |_, cx| {
6621 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
6622 })
6623 });
6624 cx.run_until_parked();
6625 assert_eq!(
6626 visible_entries_as_strings(&panel, 0..20, cx),
6627 &[
6628 "v project_root",
6629 " > .git",
6630 " v dir_1",
6631 " > gitignored_dir",
6632 " file_1.py <== selected",
6633 " file_2.py",
6634 " file_3.py",
6635 " > dir_2",
6636 " .gitignore",
6637 ],
6638 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
6639 );
6640
6641 panel.update(cx, |panel, cx| {
6642 panel.project.update(cx, |_, cx| {
6643 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
6644 })
6645 });
6646 cx.run_until_parked();
6647 assert_eq!(
6648 visible_entries_as_strings(&panel, 0..20, cx),
6649 &[
6650 "v project_root",
6651 " > .git",
6652 " v dir_1",
6653 " > gitignored_dir",
6654 " file_1.py",
6655 " file_2.py",
6656 " file_3.py",
6657 " v dir_2",
6658 " file_1.py <== selected",
6659 " file_2.py",
6660 " file_3.py",
6661 " .gitignore",
6662 ],
6663 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
6664 );
6665
6666 panel.update(cx, |panel, cx| {
6667 panel.project.update(cx, |_, cx| {
6668 cx.emit(project::Event::ActiveEntryChanged(Some(
6669 gitignored_dir_file,
6670 )))
6671 })
6672 });
6673 cx.run_until_parked();
6674 assert_eq!(
6675 visible_entries_as_strings(&panel, 0..20, cx),
6676 &[
6677 "v project_root",
6678 " > .git",
6679 " v dir_1",
6680 " > gitignored_dir",
6681 " file_1.py",
6682 " file_2.py",
6683 " file_3.py",
6684 " v dir_2",
6685 " file_1.py <== selected",
6686 " file_2.py",
6687 " file_3.py",
6688 " .gitignore",
6689 ],
6690 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
6691 );
6692
6693 panel.update(cx, |panel, cx| {
6694 panel.project.update(cx, |_, cx| {
6695 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
6696 })
6697 });
6698 cx.run_until_parked();
6699 assert_eq!(
6700 visible_entries_as_strings(&panel, 0..20, cx),
6701 &[
6702 "v project_root",
6703 " > .git",
6704 " v dir_1",
6705 " v gitignored_dir",
6706 " file_a.py <== selected",
6707 " file_b.py",
6708 " file_c.py",
6709 " file_1.py",
6710 " file_2.py",
6711 " file_3.py",
6712 " v dir_2",
6713 " file_1.py",
6714 " file_2.py",
6715 " file_3.py",
6716 " .gitignore",
6717 ],
6718 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
6719 );
6720 }
6721
6722 #[gpui::test]
6723 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
6724 init_test_with_editor(cx);
6725 cx.update(|cx| {
6726 cx.update_global::<SettingsStore, _>(|store, cx| {
6727 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6728 worktree_settings.file_scan_exclusions = Some(Vec::new());
6729 });
6730 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6731 project_panel_settings.auto_reveal_entries = Some(false)
6732 });
6733 })
6734 });
6735
6736 let fs = FakeFs::new(cx.background_executor.clone());
6737 fs.insert_tree(
6738 "/project_root",
6739 json!({
6740 ".git": {},
6741 ".gitignore": "**/gitignored_dir",
6742 "dir_1": {
6743 "file_1.py": "# File 1_1 contents",
6744 "file_2.py": "# File 1_2 contents",
6745 "file_3.py": "# File 1_3 contents",
6746 "gitignored_dir": {
6747 "file_a.py": "# File contents",
6748 "file_b.py": "# File contents",
6749 "file_c.py": "# File contents",
6750 },
6751 },
6752 "dir_2": {
6753 "file_1.py": "# File 2_1 contents",
6754 "file_2.py": "# File 2_2 contents",
6755 "file_3.py": "# File 2_3 contents",
6756 }
6757 }),
6758 )
6759 .await;
6760
6761 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6762 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6763 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6764 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6765
6766 assert_eq!(
6767 visible_entries_as_strings(&panel, 0..20, cx),
6768 &[
6769 "v project_root",
6770 " > .git",
6771 " > dir_1",
6772 " > dir_2",
6773 " .gitignore",
6774 ]
6775 );
6776
6777 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
6778 .expect("dir 1 file is not ignored and should have an entry");
6779 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
6780 .expect("dir 2 file is not ignored and should have an entry");
6781 let gitignored_dir_file =
6782 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
6783 assert_eq!(
6784 gitignored_dir_file, None,
6785 "File in the gitignored dir should not have an entry before its dir is toggled"
6786 );
6787
6788 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6789 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6790 cx.run_until_parked();
6791 assert_eq!(
6792 visible_entries_as_strings(&panel, 0..20, cx),
6793 &[
6794 "v project_root",
6795 " > .git",
6796 " v dir_1",
6797 " v gitignored_dir <== selected",
6798 " file_a.py",
6799 " file_b.py",
6800 " file_c.py",
6801 " file_1.py",
6802 " file_2.py",
6803 " file_3.py",
6804 " > dir_2",
6805 " .gitignore",
6806 ],
6807 "Should show gitignored dir file list in the project panel"
6808 );
6809 let gitignored_dir_file =
6810 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
6811 .expect("after gitignored dir got opened, a file entry should be present");
6812
6813 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6814 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6815 assert_eq!(
6816 visible_entries_as_strings(&panel, 0..20, cx),
6817 &[
6818 "v project_root",
6819 " > .git",
6820 " > dir_1 <== selected",
6821 " > dir_2",
6822 " .gitignore",
6823 ],
6824 "Should hide all dir contents again and prepare for the explicit reveal test"
6825 );
6826
6827 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
6828 panel.update(cx, |panel, cx| {
6829 panel.project.update(cx, |_, cx| {
6830 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
6831 })
6832 });
6833 cx.run_until_parked();
6834 assert_eq!(
6835 visible_entries_as_strings(&panel, 0..20, cx),
6836 &[
6837 "v project_root",
6838 " > .git",
6839 " > dir_1 <== selected",
6840 " > dir_2",
6841 " .gitignore",
6842 ],
6843 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
6844 );
6845 }
6846
6847 panel.update(cx, |panel, cx| {
6848 panel.project.update(cx, |_, cx| {
6849 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
6850 })
6851 });
6852 cx.run_until_parked();
6853 assert_eq!(
6854 visible_entries_as_strings(&panel, 0..20, cx),
6855 &[
6856 "v project_root",
6857 " > .git",
6858 " v dir_1",
6859 " > gitignored_dir",
6860 " file_1.py <== selected",
6861 " file_2.py",
6862 " file_3.py",
6863 " > dir_2",
6864 " .gitignore",
6865 ],
6866 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
6867 );
6868
6869 panel.update(cx, |panel, cx| {
6870 panel.project.update(cx, |_, cx| {
6871 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
6872 })
6873 });
6874 cx.run_until_parked();
6875 assert_eq!(
6876 visible_entries_as_strings(&panel, 0..20, cx),
6877 &[
6878 "v project_root",
6879 " > .git",
6880 " v dir_1",
6881 " > gitignored_dir",
6882 " file_1.py",
6883 " file_2.py",
6884 " file_3.py",
6885 " v dir_2",
6886 " file_1.py <== selected",
6887 " file_2.py",
6888 " file_3.py",
6889 " .gitignore",
6890 ],
6891 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
6892 );
6893
6894 panel.update(cx, |panel, cx| {
6895 panel.project.update(cx, |_, cx| {
6896 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
6897 })
6898 });
6899 cx.run_until_parked();
6900 assert_eq!(
6901 visible_entries_as_strings(&panel, 0..20, cx),
6902 &[
6903 "v project_root",
6904 " > .git",
6905 " v dir_1",
6906 " v gitignored_dir",
6907 " file_a.py <== selected",
6908 " file_b.py",
6909 " file_c.py",
6910 " file_1.py",
6911 " file_2.py",
6912 " file_3.py",
6913 " v dir_2",
6914 " file_1.py",
6915 " file_2.py",
6916 " file_3.py",
6917 " .gitignore",
6918 ],
6919 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
6920 );
6921 }
6922
6923 #[gpui::test]
6924 async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
6925 init_test(cx);
6926 cx.update(|cx| {
6927 cx.update_global::<SettingsStore, _>(|store, cx| {
6928 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
6929 project_settings.file_scan_exclusions =
6930 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
6931 });
6932 });
6933 });
6934
6935 cx.update(|cx| {
6936 register_project_item::<TestProjectItemView>(cx);
6937 });
6938
6939 let fs = FakeFs::new(cx.executor().clone());
6940 fs.insert_tree(
6941 "/root1",
6942 json!({
6943 ".dockerignore": "",
6944 ".git": {
6945 "HEAD": "",
6946 },
6947 }),
6948 )
6949 .await;
6950
6951 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
6952 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6953 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6954 let panel = workspace
6955 .update(cx, |workspace, cx| {
6956 let panel = ProjectPanel::new(workspace, cx);
6957 workspace.add_panel(panel.clone(), cx);
6958 panel
6959 })
6960 .unwrap();
6961
6962 select_path(&panel, "root1", cx);
6963 assert_eq!(
6964 visible_entries_as_strings(&panel, 0..10, cx),
6965 &["v root1 <== selected", " .dockerignore",]
6966 );
6967 workspace
6968 .update(cx, |workspace, cx| {
6969 assert!(
6970 workspace.active_item(cx).is_none(),
6971 "Should have no active items in the beginning"
6972 );
6973 })
6974 .unwrap();
6975
6976 let excluded_file_path = ".git/COMMIT_EDITMSG";
6977 let excluded_dir_path = "excluded_dir";
6978
6979 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
6980 panel.update(cx, |panel, cx| {
6981 assert!(panel.filename_editor.read(cx).is_focused(cx));
6982 });
6983 panel
6984 .update(cx, |panel, cx| {
6985 panel
6986 .filename_editor
6987 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
6988 panel.confirm_edit(cx).unwrap()
6989 })
6990 .await
6991 .unwrap();
6992
6993 assert_eq!(
6994 visible_entries_as_strings(&panel, 0..13, cx),
6995 &["v root1", " .dockerignore"],
6996 "Excluded dir should not be shown after opening a file in it"
6997 );
6998 panel.update(cx, |panel, cx| {
6999 assert!(
7000 !panel.filename_editor.read(cx).is_focused(cx),
7001 "Should have closed the file name editor"
7002 );
7003 });
7004 workspace
7005 .update(cx, |workspace, cx| {
7006 let active_entry_path = workspace
7007 .active_item(cx)
7008 .expect("should have opened and activated the excluded item")
7009 .act_as::<TestProjectItemView>(cx)
7010 .expect(
7011 "should have opened the corresponding project item for the excluded item",
7012 )
7013 .read(cx)
7014 .path
7015 .clone();
7016 assert_eq!(
7017 active_entry_path.path.as_ref(),
7018 Path::new(excluded_file_path),
7019 "Should open the excluded file"
7020 );
7021
7022 assert!(
7023 workspace.notification_ids().is_empty(),
7024 "Should have no notifications after opening an excluded file"
7025 );
7026 })
7027 .unwrap();
7028 assert!(
7029 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
7030 "Should have created the excluded file"
7031 );
7032
7033 select_path(&panel, "root1", cx);
7034 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7035 panel.update(cx, |panel, cx| {
7036 assert!(panel.filename_editor.read(cx).is_focused(cx));
7037 });
7038 panel
7039 .update(cx, |panel, cx| {
7040 panel
7041 .filename_editor
7042 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
7043 panel.confirm_edit(cx).unwrap()
7044 })
7045 .await
7046 .unwrap();
7047
7048 assert_eq!(
7049 visible_entries_as_strings(&panel, 0..13, cx),
7050 &["v root1", " .dockerignore"],
7051 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
7052 );
7053 panel.update(cx, |panel, cx| {
7054 assert!(
7055 !panel.filename_editor.read(cx).is_focused(cx),
7056 "Should have closed the file name editor"
7057 );
7058 });
7059 workspace
7060 .update(cx, |workspace, cx| {
7061 let notifications = workspace.notification_ids();
7062 assert_eq!(
7063 notifications.len(),
7064 1,
7065 "Should receive one notification with the error message"
7066 );
7067 workspace.dismiss_notification(notifications.first().unwrap(), cx);
7068 assert!(workspace.notification_ids().is_empty());
7069 })
7070 .unwrap();
7071
7072 select_path(&panel, "root1", cx);
7073 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7074 panel.update(cx, |panel, cx| {
7075 assert!(panel.filename_editor.read(cx).is_focused(cx));
7076 });
7077 panel
7078 .update(cx, |panel, cx| {
7079 panel
7080 .filename_editor
7081 .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
7082 panel.confirm_edit(cx).unwrap()
7083 })
7084 .await
7085 .unwrap();
7086
7087 assert_eq!(
7088 visible_entries_as_strings(&panel, 0..13, cx),
7089 &["v root1", " .dockerignore"],
7090 "Should not change the project panel after trying to create an excluded directory"
7091 );
7092 panel.update(cx, |panel, cx| {
7093 assert!(
7094 !panel.filename_editor.read(cx).is_focused(cx),
7095 "Should have closed the file name editor"
7096 );
7097 });
7098 workspace
7099 .update(cx, |workspace, cx| {
7100 let notifications = workspace.notification_ids();
7101 assert_eq!(
7102 notifications.len(),
7103 1,
7104 "Should receive one notification explaining that no directory is actually shown"
7105 );
7106 workspace.dismiss_notification(notifications.first().unwrap(), cx);
7107 assert!(workspace.notification_ids().is_empty());
7108 })
7109 .unwrap();
7110 assert!(
7111 fs.is_dir(Path::new("/root1/excluded_dir")).await,
7112 "Should have created the excluded directory"
7113 );
7114 }
7115
7116 #[gpui::test]
7117 async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
7118 init_test_with_editor(cx);
7119
7120 let fs = FakeFs::new(cx.executor().clone());
7121 fs.insert_tree(
7122 "/src",
7123 json!({
7124 "test": {
7125 "first.rs": "// First Rust file",
7126 "second.rs": "// Second Rust file",
7127 "third.rs": "// Third Rust file",
7128 }
7129 }),
7130 )
7131 .await;
7132
7133 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
7134 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7135 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7136 let panel = workspace
7137 .update(cx, |workspace, cx| {
7138 let panel = ProjectPanel::new(workspace, cx);
7139 workspace.add_panel(panel.clone(), cx);
7140 panel
7141 })
7142 .unwrap();
7143
7144 select_path(&panel, "src/", cx);
7145 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
7146 cx.executor().run_until_parked();
7147 assert_eq!(
7148 visible_entries_as_strings(&panel, 0..10, cx),
7149 &[
7150 //
7151 "v src <== selected",
7152 " > test"
7153 ]
7154 );
7155 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7156 panel.update(cx, |panel, cx| {
7157 assert!(panel.filename_editor.read(cx).is_focused(cx));
7158 });
7159 assert_eq!(
7160 visible_entries_as_strings(&panel, 0..10, cx),
7161 &[
7162 //
7163 "v src",
7164 " > [EDITOR: ''] <== selected",
7165 " > test"
7166 ]
7167 );
7168
7169 panel.update(cx, |panel, cx| panel.cancel(&menu::Cancel, cx));
7170 assert_eq!(
7171 visible_entries_as_strings(&panel, 0..10, cx),
7172 &[
7173 //
7174 "v src <== selected",
7175 " > test"
7176 ]
7177 );
7178 }
7179
7180 #[gpui::test]
7181 async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
7182 init_test_with_editor(cx);
7183
7184 let fs = FakeFs::new(cx.executor().clone());
7185 fs.insert_tree(
7186 "/root",
7187 json!({
7188 "dir1": {
7189 "subdir1": {},
7190 "file1.txt": "",
7191 "file2.txt": "",
7192 },
7193 "dir2": {
7194 "subdir2": {},
7195 "file3.txt": "",
7196 "file4.txt": "",
7197 },
7198 "file5.txt": "",
7199 "file6.txt": "",
7200 }),
7201 )
7202 .await;
7203
7204 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7205 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7206 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7207 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7208
7209 toggle_expand_dir(&panel, "root/dir1", cx);
7210 toggle_expand_dir(&panel, "root/dir2", cx);
7211
7212 // Test Case 1: Delete middle file in directory
7213 select_path(&panel, "root/dir1/file1.txt", cx);
7214 assert_eq!(
7215 visible_entries_as_strings(&panel, 0..15, cx),
7216 &[
7217 "v root",
7218 " v dir1",
7219 " > subdir1",
7220 " file1.txt <== selected",
7221 " file2.txt",
7222 " v dir2",
7223 " > subdir2",
7224 " file3.txt",
7225 " file4.txt",
7226 " file5.txt",
7227 " file6.txt",
7228 ],
7229 "Initial state before deleting middle file"
7230 );
7231
7232 submit_deletion(&panel, cx);
7233 assert_eq!(
7234 visible_entries_as_strings(&panel, 0..15, cx),
7235 &[
7236 "v root",
7237 " v dir1",
7238 " > subdir1",
7239 " file2.txt <== selected",
7240 " v dir2",
7241 " > subdir2",
7242 " file3.txt",
7243 " file4.txt",
7244 " file5.txt",
7245 " file6.txt",
7246 ],
7247 "Should select next file after deleting middle file"
7248 );
7249
7250 // Test Case 2: Delete last file in directory
7251 submit_deletion(&panel, cx);
7252 assert_eq!(
7253 visible_entries_as_strings(&panel, 0..15, cx),
7254 &[
7255 "v root",
7256 " v dir1",
7257 " > subdir1 <== selected",
7258 " v dir2",
7259 " > subdir2",
7260 " file3.txt",
7261 " file4.txt",
7262 " file5.txt",
7263 " file6.txt",
7264 ],
7265 "Should select next directory when last file is deleted"
7266 );
7267
7268 // Test Case 3: Delete root level file
7269 select_path(&panel, "root/file6.txt", cx);
7270 assert_eq!(
7271 visible_entries_as_strings(&panel, 0..15, cx),
7272 &[
7273 "v root",
7274 " v dir1",
7275 " > subdir1",
7276 " v dir2",
7277 " > subdir2",
7278 " file3.txt",
7279 " file4.txt",
7280 " file5.txt",
7281 " file6.txt <== selected",
7282 ],
7283 "Initial state before deleting root level file"
7284 );
7285
7286 submit_deletion(&panel, cx);
7287 assert_eq!(
7288 visible_entries_as_strings(&panel, 0..15, cx),
7289 &[
7290 "v root",
7291 " v dir1",
7292 " > subdir1",
7293 " v dir2",
7294 " > subdir2",
7295 " file3.txt",
7296 " file4.txt",
7297 " file5.txt <== selected",
7298 ],
7299 "Should select prev entry at root level"
7300 );
7301 }
7302
7303 #[gpui::test]
7304 async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
7305 init_test_with_editor(cx);
7306
7307 let fs = FakeFs::new(cx.executor().clone());
7308 fs.insert_tree(
7309 "/root",
7310 json!({
7311 "dir1": {
7312 "subdir1": {
7313 "a.txt": "",
7314 "b.txt": ""
7315 },
7316 "file1.txt": "",
7317 },
7318 "dir2": {
7319 "subdir2": {
7320 "c.txt": "",
7321 "d.txt": ""
7322 },
7323 "file2.txt": "",
7324 },
7325 "file3.txt": "",
7326 }),
7327 )
7328 .await;
7329
7330 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7331 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7332 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7333 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7334
7335 toggle_expand_dir(&panel, "root/dir1", cx);
7336 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7337 toggle_expand_dir(&panel, "root/dir2", cx);
7338 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
7339
7340 // Test Case 1: Select and delete nested directory with parent
7341 cx.simulate_modifiers_change(gpui::Modifiers {
7342 control: true,
7343 ..Default::default()
7344 });
7345 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
7346 select_path_with_mark(&panel, "root/dir1", cx);
7347
7348 assert_eq!(
7349 visible_entries_as_strings(&panel, 0..15, cx),
7350 &[
7351 "v root",
7352 " v dir1 <== selected <== marked",
7353 " v subdir1 <== marked",
7354 " a.txt",
7355 " b.txt",
7356 " file1.txt",
7357 " v dir2",
7358 " v subdir2",
7359 " c.txt",
7360 " d.txt",
7361 " file2.txt",
7362 " file3.txt",
7363 ],
7364 "Initial state before deleting nested directory with parent"
7365 );
7366
7367 submit_deletion(&panel, cx);
7368 assert_eq!(
7369 visible_entries_as_strings(&panel, 0..15, cx),
7370 &[
7371 "v root",
7372 " v dir2 <== selected",
7373 " v subdir2",
7374 " c.txt",
7375 " d.txt",
7376 " file2.txt",
7377 " file3.txt",
7378 ],
7379 "Should select next directory after deleting directory with parent"
7380 );
7381
7382 // Test Case 2: Select mixed files and directories across levels
7383 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
7384 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
7385 select_path_with_mark(&panel, "root/file3.txt", cx);
7386
7387 assert_eq!(
7388 visible_entries_as_strings(&panel, 0..15, cx),
7389 &[
7390 "v root",
7391 " v dir2",
7392 " v subdir2",
7393 " c.txt <== marked",
7394 " d.txt",
7395 " file2.txt <== marked",
7396 " file3.txt <== selected <== marked",
7397 ],
7398 "Initial state before deleting"
7399 );
7400
7401 submit_deletion(&panel, cx);
7402 assert_eq!(
7403 visible_entries_as_strings(&panel, 0..15, cx),
7404 &[
7405 "v root",
7406 " v dir2 <== selected",
7407 " v subdir2",
7408 " d.txt",
7409 ],
7410 "Should select sibling directory"
7411 );
7412 }
7413
7414 #[gpui::test]
7415 async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
7416 init_test_with_editor(cx);
7417
7418 let fs = FakeFs::new(cx.executor().clone());
7419 fs.insert_tree(
7420 "/root",
7421 json!({
7422 "dir1": {
7423 "subdir1": {
7424 "a.txt": "",
7425 "b.txt": ""
7426 },
7427 "file1.txt": "",
7428 },
7429 "dir2": {
7430 "subdir2": {
7431 "c.txt": "",
7432 "d.txt": ""
7433 },
7434 "file2.txt": "",
7435 },
7436 "file3.txt": "",
7437 "file4.txt": "",
7438 }),
7439 )
7440 .await;
7441
7442 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7443 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7444 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7445 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7446
7447 toggle_expand_dir(&panel, "root/dir1", cx);
7448 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7449 toggle_expand_dir(&panel, "root/dir2", cx);
7450 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
7451
7452 // Test Case 1: Select all root files and directories
7453 cx.simulate_modifiers_change(gpui::Modifiers {
7454 control: true,
7455 ..Default::default()
7456 });
7457 select_path_with_mark(&panel, "root/dir1", cx);
7458 select_path_with_mark(&panel, "root/dir2", cx);
7459 select_path_with_mark(&panel, "root/file3.txt", cx);
7460 select_path_with_mark(&panel, "root/file4.txt", cx);
7461 assert_eq!(
7462 visible_entries_as_strings(&panel, 0..20, cx),
7463 &[
7464 "v root",
7465 " v dir1 <== marked",
7466 " v subdir1",
7467 " a.txt",
7468 " b.txt",
7469 " file1.txt",
7470 " v dir2 <== marked",
7471 " v subdir2",
7472 " c.txt",
7473 " d.txt",
7474 " file2.txt",
7475 " file3.txt <== marked",
7476 " file4.txt <== selected <== marked",
7477 ],
7478 "State before deleting all contents"
7479 );
7480
7481 submit_deletion(&panel, cx);
7482 assert_eq!(
7483 visible_entries_as_strings(&panel, 0..20, cx),
7484 &["v root <== selected"],
7485 "Only empty root directory should remain after deleting all contents"
7486 );
7487 }
7488
7489 #[gpui::test]
7490 async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
7491 init_test_with_editor(cx);
7492
7493 let fs = FakeFs::new(cx.executor().clone());
7494 fs.insert_tree(
7495 "/root",
7496 json!({
7497 "dir1": {
7498 "subdir1": {
7499 "file_a.txt": "content a",
7500 "file_b.txt": "content b",
7501 },
7502 "subdir2": {
7503 "file_c.txt": "content c",
7504 },
7505 "file1.txt": "content 1",
7506 },
7507 "dir2": {
7508 "file2.txt": "content 2",
7509 },
7510 }),
7511 )
7512 .await;
7513
7514 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7515 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7516 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7517 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7518
7519 toggle_expand_dir(&panel, "root/dir1", cx);
7520 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7521 toggle_expand_dir(&panel, "root/dir2", cx);
7522 cx.simulate_modifiers_change(gpui::Modifiers {
7523 control: true,
7524 ..Default::default()
7525 });
7526
7527 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
7528 select_path_with_mark(&panel, "root/dir1", cx);
7529 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
7530 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
7531
7532 assert_eq!(
7533 visible_entries_as_strings(&panel, 0..20, cx),
7534 &[
7535 "v root",
7536 " v dir1 <== marked",
7537 " v subdir1 <== marked",
7538 " file_a.txt <== selected <== marked",
7539 " file_b.txt",
7540 " > subdir2",
7541 " file1.txt",
7542 " v dir2",
7543 " file2.txt",
7544 ],
7545 "State with parent dir, subdir, and file selected"
7546 );
7547 submit_deletion(&panel, cx);
7548 assert_eq!(
7549 visible_entries_as_strings(&panel, 0..20, cx),
7550 &["v root", " v dir2 <== selected", " file2.txt",],
7551 "Only dir2 should remain after deletion"
7552 );
7553 }
7554
7555 #[gpui::test]
7556 async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
7557 init_test_with_editor(cx);
7558
7559 let fs = FakeFs::new(cx.executor().clone());
7560 // First worktree
7561 fs.insert_tree(
7562 "/root1",
7563 json!({
7564 "dir1": {
7565 "file1.txt": "content 1",
7566 "file2.txt": "content 2",
7567 },
7568 "dir2": {
7569 "file3.txt": "content 3",
7570 },
7571 }),
7572 )
7573 .await;
7574
7575 // Second worktree
7576 fs.insert_tree(
7577 "/root2",
7578 json!({
7579 "dir3": {
7580 "file4.txt": "content 4",
7581 "file5.txt": "content 5",
7582 },
7583 "file6.txt": "content 6",
7584 }),
7585 )
7586 .await;
7587
7588 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7589 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7590 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7591 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7592
7593 // Expand all directories for testing
7594 toggle_expand_dir(&panel, "root1/dir1", cx);
7595 toggle_expand_dir(&panel, "root1/dir2", cx);
7596 toggle_expand_dir(&panel, "root2/dir3", cx);
7597
7598 // Test Case 1: Delete files across different worktrees
7599 cx.simulate_modifiers_change(gpui::Modifiers {
7600 control: true,
7601 ..Default::default()
7602 });
7603 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
7604 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
7605
7606 assert_eq!(
7607 visible_entries_as_strings(&panel, 0..20, cx),
7608 &[
7609 "v root1",
7610 " v dir1",
7611 " file1.txt <== marked",
7612 " file2.txt",
7613 " v dir2",
7614 " file3.txt",
7615 "v root2",
7616 " v dir3",
7617 " file4.txt <== selected <== marked",
7618 " file5.txt",
7619 " file6.txt",
7620 ],
7621 "Initial state with files selected from different worktrees"
7622 );
7623
7624 submit_deletion(&panel, cx);
7625 assert_eq!(
7626 visible_entries_as_strings(&panel, 0..20, cx),
7627 &[
7628 "v root1",
7629 " v dir1",
7630 " file2.txt",
7631 " v dir2",
7632 " file3.txt",
7633 "v root2",
7634 " v dir3",
7635 " file5.txt <== selected",
7636 " file6.txt",
7637 ],
7638 "Should select next file in the last worktree after deletion"
7639 );
7640
7641 // Test Case 2: Delete directories from different worktrees
7642 select_path_with_mark(&panel, "root1/dir1", cx);
7643 select_path_with_mark(&panel, "root2/dir3", cx);
7644
7645 assert_eq!(
7646 visible_entries_as_strings(&panel, 0..20, cx),
7647 &[
7648 "v root1",
7649 " v dir1 <== marked",
7650 " file2.txt",
7651 " v dir2",
7652 " file3.txt",
7653 "v root2",
7654 " v dir3 <== selected <== marked",
7655 " file5.txt",
7656 " file6.txt",
7657 ],
7658 "State with directories marked from different worktrees"
7659 );
7660
7661 submit_deletion(&panel, cx);
7662 assert_eq!(
7663 visible_entries_as_strings(&panel, 0..20, cx),
7664 &[
7665 "v root1",
7666 " v dir2",
7667 " file3.txt",
7668 "v root2",
7669 " file6.txt <== selected",
7670 ],
7671 "Should select remaining file in last worktree after directory deletion"
7672 );
7673
7674 // Test Case 4: Delete all remaining files except roots
7675 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
7676 select_path_with_mark(&panel, "root2/file6.txt", cx);
7677
7678 assert_eq!(
7679 visible_entries_as_strings(&panel, 0..20, cx),
7680 &[
7681 "v root1",
7682 " v dir2",
7683 " file3.txt <== marked",
7684 "v root2",
7685 " file6.txt <== selected <== marked",
7686 ],
7687 "State with all remaining files marked"
7688 );
7689
7690 submit_deletion(&panel, cx);
7691 assert_eq!(
7692 visible_entries_as_strings(&panel, 0..20, cx),
7693 &["v root1", " v dir2", "v root2 <== selected"],
7694 "Second parent root should be selected after deleting"
7695 );
7696 }
7697
7698 #[gpui::test]
7699 async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
7700 init_test_with_editor(cx);
7701
7702 let fs = FakeFs::new(cx.executor().clone());
7703 fs.insert_tree(
7704 "/root_b",
7705 json!({
7706 "dir1": {
7707 "file1.txt": "content 1",
7708 "file2.txt": "content 2",
7709 },
7710 }),
7711 )
7712 .await;
7713
7714 fs.insert_tree(
7715 "/root_c",
7716 json!({
7717 "dir2": {},
7718 }),
7719 )
7720 .await;
7721
7722 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
7723 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7724 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7725 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7726
7727 toggle_expand_dir(&panel, "root_b/dir1", cx);
7728 toggle_expand_dir(&panel, "root_c/dir2", cx);
7729
7730 cx.simulate_modifiers_change(gpui::Modifiers {
7731 control: true,
7732 ..Default::default()
7733 });
7734 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
7735 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
7736
7737 assert_eq!(
7738 visible_entries_as_strings(&panel, 0..20, cx),
7739 &[
7740 "v root_b",
7741 " v dir1",
7742 " file1.txt <== marked",
7743 " file2.txt <== selected <== marked",
7744 "v root_c",
7745 " v dir2",
7746 ],
7747 "Initial state with files marked in root_b"
7748 );
7749
7750 submit_deletion(&panel, cx);
7751 assert_eq!(
7752 visible_entries_as_strings(&panel, 0..20, cx),
7753 &[
7754 "v root_b",
7755 " v dir1 <== selected",
7756 "v root_c",
7757 " v dir2",
7758 ],
7759 "After deletion in root_b as it's last deletion, selection should be in root_b"
7760 );
7761
7762 select_path_with_mark(&panel, "root_c/dir2", cx);
7763
7764 submit_deletion(&panel, cx);
7765 assert_eq!(
7766 visible_entries_as_strings(&panel, 0..20, cx),
7767 &["v root_b", " v dir1", "v root_c <== selected",],
7768 "After deleting from root_c, it should remain in root_c"
7769 );
7770 }
7771
7772 fn toggle_expand_dir(
7773 panel: &View<ProjectPanel>,
7774 path: impl AsRef<Path>,
7775 cx: &mut VisualTestContext,
7776 ) {
7777 let path = path.as_ref();
7778 panel.update(cx, |panel, cx| {
7779 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7780 let worktree = worktree.read(cx);
7781 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7782 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
7783 panel.toggle_expanded(entry_id, cx);
7784 return;
7785 }
7786 }
7787 panic!("no worktree for path {:?}", path);
7788 });
7789 }
7790
7791 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
7792 let path = path.as_ref();
7793 panel.update(cx, |panel, cx| {
7794 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7795 let worktree = worktree.read(cx);
7796 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7797 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
7798 panel.selection = Some(crate::SelectedEntry {
7799 worktree_id: worktree.id(),
7800 entry_id,
7801 });
7802 return;
7803 }
7804 }
7805 panic!("no worktree for path {:?}", path);
7806 });
7807 }
7808
7809 fn select_path_with_mark(
7810 panel: &View<ProjectPanel>,
7811 path: impl AsRef<Path>,
7812 cx: &mut VisualTestContext,
7813 ) {
7814 let path = path.as_ref();
7815 panel.update(cx, |panel, cx| {
7816 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7817 let worktree = worktree.read(cx);
7818 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7819 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
7820 let entry = crate::SelectedEntry {
7821 worktree_id: worktree.id(),
7822 entry_id,
7823 };
7824 if !panel.marked_entries.contains(&entry) {
7825 panel.marked_entries.insert(entry);
7826 }
7827 panel.selection = Some(entry);
7828 return;
7829 }
7830 }
7831 panic!("no worktree for path {:?}", path);
7832 });
7833 }
7834
7835 fn find_project_entry(
7836 panel: &View<ProjectPanel>,
7837 path: impl AsRef<Path>,
7838 cx: &mut VisualTestContext,
7839 ) -> Option<ProjectEntryId> {
7840 let path = path.as_ref();
7841 panel.update(cx, |panel, cx| {
7842 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7843 let worktree = worktree.read(cx);
7844 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7845 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
7846 }
7847 }
7848 panic!("no worktree for path {path:?}");
7849 })
7850 }
7851
7852 fn visible_entries_as_strings(
7853 panel: &View<ProjectPanel>,
7854 range: Range<usize>,
7855 cx: &mut VisualTestContext,
7856 ) -> Vec<String> {
7857 let mut result = Vec::new();
7858 let mut project_entries = HashSet::default();
7859 let mut has_editor = false;
7860
7861 panel.update(cx, |panel, cx| {
7862 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
7863 if details.is_editing {
7864 assert!(!has_editor, "duplicate editor entry");
7865 has_editor = true;
7866 } else {
7867 assert!(
7868 project_entries.insert(project_entry),
7869 "duplicate project entry {:?} {:?}",
7870 project_entry,
7871 details
7872 );
7873 }
7874
7875 let indent = " ".repeat(details.depth);
7876 let icon = if details.kind.is_dir() {
7877 if details.is_expanded {
7878 "v "
7879 } else {
7880 "> "
7881 }
7882 } else {
7883 " "
7884 };
7885 let name = if details.is_editing {
7886 format!("[EDITOR: '{}']", details.filename)
7887 } else if details.is_processing {
7888 format!("[PROCESSING: '{}']", details.filename)
7889 } else {
7890 details.filename.clone()
7891 };
7892 let selected = if details.is_selected {
7893 " <== selected"
7894 } else {
7895 ""
7896 };
7897 let marked = if details.is_marked {
7898 " <== marked"
7899 } else {
7900 ""
7901 };
7902
7903 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
7904 });
7905 });
7906
7907 result
7908 }
7909
7910 fn init_test(cx: &mut TestAppContext) {
7911 cx.update(|cx| {
7912 let settings_store = SettingsStore::test(cx);
7913 cx.set_global(settings_store);
7914 init_settings(cx);
7915 theme::init(theme::LoadThemes::JustBase, cx);
7916 language::init(cx);
7917 editor::init_settings(cx);
7918 crate::init((), cx);
7919 workspace::init_settings(cx);
7920 client::init_settings(cx);
7921 Project::init_settings(cx);
7922
7923 cx.update_global::<SettingsStore, _>(|store, cx| {
7924 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7925 project_panel_settings.auto_fold_dirs = Some(false);
7926 });
7927 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7928 worktree_settings.file_scan_exclusions = Some(Vec::new());
7929 });
7930 });
7931 });
7932 }
7933
7934 fn init_test_with_editor(cx: &mut TestAppContext) {
7935 cx.update(|cx| {
7936 let app_state = AppState::test(cx);
7937 theme::init(theme::LoadThemes::JustBase, cx);
7938 init_settings(cx);
7939 language::init(cx);
7940 editor::init(cx);
7941 crate::init((), cx);
7942 workspace::init(app_state.clone(), cx);
7943 Project::init_settings(cx);
7944
7945 cx.update_global::<SettingsStore, _>(|store, cx| {
7946 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7947 project_panel_settings.auto_fold_dirs = Some(false);
7948 });
7949 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7950 worktree_settings.file_scan_exclusions = Some(Vec::new());
7951 });
7952 });
7953 });
7954 }
7955
7956 fn ensure_single_file_is_opened(
7957 window: &WindowHandle<Workspace>,
7958 expected_path: &str,
7959 cx: &mut TestAppContext,
7960 ) {
7961 window
7962 .update(cx, |workspace, cx| {
7963 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
7964 assert_eq!(worktrees.len(), 1);
7965 let worktree_id = worktrees[0].read(cx).id();
7966
7967 let open_project_paths = workspace
7968 .panes()
7969 .iter()
7970 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
7971 .collect::<Vec<_>>();
7972 assert_eq!(
7973 open_project_paths,
7974 vec![ProjectPath {
7975 worktree_id,
7976 path: Arc::from(Path::new(expected_path))
7977 }],
7978 "Should have opened file, selected in project panel"
7979 );
7980 })
7981 .unwrap();
7982 }
7983
7984 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
7985 assert!(
7986 !cx.has_pending_prompt(),
7987 "Should have no prompts before the deletion"
7988 );
7989 panel.update(cx, |panel, cx| {
7990 panel.delete(&Delete { skip_prompt: false }, cx)
7991 });
7992 assert!(
7993 cx.has_pending_prompt(),
7994 "Should have a prompt after the deletion"
7995 );
7996 cx.simulate_prompt_answer(0);
7997 assert!(
7998 !cx.has_pending_prompt(),
7999 "Should have no prompts after prompt was replied to"
8000 );
8001 cx.executor().run_until_parked();
8002 }
8003
8004 fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
8005 assert!(
8006 !cx.has_pending_prompt(),
8007 "Should have no prompts before the deletion"
8008 );
8009 panel.update(cx, |panel, cx| {
8010 panel.delete(&Delete { skip_prompt: true }, cx)
8011 });
8012 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
8013 cx.executor().run_until_parked();
8014 }
8015
8016 fn ensure_no_open_items_and_panes(
8017 workspace: &WindowHandle<Workspace>,
8018 cx: &mut VisualTestContext,
8019 ) {
8020 assert!(
8021 !cx.has_pending_prompt(),
8022 "Should have no prompts after deletion operation closes the file"
8023 );
8024 workspace
8025 .read_with(cx, |workspace, cx| {
8026 let open_project_paths = workspace
8027 .panes()
8028 .iter()
8029 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
8030 .collect::<Vec<_>>();
8031 assert!(
8032 open_project_paths.is_empty(),
8033 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
8034 );
8035 })
8036 .unwrap();
8037 }
8038
8039 struct TestProjectItemView {
8040 focus_handle: FocusHandle,
8041 path: ProjectPath,
8042 }
8043
8044 struct TestProjectItem {
8045 path: ProjectPath,
8046 }
8047
8048 impl project::ProjectItem for TestProjectItem {
8049 fn try_open(
8050 _project: &Model<Project>,
8051 path: &ProjectPath,
8052 cx: &mut AppContext,
8053 ) -> Option<Task<gpui::Result<Model<Self>>>> {
8054 let path = path.clone();
8055 Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
8056 }
8057
8058 fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
8059 None
8060 }
8061
8062 fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
8063 Some(self.path.clone())
8064 }
8065
8066 fn is_dirty(&self) -> bool {
8067 false
8068 }
8069 }
8070
8071 impl ProjectItem for TestProjectItemView {
8072 type Item = TestProjectItem;
8073
8074 fn for_project_item(
8075 _: Model<Project>,
8076 project_item: Model<Self::Item>,
8077 cx: &mut ViewContext<Self>,
8078 ) -> Self
8079 where
8080 Self: Sized,
8081 {
8082 Self {
8083 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
8084 focus_handle: cx.focus_handle(),
8085 }
8086 }
8087 }
8088
8089 impl Item for TestProjectItemView {
8090 type Event = ();
8091 }
8092
8093 impl EventEmitter<()> for TestProjectItemView {}
8094
8095 impl FocusableView for TestProjectItemView {
8096 fn focus_handle(&self, _: &AppContext) -> FocusHandle {
8097 self.focus_handle.clone()
8098 }
8099 }
8100
8101 impl Render for TestProjectItemView {
8102 fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
8103 Empty
8104 }
8105 }
8106}