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