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