Files
Ely-GPUI-Components/src/chat/code.rs
T
ZacharyZhang-NY 81e73b79c1 Capture · Roots: roots drop w_full; hosts give the width
145 component roots drop w_full, the early returns included. Three
field rows keep it, Input, InputGroup and TagInput: stretched without
a width, taffy 0.9 laid the field's flex_1 share at no width in the
template picker at 280px, and the placeholder drew nothing. Container
keeps it, since mx_auto needs a width to fill.

Hosts that relied on a root's width now give it: MessageList boxes
each row at the list's width, the splash gives its bar the column's,
and test stages that mount a component as the window's root sit in a
size_full box. Tests hold a narrow row across the list and the splash
bar across its column; each goes red without its fix.

The gallery gains --narrow <px>, which lays each page in a padded
card at that width and shoots it without its script. The page
header wraps: the title takes the room and the theme switcher drops
below, where a long summary used to push it past the edge. A file
panel step waits for the panel to become key instead of a fixed
pause, which lost a race in a capture.
2026-09-28 13:07:22 -04:00

192 lines
6.9 KiB
Rust

use std::{ops::Range, rc::Rc};
use gpui::{
App, ElementId, HighlightStyle, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, StyledText, Window, div, prelude::*,
relative,
};
use crate::{
buttons::{Button, ButtonVariant, CopyButton, IconButton},
editor::code_colors,
forms::Run,
layout::on_axis,
primitives::{Disclosure, IconName},
theme::{ActiveTheme, ControlSize, Radius, TextSize},
};
/// Lines a block shows before it folds.
const FOLD_AFTER: usize = 14;
/// Where `code` folds after `lines`: the byte end of the lines kept, when there are more.
pub(crate) fn fold_at(code: &str, lines: usize) -> Option<usize> {
code.match_indices('\n')
.nth(lines.checked_sub(1)?)
.map(|(at, _)| at)
.filter(|at| code[at + 1..].contains(|c: char| !c.is_whitespace()))
}
/// Code in a message: its language, a copy button, and the owner's download, run and apply; long code folds to a few lines until opened.
#[derive(IntoElement)]
pub struct CodeBlock {
id: ElementId,
code: SharedString,
language: Option<SharedString>,
on_download: Option<Run>,
on_run: Option<Run>,
on_apply: Option<Run>,
}
impl CodeBlock {
pub fn new(id: impl Into<ElementId>, code: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
code: code.into(),
language: None,
on_download: None,
on_run: None,
on_apply: None,
}
}
pub fn language(mut self, language: impl Into<SharedString>) -> Self {
self.language = Some(language.into());
self
}
pub fn on_download(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_download = Some(Rc::new(handler));
self
}
pub fn on_run(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_run = Some(Rc::new(handler));
self
}
/// Puts the code where it belongs, such as into the open file.
pub fn on_apply(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_apply = Some(Rc::new(handler));
self
}
}
impl RenderOnce for CodeBlock {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let open = window.use_keyed_state((self.id.clone(), "open"), cx, |_, _| false);
let opened = *open.read(cx);
let theme = cx.theme();
let colors = theme.colors.clone();
let code = self.code.trim_end_matches('\n').to_string();
let fold = fold_at(&code, FOLD_AFTER).filter(|_| !opened);
let hidden = fold.map(|at| code[at..].lines().count() - 1);
let shown = fold.map_or(code.as_str(), |at| &code[..at]).to_string();
let styles: Vec<(Range<usize>, HighlightStyle)> = code_colors(&shown, cx);
let action = |key: &str, icon: IconName, tip: &'static str, run: Option<Run>| {
run.map(|run| {
IconButton::new((self.id.clone(), key.to_string()), icon)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip(tip)
.on_click(move |_, window, cx| run(window, cx))
})
};
let lines = code.lines().count();
let toggle = open.clone();
div()
.flex()
.flex_col()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.bg(colors.sunken)
.child(
div()
.flex()
.items_center()
.gap_1()
.pl_3()
.pr_1()
.py_0p5()
.border_b_1()
.border_color(colors.border)
.child(
div()
.flex_1()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(self.language.clone().unwrap_or_else(|| "code".into())),
)
.children(action("run", IconName::Play, "Run", self.on_run))
.children(action("apply", IconName::Check, "Apply", self.on_apply))
.children(action(
"download",
IconName::Download,
"Download",
self.on_download,
))
.child(CopyButton::new((self.id.clone(), "copy"), code.clone())),
)
.child(
on_axis(div().id((self.id.clone(), "code")))
.overflow_x_scroll()
.px_3()
.py_2()
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Sm))
.line_height(relative(1.55))
.text_color(colors.syntax.variable)
.whitespace_nowrap()
.child(StyledText::new(shown).with_highlights(styles)),
)
.when(lines > FOLD_AFTER, |block| {
block.child(
div()
.flex()
.items_center()
.gap_1()
.px_1()
.py_0p5()
.border_t_1()
.border_color(colors.border)
.child(
Button::new(
(self.id.clone(), "fold"),
match hidden {
Some(hidden) => format!("Show {hidden} more lines"),
None => "Show less".to_string(),
},
)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
toggle.update(cx, |open, cx| {
*open = !*open;
log::info!("code block: open {open}");
cx.notify();
})
}),
)
.child(Disclosure::new((self.id.clone(), "chevron"), opened)),
)
})
}
}
#[cfg(test)]
mod tests {
use super::fold_at;
#[test]
fn long_code_folds_after_its_lines() {
assert_eq!(fold_at("a\nb\nc", 2), Some(3));
assert_eq!(fold_at("a\nb", 2), None, "two lines fit");
assert_eq!(
fold_at("a\nb\n\n", 2),
None,
"blank lines past the fold do not count"
);
assert_eq!(fold_at("a", 0), None);
}
}