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