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