From d7a25c169693f8777e22f1acccd066d64265a118 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 15 Jul 2024 22:58:18 +0300 Subject: [PATCH] Add an experimental, WIP diagnostics grouping panel (#14515) Provide a current, broken state as an experimental way to browse diagnostics. The diagnostics are grouped by lines and reduced into a block that, in case of multiple diagnostics per line, could be toggled back and forth to show more diagnostics on the line. Use `grouped_diagnostics::Deploy` to show the panel. Issues remaining: * panic on warnings toggle due to incorrect excerpt manipulation * badly styled blocks * no key bindings to navigate between blocks and toggle them * overall odd usability gains for certain groups of people Due to all above, the thing is feature-gated and not exposed to regular people. Release Notes: - N/A --- Cargo.lock | 2 + crates/assistant/src/assistant_panel.rs | 11 +- crates/diagnostics/Cargo.toml | 2 + crates/diagnostics/src/diagnostics.rs | 11 +- crates/diagnostics/src/diagnostics_tests.rs | 6 +- crates/diagnostics/src/grouped_diagnostics.rs | 1419 +++++++++++++++++ crates/editor/src/display_map.rs | 1 + crates/editor/src/display_map/block_map.rs | 73 +- crates/editor/src/editor.rs | 94 +- crates/editor/src/element.rs | 105 +- crates/feature_flags/src/feature_flags.rs | 5 + crates/multi_buffer/src/multi_buffer.rs | 11 +- crates/project/src/project.rs | 2 +- 13 files changed, 1647 insertions(+), 95 deletions(-) create mode 100644 crates/diagnostics/src/grouped_diagnostics.rs diff --git a/Cargo.lock b/Cargo.lock index 3298a1a775..5224e111f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3402,11 +3402,13 @@ dependencies = [ "ctor", "editor", "env_logger", + "feature_flags", "futures 0.3.28", "gpui", "language", "log", "lsp", + "multi_buffer", "pretty_assertions", "project", "rand 0.8.5", diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 338e814eee..dbd00e33eb 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -2549,10 +2549,13 @@ fn render_slash_command_output_toggle( fold: ToggleFold, _cx: &mut WindowContext, ) -> AnyElement { - Disclosure::new(("slash-command-output-fold-indicator", row.0), !is_folded) - .selected(is_folded) - .on_click(move |_e, cx| fold(!is_folded, cx)) - .into_any_element() + Disclosure::new( + ("slash-command-output-fold-indicator", row.0 as u64), + !is_folded, + ) + .selected(is_folded) + .on_click(move |_e, cx| fold(!is_folded, cx)) + .into_any_element() } fn render_pending_slash_command_gutter_decoration( diff --git a/crates/diagnostics/Cargo.toml b/crates/diagnostics/Cargo.toml index 48f05444e4..74641480d2 100644 --- a/crates/diagnostics/Cargo.toml +++ b/crates/diagnostics/Cargo.toml @@ -18,11 +18,13 @@ collections.workspace = true ctor.workspace = true editor.workspace = true env_logger.workspace = true +feature_flags.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true log.workspace = true lsp.workspace = true +multi_buffer.workspace = true project.workspace = true rand.workspace = true schemars.workspace = true diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 419e3d3fdc..06b74de825 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -4,6 +4,7 @@ mod toolbar_controls; #[cfg(test)] mod diagnostics_tests; +mod grouped_diagnostics; use anyhow::Result; use collections::{BTreeSet, HashSet}; @@ -14,6 +15,7 @@ use editor::{ scroll::Autoscroll, Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, ToOffset, }; +use feature_flags::FeatureFlagAppExt; use futures::{ channel::mpsc::{self, UnboundedSender}, StreamExt as _, @@ -52,6 +54,9 @@ pub fn init(cx: &mut AppContext) { ProjectDiagnosticsSettings::register(cx); cx.observe_new_views(ProjectDiagnosticsEditor::register) .detach(); + if !cx.has_flag::() { + grouped_diagnostics::init(cx); + } } struct ProjectDiagnosticsEditor { @@ -466,7 +471,9 @@ impl ProjectDiagnosticsEditor { position: (excerpt_id, entry.range.start), height: diagnostic.message.matches('\n').count() as u8 + 1, style: BlockStyle::Fixed, - render: diagnostic_block_renderer(diagnostic, true), + render: diagnostic_block_renderer( + diagnostic, None, true, true, + ), disposition: BlockDisposition::Below, }); } @@ -798,7 +805,7 @@ impl Item for ProjectDiagnosticsEditor { const DIAGNOSTIC_HEADER: &'static str = "diagnostic header"; fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock { - let (message, code_ranges) = highlight_diagnostic_message(&diagnostic); + let (message, code_ranges) = highlight_diagnostic_message(&diagnostic, None); let message: SharedString = message; Box::new(move |cx| { let highlight_style: HighlightStyle = cx.theme().colors().text_accent.into(); diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index 04a1ce03f3..044b872590 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -973,8 +973,8 @@ fn editor_blocks( blocks.extend( snapshot .blocks_in_range(DisplayRow(0)..snapshot.max_point().row()) - .enumerate() - .filter_map(|(ix, (row, block))| { + .filter_map(|(row, block)| { + let transform_block_id = block.id(); let name: SharedString = match block { TransformBlock::Custom(block) => { let mut element = block.render(&mut BlockContext { @@ -984,7 +984,7 @@ fn editor_blocks( line_height: px(0.), em_width: px(0.), max_width: px(0.), - block_id: ix, + transform_block_id, editor_style: &editor::EditorStyle::default(), }); let element = element.downcast_mut::>().unwrap(); diff --git a/crates/diagnostics/src/grouped_diagnostics.rs b/crates/diagnostics/src/grouped_diagnostics.rs new file mode 100644 index 0000000000..db790e75f2 --- /dev/null +++ b/crates/diagnostics/src/grouped_diagnostics.rs @@ -0,0 +1,1419 @@ +use anyhow::Result; +use collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use editor::{ + diagnostic_block_renderer, + display_map::{ + BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock, + TransformBlockId, + }, + scroll::Autoscroll, + Bias, Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, ToPoint, +}; +use futures::{ + channel::mpsc::{self, UnboundedSender}, + StreamExt as _, +}; +use gpui::{ + actions, div, AnyElement, AnyView, AppContext, Context, EventEmitter, FocusHandle, + FocusableView, InteractiveElement, IntoElement, Model, ParentElement, Render, SharedString, + Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext, +}; +use language::{ + Buffer, BufferSnapshot, DiagnosticEntry, DiagnosticSeverity, OffsetRangeExt, ToOffset, + ToPoint as _, +}; +use lsp::LanguageServerId; +use multi_buffer::{build_excerpt_ranges, ExpandExcerptDirection, MultiBufferRow}; +use project::{DiagnosticSummary, Project, ProjectPath}; +use settings::Settings; +use std::{ + any::{Any, TypeId}, + cmp::Ordering, + ops::Range, + sync::{ + atomic::{self, AtomicBool}, + Arc, + }, +}; +use theme::ActiveTheme; +use ui::{h_flex, prelude::*, Icon, IconName, Label}; +use util::{debug_panic, ResultExt}; +use workspace::{ + item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams}, + ItemNavHistory, Pane, ToolbarItemLocation, Workspace, +}; + +use crate::project_diagnostics_settings::ProjectDiagnosticsSettings; +actions!(grouped_diagnostics, [Deploy, ToggleWarnings]); + +pub fn init(cx: &mut AppContext) { + cx.observe_new_views(GroupedDiagnosticsEditor::register) + .detach(); +} + +struct GroupedDiagnosticsEditor { + project: Model, + workspace: WeakView, + focus_handle: FocusHandle, + editor: View, + summary: DiagnosticSummary, + excerpts: Model, + path_states: Vec, + paths_to_update: BTreeSet<(ProjectPath, LanguageServerId)>, + include_warnings: bool, + context: u32, + update_paths_tx: UnboundedSender<(ProjectPath, Option)>, + _update_excerpts_task: Task>, + _subscription: Subscription, +} + +struct PathState { + path: ProjectPath, + first_excerpt_id: Option, + last_excerpt_id: Option, + diagnostics: Vec<(DiagnosticData, BlockId)>, +} + +#[derive(Debug, Clone)] +struct DiagnosticData { + language_server_id: LanguageServerId, + is_primary: bool, + entry: DiagnosticEntry, +} + +impl DiagnosticData { + fn diagnostic_entries_equal(&self, other: &DiagnosticData) -> bool { + self.language_server_id == other.language_server_id + && self.is_primary == other.is_primary + && self.entry.range == other.entry.range + && equal_without_group_ids(&self.entry.diagnostic, &other.entry.diagnostic) + } +} + +// `group_id` can differ between LSP server diagnostics output, +// hence ignore it when checking diagnostics for updates. +fn equal_without_group_ids(a: &language::Diagnostic, b: &language::Diagnostic) -> bool { + a.source == b.source + && a.code == b.code + && a.severity == b.severity + && a.message == b.message + && a.is_primary == b.is_primary + && a.is_disk_based == b.is_disk_based + && a.is_unnecessary == b.is_unnecessary +} + +impl EventEmitter for GroupedDiagnosticsEditor {} + +impl Render for GroupedDiagnosticsEditor { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let child = if self.path_states.is_empty() { + div() + .bg(cx.theme().colors().editor_background) + .flex() + .items_center() + .justify_center() + .size_full() + .child(Label::new("No problems in workspace")) + } else { + div().size_full().child(self.editor.clone()) + }; + + div() + .track_focus(&self.focus_handle) + .when(self.path_states.is_empty(), |el| { + el.key_context("EmptyPane") + }) + .size_full() + .on_action(cx.listener(Self::toggle_warnings)) + .child(child) + } +} + +impl GroupedDiagnosticsEditor { + fn register(workspace: &mut Workspace, _: &mut ViewContext) { + workspace.register_action(Self::deploy); + } + + fn new_with_context( + context: u32, + project_handle: Model, + workspace: WeakView, + cx: &mut ViewContext, + ) -> Self { + let project_event_subscription = + cx.subscribe(&project_handle, |this, project, event, cx| match event { + project::Event::DiskBasedDiagnosticsStarted { .. } => { + cx.notify(); + } + project::Event::DiskBasedDiagnosticsFinished { language_server_id } => { + log::debug!("disk based diagnostics finished for server {language_server_id}"); + this.enqueue_update_stale_excerpts(Some(*language_server_id)); + } + project::Event::DiagnosticsUpdated { + language_server_id, + path, + } => { + this.paths_to_update + .insert((path.clone(), *language_server_id)); + this.summary = project.read(cx).diagnostic_summary(false, cx); + cx.emit(EditorEvent::TitleChanged); + + if this.editor.focus_handle(cx).contains_focused(cx) || this.focus_handle.contains_focused(cx) { + log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. recording change"); + } else { + log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. updating excerpts"); + this.enqueue_update_stale_excerpts(Some(*language_server_id)); + } + } + _ => {} + }); + + let focus_handle = cx.focus_handle(); + cx.on_focus_in(&focus_handle, |this, cx| this.focus_in(cx)) + .detach(); + cx.on_focus_out(&focus_handle, |this, _event, cx| this.focus_out(cx)) + .detach(); + + let excerpts = cx.new_model(|cx| { + MultiBuffer::new( + project_handle.read(cx).replica_id(), + project_handle.read(cx).capability(), + ) + }); + let editor = cx.new_view(|cx| { + let mut editor = + Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), false, cx); + editor.set_vertical_scroll_margin(5, cx); + editor + }); + cx.subscribe(&editor, |this, _editor, event: &EditorEvent, cx| { + cx.emit(event.clone()); + match event { + EditorEvent::Focused => { + if this.path_states.is_empty() { + cx.focus(&this.focus_handle); + } + } + EditorEvent::Blurred => this.enqueue_update_stale_excerpts(None), + _ => {} + } + }) + .detach(); + + let (update_excerpts_tx, mut update_excerpts_rx) = mpsc::unbounded(); + + let project = project_handle.read(cx); + let mut this = Self { + project: project_handle.clone(), + context, + summary: project.diagnostic_summary(false, cx), + workspace, + excerpts, + focus_handle, + editor, + path_states: Vec::new(), + paths_to_update: BTreeSet::new(), + include_warnings: ProjectDiagnosticsSettings::get_global(cx).include_warnings, + update_paths_tx: update_excerpts_tx, + _update_excerpts_task: cx.spawn(move |this, mut cx| async move { + while let Some((path, language_server_id)) = update_excerpts_rx.next().await { + if let Some(buffer) = project_handle + .update(&mut cx, |project, cx| project.open_buffer(path.clone(), cx))? + .await + .log_err() + { + this.update(&mut cx, |this, cx| { + this.update_excerpts(path, language_server_id, buffer, cx); + })?; + } + } + anyhow::Ok(()) + }), + _subscription: project_event_subscription, + }; + this.enqueue_update_all_excerpts(cx); + this + } + + fn new( + project_handle: Model, + workspace: WeakView, + cx: &mut ViewContext, + ) -> Self { + Self::new_with_context( + editor::DEFAULT_MULTIBUFFER_CONTEXT, + project_handle, + workspace, + cx, + ) + } + + fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext) { + if let Some(existing) = workspace.item_of_type::(cx) { + workspace.activate_item(&existing, cx); + } else { + let workspace_handle = cx.view().downgrade(); + let diagnostics = cx.new_view(|cx| { + GroupedDiagnosticsEditor::new(workspace.project().clone(), workspace_handle, cx) + }); + workspace.add_item_to_active_pane(Box::new(diagnostics), None, cx); + } + } + + fn toggle_warnings(&mut self, _: &ToggleWarnings, cx: &mut ViewContext) { + self.include_warnings = !self.include_warnings; + self.enqueue_update_all_excerpts(cx); + cx.notify(); + } + + fn focus_in(&mut self, cx: &mut ViewContext) { + if self.focus_handle.is_focused(cx) && !self.path_states.is_empty() { + self.editor.focus_handle(cx).focus(cx) + } + } + + fn focus_out(&mut self, cx: &mut ViewContext) { + if !self.focus_handle.is_focused(cx) && !self.editor.focus_handle(cx).is_focused(cx) { + self.enqueue_update_stale_excerpts(None); + } + } + + /// Enqueue an update of all excerpts. Updates all paths that either + /// currently have diagnostics or are currently present in this view. + fn enqueue_update_all_excerpts(&mut self, cx: &mut ViewContext) { + self.project.update(cx, |project, cx| { + let mut paths = project + .diagnostic_summaries(false, cx) + .map(|(path, _, _)| path) + .collect::>(); + paths.extend(self.path_states.iter().map(|state| state.path.clone())); + for path in paths { + self.update_paths_tx.unbounded_send((path, None)).unwrap(); + } + }); + } + + /// Enqueue an update of the excerpts for any path whose diagnostics are known + /// to have changed. If a language server id is passed, then only the excerpts for + /// that language server's diagnostics will be updated. Otherwise, all stale excerpts + /// will be refreshed. + fn enqueue_update_stale_excerpts(&mut self, language_server_id: Option) { + for (path, server_id) in &self.paths_to_update { + if language_server_id.map_or(true, |id| id == *server_id) { + self.update_paths_tx + .unbounded_send((path.clone(), Some(*server_id))) + .unwrap(); + } + } + } + + fn update_excerpts( + &mut self, + path_to_update: ProjectPath, + server_to_update: Option, + buffer: Model, + cx: &mut ViewContext, + ) { + self.paths_to_update.retain(|(path, server_id)| { + *path != path_to_update + || server_to_update.map_or(false, |to_update| *server_id != to_update) + }); + + // TODO kb change selections as in the old panel, to the next primary diagnostics + // TODO kb make [shift-]f8 to work, jump to the next block group + let _was_empty = self.path_states.is_empty(); + let path_ix = match self.path_states.binary_search_by(|probe| { + project::compare_paths((&probe.path.path, true), (&path_to_update.path, true)) + }) { + Ok(ix) => ix, + Err(ix) => { + self.path_states.insert( + ix, + PathState { + path: path_to_update.clone(), + diagnostics: Vec::new(), + last_excerpt_id: None, + first_excerpt_id: None, + }, + ); + ix + } + }; + + // TODO kb when warnings are turned off, there's a lot of refresh for many paths happening, why? + let max_severity = if self.include_warnings { + DiagnosticSeverity::WARNING + } else { + DiagnosticSeverity::ERROR + }; + + let excerpt_borders = self.excerpt_borders_for_path(path_ix); + let path_state = &mut self.path_states[path_ix]; + let buffer_snapshot = buffer.read(cx).snapshot(); + + let mut path_update = PathUpdate::new( + excerpt_borders, + &buffer_snapshot, + server_to_update, + max_severity, + path_state, + ); + path_update.prepare_excerpt_data( + self.context, + self.excerpts.read(cx).snapshot(cx), + buffer.read(cx).snapshot(), + path_state.diagnostics.iter(), + ); + self.excerpts.update(cx, |multi_buffer, cx| { + path_update.apply_excerpt_changes( + path_state, + self.context, + buffer_snapshot, + multi_buffer, + buffer, + cx, + ); + }); + + let new_multi_buffer_snapshot = self.excerpts.read(cx).snapshot(cx); + let blocks_to_insert = + path_update.prepare_blocks_to_insert(self.editor.clone(), new_multi_buffer_snapshot); + + let new_block_ids = self.editor.update(cx, |editor, cx| { + editor.remove_blocks(std::mem::take(&mut path_update.blocks_to_remove), None, cx); + editor.insert_blocks(blocks_to_insert, Some(Autoscroll::fit()), cx) + }); + path_state.diagnostics = path_update.new_blocks(new_block_ids); + + if self.path_states.is_empty() { + if self.editor.focus_handle(cx).is_focused(cx) { + cx.focus(&self.focus_handle); + } + } else if self.focus_handle.is_focused(cx) { + let focus_handle = self.editor.focus_handle(cx); + cx.focus(&focus_handle); + } + + #[cfg(test)] + self.check_invariants(cx); + + cx.notify(); + } + + fn excerpt_borders_for_path(&self, path_ix: usize) -> (Option, Option) { + let previous_path_state_ix = + Some(path_ix.saturating_sub(1)).filter(|&previous_path_ix| previous_path_ix != path_ix); + let next_path_state_ix = path_ix + 1; + let start = previous_path_state_ix.and_then(|i| { + self.path_states[..=i] + .iter() + .rev() + .find_map(|state| state.last_excerpt_id) + }); + let end = self.path_states[next_path_state_ix..] + .iter() + .find_map(|state| state.first_excerpt_id); + (start, end) + } + + #[cfg(test)] + fn check_invariants(&self, cx: &mut ViewContext) { + let mut excerpts = Vec::new(); + for (id, buffer, _) in self.excerpts.read(cx).snapshot(cx).excerpts() { + if let Some(file) = buffer.file() { + excerpts.push((id, file.path().clone())); + } + } + + let mut prev_path = None; + for (_, path) in &excerpts { + if let Some(prev_path) = prev_path { + if path < prev_path { + panic!("excerpts are not sorted by path {:?}", excerpts); + } + } + prev_path = Some(path); + } + } +} + +impl FocusableView for GroupedDiagnosticsEditor { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Item for GroupedDiagnosticsEditor { + type Event = EditorEvent; + + fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn deactivated(&mut self, cx: &mut ViewContext) { + self.editor.update(cx, |editor, cx| editor.deactivated(cx)); + } + + fn navigate(&mut self, data: Box, cx: &mut ViewContext) -> bool { + self.editor + .update(cx, |editor, cx| editor.navigate(data, cx)) + } + + fn tab_tooltip_text(&self, _: &AppContext) -> Option { + Some("Project Diagnostics".into()) + } + + fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement { + if self.summary.error_count == 0 && self.summary.warning_count == 0 { + Label::new("No problems") + .color(if params.selected { + Color::Default + } else { + Color::Muted + }) + .into_any_element() + } else { + h_flex() + .gap_1() + .when(self.summary.error_count > 0, |then| { + then.child( + h_flex() + .gap_1() + .child(Icon::new(IconName::XCircle).color(Color::Error)) + .child(Label::new(self.summary.error_count.to_string()).color( + if params.selected { + Color::Default + } else { + Color::Muted + }, + )), + ) + }) + .when(self.summary.warning_count > 0, |then| { + then.child( + h_flex() + .gap_1() + .child(Icon::new(IconName::ExclamationTriangle).color(Color::Warning)) + .child(Label::new(self.summary.warning_count.to_string()).color( + if params.selected { + Color::Default + } else { + Color::Muted + }, + )), + ) + }) + .into_any_element() + } + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("project diagnostics") + } + + fn for_each_project_item( + &self, + cx: &AppContext, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item), + ) { + self.editor.for_each_project_item(cx, f) + } + + fn is_singleton(&self, _: &AppContext) -> bool { + false + } + + fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext) { + self.editor.update(cx, |editor, _| { + editor.set_nav_history(Some(nav_history)); + }); + } + + fn clone_on_split( + &self, + _workspace_id: Option, + cx: &mut ViewContext, + ) -> Option> + where + Self: Sized, + { + Some(cx.new_view(|cx| { + GroupedDiagnosticsEditor::new(self.project.clone(), self.workspace.clone(), cx) + })) + } + + fn is_dirty(&self, cx: &AppContext) -> bool { + self.excerpts.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &AppContext) -> bool { + self.excerpts.read(cx).has_conflict(cx) + } + + fn can_save(&self, _: &AppContext) -> bool { + true + } + + fn save( + &mut self, + format: bool, + project: Model, + cx: &mut ViewContext, + ) -> Task> { + self.editor.save(format, project, cx) + } + + fn save_as( + &mut self, + _: Model, + _: ProjectPath, + _: &mut ViewContext, + ) -> Task> { + unreachable!() + } + + fn reload(&mut self, project: Model, cx: &mut ViewContext) -> Task> { + self.editor.reload(project, cx) + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a View, + _: &'a AppContext, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.to_any()) + } else if type_id == TypeId::of::() { + Some(self.editor.to_any()) + } else { + None + } + } + + fn breadcrumb_location(&self) -> ToolbarItemLocation { + ToolbarItemLocation::PrimaryLeft + } + + fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option> { + self.editor.breadcrumbs(theme, cx) + } + + fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext) { + self.editor + .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx)); + } + + fn serialized_item_kind() -> Option<&'static str> { + Some("diagnostics") + } + + fn deserialize( + project: Model, + workspace: WeakView, + _workspace_id: workspace::WorkspaceId, + _item_id: workspace::ItemId, + cx: &mut ViewContext, + ) -> Task>> { + Task::ready(Ok(cx.new_view(|cx| Self::new(project, workspace, cx)))) + } +} + +fn compare_data_locations( + old: &DiagnosticData, + new: &DiagnosticData, + snapshot: &BufferSnapshot, +) -> Ordering { + compare_diagnostics(&old.entry, &new.entry, snapshot) + .then_with(|| old.language_server_id.cmp(&new.language_server_id)) +} + +fn compare_diagnostics( + old: &DiagnosticEntry, + new: &DiagnosticEntry, + snapshot: &BufferSnapshot, +) -> Ordering { + compare_diagnostic_ranges(&old.range, &new.range, snapshot) + .then_with(|| old.diagnostic.message.cmp(&new.diagnostic.message)) +} + +fn compare_diagnostic_ranges( + old: &Range, + new: &Range, + snapshot: &BufferSnapshot, +) -> Ordering { + // The diagnostics may point to a previously open Buffer for this file. + if !old.start.is_valid(snapshot) || !new.start.is_valid(snapshot) { + return Ordering::Greater; + } + + old.start + .to_offset(snapshot) + .cmp(&new.start.to_offset(snapshot)) + .then_with(|| { + old.end + .to_offset(snapshot) + .cmp(&new.end.to_offset(snapshot)) + }) +} + +// TODO kb wrong? What to do here instead? +fn compare_diagnostic_range_edges( + old: &Range, + new: &Range, + snapshot: &BufferSnapshot, +) -> (Ordering, Ordering) { + // The diagnostics may point to a previously open Buffer for this file. + let start_cmp = match (old.start.is_valid(snapshot), new.start.is_valid(snapshot)) { + (false, false) => old.start.offset.cmp(&new.start.offset), + (false, true) => Ordering::Greater, + (true, false) => Ordering::Less, + (true, true) => old.start.cmp(&new.start, snapshot), + }; + + let end_cmp = old + .end + .to_offset(snapshot) + .cmp(&new.end.to_offset(snapshot)); + (start_cmp, end_cmp) +} + +#[derive(Debug)] +struct PathUpdate { + path_excerpts_borders: (Option, Option), + latest_excerpt_id: ExcerptId, + new_diagnostics: Vec<(DiagnosticData, Option)>, + diagnostics_by_row_label: BTreeMap)>, + blocks_to_remove: HashSet, + unchanged_blocks: HashMap, + excerpts_with_new_diagnostics: HashSet, + excerpts_to_remove: Vec, + excerpt_expands: HashMap<(ExpandExcerptDirection, u32), Vec>, + excerpts_to_add: HashMap>>, + first_excerpt_id: Option, + last_excerpt_id: Option, +} + +impl PathUpdate { + fn new( + path_excerpts_borders: (Option, Option), + buffer_snapshot: &BufferSnapshot, + server_to_update: Option, + max_severity: DiagnosticSeverity, + path_state: &PathState, + ) -> Self { + let mut blocks_to_remove = HashSet::default(); + let mut removed_groups = HashSet::default(); + let mut new_diagnostics = path_state + .diagnostics + .iter() + .filter(|(diagnostic_data, _)| { + server_to_update.map_or(true, |server_id| { + diagnostic_data.language_server_id != server_id + }) + }) + .filter(|(diagnostic_data, block_id)| { + let diagnostic = &diagnostic_data.entry.diagnostic; + let retain = !diagnostic.is_primary || diagnostic.severity <= max_severity; + if !retain { + removed_groups.insert(diagnostic.group_id); + blocks_to_remove.insert(*block_id); + } + retain + }) + .map(|(diagnostic, block_id)| (diagnostic.clone(), Some(*block_id))) + .collect::>(); + new_diagnostics.retain(|(diagnostic_data, block_id)| { + let retain = !removed_groups.contains(&diagnostic_data.entry.diagnostic.group_id); + if !retain { + if let Some(block_id) = block_id { + blocks_to_remove.insert(*block_id); + } + } + retain + }); + for (server_id, group) in buffer_snapshot + .diagnostic_groups(server_to_update) + .into_iter() + .filter(|(_, group)| { + group.entries[group.primary_ix].diagnostic.severity <= max_severity + }) + { + for (diagnostic_index, diagnostic) in group.entries.iter().enumerate() { + let new_data = DiagnosticData { + language_server_id: server_id, + is_primary: diagnostic_index == group.primary_ix, + entry: diagnostic.clone(), + }; + let (Ok(i) | Err(i)) = new_diagnostics.binary_search_by(|probe| { + compare_data_locations(&probe.0, &new_data, &buffer_snapshot) + }); + new_diagnostics.insert(i, (new_data, None)); + } + } + + let latest_excerpt_id = path_excerpts_borders.0.unwrap_or_else(|| ExcerptId::min()); + Self { + latest_excerpt_id, + path_excerpts_borders, + new_diagnostics, + blocks_to_remove, + diagnostics_by_row_label: BTreeMap::new(), + excerpts_to_remove: Vec::new(), + excerpts_with_new_diagnostics: HashSet::default(), + unchanged_blocks: HashMap::default(), + excerpts_to_add: HashMap::default(), + excerpt_expands: HashMap::default(), + first_excerpt_id: None, + last_excerpt_id: None, + } + } + + fn prepare_excerpt_data<'a>( + &'a mut self, + context: u32, + multi_buffer_snapshot: MultiBufferSnapshot, + buffer_snapshot: BufferSnapshot, + current_diagnostics: impl Iterator + 'a, + ) { + let mut current_diagnostics = current_diagnostics.fuse().peekable(); + let mut excerpts_to_expand = + HashMap::>::default(); + let mut current_excerpts = path_state_excerpts( + self.path_excerpts_borders.0, + self.path_excerpts_borders.1, + &multi_buffer_snapshot, + ) + .fuse() + .peekable(); + + for (diagnostic_index, (new_diagnostic, existing_block)) in + self.new_diagnostics.iter().enumerate() + { + if let Some(existing_block) = existing_block { + self.unchanged_blocks + .insert(diagnostic_index, *existing_block); + } + + loop { + match current_excerpts.peek() { + None => { + let excerpt_ranges = self + .excerpts_to_add + .entry(self.latest_excerpt_id) + .or_default(); + let new_range = new_diagnostic.entry.range.clone(); + let (Ok(i) | Err(i)) = excerpt_ranges.binary_search_by(|probe| { + compare_diagnostic_ranges(probe, &new_range, &buffer_snapshot) + }); + excerpt_ranges.insert(i, new_range); + break; + } + Some((current_excerpt_id, _, current_excerpt_range)) => { + match compare_diagnostic_range_edges( + ¤t_excerpt_range.context, + &new_diagnostic.entry.range, + &buffer_snapshot, + ) { + /* + new_s new_e + ----[---->><<----]-- + cur_s cur_e + */ + ( + Ordering::Less | Ordering::Equal, + Ordering::Greater | Ordering::Equal, + ) => { + self.excerpts_with_new_diagnostics + .insert(*current_excerpt_id); + if self.first_excerpt_id.is_none() { + self.first_excerpt_id = Some(*current_excerpt_id); + } + self.last_excerpt_id = Some(*current_excerpt_id); + break; + } + /* + cur_s cur_e + ---->>>>>[--]<<<<<-- + new_s new_e + */ + ( + Ordering::Greater | Ordering::Equal, + Ordering::Less | Ordering::Equal, + ) => { + let expand_up = current_excerpt_range + .context + .start + .to_point(&buffer_snapshot) + .row + .saturating_sub( + new_diagnostic + .entry + .range + .start + .to_point(&buffer_snapshot) + .row, + ); + let expand_down = new_diagnostic + .entry + .range + .end + .to_point(&buffer_snapshot) + .row + .saturating_sub( + current_excerpt_range + .context + .end + .to_point(&buffer_snapshot) + .row, + ); + let expand_value = excerpts_to_expand + .entry(*current_excerpt_id) + .or_default() + .entry(ExpandExcerptDirection::UpAndDown) + .or_default(); + *expand_value = (*expand_value).max(expand_up).max(expand_down); + self.excerpts_with_new_diagnostics + .insert(*current_excerpt_id); + if self.first_excerpt_id.is_none() { + self.first_excerpt_id = Some(*current_excerpt_id); + } + self.last_excerpt_id = Some(*current_excerpt_id); + break; + } + /* + new_s new_e + > < + ----[---->>>]<<<<<-- + cur_s cur_e + + or + new_s new_e + > < + ----[----]-->>><<<-- + cur_s cur_e + */ + (Ordering::Less, Ordering::Less) => { + if current_excerpt_range + .context + .end + .cmp(&new_diagnostic.entry.range.start, &buffer_snapshot) + .is_ge() + { + let expand_down = new_diagnostic + .entry + .range + .end + .to_point(&buffer_snapshot) + .row + .saturating_sub( + current_excerpt_range + .context + .end + .to_point(&buffer_snapshot) + .row, + ); + let expand_value = excerpts_to_expand + .entry(*current_excerpt_id) + .or_default() + .entry(ExpandExcerptDirection::Down) + .or_default(); + *expand_value = (*expand_value).max(expand_down); + self.excerpts_with_new_diagnostics + .insert(*current_excerpt_id); + if self.first_excerpt_id.is_none() { + self.first_excerpt_id = Some(*current_excerpt_id); + } + self.last_excerpt_id = Some(*current_excerpt_id); + break; + } else if !self + .excerpts_with_new_diagnostics + .contains(current_excerpt_id) + { + self.excerpts_to_remove.push(*current_excerpt_id); + } + } + /* + cur_s cur_e + ---->>>>>[<<<<----]-- + > < + new_s new_e + + or + cur_s cur_e + ---->>><<<--[----]-- + > < + new_s new_e + */ + (Ordering::Greater, Ordering::Greater) => { + if current_excerpt_range + .context + .start + .cmp(&new_diagnostic.entry.range.end, &buffer_snapshot) + .is_le() + { + let expand_up = current_excerpt_range + .context + .start + .to_point(&buffer_snapshot) + .row + .saturating_sub( + new_diagnostic + .entry + .range + .start + .to_point(&buffer_snapshot) + .row, + ); + let expand_value = excerpts_to_expand + .entry(*current_excerpt_id) + .or_default() + .entry(ExpandExcerptDirection::Up) + .or_default(); + *expand_value = (*expand_value).max(expand_up); + self.excerpts_with_new_diagnostics + .insert(*current_excerpt_id); + if self.first_excerpt_id.is_none() { + self.first_excerpt_id = Some(*current_excerpt_id); + } + self.last_excerpt_id = Some(*current_excerpt_id); + break; + } else { + let excerpt_ranges = self + .excerpts_to_add + .entry(self.latest_excerpt_id) + .or_default(); + let new_range = new_diagnostic.entry.range.clone(); + let (Ok(i) | Err(i)) = + excerpt_ranges.binary_search_by(|probe| { + compare_diagnostic_ranges( + probe, + &new_range, + &buffer_snapshot, + ) + }); + excerpt_ranges.insert(i, new_range); + break; + } + } + } + if let Some((next_id, ..)) = current_excerpts.next() { + self.latest_excerpt_id = next_id; + } + } + } + } + + loop { + match current_diagnostics.peek() { + None => break, + Some((current_diagnostic, current_block)) => { + match compare_data_locations( + current_diagnostic, + new_diagnostic, + &buffer_snapshot, + ) { + Ordering::Less => { + self.blocks_to_remove.insert(*current_block); + } + Ordering::Equal => { + if current_diagnostic.diagnostic_entries_equal(&new_diagnostic) { + self.unchanged_blocks + .insert(diagnostic_index, *current_block); + } else { + self.blocks_to_remove.insert(*current_block); + } + let _ = current_diagnostics.next(); + break; + } + Ordering::Greater => break, + } + let _ = current_diagnostics.next(); + } + } + } + } + + self.excerpts_to_remove.retain(|excerpt_id| { + !self.excerpts_with_new_diagnostics.contains(excerpt_id) + && !excerpts_to_expand.contains_key(excerpt_id) + }); + self.excerpts_to_remove.extend( + current_excerpts + .filter(|(excerpt_id, ..)| { + !self.excerpts_with_new_diagnostics.contains(excerpt_id) + && !excerpts_to_expand.contains_key(excerpt_id) + }) + .map(|(excerpt_id, ..)| excerpt_id), + ); + let mut excerpt_expands = HashMap::default(); + for (excerpt_id, directions) in excerpts_to_expand { + let excerpt_expand = if directions.len() > 1 { + Some(( + ExpandExcerptDirection::UpAndDown, + directions + .values() + .max() + .copied() + .unwrap_or_default() + .max(context), + )) + } else { + directions + .into_iter() + .next() + .map(|(direction, expand)| (direction, expand.max(context))) + }; + if let Some(expand) = excerpt_expand { + excerpt_expands + .entry(expand) + .or_insert_with(|| Vec::new()) + .push(excerpt_id); + } + } + self.blocks_to_remove + .extend(current_diagnostics.map(|(_, block_id)| block_id)); + } + + fn apply_excerpt_changes( + &mut self, + path_state: &mut PathState, + context: u32, + buffer_snapshot: BufferSnapshot, + multi_buffer: &mut MultiBuffer, + buffer: Model, + cx: &mut gpui::ModelContext, + ) { + let max_point = buffer_snapshot.max_point(); + for (after_excerpt_id, ranges) in std::mem::take(&mut self.excerpts_to_add) { + let ranges = ranges + .into_iter() + .map(|range| { + let mut extended_point_range = range.to_point(&buffer_snapshot); + extended_point_range.start.row = + extended_point_range.start.row.saturating_sub(context); + extended_point_range.start.column = 0; + extended_point_range.end.row = + (extended_point_range.end.row + context).min(max_point.row); + extended_point_range.end.column = u32::MAX; + let extended_start = + buffer_snapshot.clip_point(extended_point_range.start, Bias::Left); + let extended_end = + buffer_snapshot.clip_point(extended_point_range.end, Bias::Right); + extended_start..extended_end + }) + .collect::>(); + let (joined_ranges, _) = build_excerpt_ranges(&buffer_snapshot, &ranges, context); + let excerpts = multi_buffer.insert_excerpts_after( + after_excerpt_id, + buffer.clone(), + joined_ranges, + cx, + ); + if self.first_excerpt_id.is_none() { + self.first_excerpt_id = excerpts.first().copied(); + } + self.last_excerpt_id = excerpts.last().copied(); + } + for ((direction, line_count), excerpts) in std::mem::take(&mut self.excerpt_expands) { + multi_buffer.expand_excerpts(excerpts, line_count, direction, cx); + } + multi_buffer.remove_excerpts(std::mem::take(&mut self.excerpts_to_remove), cx); + path_state.first_excerpt_id = self.first_excerpt_id; + path_state.last_excerpt_id = self.last_excerpt_id; + } + + fn prepare_blocks_to_insert( + &mut self, + editor: View, + multi_buffer_snapshot: MultiBufferSnapshot, + ) -> Vec> { + let mut updated_excerpts = path_state_excerpts( + self.path_excerpts_borders.0, + self.path_excerpts_borders.1, + &multi_buffer_snapshot, + ) + .fuse() + .peekable(); + let mut used_labels = BTreeMap::new(); + self.diagnostics_by_row_label = self.new_diagnostics.iter().enumerate().fold( + BTreeMap::new(), + |mut diagnostics_by_row_label, (diagnostic_index, (diagnostic, existing_block))| { + let new_diagnostic = &diagnostic.entry; + let block_position = new_diagnostic.range.start; + let excerpt_id = loop { + match updated_excerpts.peek() { + None => break None, + Some((excerpt_id, excerpt_buffer_snapshot, excerpt_range)) => { + let excerpt_range = &excerpt_range.context; + match block_position.cmp(&excerpt_range.start, excerpt_buffer_snapshot) + { + Ordering::Less => break None, + Ordering::Equal | Ordering::Greater => match block_position + .cmp(&excerpt_range.end, excerpt_buffer_snapshot) + { + Ordering::Equal | Ordering::Less => break Some(*excerpt_id), + Ordering::Greater => { + let _ = updated_excerpts.next(); + } + }, + } + } + } + }; + + let Some(position_in_multi_buffer) = excerpt_id.and_then(|excerpt_id| { + multi_buffer_snapshot.anchor_in_excerpt(excerpt_id, block_position) + }) else { + return diagnostics_by_row_label; + }; + + let multi_buffer_row = MultiBufferRow( + position_in_multi_buffer + .to_point(&multi_buffer_snapshot) + .row, + ); + + let grouped_diagnostics = &mut diagnostics_by_row_label + .entry(multi_buffer_row) + .or_insert_with(|| (position_in_multi_buffer, Vec::new())) + .1; + let new_label = used_labels + .entry(multi_buffer_row) + .or_insert_with(|| HashSet::default()) + .insert(( + new_diagnostic.diagnostic.source.as_deref(), + new_diagnostic.diagnostic.message.as_str(), + )); + + if !new_label || !grouped_diagnostics.is_empty() { + if let Some(existing_block) = existing_block { + self.blocks_to_remove.insert(*existing_block); + } + if let Some(block_id) = self.unchanged_blocks.remove(&diagnostic_index) { + self.blocks_to_remove.insert(block_id); + } + } + if new_label { + let (Ok(i) | Err(i)) = grouped_diagnostics.binary_search_by(|&probe| { + let a = &self.new_diagnostics[probe].0.entry.diagnostic; + let b = &self.new_diagnostics[diagnostic_index].0.entry.diagnostic; + a.group_id + .cmp(&b.group_id) + .then_with(|| a.is_primary.cmp(&b.is_primary).reverse()) + .then_with(|| a.severity.cmp(&b.severity)) + }); + grouped_diagnostics.insert(i, diagnostic_index); + } + + diagnostics_by_row_label + }, + ); + + self.diagnostics_by_row_label + .values() + .filter_map(|(earliest_in_row_position, diagnostics_at_line)| { + let earliest_in_row_position = *earliest_in_row_position; + match diagnostics_at_line.len() { + 0 => None, + len => { + if len == 1 { + let i = diagnostics_at_line.first().copied()?; + if self.unchanged_blocks.contains_key(&i) { + return None; + } + } + let lines_in_first_message = diagnostic_text_lines( + &self + .new_diagnostics + .get(diagnostics_at_line.first().copied()?)? + .0 + .entry + .diagnostic, + ); + let folded_block_height = lines_in_first_message.clamp(1, 2); + let diagnostics_to_render = Arc::new( + diagnostics_at_line + .iter() + .filter_map(|&index| self.new_diagnostics.get(index)) + .map(|(diagnostic_data, _)| { + diagnostic_data.entry.diagnostic.clone() + }) + .collect::>(), + ); + Some(BlockProperties { + position: earliest_in_row_position, + height: folded_block_height, + style: BlockStyle::Sticky, + render: render_same_line_diagnostics( + Arc::new(AtomicBool::new(false)), + diagnostics_to_render, + editor.clone(), + folded_block_height, + ), + disposition: BlockDisposition::Above, + }) + } + } + }) + .collect() + } + + fn new_blocks(mut self, new_block_ids: Vec) -> Vec<(DiagnosticData, BlockId)> { + let mut new_block_ids = new_block_ids.into_iter().fuse(); + for (_, (_, grouped_diagnostics)) in self.diagnostics_by_row_label { + let mut created_block_id = None; + match grouped_diagnostics.len() { + 0 => { + debug_panic!("Unexpected empty diagnostics group"); + continue; + } + 1 => { + let index = grouped_diagnostics[0]; + if let Some(&block_id) = self.unchanged_blocks.get(&index) { + self.new_diagnostics[index].1 = Some(block_id); + } else { + let Some(block_id) = + created_block_id.get_or_insert_with(|| new_block_ids.next()) + else { + debug_panic!("Expected a new block for each new diagnostic"); + continue; + }; + self.new_diagnostics[index].1 = Some(*block_id); + } + } + _ => { + let Some(block_id) = + created_block_id.get_or_insert_with(|| new_block_ids.next()) + else { + debug_panic!("Expected a new block for each new diagnostic group"); + continue; + }; + for i in grouped_diagnostics { + self.new_diagnostics[i].1 = Some(*block_id); + } + } + } + } + + self.new_diagnostics + .into_iter() + .filter_map(|(diagnostic, block_id)| Some((diagnostic, block_id?))) + .collect() + } +} + +fn render_same_line_diagnostics( + expanded: Arc, + diagnostics: Arc>, + editor_handle: View, + folded_block_height: u8, +) -> RenderBlock { + Box::new(move |cx: &mut BlockContext| { + let block_id = match cx.transform_block_id { + TransformBlockId::Block(block_id) => block_id, + _ => { + debug_panic!("Expected a block id for the diagnostics block"); + return div().into_any_element(); + } + }; + let Some(first_diagnostic) = diagnostics.first() else { + debug_panic!("Expected at least one diagnostic"); + return div().into_any_element(); + }; + let button_expanded = expanded.clone(); + let expanded = expanded.load(atomic::Ordering::Acquire); + let expand_label = if expanded { '-' } else { '+' }; + let first_diagnostics_height = diagnostic_text_lines(first_diagnostic); + let extra_diagnostics = diagnostics.len() - 1; + let toggle_expand_label = + if folded_block_height == first_diagnostics_height && extra_diagnostics == 0 { + None + } else if extra_diagnostics > 0 { + Some(format!("{expand_label}{extra_diagnostics}")) + } else { + Some(expand_label.to_string()) + }; + + let expanded_block_height = diagnostics + .iter() + .map(|diagnostic| diagnostic_text_lines(diagnostic)) + .sum::(); + let editor_handle = editor_handle.clone(); + let mut parent = v_flex(); + let mut diagnostics_iter = diagnostics.iter().fuse(); + if let Some(first_diagnostic) = diagnostics_iter.next() { + let mut renderer = diagnostic_block_renderer( + first_diagnostic.clone(), + Some(folded_block_height), + false, + true, + ); + parent = parent.child( + h_flex() + .when_some(toggle_expand_label, |parent, label| { + parent.child(Button::new(cx.transform_block_id, label).on_click({ + let diagnostics = Arc::clone(&diagnostics); + move |_, cx| { + let new_expanded = !expanded; + button_expanded.store(new_expanded, atomic::Ordering::Release); + let new_size = if new_expanded { + expanded_block_height + } else { + folded_block_height + }; + editor_handle.update(cx, |editor, cx| { + editor.replace_blocks( + HashMap::from_iter(Some(( + block_id, + ( + Some(new_size), + render_same_line_diagnostics( + Arc::clone(&button_expanded), + Arc::clone(&diagnostics), + editor_handle.clone(), + folded_block_height, + ), + ), + ))), + None, + cx, + ) + }); + } + })) + }) + .child(renderer(cx)), + ); + } + if expanded { + for diagnostic in diagnostics_iter { + let mut renderer = diagnostic_block_renderer(diagnostic.clone(), None, false, true); + parent = parent.child(renderer(cx)); + } + } + parent.into_any_element() + }) +} + +fn diagnostic_text_lines(diagnostic: &language::Diagnostic) -> u8 { + diagnostic.message.matches('\n').count() as u8 + 1 +} + +fn path_state_excerpts( + after_excerpt_id: Option, + before_excerpt_id: Option, + multi_buffer_snapshot: &editor::MultiBufferSnapshot, +) -> impl Iterator)> { + multi_buffer_snapshot + .excerpts() + .skip_while(move |&(excerpt_id, ..)| match after_excerpt_id { + Some(after_excerpt_id) => after_excerpt_id != excerpt_id, + None => false, + }) + .filter(move |&(excerpt_id, ..)| after_excerpt_id != Some(excerpt_id)) + .take_while(move |&(excerpt_id, ..)| match before_excerpt_id { + Some(before_excerpt_id) => before_excerpt_id != excerpt_id, + None => true, + }) +} diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 1da5201237..62977fdc8c 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -30,6 +30,7 @@ use crate::{ pub use block_map::{ BlockBufferRows, BlockChunks as DisplayChunks, BlockContext, BlockDisposition, BlockId, BlockMap, BlockPoint, BlockProperties, BlockStyle, RenderBlock, TransformBlock, + TransformBlockId, }; use block_map::{BlockRow, BlockSnapshot}; use collections::{HashMap, HashSet}; diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index db9a770fc8..37f6a342a8 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -4,7 +4,7 @@ use super::{ }; use crate::{EditorStyle, GutterDimensions}; use collections::{Bound, HashMap, HashSet}; -use gpui::{AnyElement, Pixels, WindowContext}; +use gpui::{AnyElement, EntityId, Pixels, WindowContext}; use language::{BufferSnapshot, Chunk, Patch, Point}; use multi_buffer::{Anchor, ExcerptId, ExcerptRange, MultiBufferRow, ToPoint as _}; use parking_lot::Mutex; @@ -20,6 +20,7 @@ use std::{ }; use sum_tree::{Bias, SumTree}; use text::Edit; +use ui::ElementId; const NEWLINES: &[u8] = &[b'\n'; u8::MAX as usize]; @@ -53,6 +54,12 @@ pub struct BlockSnapshot { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct BlockId(usize); +impl Into for BlockId { + fn into(self) -> ElementId { + ElementId::Integer(self.0) + } +} + #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)] pub struct BlockPoint(pub Point); @@ -62,7 +69,7 @@ pub struct BlockRow(pub(super) u32); #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)] struct WrapRow(u32); -pub type RenderBlock = Box AnyElement>; +pub type RenderBlock = Box AnyElement>; pub struct Block { id: BlockId, @@ -77,11 +84,22 @@ pub struct BlockProperties

{ pub position: P, pub height: u8, pub style: BlockStyle, - pub render: Box AnyElement>, + pub render: RenderBlock, pub disposition: BlockDisposition, } -#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] +impl Debug for BlockProperties

{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BlockProperties") + .field("position", &self.position) + .field("height", &self.height) + .field("style", &self.style) + .field("disposition", &self.disposition) + .finish() + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] pub enum BlockStyle { Fixed, Flex, @@ -95,10 +113,47 @@ pub struct BlockContext<'a, 'b> { pub gutter_dimensions: &'b GutterDimensions, pub em_width: Pixels, pub line_height: Pixels, - pub block_id: usize, + pub transform_block_id: TransformBlockId, pub editor_style: &'b EditorStyle, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TransformBlockId { + Block(BlockId), + ExcerptHeader(ExcerptId), + ExcerptFooter(ExcerptId), +} + +impl From for EntityId { + fn from(value: TransformBlockId) -> Self { + match value { + TransformBlockId::Block(BlockId(id)) => EntityId::from(id as u64), + TransformBlockId::ExcerptHeader(id) => id.into(), + TransformBlockId::ExcerptFooter(id) => id.into(), + } + } +} + +impl Into for TransformBlockId { + fn into(self) -> ElementId { + match self { + Self::Block(BlockId(id)) => ("Block", id).into(), + Self::ExcerptHeader(id) => ("ExcerptHeader", EntityId::from(id)).into(), + Self::ExcerptFooter(id) => ("ExcerptFooter", EntityId::from(id)).into(), + } + } +} + +impl std::fmt::Display for TransformBlockId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Block(id) => write!(f, "Block({id:?})"), + Self::ExcerptHeader(id) => write!(f, "ExcerptHeader({id:?})"), + Self::ExcerptFooter(id) => write!(f, "ExcerptFooter({id:?})"), + } + } +} + /// Whether the block should be considered above or below the anchor line #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum BlockDisposition { @@ -157,6 +212,14 @@ impl BlockLike for TransformBlock { } impl TransformBlock { + pub fn id(&self) -> TransformBlockId { + match self { + TransformBlock::Custom(block) => TransformBlockId::Block(block.id), + TransformBlock::ExcerptHeader { id, .. } => TransformBlockId::ExcerptHeader(*id), + TransformBlock::ExcerptFooter { id, .. } => TransformBlockId::ExcerptFooter(*id), + } + } + fn disposition(&self) -> BlockDisposition { match self { TransformBlock::Custom(block) => block.disposition, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 6e9e1e871a..e62369164c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -68,12 +68,12 @@ use git::diff_hunk_to_display; use gpui::{ div, impl_actions, point, prelude::*, px, relative, size, uniform_list, Action, AnyElement, AppContext, AsyncWindowContext, AvailableSpace, BackgroundExecutor, Bounds, ClipboardItem, - Context, DispatchPhase, ElementId, EventEmitter, FocusHandle, FocusOutEvent, FocusableView, - FontId, FontStyle, FontWeight, HighlightStyle, Hsla, InteractiveText, KeyContext, - ListSizingBehavior, Model, MouseButton, PaintQuad, ParentElement, Pixels, Render, SharedString, - Size, StrikethroughStyle, Styled, StyledText, Subscription, Task, TextStyle, UnderlineStyle, - UniformListScrollHandle, View, ViewContext, ViewInputHandler, VisualContext, WeakFocusHandle, - WeakView, WhiteSpace, WindowContext, + Context, DispatchPhase, ElementId, EntityId, EventEmitter, FocusHandle, FocusOutEvent, + FocusableView, FontId, FontStyle, FontWeight, HighlightStyle, Hsla, InteractiveText, + KeyContext, ListSizingBehavior, Model, MouseButton, PaintQuad, ParentElement, Pixels, Render, + SharedString, Size, StrikethroughStyle, Styled, StyledText, Subscription, Task, TextStyle, + UnderlineStyle, UniformListScrollHandle, View, ViewContext, ViewInputHandler, VisualContext, + WeakFocusHandle, WeakView, WhiteSpace, WindowContext, }; use highlight_matching_bracket::refresh_matching_bracket_highlights; use hover_popover::{hide_hover, HoverState}; @@ -9762,7 +9762,7 @@ impl Editor { *block_id, ( None, - diagnostic_block_renderer(diagnostic.clone(), is_valid), + diagnostic_block_renderer(diagnostic.clone(), None, true, is_valid), ), ); } @@ -9815,7 +9815,7 @@ impl Editor { style: BlockStyle::Fixed, position: buffer.anchor_after(entry.range.start), height: message_height, - render: diagnostic_block_renderer(diagnostic, true), + render: diagnostic_block_renderer(diagnostic, None, true, true), disposition: BlockDisposition::Below, } }), @@ -12684,11 +12684,17 @@ impl InvalidationRegion for SnippetState { } } -pub fn diagnostic_block_renderer(diagnostic: Diagnostic, _is_valid: bool) -> RenderBlock { - let (text_without_backticks, code_ranges) = highlight_diagnostic_message(&diagnostic); +pub fn diagnostic_block_renderer( + diagnostic: Diagnostic, + max_message_rows: Option, + allow_closing: bool, + _is_valid: bool, +) -> RenderBlock { + let (text_without_backticks, code_ranges) = + highlight_diagnostic_message(&diagnostic, max_message_rows); Box::new(move |cx: &mut BlockContext| { - let group_id: SharedString = cx.block_id.to_string().into(); + let group_id: SharedString = cx.transform_block_id.to_string().into(); let mut text_style = cx.text_style().clone(); text_style.color = diagnostic_style(diagnostic.severity, cx.theme().status()); @@ -12700,23 +12706,25 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, _is_valid: bool) -> Ren let multi_line_diagnostic = diagnostic.message.contains('\n'); - let buttons = |diagnostic: &Diagnostic, block_id: usize| { + let buttons = |diagnostic: &Diagnostic, block_id: TransformBlockId| { if multi_line_diagnostic { v_flex() } else { h_flex() } - .children(diagnostic.is_primary.then(|| { - IconButton::new(("close-block", block_id), IconName::XCircle) - .icon_color(Color::Muted) - .size(ButtonSize::Compact) - .style(ButtonStyle::Transparent) - .visible_on_hover(group_id.clone()) - .on_click(move |_click, cx| cx.dispatch_action(Box::new(Cancel))) - .tooltip(|cx| Tooltip::for_action("Close Diagnostics", &Cancel, cx)) - })) + .when(allow_closing, |div| { + div.children(diagnostic.is_primary.then(|| { + IconButton::new(("close-block", EntityId::from(block_id)), IconName::XCircle) + .icon_color(Color::Muted) + .size(ButtonSize::Compact) + .style(ButtonStyle::Transparent) + .visible_on_hover(group_id.clone()) + .on_click(move |_click, cx| cx.dispatch_action(Box::new(Cancel))) + .tooltip(|cx| Tooltip::for_action("Close Diagnostics", &Cancel, cx)) + })) + }) .child( - IconButton::new(("copy-block", block_id), IconName::Copy) + IconButton::new(("copy-block", EntityId::from(block_id)), IconName::Copy) .icon_color(Color::Muted) .size(ButtonSize::Compact) .style(ButtonStyle::Transparent) @@ -12729,12 +12737,12 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, _is_valid: bool) -> Ren ) }; - let icon_size = buttons(&diagnostic, cx.block_id) + let icon_size = buttons(&diagnostic, cx.transform_block_id) .into_any_element() .layout_as_root(AvailableSpace::min_size(), cx); h_flex() - .id(cx.block_id) + .id(cx.transform_block_id) .group(group_id.clone()) .relative() .size_full() @@ -12746,7 +12754,7 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, _is_valid: bool) -> Ren .w(cx.anchor_x - cx.gutter_dimensions.width - icon_size.width) .flex_shrink(), ) - .child(buttons(&diagnostic, cx.block_id)) + .child(buttons(&diagnostic, cx.transform_block_id)) .child(div().flex().flex_shrink_0().child( StyledText::new(text_without_backticks.clone()).with_highlights( &text_style, @@ -12765,7 +12773,10 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, _is_valid: bool) -> Ren }) } -pub fn highlight_diagnostic_message(diagnostic: &Diagnostic) -> (SharedString, Vec>) { +pub fn highlight_diagnostic_message( + diagnostic: &Diagnostic, + mut max_message_rows: Option, +) -> (SharedString, Vec>) { let mut text_without_backticks = String::new(); let mut code_ranges = Vec::new(); @@ -12777,18 +12788,45 @@ pub fn highlight_diagnostic_message(diagnostic: &Diagnostic) -> (SharedString, V let mut prev_offset = 0; let mut in_code_block = false; + let mut newline_indices = diagnostic + .message + .match_indices('\n') + .map(|(ix, _)| ix) + .fuse() + .peekable(); for (ix, _) in diagnostic .message .match_indices('`') .chain([(diagnostic.message.len(), "")]) { + let mut trimmed_ix = ix; + while let Some(newline_index) = newline_indices.peek() { + if *newline_index < ix { + if let Some(rows_left) = &mut max_message_rows { + if *rows_left == 0 { + trimmed_ix = newline_index.saturating_sub(1); + break; + } else { + *rows_left -= 1; + } + } + let _ = newline_indices.next(); + } else { + break; + } + } let prev_len = text_without_backticks.len(); - text_without_backticks.push_str(&diagnostic.message[prev_offset..ix]); - prev_offset = ix + 1; + let new_text = &diagnostic.message[prev_offset..trimmed_ix]; + text_without_backticks.push_str(new_text); if in_code_block { code_ranges.push(prev_len..text_without_backticks.len()); } + prev_offset = trimmed_ix + 1; in_code_block = !in_code_block; + if trimmed_ix != ix { + text_without_backticks.push_str("..."); + break; + } } (text_without_backticks.into(), code_ranges) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 5a560cfd04..60960918eb 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1,4 +1,5 @@ use crate::editor_settings::ScrollBeyondLastLine; +use crate::TransformBlockId; use crate::{ blame_entry_tooltip::{blame_entry_relative_timestamp, BlameEntryTooltip}, display_map::{ @@ -31,7 +32,7 @@ use gpui::{ anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg, transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem, ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity, - FontId, GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement, Length, + EntityId, FontId, GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement, Length, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, TextStyleRefinement, View, @@ -1939,7 +1940,6 @@ impl EditorElement { line_layouts: &[LineWithInvisibles], cx: &mut WindowContext, ) -> Vec { - let mut block_id = 0; let (fixed_blocks, non_fixed_blocks) = snapshot .blocks_in_range(rows.clone()) .partition::, _>(|(_, block)| match block { @@ -1950,7 +1950,7 @@ impl EditorElement { let render_block = |block: &TransformBlock, available_space: Size, - block_id: usize, + block_id: TransformBlockId, block_row_start: DisplayRow, cx: &mut WindowContext| { let mut element = match block { @@ -1974,7 +1974,7 @@ impl EditorElement { gutter_dimensions, line_height, em_width, - block_id, + transform_block_id: block_id, max_width: text_hitbox.size.width.max(*scroll_width), editor_style: &self.style, }) @@ -2058,7 +2058,7 @@ impl EditorElement { let header_padding = px(6.0); v_flex() - .id(("path excerpt header", block_id)) + .id(("path excerpt header", EntityId::from(block_id))) .size_full() .p(header_padding) .child( @@ -2166,7 +2166,7 @@ impl EditorElement { })) } else { v_flex() - .id(("excerpt header", block_id)) + .id(("excerpt header", EntityId::from(block_id))) .size_full() .child( div() @@ -2314,49 +2314,54 @@ impl EditorElement { } TransformBlock::ExcerptFooter { id, .. } => { - let element = v_flex().id(("excerpt footer", block_id)).size_full().child( - h_flex() - .justify_end() - .flex_none() - .w(gutter_dimensions.width - - (gutter_dimensions.left_padding + gutter_dimensions.margin)) - .h_full() - .child( - ButtonLike::new("expand-icon") - .style(ButtonStyle::Transparent) - .child( - svg() - .path(IconName::ArrowDownFromLine.path()) - .size(IconSize::XSmall.rems()) - .text_color(cx.theme().colors().editor_line_number) - .group("") - .hover(|style| { - style.text_color( - cx.theme().colors().editor_active_line_number, + let element = v_flex() + .id(("excerpt footer", EntityId::from(block_id))) + .size_full() + .child( + h_flex() + .justify_end() + .flex_none() + .w(gutter_dimensions.width + - (gutter_dimensions.left_padding + gutter_dimensions.margin)) + .h_full() + .child( + ButtonLike::new("expand-icon") + .style(ButtonStyle::Transparent) + .child( + svg() + .path(IconName::ArrowDownFromLine.path()) + .size(IconSize::XSmall.rems()) + .text_color(cx.theme().colors().editor_line_number) + .group("") + .hover(|style| { + style.text_color( + cx.theme() + .colors() + .editor_active_line_number, + ) + }), + ) + .on_click(cx.listener_for(&self.editor, { + let id = *id; + move |editor, _, cx| { + editor.expand_excerpt( + id, + multi_buffer::ExpandExcerptDirection::Down, + cx, + ); + } + })) + .tooltip({ + move |cx| { + Tooltip::for_action( + "Expand Excerpt", + &ExpandExcerpts { lines: 0 }, + cx, ) - }), - ) - .on_click(cx.listener_for(&self.editor, { - let id = *id; - move |editor, _, cx| { - editor.expand_excerpt( - id, - multi_buffer::ExpandExcerptDirection::Down, - cx, - ); - } - })) - .tooltip({ - move |cx| { - Tooltip::for_action( - "Expand Excerpt", - &ExpandExcerpts { lines: 0 }, - cx, - ) - } - }), - ), - ); + } + }), + ), + ); element.into_any() } }; @@ -2372,8 +2377,8 @@ impl EditorElement { AvailableSpace::MinContent, AvailableSpace::Definite(block.height() as f32 * line_height), ); + let block_id = block.id(); let (element, element_size) = render_block(block, available_space, block_id, row, cx); - block_id += 1; fixed_block_max_width = fixed_block_max_width.max(element_size.width + em_width); blocks.push(BlockLayout { row, @@ -2401,8 +2406,8 @@ impl EditorElement { AvailableSpace::Definite(width), AvailableSpace::Definite(block.height() as f32 * line_height), ); + let block_id = block.id(); let (element, _) = render_block(block, available_space, block_id, row, cx); - block_id += 1; blocks.push(BlockLayout { row, element, diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index 0435f52c17..c90e3e34aa 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -34,6 +34,11 @@ impl FeatureFlag for TerminalInlineAssist { const NAME: &'static str = "terminal-inline-assist"; } +pub struct GroupedDiagnostics {} +impl FeatureFlag for GroupedDiagnostics { + const NAME: &'static str = "grouped-diagnostics"; +} + pub trait FeatureFlagViewExt { fn observe_flag(&mut self, callback: F) -> Subscription where diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 663dad51bf..a9ae9202db 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -6,7 +6,7 @@ use clock::ReplicaId; use collections::{BTreeMap, Bound, HashMap, HashSet}; use futures::{channel::mpsc, SinkExt}; use git::diff::DiffHunk; -use gpui::{AppContext, EventEmitter, Model, ModelContext}; +use gpui::{AppContext, EntityId, EventEmitter, Model, ModelContext}; use itertools::Itertools; use language::{ char_kind, @@ -49,6 +49,12 @@ const NEWLINES: &[u8] = &[b'\n'; u8::MAX as usize]; #[derive(Debug, Default, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)] pub struct ExcerptId(usize); +impl From for EntityId { + fn from(id: ExcerptId) -> Self { + EntityId::from(id.0 as u64) + } +} + /// One or more [`Buffers`](Buffer) being edited in a single view. /// /// See @@ -302,6 +308,7 @@ struct ExcerptBytes<'a> { reversed: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ExpandExcerptDirection { Up, Down, @@ -4679,7 +4686,7 @@ impl ToPointUtf16 for PointUtf16 { } } -fn build_excerpt_ranges( +pub fn build_excerpt_ranges( buffer: &BufferSnapshot, ranges: &[Range], context_line_count: u32, diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index c884696b38..67170745e7 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -11720,7 +11720,7 @@ fn sort_search_matches(search_matches: &mut Vec, cx: &AppC }); } -fn compare_paths( +pub fn compare_paths( (path_a, a_is_file): (&Path, bool), (path_b, b_is_file): (&Path, bool), ) -> cmp::Ordering {