diff --git a/tests/js/gallery-settings.test.js b/tests/js/gallery-settings.test.js index c3681a29..a8fbf658 100644 --- a/tests/js/gallery-settings.test.js +++ b/tests/js/gallery-settings.test.js @@ -61,3 +61,50 @@ test("infinite scrolling is off by default and must be a boolean", () => { assert.equal(normalizeGallerySettings({ infiniteScroll: true }).infiniteScroll, true); assert.equal(normalizeGallerySettings({ infiniteScroll: "yes" }).infiniteScroll, false); }); + +test("the workspace keys have their own defaults and validation", () => { + const { WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES } = require("../../web/js/gallery-settings.js"); + assert.equal(DEFAULT_SETTINGS.workspaceLayout, "grid"); + assert.equal(DEFAULT_SETTINGS.workspaceDensity, "comfortable"); + assert.deepEqual(WORKSPACE_LAYOUTS, ["grid", "list", "masonry"]); + assert.deepEqual(WORKSPACE_DENSITIES, ["comfortable", "large", "compact"]); + assert.equal(normalizeGallerySettings({ workspaceLayout: "masonry" }).workspaceLayout, "masonry"); + assert.equal(normalizeGallerySettings({ workspaceLayout: "mosaic" }).workspaceLayout, "grid"); + assert.equal(normalizeGallerySettings({ workspaceDensity: "large" }).workspaceDensity, "large"); + assert.equal(normalizeGallerySettings({ workspaceDensity: 3 }).workspaceDensity, "comfortable"); + // The legacy gallery's own view mode is separate: it has no masonry. + assert.equal(normalizeGallerySettings({ defaultViewMode: "masonry" }).defaultViewMode, "grid"); +}); + +test("a legacy gallery Save keeps the workspace keys and every other stored key", () => { + const fs = require("node:fs"); + const path = require("node:path"); + const vm = require("node:vm"); + const settings = require("../../web/js/gallery-settings.js"); + const storage = new Map([["gallerySettings", JSON.stringify({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1 })]]); + const controls = { + lazyLoadingToggle: { checked: true }, imageQualitySelect: { value: "high" }, defaultViewModeSelect: { value: "list" }, + defaultLimitSelect: { value: "200" }, gridColumnsSelect: { value: "6" }, showImageInfoToggle: { checked: false }, + autoLoadMetadataToggle: { checked: true }, cacheMetadataToggle: { checked: true }, showFilePathsToggle: { checked: false }, + sidebarCollapsedByDefaultToggle: { checked: true }, infiniteScrollToggle: { checked: false }, debugModeToggle: { checked: false }, + apiTimeoutInput: { value: "30" }, checkThumbnailsAtStartup: { checked: true }, videoAutoplayToggle: { checked: true }, + videoMuteToggle: { checked: false }, videoLoopToggle: { checked: true }, + }; + const context = vm.createContext({ + document: { addEventListener() {}, getElementById: id => controls[id] }, + window: {}, console: { debug() {}, warn() {}, error() {} }, GallerySettings: settings, + localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) }, + }); + const file = path.join(__dirname, "../../web/js/gallery.js"); + const Manager = vm.runInContext(`${fs.readFileSync(file, "utf8")}\nGalleryManager;`, context, { filename: file }); + const manager = Object.create(Manager.prototype); + Object.assign(manager, { showNotification() {}, hideSettings() {}, applySettings() {} }); + manager.saveSettings(); + const saved = JSON.parse(storage.get("gallerySettings")); + assert.equal(saved.workspaceLayout, "masonry"); + assert.equal(saved.workspaceDensity, "compact"); + assert.equal(saved.someOtherTool, 1); + assert.equal(saved.defaultViewMode, "list"); + assert.equal(saved.showImageInfo, false); + assert.equal(saved.videoMute, false); +}); diff --git a/tests/js/workspace-preferences.test.js b/tests/js/workspace-preferences.test.js new file mode 100644 index 00000000..bf7f4d94 --- /dev/null +++ b/tests/js/workspace-preferences.test.js @@ -0,0 +1,179 @@ +// Browser display defaults (#249 System S3 Task 7): the workspace reads and writes the legacy gallery's own +// `gallerySettings` object (Q3, "One display configuration"), writes only the keys it changed, never consumes the +// legacy paging keys, and says when this browser refused to keep a change. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import gallerySettings from '../../web/js/gallery-settings.js'; + +const module = await import('../../web/js/workspace/preferences.mjs').catch(error => { + if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; + return {}; +}); +globalThis.GallerySettings = gallerySettings; +const KEY = 'gallerySettings'; + +/** A localStorage stand-in holding `text` under the settings key; `setItem` can be made to throw. */ +function storage(text = null, { quota = false } = {}) { + const writes = []; + const store = { + text, + writes, + getItem(key) { assert.equal(key, KEY); return store.text; }, + setItem(key, value) { + assert.equal(key, KEY); + writes.push(value); + if (quota) { const error = new Error('Quota exceeded'); error.name = 'QuotaExceededError'; throw error; } + store.text = value; + }, + removeItem() { assert.fail('Preferences never remove the shared settings'); }, + }; + return store; +} +const denied = () => { const error = new Error('Denied'); error.name = 'SecurityError'; throw error; }; + +test('normalizes new workspace keys and keeps legacy keys', () => { + assert.equal(typeof module.readPreferences, 'function', 'preferences.mjs exports readPreferences'); + module.resetTabPreferences(); + const legacy = { defaultViewMode: 'list', gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'sig', extra: 1 }; + const store = storage(JSON.stringify({ ...legacy, workspaceLayout: 'masonry', workspaceDensity: 'compact', showFilePaths: false })); + const { values, persisted } = module.readPreferences(() => store); + assert.equal(persisted, true); + assert.equal(values.workspaceLayout, 'masonry'); + assert.equal(values.workspaceDensity, 'compact'); + assert.equal(values.showFilePaths, false); + // Invalid workspace values fall back to the defaults, as every legacy key does. + store.text = JSON.stringify({ workspaceLayout: 'mosaic', workspaceDensity: 7, videoMute: 'no' }); + const fallback = module.readPreferences(() => store).values; + assert.equal(fallback.workspaceLayout, 'grid'); + assert.equal(fallback.workspaceDensity, 'comfortable'); + assert.equal(fallback.videoMute, true); + // The legacy normalizer knows the new keys and still keeps unknown ones. + const normalized = gallerySettings.normalizeGallerySettings({ ...legacy, workspaceLayout: 'list' }); + assert.equal(normalized.workspaceLayout, 'list'); + assert.equal(normalized.workspaceDensity, 'comfortable'); + assert.equal(normalized.extra, 1); + assert.equal(normalized.thumbnailPromptDismissed, 'sig'); + assert.deepEqual(gallerySettings.WORKSPACE_LAYOUTS, ['grid', 'list', 'masonry']); + assert.deepEqual(gallerySettings.WORKSPACE_DENSITIES, ['comfortable', 'large', 'compact']); +}); + +test('write touches only dirty keys', () => { + module.resetTabPreferences(); + const stored = { defaultViewMode: 'list', gridColumns: 5, thumbnailsGenerated: true, extra: { kept: true }, videoMute: false }; + const store = storage(JSON.stringify(stored)); + const result = module.writePreferences({ workspaceLayout: 'masonry', showImageInfo: false }, () => store); + assert.deepEqual(result, { persisted: true, conflicts: [] }); + assert.equal(store.writes.length, 1); + // Every other key, legacy or unknown, is written back exactly as it was stored: no defaults are filled in. + assert.deepEqual(JSON.parse(store.text), { ...stored, workspaceLayout: 'masonry', showImageInfo: false }); + // It re-reads the stored object, so a key another page wrote after this one read is kept. + store.text = JSON.stringify({ ...JSON.parse(store.text), gridColumns: 9 }); + module.writePreferences({ videoLoop: false }, () => store); + assert.equal(JSON.parse(store.text).gridColumns, 9); + assert.equal(JSON.parse(store.text).workspaceLayout, 'masonry'); + // An empty or malformed stored value starts from an empty object, never the defaults. + for (const text of [null, '{broken', '[]', '42']) { + const fresh = storage(text); + assert.equal(module.writePreferences({ cacheMetadata: false }, () => fresh).persisted, true); + assert.deepEqual(JSON.parse(fresh.text), { cacheMetadata: false }); + } + // Only workspace keys with valid values are accepted; nothing is written for a bad change. + for (const bad of [{ gridColumns: 4 }, { defaultLimit: 50 }, { workspaceLayout: 'mosaic' }, { videoMute: 'yes' }, { imageQuality: 'ultra' }]) { + const untouched = storage(JSON.stringify(stored)); + assert.throws(() => module.writePreferences(bad, () => untouched), TypeError); + assert.equal(untouched.writes.length, 0); + } + // A key changed elsewhere since this tab read it (its baseline) is reported, and the explicit save still wins. + const elsewhere = storage(JSON.stringify({ videoLoop: false })); + const outcome = module.writePreferences({ videoLoop: true, videoMute: false }, () => elsewhere, { baseline: { videoLoop: true, videoMute: true } }); + assert.deepEqual(outcome, { persisted: true, conflicts: ['videoLoop'] }); + assert.equal(JSON.parse(elsewhere.text).videoLoop, true); +}); + +test('denied storage reports tab-only', () => { + module.resetTabPreferences(); + const read = module.readPreferences(denied); + assert.equal(read.persisted, false); + assert.equal(read.values.workspaceLayout, 'grid'); + const result = module.writePreferences({ workspaceLayout: 'list', videoAutoplay: true }, denied); + assert.deepEqual(result, { persisted: false, conflicts: [] }); + // The change is kept for this tab: every reader in it (a new Images visit, the viewer) sees it. + const tab = module.readPreferences(denied); + assert.equal(tab.persisted, false); + assert.equal(tab.values.workspaceLayout, 'list'); + assert.equal(tab.values.videoAutoplay, true); + // A getItem that throws is denied storage too. + const throwing = { getItem: denied, setItem: denied }; + assert.equal(module.readPreferences(() => throwing).values.workspaceLayout, 'list'); + assert.equal(module.writePreferences({ workspaceDensity: 'large' }, () => throwing).persisted, false); + // Once storage accepts a write, the tab copy of that key gives way to the stored value. + const working = storage(JSON.stringify({ workspaceLayout: 'masonry' })); + assert.equal(module.writePreferences({ workspaceLayout: 'masonry' }, () => working).persisted, true); + assert.equal(module.readPreferences(() => working).values.workspaceLayout, 'masonry'); + module.resetTabPreferences(); + assert.equal(module.readPreferences(denied).values.videoAutoplay, false); +}); + +test('quota error keeps previous bytes', () => { + module.resetTabPreferences(); + const before = JSON.stringify({ gridColumns: 6, workspaceDensity: 'large' }); + const store = storage(before, { quota: true }); + const result = module.writePreferences({ workspaceDensity: 'compact' }, () => store); + assert.deepEqual(result, { persisted: false, conflicts: [] }); + assert.equal(store.writes.length, 1); + assert.equal(store.text, before); + // This tab keeps the change it could not store. + assert.equal(module.readPreferences(() => store).values.workspaceDensity, 'compact'); + module.resetTabPreferences(); + assert.equal(module.readPreferences(() => store).values.workspaceDensity, 'large'); +}); + +/** A window stand-in that records its storage listeners. */ +function fakeWindow() { + const listeners = new Set(); + return { + listeners, + addEventListener(type, listener) { assert.equal(type, 'storage'); listeners.add(listener); }, + removeEventListener(type, listener) { assert.equal(type, 'storage'); listeners.delete(listener); }, + fire(event) { for (const listener of [...listeners]) listener(event); }, + }; +} + +test('storage event updates clean values and flags dirty conflicts', () => { + module.resetTabPreferences(); + const store = storage(JSON.stringify({ workspaceLayout: 'list', videoMute: false })); + const win = fakeWindow(); + const seen = []; + const unsubscribe = module.subscribePreferences(win, next => seen.push(next), () => store); + assert.equal(win.listeners.size, 1); + // Another key, or another storage area's event, is not ours. + win.fire({ key: 'pm_update_notice_dismissed', storageArea: store }); + win.fire({ key: KEY, storageArea: { other: true } }); + assert.equal(seen.length, 0); + win.fire({ key: KEY, storageArea: store }); + win.fire({ key: null, storageArea: store }); // localStorage.clear() elsewhere + assert.equal(seen.length, 2); + assert.equal(seen[0].values.workspaceLayout, 'list'); + unsubscribe(); + assert.equal(win.listeners.size, 0); + // The reconciliation of the System drafts: clean values follow the other page; a key this tab has a draft for and + // the other page changed is a conflict, and the draft stays. + const baseline = { workspaceLayout: 'grid', videoMute: true, videoLoop: true }; + const next = { workspaceLayout: 'list', videoMute: true, videoLoop: false }; + assert.deepEqual(module.reconcilePreferences({ baseline, drafts: { videoMute: false, videoLoop: false }, next }), { conflicts: [] }); + assert.deepEqual(module.reconcilePreferences({ baseline, drafts: { workspaceLayout: 'masonry' }, next }), { conflicts: ['workspaceLayout'] }); + assert.deepEqual(module.reconcilePreferences({ baseline, drafts: { workspaceLayout: 'list' }, next }), { conflicts: [] }); +}); + +test('legacy paging keys are never consumed by the workspace', () => { + module.resetTabPreferences(); + const store = storage(JSON.stringify({ defaultLimit: 500, gridColumns: 12, infiniteScroll: true, lazyLoading: false, + defaultViewMode: 'list', debugMode: true, apiTimeout: 9, thumbnailsGenerated: true, checkThumbnailsAtStartup: false })); + const { values } = module.readPreferences(() => store); + assert.deepEqual(Object.keys(values).sort(), [...module.WORKSPACE_PREFERENCE_KEYS].sort()); + for (const key of ['defaultLimit', 'gridColumns', 'infiniteScroll', 'lazyLoading', 'defaultViewMode', 'debugMode', 'apiTimeout', + 'thumbnailsGenerated', 'checkThumbnailsAtStartup', 'thumbnailPromptDismissed']) assert.equal(Object.hasOwn(values, key), false, key); + // The display defaults are the gallery's own defaults for these keys. + const defaults = module.preferenceDefaults(); + for (const key of module.WORKSPACE_PREFERENCE_KEYS) assert.equal(defaults[key], gallerySettings.DEFAULT_SETTINGS[key], key); +}); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 88dfa16e..a8492511 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -37,15 +37,15 @@ }, "gallery-settings.js": { "lines": 98, - "branches": 90, + "branches": 94, "functions": 98, "statements": 98 }, "gallery.js": { - "lines": 40, + "lines": 41, "branches": 63, "functions": 44, - "statements": 40 + "statements": 41 }, "html-escape.js": { "lines": 98, @@ -401,6 +401,12 @@ "functions": 98, "statements": 98 }, + "workspace/preferences.mjs": { + "lines": 98, + "branches": 94, + "functions": 91, + "statements": 98 + }, "workspace/prompt-blocks.mjs": { "lines": 90, "branches": 92, diff --git a/web/js/gallery-settings.js b/web/js/gallery-settings.js index 4c21dd62..3ea591d0 100644 --- a/web/js/gallery-settings.js +++ b/web/js/gallery-settings.js @@ -4,6 +4,9 @@ * * Settings are per browser (localStorage key "gallerySettings"). Anything * stored can be stale or hand-edited, so every value is checked before use. + * The workspace shares this object ("One display configuration", #249 System + * S3): it adds workspaceLayout and workspaceDensity, and every key either page + * does not know is kept. * * Loadable in the browser (window.GallerySettings) and in Node for tests. */ @@ -11,6 +14,9 @@ "use strict"; const VIEW_MODES = ["grid", "list"]; + // The workspace Images defaults; the legacy gallery's defaultViewMode has no masonry. + const WORKSPACE_LAYOUTS = ["grid", "list", "masonry"]; + const WORKSPACE_DENSITIES = ["comfortable", "large", "compact"]; const LIMIT_OPTIONS = [50, 100, 200, 500]; const MIN_COLUMNS = 2; const MAX_COLUMNS = 12; @@ -34,6 +40,8 @@ videoAutoplay: false, videoMute: true, videoLoop: true, + workspaceLayout: "grid", + workspaceDensity: "comfortable", }); function toInt(value) { @@ -72,6 +80,8 @@ videoAutoplay: bool(merged.videoAutoplay, DEFAULT_SETTINGS.videoAutoplay), videoMute: bool(merged.videoMute, DEFAULT_SETTINGS.videoMute), videoLoop: bool(merged.videoLoop, DEFAULT_SETTINGS.videoLoop), + workspaceLayout: WORKSPACE_LAYOUTS.includes(merged.workspaceLayout) ? merged.workspaceLayout : DEFAULT_SETTINGS.workspaceLayout, + workspaceDensity: WORKSPACE_DENSITIES.includes(merged.workspaceDensity) ? merged.workspaceDensity : DEFAULT_SETTINGS.workspaceDensity, }; } @@ -81,7 +91,7 @@ return `repeat(${n}, minmax(0, 1fr))`; } - const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, normalizeGallerySettings, gridColumnsStyle }; + const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES, normalizeGallerySettings, gridColumnsStyle }; if (typeof module !== "undefined" && module.exports) module.exports = api; else root.GallerySettings = api; })(typeof window !== "undefined" ? window : globalThis); diff --git a/web/js/gallery.js b/web/js/gallery.js index ee9f24bd..cad3c2ca 100644 --- a/web/js/gallery.js +++ b/web/js/gallery.js @@ -1768,7 +1768,10 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; } saveSettings() { + // The workspace shares this object (its layout and density, #249 System S3), so a Save starts from what is + // stored and keeps every key this dialog does not edit. const settings = { + ...this.getSettings(), lazyLoading: document.getElementById('lazyLoadingToggle').checked, imageQuality: document.getElementById('imageQualitySelect').value, defaultViewMode: document.getElementById('defaultViewModeSelect').value, @@ -1782,8 +1785,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; infiniteScroll: document.getElementById('infiniteScrollToggle').checked, debugMode: document.getElementById('debugModeToggle').checked, apiTimeout: parseInt(document.getElementById('apiTimeoutInput').value), - thumbnailsGenerated: this.getSettings().thumbnailsGenerated, // Preserve this - thumbnailPromptDismissed: this.getSettings().thumbnailPromptDismissed, checkThumbnailsAtStartup: document.getElementById('checkThumbnailsAtStartup').checked, // Video settings videoAutoplay: document.getElementById('videoAutoplayToggle').checked, diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index 4427dd64..c4e4869f 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -2,14 +2,14 @@ import {createScope, createButton} from '../shared/toolkit/core.js'; import {createViewerSession} from './viewer-state.mjs'; import {rootedFileAdapter} from './inspection-state.mjs'; import {mountFileInspector} from './file-inspector.mjs'; +import {readPreferences} from './preferences.mjs'; let viewerId = 0; // Upper bound on waiting for a closed Viewer's in-flight image view before destroying it. const PENDING_VIEW_DESTROY_MS = 10000; +/** The video defaults, read through the shared display defaults (preferences.mjs, #249 System S3). */ export function readViewerPreferences(getStorage = () => window.localStorage, normalize = globalThis.GallerySettings.normalizeGallerySettings) { - let stored; - try { stored = JSON.parse(getStorage().getItem('gallerySettings')); } catch { stored = null; } - const {videoAutoplay,videoMute,videoLoop} = normalize(stored); + const {videoAutoplay,videoMute,videoLoop} = readPreferences(getStorage,normalize).values; return {videoAutoplay,videoMute,videoLoop}; } function element(tag, className, text) { diff --git a/web/js/workspace/preferences.mjs b/web/js/workspace/preferences.mjs new file mode 100644 index 00000000..96cb095c --- /dev/null +++ b/web/js/workspace/preferences.mjs @@ -0,0 +1,106 @@ +/** + * Browser display defaults (#249 System S3 Task 7). The workspace keeps them in the legacy gallery's own + * `gallerySettings` object (Q3, "One display configuration"), so the gallery, the workspace and the viewer read one + * configuration: the new `workspaceLayout` and `workspaceDensity` beside the legacy keys they share (metadata, paths, + * card details, viewer details, image quality and the video defaults). The legacy paging keys (`defaultLimit`, + * `gridColumns`, `infiniteScroll`, …) belong to the legacy gallery and are never read here (README continuous-browsing + * ruling). + * + * - `readPreferences(getStorage, normalize)` → `{values, persisted}`: the workspace keys, normalized by `gallery-settings.js`; + * `persisted` is false when this browser refused its storage. + * - `writePreferences(dirty, getStorage, {baseline})` → `{persisted, conflicts}`: re-reads the stored object and writes + * back only the dirty keys, every other key byte-for-byte as stored. A write the browser refused (denied storage, a + * full quota) leaves the stored bytes as they were and keeps the change for this tab only, so every reader in this + * tab still sees it. `conflicts` lists the dirty keys another page changed since `baseline` was read; the explicit + * save still wins. + * - `subscribePreferences(win, onChange, getStorage)` → unsubscribe: `onChange(readPreferences())` after another page + * changed the settings (a `storage` event). + * - `reconcilePreferences({baseline, drafts, next})` → `{conflicts}`: which drafts such a change overtook. + * + * Every storage access is in try/catch: a private window, blocked site data or a malformed value never breaks a page. + */ +export const PREFERENCES_KEY = 'gallerySettings'; +export const WORKSPACE_PREFERENCE_KEYS = Object.freeze(['workspaceLayout', 'workspaceDensity', 'autoLoadMetadata', 'cacheMetadata', + 'imageQuality', 'videoAutoplay', 'videoMute', 'videoLoop', 'showFilePaths', 'showImageInfo', 'sidebarCollapsedByDefault']); + +// The changes this tab could not store, overlaid on every read until storage accepts them. +const tabOnly = new Map(); +const defaultStorage = () => globalThis.localStorage; +const normalizer = () => globalThis.GallerySettings.normalizeGallerySettings; +const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value); + +/** Forget what this tab kept without storage (tests, and nothing else). */ +export function resetTabPreferences() { + tabOnly.clear(); +} + +const pick = settings => Object.fromEntries(WORKSPACE_PREFERENCE_KEYS.map(key => [key, settings[key]])); +/** The gallery's defaults for the workspace keys. */ +export function preferenceDefaults() { + return pick(normalizer()(null)); +} + +/** The stored object (`{}` when absent or malformed), or null when storage is unavailable. */ +function storedObject(storage) { + let text; + try { text = storage.getItem(PREFERENCES_KEY); } catch { return null; } + try { + const parsed = text ? JSON.parse(text) : null; + return isObject(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function storageOf(getStorage) { + try { return getStorage() ?? null; } catch { return null; } +} + +/** `normalize` is gallery-settings.js's `normalizeGallerySettings` (the page's `window.GallerySettings` by default). */ +export function readPreferences(getStorage = defaultStorage, normalize = normalizer()) { + const storage = storageOf(getStorage); + const stored = storage ? storedObject(storage) : null; + const values = pick(normalize({ ...(stored ?? {}), ...Object.fromEntries(tabOnly) })); + return { values, persisted: stored !== null }; +} + +/** A change is only a workspace key with a value the gallery's normalizer keeps as given. */ +function validate(dirty) { + if (!isObject(dirty)) throw new TypeError('Preferences need an object of changes'); + for (const [key, value] of Object.entries(dirty)) { + if (!WORKSPACE_PREFERENCE_KEYS.includes(key)) throw new TypeError(`Not a workspace display preference: ${key}`); + if (normalizer()({ [key]: value })[key] !== value) throw new TypeError(`Invalid value for ${key}`); + } +} + +export function writePreferences(dirty, getStorage = defaultStorage, { baseline = null } = {}) { + validate(dirty); + const keep = () => { for (const [key, value] of Object.entries(dirty)) tabOnly.set(key, value); }; + const storage = storageOf(getStorage); + const stored = storage ? storedObject(storage) : null; + if (stored === null) { keep(); return { persisted: false, conflicts: [] }; } + const current = pick(normalizer()(stored)); + const conflicts = baseline ? Object.keys(dirty).filter(key => Object.hasOwn(baseline, key) && current[key] !== baseline[key]) : []; + try { + storage.setItem(PREFERENCES_KEY, JSON.stringify({ ...stored, ...dirty })); + } catch { + keep(); + return { persisted: false, conflicts }; + } + for (const key of Object.keys(dirty)) tabOnly.delete(key); + return { persisted: true, conflicts }; +} + +export function subscribePreferences(win, onChange, getStorage = defaultStorage) { + const listener = event => { + if (event.key !== null && event.key !== PREFERENCES_KEY) return; + if (event.storageArea && event.storageArea !== storageOf(getStorage)) return; + onChange(readPreferences(getStorage)); + }; + win.addEventListener('storage', listener); + return () => win.removeEventListener('storage', listener); +} + +export function reconcilePreferences({ baseline, drafts, next }) { + return { conflicts: Object.keys(drafts).filter(key => next[key] !== baseline[key] && next[key] !== drafts[key]) }; +}