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