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