344 lines
16 KiB
Markdown
344 lines
16 KiB
Markdown
# 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:
|
|
|
|
```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<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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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.
|