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:
+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