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