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, Stateful, TestAppContext, VisualTestContext, WindowContext};
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
1053 view.next_notification(cx).await;
1054 view.update(cx, |view, cx| {
1055 assert_eq!(
1056 editor_blocks(&view.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 view.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 view.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
1115 // Diagnostics are added for another earlier path.
1116 project.update(cx, |project, cx| {
1117 project.disk_based_diagnostics_started(language_server_id, cx);
1118 project
1119 .update_diagnostic_entries(
1120 language_server_id,
1121 PathBuf::from("/test/consts.rs"),
1122 None,
1123 vec![DiagnosticEntry {
1124 range: Unclipped(PointUtf16::new(0, 15))..Unclipped(PointUtf16::new(0, 15)),
1125 diagnostic: Diagnostic {
1126 message: "mismatched types\nexpected `usize`, found `char`".to_string(),
1127 severity: DiagnosticSeverity::ERROR,
1128 is_primary: true,
1129 is_disk_based: true,
1130 group_id: 0,
1131 ..Default::default()
1132 },
1133 }],
1134 cx,
1135 )
1136 .unwrap();
1137 project.disk_based_diagnostics_finished(language_server_id, cx);
1138 });
1139
1140 view.next_notification(cx).await;
1141 view.update(cx, |view, cx| {
1142 assert_eq!(
1143 editor_blocks(&view.editor, cx),
1144 [
1145 (0, "path header block".into()),
1146 (2, "diagnostic header".into()),
1147 (7, "path header block".into()),
1148 (9, "diagnostic header".into()),
1149 (22, "collapsed context".into()),
1150 (23, "diagnostic header".into()),
1151 (32, "collapsed context".into()),
1152 ]
1153 );
1154 assert_eq!(
1155 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1156 concat!(
1157 //
1158 // consts.rs
1159 //
1160 "\n", // filename
1161 "\n", // padding
1162 // diagnostic group 1
1163 "\n", // primary message
1164 "\n", // padding
1165 "const a: i32 = 'a';\n",
1166 "\n", // supporting diagnostic
1167 "const b: i32 = c;\n",
1168 //
1169 // main.rs
1170 //
1171 "\n", // filename
1172 "\n", // padding
1173 // diagnostic group 1
1174 "\n", // primary message
1175 "\n", // padding
1176 " let x = vec![];\n",
1177 " let y = vec![];\n",
1178 "\n", // supporting diagnostic
1179 " a(x);\n",
1180 " b(y);\n",
1181 "\n", // supporting diagnostic
1182 " // comment 1\n",
1183 " // comment 2\n",
1184 " c(y);\n",
1185 "\n", // supporting diagnostic
1186 " d(x);\n",
1187 "\n", // collapsed context
1188 // diagnostic group 2
1189 "\n", // primary message
1190 "\n", // filename
1191 "fn main() {\n",
1192 " let x = vec![];\n",
1193 "\n", // supporting diagnostic
1194 " let y = vec![];\n",
1195 " a(x);\n",
1196 "\n", // supporting diagnostic
1197 " b(y);\n",
1198 "\n", // context ellipsis
1199 " c(y);\n",
1200 " d(x);\n",
1201 "\n", // supporting diagnostic
1202 "}"
1203 )
1204 );
1205
1206 // Cursor keeps its position.
1207 view.editor.update(cx, |editor, cx| {
1208 assert_eq!(
1209 editor.selections.display_ranges(cx),
1210 [DisplayPoint::new(19, 6)..DisplayPoint::new(19, 6)]
1211 );
1212 });
1213 });
1214
1215 // Diagnostics are added to the first path
1216 project.update(cx, |project, cx| {
1217 project.disk_based_diagnostics_started(language_server_id, cx);
1218 project
1219 .update_diagnostic_entries(
1220 language_server_id,
1221 PathBuf::from("/test/consts.rs"),
1222 None,
1223 vec![
1224 DiagnosticEntry {
1225 range: Unclipped(PointUtf16::new(0, 15))
1226 ..Unclipped(PointUtf16::new(0, 15)),
1227 diagnostic: Diagnostic {
1228 message: "mismatched types\nexpected `usize`, found `char`"
1229 .to_string(),
1230 severity: DiagnosticSeverity::ERROR,
1231 is_primary: true,
1232 is_disk_based: true,
1233 group_id: 0,
1234 ..Default::default()
1235 },
1236 },
1237 DiagnosticEntry {
1238 range: Unclipped(PointUtf16::new(1, 15))
1239 ..Unclipped(PointUtf16::new(1, 15)),
1240 diagnostic: Diagnostic {
1241 message: "unresolved name `c`".to_string(),
1242 severity: DiagnosticSeverity::ERROR,
1243 is_primary: true,
1244 is_disk_based: true,
1245 group_id: 1,
1246 ..Default::default()
1247 },
1248 },
1249 ],
1250 cx,
1251 )
1252 .unwrap();
1253 project.disk_based_diagnostics_finished(language_server_id, cx);
1254 });
1255
1256 view.next_notification(cx).await;
1257 view.update(cx, |view, cx| {
1258 assert_eq!(
1259 editor_blocks(&view.editor, cx),
1260 [
1261 (0, "path header block".into()),
1262 (2, "diagnostic header".into()),
1263 (7, "collapsed context".into()),
1264 (8, "diagnostic header".into()),
1265 (13, "path header block".into()),
1266 (15, "diagnostic header".into()),
1267 (28, "collapsed context".into()),
1268 (29, "diagnostic header".into()),
1269 (38, "collapsed context".into()),
1270 ]
1271 );
1272 assert_eq!(
1273 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1274 concat!(
1275 //
1276 // consts.rs
1277 //
1278 "\n", // filename
1279 "\n", // padding
1280 // diagnostic group 1
1281 "\n", // primary message
1282 "\n", // padding
1283 "const a: i32 = 'a';\n",
1284 "\n", // supporting diagnostic
1285 "const b: i32 = c;\n",
1286 "\n", // context ellipsis
1287 // diagnostic group 2
1288 "\n", // primary message
1289 "\n", // padding
1290 "const a: i32 = 'a';\n",
1291 "const b: i32 = c;\n",
1292 "\n", // supporting diagnostic
1293 //
1294 // main.rs
1295 //
1296 "\n", // filename
1297 "\n", // padding
1298 // diagnostic group 1
1299 "\n", // primary message
1300 "\n", // padding
1301 " let x = vec![];\n",
1302 " let y = vec![];\n",
1303 "\n", // supporting diagnostic
1304 " a(x);\n",
1305 " b(y);\n",
1306 "\n", // supporting diagnostic
1307 " // comment 1\n",
1308 " // comment 2\n",
1309 " c(y);\n",
1310 "\n", // supporting diagnostic
1311 " d(x);\n",
1312 "\n", // context ellipsis
1313 // diagnostic group 2
1314 "\n", // primary message
1315 "\n", // filename
1316 "fn main() {\n",
1317 " let x = vec![];\n",
1318 "\n", // supporting diagnostic
1319 " let y = vec![];\n",
1320 " a(x);\n",
1321 "\n", // supporting diagnostic
1322 " b(y);\n",
1323 "\n", // context ellipsis
1324 " c(y);\n",
1325 " d(x);\n",
1326 "\n", // supporting diagnostic
1327 "}"
1328 )
1329 );
1330 });
1331 }
1332
1333 #[gpui::test]
1334 async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
1335 init_test(cx);
1336
1337 let fs = FakeFs::new(cx.executor());
1338 fs.insert_tree(
1339 "/test",
1340 json!({
1341 "main.js": "
1342 a();
1343 b();
1344 c();
1345 d();
1346 e();
1347 ".unindent()
1348 }),
1349 )
1350 .await;
1351
1352 let server_id_1 = LanguageServerId(100);
1353 let server_id_2 = LanguageServerId(101);
1354 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1355 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1356 let cx = &mut VisualTestContext::from_window(*window, cx);
1357 let workspace = window.root(cx).unwrap();
1358
1359 let view = window.build_view(cx, |cx| {
1360 ProjectDiagnosticsEditor::new_with_context(
1361 1,
1362 project.clone(),
1363 workspace.downgrade(),
1364 cx,
1365 )
1366 });
1367
1368 // Two language servers start updating diagnostics
1369 project.update(cx, |project, cx| {
1370 project.disk_based_diagnostics_started(server_id_1, cx);
1371 project.disk_based_diagnostics_started(server_id_2, cx);
1372 project
1373 .update_diagnostic_entries(
1374 server_id_1,
1375 PathBuf::from("/test/main.js"),
1376 None,
1377 vec![DiagnosticEntry {
1378 range: Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 1)),
1379 diagnostic: Diagnostic {
1380 message: "error 1".to_string(),
1381 severity: DiagnosticSeverity::WARNING,
1382 is_primary: true,
1383 is_disk_based: true,
1384 group_id: 1,
1385 ..Default::default()
1386 },
1387 }],
1388 cx,
1389 )
1390 .unwrap();
1391 });
1392
1393 // The first language server finishes
1394 project.update(cx, |project, cx| {
1395 project.disk_based_diagnostics_finished(server_id_1, cx);
1396 });
1397
1398 // Only the first language server's diagnostics are shown.
1399 cx.executor().run_until_parked();
1400 view.update(cx, |view, cx| {
1401 assert_eq!(
1402 editor_blocks(&view.editor, cx),
1403 [
1404 (0, "path header block".into()),
1405 (2, "diagnostic header".into()),
1406 ]
1407 );
1408 assert_eq!(
1409 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1410 concat!(
1411 "\n", // filename
1412 "\n", // padding
1413 // diagnostic group 1
1414 "\n", // primary message
1415 "\n", // padding
1416 "a();\n", //
1417 "b();",
1418 )
1419 );
1420 });
1421
1422 // The second language server finishes
1423 project.update(cx, |project, cx| {
1424 project
1425 .update_diagnostic_entries(
1426 server_id_2,
1427 PathBuf::from("/test/main.js"),
1428 None,
1429 vec![DiagnosticEntry {
1430 range: Unclipped(PointUtf16::new(1, 0))..Unclipped(PointUtf16::new(1, 1)),
1431 diagnostic: Diagnostic {
1432 message: "warning 1".to_string(),
1433 severity: DiagnosticSeverity::ERROR,
1434 is_primary: true,
1435 is_disk_based: true,
1436 group_id: 2,
1437 ..Default::default()
1438 },
1439 }],
1440 cx,
1441 )
1442 .unwrap();
1443 project.disk_based_diagnostics_finished(server_id_2, cx);
1444 });
1445
1446 // Both language server's diagnostics are shown.
1447 cx.executor().run_until_parked();
1448 view.update(cx, |view, cx| {
1449 assert_eq!(
1450 editor_blocks(&view.editor, cx),
1451 [
1452 (0, "path header block".into()),
1453 (2, "diagnostic header".into()),
1454 (6, "collapsed context".into()),
1455 (7, "diagnostic header".into()),
1456 ]
1457 );
1458 assert_eq!(
1459 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1460 concat!(
1461 "\n", // filename
1462 "\n", // padding
1463 // diagnostic group 1
1464 "\n", // primary message
1465 "\n", // padding
1466 "a();\n", // location
1467 "b();\n", //
1468 "\n", // collapsed context
1469 // diagnostic group 2
1470 "\n", // primary message
1471 "\n", // padding
1472 "a();\n", // context
1473 "b();\n", //
1474 "c();", // context
1475 )
1476 );
1477 });
1478
1479 // Both language servers start updating diagnostics, and the first server finishes.
1480 project.update(cx, |project, cx| {
1481 project.disk_based_diagnostics_started(server_id_1, cx);
1482 project.disk_based_diagnostics_started(server_id_2, cx);
1483 project
1484 .update_diagnostic_entries(
1485 server_id_1,
1486 PathBuf::from("/test/main.js"),
1487 None,
1488 vec![DiagnosticEntry {
1489 range: Unclipped(PointUtf16::new(2, 0))..Unclipped(PointUtf16::new(2, 1)),
1490 diagnostic: Diagnostic {
1491 message: "warning 2".to_string(),
1492 severity: DiagnosticSeverity::WARNING,
1493 is_primary: true,
1494 is_disk_based: true,
1495 group_id: 1,
1496 ..Default::default()
1497 },
1498 }],
1499 cx,
1500 )
1501 .unwrap();
1502 project
1503 .update_diagnostic_entries(
1504 server_id_2,
1505 PathBuf::from("/test/main.rs"),
1506 None,
1507 vec![],
1508 cx,
1509 )
1510 .unwrap();
1511 project.disk_based_diagnostics_finished(server_id_1, cx);
1512 });
1513
1514 // Only the first language server's diagnostics are updated.
1515 cx.executor().run_until_parked();
1516 view.update(cx, |view, cx| {
1517 assert_eq!(
1518 editor_blocks(&view.editor, cx),
1519 [
1520 (0, "path header block".into()),
1521 (2, "diagnostic header".into()),
1522 (7, "collapsed context".into()),
1523 (8, "diagnostic header".into()),
1524 ]
1525 );
1526 assert_eq!(
1527 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1528 concat!(
1529 "\n", // filename
1530 "\n", // padding
1531 // diagnostic group 1
1532 "\n", // primary message
1533 "\n", // padding
1534 "a();\n", // location
1535 "b();\n", //
1536 "c();\n", // context
1537 "\n", // collapsed context
1538 // diagnostic group 2
1539 "\n", // primary message
1540 "\n", // padding
1541 "b();\n", // context
1542 "c();\n", //
1543 "d();", // context
1544 )
1545 );
1546 });
1547
1548 // The second language server finishes.
1549 project.update(cx, |project, cx| {
1550 project
1551 .update_diagnostic_entries(
1552 server_id_2,
1553 PathBuf::from("/test/main.js"),
1554 None,
1555 vec![DiagnosticEntry {
1556 range: Unclipped(PointUtf16::new(3, 0))..Unclipped(PointUtf16::new(3, 1)),
1557 diagnostic: Diagnostic {
1558 message: "warning 2".to_string(),
1559 severity: DiagnosticSeverity::WARNING,
1560 is_primary: true,
1561 is_disk_based: true,
1562 group_id: 1,
1563 ..Default::default()
1564 },
1565 }],
1566 cx,
1567 )
1568 .unwrap();
1569 project.disk_based_diagnostics_finished(server_id_2, cx);
1570 });
1571
1572 // Both language servers' diagnostics are updated.
1573 cx.executor().run_until_parked();
1574 view.update(cx, |view, cx| {
1575 assert_eq!(
1576 editor_blocks(&view.editor, cx),
1577 [
1578 (0, "path header block".into()),
1579 (2, "diagnostic header".into()),
1580 (7, "collapsed context".into()),
1581 (8, "diagnostic header".into()),
1582 ]
1583 );
1584 assert_eq!(
1585 view.editor.update(cx, |editor, cx| editor.display_text(cx)),
1586 concat!(
1587 "\n", // filename
1588 "\n", // padding
1589 // diagnostic group 1
1590 "\n", // primary message
1591 "\n", // padding
1592 "b();\n", // location
1593 "c();\n", //
1594 "d();\n", // context
1595 "\n", // collapsed context
1596 // diagnostic group 2
1597 "\n", // primary message
1598 "\n", // padding
1599 "c();\n", // context
1600 "d();\n", //
1601 "e();", // context
1602 )
1603 );
1604 });
1605 }
1606
1607 fn init_test(cx: &mut TestAppContext) {
1608 cx.update(|cx| {
1609 let settings = SettingsStore::test(cx);
1610 cx.set_global(settings);
1611 theme::init(theme::LoadThemes::JustBase, cx);
1612 language::init(cx);
1613 client::init_settings(cx);
1614 workspace::init_settings(cx);
1615 Project::init_settings(cx);
1616 crate::init(cx);
1617 editor::init(cx);
1618 });
1619 }
1620
1621 fn editor_blocks(editor: &View<Editor>, cx: &mut WindowContext) -> Vec<(u32, SharedString)> {
1622 editor.update(cx, |editor, cx| {
1623 let snapshot = editor.snapshot(cx);
1624 snapshot
1625 .blocks_in_range(0..snapshot.max_point().row())
1626 .enumerate()
1627 .filter_map(|(ix, (row, block))| {
1628 let name: SharedString = match block {
1629 TransformBlock::Custom(block) => cx.with_element_context({
1630 |cx| -> Option<SharedString> {
1631 let mut element = block.render(&mut BlockContext {
1632 context: cx,
1633 anchor_x: px(0.),
1634 gutter_dimensions: &GutterDimensions::default(),
1635 line_height: px(0.),
1636 em_width: px(0.),
1637 max_width: px(0.),
1638 block_id: ix,
1639 editor_style: &editor::EditorStyle::default(),
1640 });
1641 let element = element.downcast_mut::<Stateful<Div>>().unwrap();
1642 element.interactivity().element_id.clone()?.try_into().ok()
1643 }
1644 })?,
1645
1646 TransformBlock::ExcerptHeader {
1647 starts_new_buffer, ..
1648 } => {
1649 if *starts_new_buffer {
1650 "path header block".into()
1651 } else {
1652 "collapsed context".into()
1653 }
1654 }
1655 };
1656
1657 Some((row, name))
1658 })
1659 .collect()
1660 })
1661 }
1662}