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