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