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