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