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