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