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
+97
View File
@@ -254,6 +254,103 @@ describe('publish — datasetRefs recomputation', () => {
});
});
describe('renameSnippet (spec §02 metadata panel)', () => {
test('renames and advances modified, without touching the editor buffer', () => {
const a = createSnippet({ id: 'a', name: 'Old', now: new Date('2026-01-01T00:00:00Z') });
store().hydrate([a], 'a');
const epochBefore = store().bufferEpoch;
store().renameSnippet('a', 'New', new Date('2026-05-01T00:00:00Z'));
const saved = store().snippets.find((s) => s.id === 'a')!;
expect(saved.name).toBe('New');
expect(saved.modified).toBe('2026-05-01T00:00:00.000Z');
expect(store().bufferEpoch).toBe(epochBefore); // name is not in the spec buffer
});
test('is a no-op for an unchanged name (modified does not advance)', () => {
const a = createSnippet({ id: 'a', name: 'Same', now: new Date('2026-01-01T00:00:00Z') });
store().hydrate([a], 'a');
store().renameSnippet('a', 'Same', new Date('2026-05-01T00:00:00Z'));
expect(store().snippets.find((s) => s.id === 'a')!.modified).toBe('2026-01-01T00:00:00.000Z');
});
test('ignores an unknown id', () => {
const a = createSnippet({ id: 'a' });
store().hydrate([a], 'a');
expect(() => store().renameSnippet('missing', 'X')).not.toThrow();
expect(store().snippets).toHaveLength(1);
});
});
describe('setComment (spec §02 metadata panel)', () => {
test('sets the comment and advances modified', () => {
const a = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') });
store().hydrate([a], 'a');
store().setComment('a', 'a useful note', new Date('2026-05-02T00:00:00Z'));
const saved = store().snippets.find((s) => s.id === 'a')!;
expect(saved.comment).toBe('a useful note');
expect(saved.modified).toBe('2026-05-02T00:00:00.000Z');
});
test('is a no-op for an unchanged comment', () => {
const a = {
...createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') }),
comment: 'x',
};
store().hydrate([a], 'a');
store().setComment('a', 'x', new Date('2026-05-02T00:00:00Z'));
expect(store().snippets.find((s) => s.id === 'a')!.modified).toBe('2026-01-01T00:00:00.000Z');
});
});
describe('duplicateActiveSnippet (spec §02 → Duplicate)', () => {
test('prepends an independent copy and makes it active', () => {
const a = {
...createSnippet({
id: 'a',
name: 'Chart',
spec: '{"a":1}',
now: new Date('2026-01-01T00:00:00Z'),
}),
comment: 'note',
tags: ['imported'],
datasetRefs: ['Sales'],
};
store().hydrate([a], 'a');
const id = store().duplicateActiveSnippet(new Date('2026-04-01T00:00:00Z'), 'copy');
expect(id).toBe('copy');
expect(store().activeSnippetId).toBe('copy');
expect(store().snippets[0].id).toBe('copy'); // prepended
const copy = store().snippets[0];
expect(copy.name).toBe('Chart (copy)');
expect(copy.spec).toBe('{"a":1}');
expect(copy.comment).toBe('note');
expect(copy.tags).toEqual(['imported']);
expect(copy.datasetRefs).toEqual(['Sales']);
expect(copy.created).toBe('2026-04-01T00:00:00.000Z');
expect(store().draftText).toBe(copy.draftSpec);
});
test('captures in-progress buffer edits before copying', () => {
const a = createSnippet({ id: 'a', spec: '{"a":1}', now: new Date('2026-01-01T00:00:00Z') });
store().hydrate([a], 'a');
store().updateDraft('{"a":99}'); // edited, not yet auto-saved
store().duplicateActiveSnippet(new Date('2026-04-01T00:00:00Z'), 'copy');
expect(store().snippets[0].draftSpec).toBe('{"a":99}'); // copy reflects the live edit
});
test('returns null when no snippet is active', () => {
store().hydrate([], null);
expect(store().duplicateActiveSnippet()).toBeNull();
});
});
describe('renameDatasetRefs', () => {
const refSpec = (name: string) => JSON.stringify({ data: { name }, mark: 'bar' }, null, 2);
+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) => {