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 let entries = visible_worktree_entries
1796 .iter()
1797 .map(|e| (e.path.clone()))
1798 .collect();
1799 for entry in visible_worktree_entries[entry_range].iter() {
1800 let status = git_status_setting.then(|| entry.git_status).flatten();
1801 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1802 let icon = match entry.kind {
1803 EntryKind::File(_) => {
1804 if show_file_icons {
1805 FileIcons::get_icon(&entry.path, cx)
1806 } else {
1807 None
1808 }
1809 }
1810 _ => {
1811 if show_folder_icons {
1812 FileIcons::get_folder_icon(is_expanded, cx)
1813 } else {
1814 FileIcons::get_chevron_icon(is_expanded, cx)
1815 }
1816 }
1817 };
1818
1819 let (depth, difference) =
1820 ProjectPanel::calculate_depth_and_difference(entry, &entries);
1821
1822 let filename = match difference {
1823 diff if diff > 1 => entry
1824 .path
1825 .iter()
1826 .skip(entry.path.components().count() - diff)
1827 .collect::<PathBuf>()
1828 .to_str()
1829 .unwrap_or_default()
1830 .to_string(),
1831 _ => entry
1832 .path
1833 .file_name()
1834 .map(|name| name.to_string_lossy().into_owned())
1835 .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
1836 };
1837 let selection = SelectedEntry {
1838 worktree_id: snapshot.id(),
1839 entry_id: entry.id,
1840 };
1841 let mut details = EntryDetails {
1842 filename,
1843 icon,
1844 path: entry.path.clone(),
1845 depth,
1846 kind: entry.kind,
1847 is_ignored: entry.is_ignored,
1848 is_expanded,
1849 is_selected: self.selection == Some(selection),
1850 is_marked: self.marked_entries.contains(&selection),
1851 is_editing: false,
1852 is_processing: false,
1853 is_cut: self
1854 .clipboard
1855 .as_ref()
1856 .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
1857 git_status: status,
1858 is_private: entry.is_private,
1859 worktree_id: *worktree_id,
1860 canonical_path: entry.canonical_path.clone(),
1861 };
1862
1863 if let Some(edit_state) = &self.edit_state {
1864 let is_edited_entry = if edit_state.is_new_entry {
1865 entry.id == NEW_ENTRY_ID
1866 } else {
1867 entry.id == edit_state.entry_id
1868 };
1869
1870 if is_edited_entry {
1871 if let Some(processing_filename) = &edit_state.processing_filename {
1872 details.is_processing = true;
1873 details.filename.clear();
1874 details.filename.push_str(processing_filename);
1875 } else {
1876 if edit_state.is_new_entry {
1877 details.filename.clear();
1878 }
1879 details.is_editing = true;
1880 }
1881 }
1882 }
1883
1884 callback(entry.id, details, cx);
1885 }
1886 }
1887 ix = end_ix;
1888 }
1889 }
1890
1891 fn calculate_depth_and_difference(
1892 entry: &Entry,
1893 visible_worktree_entries: &HashSet<Arc<Path>>,
1894 ) -> (usize, usize) {
1895 let (depth, difference) = entry
1896 .path
1897 .ancestors()
1898 .skip(1) // Skip the entry itself
1899 .find_map(|ancestor| {
1900 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
1901 let entry_path_components_count = entry.path.components().count();
1902 let parent_path_components_count = parent_entry.components().count();
1903 let difference = entry_path_components_count - parent_path_components_count;
1904 let depth = parent_entry
1905 .ancestors()
1906 .skip(1)
1907 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
1908 .count();
1909 Some((depth + 1, difference))
1910 } else {
1911 None
1912 }
1913 })
1914 .unwrap_or((0, 0));
1915
1916 (depth, difference)
1917 }
1918
1919 fn render_entry(
1920 &self,
1921 entry_id: ProjectEntryId,
1922 details: EntryDetails,
1923 cx: &mut ViewContext<Self>,
1924 ) -> Stateful<Div> {
1925 let kind = details.kind;
1926 let settings = ProjectPanelSettings::get_global(cx);
1927 let show_editor = details.is_editing && !details.is_processing;
1928 let selection = SelectedEntry {
1929 worktree_id: details.worktree_id,
1930 entry_id,
1931 };
1932 let is_marked = self.marked_entries.contains(&selection);
1933 let is_active = self
1934 .selection
1935 .map_or(false, |selection| selection.entry_id == entry_id);
1936 let width = self.size(cx);
1937 let filename_text_color =
1938 entry_git_aware_label_color(details.git_status, details.is_ignored, is_marked);
1939 let file_name = details.filename.clone();
1940 let mut icon = details.icon.clone();
1941 if settings.file_icons && show_editor && details.kind.is_file() {
1942 let filename = self.filename_editor.read(cx).text(cx);
1943 if filename.len() > 2 {
1944 icon = FileIcons::get_icon(Path::new(&filename), cx);
1945 }
1946 }
1947
1948 let canonical_path = details
1949 .canonical_path
1950 .as_ref()
1951 .map(|f| f.to_string_lossy().to_string());
1952
1953 let depth = details.depth;
1954 let worktree_id = details.worktree_id;
1955 let selections = Arc::new(self.marked_entries.clone());
1956
1957 let dragged_selection = DraggedSelection {
1958 active_selection: selection,
1959 marked_selections: selections,
1960 };
1961 div()
1962 .id(entry_id.to_proto() as usize)
1963 .on_drag(dragged_selection, move |selection, cx| {
1964 cx.new_view(|_| DraggedProjectEntryView {
1965 details: details.clone(),
1966 width,
1967 selection: selection.active_selection,
1968 selections: selection.marked_selections.clone(),
1969 })
1970 })
1971 .drag_over::<DraggedSelection>(|style, _, cx| {
1972 style.bg(cx.theme().colors().drop_target_background)
1973 })
1974 .on_drop(cx.listener(move |this, selections: &DraggedSelection, cx| {
1975 this.drag_onto(selections, entry_id, kind.is_file(), cx);
1976 }))
1977 .child(
1978 ListItem::new(entry_id.to_proto() as usize)
1979 .indent_level(depth)
1980 .indent_step_size(px(settings.indent_size))
1981 .selected(is_marked || is_active)
1982 .when_some(canonical_path, |this, path| {
1983 this.end_slot::<AnyElement>(
1984 div()
1985 .id("symlink_icon")
1986 .tooltip(move |cx| {
1987 Tooltip::text(format!("{path} • Symbolic Link"), cx)
1988 })
1989 .child(
1990 Icon::new(IconName::ArrowUpRight)
1991 .size(IconSize::Indicator)
1992 .color(filename_text_color),
1993 )
1994 .into_any_element(),
1995 )
1996 })
1997 .child(if let Some(icon) = &icon {
1998 h_flex().child(Icon::from_path(icon.to_string()).color(filename_text_color))
1999 } else {
2000 h_flex()
2001 .size(IconSize::default().rems())
2002 .invisible()
2003 .flex_none()
2004 })
2005 .child(
2006 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
2007 h_flex().h_6().w_full().child(editor.clone())
2008 } else {
2009 h_flex().h_6().child(
2010 Label::new(file_name)
2011 .single_line()
2012 .color(filename_text_color),
2013 )
2014 }
2015 .ml_1(),
2016 )
2017 .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
2018 if event.down.button == MouseButton::Right || event.down.first_mouse {
2019 return;
2020 }
2021 if !show_editor {
2022 if let Some(selection) =
2023 this.selection.filter(|_| event.down.modifiers.shift)
2024 {
2025 let current_selection = this.index_for_selection(selection);
2026 let target_selection = this.index_for_selection(SelectedEntry {
2027 entry_id,
2028 worktree_id,
2029 });
2030 if let Some(((_, _, source_index), (_, _, target_index))) =
2031 current_selection.zip(target_selection)
2032 {
2033 let range_start = source_index.min(target_index);
2034 let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
2035 let mut new_selections = BTreeSet::new();
2036 this.for_each_visible_entry(
2037 range_start..range_end,
2038 cx,
2039 |entry_id, details, _| {
2040 new_selections.insert(SelectedEntry {
2041 entry_id,
2042 worktree_id: details.worktree_id,
2043 });
2044 },
2045 );
2046
2047 this.marked_entries = this
2048 .marked_entries
2049 .union(&new_selections)
2050 .cloned()
2051 .collect();
2052
2053 this.selection = Some(SelectedEntry {
2054 entry_id,
2055 worktree_id,
2056 });
2057 // Ensure that the current entry is selected.
2058 this.marked_entries.insert(SelectedEntry {
2059 entry_id,
2060 worktree_id,
2061 });
2062 }
2063 } else if event.down.modifiers.secondary() {
2064 if !this.marked_entries.insert(selection) {
2065 this.marked_entries.remove(&selection);
2066 }
2067 } else if kind.is_dir() {
2068 this.toggle_expanded(entry_id, cx);
2069 } else {
2070 let click_count = event.up.click_count;
2071 if click_count > 1 && event.down.modifiers.secondary() {
2072 this.split_entry(entry_id, cx);
2073 } else {
2074 this.open_entry(
2075 entry_id,
2076 cx.modifiers().secondary(),
2077 click_count > 1,
2078 click_count == 1,
2079 cx,
2080 );
2081 }
2082 }
2083 }
2084 }))
2085 .on_secondary_mouse_down(cx.listener(
2086 move |this, event: &MouseDownEvent, cx| {
2087 // Stop propagation to prevent the catch-all context menu for the project
2088 // panel from being deployed.
2089 cx.stop_propagation();
2090 this.deploy_context_menu(event.position, entry_id, cx);
2091 },
2092 )),
2093 )
2094 .border_1()
2095 .border_r_2()
2096 .rounded_none()
2097 .hover(|style| {
2098 if is_active {
2099 style
2100 } else {
2101 let hover_color = cx.theme().colors().ghost_element_hover;
2102 style.bg(hover_color).border_color(hover_color)
2103 }
2104 })
2105 .when(is_marked || is_active, |this| {
2106 let colors = cx.theme().colors();
2107 this.when(is_marked, |this| this.bg(colors.ghost_element_selected))
2108 .border_color(colors.ghost_element_selected)
2109 })
2110 .when(
2111 is_active && self.focus_handle.contains_focused(cx),
2112 |this| this.border_color(Color::Selected.color(cx)),
2113 )
2114 }
2115
2116 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
2117 let mut dispatch_context = KeyContext::new_with_defaults();
2118 dispatch_context.add("ProjectPanel");
2119 dispatch_context.add("menu");
2120
2121 let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
2122 "editing"
2123 } else {
2124 "not_editing"
2125 };
2126
2127 dispatch_context.add(identifier);
2128 dispatch_context
2129 }
2130
2131 fn reveal_entry(
2132 &mut self,
2133 project: Model<Project>,
2134 entry_id: ProjectEntryId,
2135 skip_ignored: bool,
2136 cx: &mut ViewContext<'_, ProjectPanel>,
2137 ) {
2138 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
2139 let worktree = worktree.read(cx);
2140 if skip_ignored
2141 && worktree
2142 .entry_for_id(entry_id)
2143 .map_or(true, |entry| entry.is_ignored)
2144 {
2145 return;
2146 }
2147
2148 let worktree_id = worktree.id();
2149 self.marked_entries.clear();
2150 self.expand_entry(worktree_id, entry_id, cx);
2151 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
2152 self.autoscroll(cx);
2153 cx.notify();
2154 }
2155 }
2156}
2157
2158impl Render for ProjectPanel {
2159 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
2160 let has_worktree = self.visible_entries.len() != 0;
2161 let project = self.project.read(cx);
2162
2163 if has_worktree {
2164 h_flex()
2165 .id("project-panel")
2166 .size_full()
2167 .relative()
2168 .key_context(self.dispatch_context(cx))
2169 .on_action(cx.listener(Self::select_next))
2170 .on_action(cx.listener(Self::select_prev))
2171 .on_action(cx.listener(Self::select_first))
2172 .on_action(cx.listener(Self::select_last))
2173 .on_action(cx.listener(Self::select_parent))
2174 .on_action(cx.listener(Self::expand_selected_entry))
2175 .on_action(cx.listener(Self::collapse_selected_entry))
2176 .on_action(cx.listener(Self::collapse_all_entries))
2177 .on_action(cx.listener(Self::open))
2178 .on_action(cx.listener(Self::open_permanent))
2179 .on_action(cx.listener(Self::confirm))
2180 .on_action(cx.listener(Self::cancel))
2181 .on_action(cx.listener(Self::copy_path))
2182 .on_action(cx.listener(Self::copy_relative_path))
2183 .on_action(cx.listener(Self::new_search_in_directory))
2184 .on_action(cx.listener(Self::unfold_directory))
2185 .on_action(cx.listener(Self::fold_directory))
2186 .when(!project.is_read_only(), |el| {
2187 el.on_action(cx.listener(Self::new_file))
2188 .on_action(cx.listener(Self::new_directory))
2189 .on_action(cx.listener(Self::rename))
2190 .on_action(cx.listener(Self::delete))
2191 .on_action(cx.listener(Self::trash))
2192 .on_action(cx.listener(Self::cut))
2193 .on_action(cx.listener(Self::copy))
2194 .on_action(cx.listener(Self::paste))
2195 .on_action(cx.listener(Self::duplicate))
2196 })
2197 .when(project.is_local(), |el| {
2198 el.on_action(cx.listener(Self::reveal_in_finder))
2199 .on_action(cx.listener(Self::open_in_terminal))
2200 })
2201 .on_mouse_down(
2202 MouseButton::Right,
2203 cx.listener(move |this, event: &MouseDownEvent, cx| {
2204 // When deploying the context menu anywhere below the last project entry,
2205 // act as if the user clicked the root of the last worktree.
2206 if let Some(entry_id) = this.last_worktree_root_id {
2207 this.deploy_context_menu(event.position, entry_id, cx);
2208 }
2209 }),
2210 )
2211 .track_focus(&self.focus_handle)
2212 .child(
2213 uniform_list(
2214 cx.view().clone(),
2215 "entries",
2216 self.visible_entries
2217 .iter()
2218 .map(|(_, worktree_entries)| worktree_entries.len())
2219 .sum(),
2220 {
2221 |this, range, cx| {
2222 let mut items = Vec::new();
2223 this.for_each_visible_entry(range, cx, |id, details, cx| {
2224 items.push(this.render_entry(id, details, cx));
2225 });
2226 items
2227 }
2228 },
2229 )
2230 .size_full()
2231 .with_sizing_behavior(ListSizingBehavior::Infer)
2232 .track_scroll(self.scroll_handle.clone()),
2233 )
2234 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2235 deferred(
2236 anchored()
2237 .position(*position)
2238 .anchor(gpui::AnchorCorner::TopLeft)
2239 .child(menu.clone()),
2240 )
2241 .with_priority(1)
2242 }))
2243 } else {
2244 v_flex()
2245 .id("empty-project_panel")
2246 .size_full()
2247 .p_4()
2248 .track_focus(&self.focus_handle)
2249 .child(
2250 Button::new("open_project", "Open a project")
2251 .style(ButtonStyle::Filled)
2252 .full_width()
2253 .key_binding(KeyBinding::for_action(&workspace::Open, cx))
2254 .on_click(cx.listener(|this, _, cx| {
2255 this.workspace
2256 .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
2257 .log_err();
2258 })),
2259 )
2260 }
2261 }
2262}
2263
2264impl Render for DraggedProjectEntryView {
2265 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2266 let settings = ProjectPanelSettings::get_global(cx);
2267 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
2268 h_flex().font(ui_font).map(|this| {
2269 if self.selections.contains(&self.selection) {
2270 this.flex_shrink()
2271 .p_1()
2272 .items_end()
2273 .rounded_md()
2274 .child(self.selections.len().to_string())
2275 } else {
2276 this.bg(cx.theme().colors().background).w(self.width).child(
2277 ListItem::new(self.selection.entry_id.to_proto() as usize)
2278 .indent_level(self.details.depth)
2279 .indent_step_size(px(settings.indent_size))
2280 .child(if let Some(icon) = &self.details.icon {
2281 div().child(Icon::from_path(icon.to_string()))
2282 } else {
2283 div()
2284 })
2285 .child(Label::new(self.details.filename.clone())),
2286 )
2287 }
2288 })
2289 }
2290}
2291
2292impl EventEmitter<Event> for ProjectPanel {}
2293
2294impl EventEmitter<PanelEvent> for ProjectPanel {}
2295
2296impl Panel for ProjectPanel {
2297 fn position(&self, cx: &WindowContext) -> DockPosition {
2298 match ProjectPanelSettings::get_global(cx).dock {
2299 ProjectPanelDockPosition::Left => DockPosition::Left,
2300 ProjectPanelDockPosition::Right => DockPosition::Right,
2301 }
2302 }
2303
2304 fn position_is_valid(&self, position: DockPosition) -> bool {
2305 matches!(position, DockPosition::Left | DockPosition::Right)
2306 }
2307
2308 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2309 settings::update_settings_file::<ProjectPanelSettings>(
2310 self.fs.clone(),
2311 cx,
2312 move |settings| {
2313 let dock = match position {
2314 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
2315 DockPosition::Right => ProjectPanelDockPosition::Right,
2316 };
2317 settings.dock = Some(dock);
2318 },
2319 );
2320 }
2321
2322 fn size(&self, cx: &WindowContext) -> Pixels {
2323 self.width
2324 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
2325 }
2326
2327 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
2328 self.width = size;
2329 self.serialize(cx);
2330 cx.notify();
2331 }
2332
2333 fn icon(&self, cx: &WindowContext) -> Option<IconName> {
2334 ProjectPanelSettings::get_global(cx)
2335 .button
2336 .then(|| IconName::FileTree)
2337 }
2338
2339 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
2340 Some("Project Panel")
2341 }
2342
2343 fn toggle_action(&self) -> Box<dyn Action> {
2344 Box::new(ToggleFocus)
2345 }
2346
2347 fn persistent_name() -> &'static str {
2348 "Project Panel"
2349 }
2350
2351 fn starts_open(&self, cx: &WindowContext) -> bool {
2352 let project = &self.project.read(cx);
2353 project.dev_server_project_id().is_some()
2354 || project.visible_worktrees(cx).any(|tree| {
2355 tree.read(cx)
2356 .root_entry()
2357 .map_or(false, |entry| entry.is_dir())
2358 })
2359 }
2360}
2361
2362impl FocusableView for ProjectPanel {
2363 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
2364 self.focus_handle.clone()
2365 }
2366}
2367
2368impl ClipboardEntry {
2369 fn is_cut(&self) -> bool {
2370 matches!(self, Self::Cut { .. })
2371 }
2372
2373 fn items(&self) -> &BTreeSet<SelectedEntry> {
2374 match self {
2375 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
2376 }
2377 }
2378}
2379
2380#[cfg(test)]
2381mod tests {
2382 use super::*;
2383 use collections::HashSet;
2384 use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
2385 use pretty_assertions::assert_eq;
2386 use project::{FakeFs, WorktreeSettings};
2387 use serde_json::json;
2388 use settings::SettingsStore;
2389 use std::path::{Path, PathBuf};
2390 use workspace::{
2391 item::{Item, ProjectItem},
2392 register_project_item, AppState,
2393 };
2394
2395 #[gpui::test]
2396 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
2397 init_test(cx);
2398
2399 let fs = FakeFs::new(cx.executor().clone());
2400 fs.insert_tree(
2401 "/root1",
2402 json!({
2403 ".dockerignore": "",
2404 ".git": {
2405 "HEAD": "",
2406 },
2407 "a": {
2408 "0": { "q": "", "r": "", "s": "" },
2409 "1": { "t": "", "u": "" },
2410 "2": { "v": "", "w": "", "x": "", "y": "" },
2411 },
2412 "b": {
2413 "3": { "Q": "" },
2414 "4": { "R": "", "S": "", "T": "", "U": "" },
2415 },
2416 "C": {
2417 "5": {},
2418 "6": { "V": "", "W": "" },
2419 "7": { "X": "" },
2420 "8": { "Y": {}, "Z": "" }
2421 }
2422 }),
2423 )
2424 .await;
2425 fs.insert_tree(
2426 "/root2",
2427 json!({
2428 "d": {
2429 "9": ""
2430 },
2431 "e": {}
2432 }),
2433 )
2434 .await;
2435
2436 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2437 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2438 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2439 let panel = workspace
2440 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2441 .unwrap();
2442 assert_eq!(
2443 visible_entries_as_strings(&panel, 0..50, cx),
2444 &[
2445 "v root1",
2446 " > .git",
2447 " > a",
2448 " > b",
2449 " > C",
2450 " .dockerignore",
2451 "v root2",
2452 " > d",
2453 " > e",
2454 ]
2455 );
2456
2457 toggle_expand_dir(&panel, "root1/b", cx);
2458 assert_eq!(
2459 visible_entries_as_strings(&panel, 0..50, cx),
2460 &[
2461 "v root1",
2462 " > .git",
2463 " > a",
2464 " v b <== selected",
2465 " > 3",
2466 " > 4",
2467 " > C",
2468 " .dockerignore",
2469 "v root2",
2470 " > d",
2471 " > e",
2472 ]
2473 );
2474
2475 assert_eq!(
2476 visible_entries_as_strings(&panel, 6..9, cx),
2477 &[
2478 //
2479 " > C",
2480 " .dockerignore",
2481 "v root2",
2482 ]
2483 );
2484 }
2485
2486 #[gpui::test]
2487 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
2488 init_test(cx);
2489 cx.update(|cx| {
2490 cx.update_global::<SettingsStore, _>(|store, cx| {
2491 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
2492 worktree_settings.file_scan_exclusions =
2493 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
2494 });
2495 });
2496 });
2497
2498 let fs = FakeFs::new(cx.background_executor.clone());
2499 fs.insert_tree(
2500 "/root1",
2501 json!({
2502 ".dockerignore": "",
2503 ".git": {
2504 "HEAD": "",
2505 },
2506 "a": {
2507 "0": { "q": "", "r": "", "s": "" },
2508 "1": { "t": "", "u": "" },
2509 "2": { "v": "", "w": "", "x": "", "y": "" },
2510 },
2511 "b": {
2512 "3": { "Q": "" },
2513 "4": { "R": "", "S": "", "T": "", "U": "" },
2514 },
2515 "C": {
2516 "5": {},
2517 "6": { "V": "", "W": "" },
2518 "7": { "X": "" },
2519 "8": { "Y": {}, "Z": "" }
2520 }
2521 }),
2522 )
2523 .await;
2524 fs.insert_tree(
2525 "/root2",
2526 json!({
2527 "d": {
2528 "4": ""
2529 },
2530 "e": {}
2531 }),
2532 )
2533 .await;
2534
2535 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2536 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2537 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2538 let panel = workspace
2539 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2540 .unwrap();
2541 assert_eq!(
2542 visible_entries_as_strings(&panel, 0..50, cx),
2543 &[
2544 "v root1",
2545 " > a",
2546 " > b",
2547 " > C",
2548 " .dockerignore",
2549 "v root2",
2550 " > d",
2551 " > e",
2552 ]
2553 );
2554
2555 toggle_expand_dir(&panel, "root1/b", cx);
2556 assert_eq!(
2557 visible_entries_as_strings(&panel, 0..50, cx),
2558 &[
2559 "v root1",
2560 " > a",
2561 " v b <== selected",
2562 " > 3",
2563 " > C",
2564 " .dockerignore",
2565 "v root2",
2566 " > d",
2567 " > e",
2568 ]
2569 );
2570
2571 toggle_expand_dir(&panel, "root2/d", cx);
2572 assert_eq!(
2573 visible_entries_as_strings(&panel, 0..50, cx),
2574 &[
2575 "v root1",
2576 " > a",
2577 " v b",
2578 " > 3",
2579 " > C",
2580 " .dockerignore",
2581 "v root2",
2582 " v d <== selected",
2583 " > e",
2584 ]
2585 );
2586
2587 toggle_expand_dir(&panel, "root2/e", cx);
2588 assert_eq!(
2589 visible_entries_as_strings(&panel, 0..50, cx),
2590 &[
2591 "v root1",
2592 " > a",
2593 " v b",
2594 " > 3",
2595 " > C",
2596 " .dockerignore",
2597 "v root2",
2598 " v d",
2599 " v e <== selected",
2600 ]
2601 );
2602 }
2603
2604 #[gpui::test]
2605 async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
2606 init_test(cx);
2607
2608 let fs = FakeFs::new(cx.executor().clone());
2609 fs.insert_tree(
2610 "/root1",
2611 json!({
2612 "dir_1": {
2613 "nested_dir_1": {
2614 "nested_dir_2": {
2615 "nested_dir_3": {
2616 "file_a.java": "// File contents",
2617 "file_b.java": "// File contents",
2618 "file_c.java": "// File contents",
2619 "nested_dir_4": {
2620 "nested_dir_5": {
2621 "file_d.java": "// File contents",
2622 }
2623 }
2624 }
2625 }
2626 }
2627 }
2628 }),
2629 )
2630 .await;
2631 fs.insert_tree(
2632 "/root2",
2633 json!({
2634 "dir_2": {
2635 "file_1.java": "// File contents",
2636 }
2637 }),
2638 )
2639 .await;
2640
2641 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2642 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2643 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2644 cx.update(|cx| {
2645 let settings = *ProjectPanelSettings::get_global(cx);
2646 ProjectPanelSettings::override_global(
2647 ProjectPanelSettings {
2648 auto_fold_dirs: true,
2649 ..settings
2650 },
2651 cx,
2652 );
2653 });
2654 let panel = workspace
2655 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2656 .unwrap();
2657 assert_eq!(
2658 visible_entries_as_strings(&panel, 0..10, cx),
2659 &[
2660 "v root1",
2661 " > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2662 "v root2",
2663 " > dir_2",
2664 ]
2665 );
2666
2667 toggle_expand_dir(
2668 &panel,
2669 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2670 cx,
2671 );
2672 assert_eq!(
2673 visible_entries_as_strings(&panel, 0..10, cx),
2674 &[
2675 "v root1",
2676 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
2677 " > nested_dir_4/nested_dir_5",
2678 " file_a.java",
2679 " file_b.java",
2680 " file_c.java",
2681 "v root2",
2682 " > dir_2",
2683 ]
2684 );
2685
2686 toggle_expand_dir(
2687 &panel,
2688 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
2689 cx,
2690 );
2691 assert_eq!(
2692 visible_entries_as_strings(&panel, 0..10, cx),
2693 &[
2694 "v root1",
2695 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2696 " v nested_dir_4/nested_dir_5 <== selected",
2697 " file_d.java",
2698 " file_a.java",
2699 " file_b.java",
2700 " file_c.java",
2701 "v root2",
2702 " > dir_2",
2703 ]
2704 );
2705 toggle_expand_dir(&panel, "root2/dir_2", cx);
2706 assert_eq!(
2707 visible_entries_as_strings(&panel, 0..10, cx),
2708 &[
2709 "v root1",
2710 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2711 " v nested_dir_4/nested_dir_5",
2712 " file_d.java",
2713 " file_a.java",
2714 " file_b.java",
2715 " file_c.java",
2716 "v root2",
2717 " v dir_2 <== selected",
2718 " file_1.java",
2719 ]
2720 );
2721 }
2722
2723 #[gpui::test(iterations = 30)]
2724 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
2725 init_test(cx);
2726
2727 let fs = FakeFs::new(cx.executor().clone());
2728 fs.insert_tree(
2729 "/root1",
2730 json!({
2731 ".dockerignore": "",
2732 ".git": {
2733 "HEAD": "",
2734 },
2735 "a": {
2736 "0": { "q": "", "r": "", "s": "" },
2737 "1": { "t": "", "u": "" },
2738 "2": { "v": "", "w": "", "x": "", "y": "" },
2739 },
2740 "b": {
2741 "3": { "Q": "" },
2742 "4": { "R": "", "S": "", "T": "", "U": "" },
2743 },
2744 "C": {
2745 "5": {},
2746 "6": { "V": "", "W": "" },
2747 "7": { "X": "" },
2748 "8": { "Y": {}, "Z": "" }
2749 }
2750 }),
2751 )
2752 .await;
2753 fs.insert_tree(
2754 "/root2",
2755 json!({
2756 "d": {
2757 "9": ""
2758 },
2759 "e": {}
2760 }),
2761 )
2762 .await;
2763
2764 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2765 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2766 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2767 let panel = workspace
2768 .update(cx, |workspace, cx| {
2769 let panel = ProjectPanel::new(workspace, cx);
2770 workspace.add_panel(panel.clone(), cx);
2771 panel
2772 })
2773 .unwrap();
2774
2775 select_path(&panel, "root1", cx);
2776 assert_eq!(
2777 visible_entries_as_strings(&panel, 0..10, cx),
2778 &[
2779 "v root1 <== selected",
2780 " > .git",
2781 " > a",
2782 " > b",
2783 " > C",
2784 " .dockerignore",
2785 "v root2",
2786 " > d",
2787 " > e",
2788 ]
2789 );
2790
2791 // Add a file with the root folder selected. The filename editor is placed
2792 // before the first file in the root folder.
2793 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2794 panel.update(cx, |panel, cx| {
2795 assert!(panel.filename_editor.read(cx).is_focused(cx));
2796 });
2797 assert_eq!(
2798 visible_entries_as_strings(&panel, 0..10, cx),
2799 &[
2800 "v root1",
2801 " > .git",
2802 " > a",
2803 " > b",
2804 " > C",
2805 " [EDITOR: ''] <== selected",
2806 " .dockerignore",
2807 "v root2",
2808 " > d",
2809 " > e",
2810 ]
2811 );
2812
2813 let confirm = panel.update(cx, |panel, cx| {
2814 panel
2815 .filename_editor
2816 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2817 panel.confirm_edit(cx).unwrap()
2818 });
2819 assert_eq!(
2820 visible_entries_as_strings(&panel, 0..10, cx),
2821 &[
2822 "v root1",
2823 " > .git",
2824 " > a",
2825 " > b",
2826 " > C",
2827 " [PROCESSING: 'the-new-filename'] <== selected",
2828 " .dockerignore",
2829 "v root2",
2830 " > d",
2831 " > e",
2832 ]
2833 );
2834
2835 confirm.await.unwrap();
2836 assert_eq!(
2837 visible_entries_as_strings(&panel, 0..10, cx),
2838 &[
2839 "v root1",
2840 " > .git",
2841 " > a",
2842 " > b",
2843 " > C",
2844 " .dockerignore",
2845 " the-new-filename <== selected <== marked",
2846 "v root2",
2847 " > d",
2848 " > e",
2849 ]
2850 );
2851
2852 select_path(&panel, "root1/b", cx);
2853 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2854 assert_eq!(
2855 visible_entries_as_strings(&panel, 0..10, cx),
2856 &[
2857 "v root1",
2858 " > .git",
2859 " > a",
2860 " v b",
2861 " > 3",
2862 " > 4",
2863 " [EDITOR: ''] <== selected",
2864 " > C",
2865 " .dockerignore",
2866 " the-new-filename",
2867 ]
2868 );
2869
2870 panel
2871 .update(cx, |panel, cx| {
2872 panel
2873 .filename_editor
2874 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2875 panel.confirm_edit(cx).unwrap()
2876 })
2877 .await
2878 .unwrap();
2879 assert_eq!(
2880 visible_entries_as_strings(&panel, 0..10, cx),
2881 &[
2882 "v root1",
2883 " > .git",
2884 " > a",
2885 " v b",
2886 " > 3",
2887 " > 4",
2888 " another-filename.txt <== selected <== marked",
2889 " > C",
2890 " .dockerignore",
2891 " the-new-filename",
2892 ]
2893 );
2894
2895 select_path(&panel, "root1/b/another-filename.txt", cx);
2896 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2897 assert_eq!(
2898 visible_entries_as_strings(&panel, 0..10, cx),
2899 &[
2900 "v root1",
2901 " > .git",
2902 " > a",
2903 " v b",
2904 " > 3",
2905 " > 4",
2906 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
2907 " > C",
2908 " .dockerignore",
2909 " the-new-filename",
2910 ]
2911 );
2912
2913 let confirm = panel.update(cx, |panel, cx| {
2914 panel.filename_editor.update(cx, |editor, cx| {
2915 let file_name_selections = editor.selections.all::<usize>(cx);
2916 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2917 let file_name_selection = &file_name_selections[0];
2918 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2919 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2920
2921 editor.set_text("a-different-filename.tar.gz", cx)
2922 });
2923 panel.confirm_edit(cx).unwrap()
2924 });
2925 assert_eq!(
2926 visible_entries_as_strings(&panel, 0..10, cx),
2927 &[
2928 "v root1",
2929 " > .git",
2930 " > a",
2931 " v b",
2932 " > 3",
2933 " > 4",
2934 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
2935 " > C",
2936 " .dockerignore",
2937 " the-new-filename",
2938 ]
2939 );
2940
2941 confirm.await.unwrap();
2942 assert_eq!(
2943 visible_entries_as_strings(&panel, 0..10, cx),
2944 &[
2945 "v root1",
2946 " > .git",
2947 " > a",
2948 " v b",
2949 " > 3",
2950 " > 4",
2951 " a-different-filename.tar.gz <== selected",
2952 " > C",
2953 " .dockerignore",
2954 " the-new-filename",
2955 ]
2956 );
2957
2958 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2959 assert_eq!(
2960 visible_entries_as_strings(&panel, 0..10, cx),
2961 &[
2962 "v root1",
2963 " > .git",
2964 " > a",
2965 " v b",
2966 " > 3",
2967 " > 4",
2968 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2969 " > C",
2970 " .dockerignore",
2971 " the-new-filename",
2972 ]
2973 );
2974
2975 panel.update(cx, |panel, cx| {
2976 panel.filename_editor.update(cx, |editor, cx| {
2977 let file_name_selections = editor.selections.all::<usize>(cx);
2978 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2979 let file_name_selection = &file_name_selections[0];
2980 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2981 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..");
2982
2983 });
2984 panel.cancel(&menu::Cancel, cx)
2985 });
2986
2987 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2988 assert_eq!(
2989 visible_entries_as_strings(&panel, 0..10, cx),
2990 &[
2991 "v root1",
2992 " > .git",
2993 " > a",
2994 " v b",
2995 " > [EDITOR: ''] <== selected",
2996 " > 3",
2997 " > 4",
2998 " a-different-filename.tar.gz",
2999 " > C",
3000 " .dockerignore",
3001 ]
3002 );
3003
3004 let confirm = panel.update(cx, |panel, cx| {
3005 panel
3006 .filename_editor
3007 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
3008 panel.confirm_edit(cx).unwrap()
3009 });
3010 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
3011 assert_eq!(
3012 visible_entries_as_strings(&panel, 0..10, cx),
3013 &[
3014 "v root1",
3015 " > .git",
3016 " > a",
3017 " v b",
3018 " > [PROCESSING: 'new-dir']",
3019 " > 3 <== selected",
3020 " > 4",
3021 " a-different-filename.tar.gz",
3022 " > C",
3023 " .dockerignore",
3024 ]
3025 );
3026
3027 confirm.await.unwrap();
3028 assert_eq!(
3029 visible_entries_as_strings(&panel, 0..10, cx),
3030 &[
3031 "v root1",
3032 " > .git",
3033 " > a",
3034 " v b",
3035 " > 3 <== selected",
3036 " > 4",
3037 " > new-dir",
3038 " a-different-filename.tar.gz",
3039 " > C",
3040 " .dockerignore",
3041 ]
3042 );
3043
3044 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
3045 assert_eq!(
3046 visible_entries_as_strings(&panel, 0..10, cx),
3047 &[
3048 "v root1",
3049 " > .git",
3050 " > a",
3051 " v b",
3052 " > [EDITOR: '3'] <== selected",
3053 " > 4",
3054 " > new-dir",
3055 " a-different-filename.tar.gz",
3056 " > C",
3057 " .dockerignore",
3058 ]
3059 );
3060
3061 // Dismiss the rename editor when it loses focus.
3062 workspace.update(cx, |_, cx| cx.blur()).unwrap();
3063 assert_eq!(
3064 visible_entries_as_strings(&panel, 0..10, cx),
3065 &[
3066 "v root1",
3067 " > .git",
3068 " > a",
3069 " v b",
3070 " > 3 <== selected",
3071 " > 4",
3072 " > new-dir",
3073 " a-different-filename.tar.gz",
3074 " > C",
3075 " .dockerignore",
3076 ]
3077 );
3078 }
3079
3080 #[gpui::test(iterations = 10)]
3081 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
3082 init_test(cx);
3083
3084 let fs = FakeFs::new(cx.executor().clone());
3085 fs.insert_tree(
3086 "/root1",
3087 json!({
3088 ".dockerignore": "",
3089 ".git": {
3090 "HEAD": "",
3091 },
3092 "a": {
3093 "0": { "q": "", "r": "", "s": "" },
3094 "1": { "t": "", "u": "" },
3095 "2": { "v": "", "w": "", "x": "", "y": "" },
3096 },
3097 "b": {
3098 "3": { "Q": "" },
3099 "4": { "R": "", "S": "", "T": "", "U": "" },
3100 },
3101 "C": {
3102 "5": {},
3103 "6": { "V": "", "W": "" },
3104 "7": { "X": "" },
3105 "8": { "Y": {}, "Z": "" }
3106 }
3107 }),
3108 )
3109 .await;
3110 fs.insert_tree(
3111 "/root2",
3112 json!({
3113 "d": {
3114 "9": ""
3115 },
3116 "e": {}
3117 }),
3118 )
3119 .await;
3120
3121 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3122 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3123 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3124 let panel = workspace
3125 .update(cx, |workspace, cx| {
3126 let panel = ProjectPanel::new(workspace, cx);
3127 workspace.add_panel(panel.clone(), cx);
3128 panel
3129 })
3130 .unwrap();
3131
3132 select_path(&panel, "root1", cx);
3133 assert_eq!(
3134 visible_entries_as_strings(&panel, 0..10, cx),
3135 &[
3136 "v root1 <== selected",
3137 " > .git",
3138 " > a",
3139 " > b",
3140 " > C",
3141 " .dockerignore",
3142 "v root2",
3143 " > d",
3144 " > e",
3145 ]
3146 );
3147
3148 // Add a file with the root folder selected. The filename editor is placed
3149 // before the first file in the root folder.
3150 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3151 panel.update(cx, |panel, cx| {
3152 assert!(panel.filename_editor.read(cx).is_focused(cx));
3153 });
3154 assert_eq!(
3155 visible_entries_as_strings(&panel, 0..10, cx),
3156 &[
3157 "v root1",
3158 " > .git",
3159 " > a",
3160 " > b",
3161 " > C",
3162 " [EDITOR: ''] <== selected",
3163 " .dockerignore",
3164 "v root2",
3165 " > d",
3166 " > e",
3167 ]
3168 );
3169
3170 let confirm = panel.update(cx, |panel, cx| {
3171 panel.filename_editor.update(cx, |editor, cx| {
3172 editor.set_text("/bdir1/dir2/the-new-filename", cx)
3173 });
3174 panel.confirm_edit(cx).unwrap()
3175 });
3176
3177 assert_eq!(
3178 visible_entries_as_strings(&panel, 0..10, cx),
3179 &[
3180 "v root1",
3181 " > .git",
3182 " > a",
3183 " > b",
3184 " > C",
3185 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
3186 " .dockerignore",
3187 "v root2",
3188 " > d",
3189 " > e",
3190 ]
3191 );
3192
3193 confirm.await.unwrap();
3194 assert_eq!(
3195 visible_entries_as_strings(&panel, 0..13, cx),
3196 &[
3197 "v root1",
3198 " > .git",
3199 " > a",
3200 " > b",
3201 " v bdir1",
3202 " v dir2",
3203 " the-new-filename <== selected <== marked",
3204 " > C",
3205 " .dockerignore",
3206 "v root2",
3207 " > d",
3208 " > e",
3209 ]
3210 );
3211 }
3212
3213 #[gpui::test]
3214 async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
3215 init_test(cx);
3216
3217 let fs = FakeFs::new(cx.executor().clone());
3218 fs.insert_tree(
3219 "/root1",
3220 json!({
3221 ".dockerignore": "",
3222 ".git": {
3223 "HEAD": "",
3224 },
3225 }),
3226 )
3227 .await;
3228
3229 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3230 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3231 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3232 let panel = workspace
3233 .update(cx, |workspace, cx| {
3234 let panel = ProjectPanel::new(workspace, cx);
3235 workspace.add_panel(panel.clone(), cx);
3236 panel
3237 })
3238 .unwrap();
3239
3240 select_path(&panel, "root1", cx);
3241 assert_eq!(
3242 visible_entries_as_strings(&panel, 0..10, cx),
3243 &["v root1 <== selected", " > .git", " .dockerignore",]
3244 );
3245
3246 // Add a file with the root folder selected. The filename editor is placed
3247 // before the first file in the root folder.
3248 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3249 panel.update(cx, |panel, cx| {
3250 assert!(panel.filename_editor.read(cx).is_focused(cx));
3251 });
3252 assert_eq!(
3253 visible_entries_as_strings(&panel, 0..10, cx),
3254 &[
3255 "v root1",
3256 " > .git",
3257 " [EDITOR: ''] <== selected",
3258 " .dockerignore",
3259 ]
3260 );
3261
3262 let confirm = panel.update(cx, |panel, cx| {
3263 panel
3264 .filename_editor
3265 .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
3266 panel.confirm_edit(cx).unwrap()
3267 });
3268
3269 assert_eq!(
3270 visible_entries_as_strings(&panel, 0..10, cx),
3271 &[
3272 "v root1",
3273 " > .git",
3274 " [PROCESSING: '/new_dir/'] <== selected",
3275 " .dockerignore",
3276 ]
3277 );
3278
3279 confirm.await.unwrap();
3280 assert_eq!(
3281 visible_entries_as_strings(&panel, 0..13, cx),
3282 &[
3283 "v root1",
3284 " > .git",
3285 " v new_dir <== selected",
3286 " .dockerignore",
3287 ]
3288 );
3289 }
3290
3291 #[gpui::test]
3292 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
3293 init_test(cx);
3294
3295 let fs = FakeFs::new(cx.executor().clone());
3296 fs.insert_tree(
3297 "/root1",
3298 json!({
3299 "one.two.txt": "",
3300 "one.txt": ""
3301 }),
3302 )
3303 .await;
3304
3305 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3306 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3307 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3308 let panel = workspace
3309 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3310 .unwrap();
3311
3312 panel.update(cx, |panel, cx| {
3313 panel.select_next(&Default::default(), cx);
3314 panel.select_next(&Default::default(), cx);
3315 });
3316
3317 assert_eq!(
3318 visible_entries_as_strings(&panel, 0..50, cx),
3319 &[
3320 //
3321 "v root1",
3322 " one.two.txt <== selected",
3323 " one.txt",
3324 ]
3325 );
3326
3327 // Regression test - file name is created correctly when
3328 // the copied file's name contains multiple dots.
3329 panel.update(cx, |panel, cx| {
3330 panel.copy(&Default::default(), cx);
3331 panel.paste(&Default::default(), cx);
3332 });
3333 cx.executor().run_until_parked();
3334
3335 assert_eq!(
3336 visible_entries_as_strings(&panel, 0..50, cx),
3337 &[
3338 //
3339 "v root1",
3340 " one.two copy.txt",
3341 " one.two.txt <== selected",
3342 " one.txt",
3343 ]
3344 );
3345
3346 panel.update(cx, |panel, cx| {
3347 panel.paste(&Default::default(), cx);
3348 });
3349 cx.executor().run_until_parked();
3350
3351 assert_eq!(
3352 visible_entries_as_strings(&panel, 0..50, cx),
3353 &[
3354 //
3355 "v root1",
3356 " one.two copy 1.txt",
3357 " one.two copy.txt",
3358 " one.two.txt <== selected",
3359 " one.txt",
3360 ]
3361 );
3362 }
3363
3364 #[gpui::test]
3365 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
3366 init_test(cx);
3367
3368 let fs = FakeFs::new(cx.executor().clone());
3369 fs.insert_tree(
3370 "/root",
3371 json!({
3372 "a": {
3373 "one.txt": "",
3374 "two.txt": "",
3375 "inner_dir": {
3376 "three.txt": "",
3377 "four.txt": "",
3378 }
3379 },
3380 "b": {}
3381 }),
3382 )
3383 .await;
3384
3385 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3386 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3387 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3388 let panel = workspace
3389 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3390 .unwrap();
3391
3392 select_path(&panel, "root/a", cx);
3393 panel.update(cx, |panel, cx| {
3394 panel.copy(&Default::default(), cx);
3395 panel.select_next(&Default::default(), cx);
3396 panel.paste(&Default::default(), cx);
3397 });
3398 cx.executor().run_until_parked();
3399
3400 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
3401 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
3402
3403 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
3404 assert_ne!(
3405 pasted_dir_file, None,
3406 "Pasted directory file should have an entry"
3407 );
3408
3409 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
3410 assert_ne!(
3411 pasted_dir_inner_dir, None,
3412 "Directories inside pasted directory should have an entry"
3413 );
3414
3415 toggle_expand_dir(&panel, "root/b/a", cx);
3416 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
3417
3418 assert_eq!(
3419 visible_entries_as_strings(&panel, 0..50, cx),
3420 &[
3421 //
3422 "v root",
3423 " > a",
3424 " v b",
3425 " v a",
3426 " v inner_dir <== selected",
3427 " four.txt",
3428 " three.txt",
3429 " one.txt",
3430 " two.txt",
3431 ]
3432 );
3433
3434 select_path(&panel, "root", cx);
3435 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
3436 cx.executor().run_until_parked();
3437 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
3438 cx.executor().run_until_parked();
3439 assert_eq!(
3440 visible_entries_as_strings(&panel, 0..50, cx),
3441 &[
3442 //
3443 "v root <== selected",
3444 " > a",
3445 " > a copy",
3446 " > a copy 1",
3447 " v b",
3448 " v a",
3449 " v inner_dir",
3450 " four.txt",
3451 " three.txt",
3452 " one.txt",
3453 " two.txt"
3454 ]
3455 );
3456 }
3457
3458 #[gpui::test]
3459 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
3460 init_test_with_editor(cx);
3461
3462 let fs = FakeFs::new(cx.executor().clone());
3463 fs.insert_tree(
3464 "/src",
3465 json!({
3466 "test": {
3467 "first.rs": "// First Rust file",
3468 "second.rs": "// Second Rust file",
3469 "third.rs": "// Third Rust file",
3470 }
3471 }),
3472 )
3473 .await;
3474
3475 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3476 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3477 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3478 let panel = workspace
3479 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3480 .unwrap();
3481
3482 toggle_expand_dir(&panel, "src/test", cx);
3483 select_path(&panel, "src/test/first.rs", cx);
3484 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3485 cx.executor().run_until_parked();
3486 assert_eq!(
3487 visible_entries_as_strings(&panel, 0..10, cx),
3488 &[
3489 "v src",
3490 " v test",
3491 " first.rs <== selected",
3492 " second.rs",
3493 " third.rs"
3494 ]
3495 );
3496 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
3497
3498 submit_deletion(&panel, cx);
3499 assert_eq!(
3500 visible_entries_as_strings(&panel, 0..10, cx),
3501 &[
3502 "v src",
3503 " v test",
3504 " second.rs",
3505 " third.rs"
3506 ],
3507 "Project panel should have no deleted file, no other file is selected in it"
3508 );
3509 ensure_no_open_items_and_panes(&workspace, cx);
3510
3511 select_path(&panel, "src/test/second.rs", cx);
3512 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3513 cx.executor().run_until_parked();
3514 assert_eq!(
3515 visible_entries_as_strings(&panel, 0..10, cx),
3516 &[
3517 "v src",
3518 " v test",
3519 " second.rs <== selected",
3520 " third.rs"
3521 ]
3522 );
3523 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
3524
3525 workspace
3526 .update(cx, |workspace, cx| {
3527 let active_items = workspace
3528 .panes()
3529 .iter()
3530 .filter_map(|pane| pane.read(cx).active_item())
3531 .collect::<Vec<_>>();
3532 assert_eq!(active_items.len(), 1);
3533 let open_editor = active_items
3534 .into_iter()
3535 .next()
3536 .unwrap()
3537 .downcast::<Editor>()
3538 .expect("Open item should be an editor");
3539 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
3540 })
3541 .unwrap();
3542 submit_deletion_skipping_prompt(&panel, cx);
3543 assert_eq!(
3544 visible_entries_as_strings(&panel, 0..10, cx),
3545 &["v src", " v test", " third.rs"],
3546 "Project panel should have no deleted file, with one last file remaining"
3547 );
3548 ensure_no_open_items_and_panes(&workspace, cx);
3549 }
3550
3551 #[gpui::test]
3552 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
3553 init_test_with_editor(cx);
3554
3555 let fs = FakeFs::new(cx.executor().clone());
3556 fs.insert_tree(
3557 "/src",
3558 json!({
3559 "test": {
3560 "first.rs": "// First Rust file",
3561 "second.rs": "// Second Rust file",
3562 "third.rs": "// Third Rust file",
3563 }
3564 }),
3565 )
3566 .await;
3567
3568 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3569 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3570 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3571 let panel = workspace
3572 .update(cx, |workspace, cx| {
3573 let panel = ProjectPanel::new(workspace, cx);
3574 workspace.add_panel(panel.clone(), cx);
3575 panel
3576 })
3577 .unwrap();
3578
3579 select_path(&panel, "src/", cx);
3580 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3581 cx.executor().run_until_parked();
3582 assert_eq!(
3583 visible_entries_as_strings(&panel, 0..10, cx),
3584 &[
3585 //
3586 "v src <== selected",
3587 " > test"
3588 ]
3589 );
3590 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
3591 panel.update(cx, |panel, cx| {
3592 assert!(panel.filename_editor.read(cx).is_focused(cx));
3593 });
3594 assert_eq!(
3595 visible_entries_as_strings(&panel, 0..10, cx),
3596 &[
3597 //
3598 "v src",
3599 " > [EDITOR: ''] <== selected",
3600 " > test"
3601 ]
3602 );
3603 panel.update(cx, |panel, cx| {
3604 panel
3605 .filename_editor
3606 .update(cx, |editor, cx| editor.set_text("test", cx));
3607 assert!(
3608 panel.confirm_edit(cx).is_none(),
3609 "Should not allow to confirm on conflicting new directory name"
3610 )
3611 });
3612 assert_eq!(
3613 visible_entries_as_strings(&panel, 0..10, cx),
3614 &[
3615 //
3616 "v src",
3617 " > test"
3618 ],
3619 "File list should be unchanged after failed folder create confirmation"
3620 );
3621
3622 select_path(&panel, "src/test/", cx);
3623 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3624 cx.executor().run_until_parked();
3625 assert_eq!(
3626 visible_entries_as_strings(&panel, 0..10, cx),
3627 &[
3628 //
3629 "v src",
3630 " > test <== selected"
3631 ]
3632 );
3633 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3634 panel.update(cx, |panel, cx| {
3635 assert!(panel.filename_editor.read(cx).is_focused(cx));
3636 });
3637 assert_eq!(
3638 visible_entries_as_strings(&panel, 0..10, cx),
3639 &[
3640 "v src",
3641 " v test",
3642 " [EDITOR: ''] <== selected",
3643 " first.rs",
3644 " second.rs",
3645 " third.rs"
3646 ]
3647 );
3648 panel.update(cx, |panel, cx| {
3649 panel
3650 .filename_editor
3651 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
3652 assert!(
3653 panel.confirm_edit(cx).is_none(),
3654 "Should not allow to confirm on conflicting new file name"
3655 )
3656 });
3657 assert_eq!(
3658 visible_entries_as_strings(&panel, 0..10, cx),
3659 &[
3660 "v src",
3661 " v test",
3662 " first.rs",
3663 " second.rs",
3664 " third.rs"
3665 ],
3666 "File list should be unchanged after failed file create confirmation"
3667 );
3668
3669 select_path(&panel, "src/test/first.rs", cx);
3670 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3671 cx.executor().run_until_parked();
3672 assert_eq!(
3673 visible_entries_as_strings(&panel, 0..10, cx),
3674 &[
3675 "v src",
3676 " v test",
3677 " first.rs <== selected",
3678 " second.rs",
3679 " third.rs"
3680 ],
3681 );
3682 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3683 panel.update(cx, |panel, cx| {
3684 assert!(panel.filename_editor.read(cx).is_focused(cx));
3685 });
3686 assert_eq!(
3687 visible_entries_as_strings(&panel, 0..10, cx),
3688 &[
3689 "v src",
3690 " v test",
3691 " [EDITOR: 'first.rs'] <== selected",
3692 " second.rs",
3693 " third.rs"
3694 ]
3695 );
3696 panel.update(cx, |panel, cx| {
3697 panel
3698 .filename_editor
3699 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
3700 assert!(
3701 panel.confirm_edit(cx).is_none(),
3702 "Should not allow to confirm on conflicting file rename"
3703 )
3704 });
3705 assert_eq!(
3706 visible_entries_as_strings(&panel, 0..10, cx),
3707 &[
3708 "v src",
3709 " v test",
3710 " first.rs <== selected",
3711 " second.rs",
3712 " third.rs"
3713 ],
3714 "File list should be unchanged after failed rename confirmation"
3715 );
3716 }
3717
3718 #[gpui::test]
3719 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
3720 init_test_with_editor(cx);
3721
3722 let fs = FakeFs::new(cx.executor().clone());
3723 fs.insert_tree(
3724 "/project_root",
3725 json!({
3726 "dir_1": {
3727 "nested_dir": {
3728 "file_a.py": "# File contents",
3729 }
3730 },
3731 "file_1.py": "# File contents",
3732 }),
3733 )
3734 .await;
3735
3736 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3737 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3738 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3739 let panel = workspace
3740 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3741 .unwrap();
3742
3743 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3744 cx.executor().run_until_parked();
3745 select_path(&panel, "project_root/dir_1", cx);
3746 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3747 select_path(&panel, "project_root/dir_1/nested_dir", cx);
3748 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3749 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3750 cx.executor().run_until_parked();
3751 assert_eq!(
3752 visible_entries_as_strings(&panel, 0..10, cx),
3753 &[
3754 "v project_root",
3755 " v dir_1",
3756 " > nested_dir <== selected",
3757 " file_1.py",
3758 ]
3759 );
3760 }
3761
3762 #[gpui::test]
3763 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
3764 init_test_with_editor(cx);
3765
3766 let fs = FakeFs::new(cx.executor().clone());
3767 fs.insert_tree(
3768 "/project_root",
3769 json!({
3770 "dir_1": {
3771 "nested_dir": {
3772 "file_a.py": "# File contents",
3773 "file_b.py": "# File contents",
3774 "file_c.py": "# File contents",
3775 },
3776 "file_1.py": "# File contents",
3777 "file_2.py": "# File contents",
3778 "file_3.py": "# File contents",
3779 },
3780 "dir_2": {
3781 "file_1.py": "# File contents",
3782 "file_2.py": "# File contents",
3783 "file_3.py": "# File contents",
3784 }
3785 }),
3786 )
3787 .await;
3788
3789 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3790 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3791 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3792 let panel = workspace
3793 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3794 .unwrap();
3795
3796 panel.update(cx, |panel, cx| {
3797 panel.collapse_all_entries(&CollapseAllEntries, cx)
3798 });
3799 cx.executor().run_until_parked();
3800 assert_eq!(
3801 visible_entries_as_strings(&panel, 0..10, cx),
3802 &["v project_root", " > dir_1", " > dir_2",]
3803 );
3804
3805 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
3806 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3807 cx.executor().run_until_parked();
3808 assert_eq!(
3809 visible_entries_as_strings(&panel, 0..10, cx),
3810 &[
3811 "v project_root",
3812 " v dir_1 <== selected",
3813 " > nested_dir",
3814 " file_1.py",
3815 " file_2.py",
3816 " file_3.py",
3817 " > dir_2",
3818 ]
3819 );
3820 }
3821
3822 #[gpui::test]
3823 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
3824 init_test(cx);
3825
3826 let fs = FakeFs::new(cx.executor().clone());
3827 fs.as_fake().insert_tree("/root", json!({})).await;
3828 let project = Project::test(fs, ["/root".as_ref()], cx).await;
3829 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3830 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3831 let panel = workspace
3832 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3833 .unwrap();
3834
3835 // Make a new buffer with no backing file
3836 workspace
3837 .update(cx, |workspace, cx| {
3838 Editor::new_file(workspace, &Default::default(), cx)
3839 })
3840 .unwrap();
3841
3842 cx.executor().run_until_parked();
3843
3844 // "Save as" the buffer, creating a new backing file for it
3845 let save_task = workspace
3846 .update(cx, |workspace, cx| {
3847 workspace.save_active_item(workspace::SaveIntent::Save, cx)
3848 })
3849 .unwrap();
3850
3851 cx.executor().run_until_parked();
3852 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
3853 save_task.await.unwrap();
3854
3855 // Rename the file
3856 select_path(&panel, "root/new", cx);
3857 assert_eq!(
3858 visible_entries_as_strings(&panel, 0..10, cx),
3859 &["v root", " new <== selected"]
3860 );
3861 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3862 panel.update(cx, |panel, cx| {
3863 panel
3864 .filename_editor
3865 .update(cx, |editor, cx| editor.set_text("newer", cx));
3866 });
3867 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3868
3869 cx.executor().run_until_parked();
3870 assert_eq!(
3871 visible_entries_as_strings(&panel, 0..10, cx),
3872 &["v root", " newer <== selected"]
3873 );
3874
3875 workspace
3876 .update(cx, |workspace, cx| {
3877 workspace.save_active_item(workspace::SaveIntent::Save, cx)
3878 })
3879 .unwrap()
3880 .await
3881 .unwrap();
3882
3883 cx.executor().run_until_parked();
3884 // assert that saving the file doesn't restore "new"
3885 assert_eq!(
3886 visible_entries_as_strings(&panel, 0..10, cx),
3887 &["v root", " newer <== selected"]
3888 );
3889 }
3890
3891 #[gpui::test]
3892 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
3893 init_test_with_editor(cx);
3894 let fs = FakeFs::new(cx.executor().clone());
3895 fs.insert_tree(
3896 "/project_root",
3897 json!({
3898 "dir_1": {
3899 "nested_dir": {
3900 "file_a.py": "# File contents",
3901 }
3902 },
3903 "file_1.py": "# File contents",
3904 }),
3905 )
3906 .await;
3907
3908 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3909 let worktree_id =
3910 cx.update(|cx| project.read(cx).worktrees().next().unwrap().read(cx).id());
3911 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3912 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3913 let panel = workspace
3914 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3915 .unwrap();
3916 cx.update(|cx| {
3917 panel.update(cx, |this, cx| {
3918 this.select_next(&Default::default(), cx);
3919 this.expand_selected_entry(&Default::default(), cx);
3920 this.expand_selected_entry(&Default::default(), cx);
3921 this.select_next(&Default::default(), cx);
3922 this.expand_selected_entry(&Default::default(), cx);
3923 this.select_next(&Default::default(), cx);
3924 })
3925 });
3926 assert_eq!(
3927 visible_entries_as_strings(&panel, 0..10, cx),
3928 &[
3929 "v project_root",
3930 " v dir_1",
3931 " v nested_dir",
3932 " file_a.py <== selected",
3933 " file_1.py",
3934 ]
3935 );
3936 let modifiers_with_shift = gpui::Modifiers {
3937 shift: true,
3938 ..Default::default()
3939 };
3940 cx.simulate_modifiers_change(modifiers_with_shift);
3941 cx.update(|cx| {
3942 panel.update(cx, |this, cx| {
3943 this.select_next(&Default::default(), cx);
3944 })
3945 });
3946 assert_eq!(
3947 visible_entries_as_strings(&panel, 0..10, cx),
3948 &[
3949 "v project_root",
3950 " v dir_1",
3951 " v nested_dir",
3952 " file_a.py",
3953 " file_1.py <== selected <== marked",
3954 ]
3955 );
3956 cx.update(|cx| {
3957 panel.update(cx, |this, cx| {
3958 this.select_prev(&Default::default(), cx);
3959 })
3960 });
3961 assert_eq!(
3962 visible_entries_as_strings(&panel, 0..10, cx),
3963 &[
3964 "v project_root",
3965 " v dir_1",
3966 " v nested_dir",
3967 " file_a.py <== selected <== marked",
3968 " file_1.py <== marked",
3969 ]
3970 );
3971 cx.update(|cx| {
3972 panel.update(cx, |this, cx| {
3973 let drag = DraggedSelection {
3974 active_selection: this.selection.unwrap(),
3975 marked_selections: Arc::new(this.marked_entries.clone()),
3976 };
3977 let target_entry = this
3978 .project
3979 .read(cx)
3980 .entry_for_path(&(worktree_id, "").into(), cx)
3981 .unwrap();
3982 this.drag_onto(&drag, target_entry.id, false, cx);
3983 });
3984 });
3985 cx.run_until_parked();
3986 assert_eq!(
3987 visible_entries_as_strings(&panel, 0..10, cx),
3988 &[
3989 "v project_root",
3990 " v dir_1",
3991 " v nested_dir",
3992 " file_1.py <== marked",
3993 " file_a.py <== selected <== marked",
3994 ]
3995 );
3996 // ESC clears out all marks
3997 cx.update(|cx| {
3998 panel.update(cx, |this, cx| {
3999 this.cancel(&menu::Cancel, cx);
4000 })
4001 });
4002 assert_eq!(
4003 visible_entries_as_strings(&panel, 0..10, cx),
4004 &[
4005 "v project_root",
4006 " v dir_1",
4007 " v nested_dir",
4008 " file_1.py",
4009 " file_a.py <== selected",
4010 ]
4011 );
4012 // ESC clears out all marks
4013 cx.update(|cx| {
4014 panel.update(cx, |this, cx| {
4015 this.select_prev(&SelectPrev, cx);
4016 this.select_next(&SelectNext, cx);
4017 })
4018 });
4019 assert_eq!(
4020 visible_entries_as_strings(&panel, 0..10, cx),
4021 &[
4022 "v project_root",
4023 " v dir_1",
4024 " v nested_dir",
4025 " file_1.py <== marked",
4026 " file_a.py <== selected <== marked",
4027 ]
4028 );
4029 cx.simulate_modifiers_change(Default::default());
4030 cx.update(|cx| {
4031 panel.update(cx, |this, cx| {
4032 this.cut(&Cut, cx);
4033 this.select_prev(&SelectPrev, cx);
4034 this.select_prev(&SelectPrev, cx);
4035
4036 this.paste(&Paste, cx);
4037 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
4038 })
4039 });
4040 cx.run_until_parked();
4041 assert_eq!(
4042 visible_entries_as_strings(&panel, 0..10, cx),
4043 &[
4044 "v project_root",
4045 " v dir_1",
4046 " v nested_dir <== selected",
4047 " file_1.py <== marked",
4048 " file_a.py <== marked",
4049 ]
4050 );
4051 cx.simulate_modifiers_change(modifiers_with_shift);
4052 cx.update(|cx| {
4053 panel.update(cx, |this, cx| {
4054 this.expand_selected_entry(&Default::default(), cx);
4055 this.select_next(&SelectNext, cx);
4056 this.select_next(&SelectNext, cx);
4057 })
4058 });
4059 submit_deletion(&panel, cx);
4060 assert_eq!(
4061 visible_entries_as_strings(&panel, 0..10, cx),
4062 &["v project_root", " v dir_1", " v nested_dir",]
4063 );
4064 }
4065 #[gpui::test]
4066 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
4067 init_test_with_editor(cx);
4068 cx.update(|cx| {
4069 cx.update_global::<SettingsStore, _>(|store, cx| {
4070 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4071 worktree_settings.file_scan_exclusions = Some(Vec::new());
4072 });
4073 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4074 project_panel_settings.auto_reveal_entries = Some(false)
4075 });
4076 })
4077 });
4078
4079 let fs = FakeFs::new(cx.background_executor.clone());
4080 fs.insert_tree(
4081 "/project_root",
4082 json!({
4083 ".git": {},
4084 ".gitignore": "**/gitignored_dir",
4085 "dir_1": {
4086 "file_1.py": "# File 1_1 contents",
4087 "file_2.py": "# File 1_2 contents",
4088 "file_3.py": "# File 1_3 contents",
4089 "gitignored_dir": {
4090 "file_a.py": "# File contents",
4091 "file_b.py": "# File contents",
4092 "file_c.py": "# File contents",
4093 },
4094 },
4095 "dir_2": {
4096 "file_1.py": "# File 2_1 contents",
4097 "file_2.py": "# File 2_2 contents",
4098 "file_3.py": "# File 2_3 contents",
4099 }
4100 }),
4101 )
4102 .await;
4103
4104 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4105 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4106 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4107 let panel = workspace
4108 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4109 .unwrap();
4110
4111 assert_eq!(
4112 visible_entries_as_strings(&panel, 0..20, cx),
4113 &[
4114 "v project_root",
4115 " > .git",
4116 " > dir_1",
4117 " > dir_2",
4118 " .gitignore",
4119 ]
4120 );
4121
4122 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4123 .expect("dir 1 file is not ignored and should have an entry");
4124 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4125 .expect("dir 2 file is not ignored and should have an entry");
4126 let gitignored_dir_file =
4127 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4128 assert_eq!(
4129 gitignored_dir_file, None,
4130 "File in the gitignored dir should not have an entry before its dir is toggled"
4131 );
4132
4133 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4134 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4135 cx.executor().run_until_parked();
4136 assert_eq!(
4137 visible_entries_as_strings(&panel, 0..20, cx),
4138 &[
4139 "v project_root",
4140 " > .git",
4141 " v dir_1",
4142 " v gitignored_dir <== selected",
4143 " file_a.py",
4144 " file_b.py",
4145 " file_c.py",
4146 " file_1.py",
4147 " file_2.py",
4148 " file_3.py",
4149 " > dir_2",
4150 " .gitignore",
4151 ],
4152 "Should show gitignored dir file list in the project panel"
4153 );
4154 let gitignored_dir_file =
4155 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4156 .expect("after gitignored dir got opened, a file entry should be present");
4157
4158 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4159 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4160 assert_eq!(
4161 visible_entries_as_strings(&panel, 0..20, cx),
4162 &[
4163 "v project_root",
4164 " > .git",
4165 " > dir_1 <== selected",
4166 " > dir_2",
4167 " .gitignore",
4168 ],
4169 "Should hide all dir contents again and prepare for the auto reveal test"
4170 );
4171
4172 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4173 panel.update(cx, |panel, cx| {
4174 panel.project.update(cx, |_, cx| {
4175 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4176 })
4177 });
4178 cx.run_until_parked();
4179 assert_eq!(
4180 visible_entries_as_strings(&panel, 0..20, cx),
4181 &[
4182 "v project_root",
4183 " > .git",
4184 " > dir_1 <== selected",
4185 " > dir_2",
4186 " .gitignore",
4187 ],
4188 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4189 );
4190 }
4191
4192 cx.update(|cx| {
4193 cx.update_global::<SettingsStore, _>(|store, cx| {
4194 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4195 project_panel_settings.auto_reveal_entries = Some(true)
4196 });
4197 })
4198 });
4199
4200 panel.update(cx, |panel, cx| {
4201 panel.project.update(cx, |_, cx| {
4202 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
4203 })
4204 });
4205 cx.run_until_parked();
4206 assert_eq!(
4207 visible_entries_as_strings(&panel, 0..20, cx),
4208 &[
4209 "v project_root",
4210 " > .git",
4211 " v dir_1",
4212 " > gitignored_dir",
4213 " file_1.py <== selected",
4214 " file_2.py",
4215 " file_3.py",
4216 " > dir_2",
4217 " .gitignore",
4218 ],
4219 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
4220 );
4221
4222 panel.update(cx, |panel, cx| {
4223 panel.project.update(cx, |_, cx| {
4224 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
4225 })
4226 });
4227 cx.run_until_parked();
4228 assert_eq!(
4229 visible_entries_as_strings(&panel, 0..20, cx),
4230 &[
4231 "v project_root",
4232 " > .git",
4233 " v dir_1",
4234 " > gitignored_dir",
4235 " file_1.py",
4236 " file_2.py",
4237 " file_3.py",
4238 " v dir_2",
4239 " file_1.py <== selected",
4240 " file_2.py",
4241 " file_3.py",
4242 " .gitignore",
4243 ],
4244 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
4245 );
4246
4247 panel.update(cx, |panel, cx| {
4248 panel.project.update(cx, |_, cx| {
4249 cx.emit(project::Event::ActiveEntryChanged(Some(
4250 gitignored_dir_file,
4251 )))
4252 })
4253 });
4254 cx.run_until_parked();
4255 assert_eq!(
4256 visible_entries_as_strings(&panel, 0..20, cx),
4257 &[
4258 "v project_root",
4259 " > .git",
4260 " v dir_1",
4261 " > gitignored_dir",
4262 " file_1.py",
4263 " file_2.py",
4264 " file_3.py",
4265 " v dir_2",
4266 " file_1.py <== selected",
4267 " file_2.py",
4268 " file_3.py",
4269 " .gitignore",
4270 ],
4271 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
4272 );
4273
4274 panel.update(cx, |panel, cx| {
4275 panel.project.update(cx, |_, cx| {
4276 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4277 })
4278 });
4279 cx.run_until_parked();
4280 assert_eq!(
4281 visible_entries_as_strings(&panel, 0..20, cx),
4282 &[
4283 "v project_root",
4284 " > .git",
4285 " v dir_1",
4286 " v gitignored_dir",
4287 " file_a.py <== selected",
4288 " file_b.py",
4289 " file_c.py",
4290 " file_1.py",
4291 " file_2.py",
4292 " file_3.py",
4293 " v dir_2",
4294 " file_1.py",
4295 " file_2.py",
4296 " file_3.py",
4297 " .gitignore",
4298 ],
4299 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
4300 );
4301 }
4302
4303 #[gpui::test]
4304 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
4305 init_test_with_editor(cx);
4306 cx.update(|cx| {
4307 cx.update_global::<SettingsStore, _>(|store, cx| {
4308 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4309 worktree_settings.file_scan_exclusions = Some(Vec::new());
4310 });
4311 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4312 project_panel_settings.auto_reveal_entries = Some(false)
4313 });
4314 })
4315 });
4316
4317 let fs = FakeFs::new(cx.background_executor.clone());
4318 fs.insert_tree(
4319 "/project_root",
4320 json!({
4321 ".git": {},
4322 ".gitignore": "**/gitignored_dir",
4323 "dir_1": {
4324 "file_1.py": "# File 1_1 contents",
4325 "file_2.py": "# File 1_2 contents",
4326 "file_3.py": "# File 1_3 contents",
4327 "gitignored_dir": {
4328 "file_a.py": "# File contents",
4329 "file_b.py": "# File contents",
4330 "file_c.py": "# File contents",
4331 },
4332 },
4333 "dir_2": {
4334 "file_1.py": "# File 2_1 contents",
4335 "file_2.py": "# File 2_2 contents",
4336 "file_3.py": "# File 2_3 contents",
4337 }
4338 }),
4339 )
4340 .await;
4341
4342 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4343 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4344 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4345 let panel = workspace
4346 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4347 .unwrap();
4348
4349 assert_eq!(
4350 visible_entries_as_strings(&panel, 0..20, cx),
4351 &[
4352 "v project_root",
4353 " > .git",
4354 " > dir_1",
4355 " > dir_2",
4356 " .gitignore",
4357 ]
4358 );
4359
4360 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4361 .expect("dir 1 file is not ignored and should have an entry");
4362 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4363 .expect("dir 2 file is not ignored and should have an entry");
4364 let gitignored_dir_file =
4365 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4366 assert_eq!(
4367 gitignored_dir_file, None,
4368 "File in the gitignored dir should not have an entry before its dir is toggled"
4369 );
4370
4371 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4372 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4373 cx.run_until_parked();
4374 assert_eq!(
4375 visible_entries_as_strings(&panel, 0..20, cx),
4376 &[
4377 "v project_root",
4378 " > .git",
4379 " v dir_1",
4380 " v gitignored_dir <== selected",
4381 " file_a.py",
4382 " file_b.py",
4383 " file_c.py",
4384 " file_1.py",
4385 " file_2.py",
4386 " file_3.py",
4387 " > dir_2",
4388 " .gitignore",
4389 ],
4390 "Should show gitignored dir file list in the project panel"
4391 );
4392 let gitignored_dir_file =
4393 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4394 .expect("after gitignored dir got opened, a file entry should be present");
4395
4396 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4397 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4398 assert_eq!(
4399 visible_entries_as_strings(&panel, 0..20, cx),
4400 &[
4401 "v project_root",
4402 " > .git",
4403 " > dir_1 <== selected",
4404 " > dir_2",
4405 " .gitignore",
4406 ],
4407 "Should hide all dir contents again and prepare for the explicit reveal test"
4408 );
4409
4410 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4411 panel.update(cx, |panel, cx| {
4412 panel.project.update(cx, |_, cx| {
4413 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4414 })
4415 });
4416 cx.run_until_parked();
4417 assert_eq!(
4418 visible_entries_as_strings(&panel, 0..20, cx),
4419 &[
4420 "v project_root",
4421 " > .git",
4422 " > dir_1 <== selected",
4423 " > dir_2",
4424 " .gitignore",
4425 ],
4426 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4427 );
4428 }
4429
4430 panel.update(cx, |panel, cx| {
4431 panel.project.update(cx, |_, cx| {
4432 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
4433 })
4434 });
4435 cx.run_until_parked();
4436 assert_eq!(
4437 visible_entries_as_strings(&panel, 0..20, cx),
4438 &[
4439 "v project_root",
4440 " > .git",
4441 " v dir_1",
4442 " > gitignored_dir",
4443 " file_1.py <== selected",
4444 " file_2.py",
4445 " file_3.py",
4446 " > dir_2",
4447 " .gitignore",
4448 ],
4449 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
4450 );
4451
4452 panel.update(cx, |panel, cx| {
4453 panel.project.update(cx, |_, cx| {
4454 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
4455 })
4456 });
4457 cx.run_until_parked();
4458 assert_eq!(
4459 visible_entries_as_strings(&panel, 0..20, cx),
4460 &[
4461 "v project_root",
4462 " > .git",
4463 " v dir_1",
4464 " > gitignored_dir",
4465 " file_1.py",
4466 " file_2.py",
4467 " file_3.py",
4468 " v dir_2",
4469 " file_1.py <== selected",
4470 " file_2.py",
4471 " file_3.py",
4472 " .gitignore",
4473 ],
4474 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
4475 );
4476
4477 panel.update(cx, |panel, cx| {
4478 panel.project.update(cx, |_, cx| {
4479 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4480 })
4481 });
4482 cx.run_until_parked();
4483 assert_eq!(
4484 visible_entries_as_strings(&panel, 0..20, cx),
4485 &[
4486 "v project_root",
4487 " > .git",
4488 " v dir_1",
4489 " v gitignored_dir",
4490 " file_a.py <== selected",
4491 " file_b.py",
4492 " file_c.py",
4493 " file_1.py",
4494 " file_2.py",
4495 " file_3.py",
4496 " v dir_2",
4497 " file_1.py",
4498 " file_2.py",
4499 " file_3.py",
4500 " .gitignore",
4501 ],
4502 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
4503 );
4504 }
4505
4506 #[gpui::test]
4507 async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
4508 init_test(cx);
4509 cx.update(|cx| {
4510 cx.update_global::<SettingsStore, _>(|store, cx| {
4511 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
4512 project_settings.file_scan_exclusions =
4513 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
4514 });
4515 });
4516 });
4517
4518 cx.update(|cx| {
4519 register_project_item::<TestProjectItemView>(cx);
4520 });
4521
4522 let fs = FakeFs::new(cx.executor().clone());
4523 fs.insert_tree(
4524 "/root1",
4525 json!({
4526 ".dockerignore": "",
4527 ".git": {
4528 "HEAD": "",
4529 },
4530 }),
4531 )
4532 .await;
4533
4534 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4535 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4536 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4537 let panel = workspace
4538 .update(cx, |workspace, cx| {
4539 let panel = ProjectPanel::new(workspace, cx);
4540 workspace.add_panel(panel.clone(), cx);
4541 panel
4542 })
4543 .unwrap();
4544
4545 select_path(&panel, "root1", cx);
4546 assert_eq!(
4547 visible_entries_as_strings(&panel, 0..10, cx),
4548 &["v root1 <== selected", " .dockerignore",]
4549 );
4550 workspace
4551 .update(cx, |workspace, cx| {
4552 assert!(
4553 workspace.active_item(cx).is_none(),
4554 "Should have no active items in the beginning"
4555 );
4556 })
4557 .unwrap();
4558
4559 let excluded_file_path = ".git/COMMIT_EDITMSG";
4560 let excluded_dir_path = "excluded_dir";
4561
4562 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4563 panel.update(cx, |panel, cx| {
4564 assert!(panel.filename_editor.read(cx).is_focused(cx));
4565 });
4566 panel
4567 .update(cx, |panel, cx| {
4568 panel
4569 .filename_editor
4570 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
4571 panel.confirm_edit(cx).unwrap()
4572 })
4573 .await
4574 .unwrap();
4575
4576 assert_eq!(
4577 visible_entries_as_strings(&panel, 0..13, cx),
4578 &["v root1", " .dockerignore"],
4579 "Excluded dir should not be shown after opening a file in it"
4580 );
4581 panel.update(cx, |panel, cx| {
4582 assert!(
4583 !panel.filename_editor.read(cx).is_focused(cx),
4584 "Should have closed the file name editor"
4585 );
4586 });
4587 workspace
4588 .update(cx, |workspace, cx| {
4589 let active_entry_path = workspace
4590 .active_item(cx)
4591 .expect("should have opened and activated the excluded item")
4592 .act_as::<TestProjectItemView>(cx)
4593 .expect(
4594 "should have opened the corresponding project item for the excluded item",
4595 )
4596 .read(cx)
4597 .path
4598 .clone();
4599 assert_eq!(
4600 active_entry_path.path.as_ref(),
4601 Path::new(excluded_file_path),
4602 "Should open the excluded file"
4603 );
4604
4605 assert!(
4606 workspace.notification_ids().is_empty(),
4607 "Should have no notifications after opening an excluded file"
4608 );
4609 })
4610 .unwrap();
4611 assert!(
4612 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
4613 "Should have created the excluded file"
4614 );
4615
4616 select_path(&panel, "root1", cx);
4617 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4618 panel.update(cx, |panel, cx| {
4619 assert!(panel.filename_editor.read(cx).is_focused(cx));
4620 });
4621 panel
4622 .update(cx, |panel, cx| {
4623 panel
4624 .filename_editor
4625 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
4626 panel.confirm_edit(cx).unwrap()
4627 })
4628 .await
4629 .unwrap();
4630
4631 assert_eq!(
4632 visible_entries_as_strings(&panel, 0..13, cx),
4633 &["v root1", " .dockerignore"],
4634 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
4635 );
4636 panel.update(cx, |panel, cx| {
4637 assert!(
4638 !panel.filename_editor.read(cx).is_focused(cx),
4639 "Should have closed the file name editor"
4640 );
4641 });
4642 workspace
4643 .update(cx, |workspace, cx| {
4644 let notifications = workspace.notification_ids();
4645 assert_eq!(
4646 notifications.len(),
4647 1,
4648 "Should receive one notification with the error message"
4649 );
4650 workspace.dismiss_notification(notifications.first().unwrap(), cx);
4651 assert!(workspace.notification_ids().is_empty());
4652 })
4653 .unwrap();
4654
4655 select_path(&panel, "root1", cx);
4656 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4657 panel.update(cx, |panel, cx| {
4658 assert!(panel.filename_editor.read(cx).is_focused(cx));
4659 });
4660 panel
4661 .update(cx, |panel, cx| {
4662 panel
4663 .filename_editor
4664 .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
4665 panel.confirm_edit(cx).unwrap()
4666 })
4667 .await
4668 .unwrap();
4669
4670 assert_eq!(
4671 visible_entries_as_strings(&panel, 0..13, cx),
4672 &["v root1", " .dockerignore"],
4673 "Should not change the project panel after trying to create an excluded directory"
4674 );
4675 panel.update(cx, |panel, cx| {
4676 assert!(
4677 !panel.filename_editor.read(cx).is_focused(cx),
4678 "Should have closed the file name editor"
4679 );
4680 });
4681 workspace
4682 .update(cx, |workspace, cx| {
4683 let notifications = workspace.notification_ids();
4684 assert_eq!(
4685 notifications.len(),
4686 1,
4687 "Should receive one notification explaining that no directory is actually shown"
4688 );
4689 workspace.dismiss_notification(notifications.first().unwrap(), cx);
4690 assert!(workspace.notification_ids().is_empty());
4691 })
4692 .unwrap();
4693 assert!(
4694 fs.is_dir(Path::new("/root1/excluded_dir")).await,
4695 "Should have created the excluded directory"
4696 );
4697 }
4698
4699 fn toggle_expand_dir(
4700 panel: &View<ProjectPanel>,
4701 path: impl AsRef<Path>,
4702 cx: &mut VisualTestContext,
4703 ) {
4704 let path = path.as_ref();
4705 panel.update(cx, |panel, cx| {
4706 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
4707 let worktree = worktree.read(cx);
4708 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4709 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4710 panel.toggle_expanded(entry_id, cx);
4711 return;
4712 }
4713 }
4714 panic!("no worktree for path {:?}", path);
4715 });
4716 }
4717
4718 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
4719 let path = path.as_ref();
4720 panel.update(cx, |panel, cx| {
4721 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
4722 let worktree = worktree.read(cx);
4723 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4724 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4725 panel.selection = Some(crate::SelectedEntry {
4726 worktree_id: worktree.id(),
4727 entry_id,
4728 });
4729 return;
4730 }
4731 }
4732 panic!("no worktree for path {:?}", path);
4733 });
4734 }
4735
4736 fn find_project_entry(
4737 panel: &View<ProjectPanel>,
4738 path: impl AsRef<Path>,
4739 cx: &mut VisualTestContext,
4740 ) -> Option<ProjectEntryId> {
4741 let path = path.as_ref();
4742 panel.update(cx, |panel, cx| {
4743 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
4744 let worktree = worktree.read(cx);
4745 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4746 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
4747 }
4748 }
4749 panic!("no worktree for path {path:?}");
4750 })
4751 }
4752
4753 fn visible_entries_as_strings(
4754 panel: &View<ProjectPanel>,
4755 range: Range<usize>,
4756 cx: &mut VisualTestContext,
4757 ) -> Vec<String> {
4758 let mut result = Vec::new();
4759 let mut project_entries = HashSet::default();
4760 let mut has_editor = false;
4761
4762 panel.update(cx, |panel, cx| {
4763 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
4764 if details.is_editing {
4765 assert!(!has_editor, "duplicate editor entry");
4766 has_editor = true;
4767 } else {
4768 assert!(
4769 project_entries.insert(project_entry),
4770 "duplicate project entry {:?} {:?}",
4771 project_entry,
4772 details
4773 );
4774 }
4775
4776 let indent = " ".repeat(details.depth);
4777 let icon = if details.kind.is_dir() {
4778 if details.is_expanded {
4779 "v "
4780 } else {
4781 "> "
4782 }
4783 } else {
4784 " "
4785 };
4786 let name = if details.is_editing {
4787 format!("[EDITOR: '{}']", details.filename)
4788 } else if details.is_processing {
4789 format!("[PROCESSING: '{}']", details.filename)
4790 } else {
4791 details.filename.clone()
4792 };
4793 let selected = if details.is_selected {
4794 " <== selected"
4795 } else {
4796 ""
4797 };
4798 let marked = if details.is_marked {
4799 " <== marked"
4800 } else {
4801 ""
4802 };
4803
4804 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
4805 });
4806 });
4807
4808 result
4809 }
4810
4811 fn init_test(cx: &mut TestAppContext) {
4812 cx.update(|cx| {
4813 let settings_store = SettingsStore::test(cx);
4814 cx.set_global(settings_store);
4815 init_settings(cx);
4816 theme::init(theme::LoadThemes::JustBase, cx);
4817 language::init(cx);
4818 editor::init_settings(cx);
4819 crate::init((), cx);
4820 workspace::init_settings(cx);
4821 client::init_settings(cx);
4822 Project::init_settings(cx);
4823
4824 cx.update_global::<SettingsStore, _>(|store, cx| {
4825 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4826 worktree_settings.file_scan_exclusions = Some(Vec::new());
4827 });
4828 });
4829 });
4830 }
4831
4832 fn init_test_with_editor(cx: &mut TestAppContext) {
4833 cx.update(|cx| {
4834 let app_state = AppState::test(cx);
4835 theme::init(theme::LoadThemes::JustBase, cx);
4836 init_settings(cx);
4837 language::init(cx);
4838 editor::init(cx);
4839 crate::init((), cx);
4840 workspace::init(app_state.clone(), cx);
4841 Project::init_settings(cx);
4842 });
4843 }
4844
4845 fn ensure_single_file_is_opened(
4846 window: &WindowHandle<Workspace>,
4847 expected_path: &str,
4848 cx: &mut TestAppContext,
4849 ) {
4850 window
4851 .update(cx, |workspace, cx| {
4852 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
4853 assert_eq!(worktrees.len(), 1);
4854 let worktree_id = worktrees[0].read(cx).id();
4855
4856 let open_project_paths = workspace
4857 .panes()
4858 .iter()
4859 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
4860 .collect::<Vec<_>>();
4861 assert_eq!(
4862 open_project_paths,
4863 vec![ProjectPath {
4864 worktree_id,
4865 path: Arc::from(Path::new(expected_path))
4866 }],
4867 "Should have opened file, selected in project panel"
4868 );
4869 })
4870 .unwrap();
4871 }
4872
4873 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
4874 assert!(
4875 !cx.has_pending_prompt(),
4876 "Should have no prompts before the deletion"
4877 );
4878 panel.update(cx, |panel, cx| {
4879 panel.delete(&Delete { skip_prompt: false }, cx)
4880 });
4881 assert!(
4882 cx.has_pending_prompt(),
4883 "Should have a prompt after the deletion"
4884 );
4885 cx.simulate_prompt_answer(0);
4886 assert!(
4887 !cx.has_pending_prompt(),
4888 "Should have no prompts after prompt was replied to"
4889 );
4890 cx.executor().run_until_parked();
4891 }
4892
4893 fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
4894 assert!(
4895 !cx.has_pending_prompt(),
4896 "Should have no prompts before the deletion"
4897 );
4898 panel.update(cx, |panel, cx| {
4899 panel.delete(&Delete { skip_prompt: true }, cx)
4900 });
4901 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
4902 cx.executor().run_until_parked();
4903 }
4904
4905 fn ensure_no_open_items_and_panes(
4906 workspace: &WindowHandle<Workspace>,
4907 cx: &mut VisualTestContext,
4908 ) {
4909 assert!(
4910 !cx.has_pending_prompt(),
4911 "Should have no prompts after deletion operation closes the file"
4912 );
4913 workspace
4914 .read_with(cx, |workspace, cx| {
4915 let open_project_paths = workspace
4916 .panes()
4917 .iter()
4918 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
4919 .collect::<Vec<_>>();
4920 assert!(
4921 open_project_paths.is_empty(),
4922 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
4923 );
4924 })
4925 .unwrap();
4926 }
4927
4928 struct TestProjectItemView {
4929 focus_handle: FocusHandle,
4930 path: ProjectPath,
4931 }
4932
4933 struct TestProjectItem {
4934 path: ProjectPath,
4935 }
4936
4937 impl project::Item for TestProjectItem {
4938 fn try_open(
4939 _project: &Model<Project>,
4940 path: &ProjectPath,
4941 cx: &mut AppContext,
4942 ) -> Option<Task<gpui::Result<Model<Self>>>> {
4943 let path = path.clone();
4944 Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
4945 }
4946
4947 fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
4948 None
4949 }
4950
4951 fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
4952 Some(self.path.clone())
4953 }
4954 }
4955
4956 impl ProjectItem for TestProjectItemView {
4957 type Item = TestProjectItem;
4958
4959 fn for_project_item(
4960 _: Model<Project>,
4961 project_item: Model<Self::Item>,
4962 cx: &mut ViewContext<Self>,
4963 ) -> Self
4964 where
4965 Self: Sized,
4966 {
4967 Self {
4968 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
4969 focus_handle: cx.focus_handle(),
4970 }
4971 }
4972 }
4973
4974 impl Item for TestProjectItemView {
4975 type Event = ();
4976 }
4977
4978 impl EventEmitter<()> for TestProjectItemView {}
4979
4980 impl FocusableView for TestProjectItemView {
4981 fn focus_handle(&self, _: &AppContext) -> FocusHandle {
4982 self.focus_handle.clone()
4983 }
4984 }
4985
4986 impl Render for TestProjectItemView {
4987 fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
4988 Empty
4989 }
4990 }
4991}