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": { diff --git a/src/main.ts b/src/main.ts index 0310a6a..46721c3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,14 @@ import 'virtual:uno.css'; import './app.css'; import { mount } from 'svelte'; import App from './App.svelte'; +import { hydrate } from '$store/engine'; -const app = mount(App, { target: document.getElementById('app')! }); +const 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 d477acc..95f35ae 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,37 @@ 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. + * 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 { + 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 + } + } + + 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 +87,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..b084f83 --- /dev/null +++ b/tests/store/engine.test.ts @@ -0,0 +1,80 @@ +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(); +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(); + // Reset the shared history singleton so tests don't depend on order. + history.set(new CommandHistory()); + }); + + 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'); + }); +}); 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' }, ], }, }),