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