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_entity(&cx.entity()),
449 horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
450 .parent_entity(&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 let dir_path = if entry.is_dir() {
2261 entry.path.clone()
2262 } else {
2263 // entry is a file, use its parent directory
2264 match entry.path.parent() {
2265 Some(parent) => Arc::from(parent),
2266 None => {
2267 // File at root, open search with empty filter
2268 self.workspace
2269 .update(cx, |workspace, cx| {
2270 search::ProjectSearchView::new_search_in_directory(
2271 workspace,
2272 Path::new(""),
2273 window,
2274 cx,
2275 );
2276 })
2277 .ok();
2278 return;
2279 }
2280 }
2281 };
2282
2283 let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
2284 let dir_path = if include_root {
2285 let mut full_path = PathBuf::from(worktree.read(cx).root_name());
2286 full_path.push(&dir_path);
2287 Arc::from(full_path)
2288 } else {
2289 dir_path
2290 };
2291
2292 self.workspace
2293 .update(cx, |workspace, cx| {
2294 search::ProjectSearchView::new_search_in_directory(
2295 workspace, &dir_path, window, cx,
2296 );
2297 })
2298 .ok();
2299 }
2300 }
2301
2302 fn move_entry(
2303 &mut self,
2304 entry_to_move: ProjectEntryId,
2305 destination: ProjectEntryId,
2306 destination_is_file: bool,
2307 cx: &mut Context<Self>,
2308 ) {
2309 if self
2310 .project
2311 .read(cx)
2312 .entry_is_worktree_root(entry_to_move, cx)
2313 {
2314 self.move_worktree_root(entry_to_move, destination, cx)
2315 } else {
2316 self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx)
2317 }
2318 }
2319
2320 fn move_worktree_root(
2321 &mut self,
2322 entry_to_move: ProjectEntryId,
2323 destination: ProjectEntryId,
2324 cx: &mut Context<Self>,
2325 ) {
2326 self.project.update(cx, |project, cx| {
2327 let Some(worktree_to_move) = project.worktree_for_entry(entry_to_move, cx) else {
2328 return;
2329 };
2330 let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else {
2331 return;
2332 };
2333
2334 let worktree_id = worktree_to_move.read(cx).id();
2335 let destination_id = destination_worktree.read(cx).id();
2336
2337 project
2338 .move_worktree(worktree_id, destination_id, cx)
2339 .log_err();
2340 });
2341 }
2342
2343 fn move_worktree_entry(
2344 &mut self,
2345 entry_to_move: ProjectEntryId,
2346 destination: ProjectEntryId,
2347 destination_is_file: bool,
2348 cx: &mut Context<Self>,
2349 ) {
2350 if entry_to_move == destination {
2351 return;
2352 }
2353
2354 let destination_worktree = self.project.update(cx, |project, cx| {
2355 let entry_path = project.path_for_entry(entry_to_move, cx)?;
2356 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
2357
2358 let mut destination_path = destination_entry_path.as_ref();
2359 if destination_is_file {
2360 destination_path = destination_path.parent()?;
2361 }
2362
2363 let mut new_path = destination_path.to_path_buf();
2364 new_path.push(entry_path.path.file_name()?);
2365 if new_path != entry_path.path.as_ref() {
2366 let task = project.rename_entry(entry_to_move, new_path, cx);
2367 cx.foreground_executor().spawn(task).detach_and_log_err(cx);
2368 }
2369
2370 project.worktree_id_for_entry(destination, cx)
2371 });
2372
2373 if let Some(destination_worktree) = destination_worktree {
2374 self.expand_entry(destination_worktree, destination, cx);
2375 }
2376 }
2377
2378 fn index_for_selection(&self, selection: SelectedEntry) -> Option<(usize, usize, usize)> {
2379 let mut entry_index = 0;
2380 let mut visible_entries_index = 0;
2381 for (worktree_index, (worktree_id, worktree_entries, _)) in
2382 self.visible_entries.iter().enumerate()
2383 {
2384 if *worktree_id == selection.worktree_id {
2385 for entry in worktree_entries {
2386 if entry.id == selection.entry_id {
2387 return Some((worktree_index, entry_index, visible_entries_index));
2388 } else {
2389 visible_entries_index += 1;
2390 entry_index += 1;
2391 }
2392 }
2393 break;
2394 } else {
2395 visible_entries_index += worktree_entries.len();
2396 }
2397 }
2398 None
2399 }
2400
2401 fn disjoint_entries(&self, cx: &App) -> BTreeSet<SelectedEntry> {
2402 let marked_entries = self.effective_entries();
2403 let mut sanitized_entries = BTreeSet::new();
2404 if marked_entries.is_empty() {
2405 return sanitized_entries;
2406 }
2407
2408 let project = self.project.read(cx);
2409 let marked_entries_by_worktree: HashMap<WorktreeId, Vec<SelectedEntry>> = marked_entries
2410 .into_iter()
2411 .filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx))
2412 .fold(HashMap::default(), |mut map, entry| {
2413 map.entry(entry.worktree_id).or_default().push(entry);
2414 map
2415 });
2416
2417 for (worktree_id, marked_entries) in marked_entries_by_worktree {
2418 if let Some(worktree) = project.worktree_for_id(worktree_id, cx) {
2419 let worktree = worktree.read(cx);
2420 let marked_dir_paths = marked_entries
2421 .iter()
2422 .filter_map(|entry| {
2423 worktree.entry_for_id(entry.entry_id).and_then(|entry| {
2424 if entry.is_dir() {
2425 Some(entry.path.as_ref())
2426 } else {
2427 None
2428 }
2429 })
2430 })
2431 .collect::<BTreeSet<_>>();
2432
2433 sanitized_entries.extend(marked_entries.into_iter().filter(|entry| {
2434 let Some(entry_info) = worktree.entry_for_id(entry.entry_id) else {
2435 return false;
2436 };
2437 let entry_path = entry_info.path.as_ref();
2438 let inside_marked_dir = marked_dir_paths.iter().any(|&marked_dir_path| {
2439 entry_path != marked_dir_path && entry_path.starts_with(marked_dir_path)
2440 });
2441 !inside_marked_dir
2442 }));
2443 }
2444 }
2445
2446 sanitized_entries
2447 }
2448
2449 fn effective_entries(&self) -> BTreeSet<SelectedEntry> {
2450 if let Some(selection) = self.selection {
2451 let selection = SelectedEntry {
2452 entry_id: self.resolve_entry(selection.entry_id),
2453 worktree_id: selection.worktree_id,
2454 };
2455
2456 // Default to using just the selected item when nothing is marked.
2457 if self.marked_entries.is_empty() {
2458 return BTreeSet::from([selection]);
2459 }
2460
2461 // Allow operating on the selected item even when something else is marked,
2462 // making it easier to perform one-off actions without clearing a mark.
2463 if self.marked_entries.len() == 1 && !self.marked_entries.contains(&selection) {
2464 return BTreeSet::from([selection]);
2465 }
2466 }
2467
2468 // Return only marked entries since we've already handled special cases where
2469 // only selection should take precedence. At this point, marked entries may or
2470 // may not include the current selection, which is intentional.
2471 self.marked_entries
2472 .iter()
2473 .map(|entry| SelectedEntry {
2474 entry_id: self.resolve_entry(entry.entry_id),
2475 worktree_id: entry.worktree_id,
2476 })
2477 .collect::<BTreeSet<_>>()
2478 }
2479
2480 /// Finds the currently selected subentry for a given leaf entry id. If a given entry
2481 /// has no ancestors, the project entry ID that's passed in is returned as-is.
2482 fn resolve_entry(&self, id: ProjectEntryId) -> ProjectEntryId {
2483 self.ancestors
2484 .get(&id)
2485 .and_then(|ancestors| {
2486 if ancestors.current_ancestor_depth == 0 {
2487 return None;
2488 }
2489 ancestors.ancestors.get(ancestors.current_ancestor_depth)
2490 })
2491 .copied()
2492 .unwrap_or(id)
2493 }
2494
2495 pub fn selected_entry<'a>(&self, cx: &'a App) -> Option<(&'a Worktree, &'a project::Entry)> {
2496 let (worktree, entry) = self.selected_entry_handle(cx)?;
2497 Some((worktree.read(cx), entry))
2498 }
2499
2500 /// Compared to selected_entry, this function resolves to the currently
2501 /// selected subentry if dir auto-folding is enabled.
2502 fn selected_sub_entry<'a>(
2503 &self,
2504 cx: &'a App,
2505 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
2506 let (worktree, mut entry) = self.selected_entry_handle(cx)?;
2507
2508 let resolved_id = self.resolve_entry(entry.id);
2509 if resolved_id != entry.id {
2510 let worktree = worktree.read(cx);
2511 entry = worktree.entry_for_id(resolved_id)?;
2512 }
2513 Some((worktree, entry))
2514 }
2515 fn selected_entry_handle<'a>(
2516 &self,
2517 cx: &'a App,
2518 ) -> Option<(Entity<Worktree>, &'a project::Entry)> {
2519 let selection = self.selection?;
2520 let project = self.project.read(cx);
2521 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
2522 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
2523 Some((worktree, entry))
2524 }
2525
2526 fn expand_to_selection(&mut self, cx: &mut Context<Self>) -> Option<()> {
2527 let (worktree, entry) = self.selected_entry(cx)?;
2528 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
2529
2530 for path in entry.path.ancestors() {
2531 let Some(entry) = worktree.entry_for_path(path) else {
2532 continue;
2533 };
2534 if entry.is_dir() {
2535 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
2536 expanded_dir_ids.insert(idx, entry.id);
2537 }
2538 }
2539 }
2540
2541 Some(())
2542 }
2543
2544 fn update_visible_entries(
2545 &mut self,
2546 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
2547 cx: &mut Context<Self>,
2548 ) {
2549 let auto_collapse_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
2550 let project = self.project.read(cx);
2551 self.last_worktree_root_id = project
2552 .visible_worktrees(cx)
2553 .next_back()
2554 .and_then(|worktree| worktree.read(cx).root_entry())
2555 .map(|entry| entry.id);
2556
2557 let old_ancestors = std::mem::take(&mut self.ancestors);
2558 self.visible_entries.clear();
2559 let mut max_width_item = None;
2560 for worktree in project.visible_worktrees(cx) {
2561 let snapshot = worktree.read(cx).snapshot();
2562 let worktree_id = snapshot.id();
2563
2564 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
2565 hash_map::Entry::Occupied(e) => e.into_mut(),
2566 hash_map::Entry::Vacant(e) => {
2567 // The first time a worktree's root entry becomes available,
2568 // mark that root entry as expanded.
2569 if let Some(entry) = snapshot.root_entry() {
2570 e.insert(vec![entry.id]).as_slice()
2571 } else {
2572 &[]
2573 }
2574 }
2575 };
2576
2577 let mut new_entry_parent_id = None;
2578 let mut new_entry_kind = EntryKind::Dir;
2579 if let Some(edit_state) = &self.edit_state {
2580 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry() {
2581 new_entry_parent_id = Some(edit_state.entry_id);
2582 new_entry_kind = if edit_state.is_dir {
2583 EntryKind::Dir
2584 } else {
2585 EntryKind::File
2586 };
2587 }
2588 }
2589
2590 let mut visible_worktree_entries = Vec::new();
2591 let mut entry_iter = snapshot.entries(true, 0).with_git_statuses();
2592 let mut auto_folded_ancestors = vec![];
2593 while let Some(entry) = entry_iter.entry() {
2594 if auto_collapse_dirs && entry.kind.is_dir() {
2595 auto_folded_ancestors.push(entry.id);
2596 if !self.unfolded_dir_ids.contains(&entry.id) {
2597 if let Some(root_path) = snapshot.root_entry() {
2598 let mut child_entries = snapshot.child_entries(&entry.path);
2599 if let Some(child) = child_entries.next() {
2600 if entry.path != root_path.path
2601 && child_entries.next().is_none()
2602 && child.kind.is_dir()
2603 {
2604 entry_iter.advance();
2605
2606 continue;
2607 }
2608 }
2609 }
2610 }
2611 let depth = old_ancestors
2612 .get(&entry.id)
2613 .map(|ancestor| ancestor.current_ancestor_depth)
2614 .unwrap_or_default()
2615 .min(auto_folded_ancestors.len());
2616 if let Some(edit_state) = &mut self.edit_state {
2617 if edit_state.entry_id == entry.id {
2618 edit_state.depth = depth;
2619 }
2620 }
2621 let mut ancestors = std::mem::take(&mut auto_folded_ancestors);
2622 if ancestors.len() > 1 {
2623 ancestors.reverse();
2624 self.ancestors.insert(
2625 entry.id,
2626 FoldedAncestors {
2627 current_ancestor_depth: depth,
2628 ancestors,
2629 },
2630 );
2631 }
2632 }
2633 auto_folded_ancestors.clear();
2634 visible_worktree_entries.push(entry.to_owned());
2635 let precedes_new_entry = if let Some(new_entry_id) = new_entry_parent_id {
2636 entry.id == new_entry_id || {
2637 self.ancestors.get(&entry.id).map_or(false, |entries| {
2638 entries
2639 .ancestors
2640 .iter()
2641 .any(|entry_id| *entry_id == new_entry_id)
2642 })
2643 }
2644 } else {
2645 false
2646 };
2647 if precedes_new_entry {
2648 visible_worktree_entries.push(GitEntry {
2649 entry: Entry {
2650 id: NEW_ENTRY_ID,
2651 kind: new_entry_kind,
2652 path: entry.path.join("\0").into(),
2653 inode: 0,
2654 mtime: entry.mtime,
2655 size: entry.size,
2656 is_ignored: entry.is_ignored,
2657 is_external: false,
2658 is_private: false,
2659 is_always_included: entry.is_always_included,
2660 canonical_path: entry.canonical_path.clone(),
2661 char_bag: entry.char_bag,
2662 is_fifo: entry.is_fifo,
2663 },
2664 git_summary: entry.git_summary,
2665 });
2666 }
2667 let worktree_abs_path = worktree.read(cx).abs_path();
2668 let (depth, path) = if Some(entry.entry) == worktree.read(cx).root_entry() {
2669 let Some(path_name) = worktree_abs_path
2670 .file_name()
2671 .with_context(|| {
2672 format!("Worktree abs path has no file name, root entry: {entry:?}")
2673 })
2674 .log_err()
2675 else {
2676 continue;
2677 };
2678 let path = Arc::from(Path::new(path_name));
2679 let depth = 0;
2680 (depth, path)
2681 } else if entry.is_file() {
2682 let Some(path_name) = entry
2683 .path
2684 .file_name()
2685 .with_context(|| format!("Non-root entry has no file name: {entry:?}"))
2686 .log_err()
2687 else {
2688 continue;
2689 };
2690 let path = Arc::from(Path::new(path_name));
2691 let depth = entry.path.ancestors().count() - 1;
2692 (depth, path)
2693 } else {
2694 let path = self
2695 .ancestors
2696 .get(&entry.id)
2697 .and_then(|ancestors| {
2698 let outermost_ancestor = ancestors.ancestors.last()?;
2699 let root_folded_entry = worktree
2700 .read(cx)
2701 .entry_for_id(*outermost_ancestor)?
2702 .path
2703 .as_ref();
2704 entry
2705 .path
2706 .strip_prefix(root_folded_entry)
2707 .ok()
2708 .and_then(|suffix| {
2709 let full_path = Path::new(root_folded_entry.file_name()?);
2710 Some(Arc::<Path>::from(full_path.join(suffix)))
2711 })
2712 })
2713 .or_else(|| entry.path.file_name().map(Path::new).map(Arc::from))
2714 .unwrap_or_else(|| entry.path.clone());
2715 let depth = path.components().count();
2716 (depth, path)
2717 };
2718 let width_estimate = item_width_estimate(
2719 depth,
2720 path.to_string_lossy().chars().count(),
2721 entry.canonical_path.is_some(),
2722 );
2723
2724 match max_width_item.as_mut() {
2725 Some((id, worktree_id, width)) => {
2726 if *width < width_estimate {
2727 *id = entry.id;
2728 *worktree_id = worktree.read(cx).id();
2729 *width = width_estimate;
2730 }
2731 }
2732 None => {
2733 max_width_item = Some((entry.id, worktree.read(cx).id(), width_estimate))
2734 }
2735 }
2736
2737 if expanded_dir_ids.binary_search(&entry.id).is_err()
2738 && entry_iter.advance_to_sibling()
2739 {
2740 continue;
2741 }
2742 entry_iter.advance();
2743 }
2744
2745 project::sort_worktree_entries(&mut visible_worktree_entries);
2746
2747 self.visible_entries
2748 .push((worktree_id, visible_worktree_entries, OnceCell::new()));
2749 }
2750
2751 if let Some((project_entry_id, worktree_id, _)) = max_width_item {
2752 let mut visited_worktrees_length = 0;
2753 let index = self.visible_entries.iter().find_map(|(id, entries, _)| {
2754 if worktree_id == *id {
2755 entries
2756 .iter()
2757 .position(|entry| entry.id == project_entry_id)
2758 } else {
2759 visited_worktrees_length += entries.len();
2760 None
2761 }
2762 });
2763 if let Some(index) = index {
2764 self.max_width_item_index = Some(visited_worktrees_length + index);
2765 }
2766 }
2767 if let Some((worktree_id, entry_id)) = new_selected_entry {
2768 self.selection = Some(SelectedEntry {
2769 worktree_id,
2770 entry_id,
2771 });
2772 }
2773 }
2774
2775 fn expand_entry(
2776 &mut self,
2777 worktree_id: WorktreeId,
2778 entry_id: ProjectEntryId,
2779 cx: &mut Context<Self>,
2780 ) {
2781 self.project.update(cx, |project, cx| {
2782 if let Some((worktree, expanded_dir_ids)) = project
2783 .worktree_for_id(worktree_id, cx)
2784 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
2785 {
2786 project.expand_entry(worktree_id, entry_id, cx);
2787 let worktree = worktree.read(cx);
2788
2789 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
2790 loop {
2791 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
2792 expanded_dir_ids.insert(ix, entry.id);
2793 }
2794
2795 if let Some(parent_entry) =
2796 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
2797 {
2798 entry = parent_entry;
2799 } else {
2800 break;
2801 }
2802 }
2803 }
2804 }
2805 });
2806 }
2807
2808 fn drop_external_files(
2809 &mut self,
2810 paths: &[PathBuf],
2811 entry_id: ProjectEntryId,
2812 window: &mut Window,
2813 cx: &mut Context<Self>,
2814 ) {
2815 let mut paths: Vec<Arc<Path>> = paths.iter().map(|path| Arc::from(path.clone())).collect();
2816
2817 let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
2818
2819 let Some((target_directory, worktree)) = maybe!({
2820 let worktree = self.project.read(cx).worktree_for_entry(entry_id, cx)?;
2821 let entry = worktree.read(cx).entry_for_id(entry_id)?;
2822 let path = worktree.read(cx).absolutize(&entry.path).ok()?;
2823 let target_directory = if path.is_dir() {
2824 path
2825 } else {
2826 path.parent()?.to_path_buf()
2827 };
2828 Some((target_directory, worktree))
2829 }) else {
2830 return;
2831 };
2832
2833 let mut paths_to_replace = Vec::new();
2834 for path in &paths {
2835 if let Some(name) = path.file_name() {
2836 let mut target_path = target_directory.clone();
2837 target_path.push(name);
2838 if target_path.exists() {
2839 paths_to_replace.push((name.to_string_lossy().to_string(), path.clone()));
2840 }
2841 }
2842 }
2843
2844 cx.spawn_in(window, |this, mut cx| {
2845 async move {
2846 for (filename, original_path) in &paths_to_replace {
2847 let answer = cx.update(|window, cx| {
2848 window
2849 .prompt(
2850 PromptLevel::Info,
2851 format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
2852 None,
2853 &["Replace", "Cancel"],
2854 cx,
2855 )
2856 })?.await?;
2857
2858 if answer == 1 {
2859 if let Some(item_idx) = paths.iter().position(|p| p == original_path) {
2860 paths.remove(item_idx);
2861 }
2862 }
2863 }
2864
2865 if paths.is_empty() {
2866 return Ok(());
2867 }
2868
2869 let task = worktree.update(&mut cx, |worktree, cx| {
2870 worktree.copy_external_entries(target_directory, paths, true, cx)
2871 })?;
2872
2873 let opened_entries = task.await?;
2874 this.update(&mut cx, |this, cx| {
2875 if open_file_after_drop && !opened_entries.is_empty() {
2876 this.open_entry(opened_entries[0], true, false, cx);
2877 }
2878 })
2879 }
2880 .log_err()
2881 })
2882 .detach();
2883 }
2884
2885 fn drag_onto(
2886 &mut self,
2887 selections: &DraggedSelection,
2888 target_entry_id: ProjectEntryId,
2889 is_file: bool,
2890 window: &mut Window,
2891 cx: &mut Context<Self>,
2892 ) {
2893 let should_copy = window.modifiers().alt;
2894 if should_copy {
2895 let _ = maybe!({
2896 let project = self.project.read(cx);
2897 let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
2898 let worktree_id = target_worktree.read(cx).id();
2899 let target_entry = target_worktree
2900 .read(cx)
2901 .entry_for_id(target_entry_id)?
2902 .clone();
2903
2904 let mut copy_tasks = Vec::new();
2905 let mut disambiguation_range = None;
2906 for selection in selections.items() {
2907 let (new_path, new_disambiguation_range) = self.create_paste_path(
2908 selection,
2909 (target_worktree.clone(), &target_entry),
2910 cx,
2911 )?;
2912
2913 let task = self.project.update(cx, |project, cx| {
2914 project.copy_entry(selection.entry_id, None, new_path, cx)
2915 });
2916 copy_tasks.push(task);
2917 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
2918 }
2919
2920 let item_count = copy_tasks.len();
2921
2922 cx.spawn_in(window, |project_panel, mut cx| async move {
2923 let mut last_succeed = None;
2924 for task in copy_tasks.into_iter() {
2925 if let Some(Some(entry)) = task.await.log_err() {
2926 last_succeed = Some(entry.id);
2927 }
2928 }
2929 // update selection
2930 if let Some(entry_id) = last_succeed {
2931 project_panel
2932 .update_in(&mut cx, |project_panel, window, cx| {
2933 project_panel.selection = Some(SelectedEntry {
2934 worktree_id,
2935 entry_id,
2936 });
2937
2938 // if only one entry was dragged and it was disambiguated, open the rename editor
2939 if item_count == 1 && disambiguation_range.is_some() {
2940 project_panel.rename_impl(disambiguation_range, window, cx);
2941 }
2942 })
2943 .ok();
2944 }
2945 })
2946 .detach();
2947 Some(())
2948 });
2949 } else {
2950 for selection in selections.items() {
2951 self.move_entry(selection.entry_id, target_entry_id, is_file, cx);
2952 }
2953 }
2954 }
2955
2956 fn index_for_entry(
2957 &self,
2958 entry_id: ProjectEntryId,
2959 worktree_id: WorktreeId,
2960 ) -> Option<(usize, usize, usize)> {
2961 let mut worktree_ix = 0;
2962 let mut total_ix = 0;
2963 for (current_worktree_id, visible_worktree_entries, _) in &self.visible_entries {
2964 if worktree_id != *current_worktree_id {
2965 total_ix += visible_worktree_entries.len();
2966 worktree_ix += 1;
2967 continue;
2968 }
2969
2970 return visible_worktree_entries
2971 .iter()
2972 .enumerate()
2973 .find(|(_, entry)| entry.id == entry_id)
2974 .map(|(ix, _)| (worktree_ix, ix, total_ix + ix));
2975 }
2976 None
2977 }
2978
2979 fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, GitEntryRef)> {
2980 let mut offset = 0;
2981 for (worktree_id, visible_worktree_entries, _) in &self.visible_entries {
2982 if visible_worktree_entries.len() > offset + index {
2983 return visible_worktree_entries
2984 .get(index)
2985 .map(|entry| (*worktree_id, entry.to_ref()));
2986 }
2987 offset += visible_worktree_entries.len();
2988 }
2989 None
2990 }
2991
2992 fn iter_visible_entries(
2993 &self,
2994 range: Range<usize>,
2995 window: &mut Window,
2996 cx: &mut Context<ProjectPanel>,
2997 mut callback: impl FnMut(&Entry, &HashSet<Arc<Path>>, &mut Window, &mut Context<ProjectPanel>),
2998 ) {
2999 let mut ix = 0;
3000 for (_, visible_worktree_entries, entries_paths) in &self.visible_entries {
3001 if ix >= range.end {
3002 return;
3003 }
3004
3005 if ix + visible_worktree_entries.len() <= range.start {
3006 ix += visible_worktree_entries.len();
3007 continue;
3008 }
3009
3010 let end_ix = range.end.min(ix + visible_worktree_entries.len());
3011 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
3012 let entries = entries_paths.get_or_init(|| {
3013 visible_worktree_entries
3014 .iter()
3015 .map(|e| (e.path.clone()))
3016 .collect()
3017 });
3018 for entry in visible_worktree_entries[entry_range].iter() {
3019 callback(&entry, entries, window, cx);
3020 }
3021 ix = end_ix;
3022 }
3023 }
3024
3025 fn for_each_visible_entry(
3026 &self,
3027 range: Range<usize>,
3028 window: &mut Window,
3029 cx: &mut Context<ProjectPanel>,
3030 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut Window, &mut Context<ProjectPanel>),
3031 ) {
3032 let mut ix = 0;
3033 for (worktree_id, visible_worktree_entries, entries_paths) in &self.visible_entries {
3034 if ix >= range.end {
3035 return;
3036 }
3037
3038 if ix + visible_worktree_entries.len() <= range.start {
3039 ix += visible_worktree_entries.len();
3040 continue;
3041 }
3042
3043 let end_ix = range.end.min(ix + visible_worktree_entries.len());
3044 let (git_status_setting, show_file_icons, show_folder_icons) = {
3045 let settings = ProjectPanelSettings::get_global(cx);
3046 (
3047 settings.git_status,
3048 settings.file_icons,
3049 settings.folder_icons,
3050 )
3051 };
3052 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
3053 let snapshot = worktree.read(cx).snapshot();
3054 let root_name = OsStr::new(snapshot.root_name());
3055 let expanded_entry_ids = self
3056 .expanded_dir_ids
3057 .get(&snapshot.id())
3058 .map(Vec::as_slice)
3059 .unwrap_or(&[]);
3060
3061 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
3062 let entries = entries_paths.get_or_init(|| {
3063 visible_worktree_entries
3064 .iter()
3065 .map(|e| (e.path.clone()))
3066 .collect()
3067 });
3068 for entry in visible_worktree_entries[entry_range].iter() {
3069 let status = git_status_setting
3070 .then_some(entry.git_summary)
3071 .unwrap_or_default();
3072 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
3073 let icon = match entry.kind {
3074 EntryKind::File => {
3075 if show_file_icons {
3076 FileIcons::get_icon(&entry.path, cx)
3077 } else {
3078 None
3079 }
3080 }
3081 _ => {
3082 if show_folder_icons {
3083 FileIcons::get_folder_icon(is_expanded, cx)
3084 } else {
3085 FileIcons::get_chevron_icon(is_expanded, cx)
3086 }
3087 }
3088 };
3089
3090 let (depth, difference) =
3091 ProjectPanel::calculate_depth_and_difference(&entry, entries);
3092
3093 let filename = match difference {
3094 diff if diff > 1 => entry
3095 .path
3096 .iter()
3097 .skip(entry.path.components().count() - diff)
3098 .collect::<PathBuf>()
3099 .to_str()
3100 .unwrap_or_default()
3101 .to_string(),
3102 _ => entry
3103 .path
3104 .file_name()
3105 .map(|name| name.to_string_lossy().into_owned())
3106 .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
3107 };
3108 let selection = SelectedEntry {
3109 worktree_id: snapshot.id(),
3110 entry_id: entry.id,
3111 };
3112
3113 let is_marked = self.marked_entries.contains(&selection);
3114
3115 let diagnostic_severity = self
3116 .diagnostics
3117 .get(&(*worktree_id, entry.path.to_path_buf()))
3118 .cloned();
3119
3120 let filename_text_color =
3121 entry_git_aware_label_color(status, entry.is_ignored, is_marked);
3122
3123 let mut details = EntryDetails {
3124 filename,
3125 icon,
3126 path: entry.path.clone(),
3127 depth,
3128 kind: entry.kind,
3129 is_ignored: entry.is_ignored,
3130 is_expanded,
3131 is_selected: self.selection == Some(selection),
3132 is_marked,
3133 is_editing: false,
3134 is_processing: false,
3135 is_cut: self
3136 .clipboard
3137 .as_ref()
3138 .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
3139 filename_text_color,
3140 diagnostic_severity,
3141 git_status: status,
3142 is_private: entry.is_private,
3143 worktree_id: *worktree_id,
3144 canonical_path: entry.canonical_path.clone(),
3145 };
3146
3147 if let Some(edit_state) = &self.edit_state {
3148 let is_edited_entry = if edit_state.is_new_entry() {
3149 entry.id == NEW_ENTRY_ID
3150 } else {
3151 entry.id == edit_state.entry_id
3152 || self
3153 .ancestors
3154 .get(&entry.id)
3155 .is_some_and(|auto_folded_dirs| {
3156 auto_folded_dirs
3157 .ancestors
3158 .iter()
3159 .any(|entry_id| *entry_id == edit_state.entry_id)
3160 })
3161 };
3162
3163 if is_edited_entry {
3164 if let Some(processing_filename) = &edit_state.processing_filename {
3165 details.is_processing = true;
3166 if let Some(ancestors) = edit_state
3167 .leaf_entry_id
3168 .and_then(|entry| self.ancestors.get(&entry))
3169 {
3170 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;
3171 let all_components = ancestors.ancestors.len();
3172
3173 let prefix_components = all_components - position;
3174 let suffix_components = position.checked_sub(1);
3175 let mut previous_components =
3176 Path::new(&details.filename).components();
3177 let mut new_path = previous_components
3178 .by_ref()
3179 .take(prefix_components)
3180 .collect::<PathBuf>();
3181 if let Some(last_component) =
3182 Path::new(processing_filename).components().last()
3183 {
3184 new_path.push(last_component);
3185 previous_components.next();
3186 }
3187
3188 if let Some(_) = suffix_components {
3189 new_path.push(previous_components);
3190 }
3191 if let Some(str) = new_path.to_str() {
3192 details.filename.clear();
3193 details.filename.push_str(str);
3194 }
3195 } else {
3196 details.filename.clear();
3197 details.filename.push_str(processing_filename);
3198 }
3199 } else {
3200 if edit_state.is_new_entry() {
3201 details.filename.clear();
3202 }
3203 details.is_editing = true;
3204 }
3205 }
3206 }
3207
3208 callback(entry.id, details, window, cx);
3209 }
3210 }
3211 ix = end_ix;
3212 }
3213 }
3214
3215 fn find_entry_in_worktree(
3216 &self,
3217 worktree_id: WorktreeId,
3218 reverse_search: bool,
3219 only_visible_entries: bool,
3220 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3221 cx: &mut Context<Self>,
3222 ) -> Option<GitEntry> {
3223 if only_visible_entries {
3224 let entries = self
3225 .visible_entries
3226 .iter()
3227 .find_map(|(tree_id, entries, _)| {
3228 if worktree_id == *tree_id {
3229 Some(entries)
3230 } else {
3231 None
3232 }
3233 })?
3234 .clone();
3235
3236 return utils::ReversibleIterable::new(entries.iter(), reverse_search)
3237 .find(|ele| predicate(ele.to_ref(), worktree_id))
3238 .cloned();
3239 }
3240
3241 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
3242 worktree.update(cx, |tree, _| {
3243 utils::ReversibleIterable::new(
3244 tree.entries(true, 0usize).with_git_statuses(),
3245 reverse_search,
3246 )
3247 .find_single_ended(|ele| predicate(*ele, worktree_id))
3248 .map(|ele| ele.to_owned())
3249 })
3250 }
3251
3252 fn find_entry(
3253 &self,
3254 start: Option<&SelectedEntry>,
3255 reverse_search: bool,
3256 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3257 cx: &mut Context<Self>,
3258 ) -> Option<SelectedEntry> {
3259 let mut worktree_ids: Vec<_> = self
3260 .visible_entries
3261 .iter()
3262 .map(|(worktree_id, _, _)| *worktree_id)
3263 .collect();
3264
3265 let mut last_found: Option<SelectedEntry> = None;
3266
3267 if let Some(start) = start {
3268 let worktree = self
3269 .project
3270 .read(cx)
3271 .worktree_for_id(start.worktree_id, cx)?;
3272
3273 let search = worktree.update(cx, |tree, _| {
3274 let entry = tree.entry_for_id(start.entry_id)?;
3275 let root_entry = tree.root_entry()?;
3276 let tree_id = tree.id();
3277
3278 let mut first_iter = tree
3279 .traverse_from_path(true, true, true, entry.path.as_ref())
3280 .with_git_statuses();
3281
3282 if reverse_search {
3283 first_iter.next();
3284 }
3285
3286 let first = first_iter
3287 .enumerate()
3288 .take_until(|(count, entry)| entry.entry == root_entry && *count != 0usize)
3289 .map(|(_, entry)| entry)
3290 .find(|ele| predicate(*ele, tree_id))
3291 .map(|ele| ele.to_owned());
3292
3293 let second_iter = tree.entries(true, 0usize).with_git_statuses();
3294
3295 let second = if reverse_search {
3296 second_iter
3297 .take_until(|ele| ele.id == start.entry_id)
3298 .filter(|ele| predicate(*ele, tree_id))
3299 .last()
3300 .map(|ele| ele.to_owned())
3301 } else {
3302 second_iter
3303 .take_while(|ele| ele.id != start.entry_id)
3304 .filter(|ele| predicate(*ele, tree_id))
3305 .last()
3306 .map(|ele| ele.to_owned())
3307 };
3308
3309 if reverse_search {
3310 Some((second, first))
3311 } else {
3312 Some((first, second))
3313 }
3314 });
3315
3316 if let Some((first, second)) = search {
3317 let first = first.map(|entry| SelectedEntry {
3318 worktree_id: start.worktree_id,
3319 entry_id: entry.id,
3320 });
3321
3322 let second = second.map(|entry| SelectedEntry {
3323 worktree_id: start.worktree_id,
3324 entry_id: entry.id,
3325 });
3326
3327 if first.is_some() {
3328 return first;
3329 }
3330 last_found = second;
3331
3332 let idx = worktree_ids
3333 .iter()
3334 .enumerate()
3335 .find(|(_, ele)| **ele == start.worktree_id)
3336 .map(|(idx, _)| idx);
3337
3338 if let Some(idx) = idx {
3339 worktree_ids.rotate_left(idx + 1usize);
3340 worktree_ids.pop();
3341 }
3342 }
3343 }
3344
3345 for tree_id in worktree_ids.into_iter() {
3346 if let Some(found) =
3347 self.find_entry_in_worktree(tree_id, reverse_search, false, &predicate, cx)
3348 {
3349 return Some(SelectedEntry {
3350 worktree_id: tree_id,
3351 entry_id: found.id,
3352 });
3353 }
3354 }
3355
3356 last_found
3357 }
3358
3359 fn find_visible_entry(
3360 &self,
3361 start: Option<&SelectedEntry>,
3362 reverse_search: bool,
3363 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3364 cx: &mut Context<Self>,
3365 ) -> Option<SelectedEntry> {
3366 let mut worktree_ids: Vec<_> = self
3367 .visible_entries
3368 .iter()
3369 .map(|(worktree_id, _, _)| *worktree_id)
3370 .collect();
3371
3372 let mut last_found: Option<SelectedEntry> = None;
3373
3374 if let Some(start) = start {
3375 let entries = self
3376 .visible_entries
3377 .iter()
3378 .find(|(worktree_id, _, _)| *worktree_id == start.worktree_id)
3379 .map(|(_, entries, _)| entries)?;
3380
3381 let mut start_idx = entries
3382 .iter()
3383 .enumerate()
3384 .find(|(_, ele)| ele.id == start.entry_id)
3385 .map(|(idx, _)| idx)?;
3386
3387 if reverse_search {
3388 start_idx = start_idx.saturating_add(1usize);
3389 }
3390
3391 let (left, right) = entries.split_at_checked(start_idx)?;
3392
3393 let (first_iter, second_iter) = if reverse_search {
3394 (
3395 utils::ReversibleIterable::new(left.iter(), reverse_search),
3396 utils::ReversibleIterable::new(right.iter(), reverse_search),
3397 )
3398 } else {
3399 (
3400 utils::ReversibleIterable::new(right.iter(), reverse_search),
3401 utils::ReversibleIterable::new(left.iter(), reverse_search),
3402 )
3403 };
3404
3405 let first_search = first_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3406 let second_search = second_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3407
3408 if first_search.is_some() {
3409 return first_search.map(|entry| SelectedEntry {
3410 worktree_id: start.worktree_id,
3411 entry_id: entry.id,
3412 });
3413 }
3414
3415 last_found = second_search.map(|entry| SelectedEntry {
3416 worktree_id: start.worktree_id,
3417 entry_id: entry.id,
3418 });
3419
3420 let idx = worktree_ids
3421 .iter()
3422 .enumerate()
3423 .find(|(_, ele)| **ele == start.worktree_id)
3424 .map(|(idx, _)| idx);
3425
3426 if let Some(idx) = idx {
3427 worktree_ids.rotate_left(idx + 1usize);
3428 worktree_ids.pop();
3429 }
3430 }
3431
3432 for tree_id in worktree_ids.into_iter() {
3433 if let Some(found) =
3434 self.find_entry_in_worktree(tree_id, reverse_search, true, &predicate, cx)
3435 {
3436 return Some(SelectedEntry {
3437 worktree_id: tree_id,
3438 entry_id: found.id,
3439 });
3440 }
3441 }
3442
3443 last_found
3444 }
3445
3446 fn calculate_depth_and_difference(
3447 entry: &Entry,
3448 visible_worktree_entries: &HashSet<Arc<Path>>,
3449 ) -> (usize, usize) {
3450 let (depth, difference) = entry
3451 .path
3452 .ancestors()
3453 .skip(1) // Skip the entry itself
3454 .find_map(|ancestor| {
3455 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
3456 let entry_path_components_count = entry.path.components().count();
3457 let parent_path_components_count = parent_entry.components().count();
3458 let difference = entry_path_components_count - parent_path_components_count;
3459 let depth = parent_entry
3460 .ancestors()
3461 .skip(1)
3462 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
3463 .count();
3464 Some((depth + 1, difference))
3465 } else {
3466 None
3467 }
3468 })
3469 .unwrap_or((0, 0));
3470
3471 (depth, difference)
3472 }
3473
3474 fn render_entry(
3475 &self,
3476 entry_id: ProjectEntryId,
3477 details: EntryDetails,
3478 window: &mut Window,
3479 cx: &mut Context<Self>,
3480 ) -> Stateful<Div> {
3481 const GROUP_NAME: &str = "project_entry";
3482
3483 let kind = details.kind;
3484 let settings = ProjectPanelSettings::get_global(cx);
3485 let show_editor = details.is_editing && !details.is_processing;
3486
3487 let selection = SelectedEntry {
3488 worktree_id: details.worktree_id,
3489 entry_id,
3490 };
3491
3492 let is_marked = self.marked_entries.contains(&selection);
3493 let is_active = self
3494 .selection
3495 .map_or(false, |selection| selection.entry_id == entry_id);
3496
3497 let file_name = details.filename.clone();
3498
3499 let mut icon = details.icon.clone();
3500 if settings.file_icons && show_editor && details.kind.is_file() {
3501 let filename = self.filename_editor.read(cx).text(cx);
3502 if filename.len() > 2 {
3503 icon = FileIcons::get_icon(Path::new(&filename), cx);
3504 }
3505 }
3506
3507 let filename_text_color = details.filename_text_color;
3508 let diagnostic_severity = details.diagnostic_severity;
3509 let item_colors = get_item_color(cx);
3510
3511 let canonical_path = details
3512 .canonical_path
3513 .as_ref()
3514 .map(|f| f.to_string_lossy().to_string());
3515 let path = details.path.clone();
3516
3517 let depth = details.depth;
3518 let worktree_id = details.worktree_id;
3519 let selections = Arc::new(self.marked_entries.clone());
3520 let is_local = self.project.read(cx).is_local();
3521
3522 let dragged_selection = DraggedSelection {
3523 active_selection: selection,
3524 marked_selections: selections,
3525 };
3526
3527 let bg_color = if is_marked || is_active {
3528 item_colors.marked_active
3529 } else {
3530 item_colors.default
3531 };
3532
3533 let bg_hover_color = if self.mouse_down || is_marked || is_active {
3534 item_colors.marked_active
3535 } else if !is_active {
3536 item_colors.hover
3537 } else {
3538 item_colors.default
3539 };
3540
3541 let border_color =
3542 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
3543 item_colors.focused
3544 } else {
3545 bg_color
3546 };
3547
3548 let border_hover_color =
3549 if !self.mouse_down && is_active && self.focus_handle.contains_focused(window, cx) {
3550 item_colors.focused
3551 } else {
3552 bg_hover_color
3553 };
3554
3555 let folded_directory_drag_target = self.folded_directory_drag_target;
3556
3557 div()
3558 .id(entry_id.to_proto() as usize)
3559 .group(GROUP_NAME)
3560 .cursor_pointer()
3561 .rounded_none()
3562 .bg(bg_color)
3563 .border_1()
3564 .border_r_2()
3565 .border_color(border_color)
3566 .hover(|style| style.bg(bg_hover_color).border_color(border_hover_color))
3567 .when(is_local, |div| {
3568 div.on_drag_move::<ExternalPaths>(cx.listener(
3569 move |this, event: &DragMoveEvent<ExternalPaths>, _, cx| {
3570 if event.bounds.contains(&event.event.position) {
3571 if this.last_external_paths_drag_over_entry == Some(entry_id) {
3572 return;
3573 }
3574 this.last_external_paths_drag_over_entry = Some(entry_id);
3575 this.marked_entries.clear();
3576
3577 let Some((worktree, path, entry)) = maybe!({
3578 let worktree = this
3579 .project
3580 .read(cx)
3581 .worktree_for_id(selection.worktree_id, cx)?;
3582 let worktree = worktree.read(cx);
3583 let abs_path = worktree.absolutize(&path).log_err()?;
3584 let path = if abs_path.is_dir() {
3585 path.as_ref()
3586 } else {
3587 path.parent()?
3588 };
3589 let entry = worktree.entry_for_path(path)?;
3590 Some((worktree, path, entry))
3591 }) else {
3592 return;
3593 };
3594
3595 this.marked_entries.insert(SelectedEntry {
3596 entry_id: entry.id,
3597 worktree_id: worktree.id(),
3598 });
3599
3600 for entry in worktree.child_entries(path) {
3601 this.marked_entries.insert(SelectedEntry {
3602 entry_id: entry.id,
3603 worktree_id: worktree.id(),
3604 });
3605 }
3606
3607 cx.notify();
3608 }
3609 },
3610 ))
3611 .on_drop(cx.listener(
3612 move |this, external_paths: &ExternalPaths, window, cx| {
3613 this.hover_scroll_task.take();
3614 this.last_external_paths_drag_over_entry = None;
3615 this.marked_entries.clear();
3616 this.drop_external_files(external_paths.paths(), entry_id, window, cx);
3617 cx.stop_propagation();
3618 },
3619 ))
3620 })
3621 .on_drag_move::<DraggedSelection>(cx.listener(
3622 move |this, event: &DragMoveEvent<DraggedSelection>, window, cx| {
3623 if event.bounds.contains(&event.event.position) {
3624 if this.last_selection_drag_over_entry == Some(entry_id) {
3625 return;
3626 }
3627 this.last_selection_drag_over_entry = Some(entry_id);
3628 this.hover_expand_task.take();
3629
3630 if !kind.is_dir()
3631 || this
3632 .expanded_dir_ids
3633 .get(&details.worktree_id)
3634 .map_or(false, |ids| ids.binary_search(&entry_id).is_ok())
3635 {
3636 return;
3637 }
3638
3639 let bounds = event.bounds;
3640 this.hover_expand_task =
3641 Some(cx.spawn_in(window, |this, mut cx| async move {
3642 cx.background_executor()
3643 .timer(Duration::from_millis(500))
3644 .await;
3645 this.update_in(&mut cx, |this, window, cx| {
3646 this.hover_expand_task.take();
3647 if this.last_selection_drag_over_entry == Some(entry_id)
3648 && bounds.contains(&window.mouse_position())
3649 {
3650 this.expand_entry(worktree_id, entry_id, cx);
3651 this.update_visible_entries(
3652 Some((worktree_id, entry_id)),
3653 cx,
3654 );
3655 cx.notify();
3656 }
3657 })
3658 .ok();
3659 }));
3660 }
3661 },
3662 ))
3663 .on_drag(
3664 dragged_selection,
3665 move |selection, click_offset, _window, cx| {
3666 cx.new(|_| DraggedProjectEntryView {
3667 details: details.clone(),
3668 click_offset,
3669 selection: selection.active_selection,
3670 selections: selection.marked_selections.clone(),
3671 })
3672 },
3673 )
3674 .drag_over::<DraggedSelection>(move |style, _, _, _| {
3675 if folded_directory_drag_target.is_some() {
3676 return style;
3677 }
3678 style.bg(item_colors.drag_over)
3679 })
3680 .on_drop(
3681 cx.listener(move |this, selections: &DraggedSelection, window, cx| {
3682 this.hover_scroll_task.take();
3683 this.hover_expand_task.take();
3684 if folded_directory_drag_target.is_some() {
3685 return;
3686 }
3687 this.drag_onto(selections, entry_id, kind.is_file(), window, cx);
3688 }),
3689 )
3690 .on_mouse_down(
3691 MouseButton::Left,
3692 cx.listener(move |this, _, _, cx| {
3693 this.mouse_down = true;
3694 cx.propagate();
3695 }),
3696 )
3697 .on_click(
3698 cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
3699 if event.down.button == MouseButton::Right
3700 || event.down.first_mouse
3701 || show_editor
3702 {
3703 return;
3704 }
3705 if event.down.button == MouseButton::Left {
3706 this.mouse_down = false;
3707 }
3708 cx.stop_propagation();
3709
3710 if let Some(selection) = this.selection.filter(|_| event.modifiers().shift) {
3711 let current_selection = this.index_for_selection(selection);
3712 let clicked_entry = SelectedEntry {
3713 entry_id,
3714 worktree_id,
3715 };
3716 let target_selection = this.index_for_selection(clicked_entry);
3717 if let Some(((_, _, source_index), (_, _, target_index))) =
3718 current_selection.zip(target_selection)
3719 {
3720 let range_start = source_index.min(target_index);
3721 let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
3722 let mut new_selections = BTreeSet::new();
3723 this.for_each_visible_entry(
3724 range_start..range_end,
3725 window,
3726 cx,
3727 |entry_id, details, _, _| {
3728 new_selections.insert(SelectedEntry {
3729 entry_id,
3730 worktree_id: details.worktree_id,
3731 });
3732 },
3733 );
3734
3735 this.marked_entries = this
3736 .marked_entries
3737 .union(&new_selections)
3738 .cloned()
3739 .collect();
3740
3741 this.selection = Some(clicked_entry);
3742 this.marked_entries.insert(clicked_entry);
3743 }
3744 } else if event.modifiers().secondary() {
3745 if event.down.click_count > 1 {
3746 this.split_entry(entry_id, cx);
3747 } else {
3748 this.selection = Some(selection);
3749 if !this.marked_entries.insert(selection) {
3750 this.marked_entries.remove(&selection);
3751 }
3752 }
3753 } else if kind.is_dir() {
3754 this.marked_entries.clear();
3755 if event.modifiers().alt {
3756 this.toggle_expand_all(entry_id, window, cx);
3757 } else {
3758 this.toggle_expanded(entry_id, window, cx);
3759 }
3760 } else {
3761 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
3762 let click_count = event.up.click_count;
3763 let focus_opened_item = !preview_tabs_enabled || click_count > 1;
3764 let allow_preview = preview_tabs_enabled && click_count == 1;
3765 this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
3766 }
3767 }),
3768 )
3769 .child(
3770 ListItem::new(entry_id.to_proto() as usize)
3771 .indent_level(depth)
3772 .indent_step_size(px(settings.indent_size))
3773 .spacing(match settings.entry_spacing {
3774 project_panel_settings::EntrySpacing::Comfortable => ListItemSpacing::Dense,
3775 project_panel_settings::EntrySpacing::Standard => {
3776 ListItemSpacing::ExtraDense
3777 }
3778 })
3779 .selectable(false)
3780 .when_some(canonical_path, |this, path| {
3781 this.end_slot::<AnyElement>(
3782 div()
3783 .id("symlink_icon")
3784 .pr_3()
3785 .tooltip(move |window, cx| {
3786 Tooltip::with_meta(
3787 path.to_string(),
3788 None,
3789 "Symbolic Link",
3790 window,
3791 cx,
3792 )
3793 })
3794 .child(
3795 Icon::new(IconName::ArrowUpRight)
3796 .size(IconSize::Indicator)
3797 .color(filename_text_color),
3798 )
3799 .into_any_element(),
3800 )
3801 })
3802 .child(if let Some(icon) = &icon {
3803 if let Some((_, decoration_color)) =
3804 entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
3805 {
3806 let is_warning = diagnostic_severity
3807 .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
3808 .unwrap_or(false);
3809 div().child(
3810 DecoratedIcon::new(
3811 Icon::from_path(icon.clone()).color(Color::Muted),
3812 Some(
3813 IconDecoration::new(
3814 if kind.is_file() {
3815 if is_warning {
3816 IconDecorationKind::Triangle
3817 } else {
3818 IconDecorationKind::X
3819 }
3820 } else {
3821 IconDecorationKind::Dot
3822 },
3823 bg_color,
3824 cx,
3825 )
3826 .group_name(Some(GROUP_NAME.into()))
3827 .knockout_hover_color(bg_hover_color)
3828 .color(decoration_color.color(cx))
3829 .position(Point {
3830 x: px(-2.),
3831 y: px(-2.),
3832 }),
3833 ),
3834 )
3835 .into_any_element(),
3836 )
3837 } else {
3838 h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
3839 }
3840 } else {
3841 if let Some((icon_name, color)) =
3842 entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
3843 {
3844 h_flex()
3845 .size(IconSize::default().rems())
3846 .child(Icon::new(icon_name).color(color).size(IconSize::Small))
3847 } else {
3848 h_flex()
3849 .size(IconSize::default().rems())
3850 .invisible()
3851 .flex_none()
3852 }
3853 })
3854 .child(
3855 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
3856 h_flex().h_6().w_full().child(editor.clone())
3857 } else {
3858 h_flex().h_6().map(|mut this| {
3859 if let Some(folded_ancestors) = self.ancestors.get(&entry_id) {
3860 let components = Path::new(&file_name)
3861 .components()
3862 .map(|comp| {
3863 let comp_str =
3864 comp.as_os_str().to_string_lossy().into_owned();
3865 comp_str
3866 })
3867 .collect::<Vec<_>>();
3868
3869 let components_len = components.len();
3870 let active_index = components_len
3871 - 1
3872 - folded_ancestors.current_ancestor_depth;
3873 const DELIMITER: SharedString =
3874 SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
3875 for (index, component) in components.into_iter().enumerate() {
3876 if index != 0 {
3877 let delimiter_target_index = index - 1;
3878 let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - delimiter_target_index).cloned();
3879 this = this.child(
3880 div()
3881 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| {
3882 this.hover_scroll_task.take();
3883 this.folded_directory_drag_target = None;
3884 if let Some(target_entry_id) = target_entry_id {
3885 this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
3886 }
3887 }))
3888 .on_drag_move(cx.listener(
3889 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
3890 if event.bounds.contains(&event.event.position) {
3891 this.folded_directory_drag_target = Some(
3892 FoldedDirectoryDragTarget {
3893 entry_id,
3894 index: delimiter_target_index,
3895 is_delimiter_target: true,
3896 }
3897 );
3898 } else {
3899 let is_current_target = this.folded_directory_drag_target
3900 .map_or(false, |target|
3901 target.entry_id == entry_id &&
3902 target.index == delimiter_target_index &&
3903 target.is_delimiter_target
3904 );
3905 if is_current_target {
3906 this.folded_directory_drag_target = None;
3907 }
3908 }
3909
3910 },
3911 ))
3912 .child(
3913 Label::new(DELIMITER.clone())
3914 .single_line()
3915 .color(filename_text_color)
3916 )
3917 );
3918 }
3919 let id = SharedString::from(format!(
3920 "project_panel_path_component_{}_{index}",
3921 entry_id.to_usize()
3922 ));
3923 let label = div()
3924 .id(id)
3925 .on_click(cx.listener(move |this, _, _, cx| {
3926 if index != active_index {
3927 if let Some(folds) =
3928 this.ancestors.get_mut(&entry_id)
3929 {
3930 folds.current_ancestor_depth =
3931 components_len - 1 - index;
3932 cx.notify();
3933 }
3934 }
3935 }))
3936 .when(index != components_len - 1, |div|{
3937 let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned();
3938 div
3939 .on_drag_move(cx.listener(
3940 move |this, event: &DragMoveEvent<DraggedSelection>, _, _| {
3941 if event.bounds.contains(&event.event.position) {
3942 this.folded_directory_drag_target = Some(
3943 FoldedDirectoryDragTarget {
3944 entry_id,
3945 index,
3946 is_delimiter_target: false,
3947 }
3948 );
3949 } else {
3950 let is_current_target = this.folded_directory_drag_target
3951 .as_ref()
3952 .map_or(false, |target|
3953 target.entry_id == entry_id &&
3954 target.index == index &&
3955 !target.is_delimiter_target
3956 );
3957 if is_current_target {
3958 this.folded_directory_drag_target = None;
3959 }
3960 }
3961 },
3962 ))
3963 .on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| {
3964 this.hover_scroll_task.take();
3965 this.folded_directory_drag_target = None;
3966 if let Some(target_entry_id) = target_entry_id {
3967 this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx);
3968 }
3969 }))
3970 .when(folded_directory_drag_target.map_or(false, |target|
3971 target.entry_id == entry_id &&
3972 target.index == index
3973 ), |this| {
3974 this.bg(item_colors.drag_over)
3975 })
3976 })
3977 .child(
3978 Label::new(component)
3979 .single_line()
3980 .color(filename_text_color)
3981 .when(
3982 index == active_index
3983 && (is_active || is_marked),
3984 |this| this.underline(true),
3985 ),
3986 );
3987
3988 this = this.child(label);
3989 }
3990
3991 this
3992 } else {
3993 this.child(
3994 Label::new(file_name)
3995 .single_line()
3996 .color(filename_text_color),
3997 )
3998 }
3999 })
4000 }
4001 .ml_1(),
4002 )
4003 .on_secondary_mouse_down(cx.listener(
4004 move |this, event: &MouseDownEvent, window, cx| {
4005 // Stop propagation to prevent the catch-all context menu for the project
4006 // panel from being deployed.
4007 cx.stop_propagation();
4008 // Some context menu actions apply to all marked entries. If the user
4009 // right-clicks on an entry that is not marked, they may not realize the
4010 // action applies to multiple entries. To avoid inadvertent changes, all
4011 // entries are unmarked.
4012 if !this.marked_entries.contains(&selection) {
4013 this.marked_entries.clear();
4014 }
4015 this.deploy_context_menu(event.position, entry_id, window, cx);
4016 },
4017 ))
4018 .overflow_x(),
4019 )
4020 }
4021
4022 fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
4023 if !Self::should_show_scrollbar(cx)
4024 || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
4025 {
4026 return None;
4027 }
4028 Some(
4029 div()
4030 .occlude()
4031 .id("project-panel-vertical-scroll")
4032 .on_mouse_move(cx.listener(|_, _, _, cx| {
4033 cx.notify();
4034 cx.stop_propagation()
4035 }))
4036 .on_hover(|_, _, cx| {
4037 cx.stop_propagation();
4038 })
4039 .on_any_mouse_down(|_, _, cx| {
4040 cx.stop_propagation();
4041 })
4042 .on_mouse_up(
4043 MouseButton::Left,
4044 cx.listener(|this, _, window, cx| {
4045 if !this.vertical_scrollbar_state.is_dragging()
4046 && !this.focus_handle.contains_focused(window, cx)
4047 {
4048 this.hide_scrollbar(window, cx);
4049 cx.notify();
4050 }
4051
4052 cx.stop_propagation();
4053 }),
4054 )
4055 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4056 cx.notify();
4057 }))
4058 .h_full()
4059 .absolute()
4060 .right_1()
4061 .top_1()
4062 .bottom_1()
4063 .w(px(12.))
4064 .cursor_default()
4065 .children(Scrollbar::vertical(
4066 // percentage as f32..end_offset as f32,
4067 self.vertical_scrollbar_state.clone(),
4068 )),
4069 )
4070 }
4071
4072 fn render_horizontal_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
4073 if !Self::should_show_scrollbar(cx)
4074 || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
4075 {
4076 return None;
4077 }
4078
4079 let scroll_handle = self.scroll_handle.0.borrow();
4080 let longest_item_width = scroll_handle
4081 .last_item_size
4082 .filter(|size| size.contents.width > size.item.width)?
4083 .contents
4084 .width
4085 .0 as f64;
4086 if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
4087 return None;
4088 }
4089
4090 Some(
4091 div()
4092 .occlude()
4093 .id("project-panel-horizontal-scroll")
4094 .on_mouse_move(cx.listener(|_, _, _, cx| {
4095 cx.notify();
4096 cx.stop_propagation()
4097 }))
4098 .on_hover(|_, _, cx| {
4099 cx.stop_propagation();
4100 })
4101 .on_any_mouse_down(|_, _, cx| {
4102 cx.stop_propagation();
4103 })
4104 .on_mouse_up(
4105 MouseButton::Left,
4106 cx.listener(|this, _, window, cx| {
4107 if !this.horizontal_scrollbar_state.is_dragging()
4108 && !this.focus_handle.contains_focused(window, cx)
4109 {
4110 this.hide_scrollbar(window, cx);
4111 cx.notify();
4112 }
4113
4114 cx.stop_propagation();
4115 }),
4116 )
4117 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
4118 cx.notify();
4119 }))
4120 .w_full()
4121 .absolute()
4122 .right_1()
4123 .left_1()
4124 .bottom_1()
4125 .h(px(12.))
4126 .cursor_default()
4127 .when(self.width.is_some(), |this| {
4128 this.children(Scrollbar::horizontal(
4129 self.horizontal_scrollbar_state.clone(),
4130 ))
4131 }),
4132 )
4133 }
4134
4135 fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
4136 let mut dispatch_context = KeyContext::new_with_defaults();
4137 dispatch_context.add("ProjectPanel");
4138 dispatch_context.add("menu");
4139
4140 let identifier = if self.filename_editor.focus_handle(cx).is_focused(window) {
4141 "editing"
4142 } else {
4143 "not_editing"
4144 };
4145
4146 dispatch_context.add(identifier);
4147 dispatch_context
4148 }
4149
4150 fn should_show_scrollbar(cx: &App) -> bool {
4151 let show = ProjectPanelSettings::get_global(cx)
4152 .scrollbar
4153 .show
4154 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4155 match show {
4156 ShowScrollbar::Auto => true,
4157 ShowScrollbar::System => true,
4158 ShowScrollbar::Always => true,
4159 ShowScrollbar::Never => false,
4160 }
4161 }
4162
4163 fn should_autohide_scrollbar(cx: &App) -> bool {
4164 let show = ProjectPanelSettings::get_global(cx)
4165 .scrollbar
4166 .show
4167 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
4168 match show {
4169 ShowScrollbar::Auto => true,
4170 ShowScrollbar::System => cx
4171 .try_global::<ScrollbarAutoHide>()
4172 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
4173 ShowScrollbar::Always => false,
4174 ShowScrollbar::Never => true,
4175 }
4176 }
4177
4178 fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4179 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
4180 if !Self::should_autohide_scrollbar(cx) {
4181 return;
4182 }
4183 self.hide_scrollbar_task = Some(cx.spawn_in(window, |panel, mut cx| async move {
4184 cx.background_executor()
4185 .timer(SCROLLBAR_SHOW_INTERVAL)
4186 .await;
4187 panel
4188 .update(&mut cx, |panel, cx| {
4189 panel.show_scrollbar = false;
4190 cx.notify();
4191 })
4192 .log_err();
4193 }))
4194 }
4195
4196 fn reveal_entry(
4197 &mut self,
4198 project: Entity<Project>,
4199 entry_id: ProjectEntryId,
4200 skip_ignored: bool,
4201 cx: &mut Context<Self>,
4202 ) {
4203 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
4204 let worktree = worktree.read(cx);
4205 if skip_ignored
4206 && worktree
4207 .entry_for_id(entry_id)
4208 .map_or(true, |entry| entry.is_ignored)
4209 {
4210 return;
4211 }
4212
4213 let worktree_id = worktree.id();
4214 self.expand_entry(worktree_id, entry_id, cx);
4215 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
4216
4217 if self.marked_entries.len() == 1
4218 && self
4219 .marked_entries
4220 .first()
4221 .filter(|entry| entry.entry_id == entry_id)
4222 .is_none()
4223 {
4224 self.marked_entries.clear();
4225 }
4226 self.autoscroll(cx);
4227 cx.notify();
4228 }
4229 }
4230
4231 fn find_active_indent_guide(
4232 &self,
4233 indent_guides: &[IndentGuideLayout],
4234 cx: &App,
4235 ) -> Option<usize> {
4236 let (worktree, entry) = self.selected_entry(cx)?;
4237
4238 // Find the parent entry of the indent guide, this will either be the
4239 // expanded folder we have selected, or the parent of the currently
4240 // selected file/collapsed directory
4241 let mut entry = entry;
4242 loop {
4243 let is_expanded_dir = entry.is_dir()
4244 && self
4245 .expanded_dir_ids
4246 .get(&worktree.id())
4247 .map(|ids| ids.binary_search(&entry.id).is_ok())
4248 .unwrap_or(false);
4249 if is_expanded_dir {
4250 break;
4251 }
4252 entry = worktree.entry_for_path(&entry.path.parent()?)?;
4253 }
4254
4255 let (active_indent_range, depth) = {
4256 let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
4257 let child_paths = &self.visible_entries[worktree_ix].1;
4258 let mut child_count = 0;
4259 let depth = entry.path.ancestors().count();
4260 while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
4261 if entry.path.ancestors().count() <= depth {
4262 break;
4263 }
4264 child_count += 1;
4265 }
4266
4267 let start = ix + 1;
4268 let end = start + child_count;
4269
4270 let (_, entries, paths) = &self.visible_entries[worktree_ix];
4271 let visible_worktree_entries =
4272 paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect());
4273
4274 // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
4275 let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
4276 (start..end, depth)
4277 };
4278
4279 let candidates = indent_guides
4280 .iter()
4281 .enumerate()
4282 .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
4283
4284 for (i, indent) in candidates {
4285 // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
4286 if active_indent_range.start <= indent.offset.y + indent.length
4287 && indent.offset.y <= active_indent_range.end
4288 {
4289 return Some(i);
4290 }
4291 }
4292 None
4293 }
4294}
4295
4296fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
4297 const ICON_SIZE_FACTOR: usize = 2;
4298 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
4299 if is_symlink {
4300 item_width += ICON_SIZE_FACTOR;
4301 }
4302 item_width
4303}
4304
4305impl Render for ProjectPanel {
4306 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4307 let has_worktree = !self.visible_entries.is_empty();
4308 let project = self.project.read(cx);
4309 let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
4310 let show_indent_guides =
4311 ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
4312 let is_local = project.is_local();
4313
4314 if has_worktree {
4315 let item_count = self
4316 .visible_entries
4317 .iter()
4318 .map(|(_, worktree_entries, _)| worktree_entries.len())
4319 .sum();
4320
4321 fn handle_drag_move_scroll<T: 'static>(
4322 this: &mut ProjectPanel,
4323 e: &DragMoveEvent<T>,
4324 window: &mut Window,
4325 cx: &mut Context<ProjectPanel>,
4326 ) {
4327 if !e.bounds.contains(&e.event.position) {
4328 return;
4329 }
4330 this.hover_scroll_task.take();
4331 let panel_height = e.bounds.size.height;
4332 if panel_height <= px(0.) {
4333 return;
4334 }
4335
4336 let event_offset = e.event.position.y - e.bounds.origin.y;
4337 // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
4338 let hovered_region_offset = event_offset / panel_height;
4339
4340 // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
4341 // These pixels offsets were picked arbitrarily.
4342 let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
4343 8.
4344 } else if hovered_region_offset <= 0.15 {
4345 5.
4346 } else if hovered_region_offset >= 0.95 {
4347 -8.
4348 } else if hovered_region_offset >= 0.85 {
4349 -5.
4350 } else {
4351 return;
4352 };
4353 let adjustment = point(px(0.), px(vertical_scroll_offset));
4354 this.hover_scroll_task =
4355 Some(cx.spawn_in(window, move |this, mut cx| async move {
4356 loop {
4357 let should_stop_scrolling = this
4358 .update(&mut cx, |this, cx| {
4359 this.hover_scroll_task.as_ref()?;
4360 let handle = this.scroll_handle.0.borrow_mut();
4361 let offset = handle.base_handle.offset();
4362
4363 handle.base_handle.set_offset(offset + adjustment);
4364 cx.notify();
4365 Some(())
4366 })
4367 .ok()
4368 .flatten()
4369 .is_some();
4370 if should_stop_scrolling {
4371 return;
4372 }
4373 cx.background_executor()
4374 .timer(Duration::from_millis(16))
4375 .await;
4376 }
4377 }));
4378 }
4379 h_flex()
4380 .id("project-panel")
4381 .group("project-panel")
4382 .on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
4383 .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
4384 .size_full()
4385 .relative()
4386 .on_hover(cx.listener(|this, hovered, window, cx| {
4387 if *hovered {
4388 this.show_scrollbar = true;
4389 this.hide_scrollbar_task.take();
4390 cx.notify();
4391 } else if !this.focus_handle.contains_focused(window, cx) {
4392 this.hide_scrollbar(window, cx);
4393 }
4394 }))
4395 .on_click(cx.listener(|this, _event, _, cx| {
4396 cx.stop_propagation();
4397 this.selection = None;
4398 this.marked_entries.clear();
4399 }))
4400 .key_context(self.dispatch_context(window, cx))
4401 .on_action(cx.listener(Self::select_next))
4402 .on_action(cx.listener(Self::select_prev))
4403 .on_action(cx.listener(Self::select_first))
4404 .on_action(cx.listener(Self::select_last))
4405 .on_action(cx.listener(Self::select_parent))
4406 .on_action(cx.listener(Self::select_next_git_entry))
4407 .on_action(cx.listener(Self::select_prev_git_entry))
4408 .on_action(cx.listener(Self::select_next_diagnostic))
4409 .on_action(cx.listener(Self::select_prev_diagnostic))
4410 .on_action(cx.listener(Self::select_next_directory))
4411 .on_action(cx.listener(Self::select_prev_directory))
4412 .on_action(cx.listener(Self::expand_selected_entry))
4413 .on_action(cx.listener(Self::collapse_selected_entry))
4414 .on_action(cx.listener(Self::collapse_all_entries))
4415 .on_action(cx.listener(Self::open))
4416 .on_action(cx.listener(Self::open_permanent))
4417 .on_action(cx.listener(Self::confirm))
4418 .on_action(cx.listener(Self::cancel))
4419 .on_action(cx.listener(Self::copy_path))
4420 .on_action(cx.listener(Self::copy_relative_path))
4421 .on_action(cx.listener(Self::new_search_in_directory))
4422 .on_action(cx.listener(Self::unfold_directory))
4423 .on_action(cx.listener(Self::fold_directory))
4424 .on_action(cx.listener(Self::remove_from_project))
4425 .when(!project.is_read_only(cx), |el| {
4426 el.on_action(cx.listener(Self::new_file))
4427 .on_action(cx.listener(Self::new_directory))
4428 .on_action(cx.listener(Self::rename))
4429 .on_action(cx.listener(Self::delete))
4430 .on_action(cx.listener(Self::trash))
4431 .on_action(cx.listener(Self::cut))
4432 .on_action(cx.listener(Self::copy))
4433 .on_action(cx.listener(Self::paste))
4434 .on_action(cx.listener(Self::duplicate))
4435 .on_click(cx.listener(|this, event: &gpui::ClickEvent, window, cx| {
4436 if event.up.click_count > 1 {
4437 if let Some(entry_id) = this.last_worktree_root_id {
4438 let project = this.project.read(cx);
4439
4440 let worktree_id = if let Some(worktree) =
4441 project.worktree_for_entry(entry_id, cx)
4442 {
4443 worktree.read(cx).id()
4444 } else {
4445 return;
4446 };
4447
4448 this.selection = Some(SelectedEntry {
4449 worktree_id,
4450 entry_id,
4451 });
4452
4453 this.new_file(&NewFile, window, cx);
4454 }
4455 }
4456 }))
4457 })
4458 .when(project.is_local(), |el| {
4459 el.on_action(cx.listener(Self::reveal_in_finder))
4460 .on_action(cx.listener(Self::open_system))
4461 .on_action(cx.listener(Self::open_in_terminal))
4462 })
4463 .when(project.is_via_ssh(), |el| {
4464 el.on_action(cx.listener(Self::open_in_terminal))
4465 })
4466 .on_mouse_down(
4467 MouseButton::Right,
4468 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
4469 // When deploying the context menu anywhere below the last project entry,
4470 // act as if the user clicked the root of the last worktree.
4471 if let Some(entry_id) = this.last_worktree_root_id {
4472 this.deploy_context_menu(event.position, entry_id, window, cx);
4473 }
4474 }),
4475 )
4476 .track_focus(&self.focus_handle(cx))
4477 .child(
4478 uniform_list(cx.entity().clone(), "entries", item_count, {
4479 |this, range, window, cx| {
4480 let mut items = Vec::with_capacity(range.end - range.start);
4481 this.for_each_visible_entry(
4482 range,
4483 window,
4484 cx,
4485 |id, details, window, cx| {
4486 items.push(this.render_entry(id, details, window, cx));
4487 },
4488 );
4489 items
4490 }
4491 })
4492 .when(show_indent_guides, |list| {
4493 list.with_decoration(
4494 ui::indent_guides(
4495 cx.entity().clone(),
4496 px(indent_size),
4497 IndentGuideColors::panel(cx),
4498 |this, range, window, cx| {
4499 let mut items =
4500 SmallVec::with_capacity(range.end - range.start);
4501 this.iter_visible_entries(
4502 range,
4503 window,
4504 cx,
4505 |entry, entries, _, _| {
4506 let (depth, _) = Self::calculate_depth_and_difference(
4507 entry, entries,
4508 );
4509 items.push(depth);
4510 },
4511 );
4512 items
4513 },
4514 )
4515 .on_click(cx.listener(
4516 |this, active_indent_guide: &IndentGuideLayout, window, cx| {
4517 if window.modifiers().secondary() {
4518 let ix = active_indent_guide.offset.y;
4519 let Some((target_entry, worktree)) = maybe!({
4520 let (worktree_id, entry) = this.entry_at_index(ix)?;
4521 let worktree = this
4522 .project
4523 .read(cx)
4524 .worktree_for_id(worktree_id, cx)?;
4525 let target_entry = worktree
4526 .read(cx)
4527 .entry_for_path(&entry.path.parent()?)?;
4528 Some((target_entry, worktree))
4529 }) else {
4530 return;
4531 };
4532
4533 this.collapse_entry(target_entry.clone(), worktree, cx);
4534 }
4535 },
4536 ))
4537 .with_render_fn(
4538 cx.entity().clone(),
4539 move |this, params, _, cx| {
4540 const LEFT_OFFSET: f32 = 14.;
4541 const PADDING_Y: f32 = 4.;
4542 const HITBOX_OVERDRAW: f32 = 3.;
4543
4544 let active_indent_guide_index =
4545 this.find_active_indent_guide(¶ms.indent_guides, cx);
4546
4547 let indent_size = params.indent_size;
4548 let item_height = params.item_height;
4549
4550 params
4551 .indent_guides
4552 .into_iter()
4553 .enumerate()
4554 .map(|(idx, layout)| {
4555 let offset = if layout.continues_offscreen {
4556 px(0.)
4557 } else {
4558 px(PADDING_Y)
4559 };
4560 let bounds = Bounds::new(
4561 point(
4562 px(layout.offset.x as f32) * indent_size
4563 + px(LEFT_OFFSET),
4564 px(layout.offset.y as f32) * item_height
4565 + offset,
4566 ),
4567 size(
4568 px(1.),
4569 px(layout.length as f32) * item_height
4570 - px(offset.0 * 2.),
4571 ),
4572 );
4573 ui::RenderedIndentGuide {
4574 bounds,
4575 layout,
4576 is_active: Some(idx) == active_indent_guide_index,
4577 hitbox: Some(Bounds::new(
4578 point(
4579 bounds.origin.x - px(HITBOX_OVERDRAW),
4580 bounds.origin.y,
4581 ),
4582 size(
4583 bounds.size.width
4584 + px(2. * HITBOX_OVERDRAW),
4585 bounds.size.height,
4586 ),
4587 )),
4588 }
4589 })
4590 .collect()
4591 },
4592 ),
4593 )
4594 })
4595 .size_full()
4596 .with_sizing_behavior(ListSizingBehavior::Infer)
4597 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4598 .with_width_from_item(self.max_width_item_index)
4599 .track_scroll(self.scroll_handle.clone()),
4600 )
4601 .children(self.render_vertical_scrollbar(cx))
4602 .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
4603 this.pb_4().child(scrollbar)
4604 })
4605 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4606 deferred(
4607 anchored()
4608 .position(*position)
4609 .anchor(gpui::Corner::TopLeft)
4610 .child(menu.clone()),
4611 )
4612 .with_priority(1)
4613 }))
4614 } else {
4615 v_flex()
4616 .id("empty-project_panel")
4617 .size_full()
4618 .p_4()
4619 .track_focus(&self.focus_handle(cx))
4620 .child(
4621 Button::new("open_project", "Open a project")
4622 .full_width()
4623 .key_binding(KeyBinding::for_action(&workspace::Open, window))
4624 .on_click(cx.listener(|this, _, window, cx| {
4625 this.workspace
4626 .update(cx, |_, cx| {
4627 window.dispatch_action(Box::new(workspace::Open), cx)
4628 })
4629 .log_err();
4630 })),
4631 )
4632 .when(is_local, |div| {
4633 div.drag_over::<ExternalPaths>(|style, _, _, cx| {
4634 style.bg(cx.theme().colors().drop_target_background)
4635 })
4636 .on_drop(cx.listener(
4637 move |this, external_paths: &ExternalPaths, window, cx| {
4638 this.last_external_paths_drag_over_entry = None;
4639 this.marked_entries.clear();
4640 this.hover_scroll_task.take();
4641 if let Some(task) = this
4642 .workspace
4643 .update(cx, |workspace, cx| {
4644 workspace.open_workspace_for_paths(
4645 true,
4646 external_paths.paths().to_owned(),
4647 window,
4648 cx,
4649 )
4650 })
4651 .log_err()
4652 {
4653 task.detach_and_log_err(cx);
4654 }
4655 cx.stop_propagation();
4656 },
4657 ))
4658 })
4659 }
4660 }
4661}
4662
4663impl Render for DraggedProjectEntryView {
4664 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4665 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
4666 h_flex()
4667 .font(ui_font)
4668 .pl(self.click_offset.x + px(12.))
4669 .pt(self.click_offset.y + px(12.))
4670 .child(
4671 div()
4672 .flex()
4673 .gap_1()
4674 .items_center()
4675 .py_1()
4676 .px_2()
4677 .rounded_lg()
4678 .bg(cx.theme().colors().background)
4679 .map(|this| {
4680 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
4681 this.child(Label::new(format!("{} entries", self.selections.len())))
4682 } else {
4683 this.child(if let Some(icon) = &self.details.icon {
4684 div().child(Icon::from_path(icon.clone()))
4685 } else {
4686 div()
4687 })
4688 .child(Label::new(self.details.filename.clone()))
4689 }
4690 }),
4691 )
4692 }
4693}
4694
4695impl EventEmitter<Event> for ProjectPanel {}
4696
4697impl EventEmitter<PanelEvent> for ProjectPanel {}
4698
4699impl Panel for ProjectPanel {
4700 fn position(&self, _: &Window, cx: &App) -> DockPosition {
4701 match ProjectPanelSettings::get_global(cx).dock {
4702 ProjectPanelDockPosition::Left => DockPosition::Left,
4703 ProjectPanelDockPosition::Right => DockPosition::Right,
4704 }
4705 }
4706
4707 fn position_is_valid(&self, position: DockPosition) -> bool {
4708 matches!(position, DockPosition::Left | DockPosition::Right)
4709 }
4710
4711 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
4712 settings::update_settings_file::<ProjectPanelSettings>(
4713 self.fs.clone(),
4714 cx,
4715 move |settings, _| {
4716 let dock = match position {
4717 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
4718 DockPosition::Right => ProjectPanelDockPosition::Right,
4719 };
4720 settings.dock = Some(dock);
4721 },
4722 );
4723 }
4724
4725 fn size(&self, _: &Window, cx: &App) -> Pixels {
4726 self.width
4727 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
4728 }
4729
4730 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
4731 self.width = size;
4732 self.serialize(cx);
4733 cx.notify();
4734 }
4735
4736 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
4737 ProjectPanelSettings::get_global(cx)
4738 .button
4739 .then_some(IconName::FileTree)
4740 }
4741
4742 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
4743 Some("Project Panel")
4744 }
4745
4746 fn toggle_action(&self) -> Box<dyn Action> {
4747 Box::new(ToggleFocus)
4748 }
4749
4750 fn persistent_name() -> &'static str {
4751 "Project Panel"
4752 }
4753
4754 fn starts_open(&self, _: &Window, cx: &App) -> bool {
4755 let project = &self.project.read(cx);
4756 project.visible_worktrees(cx).any(|tree| {
4757 tree.read(cx)
4758 .root_entry()
4759 .map_or(false, |entry| entry.is_dir())
4760 })
4761 }
4762
4763 fn activation_priority(&self) -> u32 {
4764 0
4765 }
4766}
4767
4768impl Focusable for ProjectPanel {
4769 fn focus_handle(&self, _cx: &App) -> FocusHandle {
4770 self.focus_handle.clone()
4771 }
4772}
4773
4774impl ClipboardEntry {
4775 fn is_cut(&self) -> bool {
4776 matches!(self, Self::Cut { .. })
4777 }
4778
4779 fn items(&self) -> &BTreeSet<SelectedEntry> {
4780 match self {
4781 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
4782 }
4783 }
4784}
4785
4786#[cfg(test)]
4787mod tests {
4788 use super::*;
4789 use collections::HashSet;
4790 use gpui::{Empty, Entity, TestAppContext, VisualTestContext, WindowHandle};
4791 use pretty_assertions::assert_eq;
4792 use project::{FakeFs, WorktreeSettings};
4793 use serde_json::json;
4794 use settings::SettingsStore;
4795 use std::path::{Path, PathBuf};
4796 use workspace::{
4797 item::{Item, ProjectItem},
4798 register_project_item, AppState,
4799 };
4800
4801 #[gpui::test]
4802 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
4803 init_test(cx);
4804
4805 let fs = FakeFs::new(cx.executor().clone());
4806 fs.insert_tree(
4807 "/root1",
4808 json!({
4809 ".dockerignore": "",
4810 ".git": {
4811 "HEAD": "",
4812 },
4813 "a": {
4814 "0": { "q": "", "r": "", "s": "" },
4815 "1": { "t": "", "u": "" },
4816 "2": { "v": "", "w": "", "x": "", "y": "" },
4817 },
4818 "b": {
4819 "3": { "Q": "" },
4820 "4": { "R": "", "S": "", "T": "", "U": "" },
4821 },
4822 "C": {
4823 "5": {},
4824 "6": { "V": "", "W": "" },
4825 "7": { "X": "" },
4826 "8": { "Y": {}, "Z": "" }
4827 }
4828 }),
4829 )
4830 .await;
4831 fs.insert_tree(
4832 "/root2",
4833 json!({
4834 "d": {
4835 "9": ""
4836 },
4837 "e": {}
4838 }),
4839 )
4840 .await;
4841
4842 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4843 let workspace =
4844 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4845 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4846 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4847 assert_eq!(
4848 visible_entries_as_strings(&panel, 0..50, cx),
4849 &[
4850 "v root1",
4851 " > .git",
4852 " > a",
4853 " > b",
4854 " > C",
4855 " .dockerignore",
4856 "v root2",
4857 " > d",
4858 " > e",
4859 ]
4860 );
4861
4862 toggle_expand_dir(&panel, "root1/b", cx);
4863 assert_eq!(
4864 visible_entries_as_strings(&panel, 0..50, cx),
4865 &[
4866 "v root1",
4867 " > .git",
4868 " > a",
4869 " v b <== selected",
4870 " > 3",
4871 " > 4",
4872 " > C",
4873 " .dockerignore",
4874 "v root2",
4875 " > d",
4876 " > e",
4877 ]
4878 );
4879
4880 assert_eq!(
4881 visible_entries_as_strings(&panel, 6..9, cx),
4882 &[
4883 //
4884 " > C",
4885 " .dockerignore",
4886 "v root2",
4887 ]
4888 );
4889 }
4890
4891 #[gpui::test]
4892 async fn test_opening_file(cx: &mut gpui::TestAppContext) {
4893 init_test_with_editor(cx);
4894
4895 let fs = FakeFs::new(cx.executor().clone());
4896 fs.insert_tree(
4897 "/src",
4898 json!({
4899 "test": {
4900 "first.rs": "// First Rust file",
4901 "second.rs": "// Second Rust file",
4902 "third.rs": "// Third Rust file",
4903 }
4904 }),
4905 )
4906 .await;
4907
4908 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4909 let workspace =
4910 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4911 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4912 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4913
4914 toggle_expand_dir(&panel, "src/test", cx);
4915 select_path(&panel, "src/test/first.rs", cx);
4916 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
4917 cx.executor().run_until_parked();
4918 assert_eq!(
4919 visible_entries_as_strings(&panel, 0..10, cx),
4920 &[
4921 "v src",
4922 " v test",
4923 " first.rs <== selected <== marked",
4924 " second.rs",
4925 " third.rs"
4926 ]
4927 );
4928 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
4929
4930 select_path(&panel, "src/test/second.rs", cx);
4931 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
4932 cx.executor().run_until_parked();
4933 assert_eq!(
4934 visible_entries_as_strings(&panel, 0..10, cx),
4935 &[
4936 "v src",
4937 " v test",
4938 " first.rs",
4939 " second.rs <== selected <== marked",
4940 " third.rs"
4941 ]
4942 );
4943 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
4944 }
4945
4946 #[gpui::test]
4947 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
4948 init_test(cx);
4949 cx.update(|cx| {
4950 cx.update_global::<SettingsStore, _>(|store, cx| {
4951 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4952 worktree_settings.file_scan_exclusions =
4953 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
4954 });
4955 });
4956 });
4957
4958 let fs = FakeFs::new(cx.background_executor.clone());
4959 fs.insert_tree(
4960 "/root1",
4961 json!({
4962 ".dockerignore": "",
4963 ".git": {
4964 "HEAD": "",
4965 },
4966 "a": {
4967 "0": { "q": "", "r": "", "s": "" },
4968 "1": { "t": "", "u": "" },
4969 "2": { "v": "", "w": "", "x": "", "y": "" },
4970 },
4971 "b": {
4972 "3": { "Q": "" },
4973 "4": { "R": "", "S": "", "T": "", "U": "" },
4974 },
4975 "C": {
4976 "5": {},
4977 "6": { "V": "", "W": "" },
4978 "7": { "X": "" },
4979 "8": { "Y": {}, "Z": "" }
4980 }
4981 }),
4982 )
4983 .await;
4984 fs.insert_tree(
4985 "/root2",
4986 json!({
4987 "d": {
4988 "4": ""
4989 },
4990 "e": {}
4991 }),
4992 )
4993 .await;
4994
4995 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4996 let workspace =
4997 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4998 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4999 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5000 assert_eq!(
5001 visible_entries_as_strings(&panel, 0..50, cx),
5002 &[
5003 "v root1",
5004 " > a",
5005 " > b",
5006 " > C",
5007 " .dockerignore",
5008 "v root2",
5009 " > d",
5010 " > e",
5011 ]
5012 );
5013
5014 toggle_expand_dir(&panel, "root1/b", cx);
5015 assert_eq!(
5016 visible_entries_as_strings(&panel, 0..50, cx),
5017 &[
5018 "v root1",
5019 " > a",
5020 " v b <== selected",
5021 " > 3",
5022 " > C",
5023 " .dockerignore",
5024 "v root2",
5025 " > d",
5026 " > e",
5027 ]
5028 );
5029
5030 toggle_expand_dir(&panel, "root2/d", cx);
5031 assert_eq!(
5032 visible_entries_as_strings(&panel, 0..50, cx),
5033 &[
5034 "v root1",
5035 " > a",
5036 " v b",
5037 " > 3",
5038 " > C",
5039 " .dockerignore",
5040 "v root2",
5041 " v d <== selected",
5042 " > e",
5043 ]
5044 );
5045
5046 toggle_expand_dir(&panel, "root2/e", cx);
5047 assert_eq!(
5048 visible_entries_as_strings(&panel, 0..50, cx),
5049 &[
5050 "v root1",
5051 " > a",
5052 " v b",
5053 " > 3",
5054 " > C",
5055 " .dockerignore",
5056 "v root2",
5057 " v d",
5058 " v e <== selected",
5059 ]
5060 );
5061 }
5062
5063 #[gpui::test]
5064 async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
5065 init_test(cx);
5066
5067 let fs = FakeFs::new(cx.executor().clone());
5068 fs.insert_tree(
5069 "/root1",
5070 json!({
5071 "dir_1": {
5072 "nested_dir_1": {
5073 "nested_dir_2": {
5074 "nested_dir_3": {
5075 "file_a.java": "// File contents",
5076 "file_b.java": "// File contents",
5077 "file_c.java": "// File contents",
5078 "nested_dir_4": {
5079 "nested_dir_5": {
5080 "file_d.java": "// File contents",
5081 }
5082 }
5083 }
5084 }
5085 }
5086 }
5087 }),
5088 )
5089 .await;
5090 fs.insert_tree(
5091 "/root2",
5092 json!({
5093 "dir_2": {
5094 "file_1.java": "// File contents",
5095 }
5096 }),
5097 )
5098 .await;
5099
5100 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5101 let workspace =
5102 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5103 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5104 cx.update(|_, cx| {
5105 let settings = *ProjectPanelSettings::get_global(cx);
5106 ProjectPanelSettings::override_global(
5107 ProjectPanelSettings {
5108 auto_fold_dirs: true,
5109 ..settings
5110 },
5111 cx,
5112 );
5113 });
5114 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5115 assert_eq!(
5116 visible_entries_as_strings(&panel, 0..10, cx),
5117 &[
5118 "v root1",
5119 " > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
5120 "v root2",
5121 " > dir_2",
5122 ]
5123 );
5124
5125 toggle_expand_dir(
5126 &panel,
5127 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
5128 cx,
5129 );
5130 assert_eq!(
5131 visible_entries_as_strings(&panel, 0..10, cx),
5132 &[
5133 "v root1",
5134 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
5135 " > nested_dir_4/nested_dir_5",
5136 " file_a.java",
5137 " file_b.java",
5138 " file_c.java",
5139 "v root2",
5140 " > dir_2",
5141 ]
5142 );
5143
5144 toggle_expand_dir(
5145 &panel,
5146 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
5147 cx,
5148 );
5149 assert_eq!(
5150 visible_entries_as_strings(&panel, 0..10, cx),
5151 &[
5152 "v root1",
5153 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
5154 " v nested_dir_4/nested_dir_5 <== selected",
5155 " file_d.java",
5156 " file_a.java",
5157 " file_b.java",
5158 " file_c.java",
5159 "v root2",
5160 " > dir_2",
5161 ]
5162 );
5163 toggle_expand_dir(&panel, "root2/dir_2", cx);
5164 assert_eq!(
5165 visible_entries_as_strings(&panel, 0..10, cx),
5166 &[
5167 "v root1",
5168 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
5169 " v nested_dir_4/nested_dir_5",
5170 " file_d.java",
5171 " file_a.java",
5172 " file_b.java",
5173 " file_c.java",
5174 "v root2",
5175 " v dir_2 <== selected",
5176 " file_1.java",
5177 ]
5178 );
5179 }
5180
5181 #[gpui::test(iterations = 30)]
5182 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
5183 init_test(cx);
5184
5185 let fs = FakeFs::new(cx.executor().clone());
5186 fs.insert_tree(
5187 "/root1",
5188 json!({
5189 ".dockerignore": "",
5190 ".git": {
5191 "HEAD": "",
5192 },
5193 "a": {
5194 "0": { "q": "", "r": "", "s": "" },
5195 "1": { "t": "", "u": "" },
5196 "2": { "v": "", "w": "", "x": "", "y": "" },
5197 },
5198 "b": {
5199 "3": { "Q": "" },
5200 "4": { "R": "", "S": "", "T": "", "U": "" },
5201 },
5202 "C": {
5203 "5": {},
5204 "6": { "V": "", "W": "" },
5205 "7": { "X": "" },
5206 "8": { "Y": {}, "Z": "" }
5207 }
5208 }),
5209 )
5210 .await;
5211 fs.insert_tree(
5212 "/root2",
5213 json!({
5214 "d": {
5215 "9": ""
5216 },
5217 "e": {}
5218 }),
5219 )
5220 .await;
5221
5222 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5223 let workspace =
5224 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5225 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5226 let panel = workspace
5227 .update(cx, |workspace, window, cx| {
5228 let panel = ProjectPanel::new(workspace, window, cx);
5229 workspace.add_panel(panel.clone(), window, cx);
5230 panel
5231 })
5232 .unwrap();
5233
5234 select_path(&panel, "root1", cx);
5235 assert_eq!(
5236 visible_entries_as_strings(&panel, 0..10, cx),
5237 &[
5238 "v root1 <== selected",
5239 " > .git",
5240 " > a",
5241 " > b",
5242 " > C",
5243 " .dockerignore",
5244 "v root2",
5245 " > d",
5246 " > e",
5247 ]
5248 );
5249
5250 // Add a file with the root folder selected. The filename editor is placed
5251 // before the first file in the root folder.
5252 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5253 panel.update_in(cx, |panel, window, cx| {
5254 assert!(panel.filename_editor.read(cx).is_focused(window));
5255 });
5256 assert_eq!(
5257 visible_entries_as_strings(&panel, 0..10, cx),
5258 &[
5259 "v root1",
5260 " > .git",
5261 " > a",
5262 " > b",
5263 " > C",
5264 " [EDITOR: ''] <== selected",
5265 " .dockerignore",
5266 "v root2",
5267 " > d",
5268 " > e",
5269 ]
5270 );
5271
5272 let confirm = panel.update_in(cx, |panel, window, cx| {
5273 panel.filename_editor.update(cx, |editor, cx| {
5274 editor.set_text("the-new-filename", window, cx)
5275 });
5276 panel.confirm_edit(window, cx).unwrap()
5277 });
5278 assert_eq!(
5279 visible_entries_as_strings(&panel, 0..10, cx),
5280 &[
5281 "v root1",
5282 " > .git",
5283 " > a",
5284 " > b",
5285 " > C",
5286 " [PROCESSING: 'the-new-filename'] <== selected",
5287 " .dockerignore",
5288 "v root2",
5289 " > d",
5290 " > e",
5291 ]
5292 );
5293
5294 confirm.await.unwrap();
5295 assert_eq!(
5296 visible_entries_as_strings(&panel, 0..10, cx),
5297 &[
5298 "v root1",
5299 " > .git",
5300 " > a",
5301 " > b",
5302 " > C",
5303 " .dockerignore",
5304 " the-new-filename <== selected <== marked",
5305 "v root2",
5306 " > d",
5307 " > e",
5308 ]
5309 );
5310
5311 select_path(&panel, "root1/b", cx);
5312 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5313 assert_eq!(
5314 visible_entries_as_strings(&panel, 0..10, cx),
5315 &[
5316 "v root1",
5317 " > .git",
5318 " > a",
5319 " v b",
5320 " > 3",
5321 " > 4",
5322 " [EDITOR: ''] <== selected",
5323 " > C",
5324 " .dockerignore",
5325 " the-new-filename",
5326 ]
5327 );
5328
5329 panel
5330 .update_in(cx, |panel, window, cx| {
5331 panel.filename_editor.update(cx, |editor, cx| {
5332 editor.set_text("another-filename.txt", window, cx)
5333 });
5334 panel.confirm_edit(window, cx).unwrap()
5335 })
5336 .await
5337 .unwrap();
5338 assert_eq!(
5339 visible_entries_as_strings(&panel, 0..10, cx),
5340 &[
5341 "v root1",
5342 " > .git",
5343 " > a",
5344 " v b",
5345 " > 3",
5346 " > 4",
5347 " another-filename.txt <== selected <== marked",
5348 " > C",
5349 " .dockerignore",
5350 " the-new-filename",
5351 ]
5352 );
5353
5354 select_path(&panel, "root1/b/another-filename.txt", cx);
5355 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
5356 assert_eq!(
5357 visible_entries_as_strings(&panel, 0..10, cx),
5358 &[
5359 "v root1",
5360 " > .git",
5361 " > a",
5362 " v b",
5363 " > 3",
5364 " > 4",
5365 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
5366 " > C",
5367 " .dockerignore",
5368 " the-new-filename",
5369 ]
5370 );
5371
5372 let confirm = panel.update_in(cx, |panel, window, cx| {
5373 panel.filename_editor.update(cx, |editor, cx| {
5374 let file_name_selections = editor.selections.all::<usize>(cx);
5375 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5376 let file_name_selection = &file_name_selections[0];
5377 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
5378 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
5379
5380 editor.set_text("a-different-filename.tar.gz", window, cx)
5381 });
5382 panel.confirm_edit(window, cx).unwrap()
5383 });
5384 assert_eq!(
5385 visible_entries_as_strings(&panel, 0..10, cx),
5386 &[
5387 "v root1",
5388 " > .git",
5389 " > a",
5390 " v b",
5391 " > 3",
5392 " > 4",
5393 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
5394 " > C",
5395 " .dockerignore",
5396 " the-new-filename",
5397 ]
5398 );
5399
5400 confirm.await.unwrap();
5401 assert_eq!(
5402 visible_entries_as_strings(&panel, 0..10, cx),
5403 &[
5404 "v root1",
5405 " > .git",
5406 " > a",
5407 " v b",
5408 " > 3",
5409 " > 4",
5410 " a-different-filename.tar.gz <== selected",
5411 " > C",
5412 " .dockerignore",
5413 " the-new-filename",
5414 ]
5415 );
5416
5417 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
5418 assert_eq!(
5419 visible_entries_as_strings(&panel, 0..10, cx),
5420 &[
5421 "v root1",
5422 " > .git",
5423 " > a",
5424 " v b",
5425 " > 3",
5426 " > 4",
5427 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
5428 " > C",
5429 " .dockerignore",
5430 " the-new-filename",
5431 ]
5432 );
5433
5434 panel.update_in(cx, |panel, window, cx| {
5435 panel.filename_editor.update(cx, |editor, cx| {
5436 let file_name_selections = editor.selections.all::<usize>(cx);
5437 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5438 let file_name_selection = &file_name_selections[0];
5439 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
5440 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..");
5441
5442 });
5443 panel.cancel(&menu::Cancel, window, cx)
5444 });
5445
5446 panel.update_in(cx, |panel, window, cx| {
5447 panel.new_directory(&NewDirectory, window, cx)
5448 });
5449 assert_eq!(
5450 visible_entries_as_strings(&panel, 0..10, cx),
5451 &[
5452 "v root1",
5453 " > .git",
5454 " > a",
5455 " v b",
5456 " > 3",
5457 " > 4",
5458 " > [EDITOR: ''] <== selected",
5459 " a-different-filename.tar.gz",
5460 " > C",
5461 " .dockerignore",
5462 ]
5463 );
5464
5465 let confirm = panel.update_in(cx, |panel, window, cx| {
5466 panel
5467 .filename_editor
5468 .update(cx, |editor, cx| editor.set_text("new-dir", window, cx));
5469 panel.confirm_edit(window, cx).unwrap()
5470 });
5471 panel.update_in(cx, |panel, window, cx| {
5472 panel.select_next(&Default::default(), window, cx)
5473 });
5474 assert_eq!(
5475 visible_entries_as_strings(&panel, 0..10, cx),
5476 &[
5477 "v root1",
5478 " > .git",
5479 " > a",
5480 " v b",
5481 " > 3",
5482 " > 4",
5483 " > [PROCESSING: 'new-dir']",
5484 " a-different-filename.tar.gz <== selected",
5485 " > C",
5486 " .dockerignore",
5487 ]
5488 );
5489
5490 confirm.await.unwrap();
5491 assert_eq!(
5492 visible_entries_as_strings(&panel, 0..10, cx),
5493 &[
5494 "v root1",
5495 " > .git",
5496 " > a",
5497 " v b",
5498 " > 3",
5499 " > 4",
5500 " > new-dir",
5501 " a-different-filename.tar.gz <== selected",
5502 " > C",
5503 " .dockerignore",
5504 ]
5505 );
5506
5507 panel.update_in(cx, |panel, window, cx| {
5508 panel.rename(&Default::default(), window, cx)
5509 });
5510 assert_eq!(
5511 visible_entries_as_strings(&panel, 0..10, cx),
5512 &[
5513 "v root1",
5514 " > .git",
5515 " > a",
5516 " v b",
5517 " > 3",
5518 " > 4",
5519 " > new-dir",
5520 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
5521 " > C",
5522 " .dockerignore",
5523 ]
5524 );
5525
5526 // Dismiss the rename editor when it loses focus.
5527 workspace.update(cx, |_, window, _| window.blur()).unwrap();
5528 assert_eq!(
5529 visible_entries_as_strings(&panel, 0..10, cx),
5530 &[
5531 "v root1",
5532 " > .git",
5533 " > a",
5534 " v b",
5535 " > 3",
5536 " > 4",
5537 " > new-dir",
5538 " a-different-filename.tar.gz <== selected",
5539 " > C",
5540 " .dockerignore",
5541 ]
5542 );
5543 }
5544
5545 #[gpui::test(iterations = 10)]
5546 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
5547 init_test(cx);
5548
5549 let fs = FakeFs::new(cx.executor().clone());
5550 fs.insert_tree(
5551 "/root1",
5552 json!({
5553 ".dockerignore": "",
5554 ".git": {
5555 "HEAD": "",
5556 },
5557 "a": {
5558 "0": { "q": "", "r": "", "s": "" },
5559 "1": { "t": "", "u": "" },
5560 "2": { "v": "", "w": "", "x": "", "y": "" },
5561 },
5562 "b": {
5563 "3": { "Q": "" },
5564 "4": { "R": "", "S": "", "T": "", "U": "" },
5565 },
5566 "C": {
5567 "5": {},
5568 "6": { "V": "", "W": "" },
5569 "7": { "X": "" },
5570 "8": { "Y": {}, "Z": "" }
5571 }
5572 }),
5573 )
5574 .await;
5575 fs.insert_tree(
5576 "/root2",
5577 json!({
5578 "d": {
5579 "9": ""
5580 },
5581 "e": {}
5582 }),
5583 )
5584 .await;
5585
5586 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5587 let workspace =
5588 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5589 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5590 let panel = workspace
5591 .update(cx, |workspace, window, cx| {
5592 let panel = ProjectPanel::new(workspace, window, cx);
5593 workspace.add_panel(panel.clone(), window, cx);
5594 panel
5595 })
5596 .unwrap();
5597
5598 select_path(&panel, "root1", cx);
5599 assert_eq!(
5600 visible_entries_as_strings(&panel, 0..10, cx),
5601 &[
5602 "v root1 <== selected",
5603 " > .git",
5604 " > a",
5605 " > b",
5606 " > C",
5607 " .dockerignore",
5608 "v root2",
5609 " > d",
5610 " > e",
5611 ]
5612 );
5613
5614 // Add a file with the root folder selected. The filename editor is placed
5615 // before the first file in the root folder.
5616 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5617 panel.update_in(cx, |panel, window, cx| {
5618 assert!(panel.filename_editor.read(cx).is_focused(window));
5619 });
5620 assert_eq!(
5621 visible_entries_as_strings(&panel, 0..10, cx),
5622 &[
5623 "v root1",
5624 " > .git",
5625 " > a",
5626 " > b",
5627 " > C",
5628 " [EDITOR: ''] <== selected",
5629 " .dockerignore",
5630 "v root2",
5631 " > d",
5632 " > e",
5633 ]
5634 );
5635
5636 let confirm = panel.update_in(cx, |panel, window, cx| {
5637 panel.filename_editor.update(cx, |editor, cx| {
5638 editor.set_text("/bdir1/dir2/the-new-filename", window, cx)
5639 });
5640 panel.confirm_edit(window, cx).unwrap()
5641 });
5642
5643 assert_eq!(
5644 visible_entries_as_strings(&panel, 0..10, cx),
5645 &[
5646 "v root1",
5647 " > .git",
5648 " > a",
5649 " > b",
5650 " > C",
5651 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
5652 " .dockerignore",
5653 "v root2",
5654 " > d",
5655 " > e",
5656 ]
5657 );
5658
5659 confirm.await.unwrap();
5660 assert_eq!(
5661 visible_entries_as_strings(&panel, 0..13, cx),
5662 &[
5663 "v root1",
5664 " > .git",
5665 " > a",
5666 " > b",
5667 " v bdir1",
5668 " v dir2",
5669 " the-new-filename <== selected <== marked",
5670 " > C",
5671 " .dockerignore",
5672 "v root2",
5673 " > d",
5674 " > e",
5675 ]
5676 );
5677 }
5678
5679 #[gpui::test]
5680 async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
5681 init_test(cx);
5682
5683 let fs = FakeFs::new(cx.executor().clone());
5684 fs.insert_tree(
5685 "/root1",
5686 json!({
5687 ".dockerignore": "",
5688 ".git": {
5689 "HEAD": "",
5690 },
5691 }),
5692 )
5693 .await;
5694
5695 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5696 let workspace =
5697 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5698 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5699 let panel = workspace
5700 .update(cx, |workspace, window, cx| {
5701 let panel = ProjectPanel::new(workspace, window, cx);
5702 workspace.add_panel(panel.clone(), window, cx);
5703 panel
5704 })
5705 .unwrap();
5706
5707 select_path(&panel, "root1", cx);
5708 assert_eq!(
5709 visible_entries_as_strings(&panel, 0..10, cx),
5710 &["v root1 <== selected", " > .git", " .dockerignore",]
5711 );
5712
5713 // Add a file with the root folder selected. The filename editor is placed
5714 // before the first file in the root folder.
5715 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5716 panel.update_in(cx, |panel, window, cx| {
5717 assert!(panel.filename_editor.read(cx).is_focused(window));
5718 });
5719 assert_eq!(
5720 visible_entries_as_strings(&panel, 0..10, cx),
5721 &[
5722 "v root1",
5723 " > .git",
5724 " [EDITOR: ''] <== selected",
5725 " .dockerignore",
5726 ]
5727 );
5728
5729 let confirm = panel.update_in(cx, |panel, window, cx| {
5730 panel
5731 .filename_editor
5732 .update(cx, |editor, cx| editor.set_text("/new_dir/", window, cx));
5733 panel.confirm_edit(window, cx).unwrap()
5734 });
5735
5736 assert_eq!(
5737 visible_entries_as_strings(&panel, 0..10, cx),
5738 &[
5739 "v root1",
5740 " > .git",
5741 " [PROCESSING: '/new_dir/'] <== selected",
5742 " .dockerignore",
5743 ]
5744 );
5745
5746 confirm.await.unwrap();
5747 assert_eq!(
5748 visible_entries_as_strings(&panel, 0..13, cx),
5749 &[
5750 "v root1",
5751 " > .git",
5752 " v new_dir <== selected",
5753 " .dockerignore",
5754 ]
5755 );
5756 }
5757
5758 #[gpui::test]
5759 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
5760 init_test(cx);
5761
5762 let fs = FakeFs::new(cx.executor().clone());
5763 fs.insert_tree(
5764 "/root1",
5765 json!({
5766 "one.two.txt": "",
5767 "one.txt": ""
5768 }),
5769 )
5770 .await;
5771
5772 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5773 let workspace =
5774 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5775 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5776 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5777
5778 panel.update_in(cx, |panel, window, cx| {
5779 panel.select_next(&Default::default(), window, cx);
5780 panel.select_next(&Default::default(), window, cx);
5781 });
5782
5783 assert_eq!(
5784 visible_entries_as_strings(&panel, 0..50, cx),
5785 &[
5786 //
5787 "v root1",
5788 " one.txt <== selected",
5789 " one.two.txt",
5790 ]
5791 );
5792
5793 // Regression test - file name is created correctly when
5794 // the copied file's name contains multiple dots.
5795 panel.update_in(cx, |panel, window, cx| {
5796 panel.copy(&Default::default(), window, cx);
5797 panel.paste(&Default::default(), window, cx);
5798 });
5799 cx.executor().run_until_parked();
5800
5801 assert_eq!(
5802 visible_entries_as_strings(&panel, 0..50, cx),
5803 &[
5804 //
5805 "v root1",
5806 " one.txt",
5807 " [EDITOR: 'one copy.txt'] <== selected",
5808 " one.two.txt",
5809 ]
5810 );
5811
5812 panel.update_in(cx, |panel, window, cx| {
5813 panel.filename_editor.update(cx, |editor, cx| {
5814 let file_name_selections = editor.selections.all::<usize>(cx);
5815 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5816 let file_name_selection = &file_name_selections[0];
5817 assert_eq!(file_name_selection.start, "one".len(), "Should select the file name disambiguation after the original file name");
5818 assert_eq!(file_name_selection.end, "one copy".len(), "Should select the file name disambiguation until the extension");
5819 });
5820 assert!(panel.confirm_edit(window, cx).is_none());
5821 });
5822
5823 panel.update_in(cx, |panel, window, cx| {
5824 panel.paste(&Default::default(), window, cx);
5825 });
5826 cx.executor().run_until_parked();
5827
5828 assert_eq!(
5829 visible_entries_as_strings(&panel, 0..50, cx),
5830 &[
5831 //
5832 "v root1",
5833 " one.txt",
5834 " one copy.txt",
5835 " [EDITOR: 'one copy 1.txt'] <== selected",
5836 " one.two.txt",
5837 ]
5838 );
5839
5840 panel.update_in(cx, |panel, window, cx| {
5841 assert!(panel.confirm_edit(window, cx).is_none())
5842 });
5843 }
5844
5845 #[gpui::test]
5846 async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5847 init_test(cx);
5848
5849 let fs = FakeFs::new(cx.executor().clone());
5850 fs.insert_tree(
5851 "/root1",
5852 json!({
5853 "one.txt": "",
5854 "two.txt": "",
5855 "three.txt": "",
5856 "a": {
5857 "0": { "q": "", "r": "", "s": "" },
5858 "1": { "t": "", "u": "" },
5859 "2": { "v": "", "w": "", "x": "", "y": "" },
5860 },
5861 }),
5862 )
5863 .await;
5864
5865 fs.insert_tree(
5866 "/root2",
5867 json!({
5868 "one.txt": "",
5869 "two.txt": "",
5870 "four.txt": "",
5871 "b": {
5872 "3": { "Q": "" },
5873 "4": { "R": "", "S": "", "T": "", "U": "" },
5874 },
5875 }),
5876 )
5877 .await;
5878
5879 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5880 let workspace =
5881 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5882 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5883 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5884
5885 select_path(&panel, "root1/three.txt", cx);
5886 panel.update_in(cx, |panel, window, cx| {
5887 panel.cut(&Default::default(), window, cx);
5888 });
5889
5890 select_path(&panel, "root2/one.txt", cx);
5891 panel.update_in(cx, |panel, window, cx| {
5892 panel.select_next(&Default::default(), window, cx);
5893 panel.paste(&Default::default(), window, cx);
5894 });
5895 cx.executor().run_until_parked();
5896 assert_eq!(
5897 visible_entries_as_strings(&panel, 0..50, cx),
5898 &[
5899 //
5900 "v root1",
5901 " > a",
5902 " one.txt",
5903 " two.txt",
5904 "v root2",
5905 " > b",
5906 " four.txt",
5907 " one.txt",
5908 " three.txt <== selected",
5909 " two.txt",
5910 ]
5911 );
5912
5913 select_path(&panel, "root1/a", cx);
5914 panel.update_in(cx, |panel, window, cx| {
5915 panel.cut(&Default::default(), window, cx);
5916 });
5917 select_path(&panel, "root2/two.txt", cx);
5918 panel.update_in(cx, |panel, window, cx| {
5919 panel.select_next(&Default::default(), window, cx);
5920 panel.paste(&Default::default(), window, cx);
5921 });
5922
5923 cx.executor().run_until_parked();
5924 assert_eq!(
5925 visible_entries_as_strings(&panel, 0..50, cx),
5926 &[
5927 //
5928 "v root1",
5929 " one.txt",
5930 " two.txt",
5931 "v root2",
5932 " > a <== selected",
5933 " > b",
5934 " four.txt",
5935 " one.txt",
5936 " three.txt",
5937 " two.txt",
5938 ]
5939 );
5940 }
5941
5942 #[gpui::test]
5943 async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5944 init_test(cx);
5945
5946 let fs = FakeFs::new(cx.executor().clone());
5947 fs.insert_tree(
5948 "/root1",
5949 json!({
5950 "one.txt": "",
5951 "two.txt": "",
5952 "three.txt": "",
5953 "a": {
5954 "0": { "q": "", "r": "", "s": "" },
5955 "1": { "t": "", "u": "" },
5956 "2": { "v": "", "w": "", "x": "", "y": "" },
5957 },
5958 }),
5959 )
5960 .await;
5961
5962 fs.insert_tree(
5963 "/root2",
5964 json!({
5965 "one.txt": "",
5966 "two.txt": "",
5967 "four.txt": "",
5968 "b": {
5969 "3": { "Q": "" },
5970 "4": { "R": "", "S": "", "T": "", "U": "" },
5971 },
5972 }),
5973 )
5974 .await;
5975
5976 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5977 let workspace =
5978 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5979 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5980 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5981
5982 select_path(&panel, "root1/three.txt", cx);
5983 panel.update_in(cx, |panel, window, cx| {
5984 panel.copy(&Default::default(), window, cx);
5985 });
5986
5987 select_path(&panel, "root2/one.txt", cx);
5988 panel.update_in(cx, |panel, window, cx| {
5989 panel.select_next(&Default::default(), window, cx);
5990 panel.paste(&Default::default(), window, cx);
5991 });
5992 cx.executor().run_until_parked();
5993 assert_eq!(
5994 visible_entries_as_strings(&panel, 0..50, cx),
5995 &[
5996 //
5997 "v root1",
5998 " > a",
5999 " one.txt",
6000 " three.txt",
6001 " two.txt",
6002 "v root2",
6003 " > b",
6004 " four.txt",
6005 " one.txt",
6006 " three.txt <== selected",
6007 " two.txt",
6008 ]
6009 );
6010
6011 select_path(&panel, "root1/three.txt", cx);
6012 panel.update_in(cx, |panel, window, cx| {
6013 panel.copy(&Default::default(), window, cx);
6014 });
6015 select_path(&panel, "root2/two.txt", cx);
6016 panel.update_in(cx, |panel, window, cx| {
6017 panel.select_next(&Default::default(), window, cx);
6018 panel.paste(&Default::default(), window, cx);
6019 });
6020
6021 cx.executor().run_until_parked();
6022 assert_eq!(
6023 visible_entries_as_strings(&panel, 0..50, cx),
6024 &[
6025 //
6026 "v root1",
6027 " > a",
6028 " one.txt",
6029 " three.txt",
6030 " two.txt",
6031 "v root2",
6032 " > b",
6033 " four.txt",
6034 " one.txt",
6035 " three.txt",
6036 " [EDITOR: 'three copy.txt'] <== selected",
6037 " two.txt",
6038 ]
6039 );
6040
6041 panel.update_in(cx, |panel, window, cx| {
6042 panel.cancel(&menu::Cancel {}, window, cx)
6043 });
6044 cx.executor().run_until_parked();
6045
6046 select_path(&panel, "root1/a", cx);
6047 panel.update_in(cx, |panel, window, cx| {
6048 panel.copy(&Default::default(), window, cx);
6049 });
6050 select_path(&panel, "root2/two.txt", cx);
6051 panel.update_in(cx, |panel, window, cx| {
6052 panel.select_next(&Default::default(), window, cx);
6053 panel.paste(&Default::default(), window, cx);
6054 });
6055
6056 cx.executor().run_until_parked();
6057 assert_eq!(
6058 visible_entries_as_strings(&panel, 0..50, cx),
6059 &[
6060 //
6061 "v root1",
6062 " > a",
6063 " one.txt",
6064 " three.txt",
6065 " two.txt",
6066 "v root2",
6067 " > a <== selected",
6068 " > b",
6069 " four.txt",
6070 " one.txt",
6071 " three.txt",
6072 " three copy.txt",
6073 " two.txt",
6074 ]
6075 );
6076 }
6077
6078 #[gpui::test]
6079 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
6080 init_test(cx);
6081
6082 let fs = FakeFs::new(cx.executor().clone());
6083 fs.insert_tree(
6084 "/root",
6085 json!({
6086 "a": {
6087 "one.txt": "",
6088 "two.txt": "",
6089 "inner_dir": {
6090 "three.txt": "",
6091 "four.txt": "",
6092 }
6093 },
6094 "b": {}
6095 }),
6096 )
6097 .await;
6098
6099 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6100 let workspace =
6101 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6102 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6103 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6104
6105 select_path(&panel, "root/a", cx);
6106 panel.update_in(cx, |panel, window, cx| {
6107 panel.copy(&Default::default(), window, cx);
6108 panel.select_next(&Default::default(), window, cx);
6109 panel.paste(&Default::default(), window, cx);
6110 });
6111 cx.executor().run_until_parked();
6112
6113 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
6114 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
6115
6116 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
6117 assert_ne!(
6118 pasted_dir_file, None,
6119 "Pasted directory file should have an entry"
6120 );
6121
6122 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
6123 assert_ne!(
6124 pasted_dir_inner_dir, None,
6125 "Directories inside pasted directory should have an entry"
6126 );
6127
6128 toggle_expand_dir(&panel, "root/b/a", cx);
6129 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
6130
6131 assert_eq!(
6132 visible_entries_as_strings(&panel, 0..50, cx),
6133 &[
6134 //
6135 "v root",
6136 " > a",
6137 " v b",
6138 " v a",
6139 " v inner_dir <== selected",
6140 " four.txt",
6141 " three.txt",
6142 " one.txt",
6143 " two.txt",
6144 ]
6145 );
6146
6147 select_path(&panel, "root", cx);
6148 panel.update_in(cx, |panel, window, cx| {
6149 panel.paste(&Default::default(), window, cx)
6150 });
6151 cx.executor().run_until_parked();
6152 assert_eq!(
6153 visible_entries_as_strings(&panel, 0..50, cx),
6154 &[
6155 //
6156 "v root",
6157 " > a",
6158 " > [EDITOR: 'a copy'] <== selected",
6159 " v b",
6160 " v a",
6161 " v inner_dir",
6162 " four.txt",
6163 " three.txt",
6164 " one.txt",
6165 " two.txt"
6166 ]
6167 );
6168
6169 let confirm = panel.update_in(cx, |panel, window, cx| {
6170 panel
6171 .filename_editor
6172 .update(cx, |editor, cx| editor.set_text("c", window, cx));
6173 panel.confirm_edit(window, cx).unwrap()
6174 });
6175 assert_eq!(
6176 visible_entries_as_strings(&panel, 0..50, cx),
6177 &[
6178 //
6179 "v root",
6180 " > a",
6181 " > [PROCESSING: 'c'] <== selected",
6182 " v b",
6183 " v a",
6184 " v inner_dir",
6185 " four.txt",
6186 " three.txt",
6187 " one.txt",
6188 " two.txt"
6189 ]
6190 );
6191
6192 confirm.await.unwrap();
6193
6194 panel.update_in(cx, |panel, window, cx| {
6195 panel.paste(&Default::default(), window, cx)
6196 });
6197 cx.executor().run_until_parked();
6198 assert_eq!(
6199 visible_entries_as_strings(&panel, 0..50, cx),
6200 &[
6201 //
6202 "v root",
6203 " > a",
6204 " v b",
6205 " v a",
6206 " v inner_dir",
6207 " four.txt",
6208 " three.txt",
6209 " one.txt",
6210 " two.txt",
6211 " v c",
6212 " > a <== selected",
6213 " > inner_dir",
6214 " one.txt",
6215 " two.txt",
6216 ]
6217 );
6218 }
6219
6220 #[gpui::test]
6221 async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
6222 init_test(cx);
6223
6224 let fs = FakeFs::new(cx.executor().clone());
6225 fs.insert_tree(
6226 "/test",
6227 json!({
6228 "dir1": {
6229 "a.txt": "",
6230 "b.txt": "",
6231 },
6232 "dir2": {},
6233 "c.txt": "",
6234 "d.txt": "",
6235 }),
6236 )
6237 .await;
6238
6239 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
6240 let workspace =
6241 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6242 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6243 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6244
6245 toggle_expand_dir(&panel, "test/dir1", cx);
6246
6247 cx.simulate_modifiers_change(gpui::Modifiers {
6248 control: true,
6249 ..Default::default()
6250 });
6251
6252 select_path_with_mark(&panel, "test/dir1", cx);
6253 select_path_with_mark(&panel, "test/c.txt", cx);
6254
6255 assert_eq!(
6256 visible_entries_as_strings(&panel, 0..15, cx),
6257 &[
6258 "v test",
6259 " v dir1 <== marked",
6260 " a.txt",
6261 " b.txt",
6262 " > dir2",
6263 " c.txt <== selected <== marked",
6264 " d.txt",
6265 ],
6266 "Initial state before copying dir1 and c.txt"
6267 );
6268
6269 panel.update_in(cx, |panel, window, cx| {
6270 panel.copy(&Default::default(), window, cx);
6271 });
6272 select_path(&panel, "test/dir2", cx);
6273 panel.update_in(cx, |panel, window, cx| {
6274 panel.paste(&Default::default(), window, cx);
6275 });
6276 cx.executor().run_until_parked();
6277
6278 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
6279
6280 assert_eq!(
6281 visible_entries_as_strings(&panel, 0..15, cx),
6282 &[
6283 "v test",
6284 " v dir1 <== marked",
6285 " a.txt",
6286 " b.txt",
6287 " v dir2",
6288 " v dir1 <== selected",
6289 " a.txt",
6290 " b.txt",
6291 " c.txt",
6292 " c.txt <== marked",
6293 " d.txt",
6294 ],
6295 "Should copy dir1 as well as c.txt into dir2"
6296 );
6297
6298 // Disambiguating multiple files should not open the rename editor.
6299 select_path(&panel, "test/dir2", cx);
6300 panel.update_in(cx, |panel, window, cx| {
6301 panel.paste(&Default::default(), window, cx);
6302 });
6303 cx.executor().run_until_parked();
6304
6305 assert_eq!(
6306 visible_entries_as_strings(&panel, 0..15, cx),
6307 &[
6308 "v test",
6309 " v dir1 <== marked",
6310 " a.txt",
6311 " b.txt",
6312 " v dir2",
6313 " v dir1",
6314 " a.txt",
6315 " b.txt",
6316 " > dir1 copy <== selected",
6317 " c.txt",
6318 " c copy.txt",
6319 " c.txt <== marked",
6320 " d.txt",
6321 ],
6322 "Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor"
6323 );
6324 }
6325
6326 #[gpui::test]
6327 async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
6328 init_test(cx);
6329
6330 let fs = FakeFs::new(cx.executor().clone());
6331 fs.insert_tree(
6332 "/test",
6333 json!({
6334 "dir1": {
6335 "a.txt": "",
6336 "b.txt": "",
6337 },
6338 "dir2": {},
6339 "c.txt": "",
6340 "d.txt": "",
6341 }),
6342 )
6343 .await;
6344
6345 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
6346 let workspace =
6347 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6348 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6349 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6350
6351 toggle_expand_dir(&panel, "test/dir1", cx);
6352
6353 cx.simulate_modifiers_change(gpui::Modifiers {
6354 control: true,
6355 ..Default::default()
6356 });
6357
6358 select_path_with_mark(&panel, "test/dir1/a.txt", cx);
6359 select_path_with_mark(&panel, "test/dir1", cx);
6360 select_path_with_mark(&panel, "test/c.txt", cx);
6361
6362 assert_eq!(
6363 visible_entries_as_strings(&panel, 0..15, cx),
6364 &[
6365 "v test",
6366 " v dir1 <== marked",
6367 " a.txt <== marked",
6368 " b.txt",
6369 " > dir2",
6370 " c.txt <== selected <== marked",
6371 " d.txt",
6372 ],
6373 "Initial state before copying a.txt, dir1 and c.txt"
6374 );
6375
6376 panel.update_in(cx, |panel, window, cx| {
6377 panel.copy(&Default::default(), window, cx);
6378 });
6379 select_path(&panel, "test/dir2", cx);
6380 panel.update_in(cx, |panel, window, cx| {
6381 panel.paste(&Default::default(), window, cx);
6382 });
6383 cx.executor().run_until_parked();
6384
6385 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
6386
6387 assert_eq!(
6388 visible_entries_as_strings(&panel, 0..20, cx),
6389 &[
6390 "v test",
6391 " v dir1 <== marked",
6392 " a.txt <== marked",
6393 " b.txt",
6394 " v dir2",
6395 " v dir1 <== selected",
6396 " a.txt",
6397 " b.txt",
6398 " c.txt",
6399 " c.txt <== marked",
6400 " d.txt",
6401 ],
6402 "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
6403 );
6404 }
6405
6406 #[gpui::test]
6407 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
6408 init_test_with_editor(cx);
6409
6410 let fs = FakeFs::new(cx.executor().clone());
6411 fs.insert_tree(
6412 "/src",
6413 json!({
6414 "test": {
6415 "first.rs": "// First Rust file",
6416 "second.rs": "// Second Rust file",
6417 "third.rs": "// Third Rust file",
6418 }
6419 }),
6420 )
6421 .await;
6422
6423 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
6424 let workspace =
6425 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6426 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6427 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6428
6429 toggle_expand_dir(&panel, "src/test", cx);
6430 select_path(&panel, "src/test/first.rs", cx);
6431 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
6432 cx.executor().run_until_parked();
6433 assert_eq!(
6434 visible_entries_as_strings(&panel, 0..10, cx),
6435 &[
6436 "v src",
6437 " v test",
6438 " first.rs <== selected <== marked",
6439 " second.rs",
6440 " third.rs"
6441 ]
6442 );
6443 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
6444
6445 submit_deletion(&panel, cx);
6446 assert_eq!(
6447 visible_entries_as_strings(&panel, 0..10, cx),
6448 &[
6449 "v src",
6450 " v test",
6451 " second.rs <== selected",
6452 " third.rs"
6453 ],
6454 "Project panel should have no deleted file, no other file is selected in it"
6455 );
6456 ensure_no_open_items_and_panes(&workspace, cx);
6457
6458 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
6459 cx.executor().run_until_parked();
6460 assert_eq!(
6461 visible_entries_as_strings(&panel, 0..10, cx),
6462 &[
6463 "v src",
6464 " v test",
6465 " second.rs <== selected <== marked",
6466 " third.rs"
6467 ]
6468 );
6469 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
6470
6471 workspace
6472 .update(cx, |workspace, window, cx| {
6473 let active_items = workspace
6474 .panes()
6475 .iter()
6476 .filter_map(|pane| pane.read(cx).active_item())
6477 .collect::<Vec<_>>();
6478 assert_eq!(active_items.len(), 1);
6479 let open_editor = active_items
6480 .into_iter()
6481 .next()
6482 .unwrap()
6483 .downcast::<Editor>()
6484 .expect("Open item should be an editor");
6485 open_editor.update(cx, |editor, cx| {
6486 editor.set_text("Another text!", window, cx)
6487 });
6488 })
6489 .unwrap();
6490 submit_deletion_skipping_prompt(&panel, cx);
6491 assert_eq!(
6492 visible_entries_as_strings(&panel, 0..10, cx),
6493 &["v src", " v test", " third.rs <== selected"],
6494 "Project panel should have no deleted file, with one last file remaining"
6495 );
6496 ensure_no_open_items_and_panes(&workspace, cx);
6497 }
6498
6499 #[gpui::test]
6500 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
6501 init_test_with_editor(cx);
6502
6503 let fs = FakeFs::new(cx.executor().clone());
6504 fs.insert_tree(
6505 "/src",
6506 json!({
6507 "test": {
6508 "first.rs": "// First Rust file",
6509 "second.rs": "// Second Rust file",
6510 "third.rs": "// Third Rust file",
6511 }
6512 }),
6513 )
6514 .await;
6515
6516 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
6517 let workspace =
6518 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6519 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6520 let panel = workspace
6521 .update(cx, |workspace, window, cx| {
6522 let panel = ProjectPanel::new(workspace, window, cx);
6523 workspace.add_panel(panel.clone(), window, cx);
6524 panel
6525 })
6526 .unwrap();
6527
6528 select_path(&panel, "src/", cx);
6529 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
6530 cx.executor().run_until_parked();
6531 assert_eq!(
6532 visible_entries_as_strings(&panel, 0..10, cx),
6533 &[
6534 //
6535 "v src <== selected",
6536 " > test"
6537 ]
6538 );
6539 panel.update_in(cx, |panel, window, cx| {
6540 panel.new_directory(&NewDirectory, window, cx)
6541 });
6542 panel.update_in(cx, |panel, window, cx| {
6543 assert!(panel.filename_editor.read(cx).is_focused(window));
6544 });
6545 assert_eq!(
6546 visible_entries_as_strings(&panel, 0..10, cx),
6547 &[
6548 //
6549 "v src",
6550 " > [EDITOR: ''] <== selected",
6551 " > test"
6552 ]
6553 );
6554 panel.update_in(cx, |panel, window, cx| {
6555 panel
6556 .filename_editor
6557 .update(cx, |editor, cx| editor.set_text("test", window, cx));
6558 assert!(
6559 panel.confirm_edit(window, cx).is_none(),
6560 "Should not allow to confirm on conflicting new directory name"
6561 )
6562 });
6563 assert_eq!(
6564 visible_entries_as_strings(&panel, 0..10, cx),
6565 &[
6566 //
6567 "v src",
6568 " > test"
6569 ],
6570 "File list should be unchanged after failed folder create confirmation"
6571 );
6572
6573 select_path(&panel, "src/test/", cx);
6574 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
6575 cx.executor().run_until_parked();
6576 assert_eq!(
6577 visible_entries_as_strings(&panel, 0..10, cx),
6578 &[
6579 //
6580 "v src",
6581 " > test <== selected"
6582 ]
6583 );
6584 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
6585 panel.update_in(cx, |panel, window, cx| {
6586 assert!(panel.filename_editor.read(cx).is_focused(window));
6587 });
6588 assert_eq!(
6589 visible_entries_as_strings(&panel, 0..10, cx),
6590 &[
6591 "v src",
6592 " v test",
6593 " [EDITOR: ''] <== selected",
6594 " first.rs",
6595 " second.rs",
6596 " third.rs"
6597 ]
6598 );
6599 panel.update_in(cx, |panel, window, cx| {
6600 panel
6601 .filename_editor
6602 .update(cx, |editor, cx| editor.set_text("first.rs", window, cx));
6603 assert!(
6604 panel.confirm_edit(window, cx).is_none(),
6605 "Should not allow to confirm on conflicting new file name"
6606 )
6607 });
6608 assert_eq!(
6609 visible_entries_as_strings(&panel, 0..10, cx),
6610 &[
6611 "v src",
6612 " v test",
6613 " first.rs",
6614 " second.rs",
6615 " third.rs"
6616 ],
6617 "File list should be unchanged after failed file create confirmation"
6618 );
6619
6620 select_path(&panel, "src/test/first.rs", cx);
6621 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
6622 cx.executor().run_until_parked();
6623 assert_eq!(
6624 visible_entries_as_strings(&panel, 0..10, cx),
6625 &[
6626 "v src",
6627 " v test",
6628 " first.rs <== selected",
6629 " second.rs",
6630 " third.rs"
6631 ],
6632 );
6633 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
6634 panel.update_in(cx, |panel, window, cx| {
6635 assert!(panel.filename_editor.read(cx).is_focused(window));
6636 });
6637 assert_eq!(
6638 visible_entries_as_strings(&panel, 0..10, cx),
6639 &[
6640 "v src",
6641 " v test",
6642 " [EDITOR: 'first.rs'] <== selected",
6643 " second.rs",
6644 " third.rs"
6645 ]
6646 );
6647 panel.update_in(cx, |panel, window, cx| {
6648 panel
6649 .filename_editor
6650 .update(cx, |editor, cx| editor.set_text("second.rs", window, cx));
6651 assert!(
6652 panel.confirm_edit(window, cx).is_none(),
6653 "Should not allow to confirm on conflicting file rename"
6654 )
6655 });
6656 assert_eq!(
6657 visible_entries_as_strings(&panel, 0..10, cx),
6658 &[
6659 "v src",
6660 " v test",
6661 " first.rs <== selected",
6662 " second.rs",
6663 " third.rs"
6664 ],
6665 "File list should be unchanged after failed rename confirmation"
6666 );
6667 }
6668
6669 #[gpui::test]
6670 async fn test_select_directory(cx: &mut gpui::TestAppContext) {
6671 init_test_with_editor(cx);
6672
6673 let fs = FakeFs::new(cx.executor().clone());
6674 fs.insert_tree(
6675 "/project_root",
6676 json!({
6677 "dir_1": {
6678 "nested_dir": {
6679 "file_a.py": "# File contents",
6680 }
6681 },
6682 "file_1.py": "# File contents",
6683 "dir_2": {
6684
6685 },
6686 "dir_3": {
6687
6688 },
6689 "file_2.py": "# File contents",
6690 "dir_4": {
6691
6692 },
6693 }),
6694 )
6695 .await;
6696
6697 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6698 let workspace =
6699 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6700 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6701 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6702
6703 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
6704 cx.executor().run_until_parked();
6705 select_path(&panel, "project_root/dir_1", cx);
6706 cx.executor().run_until_parked();
6707 assert_eq!(
6708 visible_entries_as_strings(&panel, 0..10, cx),
6709 &[
6710 "v project_root",
6711 " > dir_1 <== selected",
6712 " > dir_2",
6713 " > dir_3",
6714 " > dir_4",
6715 " file_1.py",
6716 " file_2.py",
6717 ]
6718 );
6719 panel.update_in(cx, |panel, window, cx| {
6720 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
6721 });
6722
6723 assert_eq!(
6724 visible_entries_as_strings(&panel, 0..10, cx),
6725 &[
6726 "v project_root <== selected",
6727 " > dir_1",
6728 " > dir_2",
6729 " > dir_3",
6730 " > dir_4",
6731 " file_1.py",
6732 " file_2.py",
6733 ]
6734 );
6735
6736 panel.update_in(cx, |panel, window, cx| {
6737 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
6738 });
6739
6740 assert_eq!(
6741 visible_entries_as_strings(&panel, 0..10, cx),
6742 &[
6743 "v project_root",
6744 " > dir_1",
6745 " > dir_2",
6746 " > dir_3",
6747 " > dir_4 <== selected",
6748 " file_1.py",
6749 " file_2.py",
6750 ]
6751 );
6752
6753 panel.update_in(cx, |panel, window, cx| {
6754 panel.select_next_directory(&SelectNextDirectory, window, cx)
6755 });
6756
6757 assert_eq!(
6758 visible_entries_as_strings(&panel, 0..10, cx),
6759 &[
6760 "v project_root <== selected",
6761 " > dir_1",
6762 " > dir_2",
6763 " > dir_3",
6764 " > dir_4",
6765 " file_1.py",
6766 " file_2.py",
6767 ]
6768 );
6769 }
6770
6771 #[gpui::test]
6772 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
6773 init_test_with_editor(cx);
6774
6775 let fs = FakeFs::new(cx.executor().clone());
6776 fs.insert_tree(
6777 "/project_root",
6778 json!({
6779 "dir_1": {
6780 "nested_dir": {
6781 "file_a.py": "# File contents",
6782 }
6783 },
6784 "file_1.py": "# File contents",
6785 }),
6786 )
6787 .await;
6788
6789 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6790 let workspace =
6791 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6792 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6793 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6794
6795 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
6796 cx.executor().run_until_parked();
6797 select_path(&panel, "project_root/dir_1", cx);
6798 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
6799 select_path(&panel, "project_root/dir_1/nested_dir", cx);
6800 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
6801 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
6802 cx.executor().run_until_parked();
6803 assert_eq!(
6804 visible_entries_as_strings(&panel, 0..10, cx),
6805 &[
6806 "v project_root",
6807 " v dir_1",
6808 " > nested_dir <== selected",
6809 " file_1.py",
6810 ]
6811 );
6812 }
6813
6814 #[gpui::test]
6815 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
6816 init_test_with_editor(cx);
6817
6818 let fs = FakeFs::new(cx.executor().clone());
6819 fs.insert_tree(
6820 "/project_root",
6821 json!({
6822 "dir_1": {
6823 "nested_dir": {
6824 "file_a.py": "# File contents",
6825 "file_b.py": "# File contents",
6826 "file_c.py": "# File contents",
6827 },
6828 "file_1.py": "# File contents",
6829 "file_2.py": "# File contents",
6830 "file_3.py": "# File contents",
6831 },
6832 "dir_2": {
6833 "file_1.py": "# File contents",
6834 "file_2.py": "# File contents",
6835 "file_3.py": "# File contents",
6836 }
6837 }),
6838 )
6839 .await;
6840
6841 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6842 let workspace =
6843 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6844 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6845 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6846
6847 panel.update_in(cx, |panel, window, cx| {
6848 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
6849 });
6850 cx.executor().run_until_parked();
6851 assert_eq!(
6852 visible_entries_as_strings(&panel, 0..10, cx),
6853 &["v project_root", " > dir_1", " > dir_2",]
6854 );
6855
6856 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
6857 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6858 cx.executor().run_until_parked();
6859 assert_eq!(
6860 visible_entries_as_strings(&panel, 0..10, cx),
6861 &[
6862 "v project_root",
6863 " v dir_1 <== selected",
6864 " > nested_dir",
6865 " file_1.py",
6866 " file_2.py",
6867 " file_3.py",
6868 " > dir_2",
6869 ]
6870 );
6871 }
6872
6873 #[gpui::test]
6874 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
6875 init_test(cx);
6876
6877 let fs = FakeFs::new(cx.executor().clone());
6878 fs.as_fake().insert_tree("/root", json!({})).await;
6879 let project = Project::test(fs, ["/root".as_ref()], cx).await;
6880 let workspace =
6881 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6882 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6883 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6884
6885 // Make a new buffer with no backing file
6886 workspace
6887 .update(cx, |workspace, window, cx| {
6888 Editor::new_file(workspace, &Default::default(), window, cx)
6889 })
6890 .unwrap();
6891
6892 cx.executor().run_until_parked();
6893
6894 // "Save as" the buffer, creating a new backing file for it
6895 let save_task = workspace
6896 .update(cx, |workspace, window, cx| {
6897 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
6898 })
6899 .unwrap();
6900
6901 cx.executor().run_until_parked();
6902 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
6903 save_task.await.unwrap();
6904
6905 // Rename the file
6906 select_path(&panel, "root/new", cx);
6907 assert_eq!(
6908 visible_entries_as_strings(&panel, 0..10, cx),
6909 &["v root", " new <== selected"]
6910 );
6911 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
6912 panel.update_in(cx, |panel, window, cx| {
6913 panel
6914 .filename_editor
6915 .update(cx, |editor, cx| editor.set_text("newer", window, cx));
6916 });
6917 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
6918
6919 cx.executor().run_until_parked();
6920 assert_eq!(
6921 visible_entries_as_strings(&panel, 0..10, cx),
6922 &["v root", " newer <== selected"]
6923 );
6924
6925 workspace
6926 .update(cx, |workspace, window, cx| {
6927 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
6928 })
6929 .unwrap()
6930 .await
6931 .unwrap();
6932
6933 cx.executor().run_until_parked();
6934 // assert that saving the file doesn't restore "new"
6935 assert_eq!(
6936 visible_entries_as_strings(&panel, 0..10, cx),
6937 &["v root", " newer <== selected"]
6938 );
6939 }
6940
6941 #[gpui::test]
6942 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
6943 init_test_with_editor(cx);
6944 let fs = FakeFs::new(cx.executor().clone());
6945 fs.insert_tree(
6946 "/project_root",
6947 json!({
6948 "dir_1": {
6949 "nested_dir": {
6950 "file_a.py": "# File contents",
6951 }
6952 },
6953 "file_1.py": "# File contents",
6954 }),
6955 )
6956 .await;
6957
6958 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6959 let worktree_id =
6960 cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
6961 let workspace =
6962 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6963 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6964 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6965 cx.update(|window, cx| {
6966 panel.update(cx, |this, cx| {
6967 this.select_next(&Default::default(), window, cx);
6968 this.expand_selected_entry(&Default::default(), window, cx);
6969 this.expand_selected_entry(&Default::default(), window, cx);
6970 this.select_next(&Default::default(), window, cx);
6971 this.expand_selected_entry(&Default::default(), window, cx);
6972 this.select_next(&Default::default(), window, cx);
6973 })
6974 });
6975 assert_eq!(
6976 visible_entries_as_strings(&panel, 0..10, cx),
6977 &[
6978 "v project_root",
6979 " v dir_1",
6980 " v nested_dir",
6981 " file_a.py <== selected",
6982 " file_1.py",
6983 ]
6984 );
6985 let modifiers_with_shift = gpui::Modifiers {
6986 shift: true,
6987 ..Default::default()
6988 };
6989 cx.simulate_modifiers_change(modifiers_with_shift);
6990 cx.update(|window, cx| {
6991 panel.update(cx, |this, cx| {
6992 this.select_next(&Default::default(), window, cx);
6993 })
6994 });
6995 assert_eq!(
6996 visible_entries_as_strings(&panel, 0..10, cx),
6997 &[
6998 "v project_root",
6999 " v dir_1",
7000 " v nested_dir",
7001 " file_a.py",
7002 " file_1.py <== selected <== marked",
7003 ]
7004 );
7005 cx.update(|window, cx| {
7006 panel.update(cx, |this, cx| {
7007 this.select_prev(&Default::default(), window, cx);
7008 })
7009 });
7010 assert_eq!(
7011 visible_entries_as_strings(&panel, 0..10, cx),
7012 &[
7013 "v project_root",
7014 " v dir_1",
7015 " v nested_dir",
7016 " file_a.py <== selected <== marked",
7017 " file_1.py <== marked",
7018 ]
7019 );
7020 cx.update(|window, cx| {
7021 panel.update(cx, |this, cx| {
7022 let drag = DraggedSelection {
7023 active_selection: this.selection.unwrap(),
7024 marked_selections: Arc::new(this.marked_entries.clone()),
7025 };
7026 let target_entry = this
7027 .project
7028 .read(cx)
7029 .entry_for_path(&(worktree_id, "").into(), cx)
7030 .unwrap();
7031 this.drag_onto(&drag, target_entry.id, false, window, cx);
7032 });
7033 });
7034 cx.run_until_parked();
7035 assert_eq!(
7036 visible_entries_as_strings(&panel, 0..10, cx),
7037 &[
7038 "v project_root",
7039 " v dir_1",
7040 " v nested_dir",
7041 " file_1.py <== marked",
7042 " file_a.py <== selected <== marked",
7043 ]
7044 );
7045 // ESC clears out all marks
7046 cx.update(|window, cx| {
7047 panel.update(cx, |this, cx| {
7048 this.cancel(&menu::Cancel, window, cx);
7049 })
7050 });
7051 assert_eq!(
7052 visible_entries_as_strings(&panel, 0..10, cx),
7053 &[
7054 "v project_root",
7055 " v dir_1",
7056 " v nested_dir",
7057 " file_1.py",
7058 " file_a.py <== selected",
7059 ]
7060 );
7061 // ESC clears out all marks
7062 cx.update(|window, cx| {
7063 panel.update(cx, |this, cx| {
7064 this.select_prev(&SelectPrev, window, cx);
7065 this.select_next(&SelectNext, window, cx);
7066 })
7067 });
7068 assert_eq!(
7069 visible_entries_as_strings(&panel, 0..10, cx),
7070 &[
7071 "v project_root",
7072 " v dir_1",
7073 " v nested_dir",
7074 " file_1.py <== marked",
7075 " file_a.py <== selected <== marked",
7076 ]
7077 );
7078 cx.simulate_modifiers_change(Default::default());
7079 cx.update(|window, cx| {
7080 panel.update(cx, |this, cx| {
7081 this.cut(&Cut, window, cx);
7082 this.select_prev(&SelectPrev, window, cx);
7083 this.select_prev(&SelectPrev, window, cx);
7084
7085 this.paste(&Paste, window, cx);
7086 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
7087 })
7088 });
7089 cx.run_until_parked();
7090 assert_eq!(
7091 visible_entries_as_strings(&panel, 0..10, cx),
7092 &[
7093 "v project_root",
7094 " v dir_1",
7095 " v nested_dir",
7096 " file_1.py <== marked",
7097 " file_a.py <== selected <== marked",
7098 ]
7099 );
7100 cx.simulate_modifiers_change(modifiers_with_shift);
7101 cx.update(|window, cx| {
7102 panel.update(cx, |this, cx| {
7103 this.expand_selected_entry(&Default::default(), window, cx);
7104 this.select_next(&SelectNext, window, cx);
7105 this.select_next(&SelectNext, window, cx);
7106 })
7107 });
7108 submit_deletion(&panel, cx);
7109 assert_eq!(
7110 visible_entries_as_strings(&panel, 0..10, cx),
7111 &[
7112 "v project_root",
7113 " v dir_1",
7114 " v nested_dir <== selected",
7115 ]
7116 );
7117 }
7118 #[gpui::test]
7119 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
7120 init_test_with_editor(cx);
7121 cx.update(|cx| {
7122 cx.update_global::<SettingsStore, _>(|store, cx| {
7123 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7124 worktree_settings.file_scan_exclusions = Some(Vec::new());
7125 });
7126 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7127 project_panel_settings.auto_reveal_entries = Some(false)
7128 });
7129 })
7130 });
7131
7132 let fs = FakeFs::new(cx.background_executor.clone());
7133 fs.insert_tree(
7134 "/project_root",
7135 json!({
7136 ".git": {},
7137 ".gitignore": "**/gitignored_dir",
7138 "dir_1": {
7139 "file_1.py": "# File 1_1 contents",
7140 "file_2.py": "# File 1_2 contents",
7141 "file_3.py": "# File 1_3 contents",
7142 "gitignored_dir": {
7143 "file_a.py": "# File contents",
7144 "file_b.py": "# File contents",
7145 "file_c.py": "# File contents",
7146 },
7147 },
7148 "dir_2": {
7149 "file_1.py": "# File 2_1 contents",
7150 "file_2.py": "# File 2_2 contents",
7151 "file_3.py": "# File 2_3 contents",
7152 }
7153 }),
7154 )
7155 .await;
7156
7157 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7158 let workspace =
7159 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7160 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7161 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7162
7163 assert_eq!(
7164 visible_entries_as_strings(&panel, 0..20, cx),
7165 &[
7166 "v project_root",
7167 " > .git",
7168 " > dir_1",
7169 " > dir_2",
7170 " .gitignore",
7171 ]
7172 );
7173
7174 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
7175 .expect("dir 1 file is not ignored and should have an entry");
7176 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
7177 .expect("dir 2 file is not ignored and should have an entry");
7178 let gitignored_dir_file =
7179 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
7180 assert_eq!(
7181 gitignored_dir_file, None,
7182 "File in the gitignored dir should not have an entry before its dir is toggled"
7183 );
7184
7185 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7186 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7187 cx.executor().run_until_parked();
7188 assert_eq!(
7189 visible_entries_as_strings(&panel, 0..20, cx),
7190 &[
7191 "v project_root",
7192 " > .git",
7193 " v dir_1",
7194 " v gitignored_dir <== selected",
7195 " file_a.py",
7196 " file_b.py",
7197 " file_c.py",
7198 " file_1.py",
7199 " file_2.py",
7200 " file_3.py",
7201 " > dir_2",
7202 " .gitignore",
7203 ],
7204 "Should show gitignored dir file list in the project panel"
7205 );
7206 let gitignored_dir_file =
7207 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
7208 .expect("after gitignored dir got opened, a file entry should be present");
7209
7210 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7211 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7212 assert_eq!(
7213 visible_entries_as_strings(&panel, 0..20, cx),
7214 &[
7215 "v project_root",
7216 " > .git",
7217 " > dir_1 <== selected",
7218 " > dir_2",
7219 " .gitignore",
7220 ],
7221 "Should hide all dir contents again and prepare for the auto reveal test"
7222 );
7223
7224 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
7225 panel.update(cx, |panel, cx| {
7226 panel.project.update(cx, |_, cx| {
7227 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
7228 })
7229 });
7230 cx.run_until_parked();
7231 assert_eq!(
7232 visible_entries_as_strings(&panel, 0..20, cx),
7233 &[
7234 "v project_root",
7235 " > .git",
7236 " > dir_1 <== selected",
7237 " > dir_2",
7238 " .gitignore",
7239 ],
7240 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
7241 );
7242 }
7243
7244 cx.update(|_, cx| {
7245 cx.update_global::<SettingsStore, _>(|store, cx| {
7246 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7247 project_panel_settings.auto_reveal_entries = Some(true)
7248 });
7249 })
7250 });
7251
7252 panel.update(cx, |panel, cx| {
7253 panel.project.update(cx, |_, cx| {
7254 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
7255 })
7256 });
7257 cx.run_until_parked();
7258 assert_eq!(
7259 visible_entries_as_strings(&panel, 0..20, cx),
7260 &[
7261 "v project_root",
7262 " > .git",
7263 " v dir_1",
7264 " > gitignored_dir",
7265 " file_1.py <== selected",
7266 " file_2.py",
7267 " file_3.py",
7268 " > dir_2",
7269 " .gitignore",
7270 ],
7271 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
7272 );
7273
7274 panel.update(cx, |panel, cx| {
7275 panel.project.update(cx, |_, cx| {
7276 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
7277 })
7278 });
7279 cx.run_until_parked();
7280 assert_eq!(
7281 visible_entries_as_strings(&panel, 0..20, cx),
7282 &[
7283 "v project_root",
7284 " > .git",
7285 " v dir_1",
7286 " > gitignored_dir",
7287 " file_1.py",
7288 " file_2.py",
7289 " file_3.py",
7290 " v dir_2",
7291 " file_1.py <== selected",
7292 " file_2.py",
7293 " file_3.py",
7294 " .gitignore",
7295 ],
7296 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
7297 );
7298
7299 panel.update(cx, |panel, cx| {
7300 panel.project.update(cx, |_, cx| {
7301 cx.emit(project::Event::ActiveEntryChanged(Some(
7302 gitignored_dir_file,
7303 )))
7304 })
7305 });
7306 cx.run_until_parked();
7307 assert_eq!(
7308 visible_entries_as_strings(&panel, 0..20, cx),
7309 &[
7310 "v project_root",
7311 " > .git",
7312 " v dir_1",
7313 " > gitignored_dir",
7314 " file_1.py",
7315 " file_2.py",
7316 " file_3.py",
7317 " v dir_2",
7318 " file_1.py <== selected",
7319 " file_2.py",
7320 " file_3.py",
7321 " .gitignore",
7322 ],
7323 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
7324 );
7325
7326 panel.update(cx, |panel, cx| {
7327 panel.project.update(cx, |_, cx| {
7328 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
7329 })
7330 });
7331 cx.run_until_parked();
7332 assert_eq!(
7333 visible_entries_as_strings(&panel, 0..20, cx),
7334 &[
7335 "v project_root",
7336 " > .git",
7337 " v dir_1",
7338 " v gitignored_dir",
7339 " file_a.py <== selected",
7340 " file_b.py",
7341 " file_c.py",
7342 " file_1.py",
7343 " file_2.py",
7344 " file_3.py",
7345 " v dir_2",
7346 " file_1.py",
7347 " file_2.py",
7348 " file_3.py",
7349 " .gitignore",
7350 ],
7351 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
7352 );
7353 }
7354
7355 #[gpui::test]
7356 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
7357 init_test_with_editor(cx);
7358 cx.update(|cx| {
7359 cx.update_global::<SettingsStore, _>(|store, cx| {
7360 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7361 worktree_settings.file_scan_exclusions = Some(Vec::new());
7362 });
7363 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7364 project_panel_settings.auto_reveal_entries = Some(false)
7365 });
7366 })
7367 });
7368
7369 let fs = FakeFs::new(cx.background_executor.clone());
7370 fs.insert_tree(
7371 "/project_root",
7372 json!({
7373 ".git": {},
7374 ".gitignore": "**/gitignored_dir",
7375 "dir_1": {
7376 "file_1.py": "# File 1_1 contents",
7377 "file_2.py": "# File 1_2 contents",
7378 "file_3.py": "# File 1_3 contents",
7379 "gitignored_dir": {
7380 "file_a.py": "# File contents",
7381 "file_b.py": "# File contents",
7382 "file_c.py": "# File contents",
7383 },
7384 },
7385 "dir_2": {
7386 "file_1.py": "# File 2_1 contents",
7387 "file_2.py": "# File 2_2 contents",
7388 "file_3.py": "# File 2_3 contents",
7389 }
7390 }),
7391 )
7392 .await;
7393
7394 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7395 let workspace =
7396 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7397 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7398 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7399
7400 assert_eq!(
7401 visible_entries_as_strings(&panel, 0..20, cx),
7402 &[
7403 "v project_root",
7404 " > .git",
7405 " > dir_1",
7406 " > dir_2",
7407 " .gitignore",
7408 ]
7409 );
7410
7411 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
7412 .expect("dir 1 file is not ignored and should have an entry");
7413 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
7414 .expect("dir 2 file is not ignored and should have an entry");
7415 let gitignored_dir_file =
7416 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
7417 assert_eq!(
7418 gitignored_dir_file, None,
7419 "File in the gitignored dir should not have an entry before its dir is toggled"
7420 );
7421
7422 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7423 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7424 cx.run_until_parked();
7425 assert_eq!(
7426 visible_entries_as_strings(&panel, 0..20, cx),
7427 &[
7428 "v project_root",
7429 " > .git",
7430 " v dir_1",
7431 " v gitignored_dir <== selected",
7432 " file_a.py",
7433 " file_b.py",
7434 " file_c.py",
7435 " file_1.py",
7436 " file_2.py",
7437 " file_3.py",
7438 " > dir_2",
7439 " .gitignore",
7440 ],
7441 "Should show gitignored dir file list in the project panel"
7442 );
7443 let gitignored_dir_file =
7444 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
7445 .expect("after gitignored dir got opened, a file entry should be present");
7446
7447 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7448 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7449 assert_eq!(
7450 visible_entries_as_strings(&panel, 0..20, cx),
7451 &[
7452 "v project_root",
7453 " > .git",
7454 " > dir_1 <== selected",
7455 " > dir_2",
7456 " .gitignore",
7457 ],
7458 "Should hide all dir contents again and prepare for the explicit reveal test"
7459 );
7460
7461 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
7462 panel.update(cx, |panel, cx| {
7463 panel.project.update(cx, |_, cx| {
7464 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
7465 })
7466 });
7467 cx.run_until_parked();
7468 assert_eq!(
7469 visible_entries_as_strings(&panel, 0..20, cx),
7470 &[
7471 "v project_root",
7472 " > .git",
7473 " > dir_1 <== selected",
7474 " > dir_2",
7475 " .gitignore",
7476 ],
7477 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
7478 );
7479 }
7480
7481 panel.update(cx, |panel, cx| {
7482 panel.project.update(cx, |_, cx| {
7483 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
7484 })
7485 });
7486 cx.run_until_parked();
7487 assert_eq!(
7488 visible_entries_as_strings(&panel, 0..20, cx),
7489 &[
7490 "v project_root",
7491 " > .git",
7492 " v dir_1",
7493 " > gitignored_dir",
7494 " file_1.py <== selected",
7495 " file_2.py",
7496 " file_3.py",
7497 " > dir_2",
7498 " .gitignore",
7499 ],
7500 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
7501 );
7502
7503 panel.update(cx, |panel, cx| {
7504 panel.project.update(cx, |_, cx| {
7505 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
7506 })
7507 });
7508 cx.run_until_parked();
7509 assert_eq!(
7510 visible_entries_as_strings(&panel, 0..20, cx),
7511 &[
7512 "v project_root",
7513 " > .git",
7514 " v dir_1",
7515 " > gitignored_dir",
7516 " file_1.py",
7517 " file_2.py",
7518 " file_3.py",
7519 " v dir_2",
7520 " file_1.py <== selected",
7521 " file_2.py",
7522 " file_3.py",
7523 " .gitignore",
7524 ],
7525 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
7526 );
7527
7528 panel.update(cx, |panel, cx| {
7529 panel.project.update(cx, |_, cx| {
7530 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
7531 })
7532 });
7533 cx.run_until_parked();
7534 assert_eq!(
7535 visible_entries_as_strings(&panel, 0..20, cx),
7536 &[
7537 "v project_root",
7538 " > .git",
7539 " v dir_1",
7540 " v gitignored_dir",
7541 " file_a.py <== selected",
7542 " file_b.py",
7543 " file_c.py",
7544 " file_1.py",
7545 " file_2.py",
7546 " file_3.py",
7547 " v dir_2",
7548 " file_1.py",
7549 " file_2.py",
7550 " file_3.py",
7551 " .gitignore",
7552 ],
7553 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
7554 );
7555 }
7556
7557 #[gpui::test]
7558 async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
7559 init_test(cx);
7560 cx.update(|cx| {
7561 cx.update_global::<SettingsStore, _>(|store, cx| {
7562 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
7563 project_settings.file_scan_exclusions =
7564 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
7565 });
7566 });
7567 });
7568
7569 cx.update(|cx| {
7570 register_project_item::<TestProjectItemView>(cx);
7571 });
7572
7573 let fs = FakeFs::new(cx.executor().clone());
7574 fs.insert_tree(
7575 "/root1",
7576 json!({
7577 ".dockerignore": "",
7578 ".git": {
7579 "HEAD": "",
7580 },
7581 }),
7582 )
7583 .await;
7584
7585 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
7586 let workspace =
7587 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7588 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7589 let panel = workspace
7590 .update(cx, |workspace, window, cx| {
7591 let panel = ProjectPanel::new(workspace, window, cx);
7592 workspace.add_panel(panel.clone(), window, cx);
7593 panel
7594 })
7595 .unwrap();
7596
7597 select_path(&panel, "root1", cx);
7598 assert_eq!(
7599 visible_entries_as_strings(&panel, 0..10, cx),
7600 &["v root1 <== selected", " .dockerignore",]
7601 );
7602 workspace
7603 .update(cx, |workspace, _, cx| {
7604 assert!(
7605 workspace.active_item(cx).is_none(),
7606 "Should have no active items in the beginning"
7607 );
7608 })
7609 .unwrap();
7610
7611 let excluded_file_path = ".git/COMMIT_EDITMSG";
7612 let excluded_dir_path = "excluded_dir";
7613
7614 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
7615 panel.update_in(cx, |panel, window, cx| {
7616 assert!(panel.filename_editor.read(cx).is_focused(window));
7617 });
7618 panel
7619 .update_in(cx, |panel, window, cx| {
7620 panel.filename_editor.update(cx, |editor, cx| {
7621 editor.set_text(excluded_file_path, window, cx)
7622 });
7623 panel.confirm_edit(window, cx).unwrap()
7624 })
7625 .await
7626 .unwrap();
7627
7628 assert_eq!(
7629 visible_entries_as_strings(&panel, 0..13, cx),
7630 &["v root1", " .dockerignore"],
7631 "Excluded dir should not be shown after opening a file in it"
7632 );
7633 panel.update_in(cx, |panel, window, cx| {
7634 assert!(
7635 !panel.filename_editor.read(cx).is_focused(window),
7636 "Should have closed the file name editor"
7637 );
7638 });
7639 workspace
7640 .update(cx, |workspace, _, cx| {
7641 let active_entry_path = workspace
7642 .active_item(cx)
7643 .expect("should have opened and activated the excluded item")
7644 .act_as::<TestProjectItemView>(cx)
7645 .expect(
7646 "should have opened the corresponding project item for the excluded item",
7647 )
7648 .read(cx)
7649 .path
7650 .clone();
7651 assert_eq!(
7652 active_entry_path.path.as_ref(),
7653 Path::new(excluded_file_path),
7654 "Should open the excluded file"
7655 );
7656
7657 assert!(
7658 workspace.notification_ids().is_empty(),
7659 "Should have no notifications after opening an excluded file"
7660 );
7661 })
7662 .unwrap();
7663 assert!(
7664 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
7665 "Should have created the excluded file"
7666 );
7667
7668 select_path(&panel, "root1", cx);
7669 panel.update_in(cx, |panel, window, cx| {
7670 panel.new_directory(&NewDirectory, window, cx)
7671 });
7672 panel.update_in(cx, |panel, window, cx| {
7673 assert!(panel.filename_editor.read(cx).is_focused(window));
7674 });
7675 panel
7676 .update_in(cx, |panel, window, cx| {
7677 panel.filename_editor.update(cx, |editor, cx| {
7678 editor.set_text(excluded_file_path, window, cx)
7679 });
7680 panel.confirm_edit(window, cx).unwrap()
7681 })
7682 .await
7683 .unwrap();
7684
7685 assert_eq!(
7686 visible_entries_as_strings(&panel, 0..13, cx),
7687 &["v root1", " .dockerignore"],
7688 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
7689 );
7690 panel.update_in(cx, |panel, window, cx| {
7691 assert!(
7692 !panel.filename_editor.read(cx).is_focused(window),
7693 "Should have closed the file name editor"
7694 );
7695 });
7696 workspace
7697 .update(cx, |workspace, _, cx| {
7698 let notifications = workspace.notification_ids();
7699 assert_eq!(
7700 notifications.len(),
7701 1,
7702 "Should receive one notification with the error message"
7703 );
7704 workspace.dismiss_notification(notifications.first().unwrap(), cx);
7705 assert!(workspace.notification_ids().is_empty());
7706 })
7707 .unwrap();
7708
7709 select_path(&panel, "root1", cx);
7710 panel.update_in(cx, |panel, window, cx| {
7711 panel.new_directory(&NewDirectory, window, cx)
7712 });
7713 panel.update_in(cx, |panel, window, cx| {
7714 assert!(panel.filename_editor.read(cx).is_focused(window));
7715 });
7716 panel
7717 .update_in(cx, |panel, window, cx| {
7718 panel.filename_editor.update(cx, |editor, cx| {
7719 editor.set_text(excluded_dir_path, window, cx)
7720 });
7721 panel.confirm_edit(window, cx).unwrap()
7722 })
7723 .await
7724 .unwrap();
7725
7726 assert_eq!(
7727 visible_entries_as_strings(&panel, 0..13, cx),
7728 &["v root1", " .dockerignore"],
7729 "Should not change the project panel after trying to create an excluded directory"
7730 );
7731 panel.update_in(cx, |panel, window, cx| {
7732 assert!(
7733 !panel.filename_editor.read(cx).is_focused(window),
7734 "Should have closed the file name editor"
7735 );
7736 });
7737 workspace
7738 .update(cx, |workspace, _, cx| {
7739 let notifications = workspace.notification_ids();
7740 assert_eq!(
7741 notifications.len(),
7742 1,
7743 "Should receive one notification explaining that no directory is actually shown"
7744 );
7745 workspace.dismiss_notification(notifications.first().unwrap(), cx);
7746 assert!(workspace.notification_ids().is_empty());
7747 })
7748 .unwrap();
7749 assert!(
7750 fs.is_dir(Path::new("/root1/excluded_dir")).await,
7751 "Should have created the excluded directory"
7752 );
7753 }
7754
7755 #[gpui::test]
7756 async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
7757 init_test_with_editor(cx);
7758
7759 let fs = FakeFs::new(cx.executor().clone());
7760 fs.insert_tree(
7761 "/src",
7762 json!({
7763 "test": {
7764 "first.rs": "// First Rust file",
7765 "second.rs": "// Second Rust file",
7766 "third.rs": "// Third Rust file",
7767 }
7768 }),
7769 )
7770 .await;
7771
7772 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
7773 let workspace =
7774 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7775 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7776 let panel = workspace
7777 .update(cx, |workspace, window, cx| {
7778 let panel = ProjectPanel::new(workspace, window, cx);
7779 workspace.add_panel(panel.clone(), window, cx);
7780 panel
7781 })
7782 .unwrap();
7783
7784 select_path(&panel, "src/", cx);
7785 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
7786 cx.executor().run_until_parked();
7787 assert_eq!(
7788 visible_entries_as_strings(&panel, 0..10, cx),
7789 &[
7790 //
7791 "v src <== selected",
7792 " > test"
7793 ]
7794 );
7795 panel.update_in(cx, |panel, window, cx| {
7796 panel.new_directory(&NewDirectory, window, cx)
7797 });
7798 panel.update_in(cx, |panel, window, cx| {
7799 assert!(panel.filename_editor.read(cx).is_focused(window));
7800 });
7801 assert_eq!(
7802 visible_entries_as_strings(&panel, 0..10, cx),
7803 &[
7804 //
7805 "v src",
7806 " > [EDITOR: ''] <== selected",
7807 " > test"
7808 ]
7809 );
7810
7811 panel.update_in(cx, |panel, window, cx| {
7812 panel.cancel(&menu::Cancel, window, cx)
7813 });
7814 assert_eq!(
7815 visible_entries_as_strings(&panel, 0..10, cx),
7816 &[
7817 //
7818 "v src <== selected",
7819 " > test"
7820 ]
7821 );
7822 }
7823
7824 #[gpui::test]
7825 async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
7826 init_test_with_editor(cx);
7827
7828 let fs = FakeFs::new(cx.executor().clone());
7829 fs.insert_tree(
7830 "/root",
7831 json!({
7832 "dir1": {
7833 "subdir1": {},
7834 "file1.txt": "",
7835 "file2.txt": "",
7836 },
7837 "dir2": {
7838 "subdir2": {},
7839 "file3.txt": "",
7840 "file4.txt": "",
7841 },
7842 "file5.txt": "",
7843 "file6.txt": "",
7844 }),
7845 )
7846 .await;
7847
7848 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7849 let workspace =
7850 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7851 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7852 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7853
7854 toggle_expand_dir(&panel, "root/dir1", cx);
7855 toggle_expand_dir(&panel, "root/dir2", cx);
7856
7857 // Test Case 1: Delete middle file in directory
7858 select_path(&panel, "root/dir1/file1.txt", cx);
7859 assert_eq!(
7860 visible_entries_as_strings(&panel, 0..15, cx),
7861 &[
7862 "v root",
7863 " v dir1",
7864 " > subdir1",
7865 " file1.txt <== selected",
7866 " file2.txt",
7867 " v dir2",
7868 " > subdir2",
7869 " file3.txt",
7870 " file4.txt",
7871 " file5.txt",
7872 " file6.txt",
7873 ],
7874 "Initial state before deleting middle file"
7875 );
7876
7877 submit_deletion(&panel, cx);
7878 assert_eq!(
7879 visible_entries_as_strings(&panel, 0..15, cx),
7880 &[
7881 "v root",
7882 " v dir1",
7883 " > subdir1",
7884 " file2.txt <== selected",
7885 " v dir2",
7886 " > subdir2",
7887 " file3.txt",
7888 " file4.txt",
7889 " file5.txt",
7890 " file6.txt",
7891 ],
7892 "Should select next file after deleting middle file"
7893 );
7894
7895 // Test Case 2: Delete last file in directory
7896 submit_deletion(&panel, cx);
7897 assert_eq!(
7898 visible_entries_as_strings(&panel, 0..15, cx),
7899 &[
7900 "v root",
7901 " v dir1",
7902 " > subdir1 <== selected",
7903 " v dir2",
7904 " > subdir2",
7905 " file3.txt",
7906 " file4.txt",
7907 " file5.txt",
7908 " file6.txt",
7909 ],
7910 "Should select next directory when last file is deleted"
7911 );
7912
7913 // Test Case 3: Delete root level file
7914 select_path(&panel, "root/file6.txt", cx);
7915 assert_eq!(
7916 visible_entries_as_strings(&panel, 0..15, cx),
7917 &[
7918 "v root",
7919 " v dir1",
7920 " > subdir1",
7921 " v dir2",
7922 " > subdir2",
7923 " file3.txt",
7924 " file4.txt",
7925 " file5.txt",
7926 " file6.txt <== selected",
7927 ],
7928 "Initial state before deleting root level file"
7929 );
7930
7931 submit_deletion(&panel, cx);
7932 assert_eq!(
7933 visible_entries_as_strings(&panel, 0..15, cx),
7934 &[
7935 "v root",
7936 " v dir1",
7937 " > subdir1",
7938 " v dir2",
7939 " > subdir2",
7940 " file3.txt",
7941 " file4.txt",
7942 " file5.txt <== selected",
7943 ],
7944 "Should select prev entry at root level"
7945 );
7946 }
7947
7948 #[gpui::test]
7949 async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
7950 init_test_with_editor(cx);
7951
7952 let fs = FakeFs::new(cx.executor().clone());
7953 fs.insert_tree(
7954 "/root",
7955 json!({
7956 "dir1": {
7957 "subdir1": {
7958 "a.txt": "",
7959 "b.txt": ""
7960 },
7961 "file1.txt": "",
7962 },
7963 "dir2": {
7964 "subdir2": {
7965 "c.txt": "",
7966 "d.txt": ""
7967 },
7968 "file2.txt": "",
7969 },
7970 "file3.txt": "",
7971 }),
7972 )
7973 .await;
7974
7975 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7976 let workspace =
7977 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7978 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7979 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7980
7981 toggle_expand_dir(&panel, "root/dir1", cx);
7982 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7983 toggle_expand_dir(&panel, "root/dir2", cx);
7984 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
7985
7986 // Test Case 1: Select and delete nested directory with parent
7987 cx.simulate_modifiers_change(gpui::Modifiers {
7988 control: true,
7989 ..Default::default()
7990 });
7991 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
7992 select_path_with_mark(&panel, "root/dir1", cx);
7993
7994 assert_eq!(
7995 visible_entries_as_strings(&panel, 0..15, cx),
7996 &[
7997 "v root",
7998 " v dir1 <== selected <== marked",
7999 " v subdir1 <== marked",
8000 " a.txt",
8001 " b.txt",
8002 " file1.txt",
8003 " v dir2",
8004 " v subdir2",
8005 " c.txt",
8006 " d.txt",
8007 " file2.txt",
8008 " file3.txt",
8009 ],
8010 "Initial state before deleting nested directory with parent"
8011 );
8012
8013 submit_deletion(&panel, cx);
8014 assert_eq!(
8015 visible_entries_as_strings(&panel, 0..15, cx),
8016 &[
8017 "v root",
8018 " v dir2 <== selected",
8019 " v subdir2",
8020 " c.txt",
8021 " d.txt",
8022 " file2.txt",
8023 " file3.txt",
8024 ],
8025 "Should select next directory after deleting directory with parent"
8026 );
8027
8028 // Test Case 2: Select mixed files and directories across levels
8029 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
8030 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
8031 select_path_with_mark(&panel, "root/file3.txt", cx);
8032
8033 assert_eq!(
8034 visible_entries_as_strings(&panel, 0..15, cx),
8035 &[
8036 "v root",
8037 " v dir2",
8038 " v subdir2",
8039 " c.txt <== marked",
8040 " d.txt",
8041 " file2.txt <== marked",
8042 " file3.txt <== selected <== marked",
8043 ],
8044 "Initial state before deleting"
8045 );
8046
8047 submit_deletion(&panel, cx);
8048 assert_eq!(
8049 visible_entries_as_strings(&panel, 0..15, cx),
8050 &[
8051 "v root",
8052 " v dir2 <== selected",
8053 " v subdir2",
8054 " d.txt",
8055 ],
8056 "Should select sibling directory"
8057 );
8058 }
8059
8060 #[gpui::test]
8061 async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
8062 init_test_with_editor(cx);
8063
8064 let fs = FakeFs::new(cx.executor().clone());
8065 fs.insert_tree(
8066 "/root",
8067 json!({
8068 "dir1": {
8069 "subdir1": {
8070 "a.txt": "",
8071 "b.txt": ""
8072 },
8073 "file1.txt": "",
8074 },
8075 "dir2": {
8076 "subdir2": {
8077 "c.txt": "",
8078 "d.txt": ""
8079 },
8080 "file2.txt": "",
8081 },
8082 "file3.txt": "",
8083 "file4.txt": "",
8084 }),
8085 )
8086 .await;
8087
8088 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8089 let workspace =
8090 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8091 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8092 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8093
8094 toggle_expand_dir(&panel, "root/dir1", cx);
8095 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8096 toggle_expand_dir(&panel, "root/dir2", cx);
8097 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
8098
8099 // Test Case 1: Select all root files and directories
8100 cx.simulate_modifiers_change(gpui::Modifiers {
8101 control: true,
8102 ..Default::default()
8103 });
8104 select_path_with_mark(&panel, "root/dir1", cx);
8105 select_path_with_mark(&panel, "root/dir2", cx);
8106 select_path_with_mark(&panel, "root/file3.txt", cx);
8107 select_path_with_mark(&panel, "root/file4.txt", cx);
8108 assert_eq!(
8109 visible_entries_as_strings(&panel, 0..20, cx),
8110 &[
8111 "v root",
8112 " v dir1 <== marked",
8113 " v subdir1",
8114 " a.txt",
8115 " b.txt",
8116 " file1.txt",
8117 " v dir2 <== marked",
8118 " v subdir2",
8119 " c.txt",
8120 " d.txt",
8121 " file2.txt",
8122 " file3.txt <== marked",
8123 " file4.txt <== selected <== marked",
8124 ],
8125 "State before deleting all contents"
8126 );
8127
8128 submit_deletion(&panel, cx);
8129 assert_eq!(
8130 visible_entries_as_strings(&panel, 0..20, cx),
8131 &["v root <== selected"],
8132 "Only empty root directory should remain after deleting all contents"
8133 );
8134 }
8135
8136 #[gpui::test]
8137 async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
8138 init_test_with_editor(cx);
8139
8140 let fs = FakeFs::new(cx.executor().clone());
8141 fs.insert_tree(
8142 "/root",
8143 json!({
8144 "dir1": {
8145 "subdir1": {
8146 "file_a.txt": "content a",
8147 "file_b.txt": "content b",
8148 },
8149 "subdir2": {
8150 "file_c.txt": "content c",
8151 },
8152 "file1.txt": "content 1",
8153 },
8154 "dir2": {
8155 "file2.txt": "content 2",
8156 },
8157 }),
8158 )
8159 .await;
8160
8161 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8162 let workspace =
8163 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8164 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8165 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8166
8167 toggle_expand_dir(&panel, "root/dir1", cx);
8168 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8169 toggle_expand_dir(&panel, "root/dir2", cx);
8170 cx.simulate_modifiers_change(gpui::Modifiers {
8171 control: true,
8172 ..Default::default()
8173 });
8174
8175 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
8176 select_path_with_mark(&panel, "root/dir1", cx);
8177 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
8178 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
8179
8180 assert_eq!(
8181 visible_entries_as_strings(&panel, 0..20, cx),
8182 &[
8183 "v root",
8184 " v dir1 <== marked",
8185 " v subdir1 <== marked",
8186 " file_a.txt <== selected <== marked",
8187 " file_b.txt",
8188 " > subdir2",
8189 " file1.txt",
8190 " v dir2",
8191 " file2.txt",
8192 ],
8193 "State with parent dir, subdir, and file selected"
8194 );
8195 submit_deletion(&panel, cx);
8196 assert_eq!(
8197 visible_entries_as_strings(&panel, 0..20, cx),
8198 &["v root", " v dir2 <== selected", " file2.txt",],
8199 "Only dir2 should remain after deletion"
8200 );
8201 }
8202
8203 #[gpui::test]
8204 async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
8205 init_test_with_editor(cx);
8206
8207 let fs = FakeFs::new(cx.executor().clone());
8208 // First worktree
8209 fs.insert_tree(
8210 "/root1",
8211 json!({
8212 "dir1": {
8213 "file1.txt": "content 1",
8214 "file2.txt": "content 2",
8215 },
8216 "dir2": {
8217 "file3.txt": "content 3",
8218 },
8219 }),
8220 )
8221 .await;
8222
8223 // Second worktree
8224 fs.insert_tree(
8225 "/root2",
8226 json!({
8227 "dir3": {
8228 "file4.txt": "content 4",
8229 "file5.txt": "content 5",
8230 },
8231 "file6.txt": "content 6",
8232 }),
8233 )
8234 .await;
8235
8236 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8237 let workspace =
8238 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8239 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8240 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8241
8242 // Expand all directories for testing
8243 toggle_expand_dir(&panel, "root1/dir1", cx);
8244 toggle_expand_dir(&panel, "root1/dir2", cx);
8245 toggle_expand_dir(&panel, "root2/dir3", cx);
8246
8247 // Test Case 1: Delete files across different worktrees
8248 cx.simulate_modifiers_change(gpui::Modifiers {
8249 control: true,
8250 ..Default::default()
8251 });
8252 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
8253 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
8254
8255 assert_eq!(
8256 visible_entries_as_strings(&panel, 0..20, cx),
8257 &[
8258 "v root1",
8259 " v dir1",
8260 " file1.txt <== marked",
8261 " file2.txt",
8262 " v dir2",
8263 " file3.txt",
8264 "v root2",
8265 " v dir3",
8266 " file4.txt <== selected <== marked",
8267 " file5.txt",
8268 " file6.txt",
8269 ],
8270 "Initial state with files selected from different worktrees"
8271 );
8272
8273 submit_deletion(&panel, cx);
8274 assert_eq!(
8275 visible_entries_as_strings(&panel, 0..20, cx),
8276 &[
8277 "v root1",
8278 " v dir1",
8279 " file2.txt",
8280 " v dir2",
8281 " file3.txt",
8282 "v root2",
8283 " v dir3",
8284 " file5.txt <== selected",
8285 " file6.txt",
8286 ],
8287 "Should select next file in the last worktree after deletion"
8288 );
8289
8290 // Test Case 2: Delete directories from different worktrees
8291 select_path_with_mark(&panel, "root1/dir1", cx);
8292 select_path_with_mark(&panel, "root2/dir3", cx);
8293
8294 assert_eq!(
8295 visible_entries_as_strings(&panel, 0..20, cx),
8296 &[
8297 "v root1",
8298 " v dir1 <== marked",
8299 " file2.txt",
8300 " v dir2",
8301 " file3.txt",
8302 "v root2",
8303 " v dir3 <== selected <== marked",
8304 " file5.txt",
8305 " file6.txt",
8306 ],
8307 "State with directories marked from different worktrees"
8308 );
8309
8310 submit_deletion(&panel, cx);
8311 assert_eq!(
8312 visible_entries_as_strings(&panel, 0..20, cx),
8313 &[
8314 "v root1",
8315 " v dir2",
8316 " file3.txt",
8317 "v root2",
8318 " file6.txt <== selected",
8319 ],
8320 "Should select remaining file in last worktree after directory deletion"
8321 );
8322
8323 // Test Case 4: Delete all remaining files except roots
8324 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
8325 select_path_with_mark(&panel, "root2/file6.txt", cx);
8326
8327 assert_eq!(
8328 visible_entries_as_strings(&panel, 0..20, cx),
8329 &[
8330 "v root1",
8331 " v dir2",
8332 " file3.txt <== marked",
8333 "v root2",
8334 " file6.txt <== selected <== marked",
8335 ],
8336 "State with all remaining files marked"
8337 );
8338
8339 submit_deletion(&panel, cx);
8340 assert_eq!(
8341 visible_entries_as_strings(&panel, 0..20, cx),
8342 &["v root1", " v dir2", "v root2 <== selected"],
8343 "Second parent root should be selected after deleting"
8344 );
8345 }
8346
8347 #[gpui::test]
8348 async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
8349 init_test_with_editor(cx);
8350
8351 let fs = FakeFs::new(cx.executor().clone());
8352 fs.insert_tree(
8353 "/root",
8354 json!({
8355 "dir1": {
8356 "file1.txt": "",
8357 "file2.txt": "",
8358 "file3.txt": "",
8359 },
8360 "dir2": {
8361 "file4.txt": "",
8362 "file5.txt": "",
8363 },
8364 }),
8365 )
8366 .await;
8367
8368 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8369 let workspace =
8370 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8371 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8372 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8373
8374 toggle_expand_dir(&panel, "root/dir1", cx);
8375 toggle_expand_dir(&panel, "root/dir2", cx);
8376
8377 cx.simulate_modifiers_change(gpui::Modifiers {
8378 control: true,
8379 ..Default::default()
8380 });
8381
8382 select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
8383 select_path(&panel, "root/dir1/file1.txt", cx);
8384
8385 assert_eq!(
8386 visible_entries_as_strings(&panel, 0..15, cx),
8387 &[
8388 "v root",
8389 " v dir1",
8390 " file1.txt <== selected",
8391 " file2.txt <== marked",
8392 " file3.txt",
8393 " v dir2",
8394 " file4.txt",
8395 " file5.txt",
8396 ],
8397 "Initial state with one marked entry and different selection"
8398 );
8399
8400 // Delete should operate on the selected entry (file1.txt)
8401 submit_deletion(&panel, cx);
8402 assert_eq!(
8403 visible_entries_as_strings(&panel, 0..15, cx),
8404 &[
8405 "v root",
8406 " v dir1",
8407 " file2.txt <== selected <== marked",
8408 " file3.txt",
8409 " v dir2",
8410 " file4.txt",
8411 " file5.txt",
8412 ],
8413 "Should delete selected file, not marked file"
8414 );
8415
8416 select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
8417 select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
8418 select_path(&panel, "root/dir2/file5.txt", cx);
8419
8420 assert_eq!(
8421 visible_entries_as_strings(&panel, 0..15, cx),
8422 &[
8423 "v root",
8424 " v dir1",
8425 " file2.txt <== marked",
8426 " file3.txt <== marked",
8427 " v dir2",
8428 " file4.txt <== marked",
8429 " file5.txt <== selected",
8430 ],
8431 "Initial state with multiple marked entries and different selection"
8432 );
8433
8434 // Delete should operate on all marked entries, ignoring the selection
8435 submit_deletion(&panel, cx);
8436 assert_eq!(
8437 visible_entries_as_strings(&panel, 0..15, cx),
8438 &[
8439 "v root",
8440 " v dir1",
8441 " v dir2",
8442 " file5.txt <== selected",
8443 ],
8444 "Should delete all marked files, leaving only the selected file"
8445 );
8446 }
8447
8448 #[gpui::test]
8449 async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
8450 init_test_with_editor(cx);
8451
8452 let fs = FakeFs::new(cx.executor().clone());
8453 fs.insert_tree(
8454 "/root_b",
8455 json!({
8456 "dir1": {
8457 "file1.txt": "content 1",
8458 "file2.txt": "content 2",
8459 },
8460 }),
8461 )
8462 .await;
8463
8464 fs.insert_tree(
8465 "/root_c",
8466 json!({
8467 "dir2": {},
8468 }),
8469 )
8470 .await;
8471
8472 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
8473 let workspace =
8474 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8475 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8476 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8477
8478 toggle_expand_dir(&panel, "root_b/dir1", cx);
8479 toggle_expand_dir(&panel, "root_c/dir2", cx);
8480
8481 cx.simulate_modifiers_change(gpui::Modifiers {
8482 control: true,
8483 ..Default::default()
8484 });
8485 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
8486 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
8487
8488 assert_eq!(
8489 visible_entries_as_strings(&panel, 0..20, cx),
8490 &[
8491 "v root_b",
8492 " v dir1",
8493 " file1.txt <== marked",
8494 " file2.txt <== selected <== marked",
8495 "v root_c",
8496 " v dir2",
8497 ],
8498 "Initial state with files marked in root_b"
8499 );
8500
8501 submit_deletion(&panel, cx);
8502 assert_eq!(
8503 visible_entries_as_strings(&panel, 0..20, cx),
8504 &[
8505 "v root_b",
8506 " v dir1 <== selected",
8507 "v root_c",
8508 " v dir2",
8509 ],
8510 "After deletion in root_b as it's last deletion, selection should be in root_b"
8511 );
8512
8513 select_path_with_mark(&panel, "root_c/dir2", cx);
8514
8515 submit_deletion(&panel, cx);
8516 assert_eq!(
8517 visible_entries_as_strings(&panel, 0..20, cx),
8518 &["v root_b", " v dir1", "v root_c <== selected",],
8519 "After deleting from root_c, it should remain in root_c"
8520 );
8521 }
8522
8523 fn toggle_expand_dir(
8524 panel: &Entity<ProjectPanel>,
8525 path: impl AsRef<Path>,
8526 cx: &mut VisualTestContext,
8527 ) {
8528 let path = path.as_ref();
8529 panel.update_in(cx, |panel, window, cx| {
8530 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8531 let worktree = worktree.read(cx);
8532 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8533 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8534 panel.toggle_expanded(entry_id, window, cx);
8535 return;
8536 }
8537 }
8538 panic!("no worktree for path {:?}", path);
8539 });
8540 }
8541
8542 #[gpui::test]
8543 async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
8544 init_test_with_editor(cx);
8545
8546 let fs = FakeFs::new(cx.executor().clone());
8547 fs.insert_tree(
8548 "/root",
8549 json!({
8550 ".gitignore": "**/ignored_dir\n**/ignored_nested",
8551 "dir1": {
8552 "empty1": {
8553 "empty2": {
8554 "empty3": {
8555 "file.txt": ""
8556 }
8557 }
8558 },
8559 "subdir1": {
8560 "file1.txt": "",
8561 "file2.txt": "",
8562 "ignored_nested": {
8563 "ignored_file.txt": ""
8564 }
8565 },
8566 "ignored_dir": {
8567 "subdir": {
8568 "deep_file.txt": ""
8569 }
8570 }
8571 }
8572 }),
8573 )
8574 .await;
8575
8576 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8577 let workspace =
8578 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8579 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8580
8581 // Test 1: When auto-fold is enabled
8582 cx.update(|_, cx| {
8583 let settings = *ProjectPanelSettings::get_global(cx);
8584 ProjectPanelSettings::override_global(
8585 ProjectPanelSettings {
8586 auto_fold_dirs: true,
8587 ..settings
8588 },
8589 cx,
8590 );
8591 });
8592
8593 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8594
8595 assert_eq!(
8596 visible_entries_as_strings(&panel, 0..20, cx),
8597 &["v root", " > dir1", " .gitignore",],
8598 "Initial state should show collapsed root structure"
8599 );
8600
8601 toggle_expand_dir(&panel, "root/dir1", cx);
8602 assert_eq!(
8603 visible_entries_as_strings(&panel, 0..20, cx),
8604 &[
8605 "v root",
8606 " v dir1 <== selected",
8607 " > empty1/empty2/empty3",
8608 " > ignored_dir",
8609 " > subdir1",
8610 " .gitignore",
8611 ],
8612 "Should show first level with auto-folded dirs and ignored dir visible"
8613 );
8614
8615 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
8616 panel.update(cx, |panel, cx| {
8617 let project = panel.project.read(cx);
8618 let worktree = project.worktrees(cx).next().unwrap().read(cx);
8619 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
8620 panel.update_visible_entries(None, cx);
8621 });
8622 cx.run_until_parked();
8623
8624 assert_eq!(
8625 visible_entries_as_strings(&panel, 0..20, cx),
8626 &[
8627 "v root",
8628 " v dir1 <== selected",
8629 " v empty1",
8630 " v empty2",
8631 " v empty3",
8632 " file.txt",
8633 " > ignored_dir",
8634 " v subdir1",
8635 " > ignored_nested",
8636 " file1.txt",
8637 " file2.txt",
8638 " .gitignore",
8639 ],
8640 "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
8641 );
8642
8643 // Test 2: When auto-fold is disabled
8644 cx.update(|_, cx| {
8645 let settings = *ProjectPanelSettings::get_global(cx);
8646 ProjectPanelSettings::override_global(
8647 ProjectPanelSettings {
8648 auto_fold_dirs: false,
8649 ..settings
8650 },
8651 cx,
8652 );
8653 });
8654
8655 panel.update_in(cx, |panel, window, cx| {
8656 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
8657 });
8658
8659 toggle_expand_dir(&panel, "root/dir1", cx);
8660 assert_eq!(
8661 visible_entries_as_strings(&panel, 0..20, cx),
8662 &[
8663 "v root",
8664 " v dir1 <== selected",
8665 " > empty1",
8666 " > ignored_dir",
8667 " > subdir1",
8668 " .gitignore",
8669 ],
8670 "With auto-fold disabled: should show all directories separately"
8671 );
8672
8673 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
8674 panel.update(cx, |panel, cx| {
8675 let project = panel.project.read(cx);
8676 let worktree = project.worktrees(cx).next().unwrap().read(cx);
8677 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
8678 panel.update_visible_entries(None, cx);
8679 });
8680 cx.run_until_parked();
8681
8682 assert_eq!(
8683 visible_entries_as_strings(&panel, 0..20, cx),
8684 &[
8685 "v root",
8686 " v dir1 <== selected",
8687 " v empty1",
8688 " v empty2",
8689 " v empty3",
8690 " file.txt",
8691 " > ignored_dir",
8692 " v subdir1",
8693 " > ignored_nested",
8694 " file1.txt",
8695 " file2.txt",
8696 " .gitignore",
8697 ],
8698 "After expand_all without auto-fold: should expand all dirs normally, \
8699 expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
8700 );
8701
8702 // Test 3: When explicitly called on ignored directory
8703 let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
8704 panel.update(cx, |panel, cx| {
8705 let project = panel.project.read(cx);
8706 let worktree = project.worktrees(cx).next().unwrap().read(cx);
8707 panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
8708 panel.update_visible_entries(None, cx);
8709 });
8710 cx.run_until_parked();
8711
8712 assert_eq!(
8713 visible_entries_as_strings(&panel, 0..20, cx),
8714 &[
8715 "v root",
8716 " v dir1 <== selected",
8717 " v empty1",
8718 " v empty2",
8719 " v empty3",
8720 " file.txt",
8721 " v ignored_dir",
8722 " v subdir",
8723 " deep_file.txt",
8724 " v subdir1",
8725 " > ignored_nested",
8726 " file1.txt",
8727 " file2.txt",
8728 " .gitignore",
8729 ],
8730 "After expand_all on ignored_dir: should expand all contents of the ignored directory"
8731 );
8732 }
8733
8734 #[gpui::test]
8735 async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
8736 init_test(cx);
8737
8738 let fs = FakeFs::new(cx.executor().clone());
8739 fs.insert_tree(
8740 "/root",
8741 json!({
8742 "dir1": {
8743 "subdir1": {
8744 "nested1": {
8745 "file1.txt": "",
8746 "file2.txt": ""
8747 },
8748 },
8749 "subdir2": {
8750 "file4.txt": ""
8751 }
8752 },
8753 "dir2": {
8754 "single_file": {
8755 "file5.txt": ""
8756 }
8757 }
8758 }),
8759 )
8760 .await;
8761
8762 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8763 let workspace =
8764 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8765 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8766
8767 // Test 1: Basic collapsing
8768 {
8769 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8770
8771 toggle_expand_dir(&panel, "root/dir1", cx);
8772 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8773 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
8774 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
8775
8776 assert_eq!(
8777 visible_entries_as_strings(&panel, 0..20, cx),
8778 &[
8779 "v root",
8780 " v dir1",
8781 " v subdir1",
8782 " v nested1",
8783 " file1.txt",
8784 " file2.txt",
8785 " v subdir2 <== selected",
8786 " file4.txt",
8787 " > dir2",
8788 ],
8789 "Initial state with everything expanded"
8790 );
8791
8792 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
8793 panel.update(cx, |panel, cx| {
8794 let project = panel.project.read(cx);
8795 let worktree = project.worktrees(cx).next().unwrap().read(cx);
8796 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
8797 panel.update_visible_entries(None, cx);
8798 });
8799
8800 assert_eq!(
8801 visible_entries_as_strings(&panel, 0..20, cx),
8802 &["v root", " > dir1", " > dir2",],
8803 "All subdirs under dir1 should be collapsed"
8804 );
8805 }
8806
8807 // Test 2: With auto-fold enabled
8808 {
8809 cx.update(|_, cx| {
8810 let settings = *ProjectPanelSettings::get_global(cx);
8811 ProjectPanelSettings::override_global(
8812 ProjectPanelSettings {
8813 auto_fold_dirs: true,
8814 ..settings
8815 },
8816 cx,
8817 );
8818 });
8819
8820 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8821
8822 toggle_expand_dir(&panel, "root/dir1", cx);
8823 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8824 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
8825
8826 assert_eq!(
8827 visible_entries_as_strings(&panel, 0..20, cx),
8828 &[
8829 "v root",
8830 " v dir1",
8831 " v subdir1/nested1 <== selected",
8832 " file1.txt",
8833 " file2.txt",
8834 " > subdir2",
8835 " > dir2/single_file",
8836 ],
8837 "Initial state with some dirs expanded"
8838 );
8839
8840 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
8841 panel.update(cx, |panel, cx| {
8842 let project = panel.project.read(cx);
8843 let worktree = project.worktrees(cx).next().unwrap().read(cx);
8844 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
8845 });
8846
8847 toggle_expand_dir(&panel, "root/dir1", cx);
8848
8849 assert_eq!(
8850 visible_entries_as_strings(&panel, 0..20, cx),
8851 &[
8852 "v root",
8853 " v dir1 <== selected",
8854 " > subdir1/nested1",
8855 " > subdir2",
8856 " > dir2/single_file",
8857 ],
8858 "Subdirs should be collapsed and folded with auto-fold enabled"
8859 );
8860 }
8861
8862 // Test 3: With auto-fold disabled
8863 {
8864 cx.update(|_, cx| {
8865 let settings = *ProjectPanelSettings::get_global(cx);
8866 ProjectPanelSettings::override_global(
8867 ProjectPanelSettings {
8868 auto_fold_dirs: false,
8869 ..settings
8870 },
8871 cx,
8872 );
8873 });
8874
8875 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8876
8877 toggle_expand_dir(&panel, "root/dir1", cx);
8878 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8879 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
8880
8881 assert_eq!(
8882 visible_entries_as_strings(&panel, 0..20, cx),
8883 &[
8884 "v root",
8885 " v dir1",
8886 " v subdir1",
8887 " v nested1 <== selected",
8888 " file1.txt",
8889 " file2.txt",
8890 " > subdir2",
8891 " > dir2",
8892 ],
8893 "Initial state with some dirs expanded and auto-fold disabled"
8894 );
8895
8896 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
8897 panel.update(cx, |panel, cx| {
8898 let project = panel.project.read(cx);
8899 let worktree = project.worktrees(cx).next().unwrap().read(cx);
8900 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
8901 });
8902
8903 toggle_expand_dir(&panel, "root/dir1", cx);
8904
8905 assert_eq!(
8906 visible_entries_as_strings(&panel, 0..20, cx),
8907 &[
8908 "v root",
8909 " v dir1 <== selected",
8910 " > subdir1",
8911 " > subdir2",
8912 " > dir2",
8913 ],
8914 "Subdirs should be collapsed but not folded with auto-fold disabled"
8915 );
8916 }
8917 }
8918
8919 fn select_path(
8920 panel: &Entity<ProjectPanel>,
8921 path: impl AsRef<Path>,
8922 cx: &mut VisualTestContext,
8923 ) {
8924 let path = path.as_ref();
8925 panel.update(cx, |panel, cx| {
8926 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8927 let worktree = worktree.read(cx);
8928 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8929 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8930 panel.selection = Some(crate::SelectedEntry {
8931 worktree_id: worktree.id(),
8932 entry_id,
8933 });
8934 return;
8935 }
8936 }
8937 panic!("no worktree for path {:?}", path);
8938 });
8939 }
8940
8941 fn select_path_with_mark(
8942 panel: &Entity<ProjectPanel>,
8943 path: impl AsRef<Path>,
8944 cx: &mut VisualTestContext,
8945 ) {
8946 let path = path.as_ref();
8947 panel.update(cx, |panel, cx| {
8948 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8949 let worktree = worktree.read(cx);
8950 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8951 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8952 let entry = crate::SelectedEntry {
8953 worktree_id: worktree.id(),
8954 entry_id,
8955 };
8956 if !panel.marked_entries.contains(&entry) {
8957 panel.marked_entries.insert(entry);
8958 }
8959 panel.selection = Some(entry);
8960 return;
8961 }
8962 }
8963 panic!("no worktree for path {:?}", path);
8964 });
8965 }
8966
8967 fn find_project_entry(
8968 panel: &Entity<ProjectPanel>,
8969 path: impl AsRef<Path>,
8970 cx: &mut VisualTestContext,
8971 ) -> Option<ProjectEntryId> {
8972 let path = path.as_ref();
8973 panel.update(cx, |panel, cx| {
8974 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8975 let worktree = worktree.read(cx);
8976 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8977 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
8978 }
8979 }
8980 panic!("no worktree for path {path:?}");
8981 })
8982 }
8983
8984 fn visible_entries_as_strings(
8985 panel: &Entity<ProjectPanel>,
8986 range: Range<usize>,
8987 cx: &mut VisualTestContext,
8988 ) -> Vec<String> {
8989 let mut result = Vec::new();
8990 let mut project_entries = HashSet::default();
8991 let mut has_editor = false;
8992
8993 panel.update_in(cx, |panel, window, cx| {
8994 panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
8995 if details.is_editing {
8996 assert!(!has_editor, "duplicate editor entry");
8997 has_editor = true;
8998 } else {
8999 assert!(
9000 project_entries.insert(project_entry),
9001 "duplicate project entry {:?} {:?}",
9002 project_entry,
9003 details
9004 );
9005 }
9006
9007 let indent = " ".repeat(details.depth);
9008 let icon = if details.kind.is_dir() {
9009 if details.is_expanded {
9010 "v "
9011 } else {
9012 "> "
9013 }
9014 } else {
9015 " "
9016 };
9017 let name = if details.is_editing {
9018 format!("[EDITOR: '{}']", details.filename)
9019 } else if details.is_processing {
9020 format!("[PROCESSING: '{}']", details.filename)
9021 } else {
9022 details.filename.clone()
9023 };
9024 let selected = if details.is_selected {
9025 " <== selected"
9026 } else {
9027 ""
9028 };
9029 let marked = if details.is_marked {
9030 " <== marked"
9031 } else {
9032 ""
9033 };
9034
9035 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
9036 });
9037 });
9038
9039 result
9040 }
9041
9042 fn init_test(cx: &mut TestAppContext) {
9043 cx.update(|cx| {
9044 let settings_store = SettingsStore::test(cx);
9045 cx.set_global(settings_store);
9046 init_settings(cx);
9047 theme::init(theme::LoadThemes::JustBase, cx);
9048 language::init(cx);
9049 editor::init_settings(cx);
9050 crate::init((), cx);
9051 workspace::init_settings(cx);
9052 client::init_settings(cx);
9053 Project::init_settings(cx);
9054
9055 cx.update_global::<SettingsStore, _>(|store, cx| {
9056 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
9057 project_panel_settings.auto_fold_dirs = Some(false);
9058 });
9059 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
9060 worktree_settings.file_scan_exclusions = Some(Vec::new());
9061 });
9062 });
9063 });
9064 }
9065
9066 fn init_test_with_editor(cx: &mut TestAppContext) {
9067 cx.update(|cx| {
9068 let app_state = AppState::test(cx);
9069 theme::init(theme::LoadThemes::JustBase, cx);
9070 init_settings(cx);
9071 language::init(cx);
9072 editor::init(cx);
9073 crate::init((), cx);
9074 workspace::init(app_state.clone(), cx);
9075 Project::init_settings(cx);
9076
9077 cx.update_global::<SettingsStore, _>(|store, cx| {
9078 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
9079 project_panel_settings.auto_fold_dirs = Some(false);
9080 });
9081 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
9082 worktree_settings.file_scan_exclusions = Some(Vec::new());
9083 });
9084 });
9085 });
9086 }
9087
9088 fn ensure_single_file_is_opened(
9089 window: &WindowHandle<Workspace>,
9090 expected_path: &str,
9091 cx: &mut TestAppContext,
9092 ) {
9093 window
9094 .update(cx, |workspace, _, cx| {
9095 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
9096 assert_eq!(worktrees.len(), 1);
9097 let worktree_id = worktrees[0].read(cx).id();
9098
9099 let open_project_paths = workspace
9100 .panes()
9101 .iter()
9102 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9103 .collect::<Vec<_>>();
9104 assert_eq!(
9105 open_project_paths,
9106 vec![ProjectPath {
9107 worktree_id,
9108 path: Arc::from(Path::new(expected_path))
9109 }],
9110 "Should have opened file, selected in project panel"
9111 );
9112 })
9113 .unwrap();
9114 }
9115
9116 fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9117 assert!(
9118 !cx.has_pending_prompt(),
9119 "Should have no prompts before the deletion"
9120 );
9121 panel.update_in(cx, |panel, window, cx| {
9122 panel.delete(&Delete { skip_prompt: false }, window, cx)
9123 });
9124 assert!(
9125 cx.has_pending_prompt(),
9126 "Should have a prompt after the deletion"
9127 );
9128 cx.simulate_prompt_answer(0);
9129 assert!(
9130 !cx.has_pending_prompt(),
9131 "Should have no prompts after prompt was replied to"
9132 );
9133 cx.executor().run_until_parked();
9134 }
9135
9136 fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9137 assert!(
9138 !cx.has_pending_prompt(),
9139 "Should have no prompts before the deletion"
9140 );
9141 panel.update_in(cx, |panel, window, cx| {
9142 panel.delete(&Delete { skip_prompt: true }, window, cx)
9143 });
9144 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
9145 cx.executor().run_until_parked();
9146 }
9147
9148 fn ensure_no_open_items_and_panes(
9149 workspace: &WindowHandle<Workspace>,
9150 cx: &mut VisualTestContext,
9151 ) {
9152 assert!(
9153 !cx.has_pending_prompt(),
9154 "Should have no prompts after deletion operation closes the file"
9155 );
9156 workspace
9157 .read_with(cx, |workspace, cx| {
9158 let open_project_paths = workspace
9159 .panes()
9160 .iter()
9161 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9162 .collect::<Vec<_>>();
9163 assert!(
9164 open_project_paths.is_empty(),
9165 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
9166 );
9167 })
9168 .unwrap();
9169 }
9170
9171 struct TestProjectItemView {
9172 focus_handle: FocusHandle,
9173 path: ProjectPath,
9174 }
9175
9176 struct TestProjectItem {
9177 path: ProjectPath,
9178 }
9179
9180 impl project::ProjectItem for TestProjectItem {
9181 fn try_open(
9182 _project: &Entity<Project>,
9183 path: &ProjectPath,
9184 cx: &mut App,
9185 ) -> Option<Task<gpui::Result<Entity<Self>>>> {
9186 let path = path.clone();
9187 Some(cx.spawn(|mut cx| async move { cx.new(|_| Self { path }) }))
9188 }
9189
9190 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
9191 None
9192 }
9193
9194 fn project_path(&self, _: &App) -> Option<ProjectPath> {
9195 Some(self.path.clone())
9196 }
9197
9198 fn is_dirty(&self) -> bool {
9199 false
9200 }
9201 }
9202
9203 impl ProjectItem for TestProjectItemView {
9204 type Item = TestProjectItem;
9205
9206 fn for_project_item(
9207 _: Entity<Project>,
9208 project_item: Entity<Self::Item>,
9209 _: &mut Window,
9210 cx: &mut Context<Self>,
9211 ) -> Self
9212 where
9213 Self: Sized,
9214 {
9215 Self {
9216 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
9217 focus_handle: cx.focus_handle(),
9218 }
9219 }
9220 }
9221
9222 impl Item for TestProjectItemView {
9223 type Event = ();
9224 }
9225
9226 impl EventEmitter<()> for TestProjectItemView {}
9227
9228 impl Focusable for TestProjectItemView {
9229 fn focus_handle(&self, _: &App) -> FocusHandle {
9230 self.focus_handle.clone()
9231 }
9232 }
9233
9234 impl Render for TestProjectItemView {
9235 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
9236 Empty
9237 }
9238 }
9239}