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