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