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