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