1pub mod items;
2
3use anyhow::Result;
4use collections::{BTreeSet, HashSet};
5use editor::{
6 diagnostic_block_renderer,
7 display_map::{BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock},
8 highlight_diagnostic_message,
9 scroll::autoscroll::Autoscroll,
10 Editor, ExcerptId, ExcerptRange, MultiBuffer, ToOffset,
11};
12use gpui::{
13 actions, elements::*, fonts::TextStyle, impl_internal_actions, serde_json, AnyViewHandle,
14 AppContext, Entity, ModelHandle, RenderContext, Task, View, ViewContext, ViewHandle,
15 WeakViewHandle,
16};
17use language::{
18 Anchor, Bias, Buffer, Diagnostic, DiagnosticEntry, DiagnosticSeverity, Point, Selection,
19 SelectionGoal,
20};
21use lsp::LanguageServerId;
22use project::{DiagnosticSummary, Project, ProjectPath};
23use serde_json::json;
24use settings::Settings;
25use smallvec::SmallVec;
26use std::{
27 any::{Any, TypeId},
28 borrow::Cow,
29 cmp::Ordering,
30 ops::Range,
31 path::PathBuf,
32 sync::Arc,
33};
34use util::TryFutureExt;
35use workspace::{
36 item::{Item, ItemEvent, ItemHandle},
37 ItemNavHistory, Pane, ToolbarItemLocation, Workspace,
38};
39
40actions!(diagnostics, [Deploy]);
41
42impl_internal_actions!(diagnostics, [Jump]);
43
44const CONTEXT_LINE_COUNT: u32 = 1;
45
46pub fn init(cx: &mut AppContext) {
47 cx.add_action(ProjectDiagnosticsEditor::deploy);
48 items::init(cx);
49}
50
51type Event = editor::Event;
52
53struct ProjectDiagnosticsEditor {
54 project: ModelHandle<Project>,
55 workspace: WeakViewHandle<Workspace>,
56 editor: ViewHandle<Editor>,
57 summary: DiagnosticSummary,
58 excerpts: ModelHandle<MultiBuffer>,
59 path_states: Vec<PathState>,
60 paths_to_update: BTreeSet<(ProjectPath, LanguageServerId)>,
61}
62
63struct PathState {
64 path: ProjectPath,
65 diagnostic_groups: Vec<DiagnosticGroupState>,
66}
67
68#[derive(Clone, Debug, PartialEq)]
69struct Jump {
70 path: ProjectPath,
71 position: Point,
72 anchor: Anchor,
73}
74
75struct DiagnosticGroupState {
76 language_server_id: LanguageServerId,
77 primary_diagnostic: DiagnosticEntry<language::Anchor>,
78 primary_excerpt_ix: usize,
79 excerpts: Vec<ExcerptId>,
80 blocks: HashSet<BlockId>,
81 block_count: usize,
82}
83
84impl Entity for ProjectDiagnosticsEditor {
85 type Event = Event;
86}
87
88impl View for ProjectDiagnosticsEditor {
89 fn ui_name() -> &'static str {
90 "ProjectDiagnosticsEditor"
91 }
92
93 fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
94 if self.path_states.is_empty() {
95 let theme = &cx.global::<Settings>().theme.project_diagnostics;
96 Label::new("No problems in workspace", theme.empty_message.clone())
97 .aligned()
98 .contained()
99 .with_style(theme.container)
100 .boxed()
101 } else {
102 ChildView::new(&self.editor, cx).boxed()
103 }
104 }
105
106 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
107 if cx.is_self_focused() && !self.path_states.is_empty() {
108 cx.focus(&self.editor);
109 }
110 }
111
112 fn debug_json(&self, cx: &AppContext) -> serde_json::Value {
113 let project = self.project.read(cx);
114 json!({
115 "project": json!({
116 "language_servers": project.language_server_statuses().collect::<Vec<_>>(),
117 "summary": project.diagnostic_summary(cx),
118 }),
119 "summary": self.summary,
120 "paths_to_update": self.paths_to_update.iter().map(|(path, server_id)|
121 (path.path.to_string_lossy(), server_id.0)
122 ).collect::<Vec<_>>(),
123 "paths_states": self.path_states.iter().map(|state|
124 json!({
125 "path": state.path.path.to_string_lossy(),
126 "groups": state.diagnostic_groups.iter().map(|group|
127 json!({
128 "block_count": group.blocks.len(),
129 "excerpt_count": group.excerpts.len(),
130 })
131 ).collect::<Vec<_>>(),
132 })
133 ).collect::<Vec<_>>(),
134 })
135 }
136}
137
138impl ProjectDiagnosticsEditor {
139 fn new(
140 project_handle: ModelHandle<Project>,
141 workspace: WeakViewHandle<Workspace>,
142 cx: &mut ViewContext<Self>,
143 ) -> Self {
144 cx.subscribe(&project_handle, |this, _, event, cx| match event {
145 project::Event::DiskBasedDiagnosticsFinished { language_server_id } => {
146 this.update_excerpts(Some(*language_server_id), cx);
147 this.update_title(cx);
148 }
149 project::Event::DiagnosticsUpdated {
150 language_server_id,
151 path,
152 } => {
153 this.paths_to_update
154 .insert((path.clone(), *language_server_id));
155 }
156 _ => {}
157 })
158 .detach();
159
160 let excerpts = cx.add_model(|cx| MultiBuffer::new(project_handle.read(cx).replica_id()));
161 let editor = cx.add_view(|cx| {
162 let mut editor =
163 Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), cx);
164 editor.set_vertical_scroll_margin(5, cx);
165 editor
166 });
167 cx.subscribe(&editor, |_, _, event, cx| cx.emit(event.clone()))
168 .detach();
169
170 let project = project_handle.read(cx);
171 let paths_to_update = project
172 .diagnostic_summaries(cx)
173 .map(|(path, server_id, _)| (path, server_id))
174 .collect();
175 let summary = project.diagnostic_summary(cx);
176 let mut this = Self {
177 project: project_handle,
178 summary,
179 workspace,
180 excerpts,
181 editor,
182 path_states: Default::default(),
183 paths_to_update,
184 };
185 this.update_excerpts(None, cx);
186 this
187 }
188
189 fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
190 if let Some(existing) = workspace.item_of_type::<ProjectDiagnosticsEditor>(cx) {
191 workspace.activate_item(&existing, cx);
192 } else {
193 let workspace_handle = cx.weak_handle();
194 let diagnostics = cx.add_view(|cx| {
195 ProjectDiagnosticsEditor::new(workspace.project().clone(), workspace_handle, cx)
196 });
197 workspace.add_item(Box::new(diagnostics), cx);
198 }
199 }
200
201 fn update_excerpts(
202 &mut self,
203 language_server_id: Option<LanguageServerId>,
204 cx: &mut ViewContext<Self>,
205 ) {
206 let mut paths = Vec::new();
207 self.paths_to_update.retain(|(path, server_id)| {
208 if language_server_id
209 .map_or(true, |language_server_id| language_server_id == *server_id)
210 {
211 paths.push(path.clone());
212 false
213 } else {
214 true
215 }
216 });
217 let project = self.project.clone();
218 cx.spawn(|this, mut cx| {
219 async move {
220 for path in paths {
221 let buffer = project
222 .update(&mut cx, |project, cx| project.open_buffer(path.clone(), cx))
223 .await?;
224 this.update(&mut cx, |this, cx| {
225 this.populate_excerpts(path, language_server_id, buffer, cx)
226 })
227 }
228 Result::<_, anyhow::Error>::Ok(())
229 }
230 .log_err()
231 })
232 .detach();
233 }
234
235 fn populate_excerpts(
236 &mut self,
237 path: ProjectPath,
238 language_server_id: Option<LanguageServerId>,
239 buffer: ModelHandle<Buffer>,
240 cx: &mut ViewContext<Self>,
241 ) {
242 let was_empty = self.path_states.is_empty();
243 let snapshot = buffer.read(cx).snapshot();
244 let path_ix = match self.path_states.binary_search_by_key(&&path, |e| &e.path) {
245 Ok(ix) => ix,
246 Err(ix) => {
247 self.path_states.insert(
248 ix,
249 PathState {
250 path: path.clone(),
251 diagnostic_groups: Default::default(),
252 },
253 );
254 ix
255 }
256 };
257
258 let mut prev_excerpt_id = if path_ix > 0 {
259 let prev_path_last_group = &self.path_states[path_ix - 1]
260 .diagnostic_groups
261 .last()
262 .unwrap();
263 prev_path_last_group.excerpts.last().unwrap().clone()
264 } else {
265 ExcerptId::min()
266 };
267
268 let path_state = &mut self.path_states[path_ix];
269 let mut groups_to_add = Vec::new();
270 let mut group_ixs_to_remove = Vec::new();
271 let mut blocks_to_add = Vec::new();
272 let mut blocks_to_remove = HashSet::default();
273 let mut first_excerpt_id = None;
274 let excerpts_snapshot = self.excerpts.update(cx, |excerpts, excerpts_cx| {
275 let mut old_groups = path_state.diagnostic_groups.iter().enumerate().peekable();
276 let mut new_groups = snapshot
277 .diagnostic_groups(language_server_id)
278 .into_iter()
279 .filter(|(_, group)| {
280 group.entries[group.primary_ix].diagnostic.severity
281 <= DiagnosticSeverity::WARNING
282 })
283 .peekable();
284 loop {
285 let mut to_insert = None;
286 let mut to_remove = None;
287 let mut to_keep = None;
288 match (old_groups.peek(), new_groups.peek()) {
289 (None, None) => break,
290 (None, Some(_)) => to_insert = new_groups.next(),
291 (Some((_, old_group)), None) => {
292 if language_server_id.map_or(true, |id| id == old_group.language_server_id)
293 {
294 to_remove = old_groups.next();
295 } else {
296 to_keep = old_groups.next();
297 }
298 }
299 (Some((_, old_group)), Some((_, new_group))) => {
300 let old_primary = &old_group.primary_diagnostic;
301 let new_primary = &new_group.entries[new_group.primary_ix];
302 match compare_diagnostics(old_primary, new_primary, &snapshot) {
303 Ordering::Less => {
304 if language_server_id
305 .map_or(true, |id| id == old_group.language_server_id)
306 {
307 to_remove = old_groups.next();
308 } else {
309 to_keep = old_groups.next();
310 }
311 }
312 Ordering::Equal => {
313 to_keep = old_groups.next();
314 new_groups.next();
315 }
316 Ordering::Greater => to_insert = new_groups.next(),
317 }
318 }
319 }
320
321 if let Some((language_server_id, group)) = to_insert {
322 let mut group_state = DiagnosticGroupState {
323 language_server_id,
324 primary_diagnostic: group.entries[group.primary_ix].clone(),
325 primary_excerpt_ix: 0,
326 excerpts: Default::default(),
327 blocks: Default::default(),
328 block_count: 0,
329 };
330 let mut pending_range: Option<(Range<Point>, usize)> = None;
331 let mut is_first_excerpt_for_group = true;
332 for (ix, entry) in group.entries.iter().map(Some).chain([None]).enumerate() {
333 let resolved_entry = entry.map(|e| e.resolve::<Point>(&snapshot));
334 if let Some((range, start_ix)) = &mut pending_range {
335 if let Some(entry) = resolved_entry.as_ref() {
336 if entry.range.start.row
337 <= range.end.row + 1 + CONTEXT_LINE_COUNT * 2
338 {
339 range.end = range.end.max(entry.range.end);
340 continue;
341 }
342 }
343
344 let excerpt_start =
345 Point::new(range.start.row.saturating_sub(CONTEXT_LINE_COUNT), 0);
346 let excerpt_end = snapshot.clip_point(
347 Point::new(range.end.row + CONTEXT_LINE_COUNT, u32::MAX),
348 Bias::Left,
349 );
350 let excerpt_id = excerpts
351 .insert_excerpts_after(
352 prev_excerpt_id,
353 buffer.clone(),
354 [ExcerptRange {
355 context: excerpt_start..excerpt_end,
356 primary: Some(range.clone()),
357 }],
358 excerpts_cx,
359 )
360 .pop()
361 .unwrap();
362
363 prev_excerpt_id = excerpt_id.clone();
364 first_excerpt_id.get_or_insert_with(|| prev_excerpt_id.clone());
365 group_state.excerpts.push(excerpt_id.clone());
366 let header_position = (excerpt_id.clone(), language::Anchor::MIN);
367
368 if is_first_excerpt_for_group {
369 is_first_excerpt_for_group = false;
370 let mut primary =
371 group.entries[group.primary_ix].diagnostic.clone();
372 primary.message =
373 primary.message.split('\n').next().unwrap().to_string();
374 group_state.block_count += 1;
375 blocks_to_add.push(BlockProperties {
376 position: header_position,
377 height: 2,
378 style: BlockStyle::Sticky,
379 render: diagnostic_header_renderer(primary),
380 disposition: BlockDisposition::Above,
381 });
382 }
383
384 for entry in &group.entries[*start_ix..ix] {
385 let mut diagnostic = entry.diagnostic.clone();
386 if diagnostic.is_primary {
387 group_state.primary_excerpt_ix = group_state.excerpts.len() - 1;
388 diagnostic.message =
389 entry.diagnostic.message.split('\n').skip(1).collect();
390 }
391
392 if !diagnostic.message.is_empty() {
393 group_state.block_count += 1;
394 blocks_to_add.push(BlockProperties {
395 position: (excerpt_id.clone(), entry.range.start),
396 height: diagnostic.message.matches('\n').count() as u8 + 1,
397 style: BlockStyle::Fixed,
398 render: diagnostic_block_renderer(diagnostic, true),
399 disposition: BlockDisposition::Below,
400 });
401 }
402 }
403
404 pending_range.take();
405 }
406
407 if let Some(entry) = resolved_entry {
408 pending_range = Some((entry.range.clone(), ix));
409 }
410 }
411
412 groups_to_add.push(group_state);
413 } else if let Some((group_ix, group_state)) = to_remove {
414 excerpts.remove_excerpts(group_state.excerpts.iter().copied(), excerpts_cx);
415 group_ixs_to_remove.push(group_ix);
416 blocks_to_remove.extend(group_state.blocks.iter().copied());
417 } else if let Some((_, group)) = to_keep {
418 prev_excerpt_id = group.excerpts.last().unwrap().clone();
419 first_excerpt_id.get_or_insert_with(|| prev_excerpt_id.clone());
420 }
421 }
422
423 excerpts.snapshot(excerpts_cx)
424 });
425
426 self.editor.update(cx, |editor, cx| {
427 editor.remove_blocks(blocks_to_remove, cx);
428 let block_ids = editor.insert_blocks(
429 blocks_to_add.into_iter().map(|block| {
430 let (excerpt_id, text_anchor) = block.position;
431 BlockProperties {
432 position: excerpts_snapshot.anchor_in_excerpt(excerpt_id, text_anchor),
433 height: block.height,
434 style: block.style,
435 render: block.render,
436 disposition: block.disposition,
437 }
438 }),
439 cx,
440 );
441
442 let mut block_ids = block_ids.into_iter();
443 for group_state in &mut groups_to_add {
444 group_state.blocks = block_ids.by_ref().take(group_state.block_count).collect();
445 }
446 });
447
448 for ix in group_ixs_to_remove.into_iter().rev() {
449 path_state.diagnostic_groups.remove(ix);
450 }
451 path_state.diagnostic_groups.extend(groups_to_add);
452 path_state.diagnostic_groups.sort_unstable_by(|a, b| {
453 let range_a = &a.primary_diagnostic.range;
454 let range_b = &b.primary_diagnostic.range;
455 range_a
456 .start
457 .cmp(&range_b.start, &snapshot)
458 .then_with(|| range_a.end.cmp(&range_b.end, &snapshot))
459 });
460
461 if path_state.diagnostic_groups.is_empty() {
462 self.path_states.remove(path_ix);
463 }
464
465 self.editor.update(cx, |editor, cx| {
466 let groups;
467 let mut selections;
468 let new_excerpt_ids_by_selection_id;
469 if was_empty {
470 groups = self.path_states.first()?.diagnostic_groups.as_slice();
471 new_excerpt_ids_by_selection_id = [(0, ExcerptId::min())].into_iter().collect();
472 selections = vec![Selection {
473 id: 0,
474 start: 0,
475 end: 0,
476 reversed: false,
477 goal: SelectionGoal::None,
478 }];
479 } else {
480 groups = self.path_states.get(path_ix)?.diagnostic_groups.as_slice();
481 new_excerpt_ids_by_selection_id =
482 editor.change_selections(Some(Autoscroll::fit()), cx, |s| s.refresh());
483 selections = editor.selections.all::<usize>(cx);
484 }
485
486 // If any selection has lost its position, move it to start of the next primary diagnostic.
487 let snapshot = editor.snapshot(cx);
488 for selection in &mut selections {
489 if let Some(new_excerpt_id) = new_excerpt_ids_by_selection_id.get(&selection.id) {
490 let group_ix = match groups.binary_search_by(|probe| {
491 probe
492 .excerpts
493 .last()
494 .unwrap()
495 .cmp(new_excerpt_id, &snapshot.buffer_snapshot)
496 }) {
497 Ok(ix) | Err(ix) => ix,
498 };
499 if let Some(group) = groups.get(group_ix) {
500 let offset = excerpts_snapshot
501 .anchor_in_excerpt(
502 group.excerpts[group.primary_excerpt_ix].clone(),
503 group.primary_diagnostic.range.start,
504 )
505 .to_offset(&excerpts_snapshot);
506 selection.start = offset;
507 selection.end = offset;
508 }
509 }
510 }
511 editor.change_selections(None, cx, |s| {
512 s.select(selections);
513 });
514 Some(())
515 });
516
517 if self.path_states.is_empty() {
518 if self.editor.is_focused(cx) {
519 cx.focus_self();
520 }
521 } else if cx.handle().is_focused(cx) {
522 cx.focus(&self.editor);
523 }
524 cx.notify();
525 }
526
527 fn update_title(&mut self, cx: &mut ViewContext<Self>) {
528 self.summary = self.project.read(cx).diagnostic_summary(cx);
529 cx.emit(Event::TitleChanged);
530 }
531}
532
533impl Item for ProjectDiagnosticsEditor {
534 fn tab_content(
535 &self,
536 _detail: Option<usize>,
537 style: &theme::Tab,
538 cx: &AppContext,
539 ) -> ElementBox {
540 render_summary(
541 &self.summary,
542 &style.label.text,
543 &cx.global::<Settings>().theme.project_diagnostics,
544 )
545 }
546
547 fn for_each_project_item(&self, cx: &AppContext, f: &mut dyn FnMut(usize, &dyn project::Item)) {
548 self.editor.for_each_project_item(cx, f)
549 }
550
551 fn is_singleton(&self, _: &AppContext) -> bool {
552 false
553 }
554
555 fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
556 self.editor
557 .update(cx, |editor, cx| editor.navigate(data, cx))
558 }
559
560 fn tab_tooltip_text(&self, _: &AppContext) -> Option<Cow<str>> {
561 Some("Project Diagnostics".into())
562 }
563
564 fn is_dirty(&self, cx: &AppContext) -> bool {
565 self.excerpts.read(cx).is_dirty(cx)
566 }
567
568 fn has_conflict(&self, cx: &AppContext) -> bool {
569 self.excerpts.read(cx).has_conflict(cx)
570 }
571
572 fn can_save(&self, _: &AppContext) -> bool {
573 true
574 }
575
576 fn save(
577 &mut self,
578 project: ModelHandle<Project>,
579 cx: &mut ViewContext<Self>,
580 ) -> Task<Result<()>> {
581 self.editor.save(project, cx)
582 }
583
584 fn reload(
585 &mut self,
586 project: ModelHandle<Project>,
587 cx: &mut ViewContext<Self>,
588 ) -> Task<Result<()>> {
589 self.editor.reload(project, cx)
590 }
591
592 fn save_as(
593 &mut self,
594 _: ModelHandle<Project>,
595 _: PathBuf,
596 _: &mut ViewContext<Self>,
597 ) -> Task<Result<()>> {
598 unreachable!()
599 }
600
601 fn git_diff_recalc(
602 &mut self,
603 project: ModelHandle<Project>,
604 cx: &mut ViewContext<Self>,
605 ) -> Task<Result<()>> {
606 self.editor
607 .update(cx, |editor, cx| editor.git_diff_recalc(project, cx))
608 }
609
610 fn to_item_events(event: &Self::Event) -> SmallVec<[ItemEvent; 2]> {
611 Editor::to_item_events(event)
612 }
613
614 fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
615 self.editor.update(cx, |editor, _| {
616 editor.set_nav_history(Some(nav_history));
617 });
618 }
619
620 fn clone_on_split(
621 &self,
622 _workspace_id: workspace::WorkspaceId,
623 cx: &mut ViewContext<Self>,
624 ) -> Option<Self>
625 where
626 Self: Sized,
627 {
628 Some(ProjectDiagnosticsEditor::new(
629 self.project.clone(),
630 self.workspace.clone(),
631 cx,
632 ))
633 }
634
635 fn act_as_type<'a>(
636 &'a self,
637 type_id: TypeId,
638 self_handle: &'a ViewHandle<Self>,
639 _: &'a AppContext,
640 ) -> Option<&AnyViewHandle> {
641 if type_id == TypeId::of::<Self>() {
642 Some(self_handle)
643 } else if type_id == TypeId::of::<Editor>() {
644 Some(&self.editor)
645 } else {
646 None
647 }
648 }
649
650 fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
651 self.editor.update(cx, |editor, cx| editor.deactivated(cx));
652 }
653
654 fn serialized_item_kind() -> Option<&'static str> {
655 Some("diagnostics")
656 }
657
658 fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<ElementBox>> {
659 self.editor.breadcrumbs(theme, cx)
660 }
661
662 fn breadcrumb_location(&self) -> ToolbarItemLocation {
663 ToolbarItemLocation::PrimaryLeft { flex: None }
664 }
665
666 fn deserialize(
667 project: ModelHandle<Project>,
668 workspace: WeakViewHandle<Workspace>,
669 _workspace_id: workspace::WorkspaceId,
670 _item_id: workspace::ItemId,
671 cx: &mut ViewContext<Pane>,
672 ) -> Task<Result<ViewHandle<Self>>> {
673 Task::ready(Ok(cx.add_view(|cx| Self::new(project, workspace, cx))))
674 }
675}
676
677fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
678 let (message, highlights) = highlight_diagnostic_message(&diagnostic.message);
679 Arc::new(move |cx| {
680 let settings = cx.global::<Settings>();
681 let theme = &settings.theme.editor;
682 let style = theme.diagnostic_header.clone();
683 let font_size = (style.text_scale_factor * settings.buffer_font_size).round();
684 let icon_width = cx.em_width * style.icon_width_factor;
685 let icon = if diagnostic.severity == DiagnosticSeverity::ERROR {
686 Svg::new("icons/circle_x_mark_12.svg")
687 .with_color(theme.error_diagnostic.message.text.color)
688 } else {
689 Svg::new("icons/triangle_exclamation_12.svg")
690 .with_color(theme.warning_diagnostic.message.text.color)
691 };
692
693 Flex::row()
694 .with_child(
695 icon.constrained()
696 .with_width(icon_width)
697 .aligned()
698 .contained()
699 .boxed(),
700 )
701 .with_child(
702 Label::new(
703 message.clone(),
704 style.message.label.clone().with_font_size(font_size),
705 )
706 .with_highlights(highlights.clone())
707 .contained()
708 .with_style(style.message.container)
709 .with_margin_left(cx.gutter_padding)
710 .aligned()
711 .boxed(),
712 )
713 .with_children(diagnostic.code.clone().map(|code| {
714 Label::new(code, style.code.text.clone().with_font_size(font_size))
715 .contained()
716 .with_style(style.code.container)
717 .aligned()
718 .boxed()
719 }))
720 .contained()
721 .with_style(style.container)
722 .with_padding_left(cx.gutter_padding)
723 .with_padding_right(cx.gutter_padding)
724 .expanded()
725 .named("diagnostic header")
726 })
727}
728
729pub(crate) fn render_summary(
730 summary: &DiagnosticSummary,
731 text_style: &TextStyle,
732 theme: &theme::ProjectDiagnostics,
733) -> ElementBox {
734 if summary.error_count == 0 && summary.warning_count == 0 {
735 Label::new("No problems", text_style.clone()).boxed()
736 } else {
737 let icon_width = theme.tab_icon_width;
738 let icon_spacing = theme.tab_icon_spacing;
739 let summary_spacing = theme.tab_summary_spacing;
740 Flex::row()
741 .with_children([
742 Svg::new("icons/circle_x_mark_12.svg")
743 .with_color(text_style.color)
744 .constrained()
745 .with_width(icon_width)
746 .aligned()
747 .contained()
748 .with_margin_right(icon_spacing)
749 .named("no-icon"),
750 Label::new(
751 summary.error_count.to_string(),
752 LabelStyle {
753 text: text_style.clone(),
754 highlight_text: None,
755 },
756 )
757 .aligned()
758 .boxed(),
759 Svg::new("icons/triangle_exclamation_12.svg")
760 .with_color(text_style.color)
761 .constrained()
762 .with_width(icon_width)
763 .aligned()
764 .contained()
765 .with_margin_left(summary_spacing)
766 .with_margin_right(icon_spacing)
767 .named("warn-icon"),
768 Label::new(
769 summary.warning_count.to_string(),
770 LabelStyle {
771 text: text_style.clone(),
772 highlight_text: None,
773 },
774 )
775 .aligned()
776 .boxed(),
777 ])
778 .boxed()
779 }
780}
781
782fn compare_diagnostics<L: language::ToOffset, R: language::ToOffset>(
783 lhs: &DiagnosticEntry<L>,
784 rhs: &DiagnosticEntry<R>,
785 snapshot: &language::BufferSnapshot,
786) -> Ordering {
787 lhs.range
788 .start
789 .to_offset(snapshot)
790 .cmp(&rhs.range.start.to_offset(snapshot))
791 .then_with(|| {
792 lhs.range
793 .end
794 .to_offset(snapshot)
795 .cmp(&rhs.range.end.to_offset(snapshot))
796 })
797 .then_with(|| lhs.diagnostic.message.cmp(&rhs.diagnostic.message))
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803 use editor::{
804 display_map::{BlockContext, TransformBlock},
805 DisplayPoint,
806 };
807 use gpui::TestAppContext;
808 use language::{Diagnostic, DiagnosticEntry, DiagnosticSeverity, PointUtf16, Unclipped};
809 use project::FakeFs;
810 use serde_json::json;
811 use unindent::Unindent as _;
812
813 #[gpui::test]
814 async fn test_diagnostics(cx: &mut TestAppContext) {
815 Settings::test_async(cx);
816 let fs = FakeFs::new(cx.background());
817 fs.insert_tree(
818 "/test",
819 json!({
820 "consts.rs": "
821 const a: i32 = 'a';
822 const b: i32 = c;
823 "
824 .unindent(),
825
826 "main.rs": "
827 fn main() {
828 let x = vec![];
829 let y = vec![];
830 a(x);
831 b(y);
832 // comment 1
833 // comment 2
834 c(y);
835 d(x);
836 }
837 "
838 .unindent(),
839 }),
840 )
841 .await;
842
843 let language_server_id = LanguageServerId(0);
844 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
845 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
846
847 // Create some diagnostics
848 project.update(cx, |project, cx| {
849 project
850 .update_diagnostic_entries(
851 language_server_id,
852 PathBuf::from("/test/main.rs"),
853 None,
854 vec![
855 DiagnosticEntry {
856 range: Unclipped(PointUtf16::new(1, 8))..Unclipped(PointUtf16::new(1, 9)),
857 diagnostic: Diagnostic {
858 message:
859 "move occurs because `x` has type `Vec<char>`, which does not implement the `Copy` trait"
860 .to_string(),
861 severity: DiagnosticSeverity::INFORMATION,
862 is_primary: false,
863 is_disk_based: true,
864 group_id: 1,
865 ..Default::default()
866 },
867 },
868 DiagnosticEntry {
869 range: Unclipped(PointUtf16::new(2, 8))..Unclipped(PointUtf16::new(2, 9)),
870 diagnostic: Diagnostic {
871 message:
872 "move occurs because `y` has type `Vec<char>`, which does not implement the `Copy` trait"
873 .to_string(),
874 severity: DiagnosticSeverity::INFORMATION,
875 is_primary: false,
876 is_disk_based: true,
877 group_id: 0,
878 ..Default::default()
879 },
880 },
881 DiagnosticEntry {
882 range: Unclipped(PointUtf16::new(3, 6))..Unclipped(PointUtf16::new(3, 7)),
883 diagnostic: Diagnostic {
884 message: "value moved here".to_string(),
885 severity: DiagnosticSeverity::INFORMATION,
886 is_primary: false,
887 is_disk_based: true,
888 group_id: 1,
889 ..Default::default()
890 },
891 },
892 DiagnosticEntry {
893 range: Unclipped(PointUtf16::new(4, 6))..Unclipped(PointUtf16::new(4, 7)),
894 diagnostic: Diagnostic {
895 message: "value moved here".to_string(),
896 severity: DiagnosticSeverity::INFORMATION,
897 is_primary: false,
898 is_disk_based: true,
899 group_id: 0,
900 ..Default::default()
901 },
902 },
903 DiagnosticEntry {
904 range: Unclipped(PointUtf16::new(7, 6))..Unclipped(PointUtf16::new(7, 7)),
905 diagnostic: Diagnostic {
906 message: "use of moved value\nvalue used here after move".to_string(),
907 severity: DiagnosticSeverity::ERROR,
908 is_primary: true,
909 is_disk_based: true,
910 group_id: 0,
911 ..Default::default()
912 },
913 },
914 DiagnosticEntry {
915 range: Unclipped(PointUtf16::new(8, 6))..Unclipped(PointUtf16::new(8, 7)),
916 diagnostic: Diagnostic {
917 message: "use of moved value\nvalue used here after move".to_string(),
918 severity: DiagnosticSeverity::ERROR,
919 is_primary: true,
920 is_disk_based: true,
921 group_id: 1,
922 ..Default::default()
923 },
924 },
925 ],
926 cx,
927 )
928 .unwrap();
929 });
930
931 // Open the project diagnostics view while there are already diagnostics.
932 let view = cx.add_view(&workspace, |cx| {
933 ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx)
934 });
935
936 view.next_notification(cx).await;
937 view.update(cx, |view, cx| {
938 assert_eq!(
939 editor_blocks(&view.editor, cx),
940 [
941 (0, "path header block".into()),
942 (2, "diagnostic header".into()),
943 (15, "collapsed context".into()),
944 (16, "diagnostic header".into()),
945 (25, "collapsed context".into()),
946 ]
947 );
948 assert_eq!(
949 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
950 concat!(
951 //
952 // main.rs
953 //
954 "\n", // filename
955 "\n", // padding
956 // diagnostic group 1
957 "\n", // primary message
958 "\n", // padding
959 " let x = vec![];\n",
960 " let y = vec![];\n",
961 "\n", // supporting diagnostic
962 " a(x);\n",
963 " b(y);\n",
964 "\n", // supporting diagnostic
965 " // comment 1\n",
966 " // comment 2\n",
967 " c(y);\n",
968 "\n", // supporting diagnostic
969 " d(x);\n",
970 "\n", // context ellipsis
971 // diagnostic group 2
972 "\n", // primary message
973 "\n", // padding
974 "fn main() {\n",
975 " let x = vec![];\n",
976 "\n", // supporting diagnostic
977 " let y = vec![];\n",
978 " a(x);\n",
979 "\n", // supporting diagnostic
980 " b(y);\n",
981 "\n", // context ellipsis
982 " c(y);\n",
983 " d(x);\n",
984 "\n", // supporting diagnostic
985 "}"
986 )
987 );
988
989 // Cursor is at the first diagnostic
990 view.editor.update(cx, |editor, cx| {
991 assert_eq!(
992 editor.selections.display_ranges(cx),
993 [DisplayPoint::new(12, 6)..DisplayPoint::new(12, 6)]
994 );
995 });
996 });
997
998 // Diagnostics are added for another earlier path.
999 project.update(cx, |project, cx| {
1000 project.disk_based_diagnostics_started(language_server_id, cx);
1001 project
1002 .update_diagnostic_entries(
1003 language_server_id,
1004 PathBuf::from("/test/consts.rs"),
1005 None,
1006 vec![DiagnosticEntry {
1007 range: Unclipped(PointUtf16::new(0, 15))..Unclipped(PointUtf16::new(0, 15)),
1008 diagnostic: Diagnostic {
1009 message: "mismatched types\nexpected `usize`, found `char`".to_string(),
1010 severity: DiagnosticSeverity::ERROR,
1011 is_primary: true,
1012 is_disk_based: true,
1013 group_id: 0,
1014 ..Default::default()
1015 },
1016 }],
1017 cx,
1018 )
1019 .unwrap();
1020 project.disk_based_diagnostics_finished(language_server_id, cx);
1021 });
1022
1023 view.next_notification(cx).await;
1024 view.update(cx, |view, cx| {
1025 assert_eq!(
1026 editor_blocks(&view.editor, cx),
1027 [
1028 (0, "path header block".into()),
1029 (2, "diagnostic header".into()),
1030 (7, "path header block".into()),
1031 (9, "diagnostic header".into()),
1032 (22, "collapsed context".into()),
1033 (23, "diagnostic header".into()),
1034 (32, "collapsed context".into()),
1035 ]
1036 );
1037 assert_eq!(
1038 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1039 concat!(
1040 //
1041 // consts.rs
1042 //
1043 "\n", // filename
1044 "\n", // padding
1045 // diagnostic group 1
1046 "\n", // primary message
1047 "\n", // padding
1048 "const a: i32 = 'a';\n",
1049 "\n", // supporting diagnostic
1050 "const b: i32 = c;\n",
1051 //
1052 // main.rs
1053 //
1054 "\n", // filename
1055 "\n", // padding
1056 // diagnostic group 1
1057 "\n", // primary message
1058 "\n", // padding
1059 " let x = vec![];\n",
1060 " let y = vec![];\n",
1061 "\n", // supporting diagnostic
1062 " a(x);\n",
1063 " b(y);\n",
1064 "\n", // supporting diagnostic
1065 " // comment 1\n",
1066 " // comment 2\n",
1067 " c(y);\n",
1068 "\n", // supporting diagnostic
1069 " d(x);\n",
1070 "\n", // collapsed context
1071 // diagnostic group 2
1072 "\n", // primary message
1073 "\n", // filename
1074 "fn main() {\n",
1075 " let x = vec![];\n",
1076 "\n", // supporting diagnostic
1077 " let y = vec![];\n",
1078 " a(x);\n",
1079 "\n", // supporting diagnostic
1080 " b(y);\n",
1081 "\n", // context ellipsis
1082 " c(y);\n",
1083 " d(x);\n",
1084 "\n", // supporting diagnostic
1085 "}"
1086 )
1087 );
1088
1089 // Cursor keeps its position.
1090 view.editor.update(cx, |editor, cx| {
1091 assert_eq!(
1092 editor.selections.display_ranges(cx),
1093 [DisplayPoint::new(19, 6)..DisplayPoint::new(19, 6)]
1094 );
1095 });
1096 });
1097
1098 // Diagnostics are added to the first path
1099 project.update(cx, |project, cx| {
1100 project.disk_based_diagnostics_started(language_server_id, cx);
1101 project
1102 .update_diagnostic_entries(
1103 language_server_id,
1104 PathBuf::from("/test/consts.rs"),
1105 None,
1106 vec![
1107 DiagnosticEntry {
1108 range: Unclipped(PointUtf16::new(0, 15))
1109 ..Unclipped(PointUtf16::new(0, 15)),
1110 diagnostic: Diagnostic {
1111 message: "mismatched types\nexpected `usize`, found `char`"
1112 .to_string(),
1113 severity: DiagnosticSeverity::ERROR,
1114 is_primary: true,
1115 is_disk_based: true,
1116 group_id: 0,
1117 ..Default::default()
1118 },
1119 },
1120 DiagnosticEntry {
1121 range: Unclipped(PointUtf16::new(1, 15))
1122 ..Unclipped(PointUtf16::new(1, 15)),
1123 diagnostic: Diagnostic {
1124 message: "unresolved name `c`".to_string(),
1125 severity: DiagnosticSeverity::ERROR,
1126 is_primary: true,
1127 is_disk_based: true,
1128 group_id: 1,
1129 ..Default::default()
1130 },
1131 },
1132 ],
1133 cx,
1134 )
1135 .unwrap();
1136 project.disk_based_diagnostics_finished(language_server_id, cx);
1137 });
1138
1139 view.next_notification(cx).await;
1140 view.update(cx, |view, cx| {
1141 assert_eq!(
1142 editor_blocks(&view.editor, cx),
1143 [
1144 (0, "path header block".into()),
1145 (2, "diagnostic header".into()),
1146 (7, "collapsed context".into()),
1147 (8, "diagnostic header".into()),
1148 (13, "path header block".into()),
1149 (15, "diagnostic header".into()),
1150 (28, "collapsed context".into()),
1151 (29, "diagnostic header".into()),
1152 (38, "collapsed context".into()),
1153 ]
1154 );
1155 assert_eq!(
1156 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1157 concat!(
1158 //
1159 // consts.rs
1160 //
1161 "\n", // filename
1162 "\n", // padding
1163 // diagnostic group 1
1164 "\n", // primary message
1165 "\n", // padding
1166 "const a: i32 = 'a';\n",
1167 "\n", // supporting diagnostic
1168 "const b: i32 = c;\n",
1169 "\n", // context ellipsis
1170 // diagnostic group 2
1171 "\n", // primary message
1172 "\n", // padding
1173 "const a: i32 = 'a';\n",
1174 "const b: i32 = c;\n",
1175 "\n", // supporting diagnostic
1176 //
1177 // main.rs
1178 //
1179 "\n", // filename
1180 "\n", // padding
1181 // diagnostic group 1
1182 "\n", // primary message
1183 "\n", // padding
1184 " let x = vec![];\n",
1185 " let y = vec![];\n",
1186 "\n", // supporting diagnostic
1187 " a(x);\n",
1188 " b(y);\n",
1189 "\n", // supporting diagnostic
1190 " // comment 1\n",
1191 " // comment 2\n",
1192 " c(y);\n",
1193 "\n", // supporting diagnostic
1194 " d(x);\n",
1195 "\n", // context ellipsis
1196 // diagnostic group 2
1197 "\n", // primary message
1198 "\n", // filename
1199 "fn main() {\n",
1200 " let x = vec![];\n",
1201 "\n", // supporting diagnostic
1202 " let y = vec![];\n",
1203 " a(x);\n",
1204 "\n", // supporting diagnostic
1205 " b(y);\n",
1206 "\n", // context ellipsis
1207 " c(y);\n",
1208 " d(x);\n",
1209 "\n", // supporting diagnostic
1210 "}"
1211 )
1212 );
1213 });
1214 }
1215
1216 #[gpui::test]
1217 async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
1218 Settings::test_async(cx);
1219 let fs = FakeFs::new(cx.background());
1220 fs.insert_tree(
1221 "/test",
1222 json!({
1223 "main.js": "
1224 a();
1225 b();
1226 c();
1227 d();
1228 e();
1229 ".unindent()
1230 }),
1231 )
1232 .await;
1233
1234 let server_id_1 = LanguageServerId(100);
1235 let server_id_2 = LanguageServerId(101);
1236 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1237 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1238
1239 let view = cx.add_view(&workspace, |cx| {
1240 ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx)
1241 });
1242
1243 // Two language servers start updating diagnostics
1244 project.update(cx, |project, cx| {
1245 project.disk_based_diagnostics_started(server_id_1, cx);
1246 project.disk_based_diagnostics_started(server_id_2, cx);
1247 project
1248 .update_diagnostic_entries(
1249 server_id_1,
1250 PathBuf::from("/test/main.js"),
1251 None,
1252 vec![DiagnosticEntry {
1253 range: Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 1)),
1254 diagnostic: Diagnostic {
1255 message: "error 1".to_string(),
1256 severity: DiagnosticSeverity::WARNING,
1257 is_primary: true,
1258 is_disk_based: true,
1259 group_id: 1,
1260 ..Default::default()
1261 },
1262 }],
1263 cx,
1264 )
1265 .unwrap();
1266 project
1267 .update_diagnostic_entries(
1268 server_id_2,
1269 PathBuf::from("/test/main.js"),
1270 None,
1271 vec![DiagnosticEntry {
1272 range: Unclipped(PointUtf16::new(1, 0))..Unclipped(PointUtf16::new(1, 1)),
1273 diagnostic: Diagnostic {
1274 message: "warning 1".to_string(),
1275 severity: DiagnosticSeverity::ERROR,
1276 is_primary: true,
1277 is_disk_based: true,
1278 group_id: 2,
1279 ..Default::default()
1280 },
1281 }],
1282 cx,
1283 )
1284 .unwrap();
1285 });
1286
1287 // The first language server finishes
1288 project.update(cx, |project, cx| {
1289 project.disk_based_diagnostics_finished(server_id_1, cx);
1290 });
1291
1292 // Only the first language server's diagnostics are shown.
1293 cx.foreground().run_until_parked();
1294 view.update(cx, |view, cx| {
1295 assert_eq!(
1296 editor_blocks(&view.editor, cx),
1297 [
1298 (0, "path header block".into()),
1299 (2, "diagnostic header".into()),
1300 ]
1301 );
1302 assert_eq!(
1303 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1304 concat!(
1305 "\n", // filename
1306 "\n", // padding
1307 // diagnostic group 1
1308 "\n", // primary message
1309 "\n", // padding
1310 "a();\n", //
1311 "b();",
1312 )
1313 );
1314 });
1315
1316 // The second language server finishes
1317 project.update(cx, |project, cx| {
1318 project.disk_based_diagnostics_finished(server_id_2, cx);
1319 });
1320
1321 // Both language server's diagnostics are shown.
1322 cx.foreground().run_until_parked();
1323 view.update(cx, |view, cx| {
1324 assert_eq!(
1325 editor_blocks(&view.editor, cx),
1326 [
1327 (0, "path header block".into()),
1328 (2, "diagnostic header".into()),
1329 (6, "collapsed context".into()),
1330 (7, "diagnostic header".into()),
1331 ]
1332 );
1333 assert_eq!(
1334 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1335 concat!(
1336 "\n", // filename
1337 "\n", // padding
1338 // diagnostic group 1
1339 "\n", // primary message
1340 "\n", // padding
1341 "a();\n", // location
1342 "b();\n", //
1343 "\n", // collapsed context
1344 // diagnostic group 2
1345 "\n", // primary message
1346 "\n", // padding
1347 "a();\n", // context
1348 "b();\n", //
1349 "c();", // context
1350 )
1351 );
1352 });
1353
1354 // Both language servers start updating diagnostics, and the first server finishes.
1355 project.update(cx, |project, cx| {
1356 project.disk_based_diagnostics_started(server_id_1, cx);
1357 project.disk_based_diagnostics_started(server_id_2, cx);
1358 project
1359 .update_diagnostic_entries(
1360 server_id_1,
1361 PathBuf::from("/test/main.js"),
1362 None,
1363 vec![DiagnosticEntry {
1364 range: Unclipped(PointUtf16::new(2, 0))..Unclipped(PointUtf16::new(2, 1)),
1365 diagnostic: Diagnostic {
1366 message: "warning 2".to_string(),
1367 severity: DiagnosticSeverity::WARNING,
1368 is_primary: true,
1369 is_disk_based: true,
1370 group_id: 1,
1371 ..Default::default()
1372 },
1373 }],
1374 cx,
1375 )
1376 .unwrap();
1377 project
1378 .update_diagnostic_entries(
1379 server_id_2,
1380 PathBuf::from("/test/main.rs"),
1381 None,
1382 vec![],
1383 cx,
1384 )
1385 .unwrap();
1386 project.disk_based_diagnostics_finished(server_id_1, cx);
1387 });
1388
1389 // Only the first language server's diagnostics are updated.
1390 cx.foreground().run_until_parked();
1391 view.update(cx, |view, cx| {
1392 assert_eq!(
1393 editor_blocks(&view.editor, cx),
1394 [
1395 (0, "path header block".into()),
1396 (2, "diagnostic header".into()),
1397 (7, "collapsed context".into()),
1398 (8, "diagnostic header".into()),
1399 ]
1400 );
1401 assert_eq!(
1402 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1403 concat!(
1404 "\n", // filename
1405 "\n", // padding
1406 // diagnostic group 1
1407 "\n", // primary message
1408 "\n", // padding
1409 "a();\n", // location
1410 "b();\n", //
1411 "c();\n", // context
1412 "\n", // collapsed context
1413 // diagnostic group 2
1414 "\n", // primary message
1415 "\n", // padding
1416 "b();\n", // context
1417 "c();\n", //
1418 "d();", // context
1419 )
1420 );
1421 });
1422
1423 // The second language server finishes.
1424 project.update(cx, |project, cx| {
1425 project
1426 .update_diagnostic_entries(
1427 server_id_2,
1428 PathBuf::from("/test/main.js"),
1429 None,
1430 vec![DiagnosticEntry {
1431 range: Unclipped(PointUtf16::new(3, 0))..Unclipped(PointUtf16::new(3, 1)),
1432 diagnostic: Diagnostic {
1433 message: "warning 2".to_string(),
1434 severity: DiagnosticSeverity::WARNING,
1435 is_primary: true,
1436 is_disk_based: true,
1437 group_id: 1,
1438 ..Default::default()
1439 },
1440 }],
1441 cx,
1442 )
1443 .unwrap();
1444 project.disk_based_diagnostics_finished(server_id_2, cx);
1445 });
1446
1447 // Both language servers' diagnostics are updated.
1448 cx.foreground().run_until_parked();
1449 view.update(cx, |view, cx| {
1450 assert_eq!(
1451 editor_blocks(&view.editor, cx),
1452 [
1453 (0, "path header block".into()),
1454 (2, "diagnostic header".into()),
1455 (7, "collapsed context".into()),
1456 (8, "diagnostic header".into()),
1457 ]
1458 );
1459 assert_eq!(
1460 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1461 concat!(
1462 "\n", // filename
1463 "\n", // padding
1464 // diagnostic group 1
1465 "\n", // primary message
1466 "\n", // padding
1467 "b();\n", // location
1468 "c();\n", //
1469 "d();\n", // context
1470 "\n", // collapsed context
1471 // diagnostic group 2
1472 "\n", // primary message
1473 "\n", // padding
1474 "c();\n", // context
1475 "d();\n", //
1476 "e();", // context
1477 )
1478 );
1479 });
1480 }
1481
1482 fn editor_blocks(editor: &ViewHandle<Editor>, cx: &mut AppContext) -> Vec<(u32, String)> {
1483 let mut presenter = cx.build_presenter(editor.id(), 0., Default::default());
1484 let mut cx = presenter.build_layout_context(Default::default(), false, cx);
1485 cx.render(editor, |editor, cx| {
1486 let snapshot = editor.snapshot(cx);
1487 snapshot
1488 .blocks_in_range(0..snapshot.max_point().row())
1489 .filter_map(|(row, block)| {
1490 let name = match block {
1491 TransformBlock::Custom(block) => block
1492 .render(&mut BlockContext {
1493 cx,
1494 anchor_x: 0.,
1495 scroll_x: 0.,
1496 gutter_padding: 0.,
1497 gutter_width: 0.,
1498 line_height: 0.,
1499 em_width: 0.,
1500 })
1501 .name()?
1502 .to_string(),
1503 TransformBlock::ExcerptHeader {
1504 starts_new_buffer, ..
1505 } => {
1506 if *starts_new_buffer {
1507 "path header block".to_string()
1508 } else {
1509 "collapsed context".to_string()
1510 }
1511 }
1512 };
1513
1514 Some((row, name))
1515 })
1516 .collect()
1517 })
1518 }
1519}