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