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