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