feat: add new first screen design (#7)

Reviewed-on: https://git.reya.su/reya/signed/pulls/7
This commit was merged in pull request #7.
This commit is contained in:
2026-08-30 13:31:36 +00:00
parent c76ebfb8f6
commit 767638eda2
27 changed files with 632 additions and 362 deletions
@@ -134,7 +134,7 @@ impl RepoDetailView {
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("Cloning repository"),
.child("Cloning repository..."),
)
.into_any_element()
} else if let Some(error) = error {
@@ -37,7 +37,7 @@ impl IssueDetailView {
cx: &mut Context<Self>,
) -> Self {
let comment_input =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment"));
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
Self {
focus_handle: cx.focus_handle(),
@@ -347,7 +347,7 @@ impl IssuesView {
/// through [`RepoStore::open_issue`] when confirmed.
pub(super) fn open_new_issue_dialog(store: Entity<RepoStore>, window: &mut Window, cx: &mut App) {
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title"));
let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue"));
let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue..."));
window.open_dialog(cx, move |dialog, _window, _cx| {
let subject = subject.clone();
@@ -94,7 +94,7 @@ impl PullRequestDetailView {
) -> Self {
let tree_state = cx.new(|cx| TreeState::new(cx));
let comment_input =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment"));
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
// Same display name as the repo detail panel's title.
let repo_name = store
@@ -442,9 +442,9 @@ pub(super) fn open_new_pull_request_dialog(
) {
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Pull request title"));
let description =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change"));
let patch =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output"));
cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change..."));
let patch = cx
.new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output..."));
window.open_dialog(cx, move |dialog, _window, _cx| {
let subject = subject.clone();
+245 -41
View File
@@ -1,15 +1,19 @@
use std::rc::Rc;
use assets::CustomIconName;
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, 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;
use gpui_component::{
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex,
v_virtual_list,
};
use signed_core::Announcement;
use signed_state::{ProfileStore, RepoListStore, Timestamp};
@@ -18,42 +22,155 @@ use utils::relative_time;
use super::RepoDetailView;
use crate::image_cache::{MAX_IMAGES, image_cache};
const CARD_HEIGHT: f32 = 160.;
const COLUMNS: usize = 2;
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
/// Browse all announced repositories (works anonymously).
/// How many of the newest repositories the "Recent" sort shows.
const RECENT_COUNT: usize = 10;
/// Sort of the explore list, chosen via the header's filter buttons.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum RepoFilter {
/// Every repository, newest first (the store's default order).
All,
#[default]
/// Repositories ranked by total issues + pull requests + commits.
Popular,
/// The [`RECENT_COUNT`] newest repositories.
Recent,
}
impl RepoFilter {
/// Indices into the store's `announcements` included by this filter, in
/// display order, narrowed to repositories whose name (or id) contains
/// `query`; an empty query matches everything.
fn visible(self, store: &RepoListStore, query: &str) -> Vec<usize> {
let announcements = &store.announcements;
let mut indices: Vec<usize> = (0..announcements.len()).collect();
// Narrow by the search query first, so "Recent" limits the matches
// and "Popular" ranks them.
let query = query.trim().to_lowercase();
if !query.is_empty() {
indices.retain(|&ix| {
let announcement = &announcements[ix];
let name = announcement.name.as_deref().unwrap_or(&announcement.id);
name.to_lowercase().contains(&query)
});
}
match self {
Self::All => {}
Self::Recent => indices.truncate(RECENT_COUNT),
Self::Popular => {
let counts = &store.counts;
let scores: Vec<u32> = announcements
.iter()
.map(|a| counts.get(&a.addr()).map_or(0, |c| c.score()))
.collect();
indices.sort_by(|&a, &b| scores[b].cmp(&scores[a]));
}
}
indices
}
fn icon_name(self) -> CustomIconName {
match self {
Self::All => CustomIconName::Grid,
Self::Recent => CustomIconName::Recent,
Self::Popular => CustomIconName::Trending,
}
}
}
/// Browse all announced repositories.
pub struct RepoListView {
store: Entity<RepoListStore>,
dock_area: WeakEntity<DockArea>,
focus_handle: FocusHandle,
scroll_handle: VirtualListScrollHandle,
/// Sort selected in the header filter buttons.
filter: RepoFilter,
/// Per-row heights of the virtual list.
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Number of rows [`Self::item_sizes`] was built for (the filtered repo count).
repo_len: usize,
/// Indices into the store's `announcements` matching [`Self::filter`],
/// in display order; rebuilt when the store changes, the filter is
/// switched, or the search text changes. The virtual list renders this
/// slice.
visible: Vec<usize>,
/// Search box filtering repositories by name.
search: Entity<InputState>,
/// Rebuilds the visible slice as the search text changes.
_search_subscription: Subscription,
_subscription: Subscription,
}
impl RepoListView {
pub fn new(
dock_area: WeakEntity<DockArea>,
_window: &mut Window,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let store = cx.new(|cx| RepoListStore::new(None, cx));
let store = RepoListStore::global(cx);
let subscription = cx.observe(&store, |this, store, cx| {
let count = store.read(cx).announcements.len();
if this.item_sizes.len() != count {
this.item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); count]);
// Live search over repository names
let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search..."));
let search_subscription = cx.subscribe(&search, |this, _search, event, cx| {
if matches!(event, InputEvent::Change) {
this.rebuild_rows(cx);
}
});
Self {
// Keep the visible slice and row sizes in sync with the store,
// so newly announced repositories appear without waiting for a click.
let subscription = cx.observe(&store, |this, _store, cx| {
this.rebuild_rows(cx);
});
let mut this = Self {
store,
dock_area,
focus_handle: cx.focus_handle(),
scroll_handle: VirtualListScrollHandle::new(),
item_sizes: Rc::new(vec![]),
filter: RepoFilter::default(),
item_sizes: Rc::new(Vec::new()),
repo_len: 0,
visible: Vec::new(),
search,
_search_subscription: search_subscription,
_subscription: subscription,
};
// Seed the rows right away; the store may already hold announcements
// (it loaded before the panel opened), and the first render must not
// depend on a later store update.
this.rebuild_rows(cx);
this
}
/// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the current
/// store contents, [`Self::filter`] and the search query. Called when
/// the view is created, when the store changes, when the filter is
/// switched, and on every search keystroke, so the list is ready before
/// the next render.
fn rebuild_rows(&mut self, cx: &mut Context<Self>) {
let filter = self.filter;
let query = self.search.read(cx).value();
let store = self.store.read(cx);
self.visible = filter.visible(store, &query);
// Each virtual list row holds `COLUMNS` repo cards.
let rows = self.visible.len().div_ceil(COLUMNS);
if self.repo_len != rows {
self.repo_len = rows;
self.item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]);
}
cx.notify();
}
fn open_repo(
@@ -106,14 +223,17 @@ impl RepoListView {
v_flex()
.id(ix)
.px_4()
.w_full()
.border_b(px(1.))
.flex_1()
.h_full()
.min_w_0()
.px_3()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().border)
.hover(|this| this.bg(cx.theme().list_hover))
.child(
h_flex()
.h_12()
.h_10()
.text_sm()
.font_semibold()
.whitespace_nowrap()
@@ -123,9 +243,11 @@ impl RepoListView {
.child(
div()
.h_16()
.text_sm()
.min_w_0()
.text_xs()
.text_color(cx.theme().muted_foreground)
.line_clamp(2)
.text_ellipsis()
.child(description),
)
.child(
@@ -171,6 +293,78 @@ impl RepoListView {
}))
.into_any_element()
}
fn render_header(&self, count: usize, cx: &mut Context<Self>) -> AnyElement {
h_flex()
.px_4()
.py_2()
.w_full()
.gap_3()
.child(
h_flex()
.gap_1()
.text_xs()
.child(div().font_semibold().child("Repositories"))
.child(
div()
.w_10()
.min_w_0()
.truncate()
.text_ellipsis()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(format!("({count})"))),
),
)
.child(
Input::new(&self.search)
.cleanable(true)
.w(px(180.))
.text_sm()
.border_color(cx.theme().muted)
.bg(cx.theme().muted)
.prefix(Icon::new(IconName::Search).small()),
)
.child(div().flex_1())
.child(
h_flex()
.gap_1()
.child(self.filter_button(RepoFilter::All, "All", cx))
.child(self.filter_button(RepoFilter::Popular, "Popular", cx))
.child(self.filter_button(RepoFilter::Recent, "Recent", cx)),
)
.into_any_element()
}
/// One segmented filter button of the header, styled like the issues
/// list's status filter buttons.
fn filter_button(
&self,
filter: RepoFilter,
label: &'static str,
cx: &mut Context<Self>,
) -> 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))
.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);
}))
.into_any_element()
}
}
impl BasePanel for RepoListView {
@@ -195,39 +389,28 @@ impl Focusable for RepoListView {
impl Render for RepoListView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let announcements = self.store.read(cx).announcements.clone();
let last_activity = self.store.read(cx).last_activity.clone();
let has_announcements = !announcements.is_empty();
let count = announcements.len();
let store = self.store.read(cx);
let announcements = store.announcements.clone();
let last_activity = store.last_activity.clone();
let count = self.visible.len();
let has_repos = count > 0;
v_flex()
.relative()
.image_cache(image_cache("repos", MAX_IMAGES))
.size_full()
.child(
h_flex()
.px_4()
.py_2()
.items_center()
.child(div().text_sm().font_semibold().child("Repositories"))
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(format!(" ({count})"))),
),
)
.when(!has_announcements, |this| {
.child(self.render_header(count, cx))
.when(!has_repos, |this| {
this.child(
v_flex().size_full().items_center().justify_center().child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("No repositories found. Waiting for relays..."),
.child("No repositories found yet."),
),
)
})
.when(has_announcements, |this| {
.when(has_repos, |this| {
let view = cx.entity().clone();
let sizes = self.item_sizes.clone();
@@ -235,10 +418,31 @@ impl Render for RepoListView {
v_virtual_list(view, "repos", sizes, move |this, range, _window, cx| {
let mut items = vec![];
for ix in range {
let announcement: &Announcement = &announcements[ix];
let activity = last_activity.get(&announcement.addr()).copied();
items.push(this.render_card(ix, announcement, activity, cx));
for row in range {
let mut row_cards = vec![];
for col in 0..COLUMNS {
let Some(&ix) = this.visible.get(row * COLUMNS + col) else {
break;
};
let Some(announcement) = announcements.get(ix) else {
break;
};
let activity = last_activity.get(&announcement.addr()).copied();
row_cards.push(this.render_card(ix, announcement, activity, cx));
}
items.push(
h_flex()
.id(row)
.w_full()
.h_full()
.gap_3()
.px_4()
.pt_4()
.children(row_cards)
.into_any_element(),
);
}
items
@@ -214,7 +214,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("Loading your grasp servers"),
.child("Loading your grasp servers..."),
)
},
)
+92 -32
View File
@@ -1,4 +1,5 @@
use std::ops::Range;
use std::time::{SystemTime, UNIX_EPOCH};
use assets::CustomIconName;
use dock::{
@@ -7,9 +8,11 @@ use dock::{
};
use gpui::prelude::*;
use gpui::{
AnyElement, App, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
Render, SharedString, StyleRefinement, Subscription, WeakEntity, Window, div, px, uniform_list,
AnyElement, App, ClickEvent, Context, Div, ElementId, Entity, EventEmitter, FocusHandle,
Focusable, ObjectFit, Render, SharedString, StyleRefinement, Subscription, WeakEntity, Window,
div, img, px, uniform_list,
};
use gpui_base::Button as BaseButton;
use gpui_component::avatar::Avatar;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::InputState;
@@ -40,6 +43,9 @@ pub struct SidebarPanel {
/// Observes the current user's repo store so the list re-renders.
my_repos_subscription: Option<Subscription>,
logged_in: bool,
/// Banner artwork shown behind the sign-in screen,
/// picked at random from the bundled `backgrounds/` assets.
banner: SharedString,
_subscription: Subscription,
}
@@ -56,6 +62,7 @@ impl SidebarPanel {
}
BackendEvent::SignerRequired => {
this.logged_in = false;
this.banner = pick_banner();
this.my_repos = None;
this.my_repos_subscription = None;
}
@@ -71,6 +78,7 @@ impl SidebarPanel {
my_repos: None,
my_repos_subscription: None,
logged_in,
banner: pick_banner(),
_subscription: subscription,
};
@@ -287,6 +295,87 @@ impl SidebarPanel {
cx,
)
}
/// Sign-in placeholder shown while logged out: the banner artwork fills the
/// panel behind a scrim that ends in a solid black band, keeping the CTA
/// buttons readable on a clean dark surface in both themes.
fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
v_flex()
.size_full()
.relative()
.bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground)
.child(title_bar_drag_handlers(
div()
.id("onboarding-drag")
.absolute()
.h_12()
.w_full()
.top_0()
.left_0(),
window,
cx,
))
.child(
div().absolute().inset_0().child(
img(self.banner.clone())
.size_full()
.object_fit(ObjectFit::Cover),
),
)
.child(
v_flex()
.size_full()
.justify_end()
.p_4()
.mb_4()
.gap_4()
.child(img("backgrounds/headline.png").max_w_48())
.child(
v_flex()
.gap_1()
.w_full()
.child(
BaseButton::new("onboarding")
.h_flex()
.h_8()
.px_2()
.bg(cx.theme().primary)
.hover(|this| this.bg(cx.theme().primary_hover))
.active(|this| this.bg(cx.theme().primary_active))
.text_color(cx.theme().primary_foreground)
.child(div().text_sm().font_semibold().child("Join now"))
.on_click(cx.listener(|this, _ev, window, cx| {
this.open_onboarding(window, cx)
})),
)
.child(
BaseButton::new("onboarding")
.h_flex()
.h_8()
.px_2()
.text_color(gpui::white())
.bg(gpui::white().opacity(0.1))
.hover(|this| this.bg(gpui::white().opacity(0.2)))
.active(|this| this.bg(gpui::white().opacity(0.4)))
.child(div().text_sm().child("Import identity"))
.on_click(cx.listener(|this, _ev, window, cx| {
this.open_import(window, cx)
})),
),
),
)
}
}
fn pick_banner() -> SharedString {
let num = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before unix epoch")
.subsec_nanos()
% 3
+ 1;
format!("backgrounds/banner{num}.jpg").into()
}
impl BasePanel for SidebarPanel {
@@ -316,36 +405,7 @@ impl Focusable for SidebarPanel {
impl Render for SidebarPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if !self.logged_in {
return v_flex()
.p_4()
.size_full()
.items_center()
.justify_center()
.gap_2()
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("Sign in to continue"),
)
.child(
Button::new("onboarding")
.label("Join now")
.primary()
.w_full()
.on_click(
cx.listener(|this, _ev, window, cx| this.open_onboarding(window, cx)),
),
)
.child(
Button::new("import-identity")
.label("Import identity")
.secondary()
.w_full()
.on_click(
cx.listener(|this, _ev, window, cx| this.open_import(window, cx)),
),
);
return self.render_sign_in(window, cx);
}
let backend = Backend::global(cx);