Skip to main content

ui/components/ai/
diff_block.rs

1use std::ops::Range;
2use std::sync::LazyLock;
3
4use gpui::{AnyElement, HighlightStyle, StyledText};
5use language::{DefaultLanguageRegistry, LanguageRegistry};
6
7use crate::prelude::*;
8
9/// Shares the process-wide grammar registry `CodeEditor` uses (see
10/// `code_editor.rs`'s `LANGUAGES` module docs) — grammars/queries are
11/// immutable after construction, so every `DiffBlock` reuses one registry
12/// instead of re-parsing highlight queries per instance.
13static LANGUAGES: LazyLock<DefaultLanguageRegistry> = LazyLock::new(DefaultLanguageRegistry::new);
14
15/// Monospace font for diff text (mirrors `code_editor.rs`'s `CODE_FONT_FAMILY`
16/// constant, duplicated locally since that constant is private to its
17/// module).
18const DIFF_FONT_FAMILY: &str = "IBM Plex Mono";
19const DIFF_FONT_SIZE: Pixels = px(12.5);
20
21/// Resolves a tree-sitter capture name to a style, falling back to its first
22/// dotted segment if the exact name isn't in the theme. Mirrors
23/// `code_editor.rs`'s `style_for_capture` helper — duplicated here rather
24/// than shared because that helper is private to its module and
25/// `boltz-markdown` (which has its own capture-to-style path) cannot depend
26/// on `boltz-ui`.
27fn style_for_capture(syntax: &syntax_theme::SyntaxTheme, name: &str) -> Option<HighlightStyle> {
28    syntax.style_for_name(name).or_else(|| {
29        let prefix = name.split('.').next()?;
30        (prefix != name)
31            .then(|| syntax.style_for_name(prefix))
32            .flatten()
33    })
34}
35
36fn highlighted_text(text: SharedString, extension: Option<&str>, cx: &App) -> AnyElement {
37    let language = extension.and_then(|extension| LANGUAGES.language_for_extension(extension));
38    match language {
39        Some(language) => {
40            let syntax = cx.theme().syntax();
41            let highlights: Vec<(Range<usize>, HighlightStyle)> =
42                language::highlighted_spans(language, &text)
43                    .into_iter()
44                    .filter_map(|(range, name)| {
45                        style_for_capture(syntax, &name).map(|style| (range, style))
46                    })
47                    .collect();
48            StyledText::new(text)
49                .with_highlights(highlights)
50                .into_any_element()
51        }
52        None => StyledText::new(text).into_any_element(),
53    }
54}
55
56fn diff_section(
57    label: &'static str,
58    text: SharedString,
59    extension: Option<&str>,
60    bg: gpui::Hsla,
61    label_color: Color,
62    cx: &App,
63) -> AnyElement {
64    v_flex()
65        .gap_1()
66        .p_2()
67        .rounded_md()
68        .bg(bg)
69        .child(Label::new(label).size(LabelSize::Small).color(label_color))
70        .child(
71            div()
72                .font_family(DIFF_FONT_FAMILY)
73                .text_size(DIFF_FONT_SIZE)
74                .child(highlighted_text(text, extension, cx)),
75        )
76        .into_any_element()
77}
78
79/// Renders a tool-call diff as a plain unified old/new block (Decision #2a:
80/// no `MultiBuffer`-based inline-editable diff view — see this component's
81/// call sites for that trade-off's rationale). Old/new text is
82/// syntax-highlighted via the same `language::highlighted_spans` tree-sitter
83/// path `CodeEditor` uses.
84#[derive(IntoElement, RegisterComponent)]
85pub struct DiffBlock {
86    id: ElementId,
87    old_text: SharedString,
88    new_text: SharedString,
89    language: Option<SharedString>,
90}
91
92impl DiffBlock {
93    pub fn new(
94        id: impl Into<ElementId>,
95        old_text: impl Into<SharedString>,
96        new_text: impl Into<SharedString>,
97    ) -> Self {
98        Self {
99            id: id.into(),
100            old_text: old_text.into(),
101            new_text: new_text.into(),
102            language: None,
103        }
104    }
105
106    /// File extension (no leading dot, e.g. `"rs"`) used to resolve syntax
107    /// highlighting via `language::DefaultLanguageRegistry`. Unset or
108    /// unrecognized extensions render as plain unhighlighted text.
109    pub fn language(mut self, extension: impl Into<SharedString>) -> Self {
110        self.language = Some(extension.into());
111        self
112    }
113}
114
115impl RenderOnce for DiffBlock {
116    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
117        let extension = self.language.as_deref();
118        let status = cx.theme().status();
119
120        v_flex()
121            .id(self.id)
122            .w_full()
123            .gap_1()
124            .child(diff_section(
125                "− Old",
126                self.old_text,
127                extension,
128                status.deleted_background,
129                Color::Error,
130                cx,
131            ))
132            .child(diff_section(
133                "+ New",
134                self.new_text,
135                extension,
136                status.created_background,
137                Color::Success,
138                cx,
139            ))
140    }
141}
142
143impl Component for DiffBlock {
144    fn scope() -> ComponentScope {
145        ComponentScope::Agent
146    }
147
148    fn description() -> Option<&'static str> {
149        Some(
150            "Renders a tool-call diff as a plain unified old/new text block, \
151             syntax-highlighted via the same tree-sitter path CodeEditor uses.",
152        )
153    }
154
155    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
156        Some(
157            v_flex()
158                .w_96()
159                .gap_4()
160                .child(single_example(
161                    "Rust diff",
162                    DiffBlock::new(
163                        "diff-preview-1",
164                        "fn greet() {\n    println!(\"hi\");\n}",
165                        "fn greet(name: &str) {\n    println!(\"hi, {name}\");\n}",
166                    )
167                    .language("rs")
168                    .into_any_element(),
169                ))
170                .into_any_element(),
171        )
172    }
173}