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