fix(images): show that Import → Rescan runs, and how it ended

The old Refresh button disabled itself while /gallery/rescan ran; Import →
Rescan closed its dialog and showed nothing. Now a toast says the rescan
started ("Rescanning output folders…") and how it ended ("Rescan finished.
N files in M output folders.", from the scan's own total and roots, or
"Rescan could not finish. Try again."). A reopened Import dialog shows
Rescan as "Rescanning…" and aria-disabled, and ignores a press on it.

Failing before: the held-rescan browser test saw no toast and no busy
state; the dialog unit test found no "Rescanning…" button.
This commit is contained in:
Vito Sansevero
2026-10-20 09:51:20 -07:00
parent 7096b2a62f
commit 6a7cab2eda
4 changed files with 106 additions and 20 deletions
+31 -2
View File
@@ -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');
});
+29
View File
@@ -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();
+24 -10
View File
@@ -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() {
+22 -8
View File
@@ -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. */