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