Files
signed/docs/repo-state-plan.md
T
2026-09-13 12:20:49 +07:00

16 KiB

Repository state and panel flow plan

Status: phases 1-2 implemented, phase 3 next (2026-09-13)

Builds on docs/backend-rearchitecture.md, especially §7 (notify audit), §11 (split independently-observed state), §12 (one debounce at the source) and §13 (merge the repo listing files, not the entities).

Scope: signed_state::{repo, repos}, workspace::views::{repo, issues, pull_requests, inbox}.

Goal

  1. Open an issue or PR directly, from any surface (inbox entry, notification, future deep link), without walking Explore -> repository panel first.
  2. One per-repository entity that spans both identities: the local git repository and the NIP-34 announcement, instead of today's separate LocalReposStore entry / Option<Announcement> + Option<Entity<RepoStore>> view state.

What the code does today (verified against the current tree)

1. Item panels are cached and never observe their store

The dock renders the active panel through panel.cached(...) (crates/dock/src/tab_panel.rs:777-795), so a panel re-renders only when its own entity notifies. Cross-entity reads are not reactive.

RepoDetailView observes its store (views/repo/store.rs:35-43), but the panels that read the same store do not:

  • IssueDetailView (views/issues/detail.rs:79-85) renders placeholder("Issue not found", cx) when the issue is absent, with no observer. When a directly opened store's first pass lands later and notifies, nothing re-renders the panel: it stays on "Issue not found".
  • PullRequestDetailView::load runs once from cx.defer_in (views/pull_requests/detail.rs:88-91) and caches error = "Pull request not found" when the store is empty (:124-133). It can never recover.
  • IssuesView / PullRequestsView memoize rows behind (store.version(), filter) (views/issues/mod.rs:324-351, views/pull_requests/mod.rs:334-369) but nothing re-renders them when the version changes, so an open list does not see new events either.
  • NewPullRequestView reads store.head / the announcement in new, so a late first pass does not reach its defaults.

The normal flow hides this because the repository panel is opened first: by the time the issues list or an item panel is created, the store has already applied its first pass. Opening an item directly is the case where the store is still empty at construction.

2. The initial pass waits on a timer the backend already provides

RepoStore::new defers refresh (signed_state/src/repo.rs:130-141), and refresh always waits REFRESH_DEBOUNCE = 300 ms (repo.rs:25-26, :230-242) before the local database query. RepoListStore has the same timer (repos.rs:120-123, :270-281) plus a separate refresh_initial that skips it (:257-268).

Both stores only refresh on Backend events:

  • NostrUpdate is already batched by the notification pump with its own PUMP_DEBOUNCE = 200 ms (backend.rs:37, :112-166). Per backend-rearchitecture.md §12, the per-store timers were to be dropped once the pump absorbed the bursts.
  • Published and Synced are one-off events; RefreshGate already folds them into an in-flight run.

The timers are therefore pure added latency for these two stores: ~300 ms (batched updates) to ~500 ms (pump window + store window) before local data appears.

3. Opening an item requires a hydrated Announcement

repo_store(announcement) (views/repo/actions.rs:216-219) and the inbox's lookup in RepoListStore (views/inbox.rs:384-404) need the announcement in hand and silently no-op when it is missing (RepoListStore not synced yet, deleted repo, inbox section without a matching list entry). The store itself can load the announcement from the local database; run_refresh already queries filters::announcement(&addr) (repo.rs:251-261).

4. Local and announced repositories have no shared identity

  • LocalReposStore holds scan paths; RepoListStore holds announcements; CheckoutsStore joins them (checkouts.rs:211-213, :569-609).
  • RepoDetailView encodes both modes in three option fields: initial: Option<Announcement>, store: Option<Entity<RepoStore>>, local_path: Option<PathBuf> (views/repo/mod.rs:74-85), with apply_announcement moving between them (views/repo/store.rs:13-31). The invalid combinations and the initial fallback in announcement() (views/repo/mod.rs:322-331) are the cost of the missing per-repo entity.

Design

1. Panels observe, derive into local state, notify on change

Every panel that reads a store keeps a local snapshot of exactly the slice it renders, updates it in an observer, and calls cx.notify() only when the slice changed. This is the pattern RepoDetailView::refresh_statuses (views/repo/store.rs:88-101) and SidebarPanel::refresh already use; the item panels are missing the observer half.

View Observed entity Local snapshot
IssueDetailView Entity<RepoStore> root issue, status, comments
PullRequestDetailView Entity<RepoStore> root PR, description, tip, base, clone urls
IssuesView Entity<RepoStore> visible_issues, counts, item_sizes, cache_key
PullRequestsView Entity<RepoStore> visible_prs, counts, item_sizes, cache_key
NewPullRequestView Entity<RepoStore> announced head, default base

Sketch, matching the existing idiom:

// new()
let subscription = cx.observe(&store, |this, store, cx| this.sync(store, cx));

// Copy the slice; notify only when it changed.
fn sync(&mut self, store: &Entity<RepoStore>, cx: &mut Context<Self>) {
    let store = store.read(cx);
    let issue = store.issues.iter().find(|issue| issue.id == self.issue_id).cloned();
    let comments: Vec<Event> = store.comments_of(&self.issue_id).cloned().collect();
    let status = issue.as_ref().map(|issue| store.status_of(issue));

    if self.issue != issue || self.comments != comments || self.status != status {
        self.issue = issue;
        self.comments = comments;
        self.status = status;
        cx.notify();
    }
}

PullRequestDetailView needs its one-shot load split in two:

  • bind finds the root PR in the store and snapshots description, tip, base and clone urls. Re-runnable on every store version change.
  • load_diff does the async patch/git work. Runs once bound, and again when the bound tip changes (a PR update arriving late).

2. One debounce, at the backend pump

Delete the REFRESH_DEBOUNCE timers from RepoStore and RepoListStore. Their triggers all come from Backend; the pump batches relay traffic and RefreshGate folds one-off events into an in-flight run. Keep RefreshGate exactly as is, used without a timer:

pub fn refresh(&mut self, cx: &mut Context<Self>) {
    if self.refresh.request() != RefreshRequest::Schedule {
        return;
    }
    self.run_refresh(cx);
}

RepoListStore::refresh_initial collapses into refresh; the new-time cx.defer call becomes the initial load, with no timer.

Add pub loaded: bool to RepoStore, set when the first pass applies. It separates "no data yet" from "genuinely empty": panels render a loading state while !loaded, and "not found" only after loaded.

CheckoutsStore and InboxView keep their timers in this plan. Their refresh request sources are not only the backend pump (checkout requests, settings and scan observations), so the same argument does not hold unchanged; revisit separately if measurements show the timers redundant.

3. RepoStore is the one per-repository entity

Keep the name RepoStore (Repository collides with gix::Repository, already imported in views/repo/loading.rs). Shape:

pub struct RepoStore {
    /// NIP-34 address. `None` while the repository is local-only.
    addr: Option<RepoAddr>,
    /// Latest announcement. Seeded from the open-time hint, replaced by the
    /// database's latest on the first pass. `None` while local-only.
    pub announcement: Option<Announcement>,
    /// Local working copy: the scan path for a local repo, an associated
    /// checkout for an announced one. A snapshot; `CheckoutsStore` stays the
    /// authority for the full list of checkouts.
    pub path: Option<PathBuf>,
    /// The first local pass has been applied.
    pub loaded: bool,
    // issues, patches, pull_requests, comments, status_by_root, head, flags...
    _subscription: Option<Subscription>,
}

addr is required in addition to announcement: (announcement: None, path: Some(_)) is otherwise ambiguous between "local-only" and "announced, first pass pending", and the store needs the address to run its query.

Constructors and the state transition:

impl RepoStore {
    /// Announced repository. Resolves `path` from `CheckoutsStore` if the
    /// user already has a checkout.
    pub fn new(addr: RepoAddr, hint: Option<Announcement>, cx: &mut Context<Self>) -> Self;

    /// Local repository discovered by the scan, not announced yet.
    pub fn new_local(path: PathBuf, cx: &mut Context<Self>) -> Self;

    /// Local -> NIP-34 in place. Keeps `path`, so the panel keeps its worktree.
    pub fn announce(&mut self, announcement: Announcement, cx: &mut Context<Self>);

    pub fn addr(&self) -> Option<&RepoAddr>;
}
  • new: subscribes to Backend, seeds announcement from the hint, defers remote subscribe/connect, runs the local pass immediately.
  • new_local: no Backend subscription, loaded = true, path set.
  • announce: sets addr, announcement, keeps path; installs the Backend subscription, connects the announced relays and refreshes. Called from apply_announcement, which loses its field surgery.
  • Nostr-side actions (push_repository, clone_to_folder, open_issue/open_pull_request, status changes) already have action_error("Repository announcement is not loaded yet") (repo.rs:1368-1378); they now also handle addr == None the same way.
  • announce should also CheckoutsStore::record(path, addr) for the scan path, so the association exists immediately instead of waiting for the origin/EUC match in resolve_associations. Optional, verify behavior.

RepoDetailView then holds store: Entity<RepoStore> plus explorer state only. initial and local_path are deleted; announcement() reads the store; local-mode checks become store.read(cx).addr().is_none(); load_repo opens path when not announced, and keeps today's cache-mirror flow for announced repositories.

4. Opening a repository or item needs only a RepoAddr

  • repo_store(addr: &RepoAddr, hint: Option<&Announcement>, cx) -> Entity<RepoStore>.
  • open_repo_item(dock_area, addr: &RepoAddr, item, window, cx).
  • Inbox passes its already-parsed address (signed_core::InboxItem.address, the root event's a tag) and drops the RepoListStore lookup.
  • open_upstream no longer polls the list; it opens the panel by address and the store fills it in (Phase 2).
  • RepoItem::Patch behavior is unchanged.

Phases

Phase 1 - make item panels react to the store (fixes the reported flow)

Status: implemented, except step 6.

  1. signed_state/src/repo.rs: REFRESH_DEBOUNCE and the timer spawn are gone; refresh runs immediately. loaded was added and is set in the apply closure. A first pass notifies even when it found nothing, so views leave their loading state and show the empty result.
  2. signed_state/src/repos.rs: same timer removal; refresh_initial folded into refresh.
  3. views/issues/detail.rs: observes the store and re-renders; loading placeholder while !store.loaded().
  4. views/pull_requests/detail.rs: observes the store; sync/sync_missing bind the root and load_diff does the async work, keyed to a PrBinding and guarded by a generation so late results are discarded. Loading vs not found is decided by store.loaded().
  5. views/issues/mod.rs, views/pull_requests/mod.rs: observe the store; rebuild recomputes rows/counts/item sizes and sync notifies on change. Filter buttons call rebuild before notifying.
  6. views/pull_requests/new.rs: not done. Its store-derived inputs are defaults for the compare/base selectors; re-applying them on a late store pass would clobber a selection the user already made. Left for a follow-up once the defaults can be derived without resetting the selectors.

Phase 2 - entry points by identity

Status: implemented.

  1. views/repo/actions.rs: repo_store(addr, hint, cx) seeds the store's relays from an optional hint but needs nothing else; the store loads the announcement itself. open_repo_item(addr, hint, item, window, cx) takes the address, not a hydrated announcement.
  2. views/inbox.rs: open passes its already-parsed address and the RepoListStore lookup with its silent early-return is gone. An inbox row opens whether or not the repository is in the list yet.
  3. open_repo_panel and RepoDetailView::new take an address plus an optional hint, so a repository panel opens from a RepoAddr alone. This pulls the address-based constructor forward from Phase 3 step 2.
  4. RepoDetailView::attach_store adopts the store's first announcement when initial is still empty and calls load_repo, so a panel opened by address fills in instead of waiting for the caller to have the announcement.
  5. open_upstream: the 60 x 250 ms poll and the pending_upstream field are gone. It opens the panel by address; the store's subscribe_remote fetches the announcement from the bootstrap relays and step 4 loads the explorer.

Phase 3 - one entity for local and NIP-34

  1. signed_state/src/repo.rs: addr/path options, new_local, announce, Option<Subscription>, action guards.
  2. views/repo/mod.rs: single store field; new_local; header, display name, load_repo, open_init_dialog derive from the store. The address-based new is already in place from Phase 2.
  3. views/repo/store.rs: always observe; refresh_statuses returns false when not announced.
  4. views/repo/actions.rs, header.rs, banners.rs: drop Option<Entity<RepoStore>> guards, guard on addr() instead.
  5. LocalReposStore stays as the scan index; CheckoutsStore stays the association authority.

Phase 4 - deferred, only if duplicate stores become a problem

One store per address via HashMap<RepoAddr, WeakEntity<RepoStore>> inside RepoListStore, so an item panel opened while the repository panel is open shares the same store and its subscriptions. Not needed for correctness once Phase 1 lands; each open then loads from the local database immediately.

Non-goals

  • No per-store debounce; the pump is the one debounce (§12). RepoStore and RepoListStore must not grow timers again.
  • No new global store, and no merging LocalReposStore, RepoListStore and CheckoutsStore into one entity (§13).
  • Views never query the database directly; RepoStore stays the single projection so status/comment derivation is not duplicated.
  • Explorer state (tree, refs, commits, scroll) stays in RepoDetailView.
  • No Repository rename.

Validation

  • cargo check -p signed_state -p workspace, then clippy.
  • Manual scenarios:
    1. Fresh database, never open Explore: click an issue in the inbox. Panel shows a loading state, then the issue with comments and status.
    2. Same for a PR, including the patch diff loading once the root binds.
    3. Open the issues list, then receive a new issue (second client or publish); the row appears without reopening.
    4. Local repo: open from the sidebar, Init, panel keeps the worktree and gains the nostr header; reopening from Explore shows the same data.
    5. Existing flows: Explore list, ready-to-contribute and ready-to-push banners, new issue/PR dialogs.

Open questions

  1. Should an announced repository with an associated checkout browse that checkout instead of the cache mirror? Today the panel always mirrors. RepoStore.path makes this a one-line decision later.
  2. Store sharing (Phase 4): worth it only if duplicate subscriptions show up in practice.