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