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, async move |project_panel, cx| {
1166 let new_entry = edit_task.await;
1167 project_panel.update(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( 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( 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( 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, async move |panel, cx| {
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(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(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, async move |project_panel, cx| {
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(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(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, async move |this, 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( cx, |worktree, cx| {
2913 worktree.copy_external_entries(target_directory, paths, true, cx)
2914 })?;
2915
2916 let opened_entries = task.await?;
2917 this.update(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().await
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, async move |project_panel, cx| {
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(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, async move |this, cx| {
3683 cx.background_executor()
3684 .timer(Duration::from_millis(500))
3685 .await;
3686 this.update_in(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, async move |panel, cx| {
4225 cx.background_executor()
4226 .timer(SCROLLBAR_SHOW_INTERVAL)
4227 .await;
4228 panel
4229 .update(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 = Some(cx.spawn_in(window, async move |this, cx| loop {
4391 let should_stop_scrolling = this
4392 .update(cx, |this, cx| {
4393 this.hover_scroll_task.as_ref()?;
4394 let handle = this.scroll_handle.0.borrow_mut();
4395 let offset = handle.base_handle.offset();
4396
4397 handle.base_handle.set_offset(offset + adjustment);
4398 cx.notify();
4399 Some(())
4400 })
4401 .ok()
4402 .flatten()
4403 .is_some();
4404 if should_stop_scrolling {
4405 return;
4406 }
4407 cx.background_executor()
4408 .timer(Duration::from_millis(16))
4409 .await;
4410 }));
4411 }
4412 h_flex()
4413 .id("project-panel")
4414 .group("project-panel")
4415 .on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
4416 .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
4417 .size_full()
4418 .relative()
4419 .on_hover(cx.listener(|this, hovered, window, cx| {
4420 if *hovered {
4421 this.show_scrollbar = true;
4422 this.hide_scrollbar_task.take();
4423 cx.notify();
4424 } else if !this.focus_handle.contains_focused(window, cx) {
4425 this.hide_scrollbar(window, cx);
4426 }
4427 }))
4428 .on_click(cx.listener(|this, _event, _, cx| {
4429 cx.stop_propagation();
4430 this.selection = None;
4431 this.marked_entries.clear();
4432 }))
4433 .key_context(self.dispatch_context(window, cx))
4434 .on_action(cx.listener(Self::select_next))
4435 .on_action(cx.listener(Self::select_previous))
4436 .on_action(cx.listener(Self::select_first))
4437 .on_action(cx.listener(Self::select_last))
4438 .on_action(cx.listener(Self::select_parent))
4439 .on_action(cx.listener(Self::select_next_git_entry))
4440 .on_action(cx.listener(Self::select_prev_git_entry))
4441 .on_action(cx.listener(Self::select_next_diagnostic))
4442 .on_action(cx.listener(Self::select_prev_diagnostic))
4443 .on_action(cx.listener(Self::select_next_directory))
4444 .on_action(cx.listener(Self::select_prev_directory))
4445 .on_action(cx.listener(Self::expand_selected_entry))
4446 .on_action(cx.listener(Self::collapse_selected_entry))
4447 .on_action(cx.listener(Self::collapse_all_entries))
4448 .on_action(cx.listener(Self::open))
4449 .on_action(cx.listener(Self::open_permanent))
4450 .on_action(cx.listener(Self::confirm))
4451 .on_action(cx.listener(Self::cancel))
4452 .on_action(cx.listener(Self::copy_path))
4453 .on_action(cx.listener(Self::copy_relative_path))
4454 .on_action(cx.listener(Self::new_search_in_directory))
4455 .on_action(cx.listener(Self::unfold_directory))
4456 .on_action(cx.listener(Self::fold_directory))
4457 .on_action(cx.listener(Self::remove_from_project))
4458 .when(!project.is_read_only(cx), |el| {
4459 el.on_action(cx.listener(Self::new_file))
4460 .on_action(cx.listener(Self::new_directory))
4461 .on_action(cx.listener(Self::rename))
4462 .on_action(cx.listener(Self::delete))
4463 .on_action(cx.listener(Self::trash))
4464 .on_action(cx.listener(Self::cut))
4465 .on_action(cx.listener(Self::copy))
4466 .on_action(cx.listener(Self::paste))
4467 .on_action(cx.listener(Self::duplicate))
4468 .on_click(cx.listener(|this, event: &gpui::ClickEvent, window, cx| {
4469 if event.up.click_count > 1 {
4470 if let Some(entry_id) = this.last_worktree_root_id {
4471 let project = this.project.read(cx);
4472
4473 let worktree_id = if let Some(worktree) =
4474 project.worktree_for_entry(entry_id, cx)
4475 {
4476 worktree.read(cx).id()
4477 } else {
4478 return;
4479 };
4480
4481 this.selection = Some(SelectedEntry {
4482 worktree_id,
4483 entry_id,
4484 });
4485
4486 this.new_file(&NewFile, window, cx);
4487 }
4488 }
4489 }))
4490 })
4491 .when(project.is_local(), |el| {
4492 el.on_action(cx.listener(Self::reveal_in_finder))
4493 .on_action(cx.listener(Self::open_system))
4494 .on_action(cx.listener(Self::open_in_terminal))
4495 })
4496 .when(project.is_via_ssh(), |el| {
4497 el.on_action(cx.listener(Self::open_in_terminal))
4498 })
4499 .on_mouse_down(
4500 MouseButton::Right,
4501 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
4502 // When deploying the context menu anywhere below the last project entry,
4503 // act as if the user clicked the root of the last worktree.
4504 if let Some(entry_id) = this.last_worktree_root_id {
4505 this.deploy_context_menu(event.position, entry_id, window, cx);
4506 }
4507 }),
4508 )
4509 .track_focus(&self.focus_handle(cx))
4510 .child(
4511 uniform_list(cx.entity().clone(), "entries", item_count, {
4512 |this, range, window, cx| {
4513 let mut items = Vec::with_capacity(range.end - range.start);
4514 this.for_each_visible_entry(
4515 range,
4516 window,
4517 cx,
4518 |id, details, window, cx| {
4519 items.push(this.render_entry(id, details, window, cx));
4520 },
4521 );
4522 items
4523 }
4524 })
4525 .when(show_indent_guides, |list| {
4526 list.with_decoration(
4527 ui::indent_guides(
4528 cx.entity().clone(),
4529 px(indent_size),
4530 IndentGuideColors::panel(cx),
4531 |this, range, window, cx| {
4532 let mut items =
4533 SmallVec::with_capacity(range.end - range.start);
4534 this.iter_visible_entries(
4535 range,
4536 window,
4537 cx,
4538 |entry, entries, _, _| {
4539 let (depth, _) = Self::calculate_depth_and_difference(
4540 entry, entries,
4541 );
4542 items.push(depth);
4543 },
4544 );
4545 items
4546 },
4547 )
4548 .on_click(cx.listener(
4549 |this, active_indent_guide: &IndentGuideLayout, window, cx| {
4550 if window.modifiers().secondary() {
4551 let ix = active_indent_guide.offset.y;
4552 let Some((target_entry, worktree)) = maybe!({
4553 let (worktree_id, entry) = this.entry_at_index(ix)?;
4554 let worktree = this
4555 .project
4556 .read(cx)
4557 .worktree_for_id(worktree_id, cx)?;
4558 let target_entry = worktree
4559 .read(cx)
4560 .entry_for_path(&entry.path.parent()?)?;
4561 Some((target_entry, worktree))
4562 }) else {
4563 return;
4564 };
4565
4566 this.collapse_entry(target_entry.clone(), worktree, cx);
4567 }
4568 },
4569 ))
4570 .with_render_fn(
4571 cx.entity().clone(),
4572 move |this, params, _, cx| {
4573 const LEFT_OFFSET: f32 = 14.;
4574 const PADDING_Y: f32 = 4.;
4575 const HITBOX_OVERDRAW: f32 = 3.;
4576
4577 let active_indent_guide_index =
4578 this.find_active_indent_guide(¶ms.indent_guides, cx);
4579
4580 let indent_size = params.indent_size;
4581 let item_height = params.item_height;
4582
4583 params
4584 .indent_guides
4585 .into_iter()
4586 .enumerate()
4587 .map(|(idx, layout)| {
4588 let offset = if layout.continues_offscreen {
4589 px(0.)
4590 } else {
4591 px(PADDING_Y)
4592 };
4593 let bounds = Bounds::new(
4594 point(
4595 px(layout.offset.x as f32) * indent_size
4596 + px(LEFT_OFFSET),
4597 px(layout.offset.y as f32) * item_height
4598 + offset,
4599 ),
4600 size(
4601 px(1.),
4602 px(layout.length as f32) * item_height
4603 - px(offset.0 * 2.),
4604 ),
4605 );
4606 ui::RenderedIndentGuide {
4607 bounds,
4608 layout,
4609 is_active: Some(idx) == active_indent_guide_index,
4610 hitbox: Some(Bounds::new(
4611 point(
4612 bounds.origin.x - px(HITBOX_OVERDRAW),
4613 bounds.origin.y,
4614 ),
4615 size(
4616 bounds.size.width
4617 + px(2. * HITBOX_OVERDRAW),
4618 bounds.size.height,
4619 ),
4620 )),
4621 }
4622 })
4623 .collect()
4624 },
4625 ),
4626 )
4627 })
4628 .size_full()
4629 .with_sizing_behavior(ListSizingBehavior::Infer)
4630 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4631 .with_width_from_item(self.max_width_item_index)
4632 .track_scroll(self.scroll_handle.clone()),
4633 )
4634 .children(self.render_vertical_scrollbar(cx))
4635 .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
4636 this.pb_4().child(scrollbar)
4637 })
4638 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4639 deferred(
4640 anchored()
4641 .position(*position)
4642 .anchor(gpui::Corner::TopLeft)
4643 .child(menu.clone()),
4644 )
4645 .with_priority(1)
4646 }))
4647 } else {
4648 v_flex()
4649 .id("empty-project_panel")
4650 .size_full()
4651 .p_4()
4652 .track_focus(&self.focus_handle(cx))
4653 .child(
4654 Button::new("open_project", "Open a project")
4655 .full_width()
4656 .key_binding(KeyBinding::for_action(&workspace::Open, window, cx))
4657 .on_click(cx.listener(|this, _, window, cx| {
4658 this.workspace
4659 .update(cx, |_, cx| {
4660 window.dispatch_action(Box::new(workspace::Open), cx)
4661 })
4662 .log_err();
4663 })),
4664 )
4665 .when(is_local, |div| {
4666 div.drag_over::<ExternalPaths>(|style, _, _, cx| {
4667 style.bg(cx.theme().colors().drop_target_background)
4668 })
4669 .on_drop(cx.listener(
4670 move |this, external_paths: &ExternalPaths, window, cx| {
4671 this.last_external_paths_drag_over_entry = None;
4672 this.marked_entries.clear();
4673 this.hover_scroll_task.take();
4674 if let Some(task) = this
4675 .workspace
4676 .update(cx, |workspace, cx| {
4677 workspace.open_workspace_for_paths(
4678 true,
4679 external_paths.paths().to_owned(),
4680 window,
4681 cx,
4682 )
4683 })
4684 .log_err()
4685 {
4686 task.detach_and_log_err(cx);
4687 }
4688 cx.stop_propagation();
4689 },
4690 ))
4691 })
4692 }
4693 }
4694}
4695
4696impl Render for DraggedProjectEntryView {
4697 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4698 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
4699 h_flex()
4700 .font(ui_font)
4701 .pl(self.click_offset.x + px(12.))
4702 .pt(self.click_offset.y + px(12.))
4703 .child(
4704 div()
4705 .flex()
4706 .gap_1()
4707 .items_center()
4708 .py_1()
4709 .px_2()
4710 .rounded_lg()
4711 .bg(cx.theme().colors().background)
4712 .map(|this| {
4713 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
4714 this.child(Label::new(format!("{} entries", self.selections.len())))
4715 } else {
4716 this.child(if let Some(icon) = &self.details.icon {
4717 div().child(Icon::from_path(icon.clone()))
4718 } else {
4719 div()
4720 })
4721 .child(Label::new(self.details.filename.clone()))
4722 }
4723 }),
4724 )
4725 }
4726}
4727
4728impl EventEmitter<Event> for ProjectPanel {}
4729
4730impl EventEmitter<PanelEvent> for ProjectPanel {}
4731
4732impl Panel for ProjectPanel {
4733 fn position(&self, _: &Window, cx: &App) -> DockPosition {
4734 match ProjectPanelSettings::get_global(cx).dock {
4735 ProjectPanelDockPosition::Left => DockPosition::Left,
4736 ProjectPanelDockPosition::Right => DockPosition::Right,
4737 }
4738 }
4739
4740 fn position_is_valid(&self, position: DockPosition) -> bool {
4741 matches!(position, DockPosition::Left | DockPosition::Right)
4742 }
4743
4744 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
4745 settings::update_settings_file::<ProjectPanelSettings>(
4746 self.fs.clone(),
4747 cx,
4748 move |settings, _| {
4749 let dock = match position {
4750 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
4751 DockPosition::Right => ProjectPanelDockPosition::Right,
4752 };
4753 settings.dock = Some(dock);
4754 },
4755 );
4756 }
4757
4758 fn size(&self, _: &Window, cx: &App) -> Pixels {
4759 self.width
4760 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
4761 }
4762
4763 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
4764 self.width = size;
4765 self.serialize(cx);
4766 cx.notify();
4767 }
4768
4769 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
4770 ProjectPanelSettings::get_global(cx)
4771 .button
4772 .then_some(IconName::FileTree)
4773 }
4774
4775 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
4776 Some("Project Panel")
4777 }
4778
4779 fn toggle_action(&self) -> Box<dyn Action> {
4780 Box::new(ToggleFocus)
4781 }
4782
4783 fn persistent_name() -> &'static str {
4784 "Project Panel"
4785 }
4786
4787 fn starts_open(&self, _: &Window, cx: &App) -> bool {
4788 let project = &self.project.read(cx);
4789 project.visible_worktrees(cx).any(|tree| {
4790 tree.read(cx)
4791 .root_entry()
4792 .map_or(false, |entry| entry.is_dir())
4793 })
4794 }
4795
4796 fn activation_priority(&self) -> u32 {
4797 0
4798 }
4799}
4800
4801impl Focusable for ProjectPanel {
4802 fn focus_handle(&self, _cx: &App) -> FocusHandle {
4803 self.focus_handle.clone()
4804 }
4805}
4806
4807impl ClipboardEntry {
4808 fn is_cut(&self) -> bool {
4809 matches!(self, Self::Cut { .. })
4810 }
4811
4812 fn items(&self) -> &BTreeSet<SelectedEntry> {
4813 match self {
4814 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
4815 }
4816 }
4817}
4818
4819#[cfg(test)]
4820mod tests {
4821 use super::*;
4822 use collections::HashSet;
4823 use gpui::{Empty, Entity, TestAppContext, VisualTestContext, WindowHandle};
4824 use pretty_assertions::assert_eq;
4825 use project::{FakeFs, WorktreeSettings};
4826 use serde_json::json;
4827 use settings::SettingsStore;
4828 use std::path::{Path, PathBuf};
4829 use util::{path, separator};
4830 use workspace::{
4831 item::{Item, ProjectItem},
4832 register_project_item, AppState,
4833 };
4834
4835 #[gpui::test]
4836 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
4837 init_test(cx);
4838
4839 let fs = FakeFs::new(cx.executor().clone());
4840 fs.insert_tree(
4841 "/root1",
4842 json!({
4843 ".dockerignore": "",
4844 ".git": {
4845 "HEAD": "",
4846 },
4847 "a": {
4848 "0": { "q": "", "r": "", "s": "" },
4849 "1": { "t": "", "u": "" },
4850 "2": { "v": "", "w": "", "x": "", "y": "" },
4851 },
4852 "b": {
4853 "3": { "Q": "" },
4854 "4": { "R": "", "S": "", "T": "", "U": "" },
4855 },
4856 "C": {
4857 "5": {},
4858 "6": { "V": "", "W": "" },
4859 "7": { "X": "" },
4860 "8": { "Y": {}, "Z": "" }
4861 }
4862 }),
4863 )
4864 .await;
4865 fs.insert_tree(
4866 "/root2",
4867 json!({
4868 "d": {
4869 "9": ""
4870 },
4871 "e": {}
4872 }),
4873 )
4874 .await;
4875
4876 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4877 let workspace =
4878 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4879 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4880 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4881 assert_eq!(
4882 visible_entries_as_strings(&panel, 0..50, cx),
4883 &[
4884 "v root1",
4885 " > .git",
4886 " > a",
4887 " > b",
4888 " > C",
4889 " .dockerignore",
4890 "v root2",
4891 " > d",
4892 " > e",
4893 ]
4894 );
4895
4896 toggle_expand_dir(&panel, "root1/b", cx);
4897 assert_eq!(
4898 visible_entries_as_strings(&panel, 0..50, cx),
4899 &[
4900 "v root1",
4901 " > .git",
4902 " > a",
4903 " v b <== selected",
4904 " > 3",
4905 " > 4",
4906 " > C",
4907 " .dockerignore",
4908 "v root2",
4909 " > d",
4910 " > e",
4911 ]
4912 );
4913
4914 assert_eq!(
4915 visible_entries_as_strings(&panel, 6..9, cx),
4916 &[
4917 //
4918 " > C",
4919 " .dockerignore",
4920 "v root2",
4921 ]
4922 );
4923 }
4924
4925 #[gpui::test]
4926 async fn test_opening_file(cx: &mut gpui::TestAppContext) {
4927 init_test_with_editor(cx);
4928
4929 let fs = FakeFs::new(cx.executor().clone());
4930 fs.insert_tree(
4931 path!("/src"),
4932 json!({
4933 "test": {
4934 "first.rs": "// First Rust file",
4935 "second.rs": "// Second Rust file",
4936 "third.rs": "// Third Rust file",
4937 }
4938 }),
4939 )
4940 .await;
4941
4942 let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
4943 let workspace =
4944 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
4945 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4946 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4947
4948 toggle_expand_dir(&panel, "src/test", cx);
4949 select_path(&panel, "src/test/first.rs", cx);
4950 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
4951 cx.executor().run_until_parked();
4952 assert_eq!(
4953 visible_entries_as_strings(&panel, 0..10, cx),
4954 &[
4955 "v src",
4956 " v test",
4957 " first.rs <== selected <== marked",
4958 " second.rs",
4959 " third.rs"
4960 ]
4961 );
4962 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
4963
4964 select_path(&panel, "src/test/second.rs", cx);
4965 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
4966 cx.executor().run_until_parked();
4967 assert_eq!(
4968 visible_entries_as_strings(&panel, 0..10, cx),
4969 &[
4970 "v src",
4971 " v test",
4972 " first.rs",
4973 " second.rs <== selected <== marked",
4974 " third.rs"
4975 ]
4976 );
4977 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
4978 }
4979
4980 #[gpui::test]
4981 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
4982 init_test(cx);
4983 cx.update(|cx| {
4984 cx.update_global::<SettingsStore, _>(|store, cx| {
4985 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4986 worktree_settings.file_scan_exclusions =
4987 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
4988 });
4989 });
4990 });
4991
4992 let fs = FakeFs::new(cx.background_executor.clone());
4993 fs.insert_tree(
4994 "/root1",
4995 json!({
4996 ".dockerignore": "",
4997 ".git": {
4998 "HEAD": "",
4999 },
5000 "a": {
5001 "0": { "q": "", "r": "", "s": "" },
5002 "1": { "t": "", "u": "" },
5003 "2": { "v": "", "w": "", "x": "", "y": "" },
5004 },
5005 "b": {
5006 "3": { "Q": "" },
5007 "4": { "R": "", "S": "", "T": "", "U": "" },
5008 },
5009 "C": {
5010 "5": {},
5011 "6": { "V": "", "W": "" },
5012 "7": { "X": "" },
5013 "8": { "Y": {}, "Z": "" }
5014 }
5015 }),
5016 )
5017 .await;
5018 fs.insert_tree(
5019 "/root2",
5020 json!({
5021 "d": {
5022 "4": ""
5023 },
5024 "e": {}
5025 }),
5026 )
5027 .await;
5028
5029 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5030 let workspace =
5031 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5032 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5033 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5034 assert_eq!(
5035 visible_entries_as_strings(&panel, 0..50, cx),
5036 &[
5037 "v root1",
5038 " > a",
5039 " > b",
5040 " > C",
5041 " .dockerignore",
5042 "v root2",
5043 " > d",
5044 " > e",
5045 ]
5046 );
5047
5048 toggle_expand_dir(&panel, "root1/b", cx);
5049 assert_eq!(
5050 visible_entries_as_strings(&panel, 0..50, cx),
5051 &[
5052 "v root1",
5053 " > a",
5054 " v b <== selected",
5055 " > 3",
5056 " > C",
5057 " .dockerignore",
5058 "v root2",
5059 " > d",
5060 " > e",
5061 ]
5062 );
5063
5064 toggle_expand_dir(&panel, "root2/d", cx);
5065 assert_eq!(
5066 visible_entries_as_strings(&panel, 0..50, cx),
5067 &[
5068 "v root1",
5069 " > a",
5070 " v b",
5071 " > 3",
5072 " > C",
5073 " .dockerignore",
5074 "v root2",
5075 " v d <== selected",
5076 " > e",
5077 ]
5078 );
5079
5080 toggle_expand_dir(&panel, "root2/e", cx);
5081 assert_eq!(
5082 visible_entries_as_strings(&panel, 0..50, cx),
5083 &[
5084 "v root1",
5085 " > a",
5086 " v b",
5087 " > 3",
5088 " > C",
5089 " .dockerignore",
5090 "v root2",
5091 " v d",
5092 " v e <== selected",
5093 ]
5094 );
5095 }
5096
5097 #[gpui::test]
5098 async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
5099 init_test(cx);
5100
5101 let fs = FakeFs::new(cx.executor().clone());
5102 fs.insert_tree(
5103 path!("/root1"),
5104 json!({
5105 "dir_1": {
5106 "nested_dir_1": {
5107 "nested_dir_2": {
5108 "nested_dir_3": {
5109 "file_a.java": "// File contents",
5110 "file_b.java": "// File contents",
5111 "file_c.java": "// File contents",
5112 "nested_dir_4": {
5113 "nested_dir_5": {
5114 "file_d.java": "// File contents",
5115 }
5116 }
5117 }
5118 }
5119 }
5120 }
5121 }),
5122 )
5123 .await;
5124 fs.insert_tree(
5125 path!("/root2"),
5126 json!({
5127 "dir_2": {
5128 "file_1.java": "// File contents",
5129 }
5130 }),
5131 )
5132 .await;
5133
5134 let project = Project::test(
5135 fs.clone(),
5136 [path!("/root1").as_ref(), path!("/root2").as_ref()],
5137 cx,
5138 )
5139 .await;
5140 let workspace =
5141 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5142 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5143 cx.update(|_, cx| {
5144 let settings = *ProjectPanelSettings::get_global(cx);
5145 ProjectPanelSettings::override_global(
5146 ProjectPanelSettings {
5147 auto_fold_dirs: true,
5148 ..settings
5149 },
5150 cx,
5151 );
5152 });
5153 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5154 assert_eq!(
5155 visible_entries_as_strings(&panel, 0..10, cx),
5156 &[
5157 separator!("v root1"),
5158 separator!(" > dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
5159 separator!("v root2"),
5160 separator!(" > dir_2"),
5161 ]
5162 );
5163
5164 toggle_expand_dir(
5165 &panel,
5166 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
5167 cx,
5168 );
5169 assert_eq!(
5170 visible_entries_as_strings(&panel, 0..10, cx),
5171 &[
5172 separator!("v root1"),
5173 separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected"),
5174 separator!(" > nested_dir_4/nested_dir_5"),
5175 separator!(" file_a.java"),
5176 separator!(" file_b.java"),
5177 separator!(" file_c.java"),
5178 separator!("v root2"),
5179 separator!(" > dir_2"),
5180 ]
5181 );
5182
5183 toggle_expand_dir(
5184 &panel,
5185 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
5186 cx,
5187 );
5188 assert_eq!(
5189 visible_entries_as_strings(&panel, 0..10, cx),
5190 &[
5191 separator!("v root1"),
5192 separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
5193 separator!(" v nested_dir_4/nested_dir_5 <== selected"),
5194 separator!(" file_d.java"),
5195 separator!(" file_a.java"),
5196 separator!(" file_b.java"),
5197 separator!(" file_c.java"),
5198 separator!("v root2"),
5199 separator!(" > dir_2"),
5200 ]
5201 );
5202 toggle_expand_dir(&panel, "root2/dir_2", cx);
5203 assert_eq!(
5204 visible_entries_as_strings(&panel, 0..10, cx),
5205 &[
5206 separator!("v root1"),
5207 separator!(" v dir_1/nested_dir_1/nested_dir_2/nested_dir_3"),
5208 separator!(" v nested_dir_4/nested_dir_5"),
5209 separator!(" file_d.java"),
5210 separator!(" file_a.java"),
5211 separator!(" file_b.java"),
5212 separator!(" file_c.java"),
5213 separator!("v root2"),
5214 separator!(" v dir_2 <== selected"),
5215 separator!(" file_1.java"),
5216 ]
5217 );
5218 }
5219
5220 #[gpui::test(iterations = 30)]
5221 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
5222 init_test(cx);
5223
5224 let fs = FakeFs::new(cx.executor().clone());
5225 fs.insert_tree(
5226 "/root1",
5227 json!({
5228 ".dockerignore": "",
5229 ".git": {
5230 "HEAD": "",
5231 },
5232 "a": {
5233 "0": { "q": "", "r": "", "s": "" },
5234 "1": { "t": "", "u": "" },
5235 "2": { "v": "", "w": "", "x": "", "y": "" },
5236 },
5237 "b": {
5238 "3": { "Q": "" },
5239 "4": { "R": "", "S": "", "T": "", "U": "" },
5240 },
5241 "C": {
5242 "5": {},
5243 "6": { "V": "", "W": "" },
5244 "7": { "X": "" },
5245 "8": { "Y": {}, "Z": "" }
5246 }
5247 }),
5248 )
5249 .await;
5250 fs.insert_tree(
5251 "/root2",
5252 json!({
5253 "d": {
5254 "9": ""
5255 },
5256 "e": {}
5257 }),
5258 )
5259 .await;
5260
5261 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5262 let workspace =
5263 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5264 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5265 let panel = workspace
5266 .update(cx, |workspace, window, cx| {
5267 let panel = ProjectPanel::new(workspace, window, cx);
5268 workspace.add_panel(panel.clone(), window, cx);
5269 panel
5270 })
5271 .unwrap();
5272
5273 select_path(&panel, "root1", cx);
5274 assert_eq!(
5275 visible_entries_as_strings(&panel, 0..10, cx),
5276 &[
5277 "v root1 <== selected",
5278 " > .git",
5279 " > a",
5280 " > b",
5281 " > C",
5282 " .dockerignore",
5283 "v root2",
5284 " > d",
5285 " > e",
5286 ]
5287 );
5288
5289 // Add a file with the root folder selected. The filename editor is placed
5290 // before the first file in the root folder.
5291 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5292 panel.update_in(cx, |panel, window, cx| {
5293 assert!(panel.filename_editor.read(cx).is_focused(window));
5294 });
5295 assert_eq!(
5296 visible_entries_as_strings(&panel, 0..10, cx),
5297 &[
5298 "v root1",
5299 " > .git",
5300 " > a",
5301 " > b",
5302 " > C",
5303 " [EDITOR: ''] <== selected",
5304 " .dockerignore",
5305 "v root2",
5306 " > d",
5307 " > e",
5308 ]
5309 );
5310
5311 let confirm = panel.update_in(cx, |panel, window, cx| {
5312 panel.filename_editor.update(cx, |editor, cx| {
5313 editor.set_text("the-new-filename", window, cx)
5314 });
5315 panel.confirm_edit(window, cx).unwrap()
5316 });
5317 assert_eq!(
5318 visible_entries_as_strings(&panel, 0..10, cx),
5319 &[
5320 "v root1",
5321 " > .git",
5322 " > a",
5323 " > b",
5324 " > C",
5325 " [PROCESSING: 'the-new-filename'] <== selected",
5326 " .dockerignore",
5327 "v root2",
5328 " > d",
5329 " > e",
5330 ]
5331 );
5332
5333 confirm.await.unwrap();
5334 assert_eq!(
5335 visible_entries_as_strings(&panel, 0..10, cx),
5336 &[
5337 "v root1",
5338 " > .git",
5339 " > a",
5340 " > b",
5341 " > C",
5342 " .dockerignore",
5343 " the-new-filename <== selected <== marked",
5344 "v root2",
5345 " > d",
5346 " > e",
5347 ]
5348 );
5349
5350 select_path(&panel, "root1/b", cx);
5351 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5352 assert_eq!(
5353 visible_entries_as_strings(&panel, 0..10, cx),
5354 &[
5355 "v root1",
5356 " > .git",
5357 " > a",
5358 " v b",
5359 " > 3",
5360 " > 4",
5361 " [EDITOR: ''] <== selected",
5362 " > C",
5363 " .dockerignore",
5364 " the-new-filename",
5365 ]
5366 );
5367
5368 panel
5369 .update_in(cx, |panel, window, cx| {
5370 panel.filename_editor.update(cx, |editor, cx| {
5371 editor.set_text("another-filename.txt", window, cx)
5372 });
5373 panel.confirm_edit(window, cx).unwrap()
5374 })
5375 .await
5376 .unwrap();
5377 assert_eq!(
5378 visible_entries_as_strings(&panel, 0..10, cx),
5379 &[
5380 "v root1",
5381 " > .git",
5382 " > a",
5383 " v b",
5384 " > 3",
5385 " > 4",
5386 " another-filename.txt <== selected <== marked",
5387 " > C",
5388 " .dockerignore",
5389 " the-new-filename",
5390 ]
5391 );
5392
5393 select_path(&panel, "root1/b/another-filename.txt", cx);
5394 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
5395 assert_eq!(
5396 visible_entries_as_strings(&panel, 0..10, cx),
5397 &[
5398 "v root1",
5399 " > .git",
5400 " > a",
5401 " v b",
5402 " > 3",
5403 " > 4",
5404 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
5405 " > C",
5406 " .dockerignore",
5407 " the-new-filename",
5408 ]
5409 );
5410
5411 let confirm = panel.update_in(cx, |panel, window, cx| {
5412 panel.filename_editor.update(cx, |editor, cx| {
5413 let file_name_selections = editor.selections.all::<usize>(cx);
5414 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5415 let file_name_selection = &file_name_selections[0];
5416 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
5417 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
5418
5419 editor.set_text("a-different-filename.tar.gz", window, cx)
5420 });
5421 panel.confirm_edit(window, cx).unwrap()
5422 });
5423 assert_eq!(
5424 visible_entries_as_strings(&panel, 0..10, cx),
5425 &[
5426 "v root1",
5427 " > .git",
5428 " > a",
5429 " v b",
5430 " > 3",
5431 " > 4",
5432 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
5433 " > C",
5434 " .dockerignore",
5435 " the-new-filename",
5436 ]
5437 );
5438
5439 confirm.await.unwrap();
5440 assert_eq!(
5441 visible_entries_as_strings(&panel, 0..10, cx),
5442 &[
5443 "v root1",
5444 " > .git",
5445 " > a",
5446 " v b",
5447 " > 3",
5448 " > 4",
5449 " a-different-filename.tar.gz <== selected",
5450 " > C",
5451 " .dockerignore",
5452 " the-new-filename",
5453 ]
5454 );
5455
5456 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
5457 assert_eq!(
5458 visible_entries_as_strings(&panel, 0..10, cx),
5459 &[
5460 "v root1",
5461 " > .git",
5462 " > a",
5463 " v b",
5464 " > 3",
5465 " > 4",
5466 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
5467 " > C",
5468 " .dockerignore",
5469 " the-new-filename",
5470 ]
5471 );
5472
5473 panel.update_in(cx, |panel, window, cx| {
5474 panel.filename_editor.update(cx, |editor, cx| {
5475 let file_name_selections = editor.selections.all::<usize>(cx);
5476 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5477 let file_name_selection = &file_name_selections[0];
5478 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
5479 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..");
5480
5481 });
5482 panel.cancel(&menu::Cancel, window, cx)
5483 });
5484
5485 panel.update_in(cx, |panel, window, cx| {
5486 panel.new_directory(&NewDirectory, window, cx)
5487 });
5488 assert_eq!(
5489 visible_entries_as_strings(&panel, 0..10, cx),
5490 &[
5491 "v root1",
5492 " > .git",
5493 " > a",
5494 " v b",
5495 " > 3",
5496 " > 4",
5497 " > [EDITOR: ''] <== selected",
5498 " a-different-filename.tar.gz",
5499 " > C",
5500 " .dockerignore",
5501 ]
5502 );
5503
5504 let confirm = panel.update_in(cx, |panel, window, cx| {
5505 panel
5506 .filename_editor
5507 .update(cx, |editor, cx| editor.set_text("new-dir", window, cx));
5508 panel.confirm_edit(window, cx).unwrap()
5509 });
5510 panel.update_in(cx, |panel, window, cx| {
5511 panel.select_next(&Default::default(), window, cx)
5512 });
5513 assert_eq!(
5514 visible_entries_as_strings(&panel, 0..10, cx),
5515 &[
5516 "v root1",
5517 " > .git",
5518 " > a",
5519 " v b",
5520 " > 3",
5521 " > 4",
5522 " > [PROCESSING: 'new-dir']",
5523 " a-different-filename.tar.gz <== selected",
5524 " > C",
5525 " .dockerignore",
5526 ]
5527 );
5528
5529 confirm.await.unwrap();
5530 assert_eq!(
5531 visible_entries_as_strings(&panel, 0..10, cx),
5532 &[
5533 "v root1",
5534 " > .git",
5535 " > a",
5536 " v b",
5537 " > 3",
5538 " > 4",
5539 " > new-dir",
5540 " a-different-filename.tar.gz <== selected",
5541 " > C",
5542 " .dockerignore",
5543 ]
5544 );
5545
5546 panel.update_in(cx, |panel, window, cx| {
5547 panel.rename(&Default::default(), window, cx)
5548 });
5549 assert_eq!(
5550 visible_entries_as_strings(&panel, 0..10, cx),
5551 &[
5552 "v root1",
5553 " > .git",
5554 " > a",
5555 " v b",
5556 " > 3",
5557 " > 4",
5558 " > new-dir",
5559 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
5560 " > C",
5561 " .dockerignore",
5562 ]
5563 );
5564
5565 // Dismiss the rename editor when it loses focus.
5566 workspace.update(cx, |_, window, _| window.blur()).unwrap();
5567 assert_eq!(
5568 visible_entries_as_strings(&panel, 0..10, cx),
5569 &[
5570 "v root1",
5571 " > .git",
5572 " > a",
5573 " v b",
5574 " > 3",
5575 " > 4",
5576 " > new-dir",
5577 " a-different-filename.tar.gz <== selected",
5578 " > C",
5579 " .dockerignore",
5580 ]
5581 );
5582 }
5583
5584 #[gpui::test(iterations = 10)]
5585 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
5586 init_test(cx);
5587
5588 let fs = FakeFs::new(cx.executor().clone());
5589 fs.insert_tree(
5590 "/root1",
5591 json!({
5592 ".dockerignore": "",
5593 ".git": {
5594 "HEAD": "",
5595 },
5596 "a": {
5597 "0": { "q": "", "r": "", "s": "" },
5598 "1": { "t": "", "u": "" },
5599 "2": { "v": "", "w": "", "x": "", "y": "" },
5600 },
5601 "b": {
5602 "3": { "Q": "" },
5603 "4": { "R": "", "S": "", "T": "", "U": "" },
5604 },
5605 "C": {
5606 "5": {},
5607 "6": { "V": "", "W": "" },
5608 "7": { "X": "" },
5609 "8": { "Y": {}, "Z": "" }
5610 }
5611 }),
5612 )
5613 .await;
5614 fs.insert_tree(
5615 "/root2",
5616 json!({
5617 "d": {
5618 "9": ""
5619 },
5620 "e": {}
5621 }),
5622 )
5623 .await;
5624
5625 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5626 let workspace =
5627 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5628 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5629 let panel = workspace
5630 .update(cx, |workspace, window, cx| {
5631 let panel = ProjectPanel::new(workspace, window, cx);
5632 workspace.add_panel(panel.clone(), window, cx);
5633 panel
5634 })
5635 .unwrap();
5636
5637 select_path(&panel, "root1", cx);
5638 assert_eq!(
5639 visible_entries_as_strings(&panel, 0..10, cx),
5640 &[
5641 "v root1 <== selected",
5642 " > .git",
5643 " > a",
5644 " > b",
5645 " > C",
5646 " .dockerignore",
5647 "v root2",
5648 " > d",
5649 " > e",
5650 ]
5651 );
5652
5653 // Add a file with the root folder selected. The filename editor is placed
5654 // before the first file in the root folder.
5655 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5656 panel.update_in(cx, |panel, window, cx| {
5657 assert!(panel.filename_editor.read(cx).is_focused(window));
5658 });
5659 assert_eq!(
5660 visible_entries_as_strings(&panel, 0..10, cx),
5661 &[
5662 "v root1",
5663 " > .git",
5664 " > a",
5665 " > b",
5666 " > C",
5667 " [EDITOR: ''] <== selected",
5668 " .dockerignore",
5669 "v root2",
5670 " > d",
5671 " > e",
5672 ]
5673 );
5674
5675 let confirm = panel.update_in(cx, |panel, window, cx| {
5676 panel.filename_editor.update(cx, |editor, cx| {
5677 editor.set_text("/bdir1/dir2/the-new-filename", window, cx)
5678 });
5679 panel.confirm_edit(window, cx).unwrap()
5680 });
5681
5682 assert_eq!(
5683 visible_entries_as_strings(&panel, 0..10, cx),
5684 &[
5685 "v root1",
5686 " > .git",
5687 " > a",
5688 " > b",
5689 " > C",
5690 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
5691 " .dockerignore",
5692 "v root2",
5693 " > d",
5694 " > e",
5695 ]
5696 );
5697
5698 confirm.await.unwrap();
5699 assert_eq!(
5700 visible_entries_as_strings(&panel, 0..13, cx),
5701 &[
5702 "v root1",
5703 " > .git",
5704 " > a",
5705 " > b",
5706 " v bdir1",
5707 " v dir2",
5708 " the-new-filename <== selected <== marked",
5709 " > C",
5710 " .dockerignore",
5711 "v root2",
5712 " > d",
5713 " > e",
5714 ]
5715 );
5716 }
5717
5718 #[gpui::test]
5719 async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
5720 init_test(cx);
5721
5722 let fs = FakeFs::new(cx.executor().clone());
5723 fs.insert_tree(
5724 path!("/root1"),
5725 json!({
5726 ".dockerignore": "",
5727 ".git": {
5728 "HEAD": "",
5729 },
5730 }),
5731 )
5732 .await;
5733
5734 let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await;
5735 let workspace =
5736 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5737 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5738 let panel = workspace
5739 .update(cx, |workspace, window, cx| {
5740 let panel = ProjectPanel::new(workspace, window, cx);
5741 workspace.add_panel(panel.clone(), window, cx);
5742 panel
5743 })
5744 .unwrap();
5745
5746 select_path(&panel, "root1", cx);
5747 assert_eq!(
5748 visible_entries_as_strings(&panel, 0..10, cx),
5749 &["v root1 <== selected", " > .git", " .dockerignore",]
5750 );
5751
5752 // Add a file with the root folder selected. The filename editor is placed
5753 // before the first file in the root folder.
5754 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5755 panel.update_in(cx, |panel, window, cx| {
5756 assert!(panel.filename_editor.read(cx).is_focused(window));
5757 });
5758 assert_eq!(
5759 visible_entries_as_strings(&panel, 0..10, cx),
5760 &[
5761 "v root1",
5762 " > .git",
5763 " [EDITOR: ''] <== selected",
5764 " .dockerignore",
5765 ]
5766 );
5767
5768 let confirm = panel.update_in(cx, |panel, window, cx| {
5769 // If we want to create a subdirectory, there should be no prefix slash.
5770 panel
5771 .filename_editor
5772 .update(cx, |editor, cx| editor.set_text("new_dir/", window, cx));
5773 panel.confirm_edit(window, cx).unwrap()
5774 });
5775
5776 assert_eq!(
5777 visible_entries_as_strings(&panel, 0..10, cx),
5778 &[
5779 "v root1",
5780 " > .git",
5781 " [PROCESSING: 'new_dir/'] <== selected",
5782 " .dockerignore",
5783 ]
5784 );
5785
5786 confirm.await.unwrap();
5787 assert_eq!(
5788 visible_entries_as_strings(&panel, 0..10, cx),
5789 &[
5790 "v root1",
5791 " > .git",
5792 " v new_dir <== selected",
5793 " .dockerignore",
5794 ]
5795 );
5796
5797 // Test filename with whitespace
5798 select_path(&panel, "root1", cx);
5799 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5800 let confirm = panel.update_in(cx, |panel, window, cx| {
5801 // If we want to create a subdirectory, there should be no prefix slash.
5802 panel
5803 .filename_editor
5804 .update(cx, |editor, cx| editor.set_text("new dir 2/", window, cx));
5805 panel.confirm_edit(window, cx).unwrap()
5806 });
5807 confirm.await.unwrap();
5808 assert_eq!(
5809 visible_entries_as_strings(&panel, 0..10, cx),
5810 &[
5811 "v root1",
5812 " > .git",
5813 " v new dir 2 <== selected",
5814 " v new_dir",
5815 " .dockerignore",
5816 ]
5817 );
5818
5819 // Test filename ends with "\"
5820 #[cfg(target_os = "windows")]
5821 {
5822 select_path(&panel, "root1", cx);
5823 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
5824 let confirm = panel.update_in(cx, |panel, window, cx| {
5825 // If we want to create a subdirectory, there should be no prefix slash.
5826 panel
5827 .filename_editor
5828 .update(cx, |editor, cx| editor.set_text("new_dir_3\\", window, cx));
5829 panel.confirm_edit(window, cx).unwrap()
5830 });
5831 confirm.await.unwrap();
5832 assert_eq!(
5833 visible_entries_as_strings(&panel, 0..10, cx),
5834 &[
5835 "v root1",
5836 " > .git",
5837 " v new dir 2",
5838 " v new_dir",
5839 " v new_dir_3 <== selected",
5840 " .dockerignore",
5841 ]
5842 );
5843 }
5844 }
5845
5846 #[gpui::test]
5847 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
5848 init_test(cx);
5849
5850 let fs = FakeFs::new(cx.executor().clone());
5851 fs.insert_tree(
5852 "/root1",
5853 json!({
5854 "one.two.txt": "",
5855 "one.txt": ""
5856 }),
5857 )
5858 .await;
5859
5860 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5861 let workspace =
5862 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5863 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5864 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5865
5866 panel.update_in(cx, |panel, window, cx| {
5867 panel.select_next(&Default::default(), window, cx);
5868 panel.select_next(&Default::default(), window, cx);
5869 });
5870
5871 assert_eq!(
5872 visible_entries_as_strings(&panel, 0..50, cx),
5873 &[
5874 //
5875 "v root1",
5876 " one.txt <== selected",
5877 " one.two.txt",
5878 ]
5879 );
5880
5881 // Regression test - file name is created correctly when
5882 // the copied file's name contains multiple dots.
5883 panel.update_in(cx, |panel, window, cx| {
5884 panel.copy(&Default::default(), window, cx);
5885 panel.paste(&Default::default(), window, cx);
5886 });
5887 cx.executor().run_until_parked();
5888
5889 assert_eq!(
5890 visible_entries_as_strings(&panel, 0..50, cx),
5891 &[
5892 //
5893 "v root1",
5894 " one.txt",
5895 " [EDITOR: 'one copy.txt'] <== selected <== marked",
5896 " one.two.txt",
5897 ]
5898 );
5899
5900 panel.update_in(cx, |panel, window, cx| {
5901 panel.filename_editor.update(cx, |editor, cx| {
5902 let file_name_selections = editor.selections.all::<usize>(cx);
5903 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5904 let file_name_selection = &file_name_selections[0];
5905 assert_eq!(file_name_selection.start, "one".len(), "Should select the file name disambiguation after the original file name");
5906 assert_eq!(file_name_selection.end, "one copy".len(), "Should select the file name disambiguation until the extension");
5907 });
5908 assert!(panel.confirm_edit(window, cx).is_none());
5909 });
5910
5911 panel.update_in(cx, |panel, window, cx| {
5912 panel.paste(&Default::default(), window, cx);
5913 });
5914 cx.executor().run_until_parked();
5915
5916 assert_eq!(
5917 visible_entries_as_strings(&panel, 0..50, cx),
5918 &[
5919 //
5920 "v root1",
5921 " one.txt",
5922 " one copy.txt",
5923 " [EDITOR: 'one copy 1.txt'] <== selected <== marked",
5924 " one.two.txt",
5925 ]
5926 );
5927
5928 panel.update_in(cx, |panel, window, cx| {
5929 assert!(panel.confirm_edit(window, cx).is_none())
5930 });
5931 }
5932
5933 #[gpui::test]
5934 async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5935 init_test(cx);
5936
5937 let fs = FakeFs::new(cx.executor().clone());
5938 fs.insert_tree(
5939 "/root1",
5940 json!({
5941 "one.txt": "",
5942 "two.txt": "",
5943 "three.txt": "",
5944 "a": {
5945 "0": { "q": "", "r": "", "s": "" },
5946 "1": { "t": "", "u": "" },
5947 "2": { "v": "", "w": "", "x": "", "y": "" },
5948 },
5949 }),
5950 )
5951 .await;
5952
5953 fs.insert_tree(
5954 "/root2",
5955 json!({
5956 "one.txt": "",
5957 "two.txt": "",
5958 "four.txt": "",
5959 "b": {
5960 "3": { "Q": "" },
5961 "4": { "R": "", "S": "", "T": "", "U": "" },
5962 },
5963 }),
5964 )
5965 .await;
5966
5967 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5968 let workspace =
5969 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5970 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5971 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5972
5973 select_path(&panel, "root1/three.txt", cx);
5974 panel.update_in(cx, |panel, window, cx| {
5975 panel.cut(&Default::default(), window, cx);
5976 });
5977
5978 select_path(&panel, "root2/one.txt", cx);
5979 panel.update_in(cx, |panel, window, cx| {
5980 panel.select_next(&Default::default(), window, cx);
5981 panel.paste(&Default::default(), window, cx);
5982 });
5983 cx.executor().run_until_parked();
5984 assert_eq!(
5985 visible_entries_as_strings(&panel, 0..50, cx),
5986 &[
5987 //
5988 "v root1",
5989 " > a",
5990 " one.txt",
5991 " two.txt",
5992 "v root2",
5993 " > b",
5994 " four.txt",
5995 " one.txt",
5996 " three.txt <== selected <== marked",
5997 " two.txt",
5998 ]
5999 );
6000
6001 select_path(&panel, "root1/a", cx);
6002 panel.update_in(cx, |panel, window, cx| {
6003 panel.cut(&Default::default(), window, cx);
6004 });
6005 select_path(&panel, "root2/two.txt", cx);
6006 panel.update_in(cx, |panel, window, cx| {
6007 panel.select_next(&Default::default(), window, cx);
6008 panel.paste(&Default::default(), window, cx);
6009 });
6010
6011 cx.executor().run_until_parked();
6012 assert_eq!(
6013 visible_entries_as_strings(&panel, 0..50, cx),
6014 &[
6015 //
6016 "v root1",
6017 " one.txt",
6018 " two.txt",
6019 "v root2",
6020 " > a <== selected",
6021 " > b",
6022 " four.txt",
6023 " one.txt",
6024 " three.txt <== marked",
6025 " two.txt",
6026 ]
6027 );
6028 }
6029
6030 #[gpui::test]
6031 async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
6032 init_test(cx);
6033
6034 let fs = FakeFs::new(cx.executor().clone());
6035 fs.insert_tree(
6036 "/root1",
6037 json!({
6038 "one.txt": "",
6039 "two.txt": "",
6040 "three.txt": "",
6041 "a": {
6042 "0": { "q": "", "r": "", "s": "" },
6043 "1": { "t": "", "u": "" },
6044 "2": { "v": "", "w": "", "x": "", "y": "" },
6045 },
6046 }),
6047 )
6048 .await;
6049
6050 fs.insert_tree(
6051 "/root2",
6052 json!({
6053 "one.txt": "",
6054 "two.txt": "",
6055 "four.txt": "",
6056 "b": {
6057 "3": { "Q": "" },
6058 "4": { "R": "", "S": "", "T": "", "U": "" },
6059 },
6060 }),
6061 )
6062 .await;
6063
6064 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
6065 let workspace =
6066 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6067 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6068 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6069
6070 select_path(&panel, "root1/three.txt", cx);
6071 panel.update_in(cx, |panel, window, cx| {
6072 panel.copy(&Default::default(), window, cx);
6073 });
6074
6075 select_path(&panel, "root2/one.txt", cx);
6076 panel.update_in(cx, |panel, window, cx| {
6077 panel.select_next(&Default::default(), window, cx);
6078 panel.paste(&Default::default(), window, cx);
6079 });
6080 cx.executor().run_until_parked();
6081 assert_eq!(
6082 visible_entries_as_strings(&panel, 0..50, cx),
6083 &[
6084 //
6085 "v root1",
6086 " > a",
6087 " one.txt",
6088 " three.txt",
6089 " two.txt",
6090 "v root2",
6091 " > b",
6092 " four.txt",
6093 " one.txt",
6094 " three.txt <== selected <== marked",
6095 " two.txt",
6096 ]
6097 );
6098
6099 select_path(&panel, "root1/three.txt", cx);
6100 panel.update_in(cx, |panel, window, cx| {
6101 panel.copy(&Default::default(), window, cx);
6102 });
6103 select_path(&panel, "root2/two.txt", cx);
6104 panel.update_in(cx, |panel, window, cx| {
6105 panel.select_next(&Default::default(), window, cx);
6106 panel.paste(&Default::default(), window, cx);
6107 });
6108
6109 cx.executor().run_until_parked();
6110 assert_eq!(
6111 visible_entries_as_strings(&panel, 0..50, cx),
6112 &[
6113 //
6114 "v root1",
6115 " > a",
6116 " one.txt",
6117 " three.txt",
6118 " two.txt",
6119 "v root2",
6120 " > b",
6121 " four.txt",
6122 " one.txt",
6123 " three.txt",
6124 " [EDITOR: 'three copy.txt'] <== selected <== marked",
6125 " two.txt",
6126 ]
6127 );
6128
6129 panel.update_in(cx, |panel, window, cx| {
6130 panel.cancel(&menu::Cancel {}, window, cx)
6131 });
6132 cx.executor().run_until_parked();
6133
6134 select_path(&panel, "root1/a", cx);
6135 panel.update_in(cx, |panel, window, cx| {
6136 panel.copy(&Default::default(), window, cx);
6137 });
6138 select_path(&panel, "root2/two.txt", cx);
6139 panel.update_in(cx, |panel, window, cx| {
6140 panel.select_next(&Default::default(), window, cx);
6141 panel.paste(&Default::default(), window, cx);
6142 });
6143
6144 cx.executor().run_until_parked();
6145 assert_eq!(
6146 visible_entries_as_strings(&panel, 0..50, cx),
6147 &[
6148 //
6149 "v root1",
6150 " > a",
6151 " one.txt",
6152 " three.txt",
6153 " two.txt",
6154 "v root2",
6155 " > a <== selected",
6156 " > b",
6157 " four.txt",
6158 " one.txt",
6159 " three.txt",
6160 " three copy.txt",
6161 " two.txt",
6162 ]
6163 );
6164 }
6165
6166 #[gpui::test]
6167 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
6168 init_test(cx);
6169
6170 let fs = FakeFs::new(cx.executor().clone());
6171 fs.insert_tree(
6172 "/root",
6173 json!({
6174 "a": {
6175 "one.txt": "",
6176 "two.txt": "",
6177 "inner_dir": {
6178 "three.txt": "",
6179 "four.txt": "",
6180 }
6181 },
6182 "b": {}
6183 }),
6184 )
6185 .await;
6186
6187 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6188 let workspace =
6189 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6190 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6191 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6192
6193 select_path(&panel, "root/a", cx);
6194 panel.update_in(cx, |panel, window, cx| {
6195 panel.copy(&Default::default(), window, cx);
6196 panel.select_next(&Default::default(), window, cx);
6197 panel.paste(&Default::default(), window, cx);
6198 });
6199 cx.executor().run_until_parked();
6200
6201 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
6202 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
6203
6204 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
6205 assert_ne!(
6206 pasted_dir_file, None,
6207 "Pasted directory file should have an entry"
6208 );
6209
6210 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
6211 assert_ne!(
6212 pasted_dir_inner_dir, None,
6213 "Directories inside pasted directory should have an entry"
6214 );
6215
6216 toggle_expand_dir(&panel, "root/b/a", cx);
6217 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
6218
6219 assert_eq!(
6220 visible_entries_as_strings(&panel, 0..50, cx),
6221 &[
6222 //
6223 "v root",
6224 " > a",
6225 " v b",
6226 " v a",
6227 " v inner_dir <== selected",
6228 " four.txt",
6229 " three.txt",
6230 " one.txt",
6231 " two.txt",
6232 ]
6233 );
6234
6235 select_path(&panel, "root", cx);
6236 panel.update_in(cx, |panel, window, cx| {
6237 panel.paste(&Default::default(), window, cx)
6238 });
6239 cx.executor().run_until_parked();
6240 assert_eq!(
6241 visible_entries_as_strings(&panel, 0..50, cx),
6242 &[
6243 //
6244 "v root",
6245 " > a",
6246 " > [EDITOR: 'a copy'] <== selected",
6247 " v b",
6248 " v a",
6249 " v inner_dir",
6250 " four.txt",
6251 " three.txt",
6252 " one.txt",
6253 " two.txt"
6254 ]
6255 );
6256
6257 let confirm = panel.update_in(cx, |panel, window, cx| {
6258 panel
6259 .filename_editor
6260 .update(cx, |editor, cx| editor.set_text("c", window, cx));
6261 panel.confirm_edit(window, cx).unwrap()
6262 });
6263 assert_eq!(
6264 visible_entries_as_strings(&panel, 0..50, cx),
6265 &[
6266 //
6267 "v root",
6268 " > a",
6269 " > [PROCESSING: 'c'] <== selected",
6270 " v b",
6271 " v a",
6272 " v inner_dir",
6273 " four.txt",
6274 " three.txt",
6275 " one.txt",
6276 " two.txt"
6277 ]
6278 );
6279
6280 confirm.await.unwrap();
6281
6282 panel.update_in(cx, |panel, window, cx| {
6283 panel.paste(&Default::default(), window, cx)
6284 });
6285 cx.executor().run_until_parked();
6286 assert_eq!(
6287 visible_entries_as_strings(&panel, 0..50, cx),
6288 &[
6289 //
6290 "v root",
6291 " > a",
6292 " v b",
6293 " v a",
6294 " v inner_dir",
6295 " four.txt",
6296 " three.txt",
6297 " one.txt",
6298 " two.txt",
6299 " v c",
6300 " > a <== selected",
6301 " > inner_dir",
6302 " one.txt",
6303 " two.txt",
6304 ]
6305 );
6306 }
6307
6308 #[gpui::test]
6309 async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
6310 init_test(cx);
6311
6312 let fs = FakeFs::new(cx.executor().clone());
6313 fs.insert_tree(
6314 "/test",
6315 json!({
6316 "dir1": {
6317 "a.txt": "",
6318 "b.txt": "",
6319 },
6320 "dir2": {},
6321 "c.txt": "",
6322 "d.txt": "",
6323 }),
6324 )
6325 .await;
6326
6327 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
6328 let workspace =
6329 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6330 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6331 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6332
6333 toggle_expand_dir(&panel, "test/dir1", cx);
6334
6335 cx.simulate_modifiers_change(gpui::Modifiers {
6336 control: true,
6337 ..Default::default()
6338 });
6339
6340 select_path_with_mark(&panel, "test/dir1", cx);
6341 select_path_with_mark(&panel, "test/c.txt", cx);
6342
6343 assert_eq!(
6344 visible_entries_as_strings(&panel, 0..15, cx),
6345 &[
6346 "v test",
6347 " v dir1 <== marked",
6348 " a.txt",
6349 " b.txt",
6350 " > dir2",
6351 " c.txt <== selected <== marked",
6352 " d.txt",
6353 ],
6354 "Initial state before copying dir1 and c.txt"
6355 );
6356
6357 panel.update_in(cx, |panel, window, cx| {
6358 panel.copy(&Default::default(), window, cx);
6359 });
6360 select_path(&panel, "test/dir2", cx);
6361 panel.update_in(cx, |panel, window, cx| {
6362 panel.paste(&Default::default(), window, cx);
6363 });
6364 cx.executor().run_until_parked();
6365
6366 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
6367
6368 assert_eq!(
6369 visible_entries_as_strings(&panel, 0..15, cx),
6370 &[
6371 "v test",
6372 " v dir1 <== marked",
6373 " a.txt",
6374 " b.txt",
6375 " v dir2",
6376 " v dir1 <== selected",
6377 " a.txt",
6378 " b.txt",
6379 " c.txt",
6380 " c.txt <== marked",
6381 " d.txt",
6382 ],
6383 "Should copy dir1 as well as c.txt into dir2"
6384 );
6385
6386 // Disambiguating multiple files should not open the rename editor.
6387 select_path(&panel, "test/dir2", cx);
6388 panel.update_in(cx, |panel, window, cx| {
6389 panel.paste(&Default::default(), window, cx);
6390 });
6391 cx.executor().run_until_parked();
6392
6393 assert_eq!(
6394 visible_entries_as_strings(&panel, 0..15, cx),
6395 &[
6396 "v test",
6397 " v dir1 <== marked",
6398 " a.txt",
6399 " b.txt",
6400 " v dir2",
6401 " v dir1",
6402 " a.txt",
6403 " b.txt",
6404 " > dir1 copy <== selected",
6405 " c.txt",
6406 " c copy.txt",
6407 " c.txt <== marked",
6408 " d.txt",
6409 ],
6410 "Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor"
6411 );
6412 }
6413
6414 #[gpui::test]
6415 async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
6416 init_test(cx);
6417
6418 let fs = FakeFs::new(cx.executor().clone());
6419 fs.insert_tree(
6420 "/test",
6421 json!({
6422 "dir1": {
6423 "a.txt": "",
6424 "b.txt": "",
6425 },
6426 "dir2": {},
6427 "c.txt": "",
6428 "d.txt": "",
6429 }),
6430 )
6431 .await;
6432
6433 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
6434 let workspace =
6435 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6436 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6437 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6438
6439 toggle_expand_dir(&panel, "test/dir1", cx);
6440
6441 cx.simulate_modifiers_change(gpui::Modifiers {
6442 control: true,
6443 ..Default::default()
6444 });
6445
6446 select_path_with_mark(&panel, "test/dir1/a.txt", cx);
6447 select_path_with_mark(&panel, "test/dir1", cx);
6448 select_path_with_mark(&panel, "test/c.txt", cx);
6449
6450 assert_eq!(
6451 visible_entries_as_strings(&panel, 0..15, cx),
6452 &[
6453 "v test",
6454 " v dir1 <== marked",
6455 " a.txt <== marked",
6456 " b.txt",
6457 " > dir2",
6458 " c.txt <== selected <== marked",
6459 " d.txt",
6460 ],
6461 "Initial state before copying a.txt, dir1 and c.txt"
6462 );
6463
6464 panel.update_in(cx, |panel, window, cx| {
6465 panel.copy(&Default::default(), window, cx);
6466 });
6467 select_path(&panel, "test/dir2", cx);
6468 panel.update_in(cx, |panel, window, cx| {
6469 panel.paste(&Default::default(), window, cx);
6470 });
6471 cx.executor().run_until_parked();
6472
6473 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
6474
6475 assert_eq!(
6476 visible_entries_as_strings(&panel, 0..20, cx),
6477 &[
6478 "v test",
6479 " v dir1 <== marked",
6480 " a.txt <== marked",
6481 " b.txt",
6482 " v dir2",
6483 " v dir1 <== selected",
6484 " a.txt",
6485 " b.txt",
6486 " c.txt",
6487 " c.txt <== marked",
6488 " d.txt",
6489 ],
6490 "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
6491 );
6492 }
6493
6494 #[gpui::test]
6495 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
6496 init_test_with_editor(cx);
6497
6498 let fs = FakeFs::new(cx.executor().clone());
6499 fs.insert_tree(
6500 path!("/src"),
6501 json!({
6502 "test": {
6503 "first.rs": "// First Rust file",
6504 "second.rs": "// Second Rust file",
6505 "third.rs": "// Third Rust file",
6506 }
6507 }),
6508 )
6509 .await;
6510
6511 let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await;
6512 let workspace =
6513 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6514 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6515 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6516
6517 toggle_expand_dir(&panel, "src/test", cx);
6518 select_path(&panel, "src/test/first.rs", cx);
6519 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
6520 cx.executor().run_until_parked();
6521 assert_eq!(
6522 visible_entries_as_strings(&panel, 0..10, cx),
6523 &[
6524 "v src",
6525 " v test",
6526 " first.rs <== selected <== marked",
6527 " second.rs",
6528 " third.rs"
6529 ]
6530 );
6531 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
6532
6533 submit_deletion(&panel, cx);
6534 assert_eq!(
6535 visible_entries_as_strings(&panel, 0..10, cx),
6536 &[
6537 "v src",
6538 " v test",
6539 " second.rs <== selected",
6540 " third.rs"
6541 ],
6542 "Project panel should have no deleted file, no other file is selected in it"
6543 );
6544 ensure_no_open_items_and_panes(&workspace, cx);
6545
6546 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
6547 cx.executor().run_until_parked();
6548 assert_eq!(
6549 visible_entries_as_strings(&panel, 0..10, cx),
6550 &[
6551 "v src",
6552 " v test",
6553 " second.rs <== selected <== marked",
6554 " third.rs"
6555 ]
6556 );
6557 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
6558
6559 workspace
6560 .update(cx, |workspace, window, cx| {
6561 let active_items = workspace
6562 .panes()
6563 .iter()
6564 .filter_map(|pane| pane.read(cx).active_item())
6565 .collect::<Vec<_>>();
6566 assert_eq!(active_items.len(), 1);
6567 let open_editor = active_items
6568 .into_iter()
6569 .next()
6570 .unwrap()
6571 .downcast::<Editor>()
6572 .expect("Open item should be an editor");
6573 open_editor.update(cx, |editor, cx| {
6574 editor.set_text("Another text!", window, cx)
6575 });
6576 })
6577 .unwrap();
6578 submit_deletion_skipping_prompt(&panel, cx);
6579 assert_eq!(
6580 visible_entries_as_strings(&panel, 0..10, cx),
6581 &["v src", " v test", " third.rs <== selected"],
6582 "Project panel should have no deleted file, with one last file remaining"
6583 );
6584 ensure_no_open_items_and_panes(&workspace, cx);
6585 }
6586
6587 #[gpui::test]
6588 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
6589 init_test_with_editor(cx);
6590
6591 let fs = FakeFs::new(cx.executor().clone());
6592 fs.insert_tree(
6593 "/src",
6594 json!({
6595 "test": {
6596 "first.rs": "// First Rust file",
6597 "second.rs": "// Second Rust file",
6598 "third.rs": "// Third Rust file",
6599 }
6600 }),
6601 )
6602 .await;
6603
6604 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
6605 let workspace =
6606 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6607 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6608 let panel = workspace
6609 .update(cx, |workspace, window, cx| {
6610 let panel = ProjectPanel::new(workspace, window, cx);
6611 workspace.add_panel(panel.clone(), window, cx);
6612 panel
6613 })
6614 .unwrap();
6615
6616 select_path(&panel, "src/", cx);
6617 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
6618 cx.executor().run_until_parked();
6619 assert_eq!(
6620 visible_entries_as_strings(&panel, 0..10, cx),
6621 &[
6622 //
6623 "v src <== selected",
6624 " > test"
6625 ]
6626 );
6627 panel.update_in(cx, |panel, window, cx| {
6628 panel.new_directory(&NewDirectory, window, cx)
6629 });
6630 panel.update_in(cx, |panel, window, cx| {
6631 assert!(panel.filename_editor.read(cx).is_focused(window));
6632 });
6633 assert_eq!(
6634 visible_entries_as_strings(&panel, 0..10, cx),
6635 &[
6636 //
6637 "v src",
6638 " > [EDITOR: ''] <== selected",
6639 " > test"
6640 ]
6641 );
6642 panel.update_in(cx, |panel, window, cx| {
6643 panel
6644 .filename_editor
6645 .update(cx, |editor, cx| editor.set_text("test", window, cx));
6646 assert!(
6647 panel.confirm_edit(window, cx).is_none(),
6648 "Should not allow to confirm on conflicting new directory name"
6649 )
6650 });
6651 assert_eq!(
6652 visible_entries_as_strings(&panel, 0..10, cx),
6653 &[
6654 //
6655 "v src",
6656 " > test"
6657 ],
6658 "File list should be unchanged after failed folder create confirmation"
6659 );
6660
6661 select_path(&panel, "src/test/", cx);
6662 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
6663 cx.executor().run_until_parked();
6664 assert_eq!(
6665 visible_entries_as_strings(&panel, 0..10, cx),
6666 &[
6667 //
6668 "v src",
6669 " > test <== selected"
6670 ]
6671 );
6672 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
6673 panel.update_in(cx, |panel, window, cx| {
6674 assert!(panel.filename_editor.read(cx).is_focused(window));
6675 });
6676 assert_eq!(
6677 visible_entries_as_strings(&panel, 0..10, cx),
6678 &[
6679 "v src",
6680 " v test",
6681 " [EDITOR: ''] <== selected",
6682 " first.rs",
6683 " second.rs",
6684 " third.rs"
6685 ]
6686 );
6687 panel.update_in(cx, |panel, window, cx| {
6688 panel
6689 .filename_editor
6690 .update(cx, |editor, cx| editor.set_text("first.rs", window, cx));
6691 assert!(
6692 panel.confirm_edit(window, cx).is_none(),
6693 "Should not allow to confirm on conflicting new file name"
6694 )
6695 });
6696 assert_eq!(
6697 visible_entries_as_strings(&panel, 0..10, cx),
6698 &[
6699 "v src",
6700 " v test",
6701 " first.rs",
6702 " second.rs",
6703 " third.rs"
6704 ],
6705 "File list should be unchanged after failed file create confirmation"
6706 );
6707
6708 select_path(&panel, "src/test/first.rs", cx);
6709 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
6710 cx.executor().run_until_parked();
6711 assert_eq!(
6712 visible_entries_as_strings(&panel, 0..10, cx),
6713 &[
6714 "v src",
6715 " v test",
6716 " first.rs <== selected",
6717 " second.rs",
6718 " third.rs"
6719 ],
6720 );
6721 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
6722 panel.update_in(cx, |panel, window, cx| {
6723 assert!(panel.filename_editor.read(cx).is_focused(window));
6724 });
6725 assert_eq!(
6726 visible_entries_as_strings(&panel, 0..10, cx),
6727 &[
6728 "v src",
6729 " v test",
6730 " [EDITOR: 'first.rs'] <== selected",
6731 " second.rs",
6732 " third.rs"
6733 ]
6734 );
6735 panel.update_in(cx, |panel, window, cx| {
6736 panel
6737 .filename_editor
6738 .update(cx, |editor, cx| editor.set_text("second.rs", window, cx));
6739 assert!(
6740 panel.confirm_edit(window, cx).is_none(),
6741 "Should not allow to confirm on conflicting file rename"
6742 )
6743 });
6744 assert_eq!(
6745 visible_entries_as_strings(&panel, 0..10, cx),
6746 &[
6747 "v src",
6748 " v test",
6749 " first.rs <== selected",
6750 " second.rs",
6751 " third.rs"
6752 ],
6753 "File list should be unchanged after failed rename confirmation"
6754 );
6755 }
6756
6757 #[gpui::test]
6758 async fn test_select_git_entry(cx: &mut gpui::TestAppContext) {
6759 use git::status::{FileStatus, StatusCode, TrackedStatus};
6760 use std::path::Path;
6761
6762 init_test_with_editor(cx);
6763
6764 let fs = FakeFs::new(cx.executor().clone());
6765 fs.insert_tree(
6766 "/root",
6767 json!({
6768 "tree1": {
6769 ".git": {},
6770 "dir1": {
6771 "modified1.txt": "",
6772 "unmodified1.txt": "",
6773 "modified2.txt": "",
6774 },
6775 "dir2": {
6776 "modified3.txt": "",
6777 "unmodified2.txt": "",
6778 },
6779 "modified4.txt": "",
6780 "unmodified3.txt": "",
6781 },
6782 "tree2": {
6783 ".git": {},
6784 "dir3": {
6785 "modified5.txt": "",
6786 "unmodified4.txt": "",
6787 },
6788 "modified6.txt": "",
6789 "unmodified5.txt": "",
6790 }
6791 }),
6792 )
6793 .await;
6794
6795 // Mark files as git modified
6796 let tree1_modified_files = [
6797 "dir1/modified1.txt",
6798 "dir1/modified2.txt",
6799 "modified4.txt",
6800 "dir2/modified3.txt",
6801 ];
6802
6803 let tree2_modified_files = ["dir3/modified5.txt", "modified6.txt"];
6804
6805 let root1_dot_git = Path::new("/root/tree1/.git");
6806 let root2_dot_git = Path::new("/root/tree2/.git");
6807 let set_value = FileStatus::Tracked(TrackedStatus {
6808 index_status: StatusCode::Modified,
6809 worktree_status: StatusCode::Modified,
6810 });
6811
6812 fs.with_git_state(&root1_dot_git, true, |git_repo_state| {
6813 for file_path in tree1_modified_files {
6814 git_repo_state.statuses.insert(file_path.into(), set_value);
6815 }
6816 });
6817
6818 fs.with_git_state(&root2_dot_git, true, |git_repo_state| {
6819 for file_path in tree2_modified_files {
6820 git_repo_state.statuses.insert(file_path.into(), set_value);
6821 }
6822 });
6823
6824 let project = Project::test(
6825 fs.clone(),
6826 ["/root/tree1".as_ref(), "/root/tree2".as_ref()],
6827 cx,
6828 )
6829 .await;
6830 let workspace =
6831 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6832 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6833 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6834
6835 // Check initial state
6836 assert_eq!(
6837 visible_entries_as_strings(&panel, 0..15, cx),
6838 &[
6839 "v tree1",
6840 " > .git",
6841 " > dir1",
6842 " > dir2",
6843 " modified4.txt",
6844 " unmodified3.txt",
6845 "v tree2",
6846 " > .git",
6847 " > dir3",
6848 " modified6.txt",
6849 " unmodified5.txt"
6850 ],
6851 );
6852
6853 // Test selecting next modified entry
6854 panel.update_in(cx, |panel, window, cx| {
6855 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6856 });
6857
6858 assert_eq!(
6859 visible_entries_as_strings(&panel, 0..6, cx),
6860 &[
6861 "v tree1",
6862 " > .git",
6863 " v dir1",
6864 " modified1.txt <== selected",
6865 " modified2.txt",
6866 " unmodified1.txt",
6867 ],
6868 );
6869
6870 panel.update_in(cx, |panel, window, cx| {
6871 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6872 });
6873
6874 assert_eq!(
6875 visible_entries_as_strings(&panel, 0..6, cx),
6876 &[
6877 "v tree1",
6878 " > .git",
6879 " v dir1",
6880 " modified1.txt",
6881 " modified2.txt <== selected",
6882 " unmodified1.txt",
6883 ],
6884 );
6885
6886 panel.update_in(cx, |panel, window, cx| {
6887 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6888 });
6889
6890 assert_eq!(
6891 visible_entries_as_strings(&panel, 6..9, cx),
6892 &[
6893 " v dir2",
6894 " modified3.txt <== selected",
6895 " unmodified2.txt",
6896 ],
6897 );
6898
6899 panel.update_in(cx, |panel, window, cx| {
6900 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6901 });
6902
6903 assert_eq!(
6904 visible_entries_as_strings(&panel, 9..11, cx),
6905 &[" modified4.txt <== selected", " unmodified3.txt",],
6906 );
6907
6908 panel.update_in(cx, |panel, window, cx| {
6909 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6910 });
6911
6912 assert_eq!(
6913 visible_entries_as_strings(&panel, 13..16, cx),
6914 &[
6915 " v dir3",
6916 " modified5.txt <== selected",
6917 " unmodified4.txt",
6918 ],
6919 );
6920
6921 panel.update_in(cx, |panel, window, cx| {
6922 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6923 });
6924
6925 assert_eq!(
6926 visible_entries_as_strings(&panel, 16..18, cx),
6927 &[" modified6.txt <== selected", " unmodified5.txt",],
6928 );
6929
6930 // Wraps around to first modified file
6931 panel.update_in(cx, |panel, window, cx| {
6932 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6933 });
6934
6935 assert_eq!(
6936 visible_entries_as_strings(&panel, 0..18, cx),
6937 &[
6938 "v tree1",
6939 " > .git",
6940 " v dir1",
6941 " modified1.txt <== selected",
6942 " modified2.txt",
6943 " unmodified1.txt",
6944 " v dir2",
6945 " modified3.txt",
6946 " unmodified2.txt",
6947 " modified4.txt",
6948 " unmodified3.txt",
6949 "v tree2",
6950 " > .git",
6951 " v dir3",
6952 " modified5.txt",
6953 " unmodified4.txt",
6954 " modified6.txt",
6955 " unmodified5.txt",
6956 ],
6957 );
6958
6959 // Wraps around again to last modified file
6960 panel.update_in(cx, |panel, window, cx| {
6961 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6962 });
6963
6964 assert_eq!(
6965 visible_entries_as_strings(&panel, 16..18, cx),
6966 &[" modified6.txt <== selected", " unmodified5.txt",],
6967 );
6968
6969 panel.update_in(cx, |panel, window, cx| {
6970 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6971 });
6972
6973 assert_eq!(
6974 visible_entries_as_strings(&panel, 13..16, cx),
6975 &[
6976 " v dir3",
6977 " modified5.txt <== selected",
6978 " unmodified4.txt",
6979 ],
6980 );
6981
6982 panel.update_in(cx, |panel, window, cx| {
6983 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6984 });
6985
6986 assert_eq!(
6987 visible_entries_as_strings(&panel, 9..11, cx),
6988 &[" modified4.txt <== selected", " unmodified3.txt",],
6989 );
6990
6991 panel.update_in(cx, |panel, window, cx| {
6992 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6993 });
6994
6995 assert_eq!(
6996 visible_entries_as_strings(&panel, 6..9, cx),
6997 &[
6998 " v dir2",
6999 " modified3.txt <== selected",
7000 " unmodified2.txt",
7001 ],
7002 );
7003
7004 panel.update_in(cx, |panel, window, cx| {
7005 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
7006 });
7007
7008 assert_eq!(
7009 visible_entries_as_strings(&panel, 0..6, cx),
7010 &[
7011 "v tree1",
7012 " > .git",
7013 " v dir1",
7014 " modified1.txt",
7015 " modified2.txt <== selected",
7016 " unmodified1.txt",
7017 ],
7018 );
7019
7020 panel.update_in(cx, |panel, window, cx| {
7021 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
7022 });
7023
7024 assert_eq!(
7025 visible_entries_as_strings(&panel, 0..6, cx),
7026 &[
7027 "v tree1",
7028 " > .git",
7029 " v dir1",
7030 " modified1.txt <== selected",
7031 " modified2.txt",
7032 " unmodified1.txt",
7033 ],
7034 );
7035 }
7036
7037 #[gpui::test]
7038 async fn test_select_directory(cx: &mut gpui::TestAppContext) {
7039 init_test_with_editor(cx);
7040
7041 let fs = FakeFs::new(cx.executor().clone());
7042 fs.insert_tree(
7043 "/project_root",
7044 json!({
7045 "dir_1": {
7046 "nested_dir": {
7047 "file_a.py": "# File contents",
7048 }
7049 },
7050 "file_1.py": "# File contents",
7051 "dir_2": {
7052
7053 },
7054 "dir_3": {
7055
7056 },
7057 "file_2.py": "# File contents",
7058 "dir_4": {
7059
7060 },
7061 }),
7062 )
7063 .await;
7064
7065 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7066 let workspace =
7067 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7068 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7069 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7070
7071 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7072 cx.executor().run_until_parked();
7073 select_path(&panel, "project_root/dir_1", cx);
7074 cx.executor().run_until_parked();
7075 assert_eq!(
7076 visible_entries_as_strings(&panel, 0..10, cx),
7077 &[
7078 "v project_root",
7079 " > dir_1 <== selected",
7080 " > dir_2",
7081 " > dir_3",
7082 " > dir_4",
7083 " file_1.py",
7084 " file_2.py",
7085 ]
7086 );
7087 panel.update_in(cx, |panel, window, cx| {
7088 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
7089 });
7090
7091 assert_eq!(
7092 visible_entries_as_strings(&panel, 0..10, cx),
7093 &[
7094 "v project_root <== selected",
7095 " > dir_1",
7096 " > dir_2",
7097 " > dir_3",
7098 " > dir_4",
7099 " file_1.py",
7100 " file_2.py",
7101 ]
7102 );
7103
7104 panel.update_in(cx, |panel, window, cx| {
7105 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
7106 });
7107
7108 assert_eq!(
7109 visible_entries_as_strings(&panel, 0..10, cx),
7110 &[
7111 "v project_root",
7112 " > dir_1",
7113 " > dir_2",
7114 " > dir_3",
7115 " > dir_4 <== selected",
7116 " file_1.py",
7117 " file_2.py",
7118 ]
7119 );
7120
7121 panel.update_in(cx, |panel, window, cx| {
7122 panel.select_next_directory(&SelectNextDirectory, window, cx)
7123 });
7124
7125 assert_eq!(
7126 visible_entries_as_strings(&panel, 0..10, cx),
7127 &[
7128 "v project_root <== selected",
7129 " > dir_1",
7130 " > dir_2",
7131 " > dir_3",
7132 " > dir_4",
7133 " file_1.py",
7134 " file_2.py",
7135 ]
7136 );
7137 }
7138 #[gpui::test]
7139 async fn test_select_first_last(cx: &mut gpui::TestAppContext) {
7140 init_test_with_editor(cx);
7141
7142 let fs = FakeFs::new(cx.executor().clone());
7143 fs.insert_tree(
7144 "/project_root",
7145 json!({
7146 "dir_1": {
7147 "nested_dir": {
7148 "file_a.py": "# File contents",
7149 }
7150 },
7151 "file_1.py": "# File contents",
7152 "file_2.py": "# File contents",
7153 "zdir_2": {
7154 "nested_dir2": {
7155 "file_b.py": "# File contents",
7156 }
7157 },
7158 }),
7159 )
7160 .await;
7161
7162 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7163 let workspace =
7164 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7165 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7166 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7167
7168 assert_eq!(
7169 visible_entries_as_strings(&panel, 0..10, cx),
7170 &[
7171 "v project_root",
7172 " > dir_1",
7173 " > zdir_2",
7174 " file_1.py",
7175 " file_2.py",
7176 ]
7177 );
7178 panel.update_in(cx, |panel, window, cx| {
7179 panel.select_first(&SelectFirst, window, cx)
7180 });
7181
7182 assert_eq!(
7183 visible_entries_as_strings(&panel, 0..10, cx),
7184 &[
7185 "v project_root <== selected",
7186 " > dir_1",
7187 " > zdir_2",
7188 " file_1.py",
7189 " file_2.py",
7190 ]
7191 );
7192
7193 panel.update_in(cx, |panel, window, cx| {
7194 panel.select_last(&SelectLast, window, cx)
7195 });
7196
7197 assert_eq!(
7198 visible_entries_as_strings(&panel, 0..10, cx),
7199 &[
7200 "v project_root",
7201 " > dir_1",
7202 " > zdir_2",
7203 " file_1.py",
7204 " file_2.py <== selected",
7205 ]
7206 );
7207 }
7208
7209 #[gpui::test]
7210 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
7211 init_test_with_editor(cx);
7212
7213 let fs = FakeFs::new(cx.executor().clone());
7214 fs.insert_tree(
7215 "/project_root",
7216 json!({
7217 "dir_1": {
7218 "nested_dir": {
7219 "file_a.py": "# File contents",
7220 }
7221 },
7222 "file_1.py": "# File contents",
7223 }),
7224 )
7225 .await;
7226
7227 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7228 let workspace =
7229 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7230 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7231 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7232
7233 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7234 cx.executor().run_until_parked();
7235 select_path(&panel, "project_root/dir_1", cx);
7236 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7237 select_path(&panel, "project_root/dir_1/nested_dir", cx);
7238 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7239 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7240 cx.executor().run_until_parked();
7241 assert_eq!(
7242 visible_entries_as_strings(&panel, 0..10, cx),
7243 &[
7244 "v project_root",
7245 " v dir_1",
7246 " > nested_dir <== selected",
7247 " file_1.py",
7248 ]
7249 );
7250 }
7251
7252 #[gpui::test]
7253 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
7254 init_test_with_editor(cx);
7255
7256 let fs = FakeFs::new(cx.executor().clone());
7257 fs.insert_tree(
7258 "/project_root",
7259 json!({
7260 "dir_1": {
7261 "nested_dir": {
7262 "file_a.py": "# File contents",
7263 "file_b.py": "# File contents",
7264 "file_c.py": "# File contents",
7265 },
7266 "file_1.py": "# File contents",
7267 "file_2.py": "# File contents",
7268 "file_3.py": "# File contents",
7269 },
7270 "dir_2": {
7271 "file_1.py": "# File contents",
7272 "file_2.py": "# File contents",
7273 "file_3.py": "# File contents",
7274 }
7275 }),
7276 )
7277 .await;
7278
7279 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7280 let workspace =
7281 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7282 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7283 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7284
7285 panel.update_in(cx, |panel, window, cx| {
7286 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
7287 });
7288 cx.executor().run_until_parked();
7289 assert_eq!(
7290 visible_entries_as_strings(&panel, 0..10, cx),
7291 &["v project_root", " > dir_1", " > dir_2",]
7292 );
7293
7294 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
7295 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7296 cx.executor().run_until_parked();
7297 assert_eq!(
7298 visible_entries_as_strings(&panel, 0..10, cx),
7299 &[
7300 "v project_root",
7301 " v dir_1 <== selected",
7302 " > nested_dir",
7303 " file_1.py",
7304 " file_2.py",
7305 " file_3.py",
7306 " > dir_2",
7307 ]
7308 );
7309 }
7310
7311 #[gpui::test]
7312 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
7313 init_test(cx);
7314
7315 let fs = FakeFs::new(cx.executor().clone());
7316 fs.as_fake().insert_tree(path!("/root"), json!({})).await;
7317 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
7318 let workspace =
7319 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7320 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7321 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7322
7323 // Make a new buffer with no backing file
7324 workspace
7325 .update(cx, |workspace, window, cx| {
7326 Editor::new_file(workspace, &Default::default(), window, cx)
7327 })
7328 .unwrap();
7329
7330 cx.executor().run_until_parked();
7331
7332 // "Save as" the buffer, creating a new backing file for it
7333 let save_task = workspace
7334 .update(cx, |workspace, window, cx| {
7335 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
7336 })
7337 .unwrap();
7338
7339 cx.executor().run_until_parked();
7340 cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new"))));
7341 save_task.await.unwrap();
7342
7343 // Rename the file
7344 select_path(&panel, "root/new", cx);
7345 assert_eq!(
7346 visible_entries_as_strings(&panel, 0..10, cx),
7347 &["v root", " new <== selected <== marked"]
7348 );
7349 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
7350 panel.update_in(cx, |panel, window, cx| {
7351 panel
7352 .filename_editor
7353 .update(cx, |editor, cx| editor.set_text("newer", window, cx));
7354 });
7355 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
7356
7357 cx.executor().run_until_parked();
7358 assert_eq!(
7359 visible_entries_as_strings(&panel, 0..10, cx),
7360 &["v root", " newer <== selected"]
7361 );
7362
7363 workspace
7364 .update(cx, |workspace, window, cx| {
7365 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
7366 })
7367 .unwrap()
7368 .await
7369 .unwrap();
7370
7371 cx.executor().run_until_parked();
7372 // assert that saving the file doesn't restore "new"
7373 assert_eq!(
7374 visible_entries_as_strings(&panel, 0..10, cx),
7375 &["v root", " newer <== selected"]
7376 );
7377 }
7378
7379 #[gpui::test]
7380 #[cfg_attr(target_os = "windows", ignore)]
7381 async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
7382 init_test_with_editor(cx);
7383
7384 let fs = FakeFs::new(cx.executor().clone());
7385 fs.insert_tree(
7386 "/root1",
7387 json!({
7388 "dir1": {
7389 "file1.txt": "content 1",
7390 },
7391 }),
7392 )
7393 .await;
7394
7395 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
7396 let workspace =
7397 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7398 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7399 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7400
7401 toggle_expand_dir(&panel, "root1/dir1", cx);
7402
7403 assert_eq!(
7404 visible_entries_as_strings(&panel, 0..20, cx),
7405 &["v root1", " v dir1 <== selected", " file1.txt",],
7406 "Initial state with worktrees"
7407 );
7408
7409 select_path(&panel, "root1", cx);
7410 assert_eq!(
7411 visible_entries_as_strings(&panel, 0..20, cx),
7412 &["v root1 <== selected", " v dir1", " file1.txt",],
7413 );
7414
7415 // Rename root1 to new_root1
7416 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
7417
7418 assert_eq!(
7419 visible_entries_as_strings(&panel, 0..20, cx),
7420 &[
7421 "v [EDITOR: 'root1'] <== selected",
7422 " v dir1",
7423 " file1.txt",
7424 ],
7425 );
7426
7427 let confirm = panel.update_in(cx, |panel, window, cx| {
7428 panel
7429 .filename_editor
7430 .update(cx, |editor, cx| editor.set_text("new_root1", window, cx));
7431 panel.confirm_edit(window, cx).unwrap()
7432 });
7433 confirm.await.unwrap();
7434 assert_eq!(
7435 visible_entries_as_strings(&panel, 0..20, cx),
7436 &[
7437 "v new_root1 <== selected",
7438 " v dir1",
7439 " file1.txt",
7440 ],
7441 "Should update worktree name"
7442 );
7443
7444 // Ensure internal paths have been updated
7445 select_path(&panel, "new_root1/dir1/file1.txt", cx);
7446 assert_eq!(
7447 visible_entries_as_strings(&panel, 0..20, cx),
7448 &[
7449 "v new_root1",
7450 " v dir1",
7451 " file1.txt <== selected",
7452 ],
7453 "Files in renamed worktree are selectable"
7454 );
7455 }
7456
7457 #[gpui::test]
7458 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
7459 init_test_with_editor(cx);
7460 let fs = FakeFs::new(cx.executor().clone());
7461 fs.insert_tree(
7462 "/project_root",
7463 json!({
7464 "dir_1": {
7465 "nested_dir": {
7466 "file_a.py": "# File contents",
7467 }
7468 },
7469 "file_1.py": "# File contents",
7470 }),
7471 )
7472 .await;
7473
7474 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7475 let worktree_id =
7476 cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
7477 let workspace =
7478 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7479 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7480 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7481 cx.update(|window, cx| {
7482 panel.update(cx, |this, cx| {
7483 this.select_next(&Default::default(), window, cx);
7484 this.expand_selected_entry(&Default::default(), window, cx);
7485 this.expand_selected_entry(&Default::default(), window, cx);
7486 this.select_next(&Default::default(), window, cx);
7487 this.expand_selected_entry(&Default::default(), window, cx);
7488 this.select_next(&Default::default(), window, cx);
7489 })
7490 });
7491 assert_eq!(
7492 visible_entries_as_strings(&panel, 0..10, cx),
7493 &[
7494 "v project_root",
7495 " v dir_1",
7496 " v nested_dir",
7497 " file_a.py <== selected",
7498 " file_1.py",
7499 ]
7500 );
7501 let modifiers_with_shift = gpui::Modifiers {
7502 shift: true,
7503 ..Default::default()
7504 };
7505 cx.simulate_modifiers_change(modifiers_with_shift);
7506 cx.update(|window, cx| {
7507 panel.update(cx, |this, cx| {
7508 this.select_next(&Default::default(), window, cx);
7509 })
7510 });
7511 assert_eq!(
7512 visible_entries_as_strings(&panel, 0..10, cx),
7513 &[
7514 "v project_root",
7515 " v dir_1",
7516 " v nested_dir",
7517 " file_a.py",
7518 " file_1.py <== selected <== marked",
7519 ]
7520 );
7521 cx.update(|window, cx| {
7522 panel.update(cx, |this, cx| {
7523 this.select_previous(&Default::default(), window, cx);
7524 })
7525 });
7526 assert_eq!(
7527 visible_entries_as_strings(&panel, 0..10, cx),
7528 &[
7529 "v project_root",
7530 " v dir_1",
7531 " v nested_dir",
7532 " file_a.py <== selected <== marked",
7533 " file_1.py <== marked",
7534 ]
7535 );
7536 cx.update(|window, cx| {
7537 panel.update(cx, |this, cx| {
7538 let drag = DraggedSelection {
7539 active_selection: this.selection.unwrap(),
7540 marked_selections: Arc::new(this.marked_entries.clone()),
7541 };
7542 let target_entry = this
7543 .project
7544 .read(cx)
7545 .entry_for_path(&(worktree_id, "").into(), cx)
7546 .unwrap();
7547 this.drag_onto(&drag, target_entry.id, false, window, cx);
7548 });
7549 });
7550 cx.run_until_parked();
7551 assert_eq!(
7552 visible_entries_as_strings(&panel, 0..10, cx),
7553 &[
7554 "v project_root",
7555 " v dir_1",
7556 " v nested_dir",
7557 " file_1.py <== marked",
7558 " file_a.py <== selected <== marked",
7559 ]
7560 );
7561 // ESC clears out all marks
7562 cx.update(|window, cx| {
7563 panel.update(cx, |this, cx| {
7564 this.cancel(&menu::Cancel, window, cx);
7565 })
7566 });
7567 assert_eq!(
7568 visible_entries_as_strings(&panel, 0..10, cx),
7569 &[
7570 "v project_root",
7571 " v dir_1",
7572 " v nested_dir",
7573 " file_1.py",
7574 " file_a.py <== selected",
7575 ]
7576 );
7577 // ESC clears out all marks
7578 cx.update(|window, cx| {
7579 panel.update(cx, |this, cx| {
7580 this.select_previous(&SelectPrevious, window, cx);
7581 this.select_next(&SelectNext, window, cx);
7582 })
7583 });
7584 assert_eq!(
7585 visible_entries_as_strings(&panel, 0..10, cx),
7586 &[
7587 "v project_root",
7588 " v dir_1",
7589 " v nested_dir",
7590 " file_1.py <== marked",
7591 " file_a.py <== selected <== marked",
7592 ]
7593 );
7594 cx.simulate_modifiers_change(Default::default());
7595 cx.update(|window, cx| {
7596 panel.update(cx, |this, cx| {
7597 this.cut(&Cut, window, cx);
7598 this.select_previous(&SelectPrevious, window, cx);
7599 this.select_previous(&SelectPrevious, window, cx);
7600
7601 this.paste(&Paste, window, cx);
7602 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
7603 })
7604 });
7605 cx.run_until_parked();
7606 assert_eq!(
7607 visible_entries_as_strings(&panel, 0..10, cx),
7608 &[
7609 "v project_root",
7610 " v dir_1",
7611 " v nested_dir",
7612 " file_1.py <== marked",
7613 " file_a.py <== selected <== marked",
7614 ]
7615 );
7616 cx.simulate_modifiers_change(modifiers_with_shift);
7617 cx.update(|window, cx| {
7618 panel.update(cx, |this, cx| {
7619 this.expand_selected_entry(&Default::default(), window, cx);
7620 this.select_next(&SelectNext, window, cx);
7621 this.select_next(&SelectNext, window, cx);
7622 })
7623 });
7624 submit_deletion(&panel, cx);
7625 assert_eq!(
7626 visible_entries_as_strings(&panel, 0..10, cx),
7627 &[
7628 "v project_root",
7629 " v dir_1",
7630 " v nested_dir <== selected",
7631 ]
7632 );
7633 }
7634 #[gpui::test]
7635 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
7636 init_test_with_editor(cx);
7637 cx.update(|cx| {
7638 cx.update_global::<SettingsStore, _>(|store, cx| {
7639 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7640 worktree_settings.file_scan_exclusions = Some(Vec::new());
7641 });
7642 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7643 project_panel_settings.auto_reveal_entries = Some(false)
7644 });
7645 })
7646 });
7647
7648 let fs = FakeFs::new(cx.background_executor.clone());
7649 fs.insert_tree(
7650 "/project_root",
7651 json!({
7652 ".git": {},
7653 ".gitignore": "**/gitignored_dir",
7654 "dir_1": {
7655 "file_1.py": "# File 1_1 contents",
7656 "file_2.py": "# File 1_2 contents",
7657 "file_3.py": "# File 1_3 contents",
7658 "gitignored_dir": {
7659 "file_a.py": "# File contents",
7660 "file_b.py": "# File contents",
7661 "file_c.py": "# File contents",
7662 },
7663 },
7664 "dir_2": {
7665 "file_1.py": "# File 2_1 contents",
7666 "file_2.py": "# File 2_2 contents",
7667 "file_3.py": "# File 2_3 contents",
7668 }
7669 }),
7670 )
7671 .await;
7672
7673 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7674 let workspace =
7675 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7676 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7677 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7678
7679 assert_eq!(
7680 visible_entries_as_strings(&panel, 0..20, cx),
7681 &[
7682 "v project_root",
7683 " > .git",
7684 " > dir_1",
7685 " > dir_2",
7686 " .gitignore",
7687 ]
7688 );
7689
7690 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
7691 .expect("dir 1 file is not ignored and should have an entry");
7692 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
7693 .expect("dir 2 file is not ignored and should have an entry");
7694 let gitignored_dir_file =
7695 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
7696 assert_eq!(
7697 gitignored_dir_file, None,
7698 "File in the gitignored dir should not have an entry before its dir is toggled"
7699 );
7700
7701 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7702 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7703 cx.executor().run_until_parked();
7704 assert_eq!(
7705 visible_entries_as_strings(&panel, 0..20, cx),
7706 &[
7707 "v project_root",
7708 " > .git",
7709 " v dir_1",
7710 " v gitignored_dir <== selected",
7711 " file_a.py",
7712 " file_b.py",
7713 " file_c.py",
7714 " file_1.py",
7715 " file_2.py",
7716 " file_3.py",
7717 " > dir_2",
7718 " .gitignore",
7719 ],
7720 "Should show gitignored dir file list in the project panel"
7721 );
7722 let gitignored_dir_file =
7723 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
7724 .expect("after gitignored dir got opened, a file entry should be present");
7725
7726 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7727 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7728 assert_eq!(
7729 visible_entries_as_strings(&panel, 0..20, cx),
7730 &[
7731 "v project_root",
7732 " > .git",
7733 " > dir_1 <== selected",
7734 " > dir_2",
7735 " .gitignore",
7736 ],
7737 "Should hide all dir contents again and prepare for the auto reveal test"
7738 );
7739
7740 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
7741 panel.update(cx, |panel, cx| {
7742 panel.project.update(cx, |_, cx| {
7743 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
7744 })
7745 });
7746 cx.run_until_parked();
7747 assert_eq!(
7748 visible_entries_as_strings(&panel, 0..20, cx),
7749 &[
7750 "v project_root",
7751 " > .git",
7752 " > dir_1 <== selected",
7753 " > dir_2",
7754 " .gitignore",
7755 ],
7756 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
7757 );
7758 }
7759
7760 cx.update(|_, cx| {
7761 cx.update_global::<SettingsStore, _>(|store, cx| {
7762 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7763 project_panel_settings.auto_reveal_entries = Some(true)
7764 });
7765 })
7766 });
7767
7768 panel.update(cx, |panel, cx| {
7769 panel.project.update(cx, |_, cx| {
7770 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
7771 })
7772 });
7773 cx.run_until_parked();
7774 assert_eq!(
7775 visible_entries_as_strings(&panel, 0..20, cx),
7776 &[
7777 "v project_root",
7778 " > .git",
7779 " v dir_1",
7780 " > gitignored_dir",
7781 " file_1.py <== selected <== marked",
7782 " file_2.py",
7783 " file_3.py",
7784 " > dir_2",
7785 " .gitignore",
7786 ],
7787 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
7788 );
7789
7790 panel.update(cx, |panel, cx| {
7791 panel.project.update(cx, |_, cx| {
7792 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
7793 })
7794 });
7795 cx.run_until_parked();
7796 assert_eq!(
7797 visible_entries_as_strings(&panel, 0..20, cx),
7798 &[
7799 "v project_root",
7800 " > .git",
7801 " v dir_1",
7802 " > gitignored_dir",
7803 " file_1.py",
7804 " file_2.py",
7805 " file_3.py",
7806 " v dir_2",
7807 " file_1.py <== selected <== marked",
7808 " file_2.py",
7809 " file_3.py",
7810 " .gitignore",
7811 ],
7812 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
7813 );
7814
7815 panel.update(cx, |panel, cx| {
7816 panel.project.update(cx, |_, cx| {
7817 cx.emit(project::Event::ActiveEntryChanged(Some(
7818 gitignored_dir_file,
7819 )))
7820 })
7821 });
7822 cx.run_until_parked();
7823 assert_eq!(
7824 visible_entries_as_strings(&panel, 0..20, cx),
7825 &[
7826 "v project_root",
7827 " > .git",
7828 " v dir_1",
7829 " > gitignored_dir",
7830 " file_1.py",
7831 " file_2.py",
7832 " file_3.py",
7833 " v dir_2",
7834 " file_1.py <== selected <== marked",
7835 " file_2.py",
7836 " file_3.py",
7837 " .gitignore",
7838 ],
7839 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
7840 );
7841
7842 panel.update(cx, |panel, cx| {
7843 panel.project.update(cx, |_, cx| {
7844 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
7845 })
7846 });
7847 cx.run_until_parked();
7848 assert_eq!(
7849 visible_entries_as_strings(&panel, 0..20, cx),
7850 &[
7851 "v project_root",
7852 " > .git",
7853 " v dir_1",
7854 " v gitignored_dir",
7855 " file_a.py <== selected <== marked",
7856 " file_b.py",
7857 " file_c.py",
7858 " file_1.py",
7859 " file_2.py",
7860 " file_3.py",
7861 " v dir_2",
7862 " file_1.py",
7863 " file_2.py",
7864 " file_3.py",
7865 " .gitignore",
7866 ],
7867 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
7868 );
7869 }
7870
7871 #[gpui::test]
7872 async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
7873 init_test_with_editor(cx);
7874 cx.update(|cx| {
7875 cx.update_global::<SettingsStore, _>(|store, cx| {
7876 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7877 worktree_settings.file_scan_exclusions = Some(Vec::new());
7878 worktree_settings.file_scan_inclusions =
7879 Some(vec!["always_included_but_ignored_dir/*".to_string()]);
7880 });
7881 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7882 project_panel_settings.auto_reveal_entries = Some(false)
7883 });
7884 })
7885 });
7886
7887 let fs = FakeFs::new(cx.background_executor.clone());
7888 fs.insert_tree(
7889 "/project_root",
7890 json!({
7891 ".git": {},
7892 ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
7893 "dir_1": {
7894 "file_1.py": "# File 1_1 contents",
7895 "file_2.py": "# File 1_2 contents",
7896 "file_3.py": "# File 1_3 contents",
7897 "gitignored_dir": {
7898 "file_a.py": "# File contents",
7899 "file_b.py": "# File contents",
7900 "file_c.py": "# File contents",
7901 },
7902 },
7903 "dir_2": {
7904 "file_1.py": "# File 2_1 contents",
7905 "file_2.py": "# File 2_2 contents",
7906 "file_3.py": "# File 2_3 contents",
7907 },
7908 "always_included_but_ignored_dir": {
7909 "file_a.py": "# File contents",
7910 "file_b.py": "# File contents",
7911 "file_c.py": "# File contents",
7912 },
7913 }),
7914 )
7915 .await;
7916
7917 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7918 let workspace =
7919 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7920 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7921 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7922
7923 assert_eq!(
7924 visible_entries_as_strings(&panel, 0..20, cx),
7925 &[
7926 "v project_root",
7927 " > .git",
7928 " > always_included_but_ignored_dir",
7929 " > dir_1",
7930 " > dir_2",
7931 " .gitignore",
7932 ]
7933 );
7934
7935 let gitignored_dir_file =
7936 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
7937 let always_included_but_ignored_dir_file = find_project_entry(
7938 &panel,
7939 "project_root/always_included_but_ignored_dir/file_a.py",
7940 cx,
7941 )
7942 .expect("file that is .gitignored but set to always be included should have an entry");
7943 assert_eq!(
7944 gitignored_dir_file, None,
7945 "File in the gitignored dir should not have an entry unless its directory is toggled"
7946 );
7947
7948 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7949 cx.run_until_parked();
7950 cx.update(|_, cx| {
7951 cx.update_global::<SettingsStore, _>(|store, cx| {
7952 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7953 project_panel_settings.auto_reveal_entries = Some(true)
7954 });
7955 })
7956 });
7957
7958 panel.update(cx, |panel, cx| {
7959 panel.project.update(cx, |_, cx| {
7960 cx.emit(project::Event::ActiveEntryChanged(Some(
7961 always_included_but_ignored_dir_file,
7962 )))
7963 })
7964 });
7965 cx.run_until_parked();
7966
7967 assert_eq!(
7968 visible_entries_as_strings(&panel, 0..20, cx),
7969 &[
7970 "v project_root",
7971 " > .git",
7972 " v always_included_but_ignored_dir",
7973 " file_a.py <== selected <== marked",
7974 " file_b.py",
7975 " file_c.py",
7976 " v dir_1",
7977 " > gitignored_dir",
7978 " file_1.py",
7979 " file_2.py",
7980 " file_3.py",
7981 " > dir_2",
7982 " .gitignore",
7983 ],
7984 "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
7985 );
7986 }
7987
7988 #[gpui::test]
7989 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
7990 init_test_with_editor(cx);
7991 cx.update(|cx| {
7992 cx.update_global::<SettingsStore, _>(|store, cx| {
7993 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7994 worktree_settings.file_scan_exclusions = Some(Vec::new());
7995 });
7996 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7997 project_panel_settings.auto_reveal_entries = Some(false)
7998 });
7999 })
8000 });
8001
8002 let fs = FakeFs::new(cx.background_executor.clone());
8003 fs.insert_tree(
8004 "/project_root",
8005 json!({
8006 ".git": {},
8007 ".gitignore": "**/gitignored_dir",
8008 "dir_1": {
8009 "file_1.py": "# File 1_1 contents",
8010 "file_2.py": "# File 1_2 contents",
8011 "file_3.py": "# File 1_3 contents",
8012 "gitignored_dir": {
8013 "file_a.py": "# File contents",
8014 "file_b.py": "# File contents",
8015 "file_c.py": "# File contents",
8016 },
8017 },
8018 "dir_2": {
8019 "file_1.py": "# File 2_1 contents",
8020 "file_2.py": "# File 2_2 contents",
8021 "file_3.py": "# File 2_3 contents",
8022 }
8023 }),
8024 )
8025 .await;
8026
8027 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
8028 let workspace =
8029 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8030 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8031 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8032
8033 assert_eq!(
8034 visible_entries_as_strings(&panel, 0..20, cx),
8035 &[
8036 "v project_root",
8037 " > .git",
8038 " > dir_1",
8039 " > dir_2",
8040 " .gitignore",
8041 ]
8042 );
8043
8044 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
8045 .expect("dir 1 file is not ignored and should have an entry");
8046 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
8047 .expect("dir 2 file is not ignored and should have an entry");
8048 let gitignored_dir_file =
8049 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
8050 assert_eq!(
8051 gitignored_dir_file, None,
8052 "File in the gitignored dir should not have an entry before its dir is toggled"
8053 );
8054
8055 toggle_expand_dir(&panel, "project_root/dir_1", cx);
8056 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
8057 cx.run_until_parked();
8058 assert_eq!(
8059 visible_entries_as_strings(&panel, 0..20, cx),
8060 &[
8061 "v project_root",
8062 " > .git",
8063 " v dir_1",
8064 " v gitignored_dir <== selected",
8065 " file_a.py",
8066 " file_b.py",
8067 " file_c.py",
8068 " file_1.py",
8069 " file_2.py",
8070 " file_3.py",
8071 " > dir_2",
8072 " .gitignore",
8073 ],
8074 "Should show gitignored dir file list in the project panel"
8075 );
8076 let gitignored_dir_file =
8077 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
8078 .expect("after gitignored dir got opened, a file entry should be present");
8079
8080 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
8081 toggle_expand_dir(&panel, "project_root/dir_1", cx);
8082 assert_eq!(
8083 visible_entries_as_strings(&panel, 0..20, cx),
8084 &[
8085 "v project_root",
8086 " > .git",
8087 " > dir_1 <== selected",
8088 " > dir_2",
8089 " .gitignore",
8090 ],
8091 "Should hide all dir contents again and prepare for the explicit reveal test"
8092 );
8093
8094 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
8095 panel.update(cx, |panel, cx| {
8096 panel.project.update(cx, |_, cx| {
8097 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
8098 })
8099 });
8100 cx.run_until_parked();
8101 assert_eq!(
8102 visible_entries_as_strings(&panel, 0..20, cx),
8103 &[
8104 "v project_root",
8105 " > .git",
8106 " > dir_1 <== selected",
8107 " > dir_2",
8108 " .gitignore",
8109 ],
8110 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
8111 );
8112 }
8113
8114 panel.update(cx, |panel, cx| {
8115 panel.project.update(cx, |_, cx| {
8116 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
8117 })
8118 });
8119 cx.run_until_parked();
8120 assert_eq!(
8121 visible_entries_as_strings(&panel, 0..20, cx),
8122 &[
8123 "v project_root",
8124 " > .git",
8125 " v dir_1",
8126 " > gitignored_dir",
8127 " file_1.py <== selected <== marked",
8128 " file_2.py",
8129 " file_3.py",
8130 " > dir_2",
8131 " .gitignore",
8132 ],
8133 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
8134 );
8135
8136 panel.update(cx, |panel, cx| {
8137 panel.project.update(cx, |_, cx| {
8138 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
8139 })
8140 });
8141 cx.run_until_parked();
8142 assert_eq!(
8143 visible_entries_as_strings(&panel, 0..20, cx),
8144 &[
8145 "v project_root",
8146 " > .git",
8147 " v dir_1",
8148 " > gitignored_dir",
8149 " file_1.py",
8150 " file_2.py",
8151 " file_3.py",
8152 " v dir_2",
8153 " file_1.py <== selected <== marked",
8154 " file_2.py",
8155 " file_3.py",
8156 " .gitignore",
8157 ],
8158 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
8159 );
8160
8161 panel.update(cx, |panel, cx| {
8162 panel.project.update(cx, |_, cx| {
8163 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
8164 })
8165 });
8166 cx.run_until_parked();
8167 assert_eq!(
8168 visible_entries_as_strings(&panel, 0..20, cx),
8169 &[
8170 "v project_root",
8171 " > .git",
8172 " v dir_1",
8173 " v gitignored_dir",
8174 " file_a.py <== selected <== marked",
8175 " file_b.py",
8176 " file_c.py",
8177 " file_1.py",
8178 " file_2.py",
8179 " file_3.py",
8180 " v dir_2",
8181 " file_1.py",
8182 " file_2.py",
8183 " file_3.py",
8184 " .gitignore",
8185 ],
8186 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
8187 );
8188 }
8189
8190 #[gpui::test]
8191 async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
8192 init_test(cx);
8193 cx.update(|cx| {
8194 cx.update_global::<SettingsStore, _>(|store, cx| {
8195 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
8196 project_settings.file_scan_exclusions =
8197 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
8198 });
8199 });
8200 });
8201
8202 cx.update(|cx| {
8203 register_project_item::<TestProjectItemView>(cx);
8204 });
8205
8206 let fs = FakeFs::new(cx.executor().clone());
8207 fs.insert_tree(
8208 "/root1",
8209 json!({
8210 ".dockerignore": "",
8211 ".git": {
8212 "HEAD": "",
8213 },
8214 }),
8215 )
8216 .await;
8217
8218 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
8219 let workspace =
8220 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8221 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8222 let panel = workspace
8223 .update(cx, |workspace, window, cx| {
8224 let panel = ProjectPanel::new(workspace, window, cx);
8225 workspace.add_panel(panel.clone(), window, cx);
8226 panel
8227 })
8228 .unwrap();
8229
8230 select_path(&panel, "root1", cx);
8231 assert_eq!(
8232 visible_entries_as_strings(&panel, 0..10, cx),
8233 &["v root1 <== selected", " .dockerignore",]
8234 );
8235 workspace
8236 .update(cx, |workspace, _, cx| {
8237 assert!(
8238 workspace.active_item(cx).is_none(),
8239 "Should have no active items in the beginning"
8240 );
8241 })
8242 .unwrap();
8243
8244 let excluded_file_path = ".git/COMMIT_EDITMSG";
8245 let excluded_dir_path = "excluded_dir";
8246
8247 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
8248 panel.update_in(cx, |panel, window, cx| {
8249 assert!(panel.filename_editor.read(cx).is_focused(window));
8250 });
8251 panel
8252 .update_in(cx, |panel, window, cx| {
8253 panel.filename_editor.update(cx, |editor, cx| {
8254 editor.set_text(excluded_file_path, window, cx)
8255 });
8256 panel.confirm_edit(window, cx).unwrap()
8257 })
8258 .await
8259 .unwrap();
8260
8261 assert_eq!(
8262 visible_entries_as_strings(&panel, 0..13, cx),
8263 &["v root1", " .dockerignore"],
8264 "Excluded dir should not be shown after opening a file in it"
8265 );
8266 panel.update_in(cx, |panel, window, cx| {
8267 assert!(
8268 !panel.filename_editor.read(cx).is_focused(window),
8269 "Should have closed the file name editor"
8270 );
8271 });
8272 workspace
8273 .update(cx, |workspace, _, cx| {
8274 let active_entry_path = workspace
8275 .active_item(cx)
8276 .expect("should have opened and activated the excluded item")
8277 .act_as::<TestProjectItemView>(cx)
8278 .expect(
8279 "should have opened the corresponding project item for the excluded item",
8280 )
8281 .read(cx)
8282 .path
8283 .clone();
8284 assert_eq!(
8285 active_entry_path.path.as_ref(),
8286 Path::new(excluded_file_path),
8287 "Should open the excluded file"
8288 );
8289
8290 assert!(
8291 workspace.notification_ids().is_empty(),
8292 "Should have no notifications after opening an excluded file"
8293 );
8294 })
8295 .unwrap();
8296 assert!(
8297 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
8298 "Should have created the excluded file"
8299 );
8300
8301 select_path(&panel, "root1", cx);
8302 panel.update_in(cx, |panel, window, cx| {
8303 panel.new_directory(&NewDirectory, window, cx)
8304 });
8305 panel.update_in(cx, |panel, window, cx| {
8306 assert!(panel.filename_editor.read(cx).is_focused(window));
8307 });
8308 panel
8309 .update_in(cx, |panel, window, cx| {
8310 panel.filename_editor.update(cx, |editor, cx| {
8311 editor.set_text(excluded_file_path, window, cx)
8312 });
8313 panel.confirm_edit(window, cx).unwrap()
8314 })
8315 .await
8316 .unwrap();
8317
8318 assert_eq!(
8319 visible_entries_as_strings(&panel, 0..13, cx),
8320 &["v root1", " .dockerignore"],
8321 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
8322 );
8323 panel.update_in(cx, |panel, window, cx| {
8324 assert!(
8325 !panel.filename_editor.read(cx).is_focused(window),
8326 "Should have closed the file name editor"
8327 );
8328 });
8329 workspace
8330 .update(cx, |workspace, _, cx| {
8331 let notifications = workspace.notification_ids();
8332 assert_eq!(
8333 notifications.len(),
8334 1,
8335 "Should receive one notification with the error message"
8336 );
8337 workspace.dismiss_notification(notifications.first().unwrap(), cx);
8338 assert!(workspace.notification_ids().is_empty());
8339 })
8340 .unwrap();
8341
8342 select_path(&panel, "root1", cx);
8343 panel.update_in(cx, |panel, window, cx| {
8344 panel.new_directory(&NewDirectory, window, cx)
8345 });
8346 panel.update_in(cx, |panel, window, cx| {
8347 assert!(panel.filename_editor.read(cx).is_focused(window));
8348 });
8349 panel
8350 .update_in(cx, |panel, window, cx| {
8351 panel.filename_editor.update(cx, |editor, cx| {
8352 editor.set_text(excluded_dir_path, window, cx)
8353 });
8354 panel.confirm_edit(window, cx).unwrap()
8355 })
8356 .await
8357 .unwrap();
8358
8359 assert_eq!(
8360 visible_entries_as_strings(&panel, 0..13, cx),
8361 &["v root1", " .dockerignore"],
8362 "Should not change the project panel after trying to create an excluded directory"
8363 );
8364 panel.update_in(cx, |panel, window, cx| {
8365 assert!(
8366 !panel.filename_editor.read(cx).is_focused(window),
8367 "Should have closed the file name editor"
8368 );
8369 });
8370 workspace
8371 .update(cx, |workspace, _, cx| {
8372 let notifications = workspace.notification_ids();
8373 assert_eq!(
8374 notifications.len(),
8375 1,
8376 "Should receive one notification explaining that no directory is actually shown"
8377 );
8378 workspace.dismiss_notification(notifications.first().unwrap(), cx);
8379 assert!(workspace.notification_ids().is_empty());
8380 })
8381 .unwrap();
8382 assert!(
8383 fs.is_dir(Path::new("/root1/excluded_dir")).await,
8384 "Should have created the excluded directory"
8385 );
8386 }
8387
8388 #[gpui::test]
8389 async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
8390 init_test_with_editor(cx);
8391
8392 let fs = FakeFs::new(cx.executor().clone());
8393 fs.insert_tree(
8394 "/src",
8395 json!({
8396 "test": {
8397 "first.rs": "// First Rust file",
8398 "second.rs": "// Second Rust file",
8399 "third.rs": "// Third Rust file",
8400 }
8401 }),
8402 )
8403 .await;
8404
8405 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
8406 let workspace =
8407 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8408 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8409 let panel = workspace
8410 .update(cx, |workspace, window, cx| {
8411 let panel = ProjectPanel::new(workspace, window, cx);
8412 workspace.add_panel(panel.clone(), window, cx);
8413 panel
8414 })
8415 .unwrap();
8416
8417 select_path(&panel, "src/", cx);
8418 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
8419 cx.executor().run_until_parked();
8420 assert_eq!(
8421 visible_entries_as_strings(&panel, 0..10, cx),
8422 &[
8423 //
8424 "v src <== selected",
8425 " > test"
8426 ]
8427 );
8428 panel.update_in(cx, |panel, window, cx| {
8429 panel.new_directory(&NewDirectory, window, cx)
8430 });
8431 panel.update_in(cx, |panel, window, cx| {
8432 assert!(panel.filename_editor.read(cx).is_focused(window));
8433 });
8434 assert_eq!(
8435 visible_entries_as_strings(&panel, 0..10, cx),
8436 &[
8437 //
8438 "v src",
8439 " > [EDITOR: ''] <== selected",
8440 " > test"
8441 ]
8442 );
8443
8444 panel.update_in(cx, |panel, window, cx| {
8445 panel.cancel(&menu::Cancel, window, cx)
8446 });
8447 assert_eq!(
8448 visible_entries_as_strings(&panel, 0..10, cx),
8449 &[
8450 //
8451 "v src <== selected",
8452 " > test"
8453 ]
8454 );
8455 }
8456
8457 #[gpui::test]
8458 async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
8459 init_test_with_editor(cx);
8460
8461 let fs = FakeFs::new(cx.executor().clone());
8462 fs.insert_tree(
8463 "/root",
8464 json!({
8465 "dir1": {
8466 "subdir1": {},
8467 "file1.txt": "",
8468 "file2.txt": "",
8469 },
8470 "dir2": {
8471 "subdir2": {},
8472 "file3.txt": "",
8473 "file4.txt": "",
8474 },
8475 "file5.txt": "",
8476 "file6.txt": "",
8477 }),
8478 )
8479 .await;
8480
8481 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8482 let workspace =
8483 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8484 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8485 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8486
8487 toggle_expand_dir(&panel, "root/dir1", cx);
8488 toggle_expand_dir(&panel, "root/dir2", cx);
8489
8490 // Test Case 1: Delete middle file in directory
8491 select_path(&panel, "root/dir1/file1.txt", cx);
8492 assert_eq!(
8493 visible_entries_as_strings(&panel, 0..15, cx),
8494 &[
8495 "v root",
8496 " v dir1",
8497 " > subdir1",
8498 " file1.txt <== selected",
8499 " file2.txt",
8500 " v dir2",
8501 " > subdir2",
8502 " file3.txt",
8503 " file4.txt",
8504 " file5.txt",
8505 " file6.txt",
8506 ],
8507 "Initial state before deleting middle file"
8508 );
8509
8510 submit_deletion(&panel, cx);
8511 assert_eq!(
8512 visible_entries_as_strings(&panel, 0..15, cx),
8513 &[
8514 "v root",
8515 " v dir1",
8516 " > subdir1",
8517 " file2.txt <== selected",
8518 " v dir2",
8519 " > subdir2",
8520 " file3.txt",
8521 " file4.txt",
8522 " file5.txt",
8523 " file6.txt",
8524 ],
8525 "Should select next file after deleting middle file"
8526 );
8527
8528 // Test Case 2: Delete last file in directory
8529 submit_deletion(&panel, cx);
8530 assert_eq!(
8531 visible_entries_as_strings(&panel, 0..15, cx),
8532 &[
8533 "v root",
8534 " v dir1",
8535 " > subdir1 <== selected",
8536 " v dir2",
8537 " > subdir2",
8538 " file3.txt",
8539 " file4.txt",
8540 " file5.txt",
8541 " file6.txt",
8542 ],
8543 "Should select next directory when last file is deleted"
8544 );
8545
8546 // Test Case 3: Delete root level file
8547 select_path(&panel, "root/file6.txt", cx);
8548 assert_eq!(
8549 visible_entries_as_strings(&panel, 0..15, cx),
8550 &[
8551 "v root",
8552 " v dir1",
8553 " > subdir1",
8554 " v dir2",
8555 " > subdir2",
8556 " file3.txt",
8557 " file4.txt",
8558 " file5.txt",
8559 " file6.txt <== selected",
8560 ],
8561 "Initial state before deleting root level file"
8562 );
8563
8564 submit_deletion(&panel, cx);
8565 assert_eq!(
8566 visible_entries_as_strings(&panel, 0..15, cx),
8567 &[
8568 "v root",
8569 " v dir1",
8570 " > subdir1",
8571 " v dir2",
8572 " > subdir2",
8573 " file3.txt",
8574 " file4.txt",
8575 " file5.txt <== selected",
8576 ],
8577 "Should select prev entry at root level"
8578 );
8579 }
8580
8581 #[gpui::test]
8582 async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
8583 init_test_with_editor(cx);
8584
8585 let fs = FakeFs::new(cx.executor().clone());
8586 fs.insert_tree(
8587 "/root",
8588 json!({
8589 "dir1": {
8590 "subdir1": {
8591 "a.txt": "",
8592 "b.txt": ""
8593 },
8594 "file1.txt": "",
8595 },
8596 "dir2": {
8597 "subdir2": {
8598 "c.txt": "",
8599 "d.txt": ""
8600 },
8601 "file2.txt": "",
8602 },
8603 "file3.txt": "",
8604 }),
8605 )
8606 .await;
8607
8608 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8609 let workspace =
8610 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8611 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8612 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8613
8614 toggle_expand_dir(&panel, "root/dir1", cx);
8615 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8616 toggle_expand_dir(&panel, "root/dir2", cx);
8617 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
8618
8619 // Test Case 1: Select and delete nested directory with parent
8620 cx.simulate_modifiers_change(gpui::Modifiers {
8621 control: true,
8622 ..Default::default()
8623 });
8624 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
8625 select_path_with_mark(&panel, "root/dir1", cx);
8626
8627 assert_eq!(
8628 visible_entries_as_strings(&panel, 0..15, cx),
8629 &[
8630 "v root",
8631 " v dir1 <== selected <== marked",
8632 " v subdir1 <== marked",
8633 " a.txt",
8634 " b.txt",
8635 " file1.txt",
8636 " v dir2",
8637 " v subdir2",
8638 " c.txt",
8639 " d.txt",
8640 " file2.txt",
8641 " file3.txt",
8642 ],
8643 "Initial state before deleting nested directory with parent"
8644 );
8645
8646 submit_deletion(&panel, cx);
8647 assert_eq!(
8648 visible_entries_as_strings(&panel, 0..15, cx),
8649 &[
8650 "v root",
8651 " v dir2 <== selected",
8652 " v subdir2",
8653 " c.txt",
8654 " d.txt",
8655 " file2.txt",
8656 " file3.txt",
8657 ],
8658 "Should select next directory after deleting directory with parent"
8659 );
8660
8661 // Test Case 2: Select mixed files and directories across levels
8662 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
8663 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
8664 select_path_with_mark(&panel, "root/file3.txt", cx);
8665
8666 assert_eq!(
8667 visible_entries_as_strings(&panel, 0..15, cx),
8668 &[
8669 "v root",
8670 " v dir2",
8671 " v subdir2",
8672 " c.txt <== marked",
8673 " d.txt",
8674 " file2.txt <== marked",
8675 " file3.txt <== selected <== marked",
8676 ],
8677 "Initial state before deleting"
8678 );
8679
8680 submit_deletion(&panel, cx);
8681 assert_eq!(
8682 visible_entries_as_strings(&panel, 0..15, cx),
8683 &[
8684 "v root",
8685 " v dir2 <== selected",
8686 " v subdir2",
8687 " d.txt",
8688 ],
8689 "Should select sibling directory"
8690 );
8691 }
8692
8693 #[gpui::test]
8694 async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
8695 init_test_with_editor(cx);
8696
8697 let fs = FakeFs::new(cx.executor().clone());
8698 fs.insert_tree(
8699 "/root",
8700 json!({
8701 "dir1": {
8702 "subdir1": {
8703 "a.txt": "",
8704 "b.txt": ""
8705 },
8706 "file1.txt": "",
8707 },
8708 "dir2": {
8709 "subdir2": {
8710 "c.txt": "",
8711 "d.txt": ""
8712 },
8713 "file2.txt": "",
8714 },
8715 "file3.txt": "",
8716 "file4.txt": "",
8717 }),
8718 )
8719 .await;
8720
8721 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8722 let workspace =
8723 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8724 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8725 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8726
8727 toggle_expand_dir(&panel, "root/dir1", cx);
8728 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8729 toggle_expand_dir(&panel, "root/dir2", cx);
8730 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
8731
8732 // Test Case 1: Select all root files and directories
8733 cx.simulate_modifiers_change(gpui::Modifiers {
8734 control: true,
8735 ..Default::default()
8736 });
8737 select_path_with_mark(&panel, "root/dir1", cx);
8738 select_path_with_mark(&panel, "root/dir2", cx);
8739 select_path_with_mark(&panel, "root/file3.txt", cx);
8740 select_path_with_mark(&panel, "root/file4.txt", cx);
8741 assert_eq!(
8742 visible_entries_as_strings(&panel, 0..20, cx),
8743 &[
8744 "v root",
8745 " v dir1 <== marked",
8746 " v subdir1",
8747 " a.txt",
8748 " b.txt",
8749 " file1.txt",
8750 " v dir2 <== marked",
8751 " v subdir2",
8752 " c.txt",
8753 " d.txt",
8754 " file2.txt",
8755 " file3.txt <== marked",
8756 " file4.txt <== selected <== marked",
8757 ],
8758 "State before deleting all contents"
8759 );
8760
8761 submit_deletion(&panel, cx);
8762 assert_eq!(
8763 visible_entries_as_strings(&panel, 0..20, cx),
8764 &["v root <== selected"],
8765 "Only empty root directory should remain after deleting all contents"
8766 );
8767 }
8768
8769 #[gpui::test]
8770 async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
8771 init_test_with_editor(cx);
8772
8773 let fs = FakeFs::new(cx.executor().clone());
8774 fs.insert_tree(
8775 "/root",
8776 json!({
8777 "dir1": {
8778 "subdir1": {
8779 "file_a.txt": "content a",
8780 "file_b.txt": "content b",
8781 },
8782 "subdir2": {
8783 "file_c.txt": "content c",
8784 },
8785 "file1.txt": "content 1",
8786 },
8787 "dir2": {
8788 "file2.txt": "content 2",
8789 },
8790 }),
8791 )
8792 .await;
8793
8794 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8795 let workspace =
8796 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8797 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8798 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8799
8800 toggle_expand_dir(&panel, "root/dir1", cx);
8801 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8802 toggle_expand_dir(&panel, "root/dir2", cx);
8803 cx.simulate_modifiers_change(gpui::Modifiers {
8804 control: true,
8805 ..Default::default()
8806 });
8807
8808 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
8809 select_path_with_mark(&panel, "root/dir1", cx);
8810 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
8811 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
8812
8813 assert_eq!(
8814 visible_entries_as_strings(&panel, 0..20, cx),
8815 &[
8816 "v root",
8817 " v dir1 <== marked",
8818 " v subdir1 <== marked",
8819 " file_a.txt <== selected <== marked",
8820 " file_b.txt",
8821 " > subdir2",
8822 " file1.txt",
8823 " v dir2",
8824 " file2.txt",
8825 ],
8826 "State with parent dir, subdir, and file selected"
8827 );
8828 submit_deletion(&panel, cx);
8829 assert_eq!(
8830 visible_entries_as_strings(&panel, 0..20, cx),
8831 &["v root", " v dir2 <== selected", " file2.txt",],
8832 "Only dir2 should remain after deletion"
8833 );
8834 }
8835
8836 #[gpui::test]
8837 async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
8838 init_test_with_editor(cx);
8839
8840 let fs = FakeFs::new(cx.executor().clone());
8841 // First worktree
8842 fs.insert_tree(
8843 "/root1",
8844 json!({
8845 "dir1": {
8846 "file1.txt": "content 1",
8847 "file2.txt": "content 2",
8848 },
8849 "dir2": {
8850 "file3.txt": "content 3",
8851 },
8852 }),
8853 )
8854 .await;
8855
8856 // Second worktree
8857 fs.insert_tree(
8858 "/root2",
8859 json!({
8860 "dir3": {
8861 "file4.txt": "content 4",
8862 "file5.txt": "content 5",
8863 },
8864 "file6.txt": "content 6",
8865 }),
8866 )
8867 .await;
8868
8869 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8870 let workspace =
8871 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8872 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8873 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8874
8875 // Expand all directories for testing
8876 toggle_expand_dir(&panel, "root1/dir1", cx);
8877 toggle_expand_dir(&panel, "root1/dir2", cx);
8878 toggle_expand_dir(&panel, "root2/dir3", cx);
8879
8880 // Test Case 1: Delete files across different worktrees
8881 cx.simulate_modifiers_change(gpui::Modifiers {
8882 control: true,
8883 ..Default::default()
8884 });
8885 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
8886 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
8887
8888 assert_eq!(
8889 visible_entries_as_strings(&panel, 0..20, cx),
8890 &[
8891 "v root1",
8892 " v dir1",
8893 " file1.txt <== marked",
8894 " file2.txt",
8895 " v dir2",
8896 " file3.txt",
8897 "v root2",
8898 " v dir3",
8899 " file4.txt <== selected <== marked",
8900 " file5.txt",
8901 " file6.txt",
8902 ],
8903 "Initial state with files selected from different worktrees"
8904 );
8905
8906 submit_deletion(&panel, cx);
8907 assert_eq!(
8908 visible_entries_as_strings(&panel, 0..20, cx),
8909 &[
8910 "v root1",
8911 " v dir1",
8912 " file2.txt",
8913 " v dir2",
8914 " file3.txt",
8915 "v root2",
8916 " v dir3",
8917 " file5.txt <== selected",
8918 " file6.txt",
8919 ],
8920 "Should select next file in the last worktree after deletion"
8921 );
8922
8923 // Test Case 2: Delete directories from different worktrees
8924 select_path_with_mark(&panel, "root1/dir1", cx);
8925 select_path_with_mark(&panel, "root2/dir3", cx);
8926
8927 assert_eq!(
8928 visible_entries_as_strings(&panel, 0..20, cx),
8929 &[
8930 "v root1",
8931 " v dir1 <== marked",
8932 " file2.txt",
8933 " v dir2",
8934 " file3.txt",
8935 "v root2",
8936 " v dir3 <== selected <== marked",
8937 " file5.txt",
8938 " file6.txt",
8939 ],
8940 "State with directories marked from different worktrees"
8941 );
8942
8943 submit_deletion(&panel, cx);
8944 assert_eq!(
8945 visible_entries_as_strings(&panel, 0..20, cx),
8946 &[
8947 "v root1",
8948 " v dir2",
8949 " file3.txt",
8950 "v root2",
8951 " file6.txt <== selected",
8952 ],
8953 "Should select remaining file in last worktree after directory deletion"
8954 );
8955
8956 // Test Case 4: Delete all remaining files except roots
8957 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
8958 select_path_with_mark(&panel, "root2/file6.txt", cx);
8959
8960 assert_eq!(
8961 visible_entries_as_strings(&panel, 0..20, cx),
8962 &[
8963 "v root1",
8964 " v dir2",
8965 " file3.txt <== marked",
8966 "v root2",
8967 " file6.txt <== selected <== marked",
8968 ],
8969 "State with all remaining files marked"
8970 );
8971
8972 submit_deletion(&panel, cx);
8973 assert_eq!(
8974 visible_entries_as_strings(&panel, 0..20, cx),
8975 &["v root1", " v dir2", "v root2 <== selected"],
8976 "Second parent root should be selected after deleting"
8977 );
8978 }
8979
8980 #[gpui::test]
8981 async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
8982 init_test_with_editor(cx);
8983
8984 let fs = FakeFs::new(cx.executor().clone());
8985 fs.insert_tree(
8986 "/root",
8987 json!({
8988 "dir1": {
8989 "file1.txt": "",
8990 "file2.txt": "",
8991 "file3.txt": "",
8992 },
8993 "dir2": {
8994 "file4.txt": "",
8995 "file5.txt": "",
8996 },
8997 }),
8998 )
8999 .await;
9000
9001 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
9002 let workspace =
9003 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
9004 let cx = &mut VisualTestContext::from_window(*workspace, cx);
9005 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9006
9007 toggle_expand_dir(&panel, "root/dir1", cx);
9008 toggle_expand_dir(&panel, "root/dir2", cx);
9009
9010 cx.simulate_modifiers_change(gpui::Modifiers {
9011 control: true,
9012 ..Default::default()
9013 });
9014
9015 select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
9016 select_path(&panel, "root/dir1/file1.txt", cx);
9017
9018 assert_eq!(
9019 visible_entries_as_strings(&panel, 0..15, cx),
9020 &[
9021 "v root",
9022 " v dir1",
9023 " file1.txt <== selected",
9024 " file2.txt <== marked",
9025 " file3.txt",
9026 " v dir2",
9027 " file4.txt",
9028 " file5.txt",
9029 ],
9030 "Initial state with one marked entry and different selection"
9031 );
9032
9033 // Delete should operate on the selected entry (file1.txt)
9034 submit_deletion(&panel, cx);
9035 assert_eq!(
9036 visible_entries_as_strings(&panel, 0..15, cx),
9037 &[
9038 "v root",
9039 " v dir1",
9040 " file2.txt <== selected <== marked",
9041 " file3.txt",
9042 " v dir2",
9043 " file4.txt",
9044 " file5.txt",
9045 ],
9046 "Should delete selected file, not marked file"
9047 );
9048
9049 select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
9050 select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
9051 select_path(&panel, "root/dir2/file5.txt", cx);
9052
9053 assert_eq!(
9054 visible_entries_as_strings(&panel, 0..15, cx),
9055 &[
9056 "v root",
9057 " v dir1",
9058 " file2.txt <== marked",
9059 " file3.txt <== marked",
9060 " v dir2",
9061 " file4.txt <== marked",
9062 " file5.txt <== selected",
9063 ],
9064 "Initial state with multiple marked entries and different selection"
9065 );
9066
9067 // Delete should operate on all marked entries, ignoring the selection
9068 submit_deletion(&panel, cx);
9069 assert_eq!(
9070 visible_entries_as_strings(&panel, 0..15, cx),
9071 &[
9072 "v root",
9073 " v dir1",
9074 " v dir2",
9075 " file5.txt <== selected",
9076 ],
9077 "Should delete all marked files, leaving only the selected file"
9078 );
9079 }
9080
9081 #[gpui::test]
9082 async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
9083 init_test_with_editor(cx);
9084
9085 let fs = FakeFs::new(cx.executor().clone());
9086 fs.insert_tree(
9087 "/root_b",
9088 json!({
9089 "dir1": {
9090 "file1.txt": "content 1",
9091 "file2.txt": "content 2",
9092 },
9093 }),
9094 )
9095 .await;
9096
9097 fs.insert_tree(
9098 "/root_c",
9099 json!({
9100 "dir2": {},
9101 }),
9102 )
9103 .await;
9104
9105 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
9106 let workspace =
9107 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
9108 let cx = &mut VisualTestContext::from_window(*workspace, cx);
9109 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9110
9111 toggle_expand_dir(&panel, "root_b/dir1", cx);
9112 toggle_expand_dir(&panel, "root_c/dir2", cx);
9113
9114 cx.simulate_modifiers_change(gpui::Modifiers {
9115 control: true,
9116 ..Default::default()
9117 });
9118 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
9119 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
9120
9121 assert_eq!(
9122 visible_entries_as_strings(&panel, 0..20, cx),
9123 &[
9124 "v root_b",
9125 " v dir1",
9126 " file1.txt <== marked",
9127 " file2.txt <== selected <== marked",
9128 "v root_c",
9129 " v dir2",
9130 ],
9131 "Initial state with files marked in root_b"
9132 );
9133
9134 submit_deletion(&panel, cx);
9135 assert_eq!(
9136 visible_entries_as_strings(&panel, 0..20, cx),
9137 &[
9138 "v root_b",
9139 " v dir1 <== selected",
9140 "v root_c",
9141 " v dir2",
9142 ],
9143 "After deletion in root_b as it's last deletion, selection should be in root_b"
9144 );
9145
9146 select_path_with_mark(&panel, "root_c/dir2", cx);
9147
9148 submit_deletion(&panel, cx);
9149 assert_eq!(
9150 visible_entries_as_strings(&panel, 0..20, cx),
9151 &["v root_b", " v dir1", "v root_c <== selected",],
9152 "After deleting from root_c, it should remain in root_c"
9153 );
9154 }
9155
9156 fn toggle_expand_dir(
9157 panel: &Entity<ProjectPanel>,
9158 path: impl AsRef<Path>,
9159 cx: &mut VisualTestContext,
9160 ) {
9161 let path = path.as_ref();
9162 panel.update_in(cx, |panel, window, cx| {
9163 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9164 let worktree = worktree.read(cx);
9165 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9166 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9167 panel.toggle_expanded(entry_id, window, cx);
9168 return;
9169 }
9170 }
9171 panic!("no worktree for path {:?}", path);
9172 });
9173 }
9174
9175 #[gpui::test]
9176 async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
9177 init_test_with_editor(cx);
9178
9179 let fs = FakeFs::new(cx.executor().clone());
9180 fs.insert_tree(
9181 path!("/root"),
9182 json!({
9183 ".gitignore": "**/ignored_dir\n**/ignored_nested",
9184 "dir1": {
9185 "empty1": {
9186 "empty2": {
9187 "empty3": {
9188 "file.txt": ""
9189 }
9190 }
9191 },
9192 "subdir1": {
9193 "file1.txt": "",
9194 "file2.txt": "",
9195 "ignored_nested": {
9196 "ignored_file.txt": ""
9197 }
9198 },
9199 "ignored_dir": {
9200 "subdir": {
9201 "deep_file.txt": ""
9202 }
9203 }
9204 }
9205 }),
9206 )
9207 .await;
9208
9209 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
9210 let workspace =
9211 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
9212 let cx = &mut VisualTestContext::from_window(*workspace, cx);
9213
9214 // Test 1: When auto-fold is enabled
9215 cx.update(|_, cx| {
9216 let settings = *ProjectPanelSettings::get_global(cx);
9217 ProjectPanelSettings::override_global(
9218 ProjectPanelSettings {
9219 auto_fold_dirs: true,
9220 ..settings
9221 },
9222 cx,
9223 );
9224 });
9225
9226 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9227
9228 assert_eq!(
9229 visible_entries_as_strings(&panel, 0..20, cx),
9230 &["v root", " > dir1", " .gitignore",],
9231 "Initial state should show collapsed root structure"
9232 );
9233
9234 toggle_expand_dir(&panel, "root/dir1", cx);
9235 assert_eq!(
9236 visible_entries_as_strings(&panel, 0..20, cx),
9237 &[
9238 separator!("v root"),
9239 separator!(" v dir1 <== selected"),
9240 separator!(" > empty1/empty2/empty3"),
9241 separator!(" > ignored_dir"),
9242 separator!(" > subdir1"),
9243 separator!(" .gitignore"),
9244 ],
9245 "Should show first level with auto-folded dirs and ignored dir visible"
9246 );
9247
9248 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9249 panel.update(cx, |panel, cx| {
9250 let project = panel.project.read(cx);
9251 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9252 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
9253 panel.update_visible_entries(None, cx);
9254 });
9255 cx.run_until_parked();
9256
9257 assert_eq!(
9258 visible_entries_as_strings(&panel, 0..20, cx),
9259 &[
9260 separator!("v root"),
9261 separator!(" v dir1 <== selected"),
9262 separator!(" v empty1"),
9263 separator!(" v empty2"),
9264 separator!(" v empty3"),
9265 separator!(" file.txt"),
9266 separator!(" > ignored_dir"),
9267 separator!(" v subdir1"),
9268 separator!(" > ignored_nested"),
9269 separator!(" file1.txt"),
9270 separator!(" file2.txt"),
9271 separator!(" .gitignore"),
9272 ],
9273 "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
9274 );
9275
9276 // Test 2: When auto-fold is disabled
9277 cx.update(|_, cx| {
9278 let settings = *ProjectPanelSettings::get_global(cx);
9279 ProjectPanelSettings::override_global(
9280 ProjectPanelSettings {
9281 auto_fold_dirs: false,
9282 ..settings
9283 },
9284 cx,
9285 );
9286 });
9287
9288 panel.update_in(cx, |panel, window, cx| {
9289 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
9290 });
9291
9292 toggle_expand_dir(&panel, "root/dir1", cx);
9293 assert_eq!(
9294 visible_entries_as_strings(&panel, 0..20, cx),
9295 &[
9296 separator!("v root"),
9297 separator!(" v dir1 <== selected"),
9298 separator!(" > empty1"),
9299 separator!(" > ignored_dir"),
9300 separator!(" > subdir1"),
9301 separator!(" .gitignore"),
9302 ],
9303 "With auto-fold disabled: should show all directories separately"
9304 );
9305
9306 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9307 panel.update(cx, |panel, cx| {
9308 let project = panel.project.read(cx);
9309 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9310 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
9311 panel.update_visible_entries(None, cx);
9312 });
9313 cx.run_until_parked();
9314
9315 assert_eq!(
9316 visible_entries_as_strings(&panel, 0..20, cx),
9317 &[
9318 separator!("v root"),
9319 separator!(" v dir1 <== selected"),
9320 separator!(" v empty1"),
9321 separator!(" v empty2"),
9322 separator!(" v empty3"),
9323 separator!(" file.txt"),
9324 separator!(" > ignored_dir"),
9325 separator!(" v subdir1"),
9326 separator!(" > ignored_nested"),
9327 separator!(" file1.txt"),
9328 separator!(" file2.txt"),
9329 separator!(" .gitignore"),
9330 ],
9331 "After expand_all without auto-fold: should expand all dirs normally, \
9332 expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
9333 );
9334
9335 // Test 3: When explicitly called on ignored directory
9336 let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
9337 panel.update(cx, |panel, cx| {
9338 let project = panel.project.read(cx);
9339 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9340 panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
9341 panel.update_visible_entries(None, cx);
9342 });
9343 cx.run_until_parked();
9344
9345 assert_eq!(
9346 visible_entries_as_strings(&panel, 0..20, cx),
9347 &[
9348 separator!("v root"),
9349 separator!(" v dir1 <== selected"),
9350 separator!(" v empty1"),
9351 separator!(" v empty2"),
9352 separator!(" v empty3"),
9353 separator!(" file.txt"),
9354 separator!(" v ignored_dir"),
9355 separator!(" v subdir"),
9356 separator!(" deep_file.txt"),
9357 separator!(" v subdir1"),
9358 separator!(" > ignored_nested"),
9359 separator!(" file1.txt"),
9360 separator!(" file2.txt"),
9361 separator!(" .gitignore"),
9362 ],
9363 "After expand_all on ignored_dir: should expand all contents of the ignored directory"
9364 );
9365 }
9366
9367 #[gpui::test]
9368 async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
9369 init_test(cx);
9370
9371 let fs = FakeFs::new(cx.executor().clone());
9372 fs.insert_tree(
9373 path!("/root"),
9374 json!({
9375 "dir1": {
9376 "subdir1": {
9377 "nested1": {
9378 "file1.txt": "",
9379 "file2.txt": ""
9380 },
9381 },
9382 "subdir2": {
9383 "file4.txt": ""
9384 }
9385 },
9386 "dir2": {
9387 "single_file": {
9388 "file5.txt": ""
9389 }
9390 }
9391 }),
9392 )
9393 .await;
9394
9395 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
9396 let workspace =
9397 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
9398 let cx = &mut VisualTestContext::from_window(*workspace, cx);
9399
9400 // Test 1: Basic collapsing
9401 {
9402 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9403
9404 toggle_expand_dir(&panel, "root/dir1", cx);
9405 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
9406 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
9407 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
9408
9409 assert_eq!(
9410 visible_entries_as_strings(&panel, 0..20, cx),
9411 &[
9412 separator!("v root"),
9413 separator!(" v dir1"),
9414 separator!(" v subdir1"),
9415 separator!(" v nested1"),
9416 separator!(" file1.txt"),
9417 separator!(" file2.txt"),
9418 separator!(" v subdir2 <== selected"),
9419 separator!(" file4.txt"),
9420 separator!(" > dir2"),
9421 ],
9422 "Initial state with everything expanded"
9423 );
9424
9425 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9426 panel.update(cx, |panel, cx| {
9427 let project = panel.project.read(cx);
9428 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9429 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
9430 panel.update_visible_entries(None, cx);
9431 });
9432
9433 assert_eq!(
9434 visible_entries_as_strings(&panel, 0..20, cx),
9435 &["v root", " > dir1", " > dir2",],
9436 "All subdirs under dir1 should be collapsed"
9437 );
9438 }
9439
9440 // Test 2: With auto-fold enabled
9441 {
9442 cx.update(|_, cx| {
9443 let settings = *ProjectPanelSettings::get_global(cx);
9444 ProjectPanelSettings::override_global(
9445 ProjectPanelSettings {
9446 auto_fold_dirs: true,
9447 ..settings
9448 },
9449 cx,
9450 );
9451 });
9452
9453 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9454
9455 toggle_expand_dir(&panel, "root/dir1", cx);
9456 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
9457 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
9458
9459 assert_eq!(
9460 visible_entries_as_strings(&panel, 0..20, cx),
9461 &[
9462 separator!("v root"),
9463 separator!(" v dir1"),
9464 separator!(" v subdir1/nested1 <== selected"),
9465 separator!(" file1.txt"),
9466 separator!(" file2.txt"),
9467 separator!(" > subdir2"),
9468 separator!(" > dir2/single_file"),
9469 ],
9470 "Initial state with some dirs expanded"
9471 );
9472
9473 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9474 panel.update(cx, |panel, cx| {
9475 let project = panel.project.read(cx);
9476 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9477 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
9478 });
9479
9480 toggle_expand_dir(&panel, "root/dir1", cx);
9481
9482 assert_eq!(
9483 visible_entries_as_strings(&panel, 0..20, cx),
9484 &[
9485 separator!("v root"),
9486 separator!(" v dir1 <== selected"),
9487 separator!(" > subdir1/nested1"),
9488 separator!(" > subdir2"),
9489 separator!(" > dir2/single_file"),
9490 ],
9491 "Subdirs should be collapsed and folded with auto-fold enabled"
9492 );
9493 }
9494
9495 // Test 3: With auto-fold disabled
9496 {
9497 cx.update(|_, cx| {
9498 let settings = *ProjectPanelSettings::get_global(cx);
9499 ProjectPanelSettings::override_global(
9500 ProjectPanelSettings {
9501 auto_fold_dirs: false,
9502 ..settings
9503 },
9504 cx,
9505 );
9506 });
9507
9508 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9509
9510 toggle_expand_dir(&panel, "root/dir1", cx);
9511 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
9512 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
9513
9514 assert_eq!(
9515 visible_entries_as_strings(&panel, 0..20, cx),
9516 &[
9517 separator!("v root"),
9518 separator!(" v dir1"),
9519 separator!(" v subdir1"),
9520 separator!(" v nested1 <== selected"),
9521 separator!(" file1.txt"),
9522 separator!(" file2.txt"),
9523 separator!(" > subdir2"),
9524 separator!(" > dir2"),
9525 ],
9526 "Initial state with some dirs expanded and auto-fold disabled"
9527 );
9528
9529 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9530 panel.update(cx, |panel, cx| {
9531 let project = panel.project.read(cx);
9532 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9533 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
9534 });
9535
9536 toggle_expand_dir(&panel, "root/dir1", cx);
9537
9538 assert_eq!(
9539 visible_entries_as_strings(&panel, 0..20, cx),
9540 &[
9541 separator!("v root"),
9542 separator!(" v dir1 <== selected"),
9543 separator!(" > subdir1"),
9544 separator!(" > subdir2"),
9545 separator!(" > dir2"),
9546 ],
9547 "Subdirs should be collapsed but not folded with auto-fold disabled"
9548 );
9549 }
9550 }
9551
9552 fn select_path(
9553 panel: &Entity<ProjectPanel>,
9554 path: impl AsRef<Path>,
9555 cx: &mut VisualTestContext,
9556 ) {
9557 let path = path.as_ref();
9558 panel.update(cx, |panel, cx| {
9559 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9560 let worktree = worktree.read(cx);
9561 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9562 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9563 panel.selection = Some(crate::SelectedEntry {
9564 worktree_id: worktree.id(),
9565 entry_id,
9566 });
9567 return;
9568 }
9569 }
9570 panic!("no worktree for path {:?}", path);
9571 });
9572 }
9573
9574 fn select_path_with_mark(
9575 panel: &Entity<ProjectPanel>,
9576 path: impl AsRef<Path>,
9577 cx: &mut VisualTestContext,
9578 ) {
9579 let path = path.as_ref();
9580 panel.update(cx, |panel, cx| {
9581 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9582 let worktree = worktree.read(cx);
9583 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9584 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9585 let entry = crate::SelectedEntry {
9586 worktree_id: worktree.id(),
9587 entry_id,
9588 };
9589 if !panel.marked_entries.contains(&entry) {
9590 panel.marked_entries.insert(entry);
9591 }
9592 panel.selection = Some(entry);
9593 return;
9594 }
9595 }
9596 panic!("no worktree for path {:?}", path);
9597 });
9598 }
9599
9600 fn find_project_entry(
9601 panel: &Entity<ProjectPanel>,
9602 path: impl AsRef<Path>,
9603 cx: &mut VisualTestContext,
9604 ) -> Option<ProjectEntryId> {
9605 let path = path.as_ref();
9606 panel.update(cx, |panel, cx| {
9607 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9608 let worktree = worktree.read(cx);
9609 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9610 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
9611 }
9612 }
9613 panic!("no worktree for path {path:?}");
9614 })
9615 }
9616
9617 fn visible_entries_as_strings(
9618 panel: &Entity<ProjectPanel>,
9619 range: Range<usize>,
9620 cx: &mut VisualTestContext,
9621 ) -> Vec<String> {
9622 let mut result = Vec::new();
9623 let mut project_entries = HashSet::default();
9624 let mut has_editor = false;
9625
9626 panel.update_in(cx, |panel, window, cx| {
9627 panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
9628 if details.is_editing {
9629 assert!(!has_editor, "duplicate editor entry");
9630 has_editor = true;
9631 } else {
9632 assert!(
9633 project_entries.insert(project_entry),
9634 "duplicate project entry {:?} {:?}",
9635 project_entry,
9636 details
9637 );
9638 }
9639
9640 let indent = " ".repeat(details.depth);
9641 let icon = if details.kind.is_dir() {
9642 if details.is_expanded {
9643 "v "
9644 } else {
9645 "> "
9646 }
9647 } else {
9648 " "
9649 };
9650 let name = if details.is_editing {
9651 format!("[EDITOR: '{}']", details.filename)
9652 } else if details.is_processing {
9653 format!("[PROCESSING: '{}']", details.filename)
9654 } else {
9655 details.filename.clone()
9656 };
9657 let selected = if details.is_selected {
9658 " <== selected"
9659 } else {
9660 ""
9661 };
9662 let marked = if details.is_marked {
9663 " <== marked"
9664 } else {
9665 ""
9666 };
9667
9668 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
9669 });
9670 });
9671
9672 result
9673 }
9674
9675 fn init_test(cx: &mut TestAppContext) {
9676 cx.update(|cx| {
9677 let settings_store = SettingsStore::test(cx);
9678 cx.set_global(settings_store);
9679 init_settings(cx);
9680 theme::init(theme::LoadThemes::JustBase, cx);
9681 language::init(cx);
9682 editor::init_settings(cx);
9683 crate::init(cx);
9684 workspace::init_settings(cx);
9685 client::init_settings(cx);
9686 Project::init_settings(cx);
9687
9688 cx.update_global::<SettingsStore, _>(|store, cx| {
9689 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
9690 project_panel_settings.auto_fold_dirs = Some(false);
9691 });
9692 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
9693 worktree_settings.file_scan_exclusions = Some(Vec::new());
9694 });
9695 });
9696 });
9697 }
9698
9699 fn init_test_with_editor(cx: &mut TestAppContext) {
9700 cx.update(|cx| {
9701 let app_state = AppState::test(cx);
9702 theme::init(theme::LoadThemes::JustBase, cx);
9703 init_settings(cx);
9704 language::init(cx);
9705 editor::init(cx);
9706 crate::init(cx);
9707 workspace::init(app_state.clone(), cx);
9708 Project::init_settings(cx);
9709
9710 cx.update_global::<SettingsStore, _>(|store, cx| {
9711 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
9712 project_panel_settings.auto_fold_dirs = Some(false);
9713 });
9714 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
9715 worktree_settings.file_scan_exclusions = Some(Vec::new());
9716 });
9717 });
9718 });
9719 }
9720
9721 fn ensure_single_file_is_opened(
9722 window: &WindowHandle<Workspace>,
9723 expected_path: &str,
9724 cx: &mut TestAppContext,
9725 ) {
9726 window
9727 .update(cx, |workspace, _, cx| {
9728 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
9729 assert_eq!(worktrees.len(), 1);
9730 let worktree_id = worktrees[0].read(cx).id();
9731
9732 let open_project_paths = workspace
9733 .panes()
9734 .iter()
9735 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9736 .collect::<Vec<_>>();
9737 assert_eq!(
9738 open_project_paths,
9739 vec![ProjectPath {
9740 worktree_id,
9741 path: Arc::from(Path::new(expected_path))
9742 }],
9743 "Should have opened file, selected in project panel"
9744 );
9745 })
9746 .unwrap();
9747 }
9748
9749 fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9750 assert!(
9751 !cx.has_pending_prompt(),
9752 "Should have no prompts before the deletion"
9753 );
9754 panel.update_in(cx, |panel, window, cx| {
9755 panel.delete(&Delete { skip_prompt: false }, window, cx)
9756 });
9757 assert!(
9758 cx.has_pending_prompt(),
9759 "Should have a prompt after the deletion"
9760 );
9761 cx.simulate_prompt_answer("Delete");
9762 assert!(
9763 !cx.has_pending_prompt(),
9764 "Should have no prompts after prompt was replied to"
9765 );
9766 cx.executor().run_until_parked();
9767 }
9768
9769 fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9770 assert!(
9771 !cx.has_pending_prompt(),
9772 "Should have no prompts before the deletion"
9773 );
9774 panel.update_in(cx, |panel, window, cx| {
9775 panel.delete(&Delete { skip_prompt: true }, window, cx)
9776 });
9777 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
9778 cx.executor().run_until_parked();
9779 }
9780
9781 fn ensure_no_open_items_and_panes(
9782 workspace: &WindowHandle<Workspace>,
9783 cx: &mut VisualTestContext,
9784 ) {
9785 assert!(
9786 !cx.has_pending_prompt(),
9787 "Should have no prompts after deletion operation closes the file"
9788 );
9789 workspace
9790 .read_with(cx, |workspace, cx| {
9791 let open_project_paths = workspace
9792 .panes()
9793 .iter()
9794 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9795 .collect::<Vec<_>>();
9796 assert!(
9797 open_project_paths.is_empty(),
9798 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
9799 );
9800 })
9801 .unwrap();
9802 }
9803
9804 struct TestProjectItemView {
9805 focus_handle: FocusHandle,
9806 path: ProjectPath,
9807 }
9808
9809 struct TestProjectItem {
9810 path: ProjectPath,
9811 }
9812
9813 impl project::ProjectItem for TestProjectItem {
9814 fn try_open(
9815 _project: &Entity<Project>,
9816 path: &ProjectPath,
9817 cx: &mut App,
9818 ) -> Option<Task<gpui::Result<Entity<Self>>>> {
9819 let path = path.clone();
9820 Some(cx.spawn(async move |cx| cx.new(|_| Self { path })))
9821 }
9822
9823 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
9824 None
9825 }
9826
9827 fn project_path(&self, _: &App) -> Option<ProjectPath> {
9828 Some(self.path.clone())
9829 }
9830
9831 fn is_dirty(&self) -> bool {
9832 false
9833 }
9834 }
9835
9836 impl ProjectItem for TestProjectItemView {
9837 type Item = TestProjectItem;
9838
9839 fn for_project_item(
9840 _: Entity<Project>,
9841 project_item: Entity<Self::Item>,
9842 _: &mut Window,
9843 cx: &mut Context<Self>,
9844 ) -> Self
9845 where
9846 Self: Sized,
9847 {
9848 Self {
9849 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
9850 focus_handle: cx.focus_handle(),
9851 }
9852 }
9853 }
9854
9855 impl Item for TestProjectItemView {
9856 type Event = ();
9857 }
9858
9859 impl EventEmitter<()> for TestProjectItemView {}
9860
9861 impl Focusable for TestProjectItemView {
9862 fn focus_handle(&self, _: &App) -> FocusHandle {
9863 self.focus_handle.clone()
9864 }
9865 }
9866
9867 impl Render for TestProjectItemView {
9868 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
9869 Empty
9870 }
9871 }
9872}