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