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