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, _, _, _>(
480 (entry.worktree_id.to_usize(), entry.entry_id),
481 cx,
482 |state, _| {
483 let style = match (details.is_selected, state.hovered) {
484 (false, false) => &theme.entry,
485 (false, true) => &theme.hovered_entry,
486 (true, false) => &theme.selected_entry,
487 (true, true) => &theme.hovered_selected_entry,
488 };
489 Flex::row()
490 .with_child(
491 ConstrainedBox::new(
492 Align::new(
493 ConstrainedBox::new(if is_dir {
494 if details.is_expanded {
495 Svg::new("icons/disclosure-open.svg")
496 .with_color(style.icon_color)
497 .boxed()
498 } else {
499 Svg::new("icons/disclosure-closed.svg")
500 .with_color(style.icon_color)
501 .boxed()
502 }
503 } else {
504 Empty::new().boxed()
505 })
506 .with_max_width(style.icon_size)
507 .with_max_height(style.icon_size)
508 .boxed(),
509 )
510 .boxed(),
511 )
512 .with_width(style.icon_size)
513 .boxed(),
514 )
515 .with_child(
516 Label::new(details.filename, style.text.clone())
517 .contained()
518 .with_margin_left(style.icon_spacing)
519 .aligned()
520 .left()
521 .boxed(),
522 )
523 .constrained()
524 .with_height(theme.entry.height)
525 .contained()
526 .with_style(style.container)
527 .with_padding_left(theme.container.padding.left + details.depth as f32 * 20.)
528 .boxed()
529 },
530 )
531 .on_click(move |cx| {
532 if is_dir {
533 cx.dispatch_action(ToggleExpanded(entry))
534 } else {
535 cx.dispatch_action(Open(entry))
536 }
537 })
538 .with_cursor_style(CursorStyle::PointingHand)
539 .boxed()
540 }
541}
542
543impl View for ProjectPanel {
544 fn ui_name() -> &'static str {
545 "ProjectPanel"
546 }
547
548 fn render(&mut self, _: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
549 let settings = self.settings.clone();
550 let mut container_style = settings.borrow().theme.project_panel.container;
551 let padding = std::mem::take(&mut container_style.padding);
552 let handle = self.handle.clone();
553 UniformList::new(
554 self.list.clone(),
555 self.visible_entries
556 .iter()
557 .map(|(_, worktree_entries)| worktree_entries.len())
558 .sum(),
559 move |range, items, cx| {
560 let theme = &settings.borrow().theme.project_panel;
561 let this = handle.upgrade(cx).unwrap();
562 this.update(cx.app, |this, cx| {
563 this.for_each_visible_entry(range.clone(), cx, |entry, details, cx| {
564 items.push(Self::render_entry(entry, details, theme, cx));
565 });
566 })
567 },
568 )
569 .with_padding_top(padding.top)
570 .with_padding_bottom(padding.bottom)
571 .contained()
572 .with_style(container_style)
573 .boxed()
574 }
575
576 fn keymap_context(&self, _: &AppContext) -> keymap::Context {
577 let mut cx = Self::default_keymap_context();
578 cx.set.insert("menu".into());
579 cx
580 }
581}
582
583impl Entity for ProjectPanel {
584 type Event = Event;
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590 use gpui::{TestAppContext, ViewHandle};
591 use serde_json::json;
592 use std::{collections::HashSet, path::Path};
593 use workspace::WorkspaceParams;
594
595 #[gpui::test]
596 async fn test_visible_list(mut cx: gpui::TestAppContext) {
597 let params = cx.update(WorkspaceParams::test);
598 let settings = params.settings.clone();
599 let fs = params.fs.as_fake();
600 fs.insert_tree(
601 "/root1",
602 json!({
603 ".dockerignore": "",
604 ".git": {
605 "HEAD": "",
606 },
607 "a": {
608 "0": { "q": "", "r": "", "s": "" },
609 "1": { "t": "", "u": "" },
610 "2": { "v": "", "w": "", "x": "", "y": "" },
611 },
612 "b": {
613 "3": { "Q": "" },
614 "4": { "R": "", "S": "", "T": "", "U": "" },
615 },
616 "c": {
617 "5": {},
618 "6": { "V": "", "W": "" },
619 "7": { "X": "" },
620 "8": { "Y": {}, "Z": "" }
621 }
622 }),
623 )
624 .await;
625 fs.insert_tree(
626 "/root2",
627 json!({
628 "d": {
629 "9": ""
630 },
631 "e": {}
632 }),
633 )
634 .await;
635
636 let project = cx.update(|cx| {
637 Project::local(
638 params.client.clone(),
639 params.user_store.clone(),
640 params.languages.clone(),
641 params.fs.clone(),
642 cx,
643 )
644 });
645 let (root1, _) = project
646 .update(&mut cx, |project, cx| {
647 project.find_or_create_local_worktree("/root1", false, cx)
648 })
649 .await
650 .unwrap();
651 root1
652 .read_with(&cx, |t, _| t.as_local().unwrap().scan_complete())
653 .await;
654 let (root2, _) = project
655 .update(&mut cx, |project, cx| {
656 project.find_or_create_local_worktree("/root2", false, cx)
657 })
658 .await
659 .unwrap();
660 root2
661 .read_with(&cx, |t, _| t.as_local().unwrap().scan_complete())
662 .await;
663
664 let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
665 let panel = workspace.update(&mut cx, |_, cx| ProjectPanel::new(project, settings, cx));
666 assert_eq!(
667 visible_entry_details(&panel, 0..50, &mut cx),
668 &[
669 EntryDetails {
670 filename: "root1".to_string(),
671 depth: 0,
672 is_dir: true,
673 is_expanded: true,
674 is_selected: false,
675 },
676 EntryDetails {
677 filename: ".dockerignore".to_string(),
678 depth: 1,
679 is_dir: false,
680 is_expanded: false,
681 is_selected: false,
682 },
683 EntryDetails {
684 filename: "a".to_string(),
685 depth: 1,
686 is_dir: true,
687 is_expanded: false,
688 is_selected: false,
689 },
690 EntryDetails {
691 filename: "b".to_string(),
692 depth: 1,
693 is_dir: true,
694 is_expanded: false,
695 is_selected: false,
696 },
697 EntryDetails {
698 filename: "c".to_string(),
699 depth: 1,
700 is_dir: true,
701 is_expanded: false,
702 is_selected: false,
703 },
704 EntryDetails {
705 filename: "root2".to_string(),
706 depth: 0,
707 is_dir: true,
708 is_expanded: true,
709 is_selected: false
710 },
711 EntryDetails {
712 filename: "d".to_string(),
713 depth: 1,
714 is_dir: true,
715 is_expanded: false,
716 is_selected: false
717 },
718 EntryDetails {
719 filename: "e".to_string(),
720 depth: 1,
721 is_dir: true,
722 is_expanded: false,
723 is_selected: false
724 }
725 ],
726 );
727
728 toggle_expand_dir(&panel, "root1/b", &mut cx);
729 assert_eq!(
730 visible_entry_details(&panel, 0..50, &mut cx),
731 &[
732 EntryDetails {
733 filename: "root1".to_string(),
734 depth: 0,
735 is_dir: true,
736 is_expanded: true,
737 is_selected: false,
738 },
739 EntryDetails {
740 filename: ".dockerignore".to_string(),
741 depth: 1,
742 is_dir: false,
743 is_expanded: false,
744 is_selected: false,
745 },
746 EntryDetails {
747 filename: "a".to_string(),
748 depth: 1,
749 is_dir: true,
750 is_expanded: false,
751 is_selected: false,
752 },
753 EntryDetails {
754 filename: "b".to_string(),
755 depth: 1,
756 is_dir: true,
757 is_expanded: true,
758 is_selected: true,
759 },
760 EntryDetails {
761 filename: "3".to_string(),
762 depth: 2,
763 is_dir: true,
764 is_expanded: false,
765 is_selected: false,
766 },
767 EntryDetails {
768 filename: "4".to_string(),
769 depth: 2,
770 is_dir: true,
771 is_expanded: false,
772 is_selected: false,
773 },
774 EntryDetails {
775 filename: "c".to_string(),
776 depth: 1,
777 is_dir: true,
778 is_expanded: false,
779 is_selected: false,
780 },
781 EntryDetails {
782 filename: "root2".to_string(),
783 depth: 0,
784 is_dir: true,
785 is_expanded: true,
786 is_selected: false
787 },
788 EntryDetails {
789 filename: "d".to_string(),
790 depth: 1,
791 is_dir: true,
792 is_expanded: false,
793 is_selected: false
794 },
795 EntryDetails {
796 filename: "e".to_string(),
797 depth: 1,
798 is_dir: true,
799 is_expanded: false,
800 is_selected: false
801 }
802 ]
803 );
804
805 assert_eq!(
806 visible_entry_details(&panel, 5..8, &mut cx),
807 [
808 EntryDetails {
809 filename: "4".to_string(),
810 depth: 2,
811 is_dir: true,
812 is_expanded: false,
813 is_selected: false
814 },
815 EntryDetails {
816 filename: "c".to_string(),
817 depth: 1,
818 is_dir: true,
819 is_expanded: false,
820 is_selected: false
821 },
822 EntryDetails {
823 filename: "root2".to_string(),
824 depth: 0,
825 is_dir: true,
826 is_expanded: true,
827 is_selected: false
828 }
829 ]
830 );
831
832 fn toggle_expand_dir(
833 panel: &ViewHandle<ProjectPanel>,
834 path: impl AsRef<Path>,
835 cx: &mut TestAppContext,
836 ) {
837 let path = path.as_ref();
838 panel.update(cx, |panel, cx| {
839 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
840 let worktree = worktree.read(cx);
841 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
842 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
843 panel.toggle_expanded(
844 &ToggleExpanded(ProjectEntry {
845 worktree_id: worktree.id(),
846 entry_id,
847 }),
848 cx,
849 );
850 return;
851 }
852 }
853 panic!("no worktree for path {:?}", path);
854 });
855 }
856
857 fn visible_entry_details(
858 panel: &ViewHandle<ProjectPanel>,
859 range: Range<usize>,
860 cx: &mut TestAppContext,
861 ) -> Vec<EntryDetails> {
862 let mut result = Vec::new();
863 let mut project_entries = HashSet::new();
864 panel.update(cx, |panel, cx| {
865 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
866 assert!(
867 project_entries.insert(project_entry),
868 "duplicate project entry {:?} {:?}",
869 project_entry,
870 details
871 );
872 result.push(details);
873 });
874 });
875
876 result
877 }
878 }
879}