Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gitverse",
"version": "0.4.0",
"version": "0.4.1-rc.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
11 changes: 9 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
17 changes: 13 additions & 4 deletions src/persistence/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,24 @@ export async function listSandboxes(): Promise<string[]> {

/**
* Persist the command history array.
* Errors are swallowed so that a storage failure never breaks the UI.
*/
export async function saveHistory(entries: string[]): Promise<void> {
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<string[]> {
const value = await get<string[]>(HISTORY_KEY);
return value ?? [];
try {
const value = await get<string[]>(HISTORY_KEY);
return value ?? [];
} catch {
return [];
}
}
38 changes: 35 additions & 3 deletions src/store/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,6 +41,37 @@ export const history = writable(new CommandHistory());
export const terminalLines = writable<TerminalLine[]>(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<void> {
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);
});
Expand All @@ -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') {
Expand Down
80 changes: 80 additions & 0 deletions tests/store/engine.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
4 changes: 2 additions & 2 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
}),
Expand Down
Loading