From 0f066dbf5ba147412b43caa2738fdc51169838cb Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 10:58:14 +0800 Subject: [PATCH 1/4] fix: rehydrate engine and command history from IndexedDB on boot autoLoad/deserialize and saveHistory/loadHistory all existed but were never wired, so every refresh started from a fresh empty engine and lost all git state. Add hydrate() (called once on boot from main.ts) to restore engine state and command history, and persist history after each command. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main.ts | 4 ++ src/store/engine.ts | 30 +++++++++++++-- tests/store/engine.test.ts | 77 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 tests/store/engine.test.ts diff --git a/src/main.ts b/src/main.ts index 0310a6a..14bbb8f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,10 @@ import 'virtual:uno.css'; import './app.css'; import { mount } from 'svelte'; import App from './App.svelte'; +import { hydrate } from '$store/engine'; + +// Restore the previous session from IndexedDB before/while the UI mounts. +void hydrate(); const app = mount(App, { target: document.getElementById('app')! }); diff --git a/src/store/engine.ts b/src/store/engine.ts index d477acc..4ba35ee 100644 --- a/src/store/engine.ts +++ b/src/store/engine.ts @@ -4,8 +4,8 @@ import { CommandHistory } from '$shell/history'; import { ShellRouter } from '$shell/router'; import { generatePrompt } from '$shell/prompt'; import type { PromptSegment } from '$shell/prompt'; -import { serialize } from '$persistence/serializer'; -import { autoSave } from '$persistence/storage'; +import { serialize, deserialize } from '$persistence/serializer'; +import { autoSave, autoLoad, saveHistory, loadHistory } from '$persistence/storage'; export type TerminalLine = { id: number; @@ -41,6 +41,29 @@ export const history = writable(new CommandHistory()); export const terminalLines = writable(createInitialLines()); export const engineVersion = writable(0); +/** + * Restore the previous session (engine + command history) from IndexedDB. + * Call once on boot. A missing or corrupt save leaves defaults in place. + */ +export async function hydrate(): Promise { + const json = await autoLoad(); + if (json) { + try { + engine.set(deserialize(json)); + engineVersion.update((v) => v + 1); + } catch { + // corrupt save – keep the fresh engine + } + } + + const entries = await loadHistory(); + if (entries.length > 0) { + const hist = get(history); + hist.restore(entries); + history.set(hist); + } +} + export const prompt = derived(engine, ($engine) => { return generatePrompt($engine); }); @@ -56,8 +79,9 @@ export function executeCommand(command: string): void { // Execute command via router const result = router.execute(command); - // Push command to history + // Push command to history and persist it hist.push(command); + saveHistory(hist.getEntries()); // Handle clear special case if (command.trim() === 'clear') { diff --git a/tests/store/engine.test.ts b/tests/store/engine.test.ts new file mode 100644 index 0000000..ff9e223 --- /dev/null +++ b/tests/store/engine.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { get } from 'svelte/store'; +import { GitEngine } from '$engine/index'; +import { serialize } from '$persistence/serializer'; + +// Control what the persistence layer returns without touching IndexedDB. +const autoLoadMock = vi.fn(); +const loadHistoryMock = vi.fn(); +const saveHistoryMock = vi.fn(); +vi.mock('$persistence/storage', () => ({ + autoLoad: () => autoLoadMock(), + autoSave: vi.fn(), + loadHistory: () => loadHistoryMock(), + saveHistory: (entries: string[]) => saveHistoryMock(entries), +})); + +import { engine, history, hydrate, executeCommand } from '$store/engine'; + +describe('store hydrate', () => { + beforeEach(() => { + autoLoadMock.mockReset(); + loadHistoryMock.mockReset(); + loadHistoryMock.mockResolvedValue([]); + saveHistoryMock.mockReset(); + }); + + it('restores persisted engine state on boot', async () => { + const saved = new GitEngine(); + saved.execute('git init'); + saved.getVFS().createFile('README.md', 'hello'); + saved.execute('git add README.md'); + saved.execute('git commit -m "persisted commit"'); + autoLoadMock.mockResolvedValue(serialize(saved)); + + await hydrate(); + + const live = get(engine); + const commits = live.allCommits(); + expect(commits).toHaveLength(1); + expect(commits[0].message).toBe('persisted commit'); + expect(live.isInitialized()).toBe(true); + }); + + it('leaves the live engine untouched when no autosave exists', async () => { + const before = get(engine); + autoLoadMock.mockResolvedValue(null); + + await hydrate(); + + expect(get(engine)).toBe(before); + }); + + it('ignores a corrupt autosave without throwing', async () => { + const before = get(engine); + autoLoadMock.mockResolvedValue('{ not valid json'); + + await expect(hydrate()).resolves.toBeUndefined(); + expect(get(engine)).toBe(before); + }); + + it('restores command history on boot', async () => { + autoLoadMock.mockResolvedValue(null); + loadHistoryMock.mockResolvedValue(['git init', 'git status']); + + await hydrate(); + + expect(get(history).getEntries()).toEqual(['git init', 'git status']); + }); + + it('persists command history after each command', () => { + executeCommand('git status'); + + expect(saveHistoryMock).toHaveBeenCalled(); + const lastSaved = saveHistoryMock.mock.calls.at(-1)![0] as string[]; + expect(lastSaved).toContain('git status'); + }); +}); From 70b4a0e7780c6e423c48e24835d9e4efe76b2431 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 10:58:18 +0800 Subject: [PATCH 2/4] fix: resolve PWA manifest icons against /gitverse/ base (#9) Manifest icon src used leading-slash absolute paths (/icons/...) that resolve to the domain root and 404 on the GitHub Pages project subpath. Use relative paths so they resolve against the manifest URL under /gitverse/. Co-Authored-By: Claude Opus 4.7 (1M context) --- vite.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 2e01320..c57bcff 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,8 +21,8 @@ export default defineConfig({ background_color: '#0d1117', display: 'standalone', icons: [ - { src: '/icons/icon-192.svg', sizes: '192x192', type: 'image/svg+xml' }, - { src: '/icons/icon-512.svg', sizes: '512x512', type: 'image/svg+xml' }, + { src: 'icons/icon-192.svg', sizes: '192x192', type: 'image/svg+xml' }, + { src: 'icons/icon-512.svg', sizes: '512x512', type: 'image/svg+xml' }, ], }, }), From e76396cca4ef82c9f27993a70d2d79dc580c01e5 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 10:58:23 +0800 Subject: [PATCH 3/4] 0.4.1-rc.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index aef8a08..1df5a1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitverse", - "version": "0.4.0", + "version": "0.4.1-rc.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitverse", - "version": "0.4.0", + "version": "0.4.1-rc.0", "dependencies": { "d3-dag": "1.2.1", "d3-hierarchy": "3.1.2", diff --git a/package.json b/package.json index 1693292..e1ee536 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gitverse", - "version": "0.4.0", + "version": "0.4.1-rc.0", "private": true, "type": "module", "scripts": { From 4936dc2005851f2123400cb96c2cb6d79806a6f1 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Thu, 28 May 2026 11:04:05 +0800 Subject: [PATCH 4/4] fix: render restored session before mount and harden history storage Addresses PR #10 review: - await hydrate() before mount so the graph and terminal render already populated and no early command can race the restore (was fire-and-forget) - replace the "git init to begin" banner with a restored-session notice so the terminal no longer contradicts a populated graph - guard saveHistory/loadHistory against storage failures, matching autoSave/autoLoad, so a floating saveHistory can't emit an unhandled rejection - reset the history store between tests for order-independence Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main.ts | 13 ++++++++----- src/persistence/storage.ts | 17 +++++++++++++---- src/store/engine.ts | 12 ++++++++++-- tests/store/engine.test.ts | 3 +++ 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/main.ts b/src/main.ts index 14bbb8f..46721c3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,9 +4,12 @@ import { mount } from 'svelte'; import App from './App.svelte'; import { hydrate } from '$store/engine'; -// Restore the previous session from IndexedDB before/while the UI mounts. -void hydrate(); +const target = document.getElementById('app')!; -const app = mount(App, { target: document.getElementById('app')! }); - -export default app; +// Restore the previous session from IndexedDB before first paint, so the +// graph and terminal render already-populated and no early command can +// race the restore. hydrate() never rejects (errors are swallowed at the +// storage boundary), so mount always runs. +void hydrate().finally(() => { + mount(App, { target }); +}); diff --git a/src/persistence/storage.ts b/src/persistence/storage.ts index 6cfdd86..f538950 100644 --- a/src/persistence/storage.ts +++ b/src/persistence/storage.ts @@ -86,15 +86,24 @@ export async function listSandboxes(): Promise { /** * Persist the command history array. + * Errors are swallowed so that a storage failure never breaks the UI. */ export async function saveHistory(entries: string[]): Promise { - await set(HISTORY_KEY, entries); + try { + await set(HISTORY_KEY, entries); + } catch { + // storage unavailable – silently ignore + } } /** - * Load the command history array. Returns [] if not found. + * Load the command history array. Returns [] if not found or unavailable. */ export async function loadHistory(): Promise { - const value = await get(HISTORY_KEY); - return value ?? []; + try { + const value = await get(HISTORY_KEY); + return value ?? []; + } catch { + return []; + } } diff --git a/src/store/engine.ts b/src/store/engine.ts index 4ba35ee..95f35ae 100644 --- a/src/store/engine.ts +++ b/src/store/engine.ts @@ -43,14 +43,22 @@ export const engineVersion = writable(0); /** * Restore the previous session (engine + command history) from IndexedDB. - * Call once on boot. A missing or corrupt save leaves defaults in place. + * Awaited once before mount, so the UI renders already-populated and no + * command can race the restore. A missing or corrupt save leaves defaults. */ export async function hydrate(): Promise { const json = await autoLoad(); if (json) { try { - engine.set(deserialize(json)); + const restored = deserialize(json); + engine.set(restored); engineVersion.update((v) => v + 1); + // Replace the "git init to begin" banner — it contradicts a restored repo. + if (restored.isInitialized()) { + terminalLines.set([ + { id: ++lineIdCounter, output: 'Session restored from your last visit.', color: 'dim' }, + ]); + } } catch { // corrupt save – keep the fresh engine } diff --git a/tests/store/engine.test.ts b/tests/store/engine.test.ts index ff9e223..b084f83 100644 --- a/tests/store/engine.test.ts +++ b/tests/store/engine.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { get } from 'svelte/store'; import { GitEngine } from '$engine/index'; import { serialize } from '$persistence/serializer'; +import { CommandHistory } from '$shell/history'; // Control what the persistence layer returns without touching IndexedDB. const autoLoadMock = vi.fn(); @@ -22,6 +23,8 @@ describe('store hydrate', () => { loadHistoryMock.mockReset(); loadHistoryMock.mockResolvedValue([]); saveHistoryMock.mockReset(); + // Reset the shared history singleton so tests don't depend on order. + history.set(new CommandHistory()); }); it('restores persisted engine state on boot', async () => {