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