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