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