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