* feat(system): share browser display defaults The workspace reads and writes the legacy gallery's gallerySettings object through preferences.mjs: workspaceLayout and workspaceDensity join the shared keys, a write re-reads the stored object and writes only the changed keys, denied storage or a full quota keeps the change for this tab and says so, and storage events report changes from other pages. The legacy gallery Save keeps keys it does not edit, and the viewer reads its video defaults through the shared module. * feat(system): Appearance defaults used by Images and the viewer System > Appearance is the concept's Browsing & display surface on real values: the browser rows edit this browser's display defaults through the shared gallerySettings object, and Open from ComfyUI and the node search auto-hide save to the server settings with their revision. A new Images visit starts from the default layout and size (the current view and saved views keep theirs); metadata off waits for Load metadata; cache off re-reads the shown file; paths off hides the Path row; the viewer reads the video defaults. D97 adds card details, collapsed viewer details and Reset display defaults; storage refused says Saved for this tab only. * fix(system): flag changes made while System was closed and keep embedded galleries fixed Final review fixes for System S3 (Appearance): - I1: the on-demand metadata deviation is D124 (TESTING.md, inspector markers); TESTING.md names the spec that pins it, since no concept comparison renders that state. - I2: System checks the display defaults for conflicts on mount, not only on storage events, and a browser write refuses a draft another page overtook, so a draft is never saved over another tab's value without the conflict status first. New two-tab spec. - I3: the layout and size defaults start the Images page only; embedded galleries (a prompt history's Shared gallery and dialogs) keep Grid/Comfortable. - M1: host rows whose /settings read failed say "Server settings could not be read." - M2: a rejected timeout shows the "Some changes were not saved" status. - M3: the browser loads on mount and on storage events never reject unhandled. - M4: the next accepted write stores the tab-only changes and clears the overlay. - M5: cache off never re-reads a source-changed selection, and refresh() never loads a deferred one. - M6: the System fixture refuses a config.json outside its temp root and restores it with os.replace. - M7: the inspection read counts wait on a request barrier, not frames. - M8 recorded as an open saved-views question in TESTING.md. * fix(system): answer a failed System read only after its other reads finish CI (py3.10 on ubuntu) crashed with a segfault inside SQLite in test_statistics_failure_is_an_error_envelope: the statistics and overview routes used asyncio.gather without return_exceptions, so the 500 answered on the first failure while get_statistics still ran in the executor, and the test tore the database down under it. Both routes now await every read before raising the first failure (failing before, passing after: TestFailedReadsSettleFirst). The frontend coverage merge failed on workspace/activity.mjs branches (86.3 < 87): no test asserted Activity's empty and failed-read states, so their branch was only reached in passing, depending on timing. A spec now asserts both.
239 lines
10 KiB
JavaScript
239 lines
10 KiB
JavaScript
/**
|
||
* The System draft store (#249 System S2 Task 5): every System section edits drafts behind the one Save row (the
|
||
* concept's `.save-row`, app.js renderSystem 705), in three domains:
|
||
* - `browser`: preferences kept in this browser (Appearance);
|
||
* - `host`: the server configuration, saved with the `revision` its read returned (`expected_revision`, #220);
|
||
* - `runtime`: values applied to the running server only (the log level, Q10b).
|
||
*
|
||
* `save()` writes browser, then host, then runtime, and only the domains with changes. A domain that fails keeps its
|
||
* drafts dirty and its keys `failed()`, so the section highlights them; the others are saved. A host save refused
|
||
* because the configuration changed elsewhere (`settings-changed`) is a `conflict` and keeps the draft. A host save before
|
||
* `load('host')` returned a revision fails without a request: a blind write would merge over a configuration changed
|
||
* elsewhere (S1 reads a null `expected_revision` as "merge into what is on disk"). `reloadHost()` reads the host values
|
||
* again (System calls it on mount); a host conflict reloads them itself, so the next save carries the current revision.
|
||
* Drafts live for
|
||
* the page session (Q10a): a section left and opened again shows them, and a reload of its values keeps them.
|
||
*
|
||
* Adapters: `readBrowser()` and `readRuntime()` return the values; `readHost()` returns `{values, revision}`.
|
||
* `writeBrowser(changes)` returns `{persisted}` (false: kept for this tab only) and throws an error with `code:
|
||
* 'settings-changed'` when another page changed a drafted value (a conflict: the browser values are read again),
|
||
* `writeHost(changes, {expected_revision})` the settings response (`{success, persisted, revision}`), `writeRuntime(changes)` anything;
|
||
* a write that throws failed.
|
||
*/
|
||
export { SYSTEM_SECTIONS, isSystemSection } from './state.mjs';
|
||
|
||
/** D92: the Save row's status copy; never the concept's prototype text. */
|
||
export const SETTINGS_STATUS = Object.freeze({
|
||
clean: 'No unsaved changes.',
|
||
dirty: 'Unsaved changes.',
|
||
browser: 'Saved in this browser.',
|
||
host: 'Saved to the server configuration.',
|
||
runtime: 'Applied until the server restarts.',
|
||
partial: 'Some changes were not saved. Review the highlighted settings.',
|
||
tabOnly: 'Saved for this tab only; browser storage is unavailable.',
|
||
conflict: 'Settings changed elsewhere. Reload this section to see them.',
|
||
});
|
||
|
||
export const DRAFT_DOMAINS = Object.freeze(['browser', 'host', 'runtime']);
|
||
const CONFLICT_CODE = 'settings-changed';
|
||
const ADAPTERS = ['readBrowser', 'readHost', 'readRuntime', 'writeBrowser', 'writeHost', 'writeRuntime'];
|
||
|
||
/** The status copy of one `save()` outcome (`{browser, host, runtime}`, each clean/saved/tab-only/applied/failed/conflict). */
|
||
export function statusText(outcome) {
|
||
const results = DRAFT_DOMAINS.map(domain => outcome?.[domain]);
|
||
if (results.includes('conflict')) return SETTINGS_STATUS.conflict;
|
||
if (results.includes('failed')) return SETTINGS_STATUS.partial;
|
||
const saved = [];
|
||
if (outcome?.browser === 'saved') saved.push(SETTINGS_STATUS.browser);
|
||
if (outcome?.browser === 'tab-only') saved.push(SETTINGS_STATUS.tabOnly);
|
||
if (outcome?.host === 'saved') saved.push(SETTINGS_STATUS.host);
|
||
if (outcome?.runtime === 'applied') saved.push(SETTINGS_STATUS.runtime);
|
||
return saved.join(' ') || SETTINGS_STATUS.clean;
|
||
}
|
||
|
||
function requireDomain(domain) {
|
||
if (!DRAFT_DOMAINS.includes(domain)) throw new TypeError(`Unknown settings domain: ${domain}`);
|
||
}
|
||
|
||
/** Create the store; see the module comment for the adapters. */
|
||
export function createSystemDrafts(adapters) {
|
||
for (const name of ADAPTERS) {
|
||
if (typeof adapters?.[name] !== 'function') throw new TypeError(`System drafts need ${name}`);
|
||
}
|
||
const baseline = { browser: {}, host: {}, runtime: {} };
|
||
const drafts = { browser: new Map(), host: new Map(), runtime: new Map() };
|
||
const failedKeys = { browser: new Set(), host: new Set(), runtime: new Set() };
|
||
const listeners = new Set();
|
||
let revision = null;
|
||
let hostRead = 'pending';
|
||
let outcome = null;
|
||
let saving = null;
|
||
|
||
const notify = () => { for (const listener of [...listeners]) listener(); };
|
||
const changes = domain => Object.fromEntries(drafts[domain]);
|
||
|
||
/** Read a domain's saved values; drafts are kept, and a draft equal to the new value is clean. */
|
||
async function load(domain) {
|
||
requireDomain(domain);
|
||
let values;
|
||
if (domain === 'host') {
|
||
const read = await adapters.readHost();
|
||
values = read?.values ?? {};
|
||
revision = read?.revision ?? null;
|
||
hostRead = 'loaded';
|
||
} else {
|
||
values = await (domain === 'browser' ? adapters.readBrowser() : adapters.readRuntime());
|
||
}
|
||
baseline[domain] = { ...(values ?? {}) };
|
||
for (const [key, value] of drafts[domain]) if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key);
|
||
notify();
|
||
return { ...baseline[domain] };
|
||
}
|
||
|
||
function get(domain, key) {
|
||
requireDomain(domain);
|
||
return drafts[domain].has(key) ? drafts[domain].get(key) : baseline[domain][key];
|
||
}
|
||
|
||
function set(domain, key, value) {
|
||
requireDomain(domain);
|
||
if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key);
|
||
else drafts[domain].set(key, value);
|
||
failedKeys[domain].delete(key);
|
||
outcome = null;
|
||
notify();
|
||
}
|
||
|
||
function dirty(domain) {
|
||
if (domain !== undefined) {
|
||
requireDomain(domain);
|
||
return drafts[domain].size > 0;
|
||
}
|
||
return DRAFT_DOMAINS.some(name => drafts[name].size > 0);
|
||
}
|
||
|
||
function discard(domain) {
|
||
for (const name of domain === undefined ? DRAFT_DOMAINS : [domain]) {
|
||
requireDomain(name);
|
||
drafts[name].clear();
|
||
failedKeys[name].clear();
|
||
}
|
||
outcome = null;
|
||
notify();
|
||
}
|
||
|
||
/** The values of one domain as written: they become its saved values, and drafts still equal to them are clean. */
|
||
function commit(domain, written) {
|
||
for (const [key, value] of written) {
|
||
baseline[domain][key] = value;
|
||
if (drafts[domain].has(key) && Object.is(drafts[domain].get(key), value)) drafts[domain].delete(key);
|
||
}
|
||
}
|
||
|
||
function fail(domain, written) {
|
||
for (const [key] of written) failedKeys[domain].add(key);
|
||
}
|
||
|
||
async function saveDomain(domain) {
|
||
const written = new Map(drafts[domain]);
|
||
if (written.size === 0) return 'clean';
|
||
const values = Object.fromEntries(written);
|
||
try {
|
||
if (domain === 'browser') {
|
||
const result = await adapters.writeBrowser(values);
|
||
commit(domain, written);
|
||
return result?.persisted === false ? 'tab-only' : 'saved';
|
||
}
|
||
if (domain === 'host') {
|
||
if (typeof revision !== 'string') { fail(domain, written); return 'failed'; }
|
||
const result = await adapters.writeHost(values, { expected_revision: revision });
|
||
if (result?.code === CONFLICT_CODE) { fail(domain, written); return 'conflict'; }
|
||
if (!result?.success || result.persisted !== true) { fail(domain, written); return 'failed'; }
|
||
if (typeof result.revision === 'string') revision = result.revision;
|
||
commit(domain, written);
|
||
return 'saved';
|
||
}
|
||
await adapters.writeRuntime(values);
|
||
commit(domain, written);
|
||
return 'applied';
|
||
} catch (error) {
|
||
fail(domain, written);
|
||
return error?.code === CONFLICT_CODE ? 'conflict' : 'failed';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Read the host values again; a failed read leaves host saves refused until one succeeds, and `hostState()` says
|
||
* so (System S3: the host rows show that the server settings could not be read).
|
||
*/
|
||
const reloadHost = () => load('host').then(() => true, () => { hostRead = 'failed'; notify(); return false; });
|
||
|
||
/** Save the dirty domains in order; a Save pressed while one runs joins it. */
|
||
function save() {
|
||
saving ??= (async () => {
|
||
for (const domain of DRAFT_DOMAINS) failedKeys[domain].clear();
|
||
const result = {};
|
||
for (const domain of DRAFT_DOMAINS) result[domain] = await saveDomain(domain);
|
||
// Settings changed elsewhere: read them (and their revision), keeping the drafts (Q10).
|
||
if (result.host === 'conflict') await reloadHost();
|
||
if (result.browser === 'conflict') await load('browser').catch(() => {});
|
||
return result;
|
||
})().then(result => {
|
||
saving = null;
|
||
outcome = result;
|
||
notify();
|
||
return result;
|
||
});
|
||
return saving;
|
||
}
|
||
|
||
/**
|
||
* A value the control could not take (System S3: a timeout outside 0–300): no draft, the control is highlighted and
|
||
* the status says a change was not saved.
|
||
*/
|
||
function reject(domain, key) {
|
||
requireDomain(domain);
|
||
drafts[domain].delete(key);
|
||
failedKeys[domain].add(key);
|
||
outcome = { [domain]: 'failed' };
|
||
notify();
|
||
}
|
||
|
||
/**
|
||
* Another page changed saved values this tab has drafts for (a browser `storage` event, System S3): the drafts stay
|
||
* and are highlighted, and the status says the settings changed elsewhere.
|
||
*/
|
||
function conflict(domain, keys) {
|
||
requireDomain(domain);
|
||
if (!keys.length) return;
|
||
for (const key of keys) failedKeys[domain].add(key);
|
||
outcome = { [domain]: 'conflict' };
|
||
notify();
|
||
}
|
||
|
||
return {
|
||
load,
|
||
reloadHost,
|
||
/** Whether the host values were read: `pending`, `loaded`, or `failed` (the last read failed). */
|
||
hostState: () => hostRead,
|
||
reject,
|
||
conflict,
|
||
/** The saved values of a domain (its last read or save), not the drafts. */
|
||
saved: domain => { requireDomain(domain); return { ...baseline[domain] }; },
|
||
get,
|
||
set,
|
||
dirty,
|
||
discard,
|
||
save,
|
||
changes,
|
||
saving: () => saving !== null,
|
||
failed: domain => { requireDomain(domain); return new Set(failedKeys[domain]); },
|
||
/** The Save row's text: the last save's outcome until the next edit, else unsaved or clean. */
|
||
statusText: () => (outcome ? statusText(outcome) : dirty() ? SETTINGS_STATUS.dirty : SETTINGS_STATUS.clean),
|
||
subscribe(listener) {
|
||
listeners.add(listener);
|
||
return () => listeners.delete(listener);
|
||
},
|
||
};
|
||
}
|