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