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