1use gpui::{
2 action,
3 elements::{
4 Align, ConstrainedBox, Empty, Flex, Label, MouseEventHandler, ParentElement, Svg,
5 UniformList, UniformListState,
6 },
7 keymap::{
8 self,
9 menu::{SelectNext, SelectPrev},
10 Binding,
11 },
12 platform::CursorStyle,
13 AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, ReadModel, View,
14 ViewContext, ViewHandle, WeakViewHandle,
15};
16use postage::watch;
17use project::{Project, ProjectEntry, ProjectPath, Worktree};
18use std::{
19 collections::{hash_map, HashMap},
20 ffi::OsStr,
21 ops::Range,
22};
23use workspace::{Settings, Workspace};
24
25pub struct ProjectPanel {
26 project: ModelHandle<Project>,
27 list: UniformListState,
28 visible_entries: Vec<Vec<usize>>,
29 expanded_dir_ids: HashMap<usize, Vec<usize>>,
30 selection: Option<Selection>,
31 settings: watch::Receiver<Settings>,
32 handle: WeakViewHandle<Self>,
33}
34
35#[derive(Copy, Clone)]
36struct Selection {
37 worktree_id: usize,
38 entry_id: usize,
39 index: usize,
40}
41
42#[derive(Debug, PartialEq, Eq)]
43struct EntryDetails {
44 filename: String,
45 depth: usize,
46 is_dir: bool,
47 is_expanded: bool,
48 is_selected: bool,
49}
50
51action!(ExpandSelectedEntry);
52action!(CollapseSelectedEntry);
53action!(ToggleExpanded, ProjectEntry);
54action!(Open, ProjectEntry);
55
56pub fn init(cx: &mut MutableAppContext) {
57 cx.add_action(ProjectPanel::expand_selected_entry);
58 cx.add_action(ProjectPanel::collapse_selected_entry);
59 cx.add_action(ProjectPanel::toggle_expanded);
60 cx.add_action(ProjectPanel::select_prev);
61 cx.add_action(ProjectPanel::select_next);
62 cx.add_action(ProjectPanel::open_entry);
63 cx.add_bindings([
64 Binding::new("right", ExpandSelectedEntry, Some("ProjectPanel")),
65 Binding::new("left", CollapseSelectedEntry, Some("ProjectPanel")),
66 ]);
67}
68
69pub enum Event {
70 OpenedEntry { worktree_id: usize, entry_id: usize },
71}
72
73impl ProjectPanel {
74 pub fn new(
75 project: ModelHandle<Project>,
76 settings: watch::Receiver<Settings>,
77 cx: &mut ViewContext<Workspace>,
78 ) -> ViewHandle<Self> {
79 let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
80 cx.observe(&project, |this, _, cx| {
81 this.update_visible_entries(None, cx);
82 cx.notify();
83 })
84 .detach();
85 cx.subscribe(&project, |this, _, event, cx| match event {
86 project::Event::ActiveEntryChanged(Some(ProjectEntry {
87 worktree_id,
88 entry_id,
89 })) => {
90 this.expand_entry(*worktree_id, *entry_id, cx);
91 this.update_visible_entries(Some((*worktree_id, *entry_id)), cx);
92 this.autoscroll();
93 cx.notify();
94 }
95 project::Event::WorktreeRemoved(id) => {
96 this.expanded_dir_ids.remove(id);
97 this.update_visible_entries(None, cx);
98 cx.notify();
99 }
100 _ => {}
101 })
102 .detach();
103
104 let mut this = Self {
105 project: project.clone(),
106 settings,
107 list: Default::default(),
108 visible_entries: Default::default(),
109 expanded_dir_ids: Default::default(),
110 selection: None,
111 handle: cx.handle().downgrade(),
112 };
113 this.update_visible_entries(None, cx);
114 this
115 });
116 cx.subscribe(&project_panel, move |workspace, _, event, cx| match event {
117 Event::OpenedEntry {
118 worktree_id,
119 entry_id,
120 } => {
121 if let Some(worktree) = project.read(cx).worktree_for_id(*worktree_id) {
122 if let Some(entry) = worktree.read(cx).entry_for_id(*entry_id) {
123 workspace
124 .open_entry(
125 ProjectPath {
126 worktree_id: worktree.id(),
127 path: entry.path.clone(),
128 },
129 cx,
130 )
131 .map(|t| t.detach());
132 }
133 }
134 }
135 })
136 .detach();
137
138 project_panel
139 }
140
141 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
142 if let Some((worktree, entry)) = self.selected_entry(cx) {
143 let expanded_dir_ids =
144 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
145 expanded_dir_ids
146 } else {
147 return;
148 };
149
150 if entry.is_dir() {
151 match expanded_dir_ids.binary_search(&entry.id) {
152 Ok(_) => self.select_next(&SelectNext, cx),
153 Err(ix) => {
154 expanded_dir_ids.insert(ix, entry.id);
155 self.update_visible_entries(None, cx);
156 cx.notify();
157 }
158 }
159 } else {
160 let event = Event::OpenedEntry {
161 worktree_id: worktree.id(),
162 entry_id: entry.id,
163 };
164 cx.emit(event);
165 }
166 }
167 }
168
169 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
170 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
171 let expanded_dir_ids =
172 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
173 expanded_dir_ids
174 } else {
175 return;
176 };
177
178 loop {
179 match expanded_dir_ids.binary_search(&entry.id) {
180 Ok(ix) => {
181 expanded_dir_ids.remove(ix);
182 self.update_visible_entries(Some((worktree.id(), entry.id)), cx);
183 cx.notify();
184 break;
185 }
186 Err(_) => {
187 if let Some(parent_entry) =
188 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
189 {
190 entry = parent_entry;
191 } else {
192 break;
193 }
194 }
195 }
196 }
197 }
198 }
199
200 fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
201 let ProjectEntry {
202 worktree_id,
203 entry_id,
204 } = action.0;
205
206 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
207 match expanded_dir_ids.binary_search(&entry_id) {
208 Ok(ix) => {
209 expanded_dir_ids.remove(ix);
210 }
211 Err(ix) => {
212 expanded_dir_ids.insert(ix, entry_id);
213 }
214 }
215 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
216 cx.focus_self();
217 }
218 }
219
220 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
221 if let Some(selection) = self.selection {
222 let prev_ix = selection.index.saturating_sub(1);
223 let (worktree, entry) = self.visible_entry_for_index(prev_ix, cx).unwrap();
224 self.selection = Some(Selection {
225 worktree_id: worktree.id(),
226 entry_id: entry.id,
227 index: prev_ix,
228 });
229 self.autoscroll();
230 cx.notify();
231 } else {
232 self.select_first(cx);
233 }
234 }
235
236 fn open_entry(&mut self, action: &Open, cx: &mut ViewContext<Self>) {
237 cx.emit(Event::OpenedEntry {
238 worktree_id: action.0.worktree_id,
239 entry_id: action.0.entry_id,
240 });
241 }
242
243 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
244 if let Some(selection) = self.selection {
245 let next_ix = selection.index + 1;
246 if let Some((worktree, entry)) = self.visible_entry_for_index(next_ix, cx) {
247 self.selection = Some(Selection {
248 worktree_id: worktree.id(),
249 entry_id: entry.id,
250 index: next_ix,
251 });
252 self.autoscroll();
253 cx.notify();
254 }
255 } else {
256 self.select_first(cx);
257 }
258 }
259
260 fn select_first(&mut self, cx: &mut ViewContext<Self>) {
261 if let Some(worktree) = self.project.read(cx).worktrees().first() {
262 let worktree_id = worktree.id();
263 let worktree = worktree.read(cx);
264 if let Some(root_entry) = worktree.root_entry() {
265 self.selection = Some(Selection {
266 worktree_id,
267 entry_id: root_entry.id,
268 index: 0,
269 });
270 self.autoscroll();
271 cx.notify();
272 }
273 }
274 }
275
276 fn autoscroll(&mut self) {
277 if let Some(selection) = self.selection {
278 self.list.scroll_to(selection.index);
279 }
280 }
281
282 fn visible_entry_for_index<'a>(
283 &self,
284 target_ix: usize,
285 cx: &'a AppContext,
286 ) -> Option<(&'a Worktree, &'a project::Entry)> {
287 let project = self.project.read(cx);
288 let mut offset = None;
289 let mut ix = 0;
290 for (worktree_ix, visible_entries) in self.visible_entries.iter().enumerate() {
291 if target_ix < ix + visible_entries.len() {
292 let worktree = project.worktrees()[worktree_ix].read(cx);
293 offset = Some((worktree, visible_entries[target_ix - ix]));
294 break;
295 } else {
296 ix += visible_entries.len();
297 }
298 }
299
300 offset.and_then(|(worktree, offset)| {
301 let mut entries = worktree.entries(false);
302 entries.advance_to_offset(offset);
303 Some((worktree, entries.entry()?))
304 })
305 }
306
307 fn selected_entry<'a>(&self, cx: &'a AppContext) -> Option<(&'a Worktree, &'a project::Entry)> {
308 let selection = self.selection?;
309 let project = self.project.read(cx);
310 let worktree = project.worktree_for_id(selection.worktree_id)?.read(cx);
311 Some((worktree, worktree.entry_for_id(selection.entry_id)?))
312 }
313
314 fn update_visible_entries(
315 &mut self,
316 new_selected_entry: Option<(usize, usize)>,
317 cx: &mut ViewContext<Self>,
318 ) {
319 let worktrees = self.project.read(cx).worktrees();
320 self.visible_entries.clear();
321
322 let mut entry_ix = 0;
323 for worktree in worktrees {
324 let snapshot = worktree.read(cx).snapshot();
325 let worktree_id = worktree.id();
326
327 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
328 hash_map::Entry::Occupied(e) => e.into_mut(),
329 hash_map::Entry::Vacant(e) => {
330 // The first time a worktree's root entry becomes available,
331 // mark that root entry as expanded.
332 if let Some(entry) = snapshot.root_entry() {
333 e.insert(vec![entry.id]).as_slice()
334 } else {
335 &[]
336 }
337 }
338 };
339
340 let mut visible_worktree_entries = Vec::new();
341 let mut entry_iter = snapshot.entries(false);
342 while let Some(item) = entry_iter.entry() {
343 visible_worktree_entries.push(entry_iter.offset());
344 if let Some(new_selected_entry) = new_selected_entry {
345 if new_selected_entry == (worktree.id(), item.id) {
346 self.selection = Some(Selection {
347 worktree_id,
348 entry_id: item.id,
349 index: entry_ix,
350 });
351 }
352 } else if self.selection.map_or(false, |e| {
353 e.worktree_id == worktree_id && e.entry_id == item.id
354 }) {
355 self.selection = Some(Selection {
356 worktree_id,
357 entry_id: item.id,
358 index: entry_ix,
359 });
360 }
361
362 entry_ix += 1;
363 if expanded_dir_ids.binary_search(&item.id).is_err() {
364 if entry_iter.advance_to_sibling() {
365 continue;
366 }
367 }
368 entry_iter.advance();
369 }
370 self.visible_entries.push(visible_worktree_entries);
371 }
372 }
373
374 fn expand_entry(&mut self, worktree_id: usize, entry_id: usize, cx: &mut ViewContext<Self>) {
375 let project = self.project.read(cx);
376 if let Some((worktree, expanded_dir_ids)) = project
377 .worktree_for_id(worktree_id)
378 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
379 {
380 let worktree = worktree.read(cx);
381
382 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
383 loop {
384 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
385 expanded_dir_ids.insert(ix, entry.id);
386 }
387
388 if let Some(parent_entry) =
389 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
390 {
391 entry = parent_entry;
392 } else {
393 break;
394 }
395 }
396 }
397 }
398 }
399
400 fn for_each_visible_entry<C: ReadModel>(
401 &self,
402 range: Range<usize>,
403 cx: &mut C,
404 mut callback: impl FnMut(ProjectEntry, EntryDetails, &mut C),
405 ) {
406 let project = self.project.read(cx);
407 let worktrees = project.worktrees().to_vec();
408 let mut ix = 0;
409 for (worktree_ix, visible_worktree_entries) in self.visible_entries.iter().enumerate() {
410 if ix >= range.end {
411 return;
412 }
413 if ix + visible_worktree_entries.len() <= range.start {
414 ix += visible_worktree_entries.len();
415 continue;
416 }
417
418 let end_ix = range.end.min(ix + visible_worktree_entries.len());
419 let worktree = &worktrees[worktree_ix];
420 let expanded_entry_ids = self
421 .expanded_dir_ids
422 .get(&worktree.id())
423 .map(Vec::as_slice)
424 .unwrap_or(&[]);
425 let snapshot = worktree.read(cx).snapshot();
426 let root_name = OsStr::new(snapshot.root_name());
427 let mut cursor = snapshot.entries(false);
428
429 for ix in visible_worktree_entries[range.start.saturating_sub(ix)..end_ix - ix]
430 .iter()
431 .copied()
432 {
433 cursor.advance_to_offset(ix);
434 if let Some(entry) = cursor.entry() {
435 let filename = entry.path.file_name().unwrap_or(root_name);
436 let details = EntryDetails {
437 filename: filename.to_string_lossy().to_string(),
438 depth: entry.path.components().count(),
439 is_dir: entry.is_dir(),
440 is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
441 is_selected: self.selection.map_or(false, |e| {
442 e.worktree_id == worktree.id() && e.entry_id == entry.id
443 }),
444 };
445 let entry = ProjectEntry {
446 worktree_id: worktree.id(),
447 entry_id: entry.id,
448 };
449 callback(entry, details, cx);
450 }
451 }
452 ix = end_ix;
453 }
454 }
455
456 fn render_entry(
457 entry: ProjectEntry,
458 details: EntryDetails,
459 theme: &theme::ProjectPanel,
460 cx: &mut ViewContext<Self>,
461 ) -> ElementBox {
462 let is_dir = details.is_dir;
463 MouseEventHandler::new::<Self, _, _, _>(
464 (entry.worktree_id, entry.entry_id),
465 cx,
466 |state, _| {
467 let style = match (details.is_selected, state.hovered) {
468 (false, false) => &theme.entry,
469 (false, true) => &theme.hovered_entry,
470 (true, false) => &theme.selected_entry,
471 (true, true) => &theme.hovered_selected_entry,
472 };
473 Flex::row()
474 .with_child(
475 ConstrainedBox::new(
476 Align::new(
477 ConstrainedBox::new(if is_dir {
478 if details.is_expanded {
479 Svg::new("icons/disclosure-open.svg")
480 .with_color(style.icon_color)
481 .boxed()
482 } else {
483 Svg::new("icons/disclosure-closed.svg")
484 .with_color(style.icon_color)
485 .boxed()
486 }
487 } else {
488 Empty::new().boxed()
489 })
490 .with_max_width(style.icon_size)
491 .with_max_height(style.icon_size)
492 .boxed(),
493 )
494 .boxed(),
495 )
496 .with_width(style.icon_size)
497 .boxed(),
498 )
499 .with_child(
500 Label::new(details.filename, style.text.clone())
501 .contained()
502 .with_margin_left(style.icon_spacing)
503 .aligned()
504 .left()
505 .boxed(),
506 )
507 .constrained()
508 .with_height(theme.entry.height)
509 .contained()
510 .with_style(style.container)
511 .with_padding_left(theme.container.padding.left + details.depth as f32 * 20.)
512 .boxed()
513 },
514 )
515 .on_click(move |cx| {
516 if is_dir {
517 cx.dispatch_action(ToggleExpanded(entry))
518 } else {
519 cx.dispatch_action(Open(entry))
520 }
521 })
522 .with_cursor_style(CursorStyle::PointingHand)
523 .boxed()
524 }
525}
526
527impl View for ProjectPanel {
528 fn ui_name() -> &'static str {
529 "ProjectPanel"
530 }
531
532 fn render(&mut self, _: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
533 let settings = self.settings.clone();
534 let mut container_style = settings.borrow().theme.project_panel.container;
535 let padding = std::mem::take(&mut container_style.padding);
536 let handle = self.handle.clone();
537 UniformList::new(
538 self.list.clone(),
539 self.visible_entries
540 .iter()
541 .map(|worktree_entries| worktree_entries.len())
542 .sum(),
543 move |range, items, cx| {
544 let theme = &settings.borrow().theme.project_panel;
545 let this = handle.upgrade(cx).unwrap();
546 this.update(cx.app, |this, cx| {
547 this.for_each_visible_entry(range.clone(), cx, |entry, details, cx| {
548 items.push(Self::render_entry(entry, details, theme, cx));
549 });
550 })
551 },
552 )
553 .with_padding_top(padding.top)
554 .with_padding_bottom(padding.bottom)
555 .contained()
556 .with_style(container_style)
557 .boxed()
558 }
559
560 fn keymap_context(&self, _: &AppContext) -> keymap::Context {
561 let mut cx = Self::default_keymap_context();
562 cx.set.insert("menu".into());
563 cx
564 }
565}
566
567impl Entity for ProjectPanel {
568 type Event = Event;
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use gpui::{TestAppContext, ViewHandle};
575 use serde_json::json;
576 use std::{collections::HashSet, path::Path};
577 use workspace::WorkspaceParams;
578
579 #[gpui::test]
580 async fn test_visible_list(mut cx: gpui::TestAppContext) {
581 let params = cx.update(WorkspaceParams::test);
582 let settings = params.settings.clone();
583 let fs = params.fs.as_fake();
584 fs.insert_tree(
585 "/root1",
586 json!({
587 ".dockerignore": "",
588 ".git": {
589 "HEAD": "",
590 },
591 "a": {
592 "0": { "q": "", "r": "", "s": "" },
593 "1": { "t": "", "u": "" },
594 "2": { "v": "", "w": "", "x": "", "y": "" },
595 },
596 "b": {
597 "3": { "Q": "" },
598 "4": { "R": "", "S": "", "T": "", "U": "" },
599 },
600 "c": {
601 "5": {},
602 "6": { "V": "", "W": "" },
603 "7": { "X": "" },
604 "8": { "Y": {}, "Z": "" }
605 }
606 }),
607 )
608 .await;
609 fs.insert_tree(
610 "/root2",
611 json!({
612 "d": {
613 "9": ""
614 },
615 "e": {}
616 }),
617 )
618 .await;
619
620 let project = cx.add_model(|cx| {
621 Project::new(
622 params.languages.clone(),
623 params.client.clone(),
624 params.fs.clone(),
625 cx,
626 )
627 });
628 let root1 = project
629 .update(&mut cx, |project, cx| {
630 project.add_local_worktree("/root1".as_ref(), cx)
631 })
632 .await
633 .unwrap();
634 root1
635 .read_with(&cx, |t, _| t.as_local().unwrap().scan_complete())
636 .await;
637 let root2 = project
638 .update(&mut cx, |project, cx| {
639 project.add_local_worktree("/root2".as_ref(), cx)
640 })
641 .await
642 .unwrap();
643 root2
644 .read_with(&cx, |t, _| t.as_local().unwrap().scan_complete())
645 .await;
646
647 let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
648 let panel = workspace.update(&mut cx, |_, cx| ProjectPanel::new(project, settings, cx));
649 assert_eq!(
650 visible_entry_details(&panel, 0..50, &mut cx),
651 &[
652 EntryDetails {
653 filename: "root1".to_string(),
654 depth: 0,
655 is_dir: true,
656 is_expanded: true,
657 is_selected: false,
658 },
659 EntryDetails {
660 filename: ".dockerignore".to_string(),
661 depth: 1,
662 is_dir: false,
663 is_expanded: false,
664 is_selected: false,
665 },
666 EntryDetails {
667 filename: "a".to_string(),
668 depth: 1,
669 is_dir: true,
670 is_expanded: false,
671 is_selected: false,
672 },
673 EntryDetails {
674 filename: "b".to_string(),
675 depth: 1,
676 is_dir: true,
677 is_expanded: false,
678 is_selected: false,
679 },
680 EntryDetails {
681 filename: "c".to_string(),
682 depth: 1,
683 is_dir: true,
684 is_expanded: false,
685 is_selected: false,
686 },
687 EntryDetails {
688 filename: "root2".to_string(),
689 depth: 0,
690 is_dir: true,
691 is_expanded: true,
692 is_selected: false
693 },
694 EntryDetails {
695 filename: "d".to_string(),
696 depth: 1,
697 is_dir: true,
698 is_expanded: false,
699 is_selected: false
700 },
701 EntryDetails {
702 filename: "e".to_string(),
703 depth: 1,
704 is_dir: true,
705 is_expanded: false,
706 is_selected: false
707 }
708 ],
709 );
710
711 toggle_expand_dir(&panel, "root1/b", &mut cx);
712 assert_eq!(
713 visible_entry_details(&panel, 0..50, &mut cx),
714 &[
715 EntryDetails {
716 filename: "root1".to_string(),
717 depth: 0,
718 is_dir: true,
719 is_expanded: true,
720 is_selected: false,
721 },
722 EntryDetails {
723 filename: ".dockerignore".to_string(),
724 depth: 1,
725 is_dir: false,
726 is_expanded: false,
727 is_selected: false,
728 },
729 EntryDetails {
730 filename: "a".to_string(),
731 depth: 1,
732 is_dir: true,
733 is_expanded: false,
734 is_selected: false,
735 },
736 EntryDetails {
737 filename: "b".to_string(),
738 depth: 1,
739 is_dir: true,
740 is_expanded: true,
741 is_selected: true,
742 },
743 EntryDetails {
744 filename: "3".to_string(),
745 depth: 2,
746 is_dir: true,
747 is_expanded: false,
748 is_selected: false,
749 },
750 EntryDetails {
751 filename: "4".to_string(),
752 depth: 2,
753 is_dir: true,
754 is_expanded: false,
755 is_selected: false,
756 },
757 EntryDetails {
758 filename: "c".to_string(),
759 depth: 1,
760 is_dir: true,
761 is_expanded: false,
762 is_selected: false,
763 },
764 EntryDetails {
765 filename: "root2".to_string(),
766 depth: 0,
767 is_dir: true,
768 is_expanded: true,
769 is_selected: false
770 },
771 EntryDetails {
772 filename: "d".to_string(),
773 depth: 1,
774 is_dir: true,
775 is_expanded: false,
776 is_selected: false
777 },
778 EntryDetails {
779 filename: "e".to_string(),
780 depth: 1,
781 is_dir: true,
782 is_expanded: false,
783 is_selected: false
784 }
785 ]
786 );
787
788 assert_eq!(
789 visible_entry_details(&panel, 5..8, &mut cx),
790 [
791 EntryDetails {
792 filename: "4".to_string(),
793 depth: 2,
794 is_dir: true,
795 is_expanded: false,
796 is_selected: false
797 },
798 EntryDetails {
799 filename: "c".to_string(),
800 depth: 1,
801 is_dir: true,
802 is_expanded: false,
803 is_selected: false
804 },
805 EntryDetails {
806 filename: "root2".to_string(),
807 depth: 0,
808 is_dir: true,
809 is_expanded: true,
810 is_selected: false
811 }
812 ]
813 );
814
815 fn toggle_expand_dir(
816 panel: &ViewHandle<ProjectPanel>,
817 path: impl AsRef<Path>,
818 cx: &mut TestAppContext,
819 ) {
820 let path = path.as_ref();
821 panel.update(cx, |panel, cx| {
822 for worktree in panel.project.read(cx).worktrees() {
823 let worktree = worktree.read(cx);
824 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
825 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
826 panel.toggle_expanded(
827 &ToggleExpanded(ProjectEntry {
828 worktree_id: worktree.id(),
829 entry_id,
830 }),
831 cx,
832 );
833 return;
834 }
835 }
836 panic!("no worktree for path {:?}", path);
837 });
838 }
839
840 fn visible_entry_details(
841 panel: &ViewHandle<ProjectPanel>,
842 range: Range<usize>,
843 cx: &mut TestAppContext,
844 ) -> Vec<EntryDetails> {
845 let mut result = Vec::new();
846 let mut project_entries = HashSet::new();
847 panel.update(cx, |panel, cx| {
848 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
849 assert!(
850 project_entries.insert(project_entry),
851 "duplicate project entry {:?} {:?}",
852 project_entry,
853 details
854 );
855 result.push(details);
856 });
857 });
858
859 result
860 }
861 }
862}