diff --git a/src/app/components/SnippetLibrary.module.css b/src/app/components/SnippetLibrary.module.css index d17521f..7cc0274 100644 --- a/src/app/components/SnippetLibrary.module.css +++ b/src/app/components/SnippetLibrary.module.css @@ -71,6 +71,13 @@ gap: var(--space-1); } +.nameRow { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} + .name { font-size: 13px; font-weight: 500; @@ -79,6 +86,15 @@ text-overflow: ellipsis; } +/* Unpublished-draft indicator (spec §03D / §02 status). */ +.draftDot { + flex: 0 0 auto; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); +} + .date { font-size: 11px; color: var(--text-secondary); diff --git a/src/app/components/SnippetLibrary.tsx b/src/app/components/SnippetLibrary.tsx index 4548209..a0cba07 100644 --- a/src/app/components/SnippetLibrary.tsx +++ b/src/app/components/SnippetLibrary.tsx @@ -7,6 +7,7 @@ */ import { useShallow } from 'zustand/react/shallow'; +import { hasUnpublishedChanges } from '@core/snippet'; import { confirm } from '../stores/ConfirmStore'; import { useSnippetStore } from '../stores/SnippetStore'; import styles from './SnippetLibrary.module.css'; @@ -66,7 +67,16 @@ export function SnippetLibrary() { onClick={() => selectSnippet(s.id)} >
- {s.name} + + {hasUnpublishedChanges(s) && ( + + )} + {s.name} + {relativeDate(s.modified)}
+ + + + + + + + + ); +} + export function SpecEditor() { const hostRef = useRef(null); const editorRef = useRef(null); const activeId = useSnippetStore((s) => s.activeSnippetId); + const editorView = useSnippetStore((s) => s.editorView); + const bufferEpoch = useSnippetStore((s) => s.bufferEpoch); const uiTheme = useAppStore((s) => s.uiTheme); + const error = usePreviewStore((s) => s.error); // Create the editor once, on mount. useEffect(() => { if (!hostRef.current) return; const editor = monaco.editor.create(hostRef.current, { - value: useSnippetStore.getState().draftText, + value: selectShownText(useSnippetStore.getState()), language: 'json', automaticLayout: true, minimap: { enabled: false }, @@ -59,8 +144,18 @@ export function SpecEditor() { }); editorRef.current = editor; + // Keystrokes feed the draft buffer — but only on the editable draft view. + // (Programmatic setValue while showing the read-only published spec must not + // overwrite the draft.) const sub = editor.onDidChangeModelContent(() => { - useSnippetStore.getState().updateDraft(editor.getValue()); + if (useSnippetStore.getState().editorView === 'draft') { + useSnippetStore.getState().updateDraft(editor.getValue()); + } + }); + + // Cmd/Ctrl+S publishes the current draft (spec §03D → Publish). + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { + if (useSnippetStore.getState().activeSnippetId) useSnippetStore.getState().publish(); }); return () => { @@ -70,24 +165,32 @@ export function SpecEditor() { }; }, []); - // Replace the buffer when the active snippet changes (not while typing). + // Reload the buffer on a programmatic load (select/create/delete/revert → + // bufferEpoch) or a view toggle. Not on keystrokes: typing changes neither dep. useEffect(() => { const editor = editorRef.current; if (!editor) return; - const text = useSnippetStore.getState().draftText; + const state = useSnippetStore.getState(); + const text = selectShownText(state); if (editor.getValue() !== text) editor.setValue(text); - editor.updateOptions({ readOnly: activeId === null }); - }, [activeId]); + editor.updateOptions({ + readOnly: state.activeSnippetId === null || state.editorView === 'published', + }); + }, [bufferEpoch, editorView, activeId]); - // Editor theme follows the UI theme (M1: light/dark stock themes). + // Editor theme follows the UI theme. useEffect(() => { monaco.editor.setTheme(uiTheme === 'dark' ? 'vs-dark' : 'vs'); }, [uiTheme]); return (
- {activeId === null &&
Select or create a snippet
} -
+ +
+ {activeId === null &&
Select or create a snippet
} +
+
+ {error !== null &&
{error}
}
); } diff --git a/src/app/stores/PreviewStore.ts b/src/app/stores/PreviewStore.ts new file mode 100644 index 0000000..1288f5b --- /dev/null +++ b/src/app/stores/PreviewStore.ts @@ -0,0 +1,28 @@ +/** + * Preview render status — the bridge between the Live Preview (which owns + * rendering) and the two panes that surface its outcome. + * + * Both the editor and the preview must show the same render problem: spec §03E + * puts a readable error in the **editor** pane, and spec §04 puts one in the + * **preview** pane, for the very same failure (invalid JSON, or valid JSON that + * fails to render as Vega-Lite — including an unresolved dataset reference). The + * Live Preview is the single producer; it writes the current error here and both + * panes subscribe. `null` means the current spec rendered cleanly (or is blank). + * + * Kept as its own tiny store rather than folded into the SnippetStore: this is + * transient render state, not durable domain data, and it must not be persisted. + */ + +import { create } from 'zustand'; + +export interface PreviewState { + /** The current render error message, or null when the spec renders cleanly. */ + error: string | null; + /** Set (or clear) the current render error. */ + setError: (error: string | null) => void; +} + +export const usePreviewStore = create((set) => ({ + error: null, + setError: (error) => set({ error }), +})); diff --git a/src/app/stores/SnippetStore.test.ts b/src/app/stores/SnippetStore.test.ts index 0df7fb0..a841e47 100644 --- a/src/app/stores/SnippetStore.test.ts +++ b/src/app/stores/SnippetStore.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test } from 'vitest'; import { createSnippet } from '@core/snippet'; -import { selectActiveSnippet, useSnippetStore } from './SnippetStore'; +import { selectActiveSnippet, selectShownText, useSnippetStore } from './SnippetStore'; const store = () => useSnippetStore.getState(); beforeEach(() => store().reset()); @@ -55,7 +55,10 @@ describe('selectSnippet', () => { store().updateDraft('{"a":99}'); // edit a, before the auto-save debounce fires store().selectSnippet('b'); // switch away immediately - expect(store().snippets.find((s) => s.id === 'a')!.spec).toBe('{"a":99}'); + // The edit lands in the DRAFT (auto-save never touches the published spec). + const savedA = store().snippets.find((s) => s.id === 'a')!; + expect(savedA.draftSpec).toBe('{"a":99}'); + expect(savedA.spec).toBe('{"a":1}'); // published untouched until publish expect(store().draftText).toBe('{"b":2}'); }); @@ -103,18 +106,18 @@ describe('removeSnippet', () => { }); describe('updateDraft + commitDraft (auto-save)', () => { - test('commit writes a valid buffer into the active snippet and bumps modified', () => { + test('commit writes a valid buffer into the DRAFT only and bumps modified', () => { const a = createSnippet({ id: 'a', spec: '{"a":1}', now: new Date('2026-01-01T00:00:00Z') }); store().hydrate([a], 'a'); store().updateDraft('{"a":2}'); - expect(store().snippets[0].spec).toBe('{"a":1}'); // buffer-only until commit + expect(store().snippets[0].draftSpec).toBe('{"a":1}'); // buffer-only until commit const committed = store().commitDraft(new Date('2026-05-01T00:00:00Z')); expect(committed).toBe(true); const s = selectActiveSnippet(store())!; - expect(s.spec).toBe('{"a":2}'); expect(s.draftSpec).toBe('{"a":2}'); + expect(s.spec).toBe('{"a":1}'); // published spec is NEVER touched by auto-save expect(s.modified).toBe('2026-05-01T00:00:00.000Z'); }); @@ -124,10 +127,10 @@ describe('updateDraft + commitDraft (auto-save)', () => { store().updateDraft('{"a":'); // half-typed expect(store().commitDraft()).toBe(false); - expect(store().snippets[0].spec).toBe('{"a":1}'); // stored draft untouched + expect(store().snippets[0].draftSpec).toBe('{"a":1}'); // stored draft untouched }); - test('commit is a no-op (false) when nothing changed', () => { + test('commit is a no-op (false) when the draft is unchanged', () => { const a = createSnippet({ id: 'a', spec: '{"a":1}' }); store().hydrate([a], 'a'); expect(store().commitDraft()).toBe(false); @@ -139,3 +142,85 @@ describe('updateDraft + commitDraft (auto-save)', () => { expect(store().commitDraft()).toBe(false); }); }); + +describe('publish (spec §03D → Publish)', () => { + test('promotes the draft to the published spec (the two become identical)', () => { + const a = createSnippet({ id: 'a', spec: '{"a":1}', now: new Date('2026-01-01T00:00:00Z') }); + store().hydrate([a], 'a'); + + store().updateDraft('{"a":2}'); // edit, still uncommitted in the buffer + const ok = store().publish(new Date('2026-05-01T00:00:00Z')); + + expect(ok).toBe(true); + const s = selectActiveSnippet(store())!; + expect(s.spec).toBe('{"a":2}'); // buffer was flushed and promoted + expect(s.draftSpec).toBe('{"a":2}'); + expect(s.modified).toBe('2026-05-01T00:00:00.000Z'); + }); + + test('an invalid live buffer publishes the last valid draft instead', () => { + const a = createSnippet({ id: 'a', spec: '{"a":1}' }); + store().hydrate([a], 'a'); + + store().updateDraft('{"a":2}'); + store().commitDraft(); // last valid draft = {"a":2} + store().updateDraft('{"a":'); // now the buffer is half-typed + store().publish(); + + expect(selectActiveSnippet(store())!.spec).toBe('{"a":2}'); + }); + + test('returns false when no snippet is active', () => { + store().hydrate([], null); + expect(store().publish()).toBe(false); + }); +}); + +describe('revert (spec §03D → Revert)', () => { + test('discards draft changes and reloads the editor on the draft view', () => { + const a = createSnippet({ id: 'a', spec: '{"a":1}' }); + store().hydrate([a], 'a'); + + store().updateDraft('{"a":2}'); + store().commitDraft(); + store().setEditorView('published'); + + const ok = store().revert(new Date('2026-05-01T00:00:00Z')); + expect(ok).toBe(true); + const s = selectActiveSnippet(store())!; + expect(s.draftSpec).toBe('{"a":1}'); // restored to published + expect(s.spec).toBe('{"a":1}'); + expect(store().draftText).toBe('{"a":1}'); // editor reloaded + expect(store().editorView).toBe('draft'); // back on the editable view + }); + + test('returns false when no snippet is active', () => { + store().hydrate([], null); + expect(store().revert()).toBe(false); + }); +}); + +describe('editorView + selectShownText (spec §03D)', () => { + test('draft view shows the live buffer; published view shows the stored spec', () => { + const a = createSnippet({ id: 'a', spec: '{"a":1}' }); + store().hydrate([a], 'a'); + store().updateDraft('{"a":2}'); // diverge the draft from published + + expect(store().editorView).toBe('draft'); + expect(selectShownText(store())).toBe('{"a":2}'); + + store().setEditorView('published'); + expect(selectShownText(store())).toBe('{"a":1}'); + expect(store().draftText).toBe('{"a":2}'); // buffer preserved behind the published view + }); + + test('selecting a different snippet resets the view to draft', () => { + const a = createSnippet({ id: 'a', spec: '{"a":1}', now: new Date('2026-01-01T00:00:00Z') }); + const b = createSnippet({ id: 'b', spec: '{"b":2}', now: new Date('2026-02-01T00:00:00Z') }); + store().hydrate([a, b], 'a'); + store().setEditorView('published'); + + store().selectSnippet('b'); + expect(store().editorView).toBe('draft'); + }); +}); diff --git a/src/app/stores/SnippetStore.ts b/src/app/stores/SnippetStore.ts index 0ac0aba..a6e4223 100644 --- a/src/app/stores/SnippetStore.ts +++ b/src/app/stores/SnippetStore.ts @@ -9,36 +9,66 @@ * 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`. + * 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 spec (may be mid-edit/invalid). */ + /** 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 spec into the editor buffer. */ + /** 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 editor buffer only (no persistence; debounced commit follows). */ + /** Update the draft 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. + * 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; } @@ -62,10 +92,18 @@ 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({ snippets, activeSnippetId: id, draftText: draftFor(snippets, id) }); + set((s) => ({ + snippets, + activeSnippetId: id, + draftText: draftFor(snippets, id), + editorView: 'draft', + bufferEpoch: s.bufferEpoch + 1, + })); }, createSnippet: (options) => { @@ -75,6 +113,8 @@ export const useSnippetStore = create((set, get) => ({ 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; }, @@ -86,8 +126,12 @@ export const useSnippetStore = create((set, get) => ({ // (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) }); + set((s) => ({ + activeSnippetId: id, + draftText: draftFor(s.snippets, id), + editorView: 'draft', + bufferEpoch: s.bufferEpoch + 1, + })); }, removeSnippet: (id) => { @@ -95,7 +139,13 @@ export const useSnippetStore = create((set, get) => ({ 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) }; + return { + snippets, + activeSnippetId, + draftText: draftFor(snippets, activeSnippetId), + editorView: 'draft', + bufferEpoch: s.bufferEpoch + 1, // the buffer was reloaded for the new active snippet + }; }); }, @@ -112,22 +162,83 @@ export const useSnippetStore = create((set, get) => ({ } const active = snippets.find((s) => s.id === activeSnippetId); - if (!active || (active.draftSpec === draftText && active.spec === draftText)) { - return false; // nothing changed + // 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, spec: draftText, draftSpec: draftText, modified } : s, + s.id === activeSnippetId ? { ...s, draftSpec: draftText, modified } : s, ), }); return true; }, - reset: () => set({ snippets: [], activeSnippetId: null, draftText: '' }), + 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; +};