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