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 init_test_with_editor(cx);
6760
6761 let fs = FakeFs::new(cx.executor().clone());
6762 fs.insert_tree(
6763 path!("/root"),
6764 json!({
6765 "tree1": {
6766 ".git": {},
6767 "dir1": {
6768 "modified1.txt": "1",
6769 "unmodified1.txt": "1",
6770 "modified2.txt": "1",
6771 },
6772 "dir2": {
6773 "modified3.txt": "1",
6774 "unmodified2.txt": "1",
6775 },
6776 "modified4.txt": "1",
6777 "unmodified3.txt": "1",
6778 },
6779 "tree2": {
6780 ".git": {},
6781 "dir3": {
6782 "modified5.txt": "1",
6783 "unmodified4.txt": "1",
6784 },
6785 "modified6.txt": "1",
6786 "unmodified5.txt": "1",
6787 }
6788 }),
6789 )
6790 .await;
6791
6792 // Mark files as git modified
6793 fs.set_git_content_for_repo(
6794 path!("/root/tree1/.git").as_ref(),
6795 &[
6796 ("dir1/modified1.txt".into(), "modified".into(), None),
6797 ("dir1/modified2.txt".into(), "modified".into(), None),
6798 ("modified4.txt".into(), "modified".into(), None),
6799 ("dir2/modified3.txt".into(), "modified".into(), None),
6800 ],
6801 );
6802 fs.set_git_content_for_repo(
6803 path!("/root/tree2/.git").as_ref(),
6804 &[
6805 ("dir3/modified5.txt".into(), "modified".into(), None),
6806 ("modified6.txt".into(), "modified".into(), None),
6807 ],
6808 );
6809
6810 let project = Project::test(
6811 fs.clone(),
6812 [path!("/root/tree1").as_ref(), path!("/root/tree2").as_ref()],
6813 cx,
6814 )
6815 .await;
6816 let workspace =
6817 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6818 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6819 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6820
6821 // Check initial state
6822 assert_eq!(
6823 visible_entries_as_strings(&panel, 0..15, cx),
6824 &[
6825 "v tree1",
6826 " > .git",
6827 " > dir1",
6828 " > dir2",
6829 " modified4.txt",
6830 " unmodified3.txt",
6831 "v tree2",
6832 " > .git",
6833 " > dir3",
6834 " modified6.txt",
6835 " unmodified5.txt"
6836 ],
6837 );
6838
6839 // Test selecting next modified entry
6840 panel.update_in(cx, |panel, window, cx| {
6841 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6842 });
6843
6844 assert_eq!(
6845 visible_entries_as_strings(&panel, 0..6, cx),
6846 &[
6847 "v tree1",
6848 " > .git",
6849 " v dir1",
6850 " modified1.txt <== selected",
6851 " modified2.txt",
6852 " unmodified1.txt",
6853 ],
6854 );
6855
6856 panel.update_in(cx, |panel, window, cx| {
6857 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6858 });
6859
6860 assert_eq!(
6861 visible_entries_as_strings(&panel, 0..6, cx),
6862 &[
6863 "v tree1",
6864 " > .git",
6865 " v dir1",
6866 " modified1.txt",
6867 " modified2.txt <== selected",
6868 " unmodified1.txt",
6869 ],
6870 );
6871
6872 panel.update_in(cx, |panel, window, cx| {
6873 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6874 });
6875
6876 assert_eq!(
6877 visible_entries_as_strings(&panel, 6..9, cx),
6878 &[
6879 " v dir2",
6880 " modified3.txt <== selected",
6881 " unmodified2.txt",
6882 ],
6883 );
6884
6885 panel.update_in(cx, |panel, window, cx| {
6886 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6887 });
6888
6889 assert_eq!(
6890 visible_entries_as_strings(&panel, 9..11, cx),
6891 &[" modified4.txt <== selected", " unmodified3.txt",],
6892 );
6893
6894 panel.update_in(cx, |panel, window, cx| {
6895 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6896 });
6897
6898 assert_eq!(
6899 visible_entries_as_strings(&panel, 13..16, cx),
6900 &[
6901 " v dir3",
6902 " modified5.txt <== selected",
6903 " unmodified4.txt",
6904 ],
6905 );
6906
6907 panel.update_in(cx, |panel, window, cx| {
6908 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6909 });
6910
6911 assert_eq!(
6912 visible_entries_as_strings(&panel, 16..18, cx),
6913 &[" modified6.txt <== selected", " unmodified5.txt",],
6914 );
6915
6916 // Wraps around to first modified file
6917 panel.update_in(cx, |panel, window, cx| {
6918 panel.select_next_git_entry(&SelectNextGitEntry, window, cx);
6919 });
6920
6921 assert_eq!(
6922 visible_entries_as_strings(&panel, 0..18, cx),
6923 &[
6924 "v tree1",
6925 " > .git",
6926 " v dir1",
6927 " modified1.txt <== selected",
6928 " modified2.txt",
6929 " unmodified1.txt",
6930 " v dir2",
6931 " modified3.txt",
6932 " unmodified2.txt",
6933 " modified4.txt",
6934 " unmodified3.txt",
6935 "v tree2",
6936 " > .git",
6937 " v dir3",
6938 " modified5.txt",
6939 " unmodified4.txt",
6940 " modified6.txt",
6941 " unmodified5.txt",
6942 ],
6943 );
6944
6945 // Wraps around again to last modified file
6946 panel.update_in(cx, |panel, window, cx| {
6947 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6948 });
6949
6950 assert_eq!(
6951 visible_entries_as_strings(&panel, 16..18, cx),
6952 &[" modified6.txt <== selected", " unmodified5.txt",],
6953 );
6954
6955 panel.update_in(cx, |panel, window, cx| {
6956 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6957 });
6958
6959 assert_eq!(
6960 visible_entries_as_strings(&panel, 13..16, cx),
6961 &[
6962 " v dir3",
6963 " modified5.txt <== selected",
6964 " unmodified4.txt",
6965 ],
6966 );
6967
6968 panel.update_in(cx, |panel, window, cx| {
6969 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6970 });
6971
6972 assert_eq!(
6973 visible_entries_as_strings(&panel, 9..11, cx),
6974 &[" modified4.txt <== selected", " unmodified3.txt",],
6975 );
6976
6977 panel.update_in(cx, |panel, window, cx| {
6978 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6979 });
6980
6981 assert_eq!(
6982 visible_entries_as_strings(&panel, 6..9, cx),
6983 &[
6984 " v dir2",
6985 " modified3.txt <== selected",
6986 " unmodified2.txt",
6987 ],
6988 );
6989
6990 panel.update_in(cx, |panel, window, cx| {
6991 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
6992 });
6993
6994 assert_eq!(
6995 visible_entries_as_strings(&panel, 0..6, cx),
6996 &[
6997 "v tree1",
6998 " > .git",
6999 " v dir1",
7000 " modified1.txt",
7001 " modified2.txt <== selected",
7002 " unmodified1.txt",
7003 ],
7004 );
7005
7006 panel.update_in(cx, |panel, window, cx| {
7007 panel.select_prev_git_entry(&SelectPrevGitEntry, window, cx);
7008 });
7009
7010 assert_eq!(
7011 visible_entries_as_strings(&panel, 0..6, cx),
7012 &[
7013 "v tree1",
7014 " > .git",
7015 " v dir1",
7016 " modified1.txt <== selected",
7017 " modified2.txt",
7018 " unmodified1.txt",
7019 ],
7020 );
7021 }
7022
7023 #[gpui::test]
7024 async fn test_select_directory(cx: &mut gpui::TestAppContext) {
7025 init_test_with_editor(cx);
7026
7027 let fs = FakeFs::new(cx.executor().clone());
7028 fs.insert_tree(
7029 "/project_root",
7030 json!({
7031 "dir_1": {
7032 "nested_dir": {
7033 "file_a.py": "# File contents",
7034 }
7035 },
7036 "file_1.py": "# File contents",
7037 "dir_2": {
7038
7039 },
7040 "dir_3": {
7041
7042 },
7043 "file_2.py": "# File contents",
7044 "dir_4": {
7045
7046 },
7047 }),
7048 )
7049 .await;
7050
7051 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7052 let workspace =
7053 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7054 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7055 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7056
7057 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7058 cx.executor().run_until_parked();
7059 select_path(&panel, "project_root/dir_1", cx);
7060 cx.executor().run_until_parked();
7061 assert_eq!(
7062 visible_entries_as_strings(&panel, 0..10, cx),
7063 &[
7064 "v project_root",
7065 " > dir_1 <== selected",
7066 " > dir_2",
7067 " > dir_3",
7068 " > dir_4",
7069 " file_1.py",
7070 " file_2.py",
7071 ]
7072 );
7073 panel.update_in(cx, |panel, window, cx| {
7074 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
7075 });
7076
7077 assert_eq!(
7078 visible_entries_as_strings(&panel, 0..10, cx),
7079 &[
7080 "v project_root <== selected",
7081 " > dir_1",
7082 " > dir_2",
7083 " > dir_3",
7084 " > dir_4",
7085 " file_1.py",
7086 " file_2.py",
7087 ]
7088 );
7089
7090 panel.update_in(cx, |panel, window, cx| {
7091 panel.select_prev_directory(&SelectPrevDirectory, window, cx)
7092 });
7093
7094 assert_eq!(
7095 visible_entries_as_strings(&panel, 0..10, cx),
7096 &[
7097 "v project_root",
7098 " > dir_1",
7099 " > dir_2",
7100 " > dir_3",
7101 " > dir_4 <== selected",
7102 " file_1.py",
7103 " file_2.py",
7104 ]
7105 );
7106
7107 panel.update_in(cx, |panel, window, cx| {
7108 panel.select_next_directory(&SelectNextDirectory, window, cx)
7109 });
7110
7111 assert_eq!(
7112 visible_entries_as_strings(&panel, 0..10, cx),
7113 &[
7114 "v project_root <== selected",
7115 " > dir_1",
7116 " > dir_2",
7117 " > dir_3",
7118 " > dir_4",
7119 " file_1.py",
7120 " file_2.py",
7121 ]
7122 );
7123 }
7124 #[gpui::test]
7125 async fn test_select_first_last(cx: &mut gpui::TestAppContext) {
7126 init_test_with_editor(cx);
7127
7128 let fs = FakeFs::new(cx.executor().clone());
7129 fs.insert_tree(
7130 "/project_root",
7131 json!({
7132 "dir_1": {
7133 "nested_dir": {
7134 "file_a.py": "# File contents",
7135 }
7136 },
7137 "file_1.py": "# File contents",
7138 "file_2.py": "# File contents",
7139 "zdir_2": {
7140 "nested_dir2": {
7141 "file_b.py": "# File contents",
7142 }
7143 },
7144 }),
7145 )
7146 .await;
7147
7148 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7149 let workspace =
7150 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7151 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7152 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7153
7154 assert_eq!(
7155 visible_entries_as_strings(&panel, 0..10, cx),
7156 &[
7157 "v project_root",
7158 " > dir_1",
7159 " > zdir_2",
7160 " file_1.py",
7161 " file_2.py",
7162 ]
7163 );
7164 panel.update_in(cx, |panel, window, cx| {
7165 panel.select_first(&SelectFirst, window, cx)
7166 });
7167
7168 assert_eq!(
7169 visible_entries_as_strings(&panel, 0..10, cx),
7170 &[
7171 "v project_root <== selected",
7172 " > dir_1",
7173 " > zdir_2",
7174 " file_1.py",
7175 " file_2.py",
7176 ]
7177 );
7178
7179 panel.update_in(cx, |panel, window, cx| {
7180 panel.select_last(&SelectLast, window, cx)
7181 });
7182
7183 assert_eq!(
7184 visible_entries_as_strings(&panel, 0..10, cx),
7185 &[
7186 "v project_root",
7187 " > dir_1",
7188 " > zdir_2",
7189 " file_1.py",
7190 " file_2.py <== selected",
7191 ]
7192 );
7193 }
7194
7195 #[gpui::test]
7196 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
7197 init_test_with_editor(cx);
7198
7199 let fs = FakeFs::new(cx.executor().clone());
7200 fs.insert_tree(
7201 "/project_root",
7202 json!({
7203 "dir_1": {
7204 "nested_dir": {
7205 "file_a.py": "# File contents",
7206 }
7207 },
7208 "file_1.py": "# File contents",
7209 }),
7210 )
7211 .await;
7212
7213 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7214 let workspace =
7215 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7216 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7217 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7218
7219 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7220 cx.executor().run_until_parked();
7221 select_path(&panel, "project_root/dir_1", cx);
7222 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7223 select_path(&panel, "project_root/dir_1/nested_dir", cx);
7224 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7225 panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7226 cx.executor().run_until_parked();
7227 assert_eq!(
7228 visible_entries_as_strings(&panel, 0..10, cx),
7229 &[
7230 "v project_root",
7231 " v dir_1",
7232 " > nested_dir <== selected",
7233 " file_1.py",
7234 ]
7235 );
7236 }
7237
7238 #[gpui::test]
7239 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
7240 init_test_with_editor(cx);
7241
7242 let fs = FakeFs::new(cx.executor().clone());
7243 fs.insert_tree(
7244 "/project_root",
7245 json!({
7246 "dir_1": {
7247 "nested_dir": {
7248 "file_a.py": "# File contents",
7249 "file_b.py": "# File contents",
7250 "file_c.py": "# File contents",
7251 },
7252 "file_1.py": "# File contents",
7253 "file_2.py": "# File contents",
7254 "file_3.py": "# File contents",
7255 },
7256 "dir_2": {
7257 "file_1.py": "# File contents",
7258 "file_2.py": "# File contents",
7259 "file_3.py": "# File contents",
7260 }
7261 }),
7262 )
7263 .await;
7264
7265 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7266 let workspace =
7267 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7268 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7269 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7270
7271 panel.update_in(cx, |panel, window, cx| {
7272 panel.collapse_all_entries(&CollapseAllEntries, window, cx)
7273 });
7274 cx.executor().run_until_parked();
7275 assert_eq!(
7276 visible_entries_as_strings(&panel, 0..10, cx),
7277 &["v project_root", " > dir_1", " > dir_2",]
7278 );
7279
7280 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
7281 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7282 cx.executor().run_until_parked();
7283 assert_eq!(
7284 visible_entries_as_strings(&panel, 0..10, cx),
7285 &[
7286 "v project_root",
7287 " v dir_1 <== selected",
7288 " > nested_dir",
7289 " file_1.py",
7290 " file_2.py",
7291 " file_3.py",
7292 " > dir_2",
7293 ]
7294 );
7295 }
7296
7297 #[gpui::test]
7298 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
7299 init_test(cx);
7300
7301 let fs = FakeFs::new(cx.executor().clone());
7302 fs.as_fake().insert_tree(path!("/root"), json!({})).await;
7303 let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
7304 let workspace =
7305 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7306 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7307 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7308
7309 // Make a new buffer with no backing file
7310 workspace
7311 .update(cx, |workspace, window, cx| {
7312 Editor::new_file(workspace, &Default::default(), window, cx)
7313 })
7314 .unwrap();
7315
7316 cx.executor().run_until_parked();
7317
7318 // "Save as" the buffer, creating a new backing file for it
7319 let save_task = workspace
7320 .update(cx, |workspace, window, cx| {
7321 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
7322 })
7323 .unwrap();
7324
7325 cx.executor().run_until_parked();
7326 cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new"))));
7327 save_task.await.unwrap();
7328
7329 // Rename the file
7330 select_path(&panel, "root/new", cx);
7331 assert_eq!(
7332 visible_entries_as_strings(&panel, 0..10, cx),
7333 &["v root", " new <== selected <== marked"]
7334 );
7335 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
7336 panel.update_in(cx, |panel, window, cx| {
7337 panel
7338 .filename_editor
7339 .update(cx, |editor, cx| editor.set_text("newer", window, cx));
7340 });
7341 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
7342
7343 cx.executor().run_until_parked();
7344 assert_eq!(
7345 visible_entries_as_strings(&panel, 0..10, cx),
7346 &["v root", " newer <== selected"]
7347 );
7348
7349 workspace
7350 .update(cx, |workspace, window, cx| {
7351 workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
7352 })
7353 .unwrap()
7354 .await
7355 .unwrap();
7356
7357 cx.executor().run_until_parked();
7358 // assert that saving the file doesn't restore "new"
7359 assert_eq!(
7360 visible_entries_as_strings(&panel, 0..10, cx),
7361 &["v root", " newer <== selected"]
7362 );
7363 }
7364
7365 #[gpui::test]
7366 #[cfg_attr(target_os = "windows", ignore)]
7367 async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
7368 init_test_with_editor(cx);
7369
7370 let fs = FakeFs::new(cx.executor().clone());
7371 fs.insert_tree(
7372 "/root1",
7373 json!({
7374 "dir1": {
7375 "file1.txt": "content 1",
7376 },
7377 }),
7378 )
7379 .await;
7380
7381 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
7382 let workspace =
7383 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7384 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7385 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7386
7387 toggle_expand_dir(&panel, "root1/dir1", cx);
7388
7389 assert_eq!(
7390 visible_entries_as_strings(&panel, 0..20, cx),
7391 &["v root1", " v dir1 <== selected", " file1.txt",],
7392 "Initial state with worktrees"
7393 );
7394
7395 select_path(&panel, "root1", cx);
7396 assert_eq!(
7397 visible_entries_as_strings(&panel, 0..20, cx),
7398 &["v root1 <== selected", " v dir1", " file1.txt",],
7399 );
7400
7401 // Rename root1 to new_root1
7402 panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
7403
7404 assert_eq!(
7405 visible_entries_as_strings(&panel, 0..20, cx),
7406 &[
7407 "v [EDITOR: 'root1'] <== selected",
7408 " v dir1",
7409 " file1.txt",
7410 ],
7411 );
7412
7413 let confirm = panel.update_in(cx, |panel, window, cx| {
7414 panel
7415 .filename_editor
7416 .update(cx, |editor, cx| editor.set_text("new_root1", window, cx));
7417 panel.confirm_edit(window, cx).unwrap()
7418 });
7419 confirm.await.unwrap();
7420 assert_eq!(
7421 visible_entries_as_strings(&panel, 0..20, cx),
7422 &[
7423 "v new_root1 <== selected",
7424 " v dir1",
7425 " file1.txt",
7426 ],
7427 "Should update worktree name"
7428 );
7429
7430 // Ensure internal paths have been updated
7431 select_path(&panel, "new_root1/dir1/file1.txt", cx);
7432 assert_eq!(
7433 visible_entries_as_strings(&panel, 0..20, cx),
7434 &[
7435 "v new_root1",
7436 " v dir1",
7437 " file1.txt <== selected",
7438 ],
7439 "Files in renamed worktree are selectable"
7440 );
7441 }
7442
7443 #[gpui::test]
7444 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
7445 init_test_with_editor(cx);
7446 let fs = FakeFs::new(cx.executor().clone());
7447 fs.insert_tree(
7448 "/project_root",
7449 json!({
7450 "dir_1": {
7451 "nested_dir": {
7452 "file_a.py": "# File contents",
7453 }
7454 },
7455 "file_1.py": "# File contents",
7456 }),
7457 )
7458 .await;
7459
7460 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7461 let worktree_id =
7462 cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
7463 let workspace =
7464 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7465 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7466 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7467 cx.update(|window, cx| {
7468 panel.update(cx, |this, cx| {
7469 this.select_next(&Default::default(), window, cx);
7470 this.expand_selected_entry(&Default::default(), window, cx);
7471 this.expand_selected_entry(&Default::default(), window, cx);
7472 this.select_next(&Default::default(), window, cx);
7473 this.expand_selected_entry(&Default::default(), window, cx);
7474 this.select_next(&Default::default(), window, cx);
7475 })
7476 });
7477 assert_eq!(
7478 visible_entries_as_strings(&panel, 0..10, cx),
7479 &[
7480 "v project_root",
7481 " v dir_1",
7482 " v nested_dir",
7483 " file_a.py <== selected",
7484 " file_1.py",
7485 ]
7486 );
7487 let modifiers_with_shift = gpui::Modifiers {
7488 shift: true,
7489 ..Default::default()
7490 };
7491 cx.simulate_modifiers_change(modifiers_with_shift);
7492 cx.update(|window, cx| {
7493 panel.update(cx, |this, cx| {
7494 this.select_next(&Default::default(), window, cx);
7495 })
7496 });
7497 assert_eq!(
7498 visible_entries_as_strings(&panel, 0..10, cx),
7499 &[
7500 "v project_root",
7501 " v dir_1",
7502 " v nested_dir",
7503 " file_a.py",
7504 " file_1.py <== selected <== marked",
7505 ]
7506 );
7507 cx.update(|window, cx| {
7508 panel.update(cx, |this, cx| {
7509 this.select_previous(&Default::default(), window, cx);
7510 })
7511 });
7512 assert_eq!(
7513 visible_entries_as_strings(&panel, 0..10, cx),
7514 &[
7515 "v project_root",
7516 " v dir_1",
7517 " v nested_dir",
7518 " file_a.py <== selected <== marked",
7519 " file_1.py <== marked",
7520 ]
7521 );
7522 cx.update(|window, cx| {
7523 panel.update(cx, |this, cx| {
7524 let drag = DraggedSelection {
7525 active_selection: this.selection.unwrap(),
7526 marked_selections: Arc::new(this.marked_entries.clone()),
7527 };
7528 let target_entry = this
7529 .project
7530 .read(cx)
7531 .entry_for_path(&(worktree_id, "").into(), cx)
7532 .unwrap();
7533 this.drag_onto(&drag, target_entry.id, false, window, cx);
7534 });
7535 });
7536 cx.run_until_parked();
7537 assert_eq!(
7538 visible_entries_as_strings(&panel, 0..10, cx),
7539 &[
7540 "v project_root",
7541 " v dir_1",
7542 " v nested_dir",
7543 " file_1.py <== marked",
7544 " file_a.py <== selected <== marked",
7545 ]
7546 );
7547 // ESC clears out all marks
7548 cx.update(|window, cx| {
7549 panel.update(cx, |this, cx| {
7550 this.cancel(&menu::Cancel, window, cx);
7551 })
7552 });
7553 assert_eq!(
7554 visible_entries_as_strings(&panel, 0..10, cx),
7555 &[
7556 "v project_root",
7557 " v dir_1",
7558 " v nested_dir",
7559 " file_1.py",
7560 " file_a.py <== selected",
7561 ]
7562 );
7563 // ESC clears out all marks
7564 cx.update(|window, cx| {
7565 panel.update(cx, |this, cx| {
7566 this.select_previous(&SelectPrevious, window, cx);
7567 this.select_next(&SelectNext, window, cx);
7568 })
7569 });
7570 assert_eq!(
7571 visible_entries_as_strings(&panel, 0..10, cx),
7572 &[
7573 "v project_root",
7574 " v dir_1",
7575 " v nested_dir",
7576 " file_1.py <== marked",
7577 " file_a.py <== selected <== marked",
7578 ]
7579 );
7580 cx.simulate_modifiers_change(Default::default());
7581 cx.update(|window, cx| {
7582 panel.update(cx, |this, cx| {
7583 this.cut(&Cut, window, cx);
7584 this.select_previous(&SelectPrevious, window, cx);
7585 this.select_previous(&SelectPrevious, window, cx);
7586
7587 this.paste(&Paste, window, cx);
7588 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
7589 })
7590 });
7591 cx.run_until_parked();
7592 assert_eq!(
7593 visible_entries_as_strings(&panel, 0..10, cx),
7594 &[
7595 "v project_root",
7596 " v dir_1",
7597 " v nested_dir",
7598 " file_1.py <== marked",
7599 " file_a.py <== selected <== marked",
7600 ]
7601 );
7602 cx.simulate_modifiers_change(modifiers_with_shift);
7603 cx.update(|window, cx| {
7604 panel.update(cx, |this, cx| {
7605 this.expand_selected_entry(&Default::default(), window, cx);
7606 this.select_next(&SelectNext, window, cx);
7607 this.select_next(&SelectNext, window, cx);
7608 })
7609 });
7610 submit_deletion(&panel, cx);
7611 assert_eq!(
7612 visible_entries_as_strings(&panel, 0..10, cx),
7613 &[
7614 "v project_root",
7615 " v dir_1",
7616 " v nested_dir <== selected",
7617 ]
7618 );
7619 }
7620 #[gpui::test]
7621 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
7622 init_test_with_editor(cx);
7623 cx.update(|cx| {
7624 cx.update_global::<SettingsStore, _>(|store, cx| {
7625 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7626 worktree_settings.file_scan_exclusions = Some(Vec::new());
7627 });
7628 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7629 project_panel_settings.auto_reveal_entries = Some(false)
7630 });
7631 })
7632 });
7633
7634 let fs = FakeFs::new(cx.background_executor.clone());
7635 fs.insert_tree(
7636 "/project_root",
7637 json!({
7638 ".git": {},
7639 ".gitignore": "**/gitignored_dir",
7640 "dir_1": {
7641 "file_1.py": "# File 1_1 contents",
7642 "file_2.py": "# File 1_2 contents",
7643 "file_3.py": "# File 1_3 contents",
7644 "gitignored_dir": {
7645 "file_a.py": "# File contents",
7646 "file_b.py": "# File contents",
7647 "file_c.py": "# File contents",
7648 },
7649 },
7650 "dir_2": {
7651 "file_1.py": "# File 2_1 contents",
7652 "file_2.py": "# File 2_2 contents",
7653 "file_3.py": "# File 2_3 contents",
7654 }
7655 }),
7656 )
7657 .await;
7658
7659 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7660 let workspace =
7661 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7662 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7663 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7664
7665 assert_eq!(
7666 visible_entries_as_strings(&panel, 0..20, cx),
7667 &[
7668 "v project_root",
7669 " > .git",
7670 " > dir_1",
7671 " > dir_2",
7672 " .gitignore",
7673 ]
7674 );
7675
7676 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
7677 .expect("dir 1 file is not ignored and should have an entry");
7678 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
7679 .expect("dir 2 file is not ignored and should have an entry");
7680 let gitignored_dir_file =
7681 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
7682 assert_eq!(
7683 gitignored_dir_file, None,
7684 "File in the gitignored dir should not have an entry before its dir is toggled"
7685 );
7686
7687 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7688 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7689 cx.executor().run_until_parked();
7690 assert_eq!(
7691 visible_entries_as_strings(&panel, 0..20, cx),
7692 &[
7693 "v project_root",
7694 " > .git",
7695 " v dir_1",
7696 " v gitignored_dir <== selected",
7697 " file_a.py",
7698 " file_b.py",
7699 " file_c.py",
7700 " file_1.py",
7701 " file_2.py",
7702 " file_3.py",
7703 " > dir_2",
7704 " .gitignore",
7705 ],
7706 "Should show gitignored dir file list in the project panel"
7707 );
7708 let gitignored_dir_file =
7709 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
7710 .expect("after gitignored dir got opened, a file entry should be present");
7711
7712 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7713 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7714 assert_eq!(
7715 visible_entries_as_strings(&panel, 0..20, cx),
7716 &[
7717 "v project_root",
7718 " > .git",
7719 " > dir_1 <== selected",
7720 " > dir_2",
7721 " .gitignore",
7722 ],
7723 "Should hide all dir contents again and prepare for the auto reveal test"
7724 );
7725
7726 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
7727 panel.update(cx, |panel, cx| {
7728 panel.project.update(cx, |_, cx| {
7729 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
7730 })
7731 });
7732 cx.run_until_parked();
7733 assert_eq!(
7734 visible_entries_as_strings(&panel, 0..20, cx),
7735 &[
7736 "v project_root",
7737 " > .git",
7738 " > dir_1 <== selected",
7739 " > dir_2",
7740 " .gitignore",
7741 ],
7742 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
7743 );
7744 }
7745
7746 cx.update(|_, cx| {
7747 cx.update_global::<SettingsStore, _>(|store, cx| {
7748 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7749 project_panel_settings.auto_reveal_entries = Some(true)
7750 });
7751 })
7752 });
7753
7754 panel.update(cx, |panel, cx| {
7755 panel.project.update(cx, |_, cx| {
7756 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
7757 })
7758 });
7759 cx.run_until_parked();
7760 assert_eq!(
7761 visible_entries_as_strings(&panel, 0..20, cx),
7762 &[
7763 "v project_root",
7764 " > .git",
7765 " v dir_1",
7766 " > gitignored_dir",
7767 " file_1.py <== selected <== marked",
7768 " file_2.py",
7769 " file_3.py",
7770 " > dir_2",
7771 " .gitignore",
7772 ],
7773 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
7774 );
7775
7776 panel.update(cx, |panel, cx| {
7777 panel.project.update(cx, |_, cx| {
7778 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
7779 })
7780 });
7781 cx.run_until_parked();
7782 assert_eq!(
7783 visible_entries_as_strings(&panel, 0..20, cx),
7784 &[
7785 "v project_root",
7786 " > .git",
7787 " v dir_1",
7788 " > gitignored_dir",
7789 " file_1.py",
7790 " file_2.py",
7791 " file_3.py",
7792 " v dir_2",
7793 " file_1.py <== selected <== marked",
7794 " file_2.py",
7795 " file_3.py",
7796 " .gitignore",
7797 ],
7798 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
7799 );
7800
7801 panel.update(cx, |panel, cx| {
7802 panel.project.update(cx, |_, cx| {
7803 cx.emit(project::Event::ActiveEntryChanged(Some(
7804 gitignored_dir_file,
7805 )))
7806 })
7807 });
7808 cx.run_until_parked();
7809 assert_eq!(
7810 visible_entries_as_strings(&panel, 0..20, cx),
7811 &[
7812 "v project_root",
7813 " > .git",
7814 " v dir_1",
7815 " > gitignored_dir",
7816 " file_1.py",
7817 " file_2.py",
7818 " file_3.py",
7819 " v dir_2",
7820 " file_1.py <== selected <== marked",
7821 " file_2.py",
7822 " file_3.py",
7823 " .gitignore",
7824 ],
7825 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
7826 );
7827
7828 panel.update(cx, |panel, cx| {
7829 panel.project.update(cx, |_, cx| {
7830 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
7831 })
7832 });
7833 cx.run_until_parked();
7834 assert_eq!(
7835 visible_entries_as_strings(&panel, 0..20, cx),
7836 &[
7837 "v project_root",
7838 " > .git",
7839 " v dir_1",
7840 " v gitignored_dir",
7841 " file_a.py <== selected <== marked",
7842 " file_b.py",
7843 " file_c.py",
7844 " file_1.py",
7845 " file_2.py",
7846 " file_3.py",
7847 " v dir_2",
7848 " file_1.py",
7849 " file_2.py",
7850 " file_3.py",
7851 " .gitignore",
7852 ],
7853 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
7854 );
7855 }
7856
7857 #[gpui::test]
7858 async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
7859 init_test_with_editor(cx);
7860 cx.update(|cx| {
7861 cx.update_global::<SettingsStore, _>(|store, cx| {
7862 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7863 worktree_settings.file_scan_exclusions = Some(Vec::new());
7864 worktree_settings.file_scan_inclusions =
7865 Some(vec!["always_included_but_ignored_dir/*".to_string()]);
7866 });
7867 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7868 project_panel_settings.auto_reveal_entries = Some(false)
7869 });
7870 })
7871 });
7872
7873 let fs = FakeFs::new(cx.background_executor.clone());
7874 fs.insert_tree(
7875 "/project_root",
7876 json!({
7877 ".git": {},
7878 ".gitignore": "**/gitignored_dir\n/always_included_but_ignored_dir",
7879 "dir_1": {
7880 "file_1.py": "# File 1_1 contents",
7881 "file_2.py": "# File 1_2 contents",
7882 "file_3.py": "# File 1_3 contents",
7883 "gitignored_dir": {
7884 "file_a.py": "# File contents",
7885 "file_b.py": "# File contents",
7886 "file_c.py": "# File contents",
7887 },
7888 },
7889 "dir_2": {
7890 "file_1.py": "# File 2_1 contents",
7891 "file_2.py": "# File 2_2 contents",
7892 "file_3.py": "# File 2_3 contents",
7893 },
7894 "always_included_but_ignored_dir": {
7895 "file_a.py": "# File contents",
7896 "file_b.py": "# File contents",
7897 "file_c.py": "# File contents",
7898 },
7899 }),
7900 )
7901 .await;
7902
7903 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7904 let workspace =
7905 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7906 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7907 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7908
7909 assert_eq!(
7910 visible_entries_as_strings(&panel, 0..20, cx),
7911 &[
7912 "v project_root",
7913 " > .git",
7914 " > always_included_but_ignored_dir",
7915 " > dir_1",
7916 " > dir_2",
7917 " .gitignore",
7918 ]
7919 );
7920
7921 let gitignored_dir_file =
7922 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
7923 let always_included_but_ignored_dir_file = find_project_entry(
7924 &panel,
7925 "project_root/always_included_but_ignored_dir/file_a.py",
7926 cx,
7927 )
7928 .expect("file that is .gitignored but set to always be included should have an entry");
7929 assert_eq!(
7930 gitignored_dir_file, None,
7931 "File in the gitignored dir should not have an entry unless its directory is toggled"
7932 );
7933
7934 toggle_expand_dir(&panel, "project_root/dir_1", cx);
7935 cx.run_until_parked();
7936 cx.update(|_, cx| {
7937 cx.update_global::<SettingsStore, _>(|store, cx| {
7938 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7939 project_panel_settings.auto_reveal_entries = Some(true)
7940 });
7941 })
7942 });
7943
7944 panel.update(cx, |panel, cx| {
7945 panel.project.update(cx, |_, cx| {
7946 cx.emit(project::Event::ActiveEntryChanged(Some(
7947 always_included_but_ignored_dir_file,
7948 )))
7949 })
7950 });
7951 cx.run_until_parked();
7952
7953 assert_eq!(
7954 visible_entries_as_strings(&panel, 0..20, cx),
7955 &[
7956 "v project_root",
7957 " > .git",
7958 " v always_included_but_ignored_dir",
7959 " file_a.py <== selected <== marked",
7960 " file_b.py",
7961 " file_c.py",
7962 " v dir_1",
7963 " > gitignored_dir",
7964 " file_1.py",
7965 " file_2.py",
7966 " file_3.py",
7967 " > dir_2",
7968 " .gitignore",
7969 ],
7970 "When auto reveal is enabled, a gitignored but always included selected entry should be revealed in the project panel"
7971 );
7972 }
7973
7974 #[gpui::test]
7975 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
7976 init_test_with_editor(cx);
7977 cx.update(|cx| {
7978 cx.update_global::<SettingsStore, _>(|store, cx| {
7979 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7980 worktree_settings.file_scan_exclusions = Some(Vec::new());
7981 });
7982 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7983 project_panel_settings.auto_reveal_entries = Some(false)
7984 });
7985 })
7986 });
7987
7988 let fs = FakeFs::new(cx.background_executor.clone());
7989 fs.insert_tree(
7990 "/project_root",
7991 json!({
7992 ".git": {},
7993 ".gitignore": "**/gitignored_dir",
7994 "dir_1": {
7995 "file_1.py": "# File 1_1 contents",
7996 "file_2.py": "# File 1_2 contents",
7997 "file_3.py": "# File 1_3 contents",
7998 "gitignored_dir": {
7999 "file_a.py": "# File contents",
8000 "file_b.py": "# File contents",
8001 "file_c.py": "# File contents",
8002 },
8003 },
8004 "dir_2": {
8005 "file_1.py": "# File 2_1 contents",
8006 "file_2.py": "# File 2_2 contents",
8007 "file_3.py": "# File 2_3 contents",
8008 }
8009 }),
8010 )
8011 .await;
8012
8013 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
8014 let workspace =
8015 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8016 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8017 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8018
8019 assert_eq!(
8020 visible_entries_as_strings(&panel, 0..20, cx),
8021 &[
8022 "v project_root",
8023 " > .git",
8024 " > dir_1",
8025 " > dir_2",
8026 " .gitignore",
8027 ]
8028 );
8029
8030 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
8031 .expect("dir 1 file is not ignored and should have an entry");
8032 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
8033 .expect("dir 2 file is not ignored and should have an entry");
8034 let gitignored_dir_file =
8035 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
8036 assert_eq!(
8037 gitignored_dir_file, None,
8038 "File in the gitignored dir should not have an entry before its dir is toggled"
8039 );
8040
8041 toggle_expand_dir(&panel, "project_root/dir_1", cx);
8042 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
8043 cx.run_until_parked();
8044 assert_eq!(
8045 visible_entries_as_strings(&panel, 0..20, cx),
8046 &[
8047 "v project_root",
8048 " > .git",
8049 " v dir_1",
8050 " v gitignored_dir <== selected",
8051 " file_a.py",
8052 " file_b.py",
8053 " file_c.py",
8054 " file_1.py",
8055 " file_2.py",
8056 " file_3.py",
8057 " > dir_2",
8058 " .gitignore",
8059 ],
8060 "Should show gitignored dir file list in the project panel"
8061 );
8062 let gitignored_dir_file =
8063 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
8064 .expect("after gitignored dir got opened, a file entry should be present");
8065
8066 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
8067 toggle_expand_dir(&panel, "project_root/dir_1", cx);
8068 assert_eq!(
8069 visible_entries_as_strings(&panel, 0..20, cx),
8070 &[
8071 "v project_root",
8072 " > .git",
8073 " > dir_1 <== selected",
8074 " > dir_2",
8075 " .gitignore",
8076 ],
8077 "Should hide all dir contents again and prepare for the explicit reveal test"
8078 );
8079
8080 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
8081 panel.update(cx, |panel, cx| {
8082 panel.project.update(cx, |_, cx| {
8083 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
8084 })
8085 });
8086 cx.run_until_parked();
8087 assert_eq!(
8088 visible_entries_as_strings(&panel, 0..20, cx),
8089 &[
8090 "v project_root",
8091 " > .git",
8092 " > dir_1 <== selected",
8093 " > dir_2",
8094 " .gitignore",
8095 ],
8096 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
8097 );
8098 }
8099
8100 panel.update(cx, |panel, cx| {
8101 panel.project.update(cx, |_, cx| {
8102 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
8103 })
8104 });
8105 cx.run_until_parked();
8106 assert_eq!(
8107 visible_entries_as_strings(&panel, 0..20, cx),
8108 &[
8109 "v project_root",
8110 " > .git",
8111 " v dir_1",
8112 " > gitignored_dir",
8113 " file_1.py <== selected <== marked",
8114 " file_2.py",
8115 " file_3.py",
8116 " > dir_2",
8117 " .gitignore",
8118 ],
8119 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
8120 );
8121
8122 panel.update(cx, |panel, cx| {
8123 panel.project.update(cx, |_, cx| {
8124 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
8125 })
8126 });
8127 cx.run_until_parked();
8128 assert_eq!(
8129 visible_entries_as_strings(&panel, 0..20, cx),
8130 &[
8131 "v project_root",
8132 " > .git",
8133 " v dir_1",
8134 " > gitignored_dir",
8135 " file_1.py",
8136 " file_2.py",
8137 " file_3.py",
8138 " v dir_2",
8139 " file_1.py <== selected <== marked",
8140 " file_2.py",
8141 " file_3.py",
8142 " .gitignore",
8143 ],
8144 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
8145 );
8146
8147 panel.update(cx, |panel, cx| {
8148 panel.project.update(cx, |_, cx| {
8149 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
8150 })
8151 });
8152 cx.run_until_parked();
8153 assert_eq!(
8154 visible_entries_as_strings(&panel, 0..20, cx),
8155 &[
8156 "v project_root",
8157 " > .git",
8158 " v dir_1",
8159 " v gitignored_dir",
8160 " file_a.py <== selected <== marked",
8161 " file_b.py",
8162 " file_c.py",
8163 " file_1.py",
8164 " file_2.py",
8165 " file_3.py",
8166 " v dir_2",
8167 " file_1.py",
8168 " file_2.py",
8169 " file_3.py",
8170 " .gitignore",
8171 ],
8172 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
8173 );
8174 }
8175
8176 #[gpui::test]
8177 async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
8178 init_test(cx);
8179 cx.update(|cx| {
8180 cx.update_global::<SettingsStore, _>(|store, cx| {
8181 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
8182 project_settings.file_scan_exclusions =
8183 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
8184 });
8185 });
8186 });
8187
8188 cx.update(|cx| {
8189 register_project_item::<TestProjectItemView>(cx);
8190 });
8191
8192 let fs = FakeFs::new(cx.executor().clone());
8193 fs.insert_tree(
8194 "/root1",
8195 json!({
8196 ".dockerignore": "",
8197 ".git": {
8198 "HEAD": "",
8199 },
8200 }),
8201 )
8202 .await;
8203
8204 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
8205 let workspace =
8206 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8207 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8208 let panel = workspace
8209 .update(cx, |workspace, window, cx| {
8210 let panel = ProjectPanel::new(workspace, window, cx);
8211 workspace.add_panel(panel.clone(), window, cx);
8212 panel
8213 })
8214 .unwrap();
8215
8216 select_path(&panel, "root1", cx);
8217 assert_eq!(
8218 visible_entries_as_strings(&panel, 0..10, cx),
8219 &["v root1 <== selected", " .dockerignore",]
8220 );
8221 workspace
8222 .update(cx, |workspace, _, cx| {
8223 assert!(
8224 workspace.active_item(cx).is_none(),
8225 "Should have no active items in the beginning"
8226 );
8227 })
8228 .unwrap();
8229
8230 let excluded_file_path = ".git/COMMIT_EDITMSG";
8231 let excluded_dir_path = "excluded_dir";
8232
8233 panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
8234 panel.update_in(cx, |panel, window, cx| {
8235 assert!(panel.filename_editor.read(cx).is_focused(window));
8236 });
8237 panel
8238 .update_in(cx, |panel, window, cx| {
8239 panel.filename_editor.update(cx, |editor, cx| {
8240 editor.set_text(excluded_file_path, window, cx)
8241 });
8242 panel.confirm_edit(window, cx).unwrap()
8243 })
8244 .await
8245 .unwrap();
8246
8247 assert_eq!(
8248 visible_entries_as_strings(&panel, 0..13, cx),
8249 &["v root1", " .dockerignore"],
8250 "Excluded dir should not be shown after opening a file in it"
8251 );
8252 panel.update_in(cx, |panel, window, cx| {
8253 assert!(
8254 !panel.filename_editor.read(cx).is_focused(window),
8255 "Should have closed the file name editor"
8256 );
8257 });
8258 workspace
8259 .update(cx, |workspace, _, cx| {
8260 let active_entry_path = workspace
8261 .active_item(cx)
8262 .expect("should have opened and activated the excluded item")
8263 .act_as::<TestProjectItemView>(cx)
8264 .expect(
8265 "should have opened the corresponding project item for the excluded item",
8266 )
8267 .read(cx)
8268 .path
8269 .clone();
8270 assert_eq!(
8271 active_entry_path.path.as_ref(),
8272 Path::new(excluded_file_path),
8273 "Should open the excluded file"
8274 );
8275
8276 assert!(
8277 workspace.notification_ids().is_empty(),
8278 "Should have no notifications after opening an excluded file"
8279 );
8280 })
8281 .unwrap();
8282 assert!(
8283 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
8284 "Should have created the excluded file"
8285 );
8286
8287 select_path(&panel, "root1", cx);
8288 panel.update_in(cx, |panel, window, cx| {
8289 panel.new_directory(&NewDirectory, window, cx)
8290 });
8291 panel.update_in(cx, |panel, window, cx| {
8292 assert!(panel.filename_editor.read(cx).is_focused(window));
8293 });
8294 panel
8295 .update_in(cx, |panel, window, cx| {
8296 panel.filename_editor.update(cx, |editor, cx| {
8297 editor.set_text(excluded_file_path, window, cx)
8298 });
8299 panel.confirm_edit(window, cx).unwrap()
8300 })
8301 .await
8302 .unwrap();
8303
8304 assert_eq!(
8305 visible_entries_as_strings(&panel, 0..13, cx),
8306 &["v root1", " .dockerignore"],
8307 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
8308 );
8309 panel.update_in(cx, |panel, window, cx| {
8310 assert!(
8311 !panel.filename_editor.read(cx).is_focused(window),
8312 "Should have closed the file name editor"
8313 );
8314 });
8315 workspace
8316 .update(cx, |workspace, _, cx| {
8317 let notifications = workspace.notification_ids();
8318 assert_eq!(
8319 notifications.len(),
8320 1,
8321 "Should receive one notification with the error message"
8322 );
8323 workspace.dismiss_notification(notifications.first().unwrap(), cx);
8324 assert!(workspace.notification_ids().is_empty());
8325 })
8326 .unwrap();
8327
8328 select_path(&panel, "root1", cx);
8329 panel.update_in(cx, |panel, window, cx| {
8330 panel.new_directory(&NewDirectory, window, cx)
8331 });
8332 panel.update_in(cx, |panel, window, cx| {
8333 assert!(panel.filename_editor.read(cx).is_focused(window));
8334 });
8335 panel
8336 .update_in(cx, |panel, window, cx| {
8337 panel.filename_editor.update(cx, |editor, cx| {
8338 editor.set_text(excluded_dir_path, window, cx)
8339 });
8340 panel.confirm_edit(window, cx).unwrap()
8341 })
8342 .await
8343 .unwrap();
8344
8345 assert_eq!(
8346 visible_entries_as_strings(&panel, 0..13, cx),
8347 &["v root1", " .dockerignore"],
8348 "Should not change the project panel after trying to create an excluded directory"
8349 );
8350 panel.update_in(cx, |panel, window, cx| {
8351 assert!(
8352 !panel.filename_editor.read(cx).is_focused(window),
8353 "Should have closed the file name editor"
8354 );
8355 });
8356 workspace
8357 .update(cx, |workspace, _, cx| {
8358 let notifications = workspace.notification_ids();
8359 assert_eq!(
8360 notifications.len(),
8361 1,
8362 "Should receive one notification explaining that no directory is actually shown"
8363 );
8364 workspace.dismiss_notification(notifications.first().unwrap(), cx);
8365 assert!(workspace.notification_ids().is_empty());
8366 })
8367 .unwrap();
8368 assert!(
8369 fs.is_dir(Path::new("/root1/excluded_dir")).await,
8370 "Should have created the excluded directory"
8371 );
8372 }
8373
8374 #[gpui::test]
8375 async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
8376 init_test_with_editor(cx);
8377
8378 let fs = FakeFs::new(cx.executor().clone());
8379 fs.insert_tree(
8380 "/src",
8381 json!({
8382 "test": {
8383 "first.rs": "// First Rust file",
8384 "second.rs": "// Second Rust file",
8385 "third.rs": "// Third Rust file",
8386 }
8387 }),
8388 )
8389 .await;
8390
8391 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
8392 let workspace =
8393 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8394 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8395 let panel = workspace
8396 .update(cx, |workspace, window, cx| {
8397 let panel = ProjectPanel::new(workspace, window, cx);
8398 workspace.add_panel(panel.clone(), window, cx);
8399 panel
8400 })
8401 .unwrap();
8402
8403 select_path(&panel, "src/", cx);
8404 panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
8405 cx.executor().run_until_parked();
8406 assert_eq!(
8407 visible_entries_as_strings(&panel, 0..10, cx),
8408 &[
8409 //
8410 "v src <== selected",
8411 " > test"
8412 ]
8413 );
8414 panel.update_in(cx, |panel, window, cx| {
8415 panel.new_directory(&NewDirectory, window, cx)
8416 });
8417 panel.update_in(cx, |panel, window, cx| {
8418 assert!(panel.filename_editor.read(cx).is_focused(window));
8419 });
8420 assert_eq!(
8421 visible_entries_as_strings(&panel, 0..10, cx),
8422 &[
8423 //
8424 "v src",
8425 " > [EDITOR: ''] <== selected",
8426 " > test"
8427 ]
8428 );
8429
8430 panel.update_in(cx, |panel, window, cx| {
8431 panel.cancel(&menu::Cancel, window, cx)
8432 });
8433 assert_eq!(
8434 visible_entries_as_strings(&panel, 0..10, cx),
8435 &[
8436 //
8437 "v src <== selected",
8438 " > test"
8439 ]
8440 );
8441 }
8442
8443 #[gpui::test]
8444 async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
8445 init_test_with_editor(cx);
8446
8447 let fs = FakeFs::new(cx.executor().clone());
8448 fs.insert_tree(
8449 "/root",
8450 json!({
8451 "dir1": {
8452 "subdir1": {},
8453 "file1.txt": "",
8454 "file2.txt": "",
8455 },
8456 "dir2": {
8457 "subdir2": {},
8458 "file3.txt": "",
8459 "file4.txt": "",
8460 },
8461 "file5.txt": "",
8462 "file6.txt": "",
8463 }),
8464 )
8465 .await;
8466
8467 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8468 let workspace =
8469 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8470 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8471 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8472
8473 toggle_expand_dir(&panel, "root/dir1", cx);
8474 toggle_expand_dir(&panel, "root/dir2", cx);
8475
8476 // Test Case 1: Delete middle file in directory
8477 select_path(&panel, "root/dir1/file1.txt", cx);
8478 assert_eq!(
8479 visible_entries_as_strings(&panel, 0..15, cx),
8480 &[
8481 "v root",
8482 " v dir1",
8483 " > subdir1",
8484 " file1.txt <== selected",
8485 " file2.txt",
8486 " v dir2",
8487 " > subdir2",
8488 " file3.txt",
8489 " file4.txt",
8490 " file5.txt",
8491 " file6.txt",
8492 ],
8493 "Initial state before deleting middle file"
8494 );
8495
8496 submit_deletion(&panel, cx);
8497 assert_eq!(
8498 visible_entries_as_strings(&panel, 0..15, cx),
8499 &[
8500 "v root",
8501 " v dir1",
8502 " > subdir1",
8503 " file2.txt <== selected",
8504 " v dir2",
8505 " > subdir2",
8506 " file3.txt",
8507 " file4.txt",
8508 " file5.txt",
8509 " file6.txt",
8510 ],
8511 "Should select next file after deleting middle file"
8512 );
8513
8514 // Test Case 2: Delete last file in directory
8515 submit_deletion(&panel, cx);
8516 assert_eq!(
8517 visible_entries_as_strings(&panel, 0..15, cx),
8518 &[
8519 "v root",
8520 " v dir1",
8521 " > subdir1 <== selected",
8522 " v dir2",
8523 " > subdir2",
8524 " file3.txt",
8525 " file4.txt",
8526 " file5.txt",
8527 " file6.txt",
8528 ],
8529 "Should select next directory when last file is deleted"
8530 );
8531
8532 // Test Case 3: Delete root level file
8533 select_path(&panel, "root/file6.txt", cx);
8534 assert_eq!(
8535 visible_entries_as_strings(&panel, 0..15, cx),
8536 &[
8537 "v root",
8538 " v dir1",
8539 " > subdir1",
8540 " v dir2",
8541 " > subdir2",
8542 " file3.txt",
8543 " file4.txt",
8544 " file5.txt",
8545 " file6.txt <== selected",
8546 ],
8547 "Initial state before deleting root level file"
8548 );
8549
8550 submit_deletion(&panel, cx);
8551 assert_eq!(
8552 visible_entries_as_strings(&panel, 0..15, cx),
8553 &[
8554 "v root",
8555 " v dir1",
8556 " > subdir1",
8557 " v dir2",
8558 " > subdir2",
8559 " file3.txt",
8560 " file4.txt",
8561 " file5.txt <== selected",
8562 ],
8563 "Should select prev entry at root level"
8564 );
8565 }
8566
8567 #[gpui::test]
8568 async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
8569 init_test_with_editor(cx);
8570
8571 let fs = FakeFs::new(cx.executor().clone());
8572 fs.insert_tree(
8573 "/root",
8574 json!({
8575 "dir1": {
8576 "subdir1": {
8577 "a.txt": "",
8578 "b.txt": ""
8579 },
8580 "file1.txt": "",
8581 },
8582 "dir2": {
8583 "subdir2": {
8584 "c.txt": "",
8585 "d.txt": ""
8586 },
8587 "file2.txt": "",
8588 },
8589 "file3.txt": "",
8590 }),
8591 )
8592 .await;
8593
8594 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8595 let workspace =
8596 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8597 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8598 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8599
8600 toggle_expand_dir(&panel, "root/dir1", cx);
8601 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8602 toggle_expand_dir(&panel, "root/dir2", cx);
8603 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
8604
8605 // Test Case 1: Select and delete nested directory with parent
8606 cx.simulate_modifiers_change(gpui::Modifiers {
8607 control: true,
8608 ..Default::default()
8609 });
8610 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
8611 select_path_with_mark(&panel, "root/dir1", cx);
8612
8613 assert_eq!(
8614 visible_entries_as_strings(&panel, 0..15, cx),
8615 &[
8616 "v root",
8617 " v dir1 <== selected <== marked",
8618 " v subdir1 <== marked",
8619 " a.txt",
8620 " b.txt",
8621 " file1.txt",
8622 " v dir2",
8623 " v subdir2",
8624 " c.txt",
8625 " d.txt",
8626 " file2.txt",
8627 " file3.txt",
8628 ],
8629 "Initial state before deleting nested directory with parent"
8630 );
8631
8632 submit_deletion(&panel, cx);
8633 assert_eq!(
8634 visible_entries_as_strings(&panel, 0..15, cx),
8635 &[
8636 "v root",
8637 " v dir2 <== selected",
8638 " v subdir2",
8639 " c.txt",
8640 " d.txt",
8641 " file2.txt",
8642 " file3.txt",
8643 ],
8644 "Should select next directory after deleting directory with parent"
8645 );
8646
8647 // Test Case 2: Select mixed files and directories across levels
8648 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
8649 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
8650 select_path_with_mark(&panel, "root/file3.txt", cx);
8651
8652 assert_eq!(
8653 visible_entries_as_strings(&panel, 0..15, cx),
8654 &[
8655 "v root",
8656 " v dir2",
8657 " v subdir2",
8658 " c.txt <== marked",
8659 " d.txt",
8660 " file2.txt <== marked",
8661 " file3.txt <== selected <== marked",
8662 ],
8663 "Initial state before deleting"
8664 );
8665
8666 submit_deletion(&panel, cx);
8667 assert_eq!(
8668 visible_entries_as_strings(&panel, 0..15, cx),
8669 &[
8670 "v root",
8671 " v dir2 <== selected",
8672 " v subdir2",
8673 " d.txt",
8674 ],
8675 "Should select sibling directory"
8676 );
8677 }
8678
8679 #[gpui::test]
8680 async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
8681 init_test_with_editor(cx);
8682
8683 let fs = FakeFs::new(cx.executor().clone());
8684 fs.insert_tree(
8685 "/root",
8686 json!({
8687 "dir1": {
8688 "subdir1": {
8689 "a.txt": "",
8690 "b.txt": ""
8691 },
8692 "file1.txt": "",
8693 },
8694 "dir2": {
8695 "subdir2": {
8696 "c.txt": "",
8697 "d.txt": ""
8698 },
8699 "file2.txt": "",
8700 },
8701 "file3.txt": "",
8702 "file4.txt": "",
8703 }),
8704 )
8705 .await;
8706
8707 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8708 let workspace =
8709 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8710 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8711 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8712
8713 toggle_expand_dir(&panel, "root/dir1", cx);
8714 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8715 toggle_expand_dir(&panel, "root/dir2", cx);
8716 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
8717
8718 // Test Case 1: Select all root files and directories
8719 cx.simulate_modifiers_change(gpui::Modifiers {
8720 control: true,
8721 ..Default::default()
8722 });
8723 select_path_with_mark(&panel, "root/dir1", cx);
8724 select_path_with_mark(&panel, "root/dir2", cx);
8725 select_path_with_mark(&panel, "root/file3.txt", cx);
8726 select_path_with_mark(&panel, "root/file4.txt", cx);
8727 assert_eq!(
8728 visible_entries_as_strings(&panel, 0..20, cx),
8729 &[
8730 "v root",
8731 " v dir1 <== marked",
8732 " v subdir1",
8733 " a.txt",
8734 " b.txt",
8735 " file1.txt",
8736 " v dir2 <== marked",
8737 " v subdir2",
8738 " c.txt",
8739 " d.txt",
8740 " file2.txt",
8741 " file3.txt <== marked",
8742 " file4.txt <== selected <== marked",
8743 ],
8744 "State before deleting all contents"
8745 );
8746
8747 submit_deletion(&panel, cx);
8748 assert_eq!(
8749 visible_entries_as_strings(&panel, 0..20, cx),
8750 &["v root <== selected"],
8751 "Only empty root directory should remain after deleting all contents"
8752 );
8753 }
8754
8755 #[gpui::test]
8756 async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
8757 init_test_with_editor(cx);
8758
8759 let fs = FakeFs::new(cx.executor().clone());
8760 fs.insert_tree(
8761 "/root",
8762 json!({
8763 "dir1": {
8764 "subdir1": {
8765 "file_a.txt": "content a",
8766 "file_b.txt": "content b",
8767 },
8768 "subdir2": {
8769 "file_c.txt": "content c",
8770 },
8771 "file1.txt": "content 1",
8772 },
8773 "dir2": {
8774 "file2.txt": "content 2",
8775 },
8776 }),
8777 )
8778 .await;
8779
8780 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8781 let workspace =
8782 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8783 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8784 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8785
8786 toggle_expand_dir(&panel, "root/dir1", cx);
8787 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8788 toggle_expand_dir(&panel, "root/dir2", cx);
8789 cx.simulate_modifiers_change(gpui::Modifiers {
8790 control: true,
8791 ..Default::default()
8792 });
8793
8794 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
8795 select_path_with_mark(&panel, "root/dir1", cx);
8796 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
8797 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
8798
8799 assert_eq!(
8800 visible_entries_as_strings(&panel, 0..20, cx),
8801 &[
8802 "v root",
8803 " v dir1 <== marked",
8804 " v subdir1 <== marked",
8805 " file_a.txt <== selected <== marked",
8806 " file_b.txt",
8807 " > subdir2",
8808 " file1.txt",
8809 " v dir2",
8810 " file2.txt",
8811 ],
8812 "State with parent dir, subdir, and file selected"
8813 );
8814 submit_deletion(&panel, cx);
8815 assert_eq!(
8816 visible_entries_as_strings(&panel, 0..20, cx),
8817 &["v root", " v dir2 <== selected", " file2.txt",],
8818 "Only dir2 should remain after deletion"
8819 );
8820 }
8821
8822 #[gpui::test]
8823 async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
8824 init_test_with_editor(cx);
8825
8826 let fs = FakeFs::new(cx.executor().clone());
8827 // First worktree
8828 fs.insert_tree(
8829 "/root1",
8830 json!({
8831 "dir1": {
8832 "file1.txt": "content 1",
8833 "file2.txt": "content 2",
8834 },
8835 "dir2": {
8836 "file3.txt": "content 3",
8837 },
8838 }),
8839 )
8840 .await;
8841
8842 // Second worktree
8843 fs.insert_tree(
8844 "/root2",
8845 json!({
8846 "dir3": {
8847 "file4.txt": "content 4",
8848 "file5.txt": "content 5",
8849 },
8850 "file6.txt": "content 6",
8851 }),
8852 )
8853 .await;
8854
8855 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8856 let workspace =
8857 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8858 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8859 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8860
8861 // Expand all directories for testing
8862 toggle_expand_dir(&panel, "root1/dir1", cx);
8863 toggle_expand_dir(&panel, "root1/dir2", cx);
8864 toggle_expand_dir(&panel, "root2/dir3", cx);
8865
8866 // Test Case 1: Delete files across different worktrees
8867 cx.simulate_modifiers_change(gpui::Modifiers {
8868 control: true,
8869 ..Default::default()
8870 });
8871 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
8872 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
8873
8874 assert_eq!(
8875 visible_entries_as_strings(&panel, 0..20, cx),
8876 &[
8877 "v root1",
8878 " v dir1",
8879 " file1.txt <== marked",
8880 " file2.txt",
8881 " v dir2",
8882 " file3.txt",
8883 "v root2",
8884 " v dir3",
8885 " file4.txt <== selected <== marked",
8886 " file5.txt",
8887 " file6.txt",
8888 ],
8889 "Initial state with files selected from different worktrees"
8890 );
8891
8892 submit_deletion(&panel, cx);
8893 assert_eq!(
8894 visible_entries_as_strings(&panel, 0..20, cx),
8895 &[
8896 "v root1",
8897 " v dir1",
8898 " file2.txt",
8899 " v dir2",
8900 " file3.txt",
8901 "v root2",
8902 " v dir3",
8903 " file5.txt <== selected",
8904 " file6.txt",
8905 ],
8906 "Should select next file in the last worktree after deletion"
8907 );
8908
8909 // Test Case 2: Delete directories from different worktrees
8910 select_path_with_mark(&panel, "root1/dir1", cx);
8911 select_path_with_mark(&panel, "root2/dir3", cx);
8912
8913 assert_eq!(
8914 visible_entries_as_strings(&panel, 0..20, cx),
8915 &[
8916 "v root1",
8917 " v dir1 <== marked",
8918 " file2.txt",
8919 " v dir2",
8920 " file3.txt",
8921 "v root2",
8922 " v dir3 <== selected <== marked",
8923 " file5.txt",
8924 " file6.txt",
8925 ],
8926 "State with directories marked from different worktrees"
8927 );
8928
8929 submit_deletion(&panel, cx);
8930 assert_eq!(
8931 visible_entries_as_strings(&panel, 0..20, cx),
8932 &[
8933 "v root1",
8934 " v dir2",
8935 " file3.txt",
8936 "v root2",
8937 " file6.txt <== selected",
8938 ],
8939 "Should select remaining file in last worktree after directory deletion"
8940 );
8941
8942 // Test Case 4: Delete all remaining files except roots
8943 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
8944 select_path_with_mark(&panel, "root2/file6.txt", cx);
8945
8946 assert_eq!(
8947 visible_entries_as_strings(&panel, 0..20, cx),
8948 &[
8949 "v root1",
8950 " v dir2",
8951 " file3.txt <== marked",
8952 "v root2",
8953 " file6.txt <== selected <== marked",
8954 ],
8955 "State with all remaining files marked"
8956 );
8957
8958 submit_deletion(&panel, cx);
8959 assert_eq!(
8960 visible_entries_as_strings(&panel, 0..20, cx),
8961 &["v root1", " v dir2", "v root2 <== selected"],
8962 "Second parent root should be selected after deleting"
8963 );
8964 }
8965
8966 #[gpui::test]
8967 async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
8968 init_test_with_editor(cx);
8969
8970 let fs = FakeFs::new(cx.executor().clone());
8971 fs.insert_tree(
8972 "/root",
8973 json!({
8974 "dir1": {
8975 "file1.txt": "",
8976 "file2.txt": "",
8977 "file3.txt": "",
8978 },
8979 "dir2": {
8980 "file4.txt": "",
8981 "file5.txt": "",
8982 },
8983 }),
8984 )
8985 .await;
8986
8987 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8988 let workspace =
8989 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8990 let cx = &mut VisualTestContext::from_window(*workspace, cx);
8991 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8992
8993 toggle_expand_dir(&panel, "root/dir1", cx);
8994 toggle_expand_dir(&panel, "root/dir2", cx);
8995
8996 cx.simulate_modifiers_change(gpui::Modifiers {
8997 control: true,
8998 ..Default::default()
8999 });
9000
9001 select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
9002 select_path(&panel, "root/dir1/file1.txt", cx);
9003
9004 assert_eq!(
9005 visible_entries_as_strings(&panel, 0..15, cx),
9006 &[
9007 "v root",
9008 " v dir1",
9009 " file1.txt <== selected",
9010 " file2.txt <== marked",
9011 " file3.txt",
9012 " v dir2",
9013 " file4.txt",
9014 " file5.txt",
9015 ],
9016 "Initial state with one marked entry and different selection"
9017 );
9018
9019 // Delete should operate on the selected entry (file1.txt)
9020 submit_deletion(&panel, cx);
9021 assert_eq!(
9022 visible_entries_as_strings(&panel, 0..15, cx),
9023 &[
9024 "v root",
9025 " v dir1",
9026 " file2.txt <== selected <== marked",
9027 " file3.txt",
9028 " v dir2",
9029 " file4.txt",
9030 " file5.txt",
9031 ],
9032 "Should delete selected file, not marked file"
9033 );
9034
9035 select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
9036 select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
9037 select_path(&panel, "root/dir2/file5.txt", cx);
9038
9039 assert_eq!(
9040 visible_entries_as_strings(&panel, 0..15, cx),
9041 &[
9042 "v root",
9043 " v dir1",
9044 " file2.txt <== marked",
9045 " file3.txt <== marked",
9046 " v dir2",
9047 " file4.txt <== marked",
9048 " file5.txt <== selected",
9049 ],
9050 "Initial state with multiple marked entries and different selection"
9051 );
9052
9053 // Delete should operate on all marked entries, ignoring the selection
9054 submit_deletion(&panel, cx);
9055 assert_eq!(
9056 visible_entries_as_strings(&panel, 0..15, cx),
9057 &[
9058 "v root",
9059 " v dir1",
9060 " v dir2",
9061 " file5.txt <== selected",
9062 ],
9063 "Should delete all marked files, leaving only the selected file"
9064 );
9065 }
9066
9067 #[gpui::test]
9068 async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
9069 init_test_with_editor(cx);
9070
9071 let fs = FakeFs::new(cx.executor().clone());
9072 fs.insert_tree(
9073 "/root_b",
9074 json!({
9075 "dir1": {
9076 "file1.txt": "content 1",
9077 "file2.txt": "content 2",
9078 },
9079 }),
9080 )
9081 .await;
9082
9083 fs.insert_tree(
9084 "/root_c",
9085 json!({
9086 "dir2": {},
9087 }),
9088 )
9089 .await;
9090
9091 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
9092 let workspace =
9093 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
9094 let cx = &mut VisualTestContext::from_window(*workspace, cx);
9095 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9096
9097 toggle_expand_dir(&panel, "root_b/dir1", cx);
9098 toggle_expand_dir(&panel, "root_c/dir2", cx);
9099
9100 cx.simulate_modifiers_change(gpui::Modifiers {
9101 control: true,
9102 ..Default::default()
9103 });
9104 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
9105 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
9106
9107 assert_eq!(
9108 visible_entries_as_strings(&panel, 0..20, cx),
9109 &[
9110 "v root_b",
9111 " v dir1",
9112 " file1.txt <== marked",
9113 " file2.txt <== selected <== marked",
9114 "v root_c",
9115 " v dir2",
9116 ],
9117 "Initial state with files marked in root_b"
9118 );
9119
9120 submit_deletion(&panel, cx);
9121 assert_eq!(
9122 visible_entries_as_strings(&panel, 0..20, cx),
9123 &[
9124 "v root_b",
9125 " v dir1 <== selected",
9126 "v root_c",
9127 " v dir2",
9128 ],
9129 "After deletion in root_b as it's last deletion, selection should be in root_b"
9130 );
9131
9132 select_path_with_mark(&panel, "root_c/dir2", cx);
9133
9134 submit_deletion(&panel, cx);
9135 assert_eq!(
9136 visible_entries_as_strings(&panel, 0..20, cx),
9137 &["v root_b", " v dir1", "v root_c <== selected",],
9138 "After deleting from root_c, it should remain in root_c"
9139 );
9140 }
9141
9142 fn toggle_expand_dir(
9143 panel: &Entity<ProjectPanel>,
9144 path: impl AsRef<Path>,
9145 cx: &mut VisualTestContext,
9146 ) {
9147 let path = path.as_ref();
9148 panel.update_in(cx, |panel, window, cx| {
9149 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9150 let worktree = worktree.read(cx);
9151 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9152 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9153 panel.toggle_expanded(entry_id, window, cx);
9154 return;
9155 }
9156 }
9157 panic!("no worktree for path {:?}", path);
9158 });
9159 }
9160
9161 #[gpui::test]
9162 async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
9163 init_test_with_editor(cx);
9164
9165 let fs = FakeFs::new(cx.executor().clone());
9166 fs.insert_tree(
9167 path!("/root"),
9168 json!({
9169 ".gitignore": "**/ignored_dir\n**/ignored_nested",
9170 "dir1": {
9171 "empty1": {
9172 "empty2": {
9173 "empty3": {
9174 "file.txt": ""
9175 }
9176 }
9177 },
9178 "subdir1": {
9179 "file1.txt": "",
9180 "file2.txt": "",
9181 "ignored_nested": {
9182 "ignored_file.txt": ""
9183 }
9184 },
9185 "ignored_dir": {
9186 "subdir": {
9187 "deep_file.txt": ""
9188 }
9189 }
9190 }
9191 }),
9192 )
9193 .await;
9194
9195 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
9196 let workspace =
9197 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
9198 let cx = &mut VisualTestContext::from_window(*workspace, cx);
9199
9200 // Test 1: When auto-fold is enabled
9201 cx.update(|_, cx| {
9202 let settings = *ProjectPanelSettings::get_global(cx);
9203 ProjectPanelSettings::override_global(
9204 ProjectPanelSettings {
9205 auto_fold_dirs: true,
9206 ..settings
9207 },
9208 cx,
9209 );
9210 });
9211
9212 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9213
9214 assert_eq!(
9215 visible_entries_as_strings(&panel, 0..20, cx),
9216 &["v root", " > dir1", " .gitignore",],
9217 "Initial state should show collapsed root structure"
9218 );
9219
9220 toggle_expand_dir(&panel, "root/dir1", cx);
9221 assert_eq!(
9222 visible_entries_as_strings(&panel, 0..20, cx),
9223 &[
9224 separator!("v root"),
9225 separator!(" v dir1 <== selected"),
9226 separator!(" > empty1/empty2/empty3"),
9227 separator!(" > ignored_dir"),
9228 separator!(" > subdir1"),
9229 separator!(" .gitignore"),
9230 ],
9231 "Should show first level with auto-folded dirs and ignored dir visible"
9232 );
9233
9234 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9235 panel.update(cx, |panel, cx| {
9236 let project = panel.project.read(cx);
9237 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9238 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
9239 panel.update_visible_entries(None, cx);
9240 });
9241 cx.run_until_parked();
9242
9243 assert_eq!(
9244 visible_entries_as_strings(&panel, 0..20, cx),
9245 &[
9246 separator!("v root"),
9247 separator!(" v dir1 <== selected"),
9248 separator!(" v empty1"),
9249 separator!(" v empty2"),
9250 separator!(" v empty3"),
9251 separator!(" file.txt"),
9252 separator!(" > ignored_dir"),
9253 separator!(" v subdir1"),
9254 separator!(" > ignored_nested"),
9255 separator!(" file1.txt"),
9256 separator!(" file2.txt"),
9257 separator!(" .gitignore"),
9258 ],
9259 "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
9260 );
9261
9262 // Test 2: When auto-fold is disabled
9263 cx.update(|_, cx| {
9264 let settings = *ProjectPanelSettings::get_global(cx);
9265 ProjectPanelSettings::override_global(
9266 ProjectPanelSettings {
9267 auto_fold_dirs: false,
9268 ..settings
9269 },
9270 cx,
9271 );
9272 });
9273
9274 panel.update_in(cx, |panel, window, cx| {
9275 panel.collapse_all_entries(&CollapseAllEntries, window, cx);
9276 });
9277
9278 toggle_expand_dir(&panel, "root/dir1", cx);
9279 assert_eq!(
9280 visible_entries_as_strings(&panel, 0..20, cx),
9281 &[
9282 separator!("v root"),
9283 separator!(" v dir1 <== selected"),
9284 separator!(" > empty1"),
9285 separator!(" > ignored_dir"),
9286 separator!(" > subdir1"),
9287 separator!(" .gitignore"),
9288 ],
9289 "With auto-fold disabled: should show all directories separately"
9290 );
9291
9292 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9293 panel.update(cx, |panel, cx| {
9294 let project = panel.project.read(cx);
9295 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9296 panel.expand_all_for_entry(worktree.id(), entry_id, cx);
9297 panel.update_visible_entries(None, cx);
9298 });
9299 cx.run_until_parked();
9300
9301 assert_eq!(
9302 visible_entries_as_strings(&panel, 0..20, cx),
9303 &[
9304 separator!("v root"),
9305 separator!(" v dir1 <== selected"),
9306 separator!(" v empty1"),
9307 separator!(" v empty2"),
9308 separator!(" v empty3"),
9309 separator!(" file.txt"),
9310 separator!(" > ignored_dir"),
9311 separator!(" v subdir1"),
9312 separator!(" > ignored_nested"),
9313 separator!(" file1.txt"),
9314 separator!(" file2.txt"),
9315 separator!(" .gitignore"),
9316 ],
9317 "After expand_all without auto-fold: should expand all dirs normally, \
9318 expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
9319 );
9320
9321 // Test 3: When explicitly called on ignored directory
9322 let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
9323 panel.update(cx, |panel, cx| {
9324 let project = panel.project.read(cx);
9325 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9326 panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
9327 panel.update_visible_entries(None, cx);
9328 });
9329 cx.run_until_parked();
9330
9331 assert_eq!(
9332 visible_entries_as_strings(&panel, 0..20, cx),
9333 &[
9334 separator!("v root"),
9335 separator!(" v dir1 <== selected"),
9336 separator!(" v empty1"),
9337 separator!(" v empty2"),
9338 separator!(" v empty3"),
9339 separator!(" file.txt"),
9340 separator!(" v ignored_dir"),
9341 separator!(" v subdir"),
9342 separator!(" deep_file.txt"),
9343 separator!(" v subdir1"),
9344 separator!(" > ignored_nested"),
9345 separator!(" file1.txt"),
9346 separator!(" file2.txt"),
9347 separator!(" .gitignore"),
9348 ],
9349 "After expand_all on ignored_dir: should expand all contents of the ignored directory"
9350 );
9351 }
9352
9353 #[gpui::test]
9354 async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
9355 init_test(cx);
9356
9357 let fs = FakeFs::new(cx.executor().clone());
9358 fs.insert_tree(
9359 path!("/root"),
9360 json!({
9361 "dir1": {
9362 "subdir1": {
9363 "nested1": {
9364 "file1.txt": "",
9365 "file2.txt": ""
9366 },
9367 },
9368 "subdir2": {
9369 "file4.txt": ""
9370 }
9371 },
9372 "dir2": {
9373 "single_file": {
9374 "file5.txt": ""
9375 }
9376 }
9377 }),
9378 )
9379 .await;
9380
9381 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
9382 let workspace =
9383 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
9384 let cx = &mut VisualTestContext::from_window(*workspace, cx);
9385
9386 // Test 1: Basic collapsing
9387 {
9388 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9389
9390 toggle_expand_dir(&panel, "root/dir1", cx);
9391 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
9392 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
9393 toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
9394
9395 assert_eq!(
9396 visible_entries_as_strings(&panel, 0..20, cx),
9397 &[
9398 separator!("v root"),
9399 separator!(" v dir1"),
9400 separator!(" v subdir1"),
9401 separator!(" v nested1"),
9402 separator!(" file1.txt"),
9403 separator!(" file2.txt"),
9404 separator!(" v subdir2 <== selected"),
9405 separator!(" file4.txt"),
9406 separator!(" > dir2"),
9407 ],
9408 "Initial state with everything expanded"
9409 );
9410
9411 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9412 panel.update(cx, |panel, cx| {
9413 let project = panel.project.read(cx);
9414 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9415 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
9416 panel.update_visible_entries(None, cx);
9417 });
9418
9419 assert_eq!(
9420 visible_entries_as_strings(&panel, 0..20, cx),
9421 &["v root", " > dir1", " > dir2",],
9422 "All subdirs under dir1 should be collapsed"
9423 );
9424 }
9425
9426 // Test 2: With auto-fold enabled
9427 {
9428 cx.update(|_, cx| {
9429 let settings = *ProjectPanelSettings::get_global(cx);
9430 ProjectPanelSettings::override_global(
9431 ProjectPanelSettings {
9432 auto_fold_dirs: true,
9433 ..settings
9434 },
9435 cx,
9436 );
9437 });
9438
9439 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9440
9441 toggle_expand_dir(&panel, "root/dir1", cx);
9442 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
9443 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
9444
9445 assert_eq!(
9446 visible_entries_as_strings(&panel, 0..20, cx),
9447 &[
9448 separator!("v root"),
9449 separator!(" v dir1"),
9450 separator!(" v subdir1/nested1 <== selected"),
9451 separator!(" file1.txt"),
9452 separator!(" file2.txt"),
9453 separator!(" > subdir2"),
9454 separator!(" > dir2/single_file"),
9455 ],
9456 "Initial state with some dirs expanded"
9457 );
9458
9459 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9460 panel.update(cx, |panel, cx| {
9461 let project = panel.project.read(cx);
9462 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9463 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
9464 });
9465
9466 toggle_expand_dir(&panel, "root/dir1", cx);
9467
9468 assert_eq!(
9469 visible_entries_as_strings(&panel, 0..20, cx),
9470 &[
9471 separator!("v root"),
9472 separator!(" v dir1 <== selected"),
9473 separator!(" > subdir1/nested1"),
9474 separator!(" > subdir2"),
9475 separator!(" > dir2/single_file"),
9476 ],
9477 "Subdirs should be collapsed and folded with auto-fold enabled"
9478 );
9479 }
9480
9481 // Test 3: With auto-fold disabled
9482 {
9483 cx.update(|_, cx| {
9484 let settings = *ProjectPanelSettings::get_global(cx);
9485 ProjectPanelSettings::override_global(
9486 ProjectPanelSettings {
9487 auto_fold_dirs: false,
9488 ..settings
9489 },
9490 cx,
9491 );
9492 });
9493
9494 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9495
9496 toggle_expand_dir(&panel, "root/dir1", cx);
9497 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
9498 toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
9499
9500 assert_eq!(
9501 visible_entries_as_strings(&panel, 0..20, cx),
9502 &[
9503 separator!("v root"),
9504 separator!(" v dir1"),
9505 separator!(" v subdir1"),
9506 separator!(" v nested1 <== selected"),
9507 separator!(" file1.txt"),
9508 separator!(" file2.txt"),
9509 separator!(" > subdir2"),
9510 separator!(" > dir2"),
9511 ],
9512 "Initial state with some dirs expanded and auto-fold disabled"
9513 );
9514
9515 let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9516 panel.update(cx, |panel, cx| {
9517 let project = panel.project.read(cx);
9518 let worktree = project.worktrees(cx).next().unwrap().read(cx);
9519 panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
9520 });
9521
9522 toggle_expand_dir(&panel, "root/dir1", cx);
9523
9524 assert_eq!(
9525 visible_entries_as_strings(&panel, 0..20, cx),
9526 &[
9527 separator!("v root"),
9528 separator!(" v dir1 <== selected"),
9529 separator!(" > subdir1"),
9530 separator!(" > subdir2"),
9531 separator!(" > dir2"),
9532 ],
9533 "Subdirs should be collapsed but not folded with auto-fold disabled"
9534 );
9535 }
9536 }
9537
9538 fn select_path(
9539 panel: &Entity<ProjectPanel>,
9540 path: impl AsRef<Path>,
9541 cx: &mut VisualTestContext,
9542 ) {
9543 let path = path.as_ref();
9544 panel.update(cx, |panel, cx| {
9545 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9546 let worktree = worktree.read(cx);
9547 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9548 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9549 panel.selection = Some(crate::SelectedEntry {
9550 worktree_id: worktree.id(),
9551 entry_id,
9552 });
9553 return;
9554 }
9555 }
9556 panic!("no worktree for path {:?}", path);
9557 });
9558 }
9559
9560 fn select_path_with_mark(
9561 panel: &Entity<ProjectPanel>,
9562 path: impl AsRef<Path>,
9563 cx: &mut VisualTestContext,
9564 ) {
9565 let path = path.as_ref();
9566 panel.update(cx, |panel, cx| {
9567 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9568 let worktree = worktree.read(cx);
9569 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9570 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9571 let entry = crate::SelectedEntry {
9572 worktree_id: worktree.id(),
9573 entry_id,
9574 };
9575 if !panel.marked_entries.contains(&entry) {
9576 panel.marked_entries.insert(entry);
9577 }
9578 panel.selection = Some(entry);
9579 return;
9580 }
9581 }
9582 panic!("no worktree for path {:?}", path);
9583 });
9584 }
9585
9586 fn find_project_entry(
9587 panel: &Entity<ProjectPanel>,
9588 path: impl AsRef<Path>,
9589 cx: &mut VisualTestContext,
9590 ) -> Option<ProjectEntryId> {
9591 let path = path.as_ref();
9592 panel.update(cx, |panel, cx| {
9593 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9594 let worktree = worktree.read(cx);
9595 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9596 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
9597 }
9598 }
9599 panic!("no worktree for path {path:?}");
9600 })
9601 }
9602
9603 fn visible_entries_as_strings(
9604 panel: &Entity<ProjectPanel>,
9605 range: Range<usize>,
9606 cx: &mut VisualTestContext,
9607 ) -> Vec<String> {
9608 let mut result = Vec::new();
9609 let mut project_entries = HashSet::default();
9610 let mut has_editor = false;
9611
9612 panel.update_in(cx, |panel, window, cx| {
9613 panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
9614 if details.is_editing {
9615 assert!(!has_editor, "duplicate editor entry");
9616 has_editor = true;
9617 } else {
9618 assert!(
9619 project_entries.insert(project_entry),
9620 "duplicate project entry {:?} {:?}",
9621 project_entry,
9622 details
9623 );
9624 }
9625
9626 let indent = " ".repeat(details.depth);
9627 let icon = if details.kind.is_dir() {
9628 if details.is_expanded {
9629 "v "
9630 } else {
9631 "> "
9632 }
9633 } else {
9634 " "
9635 };
9636 let name = if details.is_editing {
9637 format!("[EDITOR: '{}']", details.filename)
9638 } else if details.is_processing {
9639 format!("[PROCESSING: '{}']", details.filename)
9640 } else {
9641 details.filename.clone()
9642 };
9643 let selected = if details.is_selected {
9644 " <== selected"
9645 } else {
9646 ""
9647 };
9648 let marked = if details.is_marked {
9649 " <== marked"
9650 } else {
9651 ""
9652 };
9653
9654 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
9655 });
9656 });
9657
9658 result
9659 }
9660
9661 fn init_test(cx: &mut TestAppContext) {
9662 cx.update(|cx| {
9663 let settings_store = SettingsStore::test(cx);
9664 cx.set_global(settings_store);
9665 init_settings(cx);
9666 theme::init(theme::LoadThemes::JustBase, cx);
9667 language::init(cx);
9668 editor::init_settings(cx);
9669 crate::init(cx);
9670 workspace::init_settings(cx);
9671 client::init_settings(cx);
9672 Project::init_settings(cx);
9673
9674 cx.update_global::<SettingsStore, _>(|store, cx| {
9675 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
9676 project_panel_settings.auto_fold_dirs = Some(false);
9677 });
9678 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
9679 worktree_settings.file_scan_exclusions = Some(Vec::new());
9680 });
9681 });
9682 });
9683 }
9684
9685 fn init_test_with_editor(cx: &mut TestAppContext) {
9686 cx.update(|cx| {
9687 let app_state = AppState::test(cx);
9688 theme::init(theme::LoadThemes::JustBase, cx);
9689 init_settings(cx);
9690 language::init(cx);
9691 editor::init(cx);
9692 crate::init(cx);
9693 workspace::init(app_state.clone(), cx);
9694 Project::init_settings(cx);
9695
9696 cx.update_global::<SettingsStore, _>(|store, cx| {
9697 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
9698 project_panel_settings.auto_fold_dirs = Some(false);
9699 });
9700 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
9701 worktree_settings.file_scan_exclusions = Some(Vec::new());
9702 });
9703 });
9704 });
9705 }
9706
9707 fn ensure_single_file_is_opened(
9708 window: &WindowHandle<Workspace>,
9709 expected_path: &str,
9710 cx: &mut TestAppContext,
9711 ) {
9712 window
9713 .update(cx, |workspace, _, cx| {
9714 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
9715 assert_eq!(worktrees.len(), 1);
9716 let worktree_id = worktrees[0].read(cx).id();
9717
9718 let open_project_paths = workspace
9719 .panes()
9720 .iter()
9721 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9722 .collect::<Vec<_>>();
9723 assert_eq!(
9724 open_project_paths,
9725 vec![ProjectPath {
9726 worktree_id,
9727 path: Arc::from(Path::new(expected_path))
9728 }],
9729 "Should have opened file, selected in project panel"
9730 );
9731 })
9732 .unwrap();
9733 }
9734
9735 fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9736 assert!(
9737 !cx.has_pending_prompt(),
9738 "Should have no prompts before the deletion"
9739 );
9740 panel.update_in(cx, |panel, window, cx| {
9741 panel.delete(&Delete { skip_prompt: false }, window, cx)
9742 });
9743 assert!(
9744 cx.has_pending_prompt(),
9745 "Should have a prompt after the deletion"
9746 );
9747 cx.simulate_prompt_answer("Delete");
9748 assert!(
9749 !cx.has_pending_prompt(),
9750 "Should have no prompts after prompt was replied to"
9751 );
9752 cx.executor().run_until_parked();
9753 }
9754
9755 fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9756 assert!(
9757 !cx.has_pending_prompt(),
9758 "Should have no prompts before the deletion"
9759 );
9760 panel.update_in(cx, |panel, window, cx| {
9761 panel.delete(&Delete { skip_prompt: true }, window, cx)
9762 });
9763 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
9764 cx.executor().run_until_parked();
9765 }
9766
9767 fn ensure_no_open_items_and_panes(
9768 workspace: &WindowHandle<Workspace>,
9769 cx: &mut VisualTestContext,
9770 ) {
9771 assert!(
9772 !cx.has_pending_prompt(),
9773 "Should have no prompts after deletion operation closes the file"
9774 );
9775 workspace
9776 .read_with(cx, |workspace, cx| {
9777 let open_project_paths = workspace
9778 .panes()
9779 .iter()
9780 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9781 .collect::<Vec<_>>();
9782 assert!(
9783 open_project_paths.is_empty(),
9784 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
9785 );
9786 })
9787 .unwrap();
9788 }
9789
9790 struct TestProjectItemView {
9791 focus_handle: FocusHandle,
9792 path: ProjectPath,
9793 }
9794
9795 struct TestProjectItem {
9796 path: ProjectPath,
9797 }
9798
9799 impl project::ProjectItem for TestProjectItem {
9800 fn try_open(
9801 _project: &Entity<Project>,
9802 path: &ProjectPath,
9803 cx: &mut App,
9804 ) -> Option<Task<gpui::Result<Entity<Self>>>> {
9805 let path = path.clone();
9806 Some(cx.spawn(async move |cx| cx.new(|_| Self { path })))
9807 }
9808
9809 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
9810 None
9811 }
9812
9813 fn project_path(&self, _: &App) -> Option<ProjectPath> {
9814 Some(self.path.clone())
9815 }
9816
9817 fn is_dirty(&self) -> bool {
9818 false
9819 }
9820 }
9821
9822 impl ProjectItem for TestProjectItemView {
9823 type Item = TestProjectItem;
9824
9825 fn for_project_item(
9826 _: Entity<Project>,
9827 project_item: Entity<Self::Item>,
9828 _: &mut Window,
9829 cx: &mut Context<Self>,
9830 ) -> Self
9831 where
9832 Self: Sized,
9833 {
9834 Self {
9835 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
9836 focus_handle: cx.focus_handle(),
9837 }
9838 }
9839 }
9840
9841 impl Item for TestProjectItemView {
9842 type Event = ();
9843 }
9844
9845 impl EventEmitter<()> for TestProjectItemView {}
9846
9847 impl Focusable for TestProjectItemView {
9848 fn focus_handle(&self, _: &App) -> FocusHandle {
9849 self.focus_handle.clone()
9850 }
9851 }
9852
9853 impl Render for TestProjectItemView {
9854 fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
9855 Empty
9856 }
9857 }
9858}