mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add draft/published editor workflow with publish, revert, and dirty indicator
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<div className={styles.itemMain}>
|
||||
<span className={styles.nameRow}>
|
||||
{hasUnpublishedChanges(s) && (
|
||||
<span
|
||||
className={styles.draftDot}
|
||||
title="Has unpublished draft changes"
|
||||
aria-label="Has unpublished draft changes"
|
||||
/>
|
||||
)}
|
||||
<span className={styles.name}>{s.name}</span>
|
||||
</span>
|
||||
<span className={styles.date}>{relativeDate(s.modified)}</span>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -1,9 +1,102 @@
|
||||
.editorPane {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
height: 40px;
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* Draft / Published segmented toggle. */
|
||||
.viewToggle {
|
||||
display: inline-flex;
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
}
|
||||
|
||||
.viewOption {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: var(--bg);
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease),
|
||||
color var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.viewOption + .viewOption {
|
||||
border-left: var(--border-width) solid var(--border-strong);
|
||||
}
|
||||
|
||||
.viewOption:hover {
|
||||
background: var(--layer-01);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.viewActive,
|
||||
.viewActive:hover {
|
||||
background: var(--layer-02);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Publish / Revert buttons. */
|
||||
.action {
|
||||
appearance: none;
|
||||
height: 28px;
|
||||
padding: 0 var(--space-4);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease),
|
||||
border-color var(--dur-fast) var(--ease),
|
||||
opacity var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.action:hover:not(:disabled) {
|
||||
background: var(--layer-01);
|
||||
}
|
||||
|
||||
.action:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.publish {
|
||||
border-color: transparent;
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
.publish:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.editorWrap {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@@ -21,3 +114,20 @@
|
||||
background: var(--bg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Inline render/parse error surface (spec §03E) — monospaced, distinct. */
|
||||
.error {
|
||||
flex: 0 0 auto;
|
||||
max-height: 30%;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: var(--border-width) solid var(--support-error);
|
||||
background: var(--layer-01);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--support-error);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* Spec Editor — the center pane (spec §03).
|
||||
*
|
||||
* M1 scope: a Monaco JSON editor bound to the active snippet's draft. It runs
|
||||
* uncontrolled (raw `monaco-editor`, per docs/architecture/08): created once,
|
||||
* keystrokes push into the store's `draftText` buffer, and the buffer is pulled
|
||||
* back in only when the active snippet *changes* (select/create/delete) — never
|
||||
* keystroke-by-keystroke, which would fight the cursor. Schema validation/
|
||||
* autocomplete, the draft/published toggle, and the inline error surface arrive
|
||||
* in M2.
|
||||
* A Monaco JSON editor bound to the active snippet, running uncontrolled (raw
|
||||
* `monaco-editor`, per docs/architecture/08): created once, keystrokes push into
|
||||
* the store's `draftText` buffer. The buffer is reloaded only when the store
|
||||
* signals a programmatic load (`bufferEpoch`) or the view toggles — never
|
||||
* keystroke-by-keystroke, which would fight the cursor.
|
||||
*
|
||||
* The pane header carries the Draft/Published toggle plus Publish and Revert
|
||||
* (spec §03D). The published view is read-only — it shows the last published
|
||||
* spec for reference; all editing happens on the draft. Render problems surface
|
||||
* inline near the editor (spec §03E), mirroring the preview via PreviewStore.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
@@ -21,24 +24,106 @@ import 'monaco-editor/esm/vs/language/json/monaco.contribution';
|
||||
import '../infrastructure/monaco-env'; // side-effect: wire workers before create
|
||||
import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { confirm } from '../stores/ConfirmStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import styles from './SpecEditor.module.css';
|
||||
|
||||
// Register the bundled Vega-Lite schema once: resolves `$schema` locally (no
|
||||
// network warning) and powers validation, autocomplete, and hover docs.
|
||||
configureVegaLiteJson();
|
||||
|
||||
function EditorToolbar() {
|
||||
const activeId = useSnippetStore((s) => s.activeSnippetId);
|
||||
const editorView = useSnippetStore((s) => s.editorView);
|
||||
const setEditorView = useSnippetStore((s) => s.setEditorView);
|
||||
// Are there draft changes to revert? Use the live buffer in the draft view so
|
||||
// the control responds before the auto-save debounce commits (spec §03D).
|
||||
const dirty = useSnippetStore((s) => {
|
||||
const active = selectActiveSnippet(s);
|
||||
if (!active) return false;
|
||||
const draft = s.editorView === 'draft' ? s.draftText : active.draftSpec;
|
||||
return draft !== active.spec;
|
||||
});
|
||||
|
||||
const handlePublish = () => {
|
||||
if (!useSnippetStore.getState().activeSnippetId) return;
|
||||
useSnippetStore.getState().publish();
|
||||
// TODO: success toast "Snippet published" once the toast system lands (M6, spec §03D).
|
||||
};
|
||||
|
||||
const handleRevert = async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Revert draft',
|
||||
message:
|
||||
'Discard all draft changes and restore the last published version? This cannot be undone.',
|
||||
confirmLabel: 'Revert',
|
||||
danger: true,
|
||||
});
|
||||
if (ok) {
|
||||
useSnippetStore.getState().revert();
|
||||
// TODO: success toast "Draft reverted" once the toast system lands (M6, spec §03D).
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.viewToggle} role="group" aria-label="Editor view">
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.viewOption} ${editorView === 'draft' ? styles.viewActive : ''}`}
|
||||
aria-pressed={editorView === 'draft'}
|
||||
onClick={() => setEditorView('draft')}
|
||||
>
|
||||
Draft
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.viewOption} ${editorView === 'published' ? styles.viewActive : ''}`}
|
||||
aria-pressed={editorView === 'published'}
|
||||
onClick={() => setEditorView('published')}
|
||||
>
|
||||
Published
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className={styles.spacer} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
onClick={() => void handleRevert()}
|
||||
disabled={activeId === null || !dirty}
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.action} ${styles.publish}`}
|
||||
onClick={handlePublish}
|
||||
disabled={activeId === null}
|
||||
title="Publish (⌘/Ctrl+S)"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpecEditor() {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(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(() => {
|
||||
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 (
|
||||
<div className={styles.editorPane}>
|
||||
<EditorToolbar />
|
||||
<div className={styles.editorWrap}>
|
||||
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
|
||||
<div className={styles.editor} ref={hostRef} />
|
||||
</div>
|
||||
{error !== null && <pre className={styles.error}>{error}</pre>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user