update repo list

This commit is contained in:
2026-08-30 12:41:35 +07:00
parent eda3593982
commit c61695e350
12 changed files with 118 additions and 36 deletions
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M3.75 5.75C3.75 4.64543 4.64543 3.75 5.75 3.75H8.25C9.35457 3.75 10.25 4.64543 10.25 5.75V8.25C10.25 9.35457 9.35457 10.25 8.25 10.25H5.75C4.64543 10.25 3.75 9.35457 3.75 8.25V5.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.75 15.75C3.75 14.6454 4.64543 13.75 5.75 13.75H8.25C9.35457 13.75 10.25 14.6454 10.25 15.75V18.25C10.25 19.3546 9.35457 20.25 8.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25V15.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M13.75 17C13.75 15.2051 15.2051 13.75 17 13.75C18.7949 13.75 20.25 15.2051 20.25 17C20.25 18.7949 18.7949 20.25 17 20.25C15.2051 20.25 13.75 18.7949 13.75 17Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M13.75 5.75C13.75 4.64543 14.6454 3.75 15.75 3.75H18.25C19.3546 3.75 20.25 4.64543 20.25 5.75V8.25C20.25 9.35457 19.3546 10.25 18.25 10.25H15.75C14.6454 10.25 13.75 9.35457 13.75 8.25V5.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 7.75V12L15.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.75 4.75V8.75H6.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.25 15.0833C4.52169 18.676 7.95303 21.25 11.9864 21.25C17.1026 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1026 2.75 11.9864 2.75C8.14808 2.75 4.85497 5.08106 3.44947 8.40278" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 600 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M15.75 6.75H21.25V12.25M20.7361 7.275L14.4142 13.5878C13.633 14.3679 12.3675 14.3675 11.5868 13.5868L10.4142 12.4142C9.63316 11.6332 8.36684 11.6332 7.58579 12.4142L2.75 17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 356 B

+6
View File
@@ -93,6 +93,9 @@ pub enum CustomIconName {
Tag, Tag,
Markdown, Markdown,
Share, Share,
Trending,
Recent,
Grid,
} }
impl IconNamed for CustomIconName { impl IconNamed for CustomIconName {
@@ -118,6 +121,9 @@ impl IconNamed for CustomIconName {
CustomIconName::Tag => "icons/tag.svg", CustomIconName::Tag => "icons/tag.svg",
CustomIconName::Markdown => "icons/markdown.svg", CustomIconName::Markdown => "icons/markdown.svg",
CustomIconName::Share => "icons/share.svg", CustomIconName::Share => "icons/share.svg",
CustomIconName::Trending => "icons/trending.svg",
CustomIconName::Recent => "icons/recent.svg",
CustomIconName::Grid => "icons/grid.svg",
} }
.into() .into()
} }
+3 -3
View File
@@ -807,7 +807,7 @@ fn tree_diff(
/// Parse a `git format-patch` output (a single patch or a patch series) /// Parse a `git format-patch` output (a single patch or a patch series)
/// into the same [`CommitDiff`] structure used for commit diffs. /// into the same [`CommitDiff`] structure used for commit diffs.
/// ///
/// The mbox envelope (From/Subject/ headers, commit body and diffstat) /// The mbox envelope (From/Subject/... headers, commit body and diffstat)
/// is skipped; every `diff --git` section becomes one [`FileDiff`]. Paths /// is skipped; every `diff --git` section becomes one [`FileDiff`]. Paths
/// are taken from the section headers, with git's C-style quoting undone. /// are taken from the section headers, with git's C-style quoting undone.
/// Sections without hunks (pure renames, mode changes, binary files) are /// Sections without hunks (pure renames, mode changes, binary files) are
@@ -895,7 +895,7 @@ fn name_from_address(from: &str) -> String {
} }
} }
/// Strip the `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]` prefix from a patch /// Strip the `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]` ... prefix from a patch
/// `Subject:` header. /// `Subject:` header.
fn strip_patch_prefix(subject: &str) -> String { fn strip_patch_prefix(subject: &str) -> String {
let trimmed = subject.trim(); let trimmed = subject.trim();
@@ -1060,7 +1060,7 @@ fn parse_hunk(lines: &[&str], start: usize) -> Result<(DiffHunk, usize)> {
} }
/// The kind of a hunk body line, from its first character; lines that don't /// The kind of a hunk body line, from its first character; lines that don't
/// belong to the hunk (headers, `\ No newline`, the next section) yield /// belong to the hunk (headers, `\ No newline...`, the next section) yield
/// `None`. /// `None`.
fn line_prefix_kind(line: &str) -> Option<DiffLineKind> { fn line_prefix_kind(line: &str) -> Option<DiffLineKind> {
match line.as_bytes().first()? { match line.as_bytes().first()? {
@@ -134,7 +134,7 @@ impl RepoDetailView {
div() div()
.text_sm() .text_sm()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child("Cloning repository"), .child("Cloning repository..."),
) )
.into_any_element() .into_any_element()
} else if let Some(error) = error { } else if let Some(error) = error {
@@ -37,7 +37,7 @@ impl IssueDetailView {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let comment_input = 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 { Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
@@ -347,7 +347,7 @@ impl IssuesView {
/// through [`RepoStore::open_issue`] when confirmed. /// through [`RepoStore::open_issue`] when confirmed.
pub(super) fn open_new_issue_dialog(store: Entity<RepoStore>, window: &mut Window, cx: &mut App) { 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 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| { window.open_dialog(cx, move |dialog, _window, _cx| {
let subject = subject.clone(); let subject = subject.clone();
@@ -94,7 +94,7 @@ impl PullRequestDetailView {
) -> Self { ) -> Self {
let tree_state = cx.new(|cx| TreeState::new(cx)); let tree_state = cx.new(|cx| TreeState::new(cx));
let comment_input = 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. // Same display name as the repo detail panel's title.
let repo_name = store 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 subject = cx.new(|cx| InputState::new(window, cx).placeholder("Pull request title"));
let description = let description =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change")); cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change..."));
let patch = let patch = cx
cx.new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output")); .new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output..."));
window.open_dialog(cx, move |dialog, _window, _cx| { window.open_dialog(cx, move |dialog, _window, _cx| {
let subject = subject.clone(); let subject = subject.clone();
+91 -24
View File
@@ -1,5 +1,6 @@
use std::rc::Rc; use std::rc::Rc;
use assets::CustomIconName;
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{ use gpui::{
@@ -8,9 +9,11 @@ use gpui::{
}; };
use gpui_base::Button as BaseButton; use gpui_base::Button as BaseButton;
use gpui_component::avatar::Avatar; use gpui_component::avatar::Avatar;
use gpui_component::input::{Input, InputEvent, InputState};
use gpui_component::scroll::Scrollbar; use gpui_component::scroll::Scrollbar;
use gpui_component::{ 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_core::Announcement;
use signed_state::{ProfileStore, RepoListStore, Timestamp}; use signed_state::{ProfileStore, RepoListStore, Timestamp};
@@ -38,10 +41,24 @@ enum RepoFilter {
} }
impl RepoFilter { impl RepoFilter {
fn visible(self, store: &RepoListStore) -> Vec<usize> { /// 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 announcements = &store.announcements;
let mut indices: Vec<usize> = (0..announcements.len()).collect(); 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 { match self {
Self::All => {} Self::All => {}
Self::Recent => indices.truncate(RECENT_COUNT), Self::Recent => indices.truncate(RECENT_COUNT),
@@ -57,6 +74,14 @@ impl RepoFilter {
indices indices
} }
fn icon_name(self) -> CustomIconName {
match self {
Self::All => CustomIconName::Grid,
Self::Recent => CustomIconName::Recent,
Self::Popular => CustomIconName::Trending,
}
}
} }
/// Browse all announced repositories. /// Browse all announced repositories.
@@ -72,22 +97,35 @@ pub struct RepoListView {
/// Number of rows [`Self::item_sizes`] was built for (the filtered repo count). /// Number of rows [`Self::item_sizes`] was built for (the filtered repo count).
repo_len: usize, repo_len: usize,
/// Indices into the store's `announcements` matching [`Self::filter`], /// Indices into the store's `announcements` matching [`Self::filter`],
/// in display order; rebuilt when the store changes or the filter is switched. /// in display order; rebuilt when the store changes, the filter is
/// The virtual list renders this slice. /// switched, or the search text changes. The virtual list renders this
/// slice.
visible: Vec<usize>, 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, _subscription: Subscription,
} }
impl RepoListView { impl RepoListView {
pub fn new( pub fn new(
dock_area: WeakEntity<DockArea>, dock_area: WeakEntity<DockArea>,
_window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let store = RepoListStore::global(cx); let store = RepoListStore::global(cx);
// Keep the visible slice and row sizes in sync with the store, so // Live search over repository names
// newly announced repositories appear without waiting for a click. 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);
}
});
// 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| { let subscription = cx.observe(&store, |this, _store, cx| {
this.rebuild_rows(cx); this.rebuild_rows(cx);
}); });
@@ -101,6 +139,8 @@ impl RepoListView {
item_sizes: Rc::new(Vec::new()), item_sizes: Rc::new(Vec::new()),
repo_len: 0, repo_len: 0,
visible: Vec::new(), visible: Vec::new(),
search,
_search_subscription: search_subscription,
_subscription: subscription, _subscription: subscription,
}; };
@@ -113,13 +153,15 @@ impl RepoListView {
} }
/// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the current /// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the current
/// store contents and [`Self::filter`]. Called when the view is created, /// store contents, [`Self::filter`] and the search query. Called when
/// when the store changes, and when the filter is switched, /// the view is created, when the store changes, when the filter is
/// so the list is ready before the next render. /// switched, and on every search keystroke, so the list is ready before
/// the next render.
fn rebuild_rows(&mut self, cx: &mut Context<Self>) { fn rebuild_rows(&mut self, cx: &mut Context<Self>) {
let filter = self.filter; let filter = self.filter;
let query = self.search.read(cx).value();
let store = self.store.read(cx); let store = self.store.read(cx);
self.visible = filter.visible(store); self.visible = filter.visible(store, &query);
// Each virtual list row holds `COLUMNS` repo cards. // Each virtual list row holds `COLUMNS` repo cards.
let rows = self.visible.len().div_ceil(COLUMNS); let rows = self.visible.len().div_ceil(COLUMNS);
@@ -252,18 +294,44 @@ impl RepoListView {
.into_any_element() .into_any_element()
} }
/// Header of the explore list: title, the count of visible repositories and the sort filter buttons. fn render_header(&self, count: usize, cx: &mut Context<Self>) -> AnyElement {
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
h_flex() h_flex()
.px_4() .px_4()
.py_2() .py_2()
.w_full() .w_full()
.gap_3() .gap_3()
.items_center() .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::All, "All", cx))
.child(self.filter_button(RepoFilter::Popular, "Popular", cx)) .child(self.filter_button(RepoFilter::Popular, "Popular", cx))
.child(self.filter_button(RepoFilter::Recent, "Recent", cx)) .child(self.filter_button(RepoFilter::Recent, "Recent", cx)),
.child(div().flex_1()) )
.into_any_element() .into_any_element()
} }
@@ -275,22 +343,22 @@ impl RepoListView {
label: &'static str, label: &'static str,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> AnyElement {
let active = self.filter == filter;
BaseButton::new(label) BaseButton::new(label)
.flex() .flex()
.items_center() .items_center()
.h_7() .h_7()
.px_2() .px_2()
.gap_1() .gap_1()
.child(Icon::new(filter.icon_name()))
.child(div().text_sm().child(label)) .child(div().text_sm().child(label))
.text_color(cx.theme().button_foreground) .text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover)) .hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().primary_active)) .active(|this| this.bg(cx.theme().button_active))
.selected(self.filter == filter) .selected(active)
.when(self.filter == filter, |this| { .when(active, |this| this.bg(cx.theme().button_active))
this.bg(cx.theme().primary)
.text_color(cx.theme().primary_foreground)
})
.on_click(cx.listener(move |this, _event, _window, cx| { .on_click(cx.listener(move |this, _event, _window, cx| {
this.filter = filter; this.filter = filter;
this.rebuild_rows(cx); this.rebuild_rows(cx);
@@ -331,8 +399,7 @@ impl Render for RepoListView {
.relative() .relative()
.image_cache(image_cache("repos", MAX_IMAGES)) .image_cache(image_cache("repos", MAX_IMAGES))
.size_full() .size_full()
.pb_3() .child(self.render_header(count, cx))
.child(self.render_header(cx))
.when(!has_repos, |this| { .when(!has_repos, |this| {
this.child( this.child(
v_flex().size_full().items_center().justify_center().child( v_flex().size_full().items_center().justify_center().child(
@@ -214,7 +214,7 @@ pub fn open(dock_area: WeakEntity<DockArea>, window: &mut Window, cx: &mut App)
div() div()
.text_xs() .text_xs()
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child("Loading your grasp servers"), .child("Loading your grasp servers..."),
) )
}, },
) )