diff --git a/tests/browser/workspace-collections.spec.mjs b/tests/browser/workspace-collections.spec.mjs index 49fb3e3b..b87a55b4 100644 --- a/tests/browser/workspace-collections.spec.mjs +++ b/tests/browser/workspace-collections.spec.mjs @@ -330,3 +330,164 @@ test('renaming a collection deleted in another tab says so and shows the deleted await expect(description(page)).toHaveText('This collection no longer exists.'); await expect(rows(page)).toHaveCount(1); }); + +/** A Playwright hold on the collections list read: `held` fills as reads arrive; `release()` lets them all through. */ +async function holdListReads(page, answer = route => route.fallback()) { + const held = []; + let open = false; + await page.route(`**${API}`, route => { + if (route.request().method() !== 'GET') return route.fallback(); + if (open) return answer(route); + held.push(route); + return undefined; + }); + return { + held, + async release() { + open = true; + for (const route of held.splice(0)) await answer(route).catch(() => {}); + }, + }; +} + +test('a list read superseded by another read still opens a collection made since (review M1)', async ({ page, request }) => { + const manifest = await seed(request); + const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1')).json(); + const [file] = listing.images; + await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`); + await expect(heading(page)).toHaveText('Landscapes'); + // Another tab makes "Fresh"; this tab does not list it yet. + const added = await request.post(`${API}/items`, { data: { name: 'Fresh', + items: [{ root_index: file.root_index, root_scope: file.root_scope, path: file.path }] } }); + await expect(added).toBeOK(); + const fresh = (await added.json()).collection.id; + await expect(rows(page)).toHaveCount(2); + const hold = await holdListReads(page); + // A rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The timer's read + // supersedes that one while both are held. + await editButton(page).click(); + await nameField(page).fill('Landscape grids'); + await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('Collection renamed.'); + await page.evaluate(hash => { window.location.hash = hash; }, `#/collection/${fresh}`); + await expect.poll(() => hold.held.length).toBeGreaterThanOrEqual(2); + await expect(main(page)).not.toContainText('Collections could not be loaded.'); + await hold.release(); + await expect(heading(page)).toHaveText('Fresh'); + await expect(imageCards(page)).toHaveCount(1); +}); + +test('a listing that finds the collection gone while the list still has it offers Retry (review M2)', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Experiments; + let gone = true; + await page.route('**/prompt_manager/images/output?*', route => gone && route.request().url().includes(`collection=${id}`) + ? route.fulfill({ status: 404, contentType: 'application/json', + body: '{"success":false,"error":"Collection not found","code":"collection-not-found"}' }) + : route.fallback()); + await page.goto(`${workspace}#/images`); + await expect(rows(page)).toHaveCount(2); + // The shell's re-read is held, then fails: the page must not stay blank. + const hold = await holdListReads(page, route => route.fulfill({ status: 500, contentType: 'application/json', + body: '{"success":false,"error":"Collections could not be read."}' })); + await row(page, 'Experiments').click(); + await expect.poll(() => hold.held.length).toBe(1); + await hold.release(); + const retry = main(page).getByRole('button', { name: 'Retry', exact: true }); + await expect(retry).toBeVisible(); + await expect(main(page).locator('#gallery-continuation p')).toHaveText('Images could not be loaded. Retry or rescan the collection.'); + gone = false; + await page.unroute(`**${API}`); + await retry.click(); + await expect(imageCards(page)).toHaveCount(8); + + // The list read again still lists it: the same Retry, never a blank page. + gone = true; + await page.evaluate(() => { window.location.hash = '#/images'; }); + await expect(heading(page)).toHaveText('Images'); + await row(page, 'Experiments').click(); + await expect(retry).toBeVisible(); + gone = false; + await retry.click(); + await expect(imageCards(page)).toHaveCount(8); +}); + +test('the missing-members note follows the list read again (review L2)', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Landscapes; + let missing = null; + await page.route(`**${API}`, async route => { + if (route.request().method() !== 'GET' || missing === null) return route.fallback(); + const response = await route.fetch(); + const data = await response.json(); + data.collections = data.collections.map(item => item.id === id ? { ...item, missing } : item); + return route.fulfill({ response, json: data }); + }); + await page.goto(`${workspace}#/collection/${id}`); + await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.'); + const rename = async name => { + await editButton(page).click(); + await nameField(page).fill(name); + await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('Collection renamed.'); + }; + missing = 3; + await rename('Landscapes A'); + await expect(missingNote(page)).toHaveText('3 images in this collection are not in your current sources.'); + missing = 0; + await rename('Landscapes B'); + await expect(missingNote(page)).toHaveCount(0); + missing = 1; + await rename('Landscapes C'); + await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.'); +}); + +test('a rename that settles after its dialog closed leaves the next dialog open (review L1)', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Landscapes; + await page.goto(`${workspace}#/collection/${id}`); + await expect(imageCards(page)).toHaveCount(12); + let release; + const released = new Promise(resolve => { release = resolve; }); + await page.route(`**${API}/${id}`, async route => { + if (route.request().method() !== 'PATCH') return route.fallback(); + await released; + return route.fallback(); + }); + await editButton(page).click(); + await nameField(page).fill('Later'); + await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await page.keyboard.press('Escape'); + await expect(editDialog(page)).toHaveCount(0); + await main(page).getByRole('button', { name: 'Save view', exact: true }).click(); + const save = page.getByRole('dialog', { name: 'Save this view', exact: true }); + await expect(save).toBeVisible(); + release(); + await expect(heading(page)).toHaveText('Later'); + await expect(save).toBeVisible(); +}); + +test('a page that says a collection or view could not be opened takes focus after a route change (review L3)', async ({ page, request }) => { + await seed(request); + await page.goto(`${workspace}#/images`); + await expect(heading(page)).toHaveText('Images'); + await page.evaluate(() => { window.location.hash = '#/collection/999999'; }); + await expect(description(page)).toHaveText('This collection no longer exists.'); + await expect(heading(page)).toBeFocused(); + + await page.route(`**${API}`, route => route.request().method() === 'GET' + ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' }) + : route.fallback()); + await page.evaluate(() => { window.location.hash = '#/images'; }); + await expect(heading(page)).toHaveText('Images'); + await page.evaluate(() => { window.location.hash = '#/collection/999998'; }); + await expect(main(page).getByRole('button', { name: 'Retry', exact: true })).toBeVisible(); + await expect(heading(page)).toBeFocused(); + + await page.route('**/prompt_manager/saved-views', route => route.request().method() === 'GET' + ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' }) + : route.fallback()); + await page.evaluate(() => { window.location.hash = '#/saved/5'; }); + await expect(description(page)).toHaveText('Saved views could not be loaded. Your saved data has not changed.'); + await expect(heading(page)).toBeFocused(); +}); diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 4ea59afe..c29e0084 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -466,9 +466,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (!resizeFrame) resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; updateLayout(); }); }); scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(resizeFrame); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); }); - /** The listing refused the open collection: it was deleted (MC6, D140). */ - const collectionGone = next => Boolean(filters.collection) && isCollectionGone(next.error); - let reportedMissing = false; + /** + * The listing refused the open collection: it was deleted. A saved view says so in place (MC6, D140); a collection + * page reports it (`onCollectionMissing`) once per failed read and meanwhile shows the listing's error and Retry, + * so a page whose shell cannot confirm the deletion is never left blank. + */ + const collectionGone = next => Boolean(filters.collection) && !onCollectionMissing && isCollectionGone(next.error); + let reportedMissing = null; function continuationText(next) { if (recovering) return 'Images paused until sources and folders are available.'; if (collectionGone(next)) return ''; @@ -511,9 +515,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage retry.hidden = !next.error || gone; more.hidden = !next.hasMore || !!next.error; more.setAttribute('aria-disabled', String(next.loading || recovering)); - if (gone && onCollectionMissing) { - if (!reportedMissing) { reportedMissing = true; queueMicrotask(() => { if (!scope.signal.aborted) onCollectionMissing(); }); } - } else if (showsEmpty) { + if (onCollectionMissing && filters.collection && isCollectionGone(next.error) && reportedMissing !== next.error) { + reportedMissing = next.error; + queueMicrotask(() => { if (!scope.signal.aborted) onCollectionMissing(); }); + } + if (showsEmpty) { setText(empty, gone ? VIEW_COLLECTION_DELETED : embedded ? emptyText : NO_MATCHES); empty.hidden = !gone && (recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0); } diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 3670e764..018d181f 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -250,7 +250,9 @@ function mountWorkspace() { if (view.signal.aborted) return; // A view saved since the list loaded (another tab) is read once more before the link counts as gone. if (!savedViews.find(route.savedId) && savedViews.status === 'ready') { - await savedViews.load(); + // settled(), not load()'s own promise: a newer read (a restore, another change) can supersede this one. + savedViews.load(); + await savedViews.settled(); if (view.signal.aborted) return; } const saved = savedViews.find(route.savedId); @@ -265,7 +267,8 @@ function mountWorkspace() { window.location.replace('#/images'); return; } - const {description} = renderHeading(main, {title:route.label}); + const {description, h1} = renderHeading(main, {title:route.label}); + focusTitle(h1); description.textContent = 'Saved views could not be loaded. Your saved data has not changed.'; description.setAttribute('role', 'alert'); const retry = createButton('Retry'); @@ -319,7 +322,9 @@ function mountWorkspace() { await collections.settled(); if (view.signal.aborted) return; if (!collections.find(route.collectionId) && collections.status === 'ready') { - await collections.load(); + // settled(), not load()'s own promise: the 300 ms count read or a restore can supersede this one. + collections.load(); + await collections.settled(); if (view.signal.aborted) return; } let collection = collections.find(route.collectionId); @@ -327,12 +332,23 @@ function mountWorkspace() { if (collection) { renderPage({...COLLECTION_ROUTE, label:collection.name, title:routeTitle(COLLECTION_ROUTE)}, view, null, {collection:collection.id, // The listing found the collection gone meanwhile: the list is read again and, once it no longer lists - // the collection, the page is shown again (the deleted-collection page). - onCollectionMissing:() => collections.load().then(() => { - if (!collections.find(route.collectionId)) showAgain(); - })}); - const note = missingNote(collection.missing); - if (note) pageHeading(main).description.after(element('p', note, 'small')); + // the collection, the page is shown again (the deleted-collection page). A failed read, or a list that + // still holds it, leaves the listing's own error and Retry (images.mjs). + onCollectionMissing:async () => { + collections.load(); + await collections.settled(); + if (collections.status === 'ready' && !collections.find(route.collectionId)) showAgain(); + }}); + // MC3: the note follows every read of the list for the page's life; none while nothing is missing. + const note = element('p', '', 'small'); + const showNote = () => { + const text = missingNote(collections.find(route.collectionId)?.missing); + note.textContent = text; + if (!text) note.remove(); + else if (!note.isConnected) pageHeading(main)?.description.after(note); + }; + showNote(); + view.cleanup(collections.subscribe(showNote)); // The dialog's Delete question uses the latest stored count. mountEditAction(view, trigger => openEditCollectionDialog({ scope: view, trigger, collection: collection = {...collection, ...collections.find(collection.id), name:collection.name}, @@ -349,7 +365,8 @@ function mountWorkspace() { onGone: () => collections.settled().then(showAgain) })); return; } - const {description} = renderHeading(main, {title:route.label}); + const {description, h1} = renderHeading(main, {title:route.label}); + focusTitle(h1); document.title = `${routeTitle(route)} · Prompt Manager`; document.getElementById('workspace-breadcrumb').textContent = routeTitle(route); if (collections.status === 'ready') { @@ -370,13 +387,18 @@ function mountWorkspace() { main.append(retry); } + /** The concept focuses its page title on a hashchange navigation (app.js 1168), never on the first render. */ + function focusTitle(h1) { + if (!firstRender) h1.focus(); + firstRender = false; + } + function renderPage(route, view, saved = null, {collection = null, onCollectionMissing = null} = {}) { document.title = `${routeTitle(route)} · Prompt Manager`; // The concept's heading(); each view fills its description and actions. // The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`. const { description, h1 } = renderHeading(main, { title: route.label }); - if (!firstRender) h1.focus(); - firstRender = false; + focusTitle(h1); document.getElementById('workspace-breadcrumb').textContent = routeTitle(route); if (route.id === 'images' || route.id === 'favorites' || route.id === 'collection') { mountNewPromptAction(main, api, view); diff --git a/web/js/workspace/saved-views.mjs b/web/js/workspace/saved-views.mjs index 885d30d0..7f027860 100644 --- a/web/js/workspace/saved-views.mjs +++ b/web/js/workspace/saved-views.mjs @@ -265,15 +265,18 @@ export function openEditDialog({ scope, trigger, item, nouns, onRename, onDelete // Kept open: one rename at a time, its outcome shown in place. say(''); for (const button of [remove, cancel, save]) button.disabled = true; + // Closed meanwhile (Escape, Close): the outcome is only toasted, and a dialog opened since is left alone. + const stillOpen = () => input.isConnected; try { await onRename(name); if (scope.signal.aborted) return; - closeWorkspaceDialog(); + if (stillOpen()) closeWorkspaceDialog(); showWorkspaceToast(nouns.renamed); } catch (error) { if (scope.signal.aborted) return; for (const button of [remove, cancel, save]) button.disabled = false; const outcome = renameError(error) ?? { toast: nouns.renameFailed }; + if (!stillOpen()) { showWorkspaceToast(outcome.field ?? outcome.toast); return; } if (outcome.field) { say(outcome.field); input.focus(); return; } if (outcome.close) closeWorkspaceDialog(); showWorkspaceToast(outcome.toast);