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