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