Add distributed settings and workspace import/export (M5)

This commit is contained in:
2026-06-07 15:51:00 +03:00
parent 80bedd2a8d
commit 548aa199d9
38 changed files with 3150 additions and 101 deletions
+17
View File
@@ -69,6 +69,14 @@ export interface DatasetState {
/** Low-level: add a fully-formed dataset and select it. */
add: (dataset: Dataset) => void;
/**
* Append imported datasets (spec §08 → datasets imported before snippets). Each
* is given a fresh monotonic numeric id so a batch never collides on the
* `Date.now()` default (the `add` TODO) nor with existing ids — safe because
* datasets are referenced by **name**, not id (docs/architecture/07 §1). Names
* are assumed already de-duped by the import service. Selection is unchanged.
*/
addDatasets: (incoming: Dataset[]) => void;
/** Low-level: merge a patch into a dataset, advancing `modified`. */
update: (id: number, patch: Partial<Dataset>, now?: Date) => void;
/** Low-level: remove a dataset; clears the selection if it was selected. */
@@ -237,6 +245,15 @@ export const useDatasetStore = create<DatasetState>((set, get) => ({
// before wiring import.
add: (dataset) => set((s) => ({ datasets: [dataset, ...s.datasets], selectedId: dataset.id })),
addDatasets: (incoming) => {
if (incoming.length === 0) return;
set((s) => {
let nextId = s.datasets.reduce((max, d) => Math.max(max, d.id), 0) + 1;
const withIds = incoming.map((d) => ({ ...d, id: nextId++ }));
return { datasets: [...withIds, ...s.datasets] };
});
},
update: (id, patch, now) => {
const modified = patch.modified ?? (now ?? new Date()).toISOString();
set((s) => ({
+29
View File
@@ -0,0 +1,29 @@
/**
* Open-state for the per-pane settings disclosures (spec §07; arch 10).
*
* A single id names whichever settings popover is open — so at most one shows at a
* time, and any can be opened imperatively (the Cmd/Ctrl+, shortcut targets the
* editor cluster). Kept in its own module so the SettingsPopover component file
* exports only components (fast-refresh friendly).
*/
import { create } from 'zustand';
export interface SettingsPopoverState {
/** Id of the single open popover, or null. */
openId: string | null;
toggle: (id: string) => void;
show: (id: string) => void;
close: () => void;
}
export const useSettingsPopoverStore = create<SettingsPopoverState>((set) => ({
openId: null,
toggle: (id) => set((s) => ({ openId: s.openId === id ? null : id })),
show: (id) => set({ openId: id }),
close: () => set({ openId: null }),
}));
/** Open a settings popover by id from outside React (e.g. the Cmd/Ctrl+, shortcut). */
export const openSettingsPopover = (id: string): void =>
useSettingsPopoverStore.getState().show(id);
+23
View File
@@ -46,6 +46,13 @@ export interface SnippetState {
/** 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. */
@@ -148,6 +155,22 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
}));
},
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);
+59
View File
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { defaultSettings } from '@core/settings';
import { useUserSettingsStore } from './UserSettingsStore';
/** The managed slice (editor/performance/formatting) of a full settings record. */
function managed(s = defaultSettings()) {
return { editor: s.editor, performance: s.performance, formatting: s.formatting };
}
beforeEach(() => {
useUserSettingsStore.getState().hydrate(defaultSettings());
});
describe('UserSettingsStore', () => {
it('hydrate strips a full record to the managed slice', () => {
useUserSettingsStore.getState().hydrate(defaultSettings());
expect(useUserSettingsStore.getState().saved).toEqual(managed());
});
it('setters apply live and patch only their cluster', () => {
useUserSettingsStore.getState().setEditor({ fontSize: 16 });
expect(useUserSettingsStore.getState().saved.editor.fontSize).toBe(16);
// other editor fields and other clusters untouched
expect(useUserSettingsStore.getState().saved.editor.tabSize).toBe(2);
expect(useUserSettingsStore.getState().saved.performance.renderDebounce).toBe(1500);
useUserSettingsStore.getState().setPerformance({ renderDebounce: 800 });
expect(useUserSettingsStore.getState().saved.performance.renderDebounce).toBe(800);
useUserSettingsStore.getState().setFormatting({ dateFormat: 'iso' });
expect(useUserSettingsStore.getState().saved.formatting.dateFormat).toBe('iso');
});
it('each setter produces a fresh `saved` reference (drives the persistence subscriber)', () => {
const before = useUserSettingsStore.getState().saved;
useUserSettingsStore.getState().setEditor({ minimap: true });
const after = useUserSettingsStore.getState().saved;
expect(after).not.toBe(before);
expect(after.editor.minimap).toBe(true);
});
it('resetEditor restores only the editor cluster to defaults', () => {
useUserSettingsStore.getState().setEditor({ fontSize: 18, minimap: true, tabSize: 8 });
useUserSettingsStore.getState().setFormatting({ dateFormat: 'iso' });
useUserSettingsStore.getState().resetEditor();
expect(useUserSettingsStore.getState().saved.editor).toEqual(defaultSettings().editor);
// formatting (a different cluster) is left as the user set it
expect(useUserSettingsStore.getState().saved.formatting.dateFormat).toBe('iso');
});
it('does not alias the stored record on hydrate (mutating input later is inert)', () => {
const input = defaultSettings();
useUserSettingsStore.getState().hydrate(input);
input.editor.fontSize = 99;
expect(useUserSettingsStore.getState().saved.editor.fontSize).toBe(12);
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* User settings state (spec §07, docs/architecture/01).
*
* Holds the **applied** preferences the app reads reactively — editor,
* performance, and formatting. Following Astrolabe's distributed-settings model
* (spec §07), each cluster is edited **in place**, next to what it affects (the
* editor pane, the preview, the library), and every change applies **live**: a
* setter mutates `saved` immediately and a startup subscriber writes it through
* to localStorage (orchestration/settings). There is no draft/Apply/Cancel
* stage — that's the explicit, commit-style model we replaced.
*
* UI theme and preview fit mode are not here: they live in the AppStore (the
* header theme toggle and the preview Fit control), persisted onto the same
* `UserSettings` record by their own slice subscribers. Settings controls that
* change theme call `useAppStore.setTheme` directly, exactly like the toggle.
*/
import { create } from 'zustand';
import { defaultSettings, type UserSettings } from '@core/settings';
/** The clusters this store owns (theme + fit mode live in the AppStore). */
export type ManagedSettings = Pick<UserSettings, 'editor' | 'performance' | 'formatting'>;
/** Deep-copy the managed slice so callers never alias the stored record. */
function cloneManaged(s: ManagedSettings): ManagedSettings {
return {
editor: { ...s.editor },
performance: { ...s.performance },
formatting: { ...s.formatting },
};
}
export interface UserSettingsState {
/** The applied settings — the reactive source the editor/preview/library read. */
saved: ManagedSettings;
/** Replace `saved` from the persisted record at startup. */
hydrate: (settings: UserSettings) => void;
/** Live-patch a cluster (each produces a fresh `saved` → persistence fires). */
setEditor: (patch: Partial<UserSettings['editor']>) => void;
setPerformance: (patch: Partial<UserSettings['performance']>) => void;
setFormatting: (patch: Partial<UserSettings['formatting']>) => void;
/** Restore the editor cluster to its factory defaults (the editor popover's Reset). */
resetEditor: () => void;
}
export const useUserSettingsStore = create<UserSettingsState>((set) => ({
saved: cloneManaged(defaultSettings()),
hydrate: (settings) => set({ saved: cloneManaged(settings) }),
setEditor: (patch) =>
set((s) => ({ saved: { ...s.saved, editor: { ...s.saved.editor, ...patch } } })),
setPerformance: (patch) =>
set((s) => ({ saved: { ...s.saved, performance: { ...s.saved.performance, ...patch } } })),
setFormatting: (patch) =>
set((s) => ({ saved: { ...s.saved, formatting: { ...s.saved.formatting, ...patch } } })),
resetEditor: () =>
set((s) => ({ saved: { ...s.saved, editor: { ...defaultSettings().editor } } })),
}));