fix(inspect): abortable, stepped local reads with a busy state
The wait for the node table now ends with the selection's signal. A local read resolves and projects in bounded steps with a turn of the event loop between them, and the inspector carries aria-busy while it loads. A budget test covers maximal 2 MiB graphs and checks the stepped read is byte-identical to one synchronous build. The D117 wrap rule now targets only the summary button, and TESTING.md scopes the restart line to PRs L1 and L2.
This commit is contained in:
@@ -205,7 +205,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
scope.listen(container, 'keydown', onInteraction);
|
||||
scope.listen(container, 'click', onInteraction);
|
||||
scope.listen(container, 'wheel', onInteraction, {passive:true});
|
||||
scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector');});
|
||||
scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector'); container.removeAttribute('aria-busy');});
|
||||
|
||||
const full = (variant ?? (inline ? 'panel' : 'full')) === 'full';
|
||||
function statusView(state, parent) {
|
||||
@@ -224,6 +224,8 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
if (scope.signal.aborted) return;
|
||||
contentScope?.dispose(); contentScope = childScope(scope);
|
||||
container.replaceChildren();
|
||||
// A local read resolves on this thread in bounded steps; the busy state stays until its answer lands.
|
||||
if (state.loading) container.setAttribute('aria-busy', 'true'); else container.removeAttribute('aria-busy');
|
||||
render(state);
|
||||
}
|
||||
/**
|
||||
@@ -290,7 +292,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
* copy does: the shared toast, or the select-to-copy dialog where the clipboard is unavailable or refused.
|
||||
*/
|
||||
function summaryButton(record) {
|
||||
const button = createButton('Copy settings summary'), text = settingsSummary(record);
|
||||
const button = createButton('Copy settings summary', 'settings-summary'), text = settingsSummary(record);
|
||||
contentScope.listen(button, 'click', async () => {
|
||||
try {
|
||||
if (!navigator.clipboard) throw new Error('Clipboard requires a secure context');
|
||||
|
||||
@@ -18,6 +18,24 @@ const MAX_FILENAME_CHARS = 255;
|
||||
const MEDIA_TYPES = new Set(['image', 'video']);
|
||||
|
||||
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const abortError = () => new DOMException('The local selection ended', 'AbortError');
|
||||
|
||||
/** `promise`, or an AbortError as soon as `signal` aborts (the work behind `promise` is shared and keeps going). */
|
||||
function untilAborted(promise, signal) {
|
||||
if (!signal) return promise;
|
||||
if (signal.aborted) return Promise.reject(abortError());
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => reject(abortError());
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
promise.then(value => { signal.removeEventListener('abort', onAbort); resolve(value); },
|
||||
error => { signal.removeEventListener('abort', onAbort); reject(error); });
|
||||
});
|
||||
}
|
||||
|
||||
/** One turn of the event loop between bounded steps, so the busy state paints and input is handled. */
|
||||
function nextTurn(signal) {
|
||||
return untilAborted(new Promise(resolve => setTimeout(resolve, 0)), signal);
|
||||
}
|
||||
const pngReader = () => globalThis.PngMetadata || globalThis.window?.PngMetadata;
|
||||
|
||||
/** A display name with no path separators or control characters; never empty. */
|
||||
@@ -87,16 +105,21 @@ export function toInspectionRead(classification, result) {
|
||||
|
||||
const dimension = value => Number.isSafeInteger(value) && value > 0 ? value : null;
|
||||
|
||||
/** The roles for one local read (`{prompts, resolution}`): the bounded parse and resolution step of buildLocalPayload. */
|
||||
export function localEvidence(read, classification, semantics = null) {
|
||||
const inspection = toInspectionRead(classificationOf(classification), read);
|
||||
return inspection.read === null ? unreadableEvidence() : resolveLocalEvidence(inspection.read.records, semantics);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `inspection_version: 1` wire for one local read, before the response budget. Its roles are the server
|
||||
* inspector's for the same records (local-resolver.mjs with `semantics`, the compiled node table; null answers
|
||||
* "unavailable" as the server does), so the legacy fill in projectInspectionPayload applies exactly where it does
|
||||
* for Images.
|
||||
*/
|
||||
export function buildLocalPayload({ read, file, classification, semantics = null }) {
|
||||
export function buildLocalPayload({ read, file, classification, semantics = null, evidence = localEvidence(read, classification, semantics) }) {
|
||||
const classified = classificationOf(classification);
|
||||
const inspection = toInspectionRead(classified, read);
|
||||
const evidence = inspection.read === null ? unreadableEvidence() : resolveLocalEvidence(inspection.read.records, semantics);
|
||||
const filename = sanitizeLocalFilename(file?.name);
|
||||
const size = Number.isSafeInteger(file?.size) && file.size >= 0 ? file.size : null;
|
||||
const modified = typeof file?.lastModified === 'number' && Number.isFinite(file.lastModified) && file.lastModified >= 0 ? file.lastModified / 1000 : null;
|
||||
@@ -171,7 +194,12 @@ export const localFileAdapter = Object.freeze({
|
||||
scope(handle) { owned(handle); return LOCAL_SCOPE; },
|
||||
// An inert sentinel: the controller's same-key check works and no URL is ever built.
|
||||
inspectionPath(handle) { return `local-file:${owned(handle).key}`; },
|
||||
async read(handle, { signal } = {}) {
|
||||
/**
|
||||
* Reads, resolves and projects one selection on the page's thread in bounded steps (each bound by the 2 MiB role
|
||||
* and 4 MiB workflow budgets and the resolver's work limits) with a turn of the event loop between them, so the
|
||||
* inspector's busy state paints. Every wait ends with the selection's `signal`; `yieldControl` is for tests.
|
||||
*/
|
||||
async read(handle, { signal, yieldControl = nextTurn } = {}) {
|
||||
const secret = owned(handle);
|
||||
let result = null;
|
||||
try {
|
||||
@@ -181,10 +209,14 @@ export const localFileAdapter = Object.freeze({
|
||||
// MetadataReadError, an unreadable record. Abort and programming errors still reject.
|
||||
if (!(error instanceof DOMException) || error.name === 'AbortError' || signal?.aborted) throw error;
|
||||
}
|
||||
const semantics = await loadInspectionSemantics();
|
||||
signal?.throwIfAborted();
|
||||
const semantics = await untilAborted(loadInspectionSemantics(), signal);
|
||||
await yieldControl(signal);
|
||||
const evidence = localEvidence(result, secret.classification, semantics);
|
||||
await yieldControl(signal);
|
||||
const file = { name: secret.filename, size: secret.file.size, lastModified: secret.file.lastModified };
|
||||
return fitLocalPayload(buildLocalPayload({ read: result, file, classification: secret.classification, semantics }), MAX_RESPONSE_BYTES, { signal });
|
||||
const payload = buildLocalPayload({ read: result, file, classification: secret.classification, semantics, evidence });
|
||||
await yieldControl(signal);
|
||||
return fitLocalPayload(payload, MAX_RESPONSE_BYTES, { signal });
|
||||
},
|
||||
toRecord(result, handle, extractFacts) {
|
||||
const secret = owned(handle);
|
||||
|
||||
@@ -213,7 +213,7 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
where the toolkit's `nowrap` would push them past its edge. */
|
||||
.ca-tools #viewer-details > button { white-space: normal; /* D20 */ }
|
||||
/* LI-4: "Copy settings summary" after the facts wraps the same way in the narrow rail. */
|
||||
.ca-tools #viewer-details > div > button { white-space: normal; /* D117 */ }
|
||||
.ca-tools #viewer-details .settings-summary { white-space: normal; /* D117 */ }
|
||||
/* The Images page narrows to one column inside a narrow container (the Library-embedded gallery). */
|
||||
.images-main { container: images-page / inline-size; }
|
||||
@container images-page (inline-size < 600px) {
|
||||
|
||||
Reference in New Issue
Block a user