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