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