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