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(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.disjoint_entries(cx);
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.disjoint_entries(cx);
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(&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 the union of the currently selected entry and all marked entries.
1980 fn marked_entries(&self) -> BTreeSet<SelectedEntry> {
1981 let mut entries = self
1982 .marked_entries
1983 .iter()
1984 .map(|entry| SelectedEntry {
1985 entry_id: self.resolve_entry(entry.entry_id),
1986 worktree_id: entry.worktree_id,
1987 })
1988 .collect::<BTreeSet<_>>();
1989
1990 if let Some(selection) = self.selection {
1991 entries.insert(SelectedEntry {
1992 entry_id: self.resolve_entry(selection.entry_id),
1993 worktree_id: selection.worktree_id,
1994 });
1995 }
1996
1997 entries
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.marked_entries.clear();
2919 this.toggle_expanded(entry_id, cx);
2920 } else {
2921 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
2922 let click_count = event.up.click_count;
2923 let focus_opened_item = !preview_tabs_enabled || click_count > 1;
2924 let allow_preview = preview_tabs_enabled && click_count == 1;
2925 this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
2926 }
2927 }))
2928 .cursor_pointer()
2929 .bg(bg_color)
2930 .border_color(border_color)
2931 .child(
2932 ListItem::new(entry_id.to_proto() as usize)
2933 .indent_level(depth)
2934 .indent_step_size(px(settings.indent_size))
2935 .selectable(false)
2936 .when_some(canonical_path, |this, path| {
2937 this.end_slot::<AnyElement>(
2938 div()
2939 .id("symlink_icon")
2940 .pr_3()
2941 .tooltip(move |cx| {
2942 Tooltip::with_meta(path.to_string(), None, "Symbolic Link", cx)
2943 })
2944 .child(
2945 Icon::new(IconName::ArrowUpRight)
2946 .size(IconSize::Indicator)
2947 .color(filename_text_color),
2948 )
2949 .into_any_element(),
2950 )
2951 })
2952 .child(if let Some(icon) = &icon {
2953 // Check if there's a diagnostic severity and get the decoration color
2954 if let Some((_, decoration_color)) =
2955 entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
2956 {
2957 // Determine if the diagnostic is a warning
2958 let is_warning = diagnostic_severity
2959 .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
2960 .unwrap_or(false);
2961 div().child(
2962 DecoratedIcon::new(
2963 Icon::from_path(icon.clone()).color(Color::Muted),
2964 Some(
2965 IconDecoration::new(
2966 if kind.is_file() {
2967 if is_warning {
2968 IconDecorationKind::Triangle
2969 } else {
2970 IconDecorationKind::X
2971 }
2972 } else {
2973 IconDecorationKind::Dot
2974 },
2975 bg_color,
2976 cx,
2977 )
2978 .color(decoration_color.color(cx))
2979 .position(Point {
2980 x: px(-2.),
2981 y: px(-2.),
2982 }),
2983 ),
2984 )
2985 .into_any_element(),
2986 )
2987 } else {
2988 h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
2989 }
2990 } else {
2991 if let Some((icon_name, color)) =
2992 entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
2993 {
2994 h_flex()
2995 .size(IconSize::default().rems())
2996 .child(Icon::new(icon_name).color(color).size(IconSize::Small))
2997 } else {
2998 h_flex()
2999 .size(IconSize::default().rems())
3000 .invisible()
3001 .flex_none()
3002 }
3003 })
3004 .child(
3005 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
3006 h_flex().h_6().w_full().child(editor.clone())
3007 } else {
3008 h_flex().h_6().map(|mut this| {
3009 if let Some(folded_ancestors) = self.ancestors.get(&entry_id) {
3010 let components = Path::new(&file_name)
3011 .components()
3012 .map(|comp| {
3013 let comp_str =
3014 comp.as_os_str().to_string_lossy().into_owned();
3015 comp_str
3016 })
3017 .collect::<Vec<_>>();
3018
3019 let components_len = components.len();
3020 let active_index = components_len
3021 - 1
3022 - folded_ancestors.current_ancestor_depth;
3023 const DELIMITER: SharedString =
3024 SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
3025 for (index, component) in components.into_iter().enumerate() {
3026 if index != 0 {
3027 this = this.child(
3028 Label::new(DELIMITER.clone())
3029 .single_line()
3030 .color(filename_text_color),
3031 );
3032 }
3033 let id = SharedString::from(format!(
3034 "project_panel_path_component_{}_{index}",
3035 entry_id.to_usize()
3036 ));
3037 let label = div()
3038 .id(id)
3039 .on_click(cx.listener(move |this, _, cx| {
3040 if index != active_index {
3041 if let Some(folds) =
3042 this.ancestors.get_mut(&entry_id)
3043 {
3044 folds.current_ancestor_depth =
3045 components_len - 1 - index;
3046 cx.notify();
3047 }
3048 }
3049 }))
3050 .child(
3051 Label::new(component)
3052 .single_line()
3053 .color(filename_text_color)
3054 .when(
3055 index == active_index
3056 && (is_active || is_marked),
3057 |this| this.underline(true),
3058 ),
3059 );
3060
3061 this = this.child(label);
3062 }
3063
3064 this
3065 } else {
3066 this.child(
3067 Label::new(file_name)
3068 .single_line()
3069 .color(filename_text_color),
3070 )
3071 }
3072 })
3073 }
3074 .ml_1(),
3075 )
3076 .on_secondary_mouse_down(cx.listener(
3077 move |this, event: &MouseDownEvent, cx| {
3078 // Stop propagation to prevent the catch-all context menu for the project
3079 // panel from being deployed.
3080 cx.stop_propagation();
3081 this.deploy_context_menu(event.position, entry_id, cx);
3082 },
3083 ))
3084 .overflow_x(),
3085 )
3086 .border_1()
3087 .border_r_2()
3088 .rounded_none()
3089 .when(
3090 !self.mouse_down && is_active && self.focus_handle.contains_focused(cx),
3091 |this| this.border_color(Color::Selected.color(cx)),
3092 )
3093 }
3094
3095 fn render_vertical_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
3096 if !Self::should_show_scrollbar(cx)
3097 || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
3098 {
3099 return None;
3100 }
3101 Some(
3102 div()
3103 .occlude()
3104 .id("project-panel-vertical-scroll")
3105 .on_mouse_move(cx.listener(|_, _, cx| {
3106 cx.notify();
3107 cx.stop_propagation()
3108 }))
3109 .on_hover(|_, cx| {
3110 cx.stop_propagation();
3111 })
3112 .on_any_mouse_down(|_, cx| {
3113 cx.stop_propagation();
3114 })
3115 .on_mouse_up(
3116 MouseButton::Left,
3117 cx.listener(|this, _, cx| {
3118 if !this.vertical_scrollbar_state.is_dragging()
3119 && !this.focus_handle.contains_focused(cx)
3120 {
3121 this.hide_scrollbar(cx);
3122 cx.notify();
3123 }
3124
3125 cx.stop_propagation();
3126 }),
3127 )
3128 .on_scroll_wheel(cx.listener(|_, _, cx| {
3129 cx.notify();
3130 }))
3131 .h_full()
3132 .absolute()
3133 .right_1()
3134 .top_1()
3135 .bottom_1()
3136 .w(px(12.))
3137 .cursor_default()
3138 .children(Scrollbar::vertical(
3139 // percentage as f32..end_offset as f32,
3140 self.vertical_scrollbar_state.clone(),
3141 )),
3142 )
3143 }
3144
3145 fn render_horizontal_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
3146 if !Self::should_show_scrollbar(cx)
3147 || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
3148 {
3149 return None;
3150 }
3151
3152 let scroll_handle = self.scroll_handle.0.borrow();
3153 let longest_item_width = scroll_handle
3154 .last_item_size
3155 .filter(|size| size.contents.width > size.item.width)?
3156 .contents
3157 .width
3158 .0 as f64;
3159 if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
3160 return None;
3161 }
3162
3163 Some(
3164 div()
3165 .occlude()
3166 .id("project-panel-horizontal-scroll")
3167 .on_mouse_move(cx.listener(|_, _, cx| {
3168 cx.notify();
3169 cx.stop_propagation()
3170 }))
3171 .on_hover(|_, cx| {
3172 cx.stop_propagation();
3173 })
3174 .on_any_mouse_down(|_, cx| {
3175 cx.stop_propagation();
3176 })
3177 .on_mouse_up(
3178 MouseButton::Left,
3179 cx.listener(|this, _, cx| {
3180 if !this.horizontal_scrollbar_state.is_dragging()
3181 && !this.focus_handle.contains_focused(cx)
3182 {
3183 this.hide_scrollbar(cx);
3184 cx.notify();
3185 }
3186
3187 cx.stop_propagation();
3188 }),
3189 )
3190 .on_scroll_wheel(cx.listener(|_, _, cx| {
3191 cx.notify();
3192 }))
3193 .w_full()
3194 .absolute()
3195 .right_1()
3196 .left_1()
3197 .bottom_1()
3198 .h(px(12.))
3199 .cursor_default()
3200 .when(self.width.is_some(), |this| {
3201 this.children(Scrollbar::horizontal(
3202 self.horizontal_scrollbar_state.clone(),
3203 ))
3204 }),
3205 )
3206 }
3207
3208 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
3209 let mut dispatch_context = KeyContext::new_with_defaults();
3210 dispatch_context.add("ProjectPanel");
3211 dispatch_context.add("menu");
3212
3213 let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
3214 "editing"
3215 } else {
3216 "not_editing"
3217 };
3218
3219 dispatch_context.add(identifier);
3220 dispatch_context
3221 }
3222
3223 fn should_show_scrollbar(cx: &AppContext) -> bool {
3224 let show = ProjectPanelSettings::get_global(cx)
3225 .scrollbar
3226 .show
3227 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
3228 match show {
3229 ShowScrollbar::Auto => true,
3230 ShowScrollbar::System => true,
3231 ShowScrollbar::Always => true,
3232 ShowScrollbar::Never => false,
3233 }
3234 }
3235
3236 fn should_autohide_scrollbar(cx: &AppContext) -> bool {
3237 let show = ProjectPanelSettings::get_global(cx)
3238 .scrollbar
3239 .show
3240 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
3241 match show {
3242 ShowScrollbar::Auto => true,
3243 ShowScrollbar::System => cx
3244 .try_global::<ScrollbarAutoHide>()
3245 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
3246 ShowScrollbar::Always => false,
3247 ShowScrollbar::Never => true,
3248 }
3249 }
3250
3251 fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
3252 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
3253 if !Self::should_autohide_scrollbar(cx) {
3254 return;
3255 }
3256 self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
3257 cx.background_executor()
3258 .timer(SCROLLBAR_SHOW_INTERVAL)
3259 .await;
3260 panel
3261 .update(&mut cx, |panel, cx| {
3262 panel.show_scrollbar = false;
3263 cx.notify();
3264 })
3265 .log_err();
3266 }))
3267 }
3268
3269 fn reveal_entry(
3270 &mut self,
3271 project: Model<Project>,
3272 entry_id: ProjectEntryId,
3273 skip_ignored: bool,
3274 cx: &mut ViewContext<'_, Self>,
3275 ) {
3276 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
3277 let worktree = worktree.read(cx);
3278 if skip_ignored
3279 && worktree
3280 .entry_for_id(entry_id)
3281 .map_or(true, |entry| entry.is_ignored)
3282 {
3283 return;
3284 }
3285
3286 let worktree_id = worktree.id();
3287 self.expand_entry(worktree_id, entry_id, cx);
3288 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
3289
3290 if self.marked_entries.len() == 1
3291 && self
3292 .marked_entries
3293 .first()
3294 .filter(|entry| entry.entry_id == entry_id)
3295 .is_none()
3296 {
3297 self.marked_entries.clear();
3298 }
3299 self.autoscroll(cx);
3300 cx.notify();
3301 }
3302 }
3303
3304 fn find_active_indent_guide(
3305 &self,
3306 indent_guides: &[IndentGuideLayout],
3307 cx: &AppContext,
3308 ) -> Option<usize> {
3309 let (worktree, entry) = self.selected_entry(cx)?;
3310
3311 // Find the parent entry of the indent guide, this will either be the
3312 // expanded folder we have selected, or the parent of the currently
3313 // selected file/collapsed directory
3314 let mut entry = entry;
3315 loop {
3316 let is_expanded_dir = entry.is_dir()
3317 && self
3318 .expanded_dir_ids
3319 .get(&worktree.id())
3320 .map(|ids| ids.binary_search(&entry.id).is_ok())
3321 .unwrap_or(false);
3322 if is_expanded_dir {
3323 break;
3324 }
3325 entry = worktree.entry_for_path(&entry.path.parent()?)?;
3326 }
3327
3328 let (active_indent_range, depth) = {
3329 let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
3330 let child_paths = &self.visible_entries[worktree_ix].1;
3331 let mut child_count = 0;
3332 let depth = entry.path.ancestors().count();
3333 while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
3334 if entry.path.ancestors().count() <= depth {
3335 break;
3336 }
3337 child_count += 1;
3338 }
3339
3340 let start = ix + 1;
3341 let end = start + child_count;
3342
3343 let (_, entries, paths) = &self.visible_entries[worktree_ix];
3344 let visible_worktree_entries =
3345 paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect());
3346
3347 // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
3348 let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
3349 (start..end, depth)
3350 };
3351
3352 let candidates = indent_guides
3353 .iter()
3354 .enumerate()
3355 .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
3356
3357 for (i, indent) in candidates {
3358 // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
3359 if active_indent_range.start <= indent.offset.y + indent.length
3360 && indent.offset.y <= active_indent_range.end
3361 {
3362 return Some(i);
3363 }
3364 }
3365 None
3366 }
3367}
3368
3369fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
3370 const ICON_SIZE_FACTOR: usize = 2;
3371 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
3372 if is_symlink {
3373 item_width += ICON_SIZE_FACTOR;
3374 }
3375 item_width
3376}
3377
3378impl Render for ProjectPanel {
3379 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
3380 let has_worktree = !self.visible_entries.is_empty();
3381 let project = self.project.read(cx);
3382 let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
3383 let show_indent_guides =
3384 ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
3385 let is_local = project.is_local();
3386
3387 if has_worktree {
3388 let item_count = self
3389 .visible_entries
3390 .iter()
3391 .map(|(_, worktree_entries, _)| worktree_entries.len())
3392 .sum();
3393
3394 fn handle_drag_move_scroll<T: 'static>(
3395 this: &mut ProjectPanel,
3396 e: &DragMoveEvent<T>,
3397 cx: &mut ViewContext<ProjectPanel>,
3398 ) {
3399 if !e.bounds.contains(&e.event.position) {
3400 return;
3401 }
3402 this.hover_scroll_task.take();
3403 let panel_height = e.bounds.size.height;
3404 if panel_height <= px(0.) {
3405 return;
3406 }
3407
3408 let event_offset = e.event.position.y - e.bounds.origin.y;
3409 // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
3410 let hovered_region_offset = event_offset / panel_height;
3411
3412 // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
3413 // These pixels offsets were picked arbitrarily.
3414 let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
3415 8.
3416 } else if hovered_region_offset <= 0.15 {
3417 5.
3418 } else if hovered_region_offset >= 0.95 {
3419 -8.
3420 } else if hovered_region_offset >= 0.85 {
3421 -5.
3422 } else {
3423 return;
3424 };
3425 let adjustment = point(px(0.), px(vertical_scroll_offset));
3426 this.hover_scroll_task = Some(cx.spawn(move |this, mut cx| async move {
3427 loop {
3428 let should_stop_scrolling = this
3429 .update(&mut cx, |this, cx| {
3430 this.hover_scroll_task.as_ref()?;
3431 let handle = this.scroll_handle.0.borrow_mut();
3432 let offset = handle.base_handle.offset();
3433
3434 handle.base_handle.set_offset(offset + adjustment);
3435 cx.notify();
3436 Some(())
3437 })
3438 .ok()
3439 .flatten()
3440 .is_some();
3441 if should_stop_scrolling {
3442 return;
3443 }
3444 cx.background_executor()
3445 .timer(Duration::from_millis(16))
3446 .await;
3447 }
3448 }));
3449 }
3450 h_flex()
3451 .id("project-panel")
3452 .group("project-panel")
3453 .on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
3454 .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
3455 .size_full()
3456 .relative()
3457 .on_hover(cx.listener(|this, hovered, cx| {
3458 if *hovered {
3459 this.show_scrollbar = true;
3460 this.hide_scrollbar_task.take();
3461 cx.notify();
3462 } else if !this.focus_handle.contains_focused(cx) {
3463 this.hide_scrollbar(cx);
3464 }
3465 }))
3466 .key_context(self.dispatch_context(cx))
3467 .on_action(cx.listener(Self::select_next))
3468 .on_action(cx.listener(Self::select_prev))
3469 .on_action(cx.listener(Self::select_first))
3470 .on_action(cx.listener(Self::select_last))
3471 .on_action(cx.listener(Self::select_parent))
3472 .on_action(cx.listener(Self::expand_selected_entry))
3473 .on_action(cx.listener(Self::collapse_selected_entry))
3474 .on_action(cx.listener(Self::collapse_all_entries))
3475 .on_action(cx.listener(Self::open))
3476 .on_action(cx.listener(Self::open_permanent))
3477 .on_action(cx.listener(Self::confirm))
3478 .on_action(cx.listener(Self::cancel))
3479 .on_action(cx.listener(Self::copy_path))
3480 .on_action(cx.listener(Self::copy_relative_path))
3481 .on_action(cx.listener(Self::new_search_in_directory))
3482 .on_action(cx.listener(Self::unfold_directory))
3483 .on_action(cx.listener(Self::fold_directory))
3484 .on_action(cx.listener(Self::remove_from_project))
3485 .when(!project.is_read_only(cx), |el| {
3486 el.on_action(cx.listener(Self::new_file))
3487 .on_action(cx.listener(Self::new_directory))
3488 .on_action(cx.listener(Self::rename))
3489 .on_action(cx.listener(Self::delete))
3490 .on_action(cx.listener(Self::trash))
3491 .on_action(cx.listener(Self::cut))
3492 .on_action(cx.listener(Self::copy))
3493 .on_action(cx.listener(Self::paste))
3494 .on_action(cx.listener(Self::duplicate))
3495 .on_click(cx.listener(|this, event: &gpui::ClickEvent, cx| {
3496 if event.up.click_count > 1 {
3497 if let Some(entry_id) = this.last_worktree_root_id {
3498 let project = this.project.read(cx);
3499
3500 let worktree_id = if let Some(worktree) =
3501 project.worktree_for_entry(entry_id, cx)
3502 {
3503 worktree.read(cx).id()
3504 } else {
3505 return;
3506 };
3507
3508 this.selection = Some(SelectedEntry {
3509 worktree_id,
3510 entry_id,
3511 });
3512
3513 this.new_file(&NewFile, cx);
3514 }
3515 }
3516 }))
3517 })
3518 .when(project.is_local(), |el| {
3519 el.on_action(cx.listener(Self::reveal_in_finder))
3520 .on_action(cx.listener(Self::open_system))
3521 .on_action(cx.listener(Self::open_in_terminal))
3522 })
3523 .when(project.is_via_ssh(), |el| {
3524 el.on_action(cx.listener(Self::open_in_terminal))
3525 })
3526 .on_mouse_down(
3527 MouseButton::Right,
3528 cx.listener(move |this, event: &MouseDownEvent, cx| {
3529 // When deploying the context menu anywhere below the last project entry,
3530 // act as if the user clicked the root of the last worktree.
3531 if let Some(entry_id) = this.last_worktree_root_id {
3532 this.deploy_context_menu(event.position, entry_id, cx);
3533 }
3534 }),
3535 )
3536 .track_focus(&self.focus_handle(cx))
3537 .child(
3538 uniform_list(cx.view().clone(), "entries", item_count, {
3539 |this, range, cx| {
3540 let mut items = Vec::with_capacity(range.end - range.start);
3541 this.for_each_visible_entry(range, cx, |id, details, cx| {
3542 items.push(this.render_entry(id, details, cx));
3543 });
3544 items
3545 }
3546 })
3547 .when(show_indent_guides, |list| {
3548 list.with_decoration(
3549 ui::indent_guides(
3550 cx.view().clone(),
3551 px(indent_size),
3552 IndentGuideColors::panel(cx),
3553 |this, range, cx| {
3554 let mut items =
3555 SmallVec::with_capacity(range.end - range.start);
3556 this.iter_visible_entries(range, cx, |entry, entries, _| {
3557 let (depth, _) =
3558 Self::calculate_depth_and_difference(entry, entries);
3559 items.push(depth);
3560 });
3561 items
3562 },
3563 )
3564 .on_click(cx.listener(
3565 |this, active_indent_guide: &IndentGuideLayout, cx| {
3566 if cx.modifiers().secondary() {
3567 let ix = active_indent_guide.offset.y;
3568 let Some((target_entry, worktree)) = maybe!({
3569 let (worktree_id, entry) = this.entry_at_index(ix)?;
3570 let worktree = this
3571 .project
3572 .read(cx)
3573 .worktree_for_id(worktree_id, cx)?;
3574 let target_entry = worktree
3575 .read(cx)
3576 .entry_for_path(&entry.path.parent()?)?;
3577 Some((target_entry, worktree))
3578 }) else {
3579 return;
3580 };
3581
3582 this.collapse_entry(target_entry.clone(), worktree, cx);
3583 }
3584 },
3585 ))
3586 .with_render_fn(
3587 cx.view().clone(),
3588 move |this, params, cx| {
3589 const LEFT_OFFSET: f32 = 14.;
3590 const PADDING_Y: f32 = 4.;
3591 const HITBOX_OVERDRAW: f32 = 3.;
3592
3593 let active_indent_guide_index =
3594 this.find_active_indent_guide(¶ms.indent_guides, cx);
3595
3596 let indent_size = params.indent_size;
3597 let item_height = params.item_height;
3598
3599 params
3600 .indent_guides
3601 .into_iter()
3602 .enumerate()
3603 .map(|(idx, layout)| {
3604 let offset = if layout.continues_offscreen {
3605 px(0.)
3606 } else {
3607 px(PADDING_Y)
3608 };
3609 let bounds = Bounds::new(
3610 point(
3611 px(layout.offset.x as f32) * indent_size
3612 + px(LEFT_OFFSET),
3613 px(layout.offset.y as f32) * item_height
3614 + offset,
3615 ),
3616 size(
3617 px(1.),
3618 px(layout.length as f32) * item_height
3619 - px(offset.0 * 2.),
3620 ),
3621 );
3622 ui::RenderedIndentGuide {
3623 bounds,
3624 layout,
3625 is_active: Some(idx) == active_indent_guide_index,
3626 hitbox: Some(Bounds::new(
3627 point(
3628 bounds.origin.x - px(HITBOX_OVERDRAW),
3629 bounds.origin.y,
3630 ),
3631 size(
3632 bounds.size.width
3633 + px(2. * HITBOX_OVERDRAW),
3634 bounds.size.height,
3635 ),
3636 )),
3637 }
3638 })
3639 .collect()
3640 },
3641 ),
3642 )
3643 })
3644 .size_full()
3645 .with_sizing_behavior(ListSizingBehavior::Infer)
3646 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
3647 .with_width_from_item(self.max_width_item_index)
3648 .track_scroll(self.scroll_handle.clone()),
3649 )
3650 .children(self.render_vertical_scrollbar(cx))
3651 .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
3652 this.pb_4().child(scrollbar)
3653 })
3654 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
3655 deferred(
3656 anchored()
3657 .position(*position)
3658 .anchor(gpui::AnchorCorner::TopLeft)
3659 .child(menu.clone()),
3660 )
3661 .with_priority(1)
3662 }))
3663 } else {
3664 v_flex()
3665 .id("empty-project_panel")
3666 .size_full()
3667 .p_4()
3668 .track_focus(&self.focus_handle(cx))
3669 .child(
3670 Button::new("open_project", "Open a project")
3671 .full_width()
3672 .key_binding(KeyBinding::for_action(&workspace::Open, cx))
3673 .on_click(cx.listener(|this, _, cx| {
3674 this.workspace
3675 .update(cx, |_, cx| cx.dispatch_action(Box::new(workspace::Open)))
3676 .log_err();
3677 })),
3678 )
3679 .when(is_local, |div| {
3680 div.drag_over::<ExternalPaths>(|style, _, cx| {
3681 style.bg(cx.theme().colors().drop_target_background)
3682 })
3683 .on_drop(cx.listener(
3684 move |this, external_paths: &ExternalPaths, cx| {
3685 this.last_external_paths_drag_over_entry = None;
3686 this.marked_entries.clear();
3687 this.hover_scroll_task.take();
3688 if let Some(task) = this
3689 .workspace
3690 .update(cx, |workspace, cx| {
3691 workspace.open_workspace_for_paths(
3692 true,
3693 external_paths.paths().to_owned(),
3694 cx,
3695 )
3696 })
3697 .log_err()
3698 {
3699 task.detach_and_log_err(cx);
3700 }
3701 cx.stop_propagation();
3702 },
3703 ))
3704 })
3705 }
3706 }
3707}
3708
3709impl Render for DraggedProjectEntryView {
3710 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
3711 let settings = ProjectPanelSettings::get_global(cx);
3712 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
3713
3714 h_flex().font(ui_font).map(|this| {
3715 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
3716 this.flex_none()
3717 .w(self.width)
3718 .child(div().w(self.click_offset.x))
3719 .child(
3720 div()
3721 .p_1()
3722 .rounded_xl()
3723 .bg(cx.theme().colors().background)
3724 .child(Label::new(format!("{} entries", self.selections.len()))),
3725 )
3726 } else {
3727 this.w(self.width).bg(cx.theme().colors().background).child(
3728 ListItem::new(self.selection.entry_id.to_proto() as usize)
3729 .indent_level(self.details.depth)
3730 .indent_step_size(px(settings.indent_size))
3731 .child(if let Some(icon) = &self.details.icon {
3732 div().child(Icon::from_path(icon.clone()))
3733 } else {
3734 div()
3735 })
3736 .child(Label::new(self.details.filename.clone())),
3737 )
3738 }
3739 })
3740 }
3741}
3742
3743impl EventEmitter<Event> for ProjectPanel {}
3744
3745impl EventEmitter<PanelEvent> for ProjectPanel {}
3746
3747impl Panel for ProjectPanel {
3748 fn position(&self, cx: &WindowContext) -> DockPosition {
3749 match ProjectPanelSettings::get_global(cx).dock {
3750 ProjectPanelDockPosition::Left => DockPosition::Left,
3751 ProjectPanelDockPosition::Right => DockPosition::Right,
3752 }
3753 }
3754
3755 fn position_is_valid(&self, position: DockPosition) -> bool {
3756 matches!(position, DockPosition::Left | DockPosition::Right)
3757 }
3758
3759 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
3760 settings::update_settings_file::<ProjectPanelSettings>(
3761 self.fs.clone(),
3762 cx,
3763 move |settings, _| {
3764 let dock = match position {
3765 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
3766 DockPosition::Right => ProjectPanelDockPosition::Right,
3767 };
3768 settings.dock = Some(dock);
3769 },
3770 );
3771 }
3772
3773 fn size(&self, cx: &WindowContext) -> Pixels {
3774 self.width
3775 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
3776 }
3777
3778 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
3779 self.width = size;
3780 self.serialize(cx);
3781 cx.notify();
3782 }
3783
3784 fn icon(&self, cx: &WindowContext) -> Option<IconName> {
3785 ProjectPanelSettings::get_global(cx)
3786 .button
3787 .then_some(IconName::FileTree)
3788 }
3789
3790 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
3791 Some("Project Panel")
3792 }
3793
3794 fn toggle_action(&self) -> Box<dyn Action> {
3795 Box::new(ToggleFocus)
3796 }
3797
3798 fn persistent_name() -> &'static str {
3799 "Project Panel"
3800 }
3801
3802 fn starts_open(&self, cx: &WindowContext) -> bool {
3803 let project = &self.project.read(cx);
3804 project.visible_worktrees(cx).any(|tree| {
3805 tree.read(cx)
3806 .root_entry()
3807 .map_or(false, |entry| entry.is_dir())
3808 })
3809 }
3810}
3811
3812impl FocusableView for ProjectPanel {
3813 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
3814 self.focus_handle.clone()
3815 }
3816}
3817
3818impl ClipboardEntry {
3819 fn is_cut(&self) -> bool {
3820 matches!(self, Self::Cut { .. })
3821 }
3822
3823 fn items(&self) -> &BTreeSet<SelectedEntry> {
3824 match self {
3825 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
3826 }
3827 }
3828}
3829
3830#[cfg(test)]
3831mod tests {
3832 use super::*;
3833 use collections::HashSet;
3834 use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
3835 use pretty_assertions::assert_eq;
3836 use project::{FakeFs, WorktreeSettings};
3837 use serde_json::json;
3838 use settings::SettingsStore;
3839 use std::path::{Path, PathBuf};
3840 use ui::Context;
3841 use workspace::{
3842 item::{Item, ProjectItem},
3843 register_project_item, AppState,
3844 };
3845
3846 #[gpui::test]
3847 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
3848 init_test(cx);
3849
3850 let fs = FakeFs::new(cx.executor().clone());
3851 fs.insert_tree(
3852 "/root1",
3853 json!({
3854 ".dockerignore": "",
3855 ".git": {
3856 "HEAD": "",
3857 },
3858 "a": {
3859 "0": { "q": "", "r": "", "s": "" },
3860 "1": { "t": "", "u": "" },
3861 "2": { "v": "", "w": "", "x": "", "y": "" },
3862 },
3863 "b": {
3864 "3": { "Q": "" },
3865 "4": { "R": "", "S": "", "T": "", "U": "" },
3866 },
3867 "C": {
3868 "5": {},
3869 "6": { "V": "", "W": "" },
3870 "7": { "X": "" },
3871 "8": { "Y": {}, "Z": "" }
3872 }
3873 }),
3874 )
3875 .await;
3876 fs.insert_tree(
3877 "/root2",
3878 json!({
3879 "d": {
3880 "9": ""
3881 },
3882 "e": {}
3883 }),
3884 )
3885 .await;
3886
3887 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3888 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3889 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3890 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3891 assert_eq!(
3892 visible_entries_as_strings(&panel, 0..50, cx),
3893 &[
3894 "v root1",
3895 " > .git",
3896 " > a",
3897 " > b",
3898 " > C",
3899 " .dockerignore",
3900 "v root2",
3901 " > d",
3902 " > e",
3903 ]
3904 );
3905
3906 toggle_expand_dir(&panel, "root1/b", cx);
3907 assert_eq!(
3908 visible_entries_as_strings(&panel, 0..50, cx),
3909 &[
3910 "v root1",
3911 " > .git",
3912 " > a",
3913 " v b <== selected",
3914 " > 3",
3915 " > 4",
3916 " > C",
3917 " .dockerignore",
3918 "v root2",
3919 " > d",
3920 " > e",
3921 ]
3922 );
3923
3924 assert_eq!(
3925 visible_entries_as_strings(&panel, 6..9, cx),
3926 &[
3927 //
3928 " > C",
3929 " .dockerignore",
3930 "v root2",
3931 ]
3932 );
3933 }
3934
3935 #[gpui::test]
3936 async fn test_opening_file(cx: &mut gpui::TestAppContext) {
3937 init_test_with_editor(cx);
3938
3939 let fs = FakeFs::new(cx.executor().clone());
3940 fs.insert_tree(
3941 "/src",
3942 json!({
3943 "test": {
3944 "first.rs": "// First Rust file",
3945 "second.rs": "// Second Rust file",
3946 "third.rs": "// Third Rust file",
3947 }
3948 }),
3949 )
3950 .await;
3951
3952 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3953 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3954 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3955 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3956
3957 toggle_expand_dir(&panel, "src/test", cx);
3958 select_path(&panel, "src/test/first.rs", cx);
3959 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3960 cx.executor().run_until_parked();
3961 assert_eq!(
3962 visible_entries_as_strings(&panel, 0..10, cx),
3963 &[
3964 "v src",
3965 " v test",
3966 " first.rs <== selected <== marked",
3967 " second.rs",
3968 " third.rs"
3969 ]
3970 );
3971 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
3972
3973 select_path(&panel, "src/test/second.rs", cx);
3974 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3975 cx.executor().run_until_parked();
3976 assert_eq!(
3977 visible_entries_as_strings(&panel, 0..10, cx),
3978 &[
3979 "v src",
3980 " v test",
3981 " first.rs",
3982 " second.rs <== selected <== marked",
3983 " third.rs"
3984 ]
3985 );
3986 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
3987 }
3988
3989 #[gpui::test]
3990 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
3991 init_test(cx);
3992 cx.update(|cx| {
3993 cx.update_global::<SettingsStore, _>(|store, cx| {
3994 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3995 worktree_settings.file_scan_exclusions =
3996 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
3997 });
3998 });
3999 });
4000
4001 let fs = FakeFs::new(cx.background_executor.clone());
4002 fs.insert_tree(
4003 "/root1",
4004 json!({
4005 ".dockerignore": "",
4006 ".git": {
4007 "HEAD": "",
4008 },
4009 "a": {
4010 "0": { "q": "", "r": "", "s": "" },
4011 "1": { "t": "", "u": "" },
4012 "2": { "v": "", "w": "", "x": "", "y": "" },
4013 },
4014 "b": {
4015 "3": { "Q": "" },
4016 "4": { "R": "", "S": "", "T": "", "U": "" },
4017 },
4018 "C": {
4019 "5": {},
4020 "6": { "V": "", "W": "" },
4021 "7": { "X": "" },
4022 "8": { "Y": {}, "Z": "" }
4023 }
4024 }),
4025 )
4026 .await;
4027 fs.insert_tree(
4028 "/root2",
4029 json!({
4030 "d": {
4031 "4": ""
4032 },
4033 "e": {}
4034 }),
4035 )
4036 .await;
4037
4038 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4039 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4040 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4041 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4042 assert_eq!(
4043 visible_entries_as_strings(&panel, 0..50, cx),
4044 &[
4045 "v root1",
4046 " > a",
4047 " > b",
4048 " > C",
4049 " .dockerignore",
4050 "v root2",
4051 " > d",
4052 " > e",
4053 ]
4054 );
4055
4056 toggle_expand_dir(&panel, "root1/b", cx);
4057 assert_eq!(
4058 visible_entries_as_strings(&panel, 0..50, cx),
4059 &[
4060 "v root1",
4061 " > a",
4062 " v b <== selected",
4063 " > 3",
4064 " > C",
4065 " .dockerignore",
4066 "v root2",
4067 " > d",
4068 " > e",
4069 ]
4070 );
4071
4072 toggle_expand_dir(&panel, "root2/d", cx);
4073 assert_eq!(
4074 visible_entries_as_strings(&panel, 0..50, cx),
4075 &[
4076 "v root1",
4077 " > a",
4078 " v b",
4079 " > 3",
4080 " > C",
4081 " .dockerignore",
4082 "v root2",
4083 " v d <== selected",
4084 " > e",
4085 ]
4086 );
4087
4088 toggle_expand_dir(&panel, "root2/e", cx);
4089 assert_eq!(
4090 visible_entries_as_strings(&panel, 0..50, cx),
4091 &[
4092 "v root1",
4093 " > a",
4094 " v b",
4095 " > 3",
4096 " > C",
4097 " .dockerignore",
4098 "v root2",
4099 " v d",
4100 " v e <== selected",
4101 ]
4102 );
4103 }
4104
4105 #[gpui::test]
4106 async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
4107 init_test(cx);
4108
4109 let fs = FakeFs::new(cx.executor().clone());
4110 fs.insert_tree(
4111 "/root1",
4112 json!({
4113 "dir_1": {
4114 "nested_dir_1": {
4115 "nested_dir_2": {
4116 "nested_dir_3": {
4117 "file_a.java": "// File contents",
4118 "file_b.java": "// File contents",
4119 "file_c.java": "// File contents",
4120 "nested_dir_4": {
4121 "nested_dir_5": {
4122 "file_d.java": "// File contents",
4123 }
4124 }
4125 }
4126 }
4127 }
4128 }
4129 }),
4130 )
4131 .await;
4132 fs.insert_tree(
4133 "/root2",
4134 json!({
4135 "dir_2": {
4136 "file_1.java": "// File contents",
4137 }
4138 }),
4139 )
4140 .await;
4141
4142 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4143 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4144 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4145 cx.update(|cx| {
4146 let settings = *ProjectPanelSettings::get_global(cx);
4147 ProjectPanelSettings::override_global(
4148 ProjectPanelSettings {
4149 auto_fold_dirs: true,
4150 ..settings
4151 },
4152 cx,
4153 );
4154 });
4155 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4156 assert_eq!(
4157 visible_entries_as_strings(&panel, 0..10, cx),
4158 &[
4159 "v root1",
4160 " > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4161 "v root2",
4162 " > dir_2",
4163 ]
4164 );
4165
4166 toggle_expand_dir(
4167 &panel,
4168 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4169 cx,
4170 );
4171 assert_eq!(
4172 visible_entries_as_strings(&panel, 0..10, cx),
4173 &[
4174 "v root1",
4175 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
4176 " > nested_dir_4/nested_dir_5",
4177 " file_a.java",
4178 " file_b.java",
4179 " file_c.java",
4180 "v root2",
4181 " > dir_2",
4182 ]
4183 );
4184
4185 toggle_expand_dir(
4186 &panel,
4187 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
4188 cx,
4189 );
4190 assert_eq!(
4191 visible_entries_as_strings(&panel, 0..10, cx),
4192 &[
4193 "v root1",
4194 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4195 " v nested_dir_4/nested_dir_5 <== selected",
4196 " file_d.java",
4197 " file_a.java",
4198 " file_b.java",
4199 " file_c.java",
4200 "v root2",
4201 " > dir_2",
4202 ]
4203 );
4204 toggle_expand_dir(&panel, "root2/dir_2", cx);
4205 assert_eq!(
4206 visible_entries_as_strings(&panel, 0..10, cx),
4207 &[
4208 "v root1",
4209 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4210 " v nested_dir_4/nested_dir_5",
4211 " file_d.java",
4212 " file_a.java",
4213 " file_b.java",
4214 " file_c.java",
4215 "v root2",
4216 " v dir_2 <== selected",
4217 " file_1.java",
4218 ]
4219 );
4220 }
4221
4222 #[gpui::test(iterations = 30)]
4223 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
4224 init_test(cx);
4225
4226 let fs = FakeFs::new(cx.executor().clone());
4227 fs.insert_tree(
4228 "/root1",
4229 json!({
4230 ".dockerignore": "",
4231 ".git": {
4232 "HEAD": "",
4233 },
4234 "a": {
4235 "0": { "q": "", "r": "", "s": "" },
4236 "1": { "t": "", "u": "" },
4237 "2": { "v": "", "w": "", "x": "", "y": "" },
4238 },
4239 "b": {
4240 "3": { "Q": "" },
4241 "4": { "R": "", "S": "", "T": "", "U": "" },
4242 },
4243 "C": {
4244 "5": {},
4245 "6": { "V": "", "W": "" },
4246 "7": { "X": "" },
4247 "8": { "Y": {}, "Z": "" }
4248 }
4249 }),
4250 )
4251 .await;
4252 fs.insert_tree(
4253 "/root2",
4254 json!({
4255 "d": {
4256 "9": ""
4257 },
4258 "e": {}
4259 }),
4260 )
4261 .await;
4262
4263 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4264 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4265 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4266 let panel = workspace
4267 .update(cx, |workspace, cx| {
4268 let panel = ProjectPanel::new(workspace, cx);
4269 workspace.add_panel(panel.clone(), cx);
4270 panel
4271 })
4272 .unwrap();
4273
4274 select_path(&panel, "root1", cx);
4275 assert_eq!(
4276 visible_entries_as_strings(&panel, 0..10, cx),
4277 &[
4278 "v root1 <== selected",
4279 " > .git",
4280 " > a",
4281 " > b",
4282 " > C",
4283 " .dockerignore",
4284 "v root2",
4285 " > d",
4286 " > e",
4287 ]
4288 );
4289
4290 // Add a file with the root folder selected. The filename editor is placed
4291 // before the first file in the root folder.
4292 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4293 panel.update(cx, |panel, cx| {
4294 assert!(panel.filename_editor.read(cx).is_focused(cx));
4295 });
4296 assert_eq!(
4297 visible_entries_as_strings(&panel, 0..10, cx),
4298 &[
4299 "v root1",
4300 " > .git",
4301 " > a",
4302 " > b",
4303 " > C",
4304 " [EDITOR: ''] <== selected",
4305 " .dockerignore",
4306 "v root2",
4307 " > d",
4308 " > e",
4309 ]
4310 );
4311
4312 let confirm = panel.update(cx, |panel, cx| {
4313 panel
4314 .filename_editor
4315 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
4316 panel.confirm_edit(cx).unwrap()
4317 });
4318 assert_eq!(
4319 visible_entries_as_strings(&panel, 0..10, cx),
4320 &[
4321 "v root1",
4322 " > .git",
4323 " > a",
4324 " > b",
4325 " > C",
4326 " [PROCESSING: 'the-new-filename'] <== selected",
4327 " .dockerignore",
4328 "v root2",
4329 " > d",
4330 " > e",
4331 ]
4332 );
4333
4334 confirm.await.unwrap();
4335 assert_eq!(
4336 visible_entries_as_strings(&panel, 0..10, cx),
4337 &[
4338 "v root1",
4339 " > .git",
4340 " > a",
4341 " > b",
4342 " > C",
4343 " .dockerignore",
4344 " the-new-filename <== selected <== marked",
4345 "v root2",
4346 " > d",
4347 " > e",
4348 ]
4349 );
4350
4351 select_path(&panel, "root1/b", cx);
4352 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4353 assert_eq!(
4354 visible_entries_as_strings(&panel, 0..10, cx),
4355 &[
4356 "v root1",
4357 " > .git",
4358 " > a",
4359 " v b",
4360 " > 3",
4361 " > 4",
4362 " [EDITOR: ''] <== selected",
4363 " > C",
4364 " .dockerignore",
4365 " the-new-filename",
4366 ]
4367 );
4368
4369 panel
4370 .update(cx, |panel, cx| {
4371 panel
4372 .filename_editor
4373 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
4374 panel.confirm_edit(cx).unwrap()
4375 })
4376 .await
4377 .unwrap();
4378 assert_eq!(
4379 visible_entries_as_strings(&panel, 0..10, cx),
4380 &[
4381 "v root1",
4382 " > .git",
4383 " > a",
4384 " v b",
4385 " > 3",
4386 " > 4",
4387 " another-filename.txt <== selected <== marked",
4388 " > C",
4389 " .dockerignore",
4390 " the-new-filename",
4391 ]
4392 );
4393
4394 select_path(&panel, "root1/b/another-filename.txt", cx);
4395 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4396 assert_eq!(
4397 visible_entries_as_strings(&panel, 0..10, cx),
4398 &[
4399 "v root1",
4400 " > .git",
4401 " > a",
4402 " v b",
4403 " > 3",
4404 " > 4",
4405 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
4406 " > C",
4407 " .dockerignore",
4408 " the-new-filename",
4409 ]
4410 );
4411
4412 let confirm = panel.update(cx, |panel, cx| {
4413 panel.filename_editor.update(cx, |editor, cx| {
4414 let file_name_selections = editor.selections.all::<usize>(cx);
4415 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
4416 let file_name_selection = &file_name_selections[0];
4417 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
4418 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
4419
4420 editor.set_text("a-different-filename.tar.gz", cx)
4421 });
4422 panel.confirm_edit(cx).unwrap()
4423 });
4424 assert_eq!(
4425 visible_entries_as_strings(&panel, 0..10, cx),
4426 &[
4427 "v root1",
4428 " > .git",
4429 " > a",
4430 " v b",
4431 " > 3",
4432 " > 4",
4433 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
4434 " > C",
4435 " .dockerignore",
4436 " the-new-filename",
4437 ]
4438 );
4439
4440 confirm.await.unwrap();
4441 assert_eq!(
4442 visible_entries_as_strings(&panel, 0..10, cx),
4443 &[
4444 "v root1",
4445 " > .git",
4446 " > a",
4447 " v b",
4448 " > 3",
4449 " > 4",
4450 " a-different-filename.tar.gz <== selected",
4451 " > C",
4452 " .dockerignore",
4453 " the-new-filename",
4454 ]
4455 );
4456
4457 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4458 assert_eq!(
4459 visible_entries_as_strings(&panel, 0..10, cx),
4460 &[
4461 "v root1",
4462 " > .git",
4463 " > a",
4464 " v b",
4465 " > 3",
4466 " > 4",
4467 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
4468 " > C",
4469 " .dockerignore",
4470 " the-new-filename",
4471 ]
4472 );
4473
4474 panel.update(cx, |panel, cx| {
4475 panel.filename_editor.update(cx, |editor, cx| {
4476 let file_name_selections = editor.selections.all::<usize>(cx);
4477 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
4478 let file_name_selection = &file_name_selections[0];
4479 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
4480 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..");
4481
4482 });
4483 panel.cancel(&menu::Cancel, cx)
4484 });
4485
4486 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4487 assert_eq!(
4488 visible_entries_as_strings(&panel, 0..10, cx),
4489 &[
4490 "v root1",
4491 " > .git",
4492 " > a",
4493 " v b",
4494 " > 3",
4495 " > 4",
4496 " > [EDITOR: ''] <== selected",
4497 " a-different-filename.tar.gz",
4498 " > C",
4499 " .dockerignore",
4500 ]
4501 );
4502
4503 let confirm = panel.update(cx, |panel, cx| {
4504 panel
4505 .filename_editor
4506 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
4507 panel.confirm_edit(cx).unwrap()
4508 });
4509 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
4510 assert_eq!(
4511 visible_entries_as_strings(&panel, 0..10, cx),
4512 &[
4513 "v root1",
4514 " > .git",
4515 " > a",
4516 " v b",
4517 " > 3",
4518 " > 4",
4519 " > [PROCESSING: 'new-dir']",
4520 " a-different-filename.tar.gz <== selected",
4521 " > C",
4522 " .dockerignore",
4523 ]
4524 );
4525
4526 confirm.await.unwrap();
4527 assert_eq!(
4528 visible_entries_as_strings(&panel, 0..10, cx),
4529 &[
4530 "v root1",
4531 " > .git",
4532 " > a",
4533 " v b",
4534 " > 3",
4535 " > 4",
4536 " > new-dir",
4537 " a-different-filename.tar.gz <== selected",
4538 " > C",
4539 " .dockerignore",
4540 ]
4541 );
4542
4543 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
4544 assert_eq!(
4545 visible_entries_as_strings(&panel, 0..10, cx),
4546 &[
4547 "v root1",
4548 " > .git",
4549 " > a",
4550 " v b",
4551 " > 3",
4552 " > 4",
4553 " > new-dir",
4554 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
4555 " > C",
4556 " .dockerignore",
4557 ]
4558 );
4559
4560 // Dismiss the rename editor when it loses focus.
4561 workspace.update(cx, |_, cx| cx.blur()).unwrap();
4562 assert_eq!(
4563 visible_entries_as_strings(&panel, 0..10, cx),
4564 &[
4565 "v root1",
4566 " > .git",
4567 " > a",
4568 " v b",
4569 " > 3",
4570 " > 4",
4571 " > new-dir",
4572 " a-different-filename.tar.gz <== selected",
4573 " > C",
4574 " .dockerignore",
4575 ]
4576 );
4577 }
4578
4579 #[gpui::test(iterations = 10)]
4580 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
4581 init_test(cx);
4582
4583 let fs = FakeFs::new(cx.executor().clone());
4584 fs.insert_tree(
4585 "/root1",
4586 json!({
4587 ".dockerignore": "",
4588 ".git": {
4589 "HEAD": "",
4590 },
4591 "a": {
4592 "0": { "q": "", "r": "", "s": "" },
4593 "1": { "t": "", "u": "" },
4594 "2": { "v": "", "w": "", "x": "", "y": "" },
4595 },
4596 "b": {
4597 "3": { "Q": "" },
4598 "4": { "R": "", "S": "", "T": "", "U": "" },
4599 },
4600 "C": {
4601 "5": {},
4602 "6": { "V": "", "W": "" },
4603 "7": { "X": "" },
4604 "8": { "Y": {}, "Z": "" }
4605 }
4606 }),
4607 )
4608 .await;
4609 fs.insert_tree(
4610 "/root2",
4611 json!({
4612 "d": {
4613 "9": ""
4614 },
4615 "e": {}
4616 }),
4617 )
4618 .await;
4619
4620 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4621 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4622 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4623 let panel = workspace
4624 .update(cx, |workspace, cx| {
4625 let panel = ProjectPanel::new(workspace, cx);
4626 workspace.add_panel(panel.clone(), cx);
4627 panel
4628 })
4629 .unwrap();
4630
4631 select_path(&panel, "root1", cx);
4632 assert_eq!(
4633 visible_entries_as_strings(&panel, 0..10, cx),
4634 &[
4635 "v root1 <== selected",
4636 " > .git",
4637 " > a",
4638 " > b",
4639 " > C",
4640 " .dockerignore",
4641 "v root2",
4642 " > d",
4643 " > e",
4644 ]
4645 );
4646
4647 // Add a file with the root folder selected. The filename editor is placed
4648 // before the first file in the root folder.
4649 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4650 panel.update(cx, |panel, cx| {
4651 assert!(panel.filename_editor.read(cx).is_focused(cx));
4652 });
4653 assert_eq!(
4654 visible_entries_as_strings(&panel, 0..10, cx),
4655 &[
4656 "v root1",
4657 " > .git",
4658 " > a",
4659 " > b",
4660 " > C",
4661 " [EDITOR: ''] <== selected",
4662 " .dockerignore",
4663 "v root2",
4664 " > d",
4665 " > e",
4666 ]
4667 );
4668
4669 let confirm = panel.update(cx, |panel, cx| {
4670 panel.filename_editor.update(cx, |editor, cx| {
4671 editor.set_text("/bdir1/dir2/the-new-filename", cx)
4672 });
4673 panel.confirm_edit(cx).unwrap()
4674 });
4675
4676 assert_eq!(
4677 visible_entries_as_strings(&panel, 0..10, cx),
4678 &[
4679 "v root1",
4680 " > .git",
4681 " > a",
4682 " > b",
4683 " > C",
4684 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
4685 " .dockerignore",
4686 "v root2",
4687 " > d",
4688 " > e",
4689 ]
4690 );
4691
4692 confirm.await.unwrap();
4693 assert_eq!(
4694 visible_entries_as_strings(&panel, 0..13, cx),
4695 &[
4696 "v root1",
4697 " > .git",
4698 " > a",
4699 " > b",
4700 " v bdir1",
4701 " v dir2",
4702 " the-new-filename <== selected <== marked",
4703 " > C",
4704 " .dockerignore",
4705 "v root2",
4706 " > d",
4707 " > e",
4708 ]
4709 );
4710 }
4711
4712 #[gpui::test]
4713 async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
4714 init_test(cx);
4715
4716 let fs = FakeFs::new(cx.executor().clone());
4717 fs.insert_tree(
4718 "/root1",
4719 json!({
4720 ".dockerignore": "",
4721 ".git": {
4722 "HEAD": "",
4723 },
4724 }),
4725 )
4726 .await;
4727
4728 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4729 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4730 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4731 let panel = workspace
4732 .update(cx, |workspace, cx| {
4733 let panel = ProjectPanel::new(workspace, cx);
4734 workspace.add_panel(panel.clone(), cx);
4735 panel
4736 })
4737 .unwrap();
4738
4739 select_path(&panel, "root1", cx);
4740 assert_eq!(
4741 visible_entries_as_strings(&panel, 0..10, cx),
4742 &["v root1 <== selected", " > .git", " .dockerignore",]
4743 );
4744
4745 // Add a file with the root folder selected. The filename editor is placed
4746 // before the first file in the root folder.
4747 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4748 panel.update(cx, |panel, cx| {
4749 assert!(panel.filename_editor.read(cx).is_focused(cx));
4750 });
4751 assert_eq!(
4752 visible_entries_as_strings(&panel, 0..10, cx),
4753 &[
4754 "v root1",
4755 " > .git",
4756 " [EDITOR: ''] <== selected",
4757 " .dockerignore",
4758 ]
4759 );
4760
4761 let confirm = panel.update(cx, |panel, cx| {
4762 panel
4763 .filename_editor
4764 .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
4765 panel.confirm_edit(cx).unwrap()
4766 });
4767
4768 assert_eq!(
4769 visible_entries_as_strings(&panel, 0..10, cx),
4770 &[
4771 "v root1",
4772 " > .git",
4773 " [PROCESSING: '/new_dir/'] <== selected",
4774 " .dockerignore",
4775 ]
4776 );
4777
4778 confirm.await.unwrap();
4779 assert_eq!(
4780 visible_entries_as_strings(&panel, 0..13, cx),
4781 &[
4782 "v root1",
4783 " > .git",
4784 " v new_dir <== selected",
4785 " .dockerignore",
4786 ]
4787 );
4788 }
4789
4790 #[gpui::test]
4791 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
4792 init_test(cx);
4793
4794 let fs = FakeFs::new(cx.executor().clone());
4795 fs.insert_tree(
4796 "/root1",
4797 json!({
4798 "one.two.txt": "",
4799 "one.txt": ""
4800 }),
4801 )
4802 .await;
4803
4804 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4805 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4806 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4807 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4808
4809 panel.update(cx, |panel, cx| {
4810 panel.select_next(&Default::default(), cx);
4811 panel.select_next(&Default::default(), cx);
4812 });
4813
4814 assert_eq!(
4815 visible_entries_as_strings(&panel, 0..50, cx),
4816 &[
4817 //
4818 "v root1",
4819 " one.txt <== selected",
4820 " one.two.txt",
4821 ]
4822 );
4823
4824 // Regression test - file name is created correctly when
4825 // the copied file's name contains multiple dots.
4826 panel.update(cx, |panel, cx| {
4827 panel.copy(&Default::default(), cx);
4828 panel.paste(&Default::default(), cx);
4829 });
4830 cx.executor().run_until_parked();
4831
4832 assert_eq!(
4833 visible_entries_as_strings(&panel, 0..50, cx),
4834 &[
4835 //
4836 "v root1",
4837 " one.txt",
4838 " one copy.txt <== selected",
4839 " one.two.txt",
4840 ]
4841 );
4842
4843 panel.update(cx, |panel, cx| {
4844 panel.paste(&Default::default(), cx);
4845 });
4846 cx.executor().run_until_parked();
4847
4848 assert_eq!(
4849 visible_entries_as_strings(&panel, 0..50, cx),
4850 &[
4851 //
4852 "v root1",
4853 " one.txt",
4854 " one copy.txt",
4855 " one copy 1.txt <== selected",
4856 " one.two.txt",
4857 ]
4858 );
4859 }
4860
4861 #[gpui::test]
4862 async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
4863 init_test(cx);
4864
4865 let fs = FakeFs::new(cx.executor().clone());
4866 fs.insert_tree(
4867 "/root1",
4868 json!({
4869 "one.txt": "",
4870 "two.txt": "",
4871 "three.txt": "",
4872 "a": {
4873 "0": { "q": "", "r": "", "s": "" },
4874 "1": { "t": "", "u": "" },
4875 "2": { "v": "", "w": "", "x": "", "y": "" },
4876 },
4877 }),
4878 )
4879 .await;
4880
4881 fs.insert_tree(
4882 "/root2",
4883 json!({
4884 "one.txt": "",
4885 "two.txt": "",
4886 "four.txt": "",
4887 "b": {
4888 "3": { "Q": "" },
4889 "4": { "R": "", "S": "", "T": "", "U": "" },
4890 },
4891 }),
4892 )
4893 .await;
4894
4895 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4896 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4897 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4898 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4899
4900 select_path(&panel, "root1/three.txt", cx);
4901 panel.update(cx, |panel, cx| {
4902 panel.cut(&Default::default(), cx);
4903 });
4904
4905 select_path(&panel, "root2/one.txt", cx);
4906 panel.update(cx, |panel, cx| {
4907 panel.select_next(&Default::default(), cx);
4908 panel.paste(&Default::default(), cx);
4909 });
4910 cx.executor().run_until_parked();
4911 assert_eq!(
4912 visible_entries_as_strings(&panel, 0..50, cx),
4913 &[
4914 //
4915 "v root1",
4916 " > a",
4917 " one.txt",
4918 " two.txt",
4919 "v root2",
4920 " > b",
4921 " four.txt",
4922 " one.txt",
4923 " three.txt <== selected",
4924 " two.txt",
4925 ]
4926 );
4927
4928 select_path(&panel, "root1/a", cx);
4929 panel.update(cx, |panel, cx| {
4930 panel.cut(&Default::default(), cx);
4931 });
4932 select_path(&panel, "root2/two.txt", cx);
4933 panel.update(cx, |panel, cx| {
4934 panel.select_next(&Default::default(), cx);
4935 panel.paste(&Default::default(), cx);
4936 });
4937
4938 cx.executor().run_until_parked();
4939 assert_eq!(
4940 visible_entries_as_strings(&panel, 0..50, cx),
4941 &[
4942 //
4943 "v root1",
4944 " one.txt",
4945 " two.txt",
4946 "v root2",
4947 " > a <== selected",
4948 " > b",
4949 " four.txt",
4950 " one.txt",
4951 " three.txt",
4952 " two.txt",
4953 ]
4954 );
4955 }
4956
4957 #[gpui::test]
4958 async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
4959 init_test(cx);
4960
4961 let fs = FakeFs::new(cx.executor().clone());
4962 fs.insert_tree(
4963 "/root1",
4964 json!({
4965 "one.txt": "",
4966 "two.txt": "",
4967 "three.txt": "",
4968 "a": {
4969 "0": { "q": "", "r": "", "s": "" },
4970 "1": { "t": "", "u": "" },
4971 "2": { "v": "", "w": "", "x": "", "y": "" },
4972 },
4973 }),
4974 )
4975 .await;
4976
4977 fs.insert_tree(
4978 "/root2",
4979 json!({
4980 "one.txt": "",
4981 "two.txt": "",
4982 "four.txt": "",
4983 "b": {
4984 "3": { "Q": "" },
4985 "4": { "R": "", "S": "", "T": "", "U": "" },
4986 },
4987 }),
4988 )
4989 .await;
4990
4991 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4992 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4993 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4994 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4995
4996 select_path(&panel, "root1/three.txt", cx);
4997 panel.update(cx, |panel, cx| {
4998 panel.copy(&Default::default(), cx);
4999 });
5000
5001 select_path(&panel, "root2/one.txt", cx);
5002 panel.update(cx, |panel, cx| {
5003 panel.select_next(&Default::default(), cx);
5004 panel.paste(&Default::default(), cx);
5005 });
5006 cx.executor().run_until_parked();
5007 assert_eq!(
5008 visible_entries_as_strings(&panel, 0..50, cx),
5009 &[
5010 //
5011 "v root1",
5012 " > a",
5013 " one.txt",
5014 " three.txt",
5015 " two.txt",
5016 "v root2",
5017 " > b",
5018 " four.txt",
5019 " one.txt",
5020 " three.txt <== selected",
5021 " two.txt",
5022 ]
5023 );
5024
5025 select_path(&panel, "root1/three.txt", cx);
5026 panel.update(cx, |panel, cx| {
5027 panel.copy(&Default::default(), cx);
5028 });
5029 select_path(&panel, "root2/two.txt", cx);
5030 panel.update(cx, |panel, cx| {
5031 panel.select_next(&Default::default(), cx);
5032 panel.paste(&Default::default(), cx);
5033 });
5034
5035 cx.executor().run_until_parked();
5036 assert_eq!(
5037 visible_entries_as_strings(&panel, 0..50, cx),
5038 &[
5039 //
5040 "v root1",
5041 " > a",
5042 " one.txt",
5043 " three.txt",
5044 " two.txt",
5045 "v root2",
5046 " > b",
5047 " four.txt",
5048 " one.txt",
5049 " three.txt",
5050 " three copy.txt <== selected",
5051 " two.txt",
5052 ]
5053 );
5054
5055 select_path(&panel, "root1/a", cx);
5056 panel.update(cx, |panel, cx| {
5057 panel.copy(&Default::default(), cx);
5058 });
5059 select_path(&panel, "root2/two.txt", cx);
5060 panel.update(cx, |panel, cx| {
5061 panel.select_next(&Default::default(), cx);
5062 panel.paste(&Default::default(), cx);
5063 });
5064
5065 cx.executor().run_until_parked();
5066 assert_eq!(
5067 visible_entries_as_strings(&panel, 0..50, cx),
5068 &[
5069 //
5070 "v root1",
5071 " > a",
5072 " one.txt",
5073 " three.txt",
5074 " two.txt",
5075 "v root2",
5076 " > a <== selected",
5077 " > b",
5078 " four.txt",
5079 " one.txt",
5080 " three.txt",
5081 " three copy.txt",
5082 " two.txt",
5083 ]
5084 );
5085 }
5086
5087 #[gpui::test]
5088 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
5089 init_test(cx);
5090
5091 let fs = FakeFs::new(cx.executor().clone());
5092 fs.insert_tree(
5093 "/root",
5094 json!({
5095 "a": {
5096 "one.txt": "",
5097 "two.txt": "",
5098 "inner_dir": {
5099 "three.txt": "",
5100 "four.txt": "",
5101 }
5102 },
5103 "b": {}
5104 }),
5105 )
5106 .await;
5107
5108 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5109 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5110 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5111 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5112
5113 select_path(&panel, "root/a", cx);
5114 panel.update(cx, |panel, cx| {
5115 panel.copy(&Default::default(), cx);
5116 panel.select_next(&Default::default(), cx);
5117 panel.paste(&Default::default(), cx);
5118 });
5119 cx.executor().run_until_parked();
5120
5121 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
5122 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
5123
5124 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
5125 assert_ne!(
5126 pasted_dir_file, None,
5127 "Pasted directory file should have an entry"
5128 );
5129
5130 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
5131 assert_ne!(
5132 pasted_dir_inner_dir, None,
5133 "Directories inside pasted directory should have an entry"
5134 );
5135
5136 toggle_expand_dir(&panel, "root/b/a", cx);
5137 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
5138
5139 assert_eq!(
5140 visible_entries_as_strings(&panel, 0..50, cx),
5141 &[
5142 //
5143 "v root",
5144 " > a",
5145 " v b",
5146 " v a",
5147 " v inner_dir <== selected",
5148 " four.txt",
5149 " three.txt",
5150 " one.txt",
5151 " two.txt",
5152 ]
5153 );
5154
5155 select_path(&panel, "root", cx);
5156 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
5157 cx.executor().run_until_parked();
5158 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
5159 cx.executor().run_until_parked();
5160 assert_eq!(
5161 visible_entries_as_strings(&panel, 0..50, cx),
5162 &[
5163 //
5164 "v root",
5165 " > a",
5166 " v a copy",
5167 " > a <== selected",
5168 " > inner_dir",
5169 " one.txt",
5170 " two.txt",
5171 " v b",
5172 " v a",
5173 " v inner_dir",
5174 " four.txt",
5175 " three.txt",
5176 " one.txt",
5177 " two.txt"
5178 ]
5179 );
5180 }
5181
5182 #[gpui::test]
5183 async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
5184 init_test(cx);
5185
5186 let fs = FakeFs::new(cx.executor().clone());
5187 fs.insert_tree(
5188 "/test",
5189 json!({
5190 "dir1": {
5191 "a.txt": "",
5192 "b.txt": "",
5193 },
5194 "dir2": {},
5195 "c.txt": "",
5196 "d.txt": "",
5197 }),
5198 )
5199 .await;
5200
5201 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
5202 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5203 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5204 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5205
5206 toggle_expand_dir(&panel, "test/dir1", cx);
5207
5208 cx.simulate_modifiers_change(gpui::Modifiers {
5209 control: true,
5210 ..Default::default()
5211 });
5212
5213 select_path_with_mark(&panel, "test/dir1", cx);
5214 select_path_with_mark(&panel, "test/c.txt", cx);
5215
5216 assert_eq!(
5217 visible_entries_as_strings(&panel, 0..15, cx),
5218 &[
5219 "v test",
5220 " v dir1 <== marked",
5221 " a.txt",
5222 " b.txt",
5223 " > dir2",
5224 " c.txt <== selected <== marked",
5225 " d.txt",
5226 ],
5227 "Initial state before copying dir1 and c.txt"
5228 );
5229
5230 panel.update(cx, |panel, cx| {
5231 panel.copy(&Default::default(), cx);
5232 });
5233 select_path(&panel, "test/dir2", cx);
5234 panel.update(cx, |panel, cx| {
5235 panel.paste(&Default::default(), cx);
5236 });
5237 cx.executor().run_until_parked();
5238
5239 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
5240
5241 assert_eq!(
5242 visible_entries_as_strings(&panel, 0..15, cx),
5243 &[
5244 "v test",
5245 " v dir1 <== marked",
5246 " a.txt",
5247 " b.txt",
5248 " v dir2",
5249 " v dir1 <== selected",
5250 " a.txt",
5251 " b.txt",
5252 " c.txt",
5253 " c.txt <== marked",
5254 " d.txt",
5255 ],
5256 "Should copy dir1 as well as c.txt into dir2"
5257 );
5258 }
5259
5260 #[gpui::test]
5261 async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
5262 init_test(cx);
5263
5264 let fs = FakeFs::new(cx.executor().clone());
5265 fs.insert_tree(
5266 "/test",
5267 json!({
5268 "dir1": {
5269 "a.txt": "",
5270 "b.txt": "",
5271 },
5272 "dir2": {},
5273 "c.txt": "",
5274 "d.txt": "",
5275 }),
5276 )
5277 .await;
5278
5279 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
5280 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5281 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5282 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5283
5284 toggle_expand_dir(&panel, "test/dir1", cx);
5285
5286 cx.simulate_modifiers_change(gpui::Modifiers {
5287 control: true,
5288 ..Default::default()
5289 });
5290
5291 select_path_with_mark(&panel, "test/dir1/a.txt", cx);
5292 select_path_with_mark(&panel, "test/dir1", cx);
5293 select_path_with_mark(&panel, "test/c.txt", cx);
5294
5295 assert_eq!(
5296 visible_entries_as_strings(&panel, 0..15, cx),
5297 &[
5298 "v test",
5299 " v dir1 <== marked",
5300 " a.txt <== marked",
5301 " b.txt",
5302 " > dir2",
5303 " c.txt <== selected <== marked",
5304 " d.txt",
5305 ],
5306 "Initial state before copying a.txt, dir1 and c.txt"
5307 );
5308
5309 panel.update(cx, |panel, cx| {
5310 panel.copy(&Default::default(), cx);
5311 });
5312 select_path(&panel, "test/dir2", cx);
5313 panel.update(cx, |panel, cx| {
5314 panel.paste(&Default::default(), cx);
5315 });
5316 cx.executor().run_until_parked();
5317
5318 toggle_expand_dir(&panel, "test/dir2/dir1", cx);
5319
5320 assert_eq!(
5321 visible_entries_as_strings(&panel, 0..20, cx),
5322 &[
5323 "v test",
5324 " v dir1 <== marked",
5325 " a.txt <== marked",
5326 " b.txt",
5327 " v dir2",
5328 " v dir1 <== selected",
5329 " a.txt",
5330 " b.txt",
5331 " c.txt",
5332 " c.txt <== marked",
5333 " d.txt",
5334 ],
5335 "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
5336 );
5337 }
5338
5339 #[gpui::test]
5340 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
5341 init_test_with_editor(cx);
5342
5343 let fs = FakeFs::new(cx.executor().clone());
5344 fs.insert_tree(
5345 "/src",
5346 json!({
5347 "test": {
5348 "first.rs": "// First Rust file",
5349 "second.rs": "// Second Rust file",
5350 "third.rs": "// Third Rust file",
5351 }
5352 }),
5353 )
5354 .await;
5355
5356 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5357 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5358 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5359 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5360
5361 toggle_expand_dir(&panel, "src/test", cx);
5362 select_path(&panel, "src/test/first.rs", cx);
5363 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5364 cx.executor().run_until_parked();
5365 assert_eq!(
5366 visible_entries_as_strings(&panel, 0..10, cx),
5367 &[
5368 "v src",
5369 " v test",
5370 " first.rs <== selected <== marked",
5371 " second.rs",
5372 " third.rs"
5373 ]
5374 );
5375 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
5376
5377 submit_deletion(&panel, cx);
5378 assert_eq!(
5379 visible_entries_as_strings(&panel, 0..10, cx),
5380 &[
5381 "v src",
5382 " v test",
5383 " second.rs <== selected",
5384 " third.rs"
5385 ],
5386 "Project panel should have no deleted file, no other file is selected in it"
5387 );
5388 ensure_no_open_items_and_panes(&workspace, cx);
5389
5390 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5391 cx.executor().run_until_parked();
5392 assert_eq!(
5393 visible_entries_as_strings(&panel, 0..10, cx),
5394 &[
5395 "v src",
5396 " v test",
5397 " second.rs <== selected <== marked",
5398 " third.rs"
5399 ]
5400 );
5401 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
5402
5403 workspace
5404 .update(cx, |workspace, cx| {
5405 let active_items = workspace
5406 .panes()
5407 .iter()
5408 .filter_map(|pane| pane.read(cx).active_item())
5409 .collect::<Vec<_>>();
5410 assert_eq!(active_items.len(), 1);
5411 let open_editor = active_items
5412 .into_iter()
5413 .next()
5414 .unwrap()
5415 .downcast::<Editor>()
5416 .expect("Open item should be an editor");
5417 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
5418 })
5419 .unwrap();
5420 submit_deletion_skipping_prompt(&panel, cx);
5421 assert_eq!(
5422 visible_entries_as_strings(&panel, 0..10, cx),
5423 &["v src", " v test", " third.rs <== selected"],
5424 "Project panel should have no deleted file, with one last file remaining"
5425 );
5426 ensure_no_open_items_and_panes(&workspace, cx);
5427 }
5428
5429 #[gpui::test]
5430 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
5431 init_test_with_editor(cx);
5432
5433 let fs = FakeFs::new(cx.executor().clone());
5434 fs.insert_tree(
5435 "/src",
5436 json!({
5437 "test": {
5438 "first.rs": "// First Rust file",
5439 "second.rs": "// Second Rust file",
5440 "third.rs": "// Third Rust file",
5441 }
5442 }),
5443 )
5444 .await;
5445
5446 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5447 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5448 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5449 let panel = workspace
5450 .update(cx, |workspace, cx| {
5451 let panel = ProjectPanel::new(workspace, cx);
5452 workspace.add_panel(panel.clone(), cx);
5453 panel
5454 })
5455 .unwrap();
5456
5457 select_path(&panel, "src/", cx);
5458 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
5459 cx.executor().run_until_parked();
5460 assert_eq!(
5461 visible_entries_as_strings(&panel, 0..10, cx),
5462 &[
5463 //
5464 "v src <== selected",
5465 " > test"
5466 ]
5467 );
5468 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
5469 panel.update(cx, |panel, cx| {
5470 assert!(panel.filename_editor.read(cx).is_focused(cx));
5471 });
5472 assert_eq!(
5473 visible_entries_as_strings(&panel, 0..10, cx),
5474 &[
5475 //
5476 "v src",
5477 " > [EDITOR: ''] <== selected",
5478 " > test"
5479 ]
5480 );
5481 panel.update(cx, |panel, cx| {
5482 panel
5483 .filename_editor
5484 .update(cx, |editor, cx| editor.set_text("test", cx));
5485 assert!(
5486 panel.confirm_edit(cx).is_none(),
5487 "Should not allow to confirm on conflicting new directory name"
5488 )
5489 });
5490 assert_eq!(
5491 visible_entries_as_strings(&panel, 0..10, cx),
5492 &[
5493 //
5494 "v src",
5495 " > test"
5496 ],
5497 "File list should be unchanged after failed folder create confirmation"
5498 );
5499
5500 select_path(&panel, "src/test/", cx);
5501 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
5502 cx.executor().run_until_parked();
5503 assert_eq!(
5504 visible_entries_as_strings(&panel, 0..10, cx),
5505 &[
5506 //
5507 "v src",
5508 " > test <== selected"
5509 ]
5510 );
5511 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5512 panel.update(cx, |panel, cx| {
5513 assert!(panel.filename_editor.read(cx).is_focused(cx));
5514 });
5515 assert_eq!(
5516 visible_entries_as_strings(&panel, 0..10, cx),
5517 &[
5518 "v src",
5519 " v test",
5520 " [EDITOR: ''] <== selected",
5521 " first.rs",
5522 " second.rs",
5523 " third.rs"
5524 ]
5525 );
5526 panel.update(cx, |panel, cx| {
5527 panel
5528 .filename_editor
5529 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
5530 assert!(
5531 panel.confirm_edit(cx).is_none(),
5532 "Should not allow to confirm on conflicting new file name"
5533 )
5534 });
5535 assert_eq!(
5536 visible_entries_as_strings(&panel, 0..10, cx),
5537 &[
5538 "v src",
5539 " v test",
5540 " first.rs",
5541 " second.rs",
5542 " third.rs"
5543 ],
5544 "File list should be unchanged after failed file create confirmation"
5545 );
5546
5547 select_path(&panel, "src/test/first.rs", cx);
5548 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
5549 cx.executor().run_until_parked();
5550 assert_eq!(
5551 visible_entries_as_strings(&panel, 0..10, cx),
5552 &[
5553 "v src",
5554 " v test",
5555 " first.rs <== selected",
5556 " second.rs",
5557 " third.rs"
5558 ],
5559 );
5560 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
5561 panel.update(cx, |panel, cx| {
5562 assert!(panel.filename_editor.read(cx).is_focused(cx));
5563 });
5564 assert_eq!(
5565 visible_entries_as_strings(&panel, 0..10, cx),
5566 &[
5567 "v src",
5568 " v test",
5569 " [EDITOR: 'first.rs'] <== selected",
5570 " second.rs",
5571 " third.rs"
5572 ]
5573 );
5574 panel.update(cx, |panel, cx| {
5575 panel
5576 .filename_editor
5577 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
5578 assert!(
5579 panel.confirm_edit(cx).is_none(),
5580 "Should not allow to confirm on conflicting file rename"
5581 )
5582 });
5583 assert_eq!(
5584 visible_entries_as_strings(&panel, 0..10, cx),
5585 &[
5586 "v src",
5587 " v test",
5588 " first.rs <== selected",
5589 " second.rs",
5590 " third.rs"
5591 ],
5592 "File list should be unchanged after failed rename confirmation"
5593 );
5594 }
5595
5596 #[gpui::test]
5597 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
5598 init_test_with_editor(cx);
5599
5600 let fs = FakeFs::new(cx.executor().clone());
5601 fs.insert_tree(
5602 "/project_root",
5603 json!({
5604 "dir_1": {
5605 "nested_dir": {
5606 "file_a.py": "# File contents",
5607 }
5608 },
5609 "file_1.py": "# File contents",
5610 }),
5611 )
5612 .await;
5613
5614 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5615 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5616 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5617 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5618
5619 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5620 cx.executor().run_until_parked();
5621 select_path(&panel, "project_root/dir_1", cx);
5622 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5623 select_path(&panel, "project_root/dir_1/nested_dir", cx);
5624 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5625 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5626 cx.executor().run_until_parked();
5627 assert_eq!(
5628 visible_entries_as_strings(&panel, 0..10, cx),
5629 &[
5630 "v project_root",
5631 " v dir_1",
5632 " > nested_dir <== selected",
5633 " file_1.py",
5634 ]
5635 );
5636 }
5637
5638 #[gpui::test]
5639 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
5640 init_test_with_editor(cx);
5641
5642 let fs = FakeFs::new(cx.executor().clone());
5643 fs.insert_tree(
5644 "/project_root",
5645 json!({
5646 "dir_1": {
5647 "nested_dir": {
5648 "file_a.py": "# File contents",
5649 "file_b.py": "# File contents",
5650 "file_c.py": "# File contents",
5651 },
5652 "file_1.py": "# File contents",
5653 "file_2.py": "# File contents",
5654 "file_3.py": "# File contents",
5655 },
5656 "dir_2": {
5657 "file_1.py": "# File contents",
5658 "file_2.py": "# File contents",
5659 "file_3.py": "# File contents",
5660 }
5661 }),
5662 )
5663 .await;
5664
5665 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5666 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5667 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5668 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5669
5670 panel.update(cx, |panel, cx| {
5671 panel.collapse_all_entries(&CollapseAllEntries, cx)
5672 });
5673 cx.executor().run_until_parked();
5674 assert_eq!(
5675 visible_entries_as_strings(&panel, 0..10, cx),
5676 &["v project_root", " > dir_1", " > dir_2",]
5677 );
5678
5679 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
5680 toggle_expand_dir(&panel, "project_root/dir_1", cx);
5681 cx.executor().run_until_parked();
5682 assert_eq!(
5683 visible_entries_as_strings(&panel, 0..10, cx),
5684 &[
5685 "v project_root",
5686 " v dir_1 <== selected",
5687 " > nested_dir",
5688 " file_1.py",
5689 " file_2.py",
5690 " file_3.py",
5691 " > dir_2",
5692 ]
5693 );
5694 }
5695
5696 #[gpui::test]
5697 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
5698 init_test(cx);
5699
5700 let fs = FakeFs::new(cx.executor().clone());
5701 fs.as_fake().insert_tree("/root", json!({})).await;
5702 let project = Project::test(fs, ["/root".as_ref()], cx).await;
5703 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5704 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5705 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5706
5707 // Make a new buffer with no backing file
5708 workspace
5709 .update(cx, |workspace, cx| {
5710 Editor::new_file(workspace, &Default::default(), cx)
5711 })
5712 .unwrap();
5713
5714 cx.executor().run_until_parked();
5715
5716 // "Save as" the buffer, creating a new backing file for it
5717 let save_task = workspace
5718 .update(cx, |workspace, cx| {
5719 workspace.save_active_item(workspace::SaveIntent::Save, cx)
5720 })
5721 .unwrap();
5722
5723 cx.executor().run_until_parked();
5724 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
5725 save_task.await.unwrap();
5726
5727 // Rename the file
5728 select_path(&panel, "root/new", cx);
5729 assert_eq!(
5730 visible_entries_as_strings(&panel, 0..10, cx),
5731 &["v root", " new <== selected"]
5732 );
5733 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
5734 panel.update(cx, |panel, cx| {
5735 panel
5736 .filename_editor
5737 .update(cx, |editor, cx| editor.set_text("newer", cx));
5738 });
5739 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
5740
5741 cx.executor().run_until_parked();
5742 assert_eq!(
5743 visible_entries_as_strings(&panel, 0..10, cx),
5744 &["v root", " newer <== selected"]
5745 );
5746
5747 workspace
5748 .update(cx, |workspace, cx| {
5749 workspace.save_active_item(workspace::SaveIntent::Save, cx)
5750 })
5751 .unwrap()
5752 .await
5753 .unwrap();
5754
5755 cx.executor().run_until_parked();
5756 // assert that saving the file doesn't restore "new"
5757 assert_eq!(
5758 visible_entries_as_strings(&panel, 0..10, cx),
5759 &["v root", " newer <== selected"]
5760 );
5761 }
5762
5763 #[gpui::test]
5764 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
5765 init_test_with_editor(cx);
5766 let fs = FakeFs::new(cx.executor().clone());
5767 fs.insert_tree(
5768 "/project_root",
5769 json!({
5770 "dir_1": {
5771 "nested_dir": {
5772 "file_a.py": "# File contents",
5773 }
5774 },
5775 "file_1.py": "# File contents",
5776 }),
5777 )
5778 .await;
5779
5780 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5781 let worktree_id =
5782 cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
5783 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5784 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5785 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5786 cx.update(|cx| {
5787 panel.update(cx, |this, cx| {
5788 this.select_next(&Default::default(), cx);
5789 this.expand_selected_entry(&Default::default(), cx);
5790 this.expand_selected_entry(&Default::default(), cx);
5791 this.select_next(&Default::default(), cx);
5792 this.expand_selected_entry(&Default::default(), cx);
5793 this.select_next(&Default::default(), cx);
5794 })
5795 });
5796 assert_eq!(
5797 visible_entries_as_strings(&panel, 0..10, cx),
5798 &[
5799 "v project_root",
5800 " v dir_1",
5801 " v nested_dir",
5802 " file_a.py <== selected",
5803 " file_1.py",
5804 ]
5805 );
5806 let modifiers_with_shift = gpui::Modifiers {
5807 shift: true,
5808 ..Default::default()
5809 };
5810 cx.simulate_modifiers_change(modifiers_with_shift);
5811 cx.update(|cx| {
5812 panel.update(cx, |this, cx| {
5813 this.select_next(&Default::default(), cx);
5814 })
5815 });
5816 assert_eq!(
5817 visible_entries_as_strings(&panel, 0..10, cx),
5818 &[
5819 "v project_root",
5820 " v dir_1",
5821 " v nested_dir",
5822 " file_a.py",
5823 " file_1.py <== selected <== marked",
5824 ]
5825 );
5826 cx.update(|cx| {
5827 panel.update(cx, |this, cx| {
5828 this.select_prev(&Default::default(), cx);
5829 })
5830 });
5831 assert_eq!(
5832 visible_entries_as_strings(&panel, 0..10, cx),
5833 &[
5834 "v project_root",
5835 " v dir_1",
5836 " v nested_dir",
5837 " file_a.py <== selected <== marked",
5838 " file_1.py <== marked",
5839 ]
5840 );
5841 cx.update(|cx| {
5842 panel.update(cx, |this, cx| {
5843 let drag = DraggedSelection {
5844 active_selection: this.selection.unwrap(),
5845 marked_selections: Arc::new(this.marked_entries.clone()),
5846 };
5847 let target_entry = this
5848 .project
5849 .read(cx)
5850 .entry_for_path(&(worktree_id, "").into(), cx)
5851 .unwrap();
5852 this.drag_onto(&drag, target_entry.id, false, cx);
5853 });
5854 });
5855 cx.run_until_parked();
5856 assert_eq!(
5857 visible_entries_as_strings(&panel, 0..10, cx),
5858 &[
5859 "v project_root",
5860 " v dir_1",
5861 " v nested_dir",
5862 " file_1.py <== marked",
5863 " file_a.py <== selected <== marked",
5864 ]
5865 );
5866 // ESC clears out all marks
5867 cx.update(|cx| {
5868 panel.update(cx, |this, cx| {
5869 this.cancel(&menu::Cancel, cx);
5870 })
5871 });
5872 assert_eq!(
5873 visible_entries_as_strings(&panel, 0..10, cx),
5874 &[
5875 "v project_root",
5876 " v dir_1",
5877 " v nested_dir",
5878 " file_1.py",
5879 " file_a.py <== selected",
5880 ]
5881 );
5882 // ESC clears out all marks
5883 cx.update(|cx| {
5884 panel.update(cx, |this, cx| {
5885 this.select_prev(&SelectPrev, cx);
5886 this.select_next(&SelectNext, cx);
5887 })
5888 });
5889 assert_eq!(
5890 visible_entries_as_strings(&panel, 0..10, cx),
5891 &[
5892 "v project_root",
5893 " v dir_1",
5894 " v nested_dir",
5895 " file_1.py <== marked",
5896 " file_a.py <== selected <== marked",
5897 ]
5898 );
5899 cx.simulate_modifiers_change(Default::default());
5900 cx.update(|cx| {
5901 panel.update(cx, |this, cx| {
5902 this.cut(&Cut, cx);
5903 this.select_prev(&SelectPrev, cx);
5904 this.select_prev(&SelectPrev, cx);
5905
5906 this.paste(&Paste, cx);
5907 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
5908 })
5909 });
5910 cx.run_until_parked();
5911 assert_eq!(
5912 visible_entries_as_strings(&panel, 0..10, cx),
5913 &[
5914 "v project_root",
5915 " v dir_1",
5916 " v nested_dir",
5917 " file_1.py <== marked",
5918 " file_a.py <== selected <== marked",
5919 ]
5920 );
5921 cx.simulate_modifiers_change(modifiers_with_shift);
5922 cx.update(|cx| {
5923 panel.update(cx, |this, cx| {
5924 this.expand_selected_entry(&Default::default(), cx);
5925 this.select_next(&SelectNext, cx);
5926 this.select_next(&SelectNext, cx);
5927 })
5928 });
5929 submit_deletion(&panel, cx);
5930 assert_eq!(
5931 visible_entries_as_strings(&panel, 0..10, cx),
5932 &[
5933 "v project_root",
5934 " v dir_1",
5935 " v nested_dir <== selected",
5936 ]
5937 );
5938 }
5939 #[gpui::test]
5940 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
5941 init_test_with_editor(cx);
5942 cx.update(|cx| {
5943 cx.update_global::<SettingsStore, _>(|store, cx| {
5944 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
5945 worktree_settings.file_scan_exclusions = Some(Vec::new());
5946 });
5947 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5948 project_panel_settings.auto_reveal_entries = Some(false)
5949 });
5950 })
5951 });
5952
5953 let fs = FakeFs::new(cx.background_executor.clone());
5954 fs.insert_tree(
5955 "/project_root",
5956 json!({
5957 ".git": {},
5958 ".gitignore": "**/gitignored_dir",
5959 "dir_1": {
5960 "file_1.py": "# File 1_1 contents",
5961 "file_2.py": "# File 1_2 contents",
5962 "file_3.py": "# File 1_3 contents",
5963 "gitignored_dir": {
5964 "file_a.py": "# File contents",
5965 "file_b.py": "# File contents",
5966 "file_c.py": "# File contents",
5967 },
5968 },
5969 "dir_2": {
5970 "file_1.py": "# File 2_1 contents",
5971 "file_2.py": "# File 2_2 contents",
5972 "file_3.py": "# File 2_3 contents",
5973 }
5974 }),
5975 )
5976 .await;
5977
5978 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5979 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5980 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5981 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5982
5983 assert_eq!(
5984 visible_entries_as_strings(&panel, 0..20, cx),
5985 &[
5986 "v project_root",
5987 " > .git",
5988 " > dir_1",
5989 " > dir_2",
5990 " .gitignore",
5991 ]
5992 );
5993
5994 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
5995 .expect("dir 1 file is not ignored and should have an entry");
5996 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
5997 .expect("dir 2 file is not ignored and should have an entry");
5998 let gitignored_dir_file =
5999 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
6000 assert_eq!(
6001 gitignored_dir_file, None,
6002 "File in the gitignored dir should not have an entry before its dir is toggled"
6003 );
6004
6005 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6006 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6007 cx.executor().run_until_parked();
6008 assert_eq!(
6009 visible_entries_as_strings(&panel, 0..20, cx),
6010 &[
6011 "v project_root",
6012 " > .git",
6013 " v dir_1",
6014 " v gitignored_dir <== selected",
6015 " file_a.py",
6016 " file_b.py",
6017 " file_c.py",
6018 " file_1.py",
6019 " file_2.py",
6020 " file_3.py",
6021 " > dir_2",
6022 " .gitignore",
6023 ],
6024 "Should show gitignored dir file list in the project panel"
6025 );
6026 let gitignored_dir_file =
6027 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
6028 .expect("after gitignored dir got opened, a file entry should be present");
6029
6030 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6031 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6032 assert_eq!(
6033 visible_entries_as_strings(&panel, 0..20, cx),
6034 &[
6035 "v project_root",
6036 " > .git",
6037 " > dir_1 <== selected",
6038 " > dir_2",
6039 " .gitignore",
6040 ],
6041 "Should hide all dir contents again and prepare for the auto reveal test"
6042 );
6043
6044 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
6045 panel.update(cx, |panel, cx| {
6046 panel.project.update(cx, |_, cx| {
6047 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
6048 })
6049 });
6050 cx.run_until_parked();
6051 assert_eq!(
6052 visible_entries_as_strings(&panel, 0..20, cx),
6053 &[
6054 "v project_root",
6055 " > .git",
6056 " > dir_1 <== selected",
6057 " > dir_2",
6058 " .gitignore",
6059 ],
6060 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
6061 );
6062 }
6063
6064 cx.update(|cx| {
6065 cx.update_global::<SettingsStore, _>(|store, cx| {
6066 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6067 project_panel_settings.auto_reveal_entries = Some(true)
6068 });
6069 })
6070 });
6071
6072 panel.update(cx, |panel, cx| {
6073 panel.project.update(cx, |_, cx| {
6074 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
6075 })
6076 });
6077 cx.run_until_parked();
6078 assert_eq!(
6079 visible_entries_as_strings(&panel, 0..20, cx),
6080 &[
6081 "v project_root",
6082 " > .git",
6083 " v dir_1",
6084 " > gitignored_dir",
6085 " file_1.py <== selected",
6086 " file_2.py",
6087 " file_3.py",
6088 " > dir_2",
6089 " .gitignore",
6090 ],
6091 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
6092 );
6093
6094 panel.update(cx, |panel, cx| {
6095 panel.project.update(cx, |_, cx| {
6096 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
6097 })
6098 });
6099 cx.run_until_parked();
6100 assert_eq!(
6101 visible_entries_as_strings(&panel, 0..20, cx),
6102 &[
6103 "v project_root",
6104 " > .git",
6105 " v dir_1",
6106 " > gitignored_dir",
6107 " file_1.py",
6108 " file_2.py",
6109 " file_3.py",
6110 " v dir_2",
6111 " file_1.py <== selected",
6112 " file_2.py",
6113 " file_3.py",
6114 " .gitignore",
6115 ],
6116 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
6117 );
6118
6119 panel.update(cx, |panel, cx| {
6120 panel.project.update(cx, |_, cx| {
6121 cx.emit(project::Event::ActiveEntryChanged(Some(
6122 gitignored_dir_file,
6123 )))
6124 })
6125 });
6126 cx.run_until_parked();
6127 assert_eq!(
6128 visible_entries_as_strings(&panel, 0..20, cx),
6129 &[
6130 "v project_root",
6131 " > .git",
6132 " v dir_1",
6133 " > gitignored_dir",
6134 " file_1.py",
6135 " file_2.py",
6136 " file_3.py",
6137 " v dir_2",
6138 " file_1.py <== selected",
6139 " file_2.py",
6140 " file_3.py",
6141 " .gitignore",
6142 ],
6143 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
6144 );
6145
6146 panel.update(cx, |panel, cx| {
6147 panel.project.update(cx, |_, cx| {
6148 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
6149 })
6150 });
6151 cx.run_until_parked();
6152 assert_eq!(
6153 visible_entries_as_strings(&panel, 0..20, cx),
6154 &[
6155 "v project_root",
6156 " > .git",
6157 " v dir_1",
6158 " v gitignored_dir",
6159 " file_a.py <== selected",
6160 " file_b.py",
6161 " file_c.py",
6162 " file_1.py",
6163 " file_2.py",
6164 " file_3.py",
6165 " v dir_2",
6166 " file_1.py",
6167 " file_2.py",
6168 " file_3.py",
6169 " .gitignore",
6170 ],
6171 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
6172 );
6173 }
6174
6175 #[gpui::test]
6176 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
6177 init_test_with_editor(cx);
6178 cx.update(|cx| {
6179 cx.update_global::<SettingsStore, _>(|store, cx| {
6180 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6181 worktree_settings.file_scan_exclusions = Some(Vec::new());
6182 });
6183 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6184 project_panel_settings.auto_reveal_entries = Some(false)
6185 });
6186 })
6187 });
6188
6189 let fs = FakeFs::new(cx.background_executor.clone());
6190 fs.insert_tree(
6191 "/project_root",
6192 json!({
6193 ".git": {},
6194 ".gitignore": "**/gitignored_dir",
6195 "dir_1": {
6196 "file_1.py": "# File 1_1 contents",
6197 "file_2.py": "# File 1_2 contents",
6198 "file_3.py": "# File 1_3 contents",
6199 "gitignored_dir": {
6200 "file_a.py": "# File contents",
6201 "file_b.py": "# File contents",
6202 "file_c.py": "# File contents",
6203 },
6204 },
6205 "dir_2": {
6206 "file_1.py": "# File 2_1 contents",
6207 "file_2.py": "# File 2_2 contents",
6208 "file_3.py": "# File 2_3 contents",
6209 }
6210 }),
6211 )
6212 .await;
6213
6214 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6215 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6216 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6217 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6218
6219 assert_eq!(
6220 visible_entries_as_strings(&panel, 0..20, cx),
6221 &[
6222 "v project_root",
6223 " > .git",
6224 " > dir_1",
6225 " > dir_2",
6226 " .gitignore",
6227 ]
6228 );
6229
6230 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
6231 .expect("dir 1 file is not ignored and should have an entry");
6232 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
6233 .expect("dir 2 file is not ignored and should have an entry");
6234 let gitignored_dir_file =
6235 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
6236 assert_eq!(
6237 gitignored_dir_file, None,
6238 "File in the gitignored dir should not have an entry before its dir is toggled"
6239 );
6240
6241 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6242 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6243 cx.run_until_parked();
6244 assert_eq!(
6245 visible_entries_as_strings(&panel, 0..20, cx),
6246 &[
6247 "v project_root",
6248 " > .git",
6249 " v dir_1",
6250 " v gitignored_dir <== selected",
6251 " file_a.py",
6252 " file_b.py",
6253 " file_c.py",
6254 " file_1.py",
6255 " file_2.py",
6256 " file_3.py",
6257 " > dir_2",
6258 " .gitignore",
6259 ],
6260 "Should show gitignored dir file list in the project panel"
6261 );
6262 let gitignored_dir_file =
6263 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
6264 .expect("after gitignored dir got opened, a file entry should be present");
6265
6266 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6267 toggle_expand_dir(&panel, "project_root/dir_1", cx);
6268 assert_eq!(
6269 visible_entries_as_strings(&panel, 0..20, cx),
6270 &[
6271 "v project_root",
6272 " > .git",
6273 " > dir_1 <== selected",
6274 " > dir_2",
6275 " .gitignore",
6276 ],
6277 "Should hide all dir contents again and prepare for the explicit reveal test"
6278 );
6279
6280 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
6281 panel.update(cx, |panel, cx| {
6282 panel.project.update(cx, |_, cx| {
6283 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
6284 })
6285 });
6286 cx.run_until_parked();
6287 assert_eq!(
6288 visible_entries_as_strings(&panel, 0..20, cx),
6289 &[
6290 "v project_root",
6291 " > .git",
6292 " > dir_1 <== selected",
6293 " > dir_2",
6294 " .gitignore",
6295 ],
6296 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
6297 );
6298 }
6299
6300 panel.update(cx, |panel, cx| {
6301 panel.project.update(cx, |_, cx| {
6302 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
6303 })
6304 });
6305 cx.run_until_parked();
6306 assert_eq!(
6307 visible_entries_as_strings(&panel, 0..20, cx),
6308 &[
6309 "v project_root",
6310 " > .git",
6311 " v dir_1",
6312 " > gitignored_dir",
6313 " file_1.py <== selected",
6314 " file_2.py",
6315 " file_3.py",
6316 " > dir_2",
6317 " .gitignore",
6318 ],
6319 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
6320 );
6321
6322 panel.update(cx, |panel, cx| {
6323 panel.project.update(cx, |_, cx| {
6324 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
6325 })
6326 });
6327 cx.run_until_parked();
6328 assert_eq!(
6329 visible_entries_as_strings(&panel, 0..20, cx),
6330 &[
6331 "v project_root",
6332 " > .git",
6333 " v dir_1",
6334 " > gitignored_dir",
6335 " file_1.py",
6336 " file_2.py",
6337 " file_3.py",
6338 " v dir_2",
6339 " file_1.py <== selected",
6340 " file_2.py",
6341 " file_3.py",
6342 " .gitignore",
6343 ],
6344 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
6345 );
6346
6347 panel.update(cx, |panel, cx| {
6348 panel.project.update(cx, |_, cx| {
6349 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
6350 })
6351 });
6352 cx.run_until_parked();
6353 assert_eq!(
6354 visible_entries_as_strings(&panel, 0..20, cx),
6355 &[
6356 "v project_root",
6357 " > .git",
6358 " v dir_1",
6359 " v gitignored_dir",
6360 " file_a.py <== selected",
6361 " file_b.py",
6362 " file_c.py",
6363 " file_1.py",
6364 " file_2.py",
6365 " file_3.py",
6366 " v dir_2",
6367 " file_1.py",
6368 " file_2.py",
6369 " file_3.py",
6370 " .gitignore",
6371 ],
6372 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
6373 );
6374 }
6375
6376 #[gpui::test]
6377 async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
6378 init_test(cx);
6379 cx.update(|cx| {
6380 cx.update_global::<SettingsStore, _>(|store, cx| {
6381 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
6382 project_settings.file_scan_exclusions =
6383 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
6384 });
6385 });
6386 });
6387
6388 cx.update(|cx| {
6389 register_project_item::<TestProjectItemView>(cx);
6390 });
6391
6392 let fs = FakeFs::new(cx.executor().clone());
6393 fs.insert_tree(
6394 "/root1",
6395 json!({
6396 ".dockerignore": "",
6397 ".git": {
6398 "HEAD": "",
6399 },
6400 }),
6401 )
6402 .await;
6403
6404 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
6405 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6406 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6407 let panel = workspace
6408 .update(cx, |workspace, cx| {
6409 let panel = ProjectPanel::new(workspace, cx);
6410 workspace.add_panel(panel.clone(), cx);
6411 panel
6412 })
6413 .unwrap();
6414
6415 select_path(&panel, "root1", cx);
6416 assert_eq!(
6417 visible_entries_as_strings(&panel, 0..10, cx),
6418 &["v root1 <== selected", " .dockerignore",]
6419 );
6420 workspace
6421 .update(cx, |workspace, cx| {
6422 assert!(
6423 workspace.active_item(cx).is_none(),
6424 "Should have no active items in the beginning"
6425 );
6426 })
6427 .unwrap();
6428
6429 let excluded_file_path = ".git/COMMIT_EDITMSG";
6430 let excluded_dir_path = "excluded_dir";
6431
6432 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
6433 panel.update(cx, |panel, cx| {
6434 assert!(panel.filename_editor.read(cx).is_focused(cx));
6435 });
6436 panel
6437 .update(cx, |panel, cx| {
6438 panel
6439 .filename_editor
6440 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
6441 panel.confirm_edit(cx).unwrap()
6442 })
6443 .await
6444 .unwrap();
6445
6446 assert_eq!(
6447 visible_entries_as_strings(&panel, 0..13, cx),
6448 &["v root1", " .dockerignore"],
6449 "Excluded dir should not be shown after opening a file in it"
6450 );
6451 panel.update(cx, |panel, cx| {
6452 assert!(
6453 !panel.filename_editor.read(cx).is_focused(cx),
6454 "Should have closed the file name editor"
6455 );
6456 });
6457 workspace
6458 .update(cx, |workspace, cx| {
6459 let active_entry_path = workspace
6460 .active_item(cx)
6461 .expect("should have opened and activated the excluded item")
6462 .act_as::<TestProjectItemView>(cx)
6463 .expect(
6464 "should have opened the corresponding project item for the excluded item",
6465 )
6466 .read(cx)
6467 .path
6468 .clone();
6469 assert_eq!(
6470 active_entry_path.path.as_ref(),
6471 Path::new(excluded_file_path),
6472 "Should open the excluded file"
6473 );
6474
6475 assert!(
6476 workspace.notification_ids().is_empty(),
6477 "Should have no notifications after opening an excluded file"
6478 );
6479 })
6480 .unwrap();
6481 assert!(
6482 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
6483 "Should have created the excluded file"
6484 );
6485
6486 select_path(&panel, "root1", cx);
6487 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
6488 panel.update(cx, |panel, cx| {
6489 assert!(panel.filename_editor.read(cx).is_focused(cx));
6490 });
6491 panel
6492 .update(cx, |panel, cx| {
6493 panel
6494 .filename_editor
6495 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
6496 panel.confirm_edit(cx).unwrap()
6497 })
6498 .await
6499 .unwrap();
6500
6501 assert_eq!(
6502 visible_entries_as_strings(&panel, 0..13, cx),
6503 &["v root1", " .dockerignore"],
6504 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
6505 );
6506 panel.update(cx, |panel, cx| {
6507 assert!(
6508 !panel.filename_editor.read(cx).is_focused(cx),
6509 "Should have closed the file name editor"
6510 );
6511 });
6512 workspace
6513 .update(cx, |workspace, cx| {
6514 let notifications = workspace.notification_ids();
6515 assert_eq!(
6516 notifications.len(),
6517 1,
6518 "Should receive one notification with the error message"
6519 );
6520 workspace.dismiss_notification(notifications.first().unwrap(), cx);
6521 assert!(workspace.notification_ids().is_empty());
6522 })
6523 .unwrap();
6524
6525 select_path(&panel, "root1", cx);
6526 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
6527 panel.update(cx, |panel, cx| {
6528 assert!(panel.filename_editor.read(cx).is_focused(cx));
6529 });
6530 panel
6531 .update(cx, |panel, cx| {
6532 panel
6533 .filename_editor
6534 .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
6535 panel.confirm_edit(cx).unwrap()
6536 })
6537 .await
6538 .unwrap();
6539
6540 assert_eq!(
6541 visible_entries_as_strings(&panel, 0..13, cx),
6542 &["v root1", " .dockerignore"],
6543 "Should not change the project panel after trying to create an excluded directory"
6544 );
6545 panel.update(cx, |panel, cx| {
6546 assert!(
6547 !panel.filename_editor.read(cx).is_focused(cx),
6548 "Should have closed the file name editor"
6549 );
6550 });
6551 workspace
6552 .update(cx, |workspace, cx| {
6553 let notifications = workspace.notification_ids();
6554 assert_eq!(
6555 notifications.len(),
6556 1,
6557 "Should receive one notification explaining that no directory is actually shown"
6558 );
6559 workspace.dismiss_notification(notifications.first().unwrap(), cx);
6560 assert!(workspace.notification_ids().is_empty());
6561 })
6562 .unwrap();
6563 assert!(
6564 fs.is_dir(Path::new("/root1/excluded_dir")).await,
6565 "Should have created the excluded directory"
6566 );
6567 }
6568
6569 #[gpui::test]
6570 async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
6571 init_test_with_editor(cx);
6572
6573 let fs = FakeFs::new(cx.executor().clone());
6574 fs.insert_tree(
6575 "/src",
6576 json!({
6577 "test": {
6578 "first.rs": "// First Rust file",
6579 "second.rs": "// Second Rust file",
6580 "third.rs": "// Third Rust file",
6581 }
6582 }),
6583 )
6584 .await;
6585
6586 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
6587 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6588 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6589 let panel = workspace
6590 .update(cx, |workspace, cx| {
6591 let panel = ProjectPanel::new(workspace, cx);
6592 workspace.add_panel(panel.clone(), cx);
6593 panel
6594 })
6595 .unwrap();
6596
6597 select_path(&panel, "src/", cx);
6598 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6599 cx.executor().run_until_parked();
6600 assert_eq!(
6601 visible_entries_as_strings(&panel, 0..10, cx),
6602 &[
6603 //
6604 "v src <== selected",
6605 " > test"
6606 ]
6607 );
6608 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
6609 panel.update(cx, |panel, cx| {
6610 assert!(panel.filename_editor.read(cx).is_focused(cx));
6611 });
6612 assert_eq!(
6613 visible_entries_as_strings(&panel, 0..10, cx),
6614 &[
6615 //
6616 "v src",
6617 " > [EDITOR: ''] <== selected",
6618 " > test"
6619 ]
6620 );
6621
6622 panel.update(cx, |panel, cx| panel.cancel(&menu::Cancel, cx));
6623 assert_eq!(
6624 visible_entries_as_strings(&panel, 0..10, cx),
6625 &[
6626 //
6627 "v src <== selected",
6628 " > test"
6629 ]
6630 );
6631 }
6632
6633 #[gpui::test]
6634 async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
6635 init_test_with_editor(cx);
6636
6637 let fs = FakeFs::new(cx.executor().clone());
6638 fs.insert_tree(
6639 "/root",
6640 json!({
6641 "dir1": {
6642 "subdir1": {},
6643 "file1.txt": "",
6644 "file2.txt": "",
6645 },
6646 "dir2": {
6647 "subdir2": {},
6648 "file3.txt": "",
6649 "file4.txt": "",
6650 },
6651 "file5.txt": "",
6652 "file6.txt": "",
6653 }),
6654 )
6655 .await;
6656
6657 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6658 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6659 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6660 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6661
6662 toggle_expand_dir(&panel, "root/dir1", cx);
6663 toggle_expand_dir(&panel, "root/dir2", cx);
6664
6665 // Test Case 1: Delete middle file in directory
6666 select_path(&panel, "root/dir1/file1.txt", cx);
6667 assert_eq!(
6668 visible_entries_as_strings(&panel, 0..15, cx),
6669 &[
6670 "v root",
6671 " v dir1",
6672 " > subdir1",
6673 " file1.txt <== selected",
6674 " file2.txt",
6675 " v dir2",
6676 " > subdir2",
6677 " file3.txt",
6678 " file4.txt",
6679 " file5.txt",
6680 " file6.txt",
6681 ],
6682 "Initial state before deleting middle file"
6683 );
6684
6685 submit_deletion(&panel, cx);
6686 assert_eq!(
6687 visible_entries_as_strings(&panel, 0..15, cx),
6688 &[
6689 "v root",
6690 " v dir1",
6691 " > subdir1",
6692 " file2.txt <== selected",
6693 " v dir2",
6694 " > subdir2",
6695 " file3.txt",
6696 " file4.txt",
6697 " file5.txt",
6698 " file6.txt",
6699 ],
6700 "Should select next file after deleting middle file"
6701 );
6702
6703 // Test Case 2: Delete last file in directory
6704 submit_deletion(&panel, cx);
6705 assert_eq!(
6706 visible_entries_as_strings(&panel, 0..15, cx),
6707 &[
6708 "v root",
6709 " v dir1",
6710 " > subdir1 <== selected",
6711 " v dir2",
6712 " > subdir2",
6713 " file3.txt",
6714 " file4.txt",
6715 " file5.txt",
6716 " file6.txt",
6717 ],
6718 "Should select next directory when last file is deleted"
6719 );
6720
6721 // Test Case 3: Delete root level file
6722 select_path(&panel, "root/file6.txt", cx);
6723 assert_eq!(
6724 visible_entries_as_strings(&panel, 0..15, cx),
6725 &[
6726 "v root",
6727 " v dir1",
6728 " > subdir1",
6729 " v dir2",
6730 " > subdir2",
6731 " file3.txt",
6732 " file4.txt",
6733 " file5.txt",
6734 " file6.txt <== selected",
6735 ],
6736 "Initial state before deleting root level file"
6737 );
6738
6739 submit_deletion(&panel, cx);
6740 assert_eq!(
6741 visible_entries_as_strings(&panel, 0..15, cx),
6742 &[
6743 "v root",
6744 " v dir1",
6745 " > subdir1",
6746 " v dir2",
6747 " > subdir2",
6748 " file3.txt",
6749 " file4.txt",
6750 " file5.txt <== selected",
6751 ],
6752 "Should select prev entry at root level"
6753 );
6754 }
6755
6756 #[gpui::test]
6757 async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
6758 init_test_with_editor(cx);
6759
6760 let fs = FakeFs::new(cx.executor().clone());
6761 fs.insert_tree(
6762 "/root",
6763 json!({
6764 "dir1": {
6765 "subdir1": {
6766 "a.txt": "",
6767 "b.txt": ""
6768 },
6769 "file1.txt": "",
6770 },
6771 "dir2": {
6772 "subdir2": {
6773 "c.txt": "",
6774 "d.txt": ""
6775 },
6776 "file2.txt": "",
6777 },
6778 "file3.txt": "",
6779 }),
6780 )
6781 .await;
6782
6783 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6784 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6785 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6786 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6787
6788 toggle_expand_dir(&panel, "root/dir1", cx);
6789 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6790 toggle_expand_dir(&panel, "root/dir2", cx);
6791 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
6792
6793 // Test Case 1: Select and delete nested directory with parent
6794 cx.simulate_modifiers_change(gpui::Modifiers {
6795 control: true,
6796 ..Default::default()
6797 });
6798 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
6799 select_path_with_mark(&panel, "root/dir1", cx);
6800
6801 assert_eq!(
6802 visible_entries_as_strings(&panel, 0..15, cx),
6803 &[
6804 "v root",
6805 " v dir1 <== selected <== marked",
6806 " v subdir1 <== marked",
6807 " a.txt",
6808 " b.txt",
6809 " file1.txt",
6810 " v dir2",
6811 " v subdir2",
6812 " c.txt",
6813 " d.txt",
6814 " file2.txt",
6815 " file3.txt",
6816 ],
6817 "Initial state before deleting nested directory with parent"
6818 );
6819
6820 submit_deletion(&panel, cx);
6821 assert_eq!(
6822 visible_entries_as_strings(&panel, 0..15, cx),
6823 &[
6824 "v root",
6825 " v dir2 <== selected",
6826 " v subdir2",
6827 " c.txt",
6828 " d.txt",
6829 " file2.txt",
6830 " file3.txt",
6831 ],
6832 "Should select next directory after deleting directory with parent"
6833 );
6834
6835 // Test Case 2: Select mixed files and directories across levels
6836 select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
6837 select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
6838 select_path_with_mark(&panel, "root/file3.txt", cx);
6839
6840 assert_eq!(
6841 visible_entries_as_strings(&panel, 0..15, cx),
6842 &[
6843 "v root",
6844 " v dir2",
6845 " v subdir2",
6846 " c.txt <== marked",
6847 " d.txt",
6848 " file2.txt <== marked",
6849 " file3.txt <== selected <== marked",
6850 ],
6851 "Initial state before deleting"
6852 );
6853
6854 submit_deletion(&panel, cx);
6855 assert_eq!(
6856 visible_entries_as_strings(&panel, 0..15, cx),
6857 &[
6858 "v root",
6859 " v dir2 <== selected",
6860 " v subdir2",
6861 " d.txt",
6862 ],
6863 "Should select sibling directory"
6864 );
6865 }
6866
6867 #[gpui::test]
6868 async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
6869 init_test_with_editor(cx);
6870
6871 let fs = FakeFs::new(cx.executor().clone());
6872 fs.insert_tree(
6873 "/root",
6874 json!({
6875 "dir1": {
6876 "subdir1": {
6877 "a.txt": "",
6878 "b.txt": ""
6879 },
6880 "file1.txt": "",
6881 },
6882 "dir2": {
6883 "subdir2": {
6884 "c.txt": "",
6885 "d.txt": ""
6886 },
6887 "file2.txt": "",
6888 },
6889 "file3.txt": "",
6890 "file4.txt": "",
6891 }),
6892 )
6893 .await;
6894
6895 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6896 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6897 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6898 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6899
6900 toggle_expand_dir(&panel, "root/dir1", cx);
6901 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6902 toggle_expand_dir(&panel, "root/dir2", cx);
6903 toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
6904
6905 // Test Case 1: Select all root files and directories
6906 cx.simulate_modifiers_change(gpui::Modifiers {
6907 control: true,
6908 ..Default::default()
6909 });
6910 select_path_with_mark(&panel, "root/dir1", cx);
6911 select_path_with_mark(&panel, "root/dir2", cx);
6912 select_path_with_mark(&panel, "root/file3.txt", cx);
6913 select_path_with_mark(&panel, "root/file4.txt", cx);
6914 assert_eq!(
6915 visible_entries_as_strings(&panel, 0..20, cx),
6916 &[
6917 "v root",
6918 " v dir1 <== marked",
6919 " v subdir1",
6920 " a.txt",
6921 " b.txt",
6922 " file1.txt",
6923 " v dir2 <== marked",
6924 " v subdir2",
6925 " c.txt",
6926 " d.txt",
6927 " file2.txt",
6928 " file3.txt <== marked",
6929 " file4.txt <== selected <== marked",
6930 ],
6931 "State before deleting all contents"
6932 );
6933
6934 submit_deletion(&panel, cx);
6935 assert_eq!(
6936 visible_entries_as_strings(&panel, 0..20, cx),
6937 &["v root <== selected"],
6938 "Only empty root directory should remain after deleting all contents"
6939 );
6940 }
6941
6942 #[gpui::test]
6943 async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
6944 init_test_with_editor(cx);
6945
6946 let fs = FakeFs::new(cx.executor().clone());
6947 fs.insert_tree(
6948 "/root",
6949 json!({
6950 "dir1": {
6951 "subdir1": {
6952 "file_a.txt": "content a",
6953 "file_b.txt": "content b",
6954 },
6955 "subdir2": {
6956 "file_c.txt": "content c",
6957 },
6958 "file1.txt": "content 1",
6959 },
6960 "dir2": {
6961 "file2.txt": "content 2",
6962 },
6963 }),
6964 )
6965 .await;
6966
6967 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
6968 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6969 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6970 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6971
6972 toggle_expand_dir(&panel, "root/dir1", cx);
6973 toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
6974 toggle_expand_dir(&panel, "root/dir2", cx);
6975 cx.simulate_modifiers_change(gpui::Modifiers {
6976 control: true,
6977 ..Default::default()
6978 });
6979
6980 // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
6981 select_path_with_mark(&panel, "root/dir1", cx);
6982 select_path_with_mark(&panel, "root/dir1/subdir1", cx);
6983 select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
6984
6985 assert_eq!(
6986 visible_entries_as_strings(&panel, 0..20, cx),
6987 &[
6988 "v root",
6989 " v dir1 <== marked",
6990 " v subdir1 <== marked",
6991 " file_a.txt <== selected <== marked",
6992 " file_b.txt",
6993 " > subdir2",
6994 " file1.txt",
6995 " v dir2",
6996 " file2.txt",
6997 ],
6998 "State with parent dir, subdir, and file selected"
6999 );
7000 submit_deletion(&panel, cx);
7001 assert_eq!(
7002 visible_entries_as_strings(&panel, 0..20, cx),
7003 &["v root", " v dir2 <== selected", " file2.txt",],
7004 "Only dir2 should remain after deletion"
7005 );
7006 }
7007
7008 #[gpui::test]
7009 async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
7010 init_test_with_editor(cx);
7011
7012 let fs = FakeFs::new(cx.executor().clone());
7013 // First worktree
7014 fs.insert_tree(
7015 "/root1",
7016 json!({
7017 "dir1": {
7018 "file1.txt": "content 1",
7019 "file2.txt": "content 2",
7020 },
7021 "dir2": {
7022 "file3.txt": "content 3",
7023 },
7024 }),
7025 )
7026 .await;
7027
7028 // Second worktree
7029 fs.insert_tree(
7030 "/root2",
7031 json!({
7032 "dir3": {
7033 "file4.txt": "content 4",
7034 "file5.txt": "content 5",
7035 },
7036 "file6.txt": "content 6",
7037 }),
7038 )
7039 .await;
7040
7041 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7042 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7043 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7044 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7045
7046 // Expand all directories for testing
7047 toggle_expand_dir(&panel, "root1/dir1", cx);
7048 toggle_expand_dir(&panel, "root1/dir2", cx);
7049 toggle_expand_dir(&panel, "root2/dir3", cx);
7050
7051 // Test Case 1: Delete files across different worktrees
7052 cx.simulate_modifiers_change(gpui::Modifiers {
7053 control: true,
7054 ..Default::default()
7055 });
7056 select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
7057 select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
7058
7059 assert_eq!(
7060 visible_entries_as_strings(&panel, 0..20, cx),
7061 &[
7062 "v root1",
7063 " v dir1",
7064 " file1.txt <== marked",
7065 " file2.txt",
7066 " v dir2",
7067 " file3.txt",
7068 "v root2",
7069 " v dir3",
7070 " file4.txt <== selected <== marked",
7071 " file5.txt",
7072 " file6.txt",
7073 ],
7074 "Initial state with files selected from different worktrees"
7075 );
7076
7077 submit_deletion(&panel, cx);
7078 assert_eq!(
7079 visible_entries_as_strings(&panel, 0..20, cx),
7080 &[
7081 "v root1",
7082 " v dir1",
7083 " file2.txt",
7084 " v dir2",
7085 " file3.txt",
7086 "v root2",
7087 " v dir3",
7088 " file5.txt <== selected",
7089 " file6.txt",
7090 ],
7091 "Should select next file in the last worktree after deletion"
7092 );
7093
7094 // Test Case 2: Delete directories from different worktrees
7095 select_path_with_mark(&panel, "root1/dir1", cx);
7096 select_path_with_mark(&panel, "root2/dir3", cx);
7097
7098 assert_eq!(
7099 visible_entries_as_strings(&panel, 0..20, cx),
7100 &[
7101 "v root1",
7102 " v dir1 <== marked",
7103 " file2.txt",
7104 " v dir2",
7105 " file3.txt",
7106 "v root2",
7107 " v dir3 <== selected <== marked",
7108 " file5.txt",
7109 " file6.txt",
7110 ],
7111 "State with directories marked from different worktrees"
7112 );
7113
7114 submit_deletion(&panel, cx);
7115 assert_eq!(
7116 visible_entries_as_strings(&panel, 0..20, cx),
7117 &[
7118 "v root1",
7119 " v dir2",
7120 " file3.txt",
7121 "v root2",
7122 " file6.txt <== selected",
7123 ],
7124 "Should select remaining file in last worktree after directory deletion"
7125 );
7126
7127 // Test Case 4: Delete all remaining files except roots
7128 select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
7129 select_path_with_mark(&panel, "root2/file6.txt", cx);
7130
7131 assert_eq!(
7132 visible_entries_as_strings(&panel, 0..20, cx),
7133 &[
7134 "v root1",
7135 " v dir2",
7136 " file3.txt <== marked",
7137 "v root2",
7138 " file6.txt <== selected <== marked",
7139 ],
7140 "State with all remaining files marked"
7141 );
7142
7143 submit_deletion(&panel, cx);
7144 assert_eq!(
7145 visible_entries_as_strings(&panel, 0..20, cx),
7146 &["v root1", " v dir2", "v root2 <== selected"],
7147 "Second parent root should be selected after deleting"
7148 );
7149 }
7150
7151 #[gpui::test]
7152 async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
7153 init_test_with_editor(cx);
7154
7155 let fs = FakeFs::new(cx.executor().clone());
7156 fs.insert_tree(
7157 "/root_b",
7158 json!({
7159 "dir1": {
7160 "file1.txt": "content 1",
7161 "file2.txt": "content 2",
7162 },
7163 }),
7164 )
7165 .await;
7166
7167 fs.insert_tree(
7168 "/root_c",
7169 json!({
7170 "dir2": {},
7171 }),
7172 )
7173 .await;
7174
7175 let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
7176 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7177 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7178 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7179
7180 toggle_expand_dir(&panel, "root_b/dir1", cx);
7181 toggle_expand_dir(&panel, "root_c/dir2", cx);
7182
7183 cx.simulate_modifiers_change(gpui::Modifiers {
7184 control: true,
7185 ..Default::default()
7186 });
7187 select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
7188 select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
7189
7190 assert_eq!(
7191 visible_entries_as_strings(&panel, 0..20, cx),
7192 &[
7193 "v root_b",
7194 " v dir1",
7195 " file1.txt <== marked",
7196 " file2.txt <== selected <== marked",
7197 "v root_c",
7198 " v dir2",
7199 ],
7200 "Initial state with files marked in root_b"
7201 );
7202
7203 submit_deletion(&panel, cx);
7204 assert_eq!(
7205 visible_entries_as_strings(&panel, 0..20, cx),
7206 &[
7207 "v root_b",
7208 " v dir1 <== selected",
7209 "v root_c",
7210 " v dir2",
7211 ],
7212 "After deletion in root_b as it's last deletion, selection should be in root_b"
7213 );
7214
7215 select_path_with_mark(&panel, "root_c/dir2", cx);
7216
7217 submit_deletion(&panel, cx);
7218 assert_eq!(
7219 visible_entries_as_strings(&panel, 0..20, cx),
7220 &["v root_b", " v dir1", "v root_c <== selected",],
7221 "After deleting from root_c, it should remain in root_c"
7222 );
7223 }
7224
7225 fn toggle_expand_dir(
7226 panel: &View<ProjectPanel>,
7227 path: impl AsRef<Path>,
7228 cx: &mut VisualTestContext,
7229 ) {
7230 let path = path.as_ref();
7231 panel.update(cx, |panel, cx| {
7232 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7233 let worktree = worktree.read(cx);
7234 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7235 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
7236 panel.toggle_expanded(entry_id, cx);
7237 return;
7238 }
7239 }
7240 panic!("no worktree for path {:?}", path);
7241 });
7242 }
7243
7244 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
7245 let path = path.as_ref();
7246 panel.update(cx, |panel, cx| {
7247 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7248 let worktree = worktree.read(cx);
7249 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7250 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
7251 panel.selection = Some(crate::SelectedEntry {
7252 worktree_id: worktree.id(),
7253 entry_id,
7254 });
7255 return;
7256 }
7257 }
7258 panic!("no worktree for path {:?}", path);
7259 });
7260 }
7261
7262 fn select_path_with_mark(
7263 panel: &View<ProjectPanel>,
7264 path: impl AsRef<Path>,
7265 cx: &mut VisualTestContext,
7266 ) {
7267 let path = path.as_ref();
7268 panel.update(cx, |panel, cx| {
7269 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7270 let worktree = worktree.read(cx);
7271 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7272 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
7273 let entry = crate::SelectedEntry {
7274 worktree_id: worktree.id(),
7275 entry_id,
7276 };
7277 if !panel.marked_entries.contains(&entry) {
7278 panel.marked_entries.insert(entry);
7279 }
7280 panel.selection = Some(entry);
7281 return;
7282 }
7283 }
7284 panic!("no worktree for path {:?}", path);
7285 });
7286 }
7287
7288 fn find_project_entry(
7289 panel: &View<ProjectPanel>,
7290 path: impl AsRef<Path>,
7291 cx: &mut VisualTestContext,
7292 ) -> Option<ProjectEntryId> {
7293 let path = path.as_ref();
7294 panel.update(cx, |panel, cx| {
7295 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
7296 let worktree = worktree.read(cx);
7297 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
7298 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
7299 }
7300 }
7301 panic!("no worktree for path {path:?}");
7302 })
7303 }
7304
7305 fn visible_entries_as_strings(
7306 panel: &View<ProjectPanel>,
7307 range: Range<usize>,
7308 cx: &mut VisualTestContext,
7309 ) -> Vec<String> {
7310 let mut result = Vec::new();
7311 let mut project_entries = HashSet::default();
7312 let mut has_editor = false;
7313
7314 panel.update(cx, |panel, cx| {
7315 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
7316 if details.is_editing {
7317 assert!(!has_editor, "duplicate editor entry");
7318 has_editor = true;
7319 } else {
7320 assert!(
7321 project_entries.insert(project_entry),
7322 "duplicate project entry {:?} {:?}",
7323 project_entry,
7324 details
7325 );
7326 }
7327
7328 let indent = " ".repeat(details.depth);
7329 let icon = if details.kind.is_dir() {
7330 if details.is_expanded {
7331 "v "
7332 } else {
7333 "> "
7334 }
7335 } else {
7336 " "
7337 };
7338 let name = if details.is_editing {
7339 format!("[EDITOR: '{}']", details.filename)
7340 } else if details.is_processing {
7341 format!("[PROCESSING: '{}']", details.filename)
7342 } else {
7343 details.filename.clone()
7344 };
7345 let selected = if details.is_selected {
7346 " <== selected"
7347 } else {
7348 ""
7349 };
7350 let marked = if details.is_marked {
7351 " <== marked"
7352 } else {
7353 ""
7354 };
7355
7356 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
7357 });
7358 });
7359
7360 result
7361 }
7362
7363 fn init_test(cx: &mut TestAppContext) {
7364 cx.update(|cx| {
7365 let settings_store = SettingsStore::test(cx);
7366 cx.set_global(settings_store);
7367 init_settings(cx);
7368 theme::init(theme::LoadThemes::JustBase, cx);
7369 language::init(cx);
7370 editor::init_settings(cx);
7371 crate::init((), cx);
7372 workspace::init_settings(cx);
7373 client::init_settings(cx);
7374 Project::init_settings(cx);
7375
7376 cx.update_global::<SettingsStore, _>(|store, cx| {
7377 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7378 project_panel_settings.auto_fold_dirs = Some(false);
7379 });
7380 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7381 worktree_settings.file_scan_exclusions = Some(Vec::new());
7382 });
7383 });
7384 });
7385 }
7386
7387 fn init_test_with_editor(cx: &mut TestAppContext) {
7388 cx.update(|cx| {
7389 let app_state = AppState::test(cx);
7390 theme::init(theme::LoadThemes::JustBase, cx);
7391 init_settings(cx);
7392 language::init(cx);
7393 editor::init(cx);
7394 crate::init((), cx);
7395 workspace::init(app_state.clone(), cx);
7396 Project::init_settings(cx);
7397
7398 cx.update_global::<SettingsStore, _>(|store, cx| {
7399 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7400 project_panel_settings.auto_fold_dirs = Some(false);
7401 });
7402 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7403 worktree_settings.file_scan_exclusions = Some(Vec::new());
7404 });
7405 });
7406 });
7407 }
7408
7409 fn ensure_single_file_is_opened(
7410 window: &WindowHandle<Workspace>,
7411 expected_path: &str,
7412 cx: &mut TestAppContext,
7413 ) {
7414 window
7415 .update(cx, |workspace, cx| {
7416 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
7417 assert_eq!(worktrees.len(), 1);
7418 let worktree_id = worktrees[0].read(cx).id();
7419
7420 let open_project_paths = workspace
7421 .panes()
7422 .iter()
7423 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
7424 .collect::<Vec<_>>();
7425 assert_eq!(
7426 open_project_paths,
7427 vec![ProjectPath {
7428 worktree_id,
7429 path: Arc::from(Path::new(expected_path))
7430 }],
7431 "Should have opened file, selected in project panel"
7432 );
7433 })
7434 .unwrap();
7435 }
7436
7437 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
7438 assert!(
7439 !cx.has_pending_prompt(),
7440 "Should have no prompts before the deletion"
7441 );
7442 panel.update(cx, |panel, cx| {
7443 panel.delete(&Delete { skip_prompt: false }, cx)
7444 });
7445 assert!(
7446 cx.has_pending_prompt(),
7447 "Should have a prompt after the deletion"
7448 );
7449 cx.simulate_prompt_answer(0);
7450 assert!(
7451 !cx.has_pending_prompt(),
7452 "Should have no prompts after prompt was replied to"
7453 );
7454 cx.executor().run_until_parked();
7455 }
7456
7457 fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
7458 assert!(
7459 !cx.has_pending_prompt(),
7460 "Should have no prompts before the deletion"
7461 );
7462 panel.update(cx, |panel, cx| {
7463 panel.delete(&Delete { skip_prompt: true }, cx)
7464 });
7465 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
7466 cx.executor().run_until_parked();
7467 }
7468
7469 fn ensure_no_open_items_and_panes(
7470 workspace: &WindowHandle<Workspace>,
7471 cx: &mut VisualTestContext,
7472 ) {
7473 assert!(
7474 !cx.has_pending_prompt(),
7475 "Should have no prompts after deletion operation closes the file"
7476 );
7477 workspace
7478 .read_with(cx, |workspace, cx| {
7479 let open_project_paths = workspace
7480 .panes()
7481 .iter()
7482 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
7483 .collect::<Vec<_>>();
7484 assert!(
7485 open_project_paths.is_empty(),
7486 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
7487 );
7488 })
7489 .unwrap();
7490 }
7491
7492 struct TestProjectItemView {
7493 focus_handle: FocusHandle,
7494 path: ProjectPath,
7495 }
7496
7497 struct TestProjectItem {
7498 path: ProjectPath,
7499 }
7500
7501 impl project::Item for TestProjectItem {
7502 fn try_open(
7503 _project: &Model<Project>,
7504 path: &ProjectPath,
7505 cx: &mut AppContext,
7506 ) -> Option<Task<gpui::Result<Model<Self>>>> {
7507 let path = path.clone();
7508 Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
7509 }
7510
7511 fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
7512 None
7513 }
7514
7515 fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
7516 Some(self.path.clone())
7517 }
7518 }
7519
7520 impl ProjectItem for TestProjectItemView {
7521 type Item = TestProjectItem;
7522
7523 fn for_project_item(
7524 _: Model<Project>,
7525 project_item: Model<Self::Item>,
7526 cx: &mut ViewContext<Self>,
7527 ) -> Self
7528 where
7529 Self: Sized,
7530 {
7531 Self {
7532 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
7533 focus_handle: cx.focus_handle(),
7534 }
7535 }
7536 }
7537
7538 impl Item for TestProjectItemView {
7539 type Event = ();
7540 }
7541
7542 impl EventEmitter<()> for TestProjectItemView {}
7543
7544 impl FocusableView for TestProjectItemView {
7545 fn focus_handle(&self, _: &AppContext) -> FocusHandle {
7546 self.focus_handle.clone()
7547 }
7548 }
7549
7550 impl Render for TestProjectItemView {
7551 fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
7552 Empty
7553 }
7554 }
7555}