# 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` + `Option>` 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`, `store: Option>`, `local_path: Option` (`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` | root issue, status, comments | | `PullRequestDetailView` | `Entity` | root PR, description, tip, base, clone urls | | `IssuesView` | `Entity` | `visible_issues`, `counts`, `item_sizes`, `cache_key` | | `PullRequestsView` | `Entity` | `visible_prs`, `counts`, `item_sizes`, `cache_key` | | `NewPullRequestView` | `Entity` | announced head, default base | Sketch, matching the existing idiom: ```rust // 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, cx: &mut Context) { let store = store.read(cx); let issue = store.issues.iter().find(|issue| issue.id == self.issue_id).cloned(); let comments: Vec = 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: ```rust pub fn refresh(&mut self, cx: &mut Context) { 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: ```rust pub struct RepoStore { /// NIP-34 address. `None` while the repository is local-only. addr: Option, /// 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, /// 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, /// The first local pass has been applied. pub loaded: bool, // issues, patches, pull_requests, comments, status_by_root, head, flags... _subscription: Option, } ``` `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: ```rust impl RepoStore { /// Announced repository. Resolves `path` from `CheckoutsStore` if the /// user already has a checkout. pub fn new(addr: RepoAddr, hint: Option, cx: &mut Context) -> Self; /// Local repository discovered by the scan, not announced yet. pub fn new_local(path: PathBuf, cx: &mut Context) -> Self; /// Local -> NIP-34 in place. Keeps `path`, so the panel keeps its worktree. pub fn announce(&mut self, announcement: Announcement, cx: &mut Context); 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` 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`. - `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`, 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>` 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>` 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.