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 div()
1381 .id(entry_id.to_proto() as usize)
1382 .on_drag({
1383 let details = details.clone();
1384 move |cx| {
1385 let details = details.clone();
1386 cx.build_view(|_| DraggedProjectEntryView {
1387 details,
1388 width,
1389 entry_id,
1390 })
1391 }
1392 })
1393 .drag_over::<DraggedProjectEntryView>(|style| {
1394 style.bg(cx.theme().colors().ghost_element_hover)
1395 })
1396 .on_drop(cx.listener(
1397 move |this, dragged_view: &View<DraggedProjectEntryView>, cx| {
1398 this.move_entry(dragged_view.read(cx).entry_id, entry_id, kind.is_file(), cx);
1399 },
1400 ))
1401 .child(
1402 ListItem::new(entry_id.to_proto() as usize)
1403 .indent_level(details.depth)
1404 .indent_step_size(px(settings.indent_size))
1405 .selected(is_selected)
1406 .child(if let Some(icon) = &details.icon {
1407 div().child(IconElement::from_path(icon.to_string()))
1408 } else {
1409 div()
1410 })
1411 .child(
1412 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
1413 div().h_full().w_full().child(editor.clone())
1414 } else {
1415 div()
1416 .text_color(filename_text_color)
1417 .child(Label::new(details.filename.clone()))
1418 }
1419 .ml_1(),
1420 )
1421 .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1422 if event.down.button == MouseButton::Right {
1423 return;
1424 }
1425 if !show_editor {
1426 if kind.is_dir() {
1427 this.toggle_expanded(entry_id, cx);
1428 } else {
1429 if event.down.modifiers.command {
1430 this.split_entry(entry_id, cx);
1431 } else {
1432 this.open_entry(entry_id, event.up.click_count > 1, cx);
1433 }
1434 }
1435 }
1436 }))
1437 .on_secondary_mouse_down(cx.listener(
1438 move |this, event: &MouseDownEvent, cx| {
1439 this.deploy_context_menu(event.position, entry_id, cx);
1440 },
1441 )),
1442 )
1443 }
1444
1445 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
1446 let mut dispatch_context = KeyContext::default();
1447 dispatch_context.add("ProjectPanel");
1448 dispatch_context.add("menu");
1449
1450 let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
1451 "editing"
1452 } else {
1453 "not_editing"
1454 };
1455
1456 dispatch_context.add(identifier);
1457 dispatch_context
1458 }
1459
1460 fn reveal_entry(
1461 &mut self,
1462 project: Model<Project>,
1463 entry_id: ProjectEntryId,
1464 skip_ignored: bool,
1465 cx: &mut ViewContext<'_, ProjectPanel>,
1466 ) {
1467 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
1468 let worktree = worktree.read(cx);
1469 if skip_ignored
1470 && worktree
1471 .entry_for_id(entry_id)
1472 .map_or(true, |entry| entry.is_ignored)
1473 {
1474 return;
1475 }
1476
1477 let worktree_id = worktree.id();
1478 self.expand_entry(worktree_id, entry_id, cx);
1479 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
1480 self.autoscroll(cx);
1481 cx.notify();
1482 }
1483 }
1484}
1485
1486impl Render for ProjectPanel {
1487 type Element = Focusable<Stateful<Div>>;
1488
1489 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> Self::Element {
1490 let has_worktree = self.visible_entries.len() != 0;
1491
1492 if has_worktree {
1493 div()
1494 .id("project-panel")
1495 .size_full()
1496 .relative()
1497 .key_context(self.dispatch_context(cx))
1498 .on_action(cx.listener(Self::select_next))
1499 .on_action(cx.listener(Self::select_prev))
1500 .on_action(cx.listener(Self::expand_selected_entry))
1501 .on_action(cx.listener(Self::collapse_selected_entry))
1502 .on_action(cx.listener(Self::collapse_all_entries))
1503 .on_action(cx.listener(Self::new_file))
1504 .on_action(cx.listener(Self::new_directory))
1505 .on_action(cx.listener(Self::rename))
1506 .on_action(cx.listener(Self::delete))
1507 .on_action(cx.listener(Self::confirm))
1508 .on_action(cx.listener(Self::open_file))
1509 .on_action(cx.listener(Self::cancel))
1510 .on_action(cx.listener(Self::cut))
1511 .on_action(cx.listener(Self::copy))
1512 .on_action(cx.listener(Self::copy_path))
1513 .on_action(cx.listener(Self::copy_relative_path))
1514 .on_action(cx.listener(Self::paste))
1515 .on_action(cx.listener(Self::reveal_in_finder))
1516 .on_action(cx.listener(Self::open_in_terminal))
1517 .on_action(cx.listener(Self::new_search_in_directory))
1518 .track_focus(&self.focus_handle)
1519 .child(
1520 uniform_list(
1521 cx.view().clone(),
1522 "entries",
1523 self.visible_entries
1524 .iter()
1525 .map(|(_, worktree_entries)| worktree_entries.len())
1526 .sum(),
1527 {
1528 |this, range, cx| {
1529 let mut items = Vec::new();
1530 this.for_each_visible_entry(range, cx, |id, details, cx| {
1531 items.push(this.render_entry(id, details, cx));
1532 });
1533 items
1534 }
1535 },
1536 )
1537 .size_full()
1538 .track_scroll(self.list.clone()),
1539 )
1540 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1541 overlay()
1542 .position(*position)
1543 .anchor(gpui::AnchorCorner::TopLeft)
1544 .child(menu.clone())
1545 }))
1546 } else {
1547 v_stack()
1548 .id("empty-project_panel")
1549 .track_focus(&self.focus_handle)
1550 }
1551 }
1552}
1553
1554impl Render for DraggedProjectEntryView {
1555 type Element = Div;
1556
1557 fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
1558 let settings = ProjectPanelSettings::get_global(cx);
1559 let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
1560 h_stack()
1561 .font(ui_font)
1562 .bg(cx.theme().colors().background)
1563 .w(self.width)
1564 .child(
1565 ListItem::new(self.entry_id.to_proto() as usize)
1566 .indent_level(self.details.depth)
1567 .indent_step_size(px(settings.indent_size))
1568 .child(if let Some(icon) = &self.details.icon {
1569 div().child(IconElement::from_path(icon.to_string()))
1570 } else {
1571 div()
1572 })
1573 .child(Label::new(self.details.filename.clone())),
1574 )
1575 }
1576}
1577
1578impl EventEmitter<Event> for ProjectPanel {}
1579
1580impl EventEmitter<PanelEvent> for ProjectPanel {}
1581
1582impl Panel for ProjectPanel {
1583 fn position(&self, cx: &WindowContext) -> DockPosition {
1584 match ProjectPanelSettings::get_global(cx).dock {
1585 ProjectPanelDockPosition::Left => DockPosition::Left,
1586 ProjectPanelDockPosition::Right => DockPosition::Right,
1587 }
1588 }
1589
1590 fn position_is_valid(&self, position: DockPosition) -> bool {
1591 matches!(position, DockPosition::Left | DockPosition::Right)
1592 }
1593
1594 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1595 settings::update_settings_file::<ProjectPanelSettings>(
1596 self.fs.clone(),
1597 cx,
1598 move |settings| {
1599 let dock = match position {
1600 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1601 DockPosition::Right => ProjectPanelDockPosition::Right,
1602 };
1603 settings.dock = Some(dock);
1604 },
1605 );
1606 }
1607
1608 fn size(&self, cx: &WindowContext) -> f32 {
1609 self.width.map_or_else(
1610 || ProjectPanelSettings::get_global(cx).default_width,
1611 |width| width.0,
1612 )
1613 }
1614
1615 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1616 self.width = size.map(px);
1617 self.serialize(cx);
1618 cx.notify();
1619 }
1620
1621 fn icon(&self, _: &WindowContext) -> Option<ui::Icon> {
1622 Some(ui::Icon::FileTree)
1623 }
1624
1625 fn toggle_action(&self) -> Box<dyn Action> {
1626 Box::new(ToggleFocus)
1627 }
1628
1629 fn persistent_name() -> &'static str {
1630 "Project Panel"
1631 }
1632}
1633
1634impl FocusableView for ProjectPanel {
1635 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1636 self.focus_handle.clone()
1637 }
1638}
1639
1640impl ClipboardEntry {
1641 fn is_cut(&self) -> bool {
1642 matches!(self, Self::Cut { .. })
1643 }
1644
1645 fn entry_id(&self) -> ProjectEntryId {
1646 match self {
1647 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1648 *entry_id
1649 }
1650 }
1651 }
1652
1653 fn worktree_id(&self) -> WorktreeId {
1654 match self {
1655 ClipboardEntry::Copied { worktree_id, .. }
1656 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1657 }
1658 }
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663 use super::*;
1664 use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1665 use pretty_assertions::assert_eq;
1666 use project::{project_settings::ProjectSettings, FakeFs};
1667 use serde_json::json;
1668 use settings::SettingsStore;
1669 use std::{
1670 collections::HashSet,
1671 path::{Path, PathBuf},
1672 sync::atomic::{self, AtomicUsize},
1673 };
1674 use workspace::{dock::PanelHandle, AppState};
1675
1676 #[gpui::test]
1677 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1678 init_test(cx);
1679
1680 let fs = FakeFs::new(cx.executor().clone());
1681 fs.insert_tree(
1682 "/root1",
1683 json!({
1684 ".dockerignore": "",
1685 ".git": {
1686 "HEAD": "",
1687 },
1688 "a": {
1689 "0": { "q": "", "r": "", "s": "" },
1690 "1": { "t": "", "u": "" },
1691 "2": { "v": "", "w": "", "x": "", "y": "" },
1692 },
1693 "b": {
1694 "3": { "Q": "" },
1695 "4": { "R": "", "S": "", "T": "", "U": "" },
1696 },
1697 "C": {
1698 "5": {},
1699 "6": { "V": "", "W": "" },
1700 "7": { "X": "" },
1701 "8": { "Y": {}, "Z": "" }
1702 }
1703 }),
1704 )
1705 .await;
1706 fs.insert_tree(
1707 "/root2",
1708 json!({
1709 "d": {
1710 "9": ""
1711 },
1712 "e": {}
1713 }),
1714 )
1715 .await;
1716
1717 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1718 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1719 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1720 let panel = workspace
1721 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1722 .unwrap();
1723 assert_eq!(
1724 visible_entries_as_strings(&panel, 0..50, cx),
1725 &[
1726 "v root1",
1727 " > .git",
1728 " > a",
1729 " > b",
1730 " > C",
1731 " .dockerignore",
1732 "v root2",
1733 " > d",
1734 " > e",
1735 ]
1736 );
1737
1738 toggle_expand_dir(&panel, "root1/b", cx);
1739 assert_eq!(
1740 visible_entries_as_strings(&panel, 0..50, cx),
1741 &[
1742 "v root1",
1743 " > .git",
1744 " > a",
1745 " v b <== selected",
1746 " > 3",
1747 " > 4",
1748 " > C",
1749 " .dockerignore",
1750 "v root2",
1751 " > d",
1752 " > e",
1753 ]
1754 );
1755
1756 assert_eq!(
1757 visible_entries_as_strings(&panel, 6..9, cx),
1758 &[
1759 //
1760 " > C",
1761 " .dockerignore",
1762 "v root2",
1763 ]
1764 );
1765 }
1766
1767 #[gpui::test]
1768 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1769 init_test(cx);
1770 cx.update(|cx| {
1771 cx.update_global::<SettingsStore, _>(|store, cx| {
1772 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1773 project_settings.file_scan_exclusions =
1774 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1775 });
1776 });
1777 });
1778
1779 let fs = FakeFs::new(cx.background_executor.clone());
1780 fs.insert_tree(
1781 "/root1",
1782 json!({
1783 ".dockerignore": "",
1784 ".git": {
1785 "HEAD": "",
1786 },
1787 "a": {
1788 "0": { "q": "", "r": "", "s": "" },
1789 "1": { "t": "", "u": "" },
1790 "2": { "v": "", "w": "", "x": "", "y": "" },
1791 },
1792 "b": {
1793 "3": { "Q": "" },
1794 "4": { "R": "", "S": "", "T": "", "U": "" },
1795 },
1796 "C": {
1797 "5": {},
1798 "6": { "V": "", "W": "" },
1799 "7": { "X": "" },
1800 "8": { "Y": {}, "Z": "" }
1801 }
1802 }),
1803 )
1804 .await;
1805 fs.insert_tree(
1806 "/root2",
1807 json!({
1808 "d": {
1809 "4": ""
1810 },
1811 "e": {}
1812 }),
1813 )
1814 .await;
1815
1816 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1817 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1818 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1819 let panel = workspace
1820 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1821 .unwrap();
1822 assert_eq!(
1823 visible_entries_as_strings(&panel, 0..50, cx),
1824 &[
1825 "v root1",
1826 " > a",
1827 " > b",
1828 " > C",
1829 " .dockerignore",
1830 "v root2",
1831 " > d",
1832 " > e",
1833 ]
1834 );
1835
1836 toggle_expand_dir(&panel, "root1/b", cx);
1837 assert_eq!(
1838 visible_entries_as_strings(&panel, 0..50, cx),
1839 &[
1840 "v root1",
1841 " > a",
1842 " v b <== selected",
1843 " > 3",
1844 " > C",
1845 " .dockerignore",
1846 "v root2",
1847 " > d",
1848 " > e",
1849 ]
1850 );
1851
1852 toggle_expand_dir(&panel, "root2/d", cx);
1853 assert_eq!(
1854 visible_entries_as_strings(&panel, 0..50, cx),
1855 &[
1856 "v root1",
1857 " > a",
1858 " v b",
1859 " > 3",
1860 " > C",
1861 " .dockerignore",
1862 "v root2",
1863 " v d <== selected",
1864 " > e",
1865 ]
1866 );
1867
1868 toggle_expand_dir(&panel, "root2/e", cx);
1869 assert_eq!(
1870 visible_entries_as_strings(&panel, 0..50, cx),
1871 &[
1872 "v root1",
1873 " > a",
1874 " v b",
1875 " > 3",
1876 " > C",
1877 " .dockerignore",
1878 "v root2",
1879 " v d",
1880 " v e <== selected",
1881 ]
1882 );
1883 }
1884
1885 #[gpui::test(iterations = 30)]
1886 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1887 init_test(cx);
1888
1889 let fs = FakeFs::new(cx.executor().clone());
1890 fs.insert_tree(
1891 "/root1",
1892 json!({
1893 ".dockerignore": "",
1894 ".git": {
1895 "HEAD": "",
1896 },
1897 "a": {
1898 "0": { "q": "", "r": "", "s": "" },
1899 "1": { "t": "", "u": "" },
1900 "2": { "v": "", "w": "", "x": "", "y": "" },
1901 },
1902 "b": {
1903 "3": { "Q": "" },
1904 "4": { "R": "", "S": "", "T": "", "U": "" },
1905 },
1906 "C": {
1907 "5": {},
1908 "6": { "V": "", "W": "" },
1909 "7": { "X": "" },
1910 "8": { "Y": {}, "Z": "" }
1911 }
1912 }),
1913 )
1914 .await;
1915 fs.insert_tree(
1916 "/root2",
1917 json!({
1918 "d": {
1919 "9": ""
1920 },
1921 "e": {}
1922 }),
1923 )
1924 .await;
1925
1926 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1927 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1928 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1929 let panel = workspace
1930 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1931 .unwrap();
1932
1933 select_path(&panel, "root1", cx);
1934 assert_eq!(
1935 visible_entries_as_strings(&panel, 0..10, cx),
1936 &[
1937 "v root1 <== selected",
1938 " > .git",
1939 " > a",
1940 " > b",
1941 " > C",
1942 " .dockerignore",
1943 "v root2",
1944 " > d",
1945 " > e",
1946 ]
1947 );
1948
1949 // Add a file with the root folder selected. The filename editor is placed
1950 // before the first file in the root folder.
1951 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1952 panel.update(cx, |panel, cx| {
1953 assert!(panel.filename_editor.read(cx).is_focused(cx));
1954 });
1955 assert_eq!(
1956 visible_entries_as_strings(&panel, 0..10, cx),
1957 &[
1958 "v root1",
1959 " > .git",
1960 " > a",
1961 " > b",
1962 " > C",
1963 " [EDITOR: ''] <== selected",
1964 " .dockerignore",
1965 "v root2",
1966 " > d",
1967 " > e",
1968 ]
1969 );
1970
1971 let confirm = panel.update(cx, |panel, cx| {
1972 panel
1973 .filename_editor
1974 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1975 panel.confirm_edit(cx).unwrap()
1976 });
1977 assert_eq!(
1978 visible_entries_as_strings(&panel, 0..10, cx),
1979 &[
1980 "v root1",
1981 " > .git",
1982 " > a",
1983 " > b",
1984 " > C",
1985 " [PROCESSING: 'the-new-filename'] <== selected",
1986 " .dockerignore",
1987 "v root2",
1988 " > d",
1989 " > e",
1990 ]
1991 );
1992
1993 confirm.await.unwrap();
1994 assert_eq!(
1995 visible_entries_as_strings(&panel, 0..10, cx),
1996 &[
1997 "v root1",
1998 " > .git",
1999 " > a",
2000 " > b",
2001 " > C",
2002 " .dockerignore",
2003 " the-new-filename <== selected",
2004 "v root2",
2005 " > d",
2006 " > e",
2007 ]
2008 );
2009
2010 select_path(&panel, "root1/b", cx);
2011 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2012 assert_eq!(
2013 visible_entries_as_strings(&panel, 0..10, cx),
2014 &[
2015 "v root1",
2016 " > .git",
2017 " > a",
2018 " v b",
2019 " > 3",
2020 " > 4",
2021 " [EDITOR: ''] <== selected",
2022 " > C",
2023 " .dockerignore",
2024 " the-new-filename",
2025 ]
2026 );
2027
2028 panel
2029 .update(cx, |panel, cx| {
2030 panel
2031 .filename_editor
2032 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2033 panel.confirm_edit(cx).unwrap()
2034 })
2035 .await
2036 .unwrap();
2037 assert_eq!(
2038 visible_entries_as_strings(&panel, 0..10, cx),
2039 &[
2040 "v root1",
2041 " > .git",
2042 " > a",
2043 " v b",
2044 " > 3",
2045 " > 4",
2046 " another-filename.txt <== selected",
2047 " > C",
2048 " .dockerignore",
2049 " the-new-filename",
2050 ]
2051 );
2052
2053 select_path(&panel, "root1/b/another-filename.txt", cx);
2054 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2055 assert_eq!(
2056 visible_entries_as_strings(&panel, 0..10, cx),
2057 &[
2058 "v root1",
2059 " > .git",
2060 " > a",
2061 " v b",
2062 " > 3",
2063 " > 4",
2064 " [EDITOR: 'another-filename.txt'] <== selected",
2065 " > C",
2066 " .dockerignore",
2067 " the-new-filename",
2068 ]
2069 );
2070
2071 let confirm = panel.update(cx, |panel, cx| {
2072 panel.filename_editor.update(cx, |editor, cx| {
2073 let file_name_selections = editor.selections.all::<usize>(cx);
2074 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2075 let file_name_selection = &file_name_selections[0];
2076 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2077 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2078
2079 editor.set_text("a-different-filename.tar.gz", cx)
2080 });
2081 panel.confirm_edit(cx).unwrap()
2082 });
2083 assert_eq!(
2084 visible_entries_as_strings(&panel, 0..10, cx),
2085 &[
2086 "v root1",
2087 " > .git",
2088 " > a",
2089 " v b",
2090 " > 3",
2091 " > 4",
2092 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2093 " > C",
2094 " .dockerignore",
2095 " the-new-filename",
2096 ]
2097 );
2098
2099 confirm.await.unwrap();
2100 assert_eq!(
2101 visible_entries_as_strings(&panel, 0..10, cx),
2102 &[
2103 "v root1",
2104 " > .git",
2105 " > a",
2106 " v b",
2107 " > 3",
2108 " > 4",
2109 " a-different-filename.tar.gz <== selected",
2110 " > C",
2111 " .dockerignore",
2112 " the-new-filename",
2113 ]
2114 );
2115
2116 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2117 assert_eq!(
2118 visible_entries_as_strings(&panel, 0..10, cx),
2119 &[
2120 "v root1",
2121 " > .git",
2122 " > a",
2123 " v b",
2124 " > 3",
2125 " > 4",
2126 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2127 " > C",
2128 " .dockerignore",
2129 " the-new-filename",
2130 ]
2131 );
2132
2133 panel.update(cx, |panel, cx| {
2134 panel.filename_editor.update(cx, |editor, cx| {
2135 let file_name_selections = editor.selections.all::<usize>(cx);
2136 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2137 let file_name_selection = &file_name_selections[0];
2138 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2139 assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot..");
2140
2141 });
2142 panel.cancel(&Cancel, cx)
2143 });
2144
2145 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2146 assert_eq!(
2147 visible_entries_as_strings(&panel, 0..10, cx),
2148 &[
2149 "v root1",
2150 " > .git",
2151 " > a",
2152 " v b",
2153 " > [EDITOR: ''] <== selected",
2154 " > 3",
2155 " > 4",
2156 " a-different-filename.tar.gz",
2157 " > C",
2158 " .dockerignore",
2159 ]
2160 );
2161
2162 let confirm = panel.update(cx, |panel, cx| {
2163 panel
2164 .filename_editor
2165 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2166 panel.confirm_edit(cx).unwrap()
2167 });
2168 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2169 assert_eq!(
2170 visible_entries_as_strings(&panel, 0..10, cx),
2171 &[
2172 "v root1",
2173 " > .git",
2174 " > a",
2175 " v b",
2176 " > [PROCESSING: 'new-dir']",
2177 " > 3 <== selected",
2178 " > 4",
2179 " a-different-filename.tar.gz",
2180 " > C",
2181 " .dockerignore",
2182 ]
2183 );
2184
2185 confirm.await.unwrap();
2186 assert_eq!(
2187 visible_entries_as_strings(&panel, 0..10, cx),
2188 &[
2189 "v root1",
2190 " > .git",
2191 " > a",
2192 " v b",
2193 " > 3 <== selected",
2194 " > 4",
2195 " > new-dir",
2196 " a-different-filename.tar.gz",
2197 " > C",
2198 " .dockerignore",
2199 ]
2200 );
2201
2202 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2203 assert_eq!(
2204 visible_entries_as_strings(&panel, 0..10, cx),
2205 &[
2206 "v root1",
2207 " > .git",
2208 " > a",
2209 " v b",
2210 " > [EDITOR: '3'] <== selected",
2211 " > 4",
2212 " > new-dir",
2213 " a-different-filename.tar.gz",
2214 " > C",
2215 " .dockerignore",
2216 ]
2217 );
2218
2219 // Dismiss the rename editor when it loses focus.
2220 workspace.update(cx, |_, cx| cx.blur()).unwrap();
2221 assert_eq!(
2222 visible_entries_as_strings(&panel, 0..10, cx),
2223 &[
2224 "v root1",
2225 " > .git",
2226 " > a",
2227 " v b",
2228 " > 3 <== selected",
2229 " > 4",
2230 " > new-dir",
2231 " a-different-filename.tar.gz",
2232 " > C",
2233 " .dockerignore",
2234 ]
2235 );
2236 }
2237
2238 #[gpui::test(iterations = 10)]
2239 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2240 init_test(cx);
2241
2242 let fs = FakeFs::new(cx.executor().clone());
2243 fs.insert_tree(
2244 "/root1",
2245 json!({
2246 ".dockerignore": "",
2247 ".git": {
2248 "HEAD": "",
2249 },
2250 "a": {
2251 "0": { "q": "", "r": "", "s": "" },
2252 "1": { "t": "", "u": "" },
2253 "2": { "v": "", "w": "", "x": "", "y": "" },
2254 },
2255 "b": {
2256 "3": { "Q": "" },
2257 "4": { "R": "", "S": "", "T": "", "U": "" },
2258 },
2259 "C": {
2260 "5": {},
2261 "6": { "V": "", "W": "" },
2262 "7": { "X": "" },
2263 "8": { "Y": {}, "Z": "" }
2264 }
2265 }),
2266 )
2267 .await;
2268 fs.insert_tree(
2269 "/root2",
2270 json!({
2271 "d": {
2272 "9": ""
2273 },
2274 "e": {}
2275 }),
2276 )
2277 .await;
2278
2279 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2280 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2281 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2282 let panel = workspace
2283 .update(cx, |workspace, cx| {
2284 let panel = ProjectPanel::new(workspace, cx);
2285 workspace.add_panel(panel.clone(), cx);
2286 workspace.toggle_dock(panel.read(cx).position(cx), cx);
2287 panel
2288 })
2289 .unwrap();
2290
2291 select_path(&panel, "root1", cx);
2292 assert_eq!(
2293 visible_entries_as_strings(&panel, 0..10, cx),
2294 &[
2295 "v root1 <== selected",
2296 " > .git",
2297 " > a",
2298 " > b",
2299 " > C",
2300 " .dockerignore",
2301 "v root2",
2302 " > d",
2303 " > e",
2304 ]
2305 );
2306
2307 // Add a file with the root folder selected. The filename editor is placed
2308 // before the first file in the root folder.
2309 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2310 panel.update(cx, |panel, cx| {
2311 assert!(panel.filename_editor.read(cx).is_focused(cx));
2312 });
2313 assert_eq!(
2314 visible_entries_as_strings(&panel, 0..10, cx),
2315 &[
2316 "v root1",
2317 " > .git",
2318 " > a",
2319 " > b",
2320 " > C",
2321 " [EDITOR: ''] <== selected",
2322 " .dockerignore",
2323 "v root2",
2324 " > d",
2325 " > e",
2326 ]
2327 );
2328
2329 let confirm = panel.update(cx, |panel, cx| {
2330 panel.filename_editor.update(cx, |editor, cx| {
2331 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2332 });
2333 panel.confirm_edit(cx).unwrap()
2334 });
2335
2336 assert_eq!(
2337 visible_entries_as_strings(&panel, 0..10, cx),
2338 &[
2339 "v root1",
2340 " > .git",
2341 " > a",
2342 " > b",
2343 " > C",
2344 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2345 " .dockerignore",
2346 "v root2",
2347 " > d",
2348 " > e",
2349 ]
2350 );
2351
2352 confirm.await.unwrap();
2353 assert_eq!(
2354 visible_entries_as_strings(&panel, 0..13, cx),
2355 &[
2356 "v root1",
2357 " > .git",
2358 " > a",
2359 " > b",
2360 " v bdir1",
2361 " v dir2",
2362 " the-new-filename <== selected",
2363 " > C",
2364 " .dockerignore",
2365 "v root2",
2366 " > d",
2367 " > e",
2368 ]
2369 );
2370 }
2371
2372 #[gpui::test]
2373 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2374 init_test(cx);
2375
2376 let fs = FakeFs::new(cx.executor().clone());
2377 fs.insert_tree(
2378 "/root1",
2379 json!({
2380 "one.two.txt": "",
2381 "one.txt": ""
2382 }),
2383 )
2384 .await;
2385
2386 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2387 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2388 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2389 let panel = workspace
2390 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2391 .unwrap();
2392
2393 panel.update(cx, |panel, cx| {
2394 panel.select_next(&Default::default(), cx);
2395 panel.select_next(&Default::default(), cx);
2396 });
2397
2398 assert_eq!(
2399 visible_entries_as_strings(&panel, 0..50, cx),
2400 &[
2401 //
2402 "v root1",
2403 " one.two.txt <== selected",
2404 " one.txt",
2405 ]
2406 );
2407
2408 // Regression test - file name is created correctly when
2409 // the copied file's name contains multiple dots.
2410 panel.update(cx, |panel, cx| {
2411 panel.copy(&Default::default(), cx);
2412 panel.paste(&Default::default(), cx);
2413 });
2414 cx.executor().run_until_parked();
2415
2416 assert_eq!(
2417 visible_entries_as_strings(&panel, 0..50, cx),
2418 &[
2419 //
2420 "v root1",
2421 " one.two copy.txt",
2422 " one.two.txt <== selected",
2423 " one.txt",
2424 ]
2425 );
2426
2427 panel.update(cx, |panel, cx| {
2428 panel.paste(&Default::default(), cx);
2429 });
2430 cx.executor().run_until_parked();
2431
2432 assert_eq!(
2433 visible_entries_as_strings(&panel, 0..50, cx),
2434 &[
2435 //
2436 "v root1",
2437 " one.two copy 1.txt",
2438 " one.two copy.txt",
2439 " one.two.txt <== selected",
2440 " one.txt",
2441 ]
2442 );
2443 }
2444
2445 #[gpui::test]
2446 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2447 init_test_with_editor(cx);
2448
2449 let fs = FakeFs::new(cx.executor().clone());
2450 fs.insert_tree(
2451 "/src",
2452 json!({
2453 "test": {
2454 "first.rs": "// First Rust file",
2455 "second.rs": "// Second Rust file",
2456 "third.rs": "// Third Rust file",
2457 }
2458 }),
2459 )
2460 .await;
2461
2462 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2463 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2464 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2465 let panel = workspace
2466 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2467 .unwrap();
2468
2469 toggle_expand_dir(&panel, "src/test", cx);
2470 select_path(&panel, "src/test/first.rs", cx);
2471 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2472 cx.executor().run_until_parked();
2473 assert_eq!(
2474 visible_entries_as_strings(&panel, 0..10, cx),
2475 &[
2476 "v src",
2477 " v test",
2478 " first.rs <== selected",
2479 " second.rs",
2480 " third.rs"
2481 ]
2482 );
2483 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2484
2485 submit_deletion(&panel, cx);
2486 assert_eq!(
2487 visible_entries_as_strings(&panel, 0..10, cx),
2488 &[
2489 "v src",
2490 " v test",
2491 " second.rs",
2492 " third.rs"
2493 ],
2494 "Project panel should have no deleted file, no other file is selected in it"
2495 );
2496 ensure_no_open_items_and_panes(&workspace, cx);
2497
2498 select_path(&panel, "src/test/second.rs", cx);
2499 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2500 cx.executor().run_until_parked();
2501 assert_eq!(
2502 visible_entries_as_strings(&panel, 0..10, cx),
2503 &[
2504 "v src",
2505 " v test",
2506 " second.rs <== selected",
2507 " third.rs"
2508 ]
2509 );
2510 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2511
2512 workspace
2513 .update(cx, |workspace, cx| {
2514 let active_items = workspace
2515 .panes()
2516 .iter()
2517 .filter_map(|pane| pane.read(cx).active_item())
2518 .collect::<Vec<_>>();
2519 assert_eq!(active_items.len(), 1);
2520 let open_editor = active_items
2521 .into_iter()
2522 .next()
2523 .unwrap()
2524 .downcast::<Editor>()
2525 .expect("Open item should be an editor");
2526 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2527 })
2528 .unwrap();
2529 submit_deletion(&panel, cx);
2530 assert_eq!(
2531 visible_entries_as_strings(&panel, 0..10, cx),
2532 &["v src", " v test", " third.rs"],
2533 "Project panel should have no deleted file, with one last file remaining"
2534 );
2535 ensure_no_open_items_and_panes(&workspace, cx);
2536 }
2537
2538 #[gpui::test]
2539 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2540 init_test_with_editor(cx);
2541
2542 let fs = FakeFs::new(cx.executor().clone());
2543 fs.insert_tree(
2544 "/src",
2545 json!({
2546 "test": {
2547 "first.rs": "// First Rust file",
2548 "second.rs": "// Second Rust file",
2549 "third.rs": "// Third Rust file",
2550 }
2551 }),
2552 )
2553 .await;
2554
2555 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2556 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2557 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2558 let panel = workspace
2559 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2560 .unwrap();
2561
2562 select_path(&panel, "src/", cx);
2563 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2564 cx.executor().run_until_parked();
2565 assert_eq!(
2566 visible_entries_as_strings(&panel, 0..10, cx),
2567 &[
2568 //
2569 "v src <== selected",
2570 " > test"
2571 ]
2572 );
2573 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2574 panel.update(cx, |panel, cx| {
2575 assert!(panel.filename_editor.read(cx).is_focused(cx));
2576 });
2577 assert_eq!(
2578 visible_entries_as_strings(&panel, 0..10, cx),
2579 &[
2580 //
2581 "v src",
2582 " > [EDITOR: ''] <== selected",
2583 " > test"
2584 ]
2585 );
2586 panel.update(cx, |panel, cx| {
2587 panel
2588 .filename_editor
2589 .update(cx, |editor, cx| editor.set_text("test", cx));
2590 assert!(
2591 panel.confirm_edit(cx).is_none(),
2592 "Should not allow to confirm on conflicting new directory name"
2593 )
2594 });
2595 assert_eq!(
2596 visible_entries_as_strings(&panel, 0..10, cx),
2597 &[
2598 //
2599 "v src",
2600 " > test"
2601 ],
2602 "File list should be unchanged after failed folder create confirmation"
2603 );
2604
2605 select_path(&panel, "src/test/", cx);
2606 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2607 cx.executor().run_until_parked();
2608 assert_eq!(
2609 visible_entries_as_strings(&panel, 0..10, cx),
2610 &[
2611 //
2612 "v src",
2613 " > test <== selected"
2614 ]
2615 );
2616 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2617 panel.update(cx, |panel, cx| {
2618 assert!(panel.filename_editor.read(cx).is_focused(cx));
2619 });
2620 assert_eq!(
2621 visible_entries_as_strings(&panel, 0..10, cx),
2622 &[
2623 "v src",
2624 " v test",
2625 " [EDITOR: ''] <== selected",
2626 " first.rs",
2627 " second.rs",
2628 " third.rs"
2629 ]
2630 );
2631 panel.update(cx, |panel, cx| {
2632 panel
2633 .filename_editor
2634 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2635 assert!(
2636 panel.confirm_edit(cx).is_none(),
2637 "Should not allow to confirm on conflicting new file name"
2638 )
2639 });
2640 assert_eq!(
2641 visible_entries_as_strings(&panel, 0..10, cx),
2642 &[
2643 "v src",
2644 " v test",
2645 " first.rs",
2646 " second.rs",
2647 " third.rs"
2648 ],
2649 "File list should be unchanged after failed file create confirmation"
2650 );
2651
2652 select_path(&panel, "src/test/first.rs", cx);
2653 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2654 cx.executor().run_until_parked();
2655 assert_eq!(
2656 visible_entries_as_strings(&panel, 0..10, cx),
2657 &[
2658 "v src",
2659 " v test",
2660 " first.rs <== selected",
2661 " second.rs",
2662 " third.rs"
2663 ],
2664 );
2665 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2666 panel.update(cx, |panel, cx| {
2667 assert!(panel.filename_editor.read(cx).is_focused(cx));
2668 });
2669 assert_eq!(
2670 visible_entries_as_strings(&panel, 0..10, cx),
2671 &[
2672 "v src",
2673 " v test",
2674 " [EDITOR: 'first.rs'] <== selected",
2675 " second.rs",
2676 " third.rs"
2677 ]
2678 );
2679 panel.update(cx, |panel, cx| {
2680 panel
2681 .filename_editor
2682 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2683 assert!(
2684 panel.confirm_edit(cx).is_none(),
2685 "Should not allow to confirm on conflicting file rename"
2686 )
2687 });
2688 assert_eq!(
2689 visible_entries_as_strings(&panel, 0..10, cx),
2690 &[
2691 "v src",
2692 " v test",
2693 " first.rs <== selected",
2694 " second.rs",
2695 " third.rs"
2696 ],
2697 "File list should be unchanged after failed rename confirmation"
2698 );
2699 }
2700
2701 #[gpui::test]
2702 async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2703 init_test_with_editor(cx);
2704
2705 let fs = FakeFs::new(cx.executor().clone());
2706 fs.insert_tree(
2707 "/src",
2708 json!({
2709 "test": {
2710 "first.rs": "// First Rust file",
2711 "second.rs": "// Second Rust file",
2712 "third.rs": "// Third Rust file",
2713 }
2714 }),
2715 )
2716 .await;
2717
2718 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2719 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2720 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2721 let panel = workspace
2722 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2723 .unwrap();
2724
2725 let new_search_events_count = Arc::new(AtomicUsize::new(0));
2726 let _subscription = panel.update(cx, |_, cx| {
2727 let subcription_count = Arc::clone(&new_search_events_count);
2728 let view = cx.view().clone();
2729 cx.subscribe(&view, move |_, _, event, _| {
2730 if matches!(event, Event::NewSearchInDirectory { .. }) {
2731 subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2732 }
2733 })
2734 });
2735
2736 toggle_expand_dir(&panel, "src/test", cx);
2737 select_path(&panel, "src/test/first.rs", cx);
2738 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2739 cx.executor().run_until_parked();
2740 assert_eq!(
2741 visible_entries_as_strings(&panel, 0..10, cx),
2742 &[
2743 "v src",
2744 " v test",
2745 " first.rs <== selected",
2746 " second.rs",
2747 " third.rs"
2748 ]
2749 );
2750 panel.update(cx, |panel, cx| {
2751 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2752 });
2753 assert_eq!(
2754 new_search_events_count.load(atomic::Ordering::SeqCst),
2755 0,
2756 "Should not trigger new search in directory when called on a file"
2757 );
2758
2759 select_path(&panel, "src/test", cx);
2760 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2761 cx.executor().run_until_parked();
2762 assert_eq!(
2763 visible_entries_as_strings(&panel, 0..10, cx),
2764 &[
2765 "v src",
2766 " v test <== selected",
2767 " first.rs",
2768 " second.rs",
2769 " third.rs"
2770 ]
2771 );
2772 panel.update(cx, |panel, cx| {
2773 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2774 });
2775 assert_eq!(
2776 new_search_events_count.load(atomic::Ordering::SeqCst),
2777 1,
2778 "Should trigger new search in directory when called on a directory"
2779 );
2780 }
2781
2782 #[gpui::test]
2783 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2784 init_test_with_editor(cx);
2785
2786 let fs = FakeFs::new(cx.executor().clone());
2787 fs.insert_tree(
2788 "/project_root",
2789 json!({
2790 "dir_1": {
2791 "nested_dir": {
2792 "file_a.py": "# File contents",
2793 "file_b.py": "# File contents",
2794 "file_c.py": "# File contents",
2795 },
2796 "file_1.py": "# File contents",
2797 "file_2.py": "# File contents",
2798 "file_3.py": "# File contents",
2799 },
2800 "dir_2": {
2801 "file_1.py": "# File contents",
2802 "file_2.py": "# File contents",
2803 "file_3.py": "# File contents",
2804 }
2805 }),
2806 )
2807 .await;
2808
2809 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2810 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2811 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2812 let panel = workspace
2813 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2814 .unwrap();
2815
2816 panel.update(cx, |panel, cx| {
2817 panel.collapse_all_entries(&CollapseAllEntries, cx)
2818 });
2819 cx.executor().run_until_parked();
2820 assert_eq!(
2821 visible_entries_as_strings(&panel, 0..10, cx),
2822 &["v project_root", " > dir_1", " > dir_2",]
2823 );
2824
2825 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2826 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2827 cx.executor().run_until_parked();
2828 assert_eq!(
2829 visible_entries_as_strings(&panel, 0..10, cx),
2830 &[
2831 "v project_root",
2832 " v dir_1 <== selected",
2833 " > nested_dir",
2834 " file_1.py",
2835 " file_2.py",
2836 " file_3.py",
2837 " > dir_2",
2838 ]
2839 );
2840 }
2841
2842 #[gpui::test]
2843 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2844 init_test(cx);
2845
2846 let fs = FakeFs::new(cx.executor().clone());
2847 fs.as_fake().insert_tree("/root", json!({})).await;
2848 let project = Project::test(fs, ["/root".as_ref()], cx).await;
2849 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2850 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2851 let panel = workspace
2852 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2853 .unwrap();
2854
2855 // Make a new buffer with no backing file
2856 workspace
2857 .update(cx, |workspace, cx| {
2858 Editor::new_file(workspace, &Default::default(), cx)
2859 })
2860 .unwrap();
2861
2862 // "Save as"" the buffer, creating a new backing file for it
2863 let save_task = workspace
2864 .update(cx, |workspace, cx| {
2865 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2866 })
2867 .unwrap();
2868
2869 cx.executor().run_until_parked();
2870 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2871 save_task.await.unwrap();
2872
2873 // Rename the file
2874 select_path(&panel, "root/new", cx);
2875 assert_eq!(
2876 visible_entries_as_strings(&panel, 0..10, cx),
2877 &["v root", " new <== selected"]
2878 );
2879 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2880 panel.update(cx, |panel, cx| {
2881 panel
2882 .filename_editor
2883 .update(cx, |editor, cx| editor.set_text("newer", cx));
2884 });
2885 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2886
2887 cx.executor().run_until_parked();
2888 assert_eq!(
2889 visible_entries_as_strings(&panel, 0..10, cx),
2890 &["v root", " newer <== selected"]
2891 );
2892
2893 workspace
2894 .update(cx, |workspace, cx| {
2895 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2896 })
2897 .unwrap()
2898 .await
2899 .unwrap();
2900
2901 cx.executor().run_until_parked();
2902 // assert that saving the file doesn't restore "new"
2903 assert_eq!(
2904 visible_entries_as_strings(&panel, 0..10, cx),
2905 &["v root", " newer <== selected"]
2906 );
2907 }
2908
2909 #[gpui::test]
2910 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
2911 init_test_with_editor(cx);
2912 cx.update(|cx| {
2913 cx.update_global::<SettingsStore, _>(|store, cx| {
2914 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
2915 project_settings.file_scan_exclusions = Some(Vec::new());
2916 });
2917 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
2918 project_panel_settings.auto_reveal_entries = Some(false)
2919 });
2920 })
2921 });
2922
2923 let fs = FakeFs::new(cx.background_executor.clone());
2924 fs.insert_tree(
2925 "/project_root",
2926 json!({
2927 ".git": {},
2928 ".gitignore": "**/gitignored_dir",
2929 "dir_1": {
2930 "file_1.py": "# File 1_1 contents",
2931 "file_2.py": "# File 1_2 contents",
2932 "file_3.py": "# File 1_3 contents",
2933 "gitignored_dir": {
2934 "file_a.py": "# File contents",
2935 "file_b.py": "# File contents",
2936 "file_c.py": "# File contents",
2937 },
2938 },
2939 "dir_2": {
2940 "file_1.py": "# File 2_1 contents",
2941 "file_2.py": "# File 2_2 contents",
2942 "file_3.py": "# File 2_3 contents",
2943 }
2944 }),
2945 )
2946 .await;
2947
2948 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2949 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2950 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2951 let panel = workspace
2952 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2953 .unwrap();
2954
2955 assert_eq!(
2956 visible_entries_as_strings(&panel, 0..20, cx),
2957 &[
2958 "v project_root",
2959 " > .git",
2960 " > dir_1",
2961 " > dir_2",
2962 " .gitignore",
2963 ]
2964 );
2965
2966 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
2967 .expect("dir 1 file is not ignored and should have an entry");
2968 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
2969 .expect("dir 2 file is not ignored and should have an entry");
2970 let gitignored_dir_file =
2971 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
2972 assert_eq!(
2973 gitignored_dir_file, None,
2974 "File in the gitignored dir should not have an entry before its dir is toggled"
2975 );
2976
2977 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2978 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
2979 cx.executor().run_until_parked();
2980 assert_eq!(
2981 visible_entries_as_strings(&panel, 0..20, cx),
2982 &[
2983 "v project_root",
2984 " > .git",
2985 " v dir_1",
2986 " v gitignored_dir <== selected",
2987 " file_a.py",
2988 " file_b.py",
2989 " file_c.py",
2990 " file_1.py",
2991 " file_2.py",
2992 " file_3.py",
2993 " > dir_2",
2994 " .gitignore",
2995 ],
2996 "Should show gitignored dir file list in the project panel"
2997 );
2998 let gitignored_dir_file =
2999 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3000 .expect("after gitignored dir got opened, a file entry should be present");
3001
3002 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3003 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3004 assert_eq!(
3005 visible_entries_as_strings(&panel, 0..20, cx),
3006 &[
3007 "v project_root",
3008 " > .git",
3009 " > dir_1 <== selected",
3010 " > dir_2",
3011 " .gitignore",
3012 ],
3013 "Should hide all dir contents again and prepare for the auto reveal test"
3014 );
3015
3016 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3017 panel.update(cx, |panel, cx| {
3018 panel.project.update(cx, |_, cx| {
3019 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3020 })
3021 });
3022 cx.run_until_parked();
3023 assert_eq!(
3024 visible_entries_as_strings(&panel, 0..20, cx),
3025 &[
3026 "v project_root",
3027 " > .git",
3028 " > dir_1 <== selected",
3029 " > dir_2",
3030 " .gitignore",
3031 ],
3032 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3033 );
3034 }
3035
3036 cx.update(|cx| {
3037 cx.update_global::<SettingsStore, _>(|store, cx| {
3038 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3039 project_panel_settings.auto_reveal_entries = Some(true)
3040 });
3041 })
3042 });
3043
3044 panel.update(cx, |panel, cx| {
3045 panel.project.update(cx, |_, cx| {
3046 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3047 })
3048 });
3049 cx.run_until_parked();
3050 assert_eq!(
3051 visible_entries_as_strings(&panel, 0..20, cx),
3052 &[
3053 "v project_root",
3054 " > .git",
3055 " v dir_1",
3056 " > gitignored_dir",
3057 " file_1.py <== selected",
3058 " file_2.py",
3059 " file_3.py",
3060 " > dir_2",
3061 " .gitignore",
3062 ],
3063 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3064 );
3065
3066 panel.update(cx, |panel, cx| {
3067 panel.project.update(cx, |_, cx| {
3068 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3069 })
3070 });
3071 cx.run_until_parked();
3072 assert_eq!(
3073 visible_entries_as_strings(&panel, 0..20, cx),
3074 &[
3075 "v project_root",
3076 " > .git",
3077 " v dir_1",
3078 " > gitignored_dir",
3079 " file_1.py",
3080 " file_2.py",
3081 " file_3.py",
3082 " v dir_2",
3083 " file_1.py <== selected",
3084 " file_2.py",
3085 " file_3.py",
3086 " .gitignore",
3087 ],
3088 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3089 );
3090
3091 panel.update(cx, |panel, cx| {
3092 panel.project.update(cx, |_, cx| {
3093 cx.emit(project::Event::ActiveEntryChanged(Some(
3094 gitignored_dir_file,
3095 )))
3096 })
3097 });
3098 cx.run_until_parked();
3099 assert_eq!(
3100 visible_entries_as_strings(&panel, 0..20, cx),
3101 &[
3102 "v project_root",
3103 " > .git",
3104 " v dir_1",
3105 " > gitignored_dir",
3106 " file_1.py",
3107 " file_2.py",
3108 " file_3.py",
3109 " v dir_2",
3110 " file_1.py <== selected",
3111 " file_2.py",
3112 " file_3.py",
3113 " .gitignore",
3114 ],
3115 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3116 );
3117
3118 panel.update(cx, |panel, cx| {
3119 panel.project.update(cx, |_, cx| {
3120 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3121 })
3122 });
3123 cx.run_until_parked();
3124 assert_eq!(
3125 visible_entries_as_strings(&panel, 0..20, cx),
3126 &[
3127 "v project_root",
3128 " > .git",
3129 " v dir_1",
3130 " v gitignored_dir",
3131 " file_a.py <== selected",
3132 " file_b.py",
3133 " file_c.py",
3134 " file_1.py",
3135 " file_2.py",
3136 " file_3.py",
3137 " v dir_2",
3138 " file_1.py",
3139 " file_2.py",
3140 " file_3.py",
3141 " .gitignore",
3142 ],
3143 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3144 );
3145 }
3146
3147 #[gpui::test]
3148 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3149 init_test_with_editor(cx);
3150 cx.update(|cx| {
3151 cx.update_global::<SettingsStore, _>(|store, cx| {
3152 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3153 project_settings.file_scan_exclusions = Some(Vec::new());
3154 });
3155 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3156 project_panel_settings.auto_reveal_entries = Some(false)
3157 });
3158 })
3159 });
3160
3161 let fs = FakeFs::new(cx.background_executor.clone());
3162 fs.insert_tree(
3163 "/project_root",
3164 json!({
3165 ".git": {},
3166 ".gitignore": "**/gitignored_dir",
3167 "dir_1": {
3168 "file_1.py": "# File 1_1 contents",
3169 "file_2.py": "# File 1_2 contents",
3170 "file_3.py": "# File 1_3 contents",
3171 "gitignored_dir": {
3172 "file_a.py": "# File contents",
3173 "file_b.py": "# File contents",
3174 "file_c.py": "# File contents",
3175 },
3176 },
3177 "dir_2": {
3178 "file_1.py": "# File 2_1 contents",
3179 "file_2.py": "# File 2_2 contents",
3180 "file_3.py": "# File 2_3 contents",
3181 }
3182 }),
3183 )
3184 .await;
3185
3186 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3187 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3188 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3189 let panel = workspace
3190 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3191 .unwrap();
3192
3193 assert_eq!(
3194 visible_entries_as_strings(&panel, 0..20, cx),
3195 &[
3196 "v project_root",
3197 " > .git",
3198 " > dir_1",
3199 " > dir_2",
3200 " .gitignore",
3201 ]
3202 );
3203
3204 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3205 .expect("dir 1 file is not ignored and should have an entry");
3206 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3207 .expect("dir 2 file is not ignored and should have an entry");
3208 let gitignored_dir_file =
3209 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3210 assert_eq!(
3211 gitignored_dir_file, None,
3212 "File in the gitignored dir should not have an entry before its dir is toggled"
3213 );
3214
3215 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3216 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3217 cx.run_until_parked();
3218 assert_eq!(
3219 visible_entries_as_strings(&panel, 0..20, cx),
3220 &[
3221 "v project_root",
3222 " > .git",
3223 " v dir_1",
3224 " v gitignored_dir <== selected",
3225 " file_a.py",
3226 " file_b.py",
3227 " file_c.py",
3228 " file_1.py",
3229 " file_2.py",
3230 " file_3.py",
3231 " > dir_2",
3232 " .gitignore",
3233 ],
3234 "Should show gitignored dir file list in the project panel"
3235 );
3236 let gitignored_dir_file =
3237 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3238 .expect("after gitignored dir got opened, a file entry should be present");
3239
3240 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3241 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3242 assert_eq!(
3243 visible_entries_as_strings(&panel, 0..20, cx),
3244 &[
3245 "v project_root",
3246 " > .git",
3247 " > dir_1 <== selected",
3248 " > dir_2",
3249 " .gitignore",
3250 ],
3251 "Should hide all dir contents again and prepare for the explicit reveal test"
3252 );
3253
3254 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3255 panel.update(cx, |panel, cx| {
3256 panel.project.update(cx, |_, cx| {
3257 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3258 })
3259 });
3260 cx.run_until_parked();
3261 assert_eq!(
3262 visible_entries_as_strings(&panel, 0..20, cx),
3263 &[
3264 "v project_root",
3265 " > .git",
3266 " > dir_1 <== selected",
3267 " > dir_2",
3268 " .gitignore",
3269 ],
3270 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3271 );
3272 }
3273
3274 panel.update(cx, |panel, cx| {
3275 panel.project.update(cx, |_, cx| {
3276 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3277 })
3278 });
3279 cx.run_until_parked();
3280 assert_eq!(
3281 visible_entries_as_strings(&panel, 0..20, cx),
3282 &[
3283 "v project_root",
3284 " > .git",
3285 " v dir_1",
3286 " > gitignored_dir",
3287 " file_1.py <== selected",
3288 " file_2.py",
3289 " file_3.py",
3290 " > dir_2",
3291 " .gitignore",
3292 ],
3293 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3294 );
3295
3296 panel.update(cx, |panel, cx| {
3297 panel.project.update(cx, |_, cx| {
3298 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3299 })
3300 });
3301 cx.run_until_parked();
3302 assert_eq!(
3303 visible_entries_as_strings(&panel, 0..20, cx),
3304 &[
3305 "v project_root",
3306 " > .git",
3307 " v dir_1",
3308 " > gitignored_dir",
3309 " file_1.py",
3310 " file_2.py",
3311 " file_3.py",
3312 " v dir_2",
3313 " file_1.py <== selected",
3314 " file_2.py",
3315 " file_3.py",
3316 " .gitignore",
3317 ],
3318 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3319 );
3320
3321 panel.update(cx, |panel, cx| {
3322 panel.project.update(cx, |_, cx| {
3323 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3324 })
3325 });
3326 cx.run_until_parked();
3327 assert_eq!(
3328 visible_entries_as_strings(&panel, 0..20, cx),
3329 &[
3330 "v project_root",
3331 " > .git",
3332 " v dir_1",
3333 " v gitignored_dir",
3334 " file_a.py <== selected",
3335 " file_b.py",
3336 " file_c.py",
3337 " file_1.py",
3338 " file_2.py",
3339 " file_3.py",
3340 " v dir_2",
3341 " file_1.py",
3342 " file_2.py",
3343 " file_3.py",
3344 " .gitignore",
3345 ],
3346 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3347 );
3348 }
3349
3350 fn toggle_expand_dir(
3351 panel: &View<ProjectPanel>,
3352 path: impl AsRef<Path>,
3353 cx: &mut VisualTestContext,
3354 ) {
3355 let path = path.as_ref();
3356 panel.update(cx, |panel, cx| {
3357 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3358 let worktree = worktree.read(cx);
3359 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3360 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3361 panel.toggle_expanded(entry_id, cx);
3362 return;
3363 }
3364 }
3365 panic!("no worktree for path {:?}", path);
3366 });
3367 }
3368
3369 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
3370 let path = path.as_ref();
3371 panel.update(cx, |panel, cx| {
3372 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3373 let worktree = worktree.read(cx);
3374 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3375 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3376 panel.selection = Some(crate::Selection {
3377 worktree_id: worktree.id(),
3378 entry_id,
3379 });
3380 return;
3381 }
3382 }
3383 panic!("no worktree for path {:?}", path);
3384 });
3385 }
3386
3387 fn find_project_entry(
3388 panel: &View<ProjectPanel>,
3389 path: impl AsRef<Path>,
3390 cx: &mut VisualTestContext,
3391 ) -> Option<ProjectEntryId> {
3392 let path = path.as_ref();
3393 panel.update(cx, |panel, cx| {
3394 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3395 let worktree = worktree.read(cx);
3396 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3397 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3398 }
3399 }
3400 panic!("no worktree for path {path:?}");
3401 })
3402 }
3403
3404 fn visible_entries_as_strings(
3405 panel: &View<ProjectPanel>,
3406 range: Range<usize>,
3407 cx: &mut VisualTestContext,
3408 ) -> Vec<String> {
3409 let mut result = Vec::new();
3410 let mut project_entries = HashSet::new();
3411 let mut has_editor = false;
3412
3413 panel.update(cx, |panel, cx| {
3414 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3415 if details.is_editing {
3416 assert!(!has_editor, "duplicate editor entry");
3417 has_editor = true;
3418 } else {
3419 assert!(
3420 project_entries.insert(project_entry),
3421 "duplicate project entry {:?} {:?}",
3422 project_entry,
3423 details
3424 );
3425 }
3426
3427 let indent = " ".repeat(details.depth);
3428 let icon = if details.kind.is_dir() {
3429 if details.is_expanded {
3430 "v "
3431 } else {
3432 "> "
3433 }
3434 } else {
3435 " "
3436 };
3437 let name = if details.is_editing {
3438 format!("[EDITOR: '{}']", details.filename)
3439 } else if details.is_processing {
3440 format!("[PROCESSING: '{}']", details.filename)
3441 } else {
3442 details.filename.clone()
3443 };
3444 let selected = if details.is_selected {
3445 " <== selected"
3446 } else {
3447 ""
3448 };
3449 result.push(format!("{indent}{icon}{name}{selected}"));
3450 });
3451 });
3452
3453 result
3454 }
3455
3456 fn init_test(cx: &mut TestAppContext) {
3457 cx.update(|cx| {
3458 let settings_store = SettingsStore::test(cx);
3459 cx.set_global(settings_store);
3460 init_settings(cx);
3461 theme::init(theme::LoadThemes::JustBase, cx);
3462 language::init(cx);
3463 editor::init_settings(cx);
3464 crate::init((), cx);
3465 workspace::init_settings(cx);
3466 client::init_settings(cx);
3467 Project::init_settings(cx);
3468
3469 cx.update_global::<SettingsStore, _>(|store, cx| {
3470 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3471 project_settings.file_scan_exclusions = Some(Vec::new());
3472 });
3473 });
3474 });
3475 }
3476
3477 fn init_test_with_editor(cx: &mut TestAppContext) {
3478 cx.update(|cx| {
3479 let app_state = AppState::test(cx);
3480 theme::init(theme::LoadThemes::JustBase, cx);
3481 init_settings(cx);
3482 language::init(cx);
3483 editor::init(cx);
3484 crate::init((), cx);
3485 workspace::init(app_state.clone(), cx);
3486 Project::init_settings(cx);
3487 });
3488 }
3489
3490 fn ensure_single_file_is_opened(
3491 window: &WindowHandle<Workspace>,
3492 expected_path: &str,
3493 cx: &mut TestAppContext,
3494 ) {
3495 window
3496 .update(cx, |workspace, cx| {
3497 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3498 assert_eq!(worktrees.len(), 1);
3499 let worktree_id = worktrees[0].read(cx).id();
3500
3501 let open_project_paths = workspace
3502 .panes()
3503 .iter()
3504 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3505 .collect::<Vec<_>>();
3506 assert_eq!(
3507 open_project_paths,
3508 vec![ProjectPath {
3509 worktree_id,
3510 path: Arc::from(Path::new(expected_path))
3511 }],
3512 "Should have opened file, selected in project panel"
3513 );
3514 })
3515 .unwrap();
3516 }
3517
3518 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3519 assert!(
3520 !cx.has_pending_prompt(),
3521 "Should have no prompts before the deletion"
3522 );
3523 panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
3524 assert!(
3525 cx.has_pending_prompt(),
3526 "Should have a prompt after the deletion"
3527 );
3528 cx.simulate_prompt_answer(0);
3529 assert!(
3530 !cx.has_pending_prompt(),
3531 "Should have no prompts after prompt was replied to"
3532 );
3533 cx.executor().run_until_parked();
3534 }
3535
3536 fn ensure_no_open_items_and_panes(
3537 workspace: &WindowHandle<Workspace>,
3538 cx: &mut VisualTestContext,
3539 ) {
3540 assert!(
3541 !cx.has_pending_prompt(),
3542 "Should have no prompts after deletion operation closes the file"
3543 );
3544 workspace
3545 .read_with(cx, |workspace, cx| {
3546 let open_project_paths = workspace
3547 .panes()
3548 .iter()
3549 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3550 .collect::<Vec<_>>();
3551 assert!(
3552 open_project_paths.is_empty(),
3553 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3554 );
3555 })
3556 .unwrap();
3557 }
3558}