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::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| ProjectPanel::new(workspace, cx))
2284 .unwrap();
2285
2286 select_path(&panel, "root1", cx);
2287 assert_eq!(
2288 visible_entries_as_strings(&panel, 0..10, cx),
2289 &[
2290 "v root1 <== selected",
2291 " > .git",
2292 " > a",
2293 " > b",
2294 " > C",
2295 " .dockerignore",
2296 "v root2",
2297 " > d",
2298 " > e",
2299 ]
2300 );
2301
2302 // Add a file with the root folder selected. The filename editor is placed
2303 // before the first file in the root folder.
2304 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2305 panel.update(cx, |panel, cx| {
2306 assert!(panel.filename_editor.read(cx).is_focused(cx));
2307 });
2308 assert_eq!(
2309 visible_entries_as_strings(&panel, 0..10, cx),
2310 &[
2311 "v root1",
2312 " > .git",
2313 " > a",
2314 " > b",
2315 " > C",
2316 " [EDITOR: ''] <== selected",
2317 " .dockerignore",
2318 "v root2",
2319 " > d",
2320 " > e",
2321 ]
2322 );
2323
2324 let confirm = panel.update(cx, |panel, cx| {
2325 panel.filename_editor.update(cx, |editor, cx| {
2326 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2327 });
2328 panel.confirm_edit(cx).unwrap()
2329 });
2330
2331 assert_eq!(
2332 visible_entries_as_strings(&panel, 0..10, cx),
2333 &[
2334 "v root1",
2335 " > .git",
2336 " > a",
2337 " > b",
2338 " > C",
2339 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2340 " .dockerignore",
2341 "v root2",
2342 " > d",
2343 " > e",
2344 ]
2345 );
2346
2347 confirm.await.unwrap();
2348 assert_eq!(
2349 visible_entries_as_strings(&panel, 0..13, cx),
2350 &[
2351 "v root1",
2352 " > .git",
2353 " > a",
2354 " > b",
2355 " v bdir1",
2356 " v dir2",
2357 " the-new-filename <== selected",
2358 " > C",
2359 " .dockerignore",
2360 "v root2",
2361 " > d",
2362 " > e",
2363 ]
2364 );
2365 }
2366
2367 #[gpui::test]
2368 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2369 init_test(cx);
2370
2371 let fs = FakeFs::new(cx.executor().clone());
2372 fs.insert_tree(
2373 "/root1",
2374 json!({
2375 "one.two.txt": "",
2376 "one.txt": ""
2377 }),
2378 )
2379 .await;
2380
2381 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2382 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2383 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2384 let panel = workspace
2385 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2386 .unwrap();
2387
2388 panel.update(cx, |panel, cx| {
2389 panel.select_next(&Default::default(), cx);
2390 panel.select_next(&Default::default(), cx);
2391 });
2392
2393 assert_eq!(
2394 visible_entries_as_strings(&panel, 0..50, cx),
2395 &[
2396 //
2397 "v root1",
2398 " one.two.txt <== selected",
2399 " one.txt",
2400 ]
2401 );
2402
2403 // Regression test - file name is created correctly when
2404 // the copied file's name contains multiple dots.
2405 panel.update(cx, |panel, cx| {
2406 panel.copy(&Default::default(), cx);
2407 panel.paste(&Default::default(), cx);
2408 });
2409 cx.executor().run_until_parked();
2410
2411 assert_eq!(
2412 visible_entries_as_strings(&panel, 0..50, cx),
2413 &[
2414 //
2415 "v root1",
2416 " one.two copy.txt",
2417 " one.two.txt <== selected",
2418 " one.txt",
2419 ]
2420 );
2421
2422 panel.update(cx, |panel, cx| {
2423 panel.paste(&Default::default(), cx);
2424 });
2425 cx.executor().run_until_parked();
2426
2427 assert_eq!(
2428 visible_entries_as_strings(&panel, 0..50, cx),
2429 &[
2430 //
2431 "v root1",
2432 " one.two copy 1.txt",
2433 " one.two copy.txt",
2434 " one.two.txt <== selected",
2435 " one.txt",
2436 ]
2437 );
2438 }
2439
2440 #[gpui::test]
2441 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2442 init_test_with_editor(cx);
2443
2444 let fs = FakeFs::new(cx.executor().clone());
2445 fs.insert_tree(
2446 "/src",
2447 json!({
2448 "test": {
2449 "first.rs": "// First Rust file",
2450 "second.rs": "// Second Rust file",
2451 "third.rs": "// Third Rust file",
2452 }
2453 }),
2454 )
2455 .await;
2456
2457 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2458 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2459 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2460 let panel = workspace
2461 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2462 .unwrap();
2463
2464 toggle_expand_dir(&panel, "src/test", cx);
2465 select_path(&panel, "src/test/first.rs", cx);
2466 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2467 cx.executor().run_until_parked();
2468 assert_eq!(
2469 visible_entries_as_strings(&panel, 0..10, cx),
2470 &[
2471 "v src",
2472 " v test",
2473 " first.rs <== selected",
2474 " second.rs",
2475 " third.rs"
2476 ]
2477 );
2478 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2479
2480 submit_deletion(&panel, cx);
2481 assert_eq!(
2482 visible_entries_as_strings(&panel, 0..10, cx),
2483 &[
2484 "v src",
2485 " v test",
2486 " second.rs",
2487 " third.rs"
2488 ],
2489 "Project panel should have no deleted file, no other file is selected in it"
2490 );
2491 ensure_no_open_items_and_panes(&workspace, cx);
2492
2493 select_path(&panel, "src/test/second.rs", cx);
2494 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2495 cx.executor().run_until_parked();
2496 assert_eq!(
2497 visible_entries_as_strings(&panel, 0..10, cx),
2498 &[
2499 "v src",
2500 " v test",
2501 " second.rs <== selected",
2502 " third.rs"
2503 ]
2504 );
2505 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2506
2507 workspace
2508 .update(cx, |workspace, cx| {
2509 let active_items = workspace
2510 .panes()
2511 .iter()
2512 .filter_map(|pane| pane.read(cx).active_item())
2513 .collect::<Vec<_>>();
2514 assert_eq!(active_items.len(), 1);
2515 let open_editor = active_items
2516 .into_iter()
2517 .next()
2518 .unwrap()
2519 .downcast::<Editor>()
2520 .expect("Open item should be an editor");
2521 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2522 })
2523 .unwrap();
2524 submit_deletion(&panel, cx);
2525 assert_eq!(
2526 visible_entries_as_strings(&panel, 0..10, cx),
2527 &["v src", " v test", " third.rs"],
2528 "Project panel should have no deleted file, with one last file remaining"
2529 );
2530 ensure_no_open_items_and_panes(&workspace, cx);
2531 }
2532
2533 #[gpui::test]
2534 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2535 init_test_with_editor(cx);
2536
2537 let fs = FakeFs::new(cx.executor().clone());
2538 fs.insert_tree(
2539 "/src",
2540 json!({
2541 "test": {
2542 "first.rs": "// First Rust file",
2543 "second.rs": "// Second Rust file",
2544 "third.rs": "// Third Rust file",
2545 }
2546 }),
2547 )
2548 .await;
2549
2550 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2551 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2552 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2553 let panel = workspace
2554 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2555 .unwrap();
2556
2557 select_path(&panel, "src/", cx);
2558 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2559 cx.executor().run_until_parked();
2560 assert_eq!(
2561 visible_entries_as_strings(&panel, 0..10, cx),
2562 &[
2563 //
2564 "v src <== selected",
2565 " > test"
2566 ]
2567 );
2568 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2569 panel.update(cx, |panel, cx| {
2570 assert!(panel.filename_editor.read(cx).is_focused(cx));
2571 });
2572 assert_eq!(
2573 visible_entries_as_strings(&panel, 0..10, cx),
2574 &[
2575 //
2576 "v src",
2577 " > [EDITOR: ''] <== selected",
2578 " > test"
2579 ]
2580 );
2581 panel.update(cx, |panel, cx| {
2582 panel
2583 .filename_editor
2584 .update(cx, |editor, cx| editor.set_text("test", cx));
2585 assert!(
2586 panel.confirm_edit(cx).is_none(),
2587 "Should not allow to confirm on conflicting new directory name"
2588 )
2589 });
2590 assert_eq!(
2591 visible_entries_as_strings(&panel, 0..10, cx),
2592 &[
2593 //
2594 "v src",
2595 " > test"
2596 ],
2597 "File list should be unchanged after failed folder create confirmation"
2598 );
2599
2600 select_path(&panel, "src/test/", cx);
2601 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2602 cx.executor().run_until_parked();
2603 assert_eq!(
2604 visible_entries_as_strings(&panel, 0..10, cx),
2605 &[
2606 //
2607 "v src",
2608 " > test <== selected"
2609 ]
2610 );
2611 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2612 panel.update(cx, |panel, cx| {
2613 assert!(panel.filename_editor.read(cx).is_focused(cx));
2614 });
2615 assert_eq!(
2616 visible_entries_as_strings(&panel, 0..10, cx),
2617 &[
2618 "v src",
2619 " v test",
2620 " [EDITOR: ''] <== selected",
2621 " first.rs",
2622 " second.rs",
2623 " third.rs"
2624 ]
2625 );
2626 panel.update(cx, |panel, cx| {
2627 panel
2628 .filename_editor
2629 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2630 assert!(
2631 panel.confirm_edit(cx).is_none(),
2632 "Should not allow to confirm on conflicting new file name"
2633 )
2634 });
2635 assert_eq!(
2636 visible_entries_as_strings(&panel, 0..10, cx),
2637 &[
2638 "v src",
2639 " v test",
2640 " first.rs",
2641 " second.rs",
2642 " third.rs"
2643 ],
2644 "File list should be unchanged after failed file create confirmation"
2645 );
2646
2647 select_path(&panel, "src/test/first.rs", cx);
2648 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2649 cx.executor().run_until_parked();
2650 assert_eq!(
2651 visible_entries_as_strings(&panel, 0..10, cx),
2652 &[
2653 "v src",
2654 " v test",
2655 " first.rs <== selected",
2656 " second.rs",
2657 " third.rs"
2658 ],
2659 );
2660 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2661 panel.update(cx, |panel, cx| {
2662 assert!(panel.filename_editor.read(cx).is_focused(cx));
2663 });
2664 assert_eq!(
2665 visible_entries_as_strings(&panel, 0..10, cx),
2666 &[
2667 "v src",
2668 " v test",
2669 " [EDITOR: 'first.rs'] <== selected",
2670 " second.rs",
2671 " third.rs"
2672 ]
2673 );
2674 panel.update(cx, |panel, cx| {
2675 panel
2676 .filename_editor
2677 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2678 assert!(
2679 panel.confirm_edit(cx).is_none(),
2680 "Should not allow to confirm on conflicting file rename"
2681 )
2682 });
2683 assert_eq!(
2684 visible_entries_as_strings(&panel, 0..10, cx),
2685 &[
2686 "v src",
2687 " v test",
2688 " first.rs <== selected",
2689 " second.rs",
2690 " third.rs"
2691 ],
2692 "File list should be unchanged after failed rename confirmation"
2693 );
2694 }
2695
2696 #[gpui::test]
2697 async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2698 init_test_with_editor(cx);
2699
2700 let fs = FakeFs::new(cx.executor().clone());
2701 fs.insert_tree(
2702 "/src",
2703 json!({
2704 "test": {
2705 "first.rs": "// First Rust file",
2706 "second.rs": "// Second Rust file",
2707 "third.rs": "// Third Rust file",
2708 }
2709 }),
2710 )
2711 .await;
2712
2713 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2714 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2715 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2716 let panel = workspace
2717 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2718 .unwrap();
2719
2720 let new_search_events_count = Arc::new(AtomicUsize::new(0));
2721 let _subscription = panel.update(cx, |_, cx| {
2722 let subcription_count = Arc::clone(&new_search_events_count);
2723 let view = cx.view().clone();
2724 cx.subscribe(&view, move |_, _, event, _| {
2725 if matches!(event, Event::NewSearchInDirectory { .. }) {
2726 subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2727 }
2728 })
2729 });
2730
2731 toggle_expand_dir(&panel, "src/test", cx);
2732 select_path(&panel, "src/test/first.rs", cx);
2733 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2734 cx.executor().run_until_parked();
2735 assert_eq!(
2736 visible_entries_as_strings(&panel, 0..10, cx),
2737 &[
2738 "v src",
2739 " v test",
2740 " first.rs <== selected",
2741 " second.rs",
2742 " third.rs"
2743 ]
2744 );
2745 panel.update(cx, |panel, cx| {
2746 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2747 });
2748 assert_eq!(
2749 new_search_events_count.load(atomic::Ordering::SeqCst),
2750 0,
2751 "Should not trigger new search in directory when called on a file"
2752 );
2753
2754 select_path(&panel, "src/test", cx);
2755 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2756 cx.executor().run_until_parked();
2757 assert_eq!(
2758 visible_entries_as_strings(&panel, 0..10, cx),
2759 &[
2760 "v src",
2761 " v test <== selected",
2762 " first.rs",
2763 " second.rs",
2764 " third.rs"
2765 ]
2766 );
2767 panel.update(cx, |panel, cx| {
2768 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2769 });
2770 assert_eq!(
2771 new_search_events_count.load(atomic::Ordering::SeqCst),
2772 1,
2773 "Should trigger new search in directory when called on a directory"
2774 );
2775 }
2776
2777 #[gpui::test]
2778 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2779 init_test_with_editor(cx);
2780
2781 let fs = FakeFs::new(cx.executor().clone());
2782 fs.insert_tree(
2783 "/project_root",
2784 json!({
2785 "dir_1": {
2786 "nested_dir": {
2787 "file_a.py": "# File contents",
2788 "file_b.py": "# File contents",
2789 "file_c.py": "# File contents",
2790 },
2791 "file_1.py": "# File contents",
2792 "file_2.py": "# File contents",
2793 "file_3.py": "# File contents",
2794 },
2795 "dir_2": {
2796 "file_1.py": "# File contents",
2797 "file_2.py": "# File contents",
2798 "file_3.py": "# File contents",
2799 }
2800 }),
2801 )
2802 .await;
2803
2804 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2805 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2806 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2807 let panel = workspace
2808 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2809 .unwrap();
2810
2811 panel.update(cx, |panel, cx| {
2812 panel.collapse_all_entries(&CollapseAllEntries, cx)
2813 });
2814 cx.executor().run_until_parked();
2815 assert_eq!(
2816 visible_entries_as_strings(&panel, 0..10, cx),
2817 &["v project_root", " > dir_1", " > dir_2",]
2818 );
2819
2820 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2821 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2822 cx.executor().run_until_parked();
2823 assert_eq!(
2824 visible_entries_as_strings(&panel, 0..10, cx),
2825 &[
2826 "v project_root",
2827 " v dir_1 <== selected",
2828 " > nested_dir",
2829 " file_1.py",
2830 " file_2.py",
2831 " file_3.py",
2832 " > dir_2",
2833 ]
2834 );
2835 }
2836
2837 #[gpui::test]
2838 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2839 init_test(cx);
2840
2841 let fs = FakeFs::new(cx.executor().clone());
2842 fs.as_fake().insert_tree("/root", json!({})).await;
2843 let project = Project::test(fs, ["/root".as_ref()], cx).await;
2844 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2845 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2846 let panel = workspace
2847 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2848 .unwrap();
2849
2850 // Make a new buffer with no backing file
2851 workspace
2852 .update(cx, |workspace, cx| {
2853 Editor::new_file(workspace, &Default::default(), cx)
2854 })
2855 .unwrap();
2856
2857 // "Save as"" the buffer, creating a new backing file for it
2858 let save_task = workspace
2859 .update(cx, |workspace, cx| {
2860 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2861 })
2862 .unwrap();
2863
2864 cx.executor().run_until_parked();
2865 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2866 save_task.await.unwrap();
2867
2868 // Rename the file
2869 select_path(&panel, "root/new", cx);
2870 assert_eq!(
2871 visible_entries_as_strings(&panel, 0..10, cx),
2872 &["v root", " new <== selected"]
2873 );
2874 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2875 panel.update(cx, |panel, cx| {
2876 panel
2877 .filename_editor
2878 .update(cx, |editor, cx| editor.set_text("newer", cx));
2879 });
2880 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2881
2882 cx.executor().run_until_parked();
2883 assert_eq!(
2884 visible_entries_as_strings(&panel, 0..10, cx),
2885 &["v root", " newer <== selected"]
2886 );
2887
2888 workspace
2889 .update(cx, |workspace, cx| {
2890 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2891 })
2892 .unwrap()
2893 .await
2894 .unwrap();
2895
2896 cx.executor().run_until_parked();
2897 // assert that saving the file doesn't restore "new"
2898 assert_eq!(
2899 visible_entries_as_strings(&panel, 0..10, cx),
2900 &["v root", " newer <== selected"]
2901 );
2902 }
2903
2904 #[gpui::test]
2905 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
2906 init_test_with_editor(cx);
2907 cx.update(|cx| {
2908 cx.update_global::<SettingsStore, _>(|store, cx| {
2909 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
2910 project_settings.file_scan_exclusions = Some(Vec::new());
2911 });
2912 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
2913 project_panel_settings.auto_reveal_entries = Some(false)
2914 });
2915 })
2916 });
2917
2918 let fs = FakeFs::new(cx.background_executor.clone());
2919 fs.insert_tree(
2920 "/project_root",
2921 json!({
2922 ".git": {},
2923 ".gitignore": "**/gitignored_dir",
2924 "dir_1": {
2925 "file_1.py": "# File 1_1 contents",
2926 "file_2.py": "# File 1_2 contents",
2927 "file_3.py": "# File 1_3 contents",
2928 "gitignored_dir": {
2929 "file_a.py": "# File contents",
2930 "file_b.py": "# File contents",
2931 "file_c.py": "# File contents",
2932 },
2933 },
2934 "dir_2": {
2935 "file_1.py": "# File 2_1 contents",
2936 "file_2.py": "# File 2_2 contents",
2937 "file_3.py": "# File 2_3 contents",
2938 }
2939 }),
2940 )
2941 .await;
2942
2943 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2944 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2945 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2946 let panel = workspace
2947 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2948 .unwrap();
2949
2950 assert_eq!(
2951 visible_entries_as_strings(&panel, 0..20, cx),
2952 &[
2953 "v project_root",
2954 " > .git",
2955 " > dir_1",
2956 " > dir_2",
2957 " .gitignore",
2958 ]
2959 );
2960
2961 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
2962 .expect("dir 1 file is not ignored and should have an entry");
2963 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
2964 .expect("dir 2 file is not ignored and should have an entry");
2965 let gitignored_dir_file =
2966 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
2967 assert_eq!(
2968 gitignored_dir_file, None,
2969 "File in the gitignored dir should not have an entry before its dir is toggled"
2970 );
2971
2972 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2973 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
2974 cx.executor().run_until_parked();
2975 assert_eq!(
2976 visible_entries_as_strings(&panel, 0..20, cx),
2977 &[
2978 "v project_root",
2979 " > .git",
2980 " v dir_1",
2981 " v gitignored_dir <== selected",
2982 " file_a.py",
2983 " file_b.py",
2984 " file_c.py",
2985 " file_1.py",
2986 " file_2.py",
2987 " file_3.py",
2988 " > dir_2",
2989 " .gitignore",
2990 ],
2991 "Should show gitignored dir file list in the project panel"
2992 );
2993 let gitignored_dir_file =
2994 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
2995 .expect("after gitignored dir got opened, a file entry should be present");
2996
2997 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
2998 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2999 assert_eq!(
3000 visible_entries_as_strings(&panel, 0..20, cx),
3001 &[
3002 "v project_root",
3003 " > .git",
3004 " > dir_1 <== selected",
3005 " > dir_2",
3006 " .gitignore",
3007 ],
3008 "Should hide all dir contents again and prepare for the auto reveal test"
3009 );
3010
3011 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3012 panel.update(cx, |panel, cx| {
3013 panel.project.update(cx, |_, cx| {
3014 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3015 })
3016 });
3017 cx.run_until_parked();
3018 assert_eq!(
3019 visible_entries_as_strings(&panel, 0..20, cx),
3020 &[
3021 "v project_root",
3022 " > .git",
3023 " > dir_1 <== selected",
3024 " > dir_2",
3025 " .gitignore",
3026 ],
3027 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3028 );
3029 }
3030
3031 cx.update(|cx| {
3032 cx.update_global::<SettingsStore, _>(|store, cx| {
3033 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3034 project_panel_settings.auto_reveal_entries = Some(true)
3035 });
3036 })
3037 });
3038
3039 panel.update(cx, |panel, cx| {
3040 panel.project.update(cx, |_, cx| {
3041 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3042 })
3043 });
3044 cx.run_until_parked();
3045 assert_eq!(
3046 visible_entries_as_strings(&panel, 0..20, cx),
3047 &[
3048 "v project_root",
3049 " > .git",
3050 " v dir_1",
3051 " > gitignored_dir",
3052 " file_1.py <== selected",
3053 " file_2.py",
3054 " file_3.py",
3055 " > dir_2",
3056 " .gitignore",
3057 ],
3058 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3059 );
3060
3061 panel.update(cx, |panel, cx| {
3062 panel.project.update(cx, |_, cx| {
3063 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3064 })
3065 });
3066 cx.run_until_parked();
3067 assert_eq!(
3068 visible_entries_as_strings(&panel, 0..20, cx),
3069 &[
3070 "v project_root",
3071 " > .git",
3072 " v dir_1",
3073 " > gitignored_dir",
3074 " file_1.py",
3075 " file_2.py",
3076 " file_3.py",
3077 " v dir_2",
3078 " file_1.py <== selected",
3079 " file_2.py",
3080 " file_3.py",
3081 " .gitignore",
3082 ],
3083 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3084 );
3085
3086 panel.update(cx, |panel, cx| {
3087 panel.project.update(cx, |_, cx| {
3088 cx.emit(project::Event::ActiveEntryChanged(Some(
3089 gitignored_dir_file,
3090 )))
3091 })
3092 });
3093 cx.run_until_parked();
3094 assert_eq!(
3095 visible_entries_as_strings(&panel, 0..20, cx),
3096 &[
3097 "v project_root",
3098 " > .git",
3099 " v dir_1",
3100 " > gitignored_dir",
3101 " file_1.py",
3102 " file_2.py",
3103 " file_3.py",
3104 " v dir_2",
3105 " file_1.py <== selected",
3106 " file_2.py",
3107 " file_3.py",
3108 " .gitignore",
3109 ],
3110 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3111 );
3112
3113 panel.update(cx, |panel, cx| {
3114 panel.project.update(cx, |_, cx| {
3115 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3116 })
3117 });
3118 cx.run_until_parked();
3119 assert_eq!(
3120 visible_entries_as_strings(&panel, 0..20, cx),
3121 &[
3122 "v project_root",
3123 " > .git",
3124 " v dir_1",
3125 " v gitignored_dir",
3126 " file_a.py <== selected",
3127 " file_b.py",
3128 " file_c.py",
3129 " file_1.py",
3130 " file_2.py",
3131 " file_3.py",
3132 " v dir_2",
3133 " file_1.py",
3134 " file_2.py",
3135 " file_3.py",
3136 " .gitignore",
3137 ],
3138 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3139 );
3140 }
3141
3142 #[gpui::test]
3143 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3144 init_test_with_editor(cx);
3145 cx.update(|cx| {
3146 cx.update_global::<SettingsStore, _>(|store, cx| {
3147 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3148 project_settings.file_scan_exclusions = Some(Vec::new());
3149 });
3150 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3151 project_panel_settings.auto_reveal_entries = Some(false)
3152 });
3153 })
3154 });
3155
3156 let fs = FakeFs::new(cx.background_executor.clone());
3157 fs.insert_tree(
3158 "/project_root",
3159 json!({
3160 ".git": {},
3161 ".gitignore": "**/gitignored_dir",
3162 "dir_1": {
3163 "file_1.py": "# File 1_1 contents",
3164 "file_2.py": "# File 1_2 contents",
3165 "file_3.py": "# File 1_3 contents",
3166 "gitignored_dir": {
3167 "file_a.py": "# File contents",
3168 "file_b.py": "# File contents",
3169 "file_c.py": "# File contents",
3170 },
3171 },
3172 "dir_2": {
3173 "file_1.py": "# File 2_1 contents",
3174 "file_2.py": "# File 2_2 contents",
3175 "file_3.py": "# File 2_3 contents",
3176 }
3177 }),
3178 )
3179 .await;
3180
3181 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3182 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3183 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3184 let panel = workspace
3185 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3186 .unwrap();
3187
3188 assert_eq!(
3189 visible_entries_as_strings(&panel, 0..20, cx),
3190 &[
3191 "v project_root",
3192 " > .git",
3193 " > dir_1",
3194 " > dir_2",
3195 " .gitignore",
3196 ]
3197 );
3198
3199 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3200 .expect("dir 1 file is not ignored and should have an entry");
3201 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3202 .expect("dir 2 file is not ignored and should have an entry");
3203 let gitignored_dir_file =
3204 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3205 assert_eq!(
3206 gitignored_dir_file, None,
3207 "File in the gitignored dir should not have an entry before its dir is toggled"
3208 );
3209
3210 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3211 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3212 cx.run_until_parked();
3213 assert_eq!(
3214 visible_entries_as_strings(&panel, 0..20, cx),
3215 &[
3216 "v project_root",
3217 " > .git",
3218 " v dir_1",
3219 " v gitignored_dir <== selected",
3220 " file_a.py",
3221 " file_b.py",
3222 " file_c.py",
3223 " file_1.py",
3224 " file_2.py",
3225 " file_3.py",
3226 " > dir_2",
3227 " .gitignore",
3228 ],
3229 "Should show gitignored dir file list in the project panel"
3230 );
3231 let gitignored_dir_file =
3232 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3233 .expect("after gitignored dir got opened, a file entry should be present");
3234
3235 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3236 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3237 assert_eq!(
3238 visible_entries_as_strings(&panel, 0..20, cx),
3239 &[
3240 "v project_root",
3241 " > .git",
3242 " > dir_1 <== selected",
3243 " > dir_2",
3244 " .gitignore",
3245 ],
3246 "Should hide all dir contents again and prepare for the explicit reveal test"
3247 );
3248
3249 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3250 panel.update(cx, |panel, cx| {
3251 panel.project.update(cx, |_, cx| {
3252 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3253 })
3254 });
3255 cx.run_until_parked();
3256 assert_eq!(
3257 visible_entries_as_strings(&panel, 0..20, cx),
3258 &[
3259 "v project_root",
3260 " > .git",
3261 " > dir_1 <== selected",
3262 " > dir_2",
3263 " .gitignore",
3264 ],
3265 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3266 );
3267 }
3268
3269 panel.update(cx, |panel, cx| {
3270 panel.project.update(cx, |_, cx| {
3271 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3272 })
3273 });
3274 cx.run_until_parked();
3275 assert_eq!(
3276 visible_entries_as_strings(&panel, 0..20, cx),
3277 &[
3278 "v project_root",
3279 " > .git",
3280 " v dir_1",
3281 " > gitignored_dir",
3282 " file_1.py <== selected",
3283 " file_2.py",
3284 " file_3.py",
3285 " > dir_2",
3286 " .gitignore",
3287 ],
3288 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3289 );
3290
3291 panel.update(cx, |panel, cx| {
3292 panel.project.update(cx, |_, cx| {
3293 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3294 })
3295 });
3296 cx.run_until_parked();
3297 assert_eq!(
3298 visible_entries_as_strings(&panel, 0..20, cx),
3299 &[
3300 "v project_root",
3301 " > .git",
3302 " v dir_1",
3303 " > gitignored_dir",
3304 " file_1.py",
3305 " file_2.py",
3306 " file_3.py",
3307 " v dir_2",
3308 " file_1.py <== selected",
3309 " file_2.py",
3310 " file_3.py",
3311 " .gitignore",
3312 ],
3313 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3314 );
3315
3316 panel.update(cx, |panel, cx| {
3317 panel.project.update(cx, |_, cx| {
3318 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3319 })
3320 });
3321 cx.run_until_parked();
3322 assert_eq!(
3323 visible_entries_as_strings(&panel, 0..20, cx),
3324 &[
3325 "v project_root",
3326 " > .git",
3327 " v dir_1",
3328 " v gitignored_dir",
3329 " file_a.py <== selected",
3330 " file_b.py",
3331 " file_c.py",
3332 " file_1.py",
3333 " file_2.py",
3334 " file_3.py",
3335 " v dir_2",
3336 " file_1.py",
3337 " file_2.py",
3338 " file_3.py",
3339 " .gitignore",
3340 ],
3341 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3342 );
3343 }
3344
3345 fn toggle_expand_dir(
3346 panel: &View<ProjectPanel>,
3347 path: impl AsRef<Path>,
3348 cx: &mut VisualTestContext,
3349 ) {
3350 let path = path.as_ref();
3351 panel.update(cx, |panel, cx| {
3352 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3353 let worktree = worktree.read(cx);
3354 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3355 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3356 panel.toggle_expanded(entry_id, cx);
3357 return;
3358 }
3359 }
3360 panic!("no worktree for path {:?}", path);
3361 });
3362 }
3363
3364 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
3365 let path = path.as_ref();
3366 panel.update(cx, |panel, cx| {
3367 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3368 let worktree = worktree.read(cx);
3369 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3370 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3371 panel.selection = Some(crate::Selection {
3372 worktree_id: worktree.id(),
3373 entry_id,
3374 });
3375 return;
3376 }
3377 }
3378 panic!("no worktree for path {:?}", path);
3379 });
3380 }
3381
3382 fn find_project_entry(
3383 panel: &View<ProjectPanel>,
3384 path: impl AsRef<Path>,
3385 cx: &mut VisualTestContext,
3386 ) -> Option<ProjectEntryId> {
3387 let path = path.as_ref();
3388 panel.update(cx, |panel, cx| {
3389 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3390 let worktree = worktree.read(cx);
3391 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3392 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3393 }
3394 }
3395 panic!("no worktree for path {path:?}");
3396 })
3397 }
3398
3399 fn visible_entries_as_strings(
3400 panel: &View<ProjectPanel>,
3401 range: Range<usize>,
3402 cx: &mut VisualTestContext,
3403 ) -> Vec<String> {
3404 let mut result = Vec::new();
3405 let mut project_entries = HashSet::new();
3406 let mut has_editor = false;
3407
3408 panel.update(cx, |panel, cx| {
3409 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3410 if details.is_editing {
3411 assert!(!has_editor, "duplicate editor entry");
3412 has_editor = true;
3413 } else {
3414 assert!(
3415 project_entries.insert(project_entry),
3416 "duplicate project entry {:?} {:?}",
3417 project_entry,
3418 details
3419 );
3420 }
3421
3422 let indent = " ".repeat(details.depth);
3423 let icon = if details.kind.is_dir() {
3424 if details.is_expanded {
3425 "v "
3426 } else {
3427 "> "
3428 }
3429 } else {
3430 " "
3431 };
3432 let name = if details.is_editing {
3433 format!("[EDITOR: '{}']", details.filename)
3434 } else if details.is_processing {
3435 format!("[PROCESSING: '{}']", details.filename)
3436 } else {
3437 details.filename.clone()
3438 };
3439 let selected = if details.is_selected {
3440 " <== selected"
3441 } else {
3442 ""
3443 };
3444 result.push(format!("{indent}{icon}{name}{selected}"));
3445 });
3446 });
3447
3448 result
3449 }
3450
3451 fn init_test(cx: &mut TestAppContext) {
3452 cx.update(|cx| {
3453 let settings_store = SettingsStore::test(cx);
3454 cx.set_global(settings_store);
3455 init_settings(cx);
3456 theme::init(theme::LoadThemes::JustBase, cx);
3457 language::init(cx);
3458 editor::init_settings(cx);
3459 crate::init((), cx);
3460 workspace::init_settings(cx);
3461 client::init_settings(cx);
3462 Project::init_settings(cx);
3463
3464 cx.update_global::<SettingsStore, _>(|store, cx| {
3465 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3466 project_settings.file_scan_exclusions = Some(Vec::new());
3467 });
3468 });
3469 });
3470 }
3471
3472 fn init_test_with_editor(cx: &mut TestAppContext) {
3473 cx.update(|cx| {
3474 let app_state = AppState::test(cx);
3475 theme::init(theme::LoadThemes::JustBase, cx);
3476 init_settings(cx);
3477 language::init(cx);
3478 editor::init(cx);
3479 crate::init((), cx);
3480 workspace::init(app_state.clone(), cx);
3481 Project::init_settings(cx);
3482 });
3483 }
3484
3485 fn ensure_single_file_is_opened(
3486 window: &WindowHandle<Workspace>,
3487 expected_path: &str,
3488 cx: &mut TestAppContext,
3489 ) {
3490 window
3491 .update(cx, |workspace, cx| {
3492 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3493 assert_eq!(worktrees.len(), 1);
3494 let worktree_id = worktrees[0].read(cx).id();
3495
3496 let open_project_paths = workspace
3497 .panes()
3498 .iter()
3499 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3500 .collect::<Vec<_>>();
3501 assert_eq!(
3502 open_project_paths,
3503 vec![ProjectPath {
3504 worktree_id,
3505 path: Arc::from(Path::new(expected_path))
3506 }],
3507 "Should have opened file, selected in project panel"
3508 );
3509 })
3510 .unwrap();
3511 }
3512
3513 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3514 assert!(
3515 !cx.has_pending_prompt(),
3516 "Should have no prompts before the deletion"
3517 );
3518 panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
3519 assert!(
3520 cx.has_pending_prompt(),
3521 "Should have a prompt after the deletion"
3522 );
3523 cx.simulate_prompt_answer(0);
3524 assert!(
3525 !cx.has_pending_prompt(),
3526 "Should have no prompts after prompt was replied to"
3527 );
3528 cx.executor().run_until_parked();
3529 }
3530
3531 fn ensure_no_open_items_and_panes(
3532 workspace: &WindowHandle<Workspace>,
3533 cx: &mut VisualTestContext,
3534 ) {
3535 assert!(
3536 !cx.has_pending_prompt(),
3537 "Should have no prompts after deletion operation closes the file"
3538 );
3539 workspace
3540 .read_with(cx, |workspace, cx| {
3541 let open_project_paths = workspace
3542 .panes()
3543 .iter()
3544 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3545 .collect::<Vec<_>>();
3546 assert!(
3547 open_project_paths.is_empty(),
3548 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3549 );
3550 })
3551 .unwrap();
3552 }
3553}