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