Files
Ely-GPUI-Components/src/chat/projects.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

415 lines
15 KiB
Rust

use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
prelude::*, transparent_black,
};
use jiff::Timestamp;
use crate::{
buttons::{Button, ButtonVariant, IconButton},
data_display::Meter,
forms::{CheckState, Checkbox, Choice, OnFlag, OnValue, Pick, RadioGroup, Run},
overlays::Dialog,
primitives::{FocusRing, Icon, IconName, file_icon},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::{Ellipsis, RelativeTime, format::file_size, tabular},
};
type OnKey = OnValue;
/// A project that holds conversations: its key, name, and how many it holds.
#[derive(Clone, Debug, PartialEq)]
pub struct Project {
pub key: SharedString,
pub name: SharedString,
pub count: usize,
}
/// Projects in a column, each with how many conversations it holds, the open one marked; a press opens one.
#[derive(IntoElement)]
pub struct ProjectList {
id: ElementId,
projects: Vec<Project>,
active: Option<SharedString>,
on_select: Option<OnKey>,
}
impl ProjectList {
pub fn new(id: impl Into<ElementId>, projects: impl IntoIterator<Item = Project>) -> Self {
Self {
id: id.into(),
projects: projects.into_iter().collect(),
active: None,
on_select: None,
}
}
pub fn active(
mut self,
active: Option<SharedString>,
on_select: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.active = active;
self.on_select = Some(Rc::new(on_select));
self
}
}
impl RenderOnce for ProjectList {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
div()
.flex()
.flex_col()
.gap_0p5()
.children(self.projects.into_iter().map(|project| {
let active = self.active.as_ref() == Some(&project.key);
let select = self.on_select.clone();
let key = project.key.clone();
div()
.id((self.id.clone(), format!("project-{key}")))
.flex()
.items_center()
.gap_2()
.h(theme.control_height(ControlSize::Md))
.px_2()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(transparent_black())
.text_size(theme.text_size(TextSize::Sm))
.when(active, |row| row.bg(colors.active))
.when_some(select, |row, select| {
row.tab_index(0)
.focus_ring(cx)
.cursor_pointer()
.when(!active, |row| row.hover(|row| row.bg(colors.hover)))
.on_mouse_down(MouseButton::Left, |_, window, _| {
window.prevent_default()
})
.on_click(move |_, window, cx| {
log::info!("projects: open {key}");
select(&key, window, cx)
})
})
.child(
Icon::new(IconName::Folder)
.size(IconSize::Sm)
.color(colors.fg_muted),
)
.child(div().flex_1().min_w_0().child(Ellipsis::new(project.name)))
.child(
tabular(
div()
.flex_none()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle),
)
.child(project.count.to_string()),
)
}))
}
}
/// What a project knows beyond the conversation: its files, how full that knowledge is, a way to add more and to take one off.
#[derive(IntoElement)]
pub struct ProjectKnowledgePanel {
id: ElementId,
files: Vec<(SharedString, u64)>,
capacity: u64,
on_add: Option<Run>,
on_remove: Option<Pick>,
}
impl ProjectKnowledgePanel {
/// `files` by name and size; `capacity` is how much the project holds, in bytes.
pub fn new(
id: impl Into<ElementId>,
files: impl IntoIterator<Item = (impl Into<SharedString>, u64)>,
capacity: u64,
) -> Self {
assert!(capacity > 0, "a project holds something");
Self {
id: id.into(),
files: files
.into_iter()
.map(|(name, bytes)| (name.into(), bytes))
.collect(),
capacity,
on_add: None,
on_remove: None,
}
}
pub fn on_add(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_add = Some(Rc::new(handler));
self
}
/// Gets the index of the file to take off.
pub fn on_remove(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_remove = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ProjectKnowledgePanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let used: u64 = self.files.iter().map(|(_, bytes)| bytes).sum();
let share = used as f32 / self.capacity as f32;
div()
.flex()
.flex_col()
.gap_3()
.child(
div()
.flex()
.items_center()
.justify_between()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.font_weight(FontWeight::MEDIUM)
.child("Project knowledge"),
)
.children(self.on_add.map(|add| {
Button::new((self.id.clone(), "add"), "Add files")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.icon(IconName::Plus)
.on_click(move |_, window, cx| add(window, cx))
})),
)
.child(
Meter::new((self.id.clone(), "used"), "Capacity", share.min(1.0)).detail(format!(
"{} of {}",
file_size(used, false),
file_size(self.capacity, false)
)),
)
.child(
div()
.flex()
.flex_col()
.gap_0p5()
.children(
self.files
.into_iter()
.enumerate()
.map(|(ix, (name, bytes))| {
let remove = self.on_remove.clone();
div()
.flex()
.items_center()
.gap_2()
.py_1()
.text_size(theme.text_size(TextSize::Sm))
.child(
Icon::new(file_icon(&name, cx))
.size(IconSize::Sm)
.color(colors.fg_muted),
)
.child(div().flex_1().min_w_0().child(Ellipsis::new(name)))
.child(
tabular(
div()
.flex_none()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle),
)
.child(file_size(bytes, false)),
)
.children(remove.map(|remove| {
IconButton::new(
(self.id.clone(), format!("remove-{ix}")),
IconName::X,
)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Remove")
.on_click(
move |_, window, cx| {
log::info!("project knowledge: remove {ix}");
remove(ix, window, cx)
},
)
}))
}),
),
)
}
}
/// A conversation someone shared, to read: who shared it and when, its messages as they stood, and a way to carry it on.
#[derive(IntoElement)]
pub struct SharedConversationView {
id: ElementId,
sharer: SharedString,
at: Timestamp,
messages: AnyElement,
on_continue: Option<Run>,
}
impl SharedConversationView {
/// `messages` show the conversation, such as a `MessageList`.
pub fn new(
id: impl Into<ElementId>,
sharer: impl Into<SharedString>,
at: Timestamp,
messages: impl IntoElement,
) -> Self {
Self {
id: id.into(),
sharer: sharer.into(),
at,
messages: messages.into_any_element(),
on_continue: None,
}
}
pub fn on_continue(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_continue = Some(Rc::new(handler));
self
}
}
impl RenderOnce for SharedConversationView {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
div()
.size_full()
.flex()
.flex_col()
.child(
div()
.flex()
.flex_wrap()
.items_center()
.gap_2()
.px_4()
.py_2()
.border_b_1()
.border_color(colors.border)
.bg(colors.surface)
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(
Icon::new(IconName::Link)
.size(IconSize::Sm)
.color(colors.fg_subtle),
)
.child(format!("Shared by {}", self.sharer))
.child("·")
.child(RelativeTime::new((self.id.clone(), "when"), self.at))
.child(div().flex_1())
.children(self.on_continue.map(|carry| {
Button::new((self.id.clone(), "continue"), "Continue this conversation")
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| carry(window, cx))
})),
)
.child(div().flex_1().min_h_0().child(self.messages))
}
}
const FORMATS: [(&str, &str); 3] = [("markdown", "Markdown"), ("pdf", "PDF"), ("json", "JSON")];
/// Saves a conversation to a file: the format, whether its thinking goes with it, then export or cancel. Render it while open.
#[derive(IntoElement)]
pub struct ConversationExport {
id: ElementId,
format: SharedString,
on_format: OnValue,
thinking: Option<(bool, OnFlag)>,
on_export: Run,
on_close: Run,
}
impl ConversationExport {
/// `format` is markdown, pdf or json; `on_close` runs on Cancel, Escape or a press outside.
pub fn new(
id: impl Into<ElementId>,
format: impl Into<SharedString>,
on_format: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
on_export: impl Fn(&mut Window, &mut App) + 'static,
on_close: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
let format = format.into();
assert!(
FORMATS.iter().any(|(value, _)| *value == format.as_ref()),
"no export format {format}"
);
Self {
id: id.into(),
format,
on_format: Rc::new(on_format),
thinking: None,
on_export: Rc::new(on_export),
on_close: Rc::new(on_close),
}
}
/// Offers to include the thinking behind each answer.
pub fn thinking(
mut self,
included: bool,
on_change: impl Fn(bool, &mut Window, &mut App) + 'static,
) -> Self {
self.thinking = Some((included, Rc::new(on_change)));
self
}
}
impl RenderOnce for ConversationExport {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let (pick, export, close) = (self.on_format, self.on_export, self.on_close.clone());
let format = self.format.clone();
let (cancel, done) = ((self.id.clone(), "cancel"), (self.id.clone(), "export"));
Dialog::new(self.id.clone(), "Export conversation", move |window, cx| {
close(window, cx)
})
.child(
div()
.flex()
.flex_col()
.gap_4()
.child(
RadioGroup::new(
(self.id.clone(), "format"),
FORMATS.map(|(value, label)| Choice::new(value, label)),
)
.selected(self.format)
.on_change(move |next, window, cx| {
log::info!("export: format {next}");
pick(next, window, cx)
}),
)
.children(self.thinking.map(|(included, change)| {
Checkbox::new((self.id.clone(), "thinking"), CheckState::from(included))
.label("Include the thinking behind each answer")
.on_change(move |on, window, cx| change(on, window, cx))
})),
)
.action(move |close| {
Button::new(cancel, "Cancel")
.variant(ButtonVariant::Secondary)
.on_click(move |_, window, cx| close(window, cx))
})
.action(move |close| {
Button::new(done, "Export")
.variant(ButtonVariant::Primary)
.on_click(move |_, window, cx| {
log::info!("export: {format}");
export(window, cx);
close(window, cx)
})
})
}
}