mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add resizable panes with drag handles, min widths, and persistence
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadPanelLayout, savePanelLayout } from './ux-prefs';
|
||||
|
||||
const KEY = 'astrolabe:ux-prefs';
|
||||
|
||||
/** In-memory localStorage stub (Node's global one is non-functional). */
|
||||
function makeStorageStub() {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
||||
setItem: (k: string, v: string) => void map.set(k, String(v)),
|
||||
removeItem: (k: string) => void map.delete(k),
|
||||
clear: () => map.clear(),
|
||||
key: (i: number) => [...map.keys()][i] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('ux-prefs · panelLayout', () => {
|
||||
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('returns empty widths when nothing is stored', () => {
|
||||
expect(loadPanelLayout()).toEqual({ libraryWidth: undefined, previewWidth: undefined });
|
||||
});
|
||||
|
||||
it('round-trips stored widths', () => {
|
||||
savePanelLayout({ libraryWidth: 300, previewWidth: 420 });
|
||||
expect(loadPanelLayout()).toEqual({ libraryWidth: 300, previewWidth: 420 });
|
||||
});
|
||||
|
||||
it('merges partial writes without dropping the other width', () => {
|
||||
savePanelLayout({ libraryWidth: 300, previewWidth: 420 });
|
||||
savePanelLayout({ libraryWidth: 250 });
|
||||
expect(loadPanelLayout()).toEqual({ libraryWidth: 250, previewWidth: 420 });
|
||||
});
|
||||
|
||||
it('rejects non-positive / non-finite junk', () => {
|
||||
localStorage.setItem(
|
||||
KEY,
|
||||
JSON.stringify({ panelLayout: { libraryWidth: -5, previewWidth: 'wide' } }),
|
||||
);
|
||||
expect(loadPanelLayout()).toEqual({ libraryWidth: undefined, previewWidth: undefined });
|
||||
});
|
||||
|
||||
it('tolerates malformed JSON', () => {
|
||||
localStorage.setItem(KEY, '{ not json');
|
||||
expect(loadPanelLayout()).toEqual({ libraryWidth: undefined, previewWidth: undefined });
|
||||
});
|
||||
|
||||
it('preserves unrelated keys in the record (forward-compatible merge)', () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ sort: { sortBy: 'name', sortOrder: 'asc' } }));
|
||||
savePanelLayout({ libraryWidth: 260 });
|
||||
const stored = JSON.parse(localStorage.getItem(KEY)!) as {
|
||||
sort: { sortBy: string };
|
||||
panelLayout: { libraryWidth: number };
|
||||
};
|
||||
expect(stored.sort.sortBy).toBe('name');
|
||||
expect(stored.panelLayout.libraryWidth).toBe(260);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* UX preferences persistence (localStorage) — docs/architecture/02 §5, spec §09D.
|
||||
*
|
||||
* These preferences persist **separately** from UserSettings so they can change
|
||||
* frequently (a drag emits many width updates) without rewriting the settings
|
||||
* record. Its own key, `astrolabe:ux-prefs`, holds the snippet sort preference
|
||||
* (lands with M5/M6) and the panel layout (per-pane widths + visibility).
|
||||
*
|
||||
* This slice persists the panel **widths** only; visibility joins it when the
|
||||
* toggle strip lands. Read-with-fallback + write-through merge, the same
|
||||
* contract as the settings adapter, so adding fields later upgrades cleanly.
|
||||
*
|
||||
* Per the architecture rule this is one of the only modules that may touch
|
||||
* `localStorage`; everything else goes through these typed functions.
|
||||
*/
|
||||
|
||||
const KEY = 'astrolabe:ux-prefs';
|
||||
|
||||
/** Per-pane widths (px). Optional — a missing field falls back to its default. */
|
||||
export interface PanelLayout {
|
||||
libraryWidth?: number;
|
||||
previewWidth?: number;
|
||||
}
|
||||
|
||||
interface StoredPrefs {
|
||||
panelLayout?: PanelLayout;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
function available(): boolean {
|
||||
try {
|
||||
return typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readRaw(): StoredPrefs {
|
||||
if (!available()) return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' ? (parsed as StoredPrefs) : {};
|
||||
} catch (err) {
|
||||
console.warn('[ux-prefs] failed to read, using defaults', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeRaw(next: StoredPrefs): void {
|
||||
if (!available()) return;
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
} catch (err) {
|
||||
console.warn('[ux-prefs] failed to write', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** A finite positive number, or undefined — guards against junk in storage. */
|
||||
function posNumber(v: unknown): number | undefined {
|
||||
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
|
||||
}
|
||||
|
||||
/** The persisted panel layout, with only valid numeric widths surfaced. */
|
||||
export function loadPanelLayout(): PanelLayout {
|
||||
const stored = readRaw().panelLayout ?? {};
|
||||
return {
|
||||
libraryWidth: posNumber(stored.libraryWidth),
|
||||
previewWidth: posNumber(stored.previewWidth),
|
||||
};
|
||||
}
|
||||
|
||||
/** Persist the panel layout, preserving every other key already in the record. */
|
||||
export function savePanelLayout(layout: PanelLayout): void {
|
||||
const current = readRaw();
|
||||
writeRaw({ ...current, panelLayout: { ...current.panelLayout, ...layout } });
|
||||
}
|
||||
Reference in New Issue
Block a user