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