diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 997baf9..0affb9e 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -681,7 +681,8 @@ test('Import Rescan rescans cached files and preserves the collection when resca status: 503, json: { success: false, error: 'Rescan unavailable' }, }), { times: 1 }); await rescan(page); - await expect(page.getByRole('alert')).toContainText('could not be rescanned'); + await expect(toast(page)).toHaveText('Rescan could not finish. Try again.'); + await expect(page.getByRole('alert')).toHaveCount(0); await expect(cards(page)).toHaveCount(2); await rescan(page); await expect(cards(page)).toHaveCount(3); @@ -720,7 +721,7 @@ test('leaving during Rescan cancels it without replacing the next workspace', as await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); release(); await expect(page.getByRole('heading', { name: 'Continue creating', exact: true })).toBeVisible(); - await expect(page.getByText('Output folders could not be rescanned. Try Rescan again.', { exact: true })).toHaveCount(0); + await expect(toast(page)).not.toHaveText(/^Rescan (finished|could not finish)/); }); @@ -1406,3 +1407,31 @@ test('favorite failure keeps the previous state and announces it', async ({ page await expectStar(star(card, 'same.png'), true); expect((await listedFavorites(request))['0:shared/same.png']).toBe(true); }); + +// #249 final review I1: the old Refresh button showed that it ran; Import → Rescan says so too, and when it ends. +test('Import Rescan shows that it runs, ignores a second press and announces the outcome', async ({ page, request }) => { + const held = []; + await page.route('**/prompt_manager/gallery/rescan', route => { held.push(route); }); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + await expect(await request.post('/__test__/add-nested-image')).toBeOK(); + await rescan(page); + await expect.poll(() => held.length).toBe(1); + await expect(toast(page)).toHaveText('Rescanning output folders…'); + // Reopened while the scan runs, Rescan says it is running; a press on it is ignored and keeps the dialog open. + await page.getByRole('button', { name: 'Import', exact: true }).click(); + const running = importDialog(page).getByRole('button', { name: 'Rescanning…', exact: true }); + await expect(running).toHaveAttribute('aria-disabled', 'true'); + await running.click({ force: true }); + await expect(importDialog(page)).toBeVisible(); + expect(held.length).toBe(1); + await expect(cards(page)).toHaveCount(2); + // The scan's own answer: every file in both fixture roots (output and input). + await held[0].continue(); + await expect(toast(page)).toHaveText('Rescan finished. 3 files in 2 output folders.'); + const idle = importDialog(page).getByRole('button', { name: 'Rescan', exact: true }); + await expect(idle).not.toHaveAttribute('aria-disabled', 'true'); + await page.keyboard.press('Escape'); + await expect(cards(page)).toHaveCount(3); + await expect(caption(page)).toContainText('3 of 3 images'); +}); diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js index c86db7a..3e602fb 100644 --- a/tests/js/workspace-toolbar.test.js +++ b/tests/js/workspace-toolbar.test.js @@ -58,6 +58,7 @@ function fakeDOM() { } setAttribute(name, value) { this.attributes[name] = String(value); } getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; } + removeAttribute(name) { delete this.attributes[name]; } focus() { active = this; } select() { this.selectionStart = 0; this.selectionEnd = this.value.length; } showModal() { this.open = true; } @@ -330,6 +331,34 @@ test('refine dialog round-trips values and clear resets them', async () => { } }); +test('a running rescan marks Rescan busy and ignores a second press', async () => { + const { openImportDialog, closeWorkspaceDialog } = await dialogsModule; + const dom = fakeDOM(); + const scope = (await coreModule).createScope(); + try { + const calls = []; + const handle = openImportDialog({ scope, rescanBusy: true, onInspect: () => calls.push('inspect'), + onRescan: () => calls.push('rescan') }); + const body = byId(dom, 'modal-body'); + const busy = button(body, 'Rescanning…'); + assert.ok(busy, 'the busy Rescan says it is running'); + assert.equal(busy.getAttribute('aria-disabled'), 'true'); + busy.click(); + assert.deepEqual(calls, []); + assert.equal(modal(dom).open, true, 'an ignored press keeps the dialog open'); + handle.setRescanBusy(false); + assert.equal(busy.textContent, 'Rescan'); + assert.equal(busy.getAttribute('aria-disabled'), null); + busy.click(); + assert.deepEqual(calls, ['rescan']); + assert.equal(modal(dom).open, false); + } finally { + closeWorkspaceDialog(); + scope.dispose(); + dom.restore(); + } +}); + test('import dialog lists only real operations', async () => { const { openImportDialog, closeWorkspaceDialog } = await dialogsModule; const dom = fakeDOM(); diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 054b6f6..e3b7a9e 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -38,7 +38,15 @@ const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', const MEDIA_OPTIONS = Object.freeze([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']]); const SOURCES_FAILED = 'Sources and folders could not be loaded. Rescan from Import to retry.'; const CATEGORIES_FAILED = 'Categories could not be loaded. Rescan from Import to retry.'; -const RESCAN_FAILED = 'Output folders could not be rescanned. Try Rescan again.'; +// Import → Rescan (D15): the concept has no rescan copy, so its toasts follow the workspace pattern. The answer +// counts every listed file across the output roots it walked. +const RESCAN_STARTED = 'Rescanning output folders…'; +const RESCAN_FAILED = 'Rescan could not finish. Try again.'; +function rescanFinished(result) { + const {total, roots} = result ?? {}; + if (![total, roots].every(value => Number.isSafeInteger(value) && value >= 0)) return 'Rescan finished.'; + return `Rescan finished. ${total.toLocaleString('en-US')} ${noun(total, 'file')} in ${roots.toLocaleString('en-US')} output ${noun(roots, 'folder')}.`; +} // D7 = A: the concept's star (app.js actions.favorite) against the favorites store; its toasts without the demo's // "in this tab". The concept has no failure copy, so a failure follows the workspace "could not be …" pattern. const FAVORITE_URL = '/prompt_manager/images/favorite'; @@ -90,15 +98,17 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage scope.cleanup(() => main.classList.remove('images-main')); // The concept's Images heading (app.js renderImages): description, then Import before the shell's New prompt. const heading = embedded ? null : pageHeading(main); - let importButton = null; + let importButton = null, importDialog = null; if (heading) { heading.description.textContent = 'Your generations, their prompts, and the next idea.'; importButton = createButton(''); importButton.append(createWorkspaceIcon('upload'), el('span', 'Import')); heading.actions.prepend(importButton); scope.cleanup(() => importButton.remove()); - scope.listen(importButton, 'click', () => openImportDialog({scope, trigger:importButton, - onInspect:() => { window.location.hash = '#/inspect'; }, onRescan:rescan})); + scope.listen(importButton, 'click', () => { + importDialog = openImportDialog({scope, trigger:importButton, rescanBusy:rescanning, + onInspect:() => { window.location.hash = '#/inspect'; }, onRescan:rescan}); + }); } // The shared concept toolbar: search (filename, linked prompt text, title and tags), category and sort (D12–D14). const toolbar = embedded ? null : mountToolbar(main, {scope, categories:filters.category ? [filters.category] : [], @@ -553,23 +563,27 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage for (const control of [...toolbarControls, more, retry]) control.disabled = disabled; if (importButton) importButton.disabled = disabled; } - /** Import → "Scan an output folder" (D15): rescan the output roots, then reload the options and the listing. */ + /** + * Import → "Scan an output folder" (D15): rescan the output roots, then reload the options and the listing. A toast + * says it started and how it ended; meanwhile a reopened Import dialog shows Rescan as running. + */ async function rescan() { if (rescanning || recovering || scope.signal.aborted) return; rescanning = true; optionsRequest?.abort(); optionsRequest = null; - optionsStatus.hidden = true; + showWorkspaceToast(RESCAN_STARTED); try { - await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal}); + const result = await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal}); if (scope.signal.aborted) return; loadCategories(); if (await loadOptions() && !scope.signal.aborted) reset(); + if (!scope.signal.aborted) showWorkspaceToast(rescanFinished(result)); } catch { - if (scope.signal.aborted) return; - optionsStatus.textContent = RESCAN_FAILED; - optionsStatus.hidden = false; + // A failed rescan leaves the collection and any discovery alert as they were. + if (!scope.signal.aborted) showWorkspaceToast(RESCAN_FAILED); } finally { rescanning = false; + importDialog?.setRescanBusy(false); } } async function refreshInspectionCollection() { diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index c7cffe3..b5295ca 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -8,7 +8,7 @@ * mock-status note because every field is a real filter, and no "Prompt evidence" field because evidence is only * known per file (D14). Clear also clears the toolbar category, as the concept's does; the page applies it. * - "Import & inspect" (a1483–1491): only the real operations, Inspect a local image and Scan an output folder → - * Rescan; no "Import portable recipes" (D15). + * Rescan; no "Import portable recipes" (D15). Rescan shows its running state (aria-disabled "Rescanning…"). * - "Copy prompt" (a1223–1241): the select-to-copy fallback when the clipboard is unavailable (D22). * * Every opener takes the page `scope`: disposing it closes and removes the shell, so a dialog never outlives its page. @@ -177,6 +177,7 @@ export function openRefineDialog({ scope, fields, values = {}, onApply, onClear, ], { scope, trigger }); } +/** One operation row; a press on its busy (aria-disabled) action is ignored and keeps the dialog open. */ function operation(title, description, label, onAction) { const row = el('div', undefined, 'operation'); const text = el('div'); @@ -184,27 +185,40 @@ function operation(title, description, label, onAction) { const actions = el('div', undefined, 'row-actions'); const action = createButton(label); action.addEventListener('click', () => { + if (action.getAttribute('aria-disabled') === 'true') return; finish(); onAction(); }); actions.append(action); row.append(text, actions); - return row; + return { row, action }; } -/** "Import & inspect" with the two real operations (D15). */ -export function openImportDialog({ scope, onInspect, onRescan, trigger = null }) { +const RESCAN_LABEL = 'Rescan'; +const RESCAN_BUSY_LABEL = 'Rescanning…'; + +/** + * "Import & inspect" with the two real operations (D15). While `rescanBusy`, Rescan reads "Rescanning…" and is + * aria-disabled, so a second press is visibly ignored. Returns `{setRescanBusy}` for the page to end that state. + */ +export function openImportDialog({ scope, onInspect, onRescan, trigger = null, rescanBusy = false }) { requireScope(scope); requireCallbacks({ onInspect, onRescan }); const list = el('div', undefined, 'operation-list'); - list.append( - operation('Inspect a local image', 'Read embedded metadata before deciding to add it.', 'Open inspector', onInspect), - operation('Scan an output folder', 'Find files added or removed in the output folders.', 'Rescan', onRescan), - ); + const inspect = operation('Inspect a local image', 'Read embedded metadata before deciding to add it.', 'Open inspector', onInspect); + const rescan = operation('Scan an output folder', 'Find files added or removed in the output folders.', RESCAN_LABEL, onRescan); + list.append(inspect.row, rescan.row); + function setRescanBusy(busy) { + rescan.action.textContent = busy ? RESCAN_BUSY_LABEL : RESCAN_LABEL; + if (busy) rescan.action.setAttribute('aria-disabled', 'true'); + else rescan.action.removeAttribute('aria-disabled'); + } + setRescanBusy(rescanBusy); show('Import & inspect', [ el('p', 'Image inspection and output scanning have different effects. Choose the workflow you want.', 'small'), list, ], { scope, trigger }); + return { setRescanBusy }; } /** "Copy prompt": the text in a readonly, pre-selected textarea for a manual copy. */