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