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