mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
492 lines
20 KiB
TypeScript
492 lines
20 KiB
TypeScript
/**
|
|
* 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<SnippetState>((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;
|
|
};
|