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