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