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