Skip to content

feat(staged): cache the project list in a shared store so project views paint instantly - #907

Merged
matt2e merged 6 commits into
mainfrom
project-list-cache
Aug 5, 2026
Merged

feat(staged): cache the project list in a shared store so project views paint instantly#907
matt2e merged 6 commits into
mainfrom
project-list-cache

Conversation

@matt2e

@matt2e matt2e commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Project views each fetched and listened for their own copy of the project list, branches, and repos, so every route change re-paid the IPC cost — and a cold start into a project route waited on N × 2 calls before anything rendered. This adds a module-scoped SWR store that all project views share, and hoists the sidebar so it survives project↔repos transitions.

What changed

Shared store (lib/stores/projectsData.svelte.ts) — a runes store at module scope, so it outlives route changes and gives the Tauri app the in-memory cache cache.ts only provided on web. Owns projects, branchesByProject, reposByProject (+ derived repo counts), loading/error state, and the delete lifecycle. Centralizes the rAF-coalesced pr-status-changed listener, session-status commit refresh, setup-progress refresh, cache-stale reload, and a shared listReposForHome cache.

Views consume itProjectHome, ProjectsList, ProjectsSidebar, and ReposListView drop their local state and event listeners and render from the store; initNavigation() seeds and consumes it for last-project validation and the switch shortcuts. projectsSidebarState slims to pure UI state (width, scroll).

Sidebar hoisted to App.svelte — rendered from the app shell on both the project and repos routes, so switching keeps it mounted: the slide animation only plays on genuine visibility changes and scroll position carries over without save/restore. Context-menu orchestration (mark unread, safe-to-delete, immediate vs. confirmed delete) moves into a shared projectActions module, collapsing two per-view AlertDialogs into one ProjectDeleteDialog mounted at App level. Deleting a project now only navigates away when it's the one on screen.

Two-level readinessloaded means "the project list landed" rather than "everything hydrated", so the sidebar, main panel, and route validation each wait only for what they paint. Per-project hydration is tracked separately, marked once a fetch settles (a failed fetch can't gate a view forever) and keyed by load generation so entries survive a refresh without re-blanking a painted view. All hydration paths run through one deduping helper, so the foreground fetch, the idle drip, and the grid sweep share a request instead of racing.

Testing

Vitest covers the store (hydration, generation guards, merge behavior, SWR revalidation, listener coalescing, delete lifecycle, both readiness gates, dedupe across drip/foreground/sweep) and projectActions (immediate vs. confirmed delete, dialog confirm/cancel, failure handling, navigation policy on all three routes).

🤖 Generated with Claude Code

matt2e and others added 4 commits August 4, 2026 16:37
…s, and repos

Phase 1 of the shared project-list data plan: a module-scoped runes store
(src/lib/stores/projectsData.svelte.ts) that owns projects, branchesByProject,
reposByProject (plus derived repoCountsByProject), loading/error state, and
the project-delete lifecycle. Because it lives at module scope it survives
route changes, giving the Tauri app the in-memory SWR layer that cache.ts
only provides on web: ensureLoaded() does the full fetch once, then serves
in-memory data instantly and revalidates in the background.

Ports ProjectHome's loadGeneration guards, mergeBranchesPreservingWorktree,
and idle-queue background hydration; centralizes the rAF-coalesced
pr-status-changed listener, session-status-changed commit refresh,
project-setup-progress refresh, cache-stale reload, and a shared
listReposForHome cache invalidated by staged:pinned-repos-changed.

No view consumes the store yet — that is Phase 2. Direct vitest coverage
exercises hydration, generation guards, merge behavior, SwrResult
revalidation, listener coalescing, and the delete lifecycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Phase 2 of the project-list cache: views now render from the shared
projectsData store instead of fetching and listening on their own, so
revisiting the project list or switching projects paints instantly from
memory while the store revalidates in the background.

- ProjectHome drops its local projects/branches/repos state and event
  listeners, derives everything from the store, calls ensureLoaded()
  plus hydrateProject() on mount and selection change, and points the
  workspaceLifecycle hooks at the store. View-lifecycle side effects
  (initial-setup enqueueing, queued-session draining, run-action
  hydration) run from an effect over the store's branch map.
- ProjectsList renders the grid from the store, keeping filters,
  scroll restore, and modal state local.
- ProjectsSidebar reads the store directly, dropping its seven data
  props; pinned repos sync from the shared home-repos cache with
  optimistic drag reorder preserved.
- ReposListView serves from the home-repos cache and refreshes it
  through the store after pin/clone mutations.
- initNavigation() seeds and consumes the store for last-project
  validation and the project-switch shortcuts; projectsSidebarState
  slims to pure UI state (width, scroll).
- App.svelte starts/stops the store's global listeners once for the
  app lifetime.
- The store gains the mutation entry points views need: refreshProject(),
  projectCreated(), setBranchesByProject(), and refreshHomeRepos().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…he repos grid

Phase 3 of the project-list cache plan: the sidebar now renders from
App.svelte's workspace branch whenever the route is a project or the
repos grid, instead of living inside ProjectHome. Switching between a
project and the repos view keeps the sidebar mounted, so the slide
animation only plays when visibility genuinely changes and scroll
position carries over without the save/restore dance. A new .workspace
flex row in App.svelte mirrors ProjectHome's old wrapper; ProjectHome
keeps only its main panel and ReposListView needed no changes.

For the sidebar to behave identically on both routes, the context-menu
orchestration moves out of the views into a shared projectActions
module: mark-unread, the safe-to-delete check, immediate delete vs.
pending confirmation, and the delete lifecycle against the projectsData
store (including the branch-state cleanup previously only done by
ProjectHome). This dedupes ProjectsList's parallel copy of the delete
flow, and the two per-view AlertDialogs collapse into one
ProjectDeleteDialog mounted once at App level. The sidebar drops its
onMarkProjectUnread/onRemoveProject callback props and calls the module
directly; ProjectHome's top-bar button and ⌘⌫ shortcut route through it
too. Deleting a project now only navigates away when the deleted
project is the one on screen, so removing another project from the
sidebar (or anything from the repos route) keeps the current view.

Vitest covers the new module: immediate-vs-confirmed delete, the
confirm/cancel dialog flow, failure handling, and the navigation
policy on all three routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ation

Cold start into a project route put N x 2 IPC calls on the critical path:
projectsDataStore.ensureLoaded() only resolved once branches *and* repos
had been fetched for every project, so the sidebar's "Loading projects…",
ProjectHome's main panel, and initNavigation()'s route validation all
waited out the slowest project. Split store readiness in two so each view
waits for exactly the data it paints.

- `loaded` now means "the project list landed". applyProjectList becomes
  synchronous — seed branch entries, prune removed projects — and
  ensureLoaded() resolves there. Per-project hydration moves behind it.
- New per-project hydration tracking (_hydratedProjects), marked once a
  fetch *settles* so a failed fetch can't gate a view forever, and keyed
  by load generation so entries survive a refresh (never re-blanking a
  painted view) while the idle drip can tell "already fetched under this
  load" from "stale". Exposed as isProjectHydrated()/allProjectsHydrated.
- Every hydration path now runs through one deduping helper, so the
  foreground selected-project fetch, the idle drip and the grid's sweep
  share a request instead of racing three. refreshProject() stays
  un-deduped: it is the post-mutation refetch. The eager/background
  hydration option collapses — both loads drip through the idle queue —
  and refresh() re-hydrates what was already hydrated, in parallel.
- ProjectHome gates on the selected project's own hydration (guarded by
  the project still being in the list, so a stale id can't pin it in
  loading and the vanished-project goHome() still fires) and always
  foreground-hydrates the selection. ProjectsList keeps its
  paint-complete-or-not-at-all behaviour via allProjectsHydrated plus a
  new ensureProjectsHydrated() sweep, kicked from an effect so a list
  change or cache-stale reload re-kicks it. The sidebar and
  initNavigation() need no change: rows and route validation now come
  with the list, and PR icons/repo badges fill in through the drip.

Vitest covers the two-level readiness: ensureLoaded resolving while
branches hang, per-project and all-projects gates flipping, drip/
foreground/sweep dedupe, a rejected fetch still settling, and
projectCreated marking its project hydrated synchronously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e requested review from baxen and wesbillman as code owners August 5, 2026 01:18

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

await commands.reorderPinnedRepos(orderedKeys);

P2 Badge Refresh the shared repo cache after reordering pins

After a successful drag reorder, only the sidebar's local pinnedRepos array is rearranged; the shared projectsDataStore.homeRepos cache remains in the old order. Since the landing page, Repos page, and a remounted sidebar all read from that shared cache, the new order can appear to revert or stay stale until an unrelated refresh; update or refresh the store after reorderPinnedRepos succeeds.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +606 to +607
{#if navigation.selectedProjectId || (reposUiEnabled && navigation.showReposList)}
<ProjectsSidebar />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep run-action tracking alive with the sidebar

When users switch from a project to Repos, this keeps ProjectsSidebar mounted while ProjectHome unmounts; I checked the repo and only ProjectHome/ProjectsList call projectRunActionsStore.startListening, and ProjectHome cleanup calls stopListening, which clears executions. Any running run-action badge/activity in the surviving sidebar therefore disappears or stops updating on the Repos route; start/hydrate the run-action store from the sidebar or App as well.

Useful? React with 👍 / 👎.

Comment on lines +574 to +575
} catch (e) {
console.error('[projectsData] Failed to load home repos:', e);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Settle the home-repos cache after load failure

When the initial listReposForHome request rejects, this catch only logs and leaves _homeRepos as null. ReposListView now derives its loading state from !homeReposLoaded, so one failed initial backend call leaves the Repos page permanently showing Loading repos... with no error state until the view is remounted; mark the cache loaded with an empty result or expose an error.

Useful? React with 👍 / 👎.

matt2e and others added 2 commits August 5, 2026 12:00
Since 39793da sidebar rows render as soon as the project *list* lands, so
a project's branches and repos can still be the seeded fallbacks when the
user picks Remove Project: an empty branch list, and a repoCount of 1 for
a githubRepo project or 0 otherwise. canDeleteProjectWithoutConfirmation
treats repoCount 0 as "nothing to lose", so right-clicking Remove on an
un-hydrated multi-repo or name-only project deleted it immediately, with
no confirmation, even when it had repos and branches holding unpushed
work. In the same window branchesToClear was empty, silently skipping
timeline invalidation and workspaceLifecycle cleanup. The repos route was
the worst case — nothing foreground-hydrates there, so every project sat
un-hydrated for the length of the idle drip after a cold start.

Make the destructive path demand its inputs instead of reading whatever
the cache holds.

- The store gains ensureProjectHydrated(projectId), the single-project
  counterpart of ensureProjectsHydrated(): a no-op once the project has
  been fetched under the current load, otherwise a hydrateOnce() that
  joins any drip/sweep/foreground request already in flight. Not
  hydrateProject(), which always refetches — every remove-click on an
  already-hydrated project would pay for a round trip.
- requestRemoveProject awaits it, then re-checks isProjectDeleting so a
  second invocation racing the fetch can't double-fire or pop a dialog
  for a project mid-delete. Because hydration deliberately settles even
  when the fetch rejects (so views gated on it never hang), awaiting it
  isn't sufficient on its own: the confirmation is only skipped when
  reposByProject actually holds an entry, which only the success path
  writes. A failed fetch therefore falls through to the dialog —
  conservative, and still deletable. branchesToClear is fixed for free,
  since both the immediate and confirmed paths now run after hydration
  settled. A genuinely empty project (fetched, 0 repos) still deletes
  immediately.
- BACKGROUND_HYDRATION_DELAY_MS becomes
  BACKGROUND_HYDRATION_IDLE_TIMEOUT_MS: it is a requestIdleCallback
  timeout, not a fixed 3s delay between steps, and reading it as the
  latter has now misled two reviews about how long the un-hydrated
  window really is.

Vitest covers ensureProjectHydrated (fetches an un-hydrated project,
no-ops a hydrated one, joins an in-flight drip) and the delete flow: an
un-hydrated multi-repo project whose hydration reveals unpushed work now
confirms instead of deleting, a hydration that settled without repos
confirms, a fetched repo-less project still deletes immediately,
branchesToClear reflects the hydrated branches, and a delete that starts
during hydration bails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ies can't resurrect it

removeProject pruned the deleted project from every map but left
loadGeneration untouched, so any list apply already in flight when the
backend delete completed — an SWR revalidating promise, a concurrent
ensureLoaded() revalidation (kicked on every project switch), or
refreshProject's un-deduped _projects replacement — passed the
generation guard and wrote the pre-delete list back, resurrecting the
deleted project in every view until the next reload.

The core fix is the one-line generation bump, but the bump has two
side effects inside the store that need the same follow-through
loadProjectsAndHydrate() already does after its own bump:

- In-flight per-project hydrations become no-ops under the new
  generation, so hydrationInFlight is cleared and callers after the
  bump start fresh fetches instead of joining a request whose applies
  will be discarded.
- The running idle drip halts at its generation guard, so it is
  rescheduled under the new generation — but only for projects not yet
  hydrated (including a first hydration the bump just discarded, which
  never marked itself settled). Hydrated projects are excluded: a
  delete doesn't stale their data, so this deliberately isn't the
  refetch-everything drip a fresh list apply schedules.

Vitest pins each racing apply — an SWR revalidation, a background
ensureLoaded() revalidation, and a refreshProject list replacement,
each resolving after the delete — plus the drip restart hydrating a
still-pending project without touching the deleted one, and the
no-refetch guarantee for already-hydrated projects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e merged commit c4ef836 into main Aug 5, 2026
4 checks passed
@matt2e
matt2e deleted the project-list-cache branch August 5, 2026 03:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant