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