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