restructure
This commit is contained in:
Generated
+15
@@ -1800,6 +1800,7 @@ dependencies = [
|
||||
"gpui",
|
||||
"gpui-base",
|
||||
"gpui-component",
|
||||
"signed_ui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7934,6 +7935,19 @@ dependencies = [
|
||||
"utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signed_ui"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"assets",
|
||||
"futures",
|
||||
"gpui",
|
||||
"gpui-base",
|
||||
"gpui-component",
|
||||
"log",
|
||||
"signed_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
@@ -10763,6 +10777,7 @@ dependencies = [
|
||||
"signed_core",
|
||||
"signed_git",
|
||||
"signed_state",
|
||||
"signed_ui",
|
||||
"utils",
|
||||
]
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ publish.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
gpui-base.workspace = true
|
||||
signed_ui = { path = "../signed_ui" }
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
+1
-55
@@ -9,10 +9,7 @@
|
||||
//! Everything `gpui_component::dock` exports is re-exported here, so the app
|
||||
//! keeps importing the dock from a single place.
|
||||
|
||||
use gpui::{
|
||||
App, Div, InteractiveElement as _, MouseButton, Pixels, Stateful,
|
||||
StatefulInteractiveElement as _, Window, WindowControlArea, px,
|
||||
};
|
||||
use gpui::{Pixels, px};
|
||||
|
||||
mod dock_area;
|
||||
mod invalid_panel;
|
||||
@@ -44,54 +41,3 @@ pub(crate) fn t(key: &'static str) -> &'static str {
|
||||
_ => key,
|
||||
}
|
||||
}
|
||||
|
||||
/// State used to move the window when the title bar area is dragged.
|
||||
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.
|
||||
pub fn title_bar_drag_handlers(
|
||||
this: Stateful<Div>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Stateful<Div> {
|
||||
let state = window.use_state(cx, |_, _| WindowDragState { should_move: false });
|
||||
|
||||
this.window_control_area(WindowControlArea::Drag)
|
||||
.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = false;
|
||||
}))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = true;
|
||||
}),
|
||||
)
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = false;
|
||||
}),
|
||||
)
|
||||
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
|
||||
if state.should_move {
|
||||
state.should_move = false;
|
||||
window.start_window_move();
|
||||
}
|
||||
}))
|
||||
.on_click(|event, window, _| {
|
||||
if event.click_count() == 2 {
|
||||
if cfg!(target_os = "macos") {
|
||||
window.titlebar_double_click();
|
||||
} else {
|
||||
window.zoom_window();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@ use gpui_component::menu::DropdownMenu as _;
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Disableable as _, IconName, Selectable as _, Sizable as _, h_flex, v_flex,
|
||||
};
|
||||
use signed_ui::title_bar_drag_handlers;
|
||||
|
||||
use crate::dock_area::SkinShared;
|
||||
use crate::{
|
||||
ClosePanel, PanelControl, PanelHandle, TAB_BAR_HEIGHT, ToggleZoom, t, title_bar_drag_handlers,
|
||||
window_controls,
|
||||
ClosePanel, PanelControl, PanelHandle, TAB_BAR_HEIGHT, ToggleZoom, t, window_controls,
|
||||
};
|
||||
|
||||
/// The size the styled drag preview occupies, reported to base so a drop
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "signed_ui"
|
||||
description = "Reusable UI components and elements for Signed, built on gpui-base and gpui-component."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
assets = { path = "../assets" }
|
||||
signed_core = { path = "../signed_core" }
|
||||
|
||||
gpui.workspace = true
|
||||
gpui-base.workspace = true
|
||||
gpui-component.workspace = true
|
||||
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
@@ -0,0 +1,67 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, ClipboardItem, Div, ElementId, SharedString, div};
|
||||
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
|
||||
where
|
||||
E: Into<ElementId>,
|
||||
{
|
||||
h_flex()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.text_xs()
|
||||
.child(command.clone()),
|
||||
)
|
||||
.child(
|
||||
Clipboard::new(copy_id)
|
||||
.tooltip("Copy")
|
||||
.value(command.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn menu_copy_row(
|
||||
id: &'static str,
|
||||
title: &'static str,
|
||||
label: String,
|
||||
copy: String,
|
||||
) -> PopupMenuItem {
|
||||
let row_copy = copy.clone();
|
||||
PopupMenuItem::element(move |_window, _cx| {
|
||||
let button_copy = copy.clone();
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.items_end()
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child(div().flex_shrink_0().w_20().font_semibold().child(title))
|
||||
.child(div().flex_1().text_ellipsis().child(label.clone())),
|
||||
)
|
||||
.child(Clipboard::new(id).tooltip("Copy").value(button_copy))
|
||||
})
|
||||
.on_click(move |_, _, cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(row_copy.clone()));
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
Anchor, AnyElement, App, DismissEvent, ElementId, Entity, Focusable, SharedString,
|
||||
StyleRefinement, Window, px,
|
||||
};
|
||||
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.
|
||||
#[derive(IntoElement)]
|
||||
pub struct DropdownButton {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
anchor: Anchor,
|
||||
action: Option<AnyElement>,
|
||||
caret: Option<CaretBuilder>,
|
||||
menu: Option<MenuBuilder>,
|
||||
}
|
||||
|
||||
type MenuBuilder =
|
||||
Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>;
|
||||
type CaretBuilder = Box<dyn FnOnce(bool, &Window, &App) -> AnyElement>;
|
||||
|
||||
impl DropdownButton {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
style: StyleRefinement::default(),
|
||||
anchor: Anchor::TopRight,
|
||||
action: None,
|
||||
caret: None,
|
||||
menu: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn dropdown_menu(
|
||||
mut self,
|
||||
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Self {
|
||||
self.menu = Some(Box::new(builder));
|
||||
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.
|
||||
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||
self.anchor = anchor.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for DropdownButton {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>>,
|
||||
}
|
||||
|
||||
impl RenderOnce for DropdownButton {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
debug_assert!(
|
||||
self.menu.is_some(),
|
||||
"a DropdownButton needs a `dropdown_menu`"
|
||||
);
|
||||
|
||||
// The popover needs its own id: both the container and the popover register keyed state on this window.
|
||||
let popover_id = SharedString::from(format!("{}-popover", self.id));
|
||||
let anchor = self.anchor;
|
||||
let menu_state =
|
||||
window.use_keyed_state(popover_id.clone(), cx, |_, _| DropdownMenuState::default());
|
||||
|
||||
let caret = self.caret.unwrap_or_else(|| {
|
||||
let id = popover_id.clone();
|
||||
Box::new(move |is_open, _, cx| {
|
||||
let caret = default_caret(id.clone(), cx);
|
||||
let selected = caret.is_selected();
|
||||
caret.selected(selected || is_open).into_any_element()
|
||||
})
|
||||
});
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.refine_style(&self.style)
|
||||
.gap_0p5()
|
||||
.when_some(self.action, |this, action| this.child(action))
|
||||
.when_some(self.menu, |this, builder| {
|
||||
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.
|
||||
.overlay_closable(false)
|
||||
.trigger_with(caret)
|
||||
.content(
|
||||
move |_, window, cx| match menu_state.read(cx).menu.clone() {
|
||||
Some(menu) => menu,
|
||||
None => {
|
||||
let menu = PopupMenu::build(window, cx, |menu, window, cx| {
|
||||
builder(menu, window, cx)
|
||||
});
|
||||
menu_state
|
||||
.update(cx, |state, _| state.menu = Some(menu.clone()));
|
||||
menu.focus_handle(cx).focus(window, cx);
|
||||
|
||||
let popover_state = cx.entity();
|
||||
window
|
||||
.subscribe(&menu, cx, {
|
||||
let menu_state = menu_state.clone();
|
||||
move |_, _: &DismissEvent, window, cx| {
|
||||
popover_state.update(cx, |state, cx| {
|
||||
state.dismiss(window, cx);
|
||||
});
|
||||
menu_state.update(cx, |state, _| {
|
||||
state.menu = None;
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
menu.clone()
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The default caret: a chevron button the height of a medium button, tinted
|
||||
/// by the theme, with hover and menu-open states.
|
||||
fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton {
|
||||
BaseButton::new(id)
|
||||
.h(px(32.))
|
||||
.px_1p5()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.hover(|style| style.bg(cx.theme().secondary_hover))
|
||||
.styles(|this| {
|
||||
this.selected(|style| style.bg(cx.theme().secondary_active))
|
||||
.disabled(|style| style.opacity(0.5))
|
||||
})
|
||||
.child(Icon::new(IconName::ChevronDown).xsmall())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::div;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dropdown_button_builder_state() {
|
||||
let button = DropdownButton::new("issues")
|
||||
.action(div())
|
||||
.anchor(Anchor::BottomLeft)
|
||||
.dropdown_menu(|menu, _, _| menu);
|
||||
|
||||
assert!(button.action.is_some());
|
||||
// The caret is `None` until render, which falls back to the default.
|
||||
assert!(button.caret.is_none());
|
||||
assert!(button.menu.is_some());
|
||||
assert_eq!(button.anchor, Anchor::BottomLeft);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! 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
|
||||
//! - [`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
|
||||
//! - [`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;
|
||||
mod placeholder;
|
||||
mod segment_button;
|
||||
mod status_badge;
|
||||
mod title_bar;
|
||||
mod tree_row;
|
||||
|
||||
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;
|
||||
pub use segment_button::{CountBadge, SegmentButton};
|
||||
pub use status_badge::status_badge;
|
||||
pub use title_bar::title_bar_drag_handlers;
|
||||
pub use tree_row::tree_row;
|
||||
pub use util::middle_truncate;
|
||||
@@ -0,0 +1,78 @@
|
||||
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.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[derive(IntoElement)]
|
||||
pub struct NavItem {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
icon: gpui::AnyElement,
|
||||
label: SharedString,
|
||||
/// Trailing element rendered 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>>,
|
||||
}
|
||||
|
||||
impl NavItem {
|
||||
pub fn new<I, L, N>(id: I, label: L, icon: N) -> Self
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
L: Into<SharedString>,
|
||||
N: IntoElement,
|
||||
{
|
||||
Self {
|
||||
id: id.into(),
|
||||
icon: icon.into_any_element(),
|
||||
label: label.into(),
|
||||
style: StyleRefinement::default(),
|
||||
suffix: None,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A trailing element rendered at the right edge of the row
|
||||
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Box::new(listener));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for NavItem {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.refine_style(&self.style)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(self.icon)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(self.label),
|
||||
)
|
||||
.when_some(self.suffix, |this, suffix| {
|
||||
this.child(div().flex_shrink_0().child(suffix))
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.when_some(self.on_click, |this, listener| this.on_click(listener))
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ const MIN_FILLED: usize = 5;
|
||||
/// 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(crate) struct PixelAvatar {
|
||||
pub struct PixelAvatar {
|
||||
seed: u64,
|
||||
size: Pixels,
|
||||
style: StyleRefinement,
|
||||
@@ -26,7 +26,7 @@ pub(crate) struct PixelAvatar {
|
||||
impl PixelAvatar {
|
||||
/// Create an avatar seeded from `seed`. The seed should be a stable string
|
||||
/// unique to the entity the avatar represents.
|
||||
pub(crate) fn new(seed: impl AsRef<str>) -> Self {
|
||||
pub fn new(seed: impl AsRef<str>) -> Self {
|
||||
Self {
|
||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
||||
size: px(16.),
|
||||
@@ -0,0 +1,19 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, div};
|
||||
use gpui_component::{ActiveTheme, v_flex};
|
||||
|
||||
/// A centered muted placeholder message, filling its parent.
|
||||
pub fn placeholder(message: &str, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_4()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(message.to_string()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, ClickEvent, ElementId, SharedString, StyleRefinement, Window, div, px, relative};
|
||||
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.
|
||||
#[derive(IntoElement)]
|
||||
pub struct CountBadge {
|
||||
count: usize,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl CountBadge {
|
||||
pub fn new(count: usize) -> Self {
|
||||
Self {
|
||||
count,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for CountBadge {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for CountBadge {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
div()
|
||||
.refine_style(&self.style)
|
||||
.h_flex()
|
||||
.justify_center()
|
||||
.ml_2()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.min_w_4()
|
||||
.text_size(px(8.))
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.line_height(relative(1.))
|
||||
.child(SharedString::from(self.count.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 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").
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[derive(IntoElement)]
|
||||
pub struct SegmentButton {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
icon: Option<gpui::AnyElement>,
|
||||
label: SharedString,
|
||||
count: Option<usize>,
|
||||
selected: bool,
|
||||
primary: bool,
|
||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
|
||||
impl SegmentButton {
|
||||
pub fn new<I, L>(id: I, label: L) -> Self
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
L: Into<SharedString>,
|
||||
{
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
style: StyleRefinement::default(),
|
||||
icon: None,
|
||||
count: None,
|
||||
selected: false,
|
||||
primary: false,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The leading icon, e.g. `Icon::new(CustomIconName::GitIssueDone)`.
|
||||
pub fn icon(mut self, icon: impl IntoElement) -> Self {
|
||||
self.icon = Some(icon.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// A count shown in a badge after the label.
|
||||
pub fn count(mut self, count: usize) -> Self {
|
||||
self.count = Some(count);
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether the button reflects an active filter/tab.
|
||||
pub fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
/// Use the primary button tokens (for call-to-action buttons).
|
||||
pub fn primary(mut self) -> Self {
|
||||
self.primary = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_click = Some(Box::new(listener));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for SegmentButton {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SegmentButton {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let Self {
|
||||
id,
|
||||
style,
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
selected,
|
||||
primary,
|
||||
on_click,
|
||||
} = self;
|
||||
|
||||
let theme = cx.theme();
|
||||
let fg = if primary {
|
||||
theme.button_primary_foreground
|
||||
} else {
|
||||
theme.button_foreground
|
||||
};
|
||||
let base = if primary {
|
||||
theme.button_primary
|
||||
} else {
|
||||
theme.button_active
|
||||
};
|
||||
let hover = if primary {
|
||||
theme.button_primary_hover
|
||||
} else {
|
||||
theme.button_hover
|
||||
};
|
||||
let active = if primary {
|
||||
theme.button_primary_active
|
||||
} else {
|
||||
theme.button_active
|
||||
};
|
||||
|
||||
BaseButton::new(id)
|
||||
.refine_style(&style)
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.when_some(icon, |this, icon| this.child(icon))
|
||||
.child(div().text_sm().child(label))
|
||||
.when_some(count, |this, count| this.child(CountBadge::new(count)))
|
||||
.text_color(fg)
|
||||
.rounded(theme.radius)
|
||||
.hover(move |this| this.bg(hover))
|
||||
.active(move |this| this.bg(active))
|
||||
.selected(selected)
|
||||
.when(primary, |this| this.bg(base))
|
||||
.when(selected, |this| this.bg(active))
|
||||
.when_some(on_click, |this, listener| this.on_click(listener))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App};
|
||||
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.
|
||||
pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
|
||||
let (icon, label, tooltip, bg, fg) = match status {
|
||||
RepoStatus::Open => (
|
||||
CustomIconName::GitIssueDone,
|
||||
"open",
|
||||
"Issue is open",
|
||||
cx.theme().primary,
|
||||
cx.theme().primary_foreground,
|
||||
),
|
||||
RepoStatus::Closed => (
|
||||
CustomIconName::GitIssueClosed,
|
||||
"closed",
|
||||
"Issue is closed",
|
||||
cx.theme().danger,
|
||||
cx.theme().danger_foreground,
|
||||
),
|
||||
RepoStatus::Draft => (
|
||||
CustomIconName::GitIssueOngoing,
|
||||
"draft",
|
||||
"Issue is draft",
|
||||
cx.theme().accent,
|
||||
cx.theme().accent_foreground,
|
||||
),
|
||||
RepoStatus::Applied => (
|
||||
CustomIconName::GitIssueOpen,
|
||||
"applied",
|
||||
"Issue is completed",
|
||||
cx.theme().secondary,
|
||||
cx.theme().secondary_foreground,
|
||||
),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.id(label)
|
||||
.flex_shrink_0()
|
||||
.size_7()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(bg)
|
||||
.child(Icon::new(icon).small().text_color(fg))
|
||||
.tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use gpui::{
|
||||
App, Div, InteractiveElement as _, MouseButton, Stateful, StatefulInteractiveElement as _,
|
||||
Window, WindowControlArea,
|
||||
};
|
||||
|
||||
/// State used to move the window when the title bar area is dragged.
|
||||
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.
|
||||
pub fn title_bar_drag_handlers(
|
||||
this: Stateful<Div>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Stateful<Div> {
|
||||
let state = window.use_state(cx, |_, _| WindowDragState { should_move: false });
|
||||
|
||||
this.window_control_area(WindowControlArea::Drag)
|
||||
.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = false;
|
||||
}))
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = true;
|
||||
}),
|
||||
)
|
||||
.on_mouse_up(
|
||||
MouseButton::Left,
|
||||
window.listener_for(&state, |state, _, _, _| {
|
||||
state.should_move = false;
|
||||
}),
|
||||
)
|
||||
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
|
||||
if state.should_move {
|
||||
state.should_move = false;
|
||||
window.start_window_move();
|
||||
}
|
||||
}))
|
||||
.on_click(|event, window, _| {
|
||||
if event.click_count() == 2 {
|
||||
if cfg!(target_os = "macos") {
|
||||
window.titlebar_double_click();
|
||||
} else {
|
||||
window.zoom_window();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Window, div, px};
|
||||
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.
|
||||
pub fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) + 'static,
|
||||
{
|
||||
let item = entry.item();
|
||||
let is_folder = entry.is_folder();
|
||||
|
||||
let icon = if is_folder {
|
||||
if entry.is_expanded() {
|
||||
IconName::FolderOpen
|
||||
} else {
|
||||
IconName::FolderClosed
|
||||
}
|
||||
} else {
|
||||
IconName::File
|
||||
};
|
||||
|
||||
ListItem::new(ix)
|
||||
.pl(px(8.) + px(14.) * entry.depth() as f32)
|
||||
.selected(selected)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.overflow_hidden()
|
||||
.child(Icon::new(icon).small())
|
||||
.child(div().text_sm().text_ellipsis().child(item.label.clone())),
|
||||
)
|
||||
.on_click(move |_event, window, cx| {
|
||||
// Folders expand/collapse via the tree itself.
|
||||
if is_folder {
|
||||
return;
|
||||
}
|
||||
on_click(window, cx);
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/// `[head chars]...[tail chars]` middle truncation; the value is left alone
|
||||
/// when it is too short for the ellipsis to save space.
|
||||
pub fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
|
||||
let len = value.chars().count();
|
||||
if len <= head + tail + 3 {
|
||||
return value.to_string();
|
||||
}
|
||||
let head: String = value.chars().take(head).collect();
|
||||
let tail: String = value.chars().skip(len - tail).collect();
|
||||
format!("{head}...{tail}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn middle_truncates_long_values_only() {
|
||||
assert_eq!(
|
||||
middle_truncate(
|
||||
"a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d",
|
||||
10,
|
||||
10,
|
||||
),
|
||||
"a008def157...ad57a3564d"
|
||||
);
|
||||
assert_eq!(
|
||||
middle_truncate(
|
||||
"30617:a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d:ngit",
|
||||
10,
|
||||
10
|
||||
),
|
||||
"30617:a008...3564d:ngit"
|
||||
);
|
||||
// Too short to save space with the ellipsis: left alone.
|
||||
assert_eq!(middle_truncate("short", 10, 10), "short");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ paths = { path = "../paths" }
|
||||
signed_core = { path = "../signed_core" }
|
||||
signed_git = { path = "../signed_git" }
|
||||
signed_state = { path = "../signed_state" }
|
||||
signed_ui = { path = "../signed_ui" }
|
||||
utils = { path = "../utils" }
|
||||
|
||||
gpui.workspace = true
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
mod pixel_avatar;
|
||||
mod views;
|
||||
mod workspace;
|
||||
|
||||
pub mod image_cache;
|
||||
|
||||
use gpui::{App, AppContext, Entity, Window};
|
||||
use gpui_component::Root;
|
||||
pub use signed_ui::image_cache;
|
||||
pub use views::{RepoListView, SidebarPanel};
|
||||
pub use workspace::Workspace;
|
||||
|
||||
/// Build the root view tree. Requires `signed_state::init` and
|
||||
/// `gpui_component::init` to have been called first.
|
||||
/// Build the root view tree.
|
||||
pub fn root(window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let view = cx.new(|cx| Workspace::new(window, cx));
|
||||
cx.new(|cx| Root::new(view, window, cx))
|
||||
|
||||
@@ -6,8 +6,7 @@ use gpui_component::{ActiveTheme, Sizable, StyledExt, WindowExt, h_flex, v_flex}
|
||||
use nostr::prelude::PublicKey;
|
||||
use signed_core::Announcement;
|
||||
use signed_state::ProfileStore;
|
||||
|
||||
use super::helpers::middle_truncate;
|
||||
use signed_ui::middle_truncate;
|
||||
|
||||
/// Open the "About" dialog: every field of the repository's announcement
|
||||
/// event (NIP-34, kind 30617), as parsed into [`Announcement`].
|
||||
|
||||
@@ -12,9 +12,10 @@ use gpui_component::spinner::Spinner;
|
||||
use gpui_component::text::{TextView, TextViewState};
|
||||
use gpui_component::tree::{TreeEntry, TreeState, tree};
|
||||
use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
|
||||
use signed_ui::{placeholder, tree_row};
|
||||
|
||||
use super::RepoDetailView;
|
||||
use super::helpers::{code_language, is_markdown_path, placeholder, tree_row};
|
||||
use super::helpers::{code_language, is_markdown_path};
|
||||
|
||||
/// Width of the file explorer column.
|
||||
const TREE_WIDTH: f32 = 240.;
|
||||
|
||||
@@ -8,10 +8,10 @@ use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::spinner::Spinner;
|
||||
use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list};
|
||||
use signed_git::FileCommit;
|
||||
use signed_ui::placeholder;
|
||||
use utils::relative_time_secs;
|
||||
|
||||
use super::RepoDetailView;
|
||||
use super::helpers::placeholder;
|
||||
|
||||
/// Height of one commit row in the virtual list.
|
||||
pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.;
|
||||
|
||||
@@ -18,11 +18,11 @@ use gpui_component::{
|
||||
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
|
||||
};
|
||||
use signed_git::{CommitDiff, DiffStatus, FileCommit, FileDiff};
|
||||
use signed_ui::{placeholder, tree_row};
|
||||
use utils::relative_time_secs;
|
||||
|
||||
use super::helpers::{
|
||||
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, placeholder, render_diff_row,
|
||||
tree_items, tree_row,
|
||||
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items,
|
||||
};
|
||||
|
||||
/// Width of the changed-files column.
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
Anchor, AnyElement, App, ClipboardItem, DismissEvent, ElementId, Entity, Focusable,
|
||||
SharedString, StyleRefinement, Window, div, px,
|
||||
};
|
||||
use gpui_base::{Button as BaseButton, Popover, Selectable, StyledExt};
|
||||
use gpui_component::clipboard::Clipboard;
|
||||
use gpui_component::list::ListItem;
|
||||
use gpui_component::menu::{PopupMenu, PopupMenuItem};
|
||||
use gpui_component::tooltip::Tooltip;
|
||||
use gpui_component::tree::{TreeEntry, TreeItem};
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
||||
use gpui::{AnyElement, App, SharedString, div, px};
|
||||
use gpui_component::menu::PopupMenu;
|
||||
use gpui_component::tree::TreeItem;
|
||||
use gpui_component::{ActiveTheme, h_flex};
|
||||
use nostr::nips::nip19::{Nip19Coordinate, ToBech32};
|
||||
use signed_core::{Announcement, RepoStatus};
|
||||
use signed_core::Announcement;
|
||||
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff};
|
||||
use signed_ui::{menu_copy_row, middle_truncate};
|
||||
|
||||
/// A `Send` file-tree node: the tree is built on a background thread and
|
||||
/// converted into [`TreeItem`]s (which hold `Rc` state,
|
||||
@@ -55,44 +48,6 @@ pub(super) fn tree_items(seeds: Vec<TreeItemSeed>, expand_folders: bool) -> Vec<
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One row of a file tree: icon + name, indented by depth.
|
||||
/// Clicking a file runs `on_click`; folders expand/collapse via the tree itself.
|
||||
pub(super) fn tree_row<F>(ix: usize, entry: &TreeEntry, selected: bool, on_click: F) -> ListItem
|
||||
where
|
||||
F: Fn(&mut Window, &mut App) + 'static,
|
||||
{
|
||||
let item = entry.item();
|
||||
let is_folder = entry.is_folder();
|
||||
|
||||
let icon = if is_folder {
|
||||
if entry.is_expanded() {
|
||||
IconName::FolderOpen
|
||||
} else {
|
||||
IconName::FolderClosed
|
||||
}
|
||||
} else {
|
||||
IconName::File
|
||||
};
|
||||
|
||||
ListItem::new(ix)
|
||||
.pl(px(8.) + px(14.) * entry.depth() as f32)
|
||||
.selected(selected)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.overflow_hidden()
|
||||
.child(Icon::new(icon).small())
|
||||
.child(div().text_sm().text_ellipsis().child(item.label.clone())),
|
||||
)
|
||||
.on_click(move |_event, window, cx| {
|
||||
// Folders expand/collapse via the tree itself.
|
||||
if is_folder {
|
||||
return;
|
||||
}
|
||||
on_click(window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
/// Build nested tree items from a flat, sorted (dirs-first) entry list.
|
||||
///
|
||||
/// Returns [`TreeItemSeed`]s so the build can run off the main thread; a
|
||||
@@ -210,225 +165,6 @@ pub(super) fn is_markdown_path(path: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// A centered muted placeholder message.
|
||||
pub(super) fn placeholder(message: &str, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.p_4()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(message.to_string()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The status badge shown next to an issue or pull request: icon + colored square,
|
||||
/// with a tooltip describing the status.
|
||||
pub(super) fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
|
||||
let (icon, label, tooltip, bg, fg) = match status {
|
||||
RepoStatus::Open => (
|
||||
CustomIconName::GitIssueDone,
|
||||
"open",
|
||||
"Issue is open",
|
||||
cx.theme().primary,
|
||||
cx.theme().primary_foreground,
|
||||
),
|
||||
RepoStatus::Closed => (
|
||||
CustomIconName::GitIssueClosed,
|
||||
"closed",
|
||||
"Issue is closed",
|
||||
cx.theme().danger,
|
||||
cx.theme().danger_foreground,
|
||||
),
|
||||
RepoStatus::Draft => (
|
||||
CustomIconName::GitIssueOngoing,
|
||||
"draft",
|
||||
"Issue is draft",
|
||||
cx.theme().accent,
|
||||
cx.theme().accent_foreground,
|
||||
),
|
||||
RepoStatus::Applied => (
|
||||
CustomIconName::GitIssueOpen,
|
||||
"applied",
|
||||
"Issue is completed",
|
||||
cx.theme().secondary,
|
||||
cx.theme().secondary_foreground,
|
||||
),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.id(label)
|
||||
.flex_shrink_0()
|
||||
.size_7()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(bg)
|
||||
.child(Icon::new(icon).small().text_color(fg))
|
||||
.tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(IntoElement)]
|
||||
pub(super) struct BaseDropdownButton {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
anchor: Anchor,
|
||||
action: Option<AnyElement>,
|
||||
caret: Option<CaretBuilder>,
|
||||
menu: Option<MenuBuilder>,
|
||||
}
|
||||
|
||||
type MenuBuilder =
|
||||
Box<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static>;
|
||||
type CaretBuilder = Box<dyn FnOnce(bool, &Window, &App) -> AnyElement>;
|
||||
|
||||
impl BaseDropdownButton {
|
||||
pub(super) fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
style: StyleRefinement::default(),
|
||||
anchor: Anchor::TopRight,
|
||||
action: None,
|
||||
caret: None,
|
||||
menu: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The action half of the button. It keeps its own icon, label, tooltip
|
||||
/// and click handler.
|
||||
pub(super) 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.
|
||||
pub(super) fn dropdown_menu(
|
||||
mut self,
|
||||
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
|
||||
) -> Self {
|
||||
self.menu = Some(Box::new(builder));
|
||||
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.
|
||||
pub(super) fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
|
||||
self.anchor = anchor.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for BaseDropdownButton {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>>,
|
||||
}
|
||||
|
||||
impl RenderOnce for BaseDropdownButton {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
debug_assert!(
|
||||
self.menu.is_some(),
|
||||
"a BaseDropdownButton needs a `dropdown_menu`"
|
||||
);
|
||||
|
||||
// The popover needs its own id: both the container and the popover register keyed state on this window.
|
||||
let popover_id = SharedString::from(format!("{}-popover", self.id));
|
||||
let anchor = self.anchor;
|
||||
let menu_state =
|
||||
window.use_keyed_state(popover_id.clone(), cx, |_, _| DropdownMenuState::default());
|
||||
|
||||
let caret = self.caret.unwrap_or_else(|| {
|
||||
let id = popover_id.clone();
|
||||
Box::new(move |is_open, _, cx| {
|
||||
let caret = default_caret(id.clone(), cx);
|
||||
let selected = caret.is_selected();
|
||||
caret.selected(selected || is_open).into_any_element()
|
||||
})
|
||||
});
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.refine_style(&self.style)
|
||||
.gap_0p5()
|
||||
.when_some(self.action, |this, action| this.child(action))
|
||||
.when_some(self.menu, |this, builder| {
|
||||
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.
|
||||
.overlay_closable(false)
|
||||
.trigger_with(caret)
|
||||
.content(
|
||||
move |_, window, cx| match menu_state.read(cx).menu.clone() {
|
||||
Some(menu) => menu,
|
||||
None => {
|
||||
let menu = PopupMenu::build(window, cx, |menu, window, cx| {
|
||||
builder(menu, window, cx)
|
||||
});
|
||||
menu_state
|
||||
.update(cx, |state, _| state.menu = Some(menu.clone()));
|
||||
menu.focus_handle(cx).focus(window, cx);
|
||||
|
||||
let popover_state = cx.entity();
|
||||
window
|
||||
.subscribe(&menu, cx, {
|
||||
let menu_state = menu_state.clone();
|
||||
move |_, _: &DismissEvent, window, cx| {
|
||||
popover_state.update(cx, |state, cx| {
|
||||
state.dismiss(window, cx);
|
||||
});
|
||||
menu_state.update(cx, |state, _| {
|
||||
state.menu = None;
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
menu.clone()
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The default caret: a chevron button the height of a medium button, tinted
|
||||
/// by the theme, with hover and menu-open states.
|
||||
fn default_caret(id: impl Into<ElementId>, cx: &App) -> BaseButton {
|
||||
BaseButton::new(id)
|
||||
.h(px(32.))
|
||||
.px_1p5()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.hover(|style| style.bg(cx.theme().secondary_hover))
|
||||
.styles(|this| {
|
||||
this.selected(|style| style.bg(cx.theme().secondary_active))
|
||||
.disabled(|style| style.opacity(0.5))
|
||||
})
|
||||
.child(Icon::new(IconName::ChevronDown).xsmall())
|
||||
}
|
||||
|
||||
pub(super) struct ShareTargets {
|
||||
/// NIP-19 `naddr1...` of the announcement (with its announced relays).
|
||||
pub(super) naddr: String,
|
||||
@@ -463,25 +199,25 @@ impl ShareTargets {
|
||||
/// label while the copy button (and row click) copy the full value.
|
||||
pub(super) fn menu(&self, menu: PopupMenu) -> PopupMenu {
|
||||
menu.min_w(px(340.))
|
||||
.item(share_menu_row(
|
||||
.item(menu_copy_row(
|
||||
"copy-gitworkshop",
|
||||
"GitWorkshop",
|
||||
truncate_naddr_link(&self.gitworkshop, 4),
|
||||
self.gitworkshop.clone(),
|
||||
))
|
||||
.item(share_menu_row(
|
||||
.item(menu_copy_row(
|
||||
"copy-ditto",
|
||||
"Ditto",
|
||||
truncate_naddr_link(&self.ditto, 4),
|
||||
self.ditto.clone(),
|
||||
))
|
||||
.item(share_menu_row(
|
||||
.item(menu_copy_row(
|
||||
"copy-event-id",
|
||||
"Event ID",
|
||||
middle_truncate(&self.event_id, 10, 10),
|
||||
self.event_id.clone(),
|
||||
))
|
||||
.item(share_menu_row(
|
||||
.item(menu_copy_row(
|
||||
"copy-coordinate",
|
||||
"Coordinate",
|
||||
middle_truncate(&self.coordinate, 10, 10),
|
||||
@@ -490,50 +226,6 @@ impl ShareTargets {
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the share 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.
|
||||
pub(super) fn share_menu_row(
|
||||
id: &'static str,
|
||||
title: &'static str,
|
||||
label: String,
|
||||
copy: String,
|
||||
) -> PopupMenuItem {
|
||||
let row_copy = copy.clone();
|
||||
PopupMenuItem::element(move |_window, _cx| {
|
||||
let button_copy = copy.clone();
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_2()
|
||||
.items_end()
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child(div().flex_shrink_0().w_20().font_semibold().child(title))
|
||||
.child(div().flex_1().text_ellipsis().child(label.clone())),
|
||||
)
|
||||
.child(Clipboard::new(id).tooltip("Copy").value(button_copy))
|
||||
})
|
||||
.on_click(move |_, _, cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(row_copy.clone()));
|
||||
})
|
||||
}
|
||||
|
||||
/// `[head chars]...[tail chars]` middle truncation; the value is left alone
|
||||
/// when it is too short for the ellipsis to save space.
|
||||
pub(super) fn middle_truncate(value: &str, head: usize, tail: usize) -> String {
|
||||
let len = value.chars().count();
|
||||
if len <= head + tail + 3 {
|
||||
return value.to_string();
|
||||
}
|
||||
let head: String = value.chars().take(head).collect();
|
||||
let tail: String = value.chars().skip(len - tail).collect();
|
||||
format!("{head}...{tail}")
|
||||
}
|
||||
|
||||
/// Shorten an naddr link to `<url>/naddr1...[last tail chars]`, e.g.
|
||||
/// `https://gitworkshop.dev/naddr1...abcd`. Only the label is shortened;
|
||||
/// the value to be copied stays the full URL.
|
||||
@@ -763,28 +455,6 @@ mod tests {
|
||||
assert_eq!(code_language("README.md"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_truncates_long_values_only() {
|
||||
assert_eq!(
|
||||
middle_truncate(
|
||||
"a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d",
|
||||
10,
|
||||
10,
|
||||
),
|
||||
"a008def157...ad57a3564d"
|
||||
);
|
||||
assert_eq!(
|
||||
middle_truncate(
|
||||
"30617:a008def15796fba9a0d6fab04e8fd57089285d9fd505da5a83fe8aad57a3564d:ngit",
|
||||
10,
|
||||
10
|
||||
),
|
||||
"30617:a008...3564d:ngit"
|
||||
);
|
||||
// Too short to save space with the ellipsis: left alone.
|
||||
assert_eq!(middle_truncate("short", 10, 10), "short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naddr_link_keeps_url_and_tail() {
|
||||
assert_eq!(
|
||||
@@ -797,17 +467,4 @@ mod tests {
|
||||
"https://example.com/x"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_dropdown_button_builder_state() {
|
||||
let button = BaseDropdownButton::new("issues")
|
||||
.action(div())
|
||||
.anchor(Anchor::BottomLeft)
|
||||
.dropdown_menu(|menu, _, _| menu);
|
||||
|
||||
assert!(button.action.is_some());
|
||||
assert!(button.caret.is_some());
|
||||
assert!(button.menu.is_some());
|
||||
assert_eq!(button.anchor, Anchor::BottomLeft);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,10 @@ use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
|
||||
use nostr::prelude::{Event, EventId, PublicKey};
|
||||
use signed_core::activity_subject;
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::helpers::{placeholder, status_badge};
|
||||
use crate::image_cache::{MAX_IMAGES, image_cache};
|
||||
|
||||
/// Detail panel of a single issue.
|
||||
pub struct IssueDetailView {
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
@@ -5,9 +5,8 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, WeakEntity, Window, div, px, relative, size,
|
||||
SharedString, Size, WeakEntity, Window, div, px, size,
|
||||
};
|
||||
use gpui_base::Button as BaseButton;
|
||||
use gpui_component::avatar::Avatar;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
@@ -20,11 +19,11 @@ use gpui_component::{
|
||||
use nostr::prelude::EventId;
|
||||
use signed_core::{RepoStatus, activity_subject};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{SegmentButton, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::helpers::{placeholder, status_badge};
|
||||
use super::issue_detail::IssueDetailView;
|
||||
use crate::image_cache::{MAX_IMAGES, image_cache};
|
||||
|
||||
/// Height of one issue row in the virtual list: `py_2` padding, a 32px
|
||||
/// title line (`h_8`), a 24px meta line (`h_6`) and the 1px bottom border.
|
||||
@@ -207,106 +206,30 @@ impl IssuesView {
|
||||
.h_12()
|
||||
.gap_2()
|
||||
.child(
|
||||
BaseButton::new("all")
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(Icon::new(CustomIconName::GitIssueDone))
|
||||
.child(div().text_sm().child("All"))
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.ml_2()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.min_w_4()
|
||||
.text_size(px(8.))
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.line_height(relative(1.))
|
||||
.child(SharedString::from(total.to_string())),
|
||||
)
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
.active(|this| this.bg(cx.theme().button_active))
|
||||
SegmentButton::new("all", "All")
|
||||
.icon(Icon::new(CustomIconName::GitIssueDone))
|
||||
.count(total)
|
||||
.selected(self.filter == IssueFilter::All)
|
||||
.when(self.filter == IssueFilter::All, |this| {
|
||||
this.bg(cx.theme().button_active)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = IssueFilter::All;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
BaseButton::new("open")
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(Icon::new(CustomIconName::GitIssueOpen))
|
||||
.child(div().text_sm().child("Open"))
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.ml_2()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.min_w_4()
|
||||
.text_size(px(8.))
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.line_height(relative(1.))
|
||||
.child(SharedString::from(open.to_string())),
|
||||
)
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
SegmentButton::new("open", "Open")
|
||||
.icon(Icon::new(CustomIconName::GitIssueOpen))
|
||||
.count(open)
|
||||
.selected(self.filter == IssueFilter::Open)
|
||||
.when(self.filter == IssueFilter::Open, |this| {
|
||||
this.bg(cx.theme().button_active)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = IssueFilter::Open;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
BaseButton::new("closed")
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(Icon::new(CustomIconName::GitIssueClosed))
|
||||
.child(div().text_sm().child("Closed"))
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.ml_2()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.min_w_4()
|
||||
.text_size(px(8.))
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.line_height(relative(1.))
|
||||
.child(SharedString::from(closed.to_string())),
|
||||
)
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
SegmentButton::new("closed", "Closed")
|
||||
.icon(Icon::new(CustomIconName::GitIssueClosed))
|
||||
.count(closed)
|
||||
.selected(self.filter == IssueFilter::Closed)
|
||||
.when(self.filter == IssueFilter::Closed, |this| {
|
||||
this.bg(cx.theme().button_active)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = IssueFilter::Closed;
|
||||
cx.notify();
|
||||
@@ -315,19 +238,9 @@ impl IssuesView {
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
BaseButton::new("new")
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(Icon::new(CustomIconName::CirclePlus))
|
||||
.child(div().text_sm().child("New issue"))
|
||||
.text_color(cx.theme().button_primary_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().button_primary)
|
||||
.hover(|this| this.bg(cx.theme().button_primary_hover))
|
||||
.active(|this| this.bg(cx.theme().button_primary_active))
|
||||
SegmentButton::new("new", "New issue")
|
||||
.icon(Icon::new(CustomIconName::CirclePlus))
|
||||
.primary()
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
open_new_issue_dialog(this.store.clone(), window, cx);
|
||||
})),
|
||||
|
||||
@@ -8,15 +8,14 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
use gix::Repository;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
Action, Anchor, AnyElement, App, ClipboardItem, Context, Div, ElementId, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription,
|
||||
Task, WeakEntity, Window, div, px, relative, size,
|
||||
Action, Anchor, AnyElement, App, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, PathPromptOptions, Pixels, Render, SharedString, Size, Subscription, Task,
|
||||
WeakEntity, Window, div, px, relative, size,
|
||||
};
|
||||
use gpui_base::{Button as BaseButton, Disableable, Popover};
|
||||
use gpui_component::alert::Alert;
|
||||
use gpui_component::avatar::Avatar;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::clipboard::Clipboard;
|
||||
use gpui_component::combobox::{
|
||||
Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext,
|
||||
};
|
||||
@@ -31,9 +30,8 @@ use nostr::prelude::{EventId, RelayUrl, ToBech32};
|
||||
use signed_core::Announcement;
|
||||
use signed_git::{CommitList, FileCommit};
|
||||
use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoStore};
|
||||
|
||||
use crate::image_cache::{MAX_IMAGES, image_cache};
|
||||
use crate::pixel_avatar::PixelAvatar;
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{DropdownButton, PixelAvatar, copy_row};
|
||||
|
||||
mod about;
|
||||
mod browser;
|
||||
@@ -53,9 +51,7 @@ use browser::{
|
||||
};
|
||||
use commits::COMMIT_ROW_HEIGHT;
|
||||
use diff::CommitDiffView;
|
||||
use helpers::{
|
||||
BaseDropdownButton, ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, tree_items,
|
||||
};
|
||||
use helpers::{ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, tree_items};
|
||||
use issues::{IssuesView, open_new_issue_dialog};
|
||||
use pull_requests::{PullRequestsView, open_new_pull_request_dialog};
|
||||
|
||||
@@ -1358,7 +1354,7 @@ impl RepoDetailView {
|
||||
.gap_2()
|
||||
.justify_end()
|
||||
.child(
|
||||
BaseDropdownButton::new("issues")
|
||||
DropdownButton::new("issues")
|
||||
.action(
|
||||
BaseButton::new("issues-open")
|
||||
.child(
|
||||
@@ -1399,7 +1395,7 @@ impl RepoDetailView {
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
BaseDropdownButton::new("prs")
|
||||
DropdownButton::new("prs")
|
||||
.action(
|
||||
BaseButton::new("prs-open")
|
||||
.child(
|
||||
@@ -1442,7 +1438,7 @@ impl RepoDetailView {
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
BaseDropdownButton::new("share")
|
||||
DropdownButton::new("share")
|
||||
.action(
|
||||
Button::new("link")
|
||||
.icon(IconName::Copy)
|
||||
@@ -1523,8 +1519,8 @@ impl RepoDetailView {
|
||||
)
|
||||
.content(move |_, _window, cx| {
|
||||
let state = cx.entity();
|
||||
let ngit_row = command_row("copy-ngit", &ngit_command, cx);
|
||||
let nak_row = command_row("copy-nak", &nak_command, cx);
|
||||
let ngit_row = copy_row("copy-ngit", &ngit_command, cx);
|
||||
let nak_row = copy_row("copy-nak", &nak_command, cx);
|
||||
|
||||
v_flex()
|
||||
.w(px(440.))
|
||||
@@ -1570,7 +1566,7 @@ impl RepoDetailView {
|
||||
this.children(
|
||||
git_commands.iter().enumerate().map(
|
||||
|(ix, cmd)| {
|
||||
command_row(
|
||||
copy_row(
|
||||
format!("copy-git-{ix}"),
|
||||
cmd,
|
||||
cx,
|
||||
@@ -2026,31 +2022,3 @@ fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedSt
|
||||
|
||||
SharedString::from(url)
|
||||
}
|
||||
|
||||
fn command_row<E>(copy_id: E, command: &SharedString, cx: &mut App) -> Div
|
||||
where
|
||||
E: Into<ElementId>,
|
||||
{
|
||||
h_flex()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.bg(cx.theme().muted)
|
||||
.rounded(cx.theme().radius)
|
||||
.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.text_xs()
|
||||
.child(command.clone()),
|
||||
)
|
||||
.child(
|
||||
Clipboard::new(copy_id)
|
||||
.tooltip("Copy")
|
||||
.value(command.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,14 +27,14 @@ use nostr::prelude::{Event, EventId, Kind, Nip34Tag, PublicKey};
|
||||
use signed_core::{activity_subject, pull_request_patch};
|
||||
use signed_git::{CommitDiff, FileCommit, FileDiff, patch_commits, patch_diffs};
|
||||
use signed_state::{GitStore, ProfileStore, RepoStore};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{placeholder, status_badge, tree_row};
|
||||
use utils::{relative_time, relative_time_secs};
|
||||
|
||||
use super::diff::CommitDiffView;
|
||||
use super::helpers::{
|
||||
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, placeholder, render_diff_row,
|
||||
status_badge, tree_items, tree_row,
|
||||
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, render_diff_row, tree_items,
|
||||
};
|
||||
use crate::image_cache::{MAX_IMAGES, image_cache};
|
||||
|
||||
/// Width of the changed-files column.
|
||||
const TREE_WIDTH: f32 = 260.;
|
||||
|
||||
@@ -5,9 +5,8 @@ use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, WeakEntity, Window, div, px, relative, size,
|
||||
SharedString, Size, WeakEntity, Window, div, px, size,
|
||||
};
|
||||
use gpui_base::Button as BaseButton;
|
||||
use gpui_component::avatar::Avatar;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||
@@ -20,11 +19,11 @@ use gpui_component::{
|
||||
use nostr::prelude::{EventId, Kind};
|
||||
use signed_core::{RepoStatus, activity_subject};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{SegmentButton, placeholder, status_badge};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::helpers::{placeholder, status_badge};
|
||||
use super::pull_request_detail::PullRequestDetailView;
|
||||
use crate::image_cache::{MAX_IMAGES, image_cache};
|
||||
|
||||
/// Height of one pull request row in the virtual list; same layout as an
|
||||
/// issue row.
|
||||
@@ -227,180 +226,50 @@ impl PullRequestsView {
|
||||
.h_12()
|
||||
.gap_2()
|
||||
.child(
|
||||
BaseButton::new("all")
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(Icon::new(CustomIconName::GitPullRequest))
|
||||
.child(div().text_sm().child("All"))
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.ml_2()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.min_w_4()
|
||||
.text_size(px(8.))
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.line_height(relative(1.))
|
||||
.child(SharedString::from(total.to_string())),
|
||||
)
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
.active(|this| this.bg(cx.theme().button_active))
|
||||
SegmentButton::new("all", "All")
|
||||
.icon(Icon::new(CustomIconName::GitPullRequest))
|
||||
.count(total)
|
||||
.selected(self.filter == PullRequestFilter::All)
|
||||
.when(self.filter == PullRequestFilter::All, |this| {
|
||||
this.bg(cx.theme().button_active)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::All;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
BaseButton::new("open")
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(Icon::new(CustomIconName::GitPullRequest))
|
||||
.child(div().text_sm().child("Open"))
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.ml_2()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.min_w_4()
|
||||
.text_size(px(8.))
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.line_height(relative(1.))
|
||||
.child(SharedString::from(open.to_string())),
|
||||
)
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
.active(|this| this.bg(cx.theme().button_active))
|
||||
SegmentButton::new("open", "Open")
|
||||
.icon(Icon::new(CustomIconName::GitPullRequest))
|
||||
.count(open)
|
||||
.selected(self.filter == PullRequestFilter::Open)
|
||||
.when(self.filter == PullRequestFilter::Open, |this| {
|
||||
this.bg(cx.theme().button_active)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::Open;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
BaseButton::new("closed")
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(Icon::new(CustomIconName::GitPullRequestClosed))
|
||||
.child(div().text_sm().child("Closed"))
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.ml_2()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.min_w_4()
|
||||
.text_size(px(8.))
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.line_height(relative(1.))
|
||||
.child(SharedString::from(closed.to_string())),
|
||||
)
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
.active(|this| this.bg(cx.theme().button_active))
|
||||
SegmentButton::new("closed", "Closed")
|
||||
.icon(Icon::new(CustomIconName::GitPullRequestClosed))
|
||||
.count(closed)
|
||||
.selected(self.filter == PullRequestFilter::Closed)
|
||||
.when(self.filter == PullRequestFilter::Closed, |this| {
|
||||
this.bg(cx.theme().button_active)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::Closed;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
BaseButton::new("draft")
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(Icon::new(CustomIconName::GitPullRequestDraft))
|
||||
.child(div().text_sm().child("Draft"))
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.ml_2()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.min_w_4()
|
||||
.text_size(px(8.))
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.line_height(relative(1.))
|
||||
.child(SharedString::from(draft.to_string())),
|
||||
)
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
.active(|this| this.bg(cx.theme().button_active))
|
||||
SegmentButton::new("draft", "Draft")
|
||||
.icon(Icon::new(CustomIconName::GitPullRequestDraft))
|
||||
.count(draft)
|
||||
.selected(self.filter == PullRequestFilter::Draft)
|
||||
.when(self.filter == PullRequestFilter::Draft, |this| {
|
||||
this.bg(cx.theme().button_active)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::Draft;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
BaseButton::new("merged")
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(Icon::new(CustomIconName::GitPullRequestMerged))
|
||||
.child(div().text_sm().child("Merged"))
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_center()
|
||||
.ml_2()
|
||||
.px_1()
|
||||
.py_0p5()
|
||||
.min_w_4()
|
||||
.text_size(px(8.))
|
||||
.bg(cx.theme().muted)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.line_height(relative(1.))
|
||||
.child(SharedString::from(merged.to_string())),
|
||||
)
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
.active(|this| this.bg(cx.theme().button_active))
|
||||
SegmentButton::new("merged", "Merged")
|
||||
.icon(Icon::new(CustomIconName::GitPullRequestMerged))
|
||||
.count(merged)
|
||||
.selected(self.filter == PullRequestFilter::Merged)
|
||||
.when(self.filter == PullRequestFilter::Merged, |this| {
|
||||
this.bg(cx.theme().button_active)
|
||||
})
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.filter = PullRequestFilter::Merged;
|
||||
cx.notify();
|
||||
@@ -409,19 +278,9 @@ impl PullRequestsView {
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
BaseButton::new("new-pr")
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(Icon::new(CustomIconName::CirclePlus))
|
||||
.child(div().text_sm().child("New pull request"))
|
||||
.text_color(cx.theme().button_primary_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().button_primary)
|
||||
.hover(|this| this.bg(cx.theme().button_primary_hover))
|
||||
.active(|this| this.bg(cx.theme().button_primary_active))
|
||||
SegmentButton::new("new-pr", "New pull request")
|
||||
.icon(Icon::new(CustomIconName::CirclePlus))
|
||||
.primary()
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
open_new_pull_request_dialog(this.store.clone(), window, cx);
|
||||
})),
|
||||
|
||||
@@ -7,7 +7,6 @@ use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, Subscription, WeakEntity, Window, div, px, size,
|
||||
};
|
||||
use gpui_base::Button as BaseButton;
|
||||
use gpui_component::avatar::Avatar;
|
||||
use gpui_component::input::{Input, InputEvent, InputState};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
@@ -17,10 +16,11 @@ use gpui_component::{
|
||||
};
|
||||
use signed_core::Announcement;
|
||||
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
||||
use signed_ui::SegmentButton;
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::RepoDetailView;
|
||||
use crate::image_cache::{MAX_IMAGES, image_cache};
|
||||
|
||||
const COLUMNS: usize = 2;
|
||||
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
|
||||
@@ -340,20 +340,9 @@ impl RepoListView {
|
||||
) -> AnyElement {
|
||||
let active = self.filter == filter;
|
||||
|
||||
BaseButton::new(label)
|
||||
.flex()
|
||||
.items_center()
|
||||
.h_7()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(Icon::new(filter.icon_name()))
|
||||
.child(div().text_sm().child(label))
|
||||
.text_color(cx.theme().button_foreground)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().button_hover))
|
||||
.active(|this| this.bg(cx.theme().button_active))
|
||||
SegmentButton::new(label, label)
|
||||
.icon(Icon::new(filter.icon_name()))
|
||||
.selected(active)
|
||||
.when(active, |this| this.bg(cx.theme().button_active))
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
this.filter = filter;
|
||||
this.rebuild_rows(cx);
|
||||
|
||||
@@ -4,15 +4,11 @@ use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{
|
||||
BasePanel, DockArea, DockPlacement, Panel, PanelEvent, TAB_BAR_HEIGHT, panel_handle,
|
||||
title_bar_drag_handlers,
|
||||
};
|
||||
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, TAB_BAR_HEIGHT, panel_handle};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, Context, Div, ElementId, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, ObjectFit, Render, SharedString, StyleRefinement, Subscription, WeakEntity, Window,
|
||||
div, img, px, uniform_list,
|
||||
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render,
|
||||
SharedString, Subscription, WeakEntity, Window, div, img, px, uniform_list,
|
||||
};
|
||||
use gpui_base::Button as BaseButton;
|
||||
use gpui_component::avatar::Avatar;
|
||||
@@ -21,10 +17,10 @@ use gpui_component::input::InputState;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
||||
use signed_core::{Announcement, identifier_from_name};
|
||||
use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore, RepoListStore};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{NavItem, PixelAvatar, title_bar_drag_handlers};
|
||||
|
||||
use super::{RepoDetailView, RepoListView};
|
||||
use crate::image_cache::{MAX_IMAGES, image_cache};
|
||||
use crate::pixel_avatar::PixelAvatar;
|
||||
|
||||
mod create_repo_dialog;
|
||||
pub(crate) mod grasp_servers;
|
||||
@@ -609,75 +605,3 @@ impl Render for SidebarPanel {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A single navigation entry in the 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.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[derive(IntoElement)]
|
||||
struct NavItem {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
icon: AnyElement,
|
||||
label: SharedString,
|
||||
/// Trailing element rendered at the right edge of the row, after the (ellipsized) label.
|
||||
suffix: Option<AnyElement>,
|
||||
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
|
||||
}
|
||||
|
||||
impl NavItem {
|
||||
fn new<I, L, N>(id: I, label: L, icon: N) -> Self
|
||||
where
|
||||
I: Into<ElementId>,
|
||||
L: Into<SharedString>,
|
||||
N: IntoElement,
|
||||
{
|
||||
Self {
|
||||
id: id.into(),
|
||||
icon: icon.into_any_element(),
|
||||
label: label.into(),
|
||||
style: StyleRefinement::default(),
|
||||
suffix: None,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A trailing element rendered at the right edge of the row
|
||||
fn suffix(mut self, suffix: impl IntoElement) -> Self {
|
||||
self.suffix = Some(suffix.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
fn on_click(mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_click = Some(Box::new(listener));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for NavItem {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.refine_style(&self.style)
|
||||
.px_2()
|
||||
.py_1()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(self.icon)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(self.label),
|
||||
)
|
||||
.when_some(self.suffix, |this, suffix| {
|
||||
this.child(div().flex_shrink_0().child(suffix))
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().list_hover))
|
||||
.when_some(self.on_click, |this, listener| this.on_click(listener))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ use gpui::prelude::*;
|
||||
use gpui::{Context, Entity, Render, Subscription, Window, div, px};
|
||||
use gpui_component::{Root, StyledExt, Theme};
|
||||
use signed_state::{Backend, BackendEvent};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
|
||||
use crate::image_cache::{MAX_IMAGES, image_cache};
|
||||
use crate::views::SidebarPanel;
|
||||
use crate::views::sidebar::passphrase_dialog;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user