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