625 lines · 24.9 KB · Rust
RawDownload
use lsp::{HoverContents, LspServerLogLevel, MarkupKind};use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};use num_traits::SaturatingSub;use string_offset::CharOffset;use warp_core::send_telemetry_from_ctx;use warp_core::ui::appearance::Appearance;use warp_core::ui::theme::WarpTheme;use warp_core::ui::theme::color::internal_colors;use warp_editor::content::buffer::InitialBufferState;use warp_editor::render::element::VerticalExpansionBehavior;use warp_editor::render::model::Decoration;use warpui::elements::{    Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,    CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, HighlightedHyperlink, Hoverable,    MouseStateHandle, ParentElement, Radius, Rect, ScrollbarWidth,};use warpui::{AppContext, Element, SingletonEntity, ViewContext}; use super::editor::view::{CodeEditorRenderOptions, CodeEditorView};use super::lsp_telemetry::LspTelemetryEvent;use crate::code::local_code_editor::{    HOVER_TOOLTIP_MAX_HEIGHT, HOVER_TOOLTIP_MAX_WIDTH, HoverContentSegment, LocalCodeEditorView,    LspHoverState,};use crate::editor::InteractionState; /// A processed diagnostic with its converted offset range./// Stored on LocalCodeEditorView and used for both decoration and hover display.#[derive(Clone)]pub struct ProcessedDiagnostic {    /// The diagnostic message.    pub message: String,    /// The severity of the diagnostic.    pub severity: lsp_types::DiagnosticSeverity,    /// The start offset (0-based, for rendering).    pub start: CharOffset,    /// The end offset (0-based, for rendering).    pub end: CharOffset,} enum PendingSection {    Markdown(Vec<FormattedTextLine>),    Code { language: String, code: String },} #[derive(Default)]struct PendingSections {    sections: Vec<PendingSection>,    pending: Option<PendingSection>,    active_line_break: bool,} impl PendingSections {    fn push_formatted_line(&mut self, line: FormattedTextLine) {        match line {            FormattedTextLine::LineBreak                if self.active_line_break                    || matches!(self.pending, Some(PendingSection::Code { .. }) | None) => {}            FormattedTextLine::HorizontalRule => {                self.active_line_break = false;                if let Some(section) = self.pending.take() {                    self.sections.push(section);                }            }            FormattedTextLine::CodeBlock(code_block) => {                self.active_line_break = false;                match self.pending.take() {                    Some(pending @ PendingSection::Markdown(_)) => {                        self.sections.push(pending);                    }                    Some(PendingSection::Code { mut code, language }) => {                        if language == code_block.lang {                            code.push('\n');                            code.push_str(code_block.code.trim());                             self.pending = Some(PendingSection::Code { code, language });                            return;                        }                        self.sections.push(PendingSection::Code { code, language });                    }                    None => (),                };                self.pending = Some(PendingSection::Code {                    code: code_block.code,                    language: code_block.lang,                })            }            other => {                self.active_line_break = matches!(other, FormattedTextLine::LineBreak);                match self.pending.take() {                    Some(code @ PendingSection::Code { .. }) => {                        self.sections.push(code);                        self.pending = Some(PendingSection::Markdown(vec![other]));                    }                    Some(PendingSection::Markdown(mut markdown)) => {                        markdown.push(other);                        self.pending = Some(PendingSection::Markdown(markdown));                    }                    None => self.pending = Some(PendingSection::Markdown(vec![other])),                }            }        }    }     fn flush(self, ctx: &mut ViewContext<LocalCodeEditorView>) -> Vec<HoverContentSegment> {        let mut segments = Vec::new();        for section in self.sections {            match section {                PendingSection::Markdown(text_lines) => {                    segments.push(HoverContentSegment::Text(FormattedText::new(text_lines)))                }                PendingSection::Code { language, code } => segments.push(                    LocalCodeEditorView::create_highlighted_code_fragment(language, code, ctx),                ),            }        }         if let Some(pending) = self.pending {            match pending {                PendingSection::Markdown(text_lines) => {                    segments.push(HoverContentSegment::Text(FormattedText::new(text_lines)))                }                PendingSection::Code { language, code } => segments.push(                    LocalCodeEditorView::create_highlighted_code_fragment(language, code, ctx),                ),            }        }        segments    }} impl LocalCodeEditorView {    /// Refresh diagnostics from the LSP server.    /// This updates the cached processed diagnostics and creates decorations for the editor.    pub(super) fn refresh_diagnostics(&mut self, ctx: &mut ViewContext<Self>) {        // Update cached processed diagnostics.        self.processed_diagnostics = self.compute_processed_diagnostics(ctx);         // Convert processed diagnostics to decorations.        let appearance = Appearance::as_ref(ctx);        let error_color = appearance.theme().ui_error_color();        let warning_color = appearance.theme().ui_warning_color();         let decorations: Vec<Decoration> = self            .processed_diagnostics            .iter()            .map(|diag| {                let color = match diag.severity {                    lsp_types::DiagnosticSeverity::ERROR => error_color,                    lsp_types::DiagnosticSeverity::WARNING => warning_color,                    _ => error_color, // Fallback, though we filter to only errors/warnings                };                Decoration::new(diag.start, diag.end).with_dashed_underline(color)            })            .collect();         self.diagnostic_decorations = decorations;        self.update_editor_decorations(ctx);    }     pub(super) fn clear_diagnostics(&mut self, ctx: &mut ViewContext<Self>) {        self.processed_diagnostics.clear();        self.diagnostic_decorations.clear();        self.update_editor_decorations(ctx);    }     /// Update the editor's text decorations with diagnostic decorations.    fn update_editor_decorations(&self, ctx: &mut ViewContext<Self>) {        // Pass diagnostic decorations to the render state.        let decorations = self.diagnostic_decorations.clone();        self.editor.update(ctx, |editor, ctx| {            editor.set_diagnostic_decorations(decorations, ctx);        });    }     /// Compute processed diagnostics (errors and warnings) with their converted offset ranges.    /// Returns an empty vec if LSP server is not available or there are no diagnostics.    fn compute_processed_diagnostics(&self, ctx: &ViewContext<Self>) -> Vec<ProcessedDiagnostic> {        let Some(lsp_server) = self.lsp_server.as_ref() else {            return Vec::new();        };        let Some(file_path) = self.file_path() else {            return Vec::new();        };        let Some(doc_diagnostics) = lsp_server            .as_ref(ctx)            .diagnostics_for_path(file_path)            .ok()            .flatten()        else {            return Vec::new();        };         // Only show diagnostics that match the current buffer version.        let current_buffer_version = self.editor.as_ref(ctx).buffer_version(ctx).as_usize() as i32;        let diag_count = doc_diagnostics.diagnostics.len();        let diag_age_ms = doc_diagnostics.published_at.elapsed().as_millis();         match doc_diagnostics.version {            Some(version) if version != current_buffer_version => {                lsp_server.as_ref(ctx).log_to_server_log(                    LspServerLogLevel::Info,                    format!(                        "render: DROPPED (version mismatch) file={} render_version={current_buffer_version} diag_version={version} diag_count={diag_count} age_ms={diag_age_ms}",                        file_path.display(),                    ),                );                return Vec::new();            }            Some(version) => {                lsp_server.as_ref(ctx).log_to_server_log(                    LspServerLogLevel::Debug,                    format!(                        "render: OK file={} render_version={current_buffer_version} diag_version={version} diag_count={diag_count} age_ms={diag_age_ms}",                        file_path.display(),                    ),                );            }            None => {                lsp_server.as_ref(ctx).log_to_server_log(                    LspServerLogLevel::Debug,                    format!(                        "render: UNVERSIONED file={} render_version={current_buffer_version} diag_count={diag_count} age_ms={diag_age_ms}",                        file_path.display(),                    ),                );            }        }         doc_diagnostics            .diagnostics            .iter()            .filter_map(|diagnostic| {                // Only include errors and warnings.                let severity = diagnostic.severity?;                if !matches!(                    severity,                    lsp_types::DiagnosticSeverity::ERROR | lsp_types::DiagnosticSeverity::WARNING                ) {                    return None;                }                 // Convert LSP range to CharOffset range.                let range: lsp::types::Range = diagnostic.range.into();                let mut start_offset = self                    .editor                    .as_ref(ctx)                    .lsp_location_to_offset(&range.start, ctx);                let mut end_offset = self                    .editor                    .as_ref(ctx)                    .lsp_location_to_offset(&range.end, ctx);                 // Handle zero-width ranges by expanding to at least 1 character.                if start_offset >= end_offset {                    end_offset = start_offset + CharOffset::from(1);                }                 // Check if the diagnostic range only covers a newline character.                // This happens for diagnostics like "missing semicolon" that point to                // the end of a line. In this case, shift the range back to cover the                // last character on the line instead, so it renders visibly.                let is_single_char_range =                    end_offset.saturating_sub(&start_offset) == CharOffset::from(1);                if is_single_char_range {                    let char_at_start = self.editor.as_ref(ctx).char_at(start_offset, ctx);                    if let Some('\n') = char_at_start {                        // Shift range back by 1 to cover the character before the newline.                        if start_offset > CharOffset::from(1) {                            start_offset = start_offset.saturating_sub(&CharOffset::from(1));                            end_offset = end_offset.saturating_sub(&CharOffset::from(1));                        }                    }                }                 // Convert to 0-based offsets (render offsets).                let start = start_offset.saturating_sub(&CharOffset::from(1));                let end = end_offset.saturating_sub(&CharOffset::from(1));                 Some(ProcessedDiagnostic {                    message: diagnostic.message.clone(),                    severity,                    start,                    end,                })            })            .collect()    }     /// Get diagnostics at the given offset from the cached processed diagnostics.    /// Returns a list of ProcessedDiagnostic for any diagnostics whose range contains the offset.    /// The input offset and ProcessedDiagnostic ranges are both 0-based render offsets.    pub(super) fn diagnostics_at_offset(&self, offset: CharOffset) -> Vec<ProcessedDiagnostic> {        self.processed_diagnostics            .iter()            .filter(|diag| offset >= diag.start && offset < diag.end)            .cloned()            .collect()    }     /// Request hover information (documentation, type info) for a given offset.    pub fn hover_for_offset(&mut self, offset: CharOffset, ctx: &mut ViewContext<Self>) {        if matches!(self.lsp_hover_state, LspHoverState::None) {            return;        }         let lsp_position = self            .editor()            .as_ref(ctx)            .offset_to_lsp_position(offset, ctx);         let Some(file_path) = self.file_path() else {            return;        };         if self.lsp_server.is_none() {            log::warn!("No LSP server available for hover");            return;        }         let future = match self            .lsp_server            .as_ref()            .unwrap()            .as_ref(ctx)            .hover(file_path.to_path_buf(), lsp_position)        {            Ok(future) => future,            Err(e) => {                log::warn!("Failed to call lsp.hover: {e}");                return;            }        };         let abort_handle = ctx            .spawn(future, move |me, result, ctx| {                // Get diagnostics at the hovered offset from cached processed diagnostics.                // We always check for diagnostics, regardless of the LSP hover result.                let diagnostics = me.diagnostics_at_offset(offset);                 // Extract hover range and contents from the LSP result (if available).                let (hover_range, hover_contents) = match result {                    Ok(Some(hover_result)) => (hover_result.range, Some(hover_result.contents)),                    _ => (None, None),                };                 // Create hover segments if we have non-empty contents.                let segments = match hover_contents {                    Some(contents) if !contents.is_empty() => {                        me.create_hover_content_segments(contents, ctx)                    }                    _ => Vec::new(),                };                 // Only show the hover tooltip if there's something to display.                if segments.is_empty() && diagnostics.is_empty() {                    me.lsp_hover_state.clear();                } else {                    let had_content = !segments.is_empty();                    let had_diagnostics = !diagnostics.is_empty();                    if let Some(server) = me.lsp_server.as_ref() {                        send_telemetry_from_ctx!(                            LspTelemetryEvent::HoverShown {                                server_type: server.as_ref(ctx).server_name(),                                had_content,                                had_diagnostics,                            },                            ctx                        );                    }                     let editor = me.editor().as_ref(ctx);                     // Determine the offset range for positioning the tooltip.                    let offset_range = match hover_range {                        Some(range) => {                            let start = editor.lsp_location_to_offset(&range.start, ctx);                            let end = editor.lsp_location_to_offset(&range.end, ctx);                            // Rendering range is 0-based instead of 1-based.                            start.saturating_sub(&CharOffset::from(1))                                ..end.saturating_sub(&CharOffset::from(1))                        }                        None => match editor.word_range_at_offset(offset, ctx) {                            Some(range) => {                                range.start.saturating_sub(&CharOffset::from(1))                                    ..range.end.saturating_sub(&CharOffset::from(1))                            }                            None => offset..offset + 1,                        },                    };                     me.lsp_hover_state = LspHoverState::Loaded {                        segments,                        diagnostics,                        hovered_offset_range: offset_range,                        scroll_state: ClippedScrollStateHandle::default(),                        mouse_state: MouseStateHandle::default(),                    };                }                ctx.notify();            })            .abort_handle();         self.lsp_hover_state = LspHoverState::Loading(Some(abort_handle));    }     pub(super) fn create_highlighted_code_fragment(        language: String,        code: String,        ctx: &mut ViewContext<Self>,    ) -> HoverContentSegment {        let view = ctx.add_typed_action_view(|ctx| {            CodeEditorView::new(                None,                None,                CodeEditorRenderOptions::new(VerticalExpansionBehavior::InfiniteHeight),                ctx,            )            .with_can_show_diff_ui(false)            .with_show_line_numbers(false)        });         view.update(ctx, |view, ctx| {            view.set_show_current_line_highlights(false, ctx);            view.set_interaction_state(InteractionState::Selectable, ctx);            let state = InitialBufferState::plain_text(code.trim());            view.reset(state, ctx);            view.set_language_with_name(&language, ctx);        });         HoverContentSegment::CodeBlock { view }    }     /// Creates hover content segments from parsed FormattedText lines.    /// Code blocks are converted to CodeEditorViews for syntax highlighting,    /// while other content is grouped into FormattedText segments.    /// Consecutive code blocks with the same language are merged into a single view.    pub(super) fn create_hover_content_segments(        &mut self,        content: HoverContents,        ctx: &mut ViewContext<Self>,    ) -> Vec<HoverContentSegment> {        let mut pending = PendingSections::default();         for section in content.sections {            let text = match section.kind {                MarkupKind::Markdown => match markdown_parser::parse_markdown(&section.value) {                    Ok(text) => text,                    Err(_) => FormattedText::new([FormattedTextLine::Line(vec![                        FormattedTextFragment::plain_text(section.value),                    ])]),                },                MarkupKind::PlainText => FormattedText::new([FormattedTextLine::Line(vec![                    FormattedTextFragment::plain_text(section.value),                ])]),            };             for line in text.lines {                pending.push_formatted_line(line);            }        }         pending.flush(ctx)    }     /// Render the LSP hover tooltip if hover state is available.    pub(super) fn render_hover_tooltip(&self, app: &AppContext) -> Option<Box<dyn Element>> {        let (segments, diagnostics, scroll_state, mouse_state) = match &self.lsp_hover_state {            LspHoverState::Loaded {                segments,                diagnostics,                scroll_state,                mouse_state,                ..            } => (                segments,                diagnostics,                scroll_state.clone(),                mouse_state.clone(),            ),            _ => return None,        };         // Don't show tooltip if there's no content.        if segments.is_empty() && diagnostics.is_empty() {            return None;        }         let appearance = Appearance::as_ref(app);        let theme = appearance.theme();         // Build content column with diagnostics first, then hover info.        let mut content_column =            Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);        let mut is_first = true;         // Render diagnostics first (if any).        for diagnostic in diagnostics {            if !is_first {                content_column.add_child(Self::render_separator(theme));

Lines 1–500 of 625.