feat(staged): cache the project list in a shared store so project views paint instantly - #907
Conversation
…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>
There was a problem hiding this comment.
💡 Codex Review
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".
| {#if navigation.selectedProjectId || (reposUiEnabled && navigation.showReposList)} | ||
| <ProjectsSidebar /> |
There was a problem hiding this comment.
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 👍 / 👎.
| } catch (e) { | ||
| console.error('[projectsData] Failed to load home repos:', e); |
There was a problem hiding this comment.
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 👍 / 👎.
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>
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 × 2calls 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 cachecache.tsonly provided on web. Owns projects,branchesByProject,reposByProject(+ derived repo counts), loading/error state, and the delete lifecycle. Centralizes the rAF-coalescedpr-status-changedlistener, session-status commit refresh, setup-progress refresh, cache-stale reload, and a sharedlistReposForHomecache.Views consume it —
ProjectHome,ProjectsList,ProjectsSidebar, andReposListViewdrop their local state and event listeners and render from the store;initNavigation()seeds and consumes it for last-project validation and the switch shortcuts.projectsSidebarStateslims 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 sharedprojectActionsmodule, collapsing two per-view AlertDialogs into oneProjectDeleteDialogmounted at App level. Deleting a project now only navigates away when it's the one on screen.Two-level readiness —
loadedmeans "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