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