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