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