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