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