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