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