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