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