Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/system-state.mjs
T
Vito Sansevero ece4508438 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.
2026-10-23 05:50:56 -07:00

226 lines
9.8 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), `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 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;
} 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. */
const reloadHost = () => load('host').then(() => true, () => 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();
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, and the control is highlighted. */
function reject(domain, key) {
requireDomain(domain);
drafts[domain].delete(key);
failedKeys[domain].add(key);
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,
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);
},
};
}