Add draft/published editor workflow with publish, revert, and dirty indicator

This commit is contained in:
2026-06-05 10:45:09 +03:00
parent eec2986921
commit f4253f50ca
7 changed files with 506 additions and 43 deletions
+28
View File
@@ -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<PreviewState>((set) => ({
error: null,
setError: (error) => set({ error }),
}));
+92 -7
View File
@@ -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');
});
});
+128 -17
View File
@@ -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<SnippetState>((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<SnippetState>((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<SnippetState>((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<SnippetState>((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<SnippetState>((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;
};