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