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