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