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:
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user