fix(images): guard source recovery and wrap viewer actions

Clear stale root and folder intent, cancel old pages, and wait for current source discovery before loading a refreshed collection. Preserve safe retry through failed, superseded, and disposed discovery.

Wrap narrow viewer footer actions and wait for measured gallery viewport settling in responsive checks. Refs #216; addresses PR #224 review.
This commit is contained in:
Vito Sansevero
2026-10-18 08:52:04 -07:00
parent 20cbda7529
commit 54c9deeaa0
6 changed files with 149 additions and 21 deletions
+11
View File
@@ -568,6 +568,8 @@ test('collection viewer matches approved responsive frame',async({page,request},
await page.setViewportSize({width:390,height:844});
for(const zoom of [1,2]) {
await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);
// Absolute gallery cards relayout through ResizeObserver and rAF after CSS zoom.
await expect.poll(()=>page.evaluate(()=>({inner:innerWidth,client:document.documentElement.clientWidth,scroll:document.documentElement.scrollWidth})),{message:'Images gallery relayout settles the 390px viewport after CSS zoom'}).toEqual({inner:390,client:390,scroll:390});
expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(zoom===1?356:322,1);
expect(await modal.evaluate(node=>{const box=node.getBoundingClientRect();return box.top>=0&&box.bottom<=innerHeight+1;})).toBe(true);
await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
@@ -576,6 +578,15 @@ test('collection viewer matches approved responsive frame',async({page,request},
await rail.getByRole('button',{name:'Send prompt pair',exact:true}).focus();await expect(rail.getByRole('button',{name:'Send prompt pair',exact:true})).toBeInViewport();
await page.screenshot({path:testInfo.outputPath(`viewer-mobile-${zoom}x.png`)});await rail.getByText('Workflow & raw metadata',{exact:true}).click();
}
await rail.evaluate(node=>{node.style.fontFamily='"DejaVu Sans", sans-serif';});
await rail.getByText('Workflow & raw metadata',{exact:true}).click();
const sendPair=rail.getByRole('button',{name:'Send prompt pair',exact:true});await sendPair.focus();await expect(sendPair).toBeInViewport();
await rail.getByText('Workflow & raw metadata',{exact:true}).click();await sendPair.focus();await expect(sendPair).toBeFocused();await expect(sendPair).toBeInViewport();
const footer=await sendPair.evaluate(node=>{const box=node.getBoundingClientRect(),parent=node.closest('.file-inspector-footer').getBoundingClientRect();return{width:box.width,right:box.right,available:parent.width,parentRight:parent.right,height:box.height,minimum:matchMedia('(pointer: coarse)').matches?80:60};});
expect(footer.width).toBeLessThanOrEqual(footer.available);expect(footer.right).toBeLessThanOrEqual(footer.parentRight);expect(footer.height).toBeGreaterThanOrEqual(footer.minimum);await expect(sendPair.locator('svg')).toHaveCount(1);
expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(322,1);await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
await testInfo.attach('wider-font-footer-geometry',{body:JSON.stringify({footer,modal:await modal.evaluate(node=>({width:node.getBoundingClientRect().width,scrollWidth:node.scrollWidth,clientWidth:node.clientWidth}))}),contentType:'application/json'});
await page.screenshot({path:testInfo.outputPath('viewer-mobile-wider-font-2x.png')});
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});
const longFiles=await(await request.get('/prompt_manager/images/output?mode=workspace&q=a-very-long&limit=40')).json();const long=longFiles.images[0];
await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-open`).click();await openCollection(page);
+86
View File
@@ -478,6 +478,92 @@ test('source and folder discovery failure stays visible and Refresh restores usa
await expect(cards(page)).toHaveCount(1);
});
async function staleSourceRecovery(page, request, roots) {
await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(workspace);
const source = page.getByRole('combobox', { name: 'Source', exact: true });
const folder = page.getByRole('combobox', { name: 'Folder', exact: true });
await source.selectOption('0');await expect(folder.locator('option[value="bulk"]')).toHaveCount(1);await folder.selectOption('bulk');
const configured = page.waitForResponse(response => {
const url = new URL(response.url());return url.pathname.endsWith('/images/output') && url.searchParams.get('root') === '0' && url.searchParams.get('subfolder') === 'bulk' && url.searchParams.get('q') === 'png' && url.searchParams.get('media') === 'image' && url.searchParams.get('sort') === 'name';
});
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('png');
await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('image');await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('name');
const initial = await (await configured).json();await expect(page.locator('.image-toolbar')).toContainText('40 of 80');
let oldPage;
await page.route('**/prompt_manager/images/output?*', async route => {
if (new URL(route.request().url()).searchParams.has('cursor') && !oldPage) {
const response = await route.fetch();oldPage = { route, data: await response.json() };
} else await route.continue();
});
await page.getByRole('button', { name: 'Load more images', exact: true }).scrollIntoViewIfNeeded();await expect.poll(() => !!oldPage).toBe(true);
await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: roots } })).toBeOK();
const selected = initial.images.at(-1), key = `${selected.root_index}:${selected.path}`;
const stale = page.waitForResponse(response => response.url().includes('/images/inspect/') && response.status() === 409);
await page.locator(`article[data-image-key="${key}"] .image-open`).click();expect((await stale).status()).toBe(409);
await expect(details(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.');
await page.locator(`article[data-image-key="${key}"] .image-card-footer button`).click();
const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toContainText('The source changed.');
const pages = [], inspections = [], options = [], mutations = [];
page.on('request', request => {
const url = new URL(request.url());
if (url.pathname.endsWith('/images/output')) pages.push(url);
if (url.pathname.includes('/images/inspect/')) inspections.push(url);
if (request.method() !== 'GET') mutations.push(url.pathname);
});
await page.route('**/prompt_manager/gallery/subfolders?*', route => { options.push(route); });
await viewer.getByRole('button', { name: 'Refresh image collection', exact: true }).click();
await expect(viewer).toHaveCount(0);await expect.poll(() => options.length).toBe(1);
await oldPage.route.fulfill({ json: oldPage.data });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
return { source, folder, pages, inspections, options, mutations };
}
for (const [name, roots] of [['reordered', ['input', 'output']], ['removed', ['input']]]) {
test(`source-change recovery clears a ${name} root before discovery and safe retry`, async ({ page, request }, testInfo) => {
const recovery = await staleSourceRecovery(page, request, roots);
try {
expect(recovery.pages.map(url => url.search)).toEqual([]);expect(recovery.inspections).toEqual([]);
await expect(recovery.source).toHaveValue('all');await expect(recovery.source.locator('option')).toHaveText(['All sources']);await expect(recovery.folder).toHaveValue('');
for (const name of ['Source', 'Folder', 'Media', 'Sort']) await expect(page.getByRole('combobox', { name, exact: true })).toBeDisabled();
await expect(page.getByRole('searchbox', { name: 'Search filenames', exact: true })).toBeDisabled();
await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeDisabled();
await page.getByRole('button', { name: 'Load more images', exact: true }).evaluate(node => node.click());await recovery.source.dispatchEvent('change');
await recovery.options[0].fulfill({ status: 503, json: { success: false, error: 'Discovery unavailable' } });
await expect(page.getByRole('alert').filter({ hasText: 'Sources and folders could not be loaded.' })).toBeVisible();
expect(recovery.pages).toEqual([]);expect(recovery.inspections).toEqual([]);await expect(cards(page)).toHaveCount(0);
const retry = details(page).getByRole('button', { name: 'Refresh image collection', exact: true });await expect(retry).toBeEnabled();
if (testInfo.project.name === 'chromium' && name === 'reordered') await page.screenshot({ path: testInfo.outputPath('safe-source-discovery-failure.png') });
await retry.click();await expect.poll(() => recovery.options.length).toBe(2);
expect(new URL(recovery.options[1].request().url()).searchParams.has('root')).toBe(false);
const response = await recovery.options[1].fetch();await recovery.options[1].fulfill({ response });
await expect.poll(() => recovery.pages.length).toBe(1);
const query = recovery.pages[0].searchParams;expect(query.has('root')).toBe(false);expect(query.has('subfolder')).toBe(false);
expect(query.get('q')).toBe('png');expect(query.get('media')).toBe('image');expect(query.get('sort')).toBe('name');
await expect(recovery.source).toBeEnabled();await expect(recovery.source).toHaveValue('all');
await expect(recovery.source.locator('option')).toHaveText(['All sources', ...roots]);
await expect.poll(() => recovery.inspections.length).toBe(1);expect(recovery.inspections[0].searchParams.get('root')).toBe(name === 'reordered' ? '1' : '0');
expect(recovery.mutations).toEqual([]);
if (testInfo.project.name === 'chromium' && name === 'reordered') await page.screenshot({ path: testInfo.outputPath('safe-source-recovery-success.png') });
} finally { await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: ['output', 'input'] } })).toBeOK(); }
});
}
test('source-change recovery ignores superseded discovery and route disposal', async ({ page, request }) => {
const recovery = await staleSourceRecovery(page, request, ['input', 'output']);
try {
expect(recovery.pages).toEqual([]);await details(page).getByRole('button', { name: 'Refresh image collection', exact: true }).click();await expect.poll(() => recovery.options.length).toBe(2);
await recovery.options[0].fulfill({ json: { roots: [{ index: 0, label: 'Obsolete source label' }], subfolders: ['obsolete'] } });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
expect(recovery.pages).toEqual([]);await expect(recovery.source.locator('option')).toHaveText(['All sources']);
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
await recovery.options[1].fulfill({ json: { roots: [{ index: 0, label: 'Late source label' }], subfolders: ['late'] } });
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');expect(recovery.pages).toEqual([]);expect(recovery.inspections).toEqual([]);expect(recovery.mutations).toEqual([]);
} finally { await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: ['output', 'input'] } })).toBeOK(); }
});
test('mobile Images keeps filters, layouts and selected details within the viewport', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(workspace);
+16
View File
@@ -44,6 +44,22 @@ test('reset aborts pending requests and ignores stale success and failure', asyn
assert.equal(stream.state.loading,false);
});
test('deferred reset cancels stale source work without fetching until discovery succeeds', async () => {
const {createImageStream}=await source;const requests=[];
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}}});
const old=stream.reset({root:1,subfolder:'old'});
const deferredReset=stream.reset({root:'all',q:'png',media:'image',sort:'name'},{load:false});
assert.equal(requests[0].signal.aborted,true);assert.equal(requests.length,1);
assert.equal(await deferredReset,0);
assert.equal(stream.state.items.length,0);assert.equal(stream.state.cursor,null);assert.equal(stream.state.loading,false);
requests[0].resolve(page([image('old',1)],2,'stale'));assert.equal(await old,0);assert.equal(stream.state.items.length,0);
const fresh=stream.loadMore();assert.equal(requests.length,2);
const query=new URL(requests[1].url,'http://local').searchParams;
assert.equal(query.has('root'),false);assert.equal(query.has('subfolder'),false);assert.equal(query.get('q'),'png');assert.equal(query.get('media'),'image');assert.equal(query.get('sort'),'name');
requests[1].resolve(page([image('fresh',0)]));assert.equal(await fresh,1);assert.equal(stream.state.items[0].path,'fresh');
stream.dispose();assert.equal(await stream.reset({}, {load:false}),0);assert.equal(requests.length,2);
});
test('concurrent loads do not duplicate requests, and failures retain data and cursor for retry', async () => {
const {createImageStream}=await source; const pending=deferred(); const calls=[];
const responses=[page([image('a')],2,'next'),pending.promise,page([image('b')],2)];
+2 -2
View File
@@ -114,7 +114,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
}
}
async function reset(filters = {}) {
async function reset(filters = {}, {load = true} = {}) {
if (disposed) return 0;
const nextQuery = queryFor(filters, batchSize);
controller?.abort();
@@ -123,7 +123,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
seen = new Set();
publish({ items: Object.freeze([]), total: 0, cursor: null, hasMore: true,
loading: false, error: null, loaded: 0 });
return loadMore();
return load ? loadMore() : 0;
}
return {
+33 -19
View File
@@ -37,7 +37,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
let density = visit?.density || 'comfortable';
let metrics = workspaceImageMetrics(window.innerWidth, density);
let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest;
let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, recovering = false;
const rendered = new Map();
const itemIndexes = new Map();
const filters = { root: 'all', q: '', subfolder: '', media: 'all', sort: 'newest', ...visit?.filters };
@@ -93,14 +93,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
try {
await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal});
if (scope.signal.aborted) return;
await loadOptions();
if (!scope.signal.aborted) reset();
if (await loadOptions() && !scope.signal.aborted) reset();
} catch {
if (scope.signal.aborted) return;
optionsStatus.textContent = 'Images could not be refreshed. Try Refresh again.';
optionsStatus.hidden = false;
} finally {
refresh.disabled = false;
refresh.disabled = recovering;
}
});
const currentGallery = el('a', 'Current gallery', 'workspace-link');
@@ -325,12 +324,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
state = next;
count.textContent = embedded ? `${next.loaded.toLocaleString()} loaded · ${next.total.toLocaleString()} linked images` : `${next.loaded.toLocaleString()} of ${next.total.toLocaleString()} images`;
if (changed) updateLayout();
message.textContent = next.error ? 'Images could not be loaded. Retry or refresh the collection.' : next.loading ? 'Loading images…' : '';
message.textContent = recovering ? 'Images paused until sources and folders are available.' : next.error ? 'Images could not be loaded. Retry or refresh the collection.' : next.loading ? 'Loading images…' : '';
if (next.error) message.setAttribute('role', 'alert'); else message.setAttribute('role', 'status');
retry.hidden = !next.error;
more.hidden = !next.hasMore || !!next.error;
more.setAttribute('aria-disabled', String(next.loading));
end.textContent = !next.loading && !next.error && !next.hasMore ? (next.loaded ? 'You’re all caught up.' : embedded ? 'No linked media available for this selection.' : 'No matching images or media. Try changing the filters.') : '';
more.setAttribute('aria-disabled', String(next.loading || recovering));
end.textContent = !recovering && !next.loading && !next.error && !next.hasMore ? (next.loaded ? 'You’re all caught up.' : embedded ? 'No linked media available for this selection.' : 'No matching images or media. Try changing the filters.') : '';
if (!selection && next.items.length) inspect(next.items[0]);
scheduleLoad();
}
@@ -339,10 +338,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
stream.dispose();
for (const video of body.querySelectorAll('video')) releaseVideo(video);
});
scope.listen(more, 'click', () => stream.loadMore());
scope.listen(retry, 'click', () => stream.loadMore());
scope.listen(more, 'click', () => { if (!recovering) stream.loadMore(); });
scope.listen(retry, 'click', () => { if (!recovering) stream.loadMore(); });
function maybeLoad() {
if (!observer || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('.image-preview[open]')) return;
if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('.image-preview[open]')) return;
const bounds = continuation.getBoundingClientRect();
if (bounds.top <= window.innerHeight + 250 && bounds.bottom >= 0) stream.loadMore();
}
@@ -358,17 +357,29 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
function reset() {
mediaViewer.stopSlideshow();
main.querySelector('.image-preview[open]')?.close();
inspection.clear();
detailInspector?.dispose(); detailInspector = null;
if (!recovering) {
inspection.clear();
detailInspector?.dispose(); detailInspector = null;
details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
}
selection = null;
for (const video of details.querySelectorAll('video')) releaseVideo(video);
details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
stream.reset(filters);
stream.reset(filters, {load:!recovering});
}
async function refreshInspectionCollection() {
if (scope.signal.aborted) return;
// Numeric roots may now name another source; retire the old stream first.
recovering = true;
filters.root = 'all';filters.subfolder = '';filters.q = search.value.trim();
source.replaceChildren(new Option('All sources', 'all'));folder.replaceChildren(new Option('All folders', ''));
for (const control of [search,source,folder,media,sort,refresh,more,retry]) control.disabled = true;
optionsStatus.hidden = true;
reset();
const discovery = loadOptions(), request = optionsRequest;
if (!await discovery || scope.signal.aborted || request !== optionsRequest) return;
recovering = false;
for (const control of [search,source,folder,media,sort,refresh,more,retry]) control.disabled = false;
reset();
await loadOptions();
}
async function loadOptions() {
optionsRequest?.abort();
@@ -377,22 +388,25 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const params = new URLSearchParams({mode:'workspace', include_ancestors:'true'});
if (filters.root !== 'all') params.set('root', filters.root);
const data = await api.get(`/prompt_manager/gallery/subfolders?${params}`, {signal:request.signal});
if (scope.signal.aborted || request !== optionsRequest) return;
if (scope.signal.aborted || request !== optionsRequest) return false;
source.replaceChildren(new Option('All sources', 'all'), ...data.roots.map(root => new Option(root.label, String(root.index))));
source.value = filters.root;
folder.replaceChildren(new Option('All folders', ''), ...data.subfolders.map(path => new Option(path, path)));
folder.value = filters.subfolder;
optionsStatus.hidden = true;
return true;
} catch {
if (scope.signal.aborted || request !== optionsRequest) return;
optionsStatus.textContent = 'Sources and folders could not be loaded. Refresh to retry.';
if (scope.signal.aborted || request !== optionsRequest) return false;
optionsStatus.textContent = recovering ? 'Sources and folders could not be loaded. Refresh image collection to retry.' : 'Sources and folders could not be loaded. Refresh to retry.';
optionsStatus.hidden = false;
return false;
}
}
scope.cleanup(() => optionsRequest?.abort());
scope.listen(search, 'input', scope.debounce(() => { filters.q = search.value.trim(); reset(); }, 200));
scope.listen(search, 'input', scope.debounce(() => { if (!recovering) { filters.q = search.value.trim(); reset(); } }, 200));
for (const [input, key] of [[source, 'root'], [folder, 'subfolder'], [media, 'media'], [sort, 'sort']]) {
scope.listen(input, 'change', () => {
if (recovering) return;
filters[key] = input.value;
if (key === 'root') { filters.subfolder = ''; folder.value = ''; loadOptions(); }
reset();
+1
View File
@@ -36,6 +36,7 @@
.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
.file-inspector-footer { display: flex; flex-wrap: wrap; gap: 8px; border-block-start: 1px solid var(--ca-line); padding: 13px 17px; }
.ca-tools .file-inspector-footer :where(button, a) { font-size: 11px; min-block-size: 30px; }
.ca-tools .image-preview-details .file-inspector-footer button { white-space: normal; }
.file-inspector-footer .workspace-link { display: inline-flex; gap: 7px; border: 1px solid var(--ca-line); border-radius: 4px; padding: 5px 8px; }
.file-inspector-footer .workspace-icon { inline-size: 14px; block-size: 14px; }
.file-status { padding: 17px; }