feat: add new first screen design (#7)
Reviewed-on: https://git.reya.su/reya/signed/pulls/7
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 598 KiB |
|
After Width: | Height: | Size: 639 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -1,5 +1,5 @@
|
||||
use anyhow::Context;
|
||||
use gpui::{App, AssetSource, Result, SharedString};
|
||||
use gpui::{AssetSource, Result, SharedString};
|
||||
use gpui_component::IconNamed;
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
@@ -7,6 +7,8 @@ use rust_embed::RustEmbed;
|
||||
#[folder = "assets"]
|
||||
#[include = "icons/**/*.svg"]
|
||||
#[include = "themes/**/*.json"]
|
||||
#[include = "backgrounds/**/*.jpg"]
|
||||
#[include = "backgrounds/**/*.png"]
|
||||
#[exclude = "*.DS_Store"]
|
||||
pub struct Assets;
|
||||
|
||||
@@ -31,17 +33,12 @@ impl AssetSource for Assets {
|
||||
}
|
||||
|
||||
impl Assets {
|
||||
/// Returns the embedded theme files as `(file name, JSON content)` pairs,
|
||||
/// e.g. `("signed.json", ...)`. The content is a `ThemeSet` that can be
|
||||
/// loaded into the [`ThemeRegistry`](gpui_component::ThemeRegistry).
|
||||
pub fn themes(&self) -> Vec<(String, String)> {
|
||||
Self::iter()
|
||||
.filter(|path| path.starts_with("themes/"))
|
||||
.filter_map(|path| {
|
||||
let data = Self::get(path.as_ref())?;
|
||||
let name = path.strip_prefix("themes/").unwrap_or(path.as_ref());
|
||||
// Debug builds read files from disk (owned), release builds
|
||||
// embed them in the binary (borrowed).
|
||||
let content = match data.data {
|
||||
std::borrow::Cow::Borrowed(bytes) => {
|
||||
std::str::from_utf8(bytes).ok()?.to_owned()
|
||||
@@ -52,22 +49,6 @@ impl Assets {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn load_fonts(&self, cx: &App) -> anyhow::Result<()> {
|
||||
let font_paths = self.list("fonts")?;
|
||||
let mut embedded_fonts = Vec::new();
|
||||
for font_path in font_paths {
|
||||
if font_path.ends_with(".ttf") {
|
||||
let font_bytes = cx
|
||||
.asset_source()
|
||||
.load(&font_path)?
|
||||
.expect("Assets should never return None");
|
||||
embedded_fonts.push(font_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
cx.text_system().add_fonts(embedded_fonts)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum CustomIconName {
|
||||
@@ -91,6 +72,9 @@ pub enum CustomIconName {
|
||||
Tag,
|
||||
Markdown,
|
||||
Share,
|
||||
Trending,
|
||||
Recent,
|
||||
Grid,
|
||||
}
|
||||
|
||||
impl IconNamed for CustomIconName {
|
||||
@@ -116,6 +100,9 @@ impl IconNamed for CustomIconName {
|
||||
CustomIconName::Tag => "icons/tag.svg",
|
||||
CustomIconName::Markdown => "icons/markdown.svg",
|
||||
CustomIconName::Share => "icons/share.svg",
|
||||
CustomIconName::Trending => "icons/trending.svg",
|
||||
CustomIconName::Recent => "icons/recent.svg",
|
||||
CustomIconName::Grid => "icons/grid.svg",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -807,7 +807,7 @@ fn tree_diff(
|
||||
/// Parse a `git format-patch` output (a single patch or a patch series)
|
||||
/// 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
|
||||
/// are taken from the section headers, with git's C-style quoting undone.
|
||||
/// 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.
|
||||
fn strip_patch_prefix(subject: &str) -> String {
|
||||
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
|
||||
/// belong to the hunk (headers, `\ No newline…`, the next section) yield
|
||||
/// belong to the hunk (headers, `\ No newline...`, the next section) yield
|
||||
/// `None`.
|
||||
fn line_prefix_kind(line: &str) -> Option<DiffLineKind> {
|
||||
match line.as_bytes().first()? {
|
||||
|
||||
@@ -29,11 +29,7 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||
];
|
||||
|
||||
/// Relays used for indexing user's relay list (NIP-65).
|
||||
pub const INDEXER_RELAYS: [&str; 3] = [
|
||||
"wss://indexer.coracle.social",
|
||||
"wss://purplepag.es",
|
||||
"wss://user.kindpag.es",
|
||||
];
|
||||
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BackendEvent {
|
||||
|
||||
@@ -12,7 +12,7 @@ use gpui::{App, AppContext, Entity};
|
||||
pub use nostr_sdk::prelude::Timestamp;
|
||||
pub use profile::{Profile, ProfileStore};
|
||||
pub use repo::RepoStore;
|
||||
pub use repo_list::RepoListStore;
|
||||
pub use repo_list::{RepoActivityCounts, RepoListStore};
|
||||
use signed_nostr::new_backend;
|
||||
pub use utils::shorten_pubkey;
|
||||
|
||||
@@ -37,6 +37,11 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
|
||||
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
|
||||
// Start the explore list from the local database before the first
|
||||
// window opens; relay syncs continue in the background, so the list
|
||||
// never waits for them.
|
||||
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
||||
|
||||
// The clone cache is only meaningful on native platforms; the wasm
|
||||
// build registers an empty store so `GitStore::global` still works.
|
||||
GitStore::set_global(PathBuf::new(), cx);
|
||||
@@ -54,6 +59,11 @@ pub fn init(cx: &mut App) -> Entity<Backend> {
|
||||
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
|
||||
// Start the explore list from the local database before the first
|
||||
// window opens; relay syncs continue in the background, so the list
|
||||
// never waits for them.
|
||||
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
||||
|
||||
GitStore::set_global(PathBuf::new(), cx);
|
||||
|
||||
entity
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use gpui::{AppContext, Context, Subscription, Task};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
||||
|
||||
@@ -16,13 +16,46 @@ const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
/// How far back activity events count toward a repository's last activity.
|
||||
const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400);
|
||||
|
||||
struct GlobalRepoListStore(Entity<RepoListStore>);
|
||||
|
||||
impl Global for GlobalRepoListStore {}
|
||||
|
||||
/// Counts of NIP-34 activity events per repository, used to rank the
|
||||
/// explore list by popularity. Each patch event is a pushed commit (or a
|
||||
/// small commit series), which is the closest cross-repository proxy for
|
||||
/// commit count available from event data alone.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct RepoActivityCounts {
|
||||
/// Root `30611` issue events addressed to the repository.
|
||||
pub issues: u32,
|
||||
/// Root `3063` pull request events addressed to the repository
|
||||
/// (updates to a PR are not new PRs and don't count).
|
||||
pub pull_requests: u32,
|
||||
/// `1617` patch events addressed to the repository.
|
||||
pub commits: u32,
|
||||
}
|
||||
|
||||
impl RepoActivityCounts {
|
||||
/// Total issues + pull requests + commits; the popularity ranking key.
|
||||
pub fn score(self) -> u32 {
|
||||
self.issues + self.pull_requests + self.commits
|
||||
}
|
||||
}
|
||||
|
||||
/// Store listing repository announcements (global discovery or per-author).
|
||||
///
|
||||
/// The all-repos store (`author: None`) is created at startup by
|
||||
/// [`crate::init`] and installed as a global, so the explore panel renders
|
||||
/// what's in the local database without waiting for relays.
|
||||
pub struct RepoListStore {
|
||||
/// Shared so views can clone the list per frame without a deep copy.
|
||||
pub announcements: Arc<Vec<Announcement>>,
|
||||
/// Latest known activity timestamp per repository
|
||||
/// (announcements, state updates, patches, PRs, issues, statuses).
|
||||
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
||||
/// Issues + pull requests + commits per repository, for the Popular
|
||||
/// ranking of the explore list.
|
||||
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
||||
author: Option<PublicKey>,
|
||||
refreshing: bool,
|
||||
refresh_dirty: bool,
|
||||
@@ -33,6 +66,16 @@ pub struct RepoListStore {
|
||||
}
|
||||
|
||||
impl RepoListStore {
|
||||
/// Retrieve the global explore store (all announcements, created at
|
||||
/// startup by [`crate::init`]).
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalRepoListStore>().0.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalRepoListStore(entity));
|
||||
}
|
||||
|
||||
/// Create a store. If `author` is `None`, all announcements are listed.
|
||||
pub fn new(author: Option<PublicKey>, cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
@@ -70,6 +113,7 @@ impl RepoListStore {
|
||||
let mut store = Self {
|
||||
announcements: Arc::new(Vec::new()),
|
||||
last_activity: Arc::new(HashMap::new()),
|
||||
counts: Arc::new(HashMap::new()),
|
||||
author,
|
||||
refreshing: false,
|
||||
refresh_dirty: false,
|
||||
@@ -79,7 +123,9 @@ impl RepoListStore {
|
||||
};
|
||||
|
||||
store.subscribe_remote(cx);
|
||||
store.refresh(cx);
|
||||
// Query the local database right away; the list never waits for the
|
||||
// relay syncs started above to finish.
|
||||
store.refresh_initial(cx);
|
||||
store
|
||||
}
|
||||
|
||||
@@ -107,6 +153,18 @@ impl RepoListStore {
|
||||
});
|
||||
}
|
||||
|
||||
/// One-shot initial load: query the local database immediately (no
|
||||
/// debounce), so stored announcements appear as soon as the app opens.
|
||||
/// Only called from [`Self::new`], before any refresh can be pending.
|
||||
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||
debug_assert!(!self.debouncing);
|
||||
if self.refreshing {
|
||||
self.refresh_dirty = true;
|
||||
return;
|
||||
}
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
/// Re-query the local database. Latest announcement per repository wins.
|
||||
///
|
||||
/// Debounced: a short delay collapses bursts of requests (e.g. sync
|
||||
@@ -222,11 +280,38 @@ impl RepoListStore {
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<_, Error>((announcements, last_activity))
|
||||
// Popularity counts per repository (issues, pull requests and
|
||||
// patches). Unbounded, unlike the windowed activity query
|
||||
// above, so totals are exact.
|
||||
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
|
||||
let count_filter =
|
||||
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
|
||||
for event in client.database().query(count_filter).await? {
|
||||
if deletions.is_deleted(&event) {
|
||||
continue;
|
||||
}
|
||||
for addr in event.tags.coordinates() {
|
||||
// Skip events for repos we don't list, so the map can't
|
||||
// grow beyond the number of announcements.
|
||||
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let entry = counts.entry(addr).or_default();
|
||||
match event.kind {
|
||||
Kind::GitIssue => entry.issues += 1,
|
||||
Kind::GitPullRequest => entry.pull_requests += 1,
|
||||
Kind::GitPatch => entry.commits += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<_, Error>((announcements, last_activity, counts))
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let (announcements, last_activity) = match work.await {
|
||||
let (announcements, last_activity, counts) = match work.await {
|
||||
Ok(results) => results,
|
||||
// Database errors are transient; keep the last list.
|
||||
Err(_) => {
|
||||
@@ -239,6 +324,7 @@ impl RepoListStore {
|
||||
let again = this.update(cx, |this, cx| {
|
||||
this.announcements = Arc::new(announcements);
|
||||
this.last_activity = Arc::new(last_activity);
|
||||
this.counts = Arc::new(counts);
|
||||
cx.notify();
|
||||
|
||||
this.refreshing = false;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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..."),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||