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