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