diff --git a/TESTING.md b/TESTING.md index b615977f..de08297d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -516,14 +516,17 @@ Recorded shell deviations: ComfyUI restarted." The running actions are "Cancel job" only (no "Simulate completion"); a failed cancel toasts "The job could not be cancelled. Try again.". With no job the section says "No jobs have run since ComfyUI started. Scans and other long - operations appear here." `#/activity/` shows the list with that job's item marked - `aria-current="true"` until the job view (Activity-1 Task 3); that view is D56. + operations appear here." `#/activity/` is that job's own view (D56). When a job + ends while its Cancel job has focus, focus moves to the item's title link. The Activity store reads the list once, then follows each running job over `/prompt_manager/jobs//events`. A dropped stream re-reads the list after 1 s, - doubling to at most 30 s; a received event resets the wait. A running job the server - no longer lists, or one whose server cannot be read, is shown as ended, so no pill - stays. While the tab is visible the list is read again every 15 s (and whenever + doubling to at most 30 s; a received event resets the wait, and so does a good list + read after failed ones or after any read that was not for a dropped stream. A running + job the server no longer lists, or one whose server could not be read twice in a row, + is shown as ended, so no pill stays; one failed read (a proxy's 502) changes nothing. + A finished job the server no longer lists (it keeps 20) is dropped. Numbers are + grouped "en-US", as the server's lines are. While the tab is visible the list is read again every 15 s (and whenever Activity opens), so a job started in another tab or on the legacy admin page appears without a reload. Toasts: "Scan started." after this tab's own start; "Scan finished · N added · M errors", "Scan cancelled · N added" or "Scan failed · N added · M @@ -573,14 +576,26 @@ Recorded shell deviations: `.outcomes` with `.outcome-list` (`button[aria-pressed]` label + count, in the mockup's order Added, Needs review, Errors, No usable metadata, Already known; the view opens on the first with files) beside `.outcome-files`: one `.file-row` (`.path` - root-relative, `.why` the server's detail) per listed file, "No files in this group." + root-relative, `.why` the server's detail) per listed file. The detail wording is the + builder's (approved with Task 1): "Added to the Library as “title”", "Linked to + “title”", "Already linked to “title”", "Needs review: …", "Error: …", where the title + is the prompt's Library title (its stored title, else its text's first line), not the + mockup's sample "→ “title” · new positive + negative". Each log line is "HH:MM:SS + / · " rather than the mockup's "[HH:MM:SS] + path → + prompt #4812 (new)". The header's actions sit in the page heading's + `.heading-actions`, where the mockup has a `.panel-heading > .row-actions`. An + empty outcome shows "No files in this group." or "No listed file matches this filter.", and the note "Showing N of M" when fewer than the counter are shown (the server lists 200 per outcome), with "each row opens the image in the viewer" for Added and Already known. Then `details.term`, collapsed: "● Scan log — raw detail, click to show" and `pre[tabindex=0]` with the raw lines. - The summary follows the store; the rows and log are read from `GET - /prompt_manager/jobs/` at most once a second while it runs and once at its end. - An unknown id shows "This job is not in this session’s Activity. Jobs are kept until + The summary follows the store and carries `listed` (rows per outcome) and + `log_length`; the rows and log are read from `GET /prompt_manager/jobs/` once on + opening, then at most once a second only while what it lists changes (or, with the + log open, its length), so a job whose lists are capped and whose log is closed is not + re-read; opening the log reads its new lines. When the job ends while Cancel scan has + focus, focus moves to the job's heading. An unknown id, or a job the store's list no + longer has, shows "This job is not in this session’s Activity. Jobs are kept until ComfyUI restarts." and "All activity"; a job lost with a restart keeps what was read. At `max-width: 700px` the tiles take two columns and the outcome list stacks above the files. Each Activity item's title is now a link to its view, in the title's @@ -589,14 +604,19 @@ Recorded shell deviations: `workspace-activity.spec.mjs` covers the counters, live updates, viewer rows, the filter, the 200-row cap, Cancel scan, the busy start and an unknown or lost job. - **D57**: rows under Added and Already known are `button.file-row`s that open the - existing media viewer on that one file. The row's root label finds its source in - `/prompt_manager/gallery/subfolders?mode=workspace`, and the workspace listing - (`root`, `q` = the path) returns the listing item with that exact path: the same - identity (`root_index:path`) Images opens. A file the listing no longer has toasts - "This file is no longer in Images. Rescan from Import and try again."; other rows are - plain text, with no new file route. A start refused because another record-changing - job runs toasts "Another job is running: ." with the title linking that job's - view (the shared toast now accepts link parts). + existing media viewer on that one file, by the identity Images opens it by + (`root_index:path`). Each scan row records its root's listing index (`root_index`, + the place in `_get_all_output_dirs`) beside its label. The row's source is that index + in `/prompt_manager/gallery/subfolders?mode=workspace` and must still carry the row's + label, so a reordered or replaced source never opens another root's file; the + workspace listing's new `path` filter (one exact relative path, case-folded, at most + 1,024 characters, no traversal) returns that file. A row without an index, a changed + source or a file the listing no longer has toasts "This file is no longer in Images. + Rescan from Import and try again."; other rows are plain text, with no new file + route. A start refused because another record-changing job runs toasts "Another job + is running: <title>." with the title linking that job's view (the shared toast now + accepts link parts). A toast with a link stays 20 s instead of 6.5 s, and any toast + stays while the pointer or focus is on it. - **D29**: no prototype copy. There is no "Interactive concept" badge and no "What changes & why" button. The footer reads "ComfyAssets · Local tools. Your library.", and the title has no " concept" suffix. Save view's dialog action reads diff --git a/py/api/admin.py b/py/api/admin.py index 9bbcf07d..8519cd16 100644 --- a/py/api/admin.py +++ b/py/api/admin.py @@ -198,21 +198,28 @@ def _scan_status_line(done, total, batches, elapsed): def _scan_roots(output_dirs): - """(absolute root, public label) pairs, the most specific root first.""" + """(absolute root, public label, listing index), the most specific first. + + The index is the root's place in ``output_dirs`` (``_get_all_output_dirs``), + the ``root_index`` the workspace listing names it by. + """ public_path, _ = _public_helpers() - roots = [(os.path.abspath(str(d)), public_path(d)) for d in output_dirs] + roots = [ + (os.path.abspath(str(d)), public_path(d), index) + for index, d in enumerate(output_dirs) + ] return sorted(roots, key=lambda root: len(root[0]), reverse=True) def _scan_location(media_file, roots): - """``media_file`` relative to its output root, and that root's label.""" + """``media_file`` relative to its output root, that root's label and index.""" full = os.path.abspath(str(media_file)) folded = os.path.normcase(full) - for root, label in roots: + for root, label, index in roots: base = os.path.normcase(root).rstrip("\\/") + os.sep if folded.startswith(base): - return Path(os.path.relpath(full, root)).as_posix(), label - return Path(full).name, "" + return Path(os.path.relpath(full, root)).as_posix(), label, index + return Path(full).name, "", None def _scan_outcome(media_file, roots, status, title=None): @@ -223,8 +230,8 @@ def _scan_outcome(media_file, roots, status, title=None): group = "errors" if status == "error" else "no_metadata" template = SCAN_TITLED_DETAILS.get(status) if title else None detail = template.format(title=title) if template else SCAN_DETAILS[status] - path, label = _scan_location(media_file, roots) - row = {"path": path, "root": label, "detail": detail} + path, label, index = _scan_location(media_file, roots) + row = {"path": path, "root": label, "root_index": index, "detail": detail} where = f"{label}/{path}" if label else path return group, row, f"{SCAN_LOG_PREFIXES[group]} {where} · {detail}" @@ -1715,7 +1722,7 @@ class AdminRoutesMixin: return result["status"] return { **result, - "title": display_title((), resolution["positive"]["text"]), + "title": self._scan_title(media_file, resolution["positive"]["text"]), } if canonical.get("prompt") and resolution["state"] in ( "ambiguous", @@ -1747,7 +1754,19 @@ class AdminRoutesMixin: status = self._store_scanned_prompt(media_file, canonical, prompt_text) if not detailed or status not in SCAN_TITLED_DETAILS: return status - return {"status": status, "title": display_title((), prompt_text)} + return {"status": status, "title": self._scan_title(media_file, prompt_text)} + + def _scan_title(self, media_file, text): + """The linked prompt's Library title (its stored title, else ``text``'s + first line), as the Library and the inspector name it.""" + try: + path = str(media_file) + summary = self.db.get_image_link_summaries([path], False).get(path) + if summary and summary.get("title"): + return summary["title"] + except Exception: + self.logger.warning("Scan row title could not be read; using its text") + return display_title((), text) def _store_scanned_prompt(self, media_file, canonical, prompt_text): """Link ``media_file`` to ``prompt_text``'s prompt, saving it when new.""" diff --git a/py/api/images.py b/py/api/images.py index fb3046cb..415cde9e 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -705,6 +705,8 @@ def _workspace_filters(query: Mapping) -> dict: media = query.get("media", "all") order = query.get("sort", "newest") folder = query.get("subfolder", "").strip().replace("\\", "/") + # ``path``: one exact relative path (Activity opens a scan row by it, D57). + exact = query.get("path", "").strip().replace("\\", "/") category = query.get("category", "").strip() or None tags = sorted( {tag.strip() for tag in query.get("tags", "").split(",") if tag.strip()} @@ -723,12 +725,15 @@ def _workspace_filters(query: Mapping) -> dict: raise ValueError("Invalid gallery filters") if folder and (_has_traversal(folder) or len(folder) > 1024): raise ValueError("Invalid gallery folder") + if exact and (_has_traversal(exact) or len(exact) > 1024): + raise ValueError("Invalid gallery path") return { "text": text, "query": folded, "media": media, "order": order, "folder": folder, + "path": exact.casefold(), "category": category, "tags": tags, "favorites": favorites == "1", @@ -762,7 +767,10 @@ def _filtered_page( native_folder = folder.replace("/", os.sep) nested = native_folder + os.sep total, page, positives = 0, [], set() + exact = filters.get("path", "") for position, entry in enumerate(entries): + if exact and entry.folded != exact: + continue if folder and not ( entry.folder == native_folder or entry.folder.startswith(nested) ): @@ -1155,6 +1163,7 @@ class ImageRoutesMixin: filters["media"], filters["order"], filters["folder"], + filters["path"], root_index, [str(root) for root in output_dirs], filters["category"], diff --git a/py/api/jobs.py b/py/api/jobs.py index 8c6cde1b..4918e4c4 100644 --- a/py/api/jobs.py +++ b/py/api/jobs.py @@ -143,9 +143,10 @@ class Job: def record(self, group, row, log_line): """Count one file outcome in ``group``, list its row and log it. - ``row`` is ``{path, root, detail}``: the path relative to its output - root and the root's public label. An absolute path is cut to its file - name, so a row never reveals the server's layout. + ``row`` is ``{path, root, root_index, detail}``: the path relative to + its output root, the root's public label and its listing index (the + identity Images opens it by; None when unknown). An absolute path is + cut to its file name, so a row never reveals the server's layout. """ if group not in self.counters: raise ValueError(f"Unknown outcome group {group!r} for {self.kind}") @@ -155,11 +156,15 @@ class Job: if raw and raw != path: log_line = log_line.replace(raw, path) rows = self.outcomes[group] + index = row.get("root_index") + if isinstance(index, bool) or not isinstance(index, int) or index < 0: + index = None if len(rows) < MAX_OUTCOME_ROWS: rows.append( { "path": path, "root": str(row.get("root") or ""), + "root_index": index, "detail": str(row.get("detail") or ""), } ) @@ -219,6 +224,9 @@ class Job: "total": self.total, "counters": dict(self.counters), "scope": dict(self.scope), + # What detail() lists, so a view re-reads it only when it changed. + "listed": {group: len(rows) for group, rows in self.outcomes.items()}, + "log_length": len(self.log), } def detail(self): diff --git a/tests/browser/workspace-activity.spec.mjs b/tests/browser/workspace-activity.spec.mjs index 2c6b072c..a8adb14c 100644 --- a/tests/browser/workspace-activity.spec.mjs +++ b/tests/browser/workspace-activity.spec.mjs @@ -267,8 +267,11 @@ test('reload during a scan reattaches to the running job', async ({ page, reques // Cancel job asks the server to stop at a file boundary; the job ends cancelled with its partial counts. await step(request, job.id, { counters: { added: 7 } }); - await items(page).first().getByRole('button', { name: 'Cancel job', exact: true }).click(); + await items(page).first().getByRole('button', { name: 'Cancel job', exact: true }).focus(); + await page.keyboard.press('Enter'); await expect(toast(page)).toHaveText('Scan cancelled · 7 added'); + // The removed Cancel job hands its focus to the item's title link (review M4). + await expect(items(page).first().locator('h3 > a')).toBeFocused(); await expect(items(page).first().locator('.chip.blue')).toHaveText('Cancelled'); await expect(items(page).first().locator('p')).toHaveText(/^Cancelled \d\d:\d\d at a file boundary · 7 added · completed work is kept$/); await expect(pill(page)).toBeHidden(); @@ -384,6 +387,8 @@ test('start scan creates a job, toasts, and opens its view', async ({ page, requ const values = await tileValues(page); expect(values.map(([, label]) => label)).toEqual(TILE_LABELS); expect(values.reduce((sum, [, , value]) => sum + Number(value.replaceAll(',', '')), 0)).toBe(job.processed); + // Opening the log reads its last lines (review M12). + await scanLog(page).locator('summary').click(); await expect(scanLog(page).locator('pre')).toContainText('Scan finished'); }); @@ -498,6 +503,26 @@ test('added rows open the viewer, error rows are plain', async ({ page, request await fileRows(page).nth(1).click(); await expect(toast(page)).toHaveText('This file is no longer in Images. Rescan from Import and try again.'); await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0); + // A row opens only its own root's file: a root whose label no longer matches (a replaced source) never opens the + // other root's same path, and a row without a root index opens nothing (review M8, M9). + const lookups = []; + page.on('request', item => { const url = new URL(item.url()); if (url.pathname === '/prompt_manager/images/output' && url.searchParams.get('path')) lookups.push(url.search); }); + await step(request, job.id, { records: [ + { group: 'added', path: 'shared/same.png', root: 'output', root_index: 1, detail: 'Added' }, + { group: 'added', path: 'shared/same.png', root: 'output', root_index: null, detail: 'Added' }, + ] }); + await expect(fileRows(page)).toHaveCount(4); + for (const index of [2, 3]) { + await fileRows(page).nth(index).click(); + await expect(toast(page)).toHaveText('This file is no longer in Images. Rescan from Import and try again.'); + await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0); + } + expect(lookups).toEqual([]); + // The lookup is the exact identity, not a name search. + await fileRows(page).first().click(); + await expect(page.locator('dialog#viewer-dialog[open]')).toBeVisible(); + expect(lookups).toEqual(['?mode=workspace&root=0&path=shared%2Fsame.png&limit=5']); + await page.keyboard.press('Escape'); }); test('filter narrows the selected outcome', async ({ page, request }) => { @@ -545,6 +570,18 @@ test('job view renders 200-row cap note', async ({ page, request }) => { await filter(page).fill('file_00199'); await expect(fileRows(page)).toHaveCount(1); await expect(resultsNote(page)).toHaveText(`Showing 1 of 250 · ${VIEWER_HINT}`); + + // Review M12: with the list capped and the log closed, more files only update the counters; the detail is read + // again only when the log opens with new lines. + const reads = []; + page.on('request', item => { if (new URL(item.url()).pathname === `/prompt_manager/jobs/${job.id}`) reads.push(Date.now()); }); + await step(request, job.id, { processed: 300, bulk: { group: 'added', count: 40 } }); + await expect(tiles(page).first().locator('dd')).toHaveText('290'); + await page.waitForTimeout(1500); + expect(reads).toEqual([]); + await scanLog(page).locator('summary').click(); + await expect.poll(() => reads.length).toBe(1); + await expect(scanLog(page).locator('pre')).toContainText('bulk/file_00039.png'); }); test('cancel scan ends cancelled with partial counts', async ({ page, request }) => { @@ -552,8 +589,11 @@ test('cancel scan ends cancelled with partial counts', async ({ page, request }) const job = await startJob(request); await step(request, job.id, { processed: 9, counters: { added: 7, errors: 2 } }); await openJob(page, job.id); - await heading(page).getByRole('button', { name: 'Cancel scan', exact: true }).click(); + // From the keyboard: when the job ends and Cancel scan goes away, focus moves to the job's heading (review M4). + await heading(page).getByRole('button', { name: 'Cancel scan', exact: true }).focus(); + await page.keyboard.press('Enter'); await expect(toast(page)).toHaveText('Scan cancelled · 7 added'); + await expect(heading(page).locator('h1')).toBeFocused(); await expect(heading(page).locator('p.muted')).toHaveText(/^Cancelled \d\d:\d\d at a file boundary · 7 added · completed work is kept$/); await expect(heading(page).getByRole('button', { name: 'Cancel scan', exact: true })).toHaveCount(0); await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Cancelled after 9 of 19,622 files'); @@ -577,6 +617,9 @@ test('busy start names the running job', async ({ page, request }) => { await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toHaveAttribute('href', `#/activity/${running.id}`); await expect(page).toHaveURL(/#\/images$/); expect((await listed(request)).map(item => item.id)).toEqual([running.id]); + // Review M14: a toast with a link outlasts the plain 6.5 s, so the link stays reachable. + await page.waitForTimeout(7000); + await expect(toast(page).getByRole('link', { name: 'Database maintenance', exact: true })).toBeVisible(); await toast(page).getByRole('link', { name: 'Database maintenance', exact: true }).click(); await expect(heading(page).locator('h1')).toHaveText('Database maintenance'); // A job of another kind cancels as "Cancel job". diff --git a/tests/browser/workspace_activity_fixture.py b/tests/browser/workspace_activity_fixture.py index 7bb7f3d7..40a90547 100644 --- a/tests/browser/workspace_activity_fixture.py +++ b/tests/browser/workspace_activity_fixture.py @@ -6,7 +6,7 @@ reading files, so a test sets its progress and counters exactly: - ``POST /__test__/jobs/start`` starts one (``kind``, ``title``, ``total``, ``output_folders``) and returns its summary, or 409 while one runs; - ``POST /__test__/jobs/{id}/step`` records ``records`` (``{group, path, - root, detail}`` file outcomes, counted, listed and logged as the scan + root, root_index, detail}`` file outcomes, counted, listed and logged as the scan records them) and ``bulk`` (``{group, count, root}``: ``count`` numbered rows), sets ``processed`` (and ``total``) and ``counters``, then publishes the progress to every subscriber; @@ -28,6 +28,7 @@ POLL_SECONDS = 0.02 PREFIXES = {"added": "+", "known": "=", "review": "?", "errors": "!"} PREFIXES["no_metadata"] = "-" +ROOT_INDEXES = {"output": 0, "input": 1} def outcome_records(data): @@ -40,11 +41,14 @@ def outcome_records(data): for record in records: if not isinstance(record, dict) or record.get("group") not in PREFIXES: raise web.HTTPBadRequest() + root = str(record.get("root", "output")) rows.append( { "group": record["group"], "path": str(record.get("path", "")), - "root": str(record.get("root", "output")), + "root": root, + # The fixture's listing order: output is root 0, input root 1. + "root_index": record.get("root_index", ROOT_INDEXES.get(root)), "detail": str(record.get("detail", "")), } ) @@ -59,6 +63,7 @@ def outcome_records(data): "group": bulk["group"], "path": f"bulk/file_{index:05d}.png", "root": str(bulk.get("root", "output")), + "root_index": 0, "detail": f"Bulk row {index}", } for index in range(total) diff --git a/tests/js/workspace-activity-store.test.js b/tests/js/workspace-activity-store.test.js index cd6e367c..211825c7 100644 --- a/tests/js/workspace-activity-store.test.js +++ b/tests/js/workspace-activity-store.test.js @@ -111,12 +111,13 @@ test('store merges list and SSE updates and caps backoff', async () => { assert.deepEqual(store.running(), []); assert.equal(typeof LOST_DETAIL, 'string'); - // The server is back without the job (a restart): it stays ended, earlier finished jobs stay listed. + // The server is back without the job (a restart): it stays ended; finished jobs the server no longer lists are + // dropped, so the list never outgrows the server's (review M3). world.listError = null; world.list = []; await world.fire(); assert.equal(store.status, 'ready'); - assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_C, 'lost'], [ID_A, 'finished'], [ID_B, 'finished']]); + assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_C, 'lost']]); assert.equal(world.timers.filter(timer => !timer.cleared && !timer.fired).length, 0, 'no retry after a good read'); assert.equal(world.toasts.length, 1, 'an ended job has no finish toast'); world.dispose(); @@ -260,7 +261,7 @@ test('summaries are validated and the copy follows the spec', async () => { const loose = parseJob({ id: ID_A, state: 'running', progress: 140.4, processed: -1, total: 1.5, counters: { added: 'x', errors: 2 }, title: ' ', started_at: 'x', scope: [] }); assert.deepEqual(loose, { id: ID_A, kind: '', title: 'Background job', state: 'running', started_at: null, finished_at: null, - progress: 100, status_line: '', processed: 0, total: 0, counters: { added: 0, errors: 2 }, scope: {} }); + progress: 100, status_line: '', processed: 0, total: 0, counters: { added: 0, errors: 2 }, scope: {}, listed: {}, log_length: 0 }); assert.equal(parseJob(summary({ progress: -3 })).progress, 0); const scan = parseJob(summary({ progress: 42, processed: 8240, total: 19622, counters: { added: 1, errors: 1 } })); @@ -324,3 +325,47 @@ test('pagehide stops polling, closes streams and aborts the in-flight read; page assert.equal(signals.at(-1).aborted, true, 'disposing aborts the store reads'); assert.equal(world.open().length, 0); }); + +test('one failed read is not a restart; two in a row are, and a good read resets the backoff', async () => { + const { createActivityStore } = await source; + const { world, ...deps } = harness(); + world.list = [summary(), summary({ id: ID_B, state: 'finished', finished_at: 900 })]; + const store = createActivityStore(deps); + await store.load(); + // A single failed read (a proxy's 502) keeps the job running and its stream open (review M1). + world.listError = apiError(502, 'Bad gateway', null); + await store.load(); + assert.equal(store.status, 'error'); + assert.equal(store.find(ID_A).state, 'running'); + assert.equal(world.open().length, 1); + assert.equal(await world.fire(), 1000); + // The second failure in a row: the server is gone, so the running job ended with it. + assert.equal(store.find(ID_A).state, 'lost'); + assert.equal(world.open().length, 0); + for (let index = 0; index < 3; index += 1) await world.fire(); + // The server is back with a new running job: the backoff starts again at 1 s (review M2). + world.listError = null; + world.list = [summary({ id: ID_C })]; + await world.fire(); + assert.equal(store.status, 'ready'); + assert.deepEqual(store.jobs().map(job => [job.id, job.state]), [[ID_C, 'running'], [ID_A, 'lost']]); + world.open()[0].onerror(); + assert.equal(await world.fire(), 1000); + // A summary carries what its detail lists, for the job view's reads (review M12). + send(world.open()[0], summary({ id: ID_C, listed: { added: 3, errors: -1 }, log_length: 7 })); + assert.deepEqual([store.find(ID_C).listed, store.find(ID_C).log_length], [{ added: 3, errors: 0 }, 7]); + world.dispose(); +}); + +test('numbers group the way the server writes them, whatever the browser locale', async () => { + const { pillParts, finishToast, parseJob } = await source; + const original = Number.prototype.toLocaleString; + Number.prototype.toLocaleString = function (locale, ...rest) { return original.call(this, locale ?? 'de-DE', ...rest); }; + try { + const job = parseJob(summary({ progress: 41, processed: 8240, total: 19622, counters: { added: 1420, errors: 18 } })); + assert.equal(pillParts(job).text, 'Scanning · 41% · 8,240 / 19,622'); + assert.equal(finishToast({ ...job, state: 'finished' }), 'Scan finished · 1,420 added · 18 errors'); + } finally { + Number.prototype.toLocaleString = original; + } +}); diff --git a/tests/test_api_admin.py b/tests/test_api_admin.py index 017b0731..e7cc02bb 100644 --- a/tests/test_api_admin.py +++ b/tests/test_api_admin.py @@ -2094,6 +2094,127 @@ class TestActivityJobs(AdminAPITestCase): (legacy["processed"], legacy["found"], legacy["failed"]), (6, 4, 1) ) + async def test_cancel_after_first_batch_keeps_partial_counts(self): + """Review M7: the real scan stops at the batch boundary, keeping batch 1.""" + import threading + from unittest import mock + + from py.api import admin + + _write_png(self.output_dir / "a.png", prompt_text="a red square") + _write_png(self.output_dir / "b.png", prompt_text="a blue circle") + GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)] + first_done, release = threading.Event(), threading.Event() + original = self.api._ingest_scan_batch_sync + + def gated(batch_results, roots=(), outcomes=None): + counts = original(batch_results, roots=roots, outcomes=outcomes) + first_done.set() + release.wait(5) + return counts + + self.api._ingest_scan_batch_sync = gated + with mock.patch.object(admin, "SCAN_BATCH_SIZE", 1): + status, started = await self._json("POST", "/prompt_manager/jobs/scan") + self.assertEqual(status, 202) + job_id = started["job"]["id"] + await asyncio.get_running_loop().run_in_executor(None, first_done.wait, 5) + status, cancelled = await self._json( + "POST", f"/prompt_manager/jobs/{job_id}/cancel" + ) + self.assertEqual((status, cancelled["cancellation_requested"]), (200, True)) + release.set() + await asyncio.wait_for(self.api._scan_job.task, 10) + + status, body = await self._json("GET", f"/prompt_manager/jobs/{job_id}") + job = body["job"] + self.assertEqual(job["state"], "cancelled") + self.assertEqual((job["processed"], job["total"]), (1, 2)) + self.assertEqual(sum(job["counters"].values()), job["processed"]) + self.assertEqual(job["counters"]["added"], 1) + listed = [row["path"] for rows in job["outcomes"].values() for row in rows] + self.assertEqual(len(listed), 1) + self.assertIn(listed[0], {"a.png", "b.png"}) + self.assertIn("Scan cancelled · 1 file processed", job["log"][-1]) + # A cancel after the end is a no-op. + status, again = await self._json( + "POST", f"/prompt_manager/jobs/{job_id}/cancel" + ) + self.assertEqual((status, again["cancellation_requested"]), (200, False)) + + async def test_scan_rows_carry_their_root_index(self): + """Review M9: two roots labelled "output" stay apart by listing index.""" + second = self.outside_dir / "output" + second.mkdir(parents=True) + os.environ[EXTRA_ROOTS_ENV] = str(second) + _write_png(self.output_dir / "same.png", prompt_text="a red square") + _write_png(second / "same.png", prompt_text="a blue circle") + GalleryConfig.MONITORING_DIRECTORIES = [ + self._root(self.output_dir), + self._root(second), + ] + output_dirs = [Path(d) for d in self.api._get_all_output_dirs()] + self.assertEqual(len(output_dirs), 2) + status, started = await self._json("POST", "/prompt_manager/jobs/scan") + await asyncio.wait_for(self.api._scan_job.task, 10) + status, body = await self._json( + "GET", f"/prompt_manager/jobs/{started['job']['id']}" + ) + rows = body["job"]["outcomes"]["added"] + self.assertEqual(len(rows), 2) + self.assertEqual({row["root"] for row in rows}, {"output"}) + by_index = {row["root_index"]: row for row in rows} + self.assertEqual(set(by_index), {0, 1}) + for index, row in by_index.items(): + self.assertEqual(row["path"], "same.png") + self.assertTrue((output_dirs[index] / row["path"]).is_file()) + texts = { + index: ( + "red" + if output_dirs[index] == Path(self._root(self.output_dir)) + else "blue" + ) + for index in by_index + } + for index, row in by_index.items(): + self.assertIn(texts[index], row["detail"]) + + async def test_scan_row_names_the_prompt_by_its_stored_title(self): + """Review M10: a row names the prompt as the Library does.""" + _write_png(self.output_dir / "gen.png", prompt_text="a red square\nmore") + GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)] + summaries = self.api.db.get_image_link_summaries + + def titled(paths, allow_filename_fallback=True): + found = summaries(paths, allow_filename_fallback) + return { + path: {**summary, "title": "Stored square title"} + for path, summary in found.items() + } + + self.api.db.get_image_link_summaries = titled + status, started = await self._json("POST", "/prompt_manager/jobs/scan") + await asyncio.wait_for(self.api._scan_job.task, 10) + status, body = await self._json( + "GET", f"/prompt_manager/jobs/{started['job']['id']}" + ) + (row,) = body["job"]["outcomes"]["added"] + self.assertIn("“Stored square title”", row["detail"]) + + # Without a stored title (or an unreadable one): the text's first line. + def broken(paths, allow_filename_fallback=True): + raise RuntimeError("unreadable") + + self.api.db.get_image_link_summaries = broken + _write_png(self.output_dir / "next.png", prompt_text="a green cone\nmore") + status, started = await self._json("POST", "/prompt_manager/jobs/scan") + await asyncio.wait_for(self.api._scan_job.task, 10) + status, body = await self._json( + "GET", f"/prompt_manager/jobs/{started['job']['id']}" + ) + details = {r["path"]: r["detail"] for r in body["job"]["outcomes"]["added"]} + self.assertIn("“a green cone”", details["next.png"]) + async def test_jobs_routes_404_and_409(self): _write_png(self.output_dir / "gen.png", prompt_text="a red square") GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)] diff --git a/tests/test_api_workspace_images.py b/tests/test_api_workspace_images.py index 1eaeda2a..18c71f4c 100644 --- a/tests/test_api_workspace_images.py +++ b/tests/test_api_workspace_images.py @@ -190,6 +190,26 @@ class TestWorkspaceImages(ImageAPITestCase): self.assertEqual(data["images"], []) self.assertIsNone(data["next_cursor"]) + async def test_path_filter_lists_only_that_exact_file(self): + # Activity (D57): a scan row opens its file by root and exact relative path, + # never by a substring that also matches the same name in other folders. + self.media("ComfyUI_00001_.png") + for day in range(3): + self.media(f"2026-10-0{day + 1}/ComfyUI_00001_.png") + self.media("2026-10-01/ComfyUI_00001_.png.bak.png") + _, data = await self.get(path="ComfyUI_00001_.png") + self.assertEqual([i["path"] for i in data["images"]], ["ComfyUI_00001_.png"]) + _, data = await self.get(path="2026-10-02/ComfyUI_00001_.png") + self.assertEqual( + [i["path"] for i in data["images"]], ["2026-10-02/ComfyUI_00001_.png"] + ) + _, data = await self.get(path="missing.png") + self.assertEqual(data["images"], []) + for bad in ("../outside.png", "a" * 1025): + with self.subTest(bad=bad[:12]): + response, _ = await self.get(path=bad) + self.assertEqual(response.status, 400) + async def test_cursor_cannot_be_reused_with_changed_filters_or_tampered(self): self.media("a.png") self.media("b.png") diff --git a/tests/test_jobs.py b/tests/test_jobs.py index b07f353e..9dffb427 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -177,9 +177,13 @@ class TestJobRegistry(unittest.IsolatedAsyncioTestCase): "total", "counters", "scope", + "listed", + "log_length", }, ) self.assertEqual(summary["scope"], {"mode": "all"}) + # What the detail would list, so a view reads it only when that changed. + self.assertEqual((summary["listed"], summary["log_length"]), ({}, 0)) self.assertIsNone(summary["finished_at"]) gate.set() @@ -404,3 +408,19 @@ class TestJobOutcomesAndLog(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class TestListedSummary(unittest.TestCase): + def test_summary_counts_listed_rows_and_log_lines(self): + job = Job("scan", "Scan", groups=("added", "errors")) + job.record( + "added", {"path": "a.png", "root": "output", "root_index": 0}, "+ a.png" + ) + job.record("errors", {"path": "b.png", "root_index": "x"}, "! b.png") + summary = job.summary() + self.assertEqual(summary["listed"], {"added": 1, "errors": 1}) + self.assertEqual(summary["log_length"], 2) + rows = job.detail()["outcomes"] + self.assertEqual(rows["added"][0]["root_index"], 0) + # Only a whole non-negative number is a root index. + self.assertIsNone(rows["errors"][0]["root_index"]) diff --git a/web/js/workspace/activity-job-view.mjs b/web/js/workspace/activity-job-view.mjs index d49f945d..b3a63461 100644 --- a/web/js/workspace/activity-job-view.mjs +++ b/web/js/workspace/activity-job-view.mjs @@ -33,8 +33,7 @@ export const NO_MATCH = 'No listed file matches this filter.'; export const VIEWER_HINT = 'each row opens the image in the viewer'; export const FILE_GONE = 'This file is no longer in Images. Rescan from Import and try again.'; export const OPEN_FAILED = 'The file could not be opened. Try again.'; -const LISTING_LIMIT = 100; -const MAX_QUERY = 500; +const LISTING_LIMIT = 5; /** The counter tiles, in the spec's order and the mockup's tones. */ export const TILES = Object.freeze([ @@ -55,7 +54,8 @@ function element(tag, text, className) { return node; } -const number = value => value.toLocaleString(); +// Grouped as the server writes its lines, whatever the browser's locale (as images.mjs does). +const number = value => value.toLocaleString('en-US'); const isText = value => typeof value === 'string'; /** A job's detail read: the summary plus its listed rows per outcome and its log; null when it is not one. */ @@ -67,7 +67,9 @@ export function parseDetail(raw) { for (const [group, rows] of Object.entries(groups)) { if (!Array.isArray(rows)) continue; outcomes[group] = rows.filter(row => row && isText(row.path) && row.path) - .map(row => ({ path: row.path, root: isText(row.root) ? row.root : '', detail: isText(row.detail) ? row.detail : '' })); + .map(row => ({ path: row.path, root: isText(row.root) ? row.root : '', + root_index: Number.isSafeInteger(row.root_index) && row.root_index >= 0 ? row.root_index : null, + detail: isText(row.detail) ? row.detail : '' })); } const log = Array.isArray(raw.log) ? raw.log.filter(isText) : []; return { job, outcomes, log }; @@ -187,7 +189,11 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { let readTimer = null; let roots = null; let lastState = null; - const current = () => store.find(jobId) ?? detail?.job ?? null; + // Set once this page's own list read answered, or the detail read said the job is unknown. + let listedOnce = false; + let notFound = false; + // The store is the truth once it has read the list: a job it no longer has was pruned by the server. + const current = () => store.find(jobId) ?? (listedOnce ? null : detail?.job ?? null); const inspection = createInspectionController({ api }); scope.cleanup(() => inspection.dispose()); @@ -203,6 +209,24 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { selected ??= defaultOutcome(job.counters); } + /** The job is not (or no longer) in this session: say so instead of an empty view. */ + function showMissing() { + if (mounted) { + mounted = false; + const focused = main.contains(document.activeElement) && document.activeElement !== parts.h1; + download.remove(); + cancel.remove(); + progressPart.node.remove(); + resultsPart.node.remove(); + log.remove(); + parts.h1.textContent = 'Activity'; + parts.description.textContent = ''; + document.title = 'Activity · Prompt Manager'; + if (focused) parts.h1.focus(); + } + if (!missing.isConnected) main.append(missing); + } + // Listed rows only ever grow, so a row keeps its node across reads: a focused row (or the viewer's trigger) stays. let rowNodes = new Map(); function rowNode(row, index, openable) { @@ -237,7 +261,10 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { function render() { const job = current(); - if (!job) return; + if (!job) { + if (listedOnce || notFound) showMissing(); + return; + } showBody(job); const scan = job.kind === 'scan'; parts.h1.textContent = job.title; @@ -246,7 +273,9 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { cancel.textContent = scan ? 'Cancel scan' : 'Cancel job'; if (job.state === 'running') { if (!cancel.isConnected) parts.actions.append(cancel); - } else { + } else if (cancel.isConnected) { + // A focused Cancel that goes away hands its focus to the job's heading, never to the page body. + if (document.activeElement === cancel) parts.h1.focus(); cancel.remove(); } status.textContent = progressLine(job); @@ -289,7 +318,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { } catch (error) { if (scope.signal.aborted) return; // An unknown job: the page says so unless the store still knows it (then the rows wait for a later read). - if (error?.status === 404 && !current()) main.append(missing); + if (error?.status === 404) notFound = true; } finally { reading = false; } @@ -297,15 +326,31 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { if (readAgain) { readAgain = false; scheduleRead(); } } + /** + * Whether the detail read would change the page: more listed rows in any outcome, or (with the log open) more log + * lines. Counters come with the summary, so a job whose lists are capped and whose log is closed is not re-read. + */ + function detailChanged(job) { + if (!detail) return true; + const groups = new Set([...Object.keys(job.listed), ...Object.keys(detail.outcomes)]); + for (const group of groups) if ((job.listed[group] ?? 0) !== (detail.outcomes[group]?.length ?? 0)) return true; + return log.open && job.log_length !== detail.log.length; + } + function follow() { const job = store.find(jobId); render(); if (!job) return; - // The listed rows change while it runs, and once more as it ends; a job lost with the server cannot be read. - if (job.state === 'running' || (lastState === 'running' && job.state !== 'lost')) scheduleRead(); + // A job lost with the server cannot be read; any other is read while what it lists changes. + if (job.state !== 'lost' && (job.state === 'running' || lastState === 'running') && detailChanged(job)) scheduleRead(); lastState = job.state; } + scope.listen(log, 'toggle', () => { + const job = store.find(jobId); + if (log.open && job && job.state !== 'lost' && detailChanged(job)) scheduleRead(); + }); + scope.listen(cancel, 'click', async () => { if (cancel.getAttribute('aria-disabled') === 'true') return; cancel.setAttribute('aria-disabled', 'true'); @@ -329,17 +374,20 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { if (button?.row) openRow(button.row, button); }); - /** The listing's own item for a row (its source by label, then its path), so the viewer reads it as Images does. */ + /** + * The listing's own item for a row, by its exact identity (D57): the root index the scan recorded, still carrying + * the row's label (so a reordered or replaced source never opens another root's file), and its exact relative path. + */ async function listed(row) { + if (row.root_index === null) return null; if (!roots) { const data = await api.get('/prompt_manager/gallery/subfolders?mode=workspace', { signal: scope.signal }); roots = Array.isArray(data?.roots) ? data.roots : []; } - const root = roots.find(item => item?.label === row.root && Number.isSafeInteger(item.index)); - if (!root) return null; - const query = row.path.length > MAX_QUERY ? row.path.split('/').at(-1).slice(0, MAX_QUERY) : row.path; - const params = new URLSearchParams({ mode: 'workspace', root: String(root.index), q: query, - limit: String(LISTING_LIMIT), sort: 'newest' }); + const root = roots.find(item => item?.index === row.root_index); + if (!root || root.label !== row.root) return null; + const params = new URLSearchParams({ mode: 'workspace', root: String(root.index), path: row.path, + limit: String(LISTING_LIMIT) }); const page = await api.get(`/prompt_manager/images/output?${params}`, { signal: scope.signal }); return (Array.isArray(page?.images) ? page.images : []).find(item => item?.path === row.path && item.root_index === root.index) ?? null; @@ -364,6 +412,10 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { scope.cleanup(store.subscribe(follow)); render(); read(); - // A job that finished before this page loaded is only in the list read. - store.load(); + // A job that finished before this page loaded is only in the list read; once it answered, the store decides. + store.load().then(() => { + if (scope.signal.aborted || store.status !== 'ready') return; + listedOnce = true; + render(); + }); } diff --git a/web/js/workspace/activity-store.mjs b/web/js/workspace/activity-store.mjs index 3a248a10..2e1f9762 100644 --- a/web/js/workspace/activity-store.mjs +++ b/web/js/workspace/activity-store.mjs @@ -18,6 +18,7 @@ export const JOBS_URL = '/prompt_manager/jobs'; export const BACKOFF_FIRST_MS = 1000; export const BACKOFF_MAX_MS = 30000; export const POLL_MS = 15000; +export const LOST_AFTER_FAILURES = 2; export const LOST_DETAIL = 'This job ended when ComfyUI restarted.'; const SERVER_STATES = new Set(['running', 'finished', 'cancelled', 'failed']); const FINAL_STATES = new Set(['finished', 'cancelled', 'failed']); @@ -41,6 +42,9 @@ export function parseJob(raw) { if (!plainObject(raw) || typeof raw.id !== 'string' || !JOB_ID.test(raw.id) || !SERVER_STATES.has(raw.state)) return null; const counters = {}; if (plainObject(raw.counters)) for (const [key, value] of Object.entries(raw.counters)) counters[key] = count(value); + // What the job's detail lists per outcome, and its log length: a view re-reads the detail only when they change. + const listed = {}; + if (plainObject(raw.listed)) for (const [key, value] of Object.entries(raw.listed)) listed[key] = count(value); const progress = typeof raw.progress === 'number' && Number.isFinite(raw.progress) ? Math.round(raw.progress) : 0; return { id: raw.id, @@ -55,10 +59,13 @@ export function parseJob(raw) { total: count(raw.total), counters, scope: plainObject(raw.scope) ? { ...raw.scope } : {}, + listed, + log_length: count(raw.log_length), }; } -const number = value => value.toLocaleString(); +// Grouped as the server writes its status lines ("8,240 of 19,622"), whatever the browser's locale. +const number = value => value.toLocaleString('en-US'); const errorsOf = job => `${number(job.counters.errors ?? 0)} ${noun(job.counters.errors ?? 0, 'error')}`; /** "1,420 added". */ export const addedOf = job => `${number(job.counters.added ?? 0)} added`; @@ -113,6 +120,8 @@ export function createActivityStore({ api, scope, connect = defaultConnect, noti let jobs = []; let status = 'idle'; let attempt = 0; + // Failed list reads in a row: one (a proxy's 502, a dropped poll) is not yet a restart. + let failures = 0; let retryTimer = null; let pollTimer = null; let reads = 0; @@ -155,7 +164,7 @@ export function createActivityStore({ api, scope, connect = defaultConnect, noti }; source.onerror = () => { closeStream(id); - if (find(id)?.state === 'running') scheduleResync(); + if (find(id)?.state === 'running') scheduleResync({ drop: true }); }; } @@ -171,12 +180,15 @@ export function createActivityStore({ api, scope, connect = defaultConnect, noti emit(); } - /** Replace the jobs with the server's list; a running job it no longer has has ended with the server. */ + /** + * Replace the jobs with the server's list. A running job it no longer has ended with the server and stays as + * ended; a finished job it no longer lists was pruned there and is dropped here too. + */ function merge(list) { const next = list.map(parseJob).filter(Boolean).map(job => settled(find(job.id), job)); const ids = new Set(next.map(job => job.id)); - const carried = jobs.filter(job => !ids.has(job.id)) - .map(job => job.state === 'running' ? { ...job, state: 'lost' } : job); + const carried = jobs.filter(job => !ids.has(job.id) && (job.state === 'running' || job.state === 'lost')) + .map(job => ({ ...job, state: 'lost' })); for (const job of next) announce(find(job.id), job); jobs = [...next, ...carried]; follow(); @@ -187,13 +199,14 @@ export function createActivityStore({ api, scope, connect = defaultConnect, noti follow(); } - function scheduleResync() { + /** Re-read after the backoff; `drop` marks a re-read for a dropped stream, which keeps the backoff growing. */ + function scheduleResync({ drop = false } = {}) { if (disposed || suspended || retryTimer !== null) return; const delay = backoffDelay(attempt); attempt += 1; retryTimer = timers.setTimeout(() => { retryTimer = null; - load(); + load({ drop }); }, delay); } @@ -209,7 +222,7 @@ export function createActivityStore({ api, scope, connect = defaultConnect, noti } /** Read this session's jobs. A failed read marks running jobs ended and tries again after the backoff. */ - async function load() { + async function load({ drop = false } = {}) { if (disposed || suspended) return; const read = ++reads; // Only the newest read applies, so an older one still in flight is aborted. @@ -223,10 +236,15 @@ export function createActivityStore({ api, scope, connect = defaultConnect, noti if (!Array.isArray(result?.jobs)) throw new TypeError('Invalid job list'); merge(result.jobs); status = 'ready'; + // A good read ends the backoff (the server is back, or nothing dropped), except for a stream that keeps + // dropping while the list reads fine: that waits longer each time, and a received message resets it. + if (failures > 0 || !drop) attempt = 0; + failures = 0; schedulePoll(); } catch (error) { if (disposed || suspended || scope.signal?.aborted || controller.signal.aborted || read !== reads) return; - markLost(); + failures += 1; + if (failures >= LOST_AFTER_FAILURES) markLost(); status = 'error'; scheduleResync(); } diff --git a/web/js/workspace/activity.mjs b/web/js/workspace/activity.mjs index 49cf43e6..1a192aa9 100644 --- a/web/js/workspace/activity.mjs +++ b/web/js/workspace/activity.mjs @@ -146,6 +146,8 @@ function itemFor(job, scope) { if (!progress.isConnected) item.insertBefore(progress, actions.isConnected ? actions : null); if (!actions.isConnected) item.append(actions); } else { + // A focused Cancel job that goes away hands its focus to the item's title link, never to the page body. + if (actions.contains(document.activeElement)) link.focus(); progress.remove(); actions.remove(); } diff --git a/web/js/workspace/toast.mjs b/web/js/workspace/toast.mjs index 0ffdcd67..249509c9 100644 --- a/web/js/workspace/toast.mjs +++ b/web/js/workspace/toast.mjs @@ -1,14 +1,49 @@ /** The shell's shared toast (`#workspace-toast`, role="status"), used by every workspace page. */ -const TOAST_MS = 6500; +export const TOAST_MS = 6500; +/** A toast with a link stays longer, so a keyboard user can reach it (D57, WCAG 2.2.1). */ +export const LINK_TOAST_MS = 20000; let toastTimer; +// Stops waiting for the pointer or focus to leave a toast that a newer one replaced. +let releaseWait = null; + +/** Whether the pointer or keyboard focus rests on the toast. */ +const held = toast => typeof toast.matches === 'function' && toast.matches(':hover, :focus-within'); + +/** Clear the toast when its time is up, unless the pointer or focus is on it: then wait until they leave. */ +function scheduleClear(toast, delay) { + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + if (held(toast)) { + const release = () => { + toast.removeEventListener('pointerleave', leave); + toast.removeEventListener('focusout', later); + releaseWait = null; + }; + const leave = () => { + if (held(toast)) return; + release(); + scheduleClear(toast, TOAST_MS); + }; + // focusout fires before the next element takes focus, so it checks once focus has moved. + const later = () => setTimeout(leave); + toast.addEventListener('pointerleave', leave); + toast.addEventListener('focusout', later); + releaseWait = release; + return; + } + toast.textContent = ''; + }, delay); +} /** * Show one outcome in the shared workspace toast; it clears itself like the concept's. A message given as parts - * (`['Another job is running: ', {text, href}, '.']`) renders each `{text, href}` as a link (D57). + * (`['Another job is running: ', {text, href}, '.']`) renders each `{text, href}` as a link (D57); such a toast stays + * for LINK_TOAST_MS and while the pointer or focus is on it. */ export function showWorkspaceToast(message) { const toast = document.getElementById('workspace-toast'); if (!toast) return; + releaseWait?.(); if (Array.isArray(message)) { toast.replaceChildren(...message.map(part => { if (typeof part === 'string') return document.createTextNode(part); @@ -20,6 +55,6 @@ export function showWorkspaceToast(message) { } else { toast.textContent = message; } - clearTimeout(toastTimer); - toastTimer = setTimeout(() => {toast.textContent = '';}, TOAST_MS); + const linked = Array.isArray(message) && message.some(part => typeof part !== 'string'); + scheduleClear(toast, linked ? LINK_TOAST_MS : TOAST_MS); } diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index bb33cacf..c4f9d141 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -335,7 +335,7 @@ export function openScanReviewDialog({ scope, sources, onBackup, onStart, trigge requireCallbacks({ onBackup, onStart }); if (!Number.isSafeInteger(sources) || sources < 0) throw new TypeError('The scan review needs its source count'); const grid = el('dl', undefined, 'meta-grid'); - for (const [term, value] of [['Sources', `${sources.toLocaleString()} output ${sources === 1 ? 'folder' : 'folders'}`], + for (const [term, value] of [['Sources', `${sources.toLocaleString('en-US')} output ${sources === 1 ? 'folder' : 'folders'}`], ['Scope', 'All files']]) { const entry = el('div'); entry.append(el('dt', term), el('dd', value));