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, _, cx| {
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(filename_text_color))
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_copy_paste_directory(cx: &mut gpui::TestAppContext) {
2460 init_test(cx);
2461
2462 let fs = FakeFs::new(cx.executor().clone());
2463 fs.insert_tree(
2464 "/root",
2465 json!({
2466 "a": {
2467 "one.txt": "",
2468 "two.txt": "",
2469 "inner_dir": {
2470 "three.txt": "",
2471 "four.txt": "",
2472 }
2473 },
2474 "b": {}
2475 }),
2476 )
2477 .await;
2478
2479 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
2480 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2481 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2482 let panel = workspace
2483 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2484 .unwrap();
2485
2486 select_path(&panel, "root/a", cx);
2487 panel.update(cx, |panel, cx| {
2488 panel.copy(&Default::default(), cx);
2489 panel.select_next(&Default::default(), cx);
2490 panel.paste(&Default::default(), cx);
2491 });
2492 cx.executor().run_until_parked();
2493
2494 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
2495 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
2496
2497 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
2498 assert_ne!(
2499 pasted_dir_file, None,
2500 "Pasted directory file should have an entry"
2501 );
2502
2503 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
2504 assert_ne!(
2505 pasted_dir_inner_dir, None,
2506 "Directories inside pasted directory should have an entry"
2507 );
2508
2509 toggle_expand_dir(&panel, "root/b", cx);
2510 toggle_expand_dir(&panel, "root/b/a", cx);
2511 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
2512
2513 assert_eq!(
2514 visible_entries_as_strings(&panel, 0..50, cx),
2515 &[
2516 //
2517 "v root",
2518 " > a",
2519 " v b",
2520 " v a",
2521 " v inner_dir <== selected",
2522 " four.txt",
2523 " three.txt",
2524 " one.txt",
2525 " two.txt",
2526 ]
2527 );
2528
2529 select_path(&panel, "root", cx);
2530 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2531 cx.executor().run_until_parked();
2532 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2533 cx.executor().run_until_parked();
2534 assert_eq!(
2535 visible_entries_as_strings(&panel, 0..50, cx),
2536 &[
2537 //
2538 "v root <== selected",
2539 " > a",
2540 " > a copy",
2541 " > a copy 1",
2542 " v b",
2543 " v a",
2544 " v inner_dir",
2545 " four.txt",
2546 " three.txt",
2547 " one.txt",
2548 " two.txt"
2549 ]
2550 );
2551 }
2552
2553 #[gpui::test]
2554 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2555 init_test_with_editor(cx);
2556
2557 let fs = FakeFs::new(cx.executor().clone());
2558 fs.insert_tree(
2559 "/src",
2560 json!({
2561 "test": {
2562 "first.rs": "// First Rust file",
2563 "second.rs": "// Second Rust file",
2564 "third.rs": "// Third Rust file",
2565 }
2566 }),
2567 )
2568 .await;
2569
2570 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2571 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2572 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2573 let panel = workspace
2574 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2575 .unwrap();
2576
2577 toggle_expand_dir(&panel, "src/test", cx);
2578 select_path(&panel, "src/test/first.rs", cx);
2579 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2580 cx.executor().run_until_parked();
2581 assert_eq!(
2582 visible_entries_as_strings(&panel, 0..10, cx),
2583 &[
2584 "v src",
2585 " v test",
2586 " first.rs <== selected",
2587 " second.rs",
2588 " third.rs"
2589 ]
2590 );
2591 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2592
2593 submit_deletion(&panel, cx);
2594 assert_eq!(
2595 visible_entries_as_strings(&panel, 0..10, cx),
2596 &[
2597 "v src",
2598 " v test",
2599 " second.rs",
2600 " third.rs"
2601 ],
2602 "Project panel should have no deleted file, no other file is selected in it"
2603 );
2604 ensure_no_open_items_and_panes(&workspace, cx);
2605
2606 select_path(&panel, "src/test/second.rs", cx);
2607 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2608 cx.executor().run_until_parked();
2609 assert_eq!(
2610 visible_entries_as_strings(&panel, 0..10, cx),
2611 &[
2612 "v src",
2613 " v test",
2614 " second.rs <== selected",
2615 " third.rs"
2616 ]
2617 );
2618 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2619
2620 workspace
2621 .update(cx, |workspace, cx| {
2622 let active_items = workspace
2623 .panes()
2624 .iter()
2625 .filter_map(|pane| pane.read(cx).active_item())
2626 .collect::<Vec<_>>();
2627 assert_eq!(active_items.len(), 1);
2628 let open_editor = active_items
2629 .into_iter()
2630 .next()
2631 .unwrap()
2632 .downcast::<Editor>()
2633 .expect("Open item should be an editor");
2634 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2635 })
2636 .unwrap();
2637 submit_deletion(&panel, cx);
2638 assert_eq!(
2639 visible_entries_as_strings(&panel, 0..10, cx),
2640 &["v src", " v test", " third.rs"],
2641 "Project panel should have no deleted file, with one last file remaining"
2642 );
2643 ensure_no_open_items_and_panes(&workspace, cx);
2644 }
2645
2646 #[gpui::test]
2647 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2648 init_test_with_editor(cx);
2649
2650 let fs = FakeFs::new(cx.executor().clone());
2651 fs.insert_tree(
2652 "/src",
2653 json!({
2654 "test": {
2655 "first.rs": "// First Rust file",
2656 "second.rs": "// Second Rust file",
2657 "third.rs": "// Third Rust file",
2658 }
2659 }),
2660 )
2661 .await;
2662
2663 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2664 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2665 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2666 let panel = workspace
2667 .update(cx, |workspace, cx| {
2668 let panel = ProjectPanel::new(workspace, cx);
2669 workspace.add_panel(panel.clone(), cx);
2670 panel
2671 })
2672 .unwrap();
2673
2674 select_path(&panel, "src/", cx);
2675 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2676 cx.executor().run_until_parked();
2677 assert_eq!(
2678 visible_entries_as_strings(&panel, 0..10, cx),
2679 &[
2680 //
2681 "v src <== selected",
2682 " > test"
2683 ]
2684 );
2685 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2686 panel.update(cx, |panel, cx| {
2687 assert!(panel.filename_editor.read(cx).is_focused(cx));
2688 });
2689 assert_eq!(
2690 visible_entries_as_strings(&panel, 0..10, cx),
2691 &[
2692 //
2693 "v src",
2694 " > [EDITOR: ''] <== selected",
2695 " > test"
2696 ]
2697 );
2698 panel.update(cx, |panel, cx| {
2699 panel
2700 .filename_editor
2701 .update(cx, |editor, cx| editor.set_text("test", cx));
2702 assert!(
2703 panel.confirm_edit(cx).is_none(),
2704 "Should not allow to confirm on conflicting new directory name"
2705 )
2706 });
2707 assert_eq!(
2708 visible_entries_as_strings(&panel, 0..10, cx),
2709 &[
2710 //
2711 "v src",
2712 " > test"
2713 ],
2714 "File list should be unchanged after failed folder create confirmation"
2715 );
2716
2717 select_path(&panel, "src/test/", cx);
2718 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2719 cx.executor().run_until_parked();
2720 assert_eq!(
2721 visible_entries_as_strings(&panel, 0..10, cx),
2722 &[
2723 //
2724 "v src",
2725 " > test <== selected"
2726 ]
2727 );
2728 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2729 panel.update(cx, |panel, cx| {
2730 assert!(panel.filename_editor.read(cx).is_focused(cx));
2731 });
2732 assert_eq!(
2733 visible_entries_as_strings(&panel, 0..10, cx),
2734 &[
2735 "v src",
2736 " v test",
2737 " [EDITOR: ''] <== selected",
2738 " first.rs",
2739 " second.rs",
2740 " third.rs"
2741 ]
2742 );
2743 panel.update(cx, |panel, cx| {
2744 panel
2745 .filename_editor
2746 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2747 assert!(
2748 panel.confirm_edit(cx).is_none(),
2749 "Should not allow to confirm on conflicting new file name"
2750 )
2751 });
2752 assert_eq!(
2753 visible_entries_as_strings(&panel, 0..10, cx),
2754 &[
2755 "v src",
2756 " v test",
2757 " first.rs",
2758 " second.rs",
2759 " third.rs"
2760 ],
2761 "File list should be unchanged after failed file create confirmation"
2762 );
2763
2764 select_path(&panel, "src/test/first.rs", cx);
2765 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2766 cx.executor().run_until_parked();
2767 assert_eq!(
2768 visible_entries_as_strings(&panel, 0..10, cx),
2769 &[
2770 "v src",
2771 " v test",
2772 " first.rs <== selected",
2773 " second.rs",
2774 " third.rs"
2775 ],
2776 );
2777 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2778 panel.update(cx, |panel, cx| {
2779 assert!(panel.filename_editor.read(cx).is_focused(cx));
2780 });
2781 assert_eq!(
2782 visible_entries_as_strings(&panel, 0..10, cx),
2783 &[
2784 "v src",
2785 " v test",
2786 " [EDITOR: 'first.rs'] <== selected",
2787 " second.rs",
2788 " third.rs"
2789 ]
2790 );
2791 panel.update(cx, |panel, cx| {
2792 panel
2793 .filename_editor
2794 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2795 assert!(
2796 panel.confirm_edit(cx).is_none(),
2797 "Should not allow to confirm on conflicting file rename"
2798 )
2799 });
2800 assert_eq!(
2801 visible_entries_as_strings(&panel, 0..10, cx),
2802 &[
2803 "v src",
2804 " v test",
2805 " first.rs <== selected",
2806 " second.rs",
2807 " third.rs"
2808 ],
2809 "File list should be unchanged after failed rename confirmation"
2810 );
2811 }
2812
2813 #[gpui::test]
2814 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2815 init_test_with_editor(cx);
2816
2817 let fs = FakeFs::new(cx.executor().clone());
2818 fs.insert_tree(
2819 "/project_root",
2820 json!({
2821 "dir_1": {
2822 "nested_dir": {
2823 "file_a.py": "# File contents",
2824 "file_b.py": "# File contents",
2825 "file_c.py": "# File contents",
2826 },
2827 "file_1.py": "# File contents",
2828 "file_2.py": "# File contents",
2829 "file_3.py": "# File contents",
2830 },
2831 "dir_2": {
2832 "file_1.py": "# File contents",
2833 "file_2.py": "# File contents",
2834 "file_3.py": "# File contents",
2835 }
2836 }),
2837 )
2838 .await;
2839
2840 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2841 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2842 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2843 let panel = workspace
2844 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2845 .unwrap();
2846
2847 panel.update(cx, |panel, cx| {
2848 panel.collapse_all_entries(&CollapseAllEntries, cx)
2849 });
2850 cx.executor().run_until_parked();
2851 assert_eq!(
2852 visible_entries_as_strings(&panel, 0..10, cx),
2853 &["v project_root", " > dir_1", " > dir_2",]
2854 );
2855
2856 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2857 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2858 cx.executor().run_until_parked();
2859 assert_eq!(
2860 visible_entries_as_strings(&panel, 0..10, cx),
2861 &[
2862 "v project_root",
2863 " v dir_1 <== selected",
2864 " > nested_dir",
2865 " file_1.py",
2866 " file_2.py",
2867 " file_3.py",
2868 " > dir_2",
2869 ]
2870 );
2871 }
2872
2873 #[gpui::test]
2874 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2875 init_test(cx);
2876
2877 let fs = FakeFs::new(cx.executor().clone());
2878 fs.as_fake().insert_tree("/root", json!({})).await;
2879 let project = Project::test(fs, ["/root".as_ref()], cx).await;
2880 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2881 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2882 let panel = workspace
2883 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2884 .unwrap();
2885
2886 // Make a new buffer with no backing file
2887 workspace
2888 .update(cx, |workspace, cx| {
2889 Editor::new_file(workspace, &Default::default(), cx)
2890 })
2891 .unwrap();
2892
2893 // "Save as"" the buffer, creating a new backing file for it
2894 let save_task = workspace
2895 .update(cx, |workspace, cx| {
2896 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2897 })
2898 .unwrap();
2899
2900 cx.executor().run_until_parked();
2901 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2902 save_task.await.unwrap();
2903
2904 // Rename the file
2905 select_path(&panel, "root/new", cx);
2906 assert_eq!(
2907 visible_entries_as_strings(&panel, 0..10, cx),
2908 &["v root", " new <== selected"]
2909 );
2910 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2911 panel.update(cx, |panel, cx| {
2912 panel
2913 .filename_editor
2914 .update(cx, |editor, cx| editor.set_text("newer", cx));
2915 });
2916 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2917
2918 cx.executor().run_until_parked();
2919 assert_eq!(
2920 visible_entries_as_strings(&panel, 0..10, cx),
2921 &["v root", " newer <== selected"]
2922 );
2923
2924 workspace
2925 .update(cx, |workspace, cx| {
2926 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2927 })
2928 .unwrap()
2929 .await
2930 .unwrap();
2931
2932 cx.executor().run_until_parked();
2933 // assert that saving the file doesn't restore "new"
2934 assert_eq!(
2935 visible_entries_as_strings(&panel, 0..10, cx),
2936 &["v root", " newer <== selected"]
2937 );
2938 }
2939
2940 #[gpui::test]
2941 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
2942 init_test_with_editor(cx);
2943 cx.update(|cx| {
2944 cx.update_global::<SettingsStore, _>(|store, cx| {
2945 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
2946 project_settings.file_scan_exclusions = Some(Vec::new());
2947 });
2948 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
2949 project_panel_settings.auto_reveal_entries = Some(false)
2950 });
2951 })
2952 });
2953
2954 let fs = FakeFs::new(cx.background_executor.clone());
2955 fs.insert_tree(
2956 "/project_root",
2957 json!({
2958 ".git": {},
2959 ".gitignore": "**/gitignored_dir",
2960 "dir_1": {
2961 "file_1.py": "# File 1_1 contents",
2962 "file_2.py": "# File 1_2 contents",
2963 "file_3.py": "# File 1_3 contents",
2964 "gitignored_dir": {
2965 "file_a.py": "# File contents",
2966 "file_b.py": "# File contents",
2967 "file_c.py": "# File contents",
2968 },
2969 },
2970 "dir_2": {
2971 "file_1.py": "# File 2_1 contents",
2972 "file_2.py": "# File 2_2 contents",
2973 "file_3.py": "# File 2_3 contents",
2974 }
2975 }),
2976 )
2977 .await;
2978
2979 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2980 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2981 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2982 let panel = workspace
2983 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2984 .unwrap();
2985
2986 assert_eq!(
2987 visible_entries_as_strings(&panel, 0..20, cx),
2988 &[
2989 "v project_root",
2990 " > .git",
2991 " > dir_1",
2992 " > dir_2",
2993 " .gitignore",
2994 ]
2995 );
2996
2997 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
2998 .expect("dir 1 file is not ignored and should have an entry");
2999 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3000 .expect("dir 2 file is not ignored and should have an entry");
3001 let gitignored_dir_file =
3002 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3003 assert_eq!(
3004 gitignored_dir_file, None,
3005 "File in the gitignored dir should not have an entry before its dir is toggled"
3006 );
3007
3008 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3009 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3010 cx.executor().run_until_parked();
3011 assert_eq!(
3012 visible_entries_as_strings(&panel, 0..20, cx),
3013 &[
3014 "v project_root",
3015 " > .git",
3016 " v dir_1",
3017 " v gitignored_dir <== selected",
3018 " file_a.py",
3019 " file_b.py",
3020 " file_c.py",
3021 " file_1.py",
3022 " file_2.py",
3023 " file_3.py",
3024 " > dir_2",
3025 " .gitignore",
3026 ],
3027 "Should show gitignored dir file list in the project panel"
3028 );
3029 let gitignored_dir_file =
3030 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3031 .expect("after gitignored dir got opened, a file entry should be present");
3032
3033 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3034 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3035 assert_eq!(
3036 visible_entries_as_strings(&panel, 0..20, cx),
3037 &[
3038 "v project_root",
3039 " > .git",
3040 " > dir_1 <== selected",
3041 " > dir_2",
3042 " .gitignore",
3043 ],
3044 "Should hide all dir contents again and prepare for the auto reveal test"
3045 );
3046
3047 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3048 panel.update(cx, |panel, cx| {
3049 panel.project.update(cx, |_, cx| {
3050 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3051 })
3052 });
3053 cx.run_until_parked();
3054 assert_eq!(
3055 visible_entries_as_strings(&panel, 0..20, cx),
3056 &[
3057 "v project_root",
3058 " > .git",
3059 " > dir_1 <== selected",
3060 " > dir_2",
3061 " .gitignore",
3062 ],
3063 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3064 );
3065 }
3066
3067 cx.update(|cx| {
3068 cx.update_global::<SettingsStore, _>(|store, cx| {
3069 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3070 project_panel_settings.auto_reveal_entries = Some(true)
3071 });
3072 })
3073 });
3074
3075 panel.update(cx, |panel, cx| {
3076 panel.project.update(cx, |_, cx| {
3077 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3078 })
3079 });
3080 cx.run_until_parked();
3081 assert_eq!(
3082 visible_entries_as_strings(&panel, 0..20, cx),
3083 &[
3084 "v project_root",
3085 " > .git",
3086 " v dir_1",
3087 " > gitignored_dir",
3088 " file_1.py <== selected",
3089 " file_2.py",
3090 " file_3.py",
3091 " > dir_2",
3092 " .gitignore",
3093 ],
3094 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3095 );
3096
3097 panel.update(cx, |panel, cx| {
3098 panel.project.update(cx, |_, cx| {
3099 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3100 })
3101 });
3102 cx.run_until_parked();
3103 assert_eq!(
3104 visible_entries_as_strings(&panel, 0..20, cx),
3105 &[
3106 "v project_root",
3107 " > .git",
3108 " v dir_1",
3109 " > gitignored_dir",
3110 " file_1.py",
3111 " file_2.py",
3112 " file_3.py",
3113 " v dir_2",
3114 " file_1.py <== selected",
3115 " file_2.py",
3116 " file_3.py",
3117 " .gitignore",
3118 ],
3119 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3120 );
3121
3122 panel.update(cx, |panel, cx| {
3123 panel.project.update(cx, |_, cx| {
3124 cx.emit(project::Event::ActiveEntryChanged(Some(
3125 gitignored_dir_file,
3126 )))
3127 })
3128 });
3129 cx.run_until_parked();
3130 assert_eq!(
3131 visible_entries_as_strings(&panel, 0..20, cx),
3132 &[
3133 "v project_root",
3134 " > .git",
3135 " v dir_1",
3136 " > gitignored_dir",
3137 " file_1.py",
3138 " file_2.py",
3139 " file_3.py",
3140 " v dir_2",
3141 " file_1.py <== selected",
3142 " file_2.py",
3143 " file_3.py",
3144 " .gitignore",
3145 ],
3146 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3147 );
3148
3149 panel.update(cx, |panel, cx| {
3150 panel.project.update(cx, |_, cx| {
3151 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3152 })
3153 });
3154 cx.run_until_parked();
3155 assert_eq!(
3156 visible_entries_as_strings(&panel, 0..20, cx),
3157 &[
3158 "v project_root",
3159 " > .git",
3160 " v dir_1",
3161 " v gitignored_dir",
3162 " file_a.py <== selected",
3163 " file_b.py",
3164 " file_c.py",
3165 " file_1.py",
3166 " file_2.py",
3167 " file_3.py",
3168 " v dir_2",
3169 " file_1.py",
3170 " file_2.py",
3171 " file_3.py",
3172 " .gitignore",
3173 ],
3174 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3175 );
3176 }
3177
3178 #[gpui::test]
3179 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3180 init_test_with_editor(cx);
3181 cx.update(|cx| {
3182 cx.update_global::<SettingsStore, _>(|store, cx| {
3183 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3184 project_settings.file_scan_exclusions = Some(Vec::new());
3185 });
3186 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3187 project_panel_settings.auto_reveal_entries = Some(false)
3188 });
3189 })
3190 });
3191
3192 let fs = FakeFs::new(cx.background_executor.clone());
3193 fs.insert_tree(
3194 "/project_root",
3195 json!({
3196 ".git": {},
3197 ".gitignore": "**/gitignored_dir",
3198 "dir_1": {
3199 "file_1.py": "# File 1_1 contents",
3200 "file_2.py": "# File 1_2 contents",
3201 "file_3.py": "# File 1_3 contents",
3202 "gitignored_dir": {
3203 "file_a.py": "# File contents",
3204 "file_b.py": "# File contents",
3205 "file_c.py": "# File contents",
3206 },
3207 },
3208 "dir_2": {
3209 "file_1.py": "# File 2_1 contents",
3210 "file_2.py": "# File 2_2 contents",
3211 "file_3.py": "# File 2_3 contents",
3212 }
3213 }),
3214 )
3215 .await;
3216
3217 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3218 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3219 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3220 let panel = workspace
3221 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3222 .unwrap();
3223
3224 assert_eq!(
3225 visible_entries_as_strings(&panel, 0..20, cx),
3226 &[
3227 "v project_root",
3228 " > .git",
3229 " > dir_1",
3230 " > dir_2",
3231 " .gitignore",
3232 ]
3233 );
3234
3235 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3236 .expect("dir 1 file is not ignored and should have an entry");
3237 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3238 .expect("dir 2 file is not ignored and should have an entry");
3239 let gitignored_dir_file =
3240 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3241 assert_eq!(
3242 gitignored_dir_file, None,
3243 "File in the gitignored dir should not have an entry before its dir is toggled"
3244 );
3245
3246 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3247 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3248 cx.run_until_parked();
3249 assert_eq!(
3250 visible_entries_as_strings(&panel, 0..20, cx),
3251 &[
3252 "v project_root",
3253 " > .git",
3254 " v dir_1",
3255 " v gitignored_dir <== selected",
3256 " file_a.py",
3257 " file_b.py",
3258 " file_c.py",
3259 " file_1.py",
3260 " file_2.py",
3261 " file_3.py",
3262 " > dir_2",
3263 " .gitignore",
3264 ],
3265 "Should show gitignored dir file list in the project panel"
3266 );
3267 let gitignored_dir_file =
3268 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3269 .expect("after gitignored dir got opened, a file entry should be present");
3270
3271 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3272 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3273 assert_eq!(
3274 visible_entries_as_strings(&panel, 0..20, cx),
3275 &[
3276 "v project_root",
3277 " > .git",
3278 " > dir_1 <== selected",
3279 " > dir_2",
3280 " .gitignore",
3281 ],
3282 "Should hide all dir contents again and prepare for the explicit reveal test"
3283 );
3284
3285 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3286 panel.update(cx, |panel, cx| {
3287 panel.project.update(cx, |_, cx| {
3288 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3289 })
3290 });
3291 cx.run_until_parked();
3292 assert_eq!(
3293 visible_entries_as_strings(&panel, 0..20, cx),
3294 &[
3295 "v project_root",
3296 " > .git",
3297 " > dir_1 <== selected",
3298 " > dir_2",
3299 " .gitignore",
3300 ],
3301 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3302 );
3303 }
3304
3305 panel.update(cx, |panel, cx| {
3306 panel.project.update(cx, |_, cx| {
3307 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3308 })
3309 });
3310 cx.run_until_parked();
3311 assert_eq!(
3312 visible_entries_as_strings(&panel, 0..20, cx),
3313 &[
3314 "v project_root",
3315 " > .git",
3316 " v dir_1",
3317 " > gitignored_dir",
3318 " file_1.py <== selected",
3319 " file_2.py",
3320 " file_3.py",
3321 " > dir_2",
3322 " .gitignore",
3323 ],
3324 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3325 );
3326
3327 panel.update(cx, |panel, cx| {
3328 panel.project.update(cx, |_, cx| {
3329 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3330 })
3331 });
3332 cx.run_until_parked();
3333 assert_eq!(
3334 visible_entries_as_strings(&panel, 0..20, cx),
3335 &[
3336 "v project_root",
3337 " > .git",
3338 " v dir_1",
3339 " > gitignored_dir",
3340 " file_1.py",
3341 " file_2.py",
3342 " file_3.py",
3343 " v dir_2",
3344 " file_1.py <== selected",
3345 " file_2.py",
3346 " file_3.py",
3347 " .gitignore",
3348 ],
3349 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3350 );
3351
3352 panel.update(cx, |panel, cx| {
3353 panel.project.update(cx, |_, cx| {
3354 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3355 })
3356 });
3357 cx.run_until_parked();
3358 assert_eq!(
3359 visible_entries_as_strings(&panel, 0..20, cx),
3360 &[
3361 "v project_root",
3362 " > .git",
3363 " v dir_1",
3364 " v gitignored_dir",
3365 " file_a.py <== selected",
3366 " file_b.py",
3367 " file_c.py",
3368 " file_1.py",
3369 " file_2.py",
3370 " file_3.py",
3371 " v dir_2",
3372 " file_1.py",
3373 " file_2.py",
3374 " file_3.py",
3375 " .gitignore",
3376 ],
3377 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3378 );
3379 }
3380
3381 fn toggle_expand_dir(
3382 panel: &View<ProjectPanel>,
3383 path: impl AsRef<Path>,
3384 cx: &mut VisualTestContext,
3385 ) {
3386 let path = path.as_ref();
3387 panel.update(cx, |panel, cx| {
3388 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3389 let worktree = worktree.read(cx);
3390 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3391 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3392 panel.toggle_expanded(entry_id, cx);
3393 return;
3394 }
3395 }
3396 panic!("no worktree for path {:?}", path);
3397 });
3398 }
3399
3400 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
3401 let path = path.as_ref();
3402 panel.update(cx, |panel, cx| {
3403 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3404 let worktree = worktree.read(cx);
3405 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3406 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3407 panel.selection = Some(crate::Selection {
3408 worktree_id: worktree.id(),
3409 entry_id,
3410 });
3411 return;
3412 }
3413 }
3414 panic!("no worktree for path {:?}", path);
3415 });
3416 }
3417
3418 fn find_project_entry(
3419 panel: &View<ProjectPanel>,
3420 path: impl AsRef<Path>,
3421 cx: &mut VisualTestContext,
3422 ) -> Option<ProjectEntryId> {
3423 let path = path.as_ref();
3424 panel.update(cx, |panel, cx| {
3425 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3426 let worktree = worktree.read(cx);
3427 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3428 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3429 }
3430 }
3431 panic!("no worktree for path {path:?}");
3432 })
3433 }
3434
3435 fn visible_entries_as_strings(
3436 panel: &View<ProjectPanel>,
3437 range: Range<usize>,
3438 cx: &mut VisualTestContext,
3439 ) -> Vec<String> {
3440 let mut result = Vec::new();
3441 let mut project_entries = HashSet::new();
3442 let mut has_editor = false;
3443
3444 panel.update(cx, |panel, cx| {
3445 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3446 if details.is_editing {
3447 assert!(!has_editor, "duplicate editor entry");
3448 has_editor = true;
3449 } else {
3450 assert!(
3451 project_entries.insert(project_entry),
3452 "duplicate project entry {:?} {:?}",
3453 project_entry,
3454 details
3455 );
3456 }
3457
3458 let indent = " ".repeat(details.depth);
3459 let icon = if details.kind.is_dir() {
3460 if details.is_expanded {
3461 "v "
3462 } else {
3463 "> "
3464 }
3465 } else {
3466 " "
3467 };
3468 let name = if details.is_editing {
3469 format!("[EDITOR: '{}']", details.filename)
3470 } else if details.is_processing {
3471 format!("[PROCESSING: '{}']", details.filename)
3472 } else {
3473 details.filename.clone()
3474 };
3475 let selected = if details.is_selected {
3476 " <== selected"
3477 } else {
3478 ""
3479 };
3480 result.push(format!("{indent}{icon}{name}{selected}"));
3481 });
3482 });
3483
3484 result
3485 }
3486
3487 fn init_test(cx: &mut TestAppContext) {
3488 cx.update(|cx| {
3489 let settings_store = SettingsStore::test(cx);
3490 cx.set_global(settings_store);
3491 init_settings(cx);
3492 theme::init(theme::LoadThemes::JustBase, cx);
3493 language::init(cx);
3494 editor::init_settings(cx);
3495 crate::init((), cx);
3496 workspace::init_settings(cx);
3497 client::init_settings(cx);
3498 Project::init_settings(cx);
3499
3500 cx.update_global::<SettingsStore, _>(|store, cx| {
3501 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3502 project_settings.file_scan_exclusions = Some(Vec::new());
3503 });
3504 });
3505 });
3506 }
3507
3508 fn init_test_with_editor(cx: &mut TestAppContext) {
3509 cx.update(|cx| {
3510 let app_state = AppState::test(cx);
3511 theme::init(theme::LoadThemes::JustBase, cx);
3512 init_settings(cx);
3513 language::init(cx);
3514 editor::init(cx);
3515 crate::init((), cx);
3516 workspace::init(app_state.clone(), cx);
3517 Project::init_settings(cx);
3518 });
3519 }
3520
3521 fn ensure_single_file_is_opened(
3522 window: &WindowHandle<Workspace>,
3523 expected_path: &str,
3524 cx: &mut TestAppContext,
3525 ) {
3526 window
3527 .update(cx, |workspace, cx| {
3528 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3529 assert_eq!(worktrees.len(), 1);
3530 let worktree_id = worktrees[0].read(cx).id();
3531
3532 let open_project_paths = workspace
3533 .panes()
3534 .iter()
3535 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3536 .collect::<Vec<_>>();
3537 assert_eq!(
3538 open_project_paths,
3539 vec![ProjectPath {
3540 worktree_id,
3541 path: Arc::from(Path::new(expected_path))
3542 }],
3543 "Should have opened file, selected in project panel"
3544 );
3545 })
3546 .unwrap();
3547 }
3548
3549 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3550 assert!(
3551 !cx.has_pending_prompt(),
3552 "Should have no prompts before the deletion"
3553 );
3554 panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
3555 assert!(
3556 cx.has_pending_prompt(),
3557 "Should have a prompt after the deletion"
3558 );
3559 cx.simulate_prompt_answer(0);
3560 assert!(
3561 !cx.has_pending_prompt(),
3562 "Should have no prompts after prompt was replied to"
3563 );
3564 cx.executor().run_until_parked();
3565 }
3566
3567 fn ensure_no_open_items_and_panes(
3568 workspace: &WindowHandle<Workspace>,
3569 cx: &mut VisualTestContext,
3570 ) {
3571 assert!(
3572 !cx.has_pending_prompt(),
3573 "Should have no prompts after deletion operation closes the file"
3574 );
3575 workspace
3576 .read_with(cx, |workspace, cx| {
3577 let open_project_paths = workspace
3578 .panes()
3579 .iter()
3580 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3581 .collect::<Vec<_>>();
3582 assert!(
3583 open_project_paths.is_empty(),
3584 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3585 );
3586 })
3587 .unwrap();
3588 }
3589}