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