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:
@@ -386,12 +386,13 @@ entries).
|
|||||||
|
|
||||||
**App**
|
**App**
|
||||||
|
|
||||||
- Settings **modal** (Appearance/Editor/Performance/Formatting), Apply/Cancel/Reset,
|
- **Distributed settings** — not a modal (design review, see spec §07 + arch 10). Each
|
||||||
dirty indicator; wire render-debounce + theme + date-format through to the app.
|
cluster is a per-pane disclosure popover that applies **live**: Editor settings in the
|
||||||
Theme already switches (M1.5 header toggle + chart/editor themes) — M5 surfaces
|
editor toolbar, render debounce in the preview, date format in the library; theme stays
|
||||||
it inside the modal too (sharing the same `ui.theme`) and wires the rest.
|
the header toggle. A shared `SettingsPopover` primitive (gear + non-modal popover, APG
|
||||||
|
disclosure) backs all three. Wire render-debounce + editor options + date-format through.
|
||||||
- Header **Import**/**Export** (direct file dialog / download, no modal).
|
- Header **Import**/**Export** (direct file dialog / download, no modal).
|
||||||
- Date formatting util (smart/iso/custom) used by the library list.
|
- Date formatting util (smart/iso/custom) used by the library list + metadata panel.
|
||||||
|
|
||||||
**Tests**
|
**Tests**
|
||||||
|
|
||||||
|
|||||||
@@ -179,8 +179,13 @@ Each cohesive feature owns a store holding its durable domain state.
|
|||||||
- **`useSnippetStore`** — the snippet library: `snippets`, `activeSnippetId`, the
|
- **`useSnippetStore`** — the snippet library: `snippets`, `activeSnippetId`, the
|
||||||
working `draftSpec`, and its actions.
|
working `draftSpec`, and its actions.
|
||||||
- **`useDatasetStore`** — loaded datasets, the active dataset, inferred fields.
|
- **`useDatasetStore`** — loaded datasets, the active dataset, inferred fields.
|
||||||
- **`useSettingsStore`** — user preferences (editor options, render debounce,
|
- **`useUserSettingsStore`** — the **managed** user preferences applied _live_ as
|
||||||
date format, theme); mirrors what gets persisted to `localStorage`.
|
`saved` (editor options, render debounce, date format). Its per-cluster setters
|
||||||
|
are called by the per-pane settings popovers; the editor/preview/library read
|
||||||
|
`saved.*`. There is **no draft/Apply** — settings are distributed and commit on
|
||||||
|
change (spec §07). **UI theme and preview fit mode are NOT here** — they're
|
||||||
|
cross-cutting and live in `useAppStore` (header toggle, Fit control); all three
|
||||||
|
persist to the one `astrolabe:settings` record via slice writers (arch 02 §5).
|
||||||
|
|
||||||
### Global overlay stores (imperative trigger)
|
### Global overlay stores (imperative trigger)
|
||||||
|
|
||||||
@@ -189,6 +194,10 @@ ephemeral request state, not durable data:
|
|||||||
|
|
||||||
- **`useConfirmStore`** — the blocking confirm dialog (the `window.confirm` replacement).
|
- **`useConfirmStore`** — the blocking confirm dialog (the `window.confirm` replacement).
|
||||||
- **`useNotificationStore`** — non-blocking toasts (failed saves, etc.).
|
- **`useNotificationStore`** — non-blocking toasts (failed saves, etc.).
|
||||||
|
- **`useSettingsPopoverStore`** — which per-pane settings disclosure is open (one
|
||||||
|
at a time); its imperative `openSettingsPopover(id)` lets the Cmd/Ctrl+, shortcut
|
||||||
|
open the editor cluster. The disclosure widget contract (gear + non-modal popover,
|
||||||
|
not an ARIA menu; Esc/focus rules) is [10 · Interaction & Feedback](10-interaction-and-feedback.md) §5.
|
||||||
|
|
||||||
Each pairs its store with a thin **imperative trigger** exported alongside the hook —
|
Each pairs its store with a thin **imperative trigger** exported alongside the hook —
|
||||||
`confirm(opts): Promise<boolean>` and `notify(opts): string` — so orchestration/services can
|
`confirm(opts): Promise<boolean>` and `notify(opts): string` — so orchestration/services can
|
||||||
|
|||||||
@@ -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.
|
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.
|
- **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())`).
|
||||||
- **Write-through:** every update reads current, applies the change, and writes the whole record back immediately. No dirty-tracking, no flush step.
|
- **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.
|
- **Environment-guarded:** `localStorage` is absent or throws in some test/SSR contexts; guard access and degrade to defaults rather than throwing.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -307,16 +307,18 @@ export function loadSettings(): UserSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveSettings(s: UserSettings): void {
|
// No whole-record save — each live control merges only its slice into the shared
|
||||||
if (!available()) return;
|
// record, so the others survive (see the per-slice rule above):
|
||||||
try {
|
// saveUiTheme(theme) -> { ...current, ui: { ...current.ui, theme } }
|
||||||
localStorage.setItem(KEY, JSON.stringify({ ...s, version: CURRENT_SETTINGS_VERSION }));
|
// savePreviewFitMode(mode) -> { ...current, ui: { ...current.ui, previewFitMode } }
|
||||||
} catch (err) {
|
// saveManagedSettings(managed) -> { ...current, editor, performance, formatting }
|
||||||
console.warn('[settings] failed to save', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 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
|
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
|
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):
|
panel layout together — the plan's "ux-prefs for sort + panel layout", §09D):
|
||||||
|
|||||||
@@ -310,6 +310,31 @@ export function dedupeIncomingDatasetNames(
|
|||||||
> §6 over the imported snippet set, or run `renameDatasetEverywhere` per applied
|
> §6 over the imported snippet set, or run `renameDatasetEverywhere` per applied
|
||||||
> rename after the import is committed.
|
> rename after the import is committed.
|
||||||
|
|
||||||
|
### 5.1 Where the import/export flow lives, and its rules
|
||||||
|
|
||||||
|
**Flow:** header (Import/Export buttons in `App.tsx`) → `services/transfer.ts`
|
||||||
|
(the only store-touching layer) → pure core (`core/import-normalize.ts` shape
|
||||||
|
detection + normalization + the dedupe/rename/id-reassign helpers; `core/export-envelope.ts`)
|
||||||
|
|
||||||
|
- browser IO (`infrastructure/file-transfer.ts`). The pure helpers are unit-tested
|
||||||
|
hardest; `transfer.ts` only orchestrates (read stores → call core → commit →
|
||||||
|
notify). The behavioral contract is spec §08.
|
||||||
|
|
||||||
|
Three rules a future change must keep:
|
||||||
|
|
||||||
|
- **Datasets commit before snippets** (`DatasetStore.addDatasets` then
|
||||||
|
`SnippetStore.addSnippets`) so a snippet's by-name reference resolves against the
|
||||||
|
just-added (possibly suffixed) dataset.
|
||||||
|
- **Imported datasets get fresh monotonic numeric ids** (`addDatasets`), not their
|
||||||
|
envelope ids. Safe — and necessary — because datasets are linked **by name, not
|
||||||
|
id** (§1): id reuse would collide in IndexedDB, but renaming the _id_ breaks
|
||||||
|
nothing. (This is why the old `Date.now()`-collision TODO on `add` doesn't bite import.)
|
||||||
|
- **Rename propagation reads the spec, not only `datasetRefs`.**
|
||||||
|
`applyDatasetRenamesToSnippets` finds the referenced name via
|
||||||
|
`extractDatasetRefs(spec)` ∪ `datasetRefs`, so an imported snippet whose
|
||||||
|
`datasetRefs` is absent/stale (a hand-crafted or foreign file) still gets its
|
||||||
|
spec rewritten — the renderer resolves by spec, so a missed rename would break it.
|
||||||
|
|
||||||
**Do**
|
**Do**
|
||||||
|
|
||||||
- Reserve each chosen name immediately so collisions _within_ one import are
|
- Reserve each chosen name immediately so collisions _within_ one import are
|
||||||
|
|||||||
@@ -207,8 +207,8 @@ contract; cite it, not the external source.)_
|
|||||||
other shows the text visually with no live role. Two live regions would announce the same
|
other shows the text visually with no live role. Two live regions would announce the same
|
||||||
message twice.
|
message twice.
|
||||||
|
|
||||||
**Resolved — feature-modal dismissal & initial focus.** A feature modal (Datasets, and
|
**Resolved — feature-modal dismissal & initial focus.** A feature modal (Datasets, Chart
|
||||||
later Settings/Chart Builder) is a **passive** `dialog-modal`: dismissed by the close
|
Builder) is a **passive** `dialog-modal`: dismissed by the close
|
||||||
button, Escape, or a backdrop click (a passive modal carries no in-flight transaction, so
|
button, Escape, or a backdrop click (a passive modal carries no in-flight transaction, so
|
||||||
an outside click is a safe cancel — unlike the `alertdialog` confirm, where backdrop-dismiss
|
an outside click is a safe cancel — unlike the `alertdialog` confirm, where backdrop-dismiss
|
||||||
is forbidden). `role="dialog"` + `aria-modal` + `aria-labelledby` the title; focus is trapped
|
is forbidden). `role="dialog"` + `aria-modal` + `aria-labelledby` the title; focus is trapped
|
||||||
@@ -220,6 +220,24 @@ content's start is perceived rather than skipped to the first control; a small f
|
|||||||
(Extract) focuses its **primary field**. _(Consulted via /council → WAI-ARIA APG
|
(Extract) focuses its **primary field**. _(Consulted via /council → WAI-ARIA APG
|
||||||
`dialog-modal`. This bullet is the contract; cite it, not the APG file.)_
|
`dialog-modal`. This bullet is the contract; cite it, not the APG file.)_
|
||||||
|
|
||||||
|
**Resolved — settings are distributed, not a modal; each cluster is a disclosure popover.**
|
||||||
|
Preferences (spec §07) live next to what they affect and apply **live**: theme is the header
|
||||||
|
toggle, editor settings open from the editor toolbar, render debounce from the preview, date
|
||||||
|
format from the library. This matches the already-distributed theme + fit-mode controls,
|
||||||
|
makes a change's effect visible in the pane being configured, and keeps each block
|
||||||
|
independently extensible — so there is **no central Settings modal and no Apply/Cancel/dirty
|
||||||
|
commit step** (changes are individually reversible; the editor cluster offers a Reset). The
|
||||||
|
disclosure mechanism is a **gear button + non-modal popover**, _not_ an ARIA menu: a menu
|
||||||
|
lists actions/commands (`menuitem`/`menuitemcheckbox`/`menuitemradio`), but these panels hold
|
||||||
|
sliders, number/text inputs, and radio groups, so the container is a labelled `group`. The
|
||||||
|
gear carries `aria-expanded` + `aria-controls`; Enter/Space toggle; **Esc closes and returns
|
||||||
|
focus to the gear**; an outside click closes; at most one is open at a time; focus moves to
|
||||||
|
the first control on open (so `Cmd/Ctrl+,`, which opens the editor cluster, lands inside it).
|
||||||
|
Non-modal — **no focus trap** (unlike the feature modal above). The panel is portaled to
|
||||||
|
`<body>` and positioned `fixed` because the panes clip their content. _(Consulted via /council
|
||||||
|
→ NN/g #4 consistency, #6 recognition-over-recall, #8 minimalist; WAI-ARIA APG disclosure +
|
||||||
|
menu-and-menubar; Carbon popover/overflow-menu/text-toolbar. This bullet is the contract.)_
|
||||||
|
|
||||||
**Resolved — an error names the right fix, not a boilerplate one.** Don't staple a generic
|
**Resolved — an error names the right fix, not a boilerplate one.** Don't staple a generic
|
||||||
remedy onto every failure. A missing dataset reference is **not** a JSON/spec syntax problem,
|
remedy onto every failure. A missing dataset reference is **not** a JSON/spec syntax problem,
|
||||||
so the preview gives it a tailored, fixable line — _"Dataset «X» not found. Create it from
|
so the preview gives it a tailored, fixable line — _"Dataset «X» not found. Create it from
|
||||||
|
|||||||
+16
-15
@@ -1,13 +1,15 @@
|
|||||||
# 07 · Settings
|
# 07 · Settings
|
||||||
|
|
||||||
Astrolabe provides a **Settings** modal where users tune appearance, the spec editor, preview performance, and date formatting. All settings persist locally and apply across sessions on the same device. Settings load at startup; any unknown or missing value falls back to its factory default, so older or partial saved settings never break the app.
|
Astrolabe lets users tune appearance, the spec editor, preview performance, and date formatting. Rather than a separate Settings modal, **each preference lives next to what it affects and applies immediately** — the appearance theme is a header toggle, editor preferences sit in the editor pane, render performance in the preview pane, and date formatting in the library. All settings persist locally and apply across sessions on the same device. Settings load at startup; any unknown or missing value falls back to its factory default, so older or partial saved settings never break the app.
|
||||||
|
|
||||||
## Opening the modal
|
> **Why distributed, not a modal (resolved).** Settings were originally specified as one central modal with an explicit Apply/Cancel commit. A design review (see _Architecture 10 · Interaction & Feedback_) moved them to per-pane, live-applied controls: it matches how theme and preview fit mode already work, lets a change’s effect be seen in the very pane being configured, and keeps each settings block independently extensible. The settings, options, and defaults below are unchanged — only their presentation and commit model changed.
|
||||||
|
|
||||||
- An entry in the application header opens the Settings modal.
|
## Opening the settings
|
||||||
- The keyboard shortcut **Cmd/Ctrl+,** also opens it.
|
|
||||||
- The modal is grouped into clearly titled sections: Appearance, Editor, Performance, and Formatting.
|
- **Appearance (UI theme)** is a one-click toggle in the application header.
|
||||||
- The modal can be dismissed with a Cancel action or the standard modal-close affordance; dismissing without applying discards any pending edits and restores the last saved values.
|
- **Editor**, **Performance**, and **Formatting** clusters are each opened by a small **settings (gear) control** in the toolbar of the pane they govern — the editor pane, the preview pane, and the library, respectively. The control discloses a popover of that cluster’s controls.
|
||||||
|
- The keyboard shortcut **Cmd/Ctrl+,** opens the **Editor** settings cluster (the primary configuration surface).
|
||||||
|
- A disclosed popover is dismissed with **Esc** (which returns focus to its gear) or by clicking outside it; at most one settings popover is open at a time.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
@@ -19,9 +21,9 @@ Controls the overall UI theme. Choosing the Dark theme switches the whole applic
|
|||||||
| -------- | ----------- | ------- |
|
| -------- | ----------- | ------- |
|
||||||
| UI theme | Light, Dark | Light |
|
| UI theme | Light, Dark | Light |
|
||||||
|
|
||||||
The UI theme is also exposed as a **header toggle** for one-click switching; it
|
The UI theme is a one-click **header toggle** (shipped in M1.5). It reads and
|
||||||
reads and writes the same persisted `ui.theme` value as this Appearance control,
|
writes the persisted `ui.theme` value directly and applies immediately — there is
|
||||||
so the two always agree. (The toggle shipped in M1.5, ahead of this modal.)
|
no separate Appearance control to keep in sync.
|
||||||
|
|
||||||
### Editor
|
### Editor
|
||||||
|
|
||||||
@@ -69,15 +71,14 @@ Governs how dates are rendered throughout the app, for example the timestamps sh
|
|||||||
|
|
||||||
## Related persisted preferences (documented elsewhere)
|
## Related persisted preferences (documented elsewhere)
|
||||||
|
|
||||||
The following preferences also persist locally across sessions but are managed outside this modal and are documented in their own sections:
|
The following preferences also persist locally across sessions and, like the clusters above, are managed by controls in the pane they affect; they are documented in their own sections:
|
||||||
|
|
||||||
- **Preview fit mode** — how the preview is sized/fit; see _Live Preview_.
|
- **Preview fit mode** — how the preview is sized/fit; see _Live Preview_.
|
||||||
- **Snippet sort preference** — the snippet list's sort field and direction; see _Snippet Library_.
|
- **Snippet sort preference** — the snippet list's sort field and direction; see _Snippet Library_.
|
||||||
|
|
||||||
## Behaviors
|
## Behaviors
|
||||||
|
|
||||||
- **Apply / save**: An explicit Apply action writes all changes; they take effect immediately (e.g. the UI theme switches at once).
|
- **Live apply**: Every control applies its change immediately — there is no Apply/Cancel commit step. The effect is visible in the pane being configured (the editor reflows, the preview re-renders at the new debounce, the library re-formats its dates), so no separate confirmation is needed. This matches the always-live header theme toggle and preview Fit control.
|
||||||
- **Dirty indication**: While the form differs from the last saved state, the modal shows an "Unsaved changes" indicator.
|
- **Reset to defaults**: The **Editor** cluster offers a Reset that restores the editor settings to their factory defaults. (Other clusters are single, self-evident controls; there is no global "reset everything" — each control is individually reversible.)
|
||||||
- **Cancel / dismiss**: Closing without applying reverts the form to the last saved values and leaves stored settings untouched.
|
- **No dirty / discard state**: Because changes commit as made, there is no "unsaved changes" indicator and nothing to discard on dismiss; closing a settings popover simply hides it.
|
||||||
- **Reset to defaults**: A Reset action restores every setting to its factory default. It requires explicit confirmation before applying, then saves the defaults.
|
- **Startup load**: Settings are read on startup and applied to the UI, editor, preview, and library; missing or unrecognized values silently use their defaults.
|
||||||
- **Startup load**: Settings are read on startup and applied to the UI and editor; missing or unrecognized values silently use their defaults.
|
|
||||||
|
|||||||
@@ -85,6 +85,11 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The Import file picker is driven programmatically by its header button. */
|
||||||
|
.hiddenInput {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.panes {
|
.panes {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|||||||
+47
-4
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||||
import { LivePreview } from './components/LivePreview';
|
import { LivePreview } from './components/LivePreview';
|
||||||
import { ModalShell } from './components/ModalShell';
|
import { ModalShell } from './components/ModalShell';
|
||||||
@@ -8,8 +8,10 @@ import { SpecEditor } from './components/SpecEditor';
|
|||||||
import { ThemeToggle } from './components/ThemeToggle';
|
import { ThemeToggle } from './components/ThemeToggle';
|
||||||
import { Toaster } from './components/Toaster';
|
import { Toaster } from './components/Toaster';
|
||||||
import { openModal, setConfirm, toggleDatasets } from './modals/ModalCoordinator';
|
import { openModal, setConfirm, toggleDatasets } from './modals/ModalCoordinator';
|
||||||
|
import { openSettingsPopover } from './stores/SettingsPopoverStore';
|
||||||
import { confirm } from './stores/ConfirmStore';
|
import { confirm } from './stores/ConfirmStore';
|
||||||
import { usePanesStore } from './stores/PanesStore';
|
import { usePanesStore } from './stores/PanesStore';
|
||||||
|
import { exportWorkspace, importWorkspace } from './services/transfer';
|
||||||
import styles from './App.module.css';
|
import styles from './App.module.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +26,9 @@ import styles from './App.module.css';
|
|||||||
export function App() {
|
export function App() {
|
||||||
const libraryWidth = usePanesStore((s) => s.libraryWidth);
|
const libraryWidth = usePanesStore((s) => s.libraryWidth);
|
||||||
const previewWidth = usePanesStore((s) => s.previewWidth);
|
const previewWidth = usePanesStore((s) => s.previewWidth);
|
||||||
|
// Hidden file input driving Import — the header button proxies its click so the
|
||||||
|
// browser file picker is the only chrome (spec §08 → no intermediate dialog).
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Route the modal coordinator's discard prompt through the in-app confirm
|
// Route the modal coordinator's discard prompt through the in-app confirm
|
||||||
// dialog (docs/architecture/03 → "The coordinator seam").
|
// dialog (docs/architecture/03 → "The coordinator seam").
|
||||||
@@ -31,19 +36,31 @@ export function App() {
|
|||||||
setConfirm((message) => confirm({ title: 'Discard changes?', message, danger: true }));
|
setConfirm((message) => confirm({ title: 'Discard changes?', message, danger: true }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Cmd/Ctrl+K toggles the Datasets manager (spec §05 → Opening). The full
|
// Cmd/Ctrl+K toggles the Datasets manager (spec §05); Cmd/Ctrl+, opens the
|
||||||
// keyboard router (other shortcuts) lands in M6 (docs/architecture/04).
|
// editor settings popover — the primary preference cluster, now that settings
|
||||||
|
// are distributed per pane (spec §07). Full key router lands in M6 (arch 04).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
if (!(e.metaKey || e.ctrlKey)) return;
|
||||||
|
if (e.key === 'k' || e.key === 'K') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toggleDatasets();
|
toggleDatasets();
|
||||||
|
} else if (e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
openSettingsPopover('editor-settings');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
// Reset the input so picking the same file again still fires onChange.
|
||||||
|
e.target.value = '';
|
||||||
|
if (file) void importWorkspace(file);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.app}>
|
<div className={styles.app}>
|
||||||
{/* Skip link (WCAG 2.4.1 / GOV.UK): the first focusable element, hidden
|
{/* Skip link (WCAG 2.4.1 / GOV.UK): the first focusable element, hidden
|
||||||
@@ -64,7 +81,33 @@ export function App() {
|
|||||||
>
|
>
|
||||||
Datasets
|
Datasets
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.headerButton}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
title="Import a workspace JSON file"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.headerButton}
|
||||||
|
onClick={() => exportWorkspace()}
|
||||||
|
title="Export your workspace to a JSON file"
|
||||||
|
>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
{/* Hidden picker for Import; restricted to JSON (spec §08). */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
className={styles.hiddenInput}
|
||||||
|
onChange={handleImportFile}
|
||||||
|
aria-hidden="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* tabIndex -1 makes the landmark a focus target for the skip link. */}
|
{/* tabIndex -1 makes the landmark a focus target for the skip link. */}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type IconName =
|
|||||||
| 'dataset' // "references a dataset" — Carbon DataTable
|
| 'dataset' // "references a dataset" — Carbon DataTable
|
||||||
| 'delete' // delete — Carbon TrashCan
|
| 'delete' // delete — Carbon TrashCan
|
||||||
| 'add' // add / create-new — Carbon Add
|
| 'add' // add / create-new — Carbon Add
|
||||||
|
| 'settings' // per-pane settings disclosure (gear) — Carbon Settings
|
||||||
// Status sub-family (arch 09 §5.2) — Carbon's FILLED notification glyphs, coloured
|
// Status sub-family (arch 09 §5.2) — Carbon's FILLED notification glyphs, coloured
|
||||||
// by status (not text). A redundant non-colour severity channel (WCAG 1.4.1): the
|
// by status (not text). A redundant non-colour severity channel (WCAG 1.4.1): the
|
||||||
// triangle shape-codes warning apart from the round error/success/info.
|
// triangle shape-codes warning apart from the round error/success/info.
|
||||||
@@ -44,6 +45,12 @@ const GLYPHS: Record<IconName, ReactNode> = {
|
|||||||
<polygon points="17.4141 16 24 9.4141 22.5859 8 16 14.5859 9.4143 8 8 9.4141 14.5859 16 8 22.5859 9.4143 24 16 17.4141 22.5859 24 24 22.5859 17.4141 16" />
|
<polygon points="17.4141 16 24 9.4141 22.5859 8 16 14.5859 9.4143 8 8 9.4141 14.5859 16 8 22.5859 9.4143 24 16 17.4141 22.5859 24 24 22.5859 17.4141 16" />
|
||||||
),
|
),
|
||||||
add: <polygon points="17,15 17,8 15,8 15,15 8,15 8,17 15,17 15,24 17,24 17,17 24,17 24,15" />,
|
add: <polygon points="17,15 17,8 15,8 15,15 8,15 8,17 15,17 15,24 17,24 17,17 24,17 24,15" />,
|
||||||
|
settings: (
|
||||||
|
<>
|
||||||
|
<path d="M27,16.76c0-.25,0-.5,0-.76s0-.51,0-.77l1.92-1.68A2,2,0,0,0,29.3,11L26.94,7a2,2,0,0,0-1.73-1,2,2,0,0,0-.64.1l-2.43.82a11.35,11.35,0,0,0-1.31-.75l-.51-2.52a2,2,0,0,0-2-1.61H13.64a2,2,0,0,0-2,1.61l-.51,2.52a11.48,11.48,0,0,0-1.32.75L7.43,6.06A2,2,0,0,0,6.79,6,2,2,0,0,0,5.06,7L2.7,11a2,2,0,0,0,.41,2.51L5,15.24c0,.25,0,.5,0,.76s0,.51,0,.77L3.11,18.45A2,2,0,0,0,2.7,21L5.06,25a2,2,0,0,0,1.73,1,2,2,0,0,0,.64-.1l2.43-.82a11.35,11.35,0,0,0,1.31.75l.51,2.52a2,2,0,0,0,2,1.61h4.72a2,2,0,0,0,2-1.61l.51-2.52a11.48,11.48,0,0,0,1.32-.75l2.42.82a2,2,0,0,0,.64.1,2,2,0,0,0,1.73-1L29.3,21a2,2,0,0,0-.41-2.51ZM25.21,24l-3.43-1.16a8.86,8.86,0,0,1-2.71,1.57L18.36,28H13.64l-.71-3.55a9.36,9.36,0,0,1-2.7-1.57L6.79,24,4.43,20l2.72-2.4a8.9,8.9,0,0,1,0-3.13L4.43,12,6.79,8l3.43,1.16a8.86,8.86,0,0,1,2.71-1.57L13.64,4h4.72l.71,3.55a9.36,9.36,0,0,1,2.7,1.57L25.21,8,27.57,12l-2.72,2.4a8.9,8.9,0,0,1,0,3.13L27.57,20Z" />
|
||||||
|
<path d="M16,22a6,6,0,1,1,6-6A5.94,5.94,0,0,1,16,22Zm0-10a3.91,3.91,0,0,0-4,4,3.91,3.91,0,0,0,4,4,3.91,3.91,0,0,0,4-4A3.91,3.91,0,0,0,16,12Z" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
delete: (
|
delete: (
|
||||||
<>
|
<>
|
||||||
<rect x="12" y="12" width="2" height="12" />
|
<rect x="12" y="12" width="2" height="12" />
|
||||||
|
|||||||
@@ -26,12 +26,11 @@ import { useAppStore } from '../stores/AppStore';
|
|||||||
import { useDatasetStore } from '../stores/DatasetStore';
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
import { usePreviewStore } from '../stores/PreviewStore';
|
import { usePreviewStore } from '../stores/PreviewStore';
|
||||||
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||||
|
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
|
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
|
||||||
import styles from './LivePreview.module.css';
|
import styles from './LivePreview.module.css';
|
||||||
|
|
||||||
/** Render debounce (ms). Becomes the configurable `renderDebounce` setting in M5. */
|
|
||||||
const RENDER_DEBOUNCE_MS = 300;
|
|
||||||
|
|
||||||
/** The four fit modes in display order (spec §04 → Fit / Sizing Modes). */
|
/** The four fit modes in display order (spec §04 → Fit / Sizing Modes). */
|
||||||
const FIT_OPTIONS: ReadonlyArray<SegmentedOption<FitMode>> = [
|
const FIT_OPTIONS: ReadonlyArray<SegmentedOption<FitMode>> = [
|
||||||
{ value: 'default', label: 'Original' },
|
{ value: 'default', label: 'Original' },
|
||||||
@@ -68,6 +67,27 @@ function FitControl() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Preview settings cluster (spec §07 → Performance), disclosed beside Fit. */
|
||||||
|
function PreviewSettings() {
|
||||||
|
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
|
||||||
|
const setPerformance = useUserSettingsStore((s) => s.setPerformance);
|
||||||
|
return (
|
||||||
|
<SettingsPopover id="preview-settings" label="Preview settings" title="Preview">
|
||||||
|
<SettingRow label="Render debounce" htmlFor="set-debounce">
|
||||||
|
<RangeControl
|
||||||
|
id="set-debounce"
|
||||||
|
min={500}
|
||||||
|
max={5000}
|
||||||
|
step={100}
|
||||||
|
value={renderDebounce}
|
||||||
|
suffix="ms"
|
||||||
|
onChange={(renderDebounce) => setPerformance({ renderDebounce })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsPopover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LivePreview() {
|
export function LivePreview() {
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const handleRef = useRef<RenderHandle | null>(null);
|
const handleRef = useRef<RenderHandle | null>(null);
|
||||||
@@ -84,6 +104,8 @@ export function LivePreview() {
|
|||||||
// two are unchanged the change is typing and the debounce applies.
|
// two are unchanged the change is typing and the debounce applies.
|
||||||
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
|
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
|
||||||
const editorView = useSnippetStore((s) => s.editorView);
|
const editorView = useSnippetStore((s) => s.editorView);
|
||||||
|
// User-tunable render debounce (spec §07 → Performance).
|
||||||
|
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
|
||||||
// Seed with a sentinel epoch so the very first paint counts as a load (immediate).
|
// Seed with a sentinel epoch so the very first paint counts as a load (immediate).
|
||||||
const lastLoadRef = useRef({ bufferEpoch: -1, editorView });
|
const lastLoadRef = useRef({ bufferEpoch: -1, editorView });
|
||||||
const error = usePreviewStore((s) => s.error);
|
const error = usePreviewStore((s) => s.error);
|
||||||
@@ -102,7 +124,7 @@ export function LivePreview() {
|
|||||||
const prevLoad = lastLoadRef.current;
|
const prevLoad = lastLoadRef.current;
|
||||||
const immediate = bufferEpoch !== prevLoad.bufferEpoch || editorView !== prevLoad.editorView;
|
const immediate = bufferEpoch !== prevLoad.bufferEpoch || editorView !== prevLoad.editorView;
|
||||||
lastLoadRef.current = { bufferEpoch, editorView };
|
lastLoadRef.current = { bufferEpoch, editorView };
|
||||||
const delay = immediate ? 0 : RENDER_DEBOUNCE_MS;
|
const delay = immediate ? 0 : renderDebounce;
|
||||||
|
|
||||||
// The debounced body is async; wrap in a void IIFE so the timer callback
|
// The debounced body is async; wrap in a void IIFE so the timer callback
|
||||||
// returns void (it handles its own errors internally — nothing awaits it).
|
// returns void (it handles its own errors internally — nothing awaits it).
|
||||||
@@ -165,7 +187,7 @@ export function LivePreview() {
|
|||||||
}, delay);
|
}, delay);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [shownText, fitMode, uiTheme, datasets, setError, bufferEpoch, editorView]);
|
}, [shownText, fitMode, uiTheme, datasets, setError, bufferEpoch, editorView, renderDebounce]);
|
||||||
|
|
||||||
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
||||||
// observe the element, so we do: one observer on the stable host node for the
|
// observe the element, so we do: one observer on the stable host node for the
|
||||||
@@ -198,6 +220,7 @@ export function LivePreview() {
|
|||||||
<div className={styles.preview}>
|
<div className={styles.preview}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<FitControl />
|
<FitControl />
|
||||||
|
<PreviewSettings />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
{/* Frame is React-owned and carries the fit-sizing class; the inner host
|
{/* Frame is React-owned and carries the fit-sizing class; the inner host
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/* SettingsPopover — per-pane settings disclosure (spec §07; arch 10). */
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gear trigger — a subtle icon button matching the modal-shell close affordance. */
|
||||||
|
.gear {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: var(--border-width) solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease),
|
||||||
|
color var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gear:hover {
|
||||||
|
background: var(--layer-01);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gear[aria-expanded='true'] {
|
||||||
|
background: var(--layer-02);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gear:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The disclosed panel — portaled to <body>, positioned `fixed` from the gear's
|
||||||
|
rect (top/left|right set inline) so it escapes the panes' overflow clipping. */
|
||||||
|
.pop {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
width: 288px;
|
||||||
|
max-width: min(320px, 90vw);
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: var(--layer-01);
|
||||||
|
border: var(--border-width) solid var(--border-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row + .row {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: var(--border-width) solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared controls used inside popovers ------------------------------------- */
|
||||||
|
|
||||||
|
.range {
|
||||||
|
width: 128px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
min-width: 48px;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.number,
|
||||||
|
.text {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: var(--border-width) solid var(--border-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number {
|
||||||
|
width: 60px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
width: 160px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.number:focus-visible,
|
||||||
|
.text:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text::placeholder {
|
||||||
|
color: var(--text-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* SettingsPopover — the per-pane settings disclosure (spec §07; arch 10).
|
||||||
|
*
|
||||||
|
* Astrolabe distributes preferences to where they apply: a small gear button in a
|
||||||
|
* pane's toolbar discloses a popover of **live** controls for that cluster
|
||||||
|
* (editor / preview / library). This is the shared primitive behind all of them.
|
||||||
|
*
|
||||||
|
* It is a **disclosure + non-modal popover** (WAI-ARIA APG disclosure; Carbon
|
||||||
|
* popover), deliberately NOT an ARIA menu: a menu lists actions/commands
|
||||||
|
* (menuitem/checkbox/radio), whereas these panels hold sliders, number/text
|
||||||
|
* inputs, and radio groups — so the container is a labelled `group`, not a
|
||||||
|
* `menu`. The gear carries `aria-expanded` + `aria-controls`; Enter/Space toggle;
|
||||||
|
* Esc closes and returns focus to the gear; an outside click closes. Non-modal,
|
||||||
|
* so there is no focus trap (unlike the modal shell). At most one popover is open
|
||||||
|
* at a time, and any can be opened imperatively (the Cmd/Ctrl+, shortcut).
|
||||||
|
*
|
||||||
|
* The panel is **portaled to `document.body`** and positioned `fixed` from the
|
||||||
|
* gear's rect, because the panes clip their content (`overflow: auto/hidden`); an
|
||||||
|
* in-flow absolute popover would be cut off. Position re-measures on scroll/resize.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, type ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore';
|
||||||
|
import { Icon } from './Icon';
|
||||||
|
import styles from './SettingsPopover.module.css';
|
||||||
|
|
||||||
|
/** Gap (px) between the gear and the disclosed panel. */
|
||||||
|
const GAP = 6;
|
||||||
|
|
||||||
|
export interface SettingsPopoverProps {
|
||||||
|
/** Stable id, also the popover's element id for `aria-controls` (e.g. 'editor-settings'). */
|
||||||
|
id: string;
|
||||||
|
/** Accessible name for the gear button. */
|
||||||
|
label: string;
|
||||||
|
/** Heading shown at the top of the popover, and its group label. */
|
||||||
|
title: string;
|
||||||
|
/** Which edge of the gear the popover aligns to (default right). */
|
||||||
|
align?: 'left' | 'right';
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPopover({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
align = 'right',
|
||||||
|
children,
|
||||||
|
}: SettingsPopoverProps) {
|
||||||
|
const open = useSettingsPopoverStore((s) => s.openId === id);
|
||||||
|
const toggle = useSettingsPopoverStore((s) => s.toggle);
|
||||||
|
const close = useSettingsPopoverStore((s) => s.close);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const popRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// Position the (fixed) panel imperatively from the gear's rect — no React state,
|
||||||
|
// so there's no setState-in-effect and no re-render on scroll. The panes clip
|
||||||
|
// their content, hence the body portal + fixed positioning.
|
||||||
|
const place = useCallback(() => {
|
||||||
|
const trigger = triggerRef.current;
|
||||||
|
const pop = popRef.current;
|
||||||
|
if (!trigger || !pop) return;
|
||||||
|
const r = trigger.getBoundingClientRect();
|
||||||
|
pop.style.top = `${r.bottom + GAP}px`;
|
||||||
|
if (align === 'left') {
|
||||||
|
pop.style.left = `${r.left}px`;
|
||||||
|
pop.style.right = 'auto';
|
||||||
|
} else {
|
||||||
|
pop.style.right = `${window.innerWidth - r.right}px`;
|
||||||
|
pop.style.left = 'auto';
|
||||||
|
}
|
||||||
|
}, [align]);
|
||||||
|
|
||||||
|
// Re-place while open so the panel tracks the gear if the workspace scrolls/resizes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
window.addEventListener('resize', place);
|
||||||
|
window.addEventListener('scroll', place, true); // capture: catch pane scrolls too
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', place);
|
||||||
|
window.removeEventListener('scroll', place, true);
|
||||||
|
};
|
||||||
|
}, [open, place]);
|
||||||
|
|
||||||
|
// Esc closes + restores focus to the gear; an outside pointer click closes
|
||||||
|
// (APG disclosure; non-modal). Capture Esc so it settles here, not a parent.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
close();
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onPointer = (e: PointerEvent) => {
|
||||||
|
const t = e.target as Node;
|
||||||
|
if (!popRef.current?.contains(t) && !triggerRef.current?.contains(t)) close();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
document.addEventListener('pointerdown', onPointer, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey, true);
|
||||||
|
document.removeEventListener('pointerdown', onPointer, true);
|
||||||
|
};
|
||||||
|
}, [open, close]);
|
||||||
|
|
||||||
|
// Ref callback: on mount, position the panel before paint and move focus to the
|
||||||
|
// first control so keyboard users — including those who opened via Cmd/Ctrl+, —
|
||||||
|
// land inside it. Fires once per open (align is constant per instance).
|
||||||
|
const setPopNode = useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
popRef.current = node;
|
||||||
|
if (node) {
|
||||||
|
place();
|
||||||
|
node.querySelector<HTMLElement>('input, button, select, textarea, [tabindex]')?.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[place],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
className={styles.gear}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={id}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
onClick={() => toggle(id)}
|
||||||
|
>
|
||||||
|
<Icon name="settings" />
|
||||||
|
</button>
|
||||||
|
{open &&
|
||||||
|
createPortal(
|
||||||
|
<div ref={setPopNode} id={id} className={styles.pop} role="group" aria-label={title}>
|
||||||
|
<h4 className={styles.title}>{title}</h4>
|
||||||
|
{children}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A label + control row inside a settings popover (keeps clusters tidy + uniform). */
|
||||||
|
export function SettingRow({
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
htmlFor?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={styles.row}>
|
||||||
|
{htmlFor ? (
|
||||||
|
<label className={styles.label} htmlFor={htmlFor}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<span className={styles.label}>{label}</span>
|
||||||
|
)}
|
||||||
|
<div className={styles.control}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A right-aligned footer row for popover-level actions (e.g. Reset). */
|
||||||
|
export function SettingFooter({ children }: { children: ReactNode }) {
|
||||||
|
return <div className={styles.footer}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A range slider with a live value read-out (font size, render debounce, …). */
|
||||||
|
export function RangeControl({
|
||||||
|
id,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step = 1,
|
||||||
|
value,
|
||||||
|
suffix,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step?: number;
|
||||||
|
value: number;
|
||||||
|
suffix?: string;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="range"
|
||||||
|
className={styles.range}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span className={styles.value}>
|
||||||
|
{value}
|
||||||
|
{suffix ? ` ${suffix}` : ''}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A small integer number input (e.g. tab size). Coerces to a clamped integer. */
|
||||||
|
export function NumberControl({
|
||||||
|
id,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
className={styles.number}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={1}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(Math.min(max, Math.max(min, Math.round(Number(e.target.value) || min))))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A monospaced free-text input (e.g. the custom date format). */
|
||||||
|
export function TextControl({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
placeholder,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
placeholder?: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
className={styles.text}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A text-style reset action for a popover footer. */
|
||||||
|
export function ResetButton({ onClick, children }: { onClick: () => void; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button type="button" className={styles.reset} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,26 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Library toolbar — heading + settings gear (sort/search will join, spec §02). */
|
||||||
|
.toolbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 var(--space-2) 0 var(--space-4);
|
||||||
|
border-bottom: var(--border-width) solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.createNew {
|
.createNew {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { formatDate } from '@core/date-format';
|
||||||
import {
|
import {
|
||||||
formatSnippetSize,
|
formatSnippetSize,
|
||||||
hasUnpublishedChanges,
|
hasUnpublishedChanges,
|
||||||
@@ -21,36 +22,55 @@ import {
|
|||||||
import { confirm } from '../stores/ConfirmStore';
|
import { confirm } from '../stores/ConfirmStore';
|
||||||
import { notify } from '../stores/NotificationStore';
|
import { notify } from '../stores/NotificationStore';
|
||||||
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
|
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
|
||||||
|
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
|
import { SettingRow, SettingsPopover, TextControl } from './SettingsPopover';
|
||||||
import styles from './SnippetLibrary.module.css';
|
import styles from './SnippetLibrary.module.css';
|
||||||
|
|
||||||
|
/** Date display modes (spec §07 → Formatting). */
|
||||||
|
const DATE_FORMAT_OPTIONS: ReadonlyArray<SegmentedOption<'smart' | 'iso' | 'custom'>> = [
|
||||||
|
{ value: 'smart', label: 'Smart' },
|
||||||
|
{ value: 'iso', label: 'ISO' },
|
||||||
|
{ value: 'custom', label: 'Custom' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Library settings cluster (spec §07 → Formatting), disclosed from the library
|
||||||
|
* toolbar — it controls the timestamps the list and metadata panel render. Opens
|
||||||
|
* rightward (align left) so the wide panel clears the narrow left pane.
|
||||||
|
*/
|
||||||
|
function LibrarySettings() {
|
||||||
|
const formatting = useUserSettingsStore((s) => s.saved.formatting);
|
||||||
|
const setFormatting = useUserSettingsStore((s) => s.setFormatting);
|
||||||
|
return (
|
||||||
|
<SettingsPopover id="library-settings" label="Date format settings" title="Dates" align="left">
|
||||||
|
<SettingRow label="Date format">
|
||||||
|
<SegmentedControl
|
||||||
|
label="Date format"
|
||||||
|
options={DATE_FORMAT_OPTIONS}
|
||||||
|
value={formatting.dateFormat}
|
||||||
|
onChange={(dateFormat) => setFormatting({ dateFormat })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
{formatting.dateFormat === 'custom' && (
|
||||||
|
<SettingRow label="Custom" htmlFor="set-customdate">
|
||||||
|
<TextControl
|
||||||
|
id="set-customdate"
|
||||||
|
value={formatting.customDateFormat}
|
||||||
|
placeholder="yyyy-MM-dd HH:mm"
|
||||||
|
onChange={(customDateFormat) => setFormatting({ customDateFormat })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
)}
|
||||||
|
</SettingsPopover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Auto-save settle time for the metadata panel's Name/Comment fields, mirroring
|
/** Auto-save settle time for the metadata panel's Name/Comment fields, mirroring
|
||||||
* the editor's draft auto-save (spec §02 → "edits save automatically"). */
|
* the editor's draft auto-save (spec §02 → "edits save automatically"). */
|
||||||
const META_AUTOSAVE_MS = 400;
|
const META_AUTOSAVE_MS = 400;
|
||||||
|
|
||||||
/** Compact relative date for the list (full date formatting lands in M5). */
|
|
||||||
function relativeDate(iso: string): string {
|
|
||||||
const then = new Date(iso);
|
|
||||||
const now = new Date();
|
|
||||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
||||||
const dayMs = 24 * 60 * 60 * 1000;
|
|
||||||
const days = Math.floor(
|
|
||||||
(startOfToday.getTime() -
|
|
||||||
new Date(then.getFullYear(), then.getMonth(), then.getDate()).getTime()) /
|
|
||||||
dayMs,
|
|
||||||
);
|
|
||||||
if (days <= 0) return 'Today';
|
|
||||||
if (days === 1) return 'Yesterday';
|
|
||||||
if (days < 7) return `${days}d ago`;
|
|
||||||
return then.toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Absolute date-time for the metadata panel's read-only timestamps (the user's
|
|
||||||
* date-format setting wires in at M5; until then, the locale default). */
|
|
||||||
function formatTimestamp(iso: string): string {
|
|
||||||
return new Date(iso).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selected-Snippet Metadata Panel (spec §02). Keyed by snippet id by its caller,
|
* Selected-Snippet Metadata Panel (spec §02). Keyed by snippet id by its caller,
|
||||||
* so switching the active snippet remounts it and the local field state re-seeds
|
* so switching the active snippet remounts it and the local field state re-seeds
|
||||||
@@ -69,6 +89,7 @@ function SnippetMeta({
|
|||||||
}) {
|
}) {
|
||||||
const renameSnippet = useSnippetStore((s) => s.renameSnippet);
|
const renameSnippet = useSnippetStore((s) => s.renameSnippet);
|
||||||
const setComment = useSnippetStore((s) => s.setComment);
|
const setComment = useSnippetStore((s) => s.setComment);
|
||||||
|
const formatting = useUserSettingsStore((s) => s.saved.formatting);
|
||||||
const [name, setName] = useState(snippet.name);
|
const [name, setName] = useState(snippet.name);
|
||||||
const [comment, setCommentLocal] = useState(snippet.comment);
|
const [comment, setCommentLocal] = useState(snippet.comment);
|
||||||
|
|
||||||
@@ -112,11 +133,13 @@ function SnippetMeta({
|
|||||||
<dl className={styles.metaTimes}>
|
<dl className={styles.metaTimes}>
|
||||||
<div>
|
<div>
|
||||||
<dt>Created</dt>
|
<dt>Created</dt>
|
||||||
<dd>{formatTimestamp(snippet.created)}</dd>
|
<dd>{formatDate(snippet.created, formatting.dateFormat, formatting.customDateFormat)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Modified</dt>
|
<dt>Modified</dt>
|
||||||
<dd>{formatTimestamp(snippet.modified)}</dd>
|
<dd>
|
||||||
|
{formatDate(snippet.modified, formatting.dateFormat, formatting.customDateFormat)}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
@@ -158,6 +181,7 @@ export function SnippetLibrary() {
|
|||||||
const selectSnippet = useSnippetStore((s) => s.selectSnippet);
|
const selectSnippet = useSnippetStore((s) => s.selectSnippet);
|
||||||
const removeSnippet = useSnippetStore((s) => s.removeSnippet);
|
const removeSnippet = useSnippetStore((s) => s.removeSnippet);
|
||||||
const duplicateActiveSnippet = useSnippetStore((s) => s.duplicateActiveSnippet);
|
const duplicateActiveSnippet = useSnippetStore((s) => s.duplicateActiveSnippet);
|
||||||
|
const formatting = useUserSettingsStore((s) => s.saved.formatting);
|
||||||
|
|
||||||
// Default ordering: newest-modified first (spec §02 → Sort).
|
// Default ordering: newest-modified first (spec §02 → Sort).
|
||||||
const ordered = [...snippets].sort((a, b) => b.modified.localeCompare(a.modified));
|
const ordered = [...snippets].sort((a, b) => b.modified.localeCompare(a.modified));
|
||||||
@@ -196,6 +220,13 @@ export function SnippetLibrary() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.library}>
|
<div className={styles.library}>
|
||||||
|
{/* Library toolbar: heading + the date-format settings gear (sort/search
|
||||||
|
controls will join it here per spec §02). */}
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<span className={styles.heading}>Snippets</span>
|
||||||
|
<LibrarySettings />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Create raises no toast: the new snippet opens in the editor, so the
|
{/* Create raises no toast: the new snippet opens in the editor, so the
|
||||||
result is already on-screen (spec §02; docs/architecture/10 → Toast
|
result is already on-screen (spec §02; docs/architecture/10 → Toast
|
||||||
copy). Delete/duplicate toast because the outcome isn't visible. */}
|
copy). Delete/duplicate toast because the outcome isn't visible. */}
|
||||||
@@ -212,7 +243,7 @@ export function SnippetLibrary() {
|
|||||||
{ordered.map((s) => {
|
{ordered.map((s) => {
|
||||||
// Size is omitted under ~1 KB per spec §02; null collapses the suffix.
|
// Size is omitted under ~1 KB per spec §02; null collapses the suffix.
|
||||||
const size = formatSnippetSize(snippetSizeBytes(s));
|
const size = formatSnippetSize(snippetSizeBytes(s));
|
||||||
const date = relativeDate(s.modified);
|
const date = formatDate(s.modified, formatting.dateFormat, formatting.customDateFormat);
|
||||||
return (
|
return (
|
||||||
<li key={s.id} className={`${styles.item} ${s.id === activeId ? styles.active : ''}`}>
|
<li key={s.id} className={`${styles.item} ${s.id === activeId ? styles.active : ''}`}>
|
||||||
{/* The row's selectable area is a real <button> so it's keyboard
|
{/* The row's selectable area is a real <button> so it's keyboard
|
||||||
|
|||||||
@@ -30,7 +30,16 @@ import { hasInlineData } from '../stores/ExtractStore';
|
|||||||
import { notify } from '../stores/NotificationStore';
|
import { notify } from '../stores/NotificationStore';
|
||||||
import { usePreviewStore } from '../stores/PreviewStore';
|
import { usePreviewStore } from '../stores/PreviewStore';
|
||||||
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||||
|
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
|
import {
|
||||||
|
NumberControl,
|
||||||
|
RangeControl,
|
||||||
|
ResetButton,
|
||||||
|
SettingFooter,
|
||||||
|
SettingRow,
|
||||||
|
SettingsPopover,
|
||||||
|
} from './SettingsPopover';
|
||||||
import type { EditorView } from '../stores/SnippetStore';
|
import type { EditorView } from '../stores/SnippetStore';
|
||||||
import styles from './SpecEditor.module.css';
|
import styles from './SpecEditor.module.css';
|
||||||
|
|
||||||
@@ -40,6 +49,87 @@ const VIEW_OPTIONS: ReadonlyArray<SegmentedOption<EditorView>> = [
|
|||||||
{ value: 'published', label: 'Published' },
|
{ value: 'published', label: 'Published' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Editor syntax theme: Auto follows the app theme; overrides force one (spec §07). */
|
||||||
|
const EDITOR_THEME_OPTIONS: ReadonlyArray<SegmentedOption<string>> = [
|
||||||
|
{ value: 'auto', label: 'Auto' },
|
||||||
|
{ value: 'light', label: 'Light' },
|
||||||
|
{ value: 'dark', label: 'Dark' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ONOFF_OPTIONS: ReadonlyArray<SegmentedOption<'on' | 'off'>> = [
|
||||||
|
{ value: 'on', label: 'On' },
|
||||||
|
{ value: 'off', label: 'Off' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor settings cluster (spec §07 → Editor), disclosed from the editor toolbar
|
||||||
|
* and applied live. The id matches the Cmd/Ctrl+, shortcut target (App.tsx).
|
||||||
|
*/
|
||||||
|
function EditorSettings() {
|
||||||
|
const editor = useUserSettingsStore((s) => s.saved.editor);
|
||||||
|
const setEditor = useUserSettingsStore((s) => s.setEditor);
|
||||||
|
const resetEditor = useUserSettingsStore((s) => s.resetEditor);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsPopover id="editor-settings" label="Editor settings" title="Editor">
|
||||||
|
<SettingRow label="Font size" htmlFor="set-fontsize">
|
||||||
|
<RangeControl
|
||||||
|
id="set-fontsize"
|
||||||
|
min={10}
|
||||||
|
max={18}
|
||||||
|
value={editor.fontSize}
|
||||||
|
suffix="px"
|
||||||
|
onChange={(fontSize) => setEditor({ fontSize })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Theme">
|
||||||
|
<SegmentedControl
|
||||||
|
label="Editor theme"
|
||||||
|
options={EDITOR_THEME_OPTIONS}
|
||||||
|
value={editor.theme}
|
||||||
|
onChange={(theme) => setEditor({ theme })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Minimap">
|
||||||
|
<SegmentedControl
|
||||||
|
label="Minimap"
|
||||||
|
options={ONOFF_OPTIONS}
|
||||||
|
value={editor.minimap ? 'on' : 'off'}
|
||||||
|
onChange={(v) => setEditor({ minimap: v === 'on' })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Word wrap">
|
||||||
|
<SegmentedControl
|
||||||
|
label="Word wrap"
|
||||||
|
options={ONOFF_OPTIONS}
|
||||||
|
value={editor.wordWrap}
|
||||||
|
onChange={(wordWrap) => setEditor({ wordWrap })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Line numbers">
|
||||||
|
<SegmentedControl
|
||||||
|
label="Line numbers"
|
||||||
|
options={ONOFF_OPTIONS}
|
||||||
|
value={editor.lineNumbers}
|
||||||
|
onChange={(lineNumbers) => setEditor({ lineNumbers })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Tab size" htmlFor="set-tabsize">
|
||||||
|
<NumberControl
|
||||||
|
id="set-tabsize"
|
||||||
|
min={1}
|
||||||
|
max={8}
|
||||||
|
value={editor.tabSize}
|
||||||
|
onChange={(tabSize) => setEditor({ tabSize })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingFooter>
|
||||||
|
<ResetButton onClick={resetEditor}>Reset editor defaults</ResetButton>
|
||||||
|
</SettingFooter>
|
||||||
|
</SettingsPopover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Register the bundled Vega-Lite schema once: resolves `$schema` locally (no
|
// Register the bundled Vega-Lite schema once: resolves `$schema` locally (no
|
||||||
// network warning) and powers validation, autocomplete, and hover docs.
|
// network warning) and powers validation, autocomplete, and hover docs.
|
||||||
configureVegaLiteJson();
|
configureVegaLiteJson();
|
||||||
@@ -132,6 +222,7 @@ function EditorToolbar() {
|
|||||||
>
|
>
|
||||||
Publish
|
Publish
|
||||||
</button>
|
</button>
|
||||||
|
<EditorSettings />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -144,21 +235,26 @@ export function SpecEditor() {
|
|||||||
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
|
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
|
||||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||||
const error = usePreviewStore((s) => s.error);
|
const error = usePreviewStore((s) => s.error);
|
||||||
|
// Editor preferences (spec §07 → Editor); applied live below as they change.
|
||||||
|
const editorPrefs = useUserSettingsStore((s) => s.saved.editor);
|
||||||
|
|
||||||
// Create the editor once, on mount.
|
// Create the editor once, on mount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hostRef.current) return;
|
if (!hostRef.current) return;
|
||||||
|
// Seed from the user's current editor settings so the first paint matches.
|
||||||
|
const ed = useUserSettingsStore.getState().saved.editor;
|
||||||
const editor = monaco.editor.create(hostRef.current, {
|
const editor = monaco.editor.create(hostRef.current, {
|
||||||
value: selectShownText(useSnippetStore.getState()),
|
value: selectShownText(useSnippetStore.getState()),
|
||||||
language: 'json',
|
language: 'json',
|
||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: ed.minimap },
|
||||||
// The editor is a Plex Mono surface per the design language (doc §3.1).
|
// The editor is a Plex Mono surface per the design language (doc §3.1).
|
||||||
// Monaco needs an explicit family string — it can't read the CSS token.
|
// Monaco needs an explicit family string — it can't read the CSS token.
|
||||||
fontFamily: "'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace",
|
fontFamily: "'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace",
|
||||||
fontSize: 13,
|
fontSize: ed.fontSize,
|
||||||
tabSize: 2,
|
tabSize: ed.tabSize,
|
||||||
wordWrap: 'on',
|
wordWrap: ed.wordWrap,
|
||||||
|
lineNumbers: ed.lineNumbers,
|
||||||
folding: true,
|
folding: true,
|
||||||
showFoldingControls: 'always', // keep fold arrows visible, not only on hover
|
showFoldingControls: 'always', // keep fold arrows visible, not only on hover
|
||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
@@ -204,10 +300,27 @@ export function SpecEditor() {
|
|||||||
});
|
});
|
||||||
}, [bufferEpoch, editorView, activeId]);
|
}, [bufferEpoch, editorView, activeId]);
|
||||||
|
|
||||||
// Editor theme follows the UI theme.
|
// Apply editor preferences live as they change (spec §07 Apply → takes effect
|
||||||
|
// immediately). tabSize is a model option, so it's set on the model.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
monaco.editor.setTheme(uiTheme === 'dark' ? 'vs-dark' : 'vs');
|
const editor = editorRef.current;
|
||||||
}, [uiTheme]);
|
if (!editor) return;
|
||||||
|
editor.updateOptions({
|
||||||
|
fontSize: editorPrefs.fontSize,
|
||||||
|
wordWrap: editorPrefs.wordWrap,
|
||||||
|
lineNumbers: editorPrefs.lineNumbers,
|
||||||
|
minimap: { enabled: editorPrefs.minimap },
|
||||||
|
});
|
||||||
|
editor.getModel()?.updateOptions({ tabSize: editorPrefs.tabSize });
|
||||||
|
}, [editorPrefs]);
|
||||||
|
|
||||||
|
// Editor theme: Auto follows the app UI theme; an explicit override forces one
|
||||||
|
// (spec §07 → Editor theme, provisional).
|
||||||
|
useEffect(() => {
|
||||||
|
const pref = editorPrefs.theme;
|
||||||
|
const effective = pref === 'auto' ? uiTheme : pref;
|
||||||
|
monaco.editor.setTheme(effective === 'dark' ? 'vs-dark' : 'vs');
|
||||||
|
}, [uiTheme, editorPrefs.theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.editorPane}>
|
<div className={styles.editorPane}>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Theme toggle — a header control that flips light ⇄ dark (spec §07 Appearance).
|
* Theme toggle — a header control that flips light ⇄ dark (spec §07 Appearance).
|
||||||
*
|
*
|
||||||
* Interim home: the spec houses the UI-theme control inside the Settings modal,
|
* This header button is the UI-theme control's permanent home: the M5 settings
|
||||||
* which arrives in M5. Until then this header button is the control; it persists
|
* review distributed preferences to their panes and dropped the central Settings
|
||||||
* through the same `ui.theme` settings key, so M5 can move it into Settings (or
|
* modal, so Appearance stays a one-click header toggle (spec §07). It writes the
|
||||||
* keep it as a shortcut) without changing what's stored.
|
* persisted `ui.theme` key directly — no separate Appearance control to sync.
|
||||||
*
|
*
|
||||||
* The button shows the icon of the theme you'll switch *to* (moon when light,
|
* The button shows the icon of the theme you'll switch *to* (moon when light,
|
||||||
* sun when dark) and labels itself for screen readers. The focus ring comes from
|
* sun when dark) and labels itself for screen readers. The focus ring comes from
|
||||||
|
|||||||
@@ -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 { 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';
|
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()));
|
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
@@ -94,12 +102,12 @@ describe('settings-store · preview.fitMode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns a stored valid fit mode', () => {
|
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');
|
expect(loadPreviewFitMode()).toBe('full');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to default for an unrecognized value', () => {
|
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');
|
expect(loadPreviewFitMode()).toBe('default');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,3 +118,38 @@ describe('settings-store · preview.fitMode', () => {
|
|||||||
expect(loadUiTheme()).toBe('dark'); // the other slice survives the merge
|
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
|
* The home for the *UserSettings* record (spec §09C) under `astrolabe:settings`.
|
||||||
* `astrolabe:settings` key (spec §09C). M1.5 pulls the **theme** slice forward
|
* M1.5/M2 pulled the **theme** and **preview fit mode** slices forward (their
|
||||||
* (the toggle ships before the Settings modal), so this adapter currently wires
|
* controls shipped first); M5 adds the full record — editor, performance, and
|
||||||
* only `ui.theme`. It reads/writes with **load-with-fallback + write-through
|
* formatting groups — on the same key.
|
||||||
* 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
|
* Everything reads with **load-with-fallback** (the pure `loadSettings` in
|
||||||
* rather than clobbering anything.
|
* `@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
|
* Per the architecture rule, this is one of the only modules that may touch
|
||||||
* `localStorage`; everything else goes through these typed functions.
|
* `localStorage`; everything else goes through these typed functions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FitMode } from '@core/rendering';
|
import type { FitMode } from '@core/rendering';
|
||||||
|
import { loadSettings, type UserSettings } from '@core/settings';
|
||||||
import type { UiTheme } from '@core/theme';
|
import type { UiTheme } from '@core/theme';
|
||||||
|
|
||||||
const KEY = 'astrolabe:settings';
|
const KEY = 'astrolabe:settings';
|
||||||
@@ -24,10 +28,12 @@ const DEFAULT_THEME: UiTheme = 'light';
|
|||||||
/** Spec §04 — the Fit control defaults to Original. */
|
/** Spec §04 — the Fit control defaults to Original. */
|
||||||
const DEFAULT_FIT_MODE: FitMode = 'default';
|
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 {
|
interface StoredSettings {
|
||||||
ui?: { theme?: unknown; [k: string]: unknown };
|
ui?: { theme?: unknown; previewFitMode?: unknown; [k: string]: unknown };
|
||||||
preview?: { fitMode?: unknown; [k: string]: 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';
|
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. */
|
/** The persisted UI theme, or the default — unknown/legacy values fall back. */
|
||||||
export function loadUiTheme(): UiTheme {
|
export function loadUiTheme(): UiTheme {
|
||||||
const stored = readRaw().ui?.theme;
|
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. */
|
/** The persisted preview fit mode, or the default — unknown values fall back. */
|
||||||
export function loadPreviewFitMode(): FitMode {
|
export function loadPreviewFitMode(): FitMode {
|
||||||
const stored = readRaw().preview?.fitMode;
|
const stored = readRaw().ui?.previewFitMode;
|
||||||
return isFitMode(stored) ? stored : DEFAULT_FIT_MODE;
|
return isFitMode(stored) ? stored : DEFAULT_FIT_MODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist the preview fit mode, preserving every other key already in the record. */
|
/** Persist the preview fit mode, preserving every other key already in the record. */
|
||||||
export function savePreviewFitMode(fitMode: FitMode): void {
|
export function savePreviewFitMode(fitMode: FitMode): void {
|
||||||
const current = readRaw();
|
const current = readRaw();
|
||||||
writeRaw({ ...current, preview: { ...current.preview, fitMode } });
|
writeRaw({ ...current, ui: { ...current.ui, previewFitMode: fitMode } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@
|
|||||||
* single shell-level Save/Cancel doesn't fit). The registry therefore omits
|
* single shell-level Save/Cancel doesn't fit). The registry therefore omits
|
||||||
* `hasError`/`getError` (validity is the modal's own concern) and keeps only
|
* `hasError`/`getError` (validity is the modal's own concern) and keeps only
|
||||||
* `getState` for unsaved-change detection on close. The registry is a partial
|
* `getState` for unsaved-change detection on close. The registry is a partial
|
||||||
* map: modals land milestone by milestone (settings/about/donate → M5/M6,
|
* map: modals land milestone by milestone (about/donate → M6, chartBuilder → M4),
|
||||||
* chartBuilder → M4), so only the implemented ones are registered here.
|
* so only the implemented ones are registered here. Settings is deliberately NOT
|
||||||
|
* a modal — preferences are distributed to per-pane disclosure popovers (spec §07;
|
||||||
|
* see components/SettingsPopover).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
|
|||||||
@@ -9,11 +9,12 @@
|
|||||||
|
|
||||||
export type ModalName =
|
export type ModalName =
|
||||||
| 'datasets' // Datasets manager (list / detail / new-dataset form)
|
| 'datasets' // Datasets manager (list / detail / new-dataset form)
|
||||||
| 'settings' // Appearance, editor, performance, formatting prefs (M5)
|
|
||||||
| 'about' // About & Help (M6)
|
| 'about' // About & Help (M6)
|
||||||
| 'donate' // Donate (M6)
|
| 'donate' // Donate (M6)
|
||||||
| 'chartBuilder' // Visual no-JSON chart composition for a dataset (M4)
|
| 'chartBuilder' // Visual no-JSON chart composition for a dataset (M4)
|
||||||
| 'extract'; // Extract inline spec data into a new dataset (M3)
|
| 'extract'; // Extract inline spec data into a new dataset (M3)
|
||||||
|
// Settings is NOT a modal — preferences are distributed to per-pane disclosure
|
||||||
|
// popovers (spec §07; see components/SettingsPopover).
|
||||||
|
|
||||||
/** The active modal, or `null` when none is open (at most one at a time, §01C). */
|
/** The active modal, or `null` when none is open (at most one at a time, §01C). */
|
||||||
export type ActiveModal = ModalName | null;
|
export type ActiveModal = ModalName | null;
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Settings orchestration — bridges the (browser-free) UserSettingsStore to the
|
||||||
|
* localStorage adapter, the same store↔adapter pattern as theme/preferences.
|
||||||
|
*
|
||||||
|
* `initSettings` hydrates the applied managed groups (editor / performance /
|
||||||
|
* formatting) from the persisted record before render, so the editor, preview,
|
||||||
|
* and library read the user's settings from the first paint. `wireSettings`
|
||||||
|
* persists the managed slice whenever it changes (live, the moment a control
|
||||||
|
* changes — there is no Apply step).
|
||||||
|
*
|
||||||
|
* Theme and preview fit mode are persisted by their own slice subscribers
|
||||||
|
* (theme.ts, preferences.ts) onto the same `astrolabe:settings` record; the
|
||||||
|
* field-level write-through merge keeps the slices from clobbering each other.
|
||||||
|
*
|
||||||
|
* Settings are distributed and live-applied (spec §07): a control mutates the
|
||||||
|
* store the instant it changes, so `wireSettings` persists on every change —
|
||||||
|
* there is no Apply/Cancel commit step.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loadUserSettings, saveManagedSettings } from '../infrastructure/settings-store';
|
||||||
|
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||||
|
|
||||||
|
/** Hydrate the applied managed settings into the store. Call before render. */
|
||||||
|
export function initSettings(): void {
|
||||||
|
useUserSettingsStore.getState().hydrate(loadUserSettings());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the managed settings on change. Returns a teardown that detaches it. */
|
||||||
|
export function wireSettings(): () => void {
|
||||||
|
return useUserSettingsStore.subscribe((state, prev) => {
|
||||||
|
if (state.saved === prev.saved) return;
|
||||||
|
saveManagedSettings(state.saved);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// The browser file adapter is mocked: export drives downloadJson, import reads
|
||||||
|
// via readTextFile. The service logic (merge, notify) is what we exercise.
|
||||||
|
vi.mock('../infrastructure/file-transfer', () => ({
|
||||||
|
downloadJson: vi.fn(),
|
||||||
|
readTextFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createDataset } from '@core/dataset';
|
||||||
|
import { createSnippet } from '@core/snippet';
|
||||||
|
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
|
||||||
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
|
import { useNotificationStore } from '../stores/NotificationStore';
|
||||||
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
|
import { exportWorkspace, importWorkspace } from './transfer';
|
||||||
|
|
||||||
|
const mockedDownload = vi.mocked(downloadJson);
|
||||||
|
const mockedRead = vi.mocked(readTextFile);
|
||||||
|
|
||||||
|
/** The most recent notification raised. */
|
||||||
|
function lastNote() {
|
||||||
|
const notes = useNotificationStore.getState().notifications;
|
||||||
|
return notes[notes.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive an import from a JSON string (readTextFile is mocked to return it). */
|
||||||
|
async function importJson(text: string) {
|
||||||
|
mockedRead.mockResolvedValueOnce(text);
|
||||||
|
await importWorkspace({} as File);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useSnippetStore.getState().reset();
|
||||||
|
useDatasetStore.getState().reset();
|
||||||
|
useNotificationStore.getState().clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportWorkspace', () => {
|
||||||
|
it('reports and downloads nothing for an empty library', () => {
|
||||||
|
exportWorkspace(new Date('2026-06-07T00:00:00.000Z'));
|
||||||
|
expect(mockedDownload).not.toHaveBeenCalled();
|
||||||
|
expect(lastNote()).toMatchObject({ kind: 'info' });
|
||||||
|
expect(lastNote().message).toContain('No snippets to export');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downloads an envelope and reports counts', () => {
|
||||||
|
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'A' })]);
|
||||||
|
useDatasetStore
|
||||||
|
.getState()
|
||||||
|
.addDatasets([
|
||||||
|
createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
exportWorkspace(new Date('2026-06-07T12:00:00.000Z'));
|
||||||
|
|
||||||
|
expect(mockedDownload).toHaveBeenCalledTimes(1);
|
||||||
|
const [filename, json] = mockedDownload.mock.calls[0];
|
||||||
|
expect(filename).toBe('astrolabe-project-2026-06-07.json');
|
||||||
|
const env = JSON.parse(json) as { version: string; snippets: unknown[]; datasets: unknown[] };
|
||||||
|
expect(env.version).toBe('1.0');
|
||||||
|
expect(env.snippets).toHaveLength(1);
|
||||||
|
expect(env.datasets).toHaveLength(1);
|
||||||
|
expect(lastNote()).toMatchObject({ kind: 'success' });
|
||||||
|
expect(lastNote().message).toBe('Exported 1 snippet and 1 dataset');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importWorkspace', () => {
|
||||||
|
it('reports an invalid JSON file and leaves the workspace untouched', async () => {
|
||||||
|
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'A' })]);
|
||||||
|
await importJson('{ not json');
|
||||||
|
expect(useSnippetStore.getState().snippets).toHaveLength(1);
|
||||||
|
expect(lastNote()).toMatchObject({ kind: 'error' });
|
||||||
|
expect(lastNote().message).toContain('valid JSON');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an empty file (no snippets found)', async () => {
|
||||||
|
await importJson(JSON.stringify({ version: '1.0', snippets: [] }));
|
||||||
|
expect(useSnippetStore.getState().snippets).toHaveLength(0);
|
||||||
|
expect(lastNote()).toMatchObject({ kind: 'info' });
|
||||||
|
expect(lastNote().message).toBe('No snippets found in file.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges an envelope: datasets and snippets are appended', async () => {
|
||||||
|
await importJson(
|
||||||
|
JSON.stringify({
|
||||||
|
version: '1.0',
|
||||||
|
snippets: [
|
||||||
|
{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'A', spec: '{"mark":"bar"}' },
|
||||||
|
],
|
||||||
|
datasets: [{ id: 1, name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(useSnippetStore.getState().snippets).toHaveLength(1);
|
||||||
|
expect(useDatasetStore.getState().datasets).toHaveLength(1);
|
||||||
|
expect(useDatasetStore.getState().datasets[0].name).toBe('Sales');
|
||||||
|
expect(lastNote()).toMatchObject({ kind: 'success' });
|
||||||
|
expect(lastNote().message).toBe('Imported 1 snippet and 1 dataset');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-suffixes a clashing dataset name and rewrites the importing snippet ref', async () => {
|
||||||
|
useDatasetStore
|
||||||
|
.getState()
|
||||||
|
.addDatasets([
|
||||||
|
createDataset({ name: 'Sales', data: [{ a: 1 }], format: 'json', source: 'inline' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await importJson(
|
||||||
|
JSON.stringify({
|
||||||
|
version: '1.0',
|
||||||
|
snippets: [
|
||||||
|
{
|
||||||
|
id: 's9',
|
||||||
|
created: '2026-01-01T00:00:00.000Z',
|
||||||
|
name: 'Ref',
|
||||||
|
spec: '{"data":{"name":"Sales"},"mark":"bar"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
datasets: [{ id: 1, name: 'Sales', data: [{ b: 2 }], format: 'json', source: 'inline' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const names = useDatasetStore
|
||||||
|
.getState()
|
||||||
|
.datasets.map((d) => d.name)
|
||||||
|
.sort();
|
||||||
|
expect(names).toEqual(['Sales', 'Sales 2']);
|
||||||
|
|
||||||
|
const imported = useSnippetStore.getState().snippets.find((s) => s.name === 'Ref')!;
|
||||||
|
expect(imported.datasetRefs).toContain('Sales 2');
|
||||||
|
expect(imported.spec).toContain('Sales 2');
|
||||||
|
|
||||||
|
expect(lastNote()).toMatchObject({ kind: 'warning' });
|
||||||
|
expect(lastNote().message).toContain('Sales → Sales 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reassigns a colliding snippet id, keeping the existing one', async () => {
|
||||||
|
useSnippetStore.getState().hydrate([createSnippet({ id: 's1', name: 'Existing' })]);
|
||||||
|
|
||||||
|
await importJson(
|
||||||
|
JSON.stringify({
|
||||||
|
version: '1.0',
|
||||||
|
snippets: [{ id: 's1', created: '2026-01-01T00:00:00.000Z', name: 'Imported', spec: '{}' }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snippets = useSnippetStore.getState().snippets;
|
||||||
|
expect(snippets).toHaveLength(2);
|
||||||
|
const imported = snippets.find((s) => s.name === 'Imported')!;
|
||||||
|
expect(imported.id).not.toBe('s1');
|
||||||
|
expect(snippets.find((s) => s.name === 'Existing')!.id).toBe('s1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* Import / Export service (spec §08) — orchestration over the pure core helpers
|
||||||
|
* and the stores. The deterministic work (shape detection, normalization, name
|
||||||
|
* de-dupe, id reassignment, rename propagation, envelope/message building) lives
|
||||||
|
* in `@core/import-normalize` and `@core/export-envelope`; this layer reads the
|
||||||
|
* stores, drives the browser file adapter, commits the merge, and reports.
|
||||||
|
*
|
||||||
|
* Order matters on import (spec §08 → Merge): datasets are committed **before**
|
||||||
|
* snippets so a snippet's by-name reference resolves against the just-added
|
||||||
|
* dataset (whose name may have been auto-suffixed to avoid a clash).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { buildExportEnvelope, exportFilename, exportSummaryMessage } from '@core/export-envelope';
|
||||||
|
import {
|
||||||
|
applyDatasetRenamesToSnippets,
|
||||||
|
dedupeIncomingDatasetNames,
|
||||||
|
importSummaryMessage,
|
||||||
|
normalizeImport,
|
||||||
|
reassignCollidingSnippetIds,
|
||||||
|
} from '@core/import-normalize';
|
||||||
|
import { snippetSizeBytes } from '@core/snippet';
|
||||||
|
import { downloadJson, readTextFile } from '../infrastructure/file-transfer';
|
||||||
|
import { notify } from '../stores/NotificationStore';
|
||||||
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
|
|
||||||
|
/** Practical snippet-storage budget (spec §02 storage monitor / §08). */
|
||||||
|
const SNIPPET_BUDGET_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Round bytes to a short KB/MB string for the overage warning. */
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
const kb = bytes / 1024;
|
||||||
|
if (kb < 1024) return `${Math.max(1, Math.round(kb))} KB`;
|
||||||
|
return `${Math.round(kb / 1024)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export the whole workspace to a downloaded JSON file (spec §08 → Export).
|
||||||
|
* Flushes the live editor buffer first so in-progress draft edits are included.
|
||||||
|
* An empty library is reported and nothing is downloaded, even if datasets exist.
|
||||||
|
*/
|
||||||
|
export function exportWorkspace(now: Date = new Date()): void {
|
||||||
|
// Commit any valid, uncommitted buffer so the export reflects current work.
|
||||||
|
useSnippetStore.getState().commitDraft(now);
|
||||||
|
|
||||||
|
const snippets = useSnippetStore.getState().snippets;
|
||||||
|
const datasets = useDatasetStore.getState().datasets;
|
||||||
|
|
||||||
|
if (snippets.length === 0) {
|
||||||
|
notify({
|
||||||
|
kind: 'info',
|
||||||
|
title: 'Nothing to export',
|
||||||
|
message: 'No snippets to export. Create a snippet first, then export your workspace.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = buildExportEnvelope(snippets, datasets, { now });
|
||||||
|
downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2));
|
||||||
|
|
||||||
|
notify({
|
||||||
|
kind: 'success',
|
||||||
|
title: 'Workspace exported',
|
||||||
|
message: exportSummaryMessage(snippets.length, datasets.length),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a picked JSON file: normalize, merge (never overwrite), and save
|
||||||
|
* (spec §08 → Import). The existing workspace is never lost — an unreadable or
|
||||||
|
* unparseable file leaves it untouched, and merges only ever append.
|
||||||
|
*/
|
||||||
|
export async function importWorkspace(file: File): Promise<void> {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await readTextFile(file);
|
||||||
|
} catch (err) {
|
||||||
|
notify({
|
||||||
|
kind: 'error',
|
||||||
|
title: "Couldn't read the file",
|
||||||
|
message: 'The selected file could not be read. Choose the file again and retry.',
|
||||||
|
detail: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
notify({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Import failed',
|
||||||
|
message: 'Failed to import. Please check that the file is valid JSON.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { snippets: normSnippets, datasets: normDatasets } = normalizeImport(parsed);
|
||||||
|
|
||||||
|
if (normSnippets.length === 0) {
|
||||||
|
notify({
|
||||||
|
kind: 'info',
|
||||||
|
title: 'Nothing imported',
|
||||||
|
message: 'No snippets found in file.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datasets first: de-dupe their names against the library (and within the batch),
|
||||||
|
// then propagate any rename into the imported snippets so their references still
|
||||||
|
// resolve (spec §08 → Dataset conflicts; docs/architecture/07 §5–6).
|
||||||
|
const existingDatasetNames = useDatasetStore.getState().datasets.map((d) => d.name);
|
||||||
|
const { datasets: dedupedDatasets, renames } = dedupeIncomingDatasetNames(
|
||||||
|
existingDatasetNames,
|
||||||
|
normDatasets,
|
||||||
|
);
|
||||||
|
const renamedSnippets = applyDatasetRenamesToSnippets(normSnippets, renames);
|
||||||
|
|
||||||
|
// Reassign incoming snippet ids that clash with the library (spec §08 → ID collisions).
|
||||||
|
const existingSnippetIds = useSnippetStore.getState().snippets.map((s) => s.id);
|
||||||
|
const finalSnippets = reassignCollidingSnippetIds(existingSnippetIds, renamedSnippets);
|
||||||
|
|
||||||
|
// Commit datasets BEFORE snippets so by-name references resolve (spec §08).
|
||||||
|
useDatasetStore.getState().addDatasets(dedupedDatasets);
|
||||||
|
|
||||||
|
// Storage budget pre-check (spec §08 → Storage limit handling): warn on overage
|
||||||
|
// but still attempt the save.
|
||||||
|
const existingBytes = useSnippetStore
|
||||||
|
.getState()
|
||||||
|
.snippets.reduce((n, s) => n + snippetSizeBytes(s), 0);
|
||||||
|
const incomingBytes = finalSnippets.reduce((n, s) => n + snippetSizeBytes(s), 0);
|
||||||
|
const overage = existingBytes + incomingBytes - SNIPPET_BUDGET_BYTES;
|
||||||
|
|
||||||
|
// TODO (spec §08 → Storage limit handling): a *hard* quota failure on save
|
||||||
|
// should commit no partial snippet import. The write-through subscriber
|
||||||
|
// currently surfaces such a failure as a per-record error toast, but the
|
||||||
|
// in-memory store keeps the records (they vanish on reload). True atomic
|
||||||
|
// rollback needs a write-before-store import path — a persistence-architecture
|
||||||
|
// change deferred until the M6 storage monitor (web.dev quota estimate) lands.
|
||||||
|
useSnippetStore.getState().addSnippets(finalSnippets);
|
||||||
|
|
||||||
|
// Feedback (spec §08 → Feedback): one summary toast; a warning when datasets were
|
||||||
|
// renamed or storage is over budget, otherwise a success.
|
||||||
|
const summary = importSummaryMessage(finalSnippets.length, dedupedDatasets.length);
|
||||||
|
const clauses: string[] = [];
|
||||||
|
if (renames.length > 0) {
|
||||||
|
clauses.push(
|
||||||
|
`Renamed to avoid clashes: ${renames.map((r) => `${r.from} → ${r.to}`).join(', ')}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (overage > 0) {
|
||||||
|
clauses.push(
|
||||||
|
`This puts snippet storage about ${formatBytes(overage)} over the ~5 MB budget; ` +
|
||||||
|
`consider deleting some snippets.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clauses.length > 0) {
|
||||||
|
notify({
|
||||||
|
kind: 'warning',
|
||||||
|
title: 'Import complete',
|
||||||
|
message: `${summary}. ${clauses.join(' ')}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notify({ kind: 'success', title: 'Import complete', message: summary });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,14 @@ export interface DatasetState {
|
|||||||
|
|
||||||
/** Low-level: add a fully-formed dataset and select it. */
|
/** Low-level: add a fully-formed dataset and select it. */
|
||||||
add: (dataset: Dataset) => void;
|
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`. */
|
/** Low-level: merge a patch into a dataset, advancing `modified`. */
|
||||||
update: (id: number, patch: Partial<Dataset>, now?: Date) => void;
|
update: (id: number, patch: Partial<Dataset>, now?: Date) => void;
|
||||||
/** Low-level: remove a dataset; clears the selection if it was selected. */
|
/** 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.
|
// before wiring import.
|
||||||
add: (dataset) => set((s) => ({ datasets: [dataset, ...s.datasets], selectedId: dataset.id })),
|
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) => {
|
update: (id, patch, now) => {
|
||||||
const modified = patch.modified ?? (now ?? new Date()).toISOString();
|
const modified = patch.modified ?? (now ?? new Date()).toISOString();
|
||||||
set((s) => ({
|
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. */
|
/** Replace the library from storage and choose an active snippet. */
|
||||||
hydrate: (snippets: Snippet[], activeId?: string | null) => void;
|
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. */
|
/** Create a new snippet (sample template by default), prepend, and select it. */
|
||||||
createSnippet: (options?: CreateSnippetOptions) => string;
|
createSnippet: (options?: CreateSnippetOptions) => string;
|
||||||
/** Make a snippet active and load its draft into the editor buffer. */
|
/** 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) => {
|
createSnippet: (options) => {
|
||||||
get().commitDraft(); // flush the outgoing snippet's valid edits before switching away
|
get().commitDraft(); // flush the outgoing snippet's valid edits before switching away
|
||||||
const created = createSnippet(options);
|
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 } } })),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
formatCustom,
|
||||||
|
formatDate,
|
||||||
|
formatIso,
|
||||||
|
formatSmart,
|
||||||
|
type DateFormatMode,
|
||||||
|
} from './date-format';
|
||||||
|
|
||||||
|
/** A fixed local reference "now" for relative-date tests. */
|
||||||
|
const NOW = new Date(2026, 5, 7, 12, 0, 0); // 2026-06-07 12:00 local
|
||||||
|
const at = (y: number, mo: number, d: number, h = 9, mi = 0, s = 0) => new Date(y, mo, d, h, mi, s);
|
||||||
|
|
||||||
|
describe('formatSmart', () => {
|
||||||
|
it('renders the same calendar day as Today', () => {
|
||||||
|
expect(formatSmart(at(2026, 5, 7, 0, 1), NOW)).toBe('Today');
|
||||||
|
expect(formatSmart(at(2026, 5, 7, 23, 59), NOW)).toBe('Today');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the previous calendar day as Yesterday', () => {
|
||||||
|
expect(formatSmart(at(2026, 5, 6), NOW)).toBe('Yesterday');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders under a week as Nd ago', () => {
|
||||||
|
expect(formatSmart(at(2026, 5, 4), NOW)).toBe('3d ago');
|
||||||
|
expect(formatSmart(at(2026, 5, 1), NOW)).toBe('6d ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a full locale date at a week or older', () => {
|
||||||
|
const old = at(2026, 4, 1);
|
||||||
|
expect(formatSmart(old, NOW)).toBe(old.toLocaleDateString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flip to Yesterday on the hour within the same day', () => {
|
||||||
|
// 1 minute ago but same calendar day → still Today.
|
||||||
|
expect(formatSmart(at(2026, 5, 7, 11, 59), NOW)).toBe('Today');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatIso', () => {
|
||||||
|
it('returns a full ISO 8601 timestamp', () => {
|
||||||
|
const d = new Date('2026-06-07T10:30:00.000Z');
|
||||||
|
expect(formatIso(d)).toBe('2026-06-07T10:30:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatCustom', () => {
|
||||||
|
it('formats the placeholder pattern yyyy-MM-dd HH:mm', () => {
|
||||||
|
expect(formatCustom(at(2026, 0, 5, 8, 4), 'yyyy-MM-dd HH:mm')).toBe('2026-01-05 08:04');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports month names and short year', () => {
|
||||||
|
expect(formatCustom(at(2026, 11, 25), 'd MMMM yy')).toBe('25 December 26');
|
||||||
|
expect(formatCustom(at(2026, 2, 9), 'MMM d, yyyy')).toBe('Mar 9, 2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports 12-hour clock with meridiem', () => {
|
||||||
|
expect(formatCustom(at(2026, 5, 7, 0, 5), 'h:mm a')).toBe('12:05 AM');
|
||||||
|
expect(formatCustom(at(2026, 5, 7, 13, 5), 'h:mm a')).toBe('1:05 PM');
|
||||||
|
expect(formatCustom(at(2026, 5, 7, 12, 0), 'hh:mm a')).toBe('12:00 PM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves non-token literals verbatim', () => {
|
||||||
|
expect(formatCustom(at(2026, 0, 1), 'yyyy/MM/dd')).toBe('2026/01/01');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
it('dispatches by mode', () => {
|
||||||
|
const iso = at(2026, 5, 6, 9, 0).toISOString();
|
||||||
|
expect(formatDate(iso, 'smart', '', NOW)).toBe('Yesterday');
|
||||||
|
expect(formatDate(iso, 'iso')).toBe(new Date(iso).toISOString());
|
||||||
|
expect(formatDate(iso, 'custom', 'yyyy-MM-dd', NOW)).toBe('2026-06-06');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to ISO when a custom pattern is blank', () => {
|
||||||
|
const iso = '2026-06-07T10:30:00.000Z';
|
||||||
|
expect(formatDate(iso, 'custom', ' ')).toBe(new Date(iso).toISOString());
|
||||||
|
expect(formatDate(iso, 'custom', '')).toBe(new Date(iso).toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an unparseable timestamp verbatim rather than "Invalid Date"', () => {
|
||||||
|
expect(formatDate('not-a-date', 'iso')).toBe('not-a-date');
|
||||||
|
expect(formatDate('not-a-date', 'smart', '', NOW)).toBe('not-a-date');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults unknown modes to smart', () => {
|
||||||
|
const iso = at(2026, 5, 7).toISOString();
|
||||||
|
expect(formatDate(iso, 'bogus' as DateFormatMode, '', NOW)).toBe('Today');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Date formatting — how timestamps render throughout the app (spec §07 →
|
||||||
|
* Formatting). Governs the Snippet Library list dates and the metadata panel's
|
||||||
|
* Created/Modified, driven by the user's `formatting.dateFormat` setting.
|
||||||
|
*
|
||||||
|
* Portable core: no browser APIs, no React. `Intl` is deliberately avoided for
|
||||||
|
* the custom tokens so output is locale-stable and unit-testable; month/day
|
||||||
|
* names are the fixed English set. Pure: an ISO string in, a display string out.
|
||||||
|
*
|
||||||
|
* - smart → relative, human-friendly ("Today", "Yesterday", "3d ago", then a
|
||||||
|
* full locale date for older items).
|
||||||
|
* - iso → a full ISO 8601 timestamp.
|
||||||
|
* - custom → the user's format string (token grammar below); falls back to ISO
|
||||||
|
* when the pattern is empty.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The three date-display modes (mirrors UserSettings.formatting.dateFormat). */
|
||||||
|
export type DateFormatMode = 'smart' | 'iso' | 'custom';
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const MONTHS_SHORT = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
|
];
|
||||||
|
const MONTHS_LONG = [
|
||||||
|
'January',
|
||||||
|
'February',
|
||||||
|
'March',
|
||||||
|
'April',
|
||||||
|
'May',
|
||||||
|
'June',
|
||||||
|
'July',
|
||||||
|
'August',
|
||||||
|
'September',
|
||||||
|
'October',
|
||||||
|
'November',
|
||||||
|
'December',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Two-digit zero-pad (mirrors snippet.ts → generateSnippetName). */
|
||||||
|
function pad(n: number): string {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relative, human-friendly rendering (spec §07 → Smart). Same day → "Today",
|
||||||
|
* one calendar day back → "Yesterday", under a week → "Nd ago", else the full
|
||||||
|
* locale date. Comparison is by calendar day (local), so "Yesterday" doesn't
|
||||||
|
* flip on the exact hour. `now` is injectable for deterministic tests.
|
||||||
|
*/
|
||||||
|
export function formatSmart(date: Date, now: Date): string {
|
||||||
|
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||||
|
const days = Math.floor((startOfDay(now) - startOfDay(date)) / DAY_MS);
|
||||||
|
if (days <= 0) return 'Today';
|
||||||
|
if (days === 1) return 'Yesterday';
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full ISO 8601 timestamp (spec §07 → ISO 8601). */
|
||||||
|
export function formatIso(date: Date): string {
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tokens longest-first so MMMM matches before MMM before MM before M, etc.
|
||||||
|
const TOKEN = /yyyy|yy|MMMM|MMM|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s|a/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date with a date-fns-style token pattern, in **local** time
|
||||||
|
* (spec §07 → Custom). Supported tokens: `yyyy yy MMMM MMM MM M dd d HH H hh h
|
||||||
|
* mm m ss s a`. Text between tokens is preserved verbatim (no quoting), which is
|
||||||
|
* enough for patterns like `yyyy-MM-dd HH:mm`; a stray literal letter that
|
||||||
|
* happens to be a token (e.g. a `d` in prose) would be substituted — the field
|
||||||
|
* is a power-user affordance, so this stays simple and predictable.
|
||||||
|
*/
|
||||||
|
export function formatCustom(date: Date, pattern: string): string {
|
||||||
|
const h24 = date.getHours();
|
||||||
|
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
yyyy: String(date.getFullYear()),
|
||||||
|
yy: pad(date.getFullYear() % 100),
|
||||||
|
MMMM: MONTHS_LONG[date.getMonth()],
|
||||||
|
MMM: MONTHS_SHORT[date.getMonth()],
|
||||||
|
MM: pad(date.getMonth() + 1),
|
||||||
|
M: String(date.getMonth() + 1),
|
||||||
|
dd: pad(date.getDate()),
|
||||||
|
d: String(date.getDate()),
|
||||||
|
HH: pad(h24),
|
||||||
|
H: String(h24),
|
||||||
|
hh: pad(h12),
|
||||||
|
h: String(h12),
|
||||||
|
mm: pad(date.getMinutes()),
|
||||||
|
m: String(date.getMinutes()),
|
||||||
|
ss: pad(date.getSeconds()),
|
||||||
|
s: String(date.getSeconds()),
|
||||||
|
a: h24 < 12 ? 'AM' : 'PM',
|
||||||
|
};
|
||||||
|
return pattern.replace(TOKEN, (t) => map[t] ?? t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an ISO timestamp per the user's date-format setting (spec §07). An
|
||||||
|
* unparseable timestamp is returned verbatim rather than rendered as "Invalid
|
||||||
|
* Date", so a malformed stored value degrades gracefully. `customFormat` is used
|
||||||
|
* only in `custom` mode and falls back to ISO when blank. `now` (for `smart`)
|
||||||
|
* defaults to the current time; inject it in tests.
|
||||||
|
*/
|
||||||
|
export function formatDate(
|
||||||
|
iso: string,
|
||||||
|
mode: DateFormatMode,
|
||||||
|
customFormat = '',
|
||||||
|
now: Date = new Date(),
|
||||||
|
): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return iso;
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case 'iso':
|
||||||
|
return formatIso(date);
|
||||||
|
case 'custom':
|
||||||
|
return customFormat.trim() === '' ? formatIso(date) : formatCustom(date, customFormat);
|
||||||
|
case 'smart':
|
||||||
|
default:
|
||||||
|
return formatSmart(date, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import { createDataset, type Dataset } from './dataset';
|
||||||
|
import {
|
||||||
|
EXPORT_ENVELOPE_VERSION,
|
||||||
|
buildExportEnvelope,
|
||||||
|
exportFilename,
|
||||||
|
exportSummaryMessage,
|
||||||
|
type ExportEnvelope,
|
||||||
|
} from './export-envelope';
|
||||||
|
import { createSnippet, type Snippet } from './snippet';
|
||||||
|
|
||||||
|
const FIXED_NOW = new Date('2026-06-03T12:00:00.000Z');
|
||||||
|
|
||||||
|
function makeSnippet(overrides: Partial<Snippet> = {}): Snippet {
|
||||||
|
return { ...createSnippet({ now: FIXED_NOW, id: 's1' }), ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDataset(overrides: Partial<Dataset> = {}): Dataset {
|
||||||
|
return {
|
||||||
|
...createDataset({
|
||||||
|
name: 'D1',
|
||||||
|
data: 'a,b\n1,2',
|
||||||
|
format: 'csv',
|
||||||
|
source: 'inline',
|
||||||
|
now: FIXED_NOW,
|
||||||
|
id: 1,
|
||||||
|
}),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EXPORT_ENVELOPE_VERSION', () => {
|
||||||
|
it('is the spec-mandated "1.0"', () => {
|
||||||
|
expect(EXPORT_ENVELOPE_VERSION).toBe('1.0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildExportEnvelope', () => {
|
||||||
|
it('stamps version, ISO timestamp, and the fixed exporter tag', () => {
|
||||||
|
const env = buildExportEnvelope([], [], { now: FIXED_NOW });
|
||||||
|
expect(env.version).toBe('1.0');
|
||||||
|
expect(env.exportedAt).toBe('2026-06-03T12:00:00.000Z');
|
||||||
|
expect(env.exportedBy).toBe('Astrolabe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces the full envelope shape (matches spec §08 example fields)', () => {
|
||||||
|
const snippet = makeSnippet();
|
||||||
|
const dataset = makeDataset();
|
||||||
|
const env = buildExportEnvelope([snippet], [dataset], { now: FIXED_NOW });
|
||||||
|
const expected: ExportEnvelope = {
|
||||||
|
version: '1.0',
|
||||||
|
exportedAt: '2026-06-03T12:00:00.000Z',
|
||||||
|
exportedBy: 'Astrolabe',
|
||||||
|
snippets: [snippet],
|
||||||
|
datasets: [dataset],
|
||||||
|
};
|
||||||
|
expect(env).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the records through unchanged', () => {
|
||||||
|
const snippet = makeSnippet();
|
||||||
|
const dataset = makeDataset();
|
||||||
|
const env = buildExportEnvelope([snippet], [dataset], { now: FIXED_NOW });
|
||||||
|
expect(env.snippets[0]).toBe(snippet);
|
||||||
|
expect(env.datasets[0]).toBe(dataset);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves each record's own version field", () => {
|
||||||
|
const snippet = makeSnippet({ version: 1 });
|
||||||
|
const dataset = makeDataset({ version: 1 });
|
||||||
|
const env = buildExportEnvelope([snippet], [dataset], { now: FIXED_NOW });
|
||||||
|
expect(env.snippets[0].version).toBe(1);
|
||||||
|
expect(env.datasets[0].version).toBe(1);
|
||||||
|
// The per-record version is distinct from the envelope's file-format version.
|
||||||
|
expect(env.version).toBe('1.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not alias the caller's arrays (mutating inputs afterward is inert)", () => {
|
||||||
|
const snippets = [makeSnippet()];
|
||||||
|
const datasets = [makeDataset()];
|
||||||
|
const env = buildExportEnvelope(snippets, datasets, { now: FIXED_NOW });
|
||||||
|
|
||||||
|
snippets.push(makeSnippet({ id: 's2' }));
|
||||||
|
datasets.push(makeDataset({ id: 2, name: 'D2' }));
|
||||||
|
|
||||||
|
expect(env.snippets).toHaveLength(1);
|
||||||
|
expect(env.datasets).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty arrays', () => {
|
||||||
|
const env = buildExportEnvelope([], [], { now: FIXED_NOW });
|
||||||
|
expect(env.snippets).toEqual([]);
|
||||||
|
expect(env.datasets).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportFilename', () => {
|
||||||
|
it('formats as astrolabe-project-YYYY-MM-DD.json with zero-padding', () => {
|
||||||
|
// Local date parts; construct with local Y/M/D to avoid TZ ambiguity.
|
||||||
|
expect(exportFilename(new Date(2026, 0, 5))).toBe('astrolabe-project-2026-01-05.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zero-pads two-digit month and day', () => {
|
||||||
|
expect(exportFilename(new Date(2026, 11, 25))).toBe('astrolabe-project-2026-12-25.json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportSummaryMessage', () => {
|
||||||
|
it('reports both counts (plural)', () => {
|
||||||
|
expect(exportSummaryMessage(4, 2)).toBe('Exported 4 snippets and 2 datasets');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the dataset clause when there are no datasets', () => {
|
||||||
|
expect(exportSummaryMessage(4, 0)).toBe('Exported 4 snippets');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses singular wording for counts of 1', () => {
|
||||||
|
expect(exportSummaryMessage(1, 1)).toBe('Exported 1 snippet and 1 dataset');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('singular snippet with omitted dataset clause', () => {
|
||||||
|
expect(exportSummaryMessage(1, 0)).toBe('Exported 1 snippet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pluralizes zero counts (and omits the dataset clause)', () => {
|
||||||
|
expect(exportSummaryMessage(0, 0)).toBe('Exported 0 snippets');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Export envelope — the single JSON object Astrolabe downloads to back up or
|
||||||
|
* transfer a whole workspace (spec §08 → Export, Export envelope shape).
|
||||||
|
*
|
||||||
|
* Portable core: no browser APIs, no React. Builds the in-memory envelope, the
|
||||||
|
* download filename, and the success-toast message as plain data. The actual file
|
||||||
|
* download (Blob/anchor/DOM) is browser code that lives in the app layer; this
|
||||||
|
* module only shapes what that code serializes and reports.
|
||||||
|
*
|
||||||
|
* The envelope `version` is the **file-format** version, distinct from the
|
||||||
|
* per-record `version` fields each snippet/dataset carries (their read-time
|
||||||
|
* migration target — see snippet.ts / dataset.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Dataset } from './dataset';
|
||||||
|
import type { Snippet } from './snippet';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current export file-format version (spec §08 → "currently `\"1.0\"`"). Bump when
|
||||||
|
* the envelope shape changes in a way importers must branch on; this is NOT the
|
||||||
|
* per-record schema version.
|
||||||
|
*/
|
||||||
|
export const EXPORT_ENVELOPE_VERSION = '1.0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The downloaded file's top-level shape (spec §08 → Export envelope shape): format
|
||||||
|
* metadata plus the two complete-record arrays. Each record keeps its own `version`
|
||||||
|
* field unchanged.
|
||||||
|
*/
|
||||||
|
export interface ExportEnvelope {
|
||||||
|
/** Export format version (currently `"1.0"`). */
|
||||||
|
version: string;
|
||||||
|
/** ISO 8601 timestamp of the export. */
|
||||||
|
exportedAt: string;
|
||||||
|
/** Fixed exporter tag. */
|
||||||
|
exportedBy: 'Astrolabe';
|
||||||
|
/** All snippets, as complete records (each including its record `version`). */
|
||||||
|
snippets: Snippet[];
|
||||||
|
/** All datasets, as complete records (each including its record `version`). */
|
||||||
|
datasets: Dataset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the export envelope (spec §08 → Export). Stamps the format version, the
|
||||||
|
* export timestamp (`now.toISOString()`), and the fixed exporter tag, and copies
|
||||||
|
* the records into fresh arrays so the envelope does not alias the caller's arrays
|
||||||
|
* (the records themselves are referenced as-is — they are the complete records to
|
||||||
|
* serialize, each keeping its own `version`).
|
||||||
|
*/
|
||||||
|
export function buildExportEnvelope(
|
||||||
|
snippets: ReadonlyArray<Snippet>,
|
||||||
|
datasets: ReadonlyArray<Dataset>,
|
||||||
|
opts: { now: Date },
|
||||||
|
): ExportEnvelope {
|
||||||
|
return {
|
||||||
|
version: EXPORT_ENVELOPE_VERSION,
|
||||||
|
exportedAt: opts.now.toISOString(),
|
||||||
|
exportedBy: 'Astrolabe',
|
||||||
|
snippets: [...snippets],
|
||||||
|
datasets: [...datasets],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two-digit zero-pad for the filename date (mirrors snippet.ts → generateSnippetName). */
|
||||||
|
function pad(n: number): string {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download filename for an export (spec §08 → Filename):
|
||||||
|
* `astrolabe-project-YYYY-MM-DD.json`, where the date is today's date (export day).
|
||||||
|
* Uses local date parts, like `generateSnippetName`.
|
||||||
|
*/
|
||||||
|
export function exportFilename(now: Date): string {
|
||||||
|
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
return `astrolabe-project-${date}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pluralize a count's noun: "1 snippet" / "4 snippets". */
|
||||||
|
function countClause(count: number, noun: string): string {
|
||||||
|
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Success-toast message reporting the export counts (spec §08 → Feedback), e.g.
|
||||||
|
* "Exported 4 snippets and 2 datasets". The dataset clause is omitted entirely when
|
||||||
|
* there are no datasets; singular/plural wording adapts to the counts.
|
||||||
|
*/
|
||||||
|
export function exportSummaryMessage(snippetCount: number, datasetCount: number): string {
|
||||||
|
const snippets = countClause(snippetCount, 'snippet');
|
||||||
|
if (datasetCount === 0) return `Exported ${snippets}`;
|
||||||
|
return `Exported ${snippets} and ${countClause(datasetCount, 'dataset')}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { CURRENT_DATASET_VERSION, type Dataset } from './dataset';
|
||||||
|
import {
|
||||||
|
applyDatasetRenamesToSnippets,
|
||||||
|
dedupeIncomingDatasetNames,
|
||||||
|
importSummaryMessage,
|
||||||
|
normalizeImport,
|
||||||
|
reassignCollidingSnippetIds,
|
||||||
|
} from './import-normalize';
|
||||||
|
import { CURRENT_SNIPPET_VERSION, type Snippet } from './snippet';
|
||||||
|
|
||||||
|
/** Deterministic id generator returning id-1, id-2, … */
|
||||||
|
function counterIds(prefix = 'id'): () => string {
|
||||||
|
let n = 0;
|
||||||
|
return () => `${prefix}-${++n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIXED_NOW = new Date('2026-06-07T10:00:00.000Z');
|
||||||
|
const FIXED_NOW_ISO = FIXED_NOW.toISOString();
|
||||||
|
|
||||||
|
/** Parse a spec string and read its top-level `data.name` (typed for lint). */
|
||||||
|
function dataName(spec: string): string {
|
||||||
|
return (JSON.parse(spec) as { data: { name: string } }).data.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fully-current snippet record (carries an ISO `created`). */
|
||||||
|
function currentSnippetRecord(over: Partial<Snippet> = {}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: 's-existing',
|
||||||
|
version: CURRENT_SNIPPET_VERSION,
|
||||||
|
name: 'Bar chart',
|
||||||
|
created: '2025-01-02T03:04:05.000Z',
|
||||||
|
modified: '2025-02-02T03:04:05.000Z',
|
||||||
|
spec: '{"mark":"bar"}',
|
||||||
|
draftSpec: '{"mark":"bar"}',
|
||||||
|
comment: 'hi',
|
||||||
|
tags: ['fav'],
|
||||||
|
datasetRefs: ['Sales'],
|
||||||
|
meta: { k: 1 },
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal current dataset record. */
|
||||||
|
function datasetRecord(over: Partial<Dataset> = {}): Dataset {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
version: CURRENT_DATASET_VERSION,
|
||||||
|
name: 'Sales',
|
||||||
|
data: 'a,b\n1,2',
|
||||||
|
format: 'csv',
|
||||||
|
source: 'inline',
|
||||||
|
comment: '',
|
||||||
|
rowCount: 1,
|
||||||
|
columnCount: 2,
|
||||||
|
columns: ['a', 'b'],
|
||||||
|
columnTypes: [
|
||||||
|
{ name: 'a', type: 'number' },
|
||||||
|
{ name: 'b', type: 'number' },
|
||||||
|
],
|
||||||
|
size: 7,
|
||||||
|
created: '2025-01-01T00:00:00.000Z',
|
||||||
|
modified: '2025-01-01T00:00:00.000Z',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('normalizeImport — shape detection', () => {
|
||||||
|
it('recognizes the Astrolabe export envelope (snippets + datasets)', () => {
|
||||||
|
const parsed = {
|
||||||
|
version: '1.0',
|
||||||
|
exportedBy: 'Astrolabe',
|
||||||
|
snippets: [currentSnippetRecord()],
|
||||||
|
datasets: [datasetRecord()],
|
||||||
|
};
|
||||||
|
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||||
|
expect(result.snippets).toHaveLength(1);
|
||||||
|
expect(result.datasets).toHaveLength(1);
|
||||||
|
expect(result.datasets[0].name).toBe('Sales');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a top-level array as a bare list of snippets (no datasets)', () => {
|
||||||
|
const parsed = [currentSnippetRecord({ id: 'a' }), currentSnippetRecord({ id: 'b' })];
|
||||||
|
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||||
|
expect(result.snippets).toHaveLength(2);
|
||||||
|
expect(result.datasets).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a non-envelope object as a single snippet', () => {
|
||||||
|
const parsed = currentSnippetRecord();
|
||||||
|
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||||
|
expect(result.snippets).toHaveLength(1);
|
||||||
|
expect(result.datasets).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an object with version but no snippets array as a single snippet', () => {
|
||||||
|
// `version` is a record field here, not an envelope marker — no snippets array.
|
||||||
|
const parsed = currentSnippetRecord();
|
||||||
|
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||||
|
expect(result.snippets).toHaveLength(1);
|
||||||
|
expect(result.snippets[0].name).toBe('Bar chart');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for null / non-object / non-array junk', () => {
|
||||||
|
expect(normalizeImport(null)).toEqual({ snippets: [], datasets: [] });
|
||||||
|
expect(normalizeImport(42)).toEqual({ snippets: [], datasets: [] });
|
||||||
|
expect(normalizeImport('hello')).toEqual({ snippets: [], datasets: [] });
|
||||||
|
expect(normalizeImport(undefined)).toEqual({ snippets: [], datasets: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a non-array datasets field on the envelope', () => {
|
||||||
|
const parsed = { version: '1.0', snippets: [currentSnippetRecord()], datasets: 'nope' };
|
||||||
|
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||||
|
expect(result.datasets).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeImport — snippet normalization', () => {
|
||||||
|
it('preserves an already-current snippet and does NOT add the imported tag', () => {
|
||||||
|
const result = normalizeImport([currentSnippetRecord()], { now: FIXED_NOW });
|
||||||
|
const s = result.snippets[0];
|
||||||
|
expect(s.id).toBe('s-existing');
|
||||||
|
expect(s.name).toBe('Bar chart');
|
||||||
|
expect(s.created).toBe('2025-01-02T03:04:05.000Z');
|
||||||
|
expect(s.modified).toBe('2025-02-02T03:04:05.000Z');
|
||||||
|
expect(s.comment).toBe('hi');
|
||||||
|
expect(s.tags).toEqual(['fav']);
|
||||||
|
expect(s.datasetRefs).toEqual(['Sales']);
|
||||||
|
expect(s.meta).toEqual({ k: 1 });
|
||||||
|
expect(s.version).toBe(CURRENT_SNIPPET_VERSION);
|
||||||
|
expect(s.tags).not.toContain('imported');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills missing fields on a current snippet with sensible fallbacks', () => {
|
||||||
|
const parsed = [{ created: '2025-03-03T03:03:03.000Z', spec: '{"mark":"line"}' }];
|
||||||
|
const result = normalizeImport(parsed, { now: FIXED_NOW, makeId: counterIds() });
|
||||||
|
const s = result.snippets[0];
|
||||||
|
expect(s.id).toBe('id-1');
|
||||||
|
expect(s.name).toBe('Untitled');
|
||||||
|
expect(s.modified).toBe('2025-03-03T03:03:03.000Z'); // falls back to created
|
||||||
|
expect(s.draftSpec).toBe('{"mark":"line"}'); // falls back to spec
|
||||||
|
expect(s.comment).toBe('');
|
||||||
|
expect(s.tags).toEqual([]); // current shape → not tagged
|
||||||
|
expect(s.datasetRefs).toEqual([]);
|
||||||
|
expect(s.meta).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps foreign field names content→spec, draft→draftSpec, createdAt→created', () => {
|
||||||
|
const parsed = [
|
||||||
|
{
|
||||||
|
id: 'foreign-1',
|
||||||
|
content: '{"mark":"point"}',
|
||||||
|
draft: '{"mark":"point","x":1}',
|
||||||
|
createdAt: '2024-12-12T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||||
|
const s = result.snippets[0];
|
||||||
|
expect(s.spec).toBe('{"mark":"point"}');
|
||||||
|
expect(s.draftSpec).toBe('{"mark":"point","x":1}');
|
||||||
|
expect(s.created).toBe('2024-12-12T08:00:00.000Z'); // derived from source createdAt
|
||||||
|
expect(s.modified).toBe('2024-12-12T08:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags foreign/older snippets "imported" and generates missing timestamps to now', () => {
|
||||||
|
const parsed = [{ content: '{"mark":"area"}' }];
|
||||||
|
const result = normalizeImport(parsed, { now: FIXED_NOW, makeId: counterIds() });
|
||||||
|
const s = result.snippets[0];
|
||||||
|
expect(s.tags).toContain('imported');
|
||||||
|
expect(s.created).toBe(FIXED_NOW_ISO);
|
||||||
|
expect(s.modified).toBe(FIXED_NOW_ISO);
|
||||||
|
expect(s.id).toBe('id-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate the imported tag when already present', () => {
|
||||||
|
const parsed = [{ content: '{}', tags: ['imported', 'x'] }];
|
||||||
|
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||||
|
expect(result.snippets[0].tags).toEqual(['imported', 'x']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a bare YYYY-MM-DD created as foreign (tags imported)', () => {
|
||||||
|
// A date-only stamp is not the ISO timestamp shape → foreign → tagged + regenerated.
|
||||||
|
const parsed = [{ created: '2024-05-05', content: '{}' }];
|
||||||
|
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||||
|
const s = result.snippets[0];
|
||||||
|
expect(s.tags).toContain('imported');
|
||||||
|
expect(s.created).toBe('2024-05-05'); // derived from the present source timestamp
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces an object spec/draftSpec into pretty JSON string form', () => {
|
||||||
|
const parsed = [
|
||||||
|
{
|
||||||
|
created: '2025-01-01T00:00:00.000Z',
|
||||||
|
spec: { mark: 'bar', encoding: { x: { field: 'a' } } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||||
|
const s = result.snippets[0];
|
||||||
|
expect(s.spec).toBe(JSON.stringify({ mark: 'bar', encoding: { x: { field: 'a' } } }, null, 2));
|
||||||
|
expect(s.draftSpec).toBe(s.spec); // draft falls back to spec
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back spec to {} when entirely absent', () => {
|
||||||
|
const parsed = [{ created: '2025-01-01T00:00:00.000Z' }];
|
||||||
|
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||||
|
expect(result.snippets[0].spec).toBe('{}');
|
||||||
|
expect(result.snippets[0].draftSpec).toBe('{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic with injected now and makeId', () => {
|
||||||
|
const parsed = [{ content: '{}' }, { content: '{}' }];
|
||||||
|
const a = normalizeImport(parsed, { now: FIXED_NOW, makeId: counterIds() });
|
||||||
|
const b = normalizeImport(parsed, { now: FIXED_NOW, makeId: counterIds() });
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
expect(a.snippets.map((s) => s.id)).toEqual(['id-1', 'id-2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeImport — dataset normalization', () => {
|
||||||
|
it('preserves envelope dataset summary fields without re-profiling', () => {
|
||||||
|
const parsed = { version: '1.0', snippets: [], datasets: [datasetRecord()] };
|
||||||
|
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||||
|
const d = result.datasets[0];
|
||||||
|
expect(d.rowCount).toBe(1);
|
||||||
|
expect(d.columnCount).toBe(2);
|
||||||
|
expect(d.columns).toEqual(['a', 'b']);
|
||||||
|
expect(d.columnTypes).toHaveLength(2);
|
||||||
|
expect(d.version).toBe(CURRENT_DATASET_VERSION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills gaps with defaults and coerces id to a number', () => {
|
||||||
|
const parsed = { version: '1.0', snippets: [], datasets: [{ id: '42', name: 'D' }] };
|
||||||
|
const result = normalizeImport(parsed, { now: FIXED_NOW });
|
||||||
|
const d = result.datasets[0];
|
||||||
|
expect(d.id).toBe(42);
|
||||||
|
expect(d.columns).toEqual([]);
|
||||||
|
expect(d.columnTypes).toEqual([]);
|
||||||
|
expect(d.rowCount).toBeNull();
|
||||||
|
expect(d.columnCount).toBeNull();
|
||||||
|
expect(d.format).toBe('json');
|
||||||
|
expect(d.source).toBe('inline');
|
||||||
|
expect(d.comment).toBe('');
|
||||||
|
expect(d.size).toBe(0);
|
||||||
|
expect(d.created).toBe(FIXED_NOW_ISO);
|
||||||
|
expect(d.modified).toBe(FIXED_NOW_ISO);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dedupeIncomingDatasetNames', () => {
|
||||||
|
it('returns names unchanged when there are no collisions', () => {
|
||||||
|
const { datasets, renames } = dedupeIncomingDatasetNames(
|
||||||
|
['Other'],
|
||||||
|
[datasetRecord({ name: 'Sales' })],
|
||||||
|
);
|
||||||
|
expect(datasets[0].name).toBe('Sales');
|
||||||
|
expect(renames).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suffixes a collision with an existing library name', () => {
|
||||||
|
const { datasets, renames } = dedupeIncomingDatasetNames(
|
||||||
|
['Sales'],
|
||||||
|
[datasetRecord({ name: 'Sales' })],
|
||||||
|
);
|
||||||
|
expect(datasets[0].name).toBe('Sales 2');
|
||||||
|
expect(renames).toEqual([{ from: 'Sales', to: 'Sales 2' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reserves names as-you-go so intra-batch dupes become Sales 2, Sales 3', () => {
|
||||||
|
const { datasets, renames } = dedupeIncomingDatasetNames(
|
||||||
|
['Sales'],
|
||||||
|
[datasetRecord({ name: 'Sales' }), datasetRecord({ name: 'Sales' })],
|
||||||
|
);
|
||||||
|
expect(datasets.map((d) => d.name)).toEqual(['Sales 2', 'Sales 3']);
|
||||||
|
expect(renames).toEqual([
|
||||||
|
{ from: 'Sales', to: 'Sales 2' },
|
||||||
|
{ from: 'Sales', to: 'Sales 3' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches collisions case-insensitively (Sales vs sales)', () => {
|
||||||
|
const { datasets, renames } = dedupeIncomingDatasetNames(
|
||||||
|
['Sales'],
|
||||||
|
[datasetRecord({ name: 'sales' })],
|
||||||
|
);
|
||||||
|
expect(datasets[0].name).toBe('sales 2'); // preserves incoming casing
|
||||||
|
expect(renames).toEqual([{ from: 'sales', to: 'sales 2' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only clones datasets whose name changed', () => {
|
||||||
|
const keep = datasetRecord({ name: 'Untouched' });
|
||||||
|
const { datasets } = dedupeIncomingDatasetNames(['Sales'], [keep]);
|
||||||
|
expect(datasets[0]).toBe(keep);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reassignCollidingSnippetIds', () => {
|
||||||
|
const mk = (id: string): Snippet => ({
|
||||||
|
id,
|
||||||
|
version: CURRENT_SNIPPET_VERSION,
|
||||||
|
name: id,
|
||||||
|
created: FIXED_NOW_ISO,
|
||||||
|
modified: FIXED_NOW_ISO,
|
||||||
|
spec: '{}',
|
||||||
|
draftSpec: '{}',
|
||||||
|
comment: '',
|
||||||
|
tags: [],
|
||||||
|
datasetRefs: [],
|
||||||
|
meta: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a non-colliding id untouched (same object reference)', () => {
|
||||||
|
const s = mk('fresh');
|
||||||
|
const [out] = reassignCollidingSnippetIds(['existing'], [s], counterIds());
|
||||||
|
expect(out).toBe(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reassigns an incoming id that collides with an existing snippet', () => {
|
||||||
|
const out = reassignCollidingSnippetIds(['dup'], [mk('dup')], counterIds());
|
||||||
|
expect(out[0].id).toBe('id-1');
|
||||||
|
expect(out[0].name).toBe('dup'); // other fields preserved
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives two incoming snippets sharing one id distinct fresh ids', () => {
|
||||||
|
const out = reassignCollidingSnippetIds(['dup'], [mk('dup'), mk('dup')], counterIds());
|
||||||
|
expect(out.map((s) => s.id)).toEqual(['id-1', 'id-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a generated id that would itself collide with a reserved id', () => {
|
||||||
|
// First generated id "id-1" is already reserved → must skip to "id-2".
|
||||||
|
const out = reassignCollidingSnippetIds(['dup', 'id-1'], [mk('dup')], counterIds());
|
||||||
|
expect(out[0].id).toBe('id-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps all reassigned ids distinct even when a fresh id matches a later input id', () => {
|
||||||
|
// Reservation is left-to-right: the first colliding snippet takes "id-1" and
|
||||||
|
// reserves it; the second snippet's existing id is also "id-1", now reserved,
|
||||||
|
// so it too is reassigned (→ "id-2"). No two snippets end up sharing an id.
|
||||||
|
const out = reassignCollidingSnippetIds(['dup'], [mk('dup'), mk('id-1')], counterIds());
|
||||||
|
expect(out[0].id).toBe('id-1');
|
||||||
|
expect(out[1].id).toBe('id-2');
|
||||||
|
expect(new Set(out.map((s) => s.id)).size).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyDatasetRenamesToSnippets', () => {
|
||||||
|
const referencing: Snippet = {
|
||||||
|
id: 'r1',
|
||||||
|
version: CURRENT_SNIPPET_VERSION,
|
||||||
|
name: 'Uses Sales',
|
||||||
|
created: FIXED_NOW_ISO,
|
||||||
|
modified: FIXED_NOW_ISO,
|
||||||
|
spec: JSON.stringify({ data: { name: 'Sales' }, mark: 'bar' }, null, 2),
|
||||||
|
draftSpec: JSON.stringify({ data: { name: 'Sales' }, mark: 'line' }, null, 2),
|
||||||
|
comment: '',
|
||||||
|
tags: [],
|
||||||
|
datasetRefs: ['Sales'],
|
||||||
|
meta: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('rewrites spec, draftSpec, and datasetRefs for a referencing snippet', () => {
|
||||||
|
const [out] = applyDatasetRenamesToSnippets([referencing], [{ from: 'Sales', to: 'Sales 2' }]);
|
||||||
|
expect(dataName(out.spec)).toBe('Sales 2');
|
||||||
|
expect(dataName(out.draftSpec)).toBe('Sales 2');
|
||||||
|
expect(out.draftSpec).toContain('"line"'); // draft kept its own mark
|
||||||
|
expect(out.datasetRefs).toEqual(['Sales 2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the rename target case-insensitively', () => {
|
||||||
|
const lower: Snippet = { ...referencing, datasetRefs: ['Sales'] };
|
||||||
|
const [out] = applyDatasetRenamesToSnippets([lower], [{ from: 'sales', to: 'sales 2' }]);
|
||||||
|
expect(dataName(out.spec)).toBe('sales 2');
|
||||||
|
expect(out.datasetRefs).toEqual(['sales 2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a non-referencing snippet untouched (same reference)', () => {
|
||||||
|
const unrelated: Snippet = {
|
||||||
|
...referencing,
|
||||||
|
id: 'u1',
|
||||||
|
spec: JSON.stringify({ data: { name: 'Other' } }, null, 2),
|
||||||
|
draftSpec: JSON.stringify({ data: { name: 'Other' } }, null, 2),
|
||||||
|
datasetRefs: ['Other'],
|
||||||
|
};
|
||||||
|
const out = applyDatasetRenamesToSnippets([unrelated], [{ from: 'Sales', to: 'Sales 2' }]);
|
||||||
|
expect(out[0]).toBe(unrelated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a fresh array (no aliasing) when there are no renames', () => {
|
||||||
|
const input = [referencing];
|
||||||
|
const out = applyDatasetRenamesToSnippets(input, []);
|
||||||
|
expect(out).not.toBe(input);
|
||||||
|
expect(out[0]).toBe(referencing);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies multiple renames to one snippet', () => {
|
||||||
|
const twoRefs: Snippet = {
|
||||||
|
...referencing,
|
||||||
|
spec: JSON.stringify(
|
||||||
|
{ layer: [{ data: { name: 'Sales' } }, { data: { name: 'Regions' } }] },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
draftSpec: JSON.stringify(
|
||||||
|
{ layer: [{ data: { name: 'Sales' } }, { data: { name: 'Regions' } }] },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
datasetRefs: ['Regions', 'Sales'],
|
||||||
|
};
|
||||||
|
const [out] = applyDatasetRenamesToSnippets(
|
||||||
|
[twoRefs],
|
||||||
|
[
|
||||||
|
{ from: 'Sales', to: 'Sales 2' },
|
||||||
|
{ from: 'Regions', to: 'Regions 2' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(out.datasetRefs).toEqual(['Regions 2', 'Sales 2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importSummaryMessage', () => {
|
||||||
|
it('pluralizes snippets and includes the dataset clause', () => {
|
||||||
|
expect(importSummaryMessage(4, 2)).toBe('Imported 4 snippets and 2 datasets');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses singular wording for counts of 1', () => {
|
||||||
|
expect(importSummaryMessage(1, 1)).toBe('Imported 1 snippet and 1 dataset');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the dataset clause when datasetCount is 0', () => {
|
||||||
|
expect(importSummaryMessage(3, 0)).toBe('Imported 3 snippets');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pluralizes zero counts correctly', () => {
|
||||||
|
expect(importSummaryMessage(0, 0)).toBe('Imported 0 snippets');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
/**
|
||||||
|
* Import normalization — shape detection, per-record normalization to the current
|
||||||
|
* model, and pure merge helpers (spec §08 → Import; docs/architecture/07 §5, §6).
|
||||||
|
*
|
||||||
|
* Portable core: no browser APIs, no React, no store/file/IndexedDB access. Plain
|
||||||
|
* data in, plain data out. The app's ImportService orchestrates the file read,
|
||||||
|
* store reads (for existing names/ids), and the commit; everything *deterministic*
|
||||||
|
* about an import — recognizing the file shape, coercing each record onto the
|
||||||
|
* current `Snippet`/`Dataset` shape, de-duping names, reassigning colliding ids,
|
||||||
|
* and propagating renames into specs — lives here so it can be unit-tested hardest.
|
||||||
|
*
|
||||||
|
* `crypto.randomUUID` is a platform global (like in snippet.ts), allowed in core.
|
||||||
|
* Both id generators are injectable so tests can assert deterministically.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CURRENT_DATASET_VERSION, type DataSource, type Dataset } from './dataset';
|
||||||
|
import type { DataFormat } from './format-detection';
|
||||||
|
import { makeUniqueName } from './naming';
|
||||||
|
import { CURRENT_SNIPPET_VERSION, type Snippet } from './snippet';
|
||||||
|
import { extractDatasetRefs, recomputeDatasetRefs, renameDatasetInSpec } from './spec-refs';
|
||||||
|
import type { ColumnType } from './type-inference';
|
||||||
|
|
||||||
|
/** The tag stamped onto foreign/older snippets so the user can find imports. */
|
||||||
|
const IMPORTED_TAG = 'imported';
|
||||||
|
|
||||||
|
/** Result of normalizing a parsed import payload onto the current model. */
|
||||||
|
export interface NormalizedImport {
|
||||||
|
snippets: Snippet[];
|
||||||
|
datasets: Dataset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single dataset rename applied during dedupe (`from` original → `to` unique). */
|
||||||
|
export interface DatasetRename {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizeImportOptions {
|
||||||
|
/** Clock injection for generated timestamps; defaults to the current time. */
|
||||||
|
now?: Date;
|
||||||
|
/** Id injection for generated snippet ids; defaults to `crypto.randomUUID`. */
|
||||||
|
makeId?: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Small shape helpers (kept private; mirror migrateSnippet's fallback approach
|
||||||
|
// without importing from app/infrastructure — core cannot depend on app).
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coerce a stored spec field (object or string) into the canonical string form. */
|
||||||
|
function asSpecText(value: unknown, fallback: string): string {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (value == null) return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An ISO-ish creation timestamp marks an already-current Astrolabe snippet. We
|
||||||
|
* accept the common ISO 8601 lead-in `YYYY-MM-DDTHH:MM` (with optional seconds,
|
||||||
|
* fractional seconds, and zone) — the shape `Date#toISOString` produces. We do
|
||||||
|
* not require the trailing `Z`/offset so a hand-edited but clearly-current
|
||||||
|
* timestamp still counts; we deliberately reject a bare `YYYY-MM-DD` date, which
|
||||||
|
* older/foreign shapes use, so those still get normalized + tagged.
|
||||||
|
*/
|
||||||
|
function isIsoTimestamp(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A trimmed, non-empty string, or undefined — used to derive a source timestamp. */
|
||||||
|
function asNonEmptyString(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Per-record normalization
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize one raw snippet record onto the current `Snippet` shape.
|
||||||
|
*
|
||||||
|
* - Already-current (carries an ISO `created`): preserve existing fields with
|
||||||
|
* fallbacks; do NOT add the "imported" tag.
|
||||||
|
* - Foreign/older: map alternative field names (`content` → spec, `draft` →
|
||||||
|
* draftSpec, `createdAt` → created), generate missing timestamps, fill all
|
||||||
|
* gaps with defaults, and ensure the "imported" tag is present.
|
||||||
|
*
|
||||||
|
* Specs are coerced to canonical JSON-string form; draftSpec falls back to spec.
|
||||||
|
* `version` is always stamped to the current value (a missing/old version is the
|
||||||
|
* earliest shape, migrated up here).
|
||||||
|
*/
|
||||||
|
function normalizeSnippet(raw: unknown, nowIso: string, makeId: () => string): Snippet {
|
||||||
|
const r = isPlainObject(raw) ? raw : {};
|
||||||
|
|
||||||
|
// Field mapping for foreign shapes: prefer the current field, fall back to the
|
||||||
|
// alternative name (`content` → spec, `draft` → draftSpec, `createdAt` → created).
|
||||||
|
const specSource = r.spec ?? r.content;
|
||||||
|
const draftSource = r.draftSpec ?? r.draft;
|
||||||
|
const createdSource = asNonEmptyString(r.created) ?? asNonEmptyString(r.createdAt);
|
||||||
|
|
||||||
|
const isCurrent = isIsoTimestamp(r.created);
|
||||||
|
|
||||||
|
const spec = asSpecText(specSource, '{}');
|
||||||
|
const draftSpec = asSpecText(draftSource, spec);
|
||||||
|
|
||||||
|
// Timestamps: a current record keeps its ISO `created`; otherwise derive from a
|
||||||
|
// present source timestamp (created/createdAt), else fall back to `now`.
|
||||||
|
const created = isCurrent ? (r.created as string) : (createdSource ?? nowIso);
|
||||||
|
const modified = asNonEmptyString(r.modified) ?? created;
|
||||||
|
|
||||||
|
// Tags: preserve any existing tags; for foreign/older shapes ensure "imported"
|
||||||
|
// is present (without duplicating it).
|
||||||
|
const existingTags = Array.isArray(r.tags)
|
||||||
|
? (r.tags as unknown[]).filter((t): t is string => typeof t === 'string')
|
||||||
|
: [];
|
||||||
|
const tags = isCurrent
|
||||||
|
? existingTags
|
||||||
|
: existingTags.includes(IMPORTED_TAG)
|
||||||
|
? existingTags
|
||||||
|
: [...existingTags, IMPORTED_TAG];
|
||||||
|
|
||||||
|
const datasetRefs = Array.isArray(r.datasetRefs)
|
||||||
|
? (r.datasetRefs as unknown[]).filter((d): d is string => typeof d === 'string')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof r.id === 'string' && r.id !== '' ? r.id : makeId(),
|
||||||
|
version: CURRENT_SNIPPET_VERSION,
|
||||||
|
name: typeof r.name === 'string' ? r.name : 'Untitled',
|
||||||
|
created,
|
||||||
|
modified,
|
||||||
|
spec,
|
||||||
|
draftSpec,
|
||||||
|
comment: typeof r.comment === 'string' ? r.comment : '',
|
||||||
|
tags,
|
||||||
|
datasetRefs,
|
||||||
|
meta: isPlainObject(r.meta) ? r.meta : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize one raw dataset record onto the current `Dataset` shape. Envelope
|
||||||
|
* datasets already carry their derived summary fields (rowCount, columns, …); we
|
||||||
|
* preserve those and only fill gaps. We do NOT re-profile here — that needs the
|
||||||
|
* profiling pipeline and would be wasteful for already-summarized records.
|
||||||
|
*/
|
||||||
|
function normalizeDataset(raw: unknown, nowIso: string): Dataset {
|
||||||
|
const r = isPlainObject(raw) ? raw : {};
|
||||||
|
|
||||||
|
const created = asNonEmptyString(r.created) ?? nowIso;
|
||||||
|
const modified = asNonEmptyString(r.modified) ?? created;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof r.id === 'number' ? r.id : Number(r.id) || 0,
|
||||||
|
version: CURRENT_DATASET_VERSION,
|
||||||
|
name: typeof r.name === 'string' ? r.name : 'Untitled',
|
||||||
|
data: r.data,
|
||||||
|
format: (typeof r.format === 'string' ? r.format : 'json') as DataFormat,
|
||||||
|
source: (typeof r.source === 'string' ? r.source : 'inline') as DataSource,
|
||||||
|
comment: typeof r.comment === 'string' ? r.comment : '',
|
||||||
|
rowCount: typeof r.rowCount === 'number' ? r.rowCount : null,
|
||||||
|
columnCount: typeof r.columnCount === 'number' ? r.columnCount : null,
|
||||||
|
columns: Array.isArray(r.columns)
|
||||||
|
? (r.columns as unknown[]).filter((c): c is string => typeof c === 'string')
|
||||||
|
: [],
|
||||||
|
columnTypes: Array.isArray(r.columnTypes)
|
||||||
|
? (r.columnTypes as Array<{ name: string; type: ColumnType }>)
|
||||||
|
: [],
|
||||||
|
size: typeof r.size === 'number' ? r.size : 0,
|
||||||
|
created,
|
||||||
|
modified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Shape detection (spec §08 "Accepted inputs")
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the import shape and normalize every record onto the current model.
|
||||||
|
*
|
||||||
|
* - Envelope: object with a `version` AND a `snippets` array → its snippets
|
||||||
|
* (+ optional `datasets` array).
|
||||||
|
* - Bare array: a top-level array → a list of snippets, no datasets.
|
||||||
|
* - Single object: any other object → one snippet, no datasets.
|
||||||
|
* - junk (null / non-object / non-array) → empty.
|
||||||
|
*/
|
||||||
|
export function normalizeImport(
|
||||||
|
parsed: unknown,
|
||||||
|
opts: NormalizeImportOptions = {},
|
||||||
|
): NormalizedImport {
|
||||||
|
const nowIso = (opts.now ?? new Date()).toISOString();
|
||||||
|
const makeId = opts.makeId ?? (() => crypto.randomUUID());
|
||||||
|
|
||||||
|
let rawSnippets: unknown[] = [];
|
||||||
|
let rawDatasets: unknown[] = [];
|
||||||
|
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
// Bare array of snippets.
|
||||||
|
rawSnippets = parsed;
|
||||||
|
} else if (isPlainObject(parsed)) {
|
||||||
|
const hasEnvelope = 'version' in parsed && Array.isArray(parsed.snippets);
|
||||||
|
if (hasEnvelope) {
|
||||||
|
rawSnippets = parsed.snippets as unknown[];
|
||||||
|
if (Array.isArray(parsed.datasets)) rawDatasets = parsed.datasets;
|
||||||
|
} else {
|
||||||
|
// Single snippet object.
|
||||||
|
rawSnippets = [parsed];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// else: null / non-object / non-array junk → both stay empty.
|
||||||
|
|
||||||
|
return {
|
||||||
|
snippets: rawSnippets.map((s) => normalizeSnippet(s, nowIso, makeId)),
|
||||||
|
datasets: rawDatasets.map((d) => normalizeDataset(d, nowIso)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Merge helpers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* De-dupe incoming dataset names against the existing library and within the
|
||||||
|
* batch itself (architecture 07 §5). Names are reserved *as we go* so two incoming
|
||||||
|
* `Sales` become `Sales 2`, `Sales 3` — never two `Sales 2`. Existing datasets are
|
||||||
|
* never overwritten. Every rename is collected for reporting. Only datasets whose
|
||||||
|
* name actually changed are cloned.
|
||||||
|
*/
|
||||||
|
export function dedupeIncomingDatasetNames(
|
||||||
|
existing: ReadonlyArray<string>,
|
||||||
|
incoming: ReadonlyArray<Dataset>,
|
||||||
|
): { datasets: Dataset[]; renames: DatasetRename[] } {
|
||||||
|
const reserved = new Set(existing.map((n) => n.toLowerCase()));
|
||||||
|
const renames: DatasetRename[] = [];
|
||||||
|
|
||||||
|
const datasets = incoming.map((d) => {
|
||||||
|
const unique = makeUniqueName(d.name, reserved);
|
||||||
|
reserved.add(unique.toLowerCase()); // reserve so later imports don't collide
|
||||||
|
if (unique !== d.name) renames.push({ from: d.name, to: unique });
|
||||||
|
return unique === d.name ? d : { ...d, name: unique };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { datasets, renames };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reassign ids for incoming snippets whose id already exists (spec §08 "ID
|
||||||
|
* collisions"). The existing snippet keeps its id; the incoming one gets a fresh
|
||||||
|
* unique id. Ids are reserved *as we go* so two incoming snippets sharing one id
|
||||||
|
* both get distinct fresh ids — and a freshly-minted id can't collide with another
|
||||||
|
* incoming snippet either. Only snippets that get a new id are cloned.
|
||||||
|
*/
|
||||||
|
export function reassignCollidingSnippetIds(
|
||||||
|
existingIds: ReadonlyArray<string>,
|
||||||
|
incoming: ReadonlyArray<Snippet>,
|
||||||
|
makeId: () => string = () => crypto.randomUUID(),
|
||||||
|
): Snippet[] {
|
||||||
|
const reserved = new Set(existingIds);
|
||||||
|
|
||||||
|
return incoming.map((s) => {
|
||||||
|
if (!reserved.has(s.id)) {
|
||||||
|
reserved.add(s.id);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
let fresh = makeId();
|
||||||
|
while (reserved.has(fresh)) fresh = makeId();
|
||||||
|
reserved.add(fresh);
|
||||||
|
return { ...s, id: fresh };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propagate dataset renames (from `dedupeIncomingDatasetNames`) into the imported
|
||||||
|
* snippets that reference them (architecture 07 §6): for each rename, rewrite both
|
||||||
|
* `spec` and `draftSpec` via `renameDatasetInSpec`, then recompute `datasetRefs`
|
||||||
|
* from the rewritten spec — the spec is the source of truth, `datasetRefs` mirrors
|
||||||
|
* it. Matching is case-insensitive (consistent with the naming helpers). Only
|
||||||
|
* snippets that actually change are cloned.
|
||||||
|
*
|
||||||
|
* Applied to the imported set ONLY — existing snippets keep their references,
|
||||||
|
* because existing datasets were never renamed (collisions suffix the incoming).
|
||||||
|
*/
|
||||||
|
export function applyDatasetRenamesToSnippets(
|
||||||
|
snippets: ReadonlyArray<Snippet>,
|
||||||
|
renames: ReadonlyArray<DatasetRename>,
|
||||||
|
): Snippet[] {
|
||||||
|
if (renames.length === 0) return snippets.slice();
|
||||||
|
|
||||||
|
return snippets.map((snippet) => {
|
||||||
|
// The names this snippet actually references: what the specs literally
|
||||||
|
// reference (spec casing first, so `renameDatasetInSpec`'s case-sensitive
|
||||||
|
// match gets the right oldName) unioned with the stored `datasetRefs` mirror.
|
||||||
|
// Consulting the spec — not only datasetRefs — makes propagation robust when a
|
||||||
|
// hand-crafted/foreign import references a dataset in its spec without a
|
||||||
|
// matching datasetRefs entry; the renderer resolves by spec, so a missed
|
||||||
|
// rename would otherwise break rendering (arch 07 §3/§6).
|
||||||
|
const referenced = [
|
||||||
|
...extractDatasetRefs(snippet.spec),
|
||||||
|
...extractDatasetRefs(snippet.draftSpec),
|
||||||
|
...snippet.datasetRefs,
|
||||||
|
];
|
||||||
|
const targets: Array<{ oldName: string; to: string }> = [];
|
||||||
|
for (const { from, to } of renames) {
|
||||||
|
const ref = referenced.find((r) => r.toLowerCase() === from.toLowerCase());
|
||||||
|
if (ref !== undefined) targets.push({ oldName: ref, to });
|
||||||
|
}
|
||||||
|
if (targets.length === 0) return snippet;
|
||||||
|
|
||||||
|
let spec = snippet.spec;
|
||||||
|
let draftSpec = snippet.draftSpec;
|
||||||
|
for (const { oldName, to } of targets) {
|
||||||
|
spec = renameDatasetInSpec(spec, oldName, to);
|
||||||
|
draftSpec = renameDatasetInSpec(draftSpec, oldName, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...snippet, spec, draftSpec, datasetRefs: recomputeDatasetRefs(spec) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Feedback
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Pluralize a count: `1 thing`, `0 things`, `4 things`. */
|
||||||
|
function plural(count: number, noun: string): string {
|
||||||
|
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The import success message (spec §08 "Feedback"), e.g.
|
||||||
|
* "Imported 4 snippets and 2 datasets". The dataset clause is omitted when
|
||||||
|
* `datasetCount` is 0; singular/plural adapts for any counts ≥ 0.
|
||||||
|
*/
|
||||||
|
export function importSummaryMessage(snippetCount: number, datasetCount: number): string {
|
||||||
|
const head = `Imported ${plural(snippetCount, 'snippet')}`;
|
||||||
|
return datasetCount > 0 ? `${head} and ${plural(datasetCount, 'dataset')}` : head;
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
CURRENT_SETTINGS_VERSION,
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
defaultSettings,
|
||||||
|
loadSettings,
|
||||||
|
type UserSettings,
|
||||||
|
} from './settings';
|
||||||
|
|
||||||
|
describe('defaultSettings', () => {
|
||||||
|
it('returns the factory-default shape (spec §07)', () => {
|
||||||
|
expect(defaultSettings()).toEqual({
|
||||||
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
|
editor: {
|
||||||
|
fontSize: 12,
|
||||||
|
theme: 'auto',
|
||||||
|
minimap: false,
|
||||||
|
wordWrap: 'on',
|
||||||
|
lineNumbers: 'on',
|
||||||
|
tabSize: 2,
|
||||||
|
},
|
||||||
|
performance: { renderDebounce: 1500 },
|
||||||
|
ui: { theme: 'light', previewFitMode: 'default' },
|
||||||
|
formatting: { dateFormat: 'smart', customDateFormat: '' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns independent copies (mutating one does not affect another)', () => {
|
||||||
|
const a = defaultSettings();
|
||||||
|
const b = defaultSettings();
|
||||||
|
a.editor.fontSize = 18;
|
||||||
|
a.editor.wordWrap = 'off';
|
||||||
|
a.formatting.customDateFormat = 'yyyy';
|
||||||
|
expect(b.editor.fontSize).toBe(12);
|
||||||
|
expect(b.editor.wordWrap).toBe('on');
|
||||||
|
expect(b.formatting.customDateFormat).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches DEFAULT_SETTINGS by value', () => {
|
||||||
|
expect(defaultSettings()).toEqual(DEFAULT_SETTINGS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DEFAULT_SETTINGS', () => {
|
||||||
|
it('is deeply frozen (read-only reference)', () => {
|
||||||
|
expect(Object.isFrozen(DEFAULT_SETTINGS)).toBe(true);
|
||||||
|
expect(Object.isFrozen(DEFAULT_SETTINGS.editor)).toBe(true);
|
||||||
|
expect(Object.isFrozen(DEFAULT_SETTINGS.ui)).toBe(true);
|
||||||
|
expect(Object.isFrozen(DEFAULT_SETTINGS.formatting)).toBe(true);
|
||||||
|
expect(Object.isFrozen(DEFAULT_SETTINGS.performance)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadSettings — valid records', () => {
|
||||||
|
it('round-trips a full valid record (stamping current version)', () => {
|
||||||
|
const record: UserSettings = {
|
||||||
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
|
editor: {
|
||||||
|
fontSize: 14,
|
||||||
|
theme: 'vs-dark',
|
||||||
|
minimap: true,
|
||||||
|
wordWrap: 'off',
|
||||||
|
lineNumbers: 'off',
|
||||||
|
tabSize: 4,
|
||||||
|
},
|
||||||
|
performance: { renderDebounce: 2500 },
|
||||||
|
ui: { theme: 'dark', previewFitMode: 'full' },
|
||||||
|
formatting: { dateFormat: 'custom', customDateFormat: 'yyyy-MM-dd' },
|
||||||
|
};
|
||||||
|
expect(loadSettings(record)).toEqual(record);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts any string for editor.theme and customDateFormat', () => {
|
||||||
|
const out = loadSettings({
|
||||||
|
editor: { theme: 'my-custom-theme' },
|
||||||
|
formatting: { dateFormat: 'custom', customDateFormat: 'HH:mm' },
|
||||||
|
});
|
||||||
|
expect(out.editor.theme).toBe('my-custom-theme');
|
||||||
|
expect(out.formatting.customDateFormat).toBe('HH:mm');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadSettings — partial records fill gaps', () => {
|
||||||
|
it('keeps a single nested field and fills the rest with defaults', () => {
|
||||||
|
const out = loadSettings({ editor: { fontSize: 14 } });
|
||||||
|
expect(out.editor.fontSize).toBe(14);
|
||||||
|
expect(out).toEqual({
|
||||||
|
...defaultSettings(),
|
||||||
|
editor: { ...defaultSettings().editor, fontSize: 14 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills entirely-missing groups from defaults', () => {
|
||||||
|
const out = loadSettings({ ui: { theme: 'dark' } });
|
||||||
|
expect(out.ui.theme).toBe('dark');
|
||||||
|
expect(out.editor).toEqual(defaultSettings().editor);
|
||||||
|
expect(out.performance).toEqual(defaultSettings().performance);
|
||||||
|
expect(out.formatting).toEqual(defaultSettings().formatting);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a non-object group as empty (uses defaults)', () => {
|
||||||
|
const out = loadSettings({ editor: 'nope', performance: 42, ui: null });
|
||||||
|
expect(out.editor).toEqual(defaultSettings().editor);
|
||||||
|
expect(out.performance).toEqual(defaultSettings().performance);
|
||||||
|
expect(out.ui).toEqual(defaultSettings().ui);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadSettings — numeric coercion and clamping', () => {
|
||||||
|
it('clamps and rounds fontSize to [10, 18]', () => {
|
||||||
|
expect(loadSettings({ editor: { fontSize: 5 } }).editor.fontSize).toBe(10);
|
||||||
|
expect(loadSettings({ editor: { fontSize: 99 } }).editor.fontSize).toBe(18);
|
||||||
|
expect(loadSettings({ editor: { fontSize: 12.7 } }).editor.fontSize).toBe(13);
|
||||||
|
expect(loadSettings({ editor: { fontSize: 14 } }).editor.fontSize).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces tabSize to a positive integer', () => {
|
||||||
|
expect(loadSettings({ editor: { tabSize: 0 } }).editor.tabSize).toBe(1);
|
||||||
|
expect(loadSettings({ editor: { tabSize: -3 } }).editor.tabSize).toBe(1);
|
||||||
|
expect(loadSettings({ editor: { tabSize: 2.9 } }).editor.tabSize).toBe(3);
|
||||||
|
expect(loadSettings({ editor: { tabSize: 8 } }).editor.tabSize).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps renderDebounce to [500, 5000]', () => {
|
||||||
|
expect(loadSettings({ performance: { renderDebounce: 100 } }).performance.renderDebounce).toBe(
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
expect(loadSettings({ performance: { renderDebounce: 9999 } }).performance.renderDebounce).toBe(
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
expect(loadSettings({ performance: { renderDebounce: 1500 } }).performance.renderDebounce).toBe(
|
||||||
|
1500,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to default for non-number / non-finite numerics', () => {
|
||||||
|
const d = defaultSettings();
|
||||||
|
expect(loadSettings({ editor: { fontSize: '14' } }).editor.fontSize).toBe(d.editor.fontSize);
|
||||||
|
expect(loadSettings({ editor: { fontSize: NaN } }).editor.fontSize).toBe(d.editor.fontSize);
|
||||||
|
expect(loadSettings({ editor: { fontSize: Infinity } }).editor.fontSize).toBe(
|
||||||
|
d.editor.fontSize,
|
||||||
|
);
|
||||||
|
expect(loadSettings({ editor: { tabSize: null } }).editor.tabSize).toBe(d.editor.tabSize);
|
||||||
|
expect(
|
||||||
|
loadSettings({ performance: { renderDebounce: 'fast' } }).performance.renderDebounce,
|
||||||
|
).toBe(d.performance.renderDebounce);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadSettings — enum validation', () => {
|
||||||
|
it('falls back when an enum value is not allowed', () => {
|
||||||
|
const d = defaultSettings();
|
||||||
|
expect(loadSettings({ editor: { wordWrap: 'sometimes' } }).editor.wordWrap).toBe(
|
||||||
|
d.editor.wordWrap,
|
||||||
|
);
|
||||||
|
expect(loadSettings({ editor: { lineNumbers: 1 } }).editor.lineNumbers).toBe(
|
||||||
|
d.editor.lineNumbers,
|
||||||
|
);
|
||||||
|
expect(loadSettings({ ui: { theme: 'sepia' } }).ui.theme).toBe(d.ui.theme);
|
||||||
|
expect(loadSettings({ ui: { previewFitMode: 'tall' } }).ui.previewFitMode).toBe(
|
||||||
|
d.ui.previewFitMode,
|
||||||
|
);
|
||||||
|
expect(loadSettings({ formatting: { dateFormat: 'relative' } }).formatting.dateFormat).toBe(
|
||||||
|
d.formatting.dateFormat,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts each allowed enum value', () => {
|
||||||
|
expect(loadSettings({ editor: { wordWrap: 'off' } }).editor.wordWrap).toBe('off');
|
||||||
|
expect(loadSettings({ editor: { lineNumbers: 'off' } }).editor.lineNumbers).toBe('off');
|
||||||
|
expect(loadSettings({ ui: { theme: 'dark' } }).ui.theme).toBe('dark');
|
||||||
|
expect(loadSettings({ ui: { previewFitMode: 'width' } }).ui.previewFitMode).toBe('width');
|
||||||
|
expect(loadSettings({ ui: { previewFitMode: 'height' } }).ui.previewFitMode).toBe('height');
|
||||||
|
expect(loadSettings({ formatting: { dateFormat: 'iso' } }).formatting.dateFormat).toBe('iso');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back when minimap is not a boolean', () => {
|
||||||
|
expect(loadSettings({ editor: { minimap: 'yes' } }).editor.minimap).toBe(false);
|
||||||
|
expect(loadSettings({ editor: { minimap: true } }).editor.minimap).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadSettings — junk and edge inputs', () => {
|
||||||
|
it('returns defaults for null/undefined/array/string/number', () => {
|
||||||
|
const d = defaultSettings();
|
||||||
|
expect(loadSettings(null)).toEqual(d);
|
||||||
|
expect(loadSettings(undefined)).toEqual(d);
|
||||||
|
expect(loadSettings([])).toEqual(d);
|
||||||
|
expect(loadSettings([{ editor: { fontSize: 14 } }])).toEqual(d);
|
||||||
|
expect(loadSettings('settings')).toEqual(d);
|
||||||
|
expect(loadSettings(42)).toEqual(d);
|
||||||
|
expect(loadSettings(true)).toEqual(d);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates unknown extra keys without throwing', () => {
|
||||||
|
const out = loadSettings({
|
||||||
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
|
futureFeature: { enabled: true },
|
||||||
|
editor: { fontSize: 16, somethingNew: 'x' },
|
||||||
|
});
|
||||||
|
expect(out.editor.fontSize).toBe(16);
|
||||||
|
expect(out).not.toHaveProperty('futureFeature');
|
||||||
|
expect(out.editor).not.toHaveProperty('somethingNew');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadSettings — version stamping', () => {
|
||||||
|
it('always stamps the current version regardless of input version', () => {
|
||||||
|
expect(loadSettings({ version: 0 }).version).toBe(CURRENT_SETTINGS_VERSION);
|
||||||
|
expect(loadSettings({ version: 99 }).version).toBe(CURRENT_SETTINGS_VERSION);
|
||||||
|
expect(loadSettings({ version: 'old' }).version).toBe(CURRENT_SETTINGS_VERSION);
|
||||||
|
expect(loadSettings({}).version).toBe(CURRENT_SETTINGS_VERSION);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* UserSettings — persisted user preferences (spec §07, storage shape §09C).
|
||||||
|
*
|
||||||
|
* Portable core: no browser APIs, no React, no Monaco. Defines the settings
|
||||||
|
* record shape, the current schema version, the factory defaults, and the
|
||||||
|
* read-time load/normalize function. The localStorage wiring that actually
|
||||||
|
* reads and writes the JSON lives in infrastructure — this module only knows
|
||||||
|
* the shape and how to coerce arbitrary parsed input back into a valid record.
|
||||||
|
*
|
||||||
|
* Settings load at startup; per spec §07 "Startup load", any missing or
|
||||||
|
* unrecognized value silently falls back to its factory default, so older or
|
||||||
|
* partial saved records never break the app. `loadSettings` is the gate that
|
||||||
|
* guarantees that (the read-time migration, mirroring `migrateSnippet`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Current schema version for a UserSettings record (read-time migration target). */
|
||||||
|
export const CURRENT_SETTINGS_VERSION = 1;
|
||||||
|
|
||||||
|
export interface UserSettings {
|
||||||
|
/** Record schema version, for read-time migration (spec §09C). */
|
||||||
|
version: number;
|
||||||
|
/** Spec-editor configuration (spec §07 → Editor). Applies to the editing surface. */
|
||||||
|
editor: {
|
||||||
|
/** Editor font size, 10–18 px integer (spec §07; default 12). */
|
||||||
|
fontSize: number;
|
||||||
|
/**
|
||||||
|
* Editor color theme id. The `'auto'` sentinel follows the app UI theme
|
||||||
|
* (light app → light editor, dark → dark); any other value is an explicit
|
||||||
|
* override (spec §07; default `'auto'`).
|
||||||
|
*/
|
||||||
|
theme: string;
|
||||||
|
/** Whether the editor minimap (overview strip) is shown (spec §07; default false). */
|
||||||
|
minimap: boolean;
|
||||||
|
/** Soft-wrap long lines (spec §07; default `'on'`). */
|
||||||
|
wordWrap: 'on' | 'off';
|
||||||
|
/** Show the line-number gutter (spec §07; default `'on'`). */
|
||||||
|
lineNumbers: 'on' | 'off';
|
||||||
|
/** Indentation width in spaces, positive integer (spec §07; default 2). */
|
||||||
|
tabSize: number;
|
||||||
|
};
|
||||||
|
/** Preview-performance tuning (spec §07 → Performance). */
|
||||||
|
performance: {
|
||||||
|
/**
|
||||||
|
* Delay (ms) after the user stops typing before the preview re-renders,
|
||||||
|
* 500–5000 (spec §07; default 1500). Lower feels snappier but re-renders
|
||||||
|
* more often; higher is calmer but laggier.
|
||||||
|
*/
|
||||||
|
renderDebounce: number;
|
||||||
|
};
|
||||||
|
/** App-chrome appearance (spec §07 → Appearance; §09C). */
|
||||||
|
ui: {
|
||||||
|
/** Overall UI theme; flips the whole app chrome (spec §07; default `'light'`). */
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
/**
|
||||||
|
* Preview sizing/fit mode (set by the preview's own Fit control, not a
|
||||||
|
* settings cluster, but persisted in this record per §09C; see _Live
|
||||||
|
* Preview_). Default `'default'`.
|
||||||
|
*/
|
||||||
|
previewFitMode: 'default' | 'width' | 'height' | 'full';
|
||||||
|
};
|
||||||
|
/** Date-rendering preferences (spec §07 → Formatting). */
|
||||||
|
formatting: {
|
||||||
|
/**
|
||||||
|
* How dates render throughout the app (spec §07; default `'smart'`).
|
||||||
|
* `'smart'` = relative/human-friendly; `'iso'` = full ISO 8601 timestamp;
|
||||||
|
* `'custom'` = use `customDateFormat`.
|
||||||
|
*/
|
||||||
|
dateFormat: 'smart' | 'iso' | 'custom';
|
||||||
|
/**
|
||||||
|
* Free-text format string, used only when `dateFormat = 'custom'`
|
||||||
|
* (spec §07; default empty string).
|
||||||
|
*/
|
||||||
|
customDateFormat: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fresh UserSettings with every field at its factory default
|
||||||
|
* (spec §07). Returns an independent deep copy each call so callers may freely
|
||||||
|
* mutate the result without affecting the shared `DEFAULT_SETTINGS` constant or
|
||||||
|
* each other.
|
||||||
|
*/
|
||||||
|
export function defaultSettings(): UserSettings {
|
||||||
|
return {
|
||||||
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
|
editor: {
|
||||||
|
fontSize: 12,
|
||||||
|
theme: 'auto',
|
||||||
|
minimap: false,
|
||||||
|
wordWrap: 'on',
|
||||||
|
lineNumbers: 'on',
|
||||||
|
tabSize: 2,
|
||||||
|
},
|
||||||
|
performance: {
|
||||||
|
renderDebounce: 1500,
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
theme: 'light',
|
||||||
|
previewFitMode: 'default',
|
||||||
|
},
|
||||||
|
formatting: {
|
||||||
|
dateFormat: 'smart',
|
||||||
|
customDateFormat: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deeply-frozen factory defaults, for read-only reference (e.g. comparing a
|
||||||
|
* form against defaults). To get a mutable copy, call `defaultSettings()`.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_SETTINGS: UserSettings = deepFreeze(defaultSettings());
|
||||||
|
|
||||||
|
/** Recursively freeze an object and its nested plain-object members. */
|
||||||
|
function deepFreeze<T>(value: T): T {
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
for (const member of Object.values(value)) deepFreeze(member);
|
||||||
|
Object.freeze(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Round to an integer and clamp to `[min, max]`. */
|
||||||
|
function clampInt(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A finite number, or `null` for `NaN`/`Infinity`/non-numbers. */
|
||||||
|
function asNumber(value: unknown): number | null {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return `value` if it is one of `allowed`, else `fallback`. */
|
||||||
|
function asEnum<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||||
|
return typeof value === 'string' && (allowed as readonly string[]).includes(value)
|
||||||
|
? (value as T)
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Safely read a nested settings group as a record (tolerates non-objects). */
|
||||||
|
function group(raw: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||||
|
const value = raw[key];
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize an arbitrary parsed value into a fully-valid UserSettings — the
|
||||||
|
* read-time migration (spec §07 "Startup load"; §09C). Accepts `null`, junk, a
|
||||||
|
* partial object, or a record from an older version. Every missing or
|
||||||
|
* wrong-typed field silently falls back to its default; numeric fields are
|
||||||
|
* coerced and clamped to their ranges; enum fields are validated against their
|
||||||
|
* allowed values; unknown extra keys are ignored. The output `version` is
|
||||||
|
* always stamped to `CURRENT_SETTINGS_VERSION` (like `migrateSnippet`).
|
||||||
|
*
|
||||||
|
* Tolerates partial nesting: `{ editor: { fontSize: 14 } }` keeps 14 and fills
|
||||||
|
* the rest of `editor` (and every other group) from defaults.
|
||||||
|
*/
|
||||||
|
export function loadSettings(raw: unknown): UserSettings {
|
||||||
|
const d = defaultSettings();
|
||||||
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return d;
|
||||||
|
|
||||||
|
const r = raw as Record<string, unknown>;
|
||||||
|
const editor = group(r, 'editor');
|
||||||
|
const performance = group(r, 'performance');
|
||||||
|
const ui = group(r, 'ui');
|
||||||
|
const formatting = group(r, 'formatting');
|
||||||
|
|
||||||
|
const fontSize = asNumber(editor.fontSize);
|
||||||
|
const tabSize = asNumber(editor.tabSize);
|
||||||
|
const renderDebounce = asNumber(performance.renderDebounce);
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
|
editor: {
|
||||||
|
fontSize: fontSize === null ? d.editor.fontSize : clampInt(fontSize, 10, 18),
|
||||||
|
theme: typeof editor.theme === 'string' ? editor.theme : d.editor.theme,
|
||||||
|
minimap: typeof editor.minimap === 'boolean' ? editor.minimap : d.editor.minimap,
|
||||||
|
wordWrap: asEnum(editor.wordWrap, ['on', 'off'] as const, d.editor.wordWrap),
|
||||||
|
lineNumbers: asEnum(editor.lineNumbers, ['on', 'off'] as const, d.editor.lineNumbers),
|
||||||
|
tabSize: tabSize === null ? d.editor.tabSize : clampInt(tabSize, 1, Number.MAX_SAFE_INTEGER),
|
||||||
|
},
|
||||||
|
performance: {
|
||||||
|
renderDebounce:
|
||||||
|
renderDebounce === null
|
||||||
|
? d.performance.renderDebounce
|
||||||
|
: clampInt(renderDebounce, 500, 5000),
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
theme: asEnum(ui.theme, ['light', 'dark'] as const, d.ui.theme),
|
||||||
|
previewFitMode: asEnum(
|
||||||
|
ui.previewFitMode,
|
||||||
|
['default', 'width', 'height', 'full'] as const,
|
||||||
|
d.ui.previewFitMode,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
formatting: {
|
||||||
|
dateFormat: asEnum(
|
||||||
|
formatting.dateFormat,
|
||||||
|
['smart', 'iso', 'custom'] as const,
|
||||||
|
d.formatting.dateFormat,
|
||||||
|
),
|
||||||
|
customDateFormat:
|
||||||
|
typeof formatting.customDateFormat === 'string'
|
||||||
|
? formatting.customDateFormat
|
||||||
|
: d.formatting.customDateFormat,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import { App } from './app/App';
|
import { App } from './app/App';
|
||||||
import { initPanes, wirePanes } from './app/orchestration/panes';
|
import { initPanes, wirePanes } from './app/orchestration/panes';
|
||||||
import { initPreviewFitMode, wirePreviewFitMode } from './app/orchestration/preferences';
|
import { initPreviewFitMode, wirePreviewFitMode } from './app/orchestration/preferences';
|
||||||
|
import { initSettings, wireSettings } from './app/orchestration/settings';
|
||||||
import { initApp } from './app/orchestration/startup';
|
import { initApp } from './app/orchestration/startup';
|
||||||
import { initTheme, wireTheme } from './app/orchestration/theme';
|
import { initTheme, wireTheme } from './app/orchestration/theme';
|
||||||
import '../styles/base.css';
|
import '../styles/base.css';
|
||||||
@@ -19,6 +20,12 @@ wirePreviewFitMode();
|
|||||||
initPanes();
|
initPanes();
|
||||||
wirePanes();
|
wirePanes();
|
||||||
|
|
||||||
|
// Hydrate + persist the full UserSettings record (editor / performance /
|
||||||
|
// formatting) before render, so the editor, preview, and library read the user's
|
||||||
|
// settings from the first paint (spec §07 → Startup load).
|
||||||
|
initSettings();
|
||||||
|
wireSettings();
|
||||||
|
|
||||||
// Load the library from IndexedDB (seeding a sample on first run) and wire
|
// Load the library from IndexedDB (seeding a sample on first run) and wire
|
||||||
// persistence. Fire-and-forget: the UI renders immediately and fills in when
|
// persistence. Fire-and-forget: the UI renders immediately and fills in when
|
||||||
// hydration resolves.
|
// hydration resolves.
|
||||||
|
|||||||
Reference in New Issue
Block a user