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