feat: push checkout (#14)

Reviewed-on: https://git.reya.su/reya/signed/pulls/14
This commit was merged in pull request #14.
This commit is contained in:
2026-09-06 13:14:11 +00:00
parent 33cbe42551
commit 00167c6a8d
85 changed files with 6282 additions and 4487 deletions
+6 -7
View File
@@ -4,12 +4,14 @@ use gpui_component::clipboard::Clipboard;
use gpui_component::menu::PopupMenuItem;
use gpui_component::{ActiveTheme, StyledExt, h_flex};
/// A muted command row with a copy button: the value in a mono-friendly,
/// truncated line, with a [`Clipboard`] button copying the full value.
pub fn copy_row<E>(copy_id: E, command: &SharedString, cx: &App) -> Div
/// A muted command row with a copy button.
pub fn copy_row<E, T>(copy_id: E, command: T, cx: &App) -> Div
where
E: Into<ElementId>,
T: Into<SharedString>,
{
let command = command.into();
h_flex()
.h_8()
.w_full()
@@ -34,10 +36,7 @@ where
)
}
/// One row of a copy menu: a small title above the compact label, with
/// a copy button that flips to a check while the value is on the clipboard.
/// Clicking the row copies and dismisses the menu; the copy button stops
/// propagation so the menu stays open. Both copy `copy`, never the label.
/// One row of a copy menu, with a small title above the compact label.
pub fn menu_copy_row(
id: &'static str,
title: &'static str,
+20 -20
View File
@@ -7,11 +7,10 @@ use gpui_base::{Button as BaseButton, Popover, Selectable, StyledExt};
use gpui_component::menu::PopupMenu;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex};
/// A split dropdown button built on `gpui_base::Popover`: an action element
/// with a separate caret trigger that opens a [`PopupMenu`].
///
/// The action and the caret are caller-supplied elements, so the look stays
/// in the application; this component only owns the popover wiring.
/// A split dropdown button built on `gpui_base::Popover`.
/// An action element with a separate caret trigger that opens a [`PopupMenu`].
/// The action and the caret are caller-supplied elements, so the look stays in the app.
/// This component only owns the popover wiring.
#[derive(IntoElement)]
pub struct DropdownButton {
id: ElementId,
@@ -38,15 +37,16 @@ impl DropdownButton {
}
}
/// The action half of the button. It keeps its own icon, label, tooltip
/// and click handler.
/// The action half of the button.
/// It keeps its own icon, label, tooltip and click handler.
pub fn action(mut self, action: impl IntoElement + 'static) -> Self {
self.action = Some(action.into_any_element());
self
}
/// The menu built by `builder` — the same signature as gpui-component's
/// `DropdownButton::dropdown_menu`, so existing menu code keeps working.
/// The menu built by `builder`.
/// Matches gpui-component's `DropdownButton::dropdown_menu` signature.
/// Existing menu code keeps working.
pub fn dropdown_menu(
mut self,
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
@@ -55,10 +55,9 @@ impl DropdownButton {
self
}
/// Which corner of the caret the menu anchors to. Defaults to
/// [`Anchor::TopRight`], so the menu's right edge lines up with the
/// caret's.
#[allow(dead_code)] // API knob; current call sites use the default anchor.
/// Which corner of the caret the menu anchors to.
/// Defaults to [`Anchor::TopRight`], lining the menu's right edge up with the caret's.
#[allow(dead_code)] // API knob, current call sites use the default anchor.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self
@@ -71,8 +70,8 @@ impl Styled for DropdownButton {
}
}
/// Holds the [`PopupMenu`] entity of one popover between renders. Dismissal
/// drops it, so the menu is rebuilt with fresh items on the next open.
/// Holds the [`PopupMenu`] entity of one popover between renders.
/// Dismissal drops it, so the menu is rebuilt with fresh items on the next open.
#[derive(Default)]
struct DropdownMenuState {
menu: Option<Entity<PopupMenu>>,
@@ -85,7 +84,8 @@ impl RenderOnce for DropdownButton {
"a DropdownButton needs a `dropdown_menu`"
);
// The popover needs its own id: both the container and the popover register keyed state on this window.
// The popover needs its own id.
// The container and the popover both register keyed state on this window.
let popover_id = SharedString::from(format!("{}-popover", self.id));
let anchor = self.anchor;
let menu_state =
@@ -109,8 +109,8 @@ impl RenderOnce for DropdownButton {
this.child(
Popover::new(popover_id)
.anchor(anchor)
// The menu dismisses itself on outside click or Escape;
// the subscription below closes the popover along with it.
// The menu dismisses itself on outside click or Escape.
// The subscription below closes the popover along with it.
.overlay_closable(false)
.trigger_with(caret)
.content(
@@ -148,8 +148,8 @@ impl RenderOnce for DropdownButton {
}
}
/// The default caret: a chevron button the height of a medium button, tinted
/// by the theme, with hover and menu-open states.
/// The default caret, a chevron button the height of a medium button.
/// It is tinted by the theme and styled for hover and menu-open states.
fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton {
BaseButton::new(id)
.h(px(32.))
-139
View File
@@ -1,139 +0,0 @@
use std::collections::{HashMap, VecDeque};
use std::mem::take;
use futures::FutureExt;
use gpui::{
App, AppContext, Asset, AssetLogger, ElementId, Entity, ImageAssetLoader, ImageCache,
ImageCacheItem, ImageCacheProvider, ImageSource, Resource, hash,
};
/// Default number of images each view's cache retains. Loading a new image
/// evicts the least recently used entry once this is reached.
pub const MAX_IMAGES: usize = 128;
pub fn image_cache(id: impl Into<ElementId>, max_items: usize) -> AppImageCacheProvider {
AppImageCacheProvider {
id: id.into(),
max_items,
}
}
pub struct AppImageCacheProvider {
id: ElementId,
max_items: usize,
}
impl ImageCacheProvider for AppImageCacheProvider {
fn provide(&mut self, window: &mut gpui::Window, cx: &mut App) -> gpui::AnyImageCache {
window
.with_global_id(self.id.clone(), |id, window| {
window.with_element_state(id, |cache, _| {
let cache = cache.unwrap_or_else(|| AppImageCache::new(self.max_items, cx));
(cache.clone(), cache)
})
})
.into()
}
}
pub struct AppImageCache {
max_items: usize,
usage_list: VecDeque<u64>,
cache: HashMap<u64, (ImageCacheItem, Resource)>,
}
impl AppImageCache {
pub fn new(max_items: usize, cx: &mut App) -> Entity<Self> {
cx.new(|cx| {
log::info!("Creating AppImageCacheProvider");
cx.on_release(|this: &mut Self, cx| {
for (ix, (mut image, resource)) in take(&mut this.cache) {
if let Some(Ok(image)) = image.get() {
log::info!("Dropping image {ix}");
cx.drop_image(image, None);
}
ImageSource::Resource(resource).remove_asset(cx);
}
})
.detach();
AppImageCache {
max_items,
usage_list: VecDeque::with_capacity(max_items),
cache: HashMap::with_capacity(max_items),
}
})
}
}
impl ImageCache for AppImageCache {
fn load(
&mut self,
resource: &Resource,
window: &mut gpui::Window,
cx: &mut gpui::App,
) -> Option<Result<std::sync::Arc<gpui::RenderImage>, gpui::ImageCacheError>> {
let hash = hash(resource);
if let Some(item) = self.cache.get_mut(&hash) {
let current_idx = self
.usage_list
.iter()
.position(|item| *item == hash)
.expect("cache has an item usage_list doesn't");
self.usage_list.remove(current_idx);
self.usage_list.push_front(hash);
return item.0.get();
}
let load_future = AssetLogger::<ImageAssetLoader>::load(resource.clone(), cx);
let task = cx.background_executor().spawn(load_future).shared();
if self.usage_list.len() >= self.max_items {
log::info!("Image cache is full, evicting oldest item");
if let Some(oldest) = self.usage_list.pop_back() {
let mut image = self
.cache
.remove(&oldest)
.expect("usage_list has an item cache doesn't");
if let Some(Ok(image)) = image.0.get() {
log::info!("requesting image to be dropped");
cx.drop_image(image, Some(window));
}
ImageSource::Resource(image.1).remove_asset(cx);
}
}
self.cache.insert(
hash,
(
gpui::ImageCacheItem::Loading(task.clone()),
resource.clone(),
),
);
self.usage_list.push_front(hash);
let entity = window.current_view();
window
.spawn(cx, async move |cx| {
let result = task.await;
if let Err(err) = result {
log::error!("error loading image into cache: {:?}", err);
}
cx.on_next_frame(move |_, cx| {
cx.notify(entity);
});
})
.detach();
None
}
}
-32
View File
@@ -1,33 +1,3 @@
//! Reusable UI components and elements for Signed.
//!
//! Everything here is presentation-only: the components read theme tokens
//! through [`gpui_component::ActiveTheme`] and render with plain GPUI
//! elements, so any view in the app can use them without depending on app
//! state. They are built on the unstyled `gpui-base` primitives and the
//! styled `gpui-component` library.
//!
//! The crate is organized by component:
//!
//! - [`PixelAvatar`] — deterministic, offline "pixel art" avatar
//! - [`NavItem`] — sidebar navigation row (leading element + label + suffix)
//! - [`DropdownButton`] — split button: an action plus a caret that opens a
//! [`PopupMenu`], wired through `gpui_base::Popover`
//! - [`SegmentButton`] / [`CountBadge`] — segmented filter/tab button with an
//! optional count badge
//! - [`UserAvatar`] — user picture avatar with a name-initials fallback
//! - [`status_badge`] — NIP-34 issue/PR status badge
//! - [`placeholder`] — centered muted placeholder message
//! - [`copy_row`] / [`menu_copy_row`] — rows with a copy-to-clipboard button
//! - [`tree_row`] — one row of a file tree
//! - [`setting_row`] / [`setting_block`] — label + description rows for a
//! settings dialog, with the control on the right (row) or below (block)
//! - [`SelectOption`] — dropdown option with a display label and a stored
//! value
//! - [`title_bar_drag_handlers`] — make an element behave like a window
//! title bar (drag moves the window, double-click zooms)
//! - [`image_cache`] — per-view LRU image cache provider
//! - [`middle_truncate`] — `[head]...[tail]` string truncation
mod dropdown_button;
mod nav_item;
mod pixel_avatar;
@@ -40,12 +10,10 @@ mod tree_row;
mod user_avatar;
pub mod copy_row;
pub mod image_cache;
pub mod util;
pub use copy_row::{copy_row, menu_copy_row};
pub use dropdown_button::DropdownButton;
pub use image_cache::{MAX_IMAGES, image_cache};
pub use nav_item::NavItem;
pub use pixel_avatar::PixelAvatar;
pub use placeholder::placeholder;
+5 -4
View File
@@ -2,9 +2,10 @@ use gpui::prelude::*;
use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, div};
use gpui_component::{ActiveTheme, StyledExt, h_flex};
/// A single navigation entry in a sidebar: an arbitrary leading element
/// (an icon, avatar, ...) and a text label with a hover highlight,
/// an optional trailing suffix (e.g. a status icon) and an optional click handler.
/// A single navigation entry in a sidebar.
/// It has an arbitrary leading element, such as an icon or avatar, and a text label.
/// Hover highlights the row.
/// It can carry a trailing suffix, such as a status icon, and an optional click handler.
#[allow(clippy::type_complexity)]
#[derive(IntoElement)]
pub struct NavItem {
@@ -12,7 +13,7 @@ pub struct NavItem {
style: StyleRefinement,
icon: gpui::AnyElement,
label: SharedString,
/// Trailing element rendered at the right edge of the row, after the (ellipsized) label.
/// Trailing element at the right edge of the row, after the ellipsized label.
suffix: Option<gpui::AnyElement>,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
+19 -15
View File
@@ -9,27 +9,30 @@ const GRID_SIZE: usize = 8;
const FILL_PROBABILITY: f32 = 0.42;
/// Probability that a filled cell uses the accent shade instead of the main color.
const ACCENT_PROBABILITY: f32 = 0.25;
/// Minimum number of filled left-half cells, so a sparse roll still yields a
/// recognizable shape (each left-half cell is mirrored to a right-half one).
/// Minimum number of filled left-half cells.
/// A sparse roll still yields a recognizable shape.
/// Each left-half cell is mirrored to a right-half one.
const MIN_FILLED: usize = 5;
/// A deterministic, offline "pixel art" avatar: an 8×8 grid with horizontal
/// mirror symmetry, seeded from a stable string such as the repository id and
/// owner public key. The same seed always renders the same avatar.
/// Side length of the avatar in pixels, no setter.
const AVATAR_SIZE: Pixels = px(16.);
/// A deterministic, offline pixel-art avatar.
/// An 8×8 grid with horizontal mirror symmetry.
/// Seeded from a stable string such as the repository id and owner public key.
/// The same seed always renders the same avatar.
#[derive(IntoElement)]
pub struct PixelAvatar {
seed: u64,
size: Pixels,
style: StyleRefinement,
}
impl PixelAvatar {
/// Create an avatar seeded from `seed`. The seed should be a stable string
/// unique to the entity the avatar represents.
/// Create an avatar seeded from `seed`.
/// The seed should be a stable string unique to the entity the avatar represents.
pub fn new(seed: impl AsRef<str>) -> Self {
Self {
seed: fnv1a(seed.as_ref().as_bytes()),
size: px(16.),
style: StyleRefinement::default(),
}
}
@@ -77,7 +80,7 @@ impl RenderOnce for PixelAvatar {
.grid()
.grid_cols(GRID_SIZE as u16)
.grid_rows(GRID_SIZE as u16)
.size(self.size)
.size(AVATAR_SIZE)
.flex_shrink_0()
.overflow_hidden()
.bg(main.opacity(0.16))
@@ -85,8 +88,9 @@ impl RenderOnce for PixelAvatar {
}
}
/// Generate the 8×8 cell pattern for `seed`. Cells are `0` (empty), `1`
/// (main color) or `2` (accent shade); the right half mirrors the left half.
/// Generate the 8×8 cell pattern for `seed`.
/// Cells are `0` for empty, `1` for main color and `2` for accent shade.
/// The right half mirrors the left half.
fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
let mut rng = PixelRng::new(seed);
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
@@ -102,8 +106,8 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
}
}
// Sparse rolls can come out nearly empty; top the pattern up to the
// minimum fill, scanning from a seeded starting cell.
// Sparse rolls can come out nearly empty.
// Top the pattern up to the minimum fill, scanning from a seeded starting cell.
if filled < MIN_FILLED {
let half = GRID_SIZE * GRID_SIZE / 2;
let start = (rng.next() % half as u64) as usize;
@@ -130,7 +134,7 @@ fn set_cell(pattern: &mut [u8; GRID_SIZE * GRID_SIZE], row: usize, col: usize, v
pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value;
}
/// FNV-1a 64-bit hash; stable across platforms and runs.
/// FNV-1a 64-bit hash, stable across platforms and runs.
fn fnv1a(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325u64;
for &byte in bytes {
+9 -10
View File
@@ -3,9 +3,9 @@ use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, di
use gpui_base::{Button as BaseButton, StyledExt};
use gpui_component::ActiveTheme;
/// A small count badge shown after a label, e.g. on a segmented filter
/// button ("All 12") or a tab. Rendered from theme tokens; sized for the
/// compact header buttons it lives on.
/// A small count badge shown after a label.
/// Used on segmented filter buttons, like `All 12`, and on tabs.
/// Rendered from theme tokens and sized for the compact header buttons it lives on.
#[derive(IntoElement)]
pub struct CountBadge {
count: usize,
@@ -46,12 +46,11 @@ impl RenderOnce for CountBadge {
}
}
/// A segmented filter/tab button: an icon, a label, an optional [`CountBadge`]
/// and a selected (pressed) state, styled from the theme's button tokens.
///
/// Built on the unstyled `gpui_base::Button`, like the app's other custom
/// controls; the `primary` variant uses the primary button tokens for
/// call-to-action buttons ("New issue", "New PR").
/// A segmented filter/tab button with an icon, a label and an optional [`CountBadge`].
/// The selected state renders the button pressed, styled from theme button tokens.
/// Built on the unstyled `gpui_base::Button`, like the app's other custom controls.
/// The `primary` variant uses the primary button tokens.
/// It suits call-to-action buttons such as `New issue` and `New PR`.
#[allow(clippy::type_complexity)]
#[derive(IntoElement)]
pub struct SegmentButton {
@@ -101,7 +100,7 @@ impl SegmentButton {
self
}
/// Use the primary button tokens (for call-to-action buttons).
/// Use the primary button tokens, for call-to-action buttons.
pub fn primary(mut self) -> Self {
self.primary = true;
self
+5 -9
View File
@@ -1,15 +1,11 @@
//! Reusable settings UI: rows and blocks for building a settings dialog, plus
//! a labeled dropdown option.
use gpui::prelude::*;
use gpui::{App, SharedString, div};
use gpui_component::searchable_list::SearchableListItem;
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex};
/// A dropdown option with a display label and a stored value.
///
/// Renders the `label` in the trigger and the menu, while `value` is what a
/// [`gpui_component::select::SelectState`] reports as the selection.
/// The trigger and menu render the `label`.
/// The `value` is what [`gpui_component::select::SelectState`] reports as the selection.
#[derive(Clone)]
pub struct SelectOption {
value: SharedString,
@@ -48,7 +44,7 @@ impl SearchableListItem for SelectOption {
}
}
/// A settings row: label + description on the left, control on the right.
/// A settings row with the label and description on the left and the control on the right.
pub fn setting_row(
cx: &App,
title: impl Into<SharedString>,
@@ -85,8 +81,8 @@ pub fn setting_row(
)
}
/// A full-width settings block: title + subtitle in one header, `gap_3`
/// between the header and the control below.
/// A full-width settings block with title and subtitle in one header.
/// `gap_3` separates the header from the control below.
pub fn setting_block(
cx: &App,
title: impl Into<SharedString>,
+2 -2
View File
@@ -5,8 +5,8 @@ use gpui_component::tooltip::Tooltip;
use gpui_component::{ActiveTheme, Icon, Sizable, v_flex};
use signed_core::RepoStatus;
/// The status badge shown next to an issue or pull request: icon + colored square,
/// with a tooltip describing the status.
/// The status badge shown next to an issue or pull request.
/// It has an icon and a colored square, with a tooltip describing the status.
pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
let (icon, label, tooltip, bg, fg) = match status {
RepoStatus::Open => (
+6 -6
View File
@@ -8,12 +8,12 @@ struct WindowDragState {
should_move: bool,
}
/// Make an element behave like a window title bar: dragging it moves the
/// window, and double-clicking zooms the window (or performs the platform's
/// default title-bar double-click action on macOS).
///
/// Only the bar's non-interactive areas should get this — tabs are draggable
/// (to reorder panels) and must not move the window.
/// Make an element behave like a window title bar.
/// Dragging it moves the window.
/// Double-clicking zooms the window.
/// On macOS it runs the platform's default title-bar double-click action.
/// Only the bar's non-interactive areas should get this.
/// Tabs are draggable to reorder panels and must not move the window.
pub fn title_bar_drag_handlers(
this: Stateful<Div>,
window: &mut Window,
+3 -2
View File
@@ -4,8 +4,9 @@ use gpui_component::list::ListItem;
use gpui_component::tree::TreeEntry;
use gpui_component::{Icon, IconName, Sizable, h_flex};
/// One row of a file tree: icon + name, indented by depth.
/// Clicking a file runs `on_click`; folders expand/collapse via the tree itself.
/// One row of a file tree, an icon and a name indented by depth.
/// Clicking a file runs `on_click`.
/// Folders expand and collapse via the tree itself.
pub fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
where
F: Fn(&mut Window, &mut App) + 'static,
+4 -4
View File
@@ -3,8 +3,8 @@ use gpui::{App, SharedString, StyleRefinement, Window};
use gpui_component::avatar::Avatar;
use gpui_component::{ActiveTheme, Sizable, StyledExt};
/// A user avatar: the gpui-component [`Avatar`] sized small and rounded with
/// the theme radius, showing the user's picture or a name-initials fallback.
/// A small user avatar from gpui-component [`Avatar`], rounded with the theme radius.
/// It shows the user's picture or falls back to name initials.
#[derive(IntoElement)]
pub struct UserAvatar {
name: SharedString,
@@ -13,8 +13,8 @@ pub struct UserAvatar {
}
impl UserAvatar {
/// Create an avatar for `name`; the name seeds the initials fallback
/// shown when no picture is set.
/// Create an avatar for `name`.
/// The name seeds the initials fallback shown when no picture is set.
pub fn new(name: impl Into<SharedString>) -> Self {
Self {
name: name.into(),
+4 -3
View File
@@ -1,5 +1,6 @@
/// `[head chars]...[tail chars]` middle truncation; the value is left alone
/// when it is too short for the ellipsis to save space.
/// `[head chars]...[tail chars]` middle truncation.
///
/// Values too short for the ellipsis to save space are left alone.
pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
let len = value.chars().count();
if len <= head + tail + 3 {
@@ -32,7 +33,7 @@ mod tests {
),
"30617:a008...3564d:ngit"
);
// Too short to save space with the ellipsis: left alone.
// Too short to save space with the ellipsis, left alone.
assert_eq!(middle_truncate("short", 10, 10), "short");
}
}