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