Add snippet metadata panel, duplicate, and immediate-load preview (M4.5)

This commit is contained in:
2026-06-06 23:54:59 +03:00
parent 3e89d9a531
commit 80bedd2a8d
13 changed files with 785 additions and 51 deletions
+72 -1
View File
@@ -18,7 +18,12 @@
*/
import { create } from 'zustand';
import { createSnippet, type CreateSnippetOptions, type Snippet } from '@core/snippet';
import {
createSnippet,
duplicateSnippet as duplicateSnippetRecord,
type CreateSnippetOptions,
type Snippet,
} from '@core/snippet';
import { recomputeDatasetRefs, renameDatasetInSpec } from '@core/spec-refs';
/** Which version of the active snippet the editor is showing (spec §03D). */
@@ -47,6 +52,27 @@ export interface SnippetState {
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;
/**
* 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;
/**
@@ -158,6 +184,9 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
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,
@@ -169,6 +198,48 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
});
},
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)),
};
});
},
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) => {