From b7f94de86484ae4660e4633d78bb0ed011cc11cd Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 4 Aug 2026 16:37:46 +1000 Subject: [PATCH 1/6] feat(staged): add shared projectsData store for project list, branches, and repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Matt Toohey --- .../src/lib/stores/projectsData.svelte.ts | 630 +++++++++++++++++ .../src/lib/stores/projectsData.test.ts | 651 ++++++++++++++++++ 2 files changed, 1281 insertions(+) create mode 100644 apps/staged/src/lib/stores/projectsData.svelte.ts create mode 100644 apps/staged/src/lib/stores/projectsData.test.ts diff --git a/apps/staged/src/lib/stores/projectsData.svelte.ts b/apps/staged/src/lib/stores/projectsData.svelte.ts new file mode 100644 index 000000000..a14977678 --- /dev/null +++ b/apps/staged/src/lib/stores/projectsData.svelte.ts @@ -0,0 +1,630 @@ +/** + * Shared project-list data store. + * + * Module-scoped runes store (following the repoBadgeStore pattern) that owns + * the data every top-level view renders: the project list plus per-project + * branches and repos. Because it lives at module scope the data survives + * route changes — in the desktop app, where the SWR cache in cache.ts is + * web-only (cachedInvoke/cachedCommand short-circuit to the network under + * Tauri), this store is the in-memory cache that lets a revisited view paint + * instantly instead of replaying the full IPC fetch cascade. + * + * ensureLoaded() applies the SwrResult render-stale-then-refresh contract in + * both modes: the first call does the full fetch; later calls resolve + * immediately with the in-memory data and kick a background revalidation. + * + * Phase 1: no view consumes this store yet — ProjectsList/ProjectHome still + * own private copies of this state. Phase 2 points them here and deletes + * those copies; startListeners() gets wired once from App.svelte at that + * point. View-lifecycle side effects (workspaceLifecycle.enqueueInitialSetup, + * queued-session draining, run-action hydration) intentionally stay out of + * the store — Phase 2 wires them from the consuming views. + */ + +import { listenToEvent, type UnlistenFn } from '../transport'; +import * as commands from '../commands'; +import { repoBadgeStore } from './repoBadges.svelte'; +import type { + Branch, + PrStatusChangedEvent, + Project, + ProjectRepo, + RepoHomeItem, + SessionStatusPayload, +} from '../types'; + +/** Idle delay between background hydration steps (one project per step). */ +const BACKGROUND_HYDRATION_DELAY_MS = 3000; + +/** + * Merge incoming branches with existing ones, preserving worktreePath when + * a stale async response would overwrite an already-populated value with null. + */ +export function mergeBranchesPreservingWorktree(existing: Branch[], incoming: Branch[]): Branch[] { + return incoming.map((newBranch) => { + const prev = existing.find((b) => b.id === newBranch.id); + if (prev?.worktreePath && !newBranch.worktreePath) { + return { ...newBranch, worktreePath: prev.worktreePath }; + } + return newBranch; + }); +} + +/** Run a callback during idle time, falling back to a macrotask where + * requestIdleCallback is unavailable (Safari WKWebView, tests). */ +function scheduleDeferredTask(callback: () => void, timeout = 1500): () => void { + const schedule = + typeof requestIdleCallback === 'function' + ? (cb: () => void) => requestIdleCallback(cb, { timeout }) + : (cb: () => void) => setTimeout(cb, 0) as unknown as number; + const cancel = + typeof cancelIdleCallback === 'function' + ? (handle: number) => cancelIdleCallback(handle) + : (handle: number) => clearTimeout(handle); + + const handle = schedule(callback); + return () => cancel(handle); +} + +/** Run a callback on the next animation frame, falling back to a macrotask + * where requestAnimationFrame is unavailable (tests). */ +function scheduleFrame(callback: () => void): () => void { + if (typeof requestAnimationFrame === 'function') { + const handle = requestAnimationFrame(callback); + return () => cancelAnimationFrame(handle); + } + const handle = setTimeout(callback, 0); + return () => clearTimeout(handle); +} + +class ProjectsDataStore { + private _projects = $state([]); + private _branchesByProject = $state>(new Map()); + private _reposByProject = $state>(new Map()); + private _loading = $state(false); + private _error = $state(null); + private _loaded = $state(false); + private _deletingProjectNames = $state>(new Map()); + + // null = never loaded; distinguishes "no repos" from "not fetched yet". + private _homeRepos = $state(null); + private _homeReposLoading = $state(false); + + /** + * Guards every async apply: bumped by each full load so responses that + * resolve after a newer load started are discarded instead of clobbering + * fresher state. Same role as ProjectHome's loadGeneration. + */ + private loadGeneration = 0; + private initialLoad: Promise | null = null; + private revalidatePending = false; + private backgroundHydrationCancel: (() => void) | null = null; + + private homeReposInFlight: Promise | null = null; + private homeReposFetchToken = 0; + + private listening = false; + private unlisteners: UnlistenFn[] = []; + private pendingPrStatusEvents: PrStatusChangedEvent[] = []; + private prStatusFlushCancel: (() => void) | null = null; + + // ── Reactive reads ── + + get projects(): Project[] { + return this._projects; + } + + get branchesByProject(): Map { + return this._branchesByProject; + } + + get reposByProject(): Map { + return this._reposByProject; + } + + /** + * Repo count per project. Falls back to 1 for un-hydrated single-repo + * projects (githubRepo set) so counts render sensibly before repos load. + */ + get repoCountsByProject(): Map { + return new Map( + this._projects.map((project) => { + const repos = this._reposByProject.get(project.id); + return [project.id, repos ? repos.length : project.githubRepo ? 1 : 0] as const; + }) + ); + } + + get loading(): boolean { + return this._loading; + } + + get error(): string | null { + return this._error; + } + + get loaded(): boolean { + return this._loaded; + } + + get deletingProjectNames(): Map { + return this._deletingProjectNames; + } + + isProjectDeleting(projectId: string): boolean { + return this._deletingProjectNames.has(projectId); + } + + get homeRepos(): RepoHomeItem[] { + return this._homeRepos ?? []; + } + + get homeReposLoaded(): boolean { + return this._homeRepos !== null; + } + + get homeReposLoading(): boolean { + return this._homeReposLoading; + } + + // ── Loading ── + + /** + * Make sure the store holds data. The first call performs the full fetch + * (project list, then branches + repos for every project) and resolves when + * it completes. Later calls resolve immediately — the in-memory data is + * already renderable — and kick a background revalidation that drips + * per-project hydration through the idle queue. + * + * Load failures don't reject; they surface through `error` so callers can + * render them, mirroring the views' loadData() pattern. + */ + async ensureLoaded(): Promise { + if (this._loaded) { + void this.revalidate(); + return; + } + this.initialLoad ??= this.loadProjectsAndHydrate({ hydration: 'eager' }).finally(() => { + this.initialLoad = null; + }); + return this.initialLoad; + } + + /** Full reload (used by cache-stale and the project-delete flow). Always + * starts a new load — the generation bump discards in-flight applies. */ + async refresh(): Promise { + await this.loadProjectsAndHydrate({ hydration: 'eager' }); + if (this._homeRepos !== null) { + void this.startHomeReposFetch(); + } + } + + /** + * Hydrate branches + repos for one project. Foreground (default) fetches + * immediately — use for the selected project. Background defers to the + * idle queue so it doesn't compete with a view transition. + */ + async hydrateProject( + projectId: string, + options: { priority?: 'foreground' | 'background' } = {} + ): Promise { + const generation = this.loadGeneration; + if (options.priority === 'background') { + scheduleDeferredTask(() => { + if (generation !== this.loadGeneration) return; + this.hydrateProjectInternal(projectId, generation).catch((e) => { + console.error(`[projectsData] Failed to background hydrate project '${projectId}':`, e); + }); + }, BACKGROUND_HYDRATION_DELAY_MS); + return; + } + await this.hydrateProjectInternal(projectId, generation); + } + + private async revalidate(): Promise { + if (this.revalidatePending) return; + this.revalidatePending = true; + try { + await this.loadProjectsAndHydrate({ hydration: 'background' }); + } finally { + this.revalidatePending = false; + } + } + + private async loadProjectsAndHydrate(options: { + hydration: 'eager' | 'background'; + }): Promise { + const generation = ++this.loadGeneration; + this.cancelBackgroundHydration(); + if (this._projects.length === 0) { + this._loading = true; + } + this._error = null; + await repoBadgeStore.loadAll(); + try { + const { data, revalidating } = await commands.listProjects(); + if (generation !== this.loadGeneration) return; + await this.applyProjectList(data, generation, options); + if (generation !== this.loadGeneration) return; + this._loaded = true; + + if (revalidating) { + // Applied outside the awaited chain so callers aren't blocked on the + // SWR refresh — they already have renderable data. + revalidating + .then((fresh) => this.applyProjectList(fresh, generation, options)) + .catch((e) => { + console.error('[projectsData] Failed to revalidate project list:', e); + }); + } + } catch (e) { + if (generation !== this.loadGeneration) return; + this._error = e instanceof Error ? e.message : String(e); + } finally { + if (generation === this.loadGeneration) { + this._loading = false; + } + } + } + + /** + * Apply a fetched project list: seed branch entries so per-project + * consumers can render immediately, prune state for removed projects, and + * hydrate branches + repos — in parallel for eager loads, via the idle + * drip for background revalidations. + */ + private async applyProjectList( + projectList: Project[], + generation: number, + options: { hydration: 'eager' | 'background' } + ): Promise { + if (generation !== this.loadGeneration) return; + this._projects = projectList; + + const branchMap = new Map(); + for (const project of projectList) { + branchMap.set(project.id, this._branchesByProject.get(project.id) || []); + } + this._branchesByProject = branchMap; + + const projectIds = new Set(projectList.map((p) => p.id)); + const prunedRepos = new Map(); + for (const [projectId, repos] of this._reposByProject) { + if (projectIds.has(projectId)) prunedRepos.set(projectId, repos); + } + this._reposByProject = prunedRepos; + + if (options.hydration === 'eager') { + await Promise.all( + projectList.map(async (project) => { + try { + await this.hydrateProjectInternal(project.id, generation); + } catch (e) { + console.error(`[projectsData] Failed to hydrate project '${project.id}':`, e); + } + }) + ); + } else { + this.scheduleBackgroundHydration( + projectList.map((p) => p.id), + generation + ); + } + } + + private async hydrateProjectInternal(projectId: string, generation: number): Promise { + const [branchesResult, reposResult] = await Promise.all([ + commands.listBranchesForProject(projectId), + commands.listProjectRepos(projectId), + ]); + if (generation !== this.loadGeneration) return; + + this.applyProjectBranches(projectId, branchesResult.data, generation); + this.applyProjectRepos(projectId, reposResult.data, generation); + + if (branchesResult.revalidating) { + branchesResult.revalidating + .then((fresh) => { + this.applyProjectBranches(projectId, fresh, generation); + }) + .catch((e) => { + console.error(`[projectsData] Failed to revalidate branches for '${projectId}':`, e); + }); + } + + if (reposResult.revalidating) { + reposResult.revalidating + .then((fresh) => this.applyProjectRepos(projectId, fresh, generation)) + .catch((e) => { + console.error(`[projectsData] Failed to revalidate repos for '${projectId}':`, e); + }); + } + } + + private applyProjectBranches( + projectId: string, + branches: Branch[], + generation: number + ): Branch[] | null { + if (generation !== this.loadGeneration) return null; + + const mergedBranches = mergeBranchesPreservingWorktree( + this._branchesByProject.get(projectId) || [], + branches + ); + this._branchesByProject = new Map(this._branchesByProject).set(projectId, mergedBranches); + return mergedBranches; + } + + private applyProjectRepos(projectId: string, repos: ProjectRepo[], generation: number): void { + if (generation !== this.loadGeneration) return; + this._reposByProject = new Map(this._reposByProject).set(projectId, repos); + void repoBadgeStore.ensureForRepos( + repos.map((r) => ({ githubRepo: r.githubRepo, subpath: r.subpath })) + ); + } + + private cancelBackgroundHydration(): void { + this.backgroundHydrationCancel?.(); + this.backgroundHydrationCancel = null; + } + + /** Hydrate projects one at a time through the idle queue so background + * refreshes never contend with foreground work. */ + private scheduleBackgroundHydration(projectIds: string[], generation: number): void { + this.cancelBackgroundHydration(); + + const queue = [...projectIds]; + if (queue.length === 0) return; + + let cancelled = false; + let cancelScheduledTask: (() => void) | null = null; + + const hydrateNext = () => { + cancelScheduledTask = null; + if (cancelled || generation !== this.loadGeneration) return; + + const projectId = queue.shift(); + if (!projectId) return; + + this.hydrateProjectInternal(projectId, generation) + .catch((e) => { + console.error(`[projectsData] Failed to background hydrate project '${projectId}':`, e); + }) + .finally(() => { + if (cancelled || generation !== this.loadGeneration || queue.length === 0) return; + cancelScheduledTask = scheduleDeferredTask(hydrateNext, BACKGROUND_HYDRATION_DELAY_MS); + }); + }; + + cancelScheduledTask = scheduleDeferredTask(hydrateNext, BACKGROUND_HYDRATION_DELAY_MS); + this.backgroundHydrationCancel = () => { + cancelled = true; + cancelScheduledTask?.(); + }; + } + + // ── Home repos (shared listReposForHome cache) ── + + /** Same contract as ensureLoaded(): first call fetches, later calls + * resolve instantly and revalidate in the background. */ + async ensureHomeReposLoaded(): Promise { + if (this._homeRepos !== null) { + if (!this.homeReposInFlight) void this.startHomeReposFetch(); + return; + } + await (this.homeReposInFlight ?? this.startHomeReposFetch()); + } + + private startHomeReposFetch(): Promise { + // Token instead of generation: a pinned-repos change mid-fetch must win + // over the response already in flight. + const token = ++this.homeReposFetchToken; + if (this._homeRepos === null) { + this._homeReposLoading = true; + } + const fetchPromise = (async () => { + try { + const repos = await commands.listReposForHome(); + if (token !== this.homeReposFetchToken) return; + this._homeRepos = repos; + } catch (e) { + console.error('[projectsData] Failed to load home repos:', e); + } finally { + if (token === this.homeReposFetchToken) { + this._homeReposLoading = false; + } + } + })(); + this.homeReposInFlight = fetchPromise; + void fetchPromise.finally(() => { + // Identity check: a newer fetch may have taken over the in-flight slot. + if (this.homeReposInFlight === fetchPromise) { + this.homeReposInFlight = null; + } + }); + return fetchPromise; + } + + // ── Project-delete lifecycle ── + // + // Replaces the staged:project-delete-start/end window-event relay between + // ProjectsList and ProjectHome: the delete flow calls these directly and + // every consumer sees the same deletingProjectNames. + + projectDeleteStarted(projectId: string, name: string): void { + this._deletingProjectNames = new Map(this._deletingProjectNames).set(projectId, name); + } + + /** Mark a delete finished. `removed` prunes the project from the store + * (backend deletion succeeded); omit it when the delete failed. */ + projectDeleteFinished(projectId: string, options: { removed?: boolean } = {}): void { + const next = new Map(this._deletingProjectNames); + next.delete(projectId); + this._deletingProjectNames = next; + if (options.removed) { + this.removeProject(projectId); + } + } + + private removeProject(projectId: string): void { + this._projects = this._projects.filter((p) => p.id !== projectId); + const branches = new Map(this._branchesByProject); + branches.delete(projectId); + this._branchesByProject = branches; + const repos = new Map(this._reposByProject); + repos.delete(projectId); + this._reposByProject = repos; + } + + // ── Event listeners ── + + /** Start the global backend/window listeners. Idempotent; call once at app + * startup (App.svelte) rather than per-view. */ + startListeners(): void { + if (this.listening) return; + this.listening = true; + + // A PR-polling cycle emits one `pr-status-changed` per branch, so a storm + // of N branches arrives as N separate events. Rebuilding the branch map + // per event means N allocations + N derivation re-runs; buffer the events + // and apply a single rebuild per frame so a burst coalesces into one + // reactive flush without dropping any update. + this.unlisteners.push( + listenToEvent('pr-status-changed', (payload) => { + this.pendingPrStatusEvents.push(payload); + this.prStatusFlushCancel ??= scheduleFrame(() => this.flushPrStatusEvents()); + }) + ); + + // Refresh a project's branches when a commit session completes so the + // sprout/draft-PR icon flips as soon as the first commit lands. + this.unlisteners.push( + listenToEvent('session-status-changed', (payload) => { + void this.handleCommitSessionCompleted(payload); + }) + ); + + // Backend-driven setup progress: emitted after repo creation, worktree + // setup, and prerun actions. Only refresh display state here — setup + // itself is owned by the backend. + this.unlisteners.push( + listenToEvent('project-setup-progress', (projectId) => { + void this.handleProjectSetupProgress(projectId); + }) + ); + + const onCacheStale = () => { + void this.refresh(); + }; + window.addEventListener('cache-stale', onCacheStale); + this.unlisteners.push(() => window.removeEventListener('cache-stale', onCacheStale)); + + const onPinnedReposChanged = () => { + if (this._homeRepos !== null || this.homeReposInFlight) { + void this.startHomeReposFetch(); + } + }; + window.addEventListener('staged:pinned-repos-changed', onPinnedReposChanged); + this.unlisteners.push(() => + window.removeEventListener('staged:pinned-repos-changed', onPinnedReposChanged) + ); + } + + /** Tear down all listeners (tests, symmetry with startListeners). */ + stopListeners(): void { + for (const unlisten of this.unlisteners) { + unlisten(); + } + this.unlisteners = []; + this.prStatusFlushCancel?.(); + this.prStatusFlushCancel = null; + this.pendingPrStatusEvents = []; + this.cancelBackgroundHydration(); + this.listening = false; + } + + private flushPrStatusEvents(): void { + this.prStatusFlushCancel = null; + if (this.pendingPrStatusEvents.length === 0) return; + const events = this.pendingPrStatusEvents; + this.pendingPrStatusEvents = []; + + // Apply every buffered event onto one fresh Map. Each event re-scans the + // in-progress map, so multiple updates to the same project compound + // correctly instead of clobbering one another. + const next = new Map(this._branchesByProject); + for (const payload of events) { + for (const [projectId, branches] of next) { + const branchIndex = branches.findIndex((b) => b.id === payload.branchId); + if (branchIndex !== -1) { + const updatedBranches = [...branches]; + updatedBranches[branchIndex] = { + ...branches[branchIndex], + prState: payload.prState, + prChecksStatus: payload.prChecksStatus, + prReviewDecision: payload.prReviewDecision, + prMergeable: payload.prMergeable, + prDraft: payload.prDraft, + prHeadSha: payload.prHeadSha, + prFetchedAt: payload.prFetchedAt, + }; + next.set(projectId, updatedBranches); + break; + } + } + } + this._branchesByProject = next; + } + + private async handleCommitSessionCompleted(payload: SessionStatusPayload): Promise { + if (payload.status !== 'completed') return; + if (payload.sessionType !== 'commit') return; + const projectId = payload.projectId; + if (!projectId || !this._branchesByProject.has(projectId)) return; + const generation = this.loadGeneration; + try { + const { data: branches, revalidating } = await commands.listBranchesForProject(projectId); + this.applyProjectBranches(projectId, branches, generation); + if (revalidating) { + revalidating + .then((fresh) => { + this.applyProjectBranches(projectId, fresh, generation); + }) + .catch((e) => { + console.error( + `[projectsData] Failed to revalidate branches for project ${projectId} after commit:`, + e + ); + }); + } + } catch (e) { + console.error( + `[projectsData] Failed to refresh branches for project ${projectId} after commit:`, + e + ); + } + } + + private async handleProjectSetupProgress(projectId: string): Promise { + const generation = this.loadGeneration; + try { + const [projectsResult, branchesResult, reposResult] = await Promise.all([ + commands.listProjects(), + commands.listBranchesForProject(projectId), + commands.listProjectRepos(projectId), + ]); + if (generation !== this.loadGeneration) return; + this._projects = projectsResult.data; + const mergedBranches = this.applyProjectBranches(projectId, branchesResult.data, generation); + if (mergedBranches) { + commands.invalidateProjectBranchTimelines(mergedBranches.map((b) => b.id)); + } + this.applyProjectRepos(projectId, reposResult.data, generation); + } catch (e) { + console.error('[projectsData] Failed to refresh project after setup progress:', e); + } + } +} + +export const projectsDataStore = new ProjectsDataStore(); diff --git a/apps/staged/src/lib/stores/projectsData.test.ts b/apps/staged/src/lib/stores/projectsData.test.ts new file mode 100644 index 000000000..8283529bd --- /dev/null +++ b/apps/staged/src/lib/stores/projectsData.test.ts @@ -0,0 +1,651 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + Branch, + PrStatusChangedEvent, + Project, + ProjectRepo, + RepoHomeItem, + SessionStatusPayload, +} from '../types'; +import type { SwrResult } from '../cache'; + +// ── Fixtures ── + +function project(overrides: Partial = {}): Project { + return { + id: 'p1', + name: 'Alpha', + githubRepo: 'org/alpha', + location: 'local', + subpath: null, + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +function branch(overrides: Partial = {}): Branch { + return { + id: 'b1', + projectId: 'p1', + projectRepoId: 'r1', + branchName: 'feature', + baseBranch: 'main', + prNumber: null, + branchType: 'local', + workspaceName: null, + workstationId: null, + workspaceStatus: null, + setupComplete: true, + worktreePath: '/wt/b1', + createdAt: 0, + updatedAt: 0, + prState: null, + prChecksStatus: null, + prReviewDecision: null, + prMergeable: null, + prDraft: null, + prUrl: null, + prUpdatedAt: null, + prFetchedAt: null, + prHeadSha: null, + ...overrides, + }; +} + +function projectRepo(overrides: Partial = {}): ProjectRepo { + return { + id: 'r1', + projectId: 'p1', + githubRepo: 'org/alpha', + branchName: 'feature', + subpath: null, + isPrimary: true, + reason: null, + headRepo: null, + createdAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +function homeRepo(overrides: Partial = {}): RepoHomeItem { + return { + githubRepo: 'org/alpha', + subpath: '', + shortName: 'alpha', + hue: 120, + createdAt: 0, + pinned: false, + pinSortOrder: null, + defaultBranch: 'main', + hasLocalClone: true, + ...overrides, + }; +} + +function prEvent(overrides: Partial = {}): PrStatusChangedEvent { + return { + branchId: 'b1', + prState: 'OPEN', + prChecksStatus: 'PENDING', + prReviewDecision: null, + prMergeable: true, + prDraft: false, + prHeadSha: 'abc123', + prFetchedAt: 1, + failedChecks: [], + ...overrides, + }; +} + +function swr(data: T, revalidating: Promise | null = null): SwrResult { + return { data, revalidating }; +} + +// ── Mock plumbing ── + +type EventCallback = (payload: unknown) => void; + +let listProjects: ReturnType; +let listBranchesForProject: ReturnType; +let listProjectRepos: ReturnType; +let listReposForHome: ReturnType; +let invalidateProjectBranchTimelines: ReturnType; +let ensureForRepos: ReturnType; +let eventListeners: Map; +let windowTarget: EventTarget; + +function emit(event: string, payload: unknown): void { + for (const callback of eventListeners.get(event) ?? []) { + callback(payload); + } +} + +function tick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function importStore() { + const { projectsDataStore } = await import('./projectsData.svelte'); + return projectsDataStore; +} + +beforeEach(() => { + vi.resetModules(); + // The store's runes compile away in the app build; under vitest they stay + // plain global calls, so stub $state as identity (agent.test.ts precedent). + vi.stubGlobal('$state', (initial: unknown) => initial); + windowTarget = new EventTarget(); + vi.stubGlobal('window', windowTarget); + + eventListeners = new Map(); + listProjects = vi.fn().mockResolvedValue(swr([project()])); + listBranchesForProject = vi.fn().mockResolvedValue(swr([branch()])); + listProjectRepos = vi.fn().mockResolvedValue(swr([projectRepo()])); + listReposForHome = vi.fn().mockResolvedValue([]); + invalidateProjectBranchTimelines = vi.fn(); + ensureForRepos = vi.fn().mockResolvedValue(undefined); + + vi.doMock('../commands', () => ({ + listProjects, + listBranchesForProject, + listProjectRepos, + listReposForHome, + invalidateProjectBranchTimelines, + })); + vi.doMock('../transport', () => ({ + listenToEvent: (event: string, callback: EventCallback) => { + const callbacks = eventListeners.get(event) ?? []; + callbacks.push(callback); + eventListeners.set(event, callbacks); + return () => { + const remaining = (eventListeners.get(event) ?? []).filter((cb) => cb !== callback); + eventListeners.set(event, remaining); + }; + }, + })); + vi.doMock('./repoBadges.svelte', () => ({ + repoBadgeStore: { + loadAll: vi.fn().mockResolvedValue(undefined), + ensureForRepos, + }, + })); +}); + +afterEach(() => { + vi.doUnmock('../commands'); + vi.doUnmock('../transport'); + vi.doUnmock('./repoBadges.svelte'); + vi.unstubAllGlobals(); +}); + +// ── Tests ── + +describe('mergeBranchesPreservingWorktree', () => { + async function importMerge() { + const { mergeBranchesPreservingWorktree } = await import('./projectsData.svelte'); + return mergeBranchesPreservingWorktree; + } + + it('preserves an existing worktreePath when the incoming branch has none', async () => { + const merge = await importMerge(); + const merged = merge([branch({ worktreePath: '/wt/b1' })], [branch({ worktreePath: null })]); + expect(merged).toHaveLength(1); + expect(merged[0].worktreePath).toBe('/wt/b1'); + }); + + it('takes the incoming worktreePath when it is populated', async () => { + const merge = await importMerge(); + const merged = merge( + [branch({ worktreePath: '/wt/old' })], + [branch({ worktreePath: '/wt/new' })] + ); + expect(merged[0].worktreePath).toBe('/wt/new'); + }); + + it('adopts the incoming list shape: new branches added, missing ones dropped', async () => { + const merge = await importMerge(); + const merged = merge( + [branch({ id: 'gone' })], + [branch({ id: 'b1' }), branch({ id: 'b2', worktreePath: null })] + ); + expect(merged.map((b) => b.id)).toEqual(['b1', 'b2']); + }); +}); + +describe('ensureLoaded', () => { + it('performs the full fetch on first call: projects, branches, and repos', async () => { + const store = await importStore(); + expect(store.loaded).toBe(false); + + await store.ensureLoaded(); + + expect(listProjects).toHaveBeenCalledTimes(1); + expect(listBranchesForProject).toHaveBeenCalledWith('p1'); + expect(listProjectRepos).toHaveBeenCalledWith('p1'); + expect(store.projects).toEqual([project()]); + expect(store.branchesByProject.get('p1')).toEqual([branch()]); + expect(store.reposByProject.get('p1')).toEqual([projectRepo()]); + expect(store.repoCountsByProject.get('p1')).toBe(1); + expect(store.loaded).toBe(true); + expect(store.loading).toBe(false); + expect(store.error).toBeNull(); + expect(ensureForRepos).toHaveBeenCalled(); + }); + + it('dedupes concurrent first loads into a single fetch', async () => { + const store = await importStore(); + + await Promise.all([store.ensureLoaded(), store.ensureLoaded()]); + + expect(listProjects).toHaveBeenCalledTimes(1); + }); + + it('resolves instantly once loaded and revalidates in the background', async () => { + const store = await importStore(); + await store.ensureLoaded(); + + // Second load hangs — ensureLoaded must not wait for it. + let resolveReload!: (value: SwrResult) => void; + listProjects.mockReturnValueOnce( + new Promise>((resolve) => { + resolveReload = resolve; + }) + ); + + await store.ensureLoaded(); + expect(store.projects).toEqual([project()]); + expect(listProjects).toHaveBeenCalledTimes(2); + + resolveReload(swr([project(), project({ id: 'p2', name: 'Beta' })])); + await vi.waitFor(() => { + expect(store.projects).toHaveLength(2); + }); + // New project's branch entry is seeded so consumers can render it. + expect(store.branchesByProject.has('p2')).toBe(true); + }); + + it('prunes branches and repos of projects removed by a revalidation', async () => { + listProjects.mockResolvedValue(swr([project(), project({ id: 'p2', name: 'Beta' })])); + const store = await importStore(); + await store.ensureLoaded(); + expect(store.branchesByProject.has('p2')).toBe(true); + expect(store.reposByProject.has('p2')).toBe(true); + + listProjects.mockResolvedValue(swr([project()])); + await store.ensureLoaded(); + + await vi.waitFor(() => { + expect(store.projects).toHaveLength(1); + }); + expect(store.branchesByProject.has('p2')).toBe(false); + expect(store.reposByProject.has('p2')).toBe(false); + }); + + it('applies the SwrResult revalidating promise when it resolves', async () => { + let resolveFresh!: (value: Project[]) => void; + listProjects.mockResolvedValueOnce( + swr( + [project()], + new Promise((resolve) => { + resolveFresh = resolve; + }) + ) + ); + const store = await importStore(); + + await store.ensureLoaded(); + expect(store.projects).toEqual([project()]); + + resolveFresh([project(), project({ id: 'p2', name: 'Beta' })]); + await vi.waitFor(() => { + expect(store.projects).toHaveLength(2); + }); + }); + + it('surfaces load failures via error and retries on the next call', async () => { + listProjects.mockRejectedValueOnce(new Error('boom')); + const store = await importStore(); + + await store.ensureLoaded(); + expect(store.error).toBe('boom'); + expect(store.loaded).toBe(false); + expect(store.loading).toBe(false); + + await store.ensureLoaded(); + expect(store.error).toBeNull(); + expect(store.loaded).toBe(true); + expect(store.projects).toEqual([project()]); + }); +}); + +describe('hydrateProject', () => { + it('merges refetched branches, preserving worktreePath over a stale null', async () => { + const store = await importStore(); + await store.ensureLoaded(); + expect(store.branchesByProject.get('p1')![0].worktreePath).toBe('/wt/b1'); + + listBranchesForProject.mockResolvedValue( + swr([branch({ worktreePath: null, prState: 'OPEN' })]) + ); + await store.hydrateProject('p1'); + + const hydrated = store.branchesByProject.get('p1')![0]; + expect(hydrated.prState).toBe('OPEN'); + expect(hydrated.worktreePath).toBe('/wt/b1'); + }); + + it('applies the branches SwrResult revalidating promise', async () => { + const store = await importStore(); + await store.ensureLoaded(); + + let resolveFresh!: (value: Branch[]) => void; + listBranchesForProject.mockResolvedValueOnce( + swr( + [branch()], + new Promise((resolve) => { + resolveFresh = resolve; + }) + ) + ); + await store.hydrateProject('p1'); + + resolveFresh([branch({ prState: 'MERGED' })]); + await vi.waitFor(() => { + expect(store.branchesByProject.get('p1')![0].prState).toBe('MERGED'); + }); + }); + + it('discards a stale hydration superseded by a refresh (generation guard)', async () => { + const store = await importStore(); + await store.ensureLoaded(); + + let resolveStale!: (value: SwrResult) => void; + listBranchesForProject.mockReturnValueOnce( + new Promise>((resolve) => { + resolveStale = resolve; + }) + ); + const staleHydration = store.hydrateProject('p1'); + + // refresh() bumps the generation and lands fresh data. + listBranchesForProject.mockResolvedValue(swr([branch({ branchName: 'fresh' })])); + await store.refresh(); + expect(store.branchesByProject.get('p1')![0].branchName).toBe('fresh'); + + // The pre-refresh response resolving late must not clobber it. + resolveStale(swr([branch({ branchName: 'stale' })])); + await staleHydration; + expect(store.branchesByProject.get('p1')![0].branchName).toBe('fresh'); + }); + + it('defers background-priority hydration off the critical path', async () => { + const store = await importStore(); + await store.ensureLoaded(); + listBranchesForProject.mockClear(); + + await store.hydrateProject('p1', { priority: 'background' }); + expect(listBranchesForProject).not.toHaveBeenCalled(); + + await vi.waitFor(() => { + expect(listBranchesForProject).toHaveBeenCalledWith('p1'); + }); + }); +}); + +describe('refresh', () => { + it('reloads the project list and rehydrates eagerly', async () => { + const store = await importStore(); + await store.ensureLoaded(); + + listProjects.mockResolvedValue(swr([project({ name: 'Renamed' })])); + listBranchesForProject.mockResolvedValue(swr([branch({ prState: 'MERGED' })])); + await store.refresh(); + + expect(store.projects[0].name).toBe('Renamed'); + expect(store.branchesByProject.get('p1')![0].prState).toBe('MERGED'); + }); + + it('refetches home repos when they were previously loaded', async () => { + listReposForHome.mockResolvedValue([homeRepo()]); + const store = await importStore(); + await store.ensureLoaded(); + await store.ensureHomeReposLoaded(); + + listReposForHome.mockResolvedValue([homeRepo(), homeRepo({ githubRepo: 'org/beta' })]); + await store.refresh(); + + await vi.waitFor(() => { + expect(store.homeRepos).toHaveLength(2); + }); + }); +}); + +describe('home repos cache', () => { + it('fetches once, dedupes concurrent callers, then serves from memory', async () => { + let resolveRepos!: (value: RepoHomeItem[]) => void; + listReposForHome.mockReturnValueOnce( + new Promise((resolve) => { + resolveRepos = resolve; + }) + ); + const store = await importStore(); + expect(store.homeReposLoaded).toBe(false); + + const first = store.ensureHomeReposLoaded(); + const second = store.ensureHomeReposLoaded(); + resolveRepos([homeRepo()]); + await Promise.all([first, second]); + + expect(listReposForHome).toHaveBeenCalledTimes(1); + expect(store.homeReposLoaded).toBe(true); + expect(store.homeRepos).toEqual([homeRepo()]); + expect(store.homeReposLoading).toBe(false); + + // Later calls resolve instantly and revalidate in the background. + listReposForHome.mockResolvedValue([homeRepo(), homeRepo({ githubRepo: 'org/beta' })]); + await store.ensureHomeReposLoaded(); + await vi.waitFor(() => { + expect(store.homeRepos).toHaveLength(2); + }); + }); +}); + +describe('event listeners', () => { + it('registers listeners only once across repeated startListeners calls', async () => { + const store = await importStore(); + store.startListeners(); + store.startListeners(); + + expect(eventListeners.get('pr-status-changed')).toHaveLength(1); + expect(eventListeners.get('session-status-changed')).toHaveLength(1); + expect(eventListeners.get('project-setup-progress')).toHaveLength(1); + }); + + it('coalesces a pr-status-changed burst into one flush, last event winning', async () => { + const store = await importStore(); + await store.ensureLoaded(); + store.startListeners(); + + emit('pr-status-changed', prEvent({ prState: 'OPEN', prChecksStatus: 'PENDING' })); + emit('pr-status-changed', prEvent({ prState: 'MERGED', prChecksStatus: 'SUCCESS' })); + + // Buffered — nothing applied until the frame flush. + expect(store.branchesByProject.get('p1')![0].prState).toBeNull(); + + await tick(); + const updated = store.branchesByProject.get('p1')![0]; + expect(updated.prState).toBe('MERGED'); + expect(updated.prChecksStatus).toBe('SUCCESS'); + // Untouched fields survive the update. + expect(updated.worktreePath).toBe('/wt/b1'); + }); + + it('refetches a project’s branches when a commit session completes', async () => { + const store = await importStore(); + await store.ensureLoaded(); + store.startListeners(); + listBranchesForProject.mockClear(); + listBranchesForProject.mockResolvedValue(swr([branch({ prState: 'OPEN' })])); + + emit('session-status-changed', { + sessionId: 's1', + status: 'completed', + sessionType: 'commit', + projectId: 'p1', + } satisfies SessionStatusPayload); + + await vi.waitFor(() => { + expect(store.branchesByProject.get('p1')![0].prState).toBe('OPEN'); + }); + expect(listBranchesForProject).toHaveBeenCalledTimes(1); + }); + + it('ignores non-commit sessions and unknown projects', async () => { + const store = await importStore(); + await store.ensureLoaded(); + store.startListeners(); + listBranchesForProject.mockClear(); + + emit('session-status-changed', { + sessionId: 's1', + status: 'completed', + sessionType: 'plan', + projectId: 'p1', + } satisfies SessionStatusPayload); + emit('session-status-changed', { + sessionId: 's2', + status: 'completed', + sessionType: 'commit', + projectId: 'unknown', + } satisfies SessionStatusPayload); + + await tick(); + expect(listBranchesForProject).not.toHaveBeenCalled(); + }); + + it('refreshes the project list and one project on setup progress', async () => { + const store = await importStore(); + await store.ensureLoaded(); + store.startListeners(); + + listProjects.mockResolvedValue(swr([project({ name: 'Renamed' })])); + listBranchesForProject.mockResolvedValue( + swr([branch(), branch({ id: 'b2', worktreePath: null })]) + ); + emit('project-setup-progress', 'p1'); + + await vi.waitFor(() => { + expect(store.branchesByProject.get('p1')).toHaveLength(2); + }); + expect(store.projects[0].name).toBe('Renamed'); + expect(invalidateProjectBranchTimelines).toHaveBeenCalledWith(['b1', 'b2']); + }); + + it('reloads everything on cache-stale', async () => { + const store = await importStore(); + await store.ensureLoaded(); + store.startListeners(); + expect(listProjects).toHaveBeenCalledTimes(1); + + windowTarget.dispatchEvent(new Event('cache-stale')); + + await vi.waitFor(() => { + expect(listProjects).toHaveBeenCalledTimes(2); + }); + }); + + it('refetches home repos when pinned repos change', async () => { + listReposForHome.mockResolvedValue([homeRepo()]); + const store = await importStore(); + store.startListeners(); + await store.ensureHomeReposLoaded(); + + listReposForHome.mockResolvedValue([homeRepo({ pinned: true })]); + windowTarget.dispatchEvent(new Event('staged:pinned-repos-changed')); + + await vi.waitFor(() => { + expect(store.homeRepos[0].pinned).toBe(true); + }); + }); + + it('does not fetch home repos on pin changes before anyone loaded them', async () => { + const store = await importStore(); + store.startListeners(); + + windowTarget.dispatchEvent(new Event('staged:pinned-repos-changed')); + await tick(); + + expect(listReposForHome).not.toHaveBeenCalled(); + }); + + it('stopListeners unregisters everything', async () => { + const store = await importStore(); + await store.ensureLoaded(); + store.startListeners(); + store.stopListeners(); + + expect(eventListeners.get('pr-status-changed')).toHaveLength(0); + windowTarget.dispatchEvent(new Event('cache-stale')); + await tick(); + expect(listProjects).toHaveBeenCalledTimes(1); + }); +}); + +describe('project-delete lifecycle', () => { + it('tracks deleting projects and prunes state when removal completes', async () => { + const store = await importStore(); + await store.ensureLoaded(); + + store.projectDeleteStarted('p1', 'Alpha'); + expect(store.isProjectDeleting('p1')).toBe(true); + expect(store.deletingProjectNames.get('p1')).toBe('Alpha'); + + store.projectDeleteFinished('p1', { removed: true }); + expect(store.isProjectDeleting('p1')).toBe(false); + expect(store.projects).toHaveLength(0); + expect(store.branchesByProject.has('p1')).toBe(false); + expect(store.reposByProject.has('p1')).toBe(false); + }); + + it('keeps the project when a delete fails', async () => { + const store = await importStore(); + await store.ensureLoaded(); + + store.projectDeleteStarted('p1', 'Alpha'); + store.projectDeleteFinished('p1'); + + expect(store.isProjectDeleting('p1')).toBe(false); + expect(store.projects).toHaveLength(1); + expect(store.branchesByProject.has('p1')).toBe(true); + }); +}); + +describe('repoCountsByProject', () => { + it('falls back to 1 for un-hydrated single-repo projects, 0 otherwise', async () => { + let resolveRepos!: (value: SwrResult) => void; + listProjects.mockResolvedValue( + swr([project(), project({ id: 'p2', name: 'Beta', githubRepo: null })]) + ); + listProjectRepos.mockReturnValue( + new Promise>((resolve) => { + resolveRepos = resolve; + }) + ); + const store = await importStore(); + const load = store.ensureLoaded(); + + await vi.waitFor(() => { + expect(store.projects).toHaveLength(2); + }); + expect(store.repoCountsByProject.get('p1')).toBe(1); + expect(store.repoCountsByProject.get('p2')).toBe(0); + + resolveRepos( + swr([projectRepo(), projectRepo({ id: 'r2', githubRepo: 'org/other', subpath: 'pkg' })]) + ); + await load; + expect(store.repoCountsByProject.get('p1')).toBe(2); + }); +}); From 3105bc07625ccba4ae797bcf6d0732ac9ed6b682 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 4 Aug 2026 17:05:21 +1000 Subject: [PATCH 2/6] feat(staged): consume shared projectsData store across project views 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 Signed-off-by: Matt Toohey --- apps/staged/src/App.svelte | 5 + .../lib/features/layout/navigation.svelte.ts | 41 +- .../lib/features/projects/ProjectHome.svelte | 693 ++++-------------- .../lib/features/projects/ProjectsList.svelte | 292 +------- .../features/projects/ProjectsSidebar.svelte | 72 +- .../features/projects/ReposListView.svelte | 29 +- .../projects/projectsSidebarState.svelte.ts | 15 - .../src/lib/stores/projectsData.svelte.ts | 89 ++- .../src/lib/stores/projectsData.test.ts | 70 ++ 9 files changed, 381 insertions(+), 925 deletions(-) diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index b916f4595..e51a56ddf 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -44,6 +44,7 @@ } from './lib/features/keyboard/shortcuts'; import { runSearchShortcut } from './lib/features/keyboard/searchTargets'; import { projectStateStore } from './lib/stores/projectState.svelte'; + import { projectsDataStore } from './lib/stores/projectsData.svelte'; import { initBloxEnv } from './lib/stores/bloxEnv.svelte'; import { listenForSessionStatus } from './lib/listeners/sessionStatusListener'; import { listenForCacheInvalidation } from './lib/listeners/cacheInvalidationListener'; @@ -304,6 +305,9 @@ // Refresh provider discovery (and any loaded doctor report) once the // backend finishes installing/upgrading the managed ACP bridges. unlistenAcpToolsReconciled = listenForAcpToolsReconciled(); + // Keep the shared project-list cache fresh for the app's lifetime — the + // store dedupes, so starting before any view consumes it is safe. + projectsDataStore.startListeners(); try { await initPreferences(); @@ -493,6 +497,7 @@ unlistenCacheInvalidation?.(); unlistenPageLifecycle?.(); unlistenAcpToolsReconciled?.(); + projectsDataStore.stopListeners(); stopUpdaterLoop?.(); }); diff --git a/apps/staged/src/lib/features/layout/navigation.svelte.ts b/apps/staged/src/lib/features/layout/navigation.svelte.ts index d5a70ae41..e4770eaa1 100644 --- a/apps/staged/src/lib/features/layout/navigation.svelte.ts +++ b/apps/staged/src/lib/features/layout/navigation.svelte.ts @@ -15,11 +15,10 @@ import { clearSnapshot, SNAPSHOT_KEYS, } from '../../shared/webSnapshot'; -import * as commands from '../../api/commands'; import type { DiffScope } from '../../commands'; import type { CommitTimelineItem } from '../../types'; import { projectStateStore } from '../../stores/projectState.svelte'; -import { projectsList } from '../projects/projectsSidebarState.svelte'; +import { projectsDataStore } from '../../stores/projectsData.svelte'; import { requestProjectsListRestore } from '../projects/projectsListViewState.svelte'; import { reposUiEnabled } from '../../featureFlags'; @@ -142,6 +141,11 @@ function persistLastProject(projectId: string | null): void { * user is sent to the home screen instead. */ export async function initNavigation(): Promise { + // Kick the shared projects load immediately so the data is warming while we + // read the persisted route — every consumer (sidebar, landing page, this + // validation) shares the one fetch. + const projectsLoad = projectsDataStore.ensureLoaded(); + // `selectedProjectId` may already be set synchronously from the localStorage // mirror (web cold boot). Fall back to the async persistent store otherwise — // it is the source of truth in Tauri mode. Either way we render immediately @@ -152,24 +156,23 @@ export async function initNavigation(): Promise { // Validate the project still exists; this runs in the background relative to // the first paint, which already shows the restored project. - try { - const { data: projects } = await commands.listProjects(); - projectsList.current = projects; - const existingIds = new Set(projects.map((p) => p.id)); - if (existingIds.has(lastProjectId)) { - setDetailStack([rootRoute(), { kind: 'project', projectId: lastProjectId }]); - } else { - // Project was deleted — back out to home and clear the persisted values. - navigation.selectedProjectId = null; - await setStoreValue(LAST_PROJECT_STORE_KEY, null); - clearSnapshot(SNAPSHOT_KEYS.lastProject); - } - // Remove unread entries for projects that no longer exist - await projectStateStore.pruneDeletedProjects(existingIds); - } catch { + await projectsLoad; + if (!projectsDataStore.loaded) { // If we can't list projects (e.g. store error), keep whatever we restored. console.warn('[Navigation] Could not verify last project, keeping restored route'); + return; + } + const existingIds = new Set(projectsDataStore.projects.map((p) => p.id)); + if (existingIds.has(lastProjectId)) { + setDetailStack([rootRoute(), { kind: 'project', projectId: lastProjectId }]); + } else { + // Project was deleted — back out to home and clear the persisted values. + navigation.selectedProjectId = null; + await setStoreValue(LAST_PROJECT_STORE_KEY, null); + clearSnapshot(SNAPSHOT_KEYS.lastProject); } + // Remove unread entries for projects that no longer exist + await projectStateStore.pruneDeletedProjects(existingIds); } /** Navigate to the repos list view. */ @@ -224,7 +227,7 @@ function isModalOpen(): boolean { /** Navigate to the previous project in the list. */ export function selectPreviousProject(): void { if (currentRoute().kind !== 'project' || !navigation.selectedProjectId || isModalOpen()) return; - const projects = projectsList.current; + const projects = projectsDataStore.projects; const currentIndex = projects.findIndex((p) => p.id === navigation.selectedProjectId); if (currentIndex > 0) { selectProject(projects[currentIndex - 1].id); @@ -234,7 +237,7 @@ export function selectPreviousProject(): void { /** Navigate to the next project in the list. */ export function selectNextProject(): void { if (currentRoute().kind !== 'project' || !navigation.selectedProjectId || isModalOpen()) return; - const projects = projectsList.current; + const projects = projectsDataStore.projects; const currentIndex = projects.findIndex((p) => p.id === navigation.selectedProjectId); if (currentIndex >= 0 && currentIndex < projects.length - 1) { selectProject(projects[currentIndex + 1].id); diff --git a/apps/staged/src/lib/features/projects/ProjectHome.svelte b/apps/staged/src/lib/features/projects/ProjectHome.svelte index 4493d2462..5ceceb556 100644 --- a/apps/staged/src/lib/features/projects/ProjectHome.svelte +++ b/apps/staged/src/lib/features/projects/ProjectHome.svelte @@ -12,14 +12,12 @@ import Pause from '@lucide/svelte/icons/pause'; import Plus from '@lucide/svelte/icons/plus'; import Trash2 from '@lucide/svelte/icons/trash-2'; - import { getWindowSync, listenToEvent } from '../../transport'; + import { getWindowSync } from '../../transport'; import type { Project, ProjectRepo, Branch, WorkspaceStatus, - PrStatusChangedEvent, - SessionStatusPayload, StoreIncompatibility, } from '../../types'; import * as commands from '../../api/commands'; @@ -37,58 +35,55 @@ import * as AlertDialog from '$lib/components/ui/alert-dialog'; import { Button } from '$lib/components/ui/button'; import { toast } from 'svelte-sonner'; - import { setProjects } from './projectsSidebarState.svelte'; import { workspaceLifecycle } from './workspaceLifecycle.svelte'; import { projectRunActionsStore } from '../../stores/projectRunActions.svelte'; - import { repoBadgeStore } from '../../stores/repoBadges.svelte'; + import { projectsDataStore } from '../../stores/projectsData.svelte'; import { projectStateStore } from '../../stores/projectState.svelte'; import { canDeleteProjectWithoutConfirmation, computeSafeToDeleteSignature, } from './projectDeleteSafety'; - /** - * Merge incoming branches with existing ones, preserving worktreePath when - * a stale async response would overwrite an already-populated value with null. - */ - function mergeBranchesPreservingWorktree(existing: Branch[], incoming: Branch[]): Branch[] { - return incoming.map((newBranch) => { - const prev = existing.find((b) => b.id === newBranch.id); - if (prev?.worktreePath && !newBranch.worktreePath) { - return { ...newBranch, worktreePath: prev.worktreePath }; - } - return newBranch; - }); - } - interface Props { selectedProjectId?: string | null; } let { selectedProjectId = null }: Props = $props(); - // Data - let projects = $state([]); - let branchesByProject = $state>(new Map()); - let reposById = $state>(new Map()); - - /** Replace all cached repos for a single project with a fresh list. */ - function replaceProjectRepos(projectId: string, repos: ProjectRepo[]) { - const next = new Map(reposById); - for (const [id, repo] of next) { - if (repo.projectId === projectId) next.delete(id); + // Data comes from the shared projectsData store (module-scoped, survives + // route changes); this view only owns UI state and view-lifecycle policy. + let projects = $derived(projectsDataStore.projects); + let branchesByProject = $derived(projectsDataStore.branchesByProject); + let reposByProject = $derived(projectsDataStore.reposByProject); + let repoCountsByProject = $derived(projectsDataStore.repoCountsByProject); + let deletingProjectNames = $derived(projectsDataStore.deletingProjectNames); + + /** Repos keyed by repo id for ProjectSection's branch.projectRepoId lookups. */ + let reposById = $derived.by(() => { + const map = new Map(); + for (const repos of reposByProject.values()) { + for (const repo of repos) map.set(repo.id, repo); } - for (const repo of repos) next.set(repo.id, repo); - reposById = next; - } - let loading = $state(true); - let error = $state(null); - let loadGeneration = 0; - let initialLoadComplete = $state(false); + return map; + }); + + // The store-status check runs before the first ensureLoaded call; hold the + // loading state until it settles so the splash screen doesn't flash. + let storeCheckPending = $state(true); + let loading = $derived(storeCheckPending || projectsDataStore.loading); + // Store-status/reset failures are view-local; load failures come from the store. + let viewError = $state(null); + let error = $derived(viewError ?? projectsDataStore.error); + + let initialLoadComplete = false; let lastSelectedProjectId: string | null = null; - let backgroundHydrationCancel: (() => void) | null = null; + + // Startup queued-session drain: once per branch per mount, batched through + // the idle queue. Branches that become ready later (worktree setup or + // workspace start completing) are drained by workspaceLifecycle itself. let queuedSessionDrainCancel: (() => void) | null = null; - const queuedSessionDrainBranchIds = new Set(); + const drainedSessionBranchIds = new Set(); + const pendingDrainBranchIds = new Set(); // Store health — if non-null the DB needs a reset before we can proceed let storeIncompat = $state(null); @@ -107,7 +102,6 @@ let projectToDelete = $state(null); let branchToDelete = $state<{ branch: Branch; project: Project } | null>(null); let deletingBranches = $state>(new Set()); - let deletingProjectNames = $state>(new Map()); // Guards the delete shortcut while the async safe-to-delete check is in flight, // before projectToDelete/deletingProjectNames are set, so a held key only deletes once. let deleteShortcutPending = $state(false); @@ -120,25 +114,24 @@ let detectingProjectIds = $state>(new Set()); onMount(() => { + // Backend/window listeners for the shared data (pr-status-changed, + // session-status-changed, project-setup-progress, cache-stale) live in + // the projectsData store, started once from App.svelte. workspaceLifecycle.start({ - getBranchesByProject: () => branchesByProject, - setBranchesByProject: (next) => { - branchesByProject = next; - }, - isProjectDeleting: (projectId) => deletingProjectNames.has(projectId), + getBranchesByProject: () => projectsDataStore.branchesByProject, + setBranchesByProject: (next) => projectsDataStore.setBranchesByProject(next), + isProjectDeleting: (projectId) => projectsDataStore.isProjectDeleting(projectId), }); checkStoreAndLoad(); void projectRunActionsStore.startListening(); const onNewProject = () => handleNewProject(); - const onCacheStale = () => loadData(); window.addEventListener('staged:new-project', onNewProject); - window.addEventListener('cache-stale', onCacheStale); const onDeleteCurrentProject = (event: Event) => handleDeleteCurrentProjectShortcut(event); window.addEventListener('staged:delete-current-project', onDeleteCurrentProject); const unlistenDetection = listenToRepoActionsDetection((event) => { - const matchingProjectIds = projects + const matchingProjectIds = projectsDataStore.projects .filter((p) => p.githubRepo === event.githubRepo && p.subpath === event.subpath) .map((p) => p.id); if (matchingProjectIds.length === 0) return; @@ -154,142 +147,10 @@ detectingProjectIds = next; }); - // Listen for backend-driven setup progress events. The backend emits this - // after repo creation, after worktree setup, and after prerun actions. - // We only refresh display state here — setup itself is owned by the backend. - const unlistenProjectRepoAdded = listenToEvent( - 'project-setup-progress', - async (projectId) => { - console.log('[ProjectHome] project-setup-progress event for project', projectId); - try { - const [projectsList, branches, repos] = await Promise.all([ - commands.listProjects(), - commands.listBranchesForProject(projectId), - commands.listProjectRepos(projectId), - ]); - setProjects(projectsList.data); - projects = projectsList.data; - const mergedBranches = mergeBranchesPreservingWorktree( - branchesByProject.get(projectId) || [], - branches.data - ); - branchesByProject = new Map(branchesByProject).set(projectId, mergedBranches); - commands.invalidateProjectBranchTimelines(mergedBranches.map((b) => b.id)); - workspaceLifecycle.enqueueInitialSetup(projectId, mergedBranches); - replaceProjectRepos(projectId, repos.data); - void repoBadgeStore.ensureForRepos( - repos.data.map((r) => ({ githubRepo: r.githubRepo, subpath: r.subpath })) - ); - } catch (e) { - console.error('[ProjectHome] Failed to refresh project after setup progress:', e); - } - } - ); - - // Listen for PR status changes to update branch state. - // - // A PR-polling cycle emits one `pr-status-changed` per branch, so a storm - // of N branches arrives as N separate events. Rebuilding `branchesByProject` - // with a fresh `new Map(...)` per event means N allocations + N derivation - // re-runs, which can pile up on the main thread during a project switch. - // Buffer the events and apply a single rebuild per frame so a burst - // coalesces into one reactive flush without dropping any update. - let pendingPrStatusEvents: PrStatusChangedEvent[] = []; - let prStatusFlushHandle: number | null = null; - - const flushPrStatusEvents = () => { - prStatusFlushHandle = null; - if (pendingPrStatusEvents.length === 0) return; - const events = pendingPrStatusEvents; - pendingPrStatusEvents = []; - - // Apply every buffered event onto one fresh Map. Each event re-scans the - // in-progress map, so multiple updates to the same project compound - // correctly instead of clobbering one another. - const next = new Map(branchesByProject); - for (const payload of events) { - for (const [projectId, branches] of next) { - const branchIndex = branches.findIndex((b) => b.id === payload.branchId); - if (branchIndex !== -1) { - const existing = branches[branchIndex]; - // A live `prState` flip (e.g. OPEN → MERGED) is a genuine, user- - // visible transition rather than a poller re-confirming the same - // value. Flag it so the safe-to-delete effect recomputes promptly - // instead of waiting out its idle window (see prStateTransitionPending). - if (existing.prState !== payload.prState) { - prStateTransitionPending = true; - } - const updatedBranches = [...branches]; - updatedBranches[branchIndex] = { - ...existing, - prState: payload.prState, - prChecksStatus: payload.prChecksStatus, - prReviewDecision: payload.prReviewDecision, - prMergeable: payload.prMergeable, - prDraft: payload.prDraft, - prHeadSha: payload.prHeadSha, - prFetchedAt: payload.prFetchedAt, - }; - next.set(projectId, updatedBranches); - break; - } - } - } - branchesByProject = next; - }; - - const unlistenPrStatus = listenToEvent('pr-status-changed', (payload) => { - pendingPrStatusEvents.push(payload); - if (prStatusFlushHandle === null) { - prStatusFlushHandle = requestAnimationFrame(flushPrStatusEvents); - } - }); - - // Refresh a project's branches when a commit session completes so the - // sprout/draft-PR icon flips as soon as the first commit lands. - const unlistenSessionStatus = listenToEvent( - 'session-status-changed', - async (payload) => { - if (payload.status !== 'completed') return; - if (payload.sessionType !== 'commit') return; - const projectId = payload.projectId; - if (!projectId || !branchesByProject.has(projectId)) return; - try { - const { data: branches, revalidating } = await commands.listBranchesForProject(projectId); - branchesByProject = new Map(branchesByProject).set(projectId, branches); - if (revalidating) { - revalidating - .then((fresh) => { - branchesByProject = new Map(branchesByProject).set(projectId, fresh); - }) - .catch((e) => { - console.error( - `Failed to revalidate branches for project ${projectId} after commit:`, - e - ); - }); - } - } catch (e) { - console.error(`Failed to refresh branches for project ${projectId} after commit:`, e); - } - } - ); - return () => { - loadGeneration++; window.removeEventListener('staged:new-project', onNewProject); - window.removeEventListener('cache-stale', onCacheStale); window.removeEventListener('staged:delete-current-project', onDeleteCurrentProject); unlistenDetection(); - unlistenProjectRepoAdded(); - unlistenPrStatus(); - if (prStatusFlushHandle !== null) { - cancelAnimationFrame(prStatusFlushHandle); - prStatusFlushHandle = null; - } - pendingPrStatusEvents = []; - unlistenSessionStatus(); - cancelBackgroundHydration(); cancelQueuedSessionDrain(); workspaceLifecycle.stop(); projectRunActionsStore.stopListening(); @@ -297,18 +158,29 @@ }); async function checkStoreAndLoad() { - loading = true; try { const status = await commands.getStoreStatus(); if (status) { storeIncompat = status; - loading = false; return; } - await loadData(); + // On a revisit the store resolves instantly from memory and revalidates + // in the background; foreground-refresh the selected project only when + // this call didn't just perform the full eager load. + const wasLoaded = projectsDataStore.loaded; + const load = projectsDataStore.ensureLoaded(); + storeCheckPending = false; + await load; + lastSelectedProjectId = selectedProjectId; + initialLoadComplete = true; + if (selectedProjectId && wasLoaded) { + void projectsDataStore.hydrateProject(selectedProjectId); + } + void hydrateActionDetection(); } catch (e) { - error = e instanceof Error ? e.message : String(e); - loading = false; + viewError = e instanceof Error ? e.message : String(e); + } finally { + storeCheckPending = false; } } @@ -317,9 +189,10 @@ try { await commands.confirmResetStore(); storeIncompat = null; - await loadData(); + viewError = null; + await projectsDataStore.refresh(); } catch (e) { - error = e instanceof Error ? e.message : String(e); + viewError = e instanceof Error ? e.message : String(e); } finally { resetting = false; } @@ -343,32 +216,31 @@ return () => cancel(handle); } - function cancelBackgroundHydration() { - backgroundHydrationCancel?.(); - backgroundHydrationCancel = null; - } - function cancelQueuedSessionDrain() { queuedSessionDrainCancel?.(); queuedSessionDrainCancel = null; - queuedSessionDrainBranchIds.clear(); + pendingDrainBranchIds.clear(); } - function scheduleQueuedSessionDrain(branches: Branch[]) { - for (const branch of branches) { - const isLocalReady = branch.branchType === 'local' && branch.worktreePath; - const isRemoteReady = branch.branchType === 'remote' && branch.workspaceStatus === 'running'; - if (isLocalReady || isRemoteReady) { - queuedSessionDrainBranchIds.add(branch.id); + function scheduleQueuedSessionDrain(branchMap: Map) { + for (const branches of branchMap.values()) { + for (const branch of branches) { + const isLocalReady = branch.branchType === 'local' && branch.worktreePath; + const isRemoteReady = + branch.branchType === 'remote' && branch.workspaceStatus === 'running'; + if ((isLocalReady || isRemoteReady) && !drainedSessionBranchIds.has(branch.id)) { + drainedSessionBranchIds.add(branch.id); + pendingDrainBranchIds.add(branch.id); + } } } - if (queuedSessionDrainBranchIds.size === 0 || queuedSessionDrainCancel) return; + if (pendingDrainBranchIds.size === 0 || queuedSessionDrainCancel) return; queuedSessionDrainCancel = scheduleDeferredTask(() => { queuedSessionDrainCancel = null; - const branchIds = Array.from(queuedSessionDrainBranchIds); - queuedSessionDrainBranchIds.clear(); + const branchIds = Array.from(pendingDrainBranchIds); + pendingDrainBranchIds.clear(); for (const branchId of branchIds) { commands.drainQueuedSessions(branchId).catch((e) => { console.error('[ProjectHome] Failed to drain queued sessions on startup:', e); @@ -377,135 +249,31 @@ }, 3000); } - function applyProjectBranches( - projectId: string, - branches: Branch[], - generation: number, - options: { drainQueuedSessions?: boolean } = {} - ): Branch[] | null { - if (generation !== loadGeneration) return null; - - const mergedBranches = mergeBranchesPreservingWorktree( - branchesByProject.get(projectId) || [], - branches - ); - branchesByProject = new Map(branchesByProject).set(projectId, mergedBranches); - workspaceLifecycle.enqueueInitialSetup(projectId, mergedBranches); - projectRunActionsStore - .hydrateFromProjectBranches(branchesByProject, { - branchIds: mergedBranches.map((b) => b.id), - }) - .catch(console.error); - - if (options.drainQueuedSessions) { - scheduleQueuedSessionDrain(mergedBranches); - } - - return mergedBranches; - } - - function applyProjectRepos(projectId: string, repos: ProjectRepo[], generation: number) { - if (generation !== loadGeneration) return; - replaceProjectRepos(projectId, repos); - void repoBadgeStore.ensureForRepos( - repos.map((r) => ({ githubRepo: r.githubRepo, subpath: r.subpath })) - ); - } - - async function hydrateProject( - project: Project, - generation: number, - options: { drainQueuedSessions?: boolean } = {} - ): Promise { - const [branchesResult, reposResult] = await Promise.all([ - commands.listBranchesForProject(project.id), - commands.listProjectRepos(project.id), - ]); - if (generation !== loadGeneration) return null; - - const mergedBranches = applyProjectBranches(project.id, branchesResult.data, generation, { - drainQueuedSessions: options.drainQueuedSessions, - }); - applyProjectRepos(project.id, reposResult.data, generation); - - if (branchesResult.revalidating) { - branchesResult.revalidating - .then((fresh) => { - applyProjectBranches(project.id, fresh, generation, { - drainQueuedSessions: options.drainQueuedSessions, - }); - }) - .catch((e) => { - console.error(`[ProjectHome] Failed to revalidate branches for '${project.id}':`, e); - }); - } - - if (reposResult.revalidating) { - reposResult.revalidating - .then((fresh) => applyProjectRepos(project.id, fresh, generation)) - .catch((e) => { - console.error(`[ProjectHome] Failed to revalidate repos for '${project.id}':`, e); - }); + // View-lifecycle side effects wired off the store's branch data — worktree/ + // workspace setup, the startup queued-session drain, and run-action + // hydration. All three dedupe internally, so re-running on every branch map + // reassignment is cheap. They stay out of the store deliberately: they are + // this view's policies, not properties of the data. + $effect(() => { + const branchMap = projectsDataStore.branchesByProject; + for (const [projectId, branches] of branchMap) { + if (branches.length > 0) { + workspaceLifecycle.enqueueInitialSetup(projectId, branches); + } } + scheduleQueuedSessionDrain(branchMap); + projectRunActionsStore.hydrateFromProjectBranches(branchMap).catch(console.error); + }); - return mergedBranches; - } - - async function hydrateAllProjects(projectList: Project[], generation: number) { - await Promise.all( - projectList.map(async (project) => { - try { - await hydrateProject(project, generation, { drainQueuedSessions: true }); - } catch (e) { - console.error(`[ProjectHome] Failed to hydrate project '${project.id}':`, e); - } - }) - ); - } - - function scheduleBackgroundHydration( - projectList: Project[], - foregroundProjectId: string | null, - generation: number - ) { - cancelBackgroundHydration(); - - const queue = projectList.filter((project) => project.id !== foregroundProjectId); - if (queue.length === 0) return; - - let cancelled = false; - let cancelScheduledTask: (() => void) | null = null; - - const hydrateNext = () => { - cancelScheduledTask = null; - if (cancelled || generation !== loadGeneration) return; - - const project = queue.shift(); - if (!project) return; - - hydrateProject(project, generation, { drainQueuedSessions: true }) - .catch((e) => { - console.error(`[ProjectHome] Failed to background hydrate project '${project.id}':`, e); - }) - .finally(() => { - if (cancelled || generation !== loadGeneration || queue.length === 0) return; - cancelScheduledTask = scheduleDeferredTask(hydrateNext, 3000); - }); - }; - - cancelScheduledTask = scheduleDeferredTask(hydrateNext, 3000); - backgroundHydrationCancel = () => { - cancelled = true; - cancelScheduledTask?.(); - }; - } + let actionDetectionToken = 0; - async function hydrateActionDetection(projectList: Project[], generation: number) { + async function hydrateActionDetection() { + const token = ++actionDetectionToken; try { const contexts = await commands.listActionContexts(); - if (generation !== loadGeneration) return; + if (token !== actionDetectionToken) return; detectingProjectIds = new Set( - projectList + projectsDataStore.projects .filter((project) => contexts.some( (context) => @@ -521,122 +289,18 @@ } } - async function hydrateForCurrentSelection(projectId: string | null) { - const generation = ++loadGeneration; - cancelBackgroundHydration(); - error = null; - - const projectList = projects; - if (projectId) { - const project = projectList.find((p) => p.id === projectId); - if (!project) return; - try { - await hydrateProject(project, generation, { drainQueuedSessions: true }); - } catch (e) { - if (generation !== loadGeneration) return; - console.error(`[ProjectHome] Failed to hydrate selected project '${project.id}':`, e); - } - if (generation !== loadGeneration) return; - scheduleBackgroundHydration(projectList, projectId, generation); - } else { - await hydrateAllProjects(projectList, generation); - } - - void hydrateActionDetection(projectList, generation); - } - - async function loadData() { - const generation = ++loadGeneration; - initialLoadComplete = false; - cancelBackgroundHydration(); - if (projects.length === 0) { - loading = true; - } - error = null; - await repoBadgeStore.loadAll(); - try { - const { data: initialProjectList, revalidating: projectsRevalidating } = - await commands.listProjects(); - if (generation !== loadGeneration) return; - await applyProjectList(initialProjectList, generation); - loading = false; - - if (projectsRevalidating) { - try { - const fresh = await projectsRevalidating; - if (generation !== loadGeneration) return; - await applyProjectList(fresh, generation); - } catch (e) { - console.error('[ProjectHome] Failed to revalidate project list:', e); - } - } - } catch (e) { - if (generation !== loadGeneration) return; - error = e instanceof Error ? e.message : String(e); - } finally { - if (generation === loadGeneration) { - loading = false; - } - } - } - - /** - * Apply a list of projects fetched from the backend: seed branch/repo maps, - * hydrate the selected project first, and background-hydrate the rest. - * Called once with cached data and again if SWR revalidation yields fresh data. - */ - async function applyProjectList(projectList: Project[], generation: number) { - projects = projectList; - setProjects(projectList); - - // Seed maps so project sections can render immediately. - const branchMap = new Map(); - for (const project of projectList) { - branchMap.set(project.id, branchesByProject.get(project.id) || []); - } - branchesByProject = branchMap; - - // Drop cached repos for projects that no longer exist. - const projectIds = new Set(projectList.map((p) => p.id)); - const prunedRepos = new Map(); - for (const [id, repo] of reposById) { - if (projectIds.has(repo.projectId)) prunedRepos.set(id, repo); - } - reposById = prunedRepos; - - cancelBackgroundHydration(); - - if (selectedProjectId) { - const selectedProject = projectList.find((project) => project.id === selectedProjectId); - if (selectedProject) { - try { - await hydrateProject(selectedProject, generation, { drainQueuedSessions: true }); - } catch (e) { - if (generation !== loadGeneration) return; - console.error( - `[ProjectHome] Failed to hydrate selected project '${selectedProject.id}':`, - e - ); - } - } - if (generation !== loadGeneration) return; - scheduleBackgroundHydration(projectList, selectedProjectId, generation); - } else { - await hydrateAllProjects(projectList, generation); - } - - if (generation !== loadGeneration) return; - lastSelectedProjectId = selectedProjectId; - initialLoadComplete = true; - void hydrateActionDetection(projectList, generation); - } - $effect(() => { const projectId = selectedProjectId; if (!initialLoadComplete) return; if (projectId === lastSelectedProjectId) return; lastSelectedProjectId = projectId; - void hydrateForCurrentSelection(projectId); + // Foreground-refresh the newly selected project; ensureLoaded's + // background revalidation drips the rest through the idle queue. + void projectsDataStore.ensureLoaded(); + if (projectId) { + void projectsDataStore.hydrateProject(projectId); + } + void hydrateActionDetection(); }); let visibleProjects = $derived( @@ -684,18 +348,6 @@ let selectedProjectDetecting = $derived( selectedProject ? detectingProjectIds.has(selectedProject.id) : false ); - let repoCountsByProject = $derived( - new Map( - projects.map((project) => { - let knownCount = 0; - for (const repo of reposById.values()) { - if (repo.projectId === project.id) knownCount++; - } - const fallbackCount = project.githubRepo ? 1 : 0; - return [project.id, knownCount > 0 ? knownCount : fallbackCount] as const; - }) - ) - ); // Track which projects are safe to delete (for button styling) let safeToDeleteProjects = $state>(new Set()); let selectedProjectSafeToDelete = $derived( @@ -711,23 +363,12 @@ let selectedProjectExcludeRepos = $derived( selectedProject ? new Set( - [...reposById.values()] - .filter((repo) => repo.projectId === selectedProject.id) - .map((repo) => `${repo.githubRepo}\x00${repo.subpath ?? ''}`) + (reposByProject.get(selectedProject.id) ?? []).map( + (repo) => `${repo.githubRepo}\x00${repo.subpath ?? ''}` + ) ) : new Set() ); - let reposByProject = $derived( - new Map( - projects.map((project) => { - const repos: ProjectRepo[] = []; - for (const repo of reposById.values()) { - if (repo.projectId === project.id) repos.push(repo); - } - return [project.id, repos] as const; - }) - ) - ); // Update safe-to-delete status when branches change. // Only check visible projects — calling hasUnpushedCommits for every @@ -739,19 +380,35 @@ // depends on are unchanged; deduping on the signature keeps the expensive // per-branch git work from re-firing on every reassignment. let lastSafeSignature: string | null = null; - // Set by `flushPrStatusEvents` when a buffered `pr-status-changed` actually - // flips a branch's `prState` (e.g. → MERGED). A live transition while parked - // on a project is not a switch-time hydration storm, so the recompute below - // takes a prompt (next-tick) path instead of the idle window — keeping the - // delete button in step with the branch card's badge. Consumed (reset) by the - // effect once it acts on it. + // Set when a branch's `prState` actually flips (e.g. OPEN → MERGED) between + // runs — with the pr-status-changed listener living in the projectsData + // store, the transition is detected here by diffing against the previous + // run. A live transition while parked on a project is not a switch-time + // hydration storm, so the recompute below takes a prompt (next-tick) path + // instead of the idle window — keeping the delete button in step with the + // branch card's badge. Consumed (reset) by the effect once it acts on it. let prStateTransitionPending = false; + let lastKnownPrStates = new Map(); $effect(() => { // Read reactive deps synchronously so the effect re-subscribes correctly. const projectsSnapshot = visibleProjects; const branches = branchesByProject; const repoCounts = repoCountsByProject; + // Diff prState across all branches (not just visible ones) so a flip + // elsewhere still arms the fast path for the next genuine recompute. + const nextPrStates = new Map(); + for (const branchList of branches.values()) { + for (const b of branchList) { + nextPrStates.set(b.id, b.prState); + const previous = lastKnownPrStates.get(b.id); + if (previous !== undefined && previous !== b.prState) { + prStateTransitionPending = true; + } + } + } + lastKnownPrStates = nextPrStates; + const signature = computeSafeToDeleteSignature(projectsSnapshot, branches, repoCounts); if (signature === lastSafeSignature) { // Inputs relevant to the result are unchanged — skip the git work. @@ -851,24 +508,12 @@ projectStateStore.markAsUnread(project.id); } - async function handleProjectCreated(project: Project) { - if (!projects.some((p) => p.id === project.id)) { - projects = [...projects, project]; - } + function handleProjectCreated(project: Project) { + // The store registers the project synchronously and hydrates branches and + // repos in the background, so the modal closes instantly. + projectsDataStore.projectCreated(project); showNewProjectModal = false; selectProject(project.id); - // Hydrate branches and repos in the background so the modal closes instantly - try { - const [branches, repos] = await Promise.all([ - commands.listBranchesForProject(project.id), - commands.listProjectRepos(project.id), - ]); - branchesByProject = new Map(branchesByProject).set(project.id, branches.data); - workspaceLifecycle.enqueueInitialSetup(project.id, branches.data); - replaceProjectRepos(project.id, repos.data); - } catch (e) { - console.error('[ProjectHome] Failed to hydrate newly created project:', e); - } } async function handleDeleteProjectRequest(project: Project) { @@ -918,17 +563,12 @@ const name = projectDisplayName(projectToDelete); const branchesToClear = branchesByProject.get(id) || []; projectToDelete = null; - deletingProjectNames = new Map(deletingProjectNames).set(id, name); - window.dispatchEvent( - new CustomEvent('staged:project-delete-start', { - detail: { projectId: id, name }, - }) - ); + projectsDataStore.projectDeleteStarted(id, name); // Navigate away immediately so the user doesn't have to wait for backend deletion. // Skip projects that are already being deleted. const currentIndex = projects.findIndex((p) => p.id === id); - const alive = projects.filter((p) => p.id !== id && !deletingProjectNames.has(p.id)); + const alive = projects.filter((p) => p.id !== id && !projectsDataStore.isProjectDeleting(p.id)); if (alive.length > 0) { // Prefer the next project after the current one; fall back to the closest earlier one const next = alive.find((p) => projects.indexOf(p) > currentIndex) ?? alive[alive.length - 1]; @@ -940,16 +580,7 @@ try { await commands.deleteProject(id); projectStateStore.markAsRead(id); - projects = projects.filter((p) => p.id !== id); - setProjects(projects); - const nextBranches = new Map(branchesByProject); - nextBranches.delete(id); - branchesByProject = nextBranches; - const nextRepos = new Map(reposById); - for (const [repoId, repo] of nextRepos) { - if (repo.projectId === id) nextRepos.delete(repoId); - } - reposById = nextRepos; + projectsDataStore.projectDeleteFinished(id, { removed: true }); commands.invalidateProjectBranchTimelines(branchesToClear.map((b) => b.id)); for (const branch of branchesToClear) { workspaceLifecycle.clearBranchState(branch.id); @@ -958,15 +589,7 @@ console.error('Failed to delete project:', e); const message = e instanceof Error ? e.message : String(e); toast.error('Unable to delete project', { description: message }); - } finally { - const next = new Map(deletingProjectNames); - next.delete(id); - deletingProjectNames = next; - window.dispatchEvent( - new CustomEvent('staged:project-delete-end', { - detail: { projectId: id }, - }) - ); + projectsDataStore.projectDeleteFinished(id); } } @@ -988,24 +611,7 @@ const noteTitle = `PR #${selection.prNumber}: ${selection.prTitle}`; await commands.createProjectNote(projectId, noteTitle, selection.prBody ?? ''); } - const [projectsList, branches, repos] = await Promise.all([ - commands.listProjects(), - commands.listBranchesForProject(projectId), - commands.listProjectRepos(projectId), - ]); - setProjects(projectsList.data); - projects = projectsList.data; - const mergedBranches = mergeBranchesPreservingWorktree( - branchesByProject.get(projectId) || [], - branches.data - ); - branchesByProject = new Map(branchesByProject).set(projectId, mergedBranches); - commands.invalidateProjectBranchTimelines(mergedBranches.map((b) => b.id)); - workspaceLifecycle.enqueueInitialSetup(projectId, mergedBranches); - replaceProjectRepos(projectId, repos.data); - void repoBadgeStore.ensureForRepos( - repos.data.map((r) => ({ githubRepo: r.githubRepo, subpath: r.subpath })) - ); + await projectsDataStore.refreshProject(projectId); } catch (e) { console.error('Failed to add repo:', e); const message = e instanceof Error ? e.message : String(e); @@ -1103,22 +709,16 @@ try { if (branch.projectRepoId) { await commands.removeProjectRepo(branch.projectId, branch.projectRepoId); - const [projectsList, branches, repos] = await Promise.all([ - commands.listProjects(), - commands.listBranchesForProject(branch.projectId), - commands.listProjectRepos(branch.projectId), - ]); - setProjects(projectsList.data); - projects = projectsList.data; - branchesByProject = new Map(branchesByProject).set(branch.projectId, branches.data); - replaceProjectRepos(branch.projectId, repos.data); + await projectsDataStore.refreshProject(branch.projectId); } else { await commands.deleteBranch(branch.id); // Fallback for legacy branches without repo linkage const existing = branchesByProject.get(branch.projectId) || []; - branchesByProject = new Map(branchesByProject).set( - branch.projectId, - existing.filter((b) => b.id !== branch.id) + projectsDataStore.setBranchesByProject( + new Map(branchesByProject).set( + branch.projectId, + existing.filter((b) => b.id !== branch.id) + ) ); } commands.invalidateBranchTimeline(branch.id); @@ -1136,9 +736,11 @@ try { const updated = await commands.renameBranch(branchId, branchName); const existing = branchesByProject.get(projectId) || []; - branchesByProject = new Map(branchesByProject).set( - projectId, - existing.map((b) => (b.id === updated.id ? updated : b)) + projectsDataStore.setBranchesByProject( + new Map(branchesByProject).set( + projectId, + existing.map((b) => (b.id === updated.id ? updated : b)) + ) ); } catch (e) { console.error('Failed to rename branch:', e); @@ -1262,13 +864,6 @@
import { onMount, tick } from 'svelte'; import { fade } from 'svelte/transition'; - import { listenToEvent } from '../../transport'; import Cloud from '@lucide/svelte/icons/cloud'; import GitPullRequest from '@lucide/svelte/icons/git-pull-request'; import GitPullRequestClosed from '@lucide/svelte/icons/git-pull-request-closed'; @@ -16,15 +15,7 @@ import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal'; import Sprout from '@lucide/svelte/icons/sprout'; import Trash2 from '@lucide/svelte/icons/trash-2'; - import type { - Project, - ProjectRepo, - Branch, - PrStatusChangedEvent, - SessionStatusPayload, - WorkspaceStatus, - RepoHomeItem, - } from '../../types'; + import type { Project, WorkspaceStatus, RepoHomeItem } from '../../types'; import * as commands from '../../api/commands'; import RepoCard from './RepoCard.svelte'; import { @@ -35,6 +26,7 @@ } from '../../shared/utils'; import { projectStateStore } from '../../stores/projectState.svelte'; import { projectRunActionsStore } from '../../stores/projectRunActions.svelte'; + import { projectsDataStore } from '../../stores/projectsData.svelte'; import { openSettings, selectProject, showAllRepos } from '../layout/navigation.svelte'; import NewProjectModal from './NewProjectModal.svelte'; import { getProjectStatus } from './projectStatus'; @@ -47,7 +39,6 @@ import * as AlertDialog from '$lib/components/ui/alert-dialog'; import { Button } from '$lib/components/ui/button'; - import { setProjects } from './projectsSidebarState.svelte'; import { finishProjectsListRestore, projectsListViewState, @@ -63,35 +54,27 @@ type FilterKind = 'unread' | 'running' | { repo: string; subpath: string }; - let projects = $state([]); - let projectBranches = $state>(new Map()); - let loading = $state(true); - let error = $state(null); + // Data comes from the shared projectsData store — returning to the landing + // page paints instantly from memory while the store revalidates in the + // background. Filters, scroll restore, and modal state stay view-local. + let projects = $derived(projectsDataStore.projects); + let projectBranches = $derived(projectsDataStore.branchesByProject); + let reposByProject = $derived(projectsDataStore.reposByProject); + let repoCountsByProject = $derived(projectsDataStore.repoCountsByProject); + let deletingProjectNames = $derived(projectsDataStore.deletingProjectNames); + let homeRepos = $derived(projectsDataStore.homeRepos); + let loading = $derived(projectsDataStore.loading || !projectsDataStore.loaded); + let error = $derived(projectsDataStore.error); + let showNewProjectModal = $state(false); let isCommandKeyHeld = $state(false); - let deletingProjectNames = $state>(new Map()); let projectToDelete = $state(null); - let reposByProject = $state>(new Map()); - let reposHydrating = $state(false); let mainPanelEl = $state(null); - let repoLoadGeneration = 0; let activeFilters = $state>(new Set()); let restoreInProgress = false; let restoreToken = 0; const projectCardElements = new Map(); - let homeRepos = $state([]); - let homeReposLoading = $state(false); - - let repoCountsByProject = $derived( - new Map( - projects.map((p) => { - const repos = reposByProject.get(p.id); - return [p.id, repos ? repos.length : p.githubRepo ? 1 : 0] as const; - }) - ) - ); - /** Unique repo+subpath entries sorted alphabetically by full display string */ let repoFilters = $derived.by(() => { const counts = new Map(); @@ -265,7 +248,6 @@ projectsListViewState.restorePending && !restoreInProgress && !loading && - !reposHydrating && !error && filteredProjects.length > 0 && mainPanelEl; @@ -275,137 +257,37 @@ }); onMount(() => { - loadProjects(); + // Backend/window listeners for the shared data live in the projectsData + // store, started once from App.svelte. + void projectsDataStore.ensureLoaded(); + if (reposUiEnabled) { + void projectsDataStore.ensureHomeReposLoaded(); + } void projectRunActionsStore.startListening(); const onNewProject = () => { showNewProjectModal = true; }; - const onProjectDeleteStart = (event: Event) => { - const detail = (event as CustomEvent<{ projectId?: string; name?: string }>).detail; - const projectId = detail?.projectId; - if (!projectId) return; - const name = - detail?.name ?? projects.find((project) => project.id === projectId)?.name ?? 'Project'; - deletingProjectNames = new Map(deletingProjectNames).set(projectId, name); - }; - const onProjectDeleteEnd = (event: Event) => { - const detail = (event as CustomEvent<{ projectId?: string }>).detail; - const projectId = detail?.projectId; - if (!projectId) return; - const next = new Map(deletingProjectNames); - next.delete(projectId); - deletingProjectNames = next; - loadProjects(); - }; - const onCacheStale = () => loadProjects(); window.addEventListener('staged:new-project', onNewProject); - window.addEventListener('staged:project-delete-start', onProjectDeleteStart); - window.addEventListener('staged:project-delete-end', onProjectDeleteEnd); - window.addEventListener('cache-stale', onCacheStale); - - // Listen for PR status changes to update branch state - const unlistenPrStatus = listenToEvent('pr-status-changed', (payload) => { - // Find the project that contains this branch - for (const [projectId, branches] of projectBranches.entries()) { - const branchIndex = branches.findIndex((b) => b.id === payload.branchId); - if (branchIndex !== -1) { - // Update the branch with new PR status - const updatedBranches = [...branches]; - updatedBranches[branchIndex] = { - ...updatedBranches[branchIndex], - prState: payload.prState, - prChecksStatus: payload.prChecksStatus, - prReviewDecision: payload.prReviewDecision, - prMergeable: payload.prMergeable, - prDraft: payload.prDraft, - prHeadSha: payload.prHeadSha, - prFetchedAt: payload.prFetchedAt, - }; - projectBranches = new Map(projectBranches).set(projectId, updatedBranches); - break; - } - } - }); - - // Refresh a project's branches when a commit session completes so the - // sprout/draft-PR icon flips as soon as the first commit lands. - const unlistenSessionStatus = listenToEvent( - 'session-status-changed', - async (payload) => { - if (payload.status !== 'completed') return; - if (payload.sessionType !== 'commit') return; - const projectId = payload.projectId; - if (!projectId || !projectBranches.has(projectId)) return; - try { - const { data: branches, revalidating } = await commands.listBranchesForProject(projectId); - projectBranches = new Map(projectBranches).set(projectId, branches); - if (revalidating) { - revalidating - .then((fresh) => { - projectBranches = new Map(projectBranches).set(projectId, fresh); - }) - .catch((e) => { - console.error( - `Failed to revalidate branches for project ${projectId} after commit:`, - e - ); - }); - } - } catch (e) { - console.error(`Failed to refresh branches for project ${projectId} after commit:`, e); - } - } - ); return () => { projectRunActionsStore.stopListening(); window.removeEventListener('staged:new-project', onNewProject); - window.removeEventListener('staged:project-delete-start', onProjectDeleteStart); - window.removeEventListener('staged:project-delete-end', onProjectDeleteEnd); - window.removeEventListener('cache-stale', onCacheStale); - unlistenPrStatus(); - unlistenSessionStatus(); }; }); - async function loadProjects() { - loading = true; - error = null; - try { - await repoBadgeStore.loadAll(); - if (reposUiEnabled) void loadHomeRepos(); - const { data: initialProjects, revalidating: projectsRevalidating } = - await commands.listProjects(); - await applyProjects(initialProjects); - loading = false; - - if (projectsRevalidating) { - const fresh = await projectsRevalidating; - await applyProjects(fresh); - } - } catch (e) { - error = e instanceof Error ? e.message : String(e); - } finally { - loading = false; - } - } - - async function loadHomeRepos() { - homeReposLoading = true; - try { - homeRepos = await commands.listReposForHome(); - } catch (e) { - console.error('[ProjectsList] Failed to load home repos:', e); - } finally { - homeReposLoading = false; - } - } + // Keep run-action state hydrated for the status badges; the store call + // dedupes branches it has already queried. + $effect(() => { + projectRunActionsStore + .hydrateFromProjectBranches(projectsDataStore.branchesByProject) + .catch(console.error); + }); async function handleCloneRepo(repo: RepoHomeItem) { try { await commands.cloneRepoLocally(repo.githubRepo); - await loadHomeRepos(); + await projectsDataStore.refreshHomeRepos(); } catch (e) { console.error('[ProjectsList] Failed to clone repo:', e); const message = e instanceof Error ? e.message : String(e); @@ -413,107 +295,14 @@ } } - async function applyProjects(loadedProjects: Project[]) { - projects = loadedProjects; - setProjects(loadedProjects); - void hydrateRepos(loadedProjects); - - const branchesMap = new Map(); - const branchRevalidations: Array<{ projectId: string; promise: Promise }> = []; - await Promise.all( - loadedProjects.map(async (project) => { - try { - const { data: branches, revalidating } = await commands.listBranchesForProject( - project.id - ); - branchesMap.set(project.id, branches); - if (revalidating) { - branchRevalidations.push({ projectId: project.id, promise: revalidating }); - } - } catch (e) { - console.error(`Failed to load branches for project ${project.id}:`, e); - branchesMap.set(project.id, []); - } - }) - ); - projectBranches = branchesMap; - projectRunActionsStore.hydrateFromProjectBranches(branchesMap).catch(console.error); - - if (branchRevalidations.length > 0) { - void Promise.all( - branchRevalidations.map(async ({ projectId, promise }) => { - try { - const fresh = await promise; - projectBranches = new Map(projectBranches).set(projectId, fresh); - } catch (e) { - console.error(`Failed to revalidate branches for project ${projectId}:`, e); - } - }) - ).then(() => - projectRunActionsStore.hydrateFromProjectBranches(projectBranches).catch(console.error) - ); - } - } - - async function hydrateRepos(projectList: Project[]) { - const generation = ++repoLoadGeneration; - reposHydrating = true; - try { - const revalidations: Array<{ projectId: string; promise: Promise }> = []; - const entries = await Promise.all( - projectList.map(async (project) => { - try { - const { data: repos, revalidating } = await commands.listProjectRepos(project.id); - if (revalidating) { - revalidations.push({ projectId: project.id, promise: revalidating }); - } - return [project.id, repos] as const; - } catch (e) { - console.error(`[ProjectsList] Failed to load repos for project '${project.id}':`, e); - return [project.id, [] as ProjectRepo[]] as const; - } - }) - ); - if (generation !== repoLoadGeneration) return; - reposByProject = new Map(entries); - - // Ensure badges exist for all repos - const allRepos = entries.flatMap(([, repos]) => - repos.map((r) => ({ githubRepo: r.githubRepo, subpath: r.subpath })) - ); - void repoBadgeStore.ensureForRepos(allRepos); - - for (const { projectId, promise } of revalidations) { - void promise - .then((fresh) => { - if (generation !== repoLoadGeneration) return; - reposByProject = new Map(reposByProject).set(projectId, fresh); - void repoBadgeStore.ensureForRepos( - fresh.map((r) => ({ githubRepo: r.githubRepo, subpath: r.subpath })) - ); - }) - .catch((e) => { - console.error(`[ProjectsList] Failed to revalidate repos for '${projectId}':`, e); - }); - } - } finally { - if (generation === repoLoadGeneration) { - reposHydrating = false; - } - } - } - function handleProjectCreated(project: Project) { - if (!projects.some((p) => p.id === project.id)) { - projects = [...projects, project]; - } - void hydrateRepos(projects); + projectsDataStore.projectCreated(project); showNewProjectModal = false; selectProject(project.id); } function isProjectDeleting(projectId: string): boolean { - return deletingProjectNames.has(projectId); + return projectsDataStore.isProjectDeleting(projectId); } function openProject(projectId: string) { @@ -558,21 +347,18 @@ const name = projectDisplayName(project); const branchesToClear = projectBranches.get(id) || []; projectToDelete = null; - deletingProjectNames = new Map(deletingProjectNames).set(id, name); + projectsDataStore.projectDeleteStarted(id, name); try { await commands.deleteProject(id); projectStateStore.markAsRead(id); commands.invalidateProjectBranchTimelines(branchesToClear.map((branch) => branch.id)); - await loadProjects(); + projectsDataStore.projectDeleteFinished(id, { removed: true }); } catch (e) { console.error('Failed to delete project:', e); const message = e instanceof Error ? e.message : String(e); toast.error('Unable to delete project', { description: message }); - } finally { - const next = new Map(deletingProjectNames); - next.delete(id); - deletingProjectNames = next; + projectsDataStore.projectDeleteFinished(id); } } @@ -694,10 +480,10 @@
- {#if loading} -
Loading projects…
- {:else if error} + {#if error}
{error}
+ {:else if loading} +
Loading projects…
{:else if projects.length === 0}
{#each homeRepos as repo (repo.githubRepo + ':' + repo.subpath)} - handleCloneRepo(repo)} - onPinChange={loadHomeRepos} - /> + handleCloneRepo(repo)} /> {/each}
diff --git a/apps/staged/src/lib/features/projects/ProjectsSidebar.svelte b/apps/staged/src/lib/features/projects/ProjectsSidebar.svelte index fe69c1d67..146617351 100644 --- a/apps/staged/src/lib/features/projects/ProjectsSidebar.svelte +++ b/apps/staged/src/lib/features/projects/ProjectsSidebar.svelte @@ -13,7 +13,7 @@ import FolderGit2 from '@lucide/svelte/icons/folder-git-2'; import Mail from '@lucide/svelte/icons/mail'; import Trash2 from '@lucide/svelte/icons/trash-2'; - import type { Project, ProjectRepo, Branch, WorkspaceStatus, RepoHomeItem } from '../../types'; + import type { Project, WorkspaceStatus, RepoHomeItem } from '../../types'; import { goHome, navigation, selectProject, showAllRepos } from '../layout/navigation.svelte'; import { projectDisplayName, @@ -24,6 +24,7 @@ } from '../../shared/utils'; import RepoBadge from '../../shared/RepoBadge.svelte'; import { repoBadgeStore } from '../../stores/repoBadges.svelte'; + import { projectsDataStore } from '../../stores/projectsData.svelte'; import { projectStateStore } from '../../stores/projectState.svelte'; import Spinner from '../../shared/Spinner.svelte'; import SineWave from '../../shared/SineWave.svelte'; @@ -48,30 +49,22 @@ const devBranch = import.meta.env.VITE_DEV_BRANCH as string | undefined; interface Props { - projects: Project[]; - loading?: boolean; - error?: string | null; - deletingProjectNames?: Map; - repoCountsByProject?: Map; - reposByProject?: Map; showAllProjectsRow?: boolean; - projectBranches?: Map; onMarkProjectUnread?: (project: Project) => void; onRemoveProject?: (project: Project) => void | Promise; } - let { - projects, - loading = false, - error = null, - deletingProjectNames = new Map(), - repoCountsByProject = new Map(), - reposByProject = new Map(), - showAllProjectsRow = true, - projectBranches = new Map(), - onMarkProjectUnread, - onRemoveProject, - }: Props = $props(); + let { showAllProjectsRow = true, onMarkProjectUnread, onRemoveProject }: Props = $props(); + + // All rendered data comes from the shared projectsData store; only UI + // state (width, scroll, drag) lives here or in projectsSidebarState. + let projects = $derived(projectsDataStore.projects); + let projectBranches = $derived(projectsDataStore.branchesByProject); + let reposByProject = $derived(projectsDataStore.reposByProject); + let repoCountsByProject = $derived(projectsDataStore.repoCountsByProject); + let deletingProjectNames = $derived(projectsDataStore.deletingProjectNames); + let loading = $derived(projectsDataStore.loading || !projectsDataStore.loaded); + let error = $derived(projectsDataStore.error); let sidebarBodyEl = $state(null); let activeProjectRowEl = $state(null); @@ -81,17 +74,14 @@ let trackedSidebarBodyEl: HTMLDivElement | null = null; // ── Pinned repos ── + // Synced from the shared home-repos cache; kept as local state so a drag + // reorder applies optimistically before the persisted order round-trips. let pinnedRepos = $state([]); let dragSourceIndex = $state(null); - async function loadPinnedRepos() { - try { - const all = await commands.listReposForHome(); - pinnedRepos = all.filter((r) => r.pinned); - } catch (e) { - console.error('[ProjectsSidebar] Failed to load pinned repos:', e); - } - } + $effect(() => { + pinnedRepos = projectsDataStore.homeRepos.filter((r) => r.pinned); + }); function handleDragStart(index: number) { return (e: DragEvent) => { @@ -132,7 +122,7 @@ } catch (e) { console.error('[ProjectsSidebar] Failed to reorder pinned repos:', e); // Reload to get the correct order - await loadPinnedRepos(); + await projectsDataStore.refreshHomeRepos(); } }; } @@ -300,26 +290,25 @@ let resizing = $state(false); let resizeStartX = 0; let resizeStartWidth = SIDEBAR_DEFAULT_WIDTH; - let sidebarVisible = $derived(projectsSidebarState.hasProjects && !viewport.isMobile); + // Keep the sidebar up until a completed load proves there are no projects, + // so it doesn't flash out during startup. + let sidebarVisible = $derived( + (projects.length > 0 || !projectsDataStore.loaded) && !viewport.isMobile + ); let sidebarStyle = $derived(`width: ${projectsSidebarState.width}px;`); onMount(() => { const stopWatchingViewport = watchViewport(); void hydrateProjectsSidebarState(); - const onPinnedChanged = () => { - void loadPinnedRepos(); - }; + // Pin changes propagate through the store's staged:pinned-repos-changed + // listener; this mount only has to make sure the cache is warm. if (reposUiEnabled) { - void loadPinnedRepos(); - window.addEventListener('staged:pinned-repos-changed', onPinnedChanged); + void projectsDataStore.ensureHomeReposLoaded(); } return () => { stopWatchingViewport(); - if (reposUiEnabled) { - window.removeEventListener('staged:pinned-repos-changed', onPinnedChanged); - } }; }); @@ -427,10 +416,10 @@
@@ -612,6 +623,10 @@ (showSessionLab = false)} /> {/if} + + + {/if} @@ -640,6 +655,16 @@ flex-direction: column; } + /* Sidebar + route view side by side (mirrors ProjectHome's old wrapper). */ + .workspace { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + background-color: var(--bg-chrome); + overflow: hidden; + } + .reset-shell { display: flex; align-items: center; diff --git a/apps/staged/src/lib/features/projects/ProjectDeleteDialog.svelte b/apps/staged/src/lib/features/projects/ProjectDeleteDialog.svelte new file mode 100644 index 000000000..718e5b405 --- /dev/null +++ b/apps/staged/src/lib/features/projects/ProjectDeleteDialog.svelte @@ -0,0 +1,38 @@ + + + + !v && projectActions.cancelPendingDelete()} +> + + {#if projectActions.pendingDelete} + + Remove Project + + {`Remove "${projectDisplayName(projectActions.pendingDelete)}" from Staged? There are unmerged changes in this project's branches. Deleting this project will lose any changes not pushed to GitHub.`} + + + + Cancel + projectActions.confirmPendingDelete()} + > + Remove + + + {/if} + + diff --git a/apps/staged/src/lib/features/projects/ProjectHome.svelte b/apps/staged/src/lib/features/projects/ProjectHome.svelte index 5ceceb556..d31e05d35 100644 --- a/apps/staged/src/lib/features/projects/ProjectHome.svelte +++ b/apps/staged/src/lib/features/projects/ProjectHome.svelte @@ -29,16 +29,15 @@ import type { RepoSelection as RepoPickerSelection } from '../../shared/githubUrl'; import AddRepoModal from './AddRepoModal.svelte'; import NewProjectModal from './NewProjectModal.svelte'; - import ProjectsSidebar from './ProjectsSidebar.svelte'; import SplashScreen from './SplashScreen.svelte'; import Spinner from '../../shared/Spinner.svelte'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; import { Button } from '$lib/components/ui/button'; import { toast } from 'svelte-sonner'; import { workspaceLifecycle } from './workspaceLifecycle.svelte'; + import { projectActions } from './projectActions.svelte'; import { projectRunActionsStore } from '../../stores/projectRunActions.svelte'; import { projectsDataStore } from '../../stores/projectsData.svelte'; - import { projectStateStore } from '../../stores/projectState.svelte'; import { canDeleteProjectWithoutConfirmation, computeSafeToDeleteSignature, @@ -98,12 +97,13 @@ let projectTitleElement = $state(null); let showTopBarProjectName = $state(false); - // Delete confirmation state - let projectToDelete = $state(null); + // Delete confirmation state (remove-project confirmation lives in the + // shared projectActions module + App-level ProjectDeleteDialog) let branchToDelete = $state<{ branch: Branch; project: Project } | null>(null); let deletingBranches = $state>(new Set()); // Guards the delete shortcut while the async safe-to-delete check is in flight, - // before projectToDelete/deletingProjectNames are set, so a held key only deletes once. + // before projectActions.pendingDelete/deletingProjectNames are set, so a held + // key only deletes once. let deleteShortcutPending = $state(false); // Setup errors come from the shared workspace lifecycle orchestrator. @@ -503,11 +503,6 @@ showNewProjectModal = true; } - function handleMarkProjectUnread(project: Project) { - if (deletingProjectNames.has(project.id)) return; - projectStateStore.markAsUnread(project.id); - } - function handleProjectCreated(project: Project) { // The store registers the project synchronously and hydrates branches and // repos in the background, so the modal closes instantly. @@ -516,33 +511,12 @@ selectProject(project.id); } - async function handleDeleteProjectRequest(project: Project) { - const branches = branchesByProject.get(project.id) || []; - const repoCount = repoCountsByProject.get(project.id) || 0; - - const isSafeToDelete = await canDeleteProjectWithoutConfirmation({ - branches, - repoCount, - hasUnpushedCommits: commands.hasUnpushedCommits, - onCheckError: (e) => console.error('Failed to check unpushed commits:', e), - }); - - if (isSafeToDelete) { - // Safe to delete without confirmation - projectToDelete = project; - await confirmDeleteProject(); - } else { - // Show confirmation dialog - projectToDelete = project; - } - } - function handleDeleteCurrentProjectShortcut(event: Event) { if ( !selectedProject || deleteShortcutPending || selectedProjectDeleting || - projectToDelete || + projectActions.pendingDelete || branchToDelete || showNewProjectModal || showAddRepoModal @@ -552,47 +526,11 @@ event.preventDefault(); deleteShortcutPending = true; - void handleDeleteProjectRequest(selectedProject).finally(() => { + void projectActions.requestRemoveProject(selectedProject).finally(() => { deleteShortcutPending = false; }); } - async function confirmDeleteProject() { - if (!projectToDelete) return; - const id = projectToDelete.id; - const name = projectDisplayName(projectToDelete); - const branchesToClear = branchesByProject.get(id) || []; - projectToDelete = null; - projectsDataStore.projectDeleteStarted(id, name); - - // Navigate away immediately so the user doesn't have to wait for backend deletion. - // Skip projects that are already being deleted. - const currentIndex = projects.findIndex((p) => p.id === id); - const alive = projects.filter((p) => p.id !== id && !projectsDataStore.isProjectDeleting(p.id)); - if (alive.length > 0) { - // Prefer the next project after the current one; fall back to the closest earlier one - const next = alive.find((p) => projects.indexOf(p) > currentIndex) ?? alive[alive.length - 1]; - selectProject(next.id); - } else { - goHome(); - } - - try { - await commands.deleteProject(id); - projectStateStore.markAsRead(id); - projectsDataStore.projectDeleteFinished(id, { removed: true }); - commands.invalidateProjectBranchTimelines(branchesToClear.map((b) => b.id)); - for (const branch of branchesToClear) { - workspaceLifecycle.clearBranchState(branch.id); - } - } catch (e) { - console.error('Failed to delete project:', e); - const message = e instanceof Error ? e.message : String(e); - toast.error('Unable to delete project', { description: message }); - projectsDataStore.projectDeleteFinished(id); - } - } - // ── Branch actions ── async function handleRepoSelected(projectId: string, selection: RepoPickerSelection) { @@ -845,7 +783,7 @@ selectedProjectSafeToDelete && 'border border-destructive text-destructive', ]} title="Remove project" - onclick={() => handleDeleteProjectRequest(selectedProject)} + onclick={() => projectActions.requestRemoveProject(selectedProject)} >
- -
- - !v && (projectToDelete = null)} -> - - {#if projectToDelete} - - Remove Project - - {`Remove "${projectDisplayName(projectToDelete)}" from Staged? There are unmerged changes in this project's branches. Deleting this project will lose any changes not pushed to GitHub.`} - - - - Cancel - - Remove - - - {/if} - - - (null); let mainPanelEl = $state(null); let activeFilters = $state>(new Set()); let restoreInProgress = false; @@ -313,55 +310,6 @@ selectProject(projectId); } - function handleMarkProjectUnread(project: Project) { - if (isProjectDeleting(project.id)) return; - projectStateStore.markAsUnread(project.id); - } - - async function handleRemoveProject(project: Project) { - if (isProjectDeleting(project.id)) return; - - const deleteImmediately = await canDeleteProjectWithoutConfirmation({ - branches: projectBranches.get(project.id) || [], - repoCount: repoCountsByProject.get(project.id) ?? (project.githubRepo ? 1 : 0), - hasUnpushedCommits: commands.hasUnpushedCommits, - onCheckError: (e) => console.error('Failed to check unpushed commits:', e), - }); - - if (deleteImmediately) { - await deleteProject(project); - } else { - projectToDelete = project; - } - } - - async function confirmDeleteProject() { - if (!projectToDelete) return; - await deleteProject(projectToDelete); - } - - async function deleteProject(project: Project) { - if (isProjectDeleting(project.id)) return; - - const id = project.id; - const name = projectDisplayName(project); - const branchesToClear = projectBranches.get(id) || []; - projectToDelete = null; - projectsDataStore.projectDeleteStarted(id, name); - - try { - await commands.deleteProject(id); - projectStateStore.markAsRead(id); - commands.invalidateProjectBranchTimelines(branchesToClear.map((branch) => branch.id)); - projectsDataStore.projectDeleteFinished(id, { removed: true }); - } catch (e) { - console.error('Failed to delete project:', e); - const message = e instanceof Error ? e.message : String(e); - toast.error('Unable to delete project', { description: message }); - projectsDataStore.projectDeleteFinished(id); - } - } - function getProjectPrStatus( projectId: string ): 'merged' | 'open' | 'closed' | 'checks_failing' | 'conflict' | null { @@ -688,14 +636,14 @@ handleMarkProjectUnread(project)} + onSelect={() => projectActions.markProjectUnread(project)} > Mark as Unread handleRemoveProject(project)} + onSelect={() => projectActions.requestRemoveProject(project)} > Remove Project @@ -720,28 +668,6 @@ onClose={() => (showNewProjectModal = false)} /> - !v && (projectToDelete = null)} -> - - {#if projectToDelete} - - Remove Project - - {`Remove "${projectDisplayName(projectToDelete)}" from Staged? There are unmerged changes in this project's branches. Deleting this project will lose any changes not pushed to GitHub.`} - - - - Cancel - - Remove - - - {/if} - - -