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