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