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 is_always_included: entry.is_always_included,
2037 git_status: entry.git_status,
2038 canonical_path: entry.canonical_path.clone(),
2039 char_bag: entry.char_bag,
2040 is_fifo: entry.is_fifo,
2041 });
2042 }
2043 let worktree_abs_path = worktree.read(cx).abs_path();
2044 let (depth, path) = if Some(entry) == worktree.read(cx).root_entry() {
2045 let Some(path_name) = worktree_abs_path
2046 .file_name()
2047 .with_context(|| {
2048 format!("Worktree abs path has no file name, root entry: {entry:?}")
2049 })
2050 .log_err()
2051 else {
2052 continue;
2053 };
2054 let path = Arc::from(Path::new(path_name));
2055 let depth = 0;
2056 (depth, path)
2057 } else if entry.is_file() {
2058 let Some(path_name) = entry
2059 .path
2060 .file_name()
2061 .with_context(|| format!("Non-root entry has no file name: {entry:?}"))
2062 .log_err()
2063 else {
2064 continue;
2065 };
2066 let path = Arc::from(Path::new(path_name));
2067 let depth = entry.path.ancestors().count() - 1;
2068 (depth, path)
2069 } else {
2070 let path = self
2071 .ancestors
2072 .get(&entry.id)
2073 .and_then(|ancestors| {
2074 let outermost_ancestor = ancestors.ancestors.last()?;
2075 let root_folded_entry = worktree
2076 .read(cx)
2077 .entry_for_id(*outermost_ancestor)?
2078 .path
2079 .as_ref();
2080 entry
2081 .path
2082 .strip_prefix(root_folded_entry)
2083 .ok()
2084 .and_then(|suffix| {
2085 let full_path = Path::new(root_folded_entry.file_name()?);
2086 Some(Arc::<Path>::from(full_path.join(suffix)))
2087 })
2088 })
2089 .or_else(|| entry.path.file_name().map(Path::new).map(Arc::from))
2090 .unwrap_or_else(|| entry.path.clone());
2091 let depth = path.components().count();
2092 (depth, path)
2093 };
2094 let width_estimate = item_width_estimate(
2095 depth,
2096 path.to_string_lossy().chars().count(),
2097 entry.canonical_path.is_some(),
2098 );
2099
2100 match max_width_item.as_mut() {
2101 Some((id, worktree_id, width)) => {
2102 if *width < width_estimate {
2103 *id = entry.id;
2104 *worktree_id = worktree.read(cx).id();
2105 *width = width_estimate;
2106 }
2107 }
2108 None => {
2109 max_width_item = Some((entry.id, worktree.read(cx).id(), width_estimate))
2110 }
2111 }
2112
2113 if expanded_dir_ids.binary_search(&entry.id).is_err()
2114 && entry_iter.advance_to_sibling()
2115 {
2116 continue;
2117 }
2118 entry_iter.advance();
2119 }
2120
2121 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
2122 project::sort_worktree_entries(&mut visible_worktree_entries);
2123 self.visible_entries
2124 .push((worktree_id, visible_worktree_entries, OnceCell::new()));
2125 }
2126
2127 if let Some((project_entry_id, worktree_id, _)) = max_width_item {
2128 let mut visited_worktrees_length = 0;
2129 let index = self.visible_entries.iter().find_map(|(id, entries, _)| {
2130 if worktree_id == *id {
2131 entries
2132 .iter()
2133 .position(|entry| entry.id == project_entry_id)
2134 } else {
2135 visited_worktrees_length += entries.len();
2136 None
2137 }
2138 });
2139 if let Some(index) = index {
2140 self.max_width_item_index = Some(visited_worktrees_length + index);
2141 }
2142 }
2143 if let Some((worktree_id, entry_id)) = new_selected_entry {
2144 self.selection = Some(SelectedEntry {
2145 worktree_id,
2146 entry_id,
2147 });
2148 }
2149 }
2150
2151 fn expand_entry(
2152 &mut self,
2153 worktree_id: WorktreeId,
2154 entry_id: ProjectEntryId,
2155 cx: &mut ViewContext<Self>,
2156 ) {
2157 self.project.update(cx, |project, cx| {
2158 if let Some((worktree, expanded_dir_ids)) = project
2159 .worktree_for_id(worktree_id, cx)
2160 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
2161 {
2162 project.expand_entry(worktree_id, entry_id, cx);
2163 let worktree = worktree.read(cx);
2164
2165 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
2166 loop {
2167 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
2168 expanded_dir_ids.insert(ix, entry.id);
2169 }
2170
2171 if let Some(parent_entry) =
2172 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
2173 {
2174 entry = parent_entry;
2175 } else {
2176 break;
2177 }
2178 }
2179 }
2180 }
2181 });
2182 }
2183
2184 fn drop_external_files(
2185 &mut self,
2186 paths: &[PathBuf],
2187 entry_id: ProjectEntryId,
2188 cx: &mut ViewContext<Self>,
2189 ) {
2190 let mut paths: Vec<Arc<Path>> = paths.iter().map(|path| Arc::from(path.clone())).collect();
2191
2192 let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
2193
2194 let Some((target_directory, worktree)) = maybe!({
2195 let worktree = self.project.read(cx).worktree_for_entry(entry_id, cx)?;
2196 let entry = worktree.read(cx).entry_for_id(entry_id)?;
2197 let path = worktree.read(cx).absolutize(&entry.path).ok()?;
2198 let target_directory = if path.is_dir() {
2199 path
2200 } else {
2201 path.parent()?.to_path_buf()
2202 };
2203 Some((target_directory, worktree))
2204 }) else {
2205 return;
2206 };
2207
2208 let mut paths_to_replace = Vec::new();
2209 for path in &paths {
2210 if let Some(name) = path.file_name() {
2211 let mut target_path = target_directory.clone();
2212 target_path.push(name);
2213 if target_path.exists() {
2214 paths_to_replace.push((name.to_string_lossy().to_string(), path.clone()));
2215 }
2216 }
2217 }
2218
2219 cx.spawn(|this, mut cx| {
2220 async move {
2221 for (filename, original_path) in &paths_to_replace {
2222 let answer = cx
2223 .prompt(
2224 PromptLevel::Info,
2225 format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
2226 None,
2227 &["Replace", "Cancel"],
2228 )
2229 .await?;
2230 if answer == 1 {
2231 if let Some(item_idx) = paths.iter().position(|p| p == original_path) {
2232 paths.remove(item_idx);
2233 }
2234 }
2235 }
2236
2237 if paths.is_empty() {
2238 return Ok(());
2239 }
2240
2241 let task = worktree.update(&mut cx, |worktree, cx| {
2242 worktree.copy_external_entries(target_directory, paths, true, cx)
2243 })?;
2244
2245 let opened_entries = task.await?;
2246 this.update(&mut cx, |this, cx| {
2247 if open_file_after_drop && !opened_entries.is_empty() {
2248 this.open_entry(opened_entries[0], true, false, cx);
2249 }
2250 })
2251 }
2252 .log_err()
2253 })
2254 .detach();
2255 }
2256
2257 fn drag_onto(
2258 &mut self,
2259 selections: &DraggedSelection,
2260 target_entry_id: ProjectEntryId,
2261 is_file: bool,
2262 cx: &mut ViewContext<Self>,
2263 ) {
2264 let should_copy = cx.modifiers().alt;
2265 if should_copy {
2266 let _ = maybe!({
2267 let project = self.project.read(cx);
2268 let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
2269 let target_entry = target_worktree
2270 .read(cx)
2271 .entry_for_id(target_entry_id)?
2272 .clone();
2273 for selection in selections.items() {
2274 let new_path = self.create_paste_path(
2275 selection,
2276 (target_worktree.clone(), &target_entry),
2277 cx,
2278 )?;
2279 self.project
2280 .update(cx, |project, cx| {
2281 project.copy_entry(selection.entry_id, None, new_path, cx)
2282 })
2283 .detach_and_log_err(cx)
2284 }
2285
2286 Some(())
2287 });
2288 } else {
2289 for selection in selections.items() {
2290 self.move_entry(selection.entry_id, target_entry_id, is_file, cx);
2291 }
2292 }
2293 }
2294
2295 fn index_for_entry(
2296 &self,
2297 entry_id: ProjectEntryId,
2298 worktree_id: WorktreeId,
2299 ) -> Option<(usize, usize, usize)> {
2300 let mut worktree_ix = 0;
2301 let mut total_ix = 0;
2302 for (current_worktree_id, visible_worktree_entries, _) in &self.visible_entries {
2303 if worktree_id != *current_worktree_id {
2304 total_ix += visible_worktree_entries.len();
2305 worktree_ix += 1;
2306 continue;
2307 }
2308
2309 return visible_worktree_entries
2310 .iter()
2311 .enumerate()
2312 .find(|(_, entry)| entry.id == entry_id)
2313 .map(|(ix, _)| (worktree_ix, ix, total_ix + ix));
2314 }
2315 None
2316 }
2317
2318 fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, &Entry)> {
2319 let mut offset = 0;
2320 for (worktree_id, visible_worktree_entries, _) in &self.visible_entries {
2321 if visible_worktree_entries.len() > offset + index {
2322 return visible_worktree_entries
2323 .get(index)
2324 .map(|entry| (*worktree_id, entry));
2325 }
2326 offset += visible_worktree_entries.len();
2327 }
2328 None
2329 }
2330
2331 fn iter_visible_entries(
2332 &self,
2333 range: Range<usize>,
2334 cx: &mut ViewContext<ProjectPanel>,
2335 mut callback: impl FnMut(&Entry, &HashSet<Arc<Path>>, &mut ViewContext<ProjectPanel>),
2336 ) {
2337 let mut ix = 0;
2338 for (_, visible_worktree_entries, entries_paths) in &self.visible_entries {
2339 if ix >= range.end {
2340 return;
2341 }
2342
2343 if ix + visible_worktree_entries.len() <= range.start {
2344 ix += visible_worktree_entries.len();
2345 continue;
2346 }
2347
2348 let end_ix = range.end.min(ix + visible_worktree_entries.len());
2349 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
2350 let entries = entries_paths.get_or_init(|| {
2351 visible_worktree_entries
2352 .iter()
2353 .map(|e| (e.path.clone()))
2354 .collect()
2355 });
2356 for entry in visible_worktree_entries[entry_range].iter() {
2357 callback(entry, entries, cx);
2358 }
2359 ix = end_ix;
2360 }
2361 }
2362
2363 fn for_each_visible_entry(
2364 &self,
2365 range: Range<usize>,
2366 cx: &mut ViewContext<ProjectPanel>,
2367 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
2368 ) {
2369 let mut ix = 0;
2370 for (worktree_id, visible_worktree_entries, entries_paths) in &self.visible_entries {
2371 if ix >= range.end {
2372 return;
2373 }
2374
2375 if ix + visible_worktree_entries.len() <= range.start {
2376 ix += visible_worktree_entries.len();
2377 continue;
2378 }
2379
2380 let end_ix = range.end.min(ix + visible_worktree_entries.len());
2381 let (git_status_setting, show_file_icons, show_folder_icons) = {
2382 let settings = ProjectPanelSettings::get_global(cx);
2383 (
2384 settings.git_status,
2385 settings.file_icons,
2386 settings.folder_icons,
2387 )
2388 };
2389 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
2390 let snapshot = worktree.read(cx).snapshot();
2391 let root_name = OsStr::new(snapshot.root_name());
2392 let expanded_entry_ids = self
2393 .expanded_dir_ids
2394 .get(&snapshot.id())
2395 .map(Vec::as_slice)
2396 .unwrap_or(&[]);
2397
2398 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
2399 let entries = entries_paths.get_or_init(|| {
2400 visible_worktree_entries
2401 .iter()
2402 .map(|e| (e.path.clone()))
2403 .collect()
2404 });
2405 for entry in visible_worktree_entries[entry_range].iter() {
2406 let status = git_status_setting.then_some(entry.git_status).flatten();
2407 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
2408 let icon = match entry.kind {
2409 EntryKind::File => {
2410 if show_file_icons {
2411 FileIcons::get_icon(&entry.path, cx)
2412 } else {
2413 None
2414 }
2415 }
2416 _ => {
2417 if show_folder_icons {
2418 FileIcons::get_folder_icon(is_expanded, cx)
2419 } else {
2420 FileIcons::get_chevron_icon(is_expanded, cx)
2421 }
2422 }
2423 };
2424
2425 let (depth, difference) =
2426 ProjectPanel::calculate_depth_and_difference(entry, entries);
2427
2428 let filename = match difference {
2429 diff if diff > 1 => entry
2430 .path
2431 .iter()
2432 .skip(entry.path.components().count() - diff)
2433 .collect::<PathBuf>()
2434 .to_str()
2435 .unwrap_or_default()
2436 .to_string(),
2437 _ => entry
2438 .path
2439 .file_name()
2440 .map(|name| name.to_string_lossy().into_owned())
2441 .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
2442 };
2443 let selection = SelectedEntry {
2444 worktree_id: snapshot.id(),
2445 entry_id: entry.id,
2446 };
2447
2448 let is_marked = self.marked_entries.contains(&selection);
2449
2450 let diagnostic_severity = self
2451 .diagnostics
2452 .get(&(*worktree_id, entry.path.to_path_buf()))
2453 .cloned();
2454
2455 let filename_text_color =
2456 entry_git_aware_label_color(status, entry.is_ignored, is_marked);
2457
2458 let mut details = EntryDetails {
2459 filename,
2460 icon,
2461 path: entry.path.clone(),
2462 depth,
2463 kind: entry.kind,
2464 is_ignored: entry.is_ignored,
2465 is_expanded,
2466 is_selected: self.selection == Some(selection),
2467 is_marked,
2468 is_editing: false,
2469 is_processing: false,
2470 is_cut: self
2471 .clipboard
2472 .as_ref()
2473 .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
2474 filename_text_color,
2475 diagnostic_severity,
2476 git_status: status,
2477 is_private: entry.is_private,
2478 worktree_id: *worktree_id,
2479 canonical_path: entry.canonical_path.clone(),
2480 };
2481
2482 if let Some(edit_state) = &self.edit_state {
2483 let is_edited_entry = if edit_state.is_new_entry() {
2484 entry.id == NEW_ENTRY_ID
2485 } else {
2486 entry.id == edit_state.entry_id
2487 || self
2488 .ancestors
2489 .get(&entry.id)
2490 .is_some_and(|auto_folded_dirs| {
2491 auto_folded_dirs
2492 .ancestors
2493 .iter()
2494 .any(|entry_id| *entry_id == edit_state.entry_id)
2495 })
2496 };
2497
2498 if is_edited_entry {
2499 if let Some(processing_filename) = &edit_state.processing_filename {
2500 details.is_processing = true;
2501 if let Some(ancestors) = edit_state
2502 .leaf_entry_id
2503 .and_then(|entry| self.ancestors.get(&entry))
2504 {
2505 let position = ancestors.ancestors.iter().position(|entry_id| *entry_id == edit_state.entry_id).expect("Edited sub-entry should be an ancestor of selected leaf entry") + 1;
2506 let all_components = ancestors.ancestors.len();
2507
2508 let prefix_components = all_components - position;
2509 let suffix_components = position.checked_sub(1);
2510 let mut previous_components =
2511 Path::new(&details.filename).components();
2512 let mut new_path = previous_components
2513 .by_ref()
2514 .take(prefix_components)
2515 .collect::<PathBuf>();
2516 if let Some(last_component) =
2517 Path::new(processing_filename).components().last()
2518 {
2519 new_path.push(last_component);
2520 previous_components.next();
2521 }
2522
2523 if let Some(_) = suffix_components {
2524 new_path.push(previous_components);
2525 }
2526 if let Some(str) = new_path.to_str() {
2527 details.filename.clear();
2528 details.filename.push_str(str);
2529 }
2530 } else {
2531 details.filename.clear();
2532 details.filename.push_str(processing_filename);
2533 }
2534 } else {
2535 if edit_state.is_new_entry() {
2536 details.filename.clear();
2537 }
2538 details.is_editing = true;
2539 }
2540 }
2541 }
2542
2543 callback(entry.id, details, cx);
2544 }
2545 }
2546 ix = end_ix;
2547 }
2548 }
2549
2550 fn calculate_depth_and_difference(
2551 entry: &Entry,
2552 visible_worktree_entries: &HashSet<Arc<Path>>,
2553 ) -> (usize, usize) {
2554 let (depth, difference) = entry
2555 .path
2556 .ancestors()
2557 .skip(1) // Skip the entry itself
2558 .find_map(|ancestor| {
2559 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
2560 let entry_path_components_count = entry.path.components().count();
2561 let parent_path_components_count = parent_entry.components().count();
2562 let difference = entry_path_components_count - parent_path_components_count;
2563 let depth = parent_entry
2564 .ancestors()
2565 .skip(1)
2566 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
2567 .count();
2568 Some((depth + 1, difference))
2569 } else {
2570 None
2571 }
2572 })
2573 .unwrap_or((0, 0));
2574
2575 (depth, difference)
2576 }
2577
2578 fn render_entry(
2579 &self,
2580 entry_id: ProjectEntryId,
2581 details: EntryDetails,
2582 cx: &mut ViewContext<Self>,
2583 ) -> Stateful<Div> {
2584 let kind = details.kind;
2585 let settings = ProjectPanelSettings::get_global(cx);
2586 let show_editor = details.is_editing && !details.is_processing;
2587
2588 let selection = SelectedEntry {
2589 worktree_id: details.worktree_id,
2590 entry_id,
2591 };
2592
2593 let is_marked = self.marked_entries.contains(&selection);
2594 let is_active = self
2595 .selection
2596 .map_or(false, |selection| selection.entry_id == entry_id);
2597
2598 let width = self.size(cx);
2599 let file_name = details.filename.clone();
2600
2601 let mut icon = details.icon.clone();
2602 if settings.file_icons && show_editor && details.kind.is_file() {
2603 let filename = self.filename_editor.read(cx).text(cx);
2604 if filename.len() > 2 {
2605 icon = FileIcons::get_icon(Path::new(&filename), cx);
2606 }
2607 }
2608
2609 let filename_text_color = details.filename_text_color;
2610 let diagnostic_severity = details.diagnostic_severity;
2611 let item_colors = get_item_color(cx);
2612
2613 let canonical_path = details
2614 .canonical_path
2615 .as_ref()
2616 .map(|f| f.to_string_lossy().to_string());
2617 let path = details.path.clone();
2618
2619 let depth = details.depth;
2620 let worktree_id = details.worktree_id;
2621 let selections = Arc::new(self.marked_entries.clone());
2622 let is_local = self.project.read(cx).is_local();
2623
2624 let dragged_selection = DraggedSelection {
2625 active_selection: selection,
2626 marked_selections: selections,
2627 };
2628
2629 div()
2630 .id(entry_id.to_proto() as usize)
2631 .when(is_local, |div| {
2632 div.on_drag_move::<ExternalPaths>(cx.listener(
2633 move |this, event: &DragMoveEvent<ExternalPaths>, cx| {
2634 if event.bounds.contains(&event.event.position) {
2635 if this.last_external_paths_drag_over_entry == Some(entry_id) {
2636 return;
2637 }
2638 this.last_external_paths_drag_over_entry = Some(entry_id);
2639 this.marked_entries.clear();
2640
2641 let Some((worktree, path, entry)) = maybe!({
2642 let worktree = this
2643 .project
2644 .read(cx)
2645 .worktree_for_id(selection.worktree_id, cx)?;
2646 let worktree = worktree.read(cx);
2647 let abs_path = worktree.absolutize(&path).log_err()?;
2648 let path = if abs_path.is_dir() {
2649 path.as_ref()
2650 } else {
2651 path.parent()?
2652 };
2653 let entry = worktree.entry_for_path(path)?;
2654 Some((worktree, path, entry))
2655 }) else {
2656 return;
2657 };
2658
2659 this.marked_entries.insert(SelectedEntry {
2660 entry_id: entry.id,
2661 worktree_id: worktree.id(),
2662 });
2663
2664 for entry in worktree.child_entries(path) {
2665 this.marked_entries.insert(SelectedEntry {
2666 entry_id: entry.id,
2667 worktree_id: worktree.id(),
2668 });
2669 }
2670
2671 cx.notify();
2672 }
2673 },
2674 ))
2675 .on_drop(cx.listener(
2676 move |this, external_paths: &ExternalPaths, cx| {
2677 this.hover_scroll_task.take();
2678 this.last_external_paths_drag_over_entry = None;
2679 this.marked_entries.clear();
2680 this.drop_external_files(external_paths.paths(), entry_id, cx);
2681 cx.stop_propagation();
2682 },
2683 ))
2684 })
2685 .on_drag(dragged_selection, move |selection, click_offset, cx| {
2686 cx.new_view(|_| DraggedProjectEntryView {
2687 details: details.clone(),
2688 width,
2689 click_offset,
2690 selection: selection.active_selection,
2691 selections: selection.marked_selections.clone(),
2692 })
2693 })
2694 .drag_over::<DraggedSelection>(move |style, _, _| style.bg(item_colors.drag_over))
2695 .on_drop(cx.listener(move |this, selections: &DraggedSelection, cx| {
2696 this.hover_scroll_task.take();
2697 this.drag_onto(selections, entry_id, kind.is_file(), cx);
2698 }))
2699 .on_mouse_down(
2700 MouseButton::Left,
2701 cx.listener(move |this, _, cx| {
2702 this.mouse_down = true;
2703 cx.propagate();
2704 }),
2705 )
2706 .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
2707 if event.down.button == MouseButton::Right || event.down.first_mouse || show_editor
2708 {
2709 return;
2710 }
2711 if event.down.button == MouseButton::Left {
2712 this.mouse_down = false;
2713 }
2714 cx.stop_propagation();
2715
2716 if let Some(selection) = this.selection.filter(|_| event.down.modifiers.shift) {
2717 let current_selection = this.index_for_selection(selection);
2718 let clicked_entry = SelectedEntry {
2719 entry_id,
2720 worktree_id,
2721 };
2722 let target_selection = this.index_for_selection(clicked_entry);
2723 if let Some(((_, _, source_index), (_, _, target_index))) =
2724 current_selection.zip(target_selection)
2725 {
2726 let range_start = source_index.min(target_index);
2727 let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
2728 let mut new_selections = BTreeSet::new();
2729 this.for_each_visible_entry(
2730 range_start..range_end,
2731 cx,
2732 |entry_id, details, _| {
2733 new_selections.insert(SelectedEntry {
2734 entry_id,
2735 worktree_id: details.worktree_id,
2736 });
2737 },
2738 );
2739
2740 this.marked_entries = this
2741 .marked_entries
2742 .union(&new_selections)
2743 .cloned()
2744 .collect();
2745
2746 this.selection = Some(clicked_entry);
2747 this.marked_entries.insert(clicked_entry);
2748 }
2749 } else if event.down.modifiers.secondary() {
2750 if event.down.click_count > 1 {
2751 this.split_entry(entry_id, cx);
2752 } else if !this.marked_entries.insert(selection) {
2753 this.marked_entries.remove(&selection);
2754 }
2755 } else if kind.is_dir() {
2756 this.toggle_expanded(entry_id, cx);
2757 } else {
2758 let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
2759 let click_count = event.up.click_count;
2760 let focus_opened_item = !preview_tabs_enabled || click_count > 1;
2761 let allow_preview = preview_tabs_enabled && click_count == 1;
2762 this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
2763 }
2764 }))
2765 .cursor_pointer()
2766 .child(
2767 ListItem::new(entry_id.to_proto() as usize)
2768 .indent_level(depth)
2769 .indent_step_size(px(settings.indent_size))
2770 .selected(is_marked || is_active)
2771 .when_some(canonical_path, |this, path| {
2772 this.end_slot::<AnyElement>(
2773 div()
2774 .id("symlink_icon")
2775 .pr_3()
2776 .tooltip(move |cx| {
2777 Tooltip::with_meta(path.to_string(), None, "Symbolic Link", cx)
2778 })
2779 .child(
2780 Icon::new(IconName::ArrowUpRight)
2781 .size(IconSize::Indicator)
2782 .color(filename_text_color),
2783 )
2784 .into_any_element(),
2785 )
2786 })
2787 .child(if let Some(icon) = &icon {
2788 // Check if there's a diagnostic severity and get the decoration color
2789 if let Some((_, decoration_color)) =
2790 entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
2791 {
2792 // Determine if the diagnostic is a warning
2793 let is_warning = diagnostic_severity
2794 .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
2795 .unwrap_or(false);
2796 div().child(
2797 DecoratedIcon::new(
2798 Icon::from_path(icon.clone()).color(Color::Muted),
2799 Some(
2800 IconDecoration::new(
2801 if kind.is_file() {
2802 if is_warning {
2803 IconDecorationKind::Triangle
2804 } else {
2805 IconDecorationKind::X
2806 }
2807 } else {
2808 IconDecorationKind::Dot
2809 },
2810 if is_marked || is_active {
2811 item_colors.marked_active
2812 } else {
2813 item_colors.default
2814 },
2815 cx,
2816 )
2817 .color(decoration_color.color(cx))
2818 .position(Point {
2819 x: px(-2.),
2820 y: px(-2.),
2821 }),
2822 ),
2823 )
2824 .into_any_element(),
2825 )
2826 } else {
2827 h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
2828 }
2829 } else {
2830 if let Some((icon_name, color)) =
2831 entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
2832 {
2833 h_flex()
2834 .size(IconSize::default().rems())
2835 .child(Icon::new(icon_name).color(color).size(IconSize::Small))
2836 } else {
2837 h_flex()
2838 .size(IconSize::default().rems())
2839 .invisible()
2840 .flex_none()
2841 }
2842 })
2843 .child(
2844 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
2845 h_flex().h_6().w_full().child(editor.clone())
2846 } else {
2847 h_flex().h_6().map(|mut this| {
2848 if let Some(folded_ancestors) = self.ancestors.get(&entry_id) {
2849 let components = Path::new(&file_name)
2850 .components()
2851 .map(|comp| {
2852 let comp_str =
2853 comp.as_os_str().to_string_lossy().into_owned();
2854 comp_str
2855 })
2856 .collect::<Vec<_>>();
2857
2858 let components_len = components.len();
2859 let active_index = components_len
2860 - 1
2861 - folded_ancestors.current_ancestor_depth;
2862 const DELIMITER: SharedString =
2863 SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
2864 for (index, component) in components.into_iter().enumerate() {
2865 if index != 0 {
2866 this = this.child(
2867 Label::new(DELIMITER.clone())
2868 .single_line()
2869 .color(filename_text_color),
2870 );
2871 }
2872 let id = SharedString::from(format!(
2873 "project_panel_path_component_{}_{index}",
2874 entry_id.to_usize()
2875 ));
2876 let label = div()
2877 .id(id)
2878 .on_click(cx.listener(move |this, _, cx| {
2879 if index != active_index {
2880 if let Some(folds) =
2881 this.ancestors.get_mut(&entry_id)
2882 {
2883 folds.current_ancestor_depth =
2884 components_len - 1 - index;
2885 cx.notify();
2886 }
2887 }
2888 }))
2889 .child(
2890 Label::new(component)
2891 .single_line()
2892 .color(filename_text_color)
2893 .when(
2894 is_active && index == active_index,
2895 |this| this.underline(true),
2896 ),
2897 );
2898
2899 this = this.child(label);
2900 }
2901
2902 this
2903 } else {
2904 this.child(
2905 Label::new(file_name)
2906 .single_line()
2907 .color(filename_text_color),
2908 )
2909 }
2910 })
2911 }
2912 .ml_1(),
2913 )
2914 .on_secondary_mouse_down(cx.listener(
2915 move |this, event: &MouseDownEvent, cx| {
2916 // Stop propagation to prevent the catch-all context menu for the project
2917 // panel from being deployed.
2918 cx.stop_propagation();
2919 this.deploy_context_menu(event.position, entry_id, cx);
2920 },
2921 ))
2922 .overflow_x(),
2923 )
2924 .border_1()
2925 .border_r_2()
2926 .rounded_none()
2927 .hover(|style| {
2928 if is_active {
2929 style
2930 } else {
2931 style.bg(item_colors.hover).border_color(item_colors.hover)
2932 }
2933 })
2934 .when(is_marked || is_active, |this| {
2935 this.when(is_marked, |this| {
2936 this.bg(item_colors.marked_active)
2937 .border_color(item_colors.marked_active)
2938 })
2939 })
2940 .when(
2941 !self.mouse_down && is_active && self.focus_handle.contains_focused(cx),
2942 |this| this.border_color(Color::Selected.color(cx)),
2943 )
2944 }
2945
2946 fn render_vertical_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
2947 if !Self::should_show_scrollbar(cx)
2948 || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
2949 {
2950 return None;
2951 }
2952 Some(
2953 div()
2954 .occlude()
2955 .id("project-panel-vertical-scroll")
2956 .on_mouse_move(cx.listener(|_, _, cx| {
2957 cx.notify();
2958 cx.stop_propagation()
2959 }))
2960 .on_hover(|_, cx| {
2961 cx.stop_propagation();
2962 })
2963 .on_any_mouse_down(|_, cx| {
2964 cx.stop_propagation();
2965 })
2966 .on_mouse_up(
2967 MouseButton::Left,
2968 cx.listener(|this, _, cx| {
2969 if !this.vertical_scrollbar_state.is_dragging()
2970 && !this.focus_handle.contains_focused(cx)
2971 {
2972 this.hide_scrollbar(cx);
2973 cx.notify();
2974 }
2975
2976 cx.stop_propagation();
2977 }),
2978 )
2979 .on_scroll_wheel(cx.listener(|_, _, cx| {
2980 cx.notify();
2981 }))
2982 .h_full()
2983 .absolute()
2984 .right_1()
2985 .top_1()
2986 .bottom_1()
2987 .w(px(12.))
2988 .cursor_default()
2989 .children(Scrollbar::vertical(
2990 // percentage as f32..end_offset as f32,
2991 self.vertical_scrollbar_state.clone(),
2992 )),
2993 )
2994 }
2995
2996 fn render_horizontal_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
2997 if !Self::should_show_scrollbar(cx)
2998 || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
2999 {
3000 return None;
3001 }
3002
3003 let scroll_handle = self.scroll_handle.0.borrow();
3004 let longest_item_width = scroll_handle
3005 .last_item_size
3006 .filter(|size| size.contents.width > size.item.width)?
3007 .contents
3008 .width
3009 .0 as f64;
3010 if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
3011 return None;
3012 }
3013
3014 Some(
3015 div()
3016 .occlude()
3017 .id("project-panel-horizontal-scroll")
3018 .on_mouse_move(cx.listener(|_, _, cx| {
3019 cx.notify();
3020 cx.stop_propagation()
3021 }))
3022 .on_hover(|_, cx| {
3023 cx.stop_propagation();
3024 })
3025 .on_any_mouse_down(|_, cx| {
3026 cx.stop_propagation();
3027 })
3028 .on_mouse_up(
3029 MouseButton::Left,
3030 cx.listener(|this, _, cx| {
3031 if !this.horizontal_scrollbar_state.is_dragging()
3032 && !this.focus_handle.contains_focused(cx)
3033 {
3034 this.hide_scrollbar(cx);
3035 cx.notify();
3036 }
3037
3038 cx.stop_propagation();
3039 }),
3040 )
3041 .on_scroll_wheel(cx.listener(|_, _, cx| {
3042 cx.notify();
3043 }))
3044 .w_full()
3045 .absolute()
3046 .right_1()
3047 .left_1()
3048 .bottom_1()
3049 .h(px(12.))
3050 .cursor_default()
3051 .when(self.width.is_some(), |this| {
3052 this.children(Scrollbar::horizontal(
3053 self.horizontal_scrollbar_state.clone(),
3054 ))
3055 }),
3056 )
3057 }
3058
3059 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
3060 let mut dispatch_context = KeyContext::new_with_defaults();
3061 dispatch_context.add("ProjectPanel");
3062 dispatch_context.add("menu");
3063
3064 let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
3065 "editing"
3066 } else {
3067 "not_editing"
3068 };
3069
3070 dispatch_context.add(identifier);
3071 dispatch_context
3072 }
3073
3074 fn should_show_scrollbar(cx: &AppContext) -> bool {
3075 let show = ProjectPanelSettings::get_global(cx)
3076 .scrollbar
3077 .show
3078 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
3079 match show {
3080 ShowScrollbar::Auto => true,
3081 ShowScrollbar::System => true,
3082 ShowScrollbar::Always => true,
3083 ShowScrollbar::Never => false,
3084 }
3085 }
3086
3087 fn should_autohide_scrollbar(cx: &AppContext) -> bool {
3088 let show = ProjectPanelSettings::get_global(cx)
3089 .scrollbar
3090 .show
3091 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
3092 match show {
3093 ShowScrollbar::Auto => true,
3094 ShowScrollbar::System => cx
3095 .try_global::<ScrollbarAutoHide>()
3096 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
3097 ShowScrollbar::Always => false,
3098 ShowScrollbar::Never => true,
3099 }
3100 }
3101
3102 fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
3103 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
3104 if !Self::should_autohide_scrollbar(cx) {
3105 return;
3106 }
3107 self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
3108 cx.background_executor()
3109 .timer(SCROLLBAR_SHOW_INTERVAL)
3110 .await;
3111 panel
3112 .update(&mut cx, |panel, cx| {
3113 panel.show_scrollbar = false;
3114 cx.notify();
3115 })
3116 .log_err();
3117 }))
3118 }
3119
3120 fn reveal_entry(
3121 &mut self,
3122 project: Model<Project>,
3123 entry_id: ProjectEntryId,
3124 skip_ignored: bool,
3125 cx: &mut ViewContext<'_, Self>,
3126 ) {
3127 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
3128 let worktree = worktree.read(cx);
3129 if skip_ignored
3130 && worktree
3131 .entry_for_id(entry_id)
3132 .map_or(true, |entry| entry.is_ignored)
3133 {
3134 return;
3135 }
3136
3137 let worktree_id = worktree.id();
3138 self.expand_entry(worktree_id, entry_id, cx);
3139 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
3140
3141 if self.marked_entries.len() == 1
3142 && self
3143 .marked_entries
3144 .first()
3145 .filter(|entry| entry.entry_id == entry_id)
3146 .is_none()
3147 {
3148 self.marked_entries.clear();
3149 }
3150 self.autoscroll(cx);
3151 cx.notify();
3152 }
3153 }
3154
3155 fn find_active_indent_guide(
3156 &self,
3157 indent_guides: &[IndentGuideLayout],
3158 cx: &AppContext,
3159 ) -> Option<usize> {
3160 let (worktree, entry) = self.selected_entry(cx)?;
3161
3162 // Find the parent entry of the indent guide, this will either be the
3163 // expanded folder we have selected, or the parent of the currently
3164 // selected file/collapsed directory
3165 let mut entry = entry;
3166 loop {
3167 let is_expanded_dir = entry.is_dir()
3168 && self
3169 .expanded_dir_ids
3170 .get(&worktree.id())
3171 .map(|ids| ids.binary_search(&entry.id).is_ok())
3172 .unwrap_or(false);
3173 if is_expanded_dir {
3174 break;
3175 }
3176 entry = worktree.entry_for_path(&entry.path.parent()?)?;
3177 }
3178
3179 let (active_indent_range, depth) = {
3180 let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
3181 let child_paths = &self.visible_entries[worktree_ix].1;
3182 let mut child_count = 0;
3183 let depth = entry.path.ancestors().count();
3184 while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
3185 if entry.path.ancestors().count() <= depth {
3186 break;
3187 }
3188 child_count += 1;
3189 }
3190
3191 let start = ix + 1;
3192 let end = start + child_count;
3193
3194 let (_, entries, paths) = &self.visible_entries[worktree_ix];
3195 let visible_worktree_entries =
3196 paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect());
3197
3198 // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
3199 let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
3200 (start..end, depth)
3201 };
3202
3203 let candidates = indent_guides
3204 .iter()
3205 .enumerate()
3206 .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
3207
3208 for (i, indent) in candidates {
3209 // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
3210 if active_indent_range.start <= indent.offset.y + indent.length
3211 && indent.offset.y <= active_indent_range.end
3212 {
3213 return Some(i);
3214 }
3215 }
3216 None
3217 }
3218}
3219
3220fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
3221 const ICON_SIZE_FACTOR: usize = 2;
3222 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
3223 if is_symlink {
3224 item_width += ICON_SIZE_FACTOR;
3225 }
3226 item_width
3227}
3228
3229impl Render for ProjectPanel {
3230 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
3231 let has_worktree = !self.visible_entries.is_empty();
3232 let project = self.project.read(cx);
3233 let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
3234 let show_indent_guides =
3235 ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
3236 let is_local = project.is_local();
3237
3238 if has_worktree {
3239 let item_count = self
3240 .visible_entries
3241 .iter()
3242 .map(|(_, worktree_entries, _)| worktree_entries.len())
3243 .sum();
3244
3245 fn handle_drag_move_scroll<T: 'static>(
3246 this: &mut ProjectPanel,
3247 e: &DragMoveEvent<T>,
3248 cx: &mut ViewContext<ProjectPanel>,
3249 ) {
3250 if !e.bounds.contains(&e.event.position) {
3251 return;
3252 }
3253 this.hover_scroll_task.take();
3254 let panel_height = e.bounds.size.height;
3255 if panel_height <= px(0.) {
3256 return;
3257 }
3258
3259 let event_offset = e.event.position.y - e.bounds.origin.y;
3260 // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
3261 let hovered_region_offset = event_offset / panel_height;
3262
3263 // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
3264 // These pixels offsets were picked arbitrarily.
3265 let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
3266 8.
3267 } else if hovered_region_offset <= 0.15 {
3268 5.
3269 } else if hovered_region_offset >= 0.95 {
3270 -8.
3271 } else if hovered_region_offset >= 0.85 {
3272 -5.
3273 } else {
3274 return;
3275 };
3276 let adjustment = point(px(0.), px(vertical_scroll_offset));
3277 this.hover_scroll_task = Some(cx.spawn(move |this, mut cx| async move {
3278 loop {
3279 let should_stop_scrolling = this
3280 .update(&mut cx, |this, cx| {
3281 this.hover_scroll_task.as_ref()?;
3282 let handle = this.scroll_handle.0.borrow_mut();
3283 let offset = handle.base_handle.offset();
3284
3285 handle.base_handle.set_offset(offset + adjustment);
3286 cx.notify();
3287 Some(())
3288 })
3289 .ok()
3290 .flatten()
3291 .is_some();
3292 if should_stop_scrolling {
3293 return;
3294 }
3295 cx.background_executor()
3296 .timer(Duration::from_millis(16))
3297 .await;
3298 }
3299 }));
3300 }
3301 h_flex()
3302 .id("project-panel")
3303 .group("project-panel")
3304 .on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
3305 .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
3306 .size_full()
3307 .relative()
3308 .on_hover(cx.listener(|this, hovered, cx| {
3309 if *hovered {
3310 this.show_scrollbar = true;
3311 this.hide_scrollbar_task.take();
3312 cx.notify();
3313 } else if !this.focus_handle.contains_focused(cx) {
3314 this.hide_scrollbar(cx);
3315 }
3316 }))
3317 .key_context(self.dispatch_context(cx))
3318 .on_action(cx.listener(Self::select_next))
3319 .on_action(cx.listener(Self::select_prev))
3320 .on_action(cx.listener(Self::select_first))
3321 .on_action(cx.listener(Self::select_last))
3322 .on_action(cx.listener(Self::select_parent))
3323 .on_action(cx.listener(Self::expand_selected_entry))
3324 .on_action(cx.listener(Self::collapse_selected_entry))
3325 .on_action(cx.listener(Self::collapse_all_entries))
3326 .on_action(cx.listener(Self::open))
3327 .on_action(cx.listener(Self::open_permanent))
3328 .on_action(cx.listener(Self::confirm))
3329 .on_action(cx.listener(Self::cancel))
3330 .on_action(cx.listener(Self::copy_path))
3331 .on_action(cx.listener(Self::copy_relative_path))
3332 .on_action(cx.listener(Self::new_search_in_directory))
3333 .on_action(cx.listener(Self::unfold_directory))
3334 .on_action(cx.listener(Self::fold_directory))
3335 .on_action(cx.listener(Self::remove_from_project))
3336 .when(!project.is_read_only(cx), |el| {
3337 el.on_action(cx.listener(Self::new_file))
3338 .on_action(cx.listener(Self::new_directory))
3339 .on_action(cx.listener(Self::rename))
3340 .on_action(cx.listener(Self::delete))
3341 .on_action(cx.listener(Self::trash))
3342 .on_action(cx.listener(Self::cut))
3343 .on_action(cx.listener(Self::copy))
3344 .on_action(cx.listener(Self::paste))
3345 .on_action(cx.listener(Self::duplicate))
3346 .on_click(cx.listener(|this, event: &gpui::ClickEvent, cx| {
3347 if event.up.click_count > 1 {
3348 if let Some(entry_id) = this.last_worktree_root_id {
3349 let project = this.project.read(cx);
3350
3351 let worktree_id = if let Some(worktree) =
3352 project.worktree_for_entry(entry_id, cx)
3353 {
3354 worktree.read(cx).id()
3355 } else {
3356 return;
3357 };
3358
3359 this.selection = Some(SelectedEntry {
3360 worktree_id,
3361 entry_id,
3362 });
3363
3364 this.new_file(&NewFile, cx);
3365 }
3366 }
3367 }))
3368 })
3369 .when(project.is_local(), |el| {
3370 el.on_action(cx.listener(Self::reveal_in_finder))
3371 .on_action(cx.listener(Self::open_system))
3372 .on_action(cx.listener(Self::open_in_terminal))
3373 })
3374 .when(project.is_via_ssh(), |el| {
3375 el.on_action(cx.listener(Self::open_in_terminal))
3376 })
3377 .on_mouse_down(
3378 MouseButton::Right,
3379 cx.listener(move |this, event: &MouseDownEvent, cx| {
3380 // When deploying the context menu anywhere below the last project entry,
3381 // act as if the user clicked the root of the last worktree.
3382 if let Some(entry_id) = this.last_worktree_root_id {
3383 this.deploy_context_menu(event.position, entry_id, cx);
3384 }
3385 }),
3386 )
3387 .track_focus(&self.focus_handle(cx))
3388 .child(
3389 uniform_list(cx.view().clone(), "entries", item_count, {
3390 |this, range, cx| {
3391 let mut items = Vec::with_capacity(range.end - range.start);
3392 this.for_each_visible_entry(range, cx, |id, details, cx| {
3393 items.push(this.render_entry(id, details, cx));
3394 });
3395 items
3396 }
3397 })
3398 .when(show_indent_guides, |list| {
3399 list.with_decoration(
3400 ui::indent_guides(
3401 cx.view().clone(),
3402 px(indent_size),
3403 IndentGuideColors::panel(cx),
3404 |this, range, cx| {
3405 let mut items =
3406 SmallVec::with_capacity(range.end - range.start);
3407 this.iter_visible_entries(range, cx, |entry, entries, _| {
3408 let (depth, _) =
3409 Self::calculate_depth_and_difference(entry, entries);
3410 items.push(depth);
3411 });
3412 items
3413 },
3414 )
3415 .on_click(cx.listener(
3416 |this, active_indent_guide: &IndentGuideLayout, cx| {
3417 if cx.modifiers().secondary() {
3418 let ix = active_indent_guide.offset.y;
3419 let Some((target_entry, worktree)) = maybe!({
3420 let (worktree_id, entry) = this.entry_at_index(ix)?;
3421 let worktree = this
3422 .project
3423 .read(cx)
3424 .worktree_for_id(worktree_id, cx)?;
3425 let target_entry = worktree
3426 .read(cx)
3427 .entry_for_path(&entry.path.parent()?)?;
3428 Some((target_entry, worktree))
3429 }) else {
3430 return;
3431 };
3432
3433 this.collapse_entry(target_entry.clone(), worktree, cx);
3434 }
3435 },
3436 ))
3437 .with_render_fn(
3438 cx.view().clone(),
3439 move |this, params, cx| {
3440 const LEFT_OFFSET: f32 = 14.;
3441 const PADDING_Y: f32 = 4.;
3442 const HITBOX_OVERDRAW: f32 = 3.;
3443
3444 let active_indent_guide_index =
3445 this.find_active_indent_guide(¶ms.indent_guides, cx);
3446
3447 let indent_size = params.indent_size;
3448 let item_height = params.item_height;
3449
3450 params
3451 .indent_guides
3452 .into_iter()
3453 .enumerate()
3454 .map(|(idx, layout)| {
3455 let offset = if layout.continues_offscreen {
3456 px(0.)
3457 } else {
3458 px(PADDING_Y)
3459 };
3460 let bounds = Bounds::new(
3461 point(
3462 px(layout.offset.x as f32) * indent_size
3463 + px(LEFT_OFFSET),
3464 px(layout.offset.y as f32) * item_height
3465 + offset,
3466 ),
3467 size(
3468 px(1.),
3469 px(layout.length as f32) * item_height
3470 - px(offset.0 * 2.),
3471 ),
3472 );
3473 ui::RenderedIndentGuide {
3474 bounds,
3475 layout,
3476 is_active: Some(idx) == active_indent_guide_index,
3477 hitbox: Some(Bounds::new(
3478 point(
3479 bounds.origin.x - px(HITBOX_OVERDRAW),
3480 bounds.origin.y,
3481 ),
3482 size(
3483 bounds.size.width
3484 + px(2. * HITBOX_OVERDRAW),
3485 bounds.size.height,
3486 ),
3487 )),
3488 }
3489 })
3490 .collect()
3491 },
3492 ),
3493 )
3494 })
3495 .size_full()
3496 .with_sizing_behavior(ListSizingBehavior::Infer)
3497 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
3498 .with_width_from_item(self.max_width_item_index)
3499 .track_scroll(self.scroll_handle.clone()),
3500 )
3501 .children(self.render_vertical_scrollbar(cx))
3502 .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
3503 this.pb_4().child(scrollbar)
3504 })
3505 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
3506 deferred(
3507 anchored()
3508 .position(*position)
3509 .anchor(gpui::AnchorCorner::TopLeft)
3510 .child(menu.clone()),
3511 )
3512 .with_priority(1)
3513 }))
3514 } else {
3515 v_flex()
3516 .id("empty-project_panel")
3517 .size_full()
3518 .p_4()
3519 .track_focus(&self.focus_handle(cx))
3520 .child(
3521 Button::new("open_project", "Open a project")
3522 .full_width()
3523 .key_binding(KeyBinding::for_action(&workspace::Open, cx))
3524 .on_click(cx.listener(|this, _, cx| {
3525 this.workspace
3526 .update(cx, |_, cx| cx.dispatch_action(Box::new(workspace::Open)))
3527 .log_err();
3528 })),
3529 )
3530 .when(is_local, |div| {
3531 div.drag_over::<ExternalPaths>(|style, _, cx| {
3532 style.bg(cx.theme().colors().drop_target_background)
3533 })
3534 .on_drop(cx.listener(
3535 move |this, external_paths: &ExternalPaths, cx| {
3536 this.last_external_paths_drag_over_entry = None;
3537 this.marked_entries.clear();
3538 this.hover_scroll_task.take();
3539 if let Some(task) = this
3540 .workspace
3541 .update(cx, |workspace, cx| {
3542 workspace.open_workspace_for_paths(
3543 true,
3544 external_paths.paths().to_owned(),
3545 cx,
3546 )
3547 })
3548 .log_err()
3549 {
3550 task.detach_and_log_err(cx);
3551 }
3552 cx.stop_propagation();
3553 },
3554 ))
3555 })
3556 }
3557 }
3558}
3559
3560impl Render for DraggedProjectEntryView {
3561 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
3562 let settings = ProjectPanelSettings::get_global(cx);
3563 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
3564
3565 h_flex().font(ui_font).map(|this| {
3566 if self.selections.len() > 1 && self.selections.contains(&self.selection) {
3567 this.flex_none()
3568 .w(self.width)
3569 .child(div().w(self.click_offset.x))
3570 .child(
3571 div()
3572 .p_1()
3573 .rounded_xl()
3574 .bg(cx.theme().colors().background)
3575 .child(Label::new(format!("{} entries", self.selections.len()))),
3576 )
3577 } else {
3578 this.w(self.width).bg(cx.theme().colors().background).child(
3579 ListItem::new(self.selection.entry_id.to_proto() as usize)
3580 .indent_level(self.details.depth)
3581 .indent_step_size(px(settings.indent_size))
3582 .child(if let Some(icon) = &self.details.icon {
3583 div().child(Icon::from_path(icon.clone()))
3584 } else {
3585 div()
3586 })
3587 .child(Label::new(self.details.filename.clone())),
3588 )
3589 }
3590 })
3591 }
3592}
3593
3594impl EventEmitter<Event> for ProjectPanel {}
3595
3596impl EventEmitter<PanelEvent> for ProjectPanel {}
3597
3598impl Panel for ProjectPanel {
3599 fn position(&self, cx: &WindowContext) -> DockPosition {
3600 match ProjectPanelSettings::get_global(cx).dock {
3601 ProjectPanelDockPosition::Left => DockPosition::Left,
3602 ProjectPanelDockPosition::Right => DockPosition::Right,
3603 }
3604 }
3605
3606 fn position_is_valid(&self, position: DockPosition) -> bool {
3607 matches!(position, DockPosition::Left | DockPosition::Right)
3608 }
3609
3610 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
3611 settings::update_settings_file::<ProjectPanelSettings>(
3612 self.fs.clone(),
3613 cx,
3614 move |settings, _| {
3615 let dock = match position {
3616 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
3617 DockPosition::Right => ProjectPanelDockPosition::Right,
3618 };
3619 settings.dock = Some(dock);
3620 },
3621 );
3622 }
3623
3624 fn size(&self, cx: &WindowContext) -> Pixels {
3625 self.width
3626 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
3627 }
3628
3629 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
3630 self.width = size;
3631 self.serialize(cx);
3632 cx.notify();
3633 }
3634
3635 fn icon(&self, cx: &WindowContext) -> Option<IconName> {
3636 ProjectPanelSettings::get_global(cx)
3637 .button
3638 .then_some(IconName::FileTree)
3639 }
3640
3641 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
3642 Some("Project Panel")
3643 }
3644
3645 fn toggle_action(&self) -> Box<dyn Action> {
3646 Box::new(ToggleFocus)
3647 }
3648
3649 fn persistent_name() -> &'static str {
3650 "Project Panel"
3651 }
3652
3653 fn starts_open(&self, cx: &WindowContext) -> bool {
3654 let project = &self.project.read(cx);
3655 project.visible_worktrees(cx).any(|tree| {
3656 tree.read(cx)
3657 .root_entry()
3658 .map_or(false, |entry| entry.is_dir())
3659 })
3660 }
3661}
3662
3663impl FocusableView for ProjectPanel {
3664 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
3665 self.focus_handle.clone()
3666 }
3667}
3668
3669impl ClipboardEntry {
3670 fn is_cut(&self) -> bool {
3671 matches!(self, Self::Cut { .. })
3672 }
3673
3674 fn items(&self) -> &BTreeSet<SelectedEntry> {
3675 match self {
3676 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
3677 }
3678 }
3679}
3680
3681#[cfg(test)]
3682mod tests {
3683 use super::*;
3684 use collections::HashSet;
3685 use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
3686 use pretty_assertions::assert_eq;
3687 use project::{FakeFs, WorktreeSettings};
3688 use serde_json::json;
3689 use settings::SettingsStore;
3690 use std::path::{Path, PathBuf};
3691 use ui::Context;
3692 use workspace::{
3693 item::{Item, ProjectItem},
3694 register_project_item, AppState,
3695 };
3696
3697 #[gpui::test]
3698 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
3699 init_test(cx);
3700
3701 let fs = FakeFs::new(cx.executor().clone());
3702 fs.insert_tree(
3703 "/root1",
3704 json!({
3705 ".dockerignore": "",
3706 ".git": {
3707 "HEAD": "",
3708 },
3709 "a": {
3710 "0": { "q": "", "r": "", "s": "" },
3711 "1": { "t": "", "u": "" },
3712 "2": { "v": "", "w": "", "x": "", "y": "" },
3713 },
3714 "b": {
3715 "3": { "Q": "" },
3716 "4": { "R": "", "S": "", "T": "", "U": "" },
3717 },
3718 "C": {
3719 "5": {},
3720 "6": { "V": "", "W": "" },
3721 "7": { "X": "" },
3722 "8": { "Y": {}, "Z": "" }
3723 }
3724 }),
3725 )
3726 .await;
3727 fs.insert_tree(
3728 "/root2",
3729 json!({
3730 "d": {
3731 "9": ""
3732 },
3733 "e": {}
3734 }),
3735 )
3736 .await;
3737
3738 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3739 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3740 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3741 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3742 assert_eq!(
3743 visible_entries_as_strings(&panel, 0..50, cx),
3744 &[
3745 "v root1",
3746 " > .git",
3747 " > a",
3748 " > b",
3749 " > C",
3750 " .dockerignore",
3751 "v root2",
3752 " > d",
3753 " > e",
3754 ]
3755 );
3756
3757 toggle_expand_dir(&panel, "root1/b", cx);
3758 assert_eq!(
3759 visible_entries_as_strings(&panel, 0..50, cx),
3760 &[
3761 "v root1",
3762 " > .git",
3763 " > a",
3764 " v b <== selected",
3765 " > 3",
3766 " > 4",
3767 " > C",
3768 " .dockerignore",
3769 "v root2",
3770 " > d",
3771 " > e",
3772 ]
3773 );
3774
3775 assert_eq!(
3776 visible_entries_as_strings(&panel, 6..9, cx),
3777 &[
3778 //
3779 " > C",
3780 " .dockerignore",
3781 "v root2",
3782 ]
3783 );
3784 }
3785
3786 #[gpui::test]
3787 async fn test_opening_file(cx: &mut gpui::TestAppContext) {
3788 init_test_with_editor(cx);
3789
3790 let fs = FakeFs::new(cx.executor().clone());
3791 fs.insert_tree(
3792 "/src",
3793 json!({
3794 "test": {
3795 "first.rs": "// First Rust file",
3796 "second.rs": "// Second Rust file",
3797 "third.rs": "// Third Rust file",
3798 }
3799 }),
3800 )
3801 .await;
3802
3803 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3804 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3805 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3806 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3807
3808 toggle_expand_dir(&panel, "src/test", cx);
3809 select_path(&panel, "src/test/first.rs", cx);
3810 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3811 cx.executor().run_until_parked();
3812 assert_eq!(
3813 visible_entries_as_strings(&panel, 0..10, cx),
3814 &[
3815 "v src",
3816 " v test",
3817 " first.rs <== selected <== marked",
3818 " second.rs",
3819 " third.rs"
3820 ]
3821 );
3822 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
3823
3824 select_path(&panel, "src/test/second.rs", cx);
3825 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3826 cx.executor().run_until_parked();
3827 assert_eq!(
3828 visible_entries_as_strings(&panel, 0..10, cx),
3829 &[
3830 "v src",
3831 " v test",
3832 " first.rs",
3833 " second.rs <== selected <== marked",
3834 " third.rs"
3835 ]
3836 );
3837 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
3838 }
3839
3840 #[gpui::test]
3841 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
3842 init_test(cx);
3843 cx.update(|cx| {
3844 cx.update_global::<SettingsStore, _>(|store, cx| {
3845 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3846 worktree_settings.file_scan_exclusions =
3847 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
3848 });
3849 });
3850 });
3851
3852 let fs = FakeFs::new(cx.background_executor.clone());
3853 fs.insert_tree(
3854 "/root1",
3855 json!({
3856 ".dockerignore": "",
3857 ".git": {
3858 "HEAD": "",
3859 },
3860 "a": {
3861 "0": { "q": "", "r": "", "s": "" },
3862 "1": { "t": "", "u": "" },
3863 "2": { "v": "", "w": "", "x": "", "y": "" },
3864 },
3865 "b": {
3866 "3": { "Q": "" },
3867 "4": { "R": "", "S": "", "T": "", "U": "" },
3868 },
3869 "C": {
3870 "5": {},
3871 "6": { "V": "", "W": "" },
3872 "7": { "X": "" },
3873 "8": { "Y": {}, "Z": "" }
3874 }
3875 }),
3876 )
3877 .await;
3878 fs.insert_tree(
3879 "/root2",
3880 json!({
3881 "d": {
3882 "4": ""
3883 },
3884 "e": {}
3885 }),
3886 )
3887 .await;
3888
3889 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3890 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3891 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3892 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3893 assert_eq!(
3894 visible_entries_as_strings(&panel, 0..50, cx),
3895 &[
3896 "v root1",
3897 " > a",
3898 " > b",
3899 " > C",
3900 " .dockerignore",
3901 "v root2",
3902 " > d",
3903 " > e",
3904 ]
3905 );
3906
3907 toggle_expand_dir(&panel, "root1/b", cx);
3908 assert_eq!(
3909 visible_entries_as_strings(&panel, 0..50, cx),
3910 &[
3911 "v root1",
3912 " > a",
3913 " v b <== selected",
3914 " > 3",
3915 " > C",
3916 " .dockerignore",
3917 "v root2",
3918 " > d",
3919 " > e",
3920 ]
3921 );
3922
3923 toggle_expand_dir(&panel, "root2/d", cx);
3924 assert_eq!(
3925 visible_entries_as_strings(&panel, 0..50, cx),
3926 &[
3927 "v root1",
3928 " > a",
3929 " v b",
3930 " > 3",
3931 " > C",
3932 " .dockerignore",
3933 "v root2",
3934 " v d <== selected",
3935 " > e",
3936 ]
3937 );
3938
3939 toggle_expand_dir(&panel, "root2/e", cx);
3940 assert_eq!(
3941 visible_entries_as_strings(&panel, 0..50, cx),
3942 &[
3943 "v root1",
3944 " > a",
3945 " v b",
3946 " > 3",
3947 " > C",
3948 " .dockerignore",
3949 "v root2",
3950 " v d",
3951 " v e <== selected",
3952 ]
3953 );
3954 }
3955
3956 #[gpui::test]
3957 async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
3958 init_test(cx);
3959
3960 let fs = FakeFs::new(cx.executor().clone());
3961 fs.insert_tree(
3962 "/root1",
3963 json!({
3964 "dir_1": {
3965 "nested_dir_1": {
3966 "nested_dir_2": {
3967 "nested_dir_3": {
3968 "file_a.java": "// File contents",
3969 "file_b.java": "// File contents",
3970 "file_c.java": "// File contents",
3971 "nested_dir_4": {
3972 "nested_dir_5": {
3973 "file_d.java": "// File contents",
3974 }
3975 }
3976 }
3977 }
3978 }
3979 }
3980 }),
3981 )
3982 .await;
3983 fs.insert_tree(
3984 "/root2",
3985 json!({
3986 "dir_2": {
3987 "file_1.java": "// File contents",
3988 }
3989 }),
3990 )
3991 .await;
3992
3993 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3994 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3995 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3996 cx.update(|cx| {
3997 let settings = *ProjectPanelSettings::get_global(cx);
3998 ProjectPanelSettings::override_global(
3999 ProjectPanelSettings {
4000 auto_fold_dirs: true,
4001 ..settings
4002 },
4003 cx,
4004 );
4005 });
4006 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4007 assert_eq!(
4008 visible_entries_as_strings(&panel, 0..10, cx),
4009 &[
4010 "v root1",
4011 " > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4012 "v root2",
4013 " > dir_2",
4014 ]
4015 );
4016
4017 toggle_expand_dir(
4018 &panel,
4019 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4020 cx,
4021 );
4022 assert_eq!(
4023 visible_entries_as_strings(&panel, 0..10, cx),
4024 &[
4025 "v root1",
4026 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
4027 " > nested_dir_4/nested_dir_5",
4028 " file_a.java",
4029 " file_b.java",
4030 " file_c.java",
4031 "v root2",
4032 " > dir_2",
4033 ]
4034 );
4035
4036 toggle_expand_dir(
4037 &panel,
4038 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
4039 cx,
4040 );
4041 assert_eq!(
4042 visible_entries_as_strings(&panel, 0..10, cx),
4043 &[
4044 "v root1",
4045 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4046 " v nested_dir_4/nested_dir_5 <== selected",
4047 " file_d.java",
4048 " file_a.java",
4049 " file_b.java",
4050 " file_c.java",
4051 "v root2",
4052 " > dir_2",
4053 ]
4054 );
4055 toggle_expand_dir(&panel, "root2/dir_2", cx);
4056 assert_eq!(
4057 visible_entries_as_strings(&panel, 0..10, cx),
4058 &[
4059 "v root1",
4060 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4061 " v nested_dir_4/nested_dir_5",
4062 " file_d.java",
4063 " file_a.java",
4064 " file_b.java",
4065 " file_c.java",
4066 "v root2",
4067 " v dir_2 <== selected",
4068 " file_1.java",
4069 ]
4070 );
4071 }
4072
4073 #[gpui::test(iterations = 30)]
4074 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
4075 init_test(cx);
4076
4077 let fs = FakeFs::new(cx.executor().clone());
4078 fs.insert_tree(
4079 "/root1",
4080 json!({
4081 ".dockerignore": "",
4082 ".git": {
4083 "HEAD": "",
4084 },
4085 "a": {
4086 "0": { "q": "", "r": "", "s": "" },
4087 "1": { "t": "", "u": "" },
4088 "2": { "v": "", "w": "", "x": "", "y": "" },
4089 },
4090 "b": {
4091 "3": { "Q": "" },
4092 "4": { "R": "", "S": "", "T": "", "U": "" },
4093 },
4094 "C": {
4095 "5": {},
4096 "6": { "V": "", "W": "" },
4097 "7": { "X": "" },
4098 "8": { "Y": {}, "Z": "" }
4099 }
4100 }),
4101 )
4102 .await;
4103 fs.insert_tree(
4104 "/root2",
4105 json!({
4106 "d": {
4107 "9": ""
4108 },
4109 "e": {}
4110 }),
4111 )
4112 .await;
4113
4114 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4115 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4116 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4117 let panel = workspace
4118 .update(cx, |workspace, cx| {
4119 let panel = ProjectPanel::new(workspace, cx);
4120 workspace.add_panel(panel.clone(), cx);
4121 panel
4122 })
4123 .unwrap();
4124
4125 select_path(&panel, "root1", cx);
4126 assert_eq!(
4127 visible_entries_as_strings(&panel, 0..10, cx),
4128 &[
4129 "v root1 <== selected",
4130 " > .git",
4131 " > a",
4132 " > b",
4133 " > C",
4134 " .dockerignore",
4135 "v root2",
4136 " > d",
4137 " > e",
4138 ]
4139 );
4140
4141 // Add a file with the root folder selected. The filename editor is placed
4142 // before the first file in the root folder.
4143 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4144 panel.update(cx, |panel, cx| {
4145 assert!(panel.filename_editor.read(cx).is_focused(cx));
4146 });
4147 assert_eq!(
4148 visible_entries_as_strings(&panel, 0..10, cx),
4149 &[
4150 "v root1",
4151 " > .git",
4152 " > a",
4153 " > b",
4154 " > C",
4155 " [EDITOR: ''] <== selected",
4156 " .dockerignore",
4157 "v root2",
4158 " > d",
4159 " > e",
4160 ]
4161 );
4162
4163 let confirm = panel.update(cx, |panel, cx| {
4164 panel
4165 .filename_editor
4166 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
4167 panel.confirm_edit(cx).unwrap()
4168 });
4169 assert_eq!(
4170 visible_entries_as_strings(&panel, 0..10, cx),
4171 &[
4172 "v root1",
4173 " > .git",
4174 " > a",
4175 " > b",
4176 " > C",
4177 " [PROCESSING: 'the-new-filename'] <== selected",
4178 " .dockerignore",
4179 "v root2",
4180 " > d",
4181 " > e",
4182 ]
4183 );
4184
4185 confirm.await.unwrap();
4186 assert_eq!(
4187 visible_entries_as_strings(&panel, 0..10, cx),
4188 &[
4189 "v root1",
4190 " > .git",
4191 " > a",
4192 " > b",
4193 " > C",
4194 " .dockerignore",
4195 " the-new-filename <== selected <== marked",
4196 "v root2",
4197 " > d",
4198 " > e",
4199 ]
4200 );
4201
4202 select_path(&panel, "root1/b", cx);
4203 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4204 assert_eq!(
4205 visible_entries_as_strings(&panel, 0..10, cx),
4206 &[
4207 "v root1",
4208 " > .git",
4209 " > a",
4210 " v b",
4211 " > 3",
4212 " > 4",
4213 " [EDITOR: ''] <== selected",
4214 " > C",
4215 " .dockerignore",
4216 " the-new-filename",
4217 ]
4218 );
4219
4220 panel
4221 .update(cx, |panel, cx| {
4222 panel
4223 .filename_editor
4224 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
4225 panel.confirm_edit(cx).unwrap()
4226 })
4227 .await
4228 .unwrap();
4229 assert_eq!(
4230 visible_entries_as_strings(&panel, 0..10, cx),
4231 &[
4232 "v root1",
4233 " > .git",
4234 " > a",
4235 " v b",
4236 " > 3",
4237 " > 4",
4238 " another-filename.txt <== selected <== marked",
4239 " > C",
4240 " .dockerignore",
4241 " the-new-filename",
4242 ]
4243 );
4244
4245 select_path(&panel, "root1/b/another-filename.txt", cx);
4246 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4247 assert_eq!(
4248 visible_entries_as_strings(&panel, 0..10, cx),
4249 &[
4250 "v root1",
4251 " > .git",
4252 " > a",
4253 " v b",
4254 " > 3",
4255 " > 4",
4256 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
4257 " > C",
4258 " .dockerignore",
4259 " the-new-filename",
4260 ]
4261 );
4262
4263 let confirm = panel.update(cx, |panel, cx| {
4264 panel.filename_editor.update(cx, |editor, cx| {
4265 let file_name_selections = editor.selections.all::<usize>(cx);
4266 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
4267 let file_name_selection = &file_name_selections[0];
4268 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
4269 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
4270
4271 editor.set_text("a-different-filename.tar.gz", cx)
4272 });
4273 panel.confirm_edit(cx).unwrap()
4274 });
4275 assert_eq!(
4276 visible_entries_as_strings(&panel, 0..10, cx),
4277 &[
4278 "v root1",
4279 " > .git",
4280 " > a",
4281 " v b",
4282 " > 3",
4283 " > 4",
4284 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
4285 " > C",
4286 " .dockerignore",
4287 " the-new-filename",
4288 ]
4289 );
4290
4291 confirm.await.unwrap();
4292 assert_eq!(
4293 visible_entries_as_strings(&panel, 0..10, cx),
4294 &[
4295 "v root1",
4296 " > .git",
4297 " > a",
4298 " v b",
4299 " > 3",
4300 " > 4",
4301 " a-different-filename.tar.gz <== selected",
4302 " > C",
4303 " .dockerignore",
4304 " the-new-filename",
4305 ]
4306 );
4307
4308 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4309 assert_eq!(
4310 visible_entries_as_strings(&panel, 0..10, cx),
4311 &[
4312 "v root1",
4313 " > .git",
4314 " > a",
4315 " v b",
4316 " > 3",
4317 " > 4",
4318 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
4319 " > C",
4320 " .dockerignore",
4321 " the-new-filename",
4322 ]
4323 );
4324
4325 panel.update(cx, |panel, cx| {
4326 panel.filename_editor.update(cx, |editor, cx| {
4327 let file_name_selections = editor.selections.all::<usize>(cx);
4328 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
4329 let file_name_selection = &file_name_selections[0];
4330 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
4331 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..");
4332
4333 });
4334 panel.cancel(&menu::Cancel, cx)
4335 });
4336
4337 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4338 assert_eq!(
4339 visible_entries_as_strings(&panel, 0..10, cx),
4340 &[
4341 "v root1",
4342 " > .git",
4343 " > a",
4344 " v b",
4345 " > 3",
4346 " > 4",
4347 " > [EDITOR: ''] <== selected",
4348 " a-different-filename.tar.gz",
4349 " > C",
4350 " .dockerignore",
4351 ]
4352 );
4353
4354 let confirm = panel.update(cx, |panel, cx| {
4355 panel
4356 .filename_editor
4357 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
4358 panel.confirm_edit(cx).unwrap()
4359 });
4360 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
4361 assert_eq!(
4362 visible_entries_as_strings(&panel, 0..10, cx),
4363 &[
4364 "v root1",
4365 " > .git",
4366 " > a",
4367 " v b",
4368 " > 3",
4369 " > 4",
4370 " > [PROCESSING: 'new-dir']",
4371 " a-different-filename.tar.gz <== selected",
4372 " > C",
4373 " .dockerignore",
4374 ]
4375 );
4376
4377 confirm.await.unwrap();
4378 assert_eq!(
4379 visible_entries_as_strings(&panel, 0..10, cx),
4380 &[
4381 "v root1",
4382 " > .git",
4383 " > a",
4384 " v b",
4385 " > 3",
4386 " > 4",
4387 " > new-dir",
4388 " a-different-filename.tar.gz <== selected",
4389 " > C",
4390 " .dockerignore",
4391 ]
4392 );
4393
4394 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
4395 assert_eq!(
4396 visible_entries_as_strings(&panel, 0..10, cx),
4397 &[
4398 "v root1",
4399 " > .git",
4400 " > a",
4401 " v b",
4402 " > 3",
4403 " > 4",
4404 " > new-dir",
4405 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
4406 " > C",
4407 " .dockerignore",
4408 ]
4409 );
4410
4411 // Dismiss the rename editor when it loses focus.
4412 workspace.update(cx, |_, cx| cx.blur()).unwrap();
4413 assert_eq!(
4414 visible_entries_as_strings(&panel, 0..10, cx),
4415 &[
4416 "v root1",
4417 " > .git",
4418 " > a",
4419 " v b",
4420 " > 3",
4421 " > 4",
4422 " > new-dir",
4423 " a-different-filename.tar.gz <== selected",
4424 " > C",
4425 " .dockerignore",
4426 ]
4427 );
4428 }
4429
4430 #[gpui::test(iterations = 10)]
4431 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
4432 init_test(cx);
4433
4434 let fs = FakeFs::new(cx.executor().clone());
4435 fs.insert_tree(
4436 "/root1",
4437 json!({
4438 ".dockerignore": "",
4439 ".git": {
4440 "HEAD": "",
4441 },
4442 "a": {
4443 "0": { "q": "", "r": "", "s": "" },
4444 "1": { "t": "", "u": "" },
4445 "2": { "v": "", "w": "", "x": "", "y": "" },
4446 },
4447 "b": {
4448 "3": { "Q": "" },
4449 "4": { "R": "", "S": "", "T": "", "U": "" },
4450 },
4451 "C": {
4452 "5": {},
4453 "6": { "V": "", "W": "" },
4454 "7": { "X": "" },
4455 "8": { "Y": {}, "Z": "" }
4456 }
4457 }),
4458 )
4459 .await;
4460 fs.insert_tree(
4461 "/root2",
4462 json!({
4463 "d": {
4464 "9": ""
4465 },
4466 "e": {}
4467 }),
4468 )
4469 .await;
4470
4471 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4472 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4473 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4474 let panel = workspace
4475 .update(cx, |workspace, cx| {
4476 let panel = ProjectPanel::new(workspace, cx);
4477 workspace.add_panel(panel.clone(), cx);
4478 panel
4479 })
4480 .unwrap();
4481
4482 select_path(&panel, "root1", cx);
4483 assert_eq!(
4484 visible_entries_as_strings(&panel, 0..10, cx),
4485 &[
4486 "v root1 <== selected",
4487 " > .git",
4488 " > a",
4489 " > b",
4490 " > C",
4491 " .dockerignore",
4492 "v root2",
4493 " > d",
4494 " > e",
4495 ]
4496 );
4497
4498 // Add a file with the root folder selected. The filename editor is placed
4499 // before the first file in the root folder.
4500 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4501 panel.update(cx, |panel, cx| {
4502 assert!(panel.filename_editor.read(cx).is_focused(cx));
4503 });
4504 assert_eq!(
4505 visible_entries_as_strings(&panel, 0..10, cx),
4506 &[
4507 "v root1",
4508 " > .git",
4509 " > a",
4510 " > b",
4511 " > C",
4512 " [EDITOR: ''] <== selected",
4513 " .dockerignore",
4514 "v root2",
4515 " > d",
4516 " > e",
4517 ]
4518 );
4519
4520 let confirm = panel.update(cx, |panel, cx| {
4521 panel.filename_editor.update(cx, |editor, cx| {
4522 editor.set_text("/bdir1/dir2/the-new-filename", cx)
4523 });
4524 panel.confirm_edit(cx).unwrap()
4525 });
4526
4527 assert_eq!(
4528 visible_entries_as_strings(&panel, 0..10, cx),
4529 &[
4530 "v root1",
4531 " > .git",
4532 " > a",
4533 " > b",
4534 " > C",
4535 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
4536 " .dockerignore",
4537 "v root2",
4538 " > d",
4539 " > e",
4540 ]
4541 );
4542
4543 confirm.await.unwrap();
4544 assert_eq!(
4545 visible_entries_as_strings(&panel, 0..13, cx),
4546 &[
4547 "v root1",
4548 " > .git",
4549 " > a",
4550 " > b",
4551 " v bdir1",
4552 " v dir2",
4553 " the-new-filename <== selected <== marked",
4554 " > C",
4555 " .dockerignore",
4556 "v root2",
4557 " > d",
4558 " > e",
4559 ]
4560 );
4561 }
4562
4563 #[gpui::test]
4564 async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
4565 init_test(cx);
4566
4567 let fs = FakeFs::new(cx.executor().clone());
4568 fs.insert_tree(
4569 "/root1",
4570 json!({
4571 ".dockerignore": "",
4572 ".git": {
4573 "HEAD": "",
4574 },
4575 }),
4576 )
4577 .await;
4578
4579 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4580 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4581 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4582 let panel = workspace
4583 .update(cx, |workspace, cx| {
4584 let panel = ProjectPanel::new(workspace, cx);
4585 workspace.add_panel(panel.clone(), cx);
4586 panel
4587 })
4588 .unwrap();
4589
4590 select_path(&panel, "root1", cx);
4591 assert_eq!(
4592 visible_entries_as_strings(&panel, 0..10, cx),
4593 &["v root1 <== selected", " > .git", " .dockerignore",]
4594 );
4595
4596 // Add a file with the root folder selected. The filename editor is placed
4597 // before the first file in the root folder.
4598 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4599 panel.update(cx, |panel, cx| {
4600 assert!(panel.filename_editor.read(cx).is_focused(cx));
4601 });
4602 assert_eq!(
4603 visible_entries_as_strings(&panel, 0..10, cx),
4604 &[
4605 "v root1",
4606 " > .git",
4607 " [EDITOR: ''] <== selected",
4608 " .dockerignore",
4609 ]
4610 );
4611
4612 let confirm = panel.update(cx, |panel, cx| {
4613 panel
4614 .filename_editor
4615 .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
4616 panel.confirm_edit(cx).unwrap()
4617 });
4618
4619 assert_eq!(
4620 visible_entries_as_strings(&panel, 0..10, cx),
4621 &[
4622 "v root1",
4623 " > .git",
4624 " [PROCESSING: '/new_dir/'] <== selected",
4625 " .dockerignore",
4626 ]
4627 );
4628
4629 confirm.await.unwrap();
4630 assert_eq!(
4631 visible_entries_as_strings(&panel, 0..13, cx),
4632 &[
4633 "v root1",
4634 " > .git",
4635 " v new_dir <== selected",
4636 " .dockerignore",
4637 ]
4638 );
4639 }
4640
4641 #[gpui::test]
4642 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
4643 init_test(cx);
4644
4645 let fs = FakeFs::new(cx.executor().clone());
4646 fs.insert_tree(
4647 "/root1",
4648 json!({
4649 "one.two.txt": "",
4650 "one.txt": ""
4651 }),
4652 )
4653 .await;
4654
4655 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4656 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4657 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4658 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4659
4660 panel.update(cx, |panel, cx| {
4661 panel.select_next(&Default::default(), cx);
4662 panel.select_next(&Default::default(), cx);
4663 });
4664
4665 assert_eq!(
4666 visible_entries_as_strings(&panel, 0..50, cx),
4667 &[
4668 //
4669 "v root1",
4670 " one.txt <== selected",
4671 " one.two.txt",
4672 ]
4673 );
4674
4675 // Regression test - file name is created correctly when
4676 // the copied file's name contains multiple dots.
4677 panel.update(cx, |panel, cx| {
4678 panel.copy(&Default::default(), cx);
4679 panel.paste(&Default::default(), cx);
4680 });
4681 cx.executor().run_until_parked();
4682
4683 assert_eq!(
4684 visible_entries_as_strings(&panel, 0..50, cx),
4685 &[
4686 //
4687 "v root1",
4688 " one.txt",
4689 " one copy.txt <== selected",
4690 " one.two.txt",
4691 ]
4692 );
4693
4694 panel.update(cx, |panel, cx| {
4695 panel.paste(&Default::default(), cx);
4696 });
4697 cx.executor().run_until_parked();
4698
4699 assert_eq!(
4700 visible_entries_as_strings(&panel, 0..50, cx),
4701 &[
4702 //
4703 "v root1",
4704 " one.txt",
4705 " one copy.txt",
4706 " one copy 1.txt <== selected",
4707 " one.two.txt",
4708 ]
4709 );
4710 }
4711
4712 #[gpui::test]
4713 async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
4714 init_test(cx);
4715
4716 let fs = FakeFs::new(cx.executor().clone());
4717 fs.insert_tree(
4718 "/root1",
4719 json!({
4720 "one.txt": "",
4721 "two.txt": "",
4722 "three.txt": "",
4723 "a": {
4724 "0": { "q": "", "r": "", "s": "" },
4725 "1": { "t": "", "u": "" },
4726 "2": { "v": "", "w": "", "x": "", "y": "" },
4727 },
4728 }),
4729 )
4730 .await;
4731
4732 fs.insert_tree(
4733 "/root2",
4734 json!({
4735 "one.txt": "",
4736 "two.txt": "",
4737 "four.txt": "",
4738 "b": {
4739 "3": { "Q": "" },
4740 "4": { "R": "", "S": "", "T": "", "U": "" },
4741 },
4742 }),
4743 )
4744 .await;
4745
4746 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4747 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4748 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4749 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4750
4751 select_path(&panel, "root1/three.txt", cx);
4752 panel.update(cx, |panel, cx| {
4753 panel.cut(&Default::default(), cx);
4754 });
4755
4756 select_path(&panel, "root2/one.txt", cx);
4757 panel.update(cx, |panel, cx| {
4758 panel.select_next(&Default::default(), cx);
4759 panel.paste(&Default::default(), cx);
4760 });
4761 cx.executor().run_until_parked();
4762 assert_eq!(
4763 visible_entries_as_strings(&panel, 0..50, cx),
4764 &[
4765 //
4766 "v root1",
4767 " > a",
4768 " one.txt",
4769 " two.txt",
4770 "v root2",
4771 " > b",
4772 " four.txt",
4773 " one.txt",
4774 " three.txt <== selected",
4775 " two.txt",
4776 ]
4777 );
4778
4779 select_path(&panel, "root1/a", cx);
4780 panel.update(cx, |panel, cx| {
4781 panel.cut(&Default::default(), cx);
4782 });
4783 select_path(&panel, "root2/two.txt", cx);
4784 panel.update(cx, |panel, cx| {
4785 panel.select_next(&Default::default(), cx);
4786 panel.paste(&Default::default(), cx);
4787 });
4788
4789 cx.executor().run_until_parked();
4790 assert_eq!(
4791 visible_entries_as_strings(&panel, 0..50, cx),
4792 &[
4793 //
4794 "v root1",
4795 " one.txt",
4796 " two.txt",
4797 "v root2",
4798 " > a <== selected",
4799 " > b",
4800 " four.txt",
4801 " one.txt",
4802 " three.txt",
4803 " two.txt",
4804 ]
4805 );
4806 }
4807
4808 #[gpui::test]
4809 async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
4810 init_test(cx);
4811
4812 let fs = FakeFs::new(cx.executor().clone());
4813 fs.insert_tree(
4814 "/root1",
4815 json!({
4816 "one.txt": "",
4817 "two.txt": "",
4818 "three.txt": "",
4819 "a": {
4820 "0": { "q": "", "r": "", "s": "" },
4821 "1": { "t": "", "u": "" },
4822 "2": { "v": "", "w": "", "x": "", "y": "" },
4823 },
4824 }),
4825 )
4826 .await;
4827
4828 fs.insert_tree(
4829 "/root2",
4830 json!({
4831 "one.txt": "",
4832 "two.txt": "",
4833 "four.txt": "",
4834 "b": {
4835 "3": { "Q": "" },
4836 "4": { "R": "", "S": "", "T": "", "U": "" },
4837 },
4838 }),
4839 )
4840 .await;
4841
4842 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4843 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4844 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4845 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4846
4847 select_path(&panel, "root1/three.txt", cx);
4848 panel.update(cx, |panel, cx| {
4849 panel.copy(&Default::default(), cx);
4850 });
4851
4852 select_path(&panel, "root2/one.txt", cx);
4853 panel.update(cx, |panel, cx| {
4854 panel.select_next(&Default::default(), cx);
4855 panel.paste(&Default::default(), cx);
4856 });
4857 cx.executor().run_until_parked();
4858 assert_eq!(
4859 visible_entries_as_strings(&panel, 0..50, cx),
4860 &[
4861 //
4862 "v root1",
4863 " > a",
4864 " one.txt",
4865 " three.txt",
4866 " two.txt",
4867 "v root2",
4868 " > b",
4869 " four.txt",
4870 " one.txt",
4871 " three.txt <== selected",
4872 " two.txt",
4873 ]
4874 );
4875
4876 select_path(&panel, "root1/three.txt", cx);
4877 panel.update(cx, |panel, cx| {
4878 panel.copy(&Default::default(), cx);
4879 });
4880 select_path(&panel, "root2/two.txt", cx);
4881 panel.update(cx, |panel, cx| {
4882 panel.select_next(&Default::default(), cx);
4883 panel.paste(&Default::default(), cx);
4884 });
4885
4886 cx.executor().run_until_parked();
4887 assert_eq!(
4888 visible_entries_as_strings(&panel, 0..50, cx),
4889 &[
4890 //
4891 "v root1",
4892 " > a",
4893 " one.txt",
4894 " three.txt",
4895 " two.txt",
4896 "v root2",
4897 " > b",
4898 " four.txt",
4899 " one.txt",
4900 " three.txt",
4901 " three copy.txt <== selected",
4902 " two.txt",
4903 ]
4904 );
4905
4906 select_path(&panel, "root1/a", cx);
4907 panel.update(cx, |panel, cx| {
4908 panel.copy(&Default::default(), cx);
4909 });
4910 select_path(&panel, "root2/two.txt", cx);
4911 panel.update(cx, |panel, cx| {
4912 panel.select_next(&Default::default(), cx);
4913 panel.paste(&Default::default(), cx);
4914 });
4915
4916 cx.executor().run_until_parked();
4917 assert_eq!(
4918 visible_entries_as_strings(&panel, 0..50, cx),
4919 &[
4920 //
4921 "v root1",
4922 " > a",
4923 " one.txt",
4924 " three.txt",
4925 " two.txt",
4926 "v root2",
4927 " > a <== selected",
4928 " > b",
4929 " four.txt",
4930 " one.txt",
4931 " three.txt",
4932 " three copy.txt",
4933 " two.txt",
4934 ]
4935 );
4936 }
4937
4938 #[gpui::test]
4939 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
4940 init_test(cx);
4941
4942 let fs = FakeFs::new(cx.executor().clone());
4943 fs.insert_tree(
4944 "/root",
4945 json!({
4946 "a": {
4947 "one.txt": "",
4948 "two.txt": "",
4949 "inner_dir": {
4950 "three.txt": "",
4951 "four.txt": "",
4952 }
4953 },
4954 "b": {}
4955 }),
4956 )
4957 .await;
4958
4959 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4960 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4961 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4962 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4963
4964 select_path(&panel, "root/a", cx);
4965 panel.update(cx, |panel, cx| {
4966 panel.copy(&Default::default(), cx);
4967 panel.select_next(&Default::default(), cx);
4968 panel.paste(&Default::default(), cx);
4969 });
4970 cx.executor().run_until_parked();
4971
4972 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
4973 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
4974
4975 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
4976 assert_ne!(
4977 pasted_dir_file, None,
4978 "Pasted directory file should have an entry"
4979 );
4980
4981 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
4982 assert_ne!(
4983 pasted_dir_inner_dir, None,
4984 "Directories inside pasted directory should have an entry"
4985 );
4986
4987 toggle_expand_dir(&panel, "root/b/a", cx);
4988 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
4989
4990 assert_eq!(
4991 visible_entries_as_strings(&panel, 0..50, cx),
4992 &[
4993 //
4994 "v root",
4995 " > a",
4996 " v b",
4997 " v a",
4998 " v inner_dir <== selected",
4999 " four.txt",
5000 " three.txt",
5001 " one.txt",
5002 " two.txt",
5003 ]
5004 );
5005
5006 select_path(&panel, "root", cx);
5007 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
5008 cx.executor().run_until_parked();
5009 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
5010 cx.executor().run_until_parked();
5011 assert_eq!(
5012 visible_entries_as_strings(&panel, 0..50, cx),
5013 &[
5014 //
5015 "v root",
5016 " > a",
5017 " v a copy",
5018 " > a <== selected",
5019 " > inner_dir",
5020 " one.txt",
5021 " two.txt",
5022 " v b",
5023 " v a",
5024 " v inner_dir",
5025 " four.txt",
5026 " three.txt",
5027 " one.txt",
5028 " two.txt"
5029 ]
5030 );
5031 }
5032
5033 #[gpui::test]
5034 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
5035 init_test_with_editor(cx);
5036
5037 let fs = FakeFs::new(cx.executor().clone());
5038 fs.insert_tree(
5039 "/src",
5040 json!({
5041 "test": {
5042 "first.rs": "// First Rust file",
5043 "second.rs": "// Second Rust file",
5044 "third.rs": "// Third Rust file",
5045 }
5046 }),
5047 )
5048 .await;
5049
5050 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5051 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5052 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5053 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5054
5055 toggle_expand_dir(&panel, "src/test", cx);
5056 select_path(&panel, "src/test/first.rs", cx);
5057 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5058 cx.executor().run_until_parked();
5059 assert_eq!(
5060 visible_entries_as_strings(&panel, 0..10, cx),
5061 &[
5062 "v src",
5063 " v test",
5064 " first.rs <== selected <== marked",
5065 " second.rs",
5066 " third.rs"
5067 ]
5068 );
5069 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
5070
5071 submit_deletion(&panel, cx);
5072 assert_eq!(
5073 visible_entries_as_strings(&panel, 0..10, cx),
5074 &[
5075 "v src",
5076 " v test",
5077 " second.rs",
5078 " third.rs"
5079 ],
5080 "Project panel should have no deleted file, no other file is selected in it"
5081 );
5082 ensure_no_open_items_and_panes(&workspace, cx);
5083
5084 select_path(&panel, "src/test/second.rs", cx);
5085 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5086 cx.executor().run_until_parked();
5087 assert_eq!(
5088 visible_entries_as_strings(&panel, 0..10, cx),
5089 &[
5090 "v src",
5091 " v test",
5092 " second.rs <== selected <== marked",
5093 " third.rs"
5094 ]
5095 );
5096 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
5097
5098 workspace
5099 .update(cx, |workspace, cx| {
5100 let active_items = workspace
5101 .panes()
5102 .iter()
5103 .filter_map(|pane| pane.read(cx).active_item())
5104 .collect::<Vec<_>>();
5105 assert_eq!(active_items.len(), 1);
5106 let open_editor = active_items
5107 .into_iter()
5108 .next()
5109 .unwrap()
5110 .downcast::<Editor>()
5111 .expect("Open item should be an editor");
5112 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
5113 })
5114 .unwrap();
5115 submit_deletion_skipping_prompt(&panel, cx);
5116 assert_eq!(
5117 visible_entries_as_strings(&panel, 0..10, cx),
5118 &["v src", " v test", " third.rs"],
5119 "Project panel should have no deleted file, with one last file remaining"
5120 );
5121 ensure_no_open_items_and_panes(&workspace, cx);
5122 }
5123
5124 #[gpui::test]
5125 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
5126 init_test_with_editor(cx);
5127
5128 let fs = FakeFs::new(cx.executor().clone());
5129 fs.insert_tree(
5130 "/src",
5131 json!({
5132 "test": {
5133 "first.rs": "// First Rust file",
5134 "second.rs": "// Second Rust file",
5135 "third.rs": "// Third Rust file",
5136 }
5137 }),
5138 )
5139 .await;
5140
5141 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5142 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5143 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5144 let panel = workspace
5145 .update(cx, |workspace, cx| {
5146 let panel = ProjectPanel::new(workspace, cx);
5147 workspace.add_panel(panel.clone(), cx);
5148 panel
5149 })
5150 .unwrap();
5151
5152 select_path(&panel, "src/", cx);
5153 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
5154 cx.executor().run_until_parked();
5155 assert_eq!(
5156 visible_entries_as_strings(&panel, 0..10, cx),
5157 &[
5158 //
5159 "v src <== selected",
5160 " > test"
5161 ]
5162 );
5163 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
5164 panel.update(cx, |panel, cx| {
5165 assert!(panel.filename_editor.read(cx).is_focused(cx));
5166 });
5167 assert_eq!(
5168 visible_entries_as_strings(&panel, 0..10, cx),
5169 &[
5170 //
5171 "v src",
5172 " > [EDITOR: ''] <== selected",
5173 " > test"
5174 ]
5175 );
5176 panel.update(cx, |panel, cx| {
5177 panel
5178 .filename_editor
5179 .update(cx, |editor, cx| editor.set_text("test", cx));
5180 assert!(
5181 panel.confirm_edit(cx).is_none(),
5182 "Should not allow to confirm on conflicting new directory name"
5183 )
5184 });
5185 assert_eq!(
5186 visible_entries_as_strings(&panel, 0..10, cx),
5187 &[
5188 //
5189 "v src",
5190 " > test"
5191 ],
5192 "File list should be unchanged after failed folder create confirmation"
5193 );
5194
5195 select_path(&panel, "src/test/", cx);
5196 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
5197 cx.executor().run_until_parked();
5198 assert_eq!(
5199 visible_entries_as_strings(&panel, 0..10, cx),
5200 &[
5201 //
5202 "v src",
5203 " > test <== selected"
5204 ]
5205 );
5206 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5207 panel.update(cx, |panel, cx| {
5208 assert!(panel.filename_editor.read(cx).is_focused(cx));
5209 });
5210 assert_eq!(
5211 visible_entries_as_strings(&panel, 0..10, cx),
5212 &[
5213 "v src",
5214 " v test",
5215 " [EDITOR: ''] <== selected",
5216 " first.rs",
5217 " second.rs",
5218 " third.rs"
5219 ]
5220 );
5221 panel.update(cx, |panel, cx| {
5222 panel
5223 .filename_editor
5224 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
5225 assert!(
5226 panel.confirm_edit(cx).is_none(),
5227 "Should not allow to confirm on conflicting new file name"
5228 )
5229 });
5230 assert_eq!(
5231 visible_entries_as_strings(&panel, 0..10, cx),
5232 &[
5233 "v src",
5234 " v test",
5235 " first.rs",
5236 " second.rs",
5237 " third.rs"
5238 ],
5239 "File list should be unchanged after failed file create confirmation"
5240 );
5241
5242 select_path(&panel, "src/test/first.rs", cx);
5243 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
5244 cx.executor().run_until_parked();
5245 assert_eq!(
5246 visible_entries_as_strings(&panel, 0..10, cx),
5247 &[
5248 "v src",
5249 " v test",
5250 " first.rs <== selected",
5251 " second.rs",
5252 " third.rs"
5253 ],
5254 );
5255 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
5256 panel.update(cx, |panel, cx| {
5257 assert!(panel.filename_editor.read(cx).is_focused(cx));
5258 });
5259 assert_eq!(
5260 visible_entries_as_strings(&panel, 0..10, cx),
5261 &[
5262 "v src",
5263 " v test",
5264 " [EDITOR: 'first.rs'] <== selected",
5265 " second.rs",
5266 " third.rs"
5267 ]
5268 );
5269 panel.update(cx, |panel, cx| {
5270 panel
5271 .filename_editor
5272 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
5273 assert!(
5274 panel.confirm_edit(cx).is_none(),
5275 "Should not allow to confirm on conflicting file rename"
5276 )
5277 });
5278 assert_eq!(
5279 visible_entries_as_strings(&panel, 0..10, cx),
5280 &[
5281 "v src",
5282 " v test",
5283 " first.rs <== selected",
5284 " second.rs",
5285 " third.rs"
5286 ],
5287 "File list should be unchanged after failed rename confirmation"
5288 );
5289 }
5290
5291 #[gpui::test]
5292 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
5293 init_test_with_editor(cx);
5294
5295 let fs = FakeFs::new(cx.executor().clone());
5296 fs.insert_tree(
5297 "/project_root",
5298 json!({
5299 "dir_1": {
5300 "nested_dir": {
5301 "file_a.py": "# File contents",
5302 }
5303 },
5304 "file_1.py": "# File contents",
5305 }),
5306 )
5307 .await;
5308
5309 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5310 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5311 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5312 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5313
5314 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5315 cx.executor().run_until_parked();
5316 select_path(&panel, "project_root/dir_1", cx);
5317 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5318 select_path(&panel, "project_root/dir_1/nested_dir", cx);
5319 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5320 panel.update(cx, |panel, cx| panel.open(&Open, cx));
5321 cx.executor().run_until_parked();
5322 assert_eq!(
5323 visible_entries_as_strings(&panel, 0..10, cx),
5324 &[
5325 "v project_root",
5326 " v dir_1",
5327 " > nested_dir <== selected",
5328 " file_1.py",
5329 ]
5330 );
5331 }
5332
5333 #[gpui::test]
5334 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
5335 init_test_with_editor(cx);
5336
5337 let fs = FakeFs::new(cx.executor().clone());
5338 fs.insert_tree(
5339 "/project_root",
5340 json!({
5341 "dir_1": {
5342 "nested_dir": {
5343 "file_a.py": "# File contents",
5344 "file_b.py": "# File contents",
5345 "file_c.py": "# File contents",
5346 },
5347 "file_1.py": "# File contents",
5348 "file_2.py": "# File contents",
5349 "file_3.py": "# File contents",
5350 },
5351 "dir_2": {
5352 "file_1.py": "# File contents",
5353 "file_2.py": "# File contents",
5354 "file_3.py": "# File contents",
5355 }
5356 }),
5357 )
5358 .await;
5359
5360 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5361 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5362 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5363 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5364
5365 panel.update(cx, |panel, cx| {
5366 panel.collapse_all_entries(&CollapseAllEntries, cx)
5367 });
5368 cx.executor().run_until_parked();
5369 assert_eq!(
5370 visible_entries_as_strings(&panel, 0..10, cx),
5371 &["v project_root", " > dir_1", " > dir_2",]
5372 );
5373
5374 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
5375 toggle_expand_dir(&panel, "project_root/dir_1", cx);
5376 cx.executor().run_until_parked();
5377 assert_eq!(
5378 visible_entries_as_strings(&panel, 0..10, cx),
5379 &[
5380 "v project_root",
5381 " v dir_1 <== selected",
5382 " > nested_dir",
5383 " file_1.py",
5384 " file_2.py",
5385 " file_3.py",
5386 " > dir_2",
5387 ]
5388 );
5389 }
5390
5391 #[gpui::test]
5392 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
5393 init_test(cx);
5394
5395 let fs = FakeFs::new(cx.executor().clone());
5396 fs.as_fake().insert_tree("/root", json!({})).await;
5397 let project = Project::test(fs, ["/root".as_ref()], cx).await;
5398 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5399 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5400 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5401
5402 // Make a new buffer with no backing file
5403 workspace
5404 .update(cx, |workspace, cx| {
5405 Editor::new_file(workspace, &Default::default(), cx)
5406 })
5407 .unwrap();
5408
5409 cx.executor().run_until_parked();
5410
5411 // "Save as" the buffer, creating a new backing file for it
5412 let save_task = workspace
5413 .update(cx, |workspace, cx| {
5414 workspace.save_active_item(workspace::SaveIntent::Save, cx)
5415 })
5416 .unwrap();
5417
5418 cx.executor().run_until_parked();
5419 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
5420 save_task.await.unwrap();
5421
5422 // Rename the file
5423 select_path(&panel, "root/new", cx);
5424 assert_eq!(
5425 visible_entries_as_strings(&panel, 0..10, cx),
5426 &["v root", " new <== selected"]
5427 );
5428 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
5429 panel.update(cx, |panel, cx| {
5430 panel
5431 .filename_editor
5432 .update(cx, |editor, cx| editor.set_text("newer", cx));
5433 });
5434 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
5435
5436 cx.executor().run_until_parked();
5437 assert_eq!(
5438 visible_entries_as_strings(&panel, 0..10, cx),
5439 &["v root", " newer <== selected"]
5440 );
5441
5442 workspace
5443 .update(cx, |workspace, cx| {
5444 workspace.save_active_item(workspace::SaveIntent::Save, cx)
5445 })
5446 .unwrap()
5447 .await
5448 .unwrap();
5449
5450 cx.executor().run_until_parked();
5451 // assert that saving the file doesn't restore "new"
5452 assert_eq!(
5453 visible_entries_as_strings(&panel, 0..10, cx),
5454 &["v root", " newer <== selected"]
5455 );
5456 }
5457
5458 #[gpui::test]
5459 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
5460 init_test_with_editor(cx);
5461 let fs = FakeFs::new(cx.executor().clone());
5462 fs.insert_tree(
5463 "/project_root",
5464 json!({
5465 "dir_1": {
5466 "nested_dir": {
5467 "file_a.py": "# File contents",
5468 }
5469 },
5470 "file_1.py": "# File contents",
5471 }),
5472 )
5473 .await;
5474
5475 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5476 let worktree_id =
5477 cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
5478 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5479 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5480 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5481 cx.update(|cx| {
5482 panel.update(cx, |this, cx| {
5483 this.select_next(&Default::default(), cx);
5484 this.expand_selected_entry(&Default::default(), cx);
5485 this.expand_selected_entry(&Default::default(), cx);
5486 this.select_next(&Default::default(), cx);
5487 this.expand_selected_entry(&Default::default(), cx);
5488 this.select_next(&Default::default(), cx);
5489 })
5490 });
5491 assert_eq!(
5492 visible_entries_as_strings(&panel, 0..10, cx),
5493 &[
5494 "v project_root",
5495 " v dir_1",
5496 " v nested_dir",
5497 " file_a.py <== selected",
5498 " file_1.py",
5499 ]
5500 );
5501 let modifiers_with_shift = gpui::Modifiers {
5502 shift: true,
5503 ..Default::default()
5504 };
5505 cx.simulate_modifiers_change(modifiers_with_shift);
5506 cx.update(|cx| {
5507 panel.update(cx, |this, cx| {
5508 this.select_next(&Default::default(), cx);
5509 })
5510 });
5511 assert_eq!(
5512 visible_entries_as_strings(&panel, 0..10, cx),
5513 &[
5514 "v project_root",
5515 " v dir_1",
5516 " v nested_dir",
5517 " file_a.py",
5518 " file_1.py <== selected <== marked",
5519 ]
5520 );
5521 cx.update(|cx| {
5522 panel.update(cx, |this, cx| {
5523 this.select_prev(&Default::default(), cx);
5524 })
5525 });
5526 assert_eq!(
5527 visible_entries_as_strings(&panel, 0..10, cx),
5528 &[
5529 "v project_root",
5530 " v dir_1",
5531 " v nested_dir",
5532 " file_a.py <== selected <== marked",
5533 " file_1.py <== marked",
5534 ]
5535 );
5536 cx.update(|cx| {
5537 panel.update(cx, |this, cx| {
5538 let drag = DraggedSelection {
5539 active_selection: this.selection.unwrap(),
5540 marked_selections: Arc::new(this.marked_entries.clone()),
5541 };
5542 let target_entry = this
5543 .project
5544 .read(cx)
5545 .entry_for_path(&(worktree_id, "").into(), cx)
5546 .unwrap();
5547 this.drag_onto(&drag, target_entry.id, false, cx);
5548 });
5549 });
5550 cx.run_until_parked();
5551 assert_eq!(
5552 visible_entries_as_strings(&panel, 0..10, cx),
5553 &[
5554 "v project_root",
5555 " v dir_1",
5556 " v nested_dir",
5557 " file_1.py <== marked",
5558 " file_a.py <== selected <== marked",
5559 ]
5560 );
5561 // ESC clears out all marks
5562 cx.update(|cx| {
5563 panel.update(cx, |this, cx| {
5564 this.cancel(&menu::Cancel, cx);
5565 })
5566 });
5567 assert_eq!(
5568 visible_entries_as_strings(&panel, 0..10, cx),
5569 &[
5570 "v project_root",
5571 " v dir_1",
5572 " v nested_dir",
5573 " file_1.py",
5574 " file_a.py <== selected",
5575 ]
5576 );
5577 // ESC clears out all marks
5578 cx.update(|cx| {
5579 panel.update(cx, |this, cx| {
5580 this.select_prev(&SelectPrev, cx);
5581 this.select_next(&SelectNext, cx);
5582 })
5583 });
5584 assert_eq!(
5585 visible_entries_as_strings(&panel, 0..10, cx),
5586 &[
5587 "v project_root",
5588 " v dir_1",
5589 " v nested_dir",
5590 " file_1.py <== marked",
5591 " file_a.py <== selected <== marked",
5592 ]
5593 );
5594 cx.simulate_modifiers_change(Default::default());
5595 cx.update(|cx| {
5596 panel.update(cx, |this, cx| {
5597 this.cut(&Cut, cx);
5598 this.select_prev(&SelectPrev, cx);
5599 this.select_prev(&SelectPrev, cx);
5600
5601 this.paste(&Paste, cx);
5602 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
5603 })
5604 });
5605 cx.run_until_parked();
5606 assert_eq!(
5607 visible_entries_as_strings(&panel, 0..10, cx),
5608 &[
5609 "v project_root",
5610 " v dir_1",
5611 " v nested_dir",
5612 " file_1.py <== marked",
5613 " file_a.py <== selected <== marked",
5614 ]
5615 );
5616 cx.simulate_modifiers_change(modifiers_with_shift);
5617 cx.update(|cx| {
5618 panel.update(cx, |this, cx| {
5619 this.expand_selected_entry(&Default::default(), cx);
5620 this.select_next(&SelectNext, cx);
5621 this.select_next(&SelectNext, cx);
5622 })
5623 });
5624 submit_deletion(&panel, cx);
5625 assert_eq!(
5626 visible_entries_as_strings(&panel, 0..10, cx),
5627 &["v project_root", " v dir_1", " v nested_dir",]
5628 );
5629 }
5630 #[gpui::test]
5631 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
5632 init_test_with_editor(cx);
5633 cx.update(|cx| {
5634 cx.update_global::<SettingsStore, _>(|store, cx| {
5635 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
5636 worktree_settings.file_scan_exclusions = Some(Vec::new());
5637 });
5638 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5639 project_panel_settings.auto_reveal_entries = Some(false)
5640 });
5641 })
5642 });
5643
5644 let fs = FakeFs::new(cx.background_executor.clone());
5645 fs.insert_tree(
5646 "/project_root",
5647 json!({
5648 ".git": {},
5649 ".gitignore": "**/gitignored_dir",
5650 "dir_1": {
5651 "file_1.py": "# File 1_1 contents",
5652 "file_2.py": "# File 1_2 contents",
5653 "file_3.py": "# File 1_3 contents",
5654 "gitignored_dir": {
5655 "file_a.py": "# File contents",
5656 "file_b.py": "# File contents",
5657 "file_c.py": "# File contents",
5658 },
5659 },
5660 "dir_2": {
5661 "file_1.py": "# File 2_1 contents",
5662 "file_2.py": "# File 2_2 contents",
5663 "file_3.py": "# File 2_3 contents",
5664 }
5665 }),
5666 )
5667 .await;
5668
5669 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5670 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5671 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5672 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5673
5674 assert_eq!(
5675 visible_entries_as_strings(&panel, 0..20, cx),
5676 &[
5677 "v project_root",
5678 " > .git",
5679 " > dir_1",
5680 " > dir_2",
5681 " .gitignore",
5682 ]
5683 );
5684
5685 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
5686 .expect("dir 1 file is not ignored and should have an entry");
5687 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
5688 .expect("dir 2 file is not ignored and should have an entry");
5689 let gitignored_dir_file =
5690 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
5691 assert_eq!(
5692 gitignored_dir_file, None,
5693 "File in the gitignored dir should not have an entry before its dir is toggled"
5694 );
5695
5696 toggle_expand_dir(&panel, "project_root/dir_1", cx);
5697 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5698 cx.executor().run_until_parked();
5699 assert_eq!(
5700 visible_entries_as_strings(&panel, 0..20, cx),
5701 &[
5702 "v project_root",
5703 " > .git",
5704 " v dir_1",
5705 " v gitignored_dir <== selected",
5706 " file_a.py",
5707 " file_b.py",
5708 " file_c.py",
5709 " file_1.py",
5710 " file_2.py",
5711 " file_3.py",
5712 " > dir_2",
5713 " .gitignore",
5714 ],
5715 "Should show gitignored dir file list in the project panel"
5716 );
5717 let gitignored_dir_file =
5718 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
5719 .expect("after gitignored dir got opened, a file entry should be present");
5720
5721 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5722 toggle_expand_dir(&panel, "project_root/dir_1", cx);
5723 assert_eq!(
5724 visible_entries_as_strings(&panel, 0..20, cx),
5725 &[
5726 "v project_root",
5727 " > .git",
5728 " > dir_1 <== selected",
5729 " > dir_2",
5730 " .gitignore",
5731 ],
5732 "Should hide all dir contents again and prepare for the auto reveal test"
5733 );
5734
5735 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
5736 panel.update(cx, |panel, cx| {
5737 panel.project.update(cx, |_, cx| {
5738 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
5739 })
5740 });
5741 cx.run_until_parked();
5742 assert_eq!(
5743 visible_entries_as_strings(&panel, 0..20, cx),
5744 &[
5745 "v project_root",
5746 " > .git",
5747 " > dir_1 <== selected",
5748 " > dir_2",
5749 " .gitignore",
5750 ],
5751 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
5752 );
5753 }
5754
5755 cx.update(|cx| {
5756 cx.update_global::<SettingsStore, _>(|store, cx| {
5757 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5758 project_panel_settings.auto_reveal_entries = Some(true)
5759 });
5760 })
5761 });
5762
5763 panel.update(cx, |panel, cx| {
5764 panel.project.update(cx, |_, cx| {
5765 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
5766 })
5767 });
5768 cx.run_until_parked();
5769 assert_eq!(
5770 visible_entries_as_strings(&panel, 0..20, cx),
5771 &[
5772 "v project_root",
5773 " > .git",
5774 " v dir_1",
5775 " > gitignored_dir",
5776 " file_1.py <== selected",
5777 " file_2.py",
5778 " file_3.py",
5779 " > dir_2",
5780 " .gitignore",
5781 ],
5782 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
5783 );
5784
5785 panel.update(cx, |panel, cx| {
5786 panel.project.update(cx, |_, cx| {
5787 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
5788 })
5789 });
5790 cx.run_until_parked();
5791 assert_eq!(
5792 visible_entries_as_strings(&panel, 0..20, cx),
5793 &[
5794 "v project_root",
5795 " > .git",
5796 " v dir_1",
5797 " > gitignored_dir",
5798 " file_1.py",
5799 " file_2.py",
5800 " file_3.py",
5801 " v dir_2",
5802 " file_1.py <== selected",
5803 " file_2.py",
5804 " file_3.py",
5805 " .gitignore",
5806 ],
5807 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
5808 );
5809
5810 panel.update(cx, |panel, cx| {
5811 panel.project.update(cx, |_, cx| {
5812 cx.emit(project::Event::ActiveEntryChanged(Some(
5813 gitignored_dir_file,
5814 )))
5815 })
5816 });
5817 cx.run_until_parked();
5818 assert_eq!(
5819 visible_entries_as_strings(&panel, 0..20, cx),
5820 &[
5821 "v project_root",
5822 " > .git",
5823 " v dir_1",
5824 " > gitignored_dir",
5825 " file_1.py",
5826 " file_2.py",
5827 " file_3.py",
5828 " v dir_2",
5829 " file_1.py <== selected",
5830 " file_2.py",
5831 " file_3.py",
5832 " .gitignore",
5833 ],
5834 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
5835 );
5836
5837 panel.update(cx, |panel, cx| {
5838 panel.project.update(cx, |_, cx| {
5839 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
5840 })
5841 });
5842 cx.run_until_parked();
5843 assert_eq!(
5844 visible_entries_as_strings(&panel, 0..20, cx),
5845 &[
5846 "v project_root",
5847 " > .git",
5848 " v dir_1",
5849 " v gitignored_dir",
5850 " file_a.py <== selected",
5851 " file_b.py",
5852 " file_c.py",
5853 " file_1.py",
5854 " file_2.py",
5855 " file_3.py",
5856 " v dir_2",
5857 " file_1.py",
5858 " file_2.py",
5859 " file_3.py",
5860 " .gitignore",
5861 ],
5862 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
5863 );
5864 }
5865
5866 #[gpui::test]
5867 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
5868 init_test_with_editor(cx);
5869 cx.update(|cx| {
5870 cx.update_global::<SettingsStore, _>(|store, cx| {
5871 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
5872 worktree_settings.file_scan_exclusions = Some(Vec::new());
5873 });
5874 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5875 project_panel_settings.auto_reveal_entries = Some(false)
5876 });
5877 })
5878 });
5879
5880 let fs = FakeFs::new(cx.background_executor.clone());
5881 fs.insert_tree(
5882 "/project_root",
5883 json!({
5884 ".git": {},
5885 ".gitignore": "**/gitignored_dir",
5886 "dir_1": {
5887 "file_1.py": "# File 1_1 contents",
5888 "file_2.py": "# File 1_2 contents",
5889 "file_3.py": "# File 1_3 contents",
5890 "gitignored_dir": {
5891 "file_a.py": "# File contents",
5892 "file_b.py": "# File contents",
5893 "file_c.py": "# File contents",
5894 },
5895 },
5896 "dir_2": {
5897 "file_1.py": "# File 2_1 contents",
5898 "file_2.py": "# File 2_2 contents",
5899 "file_3.py": "# File 2_3 contents",
5900 }
5901 }),
5902 )
5903 .await;
5904
5905 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5906 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5907 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5908 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5909
5910 assert_eq!(
5911 visible_entries_as_strings(&panel, 0..20, cx),
5912 &[
5913 "v project_root",
5914 " > .git",
5915 " > dir_1",
5916 " > dir_2",
5917 " .gitignore",
5918 ]
5919 );
5920
5921 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
5922 .expect("dir 1 file is not ignored and should have an entry");
5923 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
5924 .expect("dir 2 file is not ignored and should have an entry");
5925 let gitignored_dir_file =
5926 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
5927 assert_eq!(
5928 gitignored_dir_file, None,
5929 "File in the gitignored dir should not have an entry before its dir is toggled"
5930 );
5931
5932 toggle_expand_dir(&panel, "project_root/dir_1", cx);
5933 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5934 cx.run_until_parked();
5935 assert_eq!(
5936 visible_entries_as_strings(&panel, 0..20, cx),
5937 &[
5938 "v project_root",
5939 " > .git",
5940 " v dir_1",
5941 " v gitignored_dir <== selected",
5942 " file_a.py",
5943 " file_b.py",
5944 " file_c.py",
5945 " file_1.py",
5946 " file_2.py",
5947 " file_3.py",
5948 " > dir_2",
5949 " .gitignore",
5950 ],
5951 "Should show gitignored dir file list in the project panel"
5952 );
5953 let gitignored_dir_file =
5954 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
5955 .expect("after gitignored dir got opened, a file entry should be present");
5956
5957 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5958 toggle_expand_dir(&panel, "project_root/dir_1", cx);
5959 assert_eq!(
5960 visible_entries_as_strings(&panel, 0..20, cx),
5961 &[
5962 "v project_root",
5963 " > .git",
5964 " > dir_1 <== selected",
5965 " > dir_2",
5966 " .gitignore",
5967 ],
5968 "Should hide all dir contents again and prepare for the explicit reveal test"
5969 );
5970
5971 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
5972 panel.update(cx, |panel, cx| {
5973 panel.project.update(cx, |_, cx| {
5974 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
5975 })
5976 });
5977 cx.run_until_parked();
5978 assert_eq!(
5979 visible_entries_as_strings(&panel, 0..20, cx),
5980 &[
5981 "v project_root",
5982 " > .git",
5983 " > dir_1 <== selected",
5984 " > dir_2",
5985 " .gitignore",
5986 ],
5987 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
5988 );
5989 }
5990
5991 panel.update(cx, |panel, cx| {
5992 panel.project.update(cx, |_, cx| {
5993 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
5994 })
5995 });
5996 cx.run_until_parked();
5997 assert_eq!(
5998 visible_entries_as_strings(&panel, 0..20, cx),
5999 &[
6000 "v project_root",
6001 " > .git",
6002 " v dir_1",
6003 " > gitignored_dir",
6004 " file_1.py <== selected",
6005 " file_2.py",
6006 " file_3.py",
6007 " > dir_2",
6008 " .gitignore",
6009 ],
6010 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
6011 );
6012
6013 panel.update(cx, |panel, cx| {
6014 panel.project.update(cx, |_, cx| {
6015 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
6016 })
6017 });
6018 cx.run_until_parked();
6019 assert_eq!(
6020 visible_entries_as_strings(&panel, 0..20, cx),
6021 &[
6022 "v project_root",
6023 " > .git",
6024 " v dir_1",
6025 " > gitignored_dir",
6026 " file_1.py",
6027 " file_2.py",
6028 " file_3.py",
6029 " v dir_2",
6030 " file_1.py <== selected",
6031 " file_2.py",
6032 " file_3.py",
6033 " .gitignore",
6034 ],
6035 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
6036 );
6037
6038 panel.update(cx, |panel, cx| {
6039 panel.project.update(cx, |_, cx| {
6040 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
6041 })
6042 });
6043 cx.run_until_parked();
6044 assert_eq!(
6045 visible_entries_as_strings(&panel, 0..20, cx),
6046 &[
6047 "v project_root",
6048 " > .git",
6049 " v dir_1",
6050 " v gitignored_dir",
6051 " file_a.py <== selected",
6052 " file_b.py",
6053 " file_c.py",
6054 " file_1.py",
6055 " file_2.py",
6056 " file_3.py",
6057 " v dir_2",
6058 " file_1.py",
6059 " file_2.py",
6060 " file_3.py",
6061 " .gitignore",
6062 ],
6063 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
6064 );
6065 }
6066
6067 #[gpui::test]
6068 async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
6069 init_test(cx);
6070 cx.update(|cx| {
6071 cx.update_global::<SettingsStore, _>(|store, cx| {
6072 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
6073 project_settings.file_scan_exclusions =
6074 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
6075 });
6076 });
6077 });
6078
6079 cx.update(|cx| {
6080 register_project_item::<TestProjectItemView>(cx);
6081 });
6082
6083 let fs = FakeFs::new(cx.executor().clone());
6084 fs.insert_tree(
6085 "/root1",
6086 json!({
6087 ".dockerignore": "",
6088 ".git": {
6089 "HEAD": "",
6090 },
6091 }),
6092 )
6093 .await;
6094
6095 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
6096 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6097 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6098 let panel = workspace
6099 .update(cx, |workspace, cx| {
6100 let panel = ProjectPanel::new(workspace, cx);
6101 workspace.add_panel(panel.clone(), cx);
6102 panel
6103 })
6104 .unwrap();
6105
6106 select_path(&panel, "root1", cx);
6107 assert_eq!(
6108 visible_entries_as_strings(&panel, 0..10, cx),
6109 &["v root1 <== selected", " .dockerignore",]
6110 );
6111 workspace
6112 .update(cx, |workspace, cx| {
6113 assert!(
6114 workspace.active_item(cx).is_none(),
6115 "Should have no active items in the beginning"
6116 );
6117 })
6118 .unwrap();
6119
6120 let excluded_file_path = ".git/COMMIT_EDITMSG";
6121 let excluded_dir_path = "excluded_dir";
6122
6123 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
6124 panel.update(cx, |panel, cx| {
6125 assert!(panel.filename_editor.read(cx).is_focused(cx));
6126 });
6127 panel
6128 .update(cx, |panel, cx| {
6129 panel
6130 .filename_editor
6131 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
6132 panel.confirm_edit(cx).unwrap()
6133 })
6134 .await
6135 .unwrap();
6136
6137 assert_eq!(
6138 visible_entries_as_strings(&panel, 0..13, cx),
6139 &["v root1", " .dockerignore"],
6140 "Excluded dir should not be shown after opening a file in it"
6141 );
6142 panel.update(cx, |panel, cx| {
6143 assert!(
6144 !panel.filename_editor.read(cx).is_focused(cx),
6145 "Should have closed the file name editor"
6146 );
6147 });
6148 workspace
6149 .update(cx, |workspace, cx| {
6150 let active_entry_path = workspace
6151 .active_item(cx)
6152 .expect("should have opened and activated the excluded item")
6153 .act_as::<TestProjectItemView>(cx)
6154 .expect(
6155 "should have opened the corresponding project item for the excluded item",
6156 )
6157 .read(cx)
6158 .path
6159 .clone();
6160 assert_eq!(
6161 active_entry_path.path.as_ref(),
6162 Path::new(excluded_file_path),
6163 "Should open the excluded file"
6164 );
6165
6166 assert!(
6167 workspace.notification_ids().is_empty(),
6168 "Should have no notifications after opening an excluded file"
6169 );
6170 })
6171 .unwrap();
6172 assert!(
6173 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
6174 "Should have created the excluded file"
6175 );
6176
6177 select_path(&panel, "root1", cx);
6178 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
6179 panel.update(cx, |panel, cx| {
6180 assert!(panel.filename_editor.read(cx).is_focused(cx));
6181 });
6182 panel
6183 .update(cx, |panel, cx| {
6184 panel
6185 .filename_editor
6186 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
6187 panel.confirm_edit(cx).unwrap()
6188 })
6189 .await
6190 .unwrap();
6191
6192 assert_eq!(
6193 visible_entries_as_strings(&panel, 0..13, cx),
6194 &["v root1", " .dockerignore"],
6195 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
6196 );
6197 panel.update(cx, |panel, cx| {
6198 assert!(
6199 !panel.filename_editor.read(cx).is_focused(cx),
6200 "Should have closed the file name editor"
6201 );
6202 });
6203 workspace
6204 .update(cx, |workspace, cx| {
6205 let notifications = workspace.notification_ids();
6206 assert_eq!(
6207 notifications.len(),
6208 1,
6209 "Should receive one notification with the error message"
6210 );
6211 workspace.dismiss_notification(notifications.first().unwrap(), cx);
6212 assert!(workspace.notification_ids().is_empty());
6213 })
6214 .unwrap();
6215
6216 select_path(&panel, "root1", cx);
6217 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
6218 panel.update(cx, |panel, cx| {
6219 assert!(panel.filename_editor.read(cx).is_focused(cx));
6220 });
6221 panel
6222 .update(cx, |panel, cx| {
6223 panel
6224 .filename_editor
6225 .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
6226 panel.confirm_edit(cx).unwrap()
6227 })
6228 .await
6229 .unwrap();
6230
6231 assert_eq!(
6232 visible_entries_as_strings(&panel, 0..13, cx),
6233 &["v root1", " .dockerignore"],
6234 "Should not change the project panel after trying to create an excluded directory"
6235 );
6236 panel.update(cx, |panel, cx| {
6237 assert!(
6238 !panel.filename_editor.read(cx).is_focused(cx),
6239 "Should have closed the file name editor"
6240 );
6241 });
6242 workspace
6243 .update(cx, |workspace, cx| {
6244 let notifications = workspace.notification_ids();
6245 assert_eq!(
6246 notifications.len(),
6247 1,
6248 "Should receive one notification explaining that no directory is actually shown"
6249 );
6250 workspace.dismiss_notification(notifications.first().unwrap(), cx);
6251 assert!(workspace.notification_ids().is_empty());
6252 })
6253 .unwrap();
6254 assert!(
6255 fs.is_dir(Path::new("/root1/excluded_dir")).await,
6256 "Should have created the excluded directory"
6257 );
6258 }
6259
6260 #[gpui::test]
6261 async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
6262 init_test_with_editor(cx);
6263
6264 let fs = FakeFs::new(cx.executor().clone());
6265 fs.insert_tree(
6266 "/src",
6267 json!({
6268 "test": {
6269 "first.rs": "// First Rust file",
6270 "second.rs": "// Second Rust file",
6271 "third.rs": "// Third Rust file",
6272 }
6273 }),
6274 )
6275 .await;
6276
6277 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
6278 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6279 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6280 let panel = workspace
6281 .update(cx, |workspace, cx| {
6282 let panel = ProjectPanel::new(workspace, cx);
6283 workspace.add_panel(panel.clone(), cx);
6284 panel
6285 })
6286 .unwrap();
6287
6288 select_path(&panel, "src/", cx);
6289 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6290 cx.executor().run_until_parked();
6291 assert_eq!(
6292 visible_entries_as_strings(&panel, 0..10, cx),
6293 &[
6294 //
6295 "v src <== selected",
6296 " > test"
6297 ]
6298 );
6299 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
6300 panel.update(cx, |panel, cx| {
6301 assert!(panel.filename_editor.read(cx).is_focused(cx));
6302 });
6303 assert_eq!(
6304 visible_entries_as_strings(&panel, 0..10, cx),
6305 &[
6306 //
6307 "v src",
6308 " > [EDITOR: ''] <== selected",
6309 " > test"
6310 ]
6311 );
6312
6313 panel.update(cx, |panel, cx| panel.cancel(&menu::Cancel, cx));
6314 assert_eq!(
6315 visible_entries_as_strings(&panel, 0..10, cx),
6316 &[
6317 //
6318 "v src <== selected",
6319 " > test"
6320 ]
6321 );
6322 }
6323
6324 fn toggle_expand_dir(
6325 panel: &View<ProjectPanel>,
6326 path: impl AsRef<Path>,
6327 cx: &mut VisualTestContext,
6328 ) {
6329 let path = path.as_ref();
6330 panel.update(cx, |panel, cx| {
6331 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6332 let worktree = worktree.read(cx);
6333 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6334 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6335 panel.toggle_expanded(entry_id, cx);
6336 return;
6337 }
6338 }
6339 panic!("no worktree for path {:?}", path);
6340 });
6341 }
6342
6343 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
6344 let path = path.as_ref();
6345 panel.update(cx, |panel, cx| {
6346 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6347 let worktree = worktree.read(cx);
6348 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6349 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
6350 panel.selection = Some(crate::SelectedEntry {
6351 worktree_id: worktree.id(),
6352 entry_id,
6353 });
6354 return;
6355 }
6356 }
6357 panic!("no worktree for path {:?}", path);
6358 });
6359 }
6360
6361 fn find_project_entry(
6362 panel: &View<ProjectPanel>,
6363 path: impl AsRef<Path>,
6364 cx: &mut VisualTestContext,
6365 ) -> Option<ProjectEntryId> {
6366 let path = path.as_ref();
6367 panel.update(cx, |panel, cx| {
6368 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
6369 let worktree = worktree.read(cx);
6370 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
6371 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
6372 }
6373 }
6374 panic!("no worktree for path {path:?}");
6375 })
6376 }
6377
6378 fn visible_entries_as_strings(
6379 panel: &View<ProjectPanel>,
6380 range: Range<usize>,
6381 cx: &mut VisualTestContext,
6382 ) -> Vec<String> {
6383 let mut result = Vec::new();
6384 let mut project_entries = HashSet::default();
6385 let mut has_editor = false;
6386
6387 panel.update(cx, |panel, cx| {
6388 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
6389 if details.is_editing {
6390 assert!(!has_editor, "duplicate editor entry");
6391 has_editor = true;
6392 } else {
6393 assert!(
6394 project_entries.insert(project_entry),
6395 "duplicate project entry {:?} {:?}",
6396 project_entry,
6397 details
6398 );
6399 }
6400
6401 let indent = " ".repeat(details.depth);
6402 let icon = if details.kind.is_dir() {
6403 if details.is_expanded {
6404 "v "
6405 } else {
6406 "> "
6407 }
6408 } else {
6409 " "
6410 };
6411 let name = if details.is_editing {
6412 format!("[EDITOR: '{}']", details.filename)
6413 } else if details.is_processing {
6414 format!("[PROCESSING: '{}']", details.filename)
6415 } else {
6416 details.filename.clone()
6417 };
6418 let selected = if details.is_selected {
6419 " <== selected"
6420 } else {
6421 ""
6422 };
6423 let marked = if details.is_marked {
6424 " <== marked"
6425 } else {
6426 ""
6427 };
6428
6429 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
6430 });
6431 });
6432
6433 result
6434 }
6435
6436 fn init_test(cx: &mut TestAppContext) {
6437 cx.update(|cx| {
6438 let settings_store = SettingsStore::test(cx);
6439 cx.set_global(settings_store);
6440 init_settings(cx);
6441 theme::init(theme::LoadThemes::JustBase, cx);
6442 language::init(cx);
6443 editor::init_settings(cx);
6444 crate::init((), cx);
6445 workspace::init_settings(cx);
6446 client::init_settings(cx);
6447 Project::init_settings(cx);
6448
6449 cx.update_global::<SettingsStore, _>(|store, cx| {
6450 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6451 project_panel_settings.auto_fold_dirs = Some(false);
6452 });
6453 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6454 worktree_settings.file_scan_exclusions = Some(Vec::new());
6455 });
6456 });
6457 });
6458 }
6459
6460 fn init_test_with_editor(cx: &mut TestAppContext) {
6461 cx.update(|cx| {
6462 let app_state = AppState::test(cx);
6463 theme::init(theme::LoadThemes::JustBase, cx);
6464 init_settings(cx);
6465 language::init(cx);
6466 editor::init(cx);
6467 crate::init((), cx);
6468 workspace::init(app_state.clone(), cx);
6469 Project::init_settings(cx);
6470
6471 cx.update_global::<SettingsStore, _>(|store, cx| {
6472 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6473 project_panel_settings.auto_fold_dirs = Some(false);
6474 });
6475 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6476 worktree_settings.file_scan_exclusions = Some(Vec::new());
6477 });
6478 });
6479 });
6480 }
6481
6482 fn ensure_single_file_is_opened(
6483 window: &WindowHandle<Workspace>,
6484 expected_path: &str,
6485 cx: &mut TestAppContext,
6486 ) {
6487 window
6488 .update(cx, |workspace, cx| {
6489 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
6490 assert_eq!(worktrees.len(), 1);
6491 let worktree_id = worktrees[0].read(cx).id();
6492
6493 let open_project_paths = workspace
6494 .panes()
6495 .iter()
6496 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
6497 .collect::<Vec<_>>();
6498 assert_eq!(
6499 open_project_paths,
6500 vec![ProjectPath {
6501 worktree_id,
6502 path: Arc::from(Path::new(expected_path))
6503 }],
6504 "Should have opened file, selected in project panel"
6505 );
6506 })
6507 .unwrap();
6508 }
6509
6510 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
6511 assert!(
6512 !cx.has_pending_prompt(),
6513 "Should have no prompts before the deletion"
6514 );
6515 panel.update(cx, |panel, cx| {
6516 panel.delete(&Delete { skip_prompt: false }, cx)
6517 });
6518 assert!(
6519 cx.has_pending_prompt(),
6520 "Should have a prompt after the deletion"
6521 );
6522 cx.simulate_prompt_answer(0);
6523 assert!(
6524 !cx.has_pending_prompt(),
6525 "Should have no prompts after prompt was replied to"
6526 );
6527 cx.executor().run_until_parked();
6528 }
6529
6530 fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
6531 assert!(
6532 !cx.has_pending_prompt(),
6533 "Should have no prompts before the deletion"
6534 );
6535 panel.update(cx, |panel, cx| {
6536 panel.delete(&Delete { skip_prompt: true }, cx)
6537 });
6538 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
6539 cx.executor().run_until_parked();
6540 }
6541
6542 fn ensure_no_open_items_and_panes(
6543 workspace: &WindowHandle<Workspace>,
6544 cx: &mut VisualTestContext,
6545 ) {
6546 assert!(
6547 !cx.has_pending_prompt(),
6548 "Should have no prompts after deletion operation closes the file"
6549 );
6550 workspace
6551 .read_with(cx, |workspace, cx| {
6552 let open_project_paths = workspace
6553 .panes()
6554 .iter()
6555 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
6556 .collect::<Vec<_>>();
6557 assert!(
6558 open_project_paths.is_empty(),
6559 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
6560 );
6561 })
6562 .unwrap();
6563 }
6564
6565 struct TestProjectItemView {
6566 focus_handle: FocusHandle,
6567 path: ProjectPath,
6568 }
6569
6570 struct TestProjectItem {
6571 path: ProjectPath,
6572 }
6573
6574 impl project::Item for TestProjectItem {
6575 fn try_open(
6576 _project: &Model<Project>,
6577 path: &ProjectPath,
6578 cx: &mut AppContext,
6579 ) -> Option<Task<gpui::Result<Model<Self>>>> {
6580 let path = path.clone();
6581 Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
6582 }
6583
6584 fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
6585 None
6586 }
6587
6588 fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
6589 Some(self.path.clone())
6590 }
6591 }
6592
6593 impl ProjectItem for TestProjectItemView {
6594 type Item = TestProjectItem;
6595
6596 fn for_project_item(
6597 _: Model<Project>,
6598 project_item: Model<Self::Item>,
6599 cx: &mut ViewContext<Self>,
6600 ) -> Self
6601 where
6602 Self: Sized,
6603 {
6604 Self {
6605 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
6606 focus_handle: cx.focus_handle(),
6607 }
6608 }
6609 }
6610
6611 impl Item for TestProjectItemView {
6612 type Event = ();
6613 }
6614
6615 impl EventEmitter<()> for TestProjectItemView {}
6616
6617 impl FocusableView for TestProjectItemView {
6618 fn focus_handle(&self, _: &AppContext) -> FocusHandle {
6619 self.focus_handle.clone()
6620 }
6621 }
6622
6623 impl Render for TestProjectItemView {
6624 fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
6625 Empty
6626 }
6627 }
6628}