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