1pub mod items;
2mod toolbar_controls;
3
4mod buffer_diagnostics;
5mod diagnostic_renderer;
6
7#[cfg(test)]
8mod diagnostics_tests;
9
10use anyhow::Result;
11use buffer_diagnostics::BufferDiagnosticsEditor;
12use collections::{BTreeSet, HashMap, HashSet};
13use diagnostic_renderer::DiagnosticBlock;
14use editor::{
15 Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey,
16 display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
17 multibuffer_context_lines,
18};
19use gpui::{
20 AnyElement, AnyView, App, AsyncApp, Context, Entity, EventEmitter, FocusHandle, FocusOutEvent,
21 Focusable, Global, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
22 Styled, Subscription, Task, WeakEntity, Window, actions, div,
23};
24use itertools::Itertools as _;
25use language::{
26 Bias, Buffer, BufferRow, BufferSnapshot, DiagnosticEntry, DiagnosticEntryRef, Point,
27 ToTreeSitterPoint,
28};
29use project::{
30 DiagnosticSummary, Project, ProjectPath,
31 project_settings::{DiagnosticSeverity, ProjectSettings},
32};
33use settings::Settings;
34use std::{
35 any::{Any, TypeId},
36 cmp,
37 ops::{Range, RangeInclusive},
38 sync::Arc,
39 time::Duration,
40};
41use text::{BufferId, OffsetRangeExt};
42use theme::ActiveTheme;
43use toolbar_controls::DiagnosticsToolbarEditor;
44pub use toolbar_controls::ToolbarControls;
45use ui::{Icon, IconName, Label, h_flex, prelude::*};
46use util::ResultExt;
47use workspace::{
48 ItemNavHistory, ToolbarItemLocation, Workspace,
49 item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
50 searchable::SearchableItemHandle,
51};
52
53actions!(
54 diagnostics,
55 [
56 /// Opens the project diagnostics view.
57 Deploy,
58 /// Toggles the display of warning-level diagnostics.
59 ToggleWarnings,
60 /// Toggles automatic refresh of diagnostics.
61 ToggleDiagnosticsRefresh
62 ]
63);
64
65#[derive(Default)]
66pub(crate) struct IncludeWarnings(bool);
67impl Global for IncludeWarnings {}
68
69pub fn init(cx: &mut App) {
70 editor::set_diagnostic_renderer(diagnostic_renderer::DiagnosticRenderer {}, cx);
71 cx.observe_new(ProjectDiagnosticsEditor::register).detach();
72 cx.observe_new(BufferDiagnosticsEditor::register).detach();
73}
74
75pub(crate) struct ProjectDiagnosticsEditor {
76 project: Entity<Project>,
77 workspace: WeakEntity<Workspace>,
78 focus_handle: FocusHandle,
79 editor: Entity<Editor>,
80 diagnostics: HashMap<BufferId, Vec<DiagnosticEntry<text::Anchor>>>,
81 blocks: HashMap<BufferId, Vec<CustomBlockId>>,
82 summary: DiagnosticSummary,
83 multibuffer: Entity<MultiBuffer>,
84 paths_to_update: BTreeSet<ProjectPath>,
85 include_warnings: bool,
86 update_excerpts_task: Option<Task<Result<()>>>,
87 diagnostic_summary_update: Task<()>,
88 _subscription: Subscription,
89}
90
91impl EventEmitter<EditorEvent> for ProjectDiagnosticsEditor {}
92
93const DIAGNOSTICS_UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
94const DIAGNOSTICS_SUMMARY_UPDATE_DEBOUNCE: Duration = Duration::from_millis(30);
95
96impl Render for ProjectDiagnosticsEditor {
97 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
98 let warning_count = if self.include_warnings {
99 self.summary.warning_count
100 } else {
101 0
102 };
103
104 let child =
105 if warning_count + self.summary.error_count == 0 && self.editor.read(cx).is_empty(cx) {
106 let label = if self.summary.warning_count == 0 {
107 SharedString::new_static("No problems in workspace")
108 } else {
109 SharedString::new_static("No errors in workspace")
110 };
111 v_flex()
112 .key_context("EmptyPane")
113 .size_full()
114 .gap_1()
115 .justify_center()
116 .items_center()
117 .text_center()
118 .bg(cx.theme().colors().editor_background)
119 .child(Label::new(label).color(Color::Muted))
120 .when(self.summary.warning_count > 0, |this| {
121 let plural_suffix = if self.summary.warning_count > 1 {
122 "s"
123 } else {
124 ""
125 };
126 let label = format!(
127 "Show {} warning{}",
128 self.summary.warning_count, plural_suffix
129 );
130 this.child(
131 Button::new("diagnostics-show-warning-label", label).on_click(
132 cx.listener(|this, _, window, cx| {
133 this.toggle_warnings(&Default::default(), window, cx);
134 cx.notify();
135 }),
136 ),
137 )
138 })
139 } else {
140 div().size_full().child(self.editor.clone())
141 };
142
143 div()
144 .key_context("Diagnostics")
145 .track_focus(&self.focus_handle(cx))
146 .size_full()
147 .on_action(cx.listener(Self::toggle_warnings))
148 .on_action(cx.listener(Self::toggle_diagnostics_refresh))
149 .child(child)
150 }
151}
152
153#[derive(PartialEq, Eq, Copy, Clone, Debug)]
154enum RetainExcerpts {
155 Yes,
156 No,
157}
158
159impl ProjectDiagnosticsEditor {
160 pub fn register(
161 workspace: &mut Workspace,
162 _window: Option<&mut Window>,
163 _: &mut Context<Workspace>,
164 ) {
165 workspace.register_action(Self::deploy);
166 }
167
168 fn new(
169 include_warnings: bool,
170 project_handle: Entity<Project>,
171 workspace: WeakEntity<Workspace>,
172 window: &mut Window,
173 cx: &mut Context<Self>,
174 ) -> Self {
175 let project_event_subscription = cx.subscribe_in(
176 &project_handle,
177 window,
178 |this, _project, event, window, cx| match event {
179 project::Event::DiskBasedDiagnosticsStarted { .. } => {
180 cx.notify();
181 }
182 project::Event::DiskBasedDiagnosticsFinished { language_server_id } => {
183 log::debug!("disk based diagnostics finished for server {language_server_id}");
184 this.close_diagnosticless_buffers(
185 window,
186 cx,
187 this.editor.focus_handle(cx).contains_focused(window, cx)
188 || this.focus_handle.contains_focused(window, cx),
189 );
190 }
191 project::Event::DiagnosticsUpdated {
192 language_server_id,
193 paths,
194 } => {
195 this.paths_to_update.extend(paths.clone());
196 this.diagnostic_summary_update = cx.spawn(async move |this, cx| {
197 cx.background_executor()
198 .timer(DIAGNOSTICS_SUMMARY_UPDATE_DEBOUNCE)
199 .await;
200 this.update(cx, |this, cx| {
201 this.update_diagnostic_summary(cx);
202 })
203 .log_err();
204 });
205
206 log::debug!(
207 "diagnostics updated for server {language_server_id}, \
208 paths {paths:?}. updating excerpts"
209 );
210 let focused = this.editor.focus_handle(cx).contains_focused(window, cx)
211 || this.focus_handle.contains_focused(window, cx);
212 this.update_stale_excerpts(
213 if focused {
214 RetainExcerpts::Yes
215 } else {
216 RetainExcerpts::No
217 },
218 window,
219 cx,
220 );
221 }
222 _ => {}
223 },
224 );
225
226 let focus_handle = cx.focus_handle();
227 cx.on_focus_in(&focus_handle, window, Self::focus_in)
228 .detach();
229 cx.on_focus_out(&focus_handle, window, Self::focus_out)
230 .detach();
231
232 let excerpts = cx.new(|cx| MultiBuffer::new(project_handle.read(cx).capability()));
233 let editor = cx.new(|cx| {
234 let mut editor =
235 Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), window, cx);
236 editor.set_vertical_scroll_margin(5, cx);
237 editor.disable_inline_diagnostics();
238 editor.set_max_diagnostics_severity(
239 if include_warnings {
240 DiagnosticSeverity::Warning
241 } else {
242 DiagnosticSeverity::Error
243 },
244 cx,
245 );
246 editor.set_all_diagnostics_active(cx);
247 editor
248 });
249 cx.subscribe_in(
250 &editor,
251 window,
252 |this, _editor, event: &EditorEvent, window, cx| {
253 cx.emit(event.clone());
254 match event {
255 EditorEvent::Focused => {
256 if this.multibuffer.read(cx).is_empty() {
257 window.focus(&this.focus_handle);
258 }
259 }
260 EditorEvent::Blurred => this.close_diagnosticless_buffers(window, cx, false),
261 EditorEvent::Saved => this.close_diagnosticless_buffers(window, cx, true),
262 EditorEvent::SelectionsChanged { .. } => {
263 this.close_diagnosticless_buffers(window, cx, true)
264 }
265 _ => {}
266 }
267 },
268 )
269 .detach();
270 cx.observe_global_in::<IncludeWarnings>(window, |this, window, cx| {
271 let include_warnings = cx.global::<IncludeWarnings>().0;
272 this.include_warnings = include_warnings;
273 this.editor.update(cx, |editor, cx| {
274 editor.set_max_diagnostics_severity(
275 if include_warnings {
276 DiagnosticSeverity::Warning
277 } else {
278 DiagnosticSeverity::Error
279 },
280 cx,
281 )
282 });
283 this.diagnostics.clear();
284 this.update_all_excerpts(window, cx);
285 })
286 .detach();
287
288 let project = project_handle.read(cx);
289 let mut this = Self {
290 project: project_handle.clone(),
291 summary: project.diagnostic_summary(false, cx),
292 diagnostics: Default::default(),
293 blocks: Default::default(),
294 include_warnings,
295 workspace,
296 multibuffer: excerpts,
297 focus_handle,
298 editor,
299 paths_to_update: Default::default(),
300 update_excerpts_task: None,
301 diagnostic_summary_update: Task::ready(()),
302 _subscription: project_event_subscription,
303 };
304 this.update_all_excerpts(window, cx);
305 this
306 }
307
308 /// Closes all excerpts of buffers that:
309 /// - have no diagnostics anymore
310 /// - are saved (not dirty)
311 /// - and, if `reatin_selections` is true, do not have selections within them
312 fn close_diagnosticless_buffers(
313 &mut self,
314 _window: &mut Window,
315 cx: &mut Context<Self>,
316 retain_selections: bool,
317 ) {
318 let buffer_ids = self.multibuffer.read(cx).all_buffer_ids();
319 let selected_buffers = self.editor.update(cx, |editor, cx| {
320 editor
321 .selections
322 .all_anchors(cx)
323 .iter()
324 .filter_map(|anchor| anchor.start.buffer_id)
325 .collect::<HashSet<_>>()
326 });
327 for buffer_id in buffer_ids {
328 if retain_selections && selected_buffers.contains(&buffer_id) {
329 continue;
330 }
331 let has_blocks = self
332 .blocks
333 .get(&buffer_id)
334 .is_none_or(|blocks| blocks.is_empty());
335 if !has_blocks {
336 continue;
337 }
338 let is_dirty = self
339 .multibuffer
340 .read(cx)
341 .buffer(buffer_id)
342 .is_some_and(|buffer| buffer.read(cx).is_dirty());
343 if !is_dirty {
344 continue;
345 }
346 self.multibuffer.update(cx, |b, cx| {
347 b.remove_excerpts_for_buffer(buffer_id, cx);
348 });
349 }
350 }
351
352 fn update_stale_excerpts(
353 &mut self,
354 mut retain_excerpts: RetainExcerpts,
355 window: &mut Window,
356 cx: &mut Context<Self>,
357 ) {
358 if self.update_excerpts_task.is_some() {
359 return;
360 }
361 if self.multibuffer.read(cx).is_dirty(cx) {
362 retain_excerpts = RetainExcerpts::Yes;
363 }
364
365 let project_handle = self.project.clone();
366 self.update_excerpts_task = Some(cx.spawn_in(window, async move |this, cx| {
367 cx.background_executor()
368 .timer(DIAGNOSTICS_UPDATE_DEBOUNCE)
369 .await;
370 loop {
371 let Some(path) = this.update(cx, |this, cx| {
372 let Some(path) = this.paths_to_update.pop_first() else {
373 this.update_excerpts_task = None;
374 cx.notify();
375 return None;
376 };
377 Some(path)
378 })?
379 else {
380 break;
381 };
382
383 if let Some(buffer) = project_handle
384 .update(cx, |project, cx| project.open_buffer(path.clone(), cx))?
385 .await
386 .log_err()
387 {
388 this.update_in(cx, |this, window, cx| {
389 this.update_excerpts(buffer, retain_excerpts, window, cx)
390 })?
391 .await?;
392 }
393 }
394 Ok(())
395 }));
396 }
397
398 fn deploy(
399 workspace: &mut Workspace,
400 _: &Deploy,
401 window: &mut Window,
402 cx: &mut Context<Workspace>,
403 ) {
404 if let Some(existing) = workspace.item_of_type::<ProjectDiagnosticsEditor>(cx) {
405 let is_active = workspace
406 .active_item(cx)
407 .is_some_and(|item| item.item_id() == existing.item_id());
408
409 workspace.activate_item(&existing, true, !is_active, window, cx);
410 } else {
411 let workspace_handle = cx.entity().downgrade();
412
413 let include_warnings = match cx.try_global::<IncludeWarnings>() {
414 Some(include_warnings) => include_warnings.0,
415 None => ProjectSettings::get_global(cx).diagnostics.include_warnings,
416 };
417
418 let diagnostics = cx.new(|cx| {
419 ProjectDiagnosticsEditor::new(
420 include_warnings,
421 workspace.project().clone(),
422 workspace_handle,
423 window,
424 cx,
425 )
426 });
427 workspace.add_item_to_active_pane(Box::new(diagnostics), None, true, window, cx);
428 }
429 }
430
431 fn toggle_warnings(&mut self, _: &ToggleWarnings, _: &mut Window, cx: &mut Context<Self>) {
432 cx.set_global(IncludeWarnings(!self.include_warnings));
433 }
434
435 fn toggle_diagnostics_refresh(
436 &mut self,
437 _: &ToggleDiagnosticsRefresh,
438 window: &mut Window,
439 cx: &mut Context<Self>,
440 ) {
441 if self.update_excerpts_task.is_some() {
442 self.update_excerpts_task = None;
443 } else {
444 self.update_all_excerpts(window, cx);
445 }
446 cx.notify();
447 }
448
449 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
450 if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
451 self.editor.focus_handle(cx).focus(window)
452 }
453 }
454
455 fn focus_out(&mut self, _: FocusOutEvent, window: &mut Window, cx: &mut Context<Self>) {
456 if !self.focus_handle.is_focused(window) && !self.editor.focus_handle(cx).is_focused(window)
457 {
458 self.close_diagnosticless_buffers(window, cx, false);
459 }
460 }
461
462 /// Enqueue an update of all excerpts. Updates all paths that either
463 /// currently have diagnostics or are currently present in this view.
464 fn update_all_excerpts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
465 self.project.update(cx, |project, cx| {
466 let mut project_paths = project
467 .diagnostic_summaries(false, cx)
468 .map(|(project_path, _, _)| project_path)
469 .collect::<BTreeSet<_>>();
470
471 self.multibuffer.update(cx, |multibuffer, cx| {
472 for buffer in multibuffer.all_buffers() {
473 if let Some(file) = buffer.read(cx).file() {
474 project_paths.insert(ProjectPath {
475 path: file.path().clone(),
476 worktree_id: file.worktree_id(cx),
477 });
478 }
479 }
480 multibuffer.clear(cx);
481 });
482
483 self.paths_to_update = project_paths;
484 });
485
486 self.update_stale_excerpts(RetainExcerpts::No, window, cx);
487 }
488
489 fn diagnostics_are_unchanged(
490 &self,
491 existing: &[DiagnosticEntry<text::Anchor>],
492 new: &[DiagnosticEntryRef<'_, text::Anchor>],
493 snapshot: &BufferSnapshot,
494 ) -> bool {
495 if existing.len() != new.len() {
496 return false;
497 }
498 existing.iter().zip(new.iter()).all(|(existing, new)| {
499 existing.diagnostic.message == new.diagnostic.message
500 && existing.diagnostic.severity == new.diagnostic.severity
501 && existing.diagnostic.is_primary == new.diagnostic.is_primary
502 && existing.range.to_offset(snapshot) == new.range.to_offset(snapshot)
503 })
504 }
505
506 fn update_excerpts(
507 &mut self,
508 buffer: Entity<Buffer>,
509 retain_excerpts: RetainExcerpts,
510 window: &mut Window,
511 cx: &mut Context<Self>,
512 ) -> Task<Result<()>> {
513 let was_empty = self.multibuffer.read(cx).is_empty();
514 let buffer_snapshot = buffer.read(cx).snapshot();
515 let buffer_id = buffer_snapshot.remote_id();
516
517 let max_severity = if self.include_warnings {
518 lsp::DiagnosticSeverity::WARNING
519 } else {
520 lsp::DiagnosticSeverity::ERROR
521 };
522
523 cx.spawn_in(window, async move |this, cx| {
524 let diagnostics = buffer_snapshot
525 .diagnostics_in_range::<_, text::Anchor>(
526 Point::zero()..buffer_snapshot.max_point(),
527 false,
528 )
529 .collect::<Vec<_>>();
530
531 let unchanged = this.update(cx, |this, _| {
532 if this.diagnostics.get(&buffer_id).is_some_and(|existing| {
533 this.diagnostics_are_unchanged(existing, &diagnostics, &buffer_snapshot)
534 }) {
535 return true;
536 }
537 this.diagnostics.insert(
538 buffer_id,
539 diagnostics
540 .iter()
541 .map(DiagnosticEntryRef::to_owned)
542 .collect(),
543 );
544 false
545 })?;
546 if unchanged {
547 return Ok(());
548 }
549
550 let mut grouped: HashMap<usize, Vec<_>> = HashMap::default();
551 for entry in diagnostics {
552 grouped
553 .entry(entry.diagnostic.group_id)
554 .or_default()
555 .push(DiagnosticEntryRef {
556 range: entry.range.to_point(&buffer_snapshot),
557 diagnostic: entry.diagnostic,
558 })
559 }
560 let mut blocks: Vec<DiagnosticBlock> = Vec::new();
561
562 for (_, group) in grouped {
563 let group_severity = group.iter().map(|d| d.diagnostic.severity).min();
564 if group_severity.is_none_or(|s| s > max_severity) {
565 continue;
566 }
567 let more = cx.update(|_, cx| {
568 crate::diagnostic_renderer::DiagnosticRenderer::diagnostic_blocks_for_group(
569 group,
570 buffer_snapshot.remote_id(),
571 Some(Arc::new(this.clone())),
572 cx,
573 )
574 })?;
575
576 blocks.extend(more);
577 }
578
579 let mut excerpt_ranges: Vec<ExcerptRange<Point>> = match retain_excerpts {
580 RetainExcerpts::No => Vec::new(),
581 RetainExcerpts::Yes => this.update(cx, |this, cx| {
582 this.multibuffer.update(cx, |multi_buffer, cx| {
583 multi_buffer
584 .excerpts_for_buffer(buffer_id, cx)
585 .into_iter()
586 .map(|(_, range)| ExcerptRange {
587 context: range.context.to_point(&buffer_snapshot),
588 primary: range.primary.to_point(&buffer_snapshot),
589 })
590 .collect()
591 })
592 })?,
593 };
594 let mut result_blocks = vec![None; excerpt_ranges.len()];
595 let context_lines = cx.update(|_, cx| multibuffer_context_lines(cx))?;
596 for b in blocks {
597 let excerpt_range = context_range_for_entry(
598 b.initial_range.clone(),
599 context_lines,
600 buffer_snapshot.clone(),
601 cx,
602 )
603 .await;
604
605 let i = excerpt_ranges
606 .binary_search_by(|probe| {
607 probe
608 .context
609 .start
610 .cmp(&excerpt_range.start)
611 .then(probe.context.end.cmp(&excerpt_range.end))
612 .then(probe.primary.start.cmp(&b.initial_range.start))
613 .then(probe.primary.end.cmp(&b.initial_range.end))
614 .then(cmp::Ordering::Greater)
615 })
616 .unwrap_or_else(|i| i);
617 excerpt_ranges.insert(
618 i,
619 ExcerptRange {
620 context: excerpt_range,
621 primary: b.initial_range.clone(),
622 },
623 );
624 result_blocks.insert(i, Some(b));
625 }
626
627 this.update_in(cx, |this, window, cx| {
628 if let Some(block_ids) = this.blocks.remove(&buffer_id) {
629 this.editor.update(cx, |editor, cx| {
630 editor.display_map.update(cx, |display_map, cx| {
631 display_map.remove_blocks(block_ids.into_iter().collect(), cx)
632 });
633 })
634 }
635 let (anchor_ranges, _) = this.multibuffer.update(cx, |multi_buffer, cx| {
636 multi_buffer.set_excerpt_ranges_for_path(
637 PathKey::for_buffer(&buffer, cx),
638 buffer.clone(),
639 &buffer_snapshot,
640 excerpt_ranges,
641 cx,
642 )
643 });
644 #[cfg(test)]
645 let cloned_blocks = result_blocks.clone();
646
647 if was_empty && let Some(anchor_range) = anchor_ranges.first() {
648 let range_to_select = anchor_range.start..anchor_range.start;
649 this.editor.update(cx, |editor, cx| {
650 editor.change_selections(Default::default(), window, cx, |s| {
651 s.select_anchor_ranges([range_to_select]);
652 })
653 });
654 if this.focus_handle.is_focused(window) {
655 this.editor.read(cx).focus_handle(cx).focus(window);
656 }
657 }
658
659 let editor_blocks = anchor_ranges
660 .into_iter()
661 .zip_eq(result_blocks.into_iter())
662 .filter_map(|(anchor, block)| {
663 let block = block?;
664 let editor = this.editor.downgrade();
665 Some(BlockProperties {
666 placement: BlockPlacement::Near(anchor.start),
667 height: Some(1),
668 style: BlockStyle::Flex,
669 render: Arc::new(move |bcx| block.render_block(editor.clone(), bcx)),
670 priority: 1,
671 })
672 });
673
674 let block_ids = this.editor.update(cx, |editor, cx| {
675 editor.display_map.update(cx, |display_map, cx| {
676 display_map.insert_blocks(editor_blocks, cx)
677 })
678 });
679
680 #[cfg(test)]
681 {
682 for (block_id, block) in
683 block_ids.iter().zip(cloned_blocks.into_iter().flatten())
684 {
685 let markdown = block.markdown.clone();
686 editor::test::set_block_content_for_tests(
687 &this.editor,
688 *block_id,
689 cx,
690 move |cx| {
691 markdown::MarkdownElement::rendered_text(
692 markdown.clone(),
693 cx,
694 editor::hover_popover::diagnostics_markdown_style,
695 )
696 },
697 );
698 }
699 }
700
701 this.blocks.insert(buffer_id, block_ids);
702 cx.notify()
703 })
704 })
705 }
706
707 fn update_diagnostic_summary(&mut self, cx: &mut Context<Self>) {
708 self.summary = self.project.read(cx).diagnostic_summary(false, cx);
709 cx.emit(EditorEvent::TitleChanged);
710 }
711}
712
713impl Focusable for ProjectDiagnosticsEditor {
714 fn focus_handle(&self, _: &App) -> FocusHandle {
715 self.focus_handle.clone()
716 }
717}
718
719impl Item for ProjectDiagnosticsEditor {
720 type Event = EditorEvent;
721
722 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
723 Editor::to_item_events(event, f)
724 }
725
726 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
727 self.editor
728 .update(cx, |editor, cx| editor.deactivated(window, cx));
729 }
730
731 fn navigate(
732 &mut self,
733 data: Box<dyn Any>,
734 window: &mut Window,
735 cx: &mut Context<Self>,
736 ) -> bool {
737 self.editor
738 .update(cx, |editor, cx| editor.navigate(data, window, cx))
739 }
740
741 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
742 Some("Project Diagnostics".into())
743 }
744
745 fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
746 "Diagnostics".into()
747 }
748
749 fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
750 h_flex()
751 .gap_1()
752 .when(
753 self.summary.error_count == 0 && self.summary.warning_count == 0,
754 |then| {
755 then.child(
756 h_flex()
757 .gap_1()
758 .child(Icon::new(IconName::Check).color(Color::Success))
759 .child(Label::new("No problems").color(params.text_color())),
760 )
761 },
762 )
763 .when(self.summary.error_count > 0, |then| {
764 then.child(
765 h_flex()
766 .gap_1()
767 .child(Icon::new(IconName::XCircle).color(Color::Error))
768 .child(
769 Label::new(self.summary.error_count.to_string())
770 .color(params.text_color()),
771 ),
772 )
773 })
774 .when(self.summary.warning_count > 0, |then| {
775 then.child(
776 h_flex()
777 .gap_1()
778 .child(Icon::new(IconName::Warning).color(Color::Warning))
779 .child(
780 Label::new(self.summary.warning_count.to_string())
781 .color(params.text_color()),
782 ),
783 )
784 })
785 .into_any_element()
786 }
787
788 fn telemetry_event_text(&self) -> Option<&'static str> {
789 Some("Project Diagnostics Opened")
790 }
791
792 fn for_each_project_item(
793 &self,
794 cx: &App,
795 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
796 ) {
797 self.editor.for_each_project_item(cx, f)
798 }
799
800 fn set_nav_history(
801 &mut self,
802 nav_history: ItemNavHistory,
803 _: &mut Window,
804 cx: &mut Context<Self>,
805 ) {
806 self.editor.update(cx, |editor, _| {
807 editor.set_nav_history(Some(nav_history));
808 });
809 }
810
811 fn can_split(&self) -> bool {
812 true
813 }
814
815 fn clone_on_split(
816 &self,
817 _workspace_id: Option<workspace::WorkspaceId>,
818 window: &mut Window,
819 cx: &mut Context<Self>,
820 ) -> Task<Option<Entity<Self>>>
821 where
822 Self: Sized,
823 {
824 Task::ready(Some(cx.new(|cx| {
825 ProjectDiagnosticsEditor::new(
826 self.include_warnings,
827 self.project.clone(),
828 self.workspace.clone(),
829 window,
830 cx,
831 )
832 })))
833 }
834
835 fn is_dirty(&self, cx: &App) -> bool {
836 self.multibuffer.read(cx).is_dirty(cx)
837 }
838
839 fn has_deleted_file(&self, cx: &App) -> bool {
840 self.multibuffer.read(cx).has_deleted_file(cx)
841 }
842
843 fn has_conflict(&self, cx: &App) -> bool {
844 self.multibuffer.read(cx).has_conflict(cx)
845 }
846
847 fn can_save(&self, _: &App) -> bool {
848 true
849 }
850
851 fn save(
852 &mut self,
853 options: SaveOptions,
854 project: Entity<Project>,
855 window: &mut Window,
856 cx: &mut Context<Self>,
857 ) -> Task<Result<()>> {
858 self.editor.save(options, project, window, cx)
859 }
860
861 fn save_as(
862 &mut self,
863 _: Entity<Project>,
864 _: ProjectPath,
865 _window: &mut Window,
866 _: &mut Context<Self>,
867 ) -> Task<Result<()>> {
868 unreachable!()
869 }
870
871 fn reload(
872 &mut self,
873 project: Entity<Project>,
874 window: &mut Window,
875 cx: &mut Context<Self>,
876 ) -> Task<Result<()>> {
877 self.editor.reload(project, window, cx)
878 }
879
880 fn act_as_type<'a>(
881 &'a self,
882 type_id: TypeId,
883 self_handle: &'a Entity<Self>,
884 _: &'a App,
885 ) -> Option<AnyView> {
886 if type_id == TypeId::of::<Self>() {
887 Some(self_handle.to_any())
888 } else if type_id == TypeId::of::<Editor>() {
889 Some(self.editor.to_any())
890 } else {
891 None
892 }
893 }
894
895 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
896 Some(Box::new(self.editor.clone()))
897 }
898
899 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
900 ToolbarItemLocation::PrimaryLeft
901 }
902
903 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
904 self.editor.breadcrumbs(theme, cx)
905 }
906
907 fn added_to_workspace(
908 &mut self,
909 workspace: &mut Workspace,
910 window: &mut Window,
911 cx: &mut Context<Self>,
912 ) {
913 self.editor.update(cx, |editor, cx| {
914 editor.added_to_workspace(workspace, window, cx)
915 });
916 }
917}
918
919impl DiagnosticsToolbarEditor for WeakEntity<ProjectDiagnosticsEditor> {
920 fn include_warnings(&self, cx: &App) -> bool {
921 self.read_with(cx, |project_diagnostics_editor, _cx| {
922 project_diagnostics_editor.include_warnings
923 })
924 .unwrap_or(false)
925 }
926
927 fn is_updating(&self, cx: &App) -> bool {
928 self.read_with(cx, |project_diagnostics_editor, cx| {
929 project_diagnostics_editor.update_excerpts_task.is_some()
930 || project_diagnostics_editor
931 .project
932 .read(cx)
933 .language_servers_running_disk_based_diagnostics(cx)
934 .next()
935 .is_some()
936 })
937 .unwrap_or(false)
938 }
939
940 fn stop_updating(&self, cx: &mut App) {
941 let _ = self.update(cx, |project_diagnostics_editor, cx| {
942 project_diagnostics_editor.update_excerpts_task = None;
943 cx.notify();
944 });
945 }
946
947 fn refresh_diagnostics(&self, window: &mut Window, cx: &mut App) {
948 let _ = self.update(cx, |project_diagnostics_editor, cx| {
949 project_diagnostics_editor.update_all_excerpts(window, cx);
950 });
951 }
952
953 fn toggle_warnings(&self, window: &mut Window, cx: &mut App) {
954 let _ = self.update(cx, |project_diagnostics_editor, cx| {
955 project_diagnostics_editor.toggle_warnings(&Default::default(), window, cx);
956 });
957 }
958
959 fn get_diagnostics_for_buffer(
960 &self,
961 buffer_id: text::BufferId,
962 cx: &App,
963 ) -> Vec<language::DiagnosticEntry<text::Anchor>> {
964 self.read_with(cx, |project_diagnostics_editor, _cx| {
965 project_diagnostics_editor
966 .diagnostics
967 .get(&buffer_id)
968 .cloned()
969 .unwrap_or_default()
970 })
971 .unwrap_or_default()
972 }
973}
974const DIAGNOSTIC_EXPANSION_ROW_LIMIT: u32 = 32;
975
976async fn context_range_for_entry(
977 range: Range<Point>,
978 context: u32,
979 snapshot: BufferSnapshot,
980 cx: &mut AsyncApp,
981) -> Range<Point> {
982 if let Some(rows) = heuristic_syntactic_expand(
983 range.clone(),
984 DIAGNOSTIC_EXPANSION_ROW_LIMIT,
985 snapshot.clone(),
986 cx,
987 )
988 .await
989 {
990 return Range {
991 start: Point::new(*rows.start(), 0),
992 end: snapshot.clip_point(Point::new(*rows.end(), u32::MAX), Bias::Left),
993 };
994 }
995 Range {
996 start: Point::new(range.start.row.saturating_sub(context), 0),
997 end: snapshot.clip_point(Point::new(range.end.row + context, u32::MAX), Bias::Left),
998 }
999}
1000
1001/// Expands the input range using syntax information from TreeSitter. This expansion will be limited
1002/// to the specified `max_row_count`.
1003///
1004/// If there is a containing outline item that is less than `max_row_count`, it will be returned.
1005/// Otherwise fairly arbitrary heuristics are applied to attempt to return a logical block of code.
1006async fn heuristic_syntactic_expand(
1007 input_range: Range<Point>,
1008 max_row_count: u32,
1009 snapshot: BufferSnapshot,
1010 cx: &mut AsyncApp,
1011) -> Option<RangeInclusive<BufferRow>> {
1012 let input_row_count = input_range.end.row - input_range.start.row;
1013 if input_row_count > max_row_count {
1014 return None;
1015 }
1016
1017 // If the outline node contains the diagnostic and is small enough, just use that.
1018 let outline_range = snapshot.outline_range_containing(input_range.clone());
1019 if let Some(outline_range) = outline_range.clone() {
1020 // Remove blank lines from start and end
1021 if let Some(start_row) = (outline_range.start.row..outline_range.end.row)
1022 .find(|row| !snapshot.line_indent_for_row(*row).is_line_blank())
1023 && let Some(end_row) = (outline_range.start.row..outline_range.end.row + 1)
1024 .rev()
1025 .find(|row| !snapshot.line_indent_for_row(*row).is_line_blank())
1026 {
1027 let row_count = end_row.saturating_sub(start_row);
1028 if row_count <= max_row_count {
1029 return Some(RangeInclusive::new(
1030 outline_range.start.row,
1031 outline_range.end.row,
1032 ));
1033 }
1034 }
1035 }
1036
1037 let mut node = snapshot.syntax_ancestor(input_range.clone())?;
1038
1039 loop {
1040 let node_start = Point::from_ts_point(node.start_position());
1041 let node_end = Point::from_ts_point(node.end_position());
1042 let node_range = node_start..node_end;
1043 let row_count = node_end.row - node_start.row + 1;
1044 let mut ancestor_range = None;
1045 let reached_outline_node = cx.background_executor().scoped({
1046 let node_range = node_range.clone();
1047 let outline_range = outline_range.clone();
1048 let ancestor_range = &mut ancestor_range;
1049 |scope| {
1050 scope.spawn(async move {
1051 // Stop if we've exceeded the row count or reached an outline node. Then, find the interval
1052 // of node children which contains the query range. For example, this allows just returning
1053 // the header of a declaration rather than the entire declaration.
1054 if row_count > max_row_count || outline_range == Some(node_range.clone()) {
1055 let mut cursor = node.walk();
1056 let mut included_child_start = None;
1057 let mut included_child_end = None;
1058 let mut previous_end = node_start;
1059 if cursor.goto_first_child() {
1060 loop {
1061 let child_node = cursor.node();
1062 let child_range =
1063 previous_end..Point::from_ts_point(child_node.end_position());
1064 if included_child_start.is_none()
1065 && child_range.contains(&input_range.start)
1066 {
1067 included_child_start = Some(child_range.start);
1068 }
1069 if child_range.contains(&input_range.end) {
1070 included_child_end = Some(child_range.end);
1071 }
1072 previous_end = child_range.end;
1073 if !cursor.goto_next_sibling() {
1074 break;
1075 }
1076 }
1077 }
1078 let end = included_child_end.unwrap_or(node_range.end);
1079 if let Some(start) = included_child_start {
1080 let row_count = end.row - start.row;
1081 if row_count < max_row_count {
1082 *ancestor_range =
1083 Some(Some(RangeInclusive::new(start.row, end.row)));
1084 return;
1085 }
1086 }
1087 *ancestor_range = Some(None);
1088 }
1089 })
1090 }
1091 });
1092 reached_outline_node.await;
1093 if let Some(node) = ancestor_range {
1094 return node;
1095 }
1096
1097 let node_name = node.grammar_name();
1098 let node_row_range = RangeInclusive::new(node_range.start.row, node_range.end.row);
1099 if node_name.ends_with("block") {
1100 return Some(node_row_range);
1101 } else if node_name.ends_with("statement") || node_name.ends_with("declaration") {
1102 // Expand to the nearest dedent or blank line for statements and declarations.
1103 let tab_size = cx
1104 .update(|cx| snapshot.settings_at(node_range.start, cx).tab_size.get())
1105 .ok()?;
1106 let indent_level = snapshot
1107 .line_indent_for_row(node_range.start.row)
1108 .len(tab_size);
1109 let rows_remaining = max_row_count.saturating_sub(row_count);
1110 let Some(start_row) = (node_range.start.row.saturating_sub(rows_remaining)
1111 ..node_range.start.row)
1112 .rev()
1113 .find(|row| {
1114 is_line_blank_or_indented_less(indent_level, *row, tab_size, &snapshot.clone())
1115 })
1116 else {
1117 return Some(node_row_range);
1118 };
1119 let rows_remaining = max_row_count.saturating_sub(node_range.end.row - start_row);
1120 let Some(end_row) = (node_range.end.row + 1
1121 ..cmp::min(
1122 node_range.end.row + rows_remaining + 1,
1123 snapshot.row_count(),
1124 ))
1125 .find(|row| {
1126 is_line_blank_or_indented_less(indent_level, *row, tab_size, &snapshot.clone())
1127 })
1128 else {
1129 return Some(node_row_range);
1130 };
1131 return Some(RangeInclusive::new(start_row, end_row));
1132 }
1133
1134 // TODO: doing this instead of walking a cursor as that doesn't work - why?
1135 let Some(parent) = node.parent() else {
1136 log::info!(
1137 "Expanding to ancestor reached the top node, so using default context line count.",
1138 );
1139 return None;
1140 };
1141 node = parent;
1142 }
1143}
1144
1145fn is_line_blank_or_indented_less(
1146 indent_level: u32,
1147 row: u32,
1148 tab_size: u32,
1149 snapshot: &BufferSnapshot,
1150) -> bool {
1151 let line_indent = snapshot.line_indent_for_row(row);
1152 line_indent.is_line_blank() || line_indent.len(tab_size) < indent_level
1153}