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