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