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