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, _, _, _>((cx.view_id(), 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(mut cx: gpui::TestAppContext) {
593 let params = cx.update(WorkspaceParams::test);
594 let settings = params.settings.clone();
595 let fs = params.fs.as_fake();
596 fs.insert_tree(
597 "/root1",
598 json!({
599 ".dockerignore": "",
600 ".git": {
601 "HEAD": "",
602 },
603 "a": {
604 "0": { "q": "", "r": "", "s": "" },
605 "1": { "t": "", "u": "" },
606 "2": { "v": "", "w": "", "x": "", "y": "" },
607 },
608 "b": {
609 "3": { "Q": "" },
610 "4": { "R": "", "S": "", "T": "", "U": "" },
611 },
612 "c": {
613 "5": {},
614 "6": { "V": "", "W": "" },
615 "7": { "X": "" },
616 "8": { "Y": {}, "Z": "" }
617 }
618 }),
619 )
620 .await;
621 fs.insert_tree(
622 "/root2",
623 json!({
624 "d": {
625 "9": ""
626 },
627 "e": {}
628 }),
629 )
630 .await;
631
632 let project = cx.update(|cx| {
633 Project::local(
634 params.client.clone(),
635 params.user_store.clone(),
636 params.languages.clone(),
637 params.fs.clone(),
638 cx,
639 )
640 });
641 let (root1, _) = project
642 .update(&mut cx, |project, cx| {
643 project.find_or_create_local_worktree("/root1", false, cx)
644 })
645 .await
646 .unwrap();
647 root1
648 .read_with(&cx, |t, _| t.as_local().unwrap().scan_complete())
649 .await;
650 let (root2, _) = project
651 .update(&mut cx, |project, cx| {
652 project.find_or_create_local_worktree("/root2", false, cx)
653 })
654 .await
655 .unwrap();
656 root2
657 .read_with(&cx, |t, _| t.as_local().unwrap().scan_complete())
658 .await;
659
660 let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
661 let panel = workspace.update(&mut cx, |_, cx| ProjectPanel::new(project, settings, cx));
662 assert_eq!(
663 visible_entry_details(&panel, 0..50, &mut cx),
664 &[
665 EntryDetails {
666 filename: "root1".to_string(),
667 depth: 0,
668 is_dir: true,
669 is_expanded: true,
670 is_selected: false,
671 },
672 EntryDetails {
673 filename: ".dockerignore".to_string(),
674 depth: 1,
675 is_dir: false,
676 is_expanded: false,
677 is_selected: false,
678 },
679 EntryDetails {
680 filename: "a".to_string(),
681 depth: 1,
682 is_dir: true,
683 is_expanded: false,
684 is_selected: false,
685 },
686 EntryDetails {
687 filename: "b".to_string(),
688 depth: 1,
689 is_dir: true,
690 is_expanded: false,
691 is_selected: false,
692 },
693 EntryDetails {
694 filename: "c".to_string(),
695 depth: 1,
696 is_dir: true,
697 is_expanded: false,
698 is_selected: false,
699 },
700 EntryDetails {
701 filename: "root2".to_string(),
702 depth: 0,
703 is_dir: true,
704 is_expanded: true,
705 is_selected: false
706 },
707 EntryDetails {
708 filename: "d".to_string(),
709 depth: 1,
710 is_dir: true,
711 is_expanded: false,
712 is_selected: false
713 },
714 EntryDetails {
715 filename: "e".to_string(),
716 depth: 1,
717 is_dir: true,
718 is_expanded: false,
719 is_selected: false
720 }
721 ],
722 );
723
724 toggle_expand_dir(&panel, "root1/b", &mut cx);
725 assert_eq!(
726 visible_entry_details(&panel, 0..50, &mut cx),
727 &[
728 EntryDetails {
729 filename: "root1".to_string(),
730 depth: 0,
731 is_dir: true,
732 is_expanded: true,
733 is_selected: false,
734 },
735 EntryDetails {
736 filename: ".dockerignore".to_string(),
737 depth: 1,
738 is_dir: false,
739 is_expanded: false,
740 is_selected: false,
741 },
742 EntryDetails {
743 filename: "a".to_string(),
744 depth: 1,
745 is_dir: true,
746 is_expanded: false,
747 is_selected: false,
748 },
749 EntryDetails {
750 filename: "b".to_string(),
751 depth: 1,
752 is_dir: true,
753 is_expanded: true,
754 is_selected: true,
755 },
756 EntryDetails {
757 filename: "3".to_string(),
758 depth: 2,
759 is_dir: true,
760 is_expanded: false,
761 is_selected: false,
762 },
763 EntryDetails {
764 filename: "4".to_string(),
765 depth: 2,
766 is_dir: true,
767 is_expanded: false,
768 is_selected: false,
769 },
770 EntryDetails {
771 filename: "c".to_string(),
772 depth: 1,
773 is_dir: true,
774 is_expanded: false,
775 is_selected: false,
776 },
777 EntryDetails {
778 filename: "root2".to_string(),
779 depth: 0,
780 is_dir: true,
781 is_expanded: true,
782 is_selected: false
783 },
784 EntryDetails {
785 filename: "d".to_string(),
786 depth: 1,
787 is_dir: true,
788 is_expanded: false,
789 is_selected: false
790 },
791 EntryDetails {
792 filename: "e".to_string(),
793 depth: 1,
794 is_dir: true,
795 is_expanded: false,
796 is_selected: false
797 }
798 ]
799 );
800
801 assert_eq!(
802 visible_entry_details(&panel, 5..8, &mut cx),
803 [
804 EntryDetails {
805 filename: "4".to_string(),
806 depth: 2,
807 is_dir: true,
808 is_expanded: false,
809 is_selected: false
810 },
811 EntryDetails {
812 filename: "c".to_string(),
813 depth: 1,
814 is_dir: true,
815 is_expanded: false,
816 is_selected: false
817 },
818 EntryDetails {
819 filename: "root2".to_string(),
820 depth: 0,
821 is_dir: true,
822 is_expanded: true,
823 is_selected: false
824 }
825 ]
826 );
827
828 fn toggle_expand_dir(
829 panel: &ViewHandle<ProjectPanel>,
830 path: impl AsRef<Path>,
831 cx: &mut TestAppContext,
832 ) {
833 let path = path.as_ref();
834 panel.update(cx, |panel, cx| {
835 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
836 let worktree = worktree.read(cx);
837 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
838 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
839 panel.toggle_expanded(
840 &ToggleExpanded(ProjectEntry {
841 worktree_id: worktree.id(),
842 entry_id,
843 }),
844 cx,
845 );
846 return;
847 }
848 }
849 panic!("no worktree for path {:?}", path);
850 });
851 }
852
853 fn visible_entry_details(
854 panel: &ViewHandle<ProjectPanel>,
855 range: Range<usize>,
856 cx: &mut TestAppContext,
857 ) -> Vec<EntryDetails> {
858 let mut result = Vec::new();
859 let mut project_entries = HashSet::new();
860 panel.update(cx, |panel, cx| {
861 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
862 assert!(
863 project_entries.insert(project_entry),
864 "duplicate project entry {:?} {:?}",
865 project_entry,
866 details
867 );
868 result.push(details);
869 });
870 });
871
872 result
873 }
874 }
875}