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, Scrollbar, ScrollbarState,
58 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 .selectable(false)
3451 .when_some(canonical_path, |this, path| {
3452 this.end_slot::<AnyElement>(
3453 div()
3454 .id("symlink_icon")
3455 .pr_3()
3456 .tooltip(move |cx| {
3457 Tooltip::with_meta(path.to_string(), None, "Symbolic Link", cx)
3458 })
3459 .child(
3460 Icon::new(IconName::ArrowUpRight)
3461 .size(IconSize::Indicator)
3462 .color(filename_text_color),
3463 )
3464 .into_any_element(),
3465 )
3466 })
3467 .child(if let Some(icon) = &icon {
3468 // Check if there's a diagnostic severity and get the decoration color
3469 if let Some((_, decoration_color)) =
3470 entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
3471 {
3472 // Determine if the diagnostic is a warning
3473 let is_warning = diagnostic_severity
3474 .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
3475 .unwrap_or(false);
3476 div().child(
3477 DecoratedIcon::new(
3478 Icon::from_path(icon.clone()).color(Color::Muted),
3479 Some(
3480 IconDecoration::new(
3481 if kind.is_file() {
3482 if is_warning {
3483 IconDecorationKind::Triangle
3484 } else {
3485 IconDecorationKind::X
3486 }
3487 } else {
3488 IconDecorationKind::Dot
3489 },
3490 default_color,
3491 cx,
3492 )
3493 .group_name(Some(GROUP_NAME.into()))
3494 .knockout_hover_color(bg_hover_color)
3495 .color(decoration_color.color(cx))
3496 .position(Point {
3497 x: px(-2.),
3498 y: px(-2.),
3499 }),
3500 ),
3501 )
3502 .into_any_element(),
3503 )
3504 } else {
3505 h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
3506 }
3507 } else {
3508 if let Some((icon_name, color)) =
3509 entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
3510 {
3511 h_flex()
3512 .size(IconSize::default().rems())
3513 .child(Icon::new(icon_name).color(color).size(IconSize::Small))
3514 } else {
3515 h_flex()
3516 .size(IconSize::default().rems())
3517 .invisible()
3518 .flex_none()
3519 }
3520 })
3521 .child(
3522 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
3523 h_flex().h_6().w_full().child(editor.clone())
3524 } else {
3525 h_flex().h_6().map(|mut this| {
3526 if let Some(folded_ancestors) = self.ancestors.get(&entry_id) {
3527 let components = Path::new(&file_name)
3528 .components()
3529 .map(|comp| {
3530 let comp_str =
3531 comp.as_os_str().to_string_lossy().into_owned();
3532 comp_str
3533 })
3534 .collect::<Vec<_>>();
3535
3536 let components_len = components.len();
3537 let active_index = components_len
3538 - 1
3539 - folded_ancestors.current_ancestor_depth;
3540 const DELIMITER: SharedString =
3541 SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
3542 for (index, component) in components.into_iter().enumerate() {
3543 if index != 0 {
3544 this = this.child(
3545 Label::new(DELIMITER.clone())
3546 .single_line()
3547 .color(filename_text_color),
3548 );
3549 }
3550 let id = SharedString::from(format!(
3551 "project_panel_path_component_{}_{index}",
3552 entry_id.to_usize()
3553 ));
3554 let label = div()
3555 .id(id)
3556 .on_click(cx.listener(move |this, _, cx| {
3557 if index != active_index {
3558 if let Some(folds) =
3559 this.ancestors.get_mut(&entry_id)
3560 {
3561 folds.current_ancestor_depth =
3562 components_len - 1 - index;
3563 cx.notify();
3564 }
3565 }
3566 }))
3567 .child(
3568 Label::new(component)
3569 .single_line()
3570 .color(filename_text_color)
3571 .when(
3572 index == active_index
3573 && (is_active || is_marked),
3574 |this| this.underline(true),
3575 ),
3576 );
3577
3578 this = this.child(label);
3579 }
3580
3581 this
3582 } else {
3583 this.child(
3584 Label::new(file_name)
3585 .single_line()
3586 .color(filename_text_color),
3587 )
3588 }
3589 })
3590 }
3591 .ml_1(),
3592 )
3593 .on_secondary_mouse_down(cx.listener(
3594 move |this, event: &MouseDownEvent, cx| {
3595 // Stop propagation to prevent the catch-all context menu for the project
3596 // panel from being deployed.
3597 cx.stop_propagation();
3598 // Some context menu actions apply to all marked entries. If the user
3599 // right-clicks on an entry that is not marked, they may not realize the
3600 // action applies to multiple entries. To avoid inadvertent changes, all
3601 // entries are unmarked.
3602 if !this.marked_entries.contains(&selection) {
3603 this.marked_entries.clear();
3604 }
3605 this.deploy_context_menu(event.position, entry_id, cx);
3606 },
3607 ))
3608 .overflow_x(),
3609 )
3610 }
3611
3612 fn render_vertical_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
3613 if !Self::should_show_scrollbar(cx)
3614 || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
3615 {
3616 return None;
3617 }
3618 Some(
3619 div()
3620 .occlude()
3621 .id("project-panel-vertical-scroll")
3622 .on_mouse_move(cx.listener(|_, _, cx| {
3623 cx.notify();
3624 cx.stop_propagation()
3625 }))
3626 .on_hover(|_, cx| {
3627 cx.stop_propagation();
3628 })
3629 .on_any_mouse_down(|_, cx| {
3630 cx.stop_propagation();
3631 })
3632 .on_mouse_up(
3633 MouseButton::Left,
3634 cx.listener(|this, _, cx| {
3635 if !this.vertical_scrollbar_state.is_dragging()
3636 && !this.focus_handle.contains_focused(cx)
3637 {
3638 this.hide_scrollbar(cx);
3639 cx.notify();
3640 }
3641
3642 cx.stop_propagation();
3643 }),
3644 )
3645 .on_scroll_wheel(cx.listener(|_, _, cx| {
3646 cx.notify();
3647 }))
3648 .h_full()
3649 .absolute()
3650 .right_1()
3651 .top_1()
3652 .bottom_1()
3653 .w(px(12.))
3654 .cursor_default()
3655 .children(Scrollbar::vertical(
3656 // percentage as f32..end_offset as f32,
3657 self.vertical_scrollbar_state.clone(),
3658 )),
3659 )
3660 }
3661
3662 fn render_horizontal_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
3663 if !Self::should_show_scrollbar(cx)
3664 || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
3665 {
3666 return None;
3667 }
3668
3669 let scroll_handle = self.scroll_handle.0.borrow();
3670 let longest_item_width = scroll_handle
3671 .last_item_size
3672 .filter(|size| size.contents.width > size.item.width)?
3673 .contents
3674 .width
3675 .0 as f64;
3676 if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
3677 return None;
3678 }
3679
3680 Some(
3681 div()
3682 .occlude()
3683 .id("project-panel-horizontal-scroll")
3684 .on_mouse_move(cx.listener(|_, _, cx| {
3685 cx.notify();
3686 cx.stop_propagation()
3687 }))
3688 .on_hover(|_, cx| {
3689 cx.stop_propagation();
3690 })
3691 .on_any_mouse_down(|_, cx| {
3692 cx.stop_propagation();
3693 })
3694 .on_mouse_up(
3695 MouseButton::Left,
3696 cx.listener(|this, _, cx| {
3697 if !this.horizontal_scrollbar_state.is_dragging()
3698 && !this.focus_handle.contains_focused(cx)
3699 {
3700 this.hide_scrollbar(cx);
3701 cx.notify();
3702 }
3703
3704 cx.stop_propagation();
3705 }),
3706 )
3707 .on_scroll_wheel(cx.listener(|_, _, cx| {
3708 cx.notify();
3709 }))
3710 .w_full()
3711 .absolute()
3712 .right_1()
3713 .left_1()
3714 .bottom_1()
3715 .h(px(12.))
3716 .cursor_default()
3717 .when(self.width.is_some(), |this| {
3718 this.children(Scrollbar::horizontal(
3719 self.horizontal_scrollbar_state.clone(),
3720 ))
3721 }),
3722 )
3723 }
3724
3725 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
3726 let mut dispatch_context = KeyContext::new_with_defaults();
3727 dispatch_context.add("ProjectPanel");
3728 dispatch_context.add("menu");
3729
3730 let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
3731 "editing"
3732 } else {
3733 "not_editing"
3734 };
3735
3736 dispatch_context.add(identifier);
3737 dispatch_context
3738 }
3739
3740 fn should_show_scrollbar(cx: &AppContext) -> bool {
3741 let show = ProjectPanelSettings::get_global(cx)
3742 .scrollbar
3743 .show
3744 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
3745 match show {
3746 ShowScrollbar::Auto => true,
3747 ShowScrollbar::System => true,
3748 ShowScrollbar::Always => true,
3749 ShowScrollbar::Never => false,
3750 }
3751 }
3752
3753 fn should_autohide_scrollbar(cx: &AppContext) -> bool {
3754 let show = ProjectPanelSettings::get_global(cx)
3755 .scrollbar
3756 .show
3757 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
3758 match show {
3759 ShowScrollbar::Auto => true,
3760 ShowScrollbar::System => cx
3761 .try_global::<ScrollbarAutoHide>()
3762 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
3763 ShowScrollbar::Always => false,
3764 ShowScrollbar::Never => true,
3765 }
3766 }
3767
3768 fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
3769 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
3770 if !Self::should_autohide_scrollbar(cx) {
3771 return;
3772 }
3773 self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
3774 cx.background_executor()
3775 .timer(SCROLLBAR_SHOW_INTERVAL)
3776 .await;
3777 panel
3778 .update(&mut cx, |panel, cx| {
3779 panel.show_scrollbar = false;
3780 cx.notify();
3781 })
3782 .log_err();
3783 }))
3784 }
3785
3786 fn reveal_entry(
3787 &mut self,
3788 project: Model<Project>,
3789 entry_id: ProjectEntryId,
3790 skip_ignored: bool,
3791 cx: &mut ViewContext<Self>,
3792 ) {
3793 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
3794 let worktree = worktree.read(cx);
3795 if skip_ignored
3796 && worktree
3797 .entry_for_id(entry_id)
3798 .map_or(true, |entry| entry.is_ignored)
3799 {
3800 return;
3801 }
3802
3803 let worktree_id = worktree.id();
3804 self.expand_entry(worktree_id, entry_id, cx);
3805 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
3806
3807 if self.marked_entries.len() == 1
3808 && self
3809 .marked_entries
3810 .first()
3811 .filter(|entry| entry.entry_id == entry_id)
3812 .is_none()
3813 {
3814 self.marked_entries.clear();
3815 }
3816 self.autoscroll(cx);
3817 cx.notify();
3818 }
3819 }
3820
3821 fn find_active_indent_guide(
3822 &self,
3823 indent_guides: &[IndentGuideLayout],
3824 cx: &AppContext,
3825 ) -> Option<usize> {
3826 let (worktree, entry) = self.selected_entry(cx)?;
3827
3828 // Find the parent entry of the indent guide, this will either be the
3829 // expanded folder we have selected, or the parent of the currently
3830 // selected file/collapsed directory
3831 let mut entry = entry;
3832 loop {
3833 let is_expanded_dir = entry.is_dir()
3834 && self
3835 .expanded_dir_ids
3836 .get(&worktree.id())
3837 .map(|ids| ids.binary_search(&entry.id).is_ok())
3838 .unwrap_or(false);
3839 if is_expanded_dir {
3840 break;
3841 }
3842 entry = worktree.entry_for_path(&entry.path.parent()?)?;
3843 }
3844
3845 let (active_indent_range, depth) = {
3846 let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
3847 let child_paths = &self.visible_entries[worktree_ix].1;
3848 let mut child_count = 0;
3849 let depth = entry.path.ancestors().count();
3850 while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
3851 if entry.path.ancestors().count() <= depth {
3852 break;
3853 }
3854 child_count += 1;
3855 }
3856
3857 let start = ix + 1;
3858 let end = start + child_count;
3859
3860 let (_, entries, paths) = &self.visible_entries[worktree_ix];
3861 let visible_worktree_entries =
3862 paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect());
3863
3864 // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
3865 let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
3866 (start..end, depth)
3867 };
3868
3869 let candidates = indent_guides
3870 .iter()
3871 .enumerate()
3872 .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
3873
3874 for (i, indent) in candidates {
3875 // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
3876 if active_indent_range.start <= indent.offset.y + indent.length
3877 && indent.offset.y <= active_indent_range.end
3878 {
3879 return Some(i);
3880 }
3881 }
3882 None
3883 }
3884}
3885
3886fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
3887 const ICON_SIZE_FACTOR: usize = 2;
3888 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
3889 if is_symlink {
3890 item_width += ICON_SIZE_FACTOR;
3891 }
3892 item_width
3893}
3894
3895impl Render for ProjectPanel {
3896 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
3897 let has_worktree = !self.visible_entries.is_empty();
3898 let project = self.project.read(cx);
3899 let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
3900 let show_indent_guides =
3901 ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
3902 let is_local = project.is_local();
3903
3904 if has_worktree {
3905 let item_count = self
3906 .visible_entries
3907 .iter()
3908 .map(|(_, worktree_entries, _)| worktree_entries.len())
3909 .sum();
3910
3911 fn handle_drag_move_scroll<T: 'static>(
3912 this: &mut ProjectPanel,
3913 e: &DragMoveEvent<T>,
3914 cx: &mut ViewContext<ProjectPanel>,
3915 ) {
3916 if !e.bounds.contains(&e.event.position) {
3917 return;
3918 }
3919 this.hover_scroll_task.take();
3920 let panel_height = e.bounds.size.height;
3921 if panel_height <= px(0.) {
3922 return;
3923 }
3924
3925 let event_offset = e.event.position.y - e.bounds.origin.y;
3926 // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
3927 let hovered_region_offset = event_offset / panel_height;
3928
3929 // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
3930 // These pixels offsets were picked arbitrarily.
3931 let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
3932 8.
3933 } else if hovered_region_offset <= 0.15 {
3934 5.
3935 } else if hovered_region_offset >= 0.95 {
3936 -8.
3937 } else if hovered_region_offset >= 0.85 {
3938 -5.
3939 } else {
3940 return;
3941 };
3942 let adjustment = point(px(0.), px(vertical_scroll_offset));
3943 this.hover_scroll_task = Some(cx.spawn(move |this, mut cx| async move {
3944 loop {
3945 let should_stop_scrolling = this
3946 .update(&mut cx, |this, cx| {
3947 this.hover_scroll_task.as_ref()?;
3948 let handle = this.scroll_handle.0.borrow_mut();
3949 let offset = handle.base_handle.offset();
3950
3951 handle.base_handle.set_offset(offset + adjustment);
3952 cx.notify();
3953 Some(())
3954 })
3955 .ok()
3956 .flatten()
3957 .is_some();
3958 if should_stop_scrolling {
3959 return;
3960 }
3961 cx.background_executor()
3962 .timer(Duration::from_millis(16))
3963 .await;
3964 }
3965 }));
3966 }
3967 h_flex()
3968 .id("project-panel")
3969 .group("project-panel")
3970 .on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
3971 .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
3972 .size_full()
3973 .relative()
3974 .on_hover(cx.listener(|this, hovered, cx| {
3975 if *hovered {
3976 this.show_scrollbar = true;
3977 this.hide_scrollbar_task.take();
3978 cx.notify();
3979 } else if !this.focus_handle.contains_focused(cx) {
3980 this.hide_scrollbar(cx);
3981 }
3982 }))
3983 .on_click(cx.listener(|this, _event, cx| {
3984 cx.stop_propagation();
3985 this.selection = None;
3986 this.marked_entries.clear();
3987 }))
3988 .key_context(self.dispatch_context(cx))
3989 .on_action(cx.listener(Self::select_next))
3990 .on_action(cx.listener(Self::select_prev))
3991 .on_action(cx.listener(Self::select_first))
3992 .on_action(cx.listener(Self::select_last))
3993 .on_action(cx.listener(Self::select_parent))
3994 .on_action(cx.listener(Self::select_next_git_entry))
3995 .on_action(cx.listener(Self::select_prev_git_entry))
3996 .on_action(cx.listener(Self::select_next_diagnostic))
3997 .on_action(cx.listener(Self::select_prev_diagnostic))
3998 .on_action(cx.listener(Self::select_next_directory))
3999 .on_action(cx.listener(Self::select_prev_directory))
4000 .on_action(cx.listener(Self::expand_selected_entry))
4001 .on_action(cx.listener(Self::collapse_selected_entry))
4002 .on_action(cx.listener(Self::collapse_all_entries))
4003 .on_action(cx.listener(Self::open))
4004 .on_action(cx.listener(Self::open_permanent))
4005 .on_action(cx.listener(Self::confirm))
4006 .on_action(cx.listener(Self::cancel))
4007 .on_action(cx.listener(Self::copy_path))
4008 .on_action(cx.listener(Self::copy_relative_path))
4009 .on_action(cx.listener(Self::new_search_in_directory))
4010 .on_action(cx.listener(Self::unfold_directory))
4011 .on_action(cx.listener(Self::fold_directory))
4012 .on_action(cx.listener(Self::remove_from_project))
4013 .when(!project.is_read_only(cx), |el| {
4014 el.on_action(cx.listener(Self::new_file))
4015 .on_action(cx.listener(Self::new_directory))
4016 .on_action(cx.listener(Self::rename))
4017 .on_action(cx.listener(Self::delete))
4018 .on_action(cx.listener(Self::trash))
4019 .on_action(cx.listener(Self::cut))
4020 .on_action(cx.listener(Self::copy))
4021 .on_action(cx.listener(Self::paste))
4022 .on_action(cx.listener(Self::duplicate))
4023 .on_click(cx.listener(|this, event: &gpui::ClickEvent, cx| {
4024 if event.up.click_count > 1 {
4025 if let Some(entry_id) = this.last_worktree_root_id {
4026 let project = this.project.read(cx);
4027
4028 let worktree_id = if let Some(worktree) =
4029 project.worktree_for_entry(entry_id, cx)
4030 {
4031 worktree.read(cx).id()
4032 } else {
4033 return;
4034 };
4035
4036 this.selection = Some(SelectedEntry {
4037 worktree_id,
4038 entry_id,
4039 });
4040
4041 this.new_file(&NewFile, cx);
4042 }
4043 }
4044 }))
4045 })
4046 .when(project.is_local(), |el| {
4047 el.on_action(cx.listener(Self::reveal_in_finder))
4048 .on_action(cx.listener(Self::open_system))
4049 .on_action(cx.listener(Self::open_in_terminal))
4050 })
4051 .when(project.is_via_ssh(), |el| {
4052 el.on_action(cx.listener(Self::open_in_terminal))
4053 })
4054 .on_mouse_down(
4055 MouseButton::Right,
4056 cx.listener(move |this, event: &MouseDownEvent, cx| {
4057 // When deploying the context menu anywhere below the last project entry,
4058 // act as if the user clicked the root of the last worktree.
4059 if let Some(entry_id) = this.last_worktree_root_id {
4060 this.deploy_context_menu(event.position, entry_id, cx);
4061 }
4062 }),
4063 )
4064 .track_focus(&self.focus_handle(cx))
4065 .child(
4066 uniform_list(cx.view().clone(), "entries", item_count, {
4067 |this, range, cx| {
4068 let mut items = Vec::with_capacity(range.end - range.start);
4069 this.for_each_visible_entry(range, cx, |id, details, cx| {
4070 items.push(this.render_entry(id, details, cx));
4071 });
4072 items
4073 }
4074 })
4075 .when(show_indent_guides, |list| {
4076 list.with_decoration(
4077 ui::indent_guides(
4078 cx.view().clone(),
4079 px(indent_size),
4080 IndentGuideColors::panel(cx),
4081 |this, range, cx| {
4082 let mut items =
4083 SmallVec::with_capacity(range.end - range.start);
4084 this.iter_visible_entries(range, cx, |entry, entries, _| {
4085 let (depth, _) =
4086 Self::calculate_depth_and_difference(entry, entries);
4087 items.push(depth);
4088 });
4089 items
4090 },
4091 )
4092 .on_click(cx.listener(
4093 |this, active_indent_guide: &IndentGuideLayout, cx| {
4094 if cx.modifiers().secondary() {
4095 let ix = active_indent_guide.offset.y;
4096 let Some((target_entry, worktree)) = maybe!({
4097 let (worktree_id, entry) = this.entry_at_index(ix)?;
4098 let worktree = this
4099 .project
4100 .read(cx)
4101 .worktree_for_id(worktree_id, cx)?;
4102 let target_entry = worktree
4103 .read(cx)
4104 .entry_for_path(&entry.path.parent()?)?;
4105 Some((target_entry, worktree))
4106 }) else {
4107 return;
4108 };
4109
4110 this.collapse_entry(target_entry.clone(), worktree, cx);
4111 }
4112 },
4113 ))
4114 .with_render_fn(
4115 cx.view().clone(),
4116 move |this, params, cx| {
4117 const LEFT_OFFSET: f32 = 14.;
4118 const PADDING_Y: f32 = 4.;
4119 const HITBOX_OVERDRAW: f32 = 3.;
4120
4121 let active_indent_guide_index =
4122 this.find_active_indent_guide(¶ms.indent_guides, cx);
4123
4124 let indent_size = params.indent_size;
4125 let item_height = params.item_height;
4126
4127 params
4128 .indent_guides
4129 .into_iter()
4130 .enumerate()
4131 .map(|(idx, layout)| {
4132 let offset = if layout.continues_offscreen {
4133 px(0.)
4134 } else {
4135 px(PADDING_Y)
4136 };
4137 let bounds = Bounds::new(
4138 point(
4139 px(layout.offset.x as f32) * indent_size
4140 + px(LEFT_OFFSET),
4141 px(layout.offset.y as f32) * item_height
4142 + offset,
4143 ),
4144 size(
4145 px(1.),
4146 px(layout.length as f32) * item_height
4147 - px(offset.0 * 2.),
4148 ),
4149 );
4150 ui::RenderedIndentGuide {
4151 bounds,
4152 layout,
4153 is_active: Some(idx) == active_indent_guide_index,
4154 hitbox: Some(Bounds::new(
4155 point(
4156 bounds.origin.x - px(HITBOX_OVERDRAW),
4157 bounds.origin.y,
4158 ),
4159 size(
4160 bounds.size.width
4161 + px(2. * HITBOX_OVERDRAW),
4162 bounds.size.height,
4163 ),
4164 )),
4165 }
4166 })
4167 .collect()
4168 },
4169 ),
4170 )
4171 })
4172 .size_full()
4173 .with_sizing_behavior(ListSizingBehavior::Infer)
4174 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4175 .with_width_from_item(self.max_width_item_index)
4176 .track_scroll(self.scroll_handle.clone()),
4177 )
4178 .children(self.render_vertical_scrollbar(cx))
4179 .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
4180 this.pb_4().child(scrollbar)
4181 })
4182 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4183 deferred(
4184 anchored()
4185 .position(*position)
4186 .anchor(gpui::Corner::TopLeft)
4187 .child(menu.clone()),
4188 )
4189 .with_priority(1)
4190 }))
4191 } else {
4192 v_flex()
4193 .id("empty-project_panel")
4194 .size_full()
4195 .p_4()
4196 .track_focus(&self.focus_handle(cx))
4197 .child(
4198 Button::new("open_project", "Open a project")
4199 .full_width()
4200 .key_binding(KeyBinding::for_action(&workspace::Open, cx))
4201 .on_click(cx.listener(|this, _, cx| {
4202 this.workspace
4203 .update(cx, |_, cx| cx.dispatch_action(Box::new(workspace::Open)))
4204 .log_err();
4205 })),
4206 )
4207 .when(is_local, |div| {
4208 div.drag_over::<ExternalPaths>(|style, _, cx| {
4209 style.bg(cx.theme().colors().drop_target_background)
4210 })
4211 .on_drop(cx.listener(
4212 move |this, external_paths: &ExternalPaths, cx| {
4213 this.last_external_paths_drag_over_entry = None;
4214 this.marked_entries.clear();
4215 this.hover_scroll_task.take();
4216 if let Some(task) = this
4217 .workspace
4218 .update(cx, |workspace, cx| {
4219 workspace.open_workspace_for_paths(
4220 true,
4221 external_paths.paths().to_owned(),
4222 cx,
4223 )
4224 })
4225 .log_err()
4226 {
4227 task.detach_and_log_err(cx);
4228 }
4229 cx.stop_propagation();
4230 },
4231 ))
4232 })
4233 }
4234 }
4235}
4236
4237impl Render for DraggedProjectEntryView {
4238 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
4239 let settings = ProjectPanelSettings::get_global(cx);
4240 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
4241
4242 h_flex().font(ui_font).map(|this| {
4243 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
4244 this.flex_none()
4245 .w(self.width)
4246 .child(div().w(self.click_offset.x))
4247 .child(
4248 div()
4249 .p_1()
4250 .rounded_xl()
4251 .bg(cx.theme().colors().background)
4252 .child(Label::new(format!("{} entries", self.selections.len()))),
4253 )
4254 } else {
4255 this.w(self.width).bg(cx.theme().colors().background).child(
4256 ListItem::new(self.selection.entry_id.to_proto() as usize)
4257 .indent_level(self.details.depth)
4258 .indent_step_size(px(settings.indent_size))
4259 .child(if let Some(icon) = &self.details.icon {
4260 div().child(Icon::from_path(icon.clone()))
4261 } else {
4262 div()
4263 })
4264 .child(Label::new(self.details.filename.clone())),
4265 )
4266 }
4267 })
4268 }
4269}
4270
4271impl EventEmitter<Event> for ProjectPanel {}
4272
4273impl EventEmitter<PanelEvent> for ProjectPanel {}
4274
4275impl Panel for ProjectPanel {
4276 fn position(&self, cx: &WindowContext) -> DockPosition {
4277 match ProjectPanelSettings::get_global(cx).dock {
4278 ProjectPanelDockPosition::Left => DockPosition::Left,
4279 ProjectPanelDockPosition::Right => DockPosition::Right,
4280 }
4281 }
4282
4283 fn position_is_valid(&self, position: DockPosition) -> bool {
4284 matches!(position, DockPosition::Left | DockPosition::Right)
4285 }
4286
4287 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
4288 settings::update_settings_file::<ProjectPanelSettings>(
4289 self.fs.clone(),
4290 cx,
4291 move |settings, _| {
4292 let dock = match position {
4293 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
4294 DockPosition::Right => ProjectPanelDockPosition::Right,
4295 };
4296 settings.dock = Some(dock);
4297 },
4298 );
4299 }
4300
4301 fn size(&self, cx: &WindowContext) -> Pixels {
4302 self.width
4303 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
4304 }
4305
4306 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
4307 self.width = size;
4308 self.serialize(cx);
4309 cx.notify();
4310 }
4311
4312 fn icon(&self, cx: &WindowContext) -> Option<IconName> {
4313 ProjectPanelSettings::get_global(cx)
4314 .button
4315 .then_some(IconName::FileTree)
4316 }
4317
4318 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
4319 Some("Project Panel")
4320 }
4321
4322 fn toggle_action(&self) -> Box<dyn Action> {
4323 Box::new(ToggleFocus)
4324 }
4325
4326 fn persistent_name() -> &'static str {
4327 "Project Panel"
4328 }
4329
4330 fn starts_open(&self, cx: &WindowContext) -> bool {
4331 let project = &self.project.read(cx);
4332 project.visible_worktrees(cx).any(|tree| {
4333 tree.read(cx)
4334 .root_entry()
4335 .map_or(false, |entry| entry.is_dir())
4336 })
4337 }
4338
4339 fn activation_priority(&self) -> u32 {
4340 0
4341 }
4342}
4343
4344impl FocusableView for ProjectPanel {
4345 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
4346 self.focus_handle.clone()
4347 }
4348}
4349
4350impl ClipboardEntry {
4351 fn is_cut(&self) -> bool {
4352 matches!(self, Self::Cut { .. })
4353 }
4354
4355 fn items(&self) -> &BTreeSet<SelectedEntry> {
4356 match self {
4357 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
4358 }
4359 }
4360}
4361
4362#[cfg(test)]
4363mod tests {
4364 use super::*;
4365 use collections::HashSet;
4366 use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
4367 use pretty_assertions::assert_eq;
4368 use project::{FakeFs, WorktreeSettings};
4369 use serde_json::json;
4370 use settings::SettingsStore;
4371 use std::path::{Path, PathBuf};
4372 use workspace::{
4373 item::{Item, ProjectItem},
4374 register_project_item, AppState,
4375 };
4376
4377 #[gpui::test]
4378 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
4379 init_test(cx);
4380
4381 let fs = FakeFs::new(cx.executor().clone());
4382 fs.insert_tree(
4383 "/root1",
4384 json!({
4385 ".dockerignore": "",
4386 ".git": {
4387 "HEAD": "",
4388 },
4389 "a": {
4390 "0": { "q": "", "r": "", "s": "" },
4391 "1": { "t": "", "u": "" },
4392 "2": { "v": "", "w": "", "x": "", "y": "" },
4393 },
4394 "b": {
4395 "3": { "Q": "" },
4396 "4": { "R": "", "S": "", "T": "", "U": "" },
4397 },
4398 "C": {
4399 "5": {},
4400 "6": { "V": "", "W": "" },
4401 "7": { "X": "" },
4402 "8": { "Y": {}, "Z": "" }
4403 }
4404 }),
4405 )
4406 .await;
4407 fs.insert_tree(
4408 "/root2",
4409 json!({
4410 "d": {
4411 "9": ""
4412 },
4413 "e": {}
4414 }),
4415 )
4416 .await;
4417
4418 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4419 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4420 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4421 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4422 assert_eq!(
4423 visible_entries_as_strings(&panel, 0..50, cx),
4424 &[
4425 "v root1",
4426 " > .git",
4427 " > a",
4428 " > b",
4429 " > C",
4430 " .dockerignore",
4431 "v root2",
4432 " > d",
4433 " > e",
4434 ]
4435 );
4436
4437 toggle_expand_dir(&panel, "root1/b", cx);
4438 assert_eq!(
4439 visible_entries_as_strings(&panel, 0..50, cx),
4440 &[
4441 "v root1",
4442 " > .git",
4443 " > a",
4444 " v b <== selected",
4445 " > 3",
4446 " > 4",
4447 " > C",
4448 " .dockerignore",
4449 "v root2",
4450 " > d",
4451 " > e",
4452 ]
4453 );
4454
4455 assert_eq!(
4456 visible_entries_as_strings(&panel, 6..9, cx),
4457 &[
4458 //
4459 " > C",
4460 " .dockerignore",
4461 "v root2",
4462 ]
4463 );
4464 }
4465
4466 #[gpui::test]
4467 async fn test_opening_file(cx: &mut gpui::TestAppContext) {
4468 init_test_with_editor(cx);
4469
4470 let fs = FakeFs::new(cx.executor().clone());
4471 fs.insert_tree(
4472 "/src",
4473 json!({
4474 "test": {
4475 "first.rs": "// First Rust file",
4476 "second.rs": "// Second Rust file",
4477 "third.rs": "// Third Rust file",
4478 }
4479 }),
4480 )
4481 .await;
4482
4483 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4484 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4485 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4486 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4487
4488 toggle_expand_dir(&panel, "src/test", cx);
4489 select_path(&panel, "src/test/first.rs", cx);
4490 panel.update(cx, |panel, cx| panel.open(&Open, cx));
4491 cx.executor().run_until_parked();
4492 assert_eq!(
4493 visible_entries_as_strings(&panel, 0..10, cx),
4494 &[
4495 "v src",
4496 " v test",
4497 " first.rs <== selected <== marked",
4498 " second.rs",
4499 " third.rs"
4500 ]
4501 );
4502 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
4503
4504 select_path(&panel, "src/test/second.rs", cx);
4505 panel.update(cx, |panel, cx| panel.open(&Open, cx));
4506 cx.executor().run_until_parked();
4507 assert_eq!(
4508 visible_entries_as_strings(&panel, 0..10, cx),
4509 &[
4510 "v src",
4511 " v test",
4512 " first.rs",
4513 " second.rs <== selected <== marked",
4514 " third.rs"
4515 ]
4516 );
4517 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
4518 }
4519
4520 #[gpui::test]
4521 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
4522 init_test(cx);
4523 cx.update(|cx| {
4524 cx.update_global::<SettingsStore, _>(|store, cx| {
4525 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4526 worktree_settings.file_scan_exclusions =
4527 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
4528 });
4529 });
4530 });
4531
4532 let fs = FakeFs::new(cx.background_executor.clone());
4533 fs.insert_tree(
4534 "/root1",
4535 json!({
4536 ".dockerignore": "",
4537 ".git": {
4538 "HEAD": "",
4539 },
4540 "a": {
4541 "0": { "q": "", "r": "", "s": "" },
4542 "1": { "t": "", "u": "" },
4543 "2": { "v": "", "w": "", "x": "", "y": "" },
4544 },
4545 "b": {
4546 "3": { "Q": "" },
4547 "4": { "R": "", "S": "", "T": "", "U": "" },
4548 },
4549 "C": {
4550 "5": {},
4551 "6": { "V": "", "W": "" },
4552 "7": { "X": "" },
4553 "8": { "Y": {}, "Z": "" }
4554 }
4555 }),
4556 )
4557 .await;
4558 fs.insert_tree(
4559 "/root2",
4560 json!({
4561 "d": {
4562 "4": ""
4563 },
4564 "e": {}
4565 }),
4566 )
4567 .await;
4568
4569 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4570 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4571 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4572 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4573 assert_eq!(
4574 visible_entries_as_strings(&panel, 0..50, cx),
4575 &[
4576 "v root1",
4577 " > a",
4578 " > b",
4579 " > C",
4580 " .dockerignore",
4581 "v root2",
4582 " > d",
4583 " > e",
4584 ]
4585 );
4586
4587 toggle_expand_dir(&panel, "root1/b", cx);
4588 assert_eq!(
4589 visible_entries_as_strings(&panel, 0..50, cx),
4590 &[
4591 "v root1",
4592 " > a",
4593 " v b <== selected",
4594 " > 3",
4595 " > C",
4596 " .dockerignore",
4597 "v root2",
4598 " > d",
4599 " > e",
4600 ]
4601 );
4602
4603 toggle_expand_dir(&panel, "root2/d", cx);
4604 assert_eq!(
4605 visible_entries_as_strings(&panel, 0..50, cx),
4606 &[
4607 "v root1",
4608 " > a",
4609 " v b",
4610 " > 3",
4611 " > C",
4612 " .dockerignore",
4613 "v root2",
4614 " v d <== selected",
4615 " > e",
4616 ]
4617 );
4618
4619 toggle_expand_dir(&panel, "root2/e", cx);
4620 assert_eq!(
4621 visible_entries_as_strings(&panel, 0..50, cx),
4622 &[
4623 "v root1",
4624 " > a",
4625 " v b",
4626 " > 3",
4627 " > C",
4628 " .dockerignore",
4629 "v root2",
4630 " v d",
4631 " v e <== selected",
4632 ]
4633 );
4634 }
4635
4636 #[gpui::test]
4637 async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
4638 init_test(cx);
4639
4640 let fs = FakeFs::new(cx.executor().clone());
4641 fs.insert_tree(
4642 "/root1",
4643 json!({
4644 "dir_1": {
4645 "nested_dir_1": {
4646 "nested_dir_2": {
4647 "nested_dir_3": {
4648 "file_a.java": "// File contents",
4649 "file_b.java": "// File contents",
4650 "file_c.java": "// File contents",
4651 "nested_dir_4": {
4652 "nested_dir_5": {
4653 "file_d.java": "// File contents",
4654 }
4655 }
4656 }
4657 }
4658 }
4659 }
4660 }),
4661 )
4662 .await;
4663 fs.insert_tree(
4664 "/root2",
4665 json!({
4666 "dir_2": {
4667 "file_1.java": "// File contents",
4668 }
4669 }),
4670 )
4671 .await;
4672
4673 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4674 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4675 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4676 cx.update(|cx| {
4677 let settings = *ProjectPanelSettings::get_global(cx);
4678 ProjectPanelSettings::override_global(
4679 ProjectPanelSettings {
4680 auto_fold_dirs: true,
4681 ..settings
4682 },
4683 cx,
4684 );
4685 });
4686 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4687 assert_eq!(
4688 visible_entries_as_strings(&panel, 0..10, cx),
4689 &[
4690 "v root1",
4691 " > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4692 "v root2",
4693 " > dir_2",
4694 ]
4695 );
4696
4697 toggle_expand_dir(
4698 &panel,
4699 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4700 cx,
4701 );
4702 assert_eq!(
4703 visible_entries_as_strings(&panel, 0..10, cx),
4704 &[
4705 "v root1",
4706 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
4707 " > nested_dir_4/nested_dir_5",
4708 " file_a.java",
4709 " file_b.java",
4710 " file_c.java",
4711 "v root2",
4712 " > dir_2",
4713 ]
4714 );
4715
4716 toggle_expand_dir(
4717 &panel,
4718 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
4719 cx,
4720 );
4721 assert_eq!(
4722 visible_entries_as_strings(&panel, 0..10, cx),
4723 &[
4724 "v root1",
4725 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4726 " v nested_dir_4/nested_dir_5 <== selected",
4727 " file_d.java",
4728 " file_a.java",
4729 " file_b.java",
4730 " file_c.java",
4731 "v root2",
4732 " > dir_2",
4733 ]
4734 );
4735 toggle_expand_dir(&panel, "root2/dir_2", cx);
4736 assert_eq!(
4737 visible_entries_as_strings(&panel, 0..10, cx),
4738 &[
4739 "v root1",
4740 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4741 " v nested_dir_4/nested_dir_5",
4742 " file_d.java",
4743 " file_a.java",
4744 " file_b.java",
4745 " file_c.java",
4746 "v root2",
4747 " v dir_2 <== selected",
4748 " file_1.java",
4749 ]
4750 );
4751 }
4752
4753 #[gpui::test(iterations = 30)]
4754 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
4755 init_test(cx);
4756
4757 let fs = FakeFs::new(cx.executor().clone());
4758 fs.insert_tree(
4759 "/root1",
4760 json!({
4761 ".dockerignore": "",
4762 ".git": {
4763 "HEAD": "",
4764 },
4765 "a": {
4766 "0": { "q": "", "r": "", "s": "" },
4767 "1": { "t": "", "u": "" },
4768 "2": { "v": "", "w": "", "x": "", "y": "" },
4769 },
4770 "b": {
4771 "3": { "Q": "" },
4772 "4": { "R": "", "S": "", "T": "", "U": "" },
4773 },
4774 "C": {
4775 "5": {},
4776 "6": { "V": "", "W": "" },
4777 "7": { "X": "" },
4778 "8": { "Y": {}, "Z": "" }
4779 }
4780 }),
4781 )
4782 .await;
4783 fs.insert_tree(
4784 "/root2",
4785 json!({
4786 "d": {
4787 "9": ""
4788 },
4789 "e": {}
4790 }),
4791 )
4792 .await;
4793
4794 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4795 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4796 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4797 let panel = workspace
4798 .update(cx, |workspace, cx| {
4799 let panel = ProjectPanel::new(workspace, cx);
4800 workspace.add_panel(panel.clone(), cx);
4801 panel
4802 })
4803 .unwrap();
4804
4805 select_path(&panel, "root1", cx);
4806 assert_eq!(
4807 visible_entries_as_strings(&panel, 0..10, cx),
4808 &[
4809 "v root1 <== selected",
4810 " > .git",
4811 " > a",
4812 " > b",
4813 " > C",
4814 " .dockerignore",
4815 "v root2",
4816 " > d",
4817 " > e",
4818 ]
4819 );
4820
4821 // Add a file with the root folder selected. The filename editor is placed
4822 // before the first file in the root folder.
4823 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4824 panel.update(cx, |panel, cx| {
4825 assert!(panel.filename_editor.read(cx).is_focused(cx));
4826 });
4827 assert_eq!(
4828 visible_entries_as_strings(&panel, 0..10, cx),
4829 &[
4830 "v root1",
4831 " > .git",
4832 " > a",
4833 " > b",
4834 " > C",
4835 " [EDITOR: ''] <== selected",
4836 " .dockerignore",
4837 "v root2",
4838 " > d",
4839 " > e",
4840 ]
4841 );
4842
4843 let confirm = panel.update(cx, |panel, cx| {
4844 panel
4845 .filename_editor
4846 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
4847 panel.confirm_edit(cx).unwrap()
4848 });
4849 assert_eq!(
4850 visible_entries_as_strings(&panel, 0..10, cx),
4851 &[
4852 "v root1",
4853 " > .git",
4854 " > a",
4855 " > b",
4856 " > C",
4857 " [PROCESSING: 'the-new-filename'] <== selected",
4858 " .dockerignore",
4859 "v root2",
4860 " > d",
4861 " > e",
4862 ]
4863 );
4864
4865 confirm.await.unwrap();
4866 assert_eq!(
4867 visible_entries_as_strings(&panel, 0..10, cx),
4868 &[
4869 "v root1",
4870 " > .git",
4871 " > a",
4872 " > b",
4873 " > C",
4874 " .dockerignore",
4875 " the-new-filename <== selected <== marked",
4876 "v root2",
4877 " > d",
4878 " > e",
4879 ]
4880 );
4881
4882 select_path(&panel, "root1/b", cx);
4883 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4884 assert_eq!(
4885 visible_entries_as_strings(&panel, 0..10, cx),
4886 &[
4887 "v root1",
4888 " > .git",
4889 " > a",
4890 " v b",
4891 " > 3",
4892 " > 4",
4893 " [EDITOR: ''] <== selected",
4894 " > C",
4895 " .dockerignore",
4896 " the-new-filename",
4897 ]
4898 );
4899
4900 panel
4901 .update(cx, |panel, cx| {
4902 panel
4903 .filename_editor
4904 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
4905 panel.confirm_edit(cx).unwrap()
4906 })
4907 .await
4908 .unwrap();
4909 assert_eq!(
4910 visible_entries_as_strings(&panel, 0..10, cx),
4911 &[
4912 "v root1",
4913 " > .git",
4914 " > a",
4915 " v b",
4916 " > 3",
4917 " > 4",
4918 " another-filename.txt <== selected <== marked",
4919 " > C",
4920 " .dockerignore",
4921 " the-new-filename",
4922 ]
4923 );
4924
4925 select_path(&panel, "root1/b/another-filename.txt", cx);
4926 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4927 assert_eq!(
4928 visible_entries_as_strings(&panel, 0..10, cx),
4929 &[
4930 "v root1",
4931 " > .git",
4932 " > a",
4933 " v b",
4934 " > 3",
4935 " > 4",
4936 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
4937 " > C",
4938 " .dockerignore",
4939 " the-new-filename",
4940 ]
4941 );
4942
4943 let confirm = panel.update(cx, |panel, cx| {
4944 panel.filename_editor.update(cx, |editor, cx| {
4945 let file_name_selections = editor.selections.all::<usize>(cx);
4946 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
4947 let file_name_selection = &file_name_selections[0];
4948 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
4949 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
4950
4951 editor.set_text("a-different-filename.tar.gz", cx)
4952 });
4953 panel.confirm_edit(cx).unwrap()
4954 });
4955 assert_eq!(
4956 visible_entries_as_strings(&panel, 0..10, cx),
4957 &[
4958 "v root1",
4959 " > .git",
4960 " > a",
4961 " v b",
4962 " > 3",
4963 " > 4",
4964 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
4965 " > C",
4966 " .dockerignore",
4967 " the-new-filename",
4968 ]
4969 );
4970
4971 confirm.await.unwrap();
4972 assert_eq!(
4973 visible_entries_as_strings(&panel, 0..10, cx),
4974 &[
4975 "v root1",
4976 " > .git",
4977 " > a",
4978 " v b",
4979 " > 3",
4980 " > 4",
4981 " a-different-filename.tar.gz <== selected",
4982 " > C",
4983 " .dockerignore",
4984 " the-new-filename",
4985 ]
4986 );
4987
4988 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4989 assert_eq!(
4990 visible_entries_as_strings(&panel, 0..10, cx),
4991 &[
4992 "v root1",
4993 " > .git",
4994 " > a",
4995 " v b",
4996 " > 3",
4997 " > 4",
4998 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
4999 " > C",
5000 " .dockerignore",
5001 " the-new-filename",
5002 ]
5003 );
5004
5005 panel.update(cx, |panel, cx| {
5006 panel.filename_editor.update(cx, |editor, cx| {
5007 let file_name_selections = editor.selections.all::<usize>(cx);
5008 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5009 let file_name_selection = &file_name_selections[0];
5010 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
5011 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..");
5012
5013 });
5014 panel.cancel(&menu::Cancel, cx)
5015 });
5016
5017 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
5018 assert_eq!(
5019 visible_entries_as_strings(&panel, 0..10, cx),
5020 &[
5021 "v root1",
5022 " > .git",
5023 " > a",
5024 " v b",
5025 " > 3",
5026 " > 4",
5027 " > [EDITOR: ''] <== selected",
5028 " a-different-filename.tar.gz",
5029 " > C",
5030 " .dockerignore",
5031 ]
5032 );
5033
5034 let confirm = panel.update(cx, |panel, cx| {
5035 panel
5036 .filename_editor
5037 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
5038 panel.confirm_edit(cx).unwrap()
5039 });
5040 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
5041 assert_eq!(
5042 visible_entries_as_strings(&panel, 0..10, cx),
5043 &[
5044 "v root1",
5045 " > .git",
5046 " > a",
5047 " v b",
5048 " > 3",
5049 " > 4",
5050 " > [PROCESSING: 'new-dir']",
5051 " a-different-filename.tar.gz <== selected",
5052 " > C",
5053 " .dockerignore",
5054 ]
5055 );
5056
5057 confirm.await.unwrap();
5058 assert_eq!(
5059 visible_entries_as_strings(&panel, 0..10, cx),
5060 &[
5061 "v root1",
5062 " > .git",
5063 " > a",
5064 " v b",
5065 " > 3",
5066 " > 4",
5067 " > new-dir",
5068 " a-different-filename.tar.gz <== selected",
5069 " > C",
5070 " .dockerignore",
5071 ]
5072 );
5073
5074 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
5075 assert_eq!(
5076 visible_entries_as_strings(&panel, 0..10, cx),
5077 &[
5078 "v root1",
5079 " > .git",
5080 " > a",
5081 " v b",
5082 " > 3",
5083 " > 4",
5084 " > new-dir",
5085 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
5086 " > C",
5087 " .dockerignore",
5088 ]
5089 );
5090
5091 // Dismiss the rename editor when it loses focus.
5092 workspace.update(cx, |_, cx| cx.blur()).unwrap();
5093 assert_eq!(
5094 visible_entries_as_strings(&panel, 0..10, cx),
5095 &[
5096 "v root1",
5097 " > .git",
5098 " > a",
5099 " v b",
5100 " > 3",
5101 " > 4",
5102 " > new-dir",
5103 " a-different-filename.tar.gz <== selected",
5104 " > C",
5105 " .dockerignore",
5106 ]
5107 );
5108 }
5109
5110 #[gpui::test(iterations = 10)]
5111 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
5112 init_test(cx);
5113
5114 let fs = FakeFs::new(cx.executor().clone());
5115 fs.insert_tree(
5116 "/root1",
5117 json!({
5118 ".dockerignore": "",
5119 ".git": {
5120 "HEAD": "",
5121 },
5122 "a": {
5123 "0": { "q": "", "r": "", "s": "" },
5124 "1": { "t": "", "u": "" },
5125 "2": { "v": "", "w": "", "x": "", "y": "" },
5126 },
5127 "b": {
5128 "3": { "Q": "" },
5129 "4": { "R": "", "S": "", "T": "", "U": "" },
5130 },
5131 "C": {
5132 "5": {},
5133 "6": { "V": "", "W": "" },
5134 "7": { "X": "" },
5135 "8": { "Y": {}, "Z": "" }
5136 }
5137 }),
5138 )
5139 .await;
5140 fs.insert_tree(
5141 "/root2",
5142 json!({
5143 "d": {
5144 "9": ""
5145 },
5146 "e": {}
5147 }),
5148 )
5149 .await;
5150
5151 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5152 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5153 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5154 let panel = workspace
5155 .update(cx, |workspace, cx| {
5156 let panel = ProjectPanel::new(workspace, cx);
5157 workspace.add_panel(panel.clone(), cx);
5158 panel
5159 })
5160 .unwrap();
5161
5162 select_path(&panel, "root1", cx);
5163 assert_eq!(
5164 visible_entries_as_strings(&panel, 0..10, cx),
5165 &[
5166 "v root1 <== selected",
5167 " > .git",
5168 " > a",
5169 " > b",
5170 " > C",
5171 " .dockerignore",
5172 "v root2",
5173 " > d",
5174 " > e",
5175 ]
5176 );
5177
5178 // Add a file with the root folder selected. The filename editor is placed
5179 // before the first file in the root folder.
5180 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5181 panel.update(cx, |panel, cx| {
5182 assert!(panel.filename_editor.read(cx).is_focused(cx));
5183 });
5184 assert_eq!(
5185 visible_entries_as_strings(&panel, 0..10, cx),
5186 &[
5187 "v root1",
5188 " > .git",
5189 " > a",
5190 " > b",
5191 " > C",
5192 " [EDITOR: ''] <== selected",
5193 " .dockerignore",
5194 "v root2",
5195 " > d",
5196 " > e",
5197 ]
5198 );
5199
5200 let confirm = panel.update(cx, |panel, cx| {
5201 panel.filename_editor.update(cx, |editor, cx| {
5202 editor.set_text("/bdir1/dir2/the-new-filename", cx)
5203 });
5204 panel.confirm_edit(cx).unwrap()
5205 });
5206
5207 assert_eq!(
5208 visible_entries_as_strings(&panel, 0..10, cx),
5209 &[
5210 "v root1",
5211 " > .git",
5212 " > a",
5213 " > b",
5214 " > C",
5215 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
5216 " .dockerignore",
5217 "v root2",
5218 " > d",
5219 " > e",
5220 ]
5221 );
5222
5223 confirm.await.unwrap();
5224 assert_eq!(
5225 visible_entries_as_strings(&panel, 0..13, cx),
5226 &[
5227 "v root1",
5228 " > .git",
5229 " > a",
5230 " > b",
5231 " v bdir1",
5232 " v dir2",
5233 " the-new-filename <== selected <== marked",
5234 " > C",
5235 " .dockerignore",
5236 "v root2",
5237 " > d",
5238 " > e",
5239 ]
5240 );
5241 }
5242
5243 #[gpui::test]
5244 async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
5245 init_test(cx);
5246
5247 let fs = FakeFs::new(cx.executor().clone());
5248 fs.insert_tree(
5249 "/root1",
5250 json!({
5251 ".dockerignore": "",
5252 ".git": {
5253 "HEAD": "",
5254 },
5255 }),
5256 )
5257 .await;
5258
5259 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5260 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5261 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5262 let panel = workspace
5263 .update(cx, |workspace, cx| {
5264 let panel = ProjectPanel::new(workspace, cx);
5265 workspace.add_panel(panel.clone(), cx);
5266 panel
5267 })
5268 .unwrap();
5269
5270 select_path(&panel, "root1", cx);
5271 assert_eq!(
5272 visible_entries_as_strings(&panel, 0..10, cx),
5273 &["v root1 <== selected", " > .git", " .dockerignore",]
5274 );
5275
5276 // Add a file with the root folder selected. The filename editor is placed
5277 // before the first file in the root folder.
5278 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5279 panel.update(cx, |panel, cx| {
5280 assert!(panel.filename_editor.read(cx).is_focused(cx));
5281 });
5282 assert_eq!(
5283 visible_entries_as_strings(&panel, 0..10, cx),
5284 &[
5285 "v root1",
5286 " > .git",
5287 " [EDITOR: ''] <== selected",
5288 " .dockerignore",
5289 ]
5290 );
5291
5292 let confirm = panel.update(cx, |panel, cx| {
5293 panel
5294 .filename_editor
5295 .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
5296 panel.confirm_edit(cx).unwrap()
5297 });
5298
5299 assert_eq!(
5300 visible_entries_as_strings(&panel, 0..10, cx),
5301 &[
5302 "v root1",
5303 " > .git",
5304 " [PROCESSING: '/new_dir/'] <== selected",
5305 " .dockerignore",
5306 ]
5307 );
5308
5309 confirm.await.unwrap();
5310 assert_eq!(
5311 visible_entries_as_strings(&panel, 0..13, cx),
5312 &[
5313 "v root1",
5314 " > .git",
5315 " v new_dir <== selected",
5316 " .dockerignore",
5317 ]
5318 );
5319 }
5320
5321 #[gpui::test]
5322 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
5323 init_test(cx);
5324
5325 let fs = FakeFs::new(cx.executor().clone());
5326 fs.insert_tree(
5327 "/root1",
5328 json!({
5329 "one.two.txt": "",
5330 "one.txt": ""
5331 }),
5332 )
5333 .await;
5334
5335 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5336 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5337 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5338 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5339
5340 panel.update(cx, |panel, cx| {
5341 panel.select_next(&Default::default(), cx);
5342 panel.select_next(&Default::default(), cx);
5343 });
5344
5345 assert_eq!(
5346 visible_entries_as_strings(&panel, 0..50, cx),
5347 &[
5348 //
5349 "v root1",
5350 " one.txt <== selected",
5351 " one.two.txt",
5352 ]
5353 );
5354
5355 // Regression test - file name is created correctly when
5356 // the copied file's name contains multiple dots.
5357 panel.update(cx, |panel, cx| {
5358 panel.copy(&Default::default(), cx);
5359 panel.paste(&Default::default(), cx);
5360 });
5361 cx.executor().run_until_parked();
5362
5363 assert_eq!(
5364 visible_entries_as_strings(&panel, 0..50, cx),
5365 &[
5366 //
5367 "v root1",
5368 " one.txt",
5369 " [EDITOR: 'one copy.txt'] <== selected",
5370 " one.two.txt",
5371 ]
5372 );
5373
5374 panel.update(cx, |panel, cx| {
5375 panel.filename_editor.update(cx, |editor, cx| {
5376 let file_name_selections = editor.selections.all::<usize>(cx);
5377 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5378 let file_name_selection = &file_name_selections[0];
5379 assert_eq!(file_name_selection.start, "one".len(), "Should select the file name disambiguation after the original file name");
5380 assert_eq!(file_name_selection.end, "one copy".len(), "Should select the file name disambiguation until the extension");
5381 });
5382 assert!(panel.confirm_edit(cx).is_none());
5383 });
5384
5385 panel.update(cx, |panel, cx| {
5386 panel.paste(&Default::default(), cx);
5387 });
5388 cx.executor().run_until_parked();
5389
5390 assert_eq!(
5391 visible_entries_as_strings(&panel, 0..50, cx),
5392 &[
5393 //
5394 "v root1",
5395 " one.txt",
5396 " one copy.txt",
5397 " [EDITOR: 'one copy 1.txt'] <== selected",
5398 " one.two.txt",
5399 ]
5400 );
5401
5402 panel.update(cx, |panel, cx| assert!(panel.confirm_edit(cx).is_none()));
5403 }
5404
5405 #[gpui::test]
5406 async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5407 init_test(cx);
5408
5409 let fs = FakeFs::new(cx.executor().clone());
5410 fs.insert_tree(
5411 "/root1",
5412 json!({
5413 "one.txt": "",
5414 "two.txt": "",
5415 "three.txt": "",
5416 "a": {
5417 "0": { "q": "", "r": "", "s": "" },
5418 "1": { "t": "", "u": "" },
5419 "2": { "v": "", "w": "", "x": "", "y": "" },
5420 },
5421 }),
5422 )
5423 .await;
5424
5425 fs.insert_tree(
5426 "/root2",
5427 json!({
5428 "one.txt": "",
5429 "two.txt": "",
5430 "four.txt": "",
5431 "b": {
5432 "3": { "Q": "" },
5433 "4": { "R": "", "S": "", "T": "", "U": "" },
5434 },
5435 }),
5436 )
5437 .await;
5438
5439 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5440 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5441 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5442 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5443
5444 select_path(&panel, "root1/three.txt", cx);
5445 panel.update(cx, |panel, cx| {
5446 panel.cut(&Default::default(), cx);
5447 });
5448
5449 select_path(&panel, "root2/one.txt", cx);
5450 panel.update(cx, |panel, cx| {
5451 panel.select_next(&Default::default(), cx);
5452 panel.paste(&Default::default(), cx);
5453 });
5454 cx.executor().run_until_parked();
5455 assert_eq!(
5456 visible_entries_as_strings(&panel, 0..50, cx),
5457 &[
5458 //
5459 "v root1",
5460 " > a",
5461 " one.txt",
5462 " two.txt",
5463 "v root2",
5464 " > b",
5465 " four.txt",
5466 " one.txt",
5467 " three.txt <== selected",
5468 " two.txt",
5469 ]
5470 );
5471
5472 select_path(&panel, "root1/a", cx);
5473 panel.update(cx, |panel, cx| {
5474 panel.cut(&Default::default(), cx);
5475 });
5476 select_path(&panel, "root2/two.txt", cx);
5477 panel.update(cx, |panel, cx| {
5478 panel.select_next(&Default::default(), cx);
5479 panel.paste(&Default::default(), cx);
5480 });
5481
5482 cx.executor().run_until_parked();
5483 assert_eq!(
5484 visible_entries_as_strings(&panel, 0..50, cx),
5485 &[
5486 //
5487 "v root1",
5488 " one.txt",
5489 " two.txt",
5490 "v root2",
5491 " > a <== selected",
5492 " > b",
5493 " four.txt",
5494 " one.txt",
5495 " three.txt",
5496 " two.txt",
5497 ]
5498 );
5499 }
5500
5501 #[gpui::test]
5502 async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5503 init_test(cx);
5504
5505 let fs = FakeFs::new(cx.executor().clone());
5506 fs.insert_tree(
5507 "/root1",
5508 json!({
5509 "one.txt": "",
5510 "two.txt": "",
5511 "three.txt": "",
5512 "a": {
5513 "0": { "q": "", "r": "", "s": "" },
5514 "1": { "t": "", "u": "" },
5515 "2": { "v": "", "w": "", "x": "", "y": "" },
5516 },
5517 }),
5518 )
5519 .await;
5520
5521 fs.insert_tree(
5522 "/root2",
5523 json!({
5524 "one.txt": "",
5525 "two.txt": "",
5526 "four.txt": "",
5527 "b": {
5528 "3": { "Q": "" },
5529 "4": { "R": "", "S": "", "T": "", "U": "" },
5530 },
5531 }),
5532 )
5533 .await;
5534
5535 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5536 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5537 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5538 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5539
5540 select_path(&panel, "root1/three.txt", cx);
5541 panel.update(cx, |panel, cx| {
5542 panel.copy(&Default::default(), cx);
5543 });
5544
5545 select_path(&panel, "root2/one.txt", cx);
5546 panel.update(cx, |panel, cx| {
5547 panel.select_next(&Default::default(), cx);
5548 panel.paste(&Default::default(), cx);
5549 });
5550 cx.executor().run_until_parked();
5551 assert_eq!(
5552 visible_entries_as_strings(&panel, 0..50, cx),
5553 &[
5554 //
5555 "v root1",
5556 " > a",
5557 " one.txt",
5558 " three.txt",
5559 " two.txt",
5560 "v root2",
5561 " > b",
5562 " four.txt",
5563 " one.txt",
5564 " three.txt <== selected",
5565 " two.txt",
5566 ]
5567 );
5568
5569 select_path(&panel, "root1/three.txt", cx);
5570 panel.update(cx, |panel, cx| {
5571 panel.copy(&Default::default(), cx);
5572 });
5573 select_path(&panel, "root2/two.txt", cx);
5574 panel.update(cx, |panel, cx| {
5575 panel.select_next(&Default::default(), cx);
5576 panel.paste(&Default::default(), cx);
5577 });
5578
5579 cx.executor().run_until_parked();
5580 assert_eq!(
5581 visible_entries_as_strings(&panel, 0..50, cx),
5582 &[
5583 //
5584 "v root1",
5585 " > a",
5586 " one.txt",
5587 " three.txt",
5588 " two.txt",
5589 "v root2",
5590 " > b",
5591 " four.txt",
5592 " one.txt",
5593 " three.txt",
5594 " [EDITOR: 'three copy.txt'] <== selected",
5595 " two.txt",
5596 ]
5597 );
5598
5599 panel.update(cx, |panel, cx| panel.cancel(&menu::Cancel {}, cx));
5600 cx.executor().run_until_parked();
5601
5602 select_path(&panel, "root1/a", cx);
5603 panel.update(cx, |panel, cx| {
5604 panel.copy(&Default::default(), cx);
5605 });
5606 select_path(&panel, "root2/two.txt", cx);
5607 panel.update(cx, |panel, cx| {
5608 panel.select_next(&Default::default(), cx);
5609 panel.paste(&Default::default(), cx);
5610 });
5611
5612 cx.executor().run_until_parked();
5613 assert_eq!(
5614 visible_entries_as_strings(&panel, 0..50, cx),
5615 &[
5616 //
5617 "v root1",
5618 " > a",
5619 " one.txt",
5620 " three.txt",
5621 " two.txt",
5622 "v root2",
5623 " > a <== selected",
5624 " > b",
5625 " four.txt",
5626 " one.txt",
5627 " three.txt",
5628 " three copy.txt",
5629 " two.txt",
5630 ]
5631 );
5632 }
5633
5634 #[gpui::test]
5635 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
5636 init_test(cx);
5637
5638 let fs = FakeFs::new(cx.executor().clone());
5639 fs.insert_tree(
5640 "/root",
5641 json!({
5642 "a": {
5643 "one.txt": "",
5644 "two.txt": "",
5645 "inner_dir": {
5646 "three.txt": "",
5647 "four.txt": "",
5648 }
5649 },
5650 "b": {}
5651 }),
5652 )
5653 .await;
5654
5655 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5656 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5657 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5658 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5659
5660 select_path(&panel, "root/a", cx);
5661 panel.update(cx, |panel, cx| {
5662 panel.copy(&Default::default(), cx);
5663 panel.select_next(&Default::default(), cx);
5664 panel.paste(&Default::default(), cx);
5665 });
5666 cx.executor().run_until_parked();
5667
5668 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
5669 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
5670
5671 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
5672 assert_ne!(
5673 pasted_dir_file, None,
5674 "Pasted directory file should have an entry"
5675 );
5676
5677 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
5678 assert_ne!(
5679 pasted_dir_inner_dir, None,
5680 "Directories inside pasted directory should have an entry"
5681 );
5682
5683 toggle_expand_dir(&panel, "root/b/a", cx);
5684 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
5685
5686 assert_eq!(
5687 visible_entries_as_strings(&panel, 0..50, cx),
5688 &[
5689 //
5690 "v root",
5691 " > a",
5692 " v b",
5693 " v a",
5694 " v inner_dir <== selected",
5695 " four.txt",
5696 " three.txt",
5697 " one.txt",
5698 " two.txt",
5699 ]
5700 );
5701
5702 select_path(&panel, "root", cx);
5703 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
5704 cx.executor().run_until_parked();
5705 assert_eq!(
5706 visible_entries_as_strings(&panel, 0..50, cx),
5707 &[
5708 //
5709 "v root",
5710 " > a",
5711 " > [EDITOR: 'a copy'] <== selected",
5712 " v b",
5713 " v a",
5714 " v inner_dir",
5715 " four.txt",
5716 " three.txt",
5717 " one.txt",
5718 " two.txt"
5719 ]
5720 );
5721
5722 let confirm = panel.update(cx, |panel, cx| {
5723 panel
5724 .filename_editor
5725 .update(cx, |editor, cx| editor.set_text("c", cx));
5726 panel.confirm_edit(cx).unwrap()
5727 });
5728 assert_eq!(
5729 visible_entries_as_strings(&panel, 0..50, cx),
5730 &[
5731 //
5732 "v root",
5733 " > a",
5734 " > [PROCESSING: 'c'] <== selected",
5735 " v b",
5736 " v a",
5737 " v inner_dir",
5738 " four.txt",
5739 " three.txt",
5740 " one.txt",
5741 " two.txt"
5742 ]
5743 );
5744
5745 confirm.await.unwrap();
5746
5747 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
5748 cx.executor().run_until_parked();
5749 assert_eq!(
5750 visible_entries_as_strings(&panel, 0..50, cx),
5751 &[
5752 //
5753 "v root",
5754 " > a",
5755 " v b",
5756 " v a",
5757 " v inner_dir",
5758 " four.txt",
5759 " three.txt",
5760 " one.txt",
5761 " two.txt",
5762 " v c",
5763 " > a <== selected",
5764 " > inner_dir",
5765 " one.txt",
5766 " two.txt",
5767 ]
5768 );
5769 }
5770
5771 #[gpui::test]
5772 async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
5773 init_test(cx);
5774
5775 let fs = FakeFs::new(cx.executor().clone());
5776 fs.insert_tree(
5777 "/test",
5778 json!({
5779 "dir1": {
5780 "a.txt": "",
5781 "b.txt": "",
5782 },
5783 "dir2": {},
5784 "c.txt": "",
5785 "d.txt": "",
5786 }),
5787 )
5788 .await;
5789
5790 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
5791 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5792 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5793 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5794
5795 toggle_expand_dir(&panel, "test/dir1", cx);
5796
5797 cx.simulate_modifiers_change(gpui::Modifiers {
5798 control: true,
5799 ..Default::default()
5800 });
5801
5802 select_path_with_mark(&panel, "test/dir1", cx);
5803 select_path_with_mark(&panel, "test/c.txt", cx);
5804
5805 assert_eq!(
5806 visible_entries_as_strings(&panel, 0..15, cx),
5807 &[
5808 "v test",
5809 " v dir1 <== marked",
5810 " a.txt",
5811 " b.txt",
5812 " > dir2",
5813 " c.txt <== selected <== marked",
5814 " d.txt",
5815 ],
5816 "Initial state before copying dir1 and c.txt"
5817 );
5818
5819 panel.update(cx, |panel, cx| {
5820 panel.copy(&Default::default(), cx);
5821 });
5822 select_path(&panel, "test/dir2", cx);
5823 panel.update(cx, |panel, cx| {
5824 panel.paste(&Default::default(), cx);
5825 });
5826 cx.executor().run_until_parked();
5827
5828 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
5829
5830 assert_eq!(
5831 visible_entries_as_strings(&panel, 0..15, cx),
5832 &[
5833 "v test",
5834 " v dir1 <== marked",
5835 " a.txt",
5836 " b.txt",
5837 " v dir2",
5838 " v dir1 <== selected",
5839 " a.txt",
5840 " b.txt",
5841 " c.txt",
5842 " c.txt <== marked",
5843 " d.txt",
5844 ],
5845 "Should copy dir1 as well as c.txt into dir2"
5846 );
5847
5848 // Disambiguating multiple files should not open the rename editor.
5849 select_path(&panel, "test/dir2", cx);
5850 panel.update(cx, |panel, cx| {
5851 panel.paste(&Default::default(), cx);
5852 });
5853 cx.executor().run_until_parked();
5854
5855 assert_eq!(
5856 visible_entries_as_strings(&panel, 0..15, cx),
5857 &[
5858 "v test",
5859 " v dir1 <== marked",
5860 " a.txt",
5861 " b.txt",
5862 " v dir2",
5863 " v dir1",
5864 " a.txt",
5865 " b.txt",
5866 " > dir1 copy <== selected",
5867 " c.txt",
5868 " c copy.txt",
5869 " c.txt <== marked",
5870 " d.txt",
5871 ],
5872 "Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor"
5873 );
5874 }
5875
5876 #[gpui::test]
5877 async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
5878 init_test(cx);
5879
5880 let fs = FakeFs::new(cx.executor().clone());
5881 fs.insert_tree(
5882 "/test",
5883 json!({
5884 "dir1": {
5885 "a.txt": "",
5886 "b.txt": "",
5887 },
5888 "dir2": {},
5889 "c.txt": "",
5890 "d.txt": "",
5891 }),
5892 )
5893 .await;
5894
5895 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
5896 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5897 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5898 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5899
5900 toggle_expand_dir(&panel, "test/dir1", cx);
5901
5902 cx.simulate_modifiers_change(gpui::Modifiers {
5903 control: true,
5904 ..Default::default()
5905 });
5906
5907 select_path_with_mark(&panel, "test/dir1/a.txt", cx);
5908 select_path_with_mark(&panel, "test/dir1", cx);
5909 select_path_with_mark(&panel, "test/c.txt", cx);
5910
5911 assert_eq!(
5912 visible_entries_as_strings(&panel, 0..15, cx),
5913 &[
5914 "v test",
5915 " v dir1 <== marked",
5916 " a.txt <== marked",
5917 " b.txt",
5918 " > dir2",
5919 " c.txt <== selected <== marked",
5920 " d.txt",
5921 ],
5922 "Initial state before copying a.txt, dir1 and c.txt"
5923 );
5924
5925 panel.update(cx, |panel, cx| {
5926 panel.copy(&Default::default(), cx);
5927 });
5928 select_path(&panel, "test/dir2", cx);
5929 panel.update(cx, |panel, cx| {
5930 panel.paste(&Default::default(), cx);
5931 });
5932 cx.executor().run_until_parked();
5933
5934 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
5935
5936 assert_eq!(
5937 visible_entries_as_strings(&panel, 0..20, cx),
5938 &[
5939 "v test",
5940 " v dir1 <== marked",
5941 " a.txt <== marked",
5942 " b.txt",
5943 " v dir2",
5944 " v dir1 <== selected",
5945 " a.txt",
5946 " b.txt",
5947 " c.txt",
5948 " c.txt <== marked",
5949 " d.txt",
5950 ],
5951 "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
5952 );
5953 }
5954
5955 #[gpui::test]
5956 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
5957 init_test_with_editor(cx);
5958
5959 let fs = FakeFs::new(cx.executor().clone());
5960 fs.insert_tree(
5961 "/src",
5962 json!({
5963 "test": {
5964 "first.rs": "// First Rust file",
5965 "second.rs": "// Second Rust file",
5966 "third.rs": "// Third Rust file",
5967 }
5968 }),
5969 )
5970 .await;
5971
5972 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5973 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5974 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5975 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5976
5977 toggle_expand_dir(&panel, "src/test", cx);
5978 select_path(&panel, "src/test/first.rs", cx);
5979 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5980 cx.executor().run_until_parked();
5981 assert_eq!(
5982 visible_entries_as_strings(&panel, 0..10, cx),
5983 &[
5984 "v src",
5985 " v test",
5986 " first.rs <== selected <== marked",
5987 " second.rs",
5988 " third.rs"
5989 ]
5990 );
5991 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
5992
5993 submit_deletion(&panel, cx);
5994 assert_eq!(
5995 visible_entries_as_strings(&panel, 0..10, cx),
5996 &[
5997 "v src",
5998 " v test",
5999 " second.rs <== selected",
6000 " third.rs"
6001 ],
6002 "Project panel should have no deleted file, no other file is selected in it"
6003 );
6004 ensure_no_open_items_and_panes(&workspace, cx);
6005
6006 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6007 cx.executor().run_until_parked();
6008 assert_eq!(
6009 visible_entries_as_strings(&panel, 0..10, cx),
6010 &[
6011 "v src",
6012 " v test",
6013 " second.rs <== selected <== marked",
6014 " third.rs"
6015 ]
6016 );
6017 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
6018
6019 workspace
6020 .update(cx, |workspace, cx| {
6021 let active_items = workspace
6022 .panes()
6023 .iter()
6024 .filter_map(|pane| pane.read(cx).active_item())
6025 .collect::<Vec<_>>();
6026 assert_eq!(active_items.len(), 1);
6027 let open_editor = active_items
6028 .into_iter()
6029 .next()
6030 .unwrap()
6031 .downcast::<Editor>()
6032 .expect("Open item should be an editor");
6033 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
6034 })
6035 .unwrap();
6036 submit_deletion_skipping_prompt(&panel, cx);
6037 assert_eq!(
6038 visible_entries_as_strings(&panel, 0..10, cx),
6039 &["v src", " v test", " third.rs <== selected"],
6040 "Project panel should have no deleted file, with one last file remaining"
6041 );
6042 ensure_no_open_items_and_panes(&workspace, cx);
6043 }
6044
6045 #[gpui::test]
6046 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
6047 init_test_with_editor(cx);
6048
6049 let fs = FakeFs::new(cx.executor().clone());
6050 fs.insert_tree(
6051 "/src",
6052 json!({
6053 "test": {
6054 "first.rs": "// First Rust file",
6055 "second.rs": "// Second Rust file",
6056 "third.rs": "// Third Rust file",
6057 }
6058 }),
6059 )
6060 .await;
6061
6062 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
6063 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6064 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6065 let panel = workspace
6066 .update(cx, |workspace, cx| {
6067 let panel = ProjectPanel::new(workspace, cx);
6068 workspace.add_panel(panel.clone(), cx);
6069 panel
6070 })
6071 .unwrap();
6072
6073 select_path(&panel, "src/", cx);
6074 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6075 cx.executor().run_until_parked();
6076 assert_eq!(
6077 visible_entries_as_strings(&panel, 0..10, cx),
6078 &[
6079 //
6080 "v src <== selected",
6081 " > test"
6082 ]
6083 );
6084 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
6085 panel.update(cx, |panel, cx| {
6086 assert!(panel.filename_editor.read(cx).is_focused(cx));
6087 });
6088 assert_eq!(
6089 visible_entries_as_strings(&panel, 0..10, cx),
6090 &[
6091 //
6092 "v src",
6093 " > [EDITOR: ''] <== selected",
6094 " > test"
6095 ]
6096 );
6097 panel.update(cx, |panel, cx| {
6098 panel
6099 .filename_editor
6100 .update(cx, |editor, cx| editor.set_text("test", cx));
6101 assert!(
6102 panel.confirm_edit(cx).is_none(),
6103 "Should not allow to confirm on conflicting new directory name"
6104 )
6105 });
6106 assert_eq!(
6107 visible_entries_as_strings(&panel, 0..10, cx),
6108 &[
6109 //
6110 "v src",
6111 " > test"
6112 ],
6113 "File list should be unchanged after failed folder create confirmation"
6114 );
6115
6116 select_path(&panel, "src/test/", cx);
6117 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6118 cx.executor().run_until_parked();
6119 assert_eq!(
6120 visible_entries_as_strings(&panel, 0..10, cx),
6121 &[
6122 //
6123 "v src",
6124 " > test <== selected"
6125 ]
6126 );
6127 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
6128 panel.update(cx, |panel, cx| {
6129 assert!(panel.filename_editor.read(cx).is_focused(cx));
6130 });
6131 assert_eq!(
6132 visible_entries_as_strings(&panel, 0..10, cx),
6133 &[
6134 "v src",
6135 " v test",
6136 " [EDITOR: ''] <== selected",
6137 " first.rs",
6138 " second.rs",
6139 " third.rs"
6140 ]
6141 );
6142 panel.update(cx, |panel, cx| {
6143 panel
6144 .filename_editor
6145 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
6146 assert!(
6147 panel.confirm_edit(cx).is_none(),
6148 "Should not allow to confirm on conflicting new file name"
6149 )
6150 });
6151 assert_eq!(
6152 visible_entries_as_strings(&panel, 0..10, cx),
6153 &[
6154 "v src",
6155 " v test",
6156 " first.rs",
6157 " second.rs",
6158 " third.rs"
6159 ],
6160 "File list should be unchanged after failed file create confirmation"
6161 );
6162
6163 select_path(&panel, "src/test/first.rs", cx);
6164 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6165 cx.executor().run_until_parked();
6166 assert_eq!(
6167 visible_entries_as_strings(&panel, 0..10, cx),
6168 &[
6169 "v src",
6170 " v test",
6171 " first.rs <== selected",
6172 " second.rs",
6173 " third.rs"
6174 ],
6175 );
6176 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
6177 panel.update(cx, |panel, cx| {
6178 assert!(panel.filename_editor.read(cx).is_focused(cx));
6179 });
6180 assert_eq!(
6181 visible_entries_as_strings(&panel, 0..10, cx),
6182 &[
6183 "v src",
6184 " v test",
6185 " [EDITOR: 'first.rs'] <== selected",
6186 " second.rs",
6187 " third.rs"
6188 ]
6189 );
6190 panel.update(cx, |panel, cx| {
6191 panel
6192 .filename_editor
6193 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
6194 assert!(
6195 panel.confirm_edit(cx).is_none(),
6196 "Should not allow to confirm on conflicting file rename"
6197 )
6198 });
6199 assert_eq!(
6200 visible_entries_as_strings(&panel, 0..10, cx),
6201 &[
6202 "v src",
6203 " v test",
6204 " first.rs <== selected",
6205 " second.rs",
6206 " third.rs"
6207 ],
6208 "File list should be unchanged after failed rename confirmation"
6209 );
6210 }
6211
6212 #[gpui::test]
6213 async fn test_select_directory(cx: &mut gpui::TestAppContext) {
6214 init_test_with_editor(cx);
6215
6216 let fs = FakeFs::new(cx.executor().clone());
6217 fs.insert_tree(
6218 "/project_root",
6219 json!({
6220 "dir_1": {
6221 "nested_dir": {
6222 "file_a.py": "# File contents",
6223 }
6224 },
6225 "file_1.py": "# File contents",
6226 "dir_2": {
6227
6228 },
6229 "dir_3": {
6230
6231 },
6232 "file_2.py": "# File contents",
6233 "dir_4": {
6234
6235 },
6236 }),
6237 )
6238 .await;
6239
6240 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6241 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6242 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6243 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6244
6245 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6246 cx.executor().run_until_parked();
6247 select_path(&panel, "project_root/dir_1", cx);
6248 cx.executor().run_until_parked();
6249 assert_eq!(
6250 visible_entries_as_strings(&panel, 0..10, cx),
6251 &[
6252 "v project_root",
6253 " > dir_1 <== selected",
6254 " > dir_2",
6255 " > dir_3",
6256 " > dir_4",
6257 " file_1.py",
6258 " file_2.py",
6259 ]
6260 );
6261 panel.update(cx, |panel, cx| {
6262 panel.select_prev_directory(&SelectPrevDirectory, cx)
6263 });
6264
6265 assert_eq!(
6266 visible_entries_as_strings(&panel, 0..10, cx),
6267 &[
6268 "v project_root <== selected",
6269 " > dir_1",
6270 " > dir_2",
6271 " > dir_3",
6272 " > dir_4",
6273 " file_1.py",
6274 " file_2.py",
6275 ]
6276 );
6277
6278 panel.update(cx, |panel, cx| {
6279 panel.select_prev_directory(&SelectPrevDirectory, cx)
6280 });
6281
6282 assert_eq!(
6283 visible_entries_as_strings(&panel, 0..10, cx),
6284 &[
6285 "v project_root",
6286 " > dir_1",
6287 " > dir_2",
6288 " > dir_3",
6289 " > dir_4 <== selected",
6290 " file_1.py",
6291 " file_2.py",
6292 ]
6293 );
6294
6295 panel.update(cx, |panel, cx| {
6296 panel.select_next_directory(&SelectNextDirectory, cx)
6297 });
6298
6299 assert_eq!(
6300 visible_entries_as_strings(&panel, 0..10, cx),
6301 &[
6302 "v project_root <== selected",
6303 " > dir_1",
6304 " > dir_2",
6305 " > dir_3",
6306 " > dir_4",
6307 " file_1.py",
6308 " file_2.py",
6309 ]
6310 );
6311 }
6312
6313 #[gpui::test]
6314 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
6315 init_test_with_editor(cx);
6316
6317 let fs = FakeFs::new(cx.executor().clone());
6318 fs.insert_tree(
6319 "/project_root",
6320 json!({
6321 "dir_1": {
6322 "nested_dir": {
6323 "file_a.py": "# File contents",
6324 }
6325 },
6326 "file_1.py": "# File contents",
6327 }),
6328 )
6329 .await;
6330
6331 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6332 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6333 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6334 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6335
6336 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6337 cx.executor().run_until_parked();
6338 select_path(&panel, "project_root/dir_1", cx);
6339 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6340 select_path(&panel, "project_root/dir_1/nested_dir", cx);
6341 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6342 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6343 cx.executor().run_until_parked();
6344 assert_eq!(
6345 visible_entries_as_strings(&panel, 0..10, cx),
6346 &[
6347 "v project_root",
6348 " v dir_1",
6349 " > nested_dir <== selected",
6350 " file_1.py",
6351 ]
6352 );
6353 }
6354
6355 #[gpui::test]
6356 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
6357 init_test_with_editor(cx);
6358
6359 let fs = FakeFs::new(cx.executor().clone());
6360 fs.insert_tree(
6361 "/project_root",
6362 json!({
6363 "dir_1": {
6364 "nested_dir": {
6365 "file_a.py": "# File contents",
6366 "file_b.py": "# File contents",
6367 "file_c.py": "# File contents",
6368 },
6369 "file_1.py": "# File contents",
6370 "file_2.py": "# File contents",
6371 "file_3.py": "# File contents",
6372 },
6373 "dir_2": {
6374 "file_1.py": "# File contents",
6375 "file_2.py": "# File contents",
6376 "file_3.py": "# File contents",
6377 }
6378 }),
6379 )
6380 .await;
6381
6382 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6383 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6384 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6385 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6386
6387 panel.update(cx, |panel, cx| {
6388 panel.collapse_all_entries(&CollapseAllEntries, cx)
6389 });
6390 cx.executor().run_until_parked();
6391 assert_eq!(
6392 visible_entries_as_strings(&panel, 0..10, cx),
6393 &["v project_root", " > dir_1", " > dir_2",]
6394 );
6395
6396 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
6397 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6398 cx.executor().run_until_parked();
6399 assert_eq!(
6400 visible_entries_as_strings(&panel, 0..10, cx),
6401 &[
6402 "v project_root",
6403 " v dir_1 <== selected",
6404 " > nested_dir",
6405 " file_1.py",
6406 " file_2.py",
6407 " file_3.py",
6408 " > dir_2",
6409 ]
6410 );
6411 }
6412
6413 #[gpui::test]
6414 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
6415 init_test(cx);
6416
6417 let fs = FakeFs::new(cx.executor().clone());
6418 fs.as_fake().insert_tree("/root", json!({})).await;
6419 let project = Project::test(fs, ["/root".as_ref()], cx).await;
6420 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6421 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6422 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6423
6424 // Make a new buffer with no backing file
6425 workspace
6426 .update(cx, |workspace, cx| {
6427 Editor::new_file(workspace, &Default::default(), cx)
6428 })
6429 .unwrap();
6430
6431 cx.executor().run_until_parked();
6432
6433 // "Save as" the buffer, creating a new backing file for it
6434 let save_task = workspace
6435 .update(cx, |workspace, cx| {
6436 workspace.save_active_item(workspace::SaveIntent::Save, cx)
6437 })
6438 .unwrap();
6439
6440 cx.executor().run_until_parked();
6441 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
6442 save_task.await.unwrap();
6443
6444 // Rename the file
6445 select_path(&panel, "root/new", cx);
6446 assert_eq!(
6447 visible_entries_as_strings(&panel, 0..10, cx),
6448 &["v root", " new <== selected"]
6449 );
6450 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
6451 panel.update(cx, |panel, cx| {
6452 panel
6453 .filename_editor
6454 .update(cx, |editor, cx| editor.set_text("newer", cx));
6455 });
6456 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6457
6458 cx.executor().run_until_parked();
6459 assert_eq!(
6460 visible_entries_as_strings(&panel, 0..10, cx),
6461 &["v root", " newer <== selected"]
6462 );
6463
6464 workspace
6465 .update(cx, |workspace, cx| {
6466 workspace.save_active_item(workspace::SaveIntent::Save, cx)
6467 })
6468 .unwrap()
6469 .await
6470 .unwrap();
6471
6472 cx.executor().run_until_parked();
6473 // assert that saving the file doesn't restore "new"
6474 assert_eq!(
6475 visible_entries_as_strings(&panel, 0..10, cx),
6476 &["v root", " newer <== selected"]
6477 );
6478 }
6479
6480 #[gpui::test]
6481 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
6482 init_test_with_editor(cx);
6483 let fs = FakeFs::new(cx.executor().clone());
6484 fs.insert_tree(
6485 "/project_root",
6486 json!({
6487 "dir_1": {
6488 "nested_dir": {
6489 "file_a.py": "# File contents",
6490 }
6491 },
6492 "file_1.py": "# File contents",
6493 }),
6494 )
6495 .await;
6496
6497 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6498 let worktree_id =
6499 cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
6500 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6501 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6502 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6503 cx.update(|cx| {
6504 panel.update(cx, |this, cx| {
6505 this.select_next(&Default::default(), cx);
6506 this.expand_selected_entry(&Default::default(), cx);
6507 this.expand_selected_entry(&Default::default(), cx);
6508 this.select_next(&Default::default(), cx);
6509 this.expand_selected_entry(&Default::default(), cx);
6510 this.select_next(&Default::default(), cx);
6511 })
6512 });
6513 assert_eq!(
6514 visible_entries_as_strings(&panel, 0..10, cx),
6515 &[
6516 "v project_root",
6517 " v dir_1",
6518 " v nested_dir",
6519 " file_a.py <== selected",
6520 " file_1.py",
6521 ]
6522 );
6523 let modifiers_with_shift = gpui::Modifiers {
6524 shift: true,
6525 ..Default::default()
6526 };
6527 cx.simulate_modifiers_change(modifiers_with_shift);
6528 cx.update(|cx| {
6529 panel.update(cx, |this, cx| {
6530 this.select_next(&Default::default(), cx);
6531 })
6532 });
6533 assert_eq!(
6534 visible_entries_as_strings(&panel, 0..10, cx),
6535 &[
6536 "v project_root",
6537 " v dir_1",
6538 " v nested_dir",
6539 " file_a.py",
6540 " file_1.py <== selected <== marked",
6541 ]
6542 );
6543 cx.update(|cx| {
6544 panel.update(cx, |this, cx| {
6545 this.select_prev(&Default::default(), cx);
6546 })
6547 });
6548 assert_eq!(
6549 visible_entries_as_strings(&panel, 0..10, cx),
6550 &[
6551 "v project_root",
6552 " v dir_1",
6553 " v nested_dir",
6554 " file_a.py <== selected <== marked",
6555 " file_1.py <== marked",
6556 ]
6557 );
6558 cx.update(|cx| {
6559 panel.update(cx, |this, cx| {
6560 let drag = DraggedSelection {
6561 active_selection: this.selection.unwrap(),
6562 marked_selections: Arc::new(this.marked_entries.clone()),
6563 };
6564 let target_entry = this
6565 .project
6566 .read(cx)
6567 .entry_for_path(&(worktree_id, "").into(), cx)
6568 .unwrap();
6569 this.drag_onto(&drag, target_entry.id, false, cx);
6570 });
6571 });
6572 cx.run_until_parked();
6573 assert_eq!(
6574 visible_entries_as_strings(&panel, 0..10, cx),
6575 &[
6576 "v project_root",
6577 " v dir_1",
6578 " v nested_dir",
6579 " file_1.py <== marked",
6580 " file_a.py <== selected <== marked",
6581 ]
6582 );
6583 // ESC clears out all marks
6584 cx.update(|cx| {
6585 panel.update(cx, |this, cx| {
6586 this.cancel(&menu::Cancel, cx);
6587 })
6588 });
6589 assert_eq!(
6590 visible_entries_as_strings(&panel, 0..10, cx),
6591 &[
6592 "v project_root",
6593 " v dir_1",
6594 " v nested_dir",
6595 " file_1.py",
6596 " file_a.py <== selected",
6597 ]
6598 );
6599 // ESC clears out all marks
6600 cx.update(|cx| {
6601 panel.update(cx, |this, cx| {
6602 this.select_prev(&SelectPrev, cx);
6603 this.select_next(&SelectNext, cx);
6604 })
6605 });
6606 assert_eq!(
6607 visible_entries_as_strings(&panel, 0..10, cx),
6608 &[
6609 "v project_root",
6610 " v dir_1",
6611 " v nested_dir",
6612 " file_1.py <== marked",
6613 " file_a.py <== selected <== marked",
6614 ]
6615 );
6616 cx.simulate_modifiers_change(Default::default());
6617 cx.update(|cx| {
6618 panel.update(cx, |this, cx| {
6619 this.cut(&Cut, cx);
6620 this.select_prev(&SelectPrev, cx);
6621 this.select_prev(&SelectPrev, cx);
6622
6623 this.paste(&Paste, cx);
6624 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
6625 })
6626 });
6627 cx.run_until_parked();
6628 assert_eq!(
6629 visible_entries_as_strings(&panel, 0..10, cx),
6630 &[
6631 "v project_root",
6632 " v dir_1",
6633 " v nested_dir",
6634 " file_1.py <== marked",
6635 " file_a.py <== selected <== marked",
6636 ]
6637 );
6638 cx.simulate_modifiers_change(modifiers_with_shift);
6639 cx.update(|cx| {
6640 panel.update(cx, |this, cx| {
6641 this.expand_selected_entry(&Default::default(), cx);
6642 this.select_next(&SelectNext, cx);
6643 this.select_next(&SelectNext, cx);
6644 })
6645 });
6646 submit_deletion(&panel, cx);
6647 assert_eq!(
6648 visible_entries_as_strings(&panel, 0..10, cx),
6649 &[
6650 "v project_root",
6651 " v dir_1",
6652 " v nested_dir <== selected",
6653 ]
6654 );
6655 }
6656 #[gpui::test]
6657 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
6658 init_test_with_editor(cx);
6659 cx.update(|cx| {
6660 cx.update_global::<SettingsStore, _>(|store, cx| {
6661 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6662 worktree_settings.file_scan_exclusions = Some(Vec::new());
6663 });
6664 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6665 project_panel_settings.auto_reveal_entries = Some(false)
6666 });
6667 })
6668 });
6669
6670 let fs = FakeFs::new(cx.background_executor.clone());
6671 fs.insert_tree(
6672 "/project_root",
6673 json!({
6674 ".git": {},
6675 ".gitignore": "**/gitignored_dir",
6676 "dir_1": {
6677 "file_1.py": "# File 1_1 contents",
6678 "file_2.py": "# File 1_2 contents",
6679 "file_3.py": "# File 1_3 contents",
6680 "gitignored_dir": {
6681 "file_a.py": "# File contents",
6682 "file_b.py": "# File contents",
6683 "file_c.py": "# File contents",
6684 },
6685 },
6686 "dir_2": {
6687 "file_1.py": "# File 2_1 contents",
6688 "file_2.py": "# File 2_2 contents",
6689 "file_3.py": "# File 2_3 contents",
6690 }
6691 }),
6692 )
6693 .await;
6694
6695 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6696 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6697 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6698 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6699
6700 assert_eq!(
6701 visible_entries_as_strings(&panel, 0..20, cx),
6702 &[
6703 "v project_root",
6704 " > .git",
6705 " > dir_1",
6706 " > dir_2",
6707 " .gitignore",
6708 ]
6709 );
6710
6711 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
6712 .expect("dir 1 file is not ignored and should have an entry");
6713 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
6714 .expect("dir 2 file is not ignored and should have an entry");
6715 let gitignored_dir_file =
6716 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
6717 assert_eq!(
6718 gitignored_dir_file, None,
6719 "File in the gitignored dir should not have an entry before its dir is toggled"
6720 );
6721
6722 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6723 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6724 cx.executor().run_until_parked();
6725 assert_eq!(
6726 visible_entries_as_strings(&panel, 0..20, cx),
6727 &[
6728 "v project_root",
6729 " > .git",
6730 " v dir_1",
6731 " v gitignored_dir <== selected",
6732 " file_a.py",
6733 " file_b.py",
6734 " file_c.py",
6735 " file_1.py",
6736 " file_2.py",
6737 " file_3.py",
6738 " > dir_2",
6739 " .gitignore",
6740 ],
6741 "Should show gitignored dir file list in the project panel"
6742 );
6743 let gitignored_dir_file =
6744 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
6745 .expect("after gitignored dir got opened, a file entry should be present");
6746
6747 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6748 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6749 assert_eq!(
6750 visible_entries_as_strings(&panel, 0..20, cx),
6751 &[
6752 "v project_root",
6753 " > .git",
6754 " > dir_1 <== selected",
6755 " > dir_2",
6756 " .gitignore",
6757 ],
6758 "Should hide all dir contents again and prepare for the auto reveal test"
6759 );
6760
6761 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
6762 panel.update(cx, |panel, cx| {
6763 panel.project.update(cx, |_, cx| {
6764 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
6765 })
6766 });
6767 cx.run_until_parked();
6768 assert_eq!(
6769 visible_entries_as_strings(&panel, 0..20, cx),
6770 &[
6771 "v project_root",
6772 " > .git",
6773 " > dir_1 <== selected",
6774 " > dir_2",
6775 " .gitignore",
6776 ],
6777 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
6778 );
6779 }
6780
6781 cx.update(|cx| {
6782 cx.update_global::<SettingsStore, _>(|store, cx| {
6783 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6784 project_panel_settings.auto_reveal_entries = Some(true)
6785 });
6786 })
6787 });
6788
6789 panel.update(cx, |panel, cx| {
6790 panel.project.update(cx, |_, cx| {
6791 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
6792 })
6793 });
6794 cx.run_until_parked();
6795 assert_eq!(
6796 visible_entries_as_strings(&panel, 0..20, cx),
6797 &[
6798 "v project_root",
6799 " > .git",
6800 " v dir_1",
6801 " > gitignored_dir",
6802 " file_1.py <== selected",
6803 " file_2.py",
6804 " file_3.py",
6805 " > dir_2",
6806 " .gitignore",
6807 ],
6808 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
6809 );
6810
6811 panel.update(cx, |panel, cx| {
6812 panel.project.update(cx, |_, cx| {
6813 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
6814 })
6815 });
6816 cx.run_until_parked();
6817 assert_eq!(
6818 visible_entries_as_strings(&panel, 0..20, cx),
6819 &[
6820 "v project_root",
6821 " > .git",
6822 " v dir_1",
6823 " > gitignored_dir",
6824 " file_1.py",
6825 " file_2.py",
6826 " file_3.py",
6827 " v dir_2",
6828 " file_1.py <== selected",
6829 " file_2.py",
6830 " file_3.py",
6831 " .gitignore",
6832 ],
6833 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
6834 );
6835
6836 panel.update(cx, |panel, cx| {
6837 panel.project.update(cx, |_, cx| {
6838 cx.emit(project::Event::ActiveEntryChanged(Some(
6839 gitignored_dir_file,
6840 )))
6841 })
6842 });
6843 cx.run_until_parked();
6844 assert_eq!(
6845 visible_entries_as_strings(&panel, 0..20, cx),
6846 &[
6847 "v project_root",
6848 " > .git",
6849 " v dir_1",
6850 " > gitignored_dir",
6851 " file_1.py",
6852 " file_2.py",
6853 " file_3.py",
6854 " v dir_2",
6855 " file_1.py <== selected",
6856 " file_2.py",
6857 " file_3.py",
6858 " .gitignore",
6859 ],
6860 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
6861 );
6862
6863 panel.update(cx, |panel, cx| {
6864 panel.project.update(cx, |_, cx| {
6865 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
6866 })
6867 });
6868 cx.run_until_parked();
6869 assert_eq!(
6870 visible_entries_as_strings(&panel, 0..20, cx),
6871 &[
6872 "v project_root",
6873 " > .git",
6874 " v dir_1",
6875 " v gitignored_dir",
6876 " file_a.py <== selected",
6877 " file_b.py",
6878 " file_c.py",
6879 " file_1.py",
6880 " file_2.py",
6881 " file_3.py",
6882 " v dir_2",
6883 " file_1.py",
6884 " file_2.py",
6885 " file_3.py",
6886 " .gitignore",
6887 ],
6888 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
6889 );
6890 }
6891
6892 #[gpui::test]
6893 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
6894 init_test_with_editor(cx);
6895 cx.update(|cx| {
6896 cx.update_global::<SettingsStore, _>(|store, cx| {
6897 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6898 worktree_settings.file_scan_exclusions = Some(Vec::new());
6899 });
6900 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6901 project_panel_settings.auto_reveal_entries = Some(false)
6902 });
6903 })
6904 });
6905
6906 let fs = FakeFs::new(cx.background_executor.clone());
6907 fs.insert_tree(
6908 "/project_root",
6909 json!({
6910 ".git": {},
6911 ".gitignore": "**/gitignored_dir",
6912 "dir_1": {
6913 "file_1.py": "# File 1_1 contents",
6914 "file_2.py": "# File 1_2 contents",
6915 "file_3.py": "# File 1_3 contents",
6916 "gitignored_dir": {
6917 "file_a.py": "# File contents",
6918 "file_b.py": "# File contents",
6919 "file_c.py": "# File contents",
6920 },
6921 },
6922 "dir_2": {
6923 "file_1.py": "# File 2_1 contents",
6924 "file_2.py": "# File 2_2 contents",
6925 "file_3.py": "# File 2_3 contents",
6926 }
6927 }),
6928 )
6929 .await;
6930
6931 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6932 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6933 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6934 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6935
6936 assert_eq!(
6937 visible_entries_as_strings(&panel, 0..20, cx),
6938 &[
6939 "v project_root",
6940 " > .git",
6941 " > dir_1",
6942 " > dir_2",
6943 " .gitignore",
6944 ]
6945 );
6946
6947 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
6948 .expect("dir 1 file is not ignored and should have an entry");
6949 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
6950 .expect("dir 2 file is not ignored and should have an entry");
6951 let gitignored_dir_file =
6952 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
6953 assert_eq!(
6954 gitignored_dir_file, None,
6955 "File in the gitignored dir should not have an entry before its dir is toggled"
6956 );
6957
6958 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6959 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6960 cx.run_until_parked();
6961 assert_eq!(
6962 visible_entries_as_strings(&panel, 0..20, cx),
6963 &[
6964 "v project_root",
6965 " > .git",
6966 " v dir_1",
6967 " v gitignored_dir <== selected",
6968 " file_a.py",
6969 " file_b.py",
6970 " file_c.py",
6971 " file_1.py",
6972 " file_2.py",
6973 " file_3.py",
6974 " > dir_2",
6975 " .gitignore",
6976 ],
6977 "Should show gitignored dir file list in the project panel"
6978 );
6979 let gitignored_dir_file =
6980 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
6981 .expect("after gitignored dir got opened, a file entry should be present");
6982
6983 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6984 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6985 assert_eq!(
6986 visible_entries_as_strings(&panel, 0..20, cx),
6987 &[
6988 "v project_root",
6989 " > .git",
6990 " > dir_1 <== selected",
6991 " > dir_2",
6992 " .gitignore",
6993 ],
6994 "Should hide all dir contents again and prepare for the explicit reveal test"
6995 );
6996
6997 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
6998 panel.update(cx, |panel, cx| {
6999 panel.project.update(cx, |_, cx| {
7000 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
7001 })
7002 });
7003 cx.run_until_parked();
7004 assert_eq!(
7005 visible_entries_as_strings(&panel, 0..20, cx),
7006 &[
7007 "v project_root",
7008 " > .git",
7009 " > dir_1 <== selected",
7010 " > dir_2",
7011 " .gitignore",
7012 ],
7013 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
7014 );
7015 }
7016
7017 panel.update(cx, |panel, cx| {
7018 panel.project.update(cx, |_, cx| {
7019 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
7020 })
7021 });
7022 cx.run_until_parked();
7023 assert_eq!(
7024 visible_entries_as_strings(&panel, 0..20, cx),
7025 &[
7026 "v project_root",
7027 " > .git",
7028 " v dir_1",
7029 " > gitignored_dir",
7030 " file_1.py <== selected",
7031 " file_2.py",
7032 " file_3.py",
7033 " > dir_2",
7034 " .gitignore",
7035 ],
7036 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
7037 );
7038
7039 panel.update(cx, |panel, cx| {
7040 panel.project.update(cx, |_, cx| {
7041 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
7042 })
7043 });
7044 cx.run_until_parked();
7045 assert_eq!(
7046 visible_entries_as_strings(&panel, 0..20, cx),
7047 &[
7048 "v project_root",
7049 " > .git",
7050 " v dir_1",
7051 " > gitignored_dir",
7052 " file_1.py",
7053 " file_2.py",
7054 " file_3.py",
7055 " v dir_2",
7056 " file_1.py <== selected",
7057 " file_2.py",
7058 " file_3.py",
7059 " .gitignore",
7060 ],
7061 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
7062 );
7063
7064 panel.update(cx, |panel, cx| {
7065 panel.project.update(cx, |_, cx| {
7066 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
7067 })
7068 });
7069 cx.run_until_parked();
7070 assert_eq!(
7071 visible_entries_as_strings(&panel, 0..20, cx),
7072 &[
7073 "v project_root",
7074 " > .git",
7075 " v dir_1",
7076 " v gitignored_dir",
7077 " file_a.py <== selected",
7078 " file_b.py",
7079 " file_c.py",
7080 " file_1.py",
7081 " file_2.py",
7082 " file_3.py",
7083 " v dir_2",
7084 " file_1.py",
7085 " file_2.py",
7086 " file_3.py",
7087 " .gitignore",
7088 ],
7089 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
7090 );
7091 }
7092
7093 #[gpui::test]
7094 async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
7095 init_test(cx);
7096 cx.update(|cx| {
7097 cx.update_global::<SettingsStore, _>(|store, cx| {
7098 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
7099 project_settings.file_scan_exclusions =
7100 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
7101 });
7102 });
7103 });
7104
7105 cx.update(|cx| {
7106 register_project_item::<TestProjectItemView>(cx);
7107 });
7108
7109 let fs = FakeFs::new(cx.executor().clone());
7110 fs.insert_tree(
7111 "/root1",
7112 json!({
7113 ".dockerignore": "",
7114 ".git": {
7115 "HEAD": "",
7116 },
7117 }),
7118 )
7119 .await;
7120
7121 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
7122 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7123 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7124 let panel = workspace
7125 .update(cx, |workspace, cx| {
7126 let panel = ProjectPanel::new(workspace, cx);
7127 workspace.add_panel(panel.clone(), cx);
7128 panel
7129 })
7130 .unwrap();
7131
7132 select_path(&panel, "root1", cx);
7133 assert_eq!(
7134 visible_entries_as_strings(&panel, 0..10, cx),
7135 &["v root1 <== selected", " .dockerignore",]
7136 );
7137 workspace
7138 .update(cx, |workspace, cx| {
7139 assert!(
7140 workspace.active_item(cx).is_none(),
7141 "Should have no active items in the beginning"
7142 );
7143 })
7144 .unwrap();
7145
7146 let excluded_file_path = ".git/COMMIT_EDITMSG";
7147 let excluded_dir_path = "excluded_dir";
7148
7149 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
7150 panel.update(cx, |panel, cx| {
7151 assert!(panel.filename_editor.read(cx).is_focused(cx));
7152 });
7153 panel
7154 .update(cx, |panel, cx| {
7155 panel
7156 .filename_editor
7157 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
7158 panel.confirm_edit(cx).unwrap()
7159 })
7160 .await
7161 .unwrap();
7162
7163 assert_eq!(
7164 visible_entries_as_strings(&panel, 0..13, cx),
7165 &["v root1", " .dockerignore"],
7166 "Excluded dir should not be shown after opening a file in it"
7167 );
7168 panel.update(cx, |panel, cx| {
7169 assert!(
7170 !panel.filename_editor.read(cx).is_focused(cx),
7171 "Should have closed the file name editor"
7172 );
7173 });
7174 workspace
7175 .update(cx, |workspace, cx| {
7176 let active_entry_path = workspace
7177 .active_item(cx)
7178 .expect("should have opened and activated the excluded item")
7179 .act_as::<TestProjectItemView>(cx)
7180 .expect(
7181 "should have opened the corresponding project item for the excluded item",
7182 )
7183 .read(cx)
7184 .path
7185 .clone();
7186 assert_eq!(
7187 active_entry_path.path.as_ref(),
7188 Path::new(excluded_file_path),
7189 "Should open the excluded file"
7190 );
7191
7192 assert!(
7193 workspace.notification_ids().is_empty(),
7194 "Should have no notifications after opening an excluded file"
7195 );
7196 })
7197 .unwrap();
7198 assert!(
7199 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
7200 "Should have created the excluded file"
7201 );
7202
7203 select_path(&panel, "root1", cx);
7204 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7205 panel.update(cx, |panel, cx| {
7206 assert!(panel.filename_editor.read(cx).is_focused(cx));
7207 });
7208 panel
7209 .update(cx, |panel, cx| {
7210 panel
7211 .filename_editor
7212 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
7213 panel.confirm_edit(cx).unwrap()
7214 })
7215 .await
7216 .unwrap();
7217
7218 assert_eq!(
7219 visible_entries_as_strings(&panel, 0..13, cx),
7220 &["v root1", " .dockerignore"],
7221 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
7222 );
7223 panel.update(cx, |panel, cx| {
7224 assert!(
7225 !panel.filename_editor.read(cx).is_focused(cx),
7226 "Should have closed the file name editor"
7227 );
7228 });
7229 workspace
7230 .update(cx, |workspace, cx| {
7231 let notifications = workspace.notification_ids();
7232 assert_eq!(
7233 notifications.len(),
7234 1,
7235 "Should receive one notification with the error message"
7236 );
7237 workspace.dismiss_notification(notifications.first().unwrap(), cx);
7238 assert!(workspace.notification_ids().is_empty());
7239 })
7240 .unwrap();
7241
7242 select_path(&panel, "root1", cx);
7243 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7244 panel.update(cx, |panel, cx| {
7245 assert!(panel.filename_editor.read(cx).is_focused(cx));
7246 });
7247 panel
7248 .update(cx, |panel, cx| {
7249 panel
7250 .filename_editor
7251 .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
7252 panel.confirm_edit(cx).unwrap()
7253 })
7254 .await
7255 .unwrap();
7256
7257 assert_eq!(
7258 visible_entries_as_strings(&panel, 0..13, cx),
7259 &["v root1", " .dockerignore"],
7260 "Should not change the project panel after trying to create an excluded directory"
7261 );
7262 panel.update(cx, |panel, cx| {
7263 assert!(
7264 !panel.filename_editor.read(cx).is_focused(cx),
7265 "Should have closed the file name editor"
7266 );
7267 });
7268 workspace
7269 .update(cx, |workspace, cx| {
7270 let notifications = workspace.notification_ids();
7271 assert_eq!(
7272 notifications.len(),
7273 1,
7274 "Should receive one notification explaining that no directory is actually shown"
7275 );
7276 workspace.dismiss_notification(notifications.first().unwrap(), cx);
7277 assert!(workspace.notification_ids().is_empty());
7278 })
7279 .unwrap();
7280 assert!(
7281 fs.is_dir(Path::new("/root1/excluded_dir")).await,
7282 "Should have created the excluded directory"
7283 );
7284 }
7285
7286 #[gpui::test]
7287 async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
7288 init_test_with_editor(cx);
7289
7290 let fs = FakeFs::new(cx.executor().clone());
7291 fs.insert_tree(
7292 "/src",
7293 json!({
7294 "test": {
7295 "first.rs": "// First Rust file",
7296 "second.rs": "// Second Rust file",
7297 "third.rs": "// Third Rust file",
7298 }
7299 }),
7300 )
7301 .await;
7302
7303 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
7304 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7305 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7306 let panel = workspace
7307 .update(cx, |workspace, cx| {
7308 let panel = ProjectPanel::new(workspace, cx);
7309 workspace.add_panel(panel.clone(), cx);
7310 panel
7311 })
7312 .unwrap();
7313
7314 select_path(&panel, "src/", cx);
7315 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
7316 cx.executor().run_until_parked();
7317 assert_eq!(
7318 visible_entries_as_strings(&panel, 0..10, cx),
7319 &[
7320 //
7321 "v src <== selected",
7322 " > test"
7323 ]
7324 );
7325 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7326 panel.update(cx, |panel, cx| {
7327 assert!(panel.filename_editor.read(cx).is_focused(cx));
7328 });
7329 assert_eq!(
7330 visible_entries_as_strings(&panel, 0..10, cx),
7331 &[
7332 //
7333 "v src",
7334 " > [EDITOR: ''] <== selected",
7335 " > test"
7336 ]
7337 );
7338
7339 panel.update(cx, |panel, cx| panel.cancel(&menu::Cancel, cx));
7340 assert_eq!(
7341 visible_entries_as_strings(&panel, 0..10, cx),
7342 &[
7343 //
7344 "v src <== selected",
7345 " > test"
7346 ]
7347 );
7348 }
7349
7350 #[gpui::test]
7351 async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
7352 init_test_with_editor(cx);
7353
7354 let fs = FakeFs::new(cx.executor().clone());
7355 fs.insert_tree(
7356 "/root",
7357 json!({
7358 "dir1": {
7359 "subdir1": {},
7360 "file1.txt": "",
7361 "file2.txt": "",
7362 },
7363 "dir2": {
7364 "subdir2": {},
7365 "file3.txt": "",
7366 "file4.txt": "",
7367 },
7368 "file5.txt": "",
7369 "file6.txt": "",
7370 }),
7371 )
7372 .await;
7373
7374 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7375 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7376 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7377 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7378
7379 toggle_expand_dir(&panel, "root/dir1", cx);
7380 toggle_expand_dir(&panel, "root/dir2", cx);
7381
7382 // Test Case 1: Delete middle file in directory
7383 select_path(&panel, "root/dir1/file1.txt", cx);
7384 assert_eq!(
7385 visible_entries_as_strings(&panel, 0..15, cx),
7386 &[
7387 "v root",
7388 " v dir1",
7389 " > subdir1",
7390 " file1.txt <== selected",
7391 " file2.txt",
7392 " v dir2",
7393 " > subdir2",
7394 " file3.txt",
7395 " file4.txt",
7396 " file5.txt",
7397 " file6.txt",
7398 ],
7399 "Initial state before deleting middle file"
7400 );
7401
7402 submit_deletion(&panel, cx);
7403 assert_eq!(
7404 visible_entries_as_strings(&panel, 0..15, cx),
7405 &[
7406 "v root",
7407 " v dir1",
7408 " > subdir1",
7409 " file2.txt <== selected",
7410 " v dir2",
7411 " > subdir2",
7412 " file3.txt",
7413 " file4.txt",
7414 " file5.txt",
7415 " file6.txt",
7416 ],
7417 "Should select next file after deleting middle file"
7418 );
7419
7420 // Test Case 2: Delete last file in directory
7421 submit_deletion(&panel, cx);
7422 assert_eq!(
7423 visible_entries_as_strings(&panel, 0..15, cx),
7424 &[
7425 "v root",
7426 " v dir1",
7427 " > subdir1 <== selected",
7428 " v dir2",
7429 " > subdir2",
7430 " file3.txt",
7431 " file4.txt",
7432 " file5.txt",
7433 " file6.txt",
7434 ],
7435 "Should select next directory when last file is deleted"
7436 );
7437
7438 // Test Case 3: Delete root level file
7439 select_path(&panel, "root/file6.txt", cx);
7440 assert_eq!(
7441 visible_entries_as_strings(&panel, 0..15, cx),
7442 &[
7443 "v root",
7444 " v dir1",
7445 " > subdir1",
7446 " v dir2",
7447 " > subdir2",
7448 " file3.txt",
7449 " file4.txt",
7450 " file5.txt",
7451 " file6.txt <== selected",
7452 ],
7453 "Initial state before deleting root level file"
7454 );
7455
7456 submit_deletion(&panel, cx);
7457 assert_eq!(
7458 visible_entries_as_strings(&panel, 0..15, cx),
7459 &[
7460 "v root",
7461 " v dir1",
7462 " > subdir1",
7463 " v dir2",
7464 " > subdir2",
7465 " file3.txt",
7466 " file4.txt",
7467 " file5.txt <== selected",
7468 ],
7469 "Should select prev entry at root level"
7470 );
7471 }
7472
7473 #[gpui::test]
7474 async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
7475 init_test_with_editor(cx);
7476
7477 let fs = FakeFs::new(cx.executor().clone());
7478 fs.insert_tree(
7479 "/root",
7480 json!({
7481 "dir1": {
7482 "subdir1": {
7483 "a.txt": "",
7484 "b.txt": ""
7485 },
7486 "file1.txt": "",
7487 },
7488 "dir2": {
7489 "subdir2": {
7490 "c.txt": "",
7491 "d.txt": ""
7492 },
7493 "file2.txt": "",
7494 },
7495 "file3.txt": "",
7496 }),
7497 )
7498 .await;
7499
7500 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7501 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7502 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7503 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7504
7505 toggle_expand_dir(&panel, "root/dir1", cx);
7506 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7507 toggle_expand_dir(&panel, "root/dir2", cx);
7508 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
7509
7510 // Test Case 1: Select and delete nested directory with parent
7511 cx.simulate_modifiers_change(gpui::Modifiers {
7512 control: true,
7513 ..Default::default()
7514 });
7515 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
7516 select_path_with_mark(&panel, "root/dir1", cx);
7517
7518 assert_eq!(
7519 visible_entries_as_strings(&panel, 0..15, cx),
7520 &[
7521 "v root",
7522 " v dir1 <== selected <== marked",
7523 " v subdir1 <== marked",
7524 " a.txt",
7525 " b.txt",
7526 " file1.txt",
7527 " v dir2",
7528 " v subdir2",
7529 " c.txt",
7530 " d.txt",
7531 " file2.txt",
7532 " file3.txt",
7533 ],
7534 "Initial state before deleting nested directory with parent"
7535 );
7536
7537 submit_deletion(&panel, cx);
7538 assert_eq!(
7539 visible_entries_as_strings(&panel, 0..15, cx),
7540 &[
7541 "v root",
7542 " v dir2 <== selected",
7543 " v subdir2",
7544 " c.txt",
7545 " d.txt",
7546 " file2.txt",
7547 " file3.txt",
7548 ],
7549 "Should select next directory after deleting directory with parent"
7550 );
7551
7552 // Test Case 2: Select mixed files and directories across levels
7553 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
7554 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
7555 select_path_with_mark(&panel, "root/file3.txt", cx);
7556
7557 assert_eq!(
7558 visible_entries_as_strings(&panel, 0..15, cx),
7559 &[
7560 "v root",
7561 " v dir2",
7562 " v subdir2",
7563 " c.txt <== marked",
7564 " d.txt",
7565 " file2.txt <== marked",
7566 " file3.txt <== selected <== marked",
7567 ],
7568 "Initial state before deleting"
7569 );
7570
7571 submit_deletion(&panel, cx);
7572 assert_eq!(
7573 visible_entries_as_strings(&panel, 0..15, cx),
7574 &[
7575 "v root",
7576 " v dir2 <== selected",
7577 " v subdir2",
7578 " d.txt",
7579 ],
7580 "Should select sibling directory"
7581 );
7582 }
7583
7584 #[gpui::test]
7585 async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
7586 init_test_with_editor(cx);
7587
7588 let fs = FakeFs::new(cx.executor().clone());
7589 fs.insert_tree(
7590 "/root",
7591 json!({
7592 "dir1": {
7593 "subdir1": {
7594 "a.txt": "",
7595 "b.txt": ""
7596 },
7597 "file1.txt": "",
7598 },
7599 "dir2": {
7600 "subdir2": {
7601 "c.txt": "",
7602 "d.txt": ""
7603 },
7604 "file2.txt": "",
7605 },
7606 "file3.txt": "",
7607 "file4.txt": "",
7608 }),
7609 )
7610 .await;
7611
7612 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7613 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7614 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7615 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7616
7617 toggle_expand_dir(&panel, "root/dir1", cx);
7618 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7619 toggle_expand_dir(&panel, "root/dir2", cx);
7620 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
7621
7622 // Test Case 1: Select all root files and directories
7623 cx.simulate_modifiers_change(gpui::Modifiers {
7624 control: true,
7625 ..Default::default()
7626 });
7627 select_path_with_mark(&panel, "root/dir1", cx);
7628 select_path_with_mark(&panel, "root/dir2", cx);
7629 select_path_with_mark(&panel, "root/file3.txt", cx);
7630 select_path_with_mark(&panel, "root/file4.txt", cx);
7631 assert_eq!(
7632 visible_entries_as_strings(&panel, 0..20, cx),
7633 &[
7634 "v root",
7635 " v dir1 <== marked",
7636 " v subdir1",
7637 " a.txt",
7638 " b.txt",
7639 " file1.txt",
7640 " v dir2 <== marked",
7641 " v subdir2",
7642 " c.txt",
7643 " d.txt",
7644 " file2.txt",
7645 " file3.txt <== marked",
7646 " file4.txt <== selected <== marked",
7647 ],
7648 "State before deleting all contents"
7649 );
7650
7651 submit_deletion(&panel, cx);
7652 assert_eq!(
7653 visible_entries_as_strings(&panel, 0..20, cx),
7654 &["v root <== selected"],
7655 "Only empty root directory should remain after deleting all contents"
7656 );
7657 }
7658
7659 #[gpui::test]
7660 async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
7661 init_test_with_editor(cx);
7662
7663 let fs = FakeFs::new(cx.executor().clone());
7664 fs.insert_tree(
7665 "/root",
7666 json!({
7667 "dir1": {
7668 "subdir1": {
7669 "file_a.txt": "content a",
7670 "file_b.txt": "content b",
7671 },
7672 "subdir2": {
7673 "file_c.txt": "content c",
7674 },
7675 "file1.txt": "content 1",
7676 },
7677 "dir2": {
7678 "file2.txt": "content 2",
7679 },
7680 }),
7681 )
7682 .await;
7683
7684 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7685 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7686 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7687 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7688
7689 toggle_expand_dir(&panel, "root/dir1", cx);
7690 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7691 toggle_expand_dir(&panel, "root/dir2", cx);
7692 cx.simulate_modifiers_change(gpui::Modifiers {
7693 control: true,
7694 ..Default::default()
7695 });
7696
7697 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
7698 select_path_with_mark(&panel, "root/dir1", cx);
7699 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
7700 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
7701
7702 assert_eq!(
7703 visible_entries_as_strings(&panel, 0..20, cx),
7704 &[
7705 "v root",
7706 " v dir1 <== marked",
7707 " v subdir1 <== marked",
7708 " file_a.txt <== selected <== marked",
7709 " file_b.txt",
7710 " > subdir2",
7711 " file1.txt",
7712 " v dir2",
7713 " file2.txt",
7714 ],
7715 "State with parent dir, subdir, and file selected"
7716 );
7717 submit_deletion(&panel, cx);
7718 assert_eq!(
7719 visible_entries_as_strings(&panel, 0..20, cx),
7720 &["v root", " v dir2 <== selected", " file2.txt",],
7721 "Only dir2 should remain after deletion"
7722 );
7723 }
7724
7725 #[gpui::test]
7726 async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
7727 init_test_with_editor(cx);
7728
7729 let fs = FakeFs::new(cx.executor().clone());
7730 // First worktree
7731 fs.insert_tree(
7732 "/root1",
7733 json!({
7734 "dir1": {
7735 "file1.txt": "content 1",
7736 "file2.txt": "content 2",
7737 },
7738 "dir2": {
7739 "file3.txt": "content 3",
7740 },
7741 }),
7742 )
7743 .await;
7744
7745 // Second worktree
7746 fs.insert_tree(
7747 "/root2",
7748 json!({
7749 "dir3": {
7750 "file4.txt": "content 4",
7751 "file5.txt": "content 5",
7752 },
7753 "file6.txt": "content 6",
7754 }),
7755 )
7756 .await;
7757
7758 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7759 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7760 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7761 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7762
7763 // Expand all directories for testing
7764 toggle_expand_dir(&panel, "root1/dir1", cx);
7765 toggle_expand_dir(&panel, "root1/dir2", cx);
7766 toggle_expand_dir(&panel, "root2/dir3", cx);
7767
7768 // Test Case 1: Delete files across different worktrees
7769 cx.simulate_modifiers_change(gpui::Modifiers {
7770 control: true,
7771 ..Default::default()
7772 });
7773 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
7774 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
7775
7776 assert_eq!(
7777 visible_entries_as_strings(&panel, 0..20, cx),
7778 &[
7779 "v root1",
7780 " v dir1",
7781 " file1.txt <== marked",
7782 " file2.txt",
7783 " v dir2",
7784 " file3.txt",
7785 "v root2",
7786 " v dir3",
7787 " file4.txt <== selected <== marked",
7788 " file5.txt",
7789 " file6.txt",
7790 ],
7791 "Initial state with files selected from different worktrees"
7792 );
7793
7794 submit_deletion(&panel, cx);
7795 assert_eq!(
7796 visible_entries_as_strings(&panel, 0..20, cx),
7797 &[
7798 "v root1",
7799 " v dir1",
7800 " file2.txt",
7801 " v dir2",
7802 " file3.txt",
7803 "v root2",
7804 " v dir3",
7805 " file5.txt <== selected",
7806 " file6.txt",
7807 ],
7808 "Should select next file in the last worktree after deletion"
7809 );
7810
7811 // Test Case 2: Delete directories from different worktrees
7812 select_path_with_mark(&panel, "root1/dir1", cx);
7813 select_path_with_mark(&panel, "root2/dir3", cx);
7814
7815 assert_eq!(
7816 visible_entries_as_strings(&panel, 0..20, cx),
7817 &[
7818 "v root1",
7819 " v dir1 <== marked",
7820 " file2.txt",
7821 " v dir2",
7822 " file3.txt",
7823 "v root2",
7824 " v dir3 <== selected <== marked",
7825 " file5.txt",
7826 " file6.txt",
7827 ],
7828 "State with directories marked from different worktrees"
7829 );
7830
7831 submit_deletion(&panel, cx);
7832 assert_eq!(
7833 visible_entries_as_strings(&panel, 0..20, cx),
7834 &[
7835 "v root1",
7836 " v dir2",
7837 " file3.txt",
7838 "v root2",
7839 " file6.txt <== selected",
7840 ],
7841 "Should select remaining file in last worktree after directory deletion"
7842 );
7843
7844 // Test Case 4: Delete all remaining files except roots
7845 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
7846 select_path_with_mark(&panel, "root2/file6.txt", cx);
7847
7848 assert_eq!(
7849 visible_entries_as_strings(&panel, 0..20, cx),
7850 &[
7851 "v root1",
7852 " v dir2",
7853 " file3.txt <== marked",
7854 "v root2",
7855 " file6.txt <== selected <== marked",
7856 ],
7857 "State with all remaining files marked"
7858 );
7859
7860 submit_deletion(&panel, cx);
7861 assert_eq!(
7862 visible_entries_as_strings(&panel, 0..20, cx),
7863 &["v root1", " v dir2", "v root2 <== selected"],
7864 "Second parent root should be selected after deleting"
7865 );
7866 }
7867
7868 #[gpui::test]
7869 async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
7870 init_test_with_editor(cx);
7871
7872 let fs = FakeFs::new(cx.executor().clone());
7873 fs.insert_tree(
7874 "/root",
7875 json!({
7876 "dir1": {
7877 "file1.txt": "",
7878 "file2.txt": "",
7879 "file3.txt": "",
7880 },
7881 "dir2": {
7882 "file4.txt": "",
7883 "file5.txt": "",
7884 },
7885 }),
7886 )
7887 .await;
7888
7889 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7890 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7891 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7892 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7893
7894 toggle_expand_dir(&panel, "root/dir1", cx);
7895 toggle_expand_dir(&panel, "root/dir2", cx);
7896
7897 cx.simulate_modifiers_change(gpui::Modifiers {
7898 control: true,
7899 ..Default::default()
7900 });
7901
7902 select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
7903 select_path(&panel, "root/dir1/file1.txt", cx);
7904
7905 assert_eq!(
7906 visible_entries_as_strings(&panel, 0..15, cx),
7907 &[
7908 "v root",
7909 " v dir1",
7910 " file1.txt <== selected",
7911 " file2.txt <== marked",
7912 " file3.txt",
7913 " v dir2",
7914 " file4.txt",
7915 " file5.txt",
7916 ],
7917 "Initial state with one marked entry and different selection"
7918 );
7919
7920 // Delete should operate on the selected entry (file1.txt)
7921 submit_deletion(&panel, cx);
7922 assert_eq!(
7923 visible_entries_as_strings(&panel, 0..15, cx),
7924 &[
7925 "v root",
7926 " v dir1",
7927 " file2.txt <== selected <== marked",
7928 " file3.txt",
7929 " v dir2",
7930 " file4.txt",
7931 " file5.txt",
7932 ],
7933 "Should delete selected file, not marked file"
7934 );
7935
7936 select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
7937 select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
7938 select_path(&panel, "root/dir2/file5.txt", cx);
7939
7940 assert_eq!(
7941 visible_entries_as_strings(&panel, 0..15, cx),
7942 &[
7943 "v root",
7944 " v dir1",
7945 " file2.txt <== marked",
7946 " file3.txt <== marked",
7947 " v dir2",
7948 " file4.txt <== marked",
7949 " file5.txt <== selected",
7950 ],
7951 "Initial state with multiple marked entries and different selection"
7952 );
7953
7954 // Delete should operate on all marked entries, ignoring the selection
7955 submit_deletion(&panel, cx);
7956 assert_eq!(
7957 visible_entries_as_strings(&panel, 0..15, cx),
7958 &[
7959 "v root",
7960 " v dir1",
7961 " v dir2",
7962 " file5.txt <== selected",
7963 ],
7964 "Should delete all marked files, leaving only the selected file"
7965 );
7966 }
7967
7968 #[gpui::test]
7969 async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
7970 init_test_with_editor(cx);
7971
7972 let fs = FakeFs::new(cx.executor().clone());
7973 fs.insert_tree(
7974 "/root_b",
7975 json!({
7976 "dir1": {
7977 "file1.txt": "content 1",
7978 "file2.txt": "content 2",
7979 },
7980 }),
7981 )
7982 .await;
7983
7984 fs.insert_tree(
7985 "/root_c",
7986 json!({
7987 "dir2": {},
7988 }),
7989 )
7990 .await;
7991
7992 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
7993 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7994 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7995 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7996
7997 toggle_expand_dir(&panel, "root_b/dir1", cx);
7998 toggle_expand_dir(&panel, "root_c/dir2", cx);
7999
8000 cx.simulate_modifiers_change(gpui::Modifiers {
8001 control: true,
8002 ..Default::default()
8003 });
8004 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
8005 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
8006
8007 assert_eq!(
8008 visible_entries_as_strings(&panel, 0..20, cx),
8009 &[
8010 "v root_b",
8011 " v dir1",
8012 " file1.txt <== marked",
8013 " file2.txt <== selected <== marked",
8014 "v root_c",
8015 " v dir2",
8016 ],
8017 "Initial state with files marked in root_b"
8018 );
8019
8020 submit_deletion(&panel, cx);
8021 assert_eq!(
8022 visible_entries_as_strings(&panel, 0..20, cx),
8023 &[
8024 "v root_b",
8025 " v dir1 <== selected",
8026 "v root_c",
8027 " v dir2",
8028 ],
8029 "After deletion in root_b as it's last deletion, selection should be in root_b"
8030 );
8031
8032 select_path_with_mark(&panel, "root_c/dir2", cx);
8033
8034 submit_deletion(&panel, cx);
8035 assert_eq!(
8036 visible_entries_as_strings(&panel, 0..20, cx),
8037 &["v root_b", " v dir1", "v root_c <== selected",],
8038 "After deleting from root_c, it should remain in root_c"
8039 );
8040 }
8041
8042 fn toggle_expand_dir(
8043 panel: &View<ProjectPanel>,
8044 path: impl AsRef<Path>,
8045 cx: &mut VisualTestContext,
8046 ) {
8047 let path = path.as_ref();
8048 panel.update(cx, |panel, cx| {
8049 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8050 let worktree = worktree.read(cx);
8051 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8052 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8053 panel.toggle_expanded(entry_id, cx);
8054 return;
8055 }
8056 }
8057 panic!("no worktree for path {:?}", path);
8058 });
8059 }
8060
8061 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
8062 let path = path.as_ref();
8063 panel.update(cx, |panel, cx| {
8064 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8065 let worktree = worktree.read(cx);
8066 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8067 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8068 panel.selection = Some(crate::SelectedEntry {
8069 worktree_id: worktree.id(),
8070 entry_id,
8071 });
8072 return;
8073 }
8074 }
8075 panic!("no worktree for path {:?}", path);
8076 });
8077 }
8078
8079 fn select_path_with_mark(
8080 panel: &View<ProjectPanel>,
8081 path: impl AsRef<Path>,
8082 cx: &mut VisualTestContext,
8083 ) {
8084 let path = path.as_ref();
8085 panel.update(cx, |panel, cx| {
8086 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8087 let worktree = worktree.read(cx);
8088 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8089 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8090 let entry = crate::SelectedEntry {
8091 worktree_id: worktree.id(),
8092 entry_id,
8093 };
8094 if !panel.marked_entries.contains(&entry) {
8095 panel.marked_entries.insert(entry);
8096 }
8097 panel.selection = Some(entry);
8098 return;
8099 }
8100 }
8101 panic!("no worktree for path {:?}", path);
8102 });
8103 }
8104
8105 fn find_project_entry(
8106 panel: &View<ProjectPanel>,
8107 path: impl AsRef<Path>,
8108 cx: &mut VisualTestContext,
8109 ) -> Option<ProjectEntryId> {
8110 let path = path.as_ref();
8111 panel.update(cx, |panel, cx| {
8112 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8113 let worktree = worktree.read(cx);
8114 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8115 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
8116 }
8117 }
8118 panic!("no worktree for path {path:?}");
8119 })
8120 }
8121
8122 fn visible_entries_as_strings(
8123 panel: &View<ProjectPanel>,
8124 range: Range<usize>,
8125 cx: &mut VisualTestContext,
8126 ) -> Vec<String> {
8127 let mut result = Vec::new();
8128 let mut project_entries = HashSet::default();
8129 let mut has_editor = false;
8130
8131 panel.update(cx, |panel, cx| {
8132 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
8133 if details.is_editing {
8134 assert!(!has_editor, "duplicate editor entry");
8135 has_editor = true;
8136 } else {
8137 assert!(
8138 project_entries.insert(project_entry),
8139 "duplicate project entry {:?} {:?}",
8140 project_entry,
8141 details
8142 );
8143 }
8144
8145 let indent = " ".repeat(details.depth);
8146 let icon = if details.kind.is_dir() {
8147 if details.is_expanded {
8148 "v "
8149 } else {
8150 "> "
8151 }
8152 } else {
8153 " "
8154 };
8155 let name = if details.is_editing {
8156 format!("[EDITOR: '{}']", details.filename)
8157 } else if details.is_processing {
8158 format!("[PROCESSING: '{}']", details.filename)
8159 } else {
8160 details.filename.clone()
8161 };
8162 let selected = if details.is_selected {
8163 " <== selected"
8164 } else {
8165 ""
8166 };
8167 let marked = if details.is_marked {
8168 " <== marked"
8169 } else {
8170 ""
8171 };
8172
8173 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
8174 });
8175 });
8176
8177 result
8178 }
8179
8180 fn init_test(cx: &mut TestAppContext) {
8181 cx.update(|cx| {
8182 let settings_store = SettingsStore::test(cx);
8183 cx.set_global(settings_store);
8184 init_settings(cx);
8185 theme::init(theme::LoadThemes::JustBase, cx);
8186 language::init(cx);
8187 editor::init_settings(cx);
8188 crate::init((), cx);
8189 workspace::init_settings(cx);
8190 client::init_settings(cx);
8191 Project::init_settings(cx);
8192
8193 cx.update_global::<SettingsStore, _>(|store, cx| {
8194 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
8195 project_panel_settings.auto_fold_dirs = Some(false);
8196 });
8197 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
8198 worktree_settings.file_scan_exclusions = Some(Vec::new());
8199 });
8200 });
8201 });
8202 }
8203
8204 fn init_test_with_editor(cx: &mut TestAppContext) {
8205 cx.update(|cx| {
8206 let app_state = AppState::test(cx);
8207 theme::init(theme::LoadThemes::JustBase, cx);
8208 init_settings(cx);
8209 language::init(cx);
8210 editor::init(cx);
8211 crate::init((), cx);
8212 workspace::init(app_state.clone(), cx);
8213 Project::init_settings(cx);
8214
8215 cx.update_global::<SettingsStore, _>(|store, cx| {
8216 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
8217 project_panel_settings.auto_fold_dirs = Some(false);
8218 });
8219 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
8220 worktree_settings.file_scan_exclusions = Some(Vec::new());
8221 });
8222 });
8223 });
8224 }
8225
8226 fn ensure_single_file_is_opened(
8227 window: &WindowHandle<Workspace>,
8228 expected_path: &str,
8229 cx: &mut TestAppContext,
8230 ) {
8231 window
8232 .update(cx, |workspace, cx| {
8233 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
8234 assert_eq!(worktrees.len(), 1);
8235 let worktree_id = worktrees[0].read(cx).id();
8236
8237 let open_project_paths = workspace
8238 .panes()
8239 .iter()
8240 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
8241 .collect::<Vec<_>>();
8242 assert_eq!(
8243 open_project_paths,
8244 vec![ProjectPath {
8245 worktree_id,
8246 path: Arc::from(Path::new(expected_path))
8247 }],
8248 "Should have opened file, selected in project panel"
8249 );
8250 })
8251 .unwrap();
8252 }
8253
8254 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
8255 assert!(
8256 !cx.has_pending_prompt(),
8257 "Should have no prompts before the deletion"
8258 );
8259 panel.update(cx, |panel, cx| {
8260 panel.delete(&Delete { skip_prompt: false }, cx)
8261 });
8262 assert!(
8263 cx.has_pending_prompt(),
8264 "Should have a prompt after the deletion"
8265 );
8266 cx.simulate_prompt_answer(0);
8267 assert!(
8268 !cx.has_pending_prompt(),
8269 "Should have no prompts after prompt was replied to"
8270 );
8271 cx.executor().run_until_parked();
8272 }
8273
8274 fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
8275 assert!(
8276 !cx.has_pending_prompt(),
8277 "Should have no prompts before the deletion"
8278 );
8279 panel.update(cx, |panel, cx| {
8280 panel.delete(&Delete { skip_prompt: true }, cx)
8281 });
8282 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
8283 cx.executor().run_until_parked();
8284 }
8285
8286 fn ensure_no_open_items_and_panes(
8287 workspace: &WindowHandle<Workspace>,
8288 cx: &mut VisualTestContext,
8289 ) {
8290 assert!(
8291 !cx.has_pending_prompt(),
8292 "Should have no prompts after deletion operation closes the file"
8293 );
8294 workspace
8295 .read_with(cx, |workspace, cx| {
8296 let open_project_paths = workspace
8297 .panes()
8298 .iter()
8299 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
8300 .collect::<Vec<_>>();
8301 assert!(
8302 open_project_paths.is_empty(),
8303 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
8304 );
8305 })
8306 .unwrap();
8307 }
8308
8309 struct TestProjectItemView {
8310 focus_handle: FocusHandle,
8311 path: ProjectPath,
8312 }
8313
8314 struct TestProjectItem {
8315 path: ProjectPath,
8316 }
8317
8318 impl project::ProjectItem for TestProjectItem {
8319 fn try_open(
8320 _project: &Model<Project>,
8321 path: &ProjectPath,
8322 cx: &mut AppContext,
8323 ) -> Option<Task<gpui::Result<Model<Self>>>> {
8324 let path = path.clone();
8325 Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
8326 }
8327
8328 fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
8329 None
8330 }
8331
8332 fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
8333 Some(self.path.clone())
8334 }
8335
8336 fn is_dirty(&self) -> bool {
8337 false
8338 }
8339 }
8340
8341 impl ProjectItem for TestProjectItemView {
8342 type Item = TestProjectItem;
8343
8344 fn for_project_item(
8345 _: Model<Project>,
8346 project_item: Model<Self::Item>,
8347 cx: &mut ViewContext<Self>,
8348 ) -> Self
8349 where
8350 Self: Sized,
8351 {
8352 Self {
8353 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
8354 focus_handle: cx.focus_handle(),
8355 }
8356 }
8357 }
8358
8359 impl Item for TestProjectItemView {
8360 type Event = ();
8361 }
8362
8363 impl EventEmitter<()> for TestProjectItemView {}
8364
8365 impl FocusableView for TestProjectItemView {
8366 fn focus_handle(&self, _: &AppContext) -> FocusHandle {
8367 self.focus_handle.clone()
8368 }
8369 }
8370
8371 impl Render for TestProjectItemView {
8372 fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
8373 Empty
8374 }
8375 }
8376}