mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add distributed settings and workspace import/export (M5)
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* File transfer adapter — the browser side of Import/Export (spec §08).
|
||||
*
|
||||
* Per the architecture rule, DOM/file APIs are confined to infrastructure: this
|
||||
* is the only module that builds a download or reads a picked file, so the
|
||||
* transfer *service* stays about orchestration (normalize, merge, notify) rather
|
||||
* than Blobs and anchors.
|
||||
*/
|
||||
|
||||
/** Trigger a client-side download of `json` text as `filename`. */
|
||||
export function downloadJson(filename: string, json: string): void {
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
// Firefox needs the anchor in the document for the click to register.
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Read a picked file's text content (rejects on an unreadable file). */
|
||||
export function readTextFile(file: File): Promise<string> {
|
||||
return file.text();
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadPreviewFitMode, loadUiTheme, savePreviewFitMode, saveUiTheme } from './settings-store';
|
||||
import { defaultSettings } from '@core/settings';
|
||||
import {
|
||||
loadPreviewFitMode,
|
||||
loadUiTheme,
|
||||
loadUserSettings,
|
||||
saveManagedSettings,
|
||||
savePreviewFitMode,
|
||||
saveUiTheme,
|
||||
} from './settings-store';
|
||||
|
||||
const KEY = 'astrolabe:settings';
|
||||
|
||||
@@ -85,7 +93,7 @@ describe('settings-store · ui.theme', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings-store · preview.fitMode', () => {
|
||||
describe('settings-store · ui.previewFitMode', () => {
|
||||
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
@@ -94,12 +102,12 @@ describe('settings-store · preview.fitMode', () => {
|
||||
});
|
||||
|
||||
it('returns a stored valid fit mode', () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ preview: { fitMode: 'full' } }));
|
||||
localStorage.setItem(KEY, JSON.stringify({ ui: { previewFitMode: 'full' } }));
|
||||
expect(loadPreviewFitMode()).toBe('full');
|
||||
});
|
||||
|
||||
it('falls back to default for an unrecognized value', () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ preview: { fitMode: 'cover' } }));
|
||||
localStorage.setItem(KEY, JSON.stringify({ ui: { previewFitMode: 'cover' } }));
|
||||
expect(loadPreviewFitMode()).toBe('default');
|
||||
});
|
||||
|
||||
@@ -110,3 +118,38 @@ describe('settings-store · preview.fitMode', () => {
|
||||
expect(loadUiTheme()).toBe('dark'); // the other slice survives the merge
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings-store · full UserSettings record', () => {
|
||||
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('loadUserSettings returns defaults when nothing is stored', () => {
|
||||
expect(loadUserSettings()).toEqual(defaultSettings());
|
||||
});
|
||||
|
||||
it('loadUserSettings normalizes a partial stored record', () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ editor: { fontSize: 99 } }));
|
||||
const loaded = loadUserSettings();
|
||||
expect(loaded.editor.fontSize).toBe(18); // clamped to the 10–18 range
|
||||
expect(loaded.performance.renderDebounce).toBe(1500); // gap filled from defaults
|
||||
});
|
||||
|
||||
it('saveManagedSettings round-trips and leaves the ui slice untouched', () => {
|
||||
saveUiTheme('dark');
|
||||
savePreviewFitMode('width');
|
||||
|
||||
const managed = defaultSettings();
|
||||
managed.editor.fontSize = 16;
|
||||
managed.performance.renderDebounce = 800;
|
||||
managed.formatting.dateFormat = 'iso';
|
||||
saveManagedSettings(managed);
|
||||
|
||||
const loaded = loadUserSettings();
|
||||
expect(loaded.editor.fontSize).toBe(16);
|
||||
expect(loaded.performance.renderDebounce).toBe(800);
|
||||
expect(loaded.formatting.dateFormat).toBe('iso');
|
||||
// The ui slice (written by the narrow slice writers) survives the managed merge.
|
||||
expect(loaded.ui.theme).toBe('dark');
|
||||
expect(loaded.ui.previewFitMode).toBe('width');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
/**
|
||||
* Settings persistence (localStorage) — docs/architecture/02 §5.
|
||||
* Settings persistence (localStorage) — docs/architecture/02 §5, spec §07/§09C.
|
||||
*
|
||||
* The authoritative home for `ui.theme` is the *UserSettings* record under the
|
||||
* `astrolabe:settings` key (spec §09C). M1.5 pulls the **theme** slice forward
|
||||
* (the toggle ships before the Settings modal), so this adapter currently wires
|
||||
* only `ui.theme`. It reads/writes with **load-with-fallback + write-through
|
||||
* merge**: a partial record written now is preserved key-for-key, so when M5
|
||||
* builds the full UserSettings adapter on this same key it upgrades cleanly
|
||||
* rather than clobbering anything.
|
||||
* The home for the *UserSettings* record (spec §09C) under `astrolabe:settings`.
|
||||
* M1.5/M2 pulled the **theme** and **preview fit mode** slices forward (their
|
||||
* controls shipped first); M5 adds the full record — editor, performance, and
|
||||
* formatting groups — on the same key.
|
||||
*
|
||||
* Everything reads with **load-with-fallback** (the pure `loadSettings` in
|
||||
* `@core/settings` normalizes whatever is stored back onto a valid record) and
|
||||
* writes with a **field-level write-through merge**, so the narrow per-slice
|
||||
* writers (theme, fit mode) and the managed-settings write never clobber each
|
||||
* other: each merges into the stored record and leaves the other groups intact.
|
||||
*
|
||||
* Per the architecture rule, this is one of the only modules that may touch
|
||||
* `localStorage`; everything else goes through these typed functions.
|
||||
*/
|
||||
|
||||
import type { FitMode } from '@core/rendering';
|
||||
import { loadSettings, type UserSettings } from '@core/settings';
|
||||
import type { UiTheme } from '@core/theme';
|
||||
|
||||
const KEY = 'astrolabe:settings';
|
||||
@@ -24,10 +28,12 @@ const DEFAULT_THEME: UiTheme = 'light';
|
||||
/** Spec §04 — the Fit control defaults to Original. */
|
||||
const DEFAULT_FIT_MODE: FitMode = 'default';
|
||||
|
||||
/** Loose view of the stored record — M5 will give this its full typed shape. */
|
||||
/** The managed slice the per-pane settings clusters own (theme + fit live in their own slices). */
|
||||
export type ManagedSettings = Pick<UserSettings, 'editor' | 'performance' | 'formatting'>;
|
||||
|
||||
/** Loose view of the stored record for the per-slice write-through merges. */
|
||||
interface StoredSettings {
|
||||
ui?: { theme?: unknown; [k: string]: unknown };
|
||||
preview?: { fitMode?: unknown; [k: string]: unknown };
|
||||
ui?: { theme?: unknown; previewFitMode?: unknown; [k: string]: unknown };
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -70,6 +76,28 @@ function isFitMode(v: unknown): v is FitMode {
|
||||
return v === 'default' || v === 'width' || v === 'height' || v === 'full';
|
||||
}
|
||||
|
||||
/**
|
||||
* The full, normalized UserSettings record — the load-with-fallback gate
|
||||
* (spec §07 "Startup load"). Used to hydrate the Settings store at startup.
|
||||
*/
|
||||
export function loadUserSettings(): UserSettings {
|
||||
return loadSettings(readRaw());
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the managed groups (editor / performance / formatting),
|
||||
* merging into the stored record so the `ui` slice (theme, fit mode) is untouched.
|
||||
*/
|
||||
export function saveManagedSettings(managed: ManagedSettings): void {
|
||||
const current = readRaw();
|
||||
writeRaw({
|
||||
...current,
|
||||
editor: managed.editor,
|
||||
performance: managed.performance,
|
||||
formatting: managed.formatting,
|
||||
});
|
||||
}
|
||||
|
||||
/** The persisted UI theme, or the default — unknown/legacy values fall back. */
|
||||
export function loadUiTheme(): UiTheme {
|
||||
const stored = readRaw().ui?.theme;
|
||||
@@ -86,12 +114,12 @@ export function saveUiTheme(theme: UiTheme): void {
|
||||
|
||||
/** The persisted preview fit mode, or the default — unknown values fall back. */
|
||||
export function loadPreviewFitMode(): FitMode {
|
||||
const stored = readRaw().preview?.fitMode;
|
||||
const stored = readRaw().ui?.previewFitMode;
|
||||
return isFitMode(stored) ? stored : DEFAULT_FIT_MODE;
|
||||
}
|
||||
|
||||
/** Persist the preview fit mode, preserving every other key already in the record. */
|
||||
export function savePreviewFitMode(fitMode: FitMode): void {
|
||||
const current = readRaw();
|
||||
writeRaw({ ...current, preview: { ...current.preview, fitMode } });
|
||||
writeRaw({ ...current, ui: { ...current.ui, previewFitMode: fitMode } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user