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