Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/system-state.mjs
T
Vito Sansevero 4d554fbf40 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.
2026-10-23 06:55:02 -07:00

239 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
},
};
}