chore: improce performance (#9)

Reviewed-on: https://git.reya.su/reya/signed/pulls/9
This commit was merged in pull request #9.
This commit is contained in:
2026-09-01 02:05:24 +00:00
parent d2468545d6
commit 05ab543fa0
24 changed files with 729 additions and 555 deletions
+12 -19
View File
@@ -27,11 +27,7 @@ use crate::tiles::SignedTilesSkin;
use crate::{TAB_BAR_HEIGHT, panel_handle};
/// What every part of the skin reads, and the dock area it belongs to.
///
/// The renderer is the only skin-owned object in the picture, so the settings
/// the old `DockArea` carried live here. It is shared by reference with the
/// per-container renderers, which are built once each and outlive any one
/// frame.
/// Shared by reference with the per-container renderers.
pub(crate) struct SkinShared {
area: WeakEntity<DockArea>,
toggle_button_visible: Cell<bool>,
@@ -201,10 +197,9 @@ impl DockAreaRenderer for SignedDockSkin {
.into_any_element()
}
/// The "unknown panel" message the old `InvalidPanel` drew.
///
/// It answers `dump` with the state it was handed, so a layout written by
/// a build that knows the panel survives a load and save here.
/// The "unknown panel" message the old `InvalidPanel` drew. It answers
/// `dump` with the state it was handed, so a layout written by a build
/// that knows the panel survives a load and save here.
fn build_placeholder(
&self,
state: &PanelState,
@@ -246,12 +241,10 @@ impl SignedDockSkin {
}
}
/// Turns the window's mouse stream into dock resizing.
///
/// A resize is driven by pointer moves that land anywhere in the window, not
/// only on the handle, so it cannot be expressed as a listener on the handle
/// itself. This element paints nothing and exists for its `paint` hook, which
/// is the only place a window-level mouse listener can be registered.
/// Turns the window's mouse stream into dock resizing. A resize is driven
/// by pointer moves anywhere in the window, so this paints nothing and
/// exists for its `paint` hook — the only place a window-level mouse
/// listener can be registered.
struct DockResizeTracker {
dock: DockContext,
shared: Rc<SkinShared>,
@@ -317,10 +310,10 @@ impl Element for DockResizeTracker {
if !phase.bubble() || shared.resizing_dock().get() != Some(placement) {
return;
}
// Dragging a closed dock's handle reopens it, as the old dock
// did. The live state is read rather than the render-time
// snapshot in `dock`, which would still say closed for the
// rest of the frame and toggle it shut again on the next move.
// Dragging a closed dock's handle reopens it. The live
// state is read rather than the render-time snapshot in
// `dock`, which would still say closed for the rest of the
// frame and toggle it shut again on the next move.
let open = shared
.area()
.upgrade()
+4 -6
View File
@@ -7,12 +7,10 @@ use gpui_component::ActiveTheme as _;
use crate::Panel;
/// Stands in for a panel this build cannot construct — one whose `panel_name`
/// no [`PanelRegistry`](gpui_base::dock::PanelRegistry) builder answers to.
///
/// It reports the original [`PanelState`] from
/// [`dump`](gpui_base::dock::Panel::dump), so a layout written by a build that
/// knows the panel survives a load and a save here rather than losing it.
/// Stands in for a panel this build cannot construct. It reports the
/// original [`PanelState`] from [`dump`](gpui_base::dock::Panel::dump), so
/// a layout written by a build that knows the panel survives a load and
/// save here.
pub(crate) struct InvalidPanel {
name: SharedString,
focus_handle: FocusHandle,
+5 -7
View File
@@ -1,12 +1,10 @@
//! The Signed dock skin.
//!
//! The dock engine lives upstream: `gpui_base::dock` owns the layout tree,
//! the drags, the zoom and the persistence, and `gpui_component::dock`
//! supplies the default appearance. This crate is the appearance the app
//! used to vendor from gpui-component — a 44px tab bar that doubles as the
//! window title bar, with pill tabs, window controls, title-bar dragging and
//! previous/next tab buttons — re-implemented against upstream's renderer
//! traits.
//! The dock engine lives upstream (`gpui_base::dock` owns the layout tree,
//! drags, zoom and persistence); this crate is the appearance the app used
//! to vendor from gpui-component — a 44px tab bar that doubles as the
//! window title bar, with pill tabs, window controls, title-bar dragging
//! and previous/next tab buttons.
//!
//! Everything `gpui_component::dock` exports is re-exported here, so the app
//! keeps importing the dock from a single place.
+23 -37
View File
@@ -84,11 +84,9 @@ impl Render for DragPanelPreview {
}
/// Where the zoom affordance goes for the group's displayed panel, or `None`
/// when there is none to offer.
///
/// Two questions, and both have to be asked. [`Panel::zoom_control`] says
/// *where* the control appears; [`gpui_base::dock::Panel::zoomable`] says
/// whether zooming happens at all, and base refuses a zoom that fails it.
/// when there is none to offer. Both [`Panel::zoom_control`] (where) and
/// [`gpui_base::dock::Panel::zoomable`] (whether) must pass; base refuses a
/// zoom that fails the latter.
fn zoom_control(group: &TabGroupContext, cx: &App) -> Option<PanelControl> {
let panel = group.active_panel()?;
panel
@@ -122,11 +120,8 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
}
}
/// One tab group's appearance.
///
/// Built per group — `DockAreaRenderer::tab_group_renderer` is called once
/// per container — so the tab bar's scroll position and the measured
/// title-bar geometry belong to the group they describe.
/// One tab group's appearance. Built once per container, so the tab bar's
/// scroll position and measured title-bar geometry belong to the group.
pub(crate) struct SignedTabGroupSkin {
shared: Rc<SkinShared>,
scroll_handle: ScrollHandle,
@@ -177,9 +172,8 @@ impl SignedTabGroupSkin {
/// The bottom or right dock whose root tab group this group is, if any.
///
/// Base bars a dock's only group from being dragged or closed, so the
/// dock cannot be emptied. A bottom/right panel is supposed to be
/// closable and movable, though — the vendored dock allowed exactly that
/// — so the skin recognizes the group and routes around the bar.
/// dock cannot be emptied — but a bottom/right panel is supposed to be
/// closable, so the skin routes around the bar for these groups.
fn is_dock_root_group(&self, group: &TabGroupContext, cx: &App) -> Option<DockPlacement> {
let area = self.shared.area().upgrade()?;
let area = area.read(cx);
@@ -270,9 +264,7 @@ impl SignedTabGroupSkin {
}
/// The previous/next tab buttons shown in the tab bar's leading prefix.
///
/// Unlike the dock toggle button they always render, but are disabled at
/// the ends of the tab strip (or while the panel is collapsed).
/// Always rendered, disabled at the ends of the strip (or collapsed).
fn render_prev_next_tab_buttons(
&self,
group: &TabGroupContext,
@@ -405,10 +397,9 @@ impl SignedTabGroupSkin {
)
}
/// One tab of the pill strip.
///
/// While collapsed, tabs lose the active style and all interactions, and
/// the strip becomes the way a closed bottom dock is opened again.
/// One tab of the pill strip. While collapsed, tabs lose the active
/// style and all interactions, and the strip becomes the way a closed
/// bottom dock is opened again.
#[allow(clippy::too_many_arguments)]
fn render_tab(
&self,
@@ -578,10 +569,8 @@ impl TabGroupRenderer for SignedTabGroupSkin {
if group.panels().is_empty() {
return div().id("tab-panel");
}
// Closing the only panel of a bottom/right dock would leave an empty
// dock, which base refuses through the group. The skin removes the
// whole dock instead — the vendored dock's close took its split
// group away just the same.
// Closing the only panel of a bottom/right dock would leave an
// empty dock, which base refuses; the skin removes the dock instead.
let dock_to_remove = (group.panels().len() <= 1)
.then(|| self.is_dock_root_group(group, cx))
.flatten();
@@ -601,8 +590,8 @@ impl TabGroupRenderer for SignedTabGroupSkin {
this.on_action({
let group = group.clone();
move |_: &ToggleZoom, window, cx| {
// The affordance decides the control, so a panel that
// offers none is not zoomed *in* by the keybinding
// The affordance decides the control, so a panel
// offering none is not zoomed *in* by the keybinding
// either. Zooming out is never refused: a panel that
// stopped offering the control while zoomed would
// otherwise strand the user with no way back.
@@ -671,12 +660,10 @@ impl TabGroupRenderer for SignedTabGroupSkin {
let right_dock_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let is_bottom_dock = bottom_dock_button.is_some();
// macOS: the traffic lights overlay the window's top-left corner. Only
// the group whose tab bar actually sits under them must reserve the
// space: the left dock (sidebar) normally clears them, and when it is
// closed or absent it is the center's left-most, top-most tab group
// that is in the corner. A bottom or right dock is never there, and
// neither is the right panel of a center split.
// macOS: the traffic lights overlay the window's top-left corner.
// Only the group whose tab bar actually sits under them reserves the
// space the center's left-most, top-most group when the left dock
// is closed or absent.
let needs_traffic_light_padding = cfg!(target_os = "macos")
&& self.shared.area().upgrade().is_some_and(|area| {
let area = area.read(cx);
@@ -703,11 +690,10 @@ impl TabGroupRenderer for SignedTabGroupSkin {
self.scroll_handle.scroll_to_item(visible_ix);
}
// The tab strip lays out its scrollable content at content width, so
// the area after the last tab only spans `min_w_16` — the rest of the
// tab bar has no element at all. Cover that dead zone with a
// measured overlay so the whole non-interactive area can drag the
// window. Its span is [last tab's right edge, suffix's left edge].
// The tab strip lays out at content width, so the area after the
// last tab has no element. Cover that dead zone (last tab's right
// edge to suffix's left edge) with a measured overlay so the whole
// non-interactive area can drag the window.
let drag_overlay = match (
self.title_bar_bounds.get(),
self.title_bar_strip_bounds.get(),
+8 -13
View File
@@ -101,13 +101,11 @@ impl SignedTilesSkin {
})
}
/// The trailing controls of a tile's title bar.
///
/// A tile has no tab bar to hang a toolbar off, so this is where its zoom,
/// close and ellipsis menu live. The entries use click handlers rather
/// than the [`ToggleZoom`](crate::ToggleZoom) and
/// [`ClosePanel`](crate::ClosePanel) actions: those are dispatched to a
/// focused tab group, and a tile is not one.
/// The trailing controls of a tile's title bar: zoom, close and the
/// ellipsis menu. They use click handlers rather than the
/// [`ToggleZoom`](crate::ToggleZoom)/[`ClosePanel`](crate::ClosePanel)
/// actions, which are dispatched to a focused tab group — a tile is
/// not one.
fn render_tile_controls(
&self,
tile: &TileContext,
@@ -388,12 +386,9 @@ impl TilesRenderer for SignedTilesSkin {
.size_full()
}
/// The canvas scrollbar.
///
/// It has to be an overlay rather than one of the frame's own children:
/// the frame is the scroll container and base appends the tiles after
/// whatever the frame carries, so a scrollbar placed there would paint and
/// hit-test underneath every tile.
/// The canvas scrollbar. It must be an overlay: the frame is the scroll
/// container and base appends the tiles after it, so a scrollbar placed
/// inside would paint and hit-test underneath every tile.
fn render_overlay(
&self,
content: Size<Pixels>,
+37 -17
View File
@@ -1,3 +1,5 @@
use std::time::Duration;
use nostr::prelude::*;
use crate::RepoAddr;
@@ -40,8 +42,10 @@ pub fn activity(addr: &RepoAddr) -> Filter {
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
}
/// Status events (`1630..=1633`) referencing a specific root event (`#e` tag).
pub fn statuses_for(root: EventId) -> Filter {
/// Status events (`1630..=1633`) referencing any of the given root events
/// (`#e` tag). Batched: one filter covers all roots, so a negentropy sync
/// reconciles them in a single session instead of one per root.
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
Filter::new()
.kinds([
Kind::GitStatusOpen,
@@ -49,16 +53,17 @@ pub fn statuses_for(root: EventId) -> Filter {
Kind::GitStatusClosed,
Kind::GitStatusDraft,
])
.event(root)
.events(roots)
}
/// Cover notes (kind 1624) and NIP-32 label events (kind 1985) referencing a
/// specific root event (`#e` tag), fetched per root like comments and
/// statuses because they carry no repository `a` tag.
pub fn annotations_for(root: EventId) -> Filter {
/// Cover notes (kind 1624) and NIP-32 label events (kind 1985) referencing
/// any of the given root events (`#e` tag), fetched per root like comments
/// and statuses because they carry no repository `a` tag. Batched, like
/// [`statuses_for`].
pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
Filter::new()
.kinds([crate::COVER_NOTE_KIND, Kind::Label])
.event(root)
.events(roots)
}
/// A user's grasp list (kind `10317`).
@@ -71,11 +76,10 @@ pub fn grasp_list(public_key: PublicKey) -> Filter {
/// NIP-22 comments (kind `1111`) referencing any of the given root events
/// (issues, patches, PRs).
///
/// Comments are not addressed to the repository — they carry no `a` tag with
/// the repo coordinate — so they must be fetched by their root reference
/// instead. NIP-22 defines the uppercase `E` tag as the root of the thread
/// (used by ngit) while some clients (including Signed itself) reference the
/// root with a lowercase `e` tag, so both are matched.
/// Comments carry no repository `a` tag, so they must be fetched by their
/// root reference. NIP-22 defines the uppercase `E` tag as the thread root
/// (used by ngit), but some clients (including Signed) use a lowercase `e`
/// tag, so both are matched.
///
/// Returns two filters because `#E` and `#e` conditions would be ANDed if
/// combined into one.
@@ -110,12 +114,28 @@ pub fn all_announcements() -> Filter {
Filter::new().kind(Kind::GitRepoAnnouncement)
}
/// All deletion-related events (NIP-09 kind `5`, NIP-62 kind `62`).
/// How far back deletion requests are fetched and stored.
///
/// Unbounded, like [`all_announcements`]: deletion requests must be known
/// before any other event can be shown.
/// A deletion request can only target events created before it, and NIP-34
/// events are all far younger than this window, so older requests can never
/// match anything shown. Bounding the window keeps the kind-5/62 set (one of
/// the largest on public relays) from being fully reconciled on every sync.
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
/// `now` minus [`DELETIONS_LOOKBACK`], quantized to whole days so identical
/// filters hash the same and the backend's sync dedup can match them.
fn deletions_since() -> Timestamp {
let now = Timestamp::now().as_secs();
Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK
}
/// All deletion-related events (NIP-09 kind `5`, NIP-62 kind `62`) within
/// [`DELETIONS_LOOKBACK`]. Deletion requests must be known before any other
/// event can be shown.
pub fn deletions() -> Filter {
Filter::new().kinds([Kind::EventDeletion, Kind::RequestToVanish])
Filter::new()
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
.since(deletions_since())
}
/// Deletion events relevant to a single repository: requests authored by
+14 -25
View File
@@ -70,10 +70,9 @@ const SCAN_SKIPPED_DIRS: [&str; 1] = ["node_modules"];
/// Walk `root` recursively and collect the paths of git repositories
/// (directories containing a `.git` entry) below it.
///
/// Hidden entries and symlinks are skipped, and directories that are
/// themselves repositories are not descended into (so nested repositories,
/// like submodule worktrees, are not reported). Results are canonicalized,
/// deduplicated and sorted by path.
/// Hidden entries and symlinks are skipped; repositories are not descended
/// into, so nested ones (e.g. submodule worktrees) are not reported.
/// Results are canonicalized, deduplicated and sorted.
pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
let mut repos = Vec::new();
if !root.is_dir() {
@@ -121,11 +120,9 @@ pub fn find_git_repos(root: &Path) -> Vec<PathBuf> {
/// Clone a repository into `path` from the first working URL in
/// `clone_urls` (the announcement's `clone` tag), then fetch the
/// `refs/nostr/*` PR refs like the cache clone does. The destination must
/// not exist yet; it is created by the clone. The first URL that works
/// wins; when none do, the error of the last failing URL is returned.
/// not exist yet. When no URL works, the last error is returned.
///
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache;
/// callers open it themselves if they need a [`gix::Repository`].
/// Unlike [`GitCache::ensure_clone`], the clone is not kept in any cache.
pub fn clone_repo(clone_urls: &[String], path: &Path) -> Result<()> {
if path.exists() {
bail!("destination {} already exists", path.display());
@@ -218,8 +215,8 @@ fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
/// `README.md` derived from `name`/`description`, and create the initial
/// commit. Returns the initial commit id.
///
/// Uses the git CLI (like [`apply_patch`]) because it handles the plumbing
/// (index writes, ref updates, default branch selection) natively.
/// Uses the git CLI (like [`apply_patch`]), which handles index writes,
/// ref updates and default branch selection natively.
pub fn init_repository(path: &Path, name: &str, description: &str) -> Result<String> {
std::fs::create_dir_all(path)
.with_context(|| format!("failed to create {}", path.display()))?;
@@ -371,11 +368,8 @@ fn git_in(dir: &Path, args: &[&str]) -> Result<String> {
}
/// Map an untrusted repository id (or display name) to a safe single path
/// component.
///
/// Replaces everything outside `[A-Za-z0-9._-]` with `_`, and rejects the
/// special components `.` and `..` so the id can't escape a directory it is
/// joined onto.
/// component: everything outside `[A-Za-z0-9._-]` becomes `_`, and the
/// special components `.` and `..` are rejected.
pub fn sanitize_path_component(id: &str) -> String {
let sanitized: String = id
.chars()
@@ -517,12 +511,9 @@ fn file_commit(commit: &gix::Commit<'_>, include_description: bool) -> Result<Fi
}
/// Find the most recent commit that changed `rel` (a path relative to the
/// worktree), like `git log -1 -- <rel>` does for non-merge commits.
///
/// Walks history from `HEAD` newest-first and returns the first commit whose
/// tree entry for `rel` differs from its first parent's; a merge that only
/// changed the file through its second parent is therefore not reported.
/// Returns `Ok(None)` if no commit touched the file (e.g. untracked files).
/// worktree), like `git log -1 -- <rel>`: the first commit, walking from
/// `HEAD` newest-first, whose tree entry for `rel` differs from its first
/// parent's. `Ok(None)` when no commit touched the file (e.g. untracked).
pub fn last_commit(repo: &gix::Repository, rel: &Path) -> Result<Option<FileCommit>> {
let rel = rel.to_path_buf();
Ok(last_commits(repo, std::slice::from_ref(&rel))?
@@ -727,10 +718,8 @@ pub struct CommitDiff {
}
/// The changes of the commit `id` (short or full) in the repository at
/// `workdir`, compared against its first parent (the empty tree for the root
/// commit), like `git show`. Directory entries and submodules are skipped;
/// their contents are reported as individual file changes. Files are sorted
/// by path.
/// `workdir`, compared against its first parent (the empty tree for the
/// root commit), like `git show`. Files are sorted by path.
pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
commit_diff(&open_with_cache(workdir)?, id)
}
+1
View File
@@ -18,6 +18,7 @@ bitcoin_hashes = "1"
gpui.workspace = true
flume.workspace = true
futures.workspace = true
anyhow.workspace = true
log.workspace = true
+121 -70
View File
@@ -1,7 +1,9 @@
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
use std::time::{Duration, Instant};
use anyhow::{Error, anyhow, bail};
use bitcoin_hashes::sha1::Hash as Sha1Hash;
@@ -32,6 +34,12 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
/// Relays used for indexing user's relay list (NIP-65).
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
/// How long an identical fetch/sync request is suppressed after it started.
/// A second panel for the same repository (or the global and per-author
/// list stores at login) doesn't duplicate a sync that just ran; after the
/// window, re-fetching is allowed again so data stays fresh.
const FETCH_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60);
#[derive(Debug, Clone)]
pub enum BackendEvent {
/// User has no signer configured.
@@ -84,6 +92,10 @@ pub struct Backend {
/// Whether the stored credential is NIP-49 encrypted and a passphrase
/// is still needed to resume the session.
passphrase_required: bool,
/// Fingerprints of recently started fetches/syncs (relay + filter set),
/// so duplicate requests within [`FETCH_DEDUP_WINDOW`] collapse into
/// one. Entries are pruned lazily on the next request.
recent_fetches: HashMap<u64, Instant>,
tasks: Vec<Task<Result<(), Error>>>,
}
@@ -134,6 +146,7 @@ impl Backend {
connected: false,
sync_progress: None,
passphrase_required: false,
recent_fetches: HashMap::new(),
tasks: vec![pump],
};
@@ -245,9 +258,8 @@ impl Backend {
/// Decrypt the NIP-49 encrypted credential stored in the keyring with
/// the given passphrase and resume the session.
///
/// The scrypt decryption runs off the UI thread. The returned task
/// yields the public key on success, or the failure reason (e.g. wrong
/// passphrase), so callers can render inline errors.
/// The scrypt decryption runs off the UI thread. The task yields the
/// public key, or the failure reason (e.g. wrong passphrase).
pub fn restore_with_passphrase(
&mut self,
password: &str,
@@ -286,9 +298,8 @@ impl Backend {
/// passphrase (NIP-49) and persist it in the keyring, then publish the
/// user's NIP-65 relay list, metadata and grasp list.
///
/// The heavy encryption runs off the UI thread. The returned task yields
/// the new public key on success, or the failure reason, so callers can
/// render progress and inline errors.
/// The encryption runs off the UI thread; the task yields the new
/// public key.
pub fn create_identity(
&mut self,
name: &str,
@@ -386,11 +397,8 @@ impl Backend {
/// a push for a not-yet-existing repository while that authorization is
/// pending (it expires after 30 minutes), like gitworkshop and ngit.
///
/// The git work (init, commit, push) runs on background threads. The
/// returned task yields the published announcement on success, so
/// callers can open the new repository right away. The announcement's
/// `relays` tag carries the grasp servers, which are also added to the
/// relay pool so the published events reach them.
/// The git work runs on background threads; the task yields the
/// published announcement.
pub fn create_repository(
&mut self,
name: &str,
@@ -435,8 +443,7 @@ impl Backend {
let servers = grasp_servers.clone();
cx.spawn(async move |this, cx| {
// 1. Initialize the local clone (main branch + README + initial
// commit) on a background thread.
// Initialize the local clone (main branch + README + initial commit).
let work = cx.background_spawn({
let path = path.clone();
let name = name.clone();
@@ -467,16 +474,14 @@ impl Backend {
let commit_sha =
Sha1Hash::from_str(&commit).map_err(|_| anyhow!("invalid initial commit id"))?;
// 2. Ensure the grasp servers are in the relay pool; the nostr
// client queues events until each relay is connected.
// The nostr client queues events until each relay is connected.
this.update(cx, |this, cx| {
let urls: Vec<String> = servers.iter().map(ToString::to_string).collect();
this.add_relays(urls, cx);
})?;
// 3. Publish the announcement, then the state event, to the
// grasp relays. The state event is the push authorization
// ("purgatory"), so it must be accepted before step 4.
// The state event is the push authorization ("purgatory"), so
// it must be accepted before the push below.
let announcement = GitRepositoryAnnouncement {
id: repo_id.clone(),
name: Some(name.clone()),
@@ -522,9 +527,8 @@ impl Backend {
}
};
// 4. Push the initial commit to every grasp server. A server
// that fails to accept the push is logged, but the creation
// only fails when no server accepted it.
// Push to every grasp server; creation only fails when no
// server accepted it.
let push = cx.background_spawn({
let path = path.clone();
let owner = owner.clone();
@@ -555,14 +559,8 @@ impl Backend {
/// state to the grasp relays, then push every branch and tag to each
/// grasp server. Also points `origin` at the first grasp server.
///
/// The events must reach the grasp servers *before* the push, like
/// [`Self::create_repository`]: GRASP servers hold the signed state
/// event in "purgatory" and only accept a push while that
/// authorization is pending.
///
/// The git work (ref listing, push) runs on background threads. The
/// returned task yields the published announcement on success, so
/// callers can switch the repository into its NIP-34 mode.
/// Same ordering constraint as [`Self::create_repository`]: the state
/// event ("purgatory") must be accepted before the push.
pub fn publish_local_repo(
&mut self,
path: PathBuf,
@@ -586,9 +584,8 @@ impl Backend {
return Task::ready(Err(anyhow!("Sign in to publish a repository")));
};
// The repository identifier is derived from the name, like
// [`Self::create_repository`]: spaces become hyphens, other
// non-alphanumeric characters (except `/`) become hyphens.
// The repository identifier is derived from the name as in
// [`Self::create_repository`].
let repo_id = identifier_from_name(&name);
if repo_id.is_empty() || repo_id.len() > 100 {
@@ -607,8 +604,6 @@ impl Backend {
let servers = grasp_servers.clone();
cx.spawn(async move |this, cx| {
// 1. Read the local repository's refs (branches, tags, HEAD)
// and its root commit on a background thread.
let work = cx.background_spawn({
let path = path.clone();
async move {
@@ -619,16 +614,14 @@ impl Backend {
});
let (state, euc) = work.await?;
// 2. Ensure the grasp servers are in the relay pool; the nostr
// client queues events until each relay is connected.
// The nostr client queues events until each relay is connected.
this.update(cx, |this, cx| {
let urls: Vec<String> = servers.iter().map(ToString::to_string).collect();
this.add_relays(urls, cx);
})?;
// 3. Publish the announcement, then the state event, to the
// grasp relays. The state event is the push authorization
// ("purgatory"), so it must be accepted before step 4.
// The state event is the push authorization ("purgatory"), so
// it must be accepted before the push below.
let announcement = GitRepositoryAnnouncement {
id: repo_id.clone(),
name: Some(name.clone()),
@@ -672,10 +665,9 @@ impl Backend {
}
};
// 4. Push every branch and tag to each grasp server. A server
// that fails to accept the push is logged, but the init only
// fails when no server accepted it. An empty repository
// (no refs yet) has nothing to push.
// Push every branch and tag to each grasp server; the init
// only fails when no server accepted it. An empty repository
// has nothing to push.
if !refs.is_empty() {
let push = cx.background_spawn({
let path = path.clone();
@@ -697,8 +689,8 @@ impl Backend {
}
}
// 5. Point `origin` at the first grasp server so later pushes
// have a target, like the create flow.
// Point `origin` at the first grasp server so later pushes
// have a target.
if let Some(base) = servers.first().and_then(grasp_base_url) {
let url = format!("{base}/{owner}/{repo_id}.git");
let path = path.clone();
@@ -732,15 +724,13 @@ impl Backend {
let relays = announcement.relays.clone();
cx.spawn(async move |this, cx| {
// 1. Read the current refs of the local clone.
let work = cx.background_spawn({
let path = path.clone();
async move { signed_git::worktree_ref_state(&path) }
});
let state = work.await?;
// 2. Publish a fresh state event; grasp servers authorize a
// push by the state they have seen.
// Grasp servers authorize a push by the state they have seen.
let refs = state.refs.clone();
let head = state.head.clone();
this.update(cx, |this, cx| {
@@ -749,7 +739,6 @@ impl Backend {
})?
.await?;
// 3. Push every branch and tag to the announced grasp servers.
if !refs.is_empty() {
let push = cx.background_spawn({
let path = path.clone();
@@ -1106,25 +1095,53 @@ impl Backend {
}));
}
/// Whether an identical fetch was started within [`FETCH_DEDUP_WINDOW`]
/// and is still recent enough to suppress a duplicate. Records the
/// fingerprint (after pruning expired entries) when returning `false`.
fn fetch_recently_started(&mut self, fingerprint: u64) -> bool {
self.recent_fetches
.retain(|_, started| started.elapsed() < FETCH_DEDUP_WINDOW);
if self.recent_fetches.contains_key(&fingerprint) {
return true;
}
self.recent_fetches.insert(fingerprint, Instant::now());
false
}
/// Connect to relays announced by a repository (NIP-34 `relays` tag) and
/// fetch its events from them: a one-shot auto-closing subscription for
/// `filters`, plus a negentropy sync so issues, patches and PRs stored
/// only on those relays are not missed.
///
/// Best-effort: failures are logged, not surfaced, because the bootstrap
/// relays already cover the repository. The relays stay in the pool, so
/// events the user publishes for this repository also reach them.
/// Deduplicated: an identical request (same relays and filters) started
/// within [`FETCH_DEDUP_WINDOW`] is skipped, so a second panel for the
/// same repository doesn't re-run the fetch.
///
/// Best-effort: failures are logged, not surfaced. The relays stay in
/// the pool, so later publishes for this repository also reach them.
pub fn connect_repo_relays(
&mut self,
relays: Vec<RelayUrl>,
filters: Vec<Filter>,
cx: &mut Context<Self>,
) {
let relay_strs: Vec<&str> = relays.iter().map(|url| url.as_str()).collect();
let fingerprint = fetch_fingerprint(&relay_strs, &filters);
if self.fetch_recently_started(fingerprint) {
log::debug!("skipping duplicate repo relay fetch");
return;
}
let client = self.client.clone();
self.tasks.push(cx.spawn(async move |_this, _cx| {
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = connect_repo_relays_only(&client, relays, filters).await {
log::warn!("repo relay fetch failed: {e}");
// Allow an immediate retry after a failure.
this.update(cx, |this, _cx| {
this.recent_fetches.remove(&fingerprint);
})
.ok();
}
Ok(())
}));
@@ -1152,7 +1169,17 @@ impl Backend {
/// reconciles the local database with the relays in both directions.
/// Emits [`BackendEvent::SyncProgress`] while running (throttled to
/// whole-percent changes) and [`BackendEvent::Synced`] on completion.
///
/// Deduplicated: an identical sync started within
/// [`FETCH_DEDUP_WINDOW`] is skipped. Observers still see the original
/// sync's progress and completion events.
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter));
if self.fetch_recently_started(fingerprint) {
log::debug!("skipping duplicate bootstrap sync");
return;
}
let client = self.client.clone();
self.sync_progress = Some((0, 0));
@@ -1210,6 +1237,8 @@ impl Backend {
Err(e) => {
this.update(cx, |this, cx| {
this.sync_progress = None;
// Allow an immediate retry after a failure.
this.recent_fetches.remove(&fingerprint);
cx.emit(BackendEvent::error(e.to_string()))
})?;
}
@@ -1221,10 +1250,9 @@ impl Backend {
/// Sign, broadcast and locally store an event. Emits
/// [`BackendEvent::Published`] on success so stores can refresh.
///
/// The returned task yields the outcome of this specific action, so
/// callers can show inline progress/errors instead of relying on
/// the global [`BackendEvent::Error`]. The task is owned by the caller;
/// dropping it cancels the publish.
/// The task yields the outcome of this specific action (for inline
/// progress/errors) and is owned by the caller; dropping it cancels
/// the publish.
pub fn send(
&mut self,
builder: EventBuilder,
@@ -1329,6 +1357,20 @@ impl Backend {
}
}
/// Fingerprint of a relay + filter set, for fetch dedup. Relays and
/// filters are sorted first so the fingerprint is order-independent.
fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
let mut relays: Vec<&str> = relays.to_vec();
relays.sort_unstable();
let mut filters: Vec<&Filter> = filters.iter().collect();
filters.sort_unstable();
let mut hasher = DefaultHasher::new();
relays.hash(&mut hasher);
filters.hash(&mut hasher);
hasher.finish()
}
/// Add the given relays, connect to them, and fetch the filters: a one-shot
/// subscription (auto-closing after EOSE) plus a negentropy sync per filter
/// as a second pass, so events that race with the subscription or relays
@@ -1343,10 +1385,15 @@ async fn connect_repo_relays_only(
return Ok(());
}
let mut added = false;
for url in &relays {
client.add_relay(url).await?;
added |= client.add_relay(url).await?;
}
// Connecting is only needed when the pool grew; connected relays no-op,
// but the call still iterates every relay in the pool.
if added {
client.connect().await;
}
client.connect().await;
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
@@ -1358,17 +1405,21 @@ async fn connect_repo_relays_only(
.collect();
client.subscribe(target).close_on(opts).await?;
for filter in filters {
let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5));
if let Err(e) = client
.sync(filter)
.with(relays.iter())
.opts(sync_opts)
.await
{
log::warn!("repo relay negentropy sync failed: {e}");
// Sync the filters concurrently: each reconciles against every relay
// either way, and a relay without NEG-XX support otherwise serializes
// its initial timeout behind every other filter.
let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5));
let syncs = filters.into_iter().map(|filter| {
let client = &client;
let relays = &relays;
let sync_opts = sync_opts.clone();
async move {
if let Err(e) = client.sync(filter).with(relays.iter()).opts(sync_opts).await {
log::warn!("repo relay negentropy sync failed: {e}");
}
}
}
});
futures::future::join_all(syncs).await;
Ok(())
}
+4 -24
View File
@@ -34,7 +34,6 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
.install_default()
.ok();
// Initialize the nostr client and universal signer.
let (client, signer) = cx.foreground_executor().block_on(async move {
let path = db_path.as_ref().to_path_buf();
new_backend(path)
@@ -42,24 +41,18 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
.expect("failed to initialize nostr backend")
});
// Initialize the backend and stores.
let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx);
// Initialize the profile store.
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.
// Seed the explore list from the local database; relay syncs continue
// in the background.
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.
// The clone cache is native-only; wasm registers an empty store so
// `GitStore::global` still works.
GitStore::set_global(PathBuf::new(), cx);
// Scan the default directories (Desktop, Documents) for local git
// repositories; the sidebar lists them next to the user's NIP-34 repos.
LocalReposStore::set_global(
cx.new(|cx| LocalReposStore::new(default_scan_paths(), cx)),
cx,
@@ -71,26 +64,13 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
/// Initialize the backend with an in-memory database on wasm.
#[cfg(target_arch = "wasm32")]
pub fn init(cx: &mut App) -> Entity<Backend> {
// Initialize the nostr client and universal signer.
let (client, signer) = new_backend().expect("failed to initialize nostr backend");
// Initialize the backend and stores.
let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx);
// Initialize the profile store.
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);
// No filesystem scan on wasm: there are no local git repositories.
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
entity
+134 -66
View File
@@ -1,5 +1,5 @@
use std::borrow::Cow;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use anyhow::Error;
@@ -33,11 +33,21 @@ pub struct RepoStore {
pub pull_requests: Vec<Event>,
/// Comments on issues / PRs, oldest first.
pub comments: Vec<Event>,
statuses: Vec<Event>,
/// Resolved status per root event (issue / patch / PR), recomputed on
/// every refresh so render paths are HashMap lookups instead of
/// scanning all status events per root.
status_by_root: HashMap<EventId, RepoStatus>,
/// Open issue / root PR counts, computed with [`Self::status_by_root`]
/// on every refresh.
open_issue_count: usize,
open_pr_count: usize,
/// Kind-1624 cover notes and kind-1985 label events referencing this
/// repository's roots (ngit / GitWorkshop extensions).
cover_notes: Vec<Event>,
labels: Vec<Event>,
/// Incremented on every applied refresh; views key their derived-data
/// caches to it instead of recomputing on every render.
version: u64,
/// Error of the last action initiated from this store, if any.
pub last_error: Option<String>,
/// Relays announced by this repository (NIP-34 `relays` tag) that we
@@ -111,9 +121,12 @@ impl RepoStore {
patches: Vec::new(),
pull_requests: Vec::new(),
comments: Vec::new(),
statuses: Vec::new(),
status_by_root: HashMap::new(),
open_issue_count: 0,
open_pr_count: 0,
cover_notes: Vec::new(),
labels: Vec::new(),
version: 0,
last_error: None,
repo_relays: HashSet::new(),
root_fetches: HashSet::new(),
@@ -141,8 +154,13 @@ impl RepoStore {
/// deletions targeting it.
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
let mut filters = vec![
filters::announcement(addr),
filters::state(addr),
// Announcement and state share author and identifier, so they
// combine into one filter: one fewer negentropy reconciliation
// per relay when fetching from the repo's announced relays.
Filter::new()
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
.author(addr.public_key)
.identifier(addr.identifier.clone()),
filters::activity(addr),
];
// Deletion requests (NIP-09/62) must be known before any event of
@@ -293,7 +311,7 @@ impl RepoStore {
.chain(&pull_requests)
.map(|e| e.id);
for root in roots {
for event in db.query(filters::statuses_for(root)).await? {
for event in db.query(filters::statuses_for([root])).await? {
if seen_statuses.insert(event.id) {
statuses.push(event);
}
@@ -312,7 +330,7 @@ impl RepoStore {
.chain(&pull_requests)
.map(|e| e.id);
for root in roots {
for event in db.query(filters::annotations_for(root)).await? {
for event in db.query(filters::annotations_for([root])).await? {
if deletions.is_deleted(&event) {
continue;
}
@@ -331,13 +349,36 @@ impl RepoStore {
sort_newest_first(&mut cover_notes);
sort_newest_first(&mut labels);
// Resolve every root's status once here; render paths do
// HashMap lookups instead of scanning all status events per
// root (quadratic, with an allocation per pair).
let maintainers = announcement
.as_ref()
.map(Announcement::effective_maintainers)
.unwrap_or_default();
let status_by_root =
resolve_statuses(&issues, &patches, &pull_requests, &statuses, &maintainers);
let open_issue_count = issues
.iter()
.filter(|issue| status_of(&status_by_root, issue) == RepoStatus::Open)
.count();
let open_pr_count = pull_requests
.iter()
.filter(|pr| {
pr.kind == Kind::GitPullRequest
&& status_of(&status_by_root, pr) == RepoStatus::Open
})
.count();
Ok::<_, Error>((
announcement,
state,
issues,
patches,
pull_requests,
statuses,
status_by_root,
open_issue_count,
open_pr_count,
comments,
cover_notes,
labels,
@@ -353,7 +394,9 @@ impl RepoStore {
issues,
patches,
pull_requests,
statuses,
status_by_root,
open_issue_count,
open_pr_count,
comments,
cover_notes,
labels,
@@ -389,9 +432,12 @@ impl RepoStore {
this.patches = patches;
this.pull_requests = pull_requests;
this.comments = comments;
this.statuses = statuses;
this.status_by_root = status_by_root;
this.open_issue_count = open_issue_count;
this.open_pr_count = open_pr_count;
this.cover_notes = cover_notes;
this.labels = labels;
this.version = this.version.wrapping_add(1);
// Comments, statuses without an `a` tag, cover notes and
// labels are not addressed to the repository, so fetch them
@@ -411,25 +457,18 @@ impl RepoStore {
.collect();
if !new_roots.is_empty() {
this.root_fetches.extend(new_roots.iter().copied());
let comment_filters = filters::comments_for(new_roots.clone());
let status_filters: Vec<Filter> = new_roots
.iter()
.copied()
.map(filters::statuses_for)
.collect();
let annotation_filters: Vec<Filter> = new_roots
.into_iter()
.map(filters::annotations_for)
.collect();
// Batch the per-root filters: one statuses filter and one
// annotations filter covering all new roots, instead of
// one filter per root (each filter is a separate
// negentropy reconciliation per relay).
let mut root_filters = filters::comments_for(new_roots.clone());
root_filters.push(filters::statuses_for(new_roots.iter().copied()));
root_filters.push(filters::annotations_for(new_roots));
let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| {
backend.subscribe_bootstrap(comment_filters.clone(), cx);
backend.connect_repo_relays(announced.clone(), comment_filters, cx);
backend.subscribe_bootstrap(status_filters.clone(), cx);
backend.connect_repo_relays(announced.clone(), status_filters, cx);
backend.subscribe_bootstrap(annotation_filters.clone(), cx);
backend.connect_repo_relays(announced, annotation_filters, cx);
backend.subscribe_bootstrap(root_filters.clone(), cx);
backend.connect_repo_relays(announced, root_filters, cx);
});
}
@@ -454,20 +493,17 @@ impl RepoStore {
}));
}
/// Resolve the status of a root event (issue / patch / PR) per NIP-34.
/// Resolve the status of a root event (issue / patch / PR) per NIP-34:
/// a lookup into the map built on the last refresh.
pub fn status_of(&self, root: &Event) -> RepoStatus {
let maintainers = self
.announcement
.as_ref()
.map(Announcement::effective_maintainers)
.unwrap_or_default();
status_of(&self.status_by_root, root)
}
let events = self
.statuses
.iter()
.filter(|e| signed_core::references_root(e, &root.id));
signed_core::resolve_status(events, &root.pubkey, &maintainers)
/// Refresh generation, incremented on every applied refresh. Views use
/// it to key their derived-data caches (filtered lists, counts) so
/// renders that change nothing stay O(1).
pub fn version(&self) -> u64 {
self.version
}
/// The effective cover note of `root` (kind 1624), if any: the latest
@@ -509,21 +545,16 @@ impl RepoStore {
/// Number of open issues: issues whose resolved status is
/// [`RepoStatus::Open`] (issues without status events default to open).
/// Cached on the last refresh.
pub fn issue_count(&self) -> usize {
self.issues
.iter()
.filter(|issue| self.status_of(issue) == RepoStatus::Open)
.count()
self.open_issue_count
}
/// Number of open pull requests: root PR events (not PR updates, whose
/// status is carried by the root) with a resolved status of
/// [`RepoStatus::Open`].
/// [`RepoStatus::Open`]. Cached on the last refresh.
pub fn pull_request_count(&self) -> usize {
self.pull_requests
.iter()
.filter(|pr| pr.kind == Kind::GitPullRequest && self.status_of(pr) == RepoStatus::Open)
.count()
self.open_pr_count
}
/// Whether `user` is the author (owner) of this repository: the public
@@ -586,15 +617,12 @@ impl RepoStore {
/// (kind 1617) carrying the `git format-patch` output, which the PR
/// references via an `e` tag (NIP-34).
///
/// The patch is published first and the PR is sent once the patch
/// event's id is known, so the two always arrive together. The proposed
/// commit is parsed from the patch's `From <commit>` header; publishing
/// without one is refused, because the PR's `c` tag (and the patch's
/// `commit`/`r` tags) must carry a real commit id for other NIP-34
/// clients to verify and apply the proposal. The PR's `clone` tag
/// carries the repository's announced mirror URLs (the commit may not be
/// pushed there yet; the linked patch is the source of truth until a
/// push backend exists).
/// The patch is published first so the PR can reference its id. The
/// proposed commit is parsed from the patch's `From <commit>` header;
/// without one publishing is refused, because the PR's `c` tag must
/// carry a real commit id for other NIP-34 clients to verify and apply
/// the proposal. The `clone` tag carries the announced mirror URLs; the
/// linked patch is the source of truth until the commit is pushed there.
pub fn open_pull_request(
&mut self,
subject: Option<String>,
@@ -783,10 +811,9 @@ impl RepoStore {
/// the merged status.
///
/// Only the repository author may merge. The clone is created on demand
/// from the announcement's clone URLs when the repository hasn't been
/// mirrored locally yet. Patch application runs on a background thread
/// (`git am`); failures (e.g. a patch that no longer applies) surface in
/// [`Self::last_error`] and no status is sent.
/// from the announcement's clone URLs when needed. Patch application
/// (`git am`) runs on a background thread; failures (e.g. a patch that
/// no longer applies) surface in [`Self::last_error`].
pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) {
self.last_error = None;
@@ -860,6 +887,49 @@ where
events.into_iter().max_by_key(|e| e.created_at)
}
/// Status of `root` from the precomputed map; roots without status events
/// default to [`RepoStatus::Open`], like [`signed_core::resolve_status`].
fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> RepoStatus {
status_by_root
.get(&root.id)
.copied()
.unwrap_or(RepoStatus::Open)
}
/// Resolve the status of every root event in one pass: status events are
/// indexed by the root they reference (`e`/`E` tag), then each root
/// resolves against its own slice. O(roots + statuses) instead of the
/// O(roots × statuses) of resolving per root on demand.
fn resolve_statuses(
issues: &[Event],
patches: &[Event],
pull_requests: &[Event],
statuses: &[Event],
maintainers: &[PublicKey],
) -> HashMap<EventId, RepoStatus> {
let mut by_root: HashMap<EventId, Vec<&Event>> = HashMap::new();
for event in statuses {
for tag in event.tags.iter() {
if matches!(tag.kind(), "e" | "E")
&& let Some(id) = tag.content().and_then(|hex| EventId::from_hex(hex).ok())
{
by_root.entry(id).or_default().push(event);
}
}
}
issues
.iter()
.chain(patches)
.chain(pull_requests)
.map(|root| {
let events = by_root.get(&root.id).map(Vec::as_slice).unwrap_or(&[]);
let status = signed_core::resolve_status(events.iter().copied(), &root.pubkey, maintainers);
(root.id, status)
})
.collect()
}
fn sort_newest_first(events: &mut [Event]) {
events.sort_by_key(|e| std::cmp::Reverse(e.created_at));
}
@@ -876,12 +946,10 @@ fn patch_current_commit(patch: &str) -> Option<&str> {
hex.split_whitespace().next().filter(|hex| hex.len() == 40)
}
/// Build a NIP-22 kind-1111 comment using the SDK's [`CommentBuilder`]:
/// uppercase `E`/`K`/`P` tags scope the thread root, lowercase `e`/`k`/`p`
/// tags the direct parent (`parent`, or the root itself for a top-level
/// comment). An `a` tag with the repository coordinate is added so Signed's
/// own activity subscriptions also match the comment (it is not part of
/// NIP-22).
/// Build a NIP-22 kind-1111 comment: uppercase `E`/`K`/`P` tags scope the
/// thread root, lowercase `e`/`k`/`p` the direct parent (or the root for a
/// top-level comment). An `a` tag with the repository coordinate (not part
/// of NIP-22) is added so Signed's own activity subscriptions also match.
fn comment_builder(
root: &Event,
parent: Option<&Event>,
+1 -2
View File
@@ -22,8 +22,7 @@ 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.
/// small series), the closest proxy for commit count in the event data.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RepoActivityCounts {
/// Root `30611` issue events addressed to the repository.
@@ -39,12 +39,9 @@ pub(super) enum FileContent {
/// A markdown document loaded into a persistent [`TextViewState`].
///
/// The state is owned by the view rather than created per render (as the
/// stateless `text::markdown` helper does), so it survives branch switches
/// in the content pane. GPUI's keyed element state is dropped as soon as the
/// element is absent for a single frame, which would otherwise re-parse the
/// whole document on the main thread every time the pane switches between
/// the README, a file preview, and the loading spinner.
/// The state is owned by the view rather than created per render: GPUI
/// drops keyed element state after one absent frame, which would re-parse
/// the whole document on every pane switch (README / file / spinner).
pub(super) struct MarkdownView {
/// Source path; `None` means the repository README.
pub(super) path: Option<SharedString>,
@@ -53,11 +50,7 @@ pub(super) struct MarkdownView {
/// A code file loaded into a persistent [`InputState`], rendered as a
/// disabled (read-only) code editor with syntax highlighting, line numbers
/// and search.
///
/// Same persistence rationale as [`MarkdownView`]: the state lives as long
/// as this view, so re-viewing the same file does not re-parse it, and
/// parsing happens on a background task inside the editor.
/// and search. Persistent for the same reason as [`MarkdownView`].
pub(super) struct CodeView {
/// Source path, relative to the worktree root.
pub(super) path: SharedString,
@@ -230,9 +223,7 @@ impl RepoDetailView {
/// Load `text` into the persistent markdown TextView state.
///
/// The state is created empty and fed via `push_str`, which parses on a
/// background task: switching files never blocks the main thread, and
/// the state lives as long as this view, so re-viewing the same document
/// does not re-parse it.
/// background task, so switching files never blocks the main thread.
pub(super) fn set_markdown(
&mut self,
path: Option<SharedString>,
@@ -269,10 +260,8 @@ impl RepoDetailView {
/// Load `text` into the persistent code editor state for `path`.
///
/// The state is created in code editor mode so the Input renders it as
/// a syntax-highlighted, read-only editor. Like [`set_markdown`], the
/// state lives as long as this view, so re-viewing the same file does
/// not re-parse it; the tree-sitter parse runs on a background task
/// inside the editor instead of blocking the main thread.
/// a syntax-highlighted, read-only editor; the tree-sitter parse runs
/// on a background task like [`set_markdown`]'s.
pub(super) fn set_code(
&mut self,
path: SharedString,
@@ -276,11 +276,8 @@ pub(super) fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
/// 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 ordinary elements supplied by the caller, so
/// the look — icons, borders, hover states, sizes — stays fully in the
/// application. The component only owns the popover wiring: opening on caret
/// click, Escape/outside dismissal, focus movement into the menu, and the
/// menu entity's lifecycle.
/// 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,
@@ -493,11 +490,10 @@ impl ShareTargets {
}
}
/// One row of the share menu: a small title on top of the compact label,
/// with a copy button that flips to a check while the value is on the
/// clipboard. Clicking the row text copies and dismisses the menu; the copy
/// button stops propagation, so the menu stays open for further copies.
/// Both copy `copy`, never the truncated label.
/// 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,
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use assets::CustomIconName;
use dock::{BasePanel, Panel, PanelEvent};
use gpui::prelude::*;
@@ -27,6 +29,10 @@ pub struct IssueDetailView {
issue_id: EventId,
/// Input state of the "leave a comment" textarea.
comment_input: Entity<TextareaState>,
/// Issue/comment bodies as shared strings, keyed by event ID, so
/// re-renders don't clone full contents again (events are immutable,
/// so the cache never needs invalidation).
contents: HashMap<EventId, SharedString>,
}
impl IssueDetailView {
@@ -44,6 +50,7 @@ impl IssueDetailView {
store,
issue_id,
comment_input,
contents: HashMap::new(),
}
}
@@ -142,6 +149,13 @@ impl IssueDetailView {
let author = profile.name();
let picture = profile.picture();
let age = relative_time(comment.created_at);
// Comment bodies are cloned into shared strings once per
// comment, not on every render.
let content = self
.contents
.entry(comment.id)
.or_insert_with(|| SharedString::from(comment.content.clone()))
.clone();
v_flex()
.gap_1()
@@ -176,11 +190,7 @@ impl IssueDetailView {
.child(SharedString::from(age)),
),
)
.child(
div()
.text_sm()
.child(SharedString::from(comment.content.clone())),
)
.child(div().text_sm().child(content))
}))
.into_any_element()
}
@@ -284,6 +294,11 @@ impl Render for IssueDetailView {
let (title, author, picture, status, age, issue_id, content) = {
let profile_store = ProfileStore::global(cx);
let profile = profile_store.read(cx).get(&issue.pubkey);
let content = self
.contents
.entry(issue.id)
.or_insert_with(|| SharedString::from(issue.content.clone()))
.clone();
(
activity_subject(issue),
@@ -292,7 +307,7 @@ impl Render for IssueDetailView {
store.status_of(issue),
relative_time(issue.created_at),
issue.id,
issue.content.clone(),
content,
)
};
@@ -358,7 +373,7 @@ impl Render for IssueDetailView {
.child(SharedString::from(age)),
),
)
.child(div().text_sm().child(SharedString::from(&content))),
.child(div().text_sm().child(content)),
)
.child(self.render_comments(&issue_id, cx))
.child(self.render_form(&issue_id, cx)),
@@ -17,7 +17,7 @@ use gpui_component::scroll::Scrollbar;
use gpui_component::{
ActiveTheme, Icon, Sizable, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
};
use nostr::prelude::{Event, EventId};
use nostr::prelude::EventId;
use signed_core::{RepoStatus, activity_subject};
use signed_state::{ProfileStore, RepoStore};
use utils::relative_time;
@@ -26,10 +26,8 @@ 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: 8px vertical padding
/// (`py_2`) on top and bottom, a 32px title line (`h_8`) and a 24px meta
/// line (`h_6`), plus the 1px bottom border; the row totals 73px. The
/// status chip (`size_7`, 28px) is shorter than the content.
/// 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.
const ISSUE_ROW_HEIGHT: f32 = 73.;
/// Status filter of the issues list, chosen via the header's filter buttons.
@@ -39,21 +37,18 @@ enum IssueFilter {
All,
/// Issues whose resolved status is [`RepoStatus::Open`].
Open,
/// Issues whose resolved status is
/// [`RepoStatus::Closed`] or [`RepoStatus::Applied`] (both are "done" states).
/// Issues whose resolved status is [`RepoStatus::Closed`] or
/// [`RepoStatus::Applied`] (both are "done" states).
Closed,
}
impl IssueFilter {
/// Whether `issue` (of `store`) is included by this filter.
fn matches(self, store: &RepoStore, issue: &Event) -> bool {
/// Whether an issue with `status` is included by this filter.
fn matches(self, status: RepoStatus) -> bool {
match self {
Self::All => true,
Self::Open => store.status_of(issue) == RepoStatus::Open,
Self::Closed => matches!(
store.status_of(issue),
RepoStatus::Closed | RepoStatus::Applied
),
Self::Open => status == RepoStatus::Open,
Self::Closed => matches!(status, RepoStatus::Closed | RepoStatus::Applied),
}
}
}
@@ -72,9 +67,15 @@ pub struct IssuesView {
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Number of rows [`Self::item_sizes`] was built for (the filtered issue count).
issue_len: usize,
/// Indices into the store's `issues` matching [`Self::filter`], rebuilt
/// every render; the virtual list renders this slice.
/// Indices into the store's `issues` matching [`Self::filter`]; the
/// virtual list renders this slice. Rebuilt only when the store
/// version or the filter changes, keyed by [`Self::cache_key`].
visible_issues: Vec<usize>,
/// Header counts `(total, open, closed)`, rebuilt with
/// [`Self::visible_issues`].
counts: (usize, usize, usize),
/// Store version and filter the cached rows/counts were built from.
cache_key: Option<(u64, IssueFilter)>,
/// Virtual list state of the issues list.
scroll_handle: VirtualListScrollHandle,
}
@@ -96,6 +97,8 @@ impl IssuesView {
item_sizes: Rc::new(Vec::new()),
issue_len: 0,
visible_issues: Vec::new(),
counts: (0, 0, 0),
cache_key: None,
scroll_handle: VirtualListScrollHandle::new(),
}
}
@@ -188,19 +191,9 @@ impl IssuesView {
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let store = self.store.read(cx);
let (total, open, closed) =
store
.issues
.iter()
.fold(
(0usize, 0usize, 0usize),
|(total, open, closed), issue| match store.status_of(issue) {
RepoStatus::Open => (total + 1, open + 1, closed),
RepoStatus::Closed => (total + 1, open, closed + 1),
RepoStatus::Draft | RepoStatus::Applied => (total + 1, open, closed),
},
);
// Counts of the last list rebuild (`render` rebuilds first when the
// store version or filter changed, so this is never stale).
let (total, open, closed) = self.counts;
h_flex()
.px_4()
@@ -433,18 +426,30 @@ impl Render for IssuesView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter;
// Indices of the issues matching the active filter; the virtual
// list renders this filtered slice.
self.visible_issues = {
// Rebuild the filtered rows and header counts only when the store
// refreshed or the filter changed; other renders reuse the cache.
let version = self.store.read(cx).version();
if self.cache_key != Some((version, filter)) {
let store = self.store.read(cx);
store
let mut counts = (0usize, 0usize, 0usize);
self.visible_issues = store
.issues
.iter()
.enumerate()
.filter(|(_, issue)| filter.matches(store, issue))
.map(|(ix, _)| ix)
.collect()
};
.filter_map(|(ix, issue)| {
let status = store.status_of(issue);
counts.0 += 1;
match status {
RepoStatus::Open => counts.1 += 1,
RepoStatus::Closed => counts.2 += 1,
RepoStatus::Draft | RepoStatus::Applied => {}
}
filter.matches(status).then_some(ix)
})
.collect();
self.counts = counts;
self.cache_key = Some((version, filter));
}
let count = self.visible_issues.len();
+95 -53
View File
@@ -27,7 +27,7 @@ use gpui_component::{
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled,
VirtualListScrollHandle, h_flex, v_flex,
};
use nostr::prelude::{RelayUrl, ToBech32};
use nostr::prelude::{EventId, RelayUrl, ToBech32};
use signed_core::Announcement;
use signed_git::{CommitList, FileCommit};
use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoStore};
@@ -98,6 +98,20 @@ struct RepoData {
head_commit: Option<FileCommit>,
}
/// Derived NIP-34 header data, cached so renders don't re-encode bech32
/// share targets and rebuild clone command strings on every frame.
struct HeaderCache {
/// Announcement event ID and owner NIP-05 this cache was built from;
/// rebuilt when either changes (a new announcement version, or the
/// owner's profile arriving with a NIP-05 identifier).
key: (EventId, Option<String>),
announcement: Rc<Announcement>,
share: Rc<ShareTargets>,
ngit_command: SharedString,
nak_command: SharedString,
git_commands: Rc<Vec<SharedString>>,
}
/// Detail view of a repository: header, stats, a file explorer with README
/// preview (cloned from the announcement's `clone` URLs), and metadata.
pub struct RepoDetailView {
@@ -170,6 +184,10 @@ pub struct RepoDetailView {
/// Bumped on every branch/tag switch; in-flight loads tagged with an
/// older generation are discarded when they complete.
ref_generation: u64,
/// Derived NIP-34 header data (share targets, clone commands),
/// rebuilt only when the announcement or the owner's NIP-05 changes
/// instead of on every render.
header_cache: Option<HeaderCache>,
/// In-flight tasks; finished tasks are pruned on every push, so the vec
/// stays bounded by the number of concurrent loads.
tasks: Vec<Task<Result<(), Error>>>,
@@ -298,6 +316,7 @@ impl RepoDetailView {
tag_select,
switching_ref: false,
ref_generation: 0,
header_cache: None,
focus_handle: cx.focus_handle(),
tasks: Vec::new(),
_subscriptions: subscriptions,
@@ -308,9 +327,7 @@ impl RepoDetailView {
/// (not yet published) repository is opened straight from disk. An
/// announced repository's local clone (if any) is loaded first without
/// touching the network, so an unreachable server can't block the
/// panel; a background fetch then refreshes the refs and commit list
/// (a fetch never changes the checked-out files, so the tree and
/// previews are left alone).
/// panel; a background fetch then refreshes the refs and commit list.
fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
@@ -427,9 +444,9 @@ impl RepoDetailView {
return;
}
if let Ok(Some((branches, tags, current_branch, head_commit))) = refresh {
let branches: Vec<SharedString> =
branches.into_iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect();
let branches: Vec<SharedString> = branches.iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.iter().map(Into::into).collect();
this.branch_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(branches), window, cx);
if let Some(branch) = current_branch {
@@ -437,14 +454,22 @@ impl RepoDetailView {
state.set_selected_values(&[branch], window, cx);
}
});
this.tag_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(tags), window, cx);
});
let new_head_commit = head_commit.as_ref().map(|c| &c.id);
let current_head_commit = this.head_commit.as_ref().map(|c| &c.id);
let head_changed = new_head_commit != current_head_commit;
this.head_commit = head_commit;
// The fetch may have brought new commits: reload the list.
this.all_commits = None;
this.loading_all_commits = false;
this.load_all_commits(cx);
if head_changed || this.all_commits.is_none() {
this.all_commits = None;
this.loading_all_commits = false;
this.load_all_commits(cx);
}
cx.notify();
}
})?;
@@ -455,8 +480,8 @@ impl RepoDetailView {
self.tasks.push(task);
}
/// Apply the loaded repository data: explorer tree, README preview, ref
/// selectors and HEAD commit, then start the commit-list walk.
/// Apply the loaded repository data: explorer tree, README preview,
/// ref selectors and HEAD commit, then start the commit-list walk.
fn apply_repo_data(&mut self, data: RepoData, window: &mut Window, cx: &mut Context<Self>) {
let RepoData {
tree,
@@ -479,10 +504,11 @@ impl RepoDetailView {
state.set_items(tree_items(tree, false), cx);
});
// Populate the branch/tag selectors with the local refs, selecting
// the branch HEAD points to.
// Populate the branch/tag selectors with the local refs,
// selecting the branch HEAD points to.
let branches: Vec<SharedString> = branches.into_iter().map(Into::into).collect();
let tags: Vec<SharedString> = tags.into_iter().map(Into::into).collect();
self.branch_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(branches), window, cx);
if let Some(branch) = current_branch {
@@ -490,11 +516,13 @@ impl RepoDetailView {
state.set_selected_values(&[branch], window, cx);
}
});
self.tag_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(tags), window, cx);
});
self.load_all_commits(cx);
if let Some((path, bytes)) = readme_path.zip(readme) {
self.readme_name = Some(path.to_string_lossy().into());
self.load_commit(&path.to_string_lossy(), cx);
@@ -504,10 +532,8 @@ impl RepoDetailView {
}
}
/// Clone the repository into a folder chosen by the user (outside the
/// cache), then open the new clone in the system file manager. Like
/// ngit's clone, this resolves the announcement's `clone` URLs and
/// clones from the first working git server.
/// Clone the repository into a folder chosen by the user (outside the cache),
/// then open the new clone in the system file manager.
fn clone_to_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.cloning {
return;
@@ -722,11 +748,8 @@ impl RepoDetailView {
/// Walk history once for every queued path on a background task, and
/// cache the latest commit touching each of them in [`Self::commits`]
/// (for the file header in the content column).
///
/// Batching shares one walk (and its object decodes) across all paths
/// queued while the previous walk was in flight, instead of walking the
/// full history per file.
/// (for the file header in the content column). Batching shares one
/// walk across all paths queued while the previous walk was in flight.
fn load_commits(&mut self, cx: &mut Context<Self>) {
if self.pending_commits.is_empty() || self.loading_commits {
return;
@@ -1029,8 +1052,7 @@ impl RepoDetailView {
/// Trigger body for the branch/tag selectors: the kind icon, the
/// selection (or placeholder) and the caret. `Combobox` replaces its
/// default trigger entirely, which is the only way to show an icon
/// inside the trigger label.
/// default trigger entirely, the only way to show an icon inside it.
fn render_ref_trigger(
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
icon: CustomIconName,
@@ -1201,7 +1223,7 @@ impl RepoDetailView {
/// The NIP-34 header (actions, issues/PR counts) or, for a local
/// repository that hasn't been published yet, the local header with an
/// Init button.
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
fn render_header(&mut self, cx: &mut Context<Self>) -> AnyElement {
if self.local_path.is_some() {
return self.render_local_header(cx);
}
@@ -1210,26 +1232,53 @@ impl RepoDetailView {
return div().into_any_element();
};
let store = store_entity.read(cx);
let Some(announcement) = store
.announcement
.as_ref()
.or(self.initial.as_ref())
.cloned()
else {
return div().into_any_element();
};
let issue_count = SharedString::from(store.issue_count().to_string());
let pr_count = SharedString::from(store.pull_request_count().to_string());
let Some(source) = store.announcement.as_ref().or(self.initial.as_ref()) else {
return div().into_any_element();
};
// The header derives bech32 share targets and clone command strings
// from the announcement; rebuild them only when the announcement or
// the owner's NIP-05 changes, not on every render.
let nip05 = ProfileStore::global(cx)
.read(cx)
.get(&source.owner)
.metadata()
.nip05
.clone()
.filter(|nip05| !nip05.trim().is_empty());
let key = (source.event_id, nip05);
if self
.header_cache
.as_ref()
.is_none_or(|cache| cache.key != key)
{
let announcement = source.clone();
let share = ShareTargets::from_announcement(&announcement);
let nostr_url = nostr_clone_url(&announcement, key.1.as_deref());
self.header_cache = Some(HeaderCache {
ngit_command: SharedString::from(format!("git clone {nostr_url}")),
nak_command: SharedString::from(format!("nak git clone {nostr_url}")),
git_commands: Rc::new(announcement.clone_urls()),
share: Rc::new(share),
announcement: Rc::new(announcement),
key,
});
}
let cache = self.header_cache.as_ref().expect("cache just built");
let announcement = cache.announcement.clone();
let share = cache.share.clone();
let ngit_command = cache.ngit_command.clone();
let nak_command = cache.nak_command.clone();
let git_commands = cache.git_commands.clone();
let name = self.display_name(cx);
let description = announcement.description();
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
let share = ShareTargets::from_announcement(&announcement);
let nostr_url = nostr_clone_url(&announcement, cx);
let ngit_command = SharedString::from(format!("git clone {nostr_url}"));
let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
let git_commands = announcement.clone_urls();
v_flex()
.on_action(
@@ -1923,9 +1972,7 @@ impl Render for RepoDetailView {
}
/// Read the worktree state of `repo` (no network): entries, README, refs
/// and HEAD commit. The tree is built off the main thread; the seeds are
/// plain owned strings and convert to `TreeItem`s (which hold `Rc` state)
/// on the main thread.
/// and HEAD commit.
fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
let entries = signed_git::worktree_entries(repo)?;
let tree = build_tree_items(&entries);
@@ -1961,16 +2008,11 @@ fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
/// The `nostr://...` clone URL of an announcement (NIP-34): the owner as a
/// NIP-05 identifier when known (npub otherwise), the first announced relay
/// as a hint, and the repository identifier.
fn nostr_clone_url(announcement: &Announcement, cx: &App) -> SharedString {
/// as a hint, and the repository identifier. `nip05` is the owner's
/// NIP-05 identifier from the profile store, already blank-filtered.
fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString {
let owner = announcement.owner;
let user = ProfileStore::global(cx)
.read(cx)
.get(&owner)
.metadata()
.nip05
.as_deref()
.filter(|nip05| !nip05.trim().is_empty())
let user = nip05
.map(str::to_owned)
.unwrap_or_else(|| owner.to_bech32().unwrap_or_else(|_| owner.to_hex()));
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
@@ -38,6 +39,10 @@ use crate::image_cache::{MAX_IMAGES, image_cache};
/// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.;
/// Height of one commit row in the commits tab's virtual list: a single
/// text line plus the 1px bottom border.
const PR_COMMIT_ROW_HEIGHT: f32 = 37.;
/// Detail panel of a single pull request.
pub struct PullRequestDetailView {
focus_handle: FocusHandle,
@@ -77,6 +82,15 @@ pub struct PullRequestDetailView {
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the diff rows.
scroll_handle: VirtualListScrollHandle,
/// Per-row heights of the commits tab's virtual list, built when the
/// patch series is loaded.
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the commits tab.
commit_scroll_handle: VirtualListScrollHandle,
/// Comment bodies as shared strings, keyed by comment event ID, so
/// re-renders don't clone full contents again (events are immutable,
/// so the cache never needs invalidation).
contents: HashMap<EventId, SharedString>,
/// In-flight tasks; finished tasks are pruned on every push, so the vec
/// stays bounded by the number of concurrent loads.
tasks: Vec<Task<Result<(), anyhow::Error>>>,
@@ -137,6 +151,9 @@ impl PullRequestDetailView {
rows: Vec::new(),
item_sizes: Rc::new(Vec::new()),
scroll_handle: VirtualListScrollHandle::new(),
commit_item_sizes: Rc::new(Vec::new()),
commit_scroll_handle: VirtualListScrollHandle::new(),
contents: HashMap::new(),
tasks: Vec::new(),
_subscriptions: subscriptions,
}
@@ -146,9 +163,8 @@ impl PullRequestDetailView {
/// and commit list on a background task and populate the tree.
///
/// The changes come from the PR's patch set (NIP-34 `e`-linked patch
/// events) when present; otherwise they live in the git repository
/// (`c`, `clone` and `merge-base` tags, per NIP-34), so the clone is
/// fetched and the `merge-base..tip` range is diffed.
/// events) when present; otherwise from the git repository (`c`,
/// `clone` and `merge-base` tags), diffing the `merge-base..tip` range.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
@@ -263,6 +279,8 @@ impl PullRequestDetailView {
this.loading = false;
this.worktree = worktree;
this.current_commit = current_commit.map(SharedString::from);
this.commit_item_sizes =
Rc::new(vec![size(px(0.), px(PR_COMMIT_ROW_HEIGHT)); commits.len()]);
this.commits = commits;
match diff {
Ok(diff) => {
@@ -799,15 +817,32 @@ impl PullRequestDetailView {
}
v_flex()
.relative()
.flex_1()
.w_full()
.min_h_0()
.overflow_y_scrollbar()
.children(
self.commits
.iter()
.enumerate()
.map(|(ix, commit)| self.render_commit_row(ix, commit, cx)),
.child(
v_virtual_list(
cx.entity().clone(),
"pr-commits",
self.commit_item_sizes.clone(),
move |this, range, _window, cx| {
range
.map(|ix| this.render_commit_row(ix, &this.commits[ix], cx))
.collect()
},
)
.track_scroll(&self.commit_scroll_handle)
.size_full(),
)
.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::vertical(&self.commit_scroll_handle)),
)
.into_any_element()
}
@@ -826,7 +861,7 @@ impl PullRequestDetailView {
h_flex()
.id(ix)
.px_4()
.py_2()
.h(px(PR_COMMIT_ROW_HEIGHT))
.gap_2()
.items_center()
.text_sm()
@@ -881,6 +916,13 @@ impl PullRequestDetailView {
let author = profile.name();
let picture = profile.picture();
let age = relative_time(comment.created_at);
// Comment bodies are cloned into shared strings once per
// comment, not on every render.
let content = self
.contents
.entry(comment.id)
.or_insert_with(|| SharedString::from(comment.content.clone()))
.clone();
v_flex()
.gap_1()
@@ -915,11 +957,7 @@ impl PullRequestDetailView {
.child(SharedString::from(age)),
),
)
.child(
div()
.text_sm()
.child(SharedString::from(comment.content.clone())),
)
.child(div().text_sm().child(content))
}))
.into_any_element()
}
@@ -17,7 +17,7 @@ use gpui_component::scroll::Scrollbar;
use gpui_component::{
ActiveTheme, Icon, Sizable, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
};
use nostr::prelude::{Event, EventId, Kind};
use nostr::prelude::{EventId, Kind};
use signed_core::{RepoStatus, activity_subject};
use signed_state::{ProfileStore, RepoStore};
use utils::relative_time;
@@ -26,11 +26,8 @@ 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 (8px vertical padding (`py_2`) on top and bottom, a 32px title
/// line (`h_8`) and a 24px meta line (`h_6`), plus the 1px bottom border),
/// so the row totals 73px. The status badge (`size_7`, 28px) is shorter
/// than the content.
/// Height of one pull request row in the virtual list; same layout as an
/// issue row.
const PR_ROW_HEIGHT: f32 = 73.;
/// Status filter of the pull request list, chosen via the header's filter
@@ -51,14 +48,14 @@ enum PullRequestFilter {
}
impl PullRequestFilter {
/// Whether `pr` (of `store`) is included by this filter.
fn matches(self, store: &RepoStore, pr: &Event) -> bool {
/// Whether a pull request with `status` is included by this filter.
fn matches(self, status: RepoStatus) -> bool {
match self {
Self::All => true,
Self::Open => store.status_of(pr) == RepoStatus::Open,
Self::Closed => store.status_of(pr) == RepoStatus::Closed,
Self::Draft => store.status_of(pr) == RepoStatus::Draft,
Self::Merged => store.status_of(pr) == RepoStatus::Applied,
Self::Open => status == RepoStatus::Open,
Self::Closed => status == RepoStatus::Closed,
Self::Draft => status == RepoStatus::Draft,
Self::Merged => status == RepoStatus::Applied,
}
}
}
@@ -79,10 +76,16 @@ pub struct PullRequestsView {
/// pull request count); rebuilt on change.
pr_len: usize,
/// Indices into the store's `pull_requests` matching [`Self::filter`]
/// (root PR events only; updates are revisions of the root and are not
/// listed separately), rebuilt every render; the virtual list renders
/// this slice.
/// (root PR events only; updates are revisions of the root); the
/// virtual list renders this slice. Rebuilt only when the store
/// version or the filter changes, keyed by [`Self::cache_key`].
visible_prs: Vec<usize>,
/// Header counts `(total, open, closed, draft, merged)` of the root
/// pull requests only (revisions are not separate PRs), rebuilt with
/// [`Self::visible_prs`].
counts: (usize, usize, usize, usize, usize),
/// Store version and filter the cached rows/counts were built from.
cache_key: Option<(u64, PullRequestFilter)>,
/// Virtual list state of the pull requests list.
scroll_handle: VirtualListScrollHandle,
}
@@ -104,6 +107,8 @@ impl PullRequestsView {
item_sizes: Rc::new(Vec::new()),
pr_len: 0,
visible_prs: Vec::new(),
counts: (0, 0, 0, 0, 0),
cache_key: None,
scroll_handle: VirtualListScrollHandle::new(),
}
}
@@ -206,16 +211,9 @@ impl PullRequestsView {
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let store = self.store.read(cx);
let (total, open, closed, draft, merged) = store.pull_requests.iter().fold(
(0usize, 0usize, 0usize, 0usize, 0usize),
|(total, open, closed, draft, merged), pr| match store.status_of(pr) {
RepoStatus::Open => (total + 1, open + 1, closed, draft, merged),
RepoStatus::Closed => (total + 1, open, closed + 1, draft, merged),
RepoStatus::Draft => (total + 1, open, closed, draft + 1, merged),
RepoStatus::Applied => (total + 1, open, closed, draft, merged + 1),
},
);
// Counts of the last list rebuild (`render` rebuilds first when the
// store version or filter changed, so this is never stale).
let (total, open, closed, draft, merged) = self.counts;
h_flex()
.px_4()
@@ -541,19 +539,38 @@ impl Render for PullRequestsView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter;
// Indices of the root pull requests matching the active filter
// (updates are revisions of the root and are not listed
// separately); the virtual list renders this filtered slice.
self.visible_prs = {
// Rebuild the filtered rows and header counts only when the store
// refreshed or the filter changed; other renders reuse the cache.
let version = self.store.read(cx).version();
if self.cache_key != Some((version, filter)) {
let store = self.store.read(cx);
store
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
self.visible_prs = store
.pull_requests
.iter()
.enumerate()
.filter(|(_, pr)| pr.kind == Kind::GitPullRequest && filter.matches(store, pr))
.map(|(ix, _)| ix)
.collect()
};
.filter_map(|(ix, pr)| {
// Kind-30620 patches are revisions of a root PR (NIP-34),
// not separate pull requests: count only root events, or
// the header counts inflate with every revision (which
// also default to `Open` in `status_of`).
if pr.kind != Kind::GitPullRequest {
return None;
}
let status = store.status_of(pr);
counts.0 += 1;
match status {
RepoStatus::Open => counts.1 += 1,
RepoStatus::Closed => counts.2 += 1,
RepoStatus::Draft => counts.3 += 1,
RepoStatus::Applied => counts.4 += 1,
}
filter.matches(status).then_some(ix)
})
.collect();
self.counts = counts;
self.cache_key = Some((version, filter));
}
let count = self.visible_prs.len();
+2 -7
View File
@@ -97,9 +97,7 @@ pub struct RepoListView {
/// 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.
/// in display order; the virtual list renders this slice.
visible: Vec<usize>,
/// Search box filtering repositories by name.
search: Entity<InputState>,
@@ -153,10 +151,7 @@ impl RepoListView {
}
/// 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.
/// store contents, [`Self::filter`] and the search query.
fn rebuild_rows(&mut self, cx: &mut Context<Self>) {
let filter = self.filter;
let query = self.search.read(cx).value();
+2 -3
View File
@@ -417,9 +417,8 @@ impl SidebarPanel {
)
}
/// 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.
/// Sign-in placeholder shown while logged out: banner artwork behind a
/// scrim so the CTA buttons stay readable in both themes.
fn render_sign_in(&self, window: &mut Window, cx: &mut Context<Self>) -> Div {
v_flex()
.size_full()