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