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