Files
Ely-GPUI-Components/src/generative/versions.rs
T
ZacharyZhang-NYandClaude Opus 5.5 5779daef82 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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:07:22 -04:00

381 lines
14 KiB
Rust

use std::{ops::Range, rc::Rc};
use gpui::{
App, ElementId, FontWeight, HighlightStyle, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, StrikethroughStyle,
Styled, StyledText, Window, div, prelude::*, transparent_black,
};
use jiff::Timestamp;
use similar::{ChangeTag, TextDiff};
use crate::{
buttons::{Button, ButtonVariant},
forms::Pick,
primitives::tab_stop,
theme::{ActiveTheme, ControlSize, Radius, TextSize},
typography::{Ellipsis, RelativeTime},
};
/// Runs of text, each kept, taken out or put in, over the byte range it holds.
type Runs = Vec<(ChangeTag, Range<usize>)>;
/// `new` against `old` as one text: each run kept, taken out, or put in, with the byte range it holds; runs of one kind join, and a space parts a run taken out from a run put in where they touch.
pub(crate) fn changes(old: &str, new: &str) -> (String, Runs) {
let (mut text, mut runs) = (String::new(), Runs::new());
let mut push = |tag: ChangeTag, part: &str| {
let start = text.len();
text.push_str(part);
match runs.last_mut() {
Some((last, range)) if *last == tag => range.end = text.len(),
_ => runs.push((tag, start..text.len())),
}
};
let (mut before, mut blank) = (ChangeTag::Equal, true);
for change in TextDiff::from_unicode_words(old, new).iter_all_changes() {
let (tag, part) = (change.tag(), change.value());
let touching = before != ChangeTag::Equal && tag != ChangeTag::Equal && before != tag;
if touching && !blank && !part.starts_with(char::is_whitespace) {
push(ChangeTag::Equal, " ");
}
push(tag, part);
(before, blank) = (tag, part.ends_with(char::is_whitespace));
}
(text, runs)
}
/// Two versions of a prompt as one text: words taken out struck through on the danger wash, words put in on the success wash.
#[derive(IntoElement)]
pub struct PromptDiff {
old: SharedString,
new: SharedString,
}
impl PromptDiff {
pub fn new(old: impl Into<SharedString>, new: impl Into<SharedString>) -> Self {
Self {
old: old.into(),
new: new.into(),
}
}
}
impl RenderOnce for PromptDiff {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let (text, runs) = changes(&self.old, &self.new);
let styles = runs.into_iter().filter_map(|(tag, range)| {
let style = match tag {
ChangeTag::Equal => return None,
ChangeTag::Delete => HighlightStyle {
background_color: Some(colors.danger_subtle),
color: Some(colors.fg_muted),
strikethrough: Some(StrikethroughStyle {
thickness: theme.underline_thickness(),
color: Some(colors.danger),
}),
..HighlightStyle::default()
},
ChangeTag::Insert => HighlightStyle {
background_color: Some(colors.success_subtle),
..HighlightStyle::default()
},
};
Some((range, style))
});
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg)
.child(StyledText::new(text).with_highlights(styles))
}
}
/// What the chosen version is set against: a heading naming the version before it, and that version's text, empty for the first.
pub(crate) fn compared(versions: &[PromptVersion], chosen: usize) -> (SharedString, SharedString) {
let heading = match versions.len() - chosen {
1 => "The first version".into(),
place => format!("Changes from version {}", place - 1).into(),
};
let base = versions
.get(chosen + 1)
.map(|version| version.text.clone())
.unwrap_or_default();
(heading, base)
}
/// A prompt as saved: its key, its words, who saved it, when, and a note on what changed.
#[derive(Clone, Debug, PartialEq)]
pub struct PromptVersion {
pub key: SharedString,
pub text: SharedString,
pub author: SharedString,
pub at: Timestamp,
pub note: SharedString,
}
/// A prompt's versions, newest first, and what the chosen one changed from the one before it. A press or Enter chooses a version; Restore brings back an older one.
#[derive(IntoElement)]
pub struct PromptVersionHistory {
id: ElementId,
versions: Vec<PromptVersion>,
chosen: usize,
on_choose: Option<Pick>,
on_restore: Option<Pick>,
}
impl PromptVersionHistory {
/// `versions` run newest first; `chosen` indexes them.
pub fn new(
id: impl Into<ElementId>,
versions: impl IntoIterator<Item = PromptVersion>,
chosen: usize,
) -> Self {
let versions: Vec<PromptVersion> = versions.into_iter().collect();
assert!(
chosen < versions.len(),
"version {chosen} of {}",
versions.len()
);
Self {
id: id.into(),
versions,
chosen,
on_choose: None,
on_restore: None,
}
}
pub fn on_choose(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_choose = Some(Rc::new(handler));
self
}
/// Gets an older version to bring back.
pub fn on_restore(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_restore = Some(Rc::new(handler));
self
}
}
impl RenderOnce for PromptVersionHistory {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let pickable = self.on_choose.is_some();
let focuses: Vec<_> = (0..self.versions.len())
.map(|ix| {
let focus = tab_stop(
(self.id.clone(), format!("focus-{ix}")).into(),
pickable,
window,
cx,
);
let focused = focus.is_focused(window);
(focus, focused)
})
.collect();
let theme = cx.theme();
let colors = theme.colors.clone();
let chosen = self.chosen;
let (heading, before) = compared(&self.versions, chosen);
let shown = &self.versions[chosen];
let restore = self
.on_restore
.clone()
.filter(|_| chosen > 0)
.map(|restore| {
Button::new((self.id.clone(), "restore"), "Restore")
.variant(ButtonVariant::Secondary)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("prompt versions: restore {chosen}");
restore(chosen, window, cx)
})
});
let rows = self.versions.iter().zip(focuses).enumerate().map(
|(ix, (version, (focus, focused)))| {
let on = ix == chosen;
let choose = self.on_choose.clone();
div()
.id((self.id.clone(), format!("version-{ix}")))
.w_full()
.flex()
.flex_col()
.gap_0p5()
.px_2()
.py_1p5()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if focused {
colors.focus
} else {
transparent_black()
})
.when(on, |row| row.bg(colors.active))
.when_some(choose, |row, choose| {
row.track_focus(&focus)
.when(!on, |row| row.hover(|style| style.bg(colors.hover)))
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| {
window.prevent_default()
})
.on_click(move |_, window, cx| {
if on {
return;
}
log::info!("prompt versions: chose {ix}");
choose(ix, window, cx)
})
})
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg)
.child(Ellipsis::new(version.note.clone())),
)
.child(
div()
.flex()
.gap_1()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(
div()
.min_w_0()
.child(Ellipsis::new(format!("{} ·", version.author))),
)
.child(div().flex_none().child(RelativeTime::new(
(self.id.clone(), format!("when-{ix}")),
version.at,
))),
)
},
);
div()
.flex()
.flex_wrap()
.items_start()
.gap_4()
.child(
div()
.flex_1()
.min_w(theme.label_width())
.flex()
.flex_col()
.gap_0p5()
.children(rows),
)
.child(
div()
.flex_1()
.min_w(theme.label_width() * 1.5)
.flex()
.flex_col()
.gap_3()
.p_4()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.child(
div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_muted)
.child(heading),
)
.children(restore),
)
.child(PromptDiff::new(before, shown.text.clone())),
)
}
}
#[cfg(test)]
mod tests {
use similar::ChangeTag;
use jiff::Timestamp;
use super::{PromptVersion, changes, compared};
#[test]
fn a_version_compares_with_the_one_before_it() {
let version = |n: usize| PromptVersion {
key: format!("v{n}").into(),
text: format!("text {n}").into(),
author: "Mira".into(),
at: Timestamp::UNIX_EPOCH,
note: format!("note {n}").into(),
};
let versions: Vec<PromptVersion> = (1..=4).rev().map(version).collect();
assert_eq!(
compared(&versions, 2),
("Changes from version 1".into(), "text 1".into())
);
assert_eq!(
compared(&versions, 3),
("The first version".into(), "".into())
);
assert_eq!(
compared(&versions, 0),
("Changes from version 3".into(), "text 3".into())
);
}
#[test]
fn a_change_keeps_words_and_marks_what_went_and_came() {
let marked = |old: &str, new: &str| {
let (text, runs) = changes(old, new);
runs.iter()
.map(|(tag, range)| (*tag, text[range.clone()].to_string()))
.collect::<Vec<_>>()
};
let went_and_came = marked("a warm tone", "a calm tone");
assert_eq!(
went_and_came,
[
(ChangeTag::Equal, "a ".into()),
(ChangeTag::Delete, "warm".into()),
(ChangeTag::Equal, " ".into()),
(ChangeTag::Insert, "calm".into()),
(ChangeTag::Equal, " tone".into())
],
"a space parts a word taken out from the word put in"
);
let added = marked("Write short notes.", "Write plain, short notes.");
assert!(
added.contains(&(ChangeTag::Insert, "plain, ".into())),
"{added:?}"
);
let text: String = marked("a very warm tone", "a calm tone")
.into_iter()
.map(|(_, part)| part)
.collect();
assert_eq!(text, "a very warm calm tone", "no doubled space");
let sentences = [
"Write short notes.",
"Write plain, short notes.",
"a very warm tone",
"a calmtone",
"You are a helpful assistant.",
"You answer support questions. Write plain replies.",
"Say sorry once. Offer the next step.",
"Always say sorry.",
"x y ",
"x z",
];
for old in sentences {
for new in sentences {
let (text, _) = changes(old, new);
assert!(!text.contains(" "), "{old:?} to {new:?}: {text:?}");
}
}
}
}