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