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