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