diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index b916f4595..56a17c31f 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -11,6 +11,8 @@ import TopBar from './lib/features/layout/TopBar.svelte'; import ProjectHome from './lib/features/projects/ProjectHome.svelte'; import ProjectsList from './lib/features/projects/ProjectsList.svelte'; + import ProjectsSidebar from './lib/features/projects/ProjectsSidebar.svelte'; + import ProjectDeleteDialog from './lib/features/projects/ProjectDeleteDialog.svelte'; import ReposListView from './lib/features/projects/ReposListView.svelte'; import SessionLauncher from './lib/features/sessions/SessionLauncher.svelte'; import SettingsPage from './lib/features/settings/SettingsPage.svelte'; @@ -44,6 +46,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 +307,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 +499,7 @@ unlistenCacheInvalidation?.(); unlistenPageLifecycle?.(); unlistenAcpToolsReconciled?.(); + projectsDataStore.stopListeners(); stopUpdaterLoop?.(); }); @@ -592,12 +599,21 @@ subpath={diffRoute.subpath} onClose={() => closeDiffRouteIfCurrent(diffRoute)} /> - {:else if reposUiEnabled && navigation.showReposList} - - {:else if navigation.selectedProjectId} - {:else} - +
+ + {#if navigation.selectedProjectId || (reposUiEnabled && navigation.showReposList)} + + {/if} + {#if reposUiEnabled && navigation.showReposList} + + {:else if navigation.selectedProjectId} + + {:else} + + {/if} +
{/if} @@ -607,6 +623,10 @@ (showSessionLab = false)} /> {/if} + + + {/if} @@ -635,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/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/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 4493d2462..a5897a8a7 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'; @@ -31,64 +29,70 @@ 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 { setProjects } from './projectsSidebarState.svelte'; import { workspaceLifecycle } from './workspaceLifecycle.svelte'; + import { projectActions } from './projectActions.svelte'; import { projectRunActionsStore } from '../../stores/projectRunActions.svelte'; - import { repoBadgeStore } from '../../stores/repoBadges.svelte'; - import { projectStateStore } from '../../stores/projectState.svelte'; + import { projectsDataStore } from '../../stores/projectsData.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); + // The store's `loaded` only covers the project list, so wait for the + // selected project's own branches too. Gated on the project still being in + // the list: a stale or deleted id must not pin the view in loading forever, + // and the "selected project vanished → goHome()" effect below needs + // `loading` to clear so it can fire. + let selectedProjectPending = $derived( + !!selectedProjectId && + projects.some((project) => project.id === selectedProjectId) && + !projectsDataStore.isProjectHydrated(selectedProjectId) + ); + let loading = $derived(storeCheckPending || projectsDataStore.loading || selectedProjectPending); + // 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); @@ -103,13 +107,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()); - 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. + // 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. @@ -120,25 +124,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 +157,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 +168,28 @@ }); async function checkStoreAndLoad() { - loading = true; try { const status = await commands.getStoreStatus(); if (status) { storeIncompat = status; - loading = false; return; } - await loadData(); + // ensureLoaded resolves on the project list alone, so the selected + // project's branches are always ours to fetch — on a cold start the + // store's idle drip dedupes into this one request. + const load = projectsDataStore.ensureLoaded(); + storeCheckPending = false; + await load; + lastSelectedProjectId = selectedProjectId; + initialLoadComplete = true; + if (selectedProjectId) { + 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 +198,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 +225,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 +258,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 +298,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 +357,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 +372,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 +389,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. @@ -846,50 +512,12 @@ showNewProjectModal = true; } - function handleMarkProjectUnread(project: Project) { - if (deletingProjectNames.has(project.id)) return; - 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) { - 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) { @@ -897,7 +525,7 @@ !selectedProject || deleteShortcutPending || selectedProjectDeleting || - projectToDelete || + projectActions.pendingDelete || branchToDelete || showNewProjectModal || showAddRepoModal @@ -907,69 +535,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; - deletingProjectNames = new Map(deletingProjectNames).set(id, name); - window.dispatchEvent( - new CustomEvent('staged:project-delete-start', { - detail: { projectId: 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)); - 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); - 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; - 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 }); - } finally { - const next = new Map(deletingProjectNames); - next.delete(id); - deletingProjectNames = next; - window.dispatchEvent( - new CustomEvent('staged:project-delete-end', { - detail: { projectId: id }, - }) - ); - } - } - // ── Branch actions ── async function handleRepoSelected(projectId: string, selection: RepoPickerSelection) { @@ -988,24 +558,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 +656,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 +683,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); @@ -1243,7 +792,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} - - - 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,19 +26,19 @@ } 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'; + import { projectActions } from './projectActions.svelte'; import * as ContextMenu from '$lib/components/ui/context-menu'; import SplashScreen from './SplashScreen.svelte'; import Spinner from '../../shared/Spinner.svelte'; import SineWave from '../../shared/SineWave.svelte'; import RepoLabel from '../../shared/RepoLabel.svelte'; import { toast } from 'svelte-sonner'; - import * as AlertDialog from '$lib/components/ui/alert-dialog'; import { Button } from '$lib/components/ui/button'; - import { setProjects } from './projectsSidebarState.svelte'; import { finishProjectsListRestore, projectsListViewState, @@ -56,42 +47,35 @@ import { darkMode } from '../../stores/isDark.svelte'; import { repoBadgeStore } from '../../stores/repoBadges.svelte'; import { badgeBg, badgeFg, badgeBgHover } from '../../shared/badgeColors'; - import { canDeleteProjectWithoutConfirmation } from './projectDeleteSafety'; import { viewport } from '../../shared/viewport.svelte'; import { reposUiEnabled } from '../../featureFlags'; import TopBarPortal from '../layout/TopBarPortal.svelte'; 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 deletingProjectNames = $derived(projectsDataStore.deletingProjectNames); + let homeRepos = $derived(projectsDataStore.homeRepos); + // The grid paints every card complete or not at all, so it waits for each + // project's branches and repos — not just the project list `loaded` covers. + let loading = $derived( + projectsDataStore.loading || !projectsDataStore.loaded || !projectsDataStore.allProjectsHydrated + ); + 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 +249,6 @@ projectsListViewState.restorePending && !restoreInProgress && !loading && - !reposHydrating && !error && filteredProjects.length > 0 && mainPanelEl; @@ -275,137 +258,46 @@ }); 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; - } - } + // Pull every project's branches and repos forward off the store's idle drip + // — the grid renders all of them. An effect rather than onMount so a list + // change or a cache-stale reload re-kicks the sweep and the loading gate + // heals itself; the store dedupes projects it has already fetched. + $effect(() => { + if (!projectsDataStore.loaded) return; + void projectsDataStore.ensureProjectsHydrated(); + }); - 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 +305,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) { @@ -524,58 +323,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; - deletingProjectNames = new Map(deletingProjectNames).set(id, name); - - try { - await commands.deleteProject(id); - projectStateStore.markAsRead(id); - commands.invalidateProjectBranchTimelines(branchesToClear.map((branch) => branch.id)); - await loadProjects(); - } 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; - } - } - function getProjectPrStatus( projectId: string ): 'merged' | 'open' | 'closed' | 'checks_failing' | 'conflict' | null { @@ -694,10 +441,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}
@@ -906,14 +649,14 @@ handleMarkProjectUnread(project)} + onSelect={() => projectActions.markProjectUnread(project)} > Mark as Unread handleRemoveProject(project)} + onSelect={() => projectActions.requestRemoveProject(project)} > Remove Project @@ -938,28 +681,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} - - -