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
+13 -11
View File
@@ -227,10 +227,10 @@ export async function saveSnippet(s: Snippet): Promise<void> {
Small, frequently-read structured records live in `localStorage`, not IndexedDB: **UserSettings** (one record) and **app/UI preferences** (snippet sort, panel layout). Why split them from `UserSettings`? UI prefs change often (drag a panel divider, toggle a sort) and shouldn't force a rewrite of the whole settings blob on every interaction.
The pattern is **load-with-fallback, write-through on change.**
The pattern is **load-with-fallback, per-slice write-through merge.**
- **Load-with-fallback:** merge the parsed stored object over a complete `DEFAULTS` constant. Missing keys (a setting added in a later build) and malformed JSON silently fall back to defaults — the app always gets a fully-populated object and never `undefined`-crashes on a new field.
- **Write-through:** every update reads current, applies the change, and writes the whole record back immediately. No dirty-tracking, no flush step.
- **Load-with-fallback:** merge the parsed stored object over a complete `DEFAULTS` constant. Missing keys (a setting added in a later build) and malformed JSON silently fall back to defaults — the app always gets a fully-populated object and never `undefined`-crashes on a new field. For `UserSettings`, the normalization is **pure and lives in `@core/settings` (`loadSettings(raw)` / `defaultSettings()`)** — it clamps ranges and validates enums; the infra adapter is just the thin localStorage reader (`loadUserSettings()` = `loadSettings(readRaw())`).
- **Per-slice write-through merge:** every update reads current, merges in **just its slice**, and writes back. **The one `astrolabe:settings` record has multiple independent writers**, because settings are distributed and live-applied (spec §07 — no central save, no Apply step): the header theme toggle writes `ui.theme`, the preview Fit control writes `ui.previewFitMode`, and the per-pane settings clusters write `editor`/`performance`/`formatting`. **A writer that replaced the whole record would clobber the slices it doesn't own** — so each must field-merge. (There is deliberately no whole-record `saveSettings`.)
- **Environment-guarded:** `localStorage` is absent or throws in some test/SSR contexts; guard access and degrade to defaults rather than throwing.
```ts
@@ -307,16 +307,18 @@ export function loadSettings(): UserSettings {
}
}
export function saveSettings(s: UserSettings): void {
if (!available()) return;
try {
localStorage.setItem(KEY, JSON.stringify({ ...s, version: CURRENT_SETTINGS_VERSION }));
} catch (err) {
console.warn('[settings] failed to save', err);
}
}
// No whole-record save — each live control merges only its slice into the shared
// record, so the others survive (see the per-slice rule above):
// saveUiTheme(theme) -> { ...current, ui: { ...current.ui, theme } }
// savePreviewFitMode(mode) -> { ...current, ui: { ...current.ui, previewFitMode } }
// saveManagedSettings(managed) -> { ...current, editor, performance, formatting }
```
> The above sketch keeps the `DEFAULTS`/merge shape inline for illustration, but
> the **authoritative** defaults + normalization now live in `@core/settings`
> (pure); the infra adapter calls them and owns only the localStorage IO + the
> slice writers. Keep the two in sync via that one core source, not a second copy here.
App/UI prefs follow the identical guard+fallback pattern, but live in **one
record under their own key**, `astrolabe:ux-prefs` (the snippet sort and the
panel layout together — the plan's "ux-prefs for sort + panel layout", §09D):