1use gpui::{
2 actions,
3 elements::{
4 Align, ConstrainedBox, Empty, Flex, Label, MouseEventHandler, ParentElement, ScrollTarget,
5 Svg, UniformList, UniformListState,
6 },
7 impl_internal_actions, keymap,
8 platform::CursorStyle,
9 AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, View, ViewContext,
10 ViewHandle, WeakViewHandle,
11};
12use project::{Entry, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
13use settings::Settings;
14use std::{
15 cmp::Ordering,
16 collections::{hash_map, HashMap},
17 ffi::OsStr,
18 ops::Range,
19};
20use unicase::UniCase;
21use workspace::{
22 menu::{SelectNext, SelectPrev},
23 Workspace,
24};
25
26pub struct ProjectPanel {
27 project: ModelHandle<Project>,
28 list: UniformListState,
29 visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
30 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
31 selection: Option<Selection>,
32 handle: WeakViewHandle<Self>,
33}
34
35#[derive(Copy, Clone)]
36struct Selection {
37 worktree_id: WorktreeId,
38 entry_id: ProjectEntryId,
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
50#[derive(Clone)]
51pub struct ToggleExpanded(pub ProjectEntryId);
52
53#[derive(Clone)]
54pub struct Open(pub ProjectEntryId);
55
56actions!(project_panel, [ExpandSelectedEntry, CollapseSelectedEntry]);
57impl_internal_actions!(project_panel, [Open, ToggleExpanded]);
58
59pub fn init(cx: &mut MutableAppContext) {
60 cx.add_action(ProjectPanel::expand_selected_entry);
61 cx.add_action(ProjectPanel::collapse_selected_entry);
62 cx.add_action(ProjectPanel::toggle_expanded);
63 cx.add_action(ProjectPanel::select_prev);
64 cx.add_action(ProjectPanel::select_next);
65 cx.add_action(ProjectPanel::open_entry);
66}
67
68pub enum Event {
69 OpenedEntry(ProjectEntryId),
70}
71
72impl ProjectPanel {
73 pub fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
74 let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
75 cx.observe(&project, |this, _, cx| {
76 this.update_visible_entries(None, cx);
77 cx.notify();
78 })
79 .detach();
80 cx.subscribe(&project, |this, project, event, cx| match event {
81 project::Event::ActiveEntryChanged(Some(entry_id)) => {
82 if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
83 {
84 this.expand_entry(worktree_id, *entry_id, cx);
85 this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
86 this.autoscroll();
87 cx.notify();
88 }
89 }
90 project::Event::WorktreeRemoved(id) => {
91 this.expanded_dir_ids.remove(id);
92 this.update_visible_entries(None, cx);
93 cx.notify();
94 }
95 _ => {}
96 })
97 .detach();
98
99 let mut this = Self {
100 project: project.clone(),
101 list: Default::default(),
102 visible_entries: Default::default(),
103 expanded_dir_ids: Default::default(),
104 selection: None,
105 handle: cx.weak_handle(),
106 };
107 this.update_visible_entries(None, cx);
108 this
109 });
110 cx.subscribe(&project_panel, move |workspace, _, event, cx| match event {
111 &Event::OpenedEntry(entry_id) => {
112 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
113 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
114 workspace
115 .open_path(
116 ProjectPath {
117 worktree_id: worktree.read(cx).id(),
118 path: entry.path.clone(),
119 },
120 cx,
121 )
122 .detach_and_log_err(cx);
123 }
124 }
125 }
126 })
127 .detach();
128
129 project_panel
130 }
131
132 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
133 if let Some((worktree, entry)) = self.selected_entry(cx) {
134 let expanded_dir_ids =
135 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
136 expanded_dir_ids
137 } else {
138 return;
139 };
140
141 if entry.is_dir() {
142 match expanded_dir_ids.binary_search(&entry.id) {
143 Ok(_) => self.select_next(&SelectNext, cx),
144 Err(ix) => {
145 expanded_dir_ids.insert(ix, entry.id);
146 self.update_visible_entries(None, cx);
147 cx.notify();
148 }
149 }
150 } else {
151 let event = Event::OpenedEntry(entry.id);
152 cx.emit(event);
153 }
154 }
155 }
156
157 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
158 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
159 let expanded_dir_ids =
160 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
161 expanded_dir_ids
162 } else {
163 return;
164 };
165
166 loop {
167 match expanded_dir_ids.binary_search(&entry.id) {
168 Ok(ix) => {
169 expanded_dir_ids.remove(ix);
170 self.update_visible_entries(Some((worktree.id(), entry.id)), cx);
171 cx.notify();
172 break;
173 }
174 Err(_) => {
175 if let Some(parent_entry) =
176 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
177 {
178 entry = parent_entry;
179 } else {
180 break;
181 }
182 }
183 }
184 }
185 }
186 }
187
188 fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
189 let entry_id = action.0;
190 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
191 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
192 match expanded_dir_ids.binary_search(&entry_id) {
193 Ok(ix) => {
194 expanded_dir_ids.remove(ix);
195 }
196 Err(ix) => {
197 expanded_dir_ids.insert(ix, entry_id);
198 }
199 }
200 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
201 cx.focus_self();
202 }
203 }
204 }
205
206 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
207 if let Some(selection) = self.selection {
208 let (mut worktree_ix, mut entry_ix, _) =
209 self.index_for_selection(selection).unwrap_or_default();
210 if entry_ix > 0 {
211 entry_ix -= 1;
212 } else {
213 if worktree_ix > 0 {
214 worktree_ix -= 1;
215 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
216 } else {
217 return;
218 }
219 }
220
221 let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
222 self.selection = Some(Selection {
223 worktree_id: *worktree_id,
224 entry_id: worktree_entries[entry_ix].id,
225 });
226 self.autoscroll();
227 cx.notify();
228 } else {
229 self.select_first(cx);
230 }
231 }
232
233 fn open_entry(&mut self, action: &Open, cx: &mut ViewContext<Self>) {
234 cx.emit(Event::OpenedEntry(action.0));
235 }
236
237 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
238 if let Some(selection) = self.selection {
239 let (mut worktree_ix, mut entry_ix, _) =
240 self.index_for_selection(selection).unwrap_or_default();
241 if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
242 if entry_ix + 1 < worktree_entries.len() {
243 entry_ix += 1;
244 } else {
245 worktree_ix += 1;
246 entry_ix = 0;
247 }
248 }
249
250 if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
251 if let Some(entry) = worktree_entries.get(entry_ix) {
252 self.selection = Some(Selection {
253 worktree_id: *worktree_id,
254 entry_id: entry.id,
255 });
256 self.autoscroll();
257 cx.notify();
258 }
259 }
260 } else {
261 self.select_first(cx);
262 }
263 }
264
265 fn select_first(&mut self, cx: &mut ViewContext<Self>) {
266 let worktree = self
267 .visible_entries
268 .first()
269 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
270 if let Some(worktree) = worktree {
271 let worktree = worktree.read(cx);
272 let worktree_id = worktree.id();
273 if let Some(root_entry) = worktree.root_entry() {
274 self.selection = Some(Selection {
275 worktree_id,
276 entry_id: root_entry.id,
277 });
278 self.autoscroll();
279 cx.notify();
280 }
281 }
282 }
283
284 fn autoscroll(&mut self) {
285 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
286 self.list.scroll_to(ScrollTarget::Show(index));
287 }
288 }
289
290 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
291 let mut worktree_index = 0;
292 let mut entry_index = 0;
293 let mut visible_entries_index = 0;
294 for (worktree_id, worktree_entries) in &self.visible_entries {
295 if *worktree_id == selection.worktree_id {
296 for entry in worktree_entries {
297 if entry.id == selection.entry_id {
298 return Some((worktree_index, entry_index, visible_entries_index));
299 } else {
300 visible_entries_index += 1;
301 entry_index += 1;
302 }
303 }
304 break;
305 } else {
306 visible_entries_index += worktree_entries.len();
307 }
308 worktree_index += 1;
309 }
310 None
311 }
312
313 fn selected_entry<'a>(&self, cx: &'a AppContext) -> Option<(&'a Worktree, &'a project::Entry)> {
314 let selection = self.selection?;
315 let project = self.project.read(cx);
316 let worktree = project.worktree_for_id(selection.worktree_id, cx)?.read(cx);
317 Some((worktree, worktree.entry_for_id(selection.entry_id)?))
318 }
319
320 fn update_visible_entries(
321 &mut self,
322 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
323 cx: &mut ViewContext<Self>,
324 ) {
325 let worktrees = self
326 .project
327 .read(cx)
328 .worktrees(cx)
329 .filter(|worktree| worktree.read(cx).is_visible());
330 self.visible_entries.clear();
331
332 for worktree in worktrees {
333 let snapshot = worktree.read(cx).snapshot();
334 let worktree_id = snapshot.id();
335
336 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
337 hash_map::Entry::Occupied(e) => e.into_mut(),
338 hash_map::Entry::Vacant(e) => {
339 // The first time a worktree's root entry becomes available,
340 // mark that root entry as expanded.
341 if let Some(entry) = snapshot.root_entry() {
342 e.insert(vec![entry.id]).as_slice()
343 } else {
344 &[]
345 }
346 }
347 };
348
349 let mut visible_worktree_entries = Vec::new();
350 let mut entry_iter = snapshot.entries(false);
351 while let Some(item) = entry_iter.entry() {
352 visible_worktree_entries.push(item.clone());
353 if expanded_dir_ids.binary_search(&item.id).is_err() {
354 if entry_iter.advance_to_sibling() {
355 continue;
356 }
357 }
358 entry_iter.advance();
359 }
360 visible_worktree_entries.sort_by(|entry_a, entry_b| {
361 let mut components_a = entry_a.path.components().peekable();
362 let mut components_b = entry_b.path.components().peekable();
363 loop {
364 match (components_a.next(), components_b.next()) {
365 (Some(component_a), Some(component_b)) => {
366 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
367 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
368 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
369 let name_a =
370 UniCase::new(component_a.as_os_str().to_string_lossy());
371 let name_b =
372 UniCase::new(component_b.as_os_str().to_string_lossy());
373 name_a.cmp(&name_b)
374 });
375 if !ordering.is_eq() {
376 return ordering;
377 }
378 }
379 (Some(_), None) => break Ordering::Greater,
380 (None, Some(_)) => break Ordering::Less,
381 (None, None) => break Ordering::Equal,
382 }
383 }
384 });
385 self.visible_entries
386 .push((worktree_id, visible_worktree_entries));
387 }
388
389 if let Some((worktree_id, entry_id)) = new_selected_entry {
390 self.selection = Some(Selection {
391 worktree_id,
392 entry_id,
393 });
394 }
395 }
396
397 fn expand_entry(
398 &mut self,
399 worktree_id: WorktreeId,
400 entry_id: ProjectEntryId,
401 cx: &mut ViewContext<Self>,
402 ) {
403 let project = self.project.read(cx);
404 if let Some((worktree, expanded_dir_ids)) = project
405 .worktree_for_id(worktree_id, cx)
406 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
407 {
408 let worktree = worktree.read(cx);
409
410 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
411 loop {
412 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
413 expanded_dir_ids.insert(ix, entry.id);
414 }
415
416 if let Some(parent_entry) =
417 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
418 {
419 entry = parent_entry;
420 } else {
421 break;
422 }
423 }
424 }
425 }
426 }
427
428 fn for_each_visible_entry(
429 &self,
430 range: Range<usize>,
431 cx: &mut ViewContext<ProjectPanel>,
432 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
433 ) {
434 let mut ix = 0;
435 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
436 if ix >= range.end {
437 return;
438 }
439 if ix + visible_worktree_entries.len() <= range.start {
440 ix += visible_worktree_entries.len();
441 continue;
442 }
443
444 let end_ix = range.end.min(ix + visible_worktree_entries.len());
445 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
446 let snapshot = worktree.read(cx).snapshot();
447 let expanded_entry_ids = self
448 .expanded_dir_ids
449 .get(&snapshot.id())
450 .map(Vec::as_slice)
451 .unwrap_or(&[]);
452 let root_name = OsStr::new(snapshot.root_name());
453 for entry in &visible_worktree_entries[range.start.saturating_sub(ix)..end_ix - ix]
454 {
455 let filename = entry.path.file_name().unwrap_or(root_name);
456 let details = EntryDetails {
457 filename: filename.to_string_lossy().to_string(),
458 depth: entry.path.components().count(),
459 is_dir: entry.is_dir(),
460 is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
461 is_selected: self.selection.map_or(false, |e| {
462 e.worktree_id == snapshot.id() && e.entry_id == entry.id
463 }),
464 };
465 callback(entry.id, details, cx);
466 }
467 }
468 ix = end_ix;
469 }
470 }
471
472 fn render_entry(
473 entry_id: ProjectEntryId,
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_id.to_usize(), 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_id))
530 } else {
531 cx.dispatch_action(Open(entry_id))
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, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
545 let theme = &cx.global::<Settings>().theme.project_panel;
546 let mut container_style = theme.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 = cx.global::<Settings>().theme.clone();
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.project_panel, 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 fs = params.fs.as_fake();
597 fs.insert_tree(
598 "/root1",
599 json!({
600 ".dockerignore": "",
601 ".git": {
602 "HEAD": "",
603 },
604 "a": {
605 "0": { "q": "", "r": "", "s": "" },
606 "1": { "t": "", "u": "" },
607 "2": { "v": "", "w": "", "x": "", "y": "" },
608 },
609 "b": {
610 "3": { "Q": "" },
611 "4": { "R": "", "S": "", "T": "", "U": "" },
612 },
613 "C": {
614 "5": {},
615 "6": { "V": "", "W": "" },
616 "7": { "X": "" },
617 "8": { "Y": {}, "Z": "" }
618 }
619 }),
620 )
621 .await;
622 fs.insert_tree(
623 "/root2",
624 json!({
625 "d": {
626 "9": ""
627 },
628 "e": {}
629 }),
630 )
631 .await;
632
633 let project = cx.update(|cx| {
634 Project::local(
635 params.client.clone(),
636 params.user_store.clone(),
637 params.languages.clone(),
638 params.fs.clone(),
639 cx,
640 )
641 });
642 let (root1, _) = project
643 .update(cx, |project, cx| {
644 project.find_or_create_local_worktree("/root1", true, cx)
645 })
646 .await
647 .unwrap();
648 root1
649 .read_with(cx, |t, _| t.as_local().unwrap().scan_complete())
650 .await;
651 let (root2, _) = project
652 .update(cx, |project, cx| {
653 project.find_or_create_local_worktree("/root2", true, cx)
654 })
655 .await
656 .unwrap();
657 root2
658 .read_with(cx, |t, _| t.as_local().unwrap().scan_complete())
659 .await;
660
661 let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
662 let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
663 assert_eq!(
664 visible_entry_details(&panel, 0..50, cx),
665 &[
666 EntryDetails {
667 filename: "root1".to_string(),
668 depth: 0,
669 is_dir: true,
670 is_expanded: true,
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: ".dockerignore".to_string(),
696 depth: 1,
697 is_dir: false,
698 is_expanded: false,
699 is_selected: false,
700 },
701 EntryDetails {
702 filename: "root2".to_string(),
703 depth: 0,
704 is_dir: true,
705 is_expanded: true,
706 is_selected: false
707 },
708 EntryDetails {
709 filename: "d".to_string(),
710 depth: 1,
711 is_dir: true,
712 is_expanded: false,
713 is_selected: false
714 },
715 EntryDetails {
716 filename: "e".to_string(),
717 depth: 1,
718 is_dir: true,
719 is_expanded: false,
720 is_selected: false
721 }
722 ],
723 );
724
725 toggle_expand_dir(&panel, "root1/b", cx);
726 assert_eq!(
727 visible_entry_details(&panel, 0..50, cx),
728 &[
729 EntryDetails {
730 filename: "root1".to_string(),
731 depth: 0,
732 is_dir: true,
733 is_expanded: true,
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: ".dockerignore".to_string(),
773 depth: 1,
774 is_dir: false,
775 is_expanded: false,
776 is_selected: false,
777 },
778 EntryDetails {
779 filename: "root2".to_string(),
780 depth: 0,
781 is_dir: true,
782 is_expanded: true,
783 is_selected: false
784 },
785 EntryDetails {
786 filename: "d".to_string(),
787 depth: 1,
788 is_dir: true,
789 is_expanded: false,
790 is_selected: false
791 },
792 EntryDetails {
793 filename: "e".to_string(),
794 depth: 1,
795 is_dir: true,
796 is_expanded: false,
797 is_selected: false
798 }
799 ]
800 );
801
802 assert_eq!(
803 visible_entry_details(&panel, 5..8, cx),
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: ".dockerignore".to_string(),
814 depth: 1,
815 is_dir: false,
816 is_expanded: false,
817 is_selected: false
818 },
819 EntryDetails {
820 filename: "root2".to_string(),
821 depth: 0,
822 is_dir: true,
823 is_expanded: true,
824 is_selected: false
825 }
826 ]
827 );
828
829 fn toggle_expand_dir(
830 panel: &ViewHandle<ProjectPanel>,
831 path: impl AsRef<Path>,
832 cx: &mut TestAppContext,
833 ) {
834 let path = path.as_ref();
835 panel.update(cx, |panel, cx| {
836 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
837 let worktree = worktree.read(cx);
838 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
839 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
840 panel.toggle_expanded(&ToggleExpanded(entry_id), cx);
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}