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