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