/** * 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. * * Draft/published model (spec §03D): every snippet carries a published `spec` * and a working `draftSpec`. Ordinary editing — keystrokes, auto-save — touches * the **draft** only; `publish` promotes the draft to published, `revert` * discards it back to published. `editorView` selects which version the editor * shows (and therefore which the preview renders); the live editable buffer * `draftText` always mirrors the draft, regardless of view. */ import { create } from 'zustand'; import { createSnippet, type CreateSnippetOptions, type Snippet } from '@core/snippet'; /** Which version of the active snippet the editor is showing (spec §03D). */ export type EditorView = 'draft' | 'published'; export interface SnippetState { snippets: Snippet[]; activeSnippetId: string | null; /** The live Monaco buffer for the active snippet's draft (may be mid-edit/invalid). */ draftText: string; /** Which version the editor shows; `'published'` is read-only (spec §03D). */ editorView: EditorView; /** * Bumped whenever the buffer is loaded *programmatically* (hydrate, select, * create, delete-fallback, revert) — never on keystrokes. The editor reloads * its content when this changes, so an external buffer load (e.g. revert) * refreshes the editor without setValue fighting the cursor mid-typing. */ bufferEpoch: number; /** 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 draft 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 draft buffer only (no persistence; debounced commit follows). */ updateDraft: (text: string) => void; /** * Persist the editor buffer into the active snippet's **draft**, if it parses * as JSON. Returns whether it committed (a half-typed, unparseable buffer is * skipped, per spec §03B). Never touches the published `spec`. `now` is * injectable for deterministic tests. */ commitDraft: (now?: Date) => boolean; /** Switch the editor between the draft and published views (spec §03D). */ setEditorView: (view: EditorView) => void; /** * Promote the active snippet's current draft to its published version (spec * §03D → Publish). Flushes the live buffer first, then makes `spec` identical * to `draftSpec`. Returns whether a snippet was active. `now` injectable. */ publish: (now?: Date) => boolean; /** * Discard the active snippet's draft, restoring it to the published version * (spec §03D → Revert), and reload the editor on the draft view. Returns * whether a snippet was active. `now` injectable. */ revert: (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((set, get) => ({ snippets: [], activeSnippetId: null, draftText: '', editorView: 'draft', bufferEpoch: 0, hydrate: (snippets, activeId) => { const id = activeId !== undefined ? activeId : newestId(snippets); set((s) => ({ snippets, activeSnippetId: id, draftText: draftFor(snippets, id), editorView: 'draft', bufferEpoch: s.bufferEpoch + 1, })); }, 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, editorView: 'draft', // a fresh snippet always opens on its editable draft bufferEpoch: s.bufferEpoch + 1, })); 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(); set((s) => ({ activeSnippetId: id, draftText: draftFor(s.snippets, id), editorView: 'draft', bufferEpoch: s.bufferEpoch + 1, })); }, 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), editorView: 'draft', bufferEpoch: s.bufferEpoch + 1, // the buffer was reloaded for the new active snippet }; }); }, 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); // Auto-save writes the DRAFT only — never the published spec (spec §03B/§03D). if (!active || active.draftSpec === draftText) { return false; // nothing changed in the draft } const modified = (now ?? new Date()).toISOString(); set({ snippets: snippets.map((s) => s.id === activeSnippetId ? { ...s, draftSpec: draftText, modified } : s, ), }); return true; }, setEditorView: (editorView) => set({ editorView }), publish: (now) => { // Flush the live buffer into the draft first, so Publish promotes exactly // what the user sees (an invalid buffer leaves the last valid draft in place). get().commitDraft(now); const { activeSnippetId, snippets } = get(); if (!activeSnippetId) return false; const modified = (now ?? new Date()).toISOString(); set({ snippets: snippets.map((s) => s.id === activeSnippetId ? // M3: recompute datasetRefs from the now-published spec (spec §03D). { ...s, spec: s.draftSpec, modified } : s, ), }); return true; }, revert: (now) => { const { activeSnippetId, snippets } = get(); if (!activeSnippetId) return false; const active = snippets.find((s) => s.id === activeSnippetId); if (!active) return false; const modified = (now ?? new Date()).toISOString(); set((s) => ({ snippets: s.snippets.map((x) => x.id === activeSnippetId ? { ...x, draftSpec: x.spec, modified } : x, ), // Reload the editor with the restored draft, on the editable view (spec §03D). draftText: active.spec, editorView: 'draft', bufferEpoch: s.bufferEpoch + 1, })); return true; }, reset: () => set({ snippets: [], activeSnippetId: null, draftText: '', editorView: 'draft', bufferEpoch: 0, }), })); /** 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; /** * Selector: the text the editor shows and the preview renders. The draft view * shows the live editable buffer; the published view shows the stored published * spec (spec §03D — "the preview always reflects the version in the editor"). */ export const selectShownText = (s: SnippetState): string => { if (s.editorView === 'published') { return selectActiveSnippet(s)?.spec ?? ''; } return s.draftText; };