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