/** * 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/snippet-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, duplicateSnippet as duplicateSnippetRecord, type CreateSnippetOptions, type Snippet, } from '@core/snippet'; import { extractDatasetRefs, recomputeDatasetRefs, renameDatasetInSpec } from '@core/spec-refs'; import { DEFAULT_SORT_BY, DEFAULT_SORT_ORDER, type SortBy, type SortOrder, } from '@core/snippet-sort'; /** 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; /** * Library view state (spec §02 → Search / Sort). These affect only which * snippets are shown and in what order — never `activeSnippetId` or any data. * The component derives its visible list from these primitives via `useMemo` * (core `filterAndSortSnippets`), NOT through a selector that builds a fresh * array (which would loop the app — MEMORY → "Zustand stable selectors"). */ searchQuery: string; sortBy: SortBy; sortOrder: SortOrder; /** Replace the library from storage and choose an active snippet. */ hydrate: (snippets: Snippet[], activeId?: string | null) => void; /** * Append imported snippets (spec §08 → merge: appended, never overwritten). Ids * are assumed already unique against the library (the import service reassigns * collisions). Keeps the current selection; selects the newest import only when * nothing is active, so an import into an empty workspace lands the user on it. */ addSnippets: (incoming: Snippet[]) => 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; /** * Rename a snippet (spec §02 metadata panel, inline name edit). Advances * `modified` (a name edit is a save, §02 → Sort), but never touches the editor * buffer — the name isn't part of the spec text. No-op for an unknown id or an * unchanged name. `now` injectable. */ renameSnippet: (id: string, name: string, now?: Date) => void; /** * Set a snippet's free-form comment (spec §02 metadata panel). Advances * `modified` like a rename; no editor-buffer effect. No-op for an unknown id or * an unchanged comment. `now` injectable. */ setComment: (id: string, comment: string, now?: Date) => void; /** Set the live search query (spec §02 → Search). Visibility only — never * touches `activeSnippetId` or any snippet data. */ setSearch: (query: string) => void; /** * Set the library sort (spec §02 → Sort): re-selecting the **active** field * flips direction; choosing a **different** field switches to it and resets to * descending. Persisted by an orchestration subscriber; never touches the * active snippet. `order` may be passed to hydrate an exact stored state. */ setSort: (by: SortBy, order?: SortOrder) => void; /** * Duplicate the active snippet (spec §02 → Duplicate): flushes the live buffer * into the source draft first so the copy reflects in-progress edits, then * prepends an independent copy ("(copy)" name, fresh identity/timestamps) and * makes it active. Returns the new id, or null if no snippet is active. `now` * injectable; `id` injectable for deterministic tests. */ duplicateActiveSnippet: (now?: Date, id?: string) => string | null; /** Update the draft buffer only (no persistence; debounced commit follows). */ updateDraft: (text: string) => void; /** * Replace the active snippet's draft spec with new text and reload the editor * on the draft view (bumps `bufferEpoch`). Used by programmatic rewrites such * as Extract-to-Dataset (spec §03F), which substitutes inline data for a * by-name reference. No-op when no snippet is active. `now` injectable. */ replaceActiveDraft: (text: string, now?: Date) => 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; /** * Propagate a dataset rename across every referencing snippet: rewrite the * named-data references in both `spec` and `draftSpec`, recompute `datasetRefs`, * and refresh the live editor buffer if the active snippet was rewritten — so a * user mid-edit doesn't see their draft silently break (docs/architecture/07 * §6). Returns the number of snippets changed. `now` injectable. */ renameDatasetRefs: (oldName: string, newName: string, now?: Date) => number; /** * 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, searchQuery: '', sortBy: DEFAULT_SORT_BY, sortOrder: DEFAULT_SORT_ORDER, 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, })); }, addSnippets: (incoming) => { if (incoming.length === 0) return; set((s) => { const snippets = [...incoming, ...s.snippets]; if (s.activeSnippetId !== null) return { snippets }; const id = newestId(snippets); return { 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 created = createSnippet(options); // Mirror datasetRefs from the draft at creation, so a snippet built with a // named-data reference (Chart Builder, §06) is linked to its dataset immediately. // datasetRefs tracks the draft — the version being edited — at every mutation // (create/auto-save/extract/revert/publish), so the link reflects what the user // sees, not only the last publish (docs/architecture/07 §3). Inline-data specs // (the sample template) resolve to no refs. spec === draftSpec at creation. const snippet = { ...created, datasetRefs: recomputeDatasetRefs(created.draftSpec) }; 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 }; // Deleting the active snippet falls back to the newest remaining one, so the // editor and detail panel stay populated (spec §02 → Delete); null only when // none remain. 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 }; }); }, renameSnippet: (id, name, now) => { set((s) => { const target = s.snippets.find((x) => x.id === id); if (!target || target.name === name) return s; // unknown id or no change const modified = (now ?? new Date()).toISOString(); return { snippets: s.snippets.map((x) => (x.id === id ? { ...x, name, modified } : x)), }; }); }, setComment: (id, comment, now) => { set((s) => { const target = s.snippets.find((x) => x.id === id); if (!target || target.comment === comment) return s; // unknown id or no change const modified = (now ?? new Date()).toISOString(); return { snippets: s.snippets.map((x) => (x.id === id ? { ...x, comment, modified } : x)), }; }); }, setSearch: (searchQuery) => set({ searchQuery }), setSort: (by, order) => { set((s) => { // An explicit order hydrates a stored state directly (orchestration init). if (order !== undefined) return { sortBy: by, sortOrder: order }; // Re-selecting the active field flips direction; a different field resets // to descending (spec §02 → Sort). if (by === s.sortBy) { return { sortOrder: s.sortOrder === 'desc' ? 'asc' : 'desc' }; } return { sortBy: by, sortOrder: 'desc' }; }); }, duplicateActiveSnippet: (now, id) => { // Flush the live buffer into the source draft first, so the copy faithfully // mirrors what the user currently sees, not the last auto-saved draft. get().commitDraft(now); const { activeSnippetId, snippets } = get(); if (!activeSnippetId) return null; const source = snippets.find((s) => s.id === activeSnippetId); if (!source) return null; const copy = duplicateSnippetRecord(source, { now, id }); set((s) => ({ snippets: [copy, ...s.snippets], activeSnippetId: copy.id, draftText: copy.draftSpec, editorView: 'draft', // a fresh copy opens on its editable draft bufferEpoch: s.bufferEpoch + 1, })); return copy.id; }, updateDraft: (draftText) => set({ draftText }), replaceActiveDraft: (text, now) => { const { activeSnippetId } = get(); if (!activeSnippetId) return; const modified = (now ?? new Date()).toISOString(); const datasetRefs = recomputeDatasetRefs(text); set((s) => ({ snippets: s.snippets.map((x) => // Recompute datasetRefs from the new draft so a programmatic rewrite that // introduces a by-name reference — Extract-to-Dataset (spec §03F) — links // the dataset to the snippet immediately, not only on the next publish. x.id === activeSnippetId ? { ...x, draftSpec: text, datasetRefs, modified } : x, ), draftText: text, editorView: 'draft', bufferEpoch: s.bufferEpoch + 1, })); }, 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(); // Recompute datasetRefs from the just-committed draft so a hand-typed by-name // reference links to its dataset as soon as auto-save fires. This runs on the // debounced auto-save and only on valid JSON (the parse guard above), so it is // not "every keystroke" and never sees a transiently-invalid draft — it keeps // datasetRefs mirroring the draft the user is editing (docs/architecture/07 §3). const datasetRefs = recomputeDatasetRefs(draftText); set({ snippets: snippets.map((s) => s.id === activeSnippetId ? { ...s, draftSpec: draftText, datasetRefs, modified } : s, ), }); return true; }, setEditorView: (editorView) => set({ editorView }), renameDatasetRefs: (oldName, newName, now) => { if (oldName === newName) return 0; const lower = oldName.toLowerCase(); const { snippets, activeSnippetId, editorView } = get(); let updated = 0; let activeDraftAfter: string | null = null; // Whether a spec text references the old name (case-insensitive). Reads the // content rather than reserializing, so a non-referencing snippet's text is // left byte-for-byte intact (no spurious reformat/modified bump). const referencesOld = (text: string): boolean => extractDatasetRefs(text).some((r) => r.toLowerCase() === lower); const next = snippets.map((s) => { // Consult both spec and draft, not only datasetRefs: datasetRefs now mirrors // the DRAFT, so a name still referenced only by the published spec (the user // removed it from the draft but hasn't published) would otherwise be missed // and the published spec would rot to a now-renamed dataset (arch 07 §6). if (!referencesOld(s.spec) && !referencesOld(s.draftSpec)) return s; updated++; const spec = renameDatasetInSpec(s.spec, oldName, newName); const draftSpec = renameDatasetInSpec(s.draftSpec, oldName, newName); if (s.id === activeSnippetId) activeDraftAfter = draftSpec; return { ...s, spec, draftSpec, datasetRefs: recomputeDatasetRefs(draftSpec), modified: (now ?? new Date()).toISOString(), }; }); if (updated === 0) return 0; set((st) => ({ snippets: next, // If the active snippet's draft was rewritten, reload the editor buffer so // the live (draft-view) buffer reflects the new name and the next auto-save // doesn't clobber the rewrite with the stale old name. ...(activeDraftAfter !== null && editorView === 'draft' ? { draftText: activeDraftAfter, bufferEpoch: st.bufferEpoch + 1 } : activeDraftAfter !== null ? { bufferEpoch: st.bufferEpoch + 1 } : {}), })); return updated; }, 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 ? // Promote the draft and recompute datasetRefs from the now-published // spec, so the bidirectional snippet↔dataset link mirrors reality // (spec §03D, docs/architecture/07 §3). { ...s, spec: s.draftSpec, datasetRefs: recomputeDatasetRefs(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(); // The draft is restored to the published spec, so datasetRefs must mirror it // again — drop any link that existed only in the discarded draft (e.g. an // Extract-to-Dataset rewrite the user reverted before publishing). const datasetRefs = recomputeDatasetRefs(active.spec); set((s) => ({ snippets: s.snippets.map((x) => x.id === activeSnippetId ? { ...x, draftSpec: x.spec, datasetRefs, 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, searchQuery: '', sortBy: DEFAULT_SORT_BY, sortOrder: DEFAULT_SORT_ORDER, }), })); /** 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; };