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