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(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().clone()
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.clone();
455 first_excerpt_id.get_or_insert_with(|| prev_excerpt_id.clone());
456 group_state.excerpts.push(excerpt_id.clone());
457 let header_position = (excerpt_id.clone(), 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.clone(), 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().clone();
510 first_excerpt_id.get_or_insert_with(|| prev_excerpt_id.clone());
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].clone(),
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(&mut self, project: Model<Project>, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
739 self.editor.save(project, cx)
740 }
741
742 fn save_as(
743 &mut self,
744 _: Model<Project>,
745 _: PathBuf,
746 _: &mut ViewContext<Self>,
747 ) -> Task<Result<()>> {
748 unreachable!()
749 }
750
751 fn reload(&mut self, project: Model<Project>, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
752 self.editor.reload(project, cx)
753 }
754
755 fn act_as_type<'a>(
756 &'a self,
757 type_id: TypeId,
758 self_handle: &'a View<Self>,
759 _: &'a AppContext,
760 ) -> Option<AnyView> {
761 if type_id == TypeId::of::<Self>() {
762 Some(self_handle.to_any())
763 } else if type_id == TypeId::of::<Editor>() {
764 Some(self.editor.to_any())
765 } else {
766 None
767 }
768 }
769
770 fn breadcrumb_location(&self) -> ToolbarItemLocation {
771 ToolbarItemLocation::PrimaryLeft
772 }
773
774 fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
775 self.editor.breadcrumbs(theme, cx)
776 }
777
778 fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
779 self.editor
780 .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx));
781 }
782
783 fn serialized_item_kind() -> Option<&'static str> {
784 Some("diagnostics")
785 }
786
787 fn deserialize(
788 project: Model<Project>,
789 workspace: WeakView<Workspace>,
790 _workspace_id: workspace::WorkspaceId,
791 _item_id: workspace::ItemId,
792 cx: &mut ViewContext<Pane>,
793 ) -> Task<Result<View<Self>>> {
794 Task::ready(Ok(cx.new_view(|cx| Self::new(project, workspace, cx))))
795 }
796}
797
798fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
799 let (message, code_ranges) = highlight_diagnostic_message(&diagnostic);
800 let message: SharedString = message.into();
801 Arc::new(move |cx| {
802 let highlight_style: HighlightStyle = cx.theme().colors().text_accent.into();
803 h_flex()
804 .id("diagnostic header")
805 .py_2()
806 .pl_10()
807 .pr_5()
808 .w_full()
809 .justify_between()
810 .gap_2()
811 .child(
812 h_flex()
813 .gap_3()
814 .map(|stack| {
815 stack.child(
816 svg()
817 .size(cx.text_style().font_size)
818 .flex_none()
819 .map(|icon| {
820 if diagnostic.severity == DiagnosticSeverity::ERROR {
821 icon.path(IconName::XCircle.path())
822 .text_color(Color::Error.color(cx))
823 } else {
824 icon.path(IconName::ExclamationTriangle.path())
825 .text_color(Color::Warning.color(cx))
826 }
827 }),
828 )
829 })
830 .child(
831 h_flex()
832 .gap_1()
833 .child(
834 StyledText::new(message.clone()).with_highlights(
835 &cx.text_style(),
836 code_ranges
837 .iter()
838 .map(|range| (range.clone(), highlight_style)),
839 ),
840 )
841 .when_some(diagnostic.code.as_ref(), |stack, code| {
842 stack.child(
843 div()
844 .child(SharedString::from(format!("({code})")))
845 .text_color(cx.theme().colors().text_muted),
846 )
847 }),
848 ),
849 )
850 .child(
851 h_flex()
852 .gap_1()
853 .when_some(diagnostic.source.as_ref(), |stack, source| {
854 stack.child(
855 div()
856 .child(SharedString::from(source.clone()))
857 .text_color(cx.theme().colors().text_muted),
858 )
859 }),
860 )
861 .into_any_element()
862 })
863}
864
865fn compare_diagnostics<L: language::ToOffset, R: language::ToOffset>(
866 lhs: &DiagnosticEntry<L>,
867 rhs: &DiagnosticEntry<R>,
868 snapshot: &language::BufferSnapshot,
869) -> Ordering {
870 lhs.range
871 .start
872 .to_offset(snapshot)
873 .cmp(&rhs.range.start.to_offset(snapshot))
874 .then_with(|| {
875 lhs.range
876 .end
877 .to_offset(snapshot)
878 .cmp(&rhs.range.end.to_offset(snapshot))
879 })
880 .then_with(|| lhs.diagnostic.message.cmp(&rhs.diagnostic.message))
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886 use editor::{
887 display_map::{BlockContext, TransformBlock},
888 DisplayPoint,
889 };
890 use gpui::{px, TestAppContext, VisualTestContext, WindowContext};
891 use language::{Diagnostic, DiagnosticEntry, DiagnosticSeverity, PointUtf16, Unclipped};
892 use project::FakeFs;
893 use serde_json::json;
894 use settings::SettingsStore;
895 use unindent::Unindent as _;
896
897 #[gpui::test]
898 async fn test_diagnostics(cx: &mut TestAppContext) {
899 init_test(cx);
900
901 let fs = FakeFs::new(cx.executor());
902 fs.insert_tree(
903 "/test",
904 json!({
905 "consts.rs": "
906 const a: i32 = 'a';
907 const b: i32 = c;
908 "
909 .unindent(),
910
911 "main.rs": "
912 fn main() {
913 let x = vec![];
914 let y = vec![];
915 a(x);
916 b(y);
917 // comment 1
918 // comment 2
919 c(y);
920 d(x);
921 }
922 "
923 .unindent(),
924 }),
925 )
926 .await;
927
928 let language_server_id = LanguageServerId(0);
929 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
930 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
931 let cx = &mut VisualTestContext::from_window(*window, cx);
932 let workspace = window.root(cx).unwrap();
933
934 // Create some diagnostics
935 project.update(cx, |project, cx| {
936 project
937 .update_diagnostic_entries(
938 language_server_id,
939 PathBuf::from("/test/main.rs"),
940 None,
941 vec![
942 DiagnosticEntry {
943 range: Unclipped(PointUtf16::new(1, 8))..Unclipped(PointUtf16::new(1, 9)),
944 diagnostic: Diagnostic {
945 message:
946 "move occurs because `x` has type `Vec<char>`, which does not implement the `Copy` trait"
947 .to_string(),
948 severity: DiagnosticSeverity::INFORMATION,
949 is_primary: false,
950 is_disk_based: true,
951 group_id: 1,
952 ..Default::default()
953 },
954 },
955 DiagnosticEntry {
956 range: Unclipped(PointUtf16::new(2, 8))..Unclipped(PointUtf16::new(2, 9)),
957 diagnostic: Diagnostic {
958 message:
959 "move occurs because `y` has type `Vec<char>`, which does not implement the `Copy` trait"
960 .to_string(),
961 severity: DiagnosticSeverity::INFORMATION,
962 is_primary: false,
963 is_disk_based: true,
964 group_id: 0,
965 ..Default::default()
966 },
967 },
968 DiagnosticEntry {
969 range: Unclipped(PointUtf16::new(3, 6))..Unclipped(PointUtf16::new(3, 7)),
970 diagnostic: Diagnostic {
971 message: "value moved here".to_string(),
972 severity: DiagnosticSeverity::INFORMATION,
973 is_primary: false,
974 is_disk_based: true,
975 group_id: 1,
976 ..Default::default()
977 },
978 },
979 DiagnosticEntry {
980 range: Unclipped(PointUtf16::new(4, 6))..Unclipped(PointUtf16::new(4, 7)),
981 diagnostic: Diagnostic {
982 message: "value moved here".to_string(),
983 severity: DiagnosticSeverity::INFORMATION,
984 is_primary: false,
985 is_disk_based: true,
986 group_id: 0,
987 ..Default::default()
988 },
989 },
990 DiagnosticEntry {
991 range: Unclipped(PointUtf16::new(7, 6))..Unclipped(PointUtf16::new(7, 7)),
992 diagnostic: Diagnostic {
993 message: "use of moved value\nvalue used here after move".to_string(),
994 severity: DiagnosticSeverity::ERROR,
995 is_primary: true,
996 is_disk_based: true,
997 group_id: 0,
998 ..Default::default()
999 },
1000 },
1001 DiagnosticEntry {
1002 range: Unclipped(PointUtf16::new(8, 6))..Unclipped(PointUtf16::new(8, 7)),
1003 diagnostic: Diagnostic {
1004 message: "use of moved value\nvalue used here after move".to_string(),
1005 severity: DiagnosticSeverity::ERROR,
1006 is_primary: true,
1007 is_disk_based: true,
1008 group_id: 1,
1009 ..Default::default()
1010 },
1011 },
1012 ],
1013 cx,
1014 )
1015 .unwrap();
1016 });
1017
1018 // Open the project diagnostics view while there are already diagnostics.
1019 let view = window.build_view(cx, |cx| {
1020 ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx)
1021 });
1022
1023 view.next_notification(cx).await;
1024 view.update(cx, |view, cx| {
1025 assert_eq!(
1026 editor_blocks(&view.editor, cx),
1027 [
1028 (0, "path header block".into()),
1029 (2, "diagnostic header".into()),
1030 (15, "collapsed context".into()),
1031 (16, "diagnostic header".into()),
1032 (25, "collapsed context".into()),
1033 ]
1034 );
1035 assert_eq!(
1036 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1037 concat!(
1038 //
1039 // main.rs
1040 //
1041 "\n", // filename
1042 "\n", // padding
1043 // diagnostic group 1
1044 "\n", // primary message
1045 "\n", // padding
1046 " let x = vec![];\n",
1047 " let y = vec![];\n",
1048 "\n", // supporting diagnostic
1049 " a(x);\n",
1050 " b(y);\n",
1051 "\n", // supporting diagnostic
1052 " // comment 1\n",
1053 " // comment 2\n",
1054 " c(y);\n",
1055 "\n", // supporting diagnostic
1056 " d(x);\n",
1057 "\n", // context ellipsis
1058 // diagnostic group 2
1059 "\n", // primary message
1060 "\n", // padding
1061 "fn main() {\n",
1062 " let x = vec![];\n",
1063 "\n", // supporting diagnostic
1064 " let y = vec![];\n",
1065 " a(x);\n",
1066 "\n", // supporting diagnostic
1067 " b(y);\n",
1068 "\n", // context ellipsis
1069 " c(y);\n",
1070 " d(x);\n",
1071 "\n", // supporting diagnostic
1072 "}"
1073 )
1074 );
1075
1076 // Cursor is at the first diagnostic
1077 view.editor.update(cx, |editor, cx| {
1078 assert_eq!(
1079 editor.selections.display_ranges(cx),
1080 [DisplayPoint::new(12, 6)..DisplayPoint::new(12, 6)]
1081 );
1082 });
1083 });
1084
1085 // Diagnostics are added for another earlier path.
1086 project.update(cx, |project, cx| {
1087 project.disk_based_diagnostics_started(language_server_id, cx);
1088 project
1089 .update_diagnostic_entries(
1090 language_server_id,
1091 PathBuf::from("/test/consts.rs"),
1092 None,
1093 vec![DiagnosticEntry {
1094 range: Unclipped(PointUtf16::new(0, 15))..Unclipped(PointUtf16::new(0, 15)),
1095 diagnostic: Diagnostic {
1096 message: "mismatched types\nexpected `usize`, found `char`".to_string(),
1097 severity: DiagnosticSeverity::ERROR,
1098 is_primary: true,
1099 is_disk_based: true,
1100 group_id: 0,
1101 ..Default::default()
1102 },
1103 }],
1104 cx,
1105 )
1106 .unwrap();
1107 project.disk_based_diagnostics_finished(language_server_id, cx);
1108 });
1109
1110 view.next_notification(cx).await;
1111 view.update(cx, |view, cx| {
1112 assert_eq!(
1113 editor_blocks(&view.editor, cx),
1114 [
1115 (0, "path header block".into()),
1116 (2, "diagnostic header".into()),
1117 (7, "path header block".into()),
1118 (9, "diagnostic header".into()),
1119 (22, "collapsed context".into()),
1120 (23, "diagnostic header".into()),
1121 (32, "collapsed context".into()),
1122 ]
1123 );
1124 assert_eq!(
1125 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1126 concat!(
1127 //
1128 // consts.rs
1129 //
1130 "\n", // filename
1131 "\n", // padding
1132 // diagnostic group 1
1133 "\n", // primary message
1134 "\n", // padding
1135 "const a: i32 = 'a';\n",
1136 "\n", // supporting diagnostic
1137 "const b: i32 = c;\n",
1138 //
1139 // main.rs
1140 //
1141 "\n", // filename
1142 "\n", // padding
1143 // diagnostic group 1
1144 "\n", // primary message
1145 "\n", // padding
1146 " let x = vec![];\n",
1147 " let y = vec![];\n",
1148 "\n", // supporting diagnostic
1149 " a(x);\n",
1150 " b(y);\n",
1151 "\n", // supporting diagnostic
1152 " // comment 1\n",
1153 " // comment 2\n",
1154 " c(y);\n",
1155 "\n", // supporting diagnostic
1156 " d(x);\n",
1157 "\n", // collapsed context
1158 // diagnostic group 2
1159 "\n", // primary message
1160 "\n", // filename
1161 "fn main() {\n",
1162 " let x = vec![];\n",
1163 "\n", // supporting diagnostic
1164 " let y = vec![];\n",
1165 " a(x);\n",
1166 "\n", // supporting diagnostic
1167 " b(y);\n",
1168 "\n", // context ellipsis
1169 " c(y);\n",
1170 " d(x);\n",
1171 "\n", // supporting diagnostic
1172 "}"
1173 )
1174 );
1175
1176 // Cursor keeps its position.
1177 view.editor.update(cx, |editor, cx| {
1178 assert_eq!(
1179 editor.selections.display_ranges(cx),
1180 [DisplayPoint::new(19, 6)..DisplayPoint::new(19, 6)]
1181 );
1182 });
1183 });
1184
1185 // Diagnostics are added to the first path
1186 project.update(cx, |project, cx| {
1187 project.disk_based_diagnostics_started(language_server_id, cx);
1188 project
1189 .update_diagnostic_entries(
1190 language_server_id,
1191 PathBuf::from("/test/consts.rs"),
1192 None,
1193 vec![
1194 DiagnosticEntry {
1195 range: Unclipped(PointUtf16::new(0, 15))
1196 ..Unclipped(PointUtf16::new(0, 15)),
1197 diagnostic: Diagnostic {
1198 message: "mismatched types\nexpected `usize`, found `char`"
1199 .to_string(),
1200 severity: DiagnosticSeverity::ERROR,
1201 is_primary: true,
1202 is_disk_based: true,
1203 group_id: 0,
1204 ..Default::default()
1205 },
1206 },
1207 DiagnosticEntry {
1208 range: Unclipped(PointUtf16::new(1, 15))
1209 ..Unclipped(PointUtf16::new(1, 15)),
1210 diagnostic: Diagnostic {
1211 message: "unresolved name `c`".to_string(),
1212 severity: DiagnosticSeverity::ERROR,
1213 is_primary: true,
1214 is_disk_based: true,
1215 group_id: 1,
1216 ..Default::default()
1217 },
1218 },
1219 ],
1220 cx,
1221 )
1222 .unwrap();
1223 project.disk_based_diagnostics_finished(language_server_id, cx);
1224 });
1225
1226 view.next_notification(cx).await;
1227 view.update(cx, |view, cx| {
1228 assert_eq!(
1229 editor_blocks(&view.editor, cx),
1230 [
1231 (0, "path header block".into()),
1232 (2, "diagnostic header".into()),
1233 (7, "collapsed context".into()),
1234 (8, "diagnostic header".into()),
1235 (13, "path header block".into()),
1236 (15, "diagnostic header".into()),
1237 (28, "collapsed context".into()),
1238 (29, "diagnostic header".into()),
1239 (38, "collapsed context".into()),
1240 ]
1241 );
1242 assert_eq!(
1243 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1244 concat!(
1245 //
1246 // consts.rs
1247 //
1248 "\n", // filename
1249 "\n", // padding
1250 // diagnostic group 1
1251 "\n", // primary message
1252 "\n", // padding
1253 "const a: i32 = 'a';\n",
1254 "\n", // supporting diagnostic
1255 "const b: i32 = c;\n",
1256 "\n", // context ellipsis
1257 // diagnostic group 2
1258 "\n", // primary message
1259 "\n", // padding
1260 "const a: i32 = 'a';\n",
1261 "const b: i32 = c;\n",
1262 "\n", // supporting diagnostic
1263 //
1264 // main.rs
1265 //
1266 "\n", // filename
1267 "\n", // padding
1268 // diagnostic group 1
1269 "\n", // primary message
1270 "\n", // padding
1271 " let x = vec![];\n",
1272 " let y = vec![];\n",
1273 "\n", // supporting diagnostic
1274 " a(x);\n",
1275 " b(y);\n",
1276 "\n", // supporting diagnostic
1277 " // comment 1\n",
1278 " // comment 2\n",
1279 " c(y);\n",
1280 "\n", // supporting diagnostic
1281 " d(x);\n",
1282 "\n", // context ellipsis
1283 // diagnostic group 2
1284 "\n", // primary message
1285 "\n", // filename
1286 "fn main() {\n",
1287 " let x = vec![];\n",
1288 "\n", // supporting diagnostic
1289 " let y = vec![];\n",
1290 " a(x);\n",
1291 "\n", // supporting diagnostic
1292 " b(y);\n",
1293 "\n", // context ellipsis
1294 " c(y);\n",
1295 " d(x);\n",
1296 "\n", // supporting diagnostic
1297 "}"
1298 )
1299 );
1300 });
1301 }
1302
1303 #[gpui::test]
1304 async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
1305 init_test(cx);
1306
1307 let fs = FakeFs::new(cx.executor());
1308 fs.insert_tree(
1309 "/test",
1310 json!({
1311 "main.js": "
1312 a();
1313 b();
1314 c();
1315 d();
1316 e();
1317 ".unindent()
1318 }),
1319 )
1320 .await;
1321
1322 let server_id_1 = LanguageServerId(100);
1323 let server_id_2 = LanguageServerId(101);
1324 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1325 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1326 let cx = &mut VisualTestContext::from_window(*window, cx);
1327 let workspace = window.root(cx).unwrap();
1328
1329 let view = window.build_view(cx, |cx| {
1330 ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx)
1331 });
1332
1333 // Two language servers start updating diagnostics
1334 project.update(cx, |project, cx| {
1335 project.disk_based_diagnostics_started(server_id_1, cx);
1336 project.disk_based_diagnostics_started(server_id_2, cx);
1337 project
1338 .update_diagnostic_entries(
1339 server_id_1,
1340 PathBuf::from("/test/main.js"),
1341 None,
1342 vec![DiagnosticEntry {
1343 range: Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 1)),
1344 diagnostic: Diagnostic {
1345 message: "error 1".to_string(),
1346 severity: DiagnosticSeverity::WARNING,
1347 is_primary: true,
1348 is_disk_based: true,
1349 group_id: 1,
1350 ..Default::default()
1351 },
1352 }],
1353 cx,
1354 )
1355 .unwrap();
1356 });
1357
1358 // The first language server finishes
1359 project.update(cx, |project, cx| {
1360 project.disk_based_diagnostics_finished(server_id_1, cx);
1361 });
1362
1363 // Only the first language server's diagnostics are shown.
1364 cx.executor().run_until_parked();
1365 view.update(cx, |view, cx| {
1366 assert_eq!(
1367 editor_blocks(&view.editor, cx),
1368 [
1369 (0, "path header block".into()),
1370 (2, "diagnostic header".into()),
1371 ]
1372 );
1373 assert_eq!(
1374 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1375 concat!(
1376 "\n", // filename
1377 "\n", // padding
1378 // diagnostic group 1
1379 "\n", // primary message
1380 "\n", // padding
1381 "a();\n", //
1382 "b();",
1383 )
1384 );
1385 });
1386
1387 // The second language server finishes
1388 project.update(cx, |project, cx| {
1389 project
1390 .update_diagnostic_entries(
1391 server_id_2,
1392 PathBuf::from("/test/main.js"),
1393 None,
1394 vec![DiagnosticEntry {
1395 range: Unclipped(PointUtf16::new(1, 0))..Unclipped(PointUtf16::new(1, 1)),
1396 diagnostic: Diagnostic {
1397 message: "warning 1".to_string(),
1398 severity: DiagnosticSeverity::ERROR,
1399 is_primary: true,
1400 is_disk_based: true,
1401 group_id: 2,
1402 ..Default::default()
1403 },
1404 }],
1405 cx,
1406 )
1407 .unwrap();
1408 project.disk_based_diagnostics_finished(server_id_2, cx);
1409 });
1410
1411 // Both language server's diagnostics are shown.
1412 cx.executor().run_until_parked();
1413 view.update(cx, |view, cx| {
1414 assert_eq!(
1415 editor_blocks(&view.editor, cx),
1416 [
1417 (0, "path header block".into()),
1418 (2, "diagnostic header".into()),
1419 (6, "collapsed context".into()),
1420 (7, "diagnostic header".into()),
1421 ]
1422 );
1423 assert_eq!(
1424 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1425 concat!(
1426 "\n", // filename
1427 "\n", // padding
1428 // diagnostic group 1
1429 "\n", // primary message
1430 "\n", // padding
1431 "a();\n", // location
1432 "b();\n", //
1433 "\n", // collapsed context
1434 // diagnostic group 2
1435 "\n", // primary message
1436 "\n", // padding
1437 "a();\n", // context
1438 "b();\n", //
1439 "c();", // context
1440 )
1441 );
1442 });
1443
1444 // Both language servers start updating diagnostics, and the first server finishes.
1445 project.update(cx, |project, cx| {
1446 project.disk_based_diagnostics_started(server_id_1, cx);
1447 project.disk_based_diagnostics_started(server_id_2, cx);
1448 project
1449 .update_diagnostic_entries(
1450 server_id_1,
1451 PathBuf::from("/test/main.js"),
1452 None,
1453 vec![DiagnosticEntry {
1454 range: Unclipped(PointUtf16::new(2, 0))..Unclipped(PointUtf16::new(2, 1)),
1455 diagnostic: Diagnostic {
1456 message: "warning 2".to_string(),
1457 severity: DiagnosticSeverity::WARNING,
1458 is_primary: true,
1459 is_disk_based: true,
1460 group_id: 1,
1461 ..Default::default()
1462 },
1463 }],
1464 cx,
1465 )
1466 .unwrap();
1467 project
1468 .update_diagnostic_entries(
1469 server_id_2,
1470 PathBuf::from("/test/main.rs"),
1471 None,
1472 vec![],
1473 cx,
1474 )
1475 .unwrap();
1476 project.disk_based_diagnostics_finished(server_id_1, cx);
1477 });
1478
1479 // Only the first language server's diagnostics are updated.
1480 cx.executor().run_until_parked();
1481 view.update(cx, |view, cx| {
1482 assert_eq!(
1483 editor_blocks(&view.editor, cx),
1484 [
1485 (0, "path header block".into()),
1486 (2, "diagnostic header".into()),
1487 (7, "collapsed context".into()),
1488 (8, "diagnostic header".into()),
1489 ]
1490 );
1491 assert_eq!(
1492 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1493 concat!(
1494 "\n", // filename
1495 "\n", // padding
1496 // diagnostic group 1
1497 "\n", // primary message
1498 "\n", // padding
1499 "a();\n", // location
1500 "b();\n", //
1501 "c();\n", // context
1502 "\n", // collapsed context
1503 // diagnostic group 2
1504 "\n", // primary message
1505 "\n", // padding
1506 "b();\n", // context
1507 "c();\n", //
1508 "d();", // context
1509 )
1510 );
1511 });
1512
1513 // The second language server finishes.
1514 project.update(cx, |project, cx| {
1515 project
1516 .update_diagnostic_entries(
1517 server_id_2,
1518 PathBuf::from("/test/main.js"),
1519 None,
1520 vec![DiagnosticEntry {
1521 range: Unclipped(PointUtf16::new(3, 0))..Unclipped(PointUtf16::new(3, 1)),
1522 diagnostic: Diagnostic {
1523 message: "warning 2".to_string(),
1524 severity: DiagnosticSeverity::WARNING,
1525 is_primary: true,
1526 is_disk_based: true,
1527 group_id: 1,
1528 ..Default::default()
1529 },
1530 }],
1531 cx,
1532 )
1533 .unwrap();
1534 project.disk_based_diagnostics_finished(server_id_2, cx);
1535 });
1536
1537 // Both language servers' diagnostics are updated.
1538 cx.executor().run_until_parked();
1539 view.update(cx, |view, cx| {
1540 assert_eq!(
1541 editor_blocks(&view.editor, cx),
1542 [
1543 (0, "path header block".into()),
1544 (2, "diagnostic header".into()),
1545 (7, "collapsed context".into()),
1546 (8, "diagnostic header".into()),
1547 ]
1548 );
1549 assert_eq!(
1550 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1551 concat!(
1552 "\n", // filename
1553 "\n", // padding
1554 // diagnostic group 1
1555 "\n", // primary message
1556 "\n", // padding
1557 "b();\n", // location
1558 "c();\n", //
1559 "d();\n", // context
1560 "\n", // collapsed context
1561 // diagnostic group 2
1562 "\n", // primary message
1563 "\n", // padding
1564 "c();\n", // context
1565 "d();\n", //
1566 "e();", // context
1567 )
1568 );
1569 });
1570 }
1571
1572 fn init_test(cx: &mut TestAppContext) {
1573 cx.update(|cx| {
1574 let settings = SettingsStore::test(cx);
1575 cx.set_global(settings);
1576 theme::init(theme::LoadThemes::JustBase, cx);
1577 language::init(cx);
1578 client::init_settings(cx);
1579 workspace::init_settings(cx);
1580 Project::init_settings(cx);
1581 crate::init(cx);
1582 editor::init(cx);
1583 });
1584 }
1585
1586 fn editor_blocks(editor: &View<Editor>, cx: &mut WindowContext) -> Vec<(u32, SharedString)> {
1587 let editor_view = editor.clone();
1588 editor.update(cx, |editor, cx| {
1589 let snapshot = editor.snapshot(cx);
1590 snapshot
1591 .blocks_in_range(0..snapshot.max_point().row())
1592 .enumerate()
1593 .filter_map(|(ix, (row, block))| {
1594 let name: SharedString = match block {
1595 TransformBlock::Custom(block) => cx.with_element_context({
1596 let editor_view = editor_view.clone();
1597 |cx| -> Option<SharedString> {
1598 block
1599 .render(&mut BlockContext {
1600 context: cx,
1601 anchor_x: px(0.),
1602 gutter_padding: px(0.),
1603 gutter_width: px(0.),
1604 line_height: px(0.),
1605 em_width: px(0.),
1606 block_id: ix,
1607 view: editor_view,
1608 editor_style: &editor::EditorStyle::default(),
1609 })
1610 .inner_id()?
1611 .try_into()
1612 .ok()
1613 }
1614 })?,
1615
1616 TransformBlock::ExcerptHeader {
1617 starts_new_buffer, ..
1618 } => {
1619 if *starts_new_buffer {
1620 "path header block".into()
1621 } else {
1622 "collapsed context".into()
1623 }
1624 }
1625 };
1626
1627 Some((row, name))
1628 })
1629 .collect()
1630 })
1631 }
1632}