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::GitRepositoryUpdated
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.marked_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.marked_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 if let Some((worktree, _)) = self.selected_sub_entry(cx) {
1999 let worktree_id = worktree.read(cx).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.marked_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 // Returns the union of the currently selected entry and all marked entries.
2205 fn marked_entries(&self) -> BTreeSet<SelectedEntry> {
2206 let mut entries = self
2207 .marked_entries
2208 .iter()
2209 .map(|entry| SelectedEntry {
2210 entry_id: self.resolve_entry(entry.entry_id),
2211 worktree_id: entry.worktree_id,
2212 })
2213 .collect::<BTreeSet<_>>();
2214
2215 if let Some(selection) = self.selection {
2216 entries.insert(SelectedEntry {
2217 entry_id: self.resolve_entry(selection.entry_id),
2218 worktree_id: selection.worktree_id,
2219 });
2220 }
2221
2222 entries
2223 }
2224
2225 /// Finds the currently selected subentry for a given leaf entry id. If a given entry
2226 /// has no ancestors, the project entry ID that's passed in is returned as-is.
2227 fn resolve_entry(&self, id: ProjectEntryId) -> ProjectEntryId {
2228 self.ancestors
2229 .get(&id)
2230 .and_then(|ancestors| {
2231 if ancestors.current_ancestor_depth == 0 {
2232 return None;
2233 }
2234 ancestors.ancestors.get(ancestors.current_ancestor_depth)
2235 })
2236 .copied()
2237 .unwrap_or(id)
2238 }
2239
2240 pub fn selected_entry<'a>(
2241 &self,
2242 cx: &'a AppContext,
2243 ) -> Option<(&'a Worktree, &'a project::Entry)> {
2244 let (worktree, entry) = self.selected_entry_handle(cx)?;
2245 Some((worktree.read(cx), entry))
2246 }
2247
2248 /// Compared to selected_entry, this function resolves to the currently
2249 /// selected subentry if dir auto-folding is enabled.
2250 fn selected_sub_entry<'a>(
2251 &self,
2252 cx: &'a AppContext,
2253 ) -> Option<(Model<Worktree>, &'a project::Entry)> {
2254 let (worktree, mut entry) = self.selected_entry_handle(cx)?;
2255
2256 let resolved_id = self.resolve_entry(entry.id);
2257 if resolved_id != entry.id {
2258 let worktree = worktree.read(cx);
2259 entry = worktree.entry_for_id(resolved_id)?;
2260 }
2261 Some((worktree, entry))
2262 }
2263 fn selected_entry_handle<'a>(
2264 &self,
2265 cx: &'a AppContext,
2266 ) -> Option<(Model<Worktree>, &'a project::Entry)> {
2267 let selection = self.selection?;
2268 let project = self.project.read(cx);
2269 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
2270 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
2271 Some((worktree, entry))
2272 }
2273
2274 fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
2275 let (worktree, entry) = self.selected_entry(cx)?;
2276 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
2277
2278 for path in entry.path.ancestors() {
2279 let Some(entry) = worktree.entry_for_path(path) else {
2280 continue;
2281 };
2282 if entry.is_dir() {
2283 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
2284 expanded_dir_ids.insert(idx, entry.id);
2285 }
2286 }
2287 }
2288
2289 Some(())
2290 }
2291
2292 fn update_visible_entries(
2293 &mut self,
2294 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
2295 cx: &mut ViewContext<Self>,
2296 ) {
2297 let auto_collapse_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
2298 let project = self.project.read(cx);
2299 self.last_worktree_root_id = project
2300 .visible_worktrees(cx)
2301 .next_back()
2302 .and_then(|worktree| worktree.read(cx).root_entry())
2303 .map(|entry| entry.id);
2304
2305 let old_ancestors = std::mem::take(&mut self.ancestors);
2306 self.visible_entries.clear();
2307 let mut max_width_item = None;
2308 for worktree in project.visible_worktrees(cx) {
2309 let snapshot = worktree.read(cx).snapshot();
2310 let worktree_id = snapshot.id();
2311
2312 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
2313 hash_map::Entry::Occupied(e) => e.into_mut(),
2314 hash_map::Entry::Vacant(e) => {
2315 // The first time a worktree's root entry becomes available,
2316 // mark that root entry as expanded.
2317 if let Some(entry) = snapshot.root_entry() {
2318 e.insert(vec![entry.id]).as_slice()
2319 } else {
2320 &[]
2321 }
2322 }
2323 };
2324
2325 let mut new_entry_parent_id = None;
2326 let mut new_entry_kind = EntryKind::Dir;
2327 if let Some(edit_state) = &self.edit_state {
2328 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry() {
2329 new_entry_parent_id = Some(edit_state.entry_id);
2330 new_entry_kind = if edit_state.is_dir {
2331 EntryKind::Dir
2332 } else {
2333 EntryKind::File
2334 };
2335 }
2336 }
2337
2338 let mut visible_worktree_entries = Vec::new();
2339 let mut entry_iter = snapshot.entries(true, 0).with_git_statuses();
2340 let mut auto_folded_ancestors = vec![];
2341 while let Some(entry) = entry_iter.entry() {
2342 if auto_collapse_dirs && entry.kind.is_dir() {
2343 auto_folded_ancestors.push(entry.id);
2344 if !self.unfolded_dir_ids.contains(&entry.id) {
2345 if let Some(root_path) = snapshot.root_entry() {
2346 let mut child_entries = snapshot.child_entries(&entry.path);
2347 if let Some(child) = child_entries.next() {
2348 if entry.path != root_path.path
2349 && child_entries.next().is_none()
2350 && child.kind.is_dir()
2351 {
2352 entry_iter.advance();
2353
2354 continue;
2355 }
2356 }
2357 }
2358 }
2359 let depth = old_ancestors
2360 .get(&entry.id)
2361 .map(|ancestor| ancestor.current_ancestor_depth)
2362 .unwrap_or_default();
2363 if let Some(edit_state) = &mut self.edit_state {
2364 if edit_state.entry_id == entry.id {
2365 edit_state.depth = depth;
2366 }
2367 }
2368 let mut ancestors = std::mem::take(&mut auto_folded_ancestors);
2369 if ancestors.len() > 1 {
2370 ancestors.reverse();
2371 self.ancestors.insert(
2372 entry.id,
2373 FoldedAncestors {
2374 current_ancestor_depth: depth,
2375 ancestors,
2376 },
2377 );
2378 }
2379 }
2380 auto_folded_ancestors.clear();
2381 visible_worktree_entries.push(entry.to_owned());
2382 let precedes_new_entry = if let Some(new_entry_id) = new_entry_parent_id {
2383 entry.id == new_entry_id || {
2384 self.ancestors.get(&entry.id).map_or(false, |entries| {
2385 entries
2386 .ancestors
2387 .iter()
2388 .any(|entry_id| *entry_id == new_entry_id)
2389 })
2390 }
2391 } else {
2392 false
2393 };
2394 if precedes_new_entry {
2395 visible_worktree_entries.push(GitEntry {
2396 entry: Entry {
2397 id: NEW_ENTRY_ID,
2398 kind: new_entry_kind,
2399 path: entry.path.join("\0").into(),
2400 inode: 0,
2401 mtime: entry.mtime,
2402 size: entry.size,
2403 is_ignored: entry.is_ignored,
2404 is_external: false,
2405 is_private: false,
2406 is_always_included: entry.is_always_included,
2407 canonical_path: entry.canonical_path.clone(),
2408 char_bag: entry.char_bag,
2409 is_fifo: entry.is_fifo,
2410 },
2411 git_status: entry.git_status,
2412 });
2413 }
2414 let worktree_abs_path = worktree.read(cx).abs_path();
2415 let (depth, path) = if Some(entry.entry) == worktree.read(cx).root_entry() {
2416 let Some(path_name) = worktree_abs_path
2417 .file_name()
2418 .with_context(|| {
2419 format!("Worktree abs path has no file name, root entry: {entry:?}")
2420 })
2421 .log_err()
2422 else {
2423 continue;
2424 };
2425 let path = Arc::from(Path::new(path_name));
2426 let depth = 0;
2427 (depth, path)
2428 } else if entry.is_file() {
2429 let Some(path_name) = entry
2430 .path
2431 .file_name()
2432 .with_context(|| format!("Non-root entry has no file name: {entry:?}"))
2433 .log_err()
2434 else {
2435 continue;
2436 };
2437 let path = Arc::from(Path::new(path_name));
2438 let depth = entry.path.ancestors().count() - 1;
2439 (depth, path)
2440 } else {
2441 let path = self
2442 .ancestors
2443 .get(&entry.id)
2444 .and_then(|ancestors| {
2445 let outermost_ancestor = ancestors.ancestors.last()?;
2446 let root_folded_entry = worktree
2447 .read(cx)
2448 .entry_for_id(*outermost_ancestor)?
2449 .path
2450 .as_ref();
2451 entry
2452 .path
2453 .strip_prefix(root_folded_entry)
2454 .ok()
2455 .and_then(|suffix| {
2456 let full_path = Path::new(root_folded_entry.file_name()?);
2457 Some(Arc::<Path>::from(full_path.join(suffix)))
2458 })
2459 })
2460 .or_else(|| entry.path.file_name().map(Path::new).map(Arc::from))
2461 .unwrap_or_else(|| entry.path.clone());
2462 let depth = path.components().count();
2463 (depth, path)
2464 };
2465 let width_estimate = item_width_estimate(
2466 depth,
2467 path.to_string_lossy().chars().count(),
2468 entry.canonical_path.is_some(),
2469 );
2470
2471 match max_width_item.as_mut() {
2472 Some((id, worktree_id, width)) => {
2473 if *width < width_estimate {
2474 *id = entry.id;
2475 *worktree_id = worktree.read(cx).id();
2476 *width = width_estimate;
2477 }
2478 }
2479 None => {
2480 max_width_item = Some((entry.id, worktree.read(cx).id(), width_estimate))
2481 }
2482 }
2483
2484 if expanded_dir_ids.binary_search(&entry.id).is_err()
2485 && entry_iter.advance_to_sibling()
2486 {
2487 continue;
2488 }
2489 entry_iter.advance();
2490 }
2491
2492 project::sort_worktree_entries(&mut visible_worktree_entries);
2493
2494 self.visible_entries
2495 .push((worktree_id, visible_worktree_entries, OnceCell::new()));
2496 }
2497
2498 if let Some((project_entry_id, worktree_id, _)) = max_width_item {
2499 let mut visited_worktrees_length = 0;
2500 let index = self.visible_entries.iter().find_map(|(id, entries, _)| {
2501 if worktree_id == *id {
2502 entries
2503 .iter()
2504 .position(|entry| entry.id == project_entry_id)
2505 } else {
2506 visited_worktrees_length += entries.len();
2507 None
2508 }
2509 });
2510 if let Some(index) = index {
2511 self.max_width_item_index = Some(visited_worktrees_length + index);
2512 }
2513 }
2514 if let Some((worktree_id, entry_id)) = new_selected_entry {
2515 self.selection = Some(SelectedEntry {
2516 worktree_id,
2517 entry_id,
2518 });
2519 }
2520 }
2521
2522 fn expand_entry(
2523 &mut self,
2524 worktree_id: WorktreeId,
2525 entry_id: ProjectEntryId,
2526 cx: &mut ViewContext<Self>,
2527 ) {
2528 self.project.update(cx, |project, cx| {
2529 if let Some((worktree, expanded_dir_ids)) = project
2530 .worktree_for_id(worktree_id, cx)
2531 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
2532 {
2533 project.expand_entry(worktree_id, entry_id, cx);
2534 let worktree = worktree.read(cx);
2535
2536 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
2537 loop {
2538 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
2539 expanded_dir_ids.insert(ix, entry.id);
2540 }
2541
2542 if let Some(parent_entry) =
2543 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
2544 {
2545 entry = parent_entry;
2546 } else {
2547 break;
2548 }
2549 }
2550 }
2551 }
2552 });
2553 }
2554
2555 fn drop_external_files(
2556 &mut self,
2557 paths: &[PathBuf],
2558 entry_id: ProjectEntryId,
2559 cx: &mut ViewContext<Self>,
2560 ) {
2561 let mut paths: Vec<Arc<Path>> = paths.iter().map(|path| Arc::from(path.clone())).collect();
2562
2563 let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
2564
2565 let Some((target_directory, worktree)) = maybe!({
2566 let worktree = self.project.read(cx).worktree_for_entry(entry_id, cx)?;
2567 let entry = worktree.read(cx).entry_for_id(entry_id)?;
2568 let path = worktree.read(cx).absolutize(&entry.path).ok()?;
2569 let target_directory = if path.is_dir() {
2570 path
2571 } else {
2572 path.parent()?.to_path_buf()
2573 };
2574 Some((target_directory, worktree))
2575 }) else {
2576 return;
2577 };
2578
2579 let mut paths_to_replace = Vec::new();
2580 for path in &paths {
2581 if let Some(name) = path.file_name() {
2582 let mut target_path = target_directory.clone();
2583 target_path.push(name);
2584 if target_path.exists() {
2585 paths_to_replace.push((name.to_string_lossy().to_string(), path.clone()));
2586 }
2587 }
2588 }
2589
2590 cx.spawn(|this, mut cx| {
2591 async move {
2592 for (filename, original_path) in &paths_to_replace {
2593 let answer = cx
2594 .prompt(
2595 PromptLevel::Info,
2596 format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
2597 None,
2598 &["Replace", "Cancel"],
2599 )
2600 .await?;
2601 if answer == 1 {
2602 if let Some(item_idx) = paths.iter().position(|p| p == original_path) {
2603 paths.remove(item_idx);
2604 }
2605 }
2606 }
2607
2608 if paths.is_empty() {
2609 return Ok(());
2610 }
2611
2612 let task = worktree.update(&mut cx, |worktree, cx| {
2613 worktree.copy_external_entries(target_directory, paths, true, cx)
2614 })?;
2615
2616 let opened_entries = task.await?;
2617 this.update(&mut cx, |this, cx| {
2618 if open_file_after_drop && !opened_entries.is_empty() {
2619 this.open_entry(opened_entries[0], true, false, cx);
2620 }
2621 })
2622 }
2623 .log_err()
2624 })
2625 .detach();
2626 }
2627
2628 fn drag_onto(
2629 &mut self,
2630 selections: &DraggedSelection,
2631 target_entry_id: ProjectEntryId,
2632 is_file: bool,
2633 cx: &mut ViewContext<Self>,
2634 ) {
2635 let should_copy = cx.modifiers().alt;
2636 if should_copy {
2637 let _ = maybe!({
2638 let project = self.project.read(cx);
2639 let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
2640 let worktree_id = target_worktree.read(cx).id();
2641 let target_entry = target_worktree
2642 .read(cx)
2643 .entry_for_id(target_entry_id)?
2644 .clone();
2645
2646 let mut copy_tasks = Vec::new();
2647 let mut disambiguation_range = None;
2648 for selection in selections.items() {
2649 let (new_path, new_disambiguation_range) = self.create_paste_path(
2650 selection,
2651 (target_worktree.clone(), &target_entry),
2652 cx,
2653 )?;
2654
2655 let task = self.project.update(cx, |project, cx| {
2656 project.copy_entry(selection.entry_id, None, new_path, cx)
2657 });
2658 copy_tasks.push(task);
2659 disambiguation_range = new_disambiguation_range.or(disambiguation_range);
2660 }
2661
2662 let item_count = copy_tasks.len();
2663
2664 cx.spawn(|project_panel, mut cx| async move {
2665 let mut last_succeed = None;
2666 for task in copy_tasks.into_iter() {
2667 if let Some(Some(entry)) = task.await.log_err() {
2668 last_succeed = Some(entry.id);
2669 }
2670 }
2671 // update selection
2672 if let Some(entry_id) = last_succeed {
2673 project_panel
2674 .update(&mut cx, |project_panel, cx| {
2675 project_panel.selection = Some(SelectedEntry {
2676 worktree_id,
2677 entry_id,
2678 });
2679
2680 // if only one entry was dragged and it was disambiguated, open the rename editor
2681 if item_count == 1 && disambiguation_range.is_some() {
2682 project_panel.rename_impl(disambiguation_range, cx);
2683 }
2684 })
2685 .ok();
2686 }
2687 })
2688 .detach();
2689 Some(())
2690 });
2691 } else {
2692 for selection in selections.items() {
2693 self.move_entry(selection.entry_id, target_entry_id, is_file, cx);
2694 }
2695 }
2696 }
2697
2698 fn index_for_entry(
2699 &self,
2700 entry_id: ProjectEntryId,
2701 worktree_id: WorktreeId,
2702 ) -> Option<(usize, usize, usize)> {
2703 let mut worktree_ix = 0;
2704 let mut total_ix = 0;
2705 for (current_worktree_id, visible_worktree_entries, _) in &self.visible_entries {
2706 if worktree_id != *current_worktree_id {
2707 total_ix += visible_worktree_entries.len();
2708 worktree_ix += 1;
2709 continue;
2710 }
2711
2712 return visible_worktree_entries
2713 .iter()
2714 .enumerate()
2715 .find(|(_, entry)| entry.id == entry_id)
2716 .map(|(ix, _)| (worktree_ix, ix, total_ix + ix));
2717 }
2718 None
2719 }
2720
2721 fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, GitEntryRef)> {
2722 let mut offset = 0;
2723 for (worktree_id, visible_worktree_entries, _) in &self.visible_entries {
2724 if visible_worktree_entries.len() > offset + index {
2725 return visible_worktree_entries
2726 .get(index)
2727 .map(|entry| (*worktree_id, entry.to_ref()));
2728 }
2729 offset += visible_worktree_entries.len();
2730 }
2731 None
2732 }
2733
2734 fn iter_visible_entries(
2735 &self,
2736 range: Range<usize>,
2737 cx: &mut ViewContext<ProjectPanel>,
2738 mut callback: impl FnMut(&Entry, &HashSet<Arc<Path>>, &mut ViewContext<ProjectPanel>),
2739 ) {
2740 let mut ix = 0;
2741 for (_, visible_worktree_entries, entries_paths) in &self.visible_entries {
2742 if ix >= range.end {
2743 return;
2744 }
2745
2746 if ix + visible_worktree_entries.len() <= range.start {
2747 ix += visible_worktree_entries.len();
2748 continue;
2749 }
2750
2751 let end_ix = range.end.min(ix + visible_worktree_entries.len());
2752 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
2753 let entries = entries_paths.get_or_init(|| {
2754 visible_worktree_entries
2755 .iter()
2756 .map(|e| (e.path.clone()))
2757 .collect()
2758 });
2759 for entry in visible_worktree_entries[entry_range].iter() {
2760 callback(&entry, entries, cx);
2761 }
2762 ix = end_ix;
2763 }
2764 }
2765
2766 fn for_each_visible_entry(
2767 &self,
2768 range: Range<usize>,
2769 cx: &mut ViewContext<ProjectPanel>,
2770 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
2771 ) {
2772 let mut ix = 0;
2773 for (worktree_id, visible_worktree_entries, entries_paths) in &self.visible_entries {
2774 if ix >= range.end {
2775 return;
2776 }
2777
2778 if ix + visible_worktree_entries.len() <= range.start {
2779 ix += visible_worktree_entries.len();
2780 continue;
2781 }
2782
2783 let end_ix = range.end.min(ix + visible_worktree_entries.len());
2784 let (git_status_setting, show_file_icons, show_folder_icons) = {
2785 let settings = ProjectPanelSettings::get_global(cx);
2786 (
2787 settings.git_status,
2788 settings.file_icons,
2789 settings.folder_icons,
2790 )
2791 };
2792 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
2793 let snapshot = worktree.read(cx).snapshot();
2794 let root_name = OsStr::new(snapshot.root_name());
2795 let expanded_entry_ids = self
2796 .expanded_dir_ids
2797 .get(&snapshot.id())
2798 .map(Vec::as_slice)
2799 .unwrap_or(&[]);
2800
2801 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
2802 let entries = entries_paths.get_or_init(|| {
2803 visible_worktree_entries
2804 .iter()
2805 .map(|e| (e.path.clone()))
2806 .collect()
2807 });
2808 for entry in visible_worktree_entries[entry_range].iter() {
2809 let status = git_status_setting.then_some(entry.git_status).flatten();
2810 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
2811 let icon = match entry.kind {
2812 EntryKind::File => {
2813 if show_file_icons {
2814 FileIcons::get_icon(&entry.path, cx)
2815 } else {
2816 None
2817 }
2818 }
2819 _ => {
2820 if show_folder_icons {
2821 FileIcons::get_folder_icon(is_expanded, cx)
2822 } else {
2823 FileIcons::get_chevron_icon(is_expanded, cx)
2824 }
2825 }
2826 };
2827
2828 let (depth, difference) =
2829 ProjectPanel::calculate_depth_and_difference(&entry, entries);
2830
2831 let filename = match difference {
2832 diff if diff > 1 => entry
2833 .path
2834 .iter()
2835 .skip(entry.path.components().count() - diff)
2836 .collect::<PathBuf>()
2837 .to_str()
2838 .unwrap_or_default()
2839 .to_string(),
2840 _ => entry
2841 .path
2842 .file_name()
2843 .map(|name| name.to_string_lossy().into_owned())
2844 .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
2845 };
2846 let selection = SelectedEntry {
2847 worktree_id: snapshot.id(),
2848 entry_id: entry.id,
2849 };
2850
2851 let is_marked = self.marked_entries.contains(&selection);
2852
2853 let diagnostic_severity = self
2854 .diagnostics
2855 .get(&(*worktree_id, entry.path.to_path_buf()))
2856 .cloned();
2857
2858 let filename_text_color =
2859 entry_git_aware_label_color(status, entry.is_ignored, is_marked);
2860
2861 let mut details = EntryDetails {
2862 filename,
2863 icon,
2864 path: entry.path.clone(),
2865 depth,
2866 kind: entry.kind,
2867 is_ignored: entry.is_ignored,
2868 is_expanded,
2869 is_selected: self.selection == Some(selection),
2870 is_marked,
2871 is_editing: false,
2872 is_processing: false,
2873 is_cut: self
2874 .clipboard
2875 .as_ref()
2876 .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
2877 filename_text_color,
2878 diagnostic_severity,
2879 git_status: status,
2880 is_private: entry.is_private,
2881 worktree_id: *worktree_id,
2882 canonical_path: entry.canonical_path.clone(),
2883 };
2884
2885 if let Some(edit_state) = &self.edit_state {
2886 let is_edited_entry = if edit_state.is_new_entry() {
2887 entry.id == NEW_ENTRY_ID
2888 } else {
2889 entry.id == edit_state.entry_id
2890 || self
2891 .ancestors
2892 .get(&entry.id)
2893 .is_some_and(|auto_folded_dirs| {
2894 auto_folded_dirs
2895 .ancestors
2896 .iter()
2897 .any(|entry_id| *entry_id == edit_state.entry_id)
2898 })
2899 };
2900
2901 if is_edited_entry {
2902 if let Some(processing_filename) = &edit_state.processing_filename {
2903 details.is_processing = true;
2904 if let Some(ancestors) = edit_state
2905 .leaf_entry_id
2906 .and_then(|entry| self.ancestors.get(&entry))
2907 {
2908 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;
2909 let all_components = ancestors.ancestors.len();
2910
2911 let prefix_components = all_components - position;
2912 let suffix_components = position.checked_sub(1);
2913 let mut previous_components =
2914 Path::new(&details.filename).components();
2915 let mut new_path = previous_components
2916 .by_ref()
2917 .take(prefix_components)
2918 .collect::<PathBuf>();
2919 if let Some(last_component) =
2920 Path::new(processing_filename).components().last()
2921 {
2922 new_path.push(last_component);
2923 previous_components.next();
2924 }
2925
2926 if let Some(_) = suffix_components {
2927 new_path.push(previous_components);
2928 }
2929 if let Some(str) = new_path.to_str() {
2930 details.filename.clear();
2931 details.filename.push_str(str);
2932 }
2933 } else {
2934 details.filename.clear();
2935 details.filename.push_str(processing_filename);
2936 }
2937 } else {
2938 if edit_state.is_new_entry() {
2939 details.filename.clear();
2940 }
2941 details.is_editing = true;
2942 }
2943 }
2944 }
2945
2946 callback(entry.id, details, cx);
2947 }
2948 }
2949 ix = end_ix;
2950 }
2951 }
2952
2953 fn find_entry_in_worktree(
2954 &self,
2955 worktree_id: WorktreeId,
2956 reverse_search: bool,
2957 only_visible_entries: bool,
2958 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
2959 cx: &mut ViewContext<Self>,
2960 ) -> Option<GitEntry> {
2961 if only_visible_entries {
2962 let entries = self
2963 .visible_entries
2964 .iter()
2965 .find_map(|(tree_id, entries, _)| {
2966 if worktree_id == *tree_id {
2967 Some(entries)
2968 } else {
2969 None
2970 }
2971 })?
2972 .clone();
2973
2974 return utils::ReversibleIterable::new(entries.iter(), reverse_search)
2975 .find(|ele| predicate(ele.to_ref(), worktree_id))
2976 .cloned();
2977 }
2978
2979 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
2980 worktree.update(cx, |tree, _| {
2981 utils::ReversibleIterable::new(
2982 tree.entries(true, 0usize).with_git_statuses(),
2983 reverse_search,
2984 )
2985 .find_single_ended(|ele| predicate(*ele, worktree_id))
2986 .map(|ele| ele.to_owned())
2987 })
2988 }
2989
2990 fn find_entry(
2991 &self,
2992 start: Option<&SelectedEntry>,
2993 reverse_search: bool,
2994 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
2995 cx: &mut ViewContext<Self>,
2996 ) -> Option<SelectedEntry> {
2997 let mut worktree_ids: Vec<_> = self
2998 .visible_entries
2999 .iter()
3000 .map(|(worktree_id, _, _)| *worktree_id)
3001 .collect();
3002
3003 let mut last_found: Option<SelectedEntry> = None;
3004
3005 if let Some(start) = start {
3006 let worktree = self
3007 .project
3008 .read(cx)
3009 .worktree_for_id(start.worktree_id, cx)?;
3010
3011 let search = worktree.update(cx, |tree, _| {
3012 let entry = tree.entry_for_id(start.entry_id)?;
3013 let root_entry = tree.root_entry()?;
3014 let tree_id = tree.id();
3015
3016 let mut first_iter = tree
3017 .traverse_from_path(true, true, true, entry.path.as_ref())
3018 .with_git_statuses();
3019
3020 if reverse_search {
3021 first_iter.next();
3022 }
3023
3024 let first = first_iter
3025 .enumerate()
3026 .take_until(|(count, entry)| entry.entry == root_entry && *count != 0usize)
3027 .map(|(_, entry)| entry)
3028 .find(|ele| predicate(*ele, tree_id))
3029 .map(|ele| ele.to_owned());
3030
3031 let second_iter = tree.entries(true, 0usize).with_git_statuses();
3032
3033 let second = if reverse_search {
3034 second_iter
3035 .take_until(|ele| ele.id == start.entry_id)
3036 .filter(|ele| predicate(*ele, tree_id))
3037 .last()
3038 .map(|ele| ele.to_owned())
3039 } else {
3040 second_iter
3041 .take_while(|ele| ele.id != start.entry_id)
3042 .filter(|ele| predicate(*ele, tree_id))
3043 .last()
3044 .map(|ele| ele.to_owned())
3045 };
3046
3047 if reverse_search {
3048 Some((second, first))
3049 } else {
3050 Some((first, second))
3051 }
3052 });
3053
3054 if let Some((first, second)) = search {
3055 let first = first.map(|entry| SelectedEntry {
3056 worktree_id: start.worktree_id,
3057 entry_id: entry.id,
3058 });
3059
3060 let second = second.map(|entry| SelectedEntry {
3061 worktree_id: start.worktree_id,
3062 entry_id: entry.id,
3063 });
3064
3065 if first.is_some() {
3066 return first;
3067 }
3068 last_found = second;
3069
3070 let idx = worktree_ids
3071 .iter()
3072 .enumerate()
3073 .find(|(_, ele)| **ele == start.worktree_id)
3074 .map(|(idx, _)| idx);
3075
3076 if let Some(idx) = idx {
3077 worktree_ids.rotate_left(idx + 1usize);
3078 worktree_ids.pop();
3079 }
3080 }
3081 }
3082
3083 for tree_id in worktree_ids.into_iter() {
3084 if let Some(found) =
3085 self.find_entry_in_worktree(tree_id, reverse_search, false, &predicate, cx)
3086 {
3087 return Some(SelectedEntry {
3088 worktree_id: tree_id,
3089 entry_id: found.id,
3090 });
3091 }
3092 }
3093
3094 last_found
3095 }
3096
3097 fn find_visible_entry(
3098 &self,
3099 start: Option<&SelectedEntry>,
3100 reverse_search: bool,
3101 predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3102 cx: &mut ViewContext<Self>,
3103 ) -> Option<SelectedEntry> {
3104 let mut worktree_ids: Vec<_> = self
3105 .visible_entries
3106 .iter()
3107 .map(|(worktree_id, _, _)| *worktree_id)
3108 .collect();
3109
3110 let mut last_found: Option<SelectedEntry> = None;
3111
3112 if let Some(start) = start {
3113 let entries = self
3114 .visible_entries
3115 .iter()
3116 .find(|(worktree_id, _, _)| *worktree_id == start.worktree_id)
3117 .map(|(_, entries, _)| entries)?;
3118
3119 let mut start_idx = entries
3120 .iter()
3121 .enumerate()
3122 .find(|(_, ele)| ele.id == start.entry_id)
3123 .map(|(idx, _)| idx)?;
3124
3125 if reverse_search {
3126 start_idx = start_idx.saturating_add(1usize);
3127 }
3128
3129 let (left, right) = entries.split_at_checked(start_idx)?;
3130
3131 let (first_iter, second_iter) = if reverse_search {
3132 (
3133 utils::ReversibleIterable::new(left.iter(), reverse_search),
3134 utils::ReversibleIterable::new(right.iter(), reverse_search),
3135 )
3136 } else {
3137 (
3138 utils::ReversibleIterable::new(right.iter(), reverse_search),
3139 utils::ReversibleIterable::new(left.iter(), reverse_search),
3140 )
3141 };
3142
3143 let first_search = first_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3144 let second_search = second_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3145
3146 if first_search.is_some() {
3147 return first_search.map(|entry| SelectedEntry {
3148 worktree_id: start.worktree_id,
3149 entry_id: entry.id,
3150 });
3151 }
3152
3153 last_found = second_search.map(|entry| SelectedEntry {
3154 worktree_id: start.worktree_id,
3155 entry_id: entry.id,
3156 });
3157
3158 let idx = worktree_ids
3159 .iter()
3160 .enumerate()
3161 .find(|(_, ele)| **ele == start.worktree_id)
3162 .map(|(idx, _)| idx);
3163
3164 if let Some(idx) = idx {
3165 worktree_ids.rotate_left(idx + 1usize);
3166 worktree_ids.pop();
3167 }
3168 }
3169
3170 for tree_id in worktree_ids.into_iter() {
3171 if let Some(found) =
3172 self.find_entry_in_worktree(tree_id, reverse_search, true, &predicate, cx)
3173 {
3174 return Some(SelectedEntry {
3175 worktree_id: tree_id,
3176 entry_id: found.id,
3177 });
3178 }
3179 }
3180
3181 last_found
3182 }
3183
3184 fn calculate_depth_and_difference(
3185 entry: &Entry,
3186 visible_worktree_entries: &HashSet<Arc<Path>>,
3187 ) -> (usize, usize) {
3188 let (depth, difference) = entry
3189 .path
3190 .ancestors()
3191 .skip(1) // Skip the entry itself
3192 .find_map(|ancestor| {
3193 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
3194 let entry_path_components_count = entry.path.components().count();
3195 let parent_path_components_count = parent_entry.components().count();
3196 let difference = entry_path_components_count - parent_path_components_count;
3197 let depth = parent_entry
3198 .ancestors()
3199 .skip(1)
3200 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
3201 .count();
3202 Some((depth + 1, difference))
3203 } else {
3204 None
3205 }
3206 })
3207 .unwrap_or((0, 0));
3208
3209 (depth, difference)
3210 }
3211
3212 fn render_entry(
3213 &self,
3214 entry_id: ProjectEntryId,
3215 details: EntryDetails,
3216 cx: &mut ViewContext<Self>,
3217 ) -> Stateful<Div> {
3218 const GROUP_NAME: &str = "project_entry";
3219
3220 let kind = details.kind;
3221 let settings = ProjectPanelSettings::get_global(cx);
3222 let show_editor = details.is_editing && !details.is_processing;
3223
3224 let selection = SelectedEntry {
3225 worktree_id: details.worktree_id,
3226 entry_id,
3227 };
3228
3229 let is_marked = self.marked_entries.contains(&selection);
3230 let is_active = self
3231 .selection
3232 .map_or(false, |selection| selection.entry_id == entry_id);
3233
3234 let width = self.size(cx);
3235 let file_name = details.filename.clone();
3236
3237 let mut icon = details.icon.clone();
3238 if settings.file_icons && show_editor && details.kind.is_file() {
3239 let filename = self.filename_editor.read(cx).text(cx);
3240 if filename.len() > 2 {
3241 icon = FileIcons::get_icon(Path::new(&filename), cx);
3242 }
3243 }
3244
3245 let filename_text_color = details.filename_text_color;
3246 let diagnostic_severity = details.diagnostic_severity;
3247 let item_colors = get_item_color(cx);
3248
3249 let canonical_path = details
3250 .canonical_path
3251 .as_ref()
3252 .map(|f| f.to_string_lossy().to_string());
3253 let path = details.path.clone();
3254
3255 let depth = details.depth;
3256 let worktree_id = details.worktree_id;
3257 let selections = Arc::new(self.marked_entries.clone());
3258 let is_local = self.project.read(cx).is_local();
3259
3260 let dragged_selection = DraggedSelection {
3261 active_selection: selection,
3262 marked_selections: selections,
3263 };
3264
3265 let default_color = if is_marked || is_active {
3266 item_colors.marked_active
3267 } else {
3268 item_colors.default
3269 };
3270
3271 let bg_hover_color = if self.mouse_down || is_marked || is_active {
3272 item_colors.marked_active
3273 } else {
3274 item_colors.hover
3275 };
3276
3277 let border_color =
3278 if !self.mouse_down && is_active && self.focus_handle.contains_focused(cx) {
3279 item_colors.focused
3280 } else if self.mouse_down && is_marked || is_active {
3281 item_colors.marked_active
3282 } else {
3283 item_colors.default
3284 };
3285
3286 div()
3287 .id(entry_id.to_proto() as usize)
3288 .group(GROUP_NAME)
3289 .cursor_pointer()
3290 .rounded_none()
3291 .bg(default_color)
3292 .border_1()
3293 .border_r_2()
3294 .border_color(border_color)
3295 .hover(|style| style.bg(bg_hover_color))
3296 .when(is_local, |div| {
3297 div.on_drag_move::<ExternalPaths>(cx.listener(
3298 move |this, event: &DragMoveEvent<ExternalPaths>, cx| {
3299 if event.bounds.contains(&event.event.position) {
3300 if this.last_external_paths_drag_over_entry == Some(entry_id) {
3301 return;
3302 }
3303 this.last_external_paths_drag_over_entry = Some(entry_id);
3304 this.marked_entries.clear();
3305
3306 let Some((worktree, path, entry)) = maybe!({
3307 let worktree = this
3308 .project
3309 .read(cx)
3310 .worktree_for_id(selection.worktree_id, cx)?;
3311 let worktree = worktree.read(cx);
3312 let abs_path = worktree.absolutize(&path).log_err()?;
3313 let path = if abs_path.is_dir() {
3314 path.as_ref()
3315 } else {
3316 path.parent()?
3317 };
3318 let entry = worktree.entry_for_path(path)?;
3319 Some((worktree, path, entry))
3320 }) else {
3321 return;
3322 };
3323
3324 this.marked_entries.insert(SelectedEntry {
3325 entry_id: entry.id,
3326 worktree_id: worktree.id(),
3327 });
3328
3329 for entry in worktree.child_entries(path) {
3330 this.marked_entries.insert(SelectedEntry {
3331 entry_id: entry.id,
3332 worktree_id: worktree.id(),
3333 });
3334 }
3335
3336 cx.notify();
3337 }
3338 },
3339 ))
3340 .on_drop(cx.listener(
3341 move |this, external_paths: &ExternalPaths, cx| {
3342 this.hover_scroll_task.take();
3343 this.last_external_paths_drag_over_entry = None;
3344 this.marked_entries.clear();
3345 this.drop_external_files(external_paths.paths(), entry_id, cx);
3346 cx.stop_propagation();
3347 },
3348 ))
3349 })
3350 .on_drag(dragged_selection, move |selection, click_offset, cx| {
3351 cx.new_view(|_| DraggedProjectEntryView {
3352 details: details.clone(),
3353 width,
3354 click_offset,
3355 selection: selection.active_selection,
3356 selections: selection.marked_selections.clone(),
3357 })
3358 })
3359 .drag_over::<DraggedSelection>(move |style, _, _| style.bg(item_colors.drag_over))
3360 .on_drop(cx.listener(move |this, selections: &DraggedSelection, cx| {
3361 this.hover_scroll_task.take();
3362 this.drag_onto(selections, entry_id, kind.is_file(), cx);
3363 }))
3364 .on_mouse_down(
3365 MouseButton::Left,
3366 cx.listener(move |this, _, cx| {
3367 this.mouse_down = true;
3368 cx.propagate();
3369 }),
3370 )
3371 .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
3372 if event.down.button == MouseButton::Right || event.down.first_mouse || show_editor
3373 {
3374 return;
3375 }
3376 if event.down.button == MouseButton::Left {
3377 this.mouse_down = false;
3378 }
3379 cx.stop_propagation();
3380
3381 if let Some(selection) = this.selection.filter(|_| event.down.modifiers.shift) {
3382 let current_selection = this.index_for_selection(selection);
3383 let clicked_entry = SelectedEntry {
3384 entry_id,
3385 worktree_id,
3386 };
3387 let target_selection = this.index_for_selection(clicked_entry);
3388 if let Some(((_, _, source_index), (_, _, target_index))) =
3389 current_selection.zip(target_selection)
3390 {
3391 let range_start = source_index.min(target_index);
3392 let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
3393 let mut new_selections = BTreeSet::new();
3394 this.for_each_visible_entry(
3395 range_start..range_end,
3396 cx,
3397 |entry_id, details, _| {
3398 new_selections.insert(SelectedEntry {
3399 entry_id,
3400 worktree_id: details.worktree_id,
3401 });
3402 },
3403 );
3404
3405 this.marked_entries = this
3406 .marked_entries
3407 .union(&new_selections)
3408 .cloned()
3409 .collect();
3410
3411 this.selection = Some(clicked_entry);
3412 this.marked_entries.insert(clicked_entry);
3413 }
3414 } else if event.down.modifiers.secondary() {
3415 if event.down.click_count > 1 {
3416 this.split_entry(entry_id, cx);
3417 } else if !this.marked_entries.insert(selection) {
3418 this.marked_entries.remove(&selection);
3419 }
3420 } else if kind.is_dir() {
3421 this.marked_entries.clear();
3422 this.toggle_expanded(entry_id, cx);
3423 } else {
3424 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
3425 let click_count = event.up.click_count;
3426 let focus_opened_item = !preview_tabs_enabled || click_count > 1;
3427 let allow_preview = preview_tabs_enabled && click_count == 1;
3428 this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
3429 }
3430 }))
3431 .child(
3432 ListItem::new(entry_id.to_proto() as usize)
3433 .indent_level(depth)
3434 .indent_step_size(px(settings.indent_size))
3435 .selectable(false)
3436 .when_some(canonical_path, |this, path| {
3437 this.end_slot::<AnyElement>(
3438 div()
3439 .id("symlink_icon")
3440 .pr_3()
3441 .tooltip(move |cx| {
3442 Tooltip::with_meta(path.to_string(), None, "Symbolic Link", cx)
3443 })
3444 .child(
3445 Icon::new(IconName::ArrowUpRight)
3446 .size(IconSize::Indicator)
3447 .color(filename_text_color),
3448 )
3449 .into_any_element(),
3450 )
3451 })
3452 .child(if let Some(icon) = &icon {
3453 // Check if there's a diagnostic severity and get the decoration color
3454 if let Some((_, decoration_color)) =
3455 entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
3456 {
3457 // Determine if the diagnostic is a warning
3458 let is_warning = diagnostic_severity
3459 .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
3460 .unwrap_or(false);
3461 div().child(
3462 DecoratedIcon::new(
3463 Icon::from_path(icon.clone()).color(Color::Muted),
3464 Some(
3465 IconDecoration::new(
3466 if kind.is_file() {
3467 if is_warning {
3468 IconDecorationKind::Triangle
3469 } else {
3470 IconDecorationKind::X
3471 }
3472 } else {
3473 IconDecorationKind::Dot
3474 },
3475 default_color,
3476 cx,
3477 )
3478 .group_name(Some(GROUP_NAME.into()))
3479 .knockout_hover_color(bg_hover_color)
3480 .color(decoration_color.color(cx))
3481 .position(Point {
3482 x: px(-2.),
3483 y: px(-2.),
3484 }),
3485 ),
3486 )
3487 .into_any_element(),
3488 )
3489 } else {
3490 h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
3491 }
3492 } else {
3493 if let Some((icon_name, color)) =
3494 entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
3495 {
3496 h_flex()
3497 .size(IconSize::default().rems())
3498 .child(Icon::new(icon_name).color(color).size(IconSize::Small))
3499 } else {
3500 h_flex()
3501 .size(IconSize::default().rems())
3502 .invisible()
3503 .flex_none()
3504 }
3505 })
3506 .child(
3507 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
3508 h_flex().h_6().w_full().child(editor.clone())
3509 } else {
3510 h_flex().h_6().map(|mut this| {
3511 if let Some(folded_ancestors) = self.ancestors.get(&entry_id) {
3512 let components = Path::new(&file_name)
3513 .components()
3514 .map(|comp| {
3515 let comp_str =
3516 comp.as_os_str().to_string_lossy().into_owned();
3517 comp_str
3518 })
3519 .collect::<Vec<_>>();
3520
3521 let components_len = components.len();
3522 let active_index = components_len
3523 - 1
3524 - folded_ancestors.current_ancestor_depth;
3525 const DELIMITER: SharedString =
3526 SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
3527 for (index, component) in components.into_iter().enumerate() {
3528 if index != 0 {
3529 this = this.child(
3530 Label::new(DELIMITER.clone())
3531 .single_line()
3532 .color(filename_text_color),
3533 );
3534 }
3535 let id = SharedString::from(format!(
3536 "project_panel_path_component_{}_{index}",
3537 entry_id.to_usize()
3538 ));
3539 let label = div()
3540 .id(id)
3541 .on_click(cx.listener(move |this, _, cx| {
3542 if index != active_index {
3543 if let Some(folds) =
3544 this.ancestors.get_mut(&entry_id)
3545 {
3546 folds.current_ancestor_depth =
3547 components_len - 1 - index;
3548 cx.notify();
3549 }
3550 }
3551 }))
3552 .child(
3553 Label::new(component)
3554 .single_line()
3555 .color(filename_text_color)
3556 .when(
3557 index == active_index
3558 && (is_active || is_marked),
3559 |this| this.underline(true),
3560 ),
3561 );
3562
3563 this = this.child(label);
3564 }
3565
3566 this
3567 } else {
3568 this.child(
3569 Label::new(file_name)
3570 .single_line()
3571 .color(filename_text_color),
3572 )
3573 }
3574 })
3575 }
3576 .ml_1(),
3577 )
3578 .on_secondary_mouse_down(cx.listener(
3579 move |this, event: &MouseDownEvent, cx| {
3580 // Stop propagation to prevent the catch-all context menu for the project
3581 // panel from being deployed.
3582 cx.stop_propagation();
3583 // Some context menu actions apply to all marked entries. If the user
3584 // right-clicks on an entry that is not marked, they may not realize the
3585 // action applies to multiple entries. To avoid inadvertent changes, all
3586 // entries are unmarked.
3587 if !this.marked_entries.contains(&selection) {
3588 this.marked_entries.clear();
3589 }
3590 this.deploy_context_menu(event.position, entry_id, cx);
3591 },
3592 ))
3593 .overflow_x(),
3594 )
3595 }
3596
3597 fn render_vertical_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
3598 if !Self::should_show_scrollbar(cx)
3599 || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
3600 {
3601 return None;
3602 }
3603 Some(
3604 div()
3605 .occlude()
3606 .id("project-panel-vertical-scroll")
3607 .on_mouse_move(cx.listener(|_, _, cx| {
3608 cx.notify();
3609 cx.stop_propagation()
3610 }))
3611 .on_hover(|_, cx| {
3612 cx.stop_propagation();
3613 })
3614 .on_any_mouse_down(|_, cx| {
3615 cx.stop_propagation();
3616 })
3617 .on_mouse_up(
3618 MouseButton::Left,
3619 cx.listener(|this, _, cx| {
3620 if !this.vertical_scrollbar_state.is_dragging()
3621 && !this.focus_handle.contains_focused(cx)
3622 {
3623 this.hide_scrollbar(cx);
3624 cx.notify();
3625 }
3626
3627 cx.stop_propagation();
3628 }),
3629 )
3630 .on_scroll_wheel(cx.listener(|_, _, cx| {
3631 cx.notify();
3632 }))
3633 .h_full()
3634 .absolute()
3635 .right_1()
3636 .top_1()
3637 .bottom_1()
3638 .w(px(12.))
3639 .cursor_default()
3640 .children(Scrollbar::vertical(
3641 // percentage as f32..end_offset as f32,
3642 self.vertical_scrollbar_state.clone(),
3643 )),
3644 )
3645 }
3646
3647 fn render_horizontal_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
3648 if !Self::should_show_scrollbar(cx)
3649 || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
3650 {
3651 return None;
3652 }
3653
3654 let scroll_handle = self.scroll_handle.0.borrow();
3655 let longest_item_width = scroll_handle
3656 .last_item_size
3657 .filter(|size| size.contents.width > size.item.width)?
3658 .contents
3659 .width
3660 .0 as f64;
3661 if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
3662 return None;
3663 }
3664
3665 Some(
3666 div()
3667 .occlude()
3668 .id("project-panel-horizontal-scroll")
3669 .on_mouse_move(cx.listener(|_, _, cx| {
3670 cx.notify();
3671 cx.stop_propagation()
3672 }))
3673 .on_hover(|_, cx| {
3674 cx.stop_propagation();
3675 })
3676 .on_any_mouse_down(|_, cx| {
3677 cx.stop_propagation();
3678 })
3679 .on_mouse_up(
3680 MouseButton::Left,
3681 cx.listener(|this, _, cx| {
3682 if !this.horizontal_scrollbar_state.is_dragging()
3683 && !this.focus_handle.contains_focused(cx)
3684 {
3685 this.hide_scrollbar(cx);
3686 cx.notify();
3687 }
3688
3689 cx.stop_propagation();
3690 }),
3691 )
3692 .on_scroll_wheel(cx.listener(|_, _, cx| {
3693 cx.notify();
3694 }))
3695 .w_full()
3696 .absolute()
3697 .right_1()
3698 .left_1()
3699 .bottom_1()
3700 .h(px(12.))
3701 .cursor_default()
3702 .when(self.width.is_some(), |this| {
3703 this.children(Scrollbar::horizontal(
3704 self.horizontal_scrollbar_state.clone(),
3705 ))
3706 }),
3707 )
3708 }
3709
3710 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
3711 let mut dispatch_context = KeyContext::new_with_defaults();
3712 dispatch_context.add("ProjectPanel");
3713 dispatch_context.add("menu");
3714
3715 let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
3716 "editing"
3717 } else {
3718 "not_editing"
3719 };
3720
3721 dispatch_context.add(identifier);
3722 dispatch_context
3723 }
3724
3725 fn should_show_scrollbar(cx: &AppContext) -> bool {
3726 let show = ProjectPanelSettings::get_global(cx)
3727 .scrollbar
3728 .show
3729 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
3730 match show {
3731 ShowScrollbar::Auto => true,
3732 ShowScrollbar::System => true,
3733 ShowScrollbar::Always => true,
3734 ShowScrollbar::Never => false,
3735 }
3736 }
3737
3738 fn should_autohide_scrollbar(cx: &AppContext) -> bool {
3739 let show = ProjectPanelSettings::get_global(cx)
3740 .scrollbar
3741 .show
3742 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
3743 match show {
3744 ShowScrollbar::Auto => true,
3745 ShowScrollbar::System => cx
3746 .try_global::<ScrollbarAutoHide>()
3747 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
3748 ShowScrollbar::Always => false,
3749 ShowScrollbar::Never => true,
3750 }
3751 }
3752
3753 fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
3754 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
3755 if !Self::should_autohide_scrollbar(cx) {
3756 return;
3757 }
3758 self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
3759 cx.background_executor()
3760 .timer(SCROLLBAR_SHOW_INTERVAL)
3761 .await;
3762 panel
3763 .update(&mut cx, |panel, cx| {
3764 panel.show_scrollbar = false;
3765 cx.notify();
3766 })
3767 .log_err();
3768 }))
3769 }
3770
3771 fn reveal_entry(
3772 &mut self,
3773 project: Model<Project>,
3774 entry_id: ProjectEntryId,
3775 skip_ignored: bool,
3776 cx: &mut ViewContext<Self>,
3777 ) {
3778 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
3779 let worktree = worktree.read(cx);
3780 if skip_ignored
3781 && worktree
3782 .entry_for_id(entry_id)
3783 .map_or(true, |entry| entry.is_ignored)
3784 {
3785 return;
3786 }
3787
3788 let worktree_id = worktree.id();
3789 self.expand_entry(worktree_id, entry_id, cx);
3790 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
3791
3792 if self.marked_entries.len() == 1
3793 && self
3794 .marked_entries
3795 .first()
3796 .filter(|entry| entry.entry_id == entry_id)
3797 .is_none()
3798 {
3799 self.marked_entries.clear();
3800 }
3801 self.autoscroll(cx);
3802 cx.notify();
3803 }
3804 }
3805
3806 fn find_active_indent_guide(
3807 &self,
3808 indent_guides: &[IndentGuideLayout],
3809 cx: &AppContext,
3810 ) -> Option<usize> {
3811 let (worktree, entry) = self.selected_entry(cx)?;
3812
3813 // Find the parent entry of the indent guide, this will either be the
3814 // expanded folder we have selected, or the parent of the currently
3815 // selected file/collapsed directory
3816 let mut entry = entry;
3817 loop {
3818 let is_expanded_dir = entry.is_dir()
3819 && self
3820 .expanded_dir_ids
3821 .get(&worktree.id())
3822 .map(|ids| ids.binary_search(&entry.id).is_ok())
3823 .unwrap_or(false);
3824 if is_expanded_dir {
3825 break;
3826 }
3827 entry = worktree.entry_for_path(&entry.path.parent()?)?;
3828 }
3829
3830 let (active_indent_range, depth) = {
3831 let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
3832 let child_paths = &self.visible_entries[worktree_ix].1;
3833 let mut child_count = 0;
3834 let depth = entry.path.ancestors().count();
3835 while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
3836 if entry.path.ancestors().count() <= depth {
3837 break;
3838 }
3839 child_count += 1;
3840 }
3841
3842 let start = ix + 1;
3843 let end = start + child_count;
3844
3845 let (_, entries, paths) = &self.visible_entries[worktree_ix];
3846 let visible_worktree_entries =
3847 paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect());
3848
3849 // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
3850 let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
3851 (start..end, depth)
3852 };
3853
3854 let candidates = indent_guides
3855 .iter()
3856 .enumerate()
3857 .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
3858
3859 for (i, indent) in candidates {
3860 // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
3861 if active_indent_range.start <= indent.offset.y + indent.length
3862 && indent.offset.y <= active_indent_range.end
3863 {
3864 return Some(i);
3865 }
3866 }
3867 None
3868 }
3869}
3870
3871fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
3872 const ICON_SIZE_FACTOR: usize = 2;
3873 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
3874 if is_symlink {
3875 item_width += ICON_SIZE_FACTOR;
3876 }
3877 item_width
3878}
3879
3880impl Render for ProjectPanel {
3881 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
3882 let has_worktree = !self.visible_entries.is_empty();
3883 let project = self.project.read(cx);
3884 let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
3885 let show_indent_guides =
3886 ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
3887 let is_local = project.is_local();
3888
3889 if has_worktree {
3890 let item_count = self
3891 .visible_entries
3892 .iter()
3893 .map(|(_, worktree_entries, _)| worktree_entries.len())
3894 .sum();
3895
3896 fn handle_drag_move_scroll<T: 'static>(
3897 this: &mut ProjectPanel,
3898 e: &DragMoveEvent<T>,
3899 cx: &mut ViewContext<ProjectPanel>,
3900 ) {
3901 if !e.bounds.contains(&e.event.position) {
3902 return;
3903 }
3904 this.hover_scroll_task.take();
3905 let panel_height = e.bounds.size.height;
3906 if panel_height <= px(0.) {
3907 return;
3908 }
3909
3910 let event_offset = e.event.position.y - e.bounds.origin.y;
3911 // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
3912 let hovered_region_offset = event_offset / panel_height;
3913
3914 // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
3915 // These pixels offsets were picked arbitrarily.
3916 let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
3917 8.
3918 } else if hovered_region_offset <= 0.15 {
3919 5.
3920 } else if hovered_region_offset >= 0.95 {
3921 -8.
3922 } else if hovered_region_offset >= 0.85 {
3923 -5.
3924 } else {
3925 return;
3926 };
3927 let adjustment = point(px(0.), px(vertical_scroll_offset));
3928 this.hover_scroll_task = Some(cx.spawn(move |this, mut cx| async move {
3929 loop {
3930 let should_stop_scrolling = this
3931 .update(&mut cx, |this, cx| {
3932 this.hover_scroll_task.as_ref()?;
3933 let handle = this.scroll_handle.0.borrow_mut();
3934 let offset = handle.base_handle.offset();
3935
3936 handle.base_handle.set_offset(offset + adjustment);
3937 cx.notify();
3938 Some(())
3939 })
3940 .ok()
3941 .flatten()
3942 .is_some();
3943 if should_stop_scrolling {
3944 return;
3945 }
3946 cx.background_executor()
3947 .timer(Duration::from_millis(16))
3948 .await;
3949 }
3950 }));
3951 }
3952 h_flex()
3953 .id("project-panel")
3954 .group("project-panel")
3955 .on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
3956 .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
3957 .size_full()
3958 .relative()
3959 .on_hover(cx.listener(|this, hovered, cx| {
3960 if *hovered {
3961 this.show_scrollbar = true;
3962 this.hide_scrollbar_task.take();
3963 cx.notify();
3964 } else if !this.focus_handle.contains_focused(cx) {
3965 this.hide_scrollbar(cx);
3966 }
3967 }))
3968 .on_click(cx.listener(|this, _event, cx| {
3969 cx.stop_propagation();
3970 this.selection = None;
3971 this.marked_entries.clear();
3972 }))
3973 .key_context(self.dispatch_context(cx))
3974 .on_action(cx.listener(Self::select_next))
3975 .on_action(cx.listener(Self::select_prev))
3976 .on_action(cx.listener(Self::select_first))
3977 .on_action(cx.listener(Self::select_last))
3978 .on_action(cx.listener(Self::select_parent))
3979 .on_action(cx.listener(Self::select_next_git_entry))
3980 .on_action(cx.listener(Self::select_prev_git_entry))
3981 .on_action(cx.listener(Self::select_next_diagnostic))
3982 .on_action(cx.listener(Self::select_prev_diagnostic))
3983 .on_action(cx.listener(Self::select_next_directory))
3984 .on_action(cx.listener(Self::select_prev_directory))
3985 .on_action(cx.listener(Self::expand_selected_entry))
3986 .on_action(cx.listener(Self::collapse_selected_entry))
3987 .on_action(cx.listener(Self::collapse_all_entries))
3988 .on_action(cx.listener(Self::open))
3989 .on_action(cx.listener(Self::open_permanent))
3990 .on_action(cx.listener(Self::confirm))
3991 .on_action(cx.listener(Self::cancel))
3992 .on_action(cx.listener(Self::copy_path))
3993 .on_action(cx.listener(Self::copy_relative_path))
3994 .on_action(cx.listener(Self::new_search_in_directory))
3995 .on_action(cx.listener(Self::unfold_directory))
3996 .on_action(cx.listener(Self::fold_directory))
3997 .on_action(cx.listener(Self::remove_from_project))
3998 .when(!project.is_read_only(cx), |el| {
3999 el.on_action(cx.listener(Self::new_file))
4000 .on_action(cx.listener(Self::new_directory))
4001 .on_action(cx.listener(Self::rename))
4002 .on_action(cx.listener(Self::delete))
4003 .on_action(cx.listener(Self::trash))
4004 .on_action(cx.listener(Self::cut))
4005 .on_action(cx.listener(Self::copy))
4006 .on_action(cx.listener(Self::paste))
4007 .on_action(cx.listener(Self::duplicate))
4008 .on_click(cx.listener(|this, event: &gpui::ClickEvent, cx| {
4009 if event.up.click_count > 1 {
4010 if let Some(entry_id) = this.last_worktree_root_id {
4011 let project = this.project.read(cx);
4012
4013 let worktree_id = if let Some(worktree) =
4014 project.worktree_for_entry(entry_id, cx)
4015 {
4016 worktree.read(cx).id()
4017 } else {
4018 return;
4019 };
4020
4021 this.selection = Some(SelectedEntry {
4022 worktree_id,
4023 entry_id,
4024 });
4025
4026 this.new_file(&NewFile, cx);
4027 }
4028 }
4029 }))
4030 })
4031 .when(project.is_local(), |el| {
4032 el.on_action(cx.listener(Self::reveal_in_finder))
4033 .on_action(cx.listener(Self::open_system))
4034 .on_action(cx.listener(Self::open_in_terminal))
4035 })
4036 .when(project.is_via_ssh(), |el| {
4037 el.on_action(cx.listener(Self::open_in_terminal))
4038 })
4039 .on_mouse_down(
4040 MouseButton::Right,
4041 cx.listener(move |this, event: &MouseDownEvent, cx| {
4042 // When deploying the context menu anywhere below the last project entry,
4043 // act as if the user clicked the root of the last worktree.
4044 if let Some(entry_id) = this.last_worktree_root_id {
4045 this.deploy_context_menu(event.position, entry_id, cx);
4046 }
4047 }),
4048 )
4049 .track_focus(&self.focus_handle(cx))
4050 .child(
4051 uniform_list(cx.view().clone(), "entries", item_count, {
4052 |this, range, cx| {
4053 let mut items = Vec::with_capacity(range.end - range.start);
4054 this.for_each_visible_entry(range, cx, |id, details, cx| {
4055 items.push(this.render_entry(id, details, cx));
4056 });
4057 items
4058 }
4059 })
4060 .when(show_indent_guides, |list| {
4061 list.with_decoration(
4062 ui::indent_guides(
4063 cx.view().clone(),
4064 px(indent_size),
4065 IndentGuideColors::panel(cx),
4066 |this, range, cx| {
4067 let mut items =
4068 SmallVec::with_capacity(range.end - range.start);
4069 this.iter_visible_entries(range, cx, |entry, entries, _| {
4070 let (depth, _) =
4071 Self::calculate_depth_and_difference(entry, entries);
4072 items.push(depth);
4073 });
4074 items
4075 },
4076 )
4077 .on_click(cx.listener(
4078 |this, active_indent_guide: &IndentGuideLayout, cx| {
4079 if cx.modifiers().secondary() {
4080 let ix = active_indent_guide.offset.y;
4081 let Some((target_entry, worktree)) = maybe!({
4082 let (worktree_id, entry) = this.entry_at_index(ix)?;
4083 let worktree = this
4084 .project
4085 .read(cx)
4086 .worktree_for_id(worktree_id, cx)?;
4087 let target_entry = worktree
4088 .read(cx)
4089 .entry_for_path(&entry.path.parent()?)?;
4090 Some((target_entry, worktree))
4091 }) else {
4092 return;
4093 };
4094
4095 this.collapse_entry(target_entry.clone(), worktree, cx);
4096 }
4097 },
4098 ))
4099 .with_render_fn(
4100 cx.view().clone(),
4101 move |this, params, cx| {
4102 const LEFT_OFFSET: f32 = 14.;
4103 const PADDING_Y: f32 = 4.;
4104 const HITBOX_OVERDRAW: f32 = 3.;
4105
4106 let active_indent_guide_index =
4107 this.find_active_indent_guide(¶ms.indent_guides, cx);
4108
4109 let indent_size = params.indent_size;
4110 let item_height = params.item_height;
4111
4112 params
4113 .indent_guides
4114 .into_iter()
4115 .enumerate()
4116 .map(|(idx, layout)| {
4117 let offset = if layout.continues_offscreen {
4118 px(0.)
4119 } else {
4120 px(PADDING_Y)
4121 };
4122 let bounds = Bounds::new(
4123 point(
4124 px(layout.offset.x as f32) * indent_size
4125 + px(LEFT_OFFSET),
4126 px(layout.offset.y as f32) * item_height
4127 + offset,
4128 ),
4129 size(
4130 px(1.),
4131 px(layout.length as f32) * item_height
4132 - px(offset.0 * 2.),
4133 ),
4134 );
4135 ui::RenderedIndentGuide {
4136 bounds,
4137 layout,
4138 is_active: Some(idx) == active_indent_guide_index,
4139 hitbox: Some(Bounds::new(
4140 point(
4141 bounds.origin.x - px(HITBOX_OVERDRAW),
4142 bounds.origin.y,
4143 ),
4144 size(
4145 bounds.size.width
4146 + px(2. * HITBOX_OVERDRAW),
4147 bounds.size.height,
4148 ),
4149 )),
4150 }
4151 })
4152 .collect()
4153 },
4154 ),
4155 )
4156 })
4157 .size_full()
4158 .with_sizing_behavior(ListSizingBehavior::Infer)
4159 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4160 .with_width_from_item(self.max_width_item_index)
4161 .track_scroll(self.scroll_handle.clone()),
4162 )
4163 .children(self.render_vertical_scrollbar(cx))
4164 .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
4165 this.pb_4().child(scrollbar)
4166 })
4167 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4168 deferred(
4169 anchored()
4170 .position(*position)
4171 .anchor(gpui::Corner::TopLeft)
4172 .child(menu.clone()),
4173 )
4174 .with_priority(1)
4175 }))
4176 } else {
4177 v_flex()
4178 .id("empty-project_panel")
4179 .size_full()
4180 .p_4()
4181 .track_focus(&self.focus_handle(cx))
4182 .child(
4183 Button::new("open_project", "Open a project")
4184 .full_width()
4185 .key_binding(KeyBinding::for_action(&workspace::Open, cx))
4186 .on_click(cx.listener(|this, _, cx| {
4187 this.workspace
4188 .update(cx, |_, cx| cx.dispatch_action(Box::new(workspace::Open)))
4189 .log_err();
4190 })),
4191 )
4192 .when(is_local, |div| {
4193 div.drag_over::<ExternalPaths>(|style, _, cx| {
4194 style.bg(cx.theme().colors().drop_target_background)
4195 })
4196 .on_drop(cx.listener(
4197 move |this, external_paths: &ExternalPaths, cx| {
4198 this.last_external_paths_drag_over_entry = None;
4199 this.marked_entries.clear();
4200 this.hover_scroll_task.take();
4201 if let Some(task) = this
4202 .workspace
4203 .update(cx, |workspace, cx| {
4204 workspace.open_workspace_for_paths(
4205 true,
4206 external_paths.paths().to_owned(),
4207 cx,
4208 )
4209 })
4210 .log_err()
4211 {
4212 task.detach_and_log_err(cx);
4213 }
4214 cx.stop_propagation();
4215 },
4216 ))
4217 })
4218 }
4219 }
4220}
4221
4222impl Render for DraggedProjectEntryView {
4223 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
4224 let settings = ProjectPanelSettings::get_global(cx);
4225 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
4226
4227 h_flex().font(ui_font).map(|this| {
4228 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
4229 this.flex_none()
4230 .w(self.width)
4231 .child(div().w(self.click_offset.x))
4232 .child(
4233 div()
4234 .p_1()
4235 .rounded_xl()
4236 .bg(cx.theme().colors().background)
4237 .child(Label::new(format!("{} entries", self.selections.len()))),
4238 )
4239 } else {
4240 this.w(self.width).bg(cx.theme().colors().background).child(
4241 ListItem::new(self.selection.entry_id.to_proto() as usize)
4242 .indent_level(self.details.depth)
4243 .indent_step_size(px(settings.indent_size))
4244 .child(if let Some(icon) = &self.details.icon {
4245 div().child(Icon::from_path(icon.clone()))
4246 } else {
4247 div()
4248 })
4249 .child(Label::new(self.details.filename.clone())),
4250 )
4251 }
4252 })
4253 }
4254}
4255
4256impl EventEmitter<Event> for ProjectPanel {}
4257
4258impl EventEmitter<PanelEvent> for ProjectPanel {}
4259
4260impl Panel for ProjectPanel {
4261 fn position(&self, cx: &WindowContext) -> DockPosition {
4262 match ProjectPanelSettings::get_global(cx).dock {
4263 ProjectPanelDockPosition::Left => DockPosition::Left,
4264 ProjectPanelDockPosition::Right => DockPosition::Right,
4265 }
4266 }
4267
4268 fn position_is_valid(&self, position: DockPosition) -> bool {
4269 matches!(position, DockPosition::Left | DockPosition::Right)
4270 }
4271
4272 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
4273 settings::update_settings_file::<ProjectPanelSettings>(
4274 self.fs.clone(),
4275 cx,
4276 move |settings, _| {
4277 let dock = match position {
4278 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
4279 DockPosition::Right => ProjectPanelDockPosition::Right,
4280 };
4281 settings.dock = Some(dock);
4282 },
4283 );
4284 }
4285
4286 fn size(&self, cx: &WindowContext) -> Pixels {
4287 self.width
4288 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
4289 }
4290
4291 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
4292 self.width = size;
4293 self.serialize(cx);
4294 cx.notify();
4295 }
4296
4297 fn icon(&self, cx: &WindowContext) -> Option<IconName> {
4298 ProjectPanelSettings::get_global(cx)
4299 .button
4300 .then_some(IconName::FileTree)
4301 }
4302
4303 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
4304 Some("Project Panel")
4305 }
4306
4307 fn toggle_action(&self) -> Box<dyn Action> {
4308 Box::new(ToggleFocus)
4309 }
4310
4311 fn persistent_name() -> &'static str {
4312 "Project Panel"
4313 }
4314
4315 fn starts_open(&self, cx: &WindowContext) -> bool {
4316 let project = &self.project.read(cx);
4317 project.visible_worktrees(cx).any(|tree| {
4318 tree.read(cx)
4319 .root_entry()
4320 .map_or(false, |entry| entry.is_dir())
4321 })
4322 }
4323
4324 fn activation_priority(&self) -> u32 {
4325 0
4326 }
4327}
4328
4329impl FocusableView for ProjectPanel {
4330 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
4331 self.focus_handle.clone()
4332 }
4333}
4334
4335impl ClipboardEntry {
4336 fn is_cut(&self) -> bool {
4337 matches!(self, Self::Cut { .. })
4338 }
4339
4340 fn items(&self) -> &BTreeSet<SelectedEntry> {
4341 match self {
4342 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
4343 }
4344 }
4345}
4346
4347#[cfg(test)]
4348mod tests {
4349 use super::*;
4350 use collections::HashSet;
4351 use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
4352 use pretty_assertions::assert_eq;
4353 use project::{FakeFs, WorktreeSettings};
4354 use serde_json::json;
4355 use settings::SettingsStore;
4356 use std::path::{Path, PathBuf};
4357 use workspace::{
4358 item::{Item, ProjectItem},
4359 register_project_item, AppState,
4360 };
4361
4362 #[gpui::test]
4363 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
4364 init_test(cx);
4365
4366 let fs = FakeFs::new(cx.executor().clone());
4367 fs.insert_tree(
4368 "/root1",
4369 json!({
4370 ".dockerignore": "",
4371 ".git": {
4372 "HEAD": "",
4373 },
4374 "a": {
4375 "0": { "q": "", "r": "", "s": "" },
4376 "1": { "t": "", "u": "" },
4377 "2": { "v": "", "w": "", "x": "", "y": "" },
4378 },
4379 "b": {
4380 "3": { "Q": "" },
4381 "4": { "R": "", "S": "", "T": "", "U": "" },
4382 },
4383 "C": {
4384 "5": {},
4385 "6": { "V": "", "W": "" },
4386 "7": { "X": "" },
4387 "8": { "Y": {}, "Z": "" }
4388 }
4389 }),
4390 )
4391 .await;
4392 fs.insert_tree(
4393 "/root2",
4394 json!({
4395 "d": {
4396 "9": ""
4397 },
4398 "e": {}
4399 }),
4400 )
4401 .await;
4402
4403 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4404 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4405 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4406 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4407 assert_eq!(
4408 visible_entries_as_strings(&panel, 0..50, cx),
4409 &[
4410 "v root1",
4411 " > .git",
4412 " > a",
4413 " > b",
4414 " > C",
4415 " .dockerignore",
4416 "v root2",
4417 " > d",
4418 " > e",
4419 ]
4420 );
4421
4422 toggle_expand_dir(&panel, "root1/b", cx);
4423 assert_eq!(
4424 visible_entries_as_strings(&panel, 0..50, cx),
4425 &[
4426 "v root1",
4427 " > .git",
4428 " > a",
4429 " v b <== selected",
4430 " > 3",
4431 " > 4",
4432 " > C",
4433 " .dockerignore",
4434 "v root2",
4435 " > d",
4436 " > e",
4437 ]
4438 );
4439
4440 assert_eq!(
4441 visible_entries_as_strings(&panel, 6..9, cx),
4442 &[
4443 //
4444 " > C",
4445 " .dockerignore",
4446 "v root2",
4447 ]
4448 );
4449 }
4450
4451 #[gpui::test]
4452 async fn test_opening_file(cx: &mut gpui::TestAppContext) {
4453 init_test_with_editor(cx);
4454
4455 let fs = FakeFs::new(cx.executor().clone());
4456 fs.insert_tree(
4457 "/src",
4458 json!({
4459 "test": {
4460 "first.rs": "// First Rust file",
4461 "second.rs": "// Second Rust file",
4462 "third.rs": "// Third Rust file",
4463 }
4464 }),
4465 )
4466 .await;
4467
4468 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4469 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4470 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4471 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4472
4473 toggle_expand_dir(&panel, "src/test", cx);
4474 select_path(&panel, "src/test/first.rs", cx);
4475 panel.update(cx, |panel, cx| panel.open(&Open, cx));
4476 cx.executor().run_until_parked();
4477 assert_eq!(
4478 visible_entries_as_strings(&panel, 0..10, cx),
4479 &[
4480 "v src",
4481 " v test",
4482 " first.rs <== selected <== marked",
4483 " second.rs",
4484 " third.rs"
4485 ]
4486 );
4487 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
4488
4489 select_path(&panel, "src/test/second.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",
4498 " second.rs <== selected <== marked",
4499 " third.rs"
4500 ]
4501 );
4502 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
4503 }
4504
4505 #[gpui::test]
4506 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
4507 init_test(cx);
4508 cx.update(|cx| {
4509 cx.update_global::<SettingsStore, _>(|store, cx| {
4510 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4511 worktree_settings.file_scan_exclusions =
4512 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
4513 });
4514 });
4515 });
4516
4517 let fs = FakeFs::new(cx.background_executor.clone());
4518 fs.insert_tree(
4519 "/root1",
4520 json!({
4521 ".dockerignore": "",
4522 ".git": {
4523 "HEAD": "",
4524 },
4525 "a": {
4526 "0": { "q": "", "r": "", "s": "" },
4527 "1": { "t": "", "u": "" },
4528 "2": { "v": "", "w": "", "x": "", "y": "" },
4529 },
4530 "b": {
4531 "3": { "Q": "" },
4532 "4": { "R": "", "S": "", "T": "", "U": "" },
4533 },
4534 "C": {
4535 "5": {},
4536 "6": { "V": "", "W": "" },
4537 "7": { "X": "" },
4538 "8": { "Y": {}, "Z": "" }
4539 }
4540 }),
4541 )
4542 .await;
4543 fs.insert_tree(
4544 "/root2",
4545 json!({
4546 "d": {
4547 "4": ""
4548 },
4549 "e": {}
4550 }),
4551 )
4552 .await;
4553
4554 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4555 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4556 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4557 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4558 assert_eq!(
4559 visible_entries_as_strings(&panel, 0..50, cx),
4560 &[
4561 "v root1",
4562 " > a",
4563 " > b",
4564 " > C",
4565 " .dockerignore",
4566 "v root2",
4567 " > d",
4568 " > e",
4569 ]
4570 );
4571
4572 toggle_expand_dir(&panel, "root1/b", cx);
4573 assert_eq!(
4574 visible_entries_as_strings(&panel, 0..50, cx),
4575 &[
4576 "v root1",
4577 " > a",
4578 " v b <== selected",
4579 " > 3",
4580 " > C",
4581 " .dockerignore",
4582 "v root2",
4583 " > d",
4584 " > e",
4585 ]
4586 );
4587
4588 toggle_expand_dir(&panel, "root2/d", cx);
4589 assert_eq!(
4590 visible_entries_as_strings(&panel, 0..50, cx),
4591 &[
4592 "v root1",
4593 " > a",
4594 " v b",
4595 " > 3",
4596 " > C",
4597 " .dockerignore",
4598 "v root2",
4599 " v d <== selected",
4600 " > e",
4601 ]
4602 );
4603
4604 toggle_expand_dir(&panel, "root2/e", cx);
4605 assert_eq!(
4606 visible_entries_as_strings(&panel, 0..50, cx),
4607 &[
4608 "v root1",
4609 " > a",
4610 " v b",
4611 " > 3",
4612 " > C",
4613 " .dockerignore",
4614 "v root2",
4615 " v d",
4616 " v e <== selected",
4617 ]
4618 );
4619 }
4620
4621 #[gpui::test]
4622 async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
4623 init_test(cx);
4624
4625 let fs = FakeFs::new(cx.executor().clone());
4626 fs.insert_tree(
4627 "/root1",
4628 json!({
4629 "dir_1": {
4630 "nested_dir_1": {
4631 "nested_dir_2": {
4632 "nested_dir_3": {
4633 "file_a.java": "// File contents",
4634 "file_b.java": "// File contents",
4635 "file_c.java": "// File contents",
4636 "nested_dir_4": {
4637 "nested_dir_5": {
4638 "file_d.java": "// File contents",
4639 }
4640 }
4641 }
4642 }
4643 }
4644 }
4645 }),
4646 )
4647 .await;
4648 fs.insert_tree(
4649 "/root2",
4650 json!({
4651 "dir_2": {
4652 "file_1.java": "// File contents",
4653 }
4654 }),
4655 )
4656 .await;
4657
4658 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4659 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4660 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4661 cx.update(|cx| {
4662 let settings = *ProjectPanelSettings::get_global(cx);
4663 ProjectPanelSettings::override_global(
4664 ProjectPanelSettings {
4665 auto_fold_dirs: true,
4666 ..settings
4667 },
4668 cx,
4669 );
4670 });
4671 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4672 assert_eq!(
4673 visible_entries_as_strings(&panel, 0..10, cx),
4674 &[
4675 "v root1",
4676 " > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4677 "v root2",
4678 " > dir_2",
4679 ]
4680 );
4681
4682 toggle_expand_dir(
4683 &panel,
4684 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4685 cx,
4686 );
4687 assert_eq!(
4688 visible_entries_as_strings(&panel, 0..10, cx),
4689 &[
4690 "v root1",
4691 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
4692 " > nested_dir_4/nested_dir_5",
4693 " file_a.java",
4694 " file_b.java",
4695 " file_c.java",
4696 "v root2",
4697 " > dir_2",
4698 ]
4699 );
4700
4701 toggle_expand_dir(
4702 &panel,
4703 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
4704 cx,
4705 );
4706 assert_eq!(
4707 visible_entries_as_strings(&panel, 0..10, cx),
4708 &[
4709 "v root1",
4710 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4711 " v nested_dir_4/nested_dir_5 <== selected",
4712 " file_d.java",
4713 " file_a.java",
4714 " file_b.java",
4715 " file_c.java",
4716 "v root2",
4717 " > dir_2",
4718 ]
4719 );
4720 toggle_expand_dir(&panel, "root2/dir_2", cx);
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",
4727 " file_d.java",
4728 " file_a.java",
4729 " file_b.java",
4730 " file_c.java",
4731 "v root2",
4732 " v dir_2 <== selected",
4733 " file_1.java",
4734 ]
4735 );
4736 }
4737
4738 #[gpui::test(iterations = 30)]
4739 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
4740 init_test(cx);
4741
4742 let fs = FakeFs::new(cx.executor().clone());
4743 fs.insert_tree(
4744 "/root1",
4745 json!({
4746 ".dockerignore": "",
4747 ".git": {
4748 "HEAD": "",
4749 },
4750 "a": {
4751 "0": { "q": "", "r": "", "s": "" },
4752 "1": { "t": "", "u": "" },
4753 "2": { "v": "", "w": "", "x": "", "y": "" },
4754 },
4755 "b": {
4756 "3": { "Q": "" },
4757 "4": { "R": "", "S": "", "T": "", "U": "" },
4758 },
4759 "C": {
4760 "5": {},
4761 "6": { "V": "", "W": "" },
4762 "7": { "X": "" },
4763 "8": { "Y": {}, "Z": "" }
4764 }
4765 }),
4766 )
4767 .await;
4768 fs.insert_tree(
4769 "/root2",
4770 json!({
4771 "d": {
4772 "9": ""
4773 },
4774 "e": {}
4775 }),
4776 )
4777 .await;
4778
4779 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4780 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4781 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4782 let panel = workspace
4783 .update(cx, |workspace, cx| {
4784 let panel = ProjectPanel::new(workspace, cx);
4785 workspace.add_panel(panel.clone(), cx);
4786 panel
4787 })
4788 .unwrap();
4789
4790 select_path(&panel, "root1", cx);
4791 assert_eq!(
4792 visible_entries_as_strings(&panel, 0..10, cx),
4793 &[
4794 "v root1 <== selected",
4795 " > .git",
4796 " > a",
4797 " > b",
4798 " > C",
4799 " .dockerignore",
4800 "v root2",
4801 " > d",
4802 " > e",
4803 ]
4804 );
4805
4806 // Add a file with the root folder selected. The filename editor is placed
4807 // before the first file in the root folder.
4808 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4809 panel.update(cx, |panel, cx| {
4810 assert!(panel.filename_editor.read(cx).is_focused(cx));
4811 });
4812 assert_eq!(
4813 visible_entries_as_strings(&panel, 0..10, cx),
4814 &[
4815 "v root1",
4816 " > .git",
4817 " > a",
4818 " > b",
4819 " > C",
4820 " [EDITOR: ''] <== selected",
4821 " .dockerignore",
4822 "v root2",
4823 " > d",
4824 " > e",
4825 ]
4826 );
4827
4828 let confirm = panel.update(cx, |panel, cx| {
4829 panel
4830 .filename_editor
4831 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
4832 panel.confirm_edit(cx).unwrap()
4833 });
4834 assert_eq!(
4835 visible_entries_as_strings(&panel, 0..10, cx),
4836 &[
4837 "v root1",
4838 " > .git",
4839 " > a",
4840 " > b",
4841 " > C",
4842 " [PROCESSING: 'the-new-filename'] <== selected",
4843 " .dockerignore",
4844 "v root2",
4845 " > d",
4846 " > e",
4847 ]
4848 );
4849
4850 confirm.await.unwrap();
4851 assert_eq!(
4852 visible_entries_as_strings(&panel, 0..10, cx),
4853 &[
4854 "v root1",
4855 " > .git",
4856 " > a",
4857 " > b",
4858 " > C",
4859 " .dockerignore",
4860 " the-new-filename <== selected <== marked",
4861 "v root2",
4862 " > d",
4863 " > e",
4864 ]
4865 );
4866
4867 select_path(&panel, "root1/b", cx);
4868 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4869 assert_eq!(
4870 visible_entries_as_strings(&panel, 0..10, cx),
4871 &[
4872 "v root1",
4873 " > .git",
4874 " > a",
4875 " v b",
4876 " > 3",
4877 " > 4",
4878 " [EDITOR: ''] <== selected",
4879 " > C",
4880 " .dockerignore",
4881 " the-new-filename",
4882 ]
4883 );
4884
4885 panel
4886 .update(cx, |panel, cx| {
4887 panel
4888 .filename_editor
4889 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
4890 panel.confirm_edit(cx).unwrap()
4891 })
4892 .await
4893 .unwrap();
4894 assert_eq!(
4895 visible_entries_as_strings(&panel, 0..10, cx),
4896 &[
4897 "v root1",
4898 " > .git",
4899 " > a",
4900 " v b",
4901 " > 3",
4902 " > 4",
4903 " another-filename.txt <== selected <== marked",
4904 " > C",
4905 " .dockerignore",
4906 " the-new-filename",
4907 ]
4908 );
4909
4910 select_path(&panel, "root1/b/another-filename.txt", cx);
4911 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4912 assert_eq!(
4913 visible_entries_as_strings(&panel, 0..10, cx),
4914 &[
4915 "v root1",
4916 " > .git",
4917 " > a",
4918 " v b",
4919 " > 3",
4920 " > 4",
4921 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
4922 " > C",
4923 " .dockerignore",
4924 " the-new-filename",
4925 ]
4926 );
4927
4928 let confirm = panel.update(cx, |panel, cx| {
4929 panel.filename_editor.update(cx, |editor, cx| {
4930 let file_name_selections = editor.selections.all::<usize>(cx);
4931 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
4932 let file_name_selection = &file_name_selections[0];
4933 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
4934 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
4935
4936 editor.set_text("a-different-filename.tar.gz", cx)
4937 });
4938 panel.confirm_edit(cx).unwrap()
4939 });
4940 assert_eq!(
4941 visible_entries_as_strings(&panel, 0..10, cx),
4942 &[
4943 "v root1",
4944 " > .git",
4945 " > a",
4946 " v b",
4947 " > 3",
4948 " > 4",
4949 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
4950 " > C",
4951 " .dockerignore",
4952 " the-new-filename",
4953 ]
4954 );
4955
4956 confirm.await.unwrap();
4957 assert_eq!(
4958 visible_entries_as_strings(&panel, 0..10, cx),
4959 &[
4960 "v root1",
4961 " > .git",
4962 " > a",
4963 " v b",
4964 " > 3",
4965 " > 4",
4966 " a-different-filename.tar.gz <== selected",
4967 " > C",
4968 " .dockerignore",
4969 " the-new-filename",
4970 ]
4971 );
4972
4973 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4974 assert_eq!(
4975 visible_entries_as_strings(&panel, 0..10, cx),
4976 &[
4977 "v root1",
4978 " > .git",
4979 " > a",
4980 " v b",
4981 " > 3",
4982 " > 4",
4983 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
4984 " > C",
4985 " .dockerignore",
4986 " the-new-filename",
4987 ]
4988 );
4989
4990 panel.update(cx, |panel, cx| {
4991 panel.filename_editor.update(cx, |editor, cx| {
4992 let file_name_selections = editor.selections.all::<usize>(cx);
4993 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
4994 let file_name_selection = &file_name_selections[0];
4995 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
4996 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..");
4997
4998 });
4999 panel.cancel(&menu::Cancel, cx)
5000 });
5001
5002 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
5003 assert_eq!(
5004 visible_entries_as_strings(&panel, 0..10, cx),
5005 &[
5006 "v root1",
5007 " > .git",
5008 " > a",
5009 " v b",
5010 " > 3",
5011 " > 4",
5012 " > [EDITOR: ''] <== selected",
5013 " a-different-filename.tar.gz",
5014 " > C",
5015 " .dockerignore",
5016 ]
5017 );
5018
5019 let confirm = panel.update(cx, |panel, cx| {
5020 panel
5021 .filename_editor
5022 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
5023 panel.confirm_edit(cx).unwrap()
5024 });
5025 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
5026 assert_eq!(
5027 visible_entries_as_strings(&panel, 0..10, cx),
5028 &[
5029 "v root1",
5030 " > .git",
5031 " > a",
5032 " v b",
5033 " > 3",
5034 " > 4",
5035 " > [PROCESSING: 'new-dir']",
5036 " a-different-filename.tar.gz <== selected",
5037 " > C",
5038 " .dockerignore",
5039 ]
5040 );
5041
5042 confirm.await.unwrap();
5043 assert_eq!(
5044 visible_entries_as_strings(&panel, 0..10, cx),
5045 &[
5046 "v root1",
5047 " > .git",
5048 " > a",
5049 " v b",
5050 " > 3",
5051 " > 4",
5052 " > new-dir",
5053 " a-different-filename.tar.gz <== selected",
5054 " > C",
5055 " .dockerignore",
5056 ]
5057 );
5058
5059 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
5060 assert_eq!(
5061 visible_entries_as_strings(&panel, 0..10, cx),
5062 &[
5063 "v root1",
5064 " > .git",
5065 " > a",
5066 " v b",
5067 " > 3",
5068 " > 4",
5069 " > new-dir",
5070 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
5071 " > C",
5072 " .dockerignore",
5073 ]
5074 );
5075
5076 // Dismiss the rename editor when it loses focus.
5077 workspace.update(cx, |_, cx| cx.blur()).unwrap();
5078 assert_eq!(
5079 visible_entries_as_strings(&panel, 0..10, cx),
5080 &[
5081 "v root1",
5082 " > .git",
5083 " > a",
5084 " v b",
5085 " > 3",
5086 " > 4",
5087 " > new-dir",
5088 " a-different-filename.tar.gz <== selected",
5089 " > C",
5090 " .dockerignore",
5091 ]
5092 );
5093 }
5094
5095 #[gpui::test(iterations = 10)]
5096 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
5097 init_test(cx);
5098
5099 let fs = FakeFs::new(cx.executor().clone());
5100 fs.insert_tree(
5101 "/root1",
5102 json!({
5103 ".dockerignore": "",
5104 ".git": {
5105 "HEAD": "",
5106 },
5107 "a": {
5108 "0": { "q": "", "r": "", "s": "" },
5109 "1": { "t": "", "u": "" },
5110 "2": { "v": "", "w": "", "x": "", "y": "" },
5111 },
5112 "b": {
5113 "3": { "Q": "" },
5114 "4": { "R": "", "S": "", "T": "", "U": "" },
5115 },
5116 "C": {
5117 "5": {},
5118 "6": { "V": "", "W": "" },
5119 "7": { "X": "" },
5120 "8": { "Y": {}, "Z": "" }
5121 }
5122 }),
5123 )
5124 .await;
5125 fs.insert_tree(
5126 "/root2",
5127 json!({
5128 "d": {
5129 "9": ""
5130 },
5131 "e": {}
5132 }),
5133 )
5134 .await;
5135
5136 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5137 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5138 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5139 let panel = workspace
5140 .update(cx, |workspace, cx| {
5141 let panel = ProjectPanel::new(workspace, cx);
5142 workspace.add_panel(panel.clone(), cx);
5143 panel
5144 })
5145 .unwrap();
5146
5147 select_path(&panel, "root1", cx);
5148 assert_eq!(
5149 visible_entries_as_strings(&panel, 0..10, cx),
5150 &[
5151 "v root1 <== selected",
5152 " > .git",
5153 " > a",
5154 " > b",
5155 " > C",
5156 " .dockerignore",
5157 "v root2",
5158 " > d",
5159 " > e",
5160 ]
5161 );
5162
5163 // Add a file with the root folder selected. The filename editor is placed
5164 // before the first file in the root folder.
5165 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5166 panel.update(cx, |panel, cx| {
5167 assert!(panel.filename_editor.read(cx).is_focused(cx));
5168 });
5169 assert_eq!(
5170 visible_entries_as_strings(&panel, 0..10, cx),
5171 &[
5172 "v root1",
5173 " > .git",
5174 " > a",
5175 " > b",
5176 " > C",
5177 " [EDITOR: ''] <== selected",
5178 " .dockerignore",
5179 "v root2",
5180 " > d",
5181 " > e",
5182 ]
5183 );
5184
5185 let confirm = panel.update(cx, |panel, cx| {
5186 panel.filename_editor.update(cx, |editor, cx| {
5187 editor.set_text("/bdir1/dir2/the-new-filename", cx)
5188 });
5189 panel.confirm_edit(cx).unwrap()
5190 });
5191
5192 assert_eq!(
5193 visible_entries_as_strings(&panel, 0..10, cx),
5194 &[
5195 "v root1",
5196 " > .git",
5197 " > a",
5198 " > b",
5199 " > C",
5200 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
5201 " .dockerignore",
5202 "v root2",
5203 " > d",
5204 " > e",
5205 ]
5206 );
5207
5208 confirm.await.unwrap();
5209 assert_eq!(
5210 visible_entries_as_strings(&panel, 0..13, cx),
5211 &[
5212 "v root1",
5213 " > .git",
5214 " > a",
5215 " > b",
5216 " v bdir1",
5217 " v dir2",
5218 " the-new-filename <== selected <== marked",
5219 " > C",
5220 " .dockerignore",
5221 "v root2",
5222 " > d",
5223 " > e",
5224 ]
5225 );
5226 }
5227
5228 #[gpui::test]
5229 async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
5230 init_test(cx);
5231
5232 let fs = FakeFs::new(cx.executor().clone());
5233 fs.insert_tree(
5234 "/root1",
5235 json!({
5236 ".dockerignore": "",
5237 ".git": {
5238 "HEAD": "",
5239 },
5240 }),
5241 )
5242 .await;
5243
5244 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5245 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5246 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5247 let panel = workspace
5248 .update(cx, |workspace, cx| {
5249 let panel = ProjectPanel::new(workspace, cx);
5250 workspace.add_panel(panel.clone(), cx);
5251 panel
5252 })
5253 .unwrap();
5254
5255 select_path(&panel, "root1", cx);
5256 assert_eq!(
5257 visible_entries_as_strings(&panel, 0..10, cx),
5258 &["v root1 <== selected", " > .git", " .dockerignore",]
5259 );
5260
5261 // Add a file with the root folder selected. The filename editor is placed
5262 // before the first file in the root folder.
5263 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5264 panel.update(cx, |panel, cx| {
5265 assert!(panel.filename_editor.read(cx).is_focused(cx));
5266 });
5267 assert_eq!(
5268 visible_entries_as_strings(&panel, 0..10, cx),
5269 &[
5270 "v root1",
5271 " > .git",
5272 " [EDITOR: ''] <== selected",
5273 " .dockerignore",
5274 ]
5275 );
5276
5277 let confirm = panel.update(cx, |panel, cx| {
5278 panel
5279 .filename_editor
5280 .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
5281 panel.confirm_edit(cx).unwrap()
5282 });
5283
5284 assert_eq!(
5285 visible_entries_as_strings(&panel, 0..10, cx),
5286 &[
5287 "v root1",
5288 " > .git",
5289 " [PROCESSING: '/new_dir/'] <== selected",
5290 " .dockerignore",
5291 ]
5292 );
5293
5294 confirm.await.unwrap();
5295 assert_eq!(
5296 visible_entries_as_strings(&panel, 0..13, cx),
5297 &[
5298 "v root1",
5299 " > .git",
5300 " v new_dir <== selected",
5301 " .dockerignore",
5302 ]
5303 );
5304 }
5305
5306 #[gpui::test]
5307 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
5308 init_test(cx);
5309
5310 let fs = FakeFs::new(cx.executor().clone());
5311 fs.insert_tree(
5312 "/root1",
5313 json!({
5314 "one.two.txt": "",
5315 "one.txt": ""
5316 }),
5317 )
5318 .await;
5319
5320 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5321 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5322 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5323 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5324
5325 panel.update(cx, |panel, cx| {
5326 panel.select_next(&Default::default(), cx);
5327 panel.select_next(&Default::default(), cx);
5328 });
5329
5330 assert_eq!(
5331 visible_entries_as_strings(&panel, 0..50, cx),
5332 &[
5333 //
5334 "v root1",
5335 " one.txt <== selected",
5336 " one.two.txt",
5337 ]
5338 );
5339
5340 // Regression test - file name is created correctly when
5341 // the copied file's name contains multiple dots.
5342 panel.update(cx, |panel, cx| {
5343 panel.copy(&Default::default(), cx);
5344 panel.paste(&Default::default(), cx);
5345 });
5346 cx.executor().run_until_parked();
5347
5348 assert_eq!(
5349 visible_entries_as_strings(&panel, 0..50, cx),
5350 &[
5351 //
5352 "v root1",
5353 " one.txt",
5354 " [EDITOR: 'one copy.txt'] <== selected",
5355 " one.two.txt",
5356 ]
5357 );
5358
5359 panel.update(cx, |panel, cx| {
5360 panel.filename_editor.update(cx, |editor, cx| {
5361 let file_name_selections = editor.selections.all::<usize>(cx);
5362 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5363 let file_name_selection = &file_name_selections[0];
5364 assert_eq!(file_name_selection.start, "one".len(), "Should select the file name disambiguation after the original file name");
5365 assert_eq!(file_name_selection.end, "one copy".len(), "Should select the file name disambiguation until the extension");
5366 });
5367 assert!(panel.confirm_edit(cx).is_none());
5368 });
5369
5370 panel.update(cx, |panel, cx| {
5371 panel.paste(&Default::default(), cx);
5372 });
5373 cx.executor().run_until_parked();
5374
5375 assert_eq!(
5376 visible_entries_as_strings(&panel, 0..50, cx),
5377 &[
5378 //
5379 "v root1",
5380 " one.txt",
5381 " one copy.txt",
5382 " [EDITOR: 'one copy 1.txt'] <== selected",
5383 " one.two.txt",
5384 ]
5385 );
5386
5387 panel.update(cx, |panel, cx| assert!(panel.confirm_edit(cx).is_none()));
5388 }
5389
5390 #[gpui::test]
5391 async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5392 init_test(cx);
5393
5394 let fs = FakeFs::new(cx.executor().clone());
5395 fs.insert_tree(
5396 "/root1",
5397 json!({
5398 "one.txt": "",
5399 "two.txt": "",
5400 "three.txt": "",
5401 "a": {
5402 "0": { "q": "", "r": "", "s": "" },
5403 "1": { "t": "", "u": "" },
5404 "2": { "v": "", "w": "", "x": "", "y": "" },
5405 },
5406 }),
5407 )
5408 .await;
5409
5410 fs.insert_tree(
5411 "/root2",
5412 json!({
5413 "one.txt": "",
5414 "two.txt": "",
5415 "four.txt": "",
5416 "b": {
5417 "3": { "Q": "" },
5418 "4": { "R": "", "S": "", "T": "", "U": "" },
5419 },
5420 }),
5421 )
5422 .await;
5423
5424 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5425 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5426 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5427 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5428
5429 select_path(&panel, "root1/three.txt", cx);
5430 panel.update(cx, |panel, cx| {
5431 panel.cut(&Default::default(), cx);
5432 });
5433
5434 select_path(&panel, "root2/one.txt", cx);
5435 panel.update(cx, |panel, cx| {
5436 panel.select_next(&Default::default(), cx);
5437 panel.paste(&Default::default(), cx);
5438 });
5439 cx.executor().run_until_parked();
5440 assert_eq!(
5441 visible_entries_as_strings(&panel, 0..50, cx),
5442 &[
5443 //
5444 "v root1",
5445 " > a",
5446 " one.txt",
5447 " two.txt",
5448 "v root2",
5449 " > b",
5450 " four.txt",
5451 " one.txt",
5452 " three.txt <== selected",
5453 " two.txt",
5454 ]
5455 );
5456
5457 select_path(&panel, "root1/a", cx);
5458 panel.update(cx, |panel, cx| {
5459 panel.cut(&Default::default(), cx);
5460 });
5461 select_path(&panel, "root2/two.txt", cx);
5462 panel.update(cx, |panel, cx| {
5463 panel.select_next(&Default::default(), cx);
5464 panel.paste(&Default::default(), cx);
5465 });
5466
5467 cx.executor().run_until_parked();
5468 assert_eq!(
5469 visible_entries_as_strings(&panel, 0..50, cx),
5470 &[
5471 //
5472 "v root1",
5473 " one.txt",
5474 " two.txt",
5475 "v root2",
5476 " > a <== selected",
5477 " > b",
5478 " four.txt",
5479 " one.txt",
5480 " three.txt",
5481 " two.txt",
5482 ]
5483 );
5484 }
5485
5486 #[gpui::test]
5487 async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5488 init_test(cx);
5489
5490 let fs = FakeFs::new(cx.executor().clone());
5491 fs.insert_tree(
5492 "/root1",
5493 json!({
5494 "one.txt": "",
5495 "two.txt": "",
5496 "three.txt": "",
5497 "a": {
5498 "0": { "q": "", "r": "", "s": "" },
5499 "1": { "t": "", "u": "" },
5500 "2": { "v": "", "w": "", "x": "", "y": "" },
5501 },
5502 }),
5503 )
5504 .await;
5505
5506 fs.insert_tree(
5507 "/root2",
5508 json!({
5509 "one.txt": "",
5510 "two.txt": "",
5511 "four.txt": "",
5512 "b": {
5513 "3": { "Q": "" },
5514 "4": { "R": "", "S": "", "T": "", "U": "" },
5515 },
5516 }),
5517 )
5518 .await;
5519
5520 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5521 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5522 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5523 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5524
5525 select_path(&panel, "root1/three.txt", cx);
5526 panel.update(cx, |panel, cx| {
5527 panel.copy(&Default::default(), cx);
5528 });
5529
5530 select_path(&panel, "root2/one.txt", cx);
5531 panel.update(cx, |panel, cx| {
5532 panel.select_next(&Default::default(), cx);
5533 panel.paste(&Default::default(), cx);
5534 });
5535 cx.executor().run_until_parked();
5536 assert_eq!(
5537 visible_entries_as_strings(&panel, 0..50, cx),
5538 &[
5539 //
5540 "v root1",
5541 " > a",
5542 " one.txt",
5543 " three.txt",
5544 " two.txt",
5545 "v root2",
5546 " > b",
5547 " four.txt",
5548 " one.txt",
5549 " three.txt <== selected",
5550 " two.txt",
5551 ]
5552 );
5553
5554 select_path(&panel, "root1/three.txt", cx);
5555 panel.update(cx, |panel, cx| {
5556 panel.copy(&Default::default(), cx);
5557 });
5558 select_path(&panel, "root2/two.txt", cx);
5559 panel.update(cx, |panel, cx| {
5560 panel.select_next(&Default::default(), cx);
5561 panel.paste(&Default::default(), cx);
5562 });
5563
5564 cx.executor().run_until_parked();
5565 assert_eq!(
5566 visible_entries_as_strings(&panel, 0..50, cx),
5567 &[
5568 //
5569 "v root1",
5570 " > a",
5571 " one.txt",
5572 " three.txt",
5573 " two.txt",
5574 "v root2",
5575 " > b",
5576 " four.txt",
5577 " one.txt",
5578 " three.txt",
5579 " [EDITOR: 'three copy.txt'] <== selected",
5580 " two.txt",
5581 ]
5582 );
5583
5584 panel.update(cx, |panel, cx| panel.cancel(&menu::Cancel {}, cx));
5585 cx.executor().run_until_parked();
5586
5587 select_path(&panel, "root1/a", cx);
5588 panel.update(cx, |panel, cx| {
5589 panel.copy(&Default::default(), cx);
5590 });
5591 select_path(&panel, "root2/two.txt", cx);
5592 panel.update(cx, |panel, cx| {
5593 panel.select_next(&Default::default(), cx);
5594 panel.paste(&Default::default(), cx);
5595 });
5596
5597 cx.executor().run_until_parked();
5598 assert_eq!(
5599 visible_entries_as_strings(&panel, 0..50, cx),
5600 &[
5601 //
5602 "v root1",
5603 " > a",
5604 " one.txt",
5605 " three.txt",
5606 " two.txt",
5607 "v root2",
5608 " > a <== selected",
5609 " > b",
5610 " four.txt",
5611 " one.txt",
5612 " three.txt",
5613 " three copy.txt",
5614 " two.txt",
5615 ]
5616 );
5617 }
5618
5619 #[gpui::test]
5620 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
5621 init_test(cx);
5622
5623 let fs = FakeFs::new(cx.executor().clone());
5624 fs.insert_tree(
5625 "/root",
5626 json!({
5627 "a": {
5628 "one.txt": "",
5629 "two.txt": "",
5630 "inner_dir": {
5631 "three.txt": "",
5632 "four.txt": "",
5633 }
5634 },
5635 "b": {}
5636 }),
5637 )
5638 .await;
5639
5640 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5641 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5642 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5643 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5644
5645 select_path(&panel, "root/a", cx);
5646 panel.update(cx, |panel, cx| {
5647 panel.copy(&Default::default(), cx);
5648 panel.select_next(&Default::default(), cx);
5649 panel.paste(&Default::default(), cx);
5650 });
5651 cx.executor().run_until_parked();
5652
5653 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
5654 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
5655
5656 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
5657 assert_ne!(
5658 pasted_dir_file, None,
5659 "Pasted directory file should have an entry"
5660 );
5661
5662 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
5663 assert_ne!(
5664 pasted_dir_inner_dir, None,
5665 "Directories inside pasted directory should have an entry"
5666 );
5667
5668 toggle_expand_dir(&panel, "root/b/a", cx);
5669 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
5670
5671 assert_eq!(
5672 visible_entries_as_strings(&panel, 0..50, cx),
5673 &[
5674 //
5675 "v root",
5676 " > a",
5677 " v b",
5678 " v a",
5679 " v inner_dir <== selected",
5680 " four.txt",
5681 " three.txt",
5682 " one.txt",
5683 " two.txt",
5684 ]
5685 );
5686
5687 select_path(&panel, "root", cx);
5688 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
5689 cx.executor().run_until_parked();
5690 assert_eq!(
5691 visible_entries_as_strings(&panel, 0..50, cx),
5692 &[
5693 //
5694 "v root",
5695 " > a",
5696 " > [EDITOR: 'a copy'] <== selected",
5697 " v b",
5698 " v a",
5699 " v inner_dir",
5700 " four.txt",
5701 " three.txt",
5702 " one.txt",
5703 " two.txt"
5704 ]
5705 );
5706
5707 let confirm = panel.update(cx, |panel, cx| {
5708 panel
5709 .filename_editor
5710 .update(cx, |editor, cx| editor.set_text("c", cx));
5711 panel.confirm_edit(cx).unwrap()
5712 });
5713 assert_eq!(
5714 visible_entries_as_strings(&panel, 0..50, cx),
5715 &[
5716 //
5717 "v root",
5718 " > a",
5719 " > [PROCESSING: 'c'] <== selected",
5720 " v b",
5721 " v a",
5722 " v inner_dir",
5723 " four.txt",
5724 " three.txt",
5725 " one.txt",
5726 " two.txt"
5727 ]
5728 );
5729
5730 confirm.await.unwrap();
5731
5732 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
5733 cx.executor().run_until_parked();
5734 assert_eq!(
5735 visible_entries_as_strings(&panel, 0..50, cx),
5736 &[
5737 //
5738 "v root",
5739 " > a",
5740 " v b",
5741 " v a",
5742 " v inner_dir",
5743 " four.txt",
5744 " three.txt",
5745 " one.txt",
5746 " two.txt",
5747 " v c",
5748 " > a <== selected",
5749 " > inner_dir",
5750 " one.txt",
5751 " two.txt",
5752 ]
5753 );
5754 }
5755
5756 #[gpui::test]
5757 async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
5758 init_test(cx);
5759
5760 let fs = FakeFs::new(cx.executor().clone());
5761 fs.insert_tree(
5762 "/test",
5763 json!({
5764 "dir1": {
5765 "a.txt": "",
5766 "b.txt": "",
5767 },
5768 "dir2": {},
5769 "c.txt": "",
5770 "d.txt": "",
5771 }),
5772 )
5773 .await;
5774
5775 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
5776 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5777 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5778 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5779
5780 toggle_expand_dir(&panel, "test/dir1", cx);
5781
5782 cx.simulate_modifiers_change(gpui::Modifiers {
5783 control: true,
5784 ..Default::default()
5785 });
5786
5787 select_path_with_mark(&panel, "test/dir1", cx);
5788 select_path_with_mark(&panel, "test/c.txt", cx);
5789
5790 assert_eq!(
5791 visible_entries_as_strings(&panel, 0..15, cx),
5792 &[
5793 "v test",
5794 " v dir1 <== marked",
5795 " a.txt",
5796 " b.txt",
5797 " > dir2",
5798 " c.txt <== selected <== marked",
5799 " d.txt",
5800 ],
5801 "Initial state before copying dir1 and c.txt"
5802 );
5803
5804 panel.update(cx, |panel, cx| {
5805 panel.copy(&Default::default(), cx);
5806 });
5807 select_path(&panel, "test/dir2", cx);
5808 panel.update(cx, |panel, cx| {
5809 panel.paste(&Default::default(), cx);
5810 });
5811 cx.executor().run_until_parked();
5812
5813 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
5814
5815 assert_eq!(
5816 visible_entries_as_strings(&panel, 0..15, cx),
5817 &[
5818 "v test",
5819 " v dir1 <== marked",
5820 " a.txt",
5821 " b.txt",
5822 " v dir2",
5823 " v dir1 <== selected",
5824 " a.txt",
5825 " b.txt",
5826 " c.txt",
5827 " c.txt <== marked",
5828 " d.txt",
5829 ],
5830 "Should copy dir1 as well as c.txt into dir2"
5831 );
5832
5833 // Disambiguating multiple files should not open the rename editor.
5834 select_path(&panel, "test/dir2", cx);
5835 panel.update(cx, |panel, cx| {
5836 panel.paste(&Default::default(), cx);
5837 });
5838 cx.executor().run_until_parked();
5839
5840 assert_eq!(
5841 visible_entries_as_strings(&panel, 0..15, cx),
5842 &[
5843 "v test",
5844 " v dir1 <== marked",
5845 " a.txt",
5846 " b.txt",
5847 " v dir2",
5848 " v dir1",
5849 " a.txt",
5850 " b.txt",
5851 " > dir1 copy <== selected",
5852 " c.txt",
5853 " c copy.txt",
5854 " c.txt <== marked",
5855 " d.txt",
5856 ],
5857 "Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor"
5858 );
5859 }
5860
5861 #[gpui::test]
5862 async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
5863 init_test(cx);
5864
5865 let fs = FakeFs::new(cx.executor().clone());
5866 fs.insert_tree(
5867 "/test",
5868 json!({
5869 "dir1": {
5870 "a.txt": "",
5871 "b.txt": "",
5872 },
5873 "dir2": {},
5874 "c.txt": "",
5875 "d.txt": "",
5876 }),
5877 )
5878 .await;
5879
5880 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
5881 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5882 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5883 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5884
5885 toggle_expand_dir(&panel, "test/dir1", cx);
5886
5887 cx.simulate_modifiers_change(gpui::Modifiers {
5888 control: true,
5889 ..Default::default()
5890 });
5891
5892 select_path_with_mark(&panel, "test/dir1/a.txt", cx);
5893 select_path_with_mark(&panel, "test/dir1", cx);
5894 select_path_with_mark(&panel, "test/c.txt", cx);
5895
5896 assert_eq!(
5897 visible_entries_as_strings(&panel, 0..15, cx),
5898 &[
5899 "v test",
5900 " v dir1 <== marked",
5901 " a.txt <== marked",
5902 " b.txt",
5903 " > dir2",
5904 " c.txt <== selected <== marked",
5905 " d.txt",
5906 ],
5907 "Initial state before copying a.txt, dir1 and c.txt"
5908 );
5909
5910 panel.update(cx, |panel, cx| {
5911 panel.copy(&Default::default(), cx);
5912 });
5913 select_path(&panel, "test/dir2", cx);
5914 panel.update(cx, |panel, cx| {
5915 panel.paste(&Default::default(), cx);
5916 });
5917 cx.executor().run_until_parked();
5918
5919 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
5920
5921 assert_eq!(
5922 visible_entries_as_strings(&panel, 0..20, cx),
5923 &[
5924 "v test",
5925 " v dir1 <== marked",
5926 " a.txt <== marked",
5927 " b.txt",
5928 " v dir2",
5929 " v dir1 <== selected",
5930 " a.txt",
5931 " b.txt",
5932 " c.txt",
5933 " c.txt <== marked",
5934 " d.txt",
5935 ],
5936 "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
5937 );
5938 }
5939
5940 #[gpui::test]
5941 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
5942 init_test_with_editor(cx);
5943
5944 let fs = FakeFs::new(cx.executor().clone());
5945 fs.insert_tree(
5946 "/src",
5947 json!({
5948 "test": {
5949 "first.rs": "// First Rust file",
5950 "second.rs": "// Second Rust file",
5951 "third.rs": "// Third Rust file",
5952 }
5953 }),
5954 )
5955 .await;
5956
5957 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5958 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5959 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5960 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5961
5962 toggle_expand_dir(&panel, "src/test", cx);
5963 select_path(&panel, "src/test/first.rs", cx);
5964 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5965 cx.executor().run_until_parked();
5966 assert_eq!(
5967 visible_entries_as_strings(&panel, 0..10, cx),
5968 &[
5969 "v src",
5970 " v test",
5971 " first.rs <== selected <== marked",
5972 " second.rs",
5973 " third.rs"
5974 ]
5975 );
5976 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
5977
5978 submit_deletion(&panel, cx);
5979 assert_eq!(
5980 visible_entries_as_strings(&panel, 0..10, cx),
5981 &[
5982 "v src",
5983 " v test",
5984 " second.rs <== selected",
5985 " third.rs"
5986 ],
5987 "Project panel should have no deleted file, no other file is selected in it"
5988 );
5989 ensure_no_open_items_and_panes(&workspace, cx);
5990
5991 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5992 cx.executor().run_until_parked();
5993 assert_eq!(
5994 visible_entries_as_strings(&panel, 0..10, cx),
5995 &[
5996 "v src",
5997 " v test",
5998 " second.rs <== selected <== marked",
5999 " third.rs"
6000 ]
6001 );
6002 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
6003
6004 workspace
6005 .update(cx, |workspace, cx| {
6006 let active_items = workspace
6007 .panes()
6008 .iter()
6009 .filter_map(|pane| pane.read(cx).active_item())
6010 .collect::<Vec<_>>();
6011 assert_eq!(active_items.len(), 1);
6012 let open_editor = active_items
6013 .into_iter()
6014 .next()
6015 .unwrap()
6016 .downcast::<Editor>()
6017 .expect("Open item should be an editor");
6018 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
6019 })
6020 .unwrap();
6021 submit_deletion_skipping_prompt(&panel, cx);
6022 assert_eq!(
6023 visible_entries_as_strings(&panel, 0..10, cx),
6024 &["v src", " v test", " third.rs <== selected"],
6025 "Project panel should have no deleted file, with one last file remaining"
6026 );
6027 ensure_no_open_items_and_panes(&workspace, cx);
6028 }
6029
6030 #[gpui::test]
6031 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
6032 init_test_with_editor(cx);
6033
6034 let fs = FakeFs::new(cx.executor().clone());
6035 fs.insert_tree(
6036 "/src",
6037 json!({
6038 "test": {
6039 "first.rs": "// First Rust file",
6040 "second.rs": "// Second Rust file",
6041 "third.rs": "// Third Rust file",
6042 }
6043 }),
6044 )
6045 .await;
6046
6047 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
6048 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6049 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6050 let panel = workspace
6051 .update(cx, |workspace, cx| {
6052 let panel = ProjectPanel::new(workspace, cx);
6053 workspace.add_panel(panel.clone(), cx);
6054 panel
6055 })
6056 .unwrap();
6057
6058 select_path(&panel, "src/", cx);
6059 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6060 cx.executor().run_until_parked();
6061 assert_eq!(
6062 visible_entries_as_strings(&panel, 0..10, cx),
6063 &[
6064 //
6065 "v src <== selected",
6066 " > test"
6067 ]
6068 );
6069 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
6070 panel.update(cx, |panel, cx| {
6071 assert!(panel.filename_editor.read(cx).is_focused(cx));
6072 });
6073 assert_eq!(
6074 visible_entries_as_strings(&panel, 0..10, cx),
6075 &[
6076 //
6077 "v src",
6078 " > [EDITOR: ''] <== selected",
6079 " > test"
6080 ]
6081 );
6082 panel.update(cx, |panel, cx| {
6083 panel
6084 .filename_editor
6085 .update(cx, |editor, cx| editor.set_text("test", cx));
6086 assert!(
6087 panel.confirm_edit(cx).is_none(),
6088 "Should not allow to confirm on conflicting new directory name"
6089 )
6090 });
6091 assert_eq!(
6092 visible_entries_as_strings(&panel, 0..10, cx),
6093 &[
6094 //
6095 "v src",
6096 " > test"
6097 ],
6098 "File list should be unchanged after failed folder create confirmation"
6099 );
6100
6101 select_path(&panel, "src/test/", cx);
6102 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6103 cx.executor().run_until_parked();
6104 assert_eq!(
6105 visible_entries_as_strings(&panel, 0..10, cx),
6106 &[
6107 //
6108 "v src",
6109 " > test <== selected"
6110 ]
6111 );
6112 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
6113 panel.update(cx, |panel, cx| {
6114 assert!(panel.filename_editor.read(cx).is_focused(cx));
6115 });
6116 assert_eq!(
6117 visible_entries_as_strings(&panel, 0..10, cx),
6118 &[
6119 "v src",
6120 " v test",
6121 " [EDITOR: ''] <== selected",
6122 " first.rs",
6123 " second.rs",
6124 " third.rs"
6125 ]
6126 );
6127 panel.update(cx, |panel, cx| {
6128 panel
6129 .filename_editor
6130 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
6131 assert!(
6132 panel.confirm_edit(cx).is_none(),
6133 "Should not allow to confirm on conflicting new file name"
6134 )
6135 });
6136 assert_eq!(
6137 visible_entries_as_strings(&panel, 0..10, cx),
6138 &[
6139 "v src",
6140 " v test",
6141 " first.rs",
6142 " second.rs",
6143 " third.rs"
6144 ],
6145 "File list should be unchanged after failed file create confirmation"
6146 );
6147
6148 select_path(&panel, "src/test/first.rs", cx);
6149 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6150 cx.executor().run_until_parked();
6151 assert_eq!(
6152 visible_entries_as_strings(&panel, 0..10, cx),
6153 &[
6154 "v src",
6155 " v test",
6156 " first.rs <== selected",
6157 " second.rs",
6158 " third.rs"
6159 ],
6160 );
6161 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
6162 panel.update(cx, |panel, cx| {
6163 assert!(panel.filename_editor.read(cx).is_focused(cx));
6164 });
6165 assert_eq!(
6166 visible_entries_as_strings(&panel, 0..10, cx),
6167 &[
6168 "v src",
6169 " v test",
6170 " [EDITOR: 'first.rs'] <== selected",
6171 " second.rs",
6172 " third.rs"
6173 ]
6174 );
6175 panel.update(cx, |panel, cx| {
6176 panel
6177 .filename_editor
6178 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
6179 assert!(
6180 panel.confirm_edit(cx).is_none(),
6181 "Should not allow to confirm on conflicting file rename"
6182 )
6183 });
6184 assert_eq!(
6185 visible_entries_as_strings(&panel, 0..10, cx),
6186 &[
6187 "v src",
6188 " v test",
6189 " first.rs <== selected",
6190 " second.rs",
6191 " third.rs"
6192 ],
6193 "File list should be unchanged after failed rename confirmation"
6194 );
6195 }
6196
6197 #[gpui::test]
6198 async fn test_select_directory(cx: &mut gpui::TestAppContext) {
6199 init_test_with_editor(cx);
6200
6201 let fs = FakeFs::new(cx.executor().clone());
6202 fs.insert_tree(
6203 "/project_root",
6204 json!({
6205 "dir_1": {
6206 "nested_dir": {
6207 "file_a.py": "# File contents",
6208 }
6209 },
6210 "file_1.py": "# File contents",
6211 "dir_2": {
6212
6213 },
6214 "dir_3": {
6215
6216 },
6217 "file_2.py": "# File contents",
6218 "dir_4": {
6219
6220 },
6221 }),
6222 )
6223 .await;
6224
6225 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6226 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6227 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6228 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6229
6230 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6231 cx.executor().run_until_parked();
6232 select_path(&panel, "project_root/dir_1", cx);
6233 cx.executor().run_until_parked();
6234 assert_eq!(
6235 visible_entries_as_strings(&panel, 0..10, cx),
6236 &[
6237 "v project_root",
6238 " > dir_1 <== selected",
6239 " > dir_2",
6240 " > dir_3",
6241 " > dir_4",
6242 " file_1.py",
6243 " file_2.py",
6244 ]
6245 );
6246 panel.update(cx, |panel, cx| {
6247 panel.select_prev_directory(&SelectPrevDirectory, cx)
6248 });
6249
6250 assert_eq!(
6251 visible_entries_as_strings(&panel, 0..10, cx),
6252 &[
6253 "v project_root <== selected",
6254 " > dir_1",
6255 " > dir_2",
6256 " > dir_3",
6257 " > dir_4",
6258 " file_1.py",
6259 " file_2.py",
6260 ]
6261 );
6262
6263 panel.update(cx, |panel, cx| {
6264 panel.select_prev_directory(&SelectPrevDirectory, cx)
6265 });
6266
6267 assert_eq!(
6268 visible_entries_as_strings(&panel, 0..10, cx),
6269 &[
6270 "v project_root",
6271 " > dir_1",
6272 " > dir_2",
6273 " > dir_3",
6274 " > dir_4 <== selected",
6275 " file_1.py",
6276 " file_2.py",
6277 ]
6278 );
6279
6280 panel.update(cx, |panel, cx| {
6281 panel.select_next_directory(&SelectNextDirectory, cx)
6282 });
6283
6284 assert_eq!(
6285 visible_entries_as_strings(&panel, 0..10, cx),
6286 &[
6287 "v project_root <== selected",
6288 " > dir_1",
6289 " > dir_2",
6290 " > dir_3",
6291 " > dir_4",
6292 " file_1.py",
6293 " file_2.py",
6294 ]
6295 );
6296 }
6297
6298 #[gpui::test]
6299 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
6300 init_test_with_editor(cx);
6301
6302 let fs = FakeFs::new(cx.executor().clone());
6303 fs.insert_tree(
6304 "/project_root",
6305 json!({
6306 "dir_1": {
6307 "nested_dir": {
6308 "file_a.py": "# File contents",
6309 }
6310 },
6311 "file_1.py": "# File contents",
6312 }),
6313 )
6314 .await;
6315
6316 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6317 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6318 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6319 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6320
6321 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6322 cx.executor().run_until_parked();
6323 select_path(&panel, "project_root/dir_1", cx);
6324 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6325 select_path(&panel, "project_root/dir_1/nested_dir", cx);
6326 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6327 panel.update(cx, |panel, cx| panel.open(&Open, cx));
6328 cx.executor().run_until_parked();
6329 assert_eq!(
6330 visible_entries_as_strings(&panel, 0..10, cx),
6331 &[
6332 "v project_root",
6333 " v dir_1",
6334 " > nested_dir <== selected",
6335 " file_1.py",
6336 ]
6337 );
6338 }
6339
6340 #[gpui::test]
6341 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
6342 init_test_with_editor(cx);
6343
6344 let fs = FakeFs::new(cx.executor().clone());
6345 fs.insert_tree(
6346 "/project_root",
6347 json!({
6348 "dir_1": {
6349 "nested_dir": {
6350 "file_a.py": "# File contents",
6351 "file_b.py": "# File contents",
6352 "file_c.py": "# File contents",
6353 },
6354 "file_1.py": "# File contents",
6355 "file_2.py": "# File contents",
6356 "file_3.py": "# File contents",
6357 },
6358 "dir_2": {
6359 "file_1.py": "# File contents",
6360 "file_2.py": "# File contents",
6361 "file_3.py": "# File contents",
6362 }
6363 }),
6364 )
6365 .await;
6366
6367 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6368 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6369 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6370 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6371
6372 panel.update(cx, |panel, cx| {
6373 panel.collapse_all_entries(&CollapseAllEntries, cx)
6374 });
6375 cx.executor().run_until_parked();
6376 assert_eq!(
6377 visible_entries_as_strings(&panel, 0..10, cx),
6378 &["v project_root", " > dir_1", " > dir_2",]
6379 );
6380
6381 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
6382 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6383 cx.executor().run_until_parked();
6384 assert_eq!(
6385 visible_entries_as_strings(&panel, 0..10, cx),
6386 &[
6387 "v project_root",
6388 " v dir_1 <== selected",
6389 " > nested_dir",
6390 " file_1.py",
6391 " file_2.py",
6392 " file_3.py",
6393 " > dir_2",
6394 ]
6395 );
6396 }
6397
6398 #[gpui::test]
6399 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
6400 init_test(cx);
6401
6402 let fs = FakeFs::new(cx.executor().clone());
6403 fs.as_fake().insert_tree("/root", json!({})).await;
6404 let project = Project::test(fs, ["/root".as_ref()], cx).await;
6405 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6406 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6407 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6408
6409 // Make a new buffer with no backing file
6410 workspace
6411 .update(cx, |workspace, cx| {
6412 Editor::new_file(workspace, &Default::default(), cx)
6413 })
6414 .unwrap();
6415
6416 cx.executor().run_until_parked();
6417
6418 // "Save as" the buffer, creating a new backing file for it
6419 let save_task = workspace
6420 .update(cx, |workspace, cx| {
6421 workspace.save_active_item(workspace::SaveIntent::Save, cx)
6422 })
6423 .unwrap();
6424
6425 cx.executor().run_until_parked();
6426 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
6427 save_task.await.unwrap();
6428
6429 // Rename the file
6430 select_path(&panel, "root/new", cx);
6431 assert_eq!(
6432 visible_entries_as_strings(&panel, 0..10, cx),
6433 &["v root", " new <== selected"]
6434 );
6435 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
6436 panel.update(cx, |panel, cx| {
6437 panel
6438 .filename_editor
6439 .update(cx, |editor, cx| editor.set_text("newer", cx));
6440 });
6441 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6442
6443 cx.executor().run_until_parked();
6444 assert_eq!(
6445 visible_entries_as_strings(&panel, 0..10, cx),
6446 &["v root", " newer <== selected"]
6447 );
6448
6449 workspace
6450 .update(cx, |workspace, cx| {
6451 workspace.save_active_item(workspace::SaveIntent::Save, cx)
6452 })
6453 .unwrap()
6454 .await
6455 .unwrap();
6456
6457 cx.executor().run_until_parked();
6458 // assert that saving the file doesn't restore "new"
6459 assert_eq!(
6460 visible_entries_as_strings(&panel, 0..10, cx),
6461 &["v root", " newer <== selected"]
6462 );
6463 }
6464
6465 #[gpui::test]
6466 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
6467 init_test_with_editor(cx);
6468 let fs = FakeFs::new(cx.executor().clone());
6469 fs.insert_tree(
6470 "/project_root",
6471 json!({
6472 "dir_1": {
6473 "nested_dir": {
6474 "file_a.py": "# File contents",
6475 }
6476 },
6477 "file_1.py": "# File contents",
6478 }),
6479 )
6480 .await;
6481
6482 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6483 let worktree_id =
6484 cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
6485 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6486 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6487 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6488 cx.update(|cx| {
6489 panel.update(cx, |this, cx| {
6490 this.select_next(&Default::default(), cx);
6491 this.expand_selected_entry(&Default::default(), cx);
6492 this.expand_selected_entry(&Default::default(), cx);
6493 this.select_next(&Default::default(), cx);
6494 this.expand_selected_entry(&Default::default(), cx);
6495 this.select_next(&Default::default(), cx);
6496 })
6497 });
6498 assert_eq!(
6499 visible_entries_as_strings(&panel, 0..10, cx),
6500 &[
6501 "v project_root",
6502 " v dir_1",
6503 " v nested_dir",
6504 " file_a.py <== selected",
6505 " file_1.py",
6506 ]
6507 );
6508 let modifiers_with_shift = gpui::Modifiers {
6509 shift: true,
6510 ..Default::default()
6511 };
6512 cx.simulate_modifiers_change(modifiers_with_shift);
6513 cx.update(|cx| {
6514 panel.update(cx, |this, cx| {
6515 this.select_next(&Default::default(), cx);
6516 })
6517 });
6518 assert_eq!(
6519 visible_entries_as_strings(&panel, 0..10, cx),
6520 &[
6521 "v project_root",
6522 " v dir_1",
6523 " v nested_dir",
6524 " file_a.py",
6525 " file_1.py <== selected <== marked",
6526 ]
6527 );
6528 cx.update(|cx| {
6529 panel.update(cx, |this, cx| {
6530 this.select_prev(&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 <== selected <== marked",
6540 " file_1.py <== marked",
6541 ]
6542 );
6543 cx.update(|cx| {
6544 panel.update(cx, |this, cx| {
6545 let drag = DraggedSelection {
6546 active_selection: this.selection.unwrap(),
6547 marked_selections: Arc::new(this.marked_entries.clone()),
6548 };
6549 let target_entry = this
6550 .project
6551 .read(cx)
6552 .entry_for_path(&(worktree_id, "").into(), cx)
6553 .unwrap();
6554 this.drag_onto(&drag, target_entry.id, false, cx);
6555 });
6556 });
6557 cx.run_until_parked();
6558 assert_eq!(
6559 visible_entries_as_strings(&panel, 0..10, cx),
6560 &[
6561 "v project_root",
6562 " v dir_1",
6563 " v nested_dir",
6564 " file_1.py <== marked",
6565 " file_a.py <== selected <== marked",
6566 ]
6567 );
6568 // ESC clears out all marks
6569 cx.update(|cx| {
6570 panel.update(cx, |this, cx| {
6571 this.cancel(&menu::Cancel, cx);
6572 })
6573 });
6574 assert_eq!(
6575 visible_entries_as_strings(&panel, 0..10, cx),
6576 &[
6577 "v project_root",
6578 " v dir_1",
6579 " v nested_dir",
6580 " file_1.py",
6581 " file_a.py <== selected",
6582 ]
6583 );
6584 // ESC clears out all marks
6585 cx.update(|cx| {
6586 panel.update(cx, |this, cx| {
6587 this.select_prev(&SelectPrev, cx);
6588 this.select_next(&SelectNext, cx);
6589 })
6590 });
6591 assert_eq!(
6592 visible_entries_as_strings(&panel, 0..10, cx),
6593 &[
6594 "v project_root",
6595 " v dir_1",
6596 " v nested_dir",
6597 " file_1.py <== marked",
6598 " file_a.py <== selected <== marked",
6599 ]
6600 );
6601 cx.simulate_modifiers_change(Default::default());
6602 cx.update(|cx| {
6603 panel.update(cx, |this, cx| {
6604 this.cut(&Cut, cx);
6605 this.select_prev(&SelectPrev, cx);
6606 this.select_prev(&SelectPrev, cx);
6607
6608 this.paste(&Paste, cx);
6609 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
6610 })
6611 });
6612 cx.run_until_parked();
6613 assert_eq!(
6614 visible_entries_as_strings(&panel, 0..10, cx),
6615 &[
6616 "v project_root",
6617 " v dir_1",
6618 " v nested_dir",
6619 " file_1.py <== marked",
6620 " file_a.py <== selected <== marked",
6621 ]
6622 );
6623 cx.simulate_modifiers_change(modifiers_with_shift);
6624 cx.update(|cx| {
6625 panel.update(cx, |this, cx| {
6626 this.expand_selected_entry(&Default::default(), cx);
6627 this.select_next(&SelectNext, cx);
6628 this.select_next(&SelectNext, cx);
6629 })
6630 });
6631 submit_deletion(&panel, cx);
6632 assert_eq!(
6633 visible_entries_as_strings(&panel, 0..10, cx),
6634 &[
6635 "v project_root",
6636 " v dir_1",
6637 " v nested_dir <== selected",
6638 ]
6639 );
6640 }
6641 #[gpui::test]
6642 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
6643 init_test_with_editor(cx);
6644 cx.update(|cx| {
6645 cx.update_global::<SettingsStore, _>(|store, cx| {
6646 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6647 worktree_settings.file_scan_exclusions = Some(Vec::new());
6648 });
6649 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6650 project_panel_settings.auto_reveal_entries = Some(false)
6651 });
6652 })
6653 });
6654
6655 let fs = FakeFs::new(cx.background_executor.clone());
6656 fs.insert_tree(
6657 "/project_root",
6658 json!({
6659 ".git": {},
6660 ".gitignore": "**/gitignored_dir",
6661 "dir_1": {
6662 "file_1.py": "# File 1_1 contents",
6663 "file_2.py": "# File 1_2 contents",
6664 "file_3.py": "# File 1_3 contents",
6665 "gitignored_dir": {
6666 "file_a.py": "# File contents",
6667 "file_b.py": "# File contents",
6668 "file_c.py": "# File contents",
6669 },
6670 },
6671 "dir_2": {
6672 "file_1.py": "# File 2_1 contents",
6673 "file_2.py": "# File 2_2 contents",
6674 "file_3.py": "# File 2_3 contents",
6675 }
6676 }),
6677 )
6678 .await;
6679
6680 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6681 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6682 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6683 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6684
6685 assert_eq!(
6686 visible_entries_as_strings(&panel, 0..20, cx),
6687 &[
6688 "v project_root",
6689 " > .git",
6690 " > dir_1",
6691 " > dir_2",
6692 " .gitignore",
6693 ]
6694 );
6695
6696 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
6697 .expect("dir 1 file is not ignored and should have an entry");
6698 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
6699 .expect("dir 2 file is not ignored and should have an entry");
6700 let gitignored_dir_file =
6701 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
6702 assert_eq!(
6703 gitignored_dir_file, None,
6704 "File in the gitignored dir should not have an entry before its dir is toggled"
6705 );
6706
6707 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6708 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6709 cx.executor().run_until_parked();
6710 assert_eq!(
6711 visible_entries_as_strings(&panel, 0..20, cx),
6712 &[
6713 "v project_root",
6714 " > .git",
6715 " v dir_1",
6716 " v gitignored_dir <== selected",
6717 " file_a.py",
6718 " file_b.py",
6719 " file_c.py",
6720 " file_1.py",
6721 " file_2.py",
6722 " file_3.py",
6723 " > dir_2",
6724 " .gitignore",
6725 ],
6726 "Should show gitignored dir file list in the project panel"
6727 );
6728 let gitignored_dir_file =
6729 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
6730 .expect("after gitignored dir got opened, a file entry should be present");
6731
6732 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6733 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6734 assert_eq!(
6735 visible_entries_as_strings(&panel, 0..20, cx),
6736 &[
6737 "v project_root",
6738 " > .git",
6739 " > dir_1 <== selected",
6740 " > dir_2",
6741 " .gitignore",
6742 ],
6743 "Should hide all dir contents again and prepare for the auto reveal test"
6744 );
6745
6746 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
6747 panel.update(cx, |panel, cx| {
6748 panel.project.update(cx, |_, cx| {
6749 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
6750 })
6751 });
6752 cx.run_until_parked();
6753 assert_eq!(
6754 visible_entries_as_strings(&panel, 0..20, cx),
6755 &[
6756 "v project_root",
6757 " > .git",
6758 " > dir_1 <== selected",
6759 " > dir_2",
6760 " .gitignore",
6761 ],
6762 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
6763 );
6764 }
6765
6766 cx.update(|cx| {
6767 cx.update_global::<SettingsStore, _>(|store, cx| {
6768 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6769 project_panel_settings.auto_reveal_entries = Some(true)
6770 });
6771 })
6772 });
6773
6774 panel.update(cx, |panel, cx| {
6775 panel.project.update(cx, |_, cx| {
6776 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
6777 })
6778 });
6779 cx.run_until_parked();
6780 assert_eq!(
6781 visible_entries_as_strings(&panel, 0..20, cx),
6782 &[
6783 "v project_root",
6784 " > .git",
6785 " v dir_1",
6786 " > gitignored_dir",
6787 " file_1.py <== selected",
6788 " file_2.py",
6789 " file_3.py",
6790 " > dir_2",
6791 " .gitignore",
6792 ],
6793 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
6794 );
6795
6796 panel.update(cx, |panel, cx| {
6797 panel.project.update(cx, |_, cx| {
6798 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
6799 })
6800 });
6801 cx.run_until_parked();
6802 assert_eq!(
6803 visible_entries_as_strings(&panel, 0..20, cx),
6804 &[
6805 "v project_root",
6806 " > .git",
6807 " v dir_1",
6808 " > gitignored_dir",
6809 " file_1.py",
6810 " file_2.py",
6811 " file_3.py",
6812 " v dir_2",
6813 " file_1.py <== selected",
6814 " file_2.py",
6815 " file_3.py",
6816 " .gitignore",
6817 ],
6818 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
6819 );
6820
6821 panel.update(cx, |panel, cx| {
6822 panel.project.update(cx, |_, cx| {
6823 cx.emit(project::Event::ActiveEntryChanged(Some(
6824 gitignored_dir_file,
6825 )))
6826 })
6827 });
6828 cx.run_until_parked();
6829 assert_eq!(
6830 visible_entries_as_strings(&panel, 0..20, cx),
6831 &[
6832 "v project_root",
6833 " > .git",
6834 " v dir_1",
6835 " > gitignored_dir",
6836 " file_1.py",
6837 " file_2.py",
6838 " file_3.py",
6839 " v dir_2",
6840 " file_1.py <== selected",
6841 " file_2.py",
6842 " file_3.py",
6843 " .gitignore",
6844 ],
6845 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
6846 );
6847
6848 panel.update(cx, |panel, cx| {
6849 panel.project.update(cx, |_, cx| {
6850 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
6851 })
6852 });
6853 cx.run_until_parked();
6854 assert_eq!(
6855 visible_entries_as_strings(&panel, 0..20, cx),
6856 &[
6857 "v project_root",
6858 " > .git",
6859 " v dir_1",
6860 " v gitignored_dir",
6861 " file_a.py <== selected",
6862 " file_b.py",
6863 " file_c.py",
6864 " file_1.py",
6865 " file_2.py",
6866 " file_3.py",
6867 " v dir_2",
6868 " file_1.py",
6869 " file_2.py",
6870 " file_3.py",
6871 " .gitignore",
6872 ],
6873 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
6874 );
6875 }
6876
6877 #[gpui::test]
6878 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
6879 init_test_with_editor(cx);
6880 cx.update(|cx| {
6881 cx.update_global::<SettingsStore, _>(|store, cx| {
6882 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6883 worktree_settings.file_scan_exclusions = Some(Vec::new());
6884 });
6885 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6886 project_panel_settings.auto_reveal_entries = Some(false)
6887 });
6888 })
6889 });
6890
6891 let fs = FakeFs::new(cx.background_executor.clone());
6892 fs.insert_tree(
6893 "/project_root",
6894 json!({
6895 ".git": {},
6896 ".gitignore": "**/gitignored_dir",
6897 "dir_1": {
6898 "file_1.py": "# File 1_1 contents",
6899 "file_2.py": "# File 1_2 contents",
6900 "file_3.py": "# File 1_3 contents",
6901 "gitignored_dir": {
6902 "file_a.py": "# File contents",
6903 "file_b.py": "# File contents",
6904 "file_c.py": "# File contents",
6905 },
6906 },
6907 "dir_2": {
6908 "file_1.py": "# File 2_1 contents",
6909 "file_2.py": "# File 2_2 contents",
6910 "file_3.py": "# File 2_3 contents",
6911 }
6912 }),
6913 )
6914 .await;
6915
6916 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6917 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6918 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6919 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6920
6921 assert_eq!(
6922 visible_entries_as_strings(&panel, 0..20, cx),
6923 &[
6924 "v project_root",
6925 " > .git",
6926 " > dir_1",
6927 " > dir_2",
6928 " .gitignore",
6929 ]
6930 );
6931
6932 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
6933 .expect("dir 1 file is not ignored and should have an entry");
6934 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
6935 .expect("dir 2 file is not ignored and should have an entry");
6936 let gitignored_dir_file =
6937 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
6938 assert_eq!(
6939 gitignored_dir_file, None,
6940 "File in the gitignored dir should not have an entry before its dir is toggled"
6941 );
6942
6943 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6944 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6945 cx.run_until_parked();
6946 assert_eq!(
6947 visible_entries_as_strings(&panel, 0..20, cx),
6948 &[
6949 "v project_root",
6950 " > .git",
6951 " v dir_1",
6952 " v gitignored_dir <== selected",
6953 " file_a.py",
6954 " file_b.py",
6955 " file_c.py",
6956 " file_1.py",
6957 " file_2.py",
6958 " file_3.py",
6959 " > dir_2",
6960 " .gitignore",
6961 ],
6962 "Should show gitignored dir file list in the project panel"
6963 );
6964 let gitignored_dir_file =
6965 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
6966 .expect("after gitignored dir got opened, a file entry should be present");
6967
6968 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6969 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6970 assert_eq!(
6971 visible_entries_as_strings(&panel, 0..20, cx),
6972 &[
6973 "v project_root",
6974 " > .git",
6975 " > dir_1 <== selected",
6976 " > dir_2",
6977 " .gitignore",
6978 ],
6979 "Should hide all dir contents again and prepare for the explicit reveal test"
6980 );
6981
6982 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
6983 panel.update(cx, |panel, cx| {
6984 panel.project.update(cx, |_, cx| {
6985 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
6986 })
6987 });
6988 cx.run_until_parked();
6989 assert_eq!(
6990 visible_entries_as_strings(&panel, 0..20, cx),
6991 &[
6992 "v project_root",
6993 " > .git",
6994 " > dir_1 <== selected",
6995 " > dir_2",
6996 " .gitignore",
6997 ],
6998 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
6999 );
7000 }
7001
7002 panel.update(cx, |panel, cx| {
7003 panel.project.update(cx, |_, cx| {
7004 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
7005 })
7006 });
7007 cx.run_until_parked();
7008 assert_eq!(
7009 visible_entries_as_strings(&panel, 0..20, cx),
7010 &[
7011 "v project_root",
7012 " > .git",
7013 " v dir_1",
7014 " > gitignored_dir",
7015 " file_1.py <== selected",
7016 " file_2.py",
7017 " file_3.py",
7018 " > dir_2",
7019 " .gitignore",
7020 ],
7021 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
7022 );
7023
7024 panel.update(cx, |panel, cx| {
7025 panel.project.update(cx, |_, cx| {
7026 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
7027 })
7028 });
7029 cx.run_until_parked();
7030 assert_eq!(
7031 visible_entries_as_strings(&panel, 0..20, cx),
7032 &[
7033 "v project_root",
7034 " > .git",
7035 " v dir_1",
7036 " > gitignored_dir",
7037 " file_1.py",
7038 " file_2.py",
7039 " file_3.py",
7040 " v dir_2",
7041 " file_1.py <== selected",
7042 " file_2.py",
7043 " file_3.py",
7044 " .gitignore",
7045 ],
7046 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
7047 );
7048
7049 panel.update(cx, |panel, cx| {
7050 panel.project.update(cx, |_, cx| {
7051 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
7052 })
7053 });
7054 cx.run_until_parked();
7055 assert_eq!(
7056 visible_entries_as_strings(&panel, 0..20, cx),
7057 &[
7058 "v project_root",
7059 " > .git",
7060 " v dir_1",
7061 " v gitignored_dir",
7062 " file_a.py <== selected",
7063 " file_b.py",
7064 " file_c.py",
7065 " file_1.py",
7066 " file_2.py",
7067 " file_3.py",
7068 " v dir_2",
7069 " file_1.py",
7070 " file_2.py",
7071 " file_3.py",
7072 " .gitignore",
7073 ],
7074 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
7075 );
7076 }
7077
7078 #[gpui::test]
7079 async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
7080 init_test(cx);
7081 cx.update(|cx| {
7082 cx.update_global::<SettingsStore, _>(|store, cx| {
7083 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
7084 project_settings.file_scan_exclusions =
7085 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
7086 });
7087 });
7088 });
7089
7090 cx.update(|cx| {
7091 register_project_item::<TestProjectItemView>(cx);
7092 });
7093
7094 let fs = FakeFs::new(cx.executor().clone());
7095 fs.insert_tree(
7096 "/root1",
7097 json!({
7098 ".dockerignore": "",
7099 ".git": {
7100 "HEAD": "",
7101 },
7102 }),
7103 )
7104 .await;
7105
7106 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
7107 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7108 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7109 let panel = workspace
7110 .update(cx, |workspace, cx| {
7111 let panel = ProjectPanel::new(workspace, cx);
7112 workspace.add_panel(panel.clone(), cx);
7113 panel
7114 })
7115 .unwrap();
7116
7117 select_path(&panel, "root1", cx);
7118 assert_eq!(
7119 visible_entries_as_strings(&panel, 0..10, cx),
7120 &["v root1 <== selected", " .dockerignore",]
7121 );
7122 workspace
7123 .update(cx, |workspace, cx| {
7124 assert!(
7125 workspace.active_item(cx).is_none(),
7126 "Should have no active items in the beginning"
7127 );
7128 })
7129 .unwrap();
7130
7131 let excluded_file_path = ".git/COMMIT_EDITMSG";
7132 let excluded_dir_path = "excluded_dir";
7133
7134 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
7135 panel.update(cx, |panel, cx| {
7136 assert!(panel.filename_editor.read(cx).is_focused(cx));
7137 });
7138 panel
7139 .update(cx, |panel, cx| {
7140 panel
7141 .filename_editor
7142 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
7143 panel.confirm_edit(cx).unwrap()
7144 })
7145 .await
7146 .unwrap();
7147
7148 assert_eq!(
7149 visible_entries_as_strings(&panel, 0..13, cx),
7150 &["v root1", " .dockerignore"],
7151 "Excluded dir should not be shown after opening a file in it"
7152 );
7153 panel.update(cx, |panel, cx| {
7154 assert!(
7155 !panel.filename_editor.read(cx).is_focused(cx),
7156 "Should have closed the file name editor"
7157 );
7158 });
7159 workspace
7160 .update(cx, |workspace, cx| {
7161 let active_entry_path = workspace
7162 .active_item(cx)
7163 .expect("should have opened and activated the excluded item")
7164 .act_as::<TestProjectItemView>(cx)
7165 .expect(
7166 "should have opened the corresponding project item for the excluded item",
7167 )
7168 .read(cx)
7169 .path
7170 .clone();
7171 assert_eq!(
7172 active_entry_path.path.as_ref(),
7173 Path::new(excluded_file_path),
7174 "Should open the excluded file"
7175 );
7176
7177 assert!(
7178 workspace.notification_ids().is_empty(),
7179 "Should have no notifications after opening an excluded file"
7180 );
7181 })
7182 .unwrap();
7183 assert!(
7184 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
7185 "Should have created the excluded file"
7186 );
7187
7188 select_path(&panel, "root1", cx);
7189 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7190 panel.update(cx, |panel, cx| {
7191 assert!(panel.filename_editor.read(cx).is_focused(cx));
7192 });
7193 panel
7194 .update(cx, |panel, cx| {
7195 panel
7196 .filename_editor
7197 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
7198 panel.confirm_edit(cx).unwrap()
7199 })
7200 .await
7201 .unwrap();
7202
7203 assert_eq!(
7204 visible_entries_as_strings(&panel, 0..13, cx),
7205 &["v root1", " .dockerignore"],
7206 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
7207 );
7208 panel.update(cx, |panel, cx| {
7209 assert!(
7210 !panel.filename_editor.read(cx).is_focused(cx),
7211 "Should have closed the file name editor"
7212 );
7213 });
7214 workspace
7215 .update(cx, |workspace, cx| {
7216 let notifications = workspace.notification_ids();
7217 assert_eq!(
7218 notifications.len(),
7219 1,
7220 "Should receive one notification with the error message"
7221 );
7222 workspace.dismiss_notification(notifications.first().unwrap(), cx);
7223 assert!(workspace.notification_ids().is_empty());
7224 })
7225 .unwrap();
7226
7227 select_path(&panel, "root1", cx);
7228 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7229 panel.update(cx, |panel, cx| {
7230 assert!(panel.filename_editor.read(cx).is_focused(cx));
7231 });
7232 panel
7233 .update(cx, |panel, cx| {
7234 panel
7235 .filename_editor
7236 .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
7237 panel.confirm_edit(cx).unwrap()
7238 })
7239 .await
7240 .unwrap();
7241
7242 assert_eq!(
7243 visible_entries_as_strings(&panel, 0..13, cx),
7244 &["v root1", " .dockerignore"],
7245 "Should not change the project panel after trying to create an excluded directory"
7246 );
7247 panel.update(cx, |panel, cx| {
7248 assert!(
7249 !panel.filename_editor.read(cx).is_focused(cx),
7250 "Should have closed the file name editor"
7251 );
7252 });
7253 workspace
7254 .update(cx, |workspace, cx| {
7255 let notifications = workspace.notification_ids();
7256 assert_eq!(
7257 notifications.len(),
7258 1,
7259 "Should receive one notification explaining that no directory is actually shown"
7260 );
7261 workspace.dismiss_notification(notifications.first().unwrap(), cx);
7262 assert!(workspace.notification_ids().is_empty());
7263 })
7264 .unwrap();
7265 assert!(
7266 fs.is_dir(Path::new("/root1/excluded_dir")).await,
7267 "Should have created the excluded directory"
7268 );
7269 }
7270
7271 #[gpui::test]
7272 async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
7273 init_test_with_editor(cx);
7274
7275 let fs = FakeFs::new(cx.executor().clone());
7276 fs.insert_tree(
7277 "/src",
7278 json!({
7279 "test": {
7280 "first.rs": "// First Rust file",
7281 "second.rs": "// Second Rust file",
7282 "third.rs": "// Third Rust file",
7283 }
7284 }),
7285 )
7286 .await;
7287
7288 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
7289 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7290 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7291 let panel = workspace
7292 .update(cx, |workspace, cx| {
7293 let panel = ProjectPanel::new(workspace, cx);
7294 workspace.add_panel(panel.clone(), cx);
7295 panel
7296 })
7297 .unwrap();
7298
7299 select_path(&panel, "src/", cx);
7300 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
7301 cx.executor().run_until_parked();
7302 assert_eq!(
7303 visible_entries_as_strings(&panel, 0..10, cx),
7304 &[
7305 //
7306 "v src <== selected",
7307 " > test"
7308 ]
7309 );
7310 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7311 panel.update(cx, |panel, cx| {
7312 assert!(panel.filename_editor.read(cx).is_focused(cx));
7313 });
7314 assert_eq!(
7315 visible_entries_as_strings(&panel, 0..10, cx),
7316 &[
7317 //
7318 "v src",
7319 " > [EDITOR: ''] <== selected",
7320 " > test"
7321 ]
7322 );
7323
7324 panel.update(cx, |panel, cx| panel.cancel(&menu::Cancel, cx));
7325 assert_eq!(
7326 visible_entries_as_strings(&panel, 0..10, cx),
7327 &[
7328 //
7329 "v src <== selected",
7330 " > test"
7331 ]
7332 );
7333 }
7334
7335 #[gpui::test]
7336 async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
7337 init_test_with_editor(cx);
7338
7339 let fs = FakeFs::new(cx.executor().clone());
7340 fs.insert_tree(
7341 "/root",
7342 json!({
7343 "dir1": {
7344 "subdir1": {},
7345 "file1.txt": "",
7346 "file2.txt": "",
7347 },
7348 "dir2": {
7349 "subdir2": {},
7350 "file3.txt": "",
7351 "file4.txt": "",
7352 },
7353 "file5.txt": "",
7354 "file6.txt": "",
7355 }),
7356 )
7357 .await;
7358
7359 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7360 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7361 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7362 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7363
7364 toggle_expand_dir(&panel, "root/dir1", cx);
7365 toggle_expand_dir(&panel, "root/dir2", cx);
7366
7367 // Test Case 1: Delete middle file in directory
7368 select_path(&panel, "root/dir1/file1.txt", cx);
7369 assert_eq!(
7370 visible_entries_as_strings(&panel, 0..15, cx),
7371 &[
7372 "v root",
7373 " v dir1",
7374 " > subdir1",
7375 " file1.txt <== selected",
7376 " file2.txt",
7377 " v dir2",
7378 " > subdir2",
7379 " file3.txt",
7380 " file4.txt",
7381 " file5.txt",
7382 " file6.txt",
7383 ],
7384 "Initial state before deleting middle file"
7385 );
7386
7387 submit_deletion(&panel, cx);
7388 assert_eq!(
7389 visible_entries_as_strings(&panel, 0..15, cx),
7390 &[
7391 "v root",
7392 " v dir1",
7393 " > subdir1",
7394 " file2.txt <== selected",
7395 " v dir2",
7396 " > subdir2",
7397 " file3.txt",
7398 " file4.txt",
7399 " file5.txt",
7400 " file6.txt",
7401 ],
7402 "Should select next file after deleting middle file"
7403 );
7404
7405 // Test Case 2: Delete last file in directory
7406 submit_deletion(&panel, cx);
7407 assert_eq!(
7408 visible_entries_as_strings(&panel, 0..15, cx),
7409 &[
7410 "v root",
7411 " v dir1",
7412 " > subdir1 <== selected",
7413 " v dir2",
7414 " > subdir2",
7415 " file3.txt",
7416 " file4.txt",
7417 " file5.txt",
7418 " file6.txt",
7419 ],
7420 "Should select next directory when last file is deleted"
7421 );
7422
7423 // Test Case 3: Delete root level file
7424 select_path(&panel, "root/file6.txt", cx);
7425 assert_eq!(
7426 visible_entries_as_strings(&panel, 0..15, cx),
7427 &[
7428 "v root",
7429 " v dir1",
7430 " > subdir1",
7431 " v dir2",
7432 " > subdir2",
7433 " file3.txt",
7434 " file4.txt",
7435 " file5.txt",
7436 " file6.txt <== selected",
7437 ],
7438 "Initial state before deleting root level file"
7439 );
7440
7441 submit_deletion(&panel, cx);
7442 assert_eq!(
7443 visible_entries_as_strings(&panel, 0..15, cx),
7444 &[
7445 "v root",
7446 " v dir1",
7447 " > subdir1",
7448 " v dir2",
7449 " > subdir2",
7450 " file3.txt",
7451 " file4.txt",
7452 " file5.txt <== selected",
7453 ],
7454 "Should select prev entry at root level"
7455 );
7456 }
7457
7458 #[gpui::test]
7459 async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
7460 init_test_with_editor(cx);
7461
7462 let fs = FakeFs::new(cx.executor().clone());
7463 fs.insert_tree(
7464 "/root",
7465 json!({
7466 "dir1": {
7467 "subdir1": {
7468 "a.txt": "",
7469 "b.txt": ""
7470 },
7471 "file1.txt": "",
7472 },
7473 "dir2": {
7474 "subdir2": {
7475 "c.txt": "",
7476 "d.txt": ""
7477 },
7478 "file2.txt": "",
7479 },
7480 "file3.txt": "",
7481 }),
7482 )
7483 .await;
7484
7485 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7486 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7487 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7488 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7489
7490 toggle_expand_dir(&panel, "root/dir1", cx);
7491 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7492 toggle_expand_dir(&panel, "root/dir2", cx);
7493 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
7494
7495 // Test Case 1: Select and delete nested directory with parent
7496 cx.simulate_modifiers_change(gpui::Modifiers {
7497 control: true,
7498 ..Default::default()
7499 });
7500 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
7501 select_path_with_mark(&panel, "root/dir1", cx);
7502
7503 assert_eq!(
7504 visible_entries_as_strings(&panel, 0..15, cx),
7505 &[
7506 "v root",
7507 " v dir1 <== selected <== marked",
7508 " v subdir1 <== marked",
7509 " a.txt",
7510 " b.txt",
7511 " file1.txt",
7512 " v dir2",
7513 " v subdir2",
7514 " c.txt",
7515 " d.txt",
7516 " file2.txt",
7517 " file3.txt",
7518 ],
7519 "Initial state before deleting nested directory with parent"
7520 );
7521
7522 submit_deletion(&panel, cx);
7523 assert_eq!(
7524 visible_entries_as_strings(&panel, 0..15, cx),
7525 &[
7526 "v root",
7527 " v dir2 <== selected",
7528 " v subdir2",
7529 " c.txt",
7530 " d.txt",
7531 " file2.txt",
7532 " file3.txt",
7533 ],
7534 "Should select next directory after deleting directory with parent"
7535 );
7536
7537 // Test Case 2: Select mixed files and directories across levels
7538 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
7539 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
7540 select_path_with_mark(&panel, "root/file3.txt", cx);
7541
7542 assert_eq!(
7543 visible_entries_as_strings(&panel, 0..15, cx),
7544 &[
7545 "v root",
7546 " v dir2",
7547 " v subdir2",
7548 " c.txt <== marked",
7549 " d.txt",
7550 " file2.txt <== marked",
7551 " file3.txt <== selected <== marked",
7552 ],
7553 "Initial state before deleting"
7554 );
7555
7556 submit_deletion(&panel, cx);
7557 assert_eq!(
7558 visible_entries_as_strings(&panel, 0..15, cx),
7559 &[
7560 "v root",
7561 " v dir2 <== selected",
7562 " v subdir2",
7563 " d.txt",
7564 ],
7565 "Should select sibling directory"
7566 );
7567 }
7568
7569 #[gpui::test]
7570 async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
7571 init_test_with_editor(cx);
7572
7573 let fs = FakeFs::new(cx.executor().clone());
7574 fs.insert_tree(
7575 "/root",
7576 json!({
7577 "dir1": {
7578 "subdir1": {
7579 "a.txt": "",
7580 "b.txt": ""
7581 },
7582 "file1.txt": "",
7583 },
7584 "dir2": {
7585 "subdir2": {
7586 "c.txt": "",
7587 "d.txt": ""
7588 },
7589 "file2.txt": "",
7590 },
7591 "file3.txt": "",
7592 "file4.txt": "",
7593 }),
7594 )
7595 .await;
7596
7597 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7598 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7599 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7600 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7601
7602 toggle_expand_dir(&panel, "root/dir1", cx);
7603 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7604 toggle_expand_dir(&panel, "root/dir2", cx);
7605 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
7606
7607 // Test Case 1: Select all root files and directories
7608 cx.simulate_modifiers_change(gpui::Modifiers {
7609 control: true,
7610 ..Default::default()
7611 });
7612 select_path_with_mark(&panel, "root/dir1", cx);
7613 select_path_with_mark(&panel, "root/dir2", cx);
7614 select_path_with_mark(&panel, "root/file3.txt", cx);
7615 select_path_with_mark(&panel, "root/file4.txt", cx);
7616 assert_eq!(
7617 visible_entries_as_strings(&panel, 0..20, cx),
7618 &[
7619 "v root",
7620 " v dir1 <== marked",
7621 " v subdir1",
7622 " a.txt",
7623 " b.txt",
7624 " file1.txt",
7625 " v dir2 <== marked",
7626 " v subdir2",
7627 " c.txt",
7628 " d.txt",
7629 " file2.txt",
7630 " file3.txt <== marked",
7631 " file4.txt <== selected <== marked",
7632 ],
7633 "State before deleting all contents"
7634 );
7635
7636 submit_deletion(&panel, cx);
7637 assert_eq!(
7638 visible_entries_as_strings(&panel, 0..20, cx),
7639 &["v root <== selected"],
7640 "Only empty root directory should remain after deleting all contents"
7641 );
7642 }
7643
7644 #[gpui::test]
7645 async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
7646 init_test_with_editor(cx);
7647
7648 let fs = FakeFs::new(cx.executor().clone());
7649 fs.insert_tree(
7650 "/root",
7651 json!({
7652 "dir1": {
7653 "subdir1": {
7654 "file_a.txt": "content a",
7655 "file_b.txt": "content b",
7656 },
7657 "subdir2": {
7658 "file_c.txt": "content c",
7659 },
7660 "file1.txt": "content 1",
7661 },
7662 "dir2": {
7663 "file2.txt": "content 2",
7664 },
7665 }),
7666 )
7667 .await;
7668
7669 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7670 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7671 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7672 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7673
7674 toggle_expand_dir(&panel, "root/dir1", cx);
7675 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7676 toggle_expand_dir(&panel, "root/dir2", cx);
7677 cx.simulate_modifiers_change(gpui::Modifiers {
7678 control: true,
7679 ..Default::default()
7680 });
7681
7682 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
7683 select_path_with_mark(&panel, "root/dir1", cx);
7684 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
7685 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
7686
7687 assert_eq!(
7688 visible_entries_as_strings(&panel, 0..20, cx),
7689 &[
7690 "v root",
7691 " v dir1 <== marked",
7692 " v subdir1 <== marked",
7693 " file_a.txt <== selected <== marked",
7694 " file_b.txt",
7695 " > subdir2",
7696 " file1.txt",
7697 " v dir2",
7698 " file2.txt",
7699 ],
7700 "State with parent dir, subdir, and file selected"
7701 );
7702 submit_deletion(&panel, cx);
7703 assert_eq!(
7704 visible_entries_as_strings(&panel, 0..20, cx),
7705 &["v root", " v dir2 <== selected", " file2.txt",],
7706 "Only dir2 should remain after deletion"
7707 );
7708 }
7709
7710 #[gpui::test]
7711 async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
7712 init_test_with_editor(cx);
7713
7714 let fs = FakeFs::new(cx.executor().clone());
7715 // First worktree
7716 fs.insert_tree(
7717 "/root1",
7718 json!({
7719 "dir1": {
7720 "file1.txt": "content 1",
7721 "file2.txt": "content 2",
7722 },
7723 "dir2": {
7724 "file3.txt": "content 3",
7725 },
7726 }),
7727 )
7728 .await;
7729
7730 // Second worktree
7731 fs.insert_tree(
7732 "/root2",
7733 json!({
7734 "dir3": {
7735 "file4.txt": "content 4",
7736 "file5.txt": "content 5",
7737 },
7738 "file6.txt": "content 6",
7739 }),
7740 )
7741 .await;
7742
7743 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7744 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7745 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7746 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7747
7748 // Expand all directories for testing
7749 toggle_expand_dir(&panel, "root1/dir1", cx);
7750 toggle_expand_dir(&panel, "root1/dir2", cx);
7751 toggle_expand_dir(&panel, "root2/dir3", cx);
7752
7753 // Test Case 1: Delete files across different worktrees
7754 cx.simulate_modifiers_change(gpui::Modifiers {
7755 control: true,
7756 ..Default::default()
7757 });
7758 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
7759 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
7760
7761 assert_eq!(
7762 visible_entries_as_strings(&panel, 0..20, cx),
7763 &[
7764 "v root1",
7765 " v dir1",
7766 " file1.txt <== marked",
7767 " file2.txt",
7768 " v dir2",
7769 " file3.txt",
7770 "v root2",
7771 " v dir3",
7772 " file4.txt <== selected <== marked",
7773 " file5.txt",
7774 " file6.txt",
7775 ],
7776 "Initial state with files selected from different worktrees"
7777 );
7778
7779 submit_deletion(&panel, cx);
7780 assert_eq!(
7781 visible_entries_as_strings(&panel, 0..20, cx),
7782 &[
7783 "v root1",
7784 " v dir1",
7785 " file2.txt",
7786 " v dir2",
7787 " file3.txt",
7788 "v root2",
7789 " v dir3",
7790 " file5.txt <== selected",
7791 " file6.txt",
7792 ],
7793 "Should select next file in the last worktree after deletion"
7794 );
7795
7796 // Test Case 2: Delete directories from different worktrees
7797 select_path_with_mark(&panel, "root1/dir1", cx);
7798 select_path_with_mark(&panel, "root2/dir3", cx);
7799
7800 assert_eq!(
7801 visible_entries_as_strings(&panel, 0..20, cx),
7802 &[
7803 "v root1",
7804 " v dir1 <== marked",
7805 " file2.txt",
7806 " v dir2",
7807 " file3.txt",
7808 "v root2",
7809 " v dir3 <== selected <== marked",
7810 " file5.txt",
7811 " file6.txt",
7812 ],
7813 "State with directories marked from different worktrees"
7814 );
7815
7816 submit_deletion(&panel, cx);
7817 assert_eq!(
7818 visible_entries_as_strings(&panel, 0..20, cx),
7819 &[
7820 "v root1",
7821 " v dir2",
7822 " file3.txt",
7823 "v root2",
7824 " file6.txt <== selected",
7825 ],
7826 "Should select remaining file in last worktree after directory deletion"
7827 );
7828
7829 // Test Case 4: Delete all remaining files except roots
7830 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
7831 select_path_with_mark(&panel, "root2/file6.txt", cx);
7832
7833 assert_eq!(
7834 visible_entries_as_strings(&panel, 0..20, cx),
7835 &[
7836 "v root1",
7837 " v dir2",
7838 " file3.txt <== marked",
7839 "v root2",
7840 " file6.txt <== selected <== marked",
7841 ],
7842 "State with all remaining files marked"
7843 );
7844
7845 submit_deletion(&panel, cx);
7846 assert_eq!(
7847 visible_entries_as_strings(&panel, 0..20, cx),
7848 &["v root1", " v dir2", "v root2 <== selected"],
7849 "Second parent root should be selected after deleting"
7850 );
7851 }
7852
7853 #[gpui::test]
7854 async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
7855 init_test_with_editor(cx);
7856
7857 let fs = FakeFs::new(cx.executor().clone());
7858 fs.insert_tree(
7859 "/root_b",
7860 json!({
7861 "dir1": {
7862 "file1.txt": "content 1",
7863 "file2.txt": "content 2",
7864 },
7865 }),
7866 )
7867 .await;
7868
7869 fs.insert_tree(
7870 "/root_c",
7871 json!({
7872 "dir2": {},
7873 }),
7874 )
7875 .await;
7876
7877 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
7878 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7879 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7880 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7881
7882 toggle_expand_dir(&panel, "root_b/dir1", cx);
7883 toggle_expand_dir(&panel, "root_c/dir2", cx);
7884
7885 cx.simulate_modifiers_change(gpui::Modifiers {
7886 control: true,
7887 ..Default::default()
7888 });
7889 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
7890 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
7891
7892 assert_eq!(
7893 visible_entries_as_strings(&panel, 0..20, cx),
7894 &[
7895 "v root_b",
7896 " v dir1",
7897 " file1.txt <== marked",
7898 " file2.txt <== selected <== marked",
7899 "v root_c",
7900 " v dir2",
7901 ],
7902 "Initial state with files marked in root_b"
7903 );
7904
7905 submit_deletion(&panel, cx);
7906 assert_eq!(
7907 visible_entries_as_strings(&panel, 0..20, cx),
7908 &[
7909 "v root_b",
7910 " v dir1 <== selected",
7911 "v root_c",
7912 " v dir2",
7913 ],
7914 "After deletion in root_b as it's last deletion, selection should be in root_b"
7915 );
7916
7917 select_path_with_mark(&panel, "root_c/dir2", cx);
7918
7919 submit_deletion(&panel, cx);
7920 assert_eq!(
7921 visible_entries_as_strings(&panel, 0..20, cx),
7922 &["v root_b", " v dir1", "v root_c <== selected",],
7923 "After deleting from root_c, it should remain in root_c"
7924 );
7925 }
7926
7927 fn toggle_expand_dir(
7928 panel: &View<ProjectPanel>,
7929 path: impl AsRef<Path>,
7930 cx: &mut VisualTestContext,
7931 ) {
7932 let path = path.as_ref();
7933 panel.update(cx, |panel, cx| {
7934 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7935 let worktree = worktree.read(cx);
7936 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7937 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
7938 panel.toggle_expanded(entry_id, cx);
7939 return;
7940 }
7941 }
7942 panic!("no worktree for path {:?}", path);
7943 });
7944 }
7945
7946 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
7947 let path = path.as_ref();
7948 panel.update(cx, |panel, cx| {
7949 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7950 let worktree = worktree.read(cx);
7951 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7952 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
7953 panel.selection = Some(crate::SelectedEntry {
7954 worktree_id: worktree.id(),
7955 entry_id,
7956 });
7957 return;
7958 }
7959 }
7960 panic!("no worktree for path {:?}", path);
7961 });
7962 }
7963
7964 fn select_path_with_mark(
7965 panel: &View<ProjectPanel>,
7966 path: impl AsRef<Path>,
7967 cx: &mut VisualTestContext,
7968 ) {
7969 let path = path.as_ref();
7970 panel.update(cx, |panel, cx| {
7971 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7972 let worktree = worktree.read(cx);
7973 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7974 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
7975 let entry = crate::SelectedEntry {
7976 worktree_id: worktree.id(),
7977 entry_id,
7978 };
7979 if !panel.marked_entries.contains(&entry) {
7980 panel.marked_entries.insert(entry);
7981 }
7982 panel.selection = Some(entry);
7983 return;
7984 }
7985 }
7986 panic!("no worktree for path {:?}", path);
7987 });
7988 }
7989
7990 fn find_project_entry(
7991 panel: &View<ProjectPanel>,
7992 path: impl AsRef<Path>,
7993 cx: &mut VisualTestContext,
7994 ) -> Option<ProjectEntryId> {
7995 let path = path.as_ref();
7996 panel.update(cx, |panel, cx| {
7997 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7998 let worktree = worktree.read(cx);
7999 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8000 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
8001 }
8002 }
8003 panic!("no worktree for path {path:?}");
8004 })
8005 }
8006
8007 fn visible_entries_as_strings(
8008 panel: &View<ProjectPanel>,
8009 range: Range<usize>,
8010 cx: &mut VisualTestContext,
8011 ) -> Vec<String> {
8012 let mut result = Vec::new();
8013 let mut project_entries = HashSet::default();
8014 let mut has_editor = false;
8015
8016 panel.update(cx, |panel, cx| {
8017 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
8018 if details.is_editing {
8019 assert!(!has_editor, "duplicate editor entry");
8020 has_editor = true;
8021 } else {
8022 assert!(
8023 project_entries.insert(project_entry),
8024 "duplicate project entry {:?} {:?}",
8025 project_entry,
8026 details
8027 );
8028 }
8029
8030 let indent = " ".repeat(details.depth);
8031 let icon = if details.kind.is_dir() {
8032 if details.is_expanded {
8033 "v "
8034 } else {
8035 "> "
8036 }
8037 } else {
8038 " "
8039 };
8040 let name = if details.is_editing {
8041 format!("[EDITOR: '{}']", details.filename)
8042 } else if details.is_processing {
8043 format!("[PROCESSING: '{}']", details.filename)
8044 } else {
8045 details.filename.clone()
8046 };
8047 let selected = if details.is_selected {
8048 " <== selected"
8049 } else {
8050 ""
8051 };
8052 let marked = if details.is_marked {
8053 " <== marked"
8054 } else {
8055 ""
8056 };
8057
8058 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
8059 });
8060 });
8061
8062 result
8063 }
8064
8065 fn init_test(cx: &mut TestAppContext) {
8066 cx.update(|cx| {
8067 let settings_store = SettingsStore::test(cx);
8068 cx.set_global(settings_store);
8069 init_settings(cx);
8070 theme::init(theme::LoadThemes::JustBase, cx);
8071 language::init(cx);
8072 editor::init_settings(cx);
8073 crate::init((), cx);
8074 workspace::init_settings(cx);
8075 client::init_settings(cx);
8076 Project::init_settings(cx);
8077
8078 cx.update_global::<SettingsStore, _>(|store, cx| {
8079 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
8080 project_panel_settings.auto_fold_dirs = Some(false);
8081 });
8082 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
8083 worktree_settings.file_scan_exclusions = Some(Vec::new());
8084 });
8085 });
8086 });
8087 }
8088
8089 fn init_test_with_editor(cx: &mut TestAppContext) {
8090 cx.update(|cx| {
8091 let app_state = AppState::test(cx);
8092 theme::init(theme::LoadThemes::JustBase, cx);
8093 init_settings(cx);
8094 language::init(cx);
8095 editor::init(cx);
8096 crate::init((), cx);
8097 workspace::init(app_state.clone(), cx);
8098 Project::init_settings(cx);
8099
8100 cx.update_global::<SettingsStore, _>(|store, cx| {
8101 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
8102 project_panel_settings.auto_fold_dirs = Some(false);
8103 });
8104 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
8105 worktree_settings.file_scan_exclusions = Some(Vec::new());
8106 });
8107 });
8108 });
8109 }
8110
8111 fn ensure_single_file_is_opened(
8112 window: &WindowHandle<Workspace>,
8113 expected_path: &str,
8114 cx: &mut TestAppContext,
8115 ) {
8116 window
8117 .update(cx, |workspace, cx| {
8118 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
8119 assert_eq!(worktrees.len(), 1);
8120 let worktree_id = worktrees[0].read(cx).id();
8121
8122 let open_project_paths = workspace
8123 .panes()
8124 .iter()
8125 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
8126 .collect::<Vec<_>>();
8127 assert_eq!(
8128 open_project_paths,
8129 vec![ProjectPath {
8130 worktree_id,
8131 path: Arc::from(Path::new(expected_path))
8132 }],
8133 "Should have opened file, selected in project panel"
8134 );
8135 })
8136 .unwrap();
8137 }
8138
8139 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
8140 assert!(
8141 !cx.has_pending_prompt(),
8142 "Should have no prompts before the deletion"
8143 );
8144 panel.update(cx, |panel, cx| {
8145 panel.delete(&Delete { skip_prompt: false }, cx)
8146 });
8147 assert!(
8148 cx.has_pending_prompt(),
8149 "Should have a prompt after the deletion"
8150 );
8151 cx.simulate_prompt_answer(0);
8152 assert!(
8153 !cx.has_pending_prompt(),
8154 "Should have no prompts after prompt was replied to"
8155 );
8156 cx.executor().run_until_parked();
8157 }
8158
8159 fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
8160 assert!(
8161 !cx.has_pending_prompt(),
8162 "Should have no prompts before the deletion"
8163 );
8164 panel.update(cx, |panel, cx| {
8165 panel.delete(&Delete { skip_prompt: true }, cx)
8166 });
8167 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
8168 cx.executor().run_until_parked();
8169 }
8170
8171 fn ensure_no_open_items_and_panes(
8172 workspace: &WindowHandle<Workspace>,
8173 cx: &mut VisualTestContext,
8174 ) {
8175 assert!(
8176 !cx.has_pending_prompt(),
8177 "Should have no prompts after deletion operation closes the file"
8178 );
8179 workspace
8180 .read_with(cx, |workspace, cx| {
8181 let open_project_paths = workspace
8182 .panes()
8183 .iter()
8184 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
8185 .collect::<Vec<_>>();
8186 assert!(
8187 open_project_paths.is_empty(),
8188 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
8189 );
8190 })
8191 .unwrap();
8192 }
8193
8194 struct TestProjectItemView {
8195 focus_handle: FocusHandle,
8196 path: ProjectPath,
8197 }
8198
8199 struct TestProjectItem {
8200 path: ProjectPath,
8201 }
8202
8203 impl project::ProjectItem for TestProjectItem {
8204 fn try_open(
8205 _project: &Model<Project>,
8206 path: &ProjectPath,
8207 cx: &mut AppContext,
8208 ) -> Option<Task<gpui::Result<Model<Self>>>> {
8209 let path = path.clone();
8210 Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
8211 }
8212
8213 fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
8214 None
8215 }
8216
8217 fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
8218 Some(self.path.clone())
8219 }
8220
8221 fn is_dirty(&self) -> bool {
8222 false
8223 }
8224 }
8225
8226 impl ProjectItem for TestProjectItemView {
8227 type Item = TestProjectItem;
8228
8229 fn for_project_item(
8230 _: Model<Project>,
8231 project_item: Model<Self::Item>,
8232 cx: &mut ViewContext<Self>,
8233 ) -> Self
8234 where
8235 Self: Sized,
8236 {
8237 Self {
8238 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
8239 focus_handle: cx.focus_handle(),
8240 }
8241 }
8242 }
8243
8244 impl Item for TestProjectItemView {
8245 type Event = ();
8246 }
8247
8248 impl EventEmitter<()> for TestProjectItemView {}
8249
8250 impl FocusableView for TestProjectItemView {
8251 fn focus_handle(&self, _: &AppContext) -> FocusHandle {
8252 self.focus_handle.clone()
8253 }
8254 }
8255
8256 impl Render for TestProjectItemView {
8257 fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
8258 Empty
8259 }
8260 }
8261}