mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Implement M1 authoring loop: library, editor, live preview, persistence
M1 MVP from docs/IMPLEMENTATION-PLAN.md, with the /alignment pass applied. - core: Snippet model + factory; prepareSpecForRender (copy-not-mutate); per-theme Vega chart config - state/orchestration: SnippetStore with debounced auto-save; IndexedDB adapter + read-time migration; startup hydration + write-through persistence - ui: SnippetLibrary, SpecEditor (Monaco edcore.main — full editor features, JSON-only languages), LivePreview - build: Monaco/Vega manual chunks; raised PWA precache ceiling - alignment: flush a valid draft on snippet switch (+regression tests); TODO breadcrumbs for the preview render race and the window.confirm delete - housekeeping: gitignore .claude/projects/
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Snippet library state (docs/architecture/01).
|
||||
*
|
||||
* Holds the durable domain state for the library: the snippets, which one is
|
||||
* active, and the live editor buffer (`draftText`). Actions are the single place
|
||||
* snippet state mutates, so they are unit-testable without a DOM or IndexedDB.
|
||||
*
|
||||
* Persistence is NOT done here — a startup subscriber (orchestration/persistence)
|
||||
* observes this store and writes through to the IndexedDB adapter. That keeps the
|
||||
* store pure and free of browser APIs.
|
||||
*
|
||||
* M1 note: there is no draft/published split yet — `commitDraft` writes the
|
||||
* buffer to both `spec` and `draftSpec` ("edits save directly"). M2 introduces
|
||||
* Publish and `commitDraft` will then write only `draftSpec`.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { createSnippet, type CreateSnippetOptions, type Snippet } from '@core/snippet';
|
||||
|
||||
export interface SnippetState {
|
||||
snippets: Snippet[];
|
||||
activeSnippetId: string | null;
|
||||
/** The live Monaco buffer for the active snippet's spec (may be mid-edit/invalid). */
|
||||
draftText: string;
|
||||
|
||||
/** Replace the library from storage and choose an active snippet. */
|
||||
hydrate: (snippets: Snippet[], activeId?: string | null) => void;
|
||||
/** Create a new snippet (sample template by default), prepend, and select it. */
|
||||
createSnippet: (options?: CreateSnippetOptions) => string;
|
||||
/** Make a snippet active and load its spec into the editor buffer. */
|
||||
selectSnippet: (id: string) => void;
|
||||
/** Remove a snippet; if it was active, fall back to the newest remaining one. */
|
||||
removeSnippet: (id: string) => void;
|
||||
/** Update the editor buffer only (no persistence; debounced commit follows). */
|
||||
updateDraft: (text: string) => void;
|
||||
/**
|
||||
* Persist the editor buffer into the active snippet, if it parses as JSON.
|
||||
* Returns whether it committed (a half-typed, unparseable buffer is skipped,
|
||||
* per spec §03B). `now` is injectable for deterministic tests.
|
||||
*/
|
||||
commitDraft: (now?: Date) => boolean;
|
||||
/** Reset to initial state (tests, future "new workspace"). */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/** Newest-modified first — the library's default ordering (spec §02 → Sort). */
|
||||
function byModifiedDesc(a: Snippet, b: Snippet): number {
|
||||
return b.modified.localeCompare(a.modified);
|
||||
}
|
||||
|
||||
/** The id of the most-recently-modified snippet, or null if there are none. */
|
||||
function newestId(snippets: Snippet[]): string | null {
|
||||
if (snippets.length === 0) return null;
|
||||
return [...snippets].sort(byModifiedDesc)[0].id;
|
||||
}
|
||||
|
||||
function draftFor(snippets: Snippet[], id: string | null): string {
|
||||
return snippets.find((s) => s.id === id)?.draftSpec ?? '';
|
||||
}
|
||||
|
||||
export const useSnippetStore = create<SnippetState>((set, get) => ({
|
||||
snippets: [],
|
||||
activeSnippetId: null,
|
||||
draftText: '',
|
||||
|
||||
hydrate: (snippets, activeId) => {
|
||||
const id = activeId !== undefined ? activeId : newestId(snippets);
|
||||
set({ snippets, activeSnippetId: id, draftText: draftFor(snippets, id) });
|
||||
},
|
||||
|
||||
createSnippet: (options) => {
|
||||
get().commitDraft(); // flush the outgoing snippet's valid edits before switching away
|
||||
const snippet = createSnippet(options);
|
||||
set((s) => ({
|
||||
snippets: [snippet, ...s.snippets],
|
||||
activeSnippetId: snippet.id,
|
||||
draftText: snippet.draftSpec,
|
||||
}));
|
||||
return snippet.id;
|
||||
},
|
||||
|
||||
selectSnippet: (id) => {
|
||||
if (id === get().activeSnippetId) return;
|
||||
// Flush the outgoing snippet's valid in-progress edits before switching, so
|
||||
// navigating away within the auto-save debounce window doesn't drop them
|
||||
// (spec §03B — auto-save preserves in-progress work). An unparseable buffer
|
||||
// is left uncommitted, exactly as the debounced auto-save would.
|
||||
get().commitDraft();
|
||||
const { snippets } = get();
|
||||
set({ activeSnippetId: id, draftText: draftFor(snippets, id) });
|
||||
},
|
||||
|
||||
removeSnippet: (id) => {
|
||||
set((s) => {
|
||||
const snippets = s.snippets.filter((x) => x.id !== id);
|
||||
if (s.activeSnippetId !== id) return { snippets };
|
||||
const activeSnippetId = newestId(snippets);
|
||||
return { snippets, activeSnippetId, draftText: draftFor(snippets, activeSnippetId) };
|
||||
});
|
||||
},
|
||||
|
||||
updateDraft: (draftText) => set({ draftText }),
|
||||
|
||||
commitDraft: (now) => {
|
||||
const { activeSnippetId, draftText, snippets } = get();
|
||||
if (!activeSnippetId) return false;
|
||||
|
||||
try {
|
||||
JSON.parse(draftText);
|
||||
} catch {
|
||||
return false; // half-typed spec — skip, retry after the next pause
|
||||
}
|
||||
|
||||
const active = snippets.find((s) => s.id === activeSnippetId);
|
||||
if (!active || (active.draftSpec === draftText && active.spec === draftText)) {
|
||||
return false; // nothing changed
|
||||
}
|
||||
|
||||
const modified = (now ?? new Date()).toISOString();
|
||||
set({
|
||||
snippets: snippets.map((s) =>
|
||||
s.id === activeSnippetId ? { ...s, spec: draftText, draftSpec: draftText, modified } : s,
|
||||
),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
reset: () => set({ snippets: [], activeSnippetId: null, draftText: '' }),
|
||||
}));
|
||||
|
||||
/** Selector: the active snippet record, or null. Derive — never store. */
|
||||
export const selectActiveSnippet = (s: SnippetState): Snippet | null =>
|
||||
s.snippets.find((x) => x.id === s.activeSnippetId) ?? null;
|
||||
Reference in New Issue
Block a user