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