mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Add distributed settings and workspace import/export (M5)
This commit is contained in:
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } } })),
|
||||
}));
|
||||
Reference in New Issue
Block a user