18 KiB
Repository state and panel flow plan
Status: phases 1-3 implemented (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
- Open an issue or PR directly, from any surface (inbox entry, notification, future deep link), without walking Explore -> repository panel first.
- One per-repository entity that spans both identities: the local git
repository and the NIP-34 announcement, instead of today's separate
LocalReposStoreentry /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) rendersplaceholder("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::loadruns once fromcx.defer_in(views/pull_requests/detail.rs:88-91) and cacheserror = "Pull request not found"when the store is empty (:124-133). It can never recover.IssuesView/PullRequestsViewmemoize 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.NewPullRequestViewreadsstore.head/ the announcement innew, 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:
NostrUpdateis already batched by the notification pump with its ownPUMP_DEBOUNCE= 200 ms (backend.rs:37,:112-166). Perbackend-rearchitecture.md§12, the per-store timers were to be dropped once the pump absorbed the bursts.PublishedandSyncedare one-off events;RefreshGatealready 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
LocalReposStoreholds scan paths;RepoListStoreholds announcements;CheckoutsStorejoins them (checkouts.rs:211-213,:569-609).RepoDetailViewencodes both modes in three option fields:initial: Option<Announcement>,store: Option<Entity<RepoStore>>,local_path: Option<PathBuf>(views/repo/mod.rs:74-85), withapply_announcementmoving between them (views/repo/store.rs:13-31). The invalid combinations and theinitialfallback inannouncement()(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:
bindfinds the root PR in the store and snapshots description, tip, base and clone urls. Re-runnable on every store version change.load_diffdoes 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 toBackend, seedsannouncementfrom the hint, defers remote subscribe/connect, runs the local pass immediately.new_local: noBackendsubscription,loaded = true, path set.announce: setsaddr,announcement, keepspath; installs theBackendsubscription, connects the announced relays and refreshes. Called fromapply_announcement, which loses its field surgery.- Nostr-side actions (
push_repository,clone_to_folder,open_issue/open_pull_request, status changes) already haveaction_error("Repository announcement is not loaded yet")(repo.rs:1368-1378); they now also handleaddr == Nonethe same way. announceshould alsoCheckoutsStore::record(path, addr)for the scan path, so the association exists immediately instead of waiting for the origin/EUC match inresolve_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'satag) and drops theRepoListStorelookup. open_upstreamno longer polls the list; it opens the panel by address and the store fills it in (Phase 2).RepoItem::Patchbehavior is unchanged.
Phases
Phase 1 - make item panels react to the store (fixes the reported flow)
Status: implemented, except step 6.
signed_state/src/repo.rs:REFRESH_DEBOUNCEand the timer spawn are gone;refreshruns immediately.loadedwas 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.signed_state/src/repos.rs: same timer removal;refresh_initialfolded intorefresh.views/issues/detail.rs: observes the store and re-renders; loading placeholder while!store.loaded().views/pull_requests/detail.rs: observes the store;sync/sync_missingbind the root andload_diffdoes the async work, keyed to aPrBindingand guarded by a generation so late results are discarded. Loading vs not found is decided bystore.loaded().views/issues/mod.rs,views/pull_requests/mod.rs: observe the store;rebuildrecomputes rows/counts/item sizes andsyncnotifies on change. Filter buttons callrebuildbefore notifying.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.
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.views/inbox.rs:openpasses its already-parsedaddressand theRepoListStorelookup with its silent early-return is gone. An inbox row opens whether or not the repository is in the list yet.open_repo_panelandRepoDetailView::newtake an address plus an optional hint, so a repository panel opens from aRepoAddralone. This pulls the address-based constructor forward from Phase 3 step 2.RepoDetailView::attach_storestarts the explorer from the store's first announcement when the panel was opened by address alone, so a panel opened by address fills in instead of waiting for the caller to have the announcement. Phase 3 moves this onto the single store observer, gated byrepo_started.open_upstream: the 60 x 250 ms poll and thepending_upstreamfield are gone. It opens the panel by address; the store'ssubscribe_remotefetches the announcement from the bootstrap relays and step 4 loads the explorer.
Phase 3 - one entity for local and NIP-34
Status: implemented.
signed_state/src/repo.rs:addr: Option<RepoAddr>,path: Option<PathBuf>,announcement: Option<Announcement>,_subscription: Option<Subscription>.new(addr, hint, cx)seeds the announcement and relays from the hint;new_local(path);announceswitches a local store to NIP-34 in place, keepingpath.addr()returnsOption<&RepoAddr>;refresh/connect_announced_relays/subscribe_remoteno-op without an address. Nostr-side actions guard withnot_announced(unit actions) oraction_error(task actions).views/repo/mod.rs: onestore: Entity<RepoStore>field.initialandlocal_pathare deleted;new_localbuilds a local store. The store observer starts the explorer once an announcement lands, tracked byrepo_started.display_name,load_repoandopen_init_dialogderive their mode fromaddr()/pathinstead of the removed fields.views/repo/store.rs: the store is observed from construction for both modes;apply_announcementcallsstore.announceon the existing entity.refresh_ready_statusesandrefresh_statusesreturn early whenaddr()isNone.views/repo/{actions,header,banners,loading}.rs: theOption<Entity<RepoStore>>guards are gone. Announced-only entry points (issue/PR lists, new PR, send patch) guard onaddr();NewPullRequestViewandPullRequestDetailViewthread the address option through their prefill/binding paths.LocalReposStorestays as the scan index;CheckoutsStorestays the association authority.
Deviations from the sketch above:
pathis set only bynew_localand kept byannounce.newdoes not resolve an associated checkout: the explorer still mirrors the cache for announced repositories, so a stored checkout path would be dead weight. The field is the seam for the open question below.new_localtakes noContext: a local store has nothing to subscribe to and no first pass to defer.newkeeps the open-time hint until the first pass has confirmed what the database holds, so a panel opened from a hint renders before the query lands and still adopts a later deletion.
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).
RepoStoreandRepoListStoremust not grow timers again. - No new global store, and no merging
LocalReposStore,RepoListStoreandCheckoutsStoreinto one entity (§13). - Views never query the database directly;
RepoStorestays the single projection so status/comment derivation is not duplicated. - Explorer state (tree, refs, commits, scroll) stays in
RepoDetailView. - No
Repositoryrename.
Validation
cargo check -p signed_state -p workspace, then clippy.- Manual scenarios:
- Fresh database, never open Explore: click an issue in the inbox. Panel shows a loading state, then the issue with comments and status.
- Same for a PR, including the patch diff loading once the root binds.
- Open the issues list, then receive a new issue (second client or publish); the row appears without reopening.
- Local repo: open from the sidebar, Init, panel keeps the worktree and gains the nostr header; reopening from Explore shows the same data.
- Existing flows: Explore list, ready-to-contribute and ready-to-push banners, new issue/PR dialogs.
Open questions
- Should an announced repository with an associated checkout browse that
checkout instead of the cache mirror? Today the panel always mirrors.
RepoStore.pathmakes this a one-line decision later. - Store sharing (Phase 4): worth it only if duplicate subscriptions show up in practice.