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