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