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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ bitcoin_hashes = "1"
|
||||
|
||||
gpui.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user