feat(workspace): add native Images browsing and infinite scroll (#201)

* feat(workspace): add native image browsing and continuous loading

* test(workspace): assert empty collections through their status

* fix(workspace): rescan refreshed images and expose ancestor folders
This commit is contained in:
Vito
2026-10-17 11:22:10 -07:00
committed by GitHub
parent d4bb5c5ba9
commit 590aa2cd0e
26 changed files with 1933 additions and 35 deletions
+11 -6
View File
@@ -129,21 +129,26 @@ gh api repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection/required
Update the manifest whenever required job names change. Keep the frontend check
free of path filters so a required check is never silently skipped.
## Unified workspace shell
## Unified workspace
The opt-in `/prompt_manager/workspace` page uses the shipped toolkit and real
`/prompt_manager/stats` API. Existing admin, gallery and metadata URLs remain
The opt-in `/prompt_manager/workspace` page opens the native Images workspace.
Overview retains real `/prompt_manager/stats` counts. Both use the shipped toolkit. Existing admin, gallery and metadata URLs remain
available while replacement workspaces are developed. Browser smoke tests in
`tests/browser/workspace.spec.mjs` cover navigation/history, keyboard skip links,
mobile layout, theme persistence/fallback, loading/retry and stale responses.
They run automatically in the existing four-browser-project workflow and frontend
coverage job. `tests/comfyui/workspace.spec.mjs` verifies the page against the
The Images cases in `tests/browser/workspace-images.spec.mjs` additionally cover
server-side filters, cursor append/retry, Grid/List/Masonry, bounded DOM, keyboard
navigation, restored browsing context, Viewer.js cleanup and actual video playback.
`tests/test_api_workspace_images.py` checks signed cursors, source identities,
legacy compatibility and header-only dimensions. Frontend stream/layout units
cover cancellation, malformed responses and bounded visibility lookup. These run
automatically in the existing four-browser-project workflow and frontend coverage job. `tests/comfyui/workspace.spec.mjs` verifies the page against the
managed host with both renderer startup configurations.
Run the focused page checks with:
```sh
npm run --prefix tools/vendor test:workflows -- workspace.spec.mjs
npm run --prefix tools/vendor test:workflows -- workspace.spec.mjs workspace-images.spec.mjs
node --test tests/js/workspace-*.test.js
```
+6
View File
@@ -4,6 +4,12 @@ The maintainer approved the [interactive concept](concepts/workspace/README.md)
on 2026-10-17. It is a design reference with simulated operations, not production
code. This plan proposes implementation order and acceptance gates.
## Delivery status
- Foundation merged in [#198](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/198).
- [#200](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/200) delivers native Images browsing, physical-file details, infinite scroll, Grid/List/Masonry, Viewer.js and video playback. The workspace opens on Images; original application entry points remain available.
- Prompt metadata, role-aware sending, tagging/review, library/history and System consolidation remain subsequent slices under #196. The physical-file inspector does not display prompt text until the shared prompt inspector/send flow is implemented.
## Scope and compatibility
Rebuild the standalone application around one shared shell. Preserve existing
+3 -1
View File
@@ -1,7 +1,9 @@
# Prompt Manager application redesign concept
Status: visual concept approved by the maintainer, 2026-10-17. Production
implementation is pending; see the [development plan](../../application-redesign.md).
implementation is progressing in tested slices; see the [development plan](../../application-redesign.md).
The production workspace now has native Images browsing; this concept remains the
approved target for the complete application.
## Review the mockup
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+220 -4
View File
@@ -1,7 +1,10 @@
"""Image and gallery API routes for PromptManager."""
import asyncio
import base64
import binascii
import hashlib
import hmac
import json
import os
import re
@@ -228,6 +231,70 @@ MAX_JSON_BODY_BYTES = 1024 * 1024
_READ_CHUNK = 64 * 1024
_WORKSPACE_CURSOR_SECRET = os.urandom(32)
def _workspace_root(query: dict, roots: list[Path]) -> int | None:
value = query.get("root")
if value is None:
return None
try:
index = int(value)
except ValueError:
raise ValueError("Invalid gallery root") from None
if not 0 <= index < len(roots):
raise ValueError("Invalid gallery root")
return index
def _workspace_key(entry: tuple, order: str) -> tuple:
path, mtime, root, index = entry
relative = path.relative_to(root).as_posix()
tie = hashlib.sha256(relative.encode("utf-8")).hexdigest()
name = path.name.casefold()
if order in ("name", "name-desc"):
return (name, tie, index, mtime)
return ((-mtime if order == "newest" else mtime), name, tie, index)
def _workspace_cursor(key: tuple, scope: str) -> str:
payload = json.dumps(
{"v": 1, "scope": scope, "key": key}, separators=(",", ":"), ensure_ascii=False
).encode()
signature = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
return base64.urlsafe_b64encode(signature + payload).decode().rstrip("=")
def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple:
try:
if len(token) > 4096:
raise ValueError()
raw = base64.b64decode(
token + "=" * (-len(token) % 4), altchars=b"-_", validate=True
)
signature, payload = raw[:32], raw[32:]
expected = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
if not hmac.compare_digest(signature, expected):
raise ValueError()
data = json.loads(payload)
key = data["key"]
if data["v"] != 1 or data["scope"] != scope or not isinstance(key, list):
raise ValueError()
types = (
(str, str, int, (float, int))
if order in ("name", "name-desc")
else ((float, int), str, str, int)
)
if len(key) != len(types) or not all(
type(value) in (kind if isinstance(kind, tuple) else (kind,))
for value, kind in zip(key, types)
):
raise ValueError()
return tuple(key)
except (ValueError, KeyError, TypeError, UnicodeError, binascii.Error):
raise ValueError("Invalid gallery cursor; refresh the gallery") from None
class _BodyTooLarge(Exception):
"""Raised when a request body exceeds MAX_JSON_BODY_BYTES."""
@@ -594,6 +661,8 @@ class ImageRoutesMixin:
"""Get all images from ComfyUI output folder(s)."""
try:
output_dirs = self._get_all_output_dirs()
if request.query.get("mode") == "workspace":
return await self._get_workspace_images(request, list(output_dirs))
if not output_dirs:
return web.json_response(
{
@@ -629,6 +698,119 @@ class ImageRoutesMixin:
{"success": False, "error": _safe_error(e)}, status=500
)
async def _get_workspace_images(
self, request, output_dirs: list[Path]
) -> web.Response:
try:
limit, _offset = parse_page_params(request.query, default_limit=100)
except ValueError:
return bad_request("limit and offset must be integers")
try:
root_index = _workspace_root(request.query, output_dirs)
query = request.query.get("q", "").strip().casefold()
media = request.query.get("media", "all")
order = request.query.get("sort", "newest")
folder = request.query.get("subfolder", "").strip().replace("\\", "/")
if (
len(query) > 500
or media not in ("all", "image", "video")
or order not in ("newest", "oldest", "name", "name-desc")
):
raise ValueError("Invalid gallery filters")
if folder and (_has_traversal(folder) or len(folder) > 1024):
raise ValueError("Invalid gallery folder")
scope = hashlib.sha256(
json.dumps(
[
query,
media,
order,
folder,
root_index,
[str(root) for root in output_dirs],
]
).encode()
).hexdigest()
token = request.query.get("cursor")
after = _workspace_cursor_key(token, scope, order) if token else None
except ValueError as exc:
return bad_request(str(exc))
listings = [
(index, root, await self._get_gallery_files(root))
for index, root in enumerate(output_dirs)
if root_index is None or index == root_index
]
page, total, has_more, next_cursor = await self._run_in_executor(
self._select_workspace_page,
listings,
folder,
query,
media,
order,
after,
limit,
scope,
)
images = await self._run_in_executor(self._format_workspace_page, page)
return web.json_response(
{
"success": True,
"images": images,
"total": total,
"limit": limit,
"has_more": has_more,
"next_cursor": next_cursor,
}
)
@staticmethod
def _select_workspace_page(
listings: list[tuple],
folder: str,
query: str,
media: str,
order: str,
after: tuple | None,
limit: int,
scope: str,
) -> tuple:
entries = []
for index, root, files in listings:
for path, mtime in files:
relative = path.relative_to(root)
if folder and not _in_subfolder(
str(relative.parent), folder.replace("/", os.sep)
):
continue
if query and query not in relative.as_posix().casefold():
continue
kind = "video" if path.suffix.lower() in VIDEO_EXTENSIONS else "image"
if media != "all" and media != kind:
continue
entries.append((path, mtime, root, index))
total = len(entries)
reverse = order == "name-desc"
entries.sort(key=lambda entry: _workspace_key(entry, order), reverse=reverse)
if after is not None:
entries = [
entry
for entry in entries
if (
_workspace_key(entry, order) < after
if reverse
else _workspace_key(entry, order) > after
)
]
page = entries[:limit]
has_more = len(entries) > limit
next_cursor = (
_workspace_cursor(_workspace_key(page[-1], order), scope)
if has_more
else None
)
return page, total, has_more, next_cursor
async def serve_image(self, request):
"""Serve the actual image file using streamed FileResponse."""
try:
@@ -775,7 +957,20 @@ class ImageRoutesMixin:
async def get_gallery_subfolders(self, request):
"""Get distinct subfolders from gallery output directories."""
try:
output_dirs = self._get_all_output_dirs()
output_dirs = list(self._get_all_output_dirs())
workspace = request.query.get("mode") == "workspace"
roots = None
if workspace:
try:
selected = _workspace_root(request.query, output_dirs)
except ValueError as exc:
return bad_request(str(exc))
roots = [
{"index": index, "label": _public_path(root)}
for index, root in enumerate(output_dirs)
]
if selected is not None:
output_dirs = [output_dirs[selected]]
subfolders = set()
for output_path in output_dirs:
files = await self._get_gallery_files(output_path)
@@ -795,9 +990,10 @@ class ImageRoutesMixin:
ancestors.add("/".join(parts[:i]))
subfolders.update(ancestors)
return web.json_response(
{"success": True, "subfolders": sorted(subfolders)}
)
payload = {"success": True, "subfolders": sorted(subfolders)}
if workspace:
payload["roots"] = roots
return web.json_response(payload)
except Exception as e:
self.logger.error(f"Subfolders error: {e}")
return web.json_response(
@@ -1529,6 +1725,26 @@ class ImageRoutesMixin:
if _in_subfolder(str(entry[0].relative_to(entry[2]).parent), subfolder)
]
def _format_workspace_page(self, page: list[tuple]) -> list[dict]:
images = self._format_output_page(page)
paths = {
(entry[3], entry[0].relative_to(entry[2]).as_posix()): entry[0]
for entry in page
}
for image in images:
image["width"] = None
image["height"] = None
if image["is_video"]:
continue
try:
with paths[(image["root_index"], image["path"])].open("rb") as stream:
with Image.open(stream) as source:
image["width"], image["height"] = source.size
except (OSError, ValueError, Image.DecompressionBombError):
# A corrupt or disappearing preview must not hide the listing.
continue
return images
def _format_output_page(self, page):
entries = []
public_roots = {}
+19 -2
View File
@@ -78,7 +78,7 @@ def create_app(root: Path) -> web.Application:
api = PromptManagerAPI()
api.add_routes(routes)
def seed(extra_images: int = 0) -> None:
def seed(extra_images: int = 0, include_video: bool = False) -> None:
with api.db.model.get_connection() as conn:
for table in (
"image_prompt_pairs",
@@ -114,25 +114,42 @@ def create_app(root: Path) -> web.Application:
)
api.db.link_image_to_prompt(pid, str(image_path))
if index == 0:
video_prompt_id = pid
for number in range(extra_images):
extra = root / directory / "bulk" / f"{number:04}.png"
extra.parent.mkdir(exist_ok=True)
shutil.copyfile(image_path, extra)
api.db.link_image_to_prompt(pid, str(extra))
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
if include_video:
# Seed after thumbnail generation to exercise a real unthumbnailed video.
video = root / "output" / "clip.webm"
shutil.copyfile(
Path(__file__).resolve().parents[1] / "fixtures" / "tiny.webm", video
)
api.db.link_image_to_prompt(video_prompt_id, str(video))
api.invalidate_gallery_cache()
@routes.post("/__test__/reset")
async def reset(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else {}
extra = data.get("extra_images", 0)
include_video = data.get("include_video", False)
if (
isinstance(extra, bool)
or not isinstance(extra, int)
or not 0 <= extra <= 200
or not isinstance(include_video, bool)
):
raise web.HTTPBadRequest()
await api._run_in_executor(seed, extra)
await api._run_in_executor(seed, extra, include_video)
return web.json_response({"success": True})
@routes.post("/__test__/add-nested-image")
async def add_nested_image(request: web.Request) -> web.Response:
destination = root / "output" / "project" / "day" / "new-output.png"
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(root / "output" / "shared" / "same.png", destination)
return web.json_response({"success": True})
@routes.get("/api/settings/Comfy.ColorPalette")
+482
View File
@@ -0,0 +1,482 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
const workspace = '/prompt_manager/workspace#/images';
const cards = page => page.locator('article[data-image-key]');
const details = page => page.getByRole('region', { name: 'Image details', exact: true });
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
page.imageErrors = [];
page.on('pageerror', error => page.imageErrors.push(error.message));
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.imageErrors).toEqual([]);
});
test('native Images keeps colliding filenames distinct and inspects the selected source', async ({ page, request }) => {
const snapshot = await (await request.get('/prompt_manager/stats')).json();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
expect(await cards(page).evaluateAll(elements => elements.map(element => element.dataset.imageKey)))
.toEqual(['0:shared/same.png', '1:shared/same.png']);
const originals = await cards(page).locator('img').evaluateAll(elements => elements.map(element => element.src));
expect(new Set(originals).size).toBe(2);
await cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true }).click();
await expect(details(page)).toContainText('same.png');
await expect(details(page)).toContainText('shared/same.png');
await expect(details(page)).toContainText(/input/i);
await cards(page).first().getByRole('button', { name: 'Open same.png', exact: true }).click();
await expect(details(page)).toContainText(/output/i);
expect((await (await request.get('/prompt_manager/stats')).json()).stats).toEqual(snapshot.stats);
});
test('workspace defaults to the native Images collection without a hash route', async ({ page }) => {
await page.goto('/prompt_manager/workspace');
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(cards(page)).toHaveCount(2);
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Images', exact: true })).toHaveAttribute('aria-current', 'page');
});
test('image selection is announced on the selected image button', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const selected = cards(page).last().getByRole('button', { name: 'Open same.png', exact: true });
await selected.click();
await expect(selected).toHaveAttribute('aria-pressed', 'true');
await expect(cards(page).first().getByRole('button', { name: 'Open same.png', exact: true }))
.toHaveAttribute('aria-pressed', 'false');
});
test('closing details preview restores its trigger focus', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const selected = cards(page).last().getByRole('button', { name: 'Open same.png', exact: true });
await selected.click();
const preview = details(page).getByRole('button', { name: 'Open full preview', exact: true });
await preview.click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(preview).toBeFocused();
});
test('a short visible batch automatically continues until the sentinel is filled or exhausted', async ({ page, request }) => {
const real = await (await request.get('/prompt_manager/images/output?limit=40')).json();
let reads = 0;
await page.route('**/prompt_manager/images/output?*', async route => {
const current = reads++;
await route.fulfill({ json: {
success: true, images: [real.images[current]], total: 2,
has_more: current === 0, next_cursor: current === 0 ? 'next-short-batch' : null,
} });
});
await page.setViewportSize({ width: 1440, height: 1200 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
expect(reads).toBe(2);
await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeHidden();
});
test('source, folder and media filters operate on real backend records', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(5);
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('1');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('all');
await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption('bulk');
await expect(cards(page)).toHaveCount(3);
await expect(cards(page).first()).toHaveAttribute('data-image-key', /^0:bulk\//);
await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('video');
await expect(cards(page)).toHaveCount(0);
await expect(page.locator('#workspace-main')).toContainText(/no (matching )?(images|media)/i);
await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('image');
await expect(cards(page)).toHaveCount(3);
await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption('');
await expect(cards(page)).toHaveCount(5);
});
test('filename search finds records beyond the initial batch and sort orders them', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('same.png');
await expect(cards(page)).toHaveCount(2);
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('0099');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0099.png');
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('same.png');
await expect(cards(page)).toHaveCount(2);
await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('oldest');
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('newest');
await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png');
});
test('layout changes retain selection and use one collection in Grid, List and Masonry', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true }).click();
for (const layout of ['List', 'Masonry', 'Grid']) {
await page.getByRole('button', { name: layout, exact: true }).click();
await expect(page.getByRole('button', { name: layout, exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(cards(page)).toHaveCount(2);
await expect(details(page)).toContainText(/input/i);
const boxes = await cards(page).evaluateAll(elements => elements.map(element => {
const box = element.getBoundingClientRect();
return { x: box.x, y: box.y, right: box.right, bottom: box.bottom };
}));
expect(boxes[0].right <= boxes[1].x || boxes[1].right <= boxes[0].x ||
boxes[0].bottom <= boxes[1].y || boxes[1].bottom <= boxes[0].y).toBe(true);
}
});
test('returning to Images restores the filtered stream, layout, selection and scroll context', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
const search = page.getByRole('searchbox', { name: 'Search filenames', exact: true });
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('0');
await search.fill('00');
await expect(page.locator('.image-toolbar')).toContainText('40 of 100 images');
await page.getByRole('button', { name: 'List', exact: true }).click();
await page.getByRole('button', { name: 'Load more images', exact: true }).click();
await expect(page.locator('.image-toolbar')).toContainText('80 of 100 images');
await cards(page).first().getByRole('button', { name: /^Open / }).click();
const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent();
await cards(page).nth(30).scrollIntoViewIfNeeded();
const previousScroll = await page.evaluate(() => window.scrollY);
expect(previousScroll).toBeGreaterThan(500);
// Hash navigation preserves the scroll position being tested; clicking the fixed
// navigation can otherwise scroll a non-sticky mobile sidebar into view first.
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(() => { window.location.hash = '/images'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(search).toHaveValue('00');
await expect(page.getByRole('combobox', { name: 'Source', exact: true })).toHaveValue('0');
await expect(page.getByRole('button', { name: 'List', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.image-toolbar')).toContainText('80 of 100 images');
await expect(cards(page)).toHaveCount(80);
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
await expect.poll(async () => Math.abs((await page.evaluate(() => window.scrollY)) - previousScroll)).toBeLessThan(120);
});
test('continuous loading appends without replacing cards or losing selected details', async ({ page, request }) => {
// Exercise the keyboard fallback independently from IntersectionObserver scrolling.
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
await cards(page).first().evaluate(element => { element.dataset.preserved = 'yes'; });
await cards(page).first().getByRole('button', { name: /^Open / }).click();
const selected = await cards(page).first().getAttribute('data-image-key');
const more = page.getByRole('button', { name: 'Load more images', exact: true });
await more.focus();
await page.keyboard.press('Enter');
await expect(cards(page)).toHaveCount(80);
await expect(cards(page).first()).toHaveAttribute('data-preserved', 'yes');
await expect(cards(page).first()).toHaveAttribute('data-image-key', selected);
await expect(details(page)).toBeVisible();
await expect(more).toBeFocused();
await page.keyboard.press('Enter');
await expect(cards(page)).toHaveCount(102);
expect(new Set(await cards(page).evaluateAll(elements => elements.map(element => element.dataset.imageKey))).size).toBe(102);
await expect(more).toBeHidden();
await expect(page.getByRole('button', { name: /next page|previous page/i })).toHaveCount(0);
});
test('scrolling to the sentinel automatically appends the next backend batch', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
const first = cards(page).first();
await first.evaluate(element => { element.dataset.preserved = 'scroll'; });
await page.getByRole('button', { name: 'Load more images', exact: true }).scrollIntoViewIfNeeded();
await expect(cards(page)).toHaveCount(80);
await expect(first).toHaveAttribute('data-preserved', 'scroll');
});
test('large continuous collections bound the mounted cards and restore earlier images on backscroll', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
const first = cards(page).first();
const firstKey = await first.getAttribute('data-image-key');
await first.getByRole('button', { name: /^Open / }).click();
const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent();
const more = page.getByRole('button', { name: 'Load more images', exact: true });
for (const loaded of [80, 120, 160, 200, 202]) {
await more.click();
await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`);
}
await expect(more).toBeHidden();
await expect.poll(() => cards(page).count()).toBeLessThan(120);
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
expect(new Set(await cards(page).evaluateAll(elements => elements.map(element => element.dataset.imageKey))).size)
.toBe(await cards(page).count());
await page.evaluate(() => window.scrollTo(0, 0));
await expect(page.locator(`article[data-image-key="${firstKey}"]`)).toBeVisible();
await expect.poll(() => cards(page).count()).toBeLessThan(120);
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
await page.locator(`article[data-image-key="${firstKey}"]`)
.getByRole('button', { name: /^Preview / }).click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.getByRole('button', { name: 'Close preview', exact: true }).click();
});
test('virtual gallery keyboard navigation reaches loaded endpoints and announces collection positions', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
const firstKey = await cards(page).first().getAttribute('data-image-key');
const firstName = await cards(page).first().getByRole('button', { name: /^Open / }).getAttribute('aria-label');
await cards(page).first().getByRole('button', { name: /^Open / }).click();
const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent();
const more = page.getByRole('button', { name: 'Load more images', exact: true });
for (const loaded of [80, 120, 160, 200, 202]) {
await more.click();
await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`);
}
await page.evaluate(() => window.scrollTo(0, 0));
const first = page.locator(`article[data-image-key="${firstKey}"]`);
await expect(first).toBeVisible();
await first.getByRole('button', { name: firstName, exact: true }).focus();
await page.keyboard.press('End');
await expect(page.locator('article[data-image-key="1:shared/same.png"]')
.getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused();
await expect(page.locator('article[data-image-key="1:shared/same.png"]')).toHaveAttribute('aria-posinset', '202');
await expect(page.locator('article[data-image-key="1:shared/same.png"]')).toHaveAttribute('aria-setsize', '202');
await expect(page.getByRole('list', { name: 'Image collection', exact: true })).toBeVisible();
await expect(page.locator('article[data-image-key="1:shared/same.png"]')).toHaveAttribute('role', 'listitem');
await page.keyboard.press('Home');
await expect(first.getByRole('button', { name: firstName, exact: true })).toBeFocused();
await expect(first).toHaveAttribute('aria-posinset', '1');
await page.keyboard.press('ArrowRight');
await expect(page.locator('article[aria-posinset="2"]').getByRole('button', { name: /^Open / })).toBeFocused();
await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle);
await expect.poll(() => cards(page).count()).toBeLessThan(120);
});
test('preview uses actual Viewer.js and cleans up when closing or leaving Images', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await cards(page).last().getByRole('button', { name: 'Preview same.png', exact: true }).click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await expect(page.locator('dialog[open] .viewer-canvas img')).toHaveAttribute('src', /root=1/);
await page.getByRole('button', { name: 'Close preview', exact: true }).click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
await cards(page).first().getByRole('button', { name: 'Preview same.png', exact: true }).click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(page.locator('.viewer-container')).toHaveCount(0);
await expect(page.locator('dialog[open]')).toHaveCount(0);
});
test('real video media has a usable card and native playback cleans up on close and navigation', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
const media = await (await request.get('/prompt_manager/images/output?limit=40')).json();
expect(media.images.find(image => image.filename === 'clip.webm').thumbnail_url).toBeNull();
await page.goto(workspace);
await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('video');
await expect(cards(page)).toHaveCount(1);
const card = cards(page).first();
await expect(card).toHaveAttribute('data-image-key', '0:clip.webm');
await expect(card.locator('img')).toHaveCount(0);
await expect(card.getByRole('button', { name: 'Open clip.webm', exact: true })).toBeVisible();
await card.getByRole('button', { name: 'Preview clip.webm', exact: true }).click();
const video = page.locator('dialog[open] video');
await expect(video).toBeVisible();
await expect(video).toHaveJSProperty('controls', true);
await expect.poll(() => video.evaluate(element => element.readyState)).toBeGreaterThanOrEqual(1);
const playing = await video.evaluate(async element => {
window.__previewVideo = element;
element.muted = true;
await element.play();
return !element.paused;
});
expect(playing).toBe(true);
await page.getByRole('button', { name: 'Close preview', exact: true }).click();
await expect(page.locator('dialog video')).toHaveCount(0);
expect(await page.evaluate(() => window.__previewVideo.paused && !window.__previewVideo.isConnected)).toBe(true);
await card.getByRole('button', { name: 'Preview clip.webm', exact: true }).click();
await expect(video).toBeVisible();
await video.evaluate(async element => {
window.__previewVideo = element;
element.muted = true;
await element.play();
});
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(page.locator('video')).toHaveCount(0);
expect(await page.evaluate(() => window.__previewVideo.paused && !window.__previewVideo.isConnected)).toBe(true);
});
test('failed image requests offer retry while stale results cannot replace a newer route', async ({ page }) => {
await page.route('**/prompt_manager/images/output?*', route => route.fulfill({
status: 503, json: { success: false, error: 'Images unavailable' },
}), { times: 1 });
await page.goto(workspace);
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
await page.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(cards(page)).toHaveCount(2);
let release;
const pending = new Promise(resolve => { release = resolve; });
let arrived;
const received = new Promise(resolve => { arrived = resolve; });
await page.route('**/prompt_manager/images/output?*', async route => {
const response = await route.fetch();
arrived();
await pending;
await route.fulfill({ response }).catch(() => {});
});
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('nothing');
await received;
await page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Overview', exact: true }).click();
release();
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(cards(page)).toHaveCount(0);
await expect(details(page)).toHaveCount(0);
});
test('source and folder discovery failure stays visible and Refresh restores usable filters', async ({ page }) => {
await page.route('**/prompt_manager/gallery/subfolders?*', route => route.fulfill({
status: 503, json: { success: false, error: 'Sources unavailable' },
}), { times: 1 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await expect(page.getByRole('alert')).toHaveText('Sources and folders could not be loaded. Refresh to retry.');
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect(page.getByRole('alert')).toHaveCount(0);
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('1');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption('shared');
await expect(cards(page)).toHaveCount(1);
});
test('mobile Images keeps filters, layouts and selected details within the viewport', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
for (const layout of ['List', 'Masonry', 'Grid']) {
await page.getByRole('button', { name: layout, exact: true }).click();
await cards(page).last().getByRole('button', { name: 'Open same.png', exact: true }).click();
await expect(details(page)).toContainText(/input/i);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
}
await expect(page.getByRole('searchbox', { name: 'Search filenames', exact: true })).toBeVisible();
});
test('a delayed search cannot replace results for a newer filename query', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(5);
let release;
let arrived;
let complete;
const pending = new Promise(resolve => { release = resolve; });
const received = new Promise(resolve => { arrived = resolve; });
const completed = new Promise(resolve => { complete = resolve; });
let delayed = false;
await page.route('**/prompt_manager/images/output?*', async route => {
if (delayed) return route.continue();
delayed = true;
const response = await route.fetch();
arrived();
await pending;
await route.fulfill({ response }).catch(() => {});
complete();
});
const search = page.getByRole('searchbox', { name: 'Search filenames', exact: true });
await search.fill('same.png');
await received;
await search.fill('0001');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0001.png');
release();
await completed;
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0001.png');
});
test('selected layout stays blue with readable text on hover in either theme', async ({ page }) => {
await page.goto(workspace);
const layout = page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true });
for (const theme of ['light', 'dark']) {
await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme);
await layout.click();
await layout.hover();
await expect(layout).toHaveCSS('background-color', 'rgb(8, 119, 187)');
await expect(layout).toHaveCSS('color', 'rgb(255, 255, 255)');
}
});
test('refresh releases the previous inspector video source', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
await page.goto(workspace);
const card = cards(page).filter({ hasText: 'clip.webm' });
await card.getByRole('button', { name: 'Open clip.webm', exact: true }).click();
await expect(details(page).locator('video')).toBeVisible();
await details(page).locator('video').evaluate(element => { window.__oldInspectorVideo = element; });
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect.poll(() => page.evaluate(() => !window.__oldInspectorVideo.isConnected)).toBe(true);
expect(await page.evaluate(() => window.__oldInspectorVideo.getAttribute('src'))).toBeNull();
});
test('Refresh rescans cached files and preserves the collection when rescan fails', async ({ page, request }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
await page.route('**/prompt_manager/gallery/rescan', route => route.fulfill({
status: 503, json: { success: false, error: 'Rescan unavailable' },
}), { times: 1 });
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('could not be refreshed');
await expect(cards(page)).toHaveCount(2);
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await expect(cards(page)).toHaveCount(3);
await expect(cards(page).filter({ hasText: 'new-output.png' })).toHaveCount(1);
await expect(page.getByRole('alert')).toBeHidden();
});
test('folder discovery offers ancestors that filter nested images', async ({ page, request }) => {
await expect(await request.post('/__test__/add-nested-image')).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(3);
const folder = page.getByRole('combobox', { name: 'Folder', exact: true });
await expect(folder.locator('option[value="project"]')).toHaveCount(1);
await folder.selectOption('project');
await expect(cards(page)).toHaveCount(1);
await expect(cards(page)).toContainText('new-output.png');
});
test('leaving during Refresh cancels it without replacing the next workspace', async ({ page }) => {
let release;
const pending = new Promise(resolve => { release = resolve; });
await page.route('**/prompt_manager/gallery/rescan', async route => {
await pending;
await route.fulfill({ json: { success: true } }).catch(() => {});
});
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const requested = page.waitForRequest('**/prompt_manager/gallery/rescan');
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await requested;
await page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Overview', exact: true }).click();
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('Images could not be refreshed. Try Refresh again.', { exact: true })).toHaveCount(0);
});
+11 -11
View File
@@ -36,7 +36,7 @@ test('workspace starts with real persisted statistics without mutating data', as
page.on('request', request => {
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) mutations.push(request.url());
});
await page.goto(workspace);
await page.goto(`${workspace}#/overview`);
await expect(page).toHaveTitle(/Prompt Manager/);
await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })).toBeVisible();
const main = page.locator('#workspace-main');
@@ -67,7 +67,7 @@ test('all shell destinations deep-link, reload and expose working existing appli
});
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
await page.goto(workspace);
await page.goto(`${workspace}#/overview`);
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
const images = navigation.getByRole('link', { name: 'Images', exact: true });
await images.focus();
@@ -80,8 +80,8 @@ test('keyboard navigation, browser history and unknown routes remain usable', as
await page.goForward();
await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
await page.goto(`${workspace}#/not-a-page`);
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await expect(navigation.getByRole('link', { name: 'Overview', exact: true }))
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(navigation.getByRole('link', { name: 'Images', exact: true }))
.toHaveAttribute('aria-current', 'page');
});
@@ -97,7 +97,7 @@ test('skip to content focuses the current view without changing its route', asyn
});
test('returning to overview refreshes persisted counts without duplicating navigation', async ({ page, request }) => {
await page.goto(workspace);
await page.goto(`${workspace}#/overview`);
const prompts = statistic(page, 'Prompts');
await expect(prompts).toHaveText('2');
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
@@ -111,7 +111,7 @@ test('returning to overview refreshes persisted counts without duplicating navig
test('appearance follows ComfyUI and persists an explicit light or dark preference', async ({ page }) => {
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ json: 'light' }));
await page.goto(workspace);
await page.goto(`${workspace}#/overview`);
const theme = page.locator('[data-ca-theme]').first();
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
const appearance = page.getByRole('combobox', { name: 'Appearance', exact: true });
@@ -129,7 +129,7 @@ test('appearance follows ComfyUI and persists an explicit light or dark preferen
test('unavailable host palette falls back to system appearance and tracks system changes', async ({ page }) => {
await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ status: 503, json: {} }));
await page.emulateMedia({ colorScheme: 'light' });
await page.goto(workspace);
await page.goto(`${workspace}#/overview`);
const theme = page.locator('[data-ca-theme]').first();
await expect(theme).toHaveAttribute('data-ca-theme', 'light');
await page.emulateMedia({ colorScheme: 'dark' });
@@ -143,7 +143,7 @@ test('overview reports loading, offers retry after failure and renders real empt
await pending;
await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } });
});
await page.goto(workspace);
await page.goto(`${workspace}#/overview`);
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
release();
await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible();
@@ -160,7 +160,7 @@ test('overview reports loading, offers retry after failure and renders real empt
test('small screens keep navigation and appearance accessible without horizontal page overflow', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto(workspace);
await page.goto(`${workspace}#/overview`);
const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true });
for (const [, label] of routes) {
await navigation.getByRole('link', { name: label, exact: true }).click();
@@ -182,7 +182,7 @@ test('leaving a loading overview prevents its delayed result from replacing the
} }).catch(() => {});
completed();
});
await page.goto(workspace);
await page.goto(`${workspace}#/overview`);
await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible();
await page.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Images', exact: true }).click();
@@ -199,7 +199,7 @@ test('cached-page restoration remounts one working shell and refreshes its data'
page.on('request', request => {
if (new URL(request.url()).pathname === '/prompt_manager/stats') reads++;
});
await page.goto(workspace);
await page.goto(`${workspace}#/overview`);
await expect(statistic(page, 'Prompts')).toHaveText('2');
for (let restore = 0; restore < 2; restore++) {
await page.evaluate(() => {
+16 -2
View File
@@ -13,16 +13,30 @@ test('workspace serves real library data beside either ComfyUI renderer', async
const errors = [];
workspace.on('pageerror', error => errors.push(error.message));
try {
await workspace.goto('/prompt_manager/workspace');
await workspace.goto('/prompt_manager/workspace#/overview');
await expect(workspace.locator('#workspace-main h1')).toHaveText('Overview');
await expect(workspace.locator('dt').filter({ hasText: /^Prompts$/ }).locator('+ dd'))
.toHaveText(stats.total_prompts.toLocaleString());
await workspace.getByRole('combobox', { name: 'Appearance' }).selectOption('light');
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
const imageResponse = workspace.waitForResponse(response => {
const url = new URL(response.url());
return url.pathname === '/prompt_manager/images/output' && url.searchParams.get('mode') === 'workspace';
});
await workspace.getByRole('navigation', { name: 'Workspace', exact: true })
.getByRole('link', { name: 'Images', exact: true }).click();
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
await expect(workspace.locator('#workspace-main a')).toHaveAttribute('href', '/prompt_manager/gallery');
const images = await imageResponse;
expect(images.ok()).toBe(true);
const collection = await images.json();
expect(collection.success).toBe(true);
await expect(workspace.getByRole('list', { name: 'Image collection', exact: true })).toBeAttached();
if (collection.images.length) {
await expect(workspace.locator('article[data-image-key]').first()).toBeVisible();
} else {
await expect(workspace.getByText('No matching images or media. Try changing the filters.', { exact: true })).toBeVisible();
}
await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveAttribute('href', '/prompt_manager/gallery');
await workspace.reload();
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
+14
View File
@@ -0,0 +1,14 @@
# Browser media fixtures
`tiny.webm` is an original, silent, one-second VP8 color test pattern generated
for this repository. It contains no user media and is dedicated to the public
domain under CC0-1.0.
Reproduce with FFmpeg:
```sh
ffmpeg -f lavfi -i testsrc=size=32x32:rate=4:duration=1 \
-c:v libvpx -pix_fmt yuv420p -an tiny.webm
```
Browser tests use the committed file; FFmpeg is not required to run them.
BIN
View File
Binary file not shown.
+161
View File
@@ -0,0 +1,161 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/images-state.mjs');
const image = (path, root_index = 0) => ({path, root_index, id:'same-hash'});
const page = (images, total = images.length, next_cursor = null) => ({images, total, next_cursor, has_more:next_cursor !== null});
const deferred = () => { let resolve, reject; const promise = new Promise((a,b) => {resolve=a;reject=b;}); return {promise,resolve,reject}; };
test('stream serializes allowed filters, appends distinct rooted paths, and emits immutable snapshots', async () => {
const { createImageStream, imageKey } = await source;
const calls = [], states = [];
const pages = [page([image('a'),image('a',1)],3,'next'), page([image('a',1),image('b')],3)];
const stream = createImageStream({api:{get:async (url, options) => { calls.push([url, options]);return pages.shift(); }},onChange:s => states.push(s),batchSize:2});
assert.equal(await stream.reset({q:'a & b',root:0,media:'image',sort:'oldest',subfolder:'nested/one',ignored:'unsafe'}), 2);
const query = new URL(calls[0][0],'http://local').searchParams;
assert.equal(query.get('q'),'a & b'); assert.equal(query.get('root'),'0');
assert.equal(query.get('subfolder'),'nested/one'); assert.equal(query.get('mode'),'workspace');
assert.equal(query.get('limit'),'2'); assert.equal(query.has('cursor'),false); assert.equal(query.has('ignored'),false);
assert.ok(calls[0][1].signal instanceof AbortSignal);
assert.equal(imageKey(image('a',0)),'0:a'); assert.notEqual(imageKey(image('a',0)), imageKey(image('a',1)));
const first = stream.state;
assert.ok(Object.isFrozen(first)); assert.ok(Object.isFrozen(first.items)); assert.ok(Object.isFrozen(first.items[0]));
const more = stream.loadMore();
assert.strictEqual(stream.state.items,first.items);
assert.equal(await more,1);
assert.equal(new URL(calls[1][0],'http://local').searchParams.get('cursor'),'next');
assert.deepEqual(stream.state.items.map(imageKey),['0:a','1:a','0:b']);
assert.equal(stream.state.loaded,3); assert.equal(stream.state.total,3); assert.equal(stream.state.hasMore,false);
assert.equal(await stream.loadMore(),0); assert.equal(calls.length,2);
assert.equal(first.items.length,2); assert.equal(states.at(-1),stream.state);
});
test('reset aborts pending requests and ignores stale success and failure', async () => {
const { createImageStream } = await source;
const requests=[]; const states=[];
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},onChange:s=>states.push(s)});
const old=stream.reset({q:'old'}); const fresh=stream.reset({q:'fresh'});
assert.equal(requests[0].signal.aborted,true);
requests[1].resolve(page([image('fresh')]));await fresh;
requests[0].resolve(page([image('old')])); assert.equal(await old,0);
assert.equal(stream.state.items[0].path,'fresh');
const failing=stream.reset({q:'failure'}); const newest=stream.reset();
requests[2].reject(new Error('stale'));requests[3].resolve(page([]));
await Promise.all([failing,newest]);assert.equal(stream.state.error,null);
assert.equal(stream.state.loading,false);
});
test('concurrent loads do not duplicate requests, and failures retain data and cursor for retry', async () => {
const {createImageStream}=await source; const pending=deferred(); const calls=[];
const responses=[page([image('a')],2,'next'),pending.promise,page([image('b')],2)];
const stream=createImageStream({api:{get:async url=>{calls.push(url);return responses.shift();}}});
await stream.reset();const more=stream.loadMore();assert.equal(await stream.loadMore(),0);assert.equal(calls.length,2);
pending.reject(new Error('offline'));assert.equal(await more,0);
assert.equal(stream.state.items.length,1);assert.equal(stream.state.cursor,'next');assert.equal(stream.state.error.message,'offline');
assert.equal(stream.state.hasMore,true);assert.equal(await stream.loadMore(),1);assert.equal(stream.state.error,null);
assert.equal(calls[1],calls[2]);
});
test('malformed pages fail atomically and allow retry without corrupting the stream', async () => {
const {createImageStream}=await source;
for(const invalid of [null,{},page('bad'),page([{}]),page([image('')]),page([image('x',-1)]),page([], -1),{...page([]),has_more:'yes'},page([],2,''),{...page([],2,'next'),has_more:false}]) {
const replies=[page([image('existing')],2,'next'),invalid,page([image('fixed')],2)];
const stream=createImageStream({api:{get:async()=>replies.shift()}});await stream.reset();
await stream.loadMore();assert.match(stream.state.error.message,/image|page|cursor/i);
assert.equal(stream.state.items[0].path,'existing'); assert.equal(stream.state.cursor,'next');
await stream.loadMore();assert.equal(stream.state.items.length,2);assert.equal(stream.state.error,null);
}
});
test('duplicate pages can advance cursors, but repeated cursors fail instead of looping', async () => {
const {createImageStream}=await source;
const replies=[page([image('a')],2,'one'),page([image('a')],2,'two'),page([image('b')],2,'two')];
const stream=createImageStream({api:{get:async()=>replies.shift()}});await stream.reset();
assert.equal(await stream.loadMore(),0);assert.equal(stream.state.cursor,'two');assert.equal(stream.state.hasMore,true);
await stream.loadMore();assert.match(stream.state.error.message,/cursor/i);assert.equal(stream.state.items.length,1);
});
test('dispose aborts the active request and suppresses notifications and further requests', async () => {
const {createImageStream}=await source;const d=deferred();let signal,count=0,notifications=0;
const stream=createImageStream({api:{get:(url,opts)=>{signal=opts.signal;count++;return d.promise;}},onChange:()=>notifications++});
const request=stream.reset();stream.dispose();stream.dispose();assert.equal(signal.aborted,true);
const before=notifications;d.reject(Object.assign(new Error('aborted'),{name:'AbortError'}));await request;
assert.equal(notifications,before);assert.equal(await stream.reset(),0);assert.equal(await stream.loadMore(),0);assert.equal(count,1);
});
test('stream validates constructor and filter input before requesting', async () => {
const {createImageStream}=await source;
assert.throws(()=>createImageStream({api:{}}),/api/i);
for(const batchSize of [0,-1,1.5,Infinity,201]) assert.throws(()=>createImageStream({api:{get(){}},batchSize}),/batch/i);
const stream=createImageStream({api:{get:async()=>page([])}});
for(const filters of [{root:-1},{root:'oops'},{media:'executable'},{sort:'random'},{q:{}},{subfolder:[]}]) await assert.rejects(stream.reset(filters),/filter/i);
await stream.reset({root:'all',media:'all',sort:'newest'});assert.equal(stream.state.hasMore,false);
});
test('the metadata stream retains earlier images beyond one rendered window and deduplicates within a batch', async () => {
const {createImageStream}=await source;
let request=0;
const stream=createImageStream({api:{get:async()=>{
const start=request++*40;
return page(Array.from({length:40},(_,index)=>image(`image-${start+index}`)),320,start+40<320?String(start+40):null);
}}});
await stream.reset();
while(stream.state.hasMore) await stream.loadMore();
assert.equal(stream.state.loaded,320);assert.equal(stream.state.items[0].path,'image-0');
assert.equal(stream.state.items.at(-1).path,'image-319');
const duplicate=createImageStream({api:{get:async()=>page([image('a'),image('a')],1)}});
assert.equal(await duplicate.reset(),1);assert.equal(duplicate.state.items.length,1);
});
test('all-source defaults are omitted and descending name sort remains supported', async () => {
const {createImageStream}=await source;let query;
const stream=createImageStream({api:{get:async url=>{query=new URL(url,'http://local').searchParams;return page([]);}}});
await stream.reset({root:'all',media:'all',sort:'name-desc'});
assert.equal(query.has('root'),false);assert.equal(query.has('media'),false);
assert.equal(query.get('sort'),'name-desc');assert.equal(query.get('limit'),'40');
});
test('restored browsing state resumes its cursor and filters without an initial request', async () => {
const {createImageStream}=await source;
const calls=[];
const cachedItems=[image('existing')];
const cache={filters:{q:'saved search',root:1,sort:'oldest',media:'image',subfolder:'nested'},state:{items:cachedItems,total:3,cursor:'saved-cursor',hasMore:true,loaded:1,loading:true,error:new Error('old error')}};
const stream=createImageStream({api:{get:async url=>{calls.push(url);return page([image('existing'),image('new')],3,'next');}},restore:cache});
assert.equal(calls.length,0);assert.equal(stream.state.items.length,1);
assert.equal(stream.state.loading,false);assert.equal(stream.state.error,null);
assert.ok(Object.isFrozen(stream.state));assert.ok(Object.isFrozen(stream.state.items));assert.ok(Object.isFrozen(stream.state.items[0]));
cachedItems[0].path='external mutation';cachedItems.push(image('external'));
assert.equal(stream.state.items[0].path,'existing');assert.equal(stream.state.items.length,1);
assert.equal(await stream.loadMore(),1);
const query=new URL(calls[0],'http://local').searchParams;
for(const [key,value] of Object.entries({q:'saved search',root:'1',sort:'oldest',media:'image',subfolder:'nested',cursor:'saved-cursor'})) assert.equal(query.get(key),value);
assert.deepEqual(stream.state.items.map(item=>item.path),['existing','new']);
await stream.reset({q:'new search'});
assert.equal(new URL(calls[1],'http://local').searchParams.has('cursor'),false);
assert.equal(new URL(calls[1],'http://local').searchParams.get('q'),'new search');
stream.dispose();assert.equal(await stream.loadMore(),0);
});
test('restoring frozen state reuses immutable metadata and respects a completed collection', async () => {
const {createImageStream}=await source;
const first=createImageStream({api:{get:async()=>page([image('saved')])}});
await first.reset();let requests=0;
const restored=createImageStream({api:{get:async()=>{requests++;return page([]);}},restore:{filters:{},state:first.state}});
assert.strictEqual(restored.state.items,first.state.items);assert.equal(restored.state.loaded,1);
assert.equal(await restored.loadMore(),0);assert.equal(requests,0);
});
test('restored interrupted initial loading can restart from the first request',async()=>{
const {createImageStream}=await source;let called=0;
const stream=createImageStream({api:{get:async url=>{called++;assert.equal(new URL(url,'http://local').searchParams.has('cursor'),false);return page([]);}},
restore:{filters:{},state:{items:[],total:0,cursor:null,hasMore:true,loading:true}}});
assert.equal(called,0);assert.equal(await stream.loadMore(),0);assert.equal(called,1);assert.equal(stream.state.hasMore,false);
});
test('malformed restored state is rejected before making requests',async()=>{
const {createImageStream}=await source;
const base={items:[image('a')],total:2,cursor:'saved',hasMore:true};
const invalid=[null,{}, {filters:{},state:null},
...[{items:'bad'},{items:[{}]},{items:[image('a'),image('a')]},{total:-1},{cursor:''},{cursor:null},{hasMore:'yes'},{hasMore:false}].map(change=>({filters:{},state:{...base,...change}})),
{filters:{root:-1},state:base}, {filters:null,state:base}];
for(const restore of invalid) assert.throws(()=>createImageStream({api:{get(){assert.fail('unexpected request');}},restore}),TypeError);
});
+80
View File
@@ -0,0 +1,80 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/image-layout.mjs');
test('layouts preserve data order, remain within width and never overlap', async () => {
const {layoutImages} = await source;
const images=Array.from({length:200},(_,i)=>({width:i%2?1024:640,height:i%2?640:1024}));
for(const width of [280,760,1400]) for(const mode of ['grid','list','masonry']) {
const {positions,height}=layoutImages(images,width,mode);
assert.equal(positions.length,200);
for(const [i,a] of positions.entries()) {
assert.ok(a.x>=0 && a.y>=0 && a.width>0 && a.height>0);
assert.ok(a.x+a.width<=width+.01);
assert.ok(a.y+a.height<=height+.01);
for(const b of positions.slice(i+1)) assert.ok(a.x+a.width<=b.x+.01 || b.x+b.width<=a.x+.01 || a.y+a.height<=b.y+.01 || b.y+b.height<=a.y+.01);
}
}
});
test('empty, unknown dimensions and narrow containers keep valid geometry',async()=>{
const {layoutImages}=await source;
assert.deepEqual(layoutImages([],0,'grid'),{positions:[],height:0,maxHeight:0});
for(const mode of ['grid','list','masonry']) {
const result=layoutImages([{}, {width:0,height:null}],1,mode);
assert.ok(Number.isFinite(result.height));
}
});
test('virtual window retains visible rows and focused card while bounding rendered media',async()=>{
const {layoutImages,visibleImages}=await source;
const {positions}=layoutImages(Array(5000).fill({}),900,'masonry');
const indexes=visibleImages(positions,10000,900,0);
assert.ok(indexes.length<100);
assert.ok(indexes.includes(0));
assert.ok(indexes.some(i=>positions[i].y>=10000 && positions[i].y<10900));
assert.deepEqual(indexes,[...indexes].sort((a,b)=>a-b));
assert.equal(new Set(indexes).size,indexes.length);
assert.ok(visibleImages(positions,0,800).includes(0));
});
test('large virtual collections inspect a bounded range rather than every loaded position', async () => {
const {layoutImages,visibleImages}=await source;
for(const mode of ['grid','list','masonry']) {
const geometry=layoutImages(Array.from({length:50000},(_,index)=>({width:640,height:index%2?320:1024})),900,mode);
for(let index=1;index<geometry.positions.length;index++) assert.ok(geometry.positions[index].y>=geometry.positions[index-1].y);
let reads=0;
const positions=new Proxy(geometry.positions,{get(target,key,receiver){
if(typeof key==='string' && /^\d+$/.test(key)) reads++;
return Reflect.get(target,key,receiver);
}});
const top=geometry.positions[25000].y;
const indexes=visibleImages(positions,top,900,49999,geometry.maxHeight);
assert.ok(reads<500,`${mode} read ${reads} positions`);
const expected=geometry.positions.flatMap((position,index)=>(index===49999 ||
(position.y+position.height>=top-900 && position.y<=top+1800))?[index]:[]);
assert.deepEqual(indexes,expected);
}
});
test('binary-searched windows handle boundaries, empty collections and focus without duplicates',async()=>{
const {layoutImages,visibleImages}=await source;
assert.deepEqual(visibleImages([],0,800,0),[]);
const {positions}=layoutImages(Array(1000).fill({}),900,'grid');
for(const focused of [-1,0,999,1000]) {
for(const top of [0,10000,1000000]) {
const actual=visibleImages(positions,top,600,focused);
const expected=positions.flatMap((position,index)=>(index===focused ||
(position.y+position.height>=top-600 && position.y<=top+1200))?[index]:[]);
assert.deepEqual(actual,expected);
}
}
assert.deepEqual(visibleImages(positions,1000000,0),[]);
});
test('reported maximum height keeps unusually tall custom layouts inside the virtual window',async()=>{
const {layoutImages,visibleImages}=await source;
const geometry=layoutImages([{}, {}, {}],10000,'grid',20000);
assert.equal(geometry.maxHeight,7572);
assert.deepEqual(visibleImages(geometry.positions,6000,200,-1,geometry.maxHeight),[0]);
});
+6 -6
View File
@@ -8,7 +8,7 @@ test('workspace routes resolve only supported deep links', async () => {
assert.equal(resolveRoute(`#/${route}`).id, route);
}
for (const hash of ['', '#', '#/unknown', '#/images/other', '#/<script>']) {
assert.equal(resolveRoute(hash).id, 'overview');
assert.equal(resolveRoute(hash).id, 'images');
}
});
@@ -35,15 +35,15 @@ test('router normalizes unknown routes, avoids duplicate renders, and disposes i
}};
const seen = [];
const dispose = mountRouter(win, route => seen.push(route.id));
assert.deepEqual(seen, ['overview']);
assert.deepEqual(replacements, [[{keep:1}, '#/overview']]);
win.location.hash = '#/images';
assert.deepEqual(seen, ['images']);
assert.deepEqual(replacements, [[{keep:1}, '#/images']]);
win.location.hash = '#/library';
win.dispatchEvent(new Event('hashchange'));
win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen, ['overview', 'images']);
assert.deepEqual(seen, ['images', 'library']);
dispose();
dispose();
win.location.hash = '#/system';
win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen, ['overview', 'images']);
assert.deepEqual(seen, ['images', 'library']);
});
+274
View File
@@ -0,0 +1,274 @@
"""Opt-in workspace gallery contracts against disposable SQLite and media."""
import os
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
try:
from test_api_images import ImageAPITestCase, make_png
from open_handles import assert_closed
except ImportError:
from tests.test_api_images import ImageAPITestCase, make_png
from tests.open_handles import assert_closed
class TestWorkspaceImages(ImageAPITestCase):
async def tearDownAsync(self):
self.api.db.close_all()
assert_closed(self, self._temp_db.name, self._output_tmp.name)
await super().tearDownAsync()
def media(self, name, mtime=100, root=None):
target = (root or self.output_dir) / name
target.parent.mkdir(parents=True, exist_ok=True)
if target.suffix == ".mp4":
target.write_bytes(b"video")
else:
make_png(target)
os.utime(target, (mtime, mtime))
return target
async def get(self, **params):
response = await self.client.get(
"/prompt_manager/images/output", params={"mode": "workspace", **params}
)
return response, await response.json()
async def test_keyset_survives_insert_and_deterministic_ties(self):
for name, stamp in (("a.png", 300), ("b.png", 200), ("c.png", 200)):
self.media(name, stamp)
response, first = await self.get(limit=2)
self.assertEqual(response.status, 200)
self.assertEqual([i["filename"] for i in first["images"]], ["a.png", "b.png"])
self.assertTrue(first["has_more"])
self.assertNotIn("offset", first)
self.media("new.png", 400)
self.api.invalidate_gallery_cache()
_, second = await self.get(limit=2, cursor=first["next_cursor"])
self.assertEqual([i["filename"] for i in second["images"]], ["c.png"])
self.assertFalse(second["has_more"])
self.assertIsNone(second["next_cursor"])
self.assertEqual(second["total"], 4)
async def test_filters_apply_before_limit_and_sorts_are_complete(self):
self.media("folder/Zebra.png", 200)
self.media("folder/apple.png", 300)
self.media("other/Zebra.mp4", 100)
_, data = await self.get(q="ZEBRA", media="image", limit=1)
self.assertEqual(data["total"], 1)
self.assertEqual(data["images"][0]["filename"], "Zebra.png")
for order, expected in (
("newest", ["apple.png", "Zebra.png", "Zebra.mp4"]),
("oldest", ["Zebra.mp4", "Zebra.png", "apple.png"]),
("name", ["apple.png", "Zebra.mp4", "Zebra.png"]),
("name-desc", ["Zebra.png", "Zebra.mp4", "apple.png"]),
):
with self.subTest(order=order):
_, data = await self.get(sort=order)
self.assertEqual([i["filename"] for i in data["images"]], expected)
_, data = await self.get(subfolder="folder", media="video")
self.assertEqual(data["images"], [])
self.assertIsNone(data["next_cursor"])
async def test_cursor_cannot_be_reused_with_changed_filters_or_tampered(self):
self.media("a.png")
self.media("b.png")
_, first = await self.get(limit=1)
token = first["next_cursor"]
self.assertIsInstance(token, str)
for params in (
{"cursor": token, "q": "a"},
{"cursor": token, "sort": "oldest"},
{"cursor": token + "x"},
{"cursor": "garbage"},
{"cursor": "x" * 4097},
):
response, data = await self.get(**params)
self.assertEqual(response.status, 400, params)
self.assertNotIn(str(self.comfy_dir), str(data))
async def test_invalid_input_is_safe_and_page_limit_is_capped(self):
for params in (
{"root": "-1"},
{"root": "1"},
{"root": "no"},
{"media": "audio"},
{"sort": "size"},
{"q": "x" * 501},
{"limit": "no"},
{"subfolder": "../secret"},
):
response, data = await self.get(**params)
self.assertEqual(response.status, 400, params)
self.assertNotIn(str(self.comfy_dir), str(data))
_, data = await self.get(limit=999999)
from py.api.prompts import MAX_PAGE_LIMIT
self.assertEqual(data["limit"], MAX_PAGE_LIMIT)
async def test_root_identity_and_root_specific_folders(self):
second = self.comfy_dir / "second"
second.mkdir()
self.media("first/a.png")
self.media("nested/b.png", root=second)
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
_, data = await self.get(root=1)
self.assertEqual(data["total"], 1)
self.assertEqual(data["images"][0]["root_index"], 1)
response = await self.client.get(
"/prompt_manager/gallery/subfolders?mode=workspace&root=1"
)
folders = await response.json()
self.assertEqual(folders["subfolders"], ["nested"])
self.assertEqual([r["index"] for r in folders["roots"]], [0, 1])
self.assertNotIn(str(self.comfy_dir), str(folders))
response = await self.client.get(
"/prompt_manager/gallery/subfolders?mode=workspace&root=7"
)
self.assertEqual(response.status, 400)
async def test_workspace_dimensions_are_header_only_and_video_is_unknown(self):
self.media("a.png")
self.media("b.mp4")
self.media("broken.png").write_bytes(b"invalid")
with patch(
"PIL.PngImagePlugin.PngImageFile.load",
side_effect=AssertionError("Do not decode"),
):
_, data = await self.get()
rows = {row["filename"]: row for row in data["images"]}
self.assertEqual((rows["a.png"]["width"], rows["a.png"]["height"]), (4, 4))
self.assertIsNone(rows["b.mp4"]["width"])
self.assertIsNone(rows["broken.png"]["height"])
response = await self.client.get("/prompt_manager/images/output")
self.assertNotIn("width", (await response.json())["images"][0])
async def test_invalid_limit_does_not_echo_private_input(self):
response, data = await self.get(limit=str(self.comfy_dir))
self.assertEqual(response.status, 400)
self.assertNotIn(str(self.comfy_dir), str(data))
async def test_each_sort_can_traverse_ties_and_limit_can_change(self):
for name in ("a.png", "b.png", "sub/b.png", "c.png"):
self.media(name)
for order in ("newest", "oldest", "name", "name-desc"):
with self.subTest(order=order):
_, complete = await self.get(sort=order)
_, first = await self.get(sort=order, limit=1)
_, rest = await self.get(
sort=order, limit=10, cursor=first["next_cursor"]
)
self.assertEqual(first["images"] + rest["images"], complete["images"])
async def test_root_ties_are_distinct_and_cursor_rejects_root_changes(self):
second = self.comfy_dir / "second"
second.mkdir()
self.media("same.png")
self.media("same.png", root=second)
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
_, first = await self.get(limit=1)
_, second_page = await self.get(limit=1, cursor=first["next_cursor"])
self.assertEqual(
[first["images"][0]["root_index"], second_page["images"][0]["root_index"]],
[0, 1],
)
response, _ = await self.get(root=0, cursor=first["next_cursor"])
self.assertEqual(response.status, 400)
self.api._get_all_output_dirs = lambda: [second, self.output_dir]
response, _ = await self.get(cursor=first["next_cursor"])
self.assertEqual(response.status, 400)
async def test_empty_roots_are_successful_empty_workspace(self):
self.api._get_all_output_dirs = lambda: []
response, data = await self.get()
self.assertEqual(response.status, 200)
self.assertEqual(data["images"], [])
self.assertEqual(data["total"], 0)
self.assertIsNone(data["next_cursor"])
async def test_deep_unicode_paths_emit_bounded_usable_cursor(self):
folder = self.output_dir.joinpath(*(["嵐" * 80] * 12))
entries = [
(folder / name, 100, self.output_dir, 0) for name in ("a.png", "b.png")
]
def format_page(page):
return [{"filename": entry[0].name} for entry in page]
with (
patch.object(
self.api,
"_get_gallery_files",
AsyncMock(return_value=[(entry[0], entry[1]) for entry in entries]),
),
patch.object(self.api, "_format_workspace_page", side_effect=format_page),
):
for order in ("newest", "oldest", "name", "name-desc"):
with self.subTest(sort=order):
response, first = await self.get(sort=order, limit=1)
self.assertEqual(response.status, 200)
self.assertLessEqual(len(first["next_cursor"]), 4096)
response, second = await self.get(
sort=order, cursor=first["next_cursor"]
)
self.assertEqual(response.status, 200)
self.assertEqual(len(second["images"]), 1)
self.assertNotEqual(first["images"], second["images"])
async def test_filter_and_sort_selection_runs_in_executor(self):
self.media("a.png")
import threading
event_thread = threading.get_ident()
from py.api import images as images_module
original = images_module._workspace_key
threads = []
def observe(entry, order):
threads.append(threading.get_ident())
return original(entry, order)
with patch.object(images_module, "_workspace_key", side_effect=observe):
response, _ = await self.get()
self.assertEqual(response.status, 200)
self.assertTrue(threads)
self.assertTrue(all(thread != event_thread for thread in threads))
async def test_workspace_reuses_scan_cache_and_legacy_contract(self):
self.media("a.png")
with patch.object(
self.api,
"_scan_gallery_files_sync",
wraps=self.api._scan_gallery_files_sync,
) as scan:
await self.get()
await self.get(q="a")
self.assertEqual(scan.call_count, 1)
response = await self.client.get(
"/prompt_manager/images/output?limit=1&offset=0"
)
data = await response.json()
self.assertEqual(data["offset"], 0)
self.assertNotIn("next_cursor", data)
response = await self.client.get("/prompt_manager/gallery/subfolders")
self.assertNotIn("roots", await response.json())
class TestWorkspaceCursorEncoding(unittest.TestCase):
def test_ntfs_unicode_basename_cursor_is_bounded_and_round_trips(self):
from py.api.images import (
_workspace_cursor,
_workspace_cursor_key,
_workspace_key,
)
root = Path("gallery")
entry = (root / ("\u0390" * 240 + ".png"), 100, root, 0)
for order in ("newest", "oldest", "name", "name-desc"):
with self.subTest(sort=order):
key = _workspace_key(entry, order)
token = _workspace_cursor(key, "scope")
self.assertLessEqual(len(token), 4096)
self.assertEqual(_workspace_cursor_key(token, "scope", order), key)
+18
View File
@@ -154,5 +154,23 @@
"branches": 93,
"functions": 98,
"statements": 98
},
"workspace/image-layout.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/images-state.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/images.mjs": {
"lines": 95,
"branches": 87,
"functions": 98,
"statements": 95
}
}
+42
View File
@@ -0,0 +1,42 @@
export function layoutImages(images, width, mode, minWidth = 240) {
const gap = 16;
const columns = mode === 'list' ? 1 : Math.max(1, Math.floor((width + gap) / (minWidth + gap)));
const cardWidth = Math.max(1, (width - gap * (columns - 1)) / columns);
const bottoms = Array(columns).fill(0);
let maxHeight = 0;
const positions = images.map((image, index) => {
const column = mode === 'masonry' ? bottoms.indexOf(Math.min(...bottoms)) : index % columns;
const ratio = image.width > 0 && image.height > 0 ? image.width / image.height : 4 / 3;
const height = mode === 'list' ? 106 :
(mode === 'masonry' ? Math.max(130, Math.min(440, cardWidth / ratio)) : cardWidth * .75) + 72;
maxHeight = Math.max(maxHeight, height);
const y = mode === 'grid' ? Math.floor(index / columns) * (height + gap) : bottoms[column];
bottoms[column] = y + height + gap;
return { x: column * (cardWidth + gap), y, width: cardWidth, height };
});
return { positions, height: Math.max(0, ...bottoms) - (images.length ? gap : 0), maxHeight };
}
export function visibleImages(positions, top, viewportHeight, focusedIndex = -1, maxHeight = 1024) {
const buffer = Math.max(600, viewportHeight);
const firstTop = top - buffer - maxHeight;
const lastTop = top + viewportHeight + buffer;
let low = 0, high = positions.length;
// Every layout emits monotonically increasing tops, including shortest-column masonry.
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (positions[middle].y < firstTop) low = middle + 1;
else high = middle;
}
const indexes = [];
for (let index = low; index < positions.length; index++) {
const position = positions[index];
if (position.y > lastTop) break;
if (index === focusedIndex || position.y + position.height >= top - buffer) indexes.push(index);
}
if (focusedIndex >= 0 && focusedIndex < positions.length && !indexes.includes(focusedIndex)) {
indexes.push(focusedIndex);
indexes.sort((a, b) => a - b);
}
return indexes;
}
+138
View File
@@ -0,0 +1,138 @@
/** Identity is the source and relative path; content hashes may be shared by files. */
export function imageKey(image) {
return `${image.root_index}:${image.path}`;
}
function queryFor(filters, batchSize) {
const query = new URLSearchParams({ mode: 'workspace', limit: String(batchSize) });
for (const key of ['q', 'subfolder']) {
const value = filters[key];
if (value !== undefined && typeof value !== 'string') throw new TypeError('Invalid image filter');
if (value) query.set(key, value);
}
const root = filters.root;
if (root !== undefined && root !== 'all' && root !== '') {
if (!/^(0|[1-9]\d*)$/.test(String(root)) || !Number.isSafeInteger(Number(root))) {
throw new TypeError('Invalid root filter');
}
query.set('root', String(root));
}
const media = filters.media ?? 'all';
if (!['all', 'image', 'video'].includes(media)) throw new TypeError('Invalid media filter');
if (media !== 'all') query.set('media', media);
const sort = filters.sort ?? 'newest';
if (!['newest', 'oldest', 'name', 'name-desc'].includes(sort)) throw new TypeError('Invalid sort filter');
query.set('sort', sort);
return query.toString();
}
function validatePage(page, cursor) {
if (!page || !Array.isArray(page.images) || !Number.isSafeInteger(page.total) || page.total < 0 ||
typeof page.has_more !== 'boolean' ||
!(page.next_cursor === null || (typeof page.next_cursor === 'string' && page.next_cursor.length > 0)) ||
page.has_more !== (page.next_cursor !== null)) {
throw new TypeError('Invalid image page');
}
if (page.has_more && page.next_cursor === cursor) throw new TypeError('Image cursor did not advance');
for (const item of page.images) {
if (!item || typeof item.path !== 'string' || !item.path ||
!Number.isSafeInteger(item.root_index) || item.root_index < 0) {
throw new TypeError('Invalid image identity');
}
}
}
/** Keep metadata for backward scrolling; the view bounds rendered media independently. */
export function createImageStream({ api, onChange = () => {}, batchSize = 40, restore }) {
if (typeof api?.get !== 'function') throw new TypeError('An API client is required');
if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 200) {
throw new TypeError('Invalid image batch size');
}
let state = Object.freeze({ items: Object.freeze([]), total: 0, cursor: null,
hasMore: true, loading: false, error: null, loaded: 0 });
let query = queryFor({}, batchSize);
let controller = null;
let disposed = false;
let seen = new Set();
if (restore !== undefined) {
if (!restore || !restore.filters || typeof restore.filters !== 'object' || Array.isArray(restore.filters) || !restore.state) {
throw new TypeError('Invalid restored image state');
}
query = queryFor(restore.filters, batchSize);
const saved = restore.state;
const pristine = Array.isArray(saved.items) && saved.items.length === 0 && saved.total === 0 &&
saved.cursor === null && saved.hasMore === true;
validatePage({ images: saved.items, total: saved.total, next_cursor: saved.cursor,
has_more: pristine ? false : saved.hasMore });
seen = new Set(saved.items.map(imageKey));
if (seen.size !== saved.items.length) throw new TypeError('Duplicate restored image identity');
const items = Object.isFrozen(saved.items) && saved.items.every(Object.isFrozen) ? saved.items :
Object.freeze(saved.items.map(item => Object.freeze({ ...item })));
state = Object.freeze({ items, total: saved.total, cursor: saved.cursor, hasMore: saved.hasMore,
loading: false, error: null, loaded: items.length });
}
function publish(update) {
state = Object.freeze({ ...state, ...update });
onChange(state);
}
async function loadMore() {
if (disposed || state.loading || !state.hasMore) return 0;
const request = new AbortController();
controller = request;
const cursor = state.cursor;
publish({ loading: true, error: null });
const parameters = new URLSearchParams(query);
if (cursor !== null) parameters.set('cursor', cursor);
try {
const page = await api.get(`/prompt_manager/images/output?${parameters}`, { signal: request.signal });
if (disposed || request !== controller) return 0;
validatePage(page, cursor);
const incoming = [];
const batchKeys = new Set();
for (const item of page.images) {
const key = imageKey(item);
if (seen.has(key) || batchKeys.has(key)) continue;
batchKeys.add(key);
incoming.push(Object.freeze({ ...item }));
}
for (const key of batchKeys) seen.add(key);
const items = incoming.length ? Object.freeze([...state.items, ...incoming]) : state.items;
controller = null;
publish({ items, total: page.total, cursor: page.next_cursor, hasMore: page.has_more,
loading: false, error: null, loaded: items.length });
return incoming.length;
} catch (error) {
if (!disposed && request === controller) {
controller = null;
publish({ loading: false, error });
}
return 0;
}
}
async function reset(filters = {}) {
if (disposed) return 0;
const nextQuery = queryFor(filters, batchSize);
controller?.abort();
controller = null;
query = nextQuery;
seen = new Set();
publish({ items: Object.freeze([]), total: 0, cursor: null, hasMore: true,
loading: false, error: null, loaded: 0 });
return loadMore();
}
return {
get state() { return state; },
reset,
loadMore,
dispose() {
if (disposed) return;
disposed = true;
controller?.abort();
controller = null;
},
};
}
+59
View File
@@ -0,0 +1,59 @@
.sidebar { position: sticky; inset-block-start: 0; block-size: 100dvh; align-self: start; }
.brand-mark { position: relative; }
.brand-mark small { position: absolute; inset-inline-end: 4px; inset-block-end: 2px; font-size: 9px; color: white; }
main.images-main { max-inline-size: none; padding: 28px; }
.image-filters { display: flex; flex-wrap: wrap; gap: 12px; padding: 16px; border: 1px solid var(--ca-line); border-radius: 9px; background: var(--ca-surface); }
.image-filters .ca-field { display: flex; flex: 1 1 140px; flex-direction: column; align-items: stretch; gap: 5px; }
.image-filters .ca-field:first-child { flex: 3 1 260px; }
.image-filters .ca-field-label { font-size: 11px; }
.image-filters :where(input, select) { inline-size: 100%; min-block-size: 36px; }
.image-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-block: 18px; }
.image-toolbar p { margin: 0; flex: 1 1 160px; font-size: 12px; }
.image-layouts { display: inline-flex; gap: 3px; }
.ca-tools .image-layouts button[aria-pressed="true"] { background: var(--ca-accent); color: white; border-color: var(--ca-accent); }
.image-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 290px; gap: 22px; align-items: start; }
.image-collection { min-inline-size: 0; }
.image-grid { position: relative; inline-size: 100%; }
.image-card { position: absolute; border: 1px solid var(--ca-line); border-radius: 9px; background: var(--ca-surface); display: grid; grid-template-rows: minmax(0, 1fr) 72px; }
.image-card.selected { border-color: var(--ca-focus); box-shadow: 0 0 0 1px var(--ca-focus); }
.image-card .image-open { display: block; padding: 0; inline-size: 100%; block-size: 100%; min-block-size: 0; border: 0; border-radius: 8px 8px 0 0; background: var(--ca-field); }
.image-open :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; }
.image-card-footer { position: relative; min-inline-size: 0; padding: 12px 45px 10px 12px; }
.image-card-footer strong { display: block; font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-card-footer small { display: block; color: var(--ca-muted); font-size: 10px; margin-block-start: 4px; }
.image-card-footer button { position: absolute; inset-inline-end: 8px; inset-block-start: 16px; }
.image-grid[data-layout="list"] .image-card { grid-template-columns: 125px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
.image-grid[data-layout="list"] .image-open { border-radius: 8px 0 0 8px; }
.image-grid[data-layout="list"] .image-card-footer { align-self: center; }
.image-details { position: sticky; inset-block-start: 18px; padding: 18px; max-block-size: calc(100dvh - 36px); overflow: auto; scrollbar-gutter: stable; }
.image-details h2 { font-size: 15px; overflow-wrap: anywhere; margin-block: 8px 16px; }
.image-details > :where(img, video) { display: block; inline-size: 100%; aspect-ratio: 4 / 3; object-fit: contain; background: var(--ca-field); border-radius: 6px; }
.image-facts { font-size: 11px; margin-block: 18px; }
.image-facts > div { padding-block: 8px; border-block-end: 1px solid var(--ca-line); }
.image-facts dt { color: var(--ca-muted); margin-block-end: 4px; }
.image-facts dd { margin: 0; overflow-wrap: anywhere; }
.image-details .workspace-link { display: flex; font-size: 11px; }
.image-continuation { text-align: center; padding-block: 24px; font-size: 12px; }
.image-continuation [role="alert"] { color: var(--ca-error-text); }
.image-preview { background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-border); border-radius: 10px; inline-size: min(1200px, calc(100% - 32px)); max-inline-size: none; padding: 0; }
.image-preview::backdrop { background: #000b; }
.image-preview header { padding: 14px 18px; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.image-preview h2 { font-size: 15px; margin: 0; overflow-wrap: anywhere; }
.image-preview-stage { position: relative; block-size: min(75dvh, 850px); background: var(--ca-field); }
.image-preview-stage > video { display: block; inline-size: 100%; block-size: 100%; object-fit: contain; }
.image-preview-stage .viewer-container { inline-size: 100% !important; block-size: 100% !important; }
@media (max-width: 1150px) {
.image-workspace { grid-template-columns: minmax(0, 1fr) 250px; gap: 16px; }
main.images-main { padding: 24px 18px; }
}
@media (max-width: 780px) {
.sidebar { position: static; block-size: auto; }
.image-workspace { grid-template-columns: minmax(0, 1fr); }
.image-details { position: static; max-block-size: none; }
.image-details > :where(img, video) { max-block-size: 280px; }
.image-filters { gap: 10px; padding: 12px; }
.image-filters .ca-field { flex-basis: 120px; }
.image-layouts { flex-wrap: wrap; }
}
.image-continuation button[aria-disabled="true"] { opacity: .55; cursor: wait; }
.options-status { font-size: 12px; padding-inline: 4px; }
+361
View File
@@ -0,0 +1,361 @@
import { createScope, createButton, createField, createSelect } from '../shared/toolkit/core.js';
import { createImageStream, imageKey } from './images-state.mjs';
import { layoutImages, visibleImages } from './image-layout.mjs';
function el(tag, text, className) {
const element = document.createElement(tag);
if (text !== undefined) element.textContent = text;
if (className) element.className = className;
return element;
}
function formatBytes(value) {
return value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;
}
let lastVisit = null;
export function mountImages(main, api, parentScope) {
const scope = createScope();
parentScope.cleanup(() => scope.dispose());
const { resolveUrl } = window.ApiClient;
const visit = lastVisit;
let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer, viewer;
let state, geometry = { positions: [], height: 0, maxHeight: 0 }, optionsRequest;
const rendered = new Map();
const itemIndexes = new Map();
const filters = { root: 'all', q: '', subfolder: '', media: 'all', sort: 'newest', ...visit?.filters };
scope.cleanup(() => {
lastVisit = state ? { filters: {...filters}, state, layout, selection, scrollY: window.scrollY } : null;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
scope.cleanup(() => main.classList.remove('images-main'));
main.append(el('p', 'Browse your images, explore the details, keep your creative context.', 'intro'));
const filtersBar = el('div', undefined, 'image-filters');
const search = el('input');
search.value = filters.q;
search.type = 'search'; search.placeholder = 'Find an image by filename or folder…'; search.maxLength = 256;
const source = createSelect([['all', 'All sources']], '');
const folder = createSelect([['', 'All folders']], '');
const media = createSelect([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']], '');
const sort = createSelect([['newest', 'Newest first'], ['oldest', 'Oldest first'], ['name', 'Filename A–Z'], ['name-desc', 'Filename Z–A']], '');
media.value = filters.media; sort.value = filters.sort;
filtersBar.append(createField('Search filenames', search, 'image-search'), createField('Source', source, 'image-source'),
createField('Folder', folder, 'image-folder'), createField('Media', media, 'image-media'), createField('Sort', sort, 'image-sort'));
const optionsStatus = el('p', '', 'options-status muted');
optionsStatus.hidden = true; optionsStatus.setAttribute('role', 'alert');
main.append(filtersBar, optionsStatus);
const toolbar = el('div', undefined, 'image-toolbar');
const count = el('p', 'Loading images…', 'muted');
const layouts = el('div', undefined, 'image-layouts');
layouts.setAttribute('role', 'group'); layouts.setAttribute('aria-label', 'Image layout');
for (const mode of ['grid', 'list', 'masonry']) {
const button = createButton(mode[0].toUpperCase() + mode.slice(1));
button.setAttribute('aria-pressed', String(mode === layout));
scope.listen(button, 'click', () => {
layout = mode;
for (const child of layouts.children) child.setAttribute('aria-pressed', String(child === button));
updateLayout();
});
layouts.append(button);
}
const refresh = createButton('Refresh');
scope.listen(refresh, 'click', async () => {
if (refresh.disabled) return;
refresh.disabled = true;
optionsRequest?.abort(); optionsRequest = null;
optionsStatus.hidden = true;
try {
await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal});
if (scope.signal.aborted) return;
await loadOptions();
if (!scope.signal.aborted) reset();
} catch {
if (scope.signal.aborted) return;
optionsStatus.textContent = 'Images could not be refreshed. Try Refresh again.';
optionsStatus.hidden = false;
} finally {
refresh.disabled = false;
}
});
const currentGallery = el('a', 'Current gallery', 'workspace-link');
currentGallery.href = resolveUrl('/prompt_manager/gallery');
toolbar.append(count, layouts, refresh, currentGallery);
main.append(toolbar);
const body = el('div', undefined, 'image-workspace');
const collection = el('section', undefined, 'image-collection');
collection.setAttribute('aria-label', 'Images');
const grid = el('div', undefined, 'image-grid');
grid.setAttribute('role', 'list'); grid.setAttribute('aria-label', 'Image collection');
const continuation = el('div', undefined, 'image-continuation');
const message = el('p');
const more = createButton('Load more images');
const retry = createButton('Retry');
const end = el('p', '', 'muted');
continuation.append(message, more, retry, end);
collection.append(grid, continuation);
const details = el('section', undefined, 'image-details panel');
details.setAttribute('aria-label', 'Image details');
details.append(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
body.append(collection, details);
main.append(body);
const dialog = el('dialog', undefined, 'image-preview');
dialog.setAttribute('aria-label', 'Image preview');
const dialogHeader = el('header');
const previewTitle = el('h2');
const close = createButton('Close preview');
dialogHeader.append(previewTitle, close);
const stage = el('div', undefined, 'image-preview-stage');
dialog.append(dialogHeader, stage);
main.append(dialog);
scope.listen(close, 'click', () => dialog.close());
scope.listen(dialog, 'close', () => { destroyPreview(); scheduleLoad(); });
function destroyPreview() {
viewer?.destroy(); viewer = null;
for (const video of stage.querySelectorAll('video')) releaseVideo(video);
stage.replaceChildren();
}
scope.cleanup(() => { dialog.close(); destroyPreview(); dialog.remove(); });
function mediaElement(image, lazy = false) {
const video = image.is_video && !image.thumbnail_url;
const node = el(video ? 'video' : 'img');
node.src = resolveUrl(image.thumbnail_url || image.url);
if (video) { node.muted = true; node.playsInline = true; node.preload = 'metadata'; }
else { node.alt = image.filename; node.decoding = 'async'; if (lazy) node.loading = 'lazy'; }
return node;
}
function releaseVideo(video) {
video.pause(); video.removeAttribute('src'); video.load();
}
function inspect(image) {
selection = imageKey(image);
for (const video of details.querySelectorAll('video')) releaseVideo(video);
const thumbnail = mediaElement(image);
const metadata = el('dl', undefined, 'image-facts');
const fields = [['Source', image.root_dir], ['Path', image.path], ['Size', formatBytes(image.size)],
['Dimensions', image.width && image.height ? `${image.width} × ${image.height}` : 'Not available'],
['Modified', new Date(image.modified_time * 1000).toLocaleString()]];
for (const [label, value] of fields) {
const row = el('div'); row.append(el('dt', label), el('dd', value)); metadata.append(row);
}
const preview = createButton('Open full preview', 'ca-primary');
// The view delegates these controls so changing selection retains no old handlers.
preview.dataset.previewKey = selection;
const download = el('a', 'Download original', 'workspace-link');
download.href = resolveUrl(image.url); download.download = image.filename;
const legacy = el('p', 'Open Current gallery for prompt details and tagging.', 'muted');
details.replaceChildren(el('p', 'SELECTED IMAGE', 'eyebrow'), el('h2', image.filename), thumbnail, metadata, preview, download, legacy);
for (const [key, card] of rendered) {
card.classList.toggle('selected', key === selection);
card.querySelector('.image-open').setAttribute('aria-pressed', String(key === selection));
}
}
scope.listen(details, 'click', event => {
const key = event.target.closest('[data-preview-key]')?.dataset.previewKey;
if (key) previewImage(state.items.find(image => imageKey(image) === key));
});
function previewImage(image) {
if (!image) return;
if (selection !== imageKey(image)) inspect(image);
destroyPreview();
previewTitle.textContent = image.filename;
if (!dialog.open) dialog.showModal();
if (image.is_video) {
const video = el('video'); video.controls = true; video.src = resolveUrl(image.url); video.preload = 'metadata';
stage.append(video);
} else if (window.Viewer) {
const original = el('img'); original.src = resolveUrl(image.url); original.alt = image.filename;
original.hidden = true; stage.append(original);
viewer = new window.Viewer(original, { inline: true, container: stage, navbar: false, title: false,
transition: false, keyboard: false, viewed() { viewer?.resize(); } });
} else {
stage.append(el('p', 'The image viewer could not load. Refresh the page to try again.'));
}
}
function cardFor(image) {
const key = imageKey(image);
const card = el('article', undefined, 'image-card');
card.dataset.imageKey = key; card.setAttribute('role', 'listitem');
const open = createButton('');
open.className = 'image-open'; open.setAttribute('aria-label', `Open ${image.filename}`);
open.title = 'Show image details'; open.setAttribute('aria-pressed', String(key === selection));
const img = mediaElement(image, true);
open.append(img);
const footer = el('div', undefined, 'image-card-footer');
const title = el('strong', image.filename); title.title = image.path;
const sub = el('small', `${formatBytes(image.size)} · ${image.media_type === 'video' ? 'Video' : 'Image'}`);
const preview = createButton('↗', 'ca-icon-button');
preview.setAttribute('aria-label', `Preview ${image.filename}`);
footer.append(title, sub, preview); card.append(open, footer);
return card;
}
scope.listen(grid, 'click', event => {
const card = event.target.closest('article[data-image-key]');
if (!card) return;
const image = state.items.find(item => imageKey(item) === card.dataset.imageKey);
if (event.target.closest('.image-card-footer button')) previewImage(image);
else if (event.target.closest('button')) inspect(image);
});
function renderVisible() {
if (!state || scope.signal.aborted) return;
const top = Math.max(0, -grid.getBoundingClientRect().top);
const active = document.activeElement?.closest('article[data-image-key]')?.dataset.imageKey;
const focused = active ? itemIndexes.get(active) ?? -1 : -1;
const indexes = state.items.length <= 120 ? state.items.map((_, index) => index) :
visibleImages(geometry.positions, top, window.innerHeight, focused, geometry.maxHeight);
const keep = new Set(indexes.map(index => imageKey(state.items[index])));
for (const [key, card] of rendered) if (!keep.has(key)) {
for (const video of card.querySelectorAll('video')) releaseVideo(video);
card.remove(); rendered.delete(key);
}
for (const index of indexes) {
const image = state.items[index], key = imageKey(image);
let card = rendered.get(key);
if (!card) { card = cardFor(image); rendered.set(key, card); }
positionCard(card, index);
}
// Keep keyboard order aligned with the collection while preserving existing nodes.
let previous = null;
for (const index of indexes) {
const card = rendered.get(imageKey(state.items[index]));
if (previous ? previous.nextElementSibling !== card : grid.firstElementChild !== card) grid.insertBefore(card, previous ? previous.nextElementSibling : grid.firstChild);
previous = card;
}
}
function positionCard(card, index) {
const image = state.items[index], position = geometry.positions[index];
card.dataset.index = index;
card.classList.toggle('selected', imageKey(image) === selection);
card.querySelector('.image-open').setAttribute('aria-pressed', String(imageKey(image) === selection));
card.setAttribute('aria-posinset', String(index + 1)); card.setAttribute('aria-setsize', String(state.total));
Object.assign(card.style, { left: `${position.x}px`, top: `${position.y}px`, width: `${position.width}px`, height: `${position.height}px` });
}
function focusImage(index, preview = false) {
if (index < 0 || index >= state.items.length) return;
const image = state.items[index], key = imageKey(image);
let card = rendered.get(key);
if (!card) {
card = cardFor(image); rendered.set(key, card); positionCard(card, index);
grid.insertBefore(card, [...grid.children].find(child => Number(child.dataset.index) > index) || null);
}
card.querySelector(preview ? '.image-card-footer button' : '.image-open').focus({ preventScroll: true });
card.scrollIntoView({ block: 'nearest' });
cancelAnimationFrame(frame); frame = 0;
renderVisible();
}
scope.listen(grid, 'keydown', event => {
const card = event.target.closest('article[data-image-key]');
if (!card) return;
const index = Number(card.dataset.index);
const columns = layout === 'list' ? 1 : Math.max(1, Math.floor((grid.clientWidth + 16) / 256));
const target = { Home: 0, End: state.items.length - 1, ArrowRight: index + 1,
ArrowLeft: index - 1, ArrowDown: index + columns, ArrowUp: index - columns }[event.key];
if (target !== undefined) { event.preventDefault(); focusImage(Math.max(0, Math.min(state.items.length - 1, target))); }
else if (event.key === 'Tab') {
const backward = event.shiftKey && event.target.matches('.image-open');
const forward = !event.shiftKey && event.target.closest('.image-card-footer');
const adjacent = backward ? index - 1 : index + 1;
if ((backward || forward) && state.items[adjacent] && !rendered.has(imageKey(state.items[adjacent]))) {
event.preventDefault(); focusImage(adjacent, backward);
}
}
});
function scheduleVisible() { if (!frame) frame = requestAnimationFrame(() => { frame = 0; renderVisible(); }); }
function updateLayout() {
if (!state || scope.signal.aborted) return;
grid.dataset.layout = layout;
cancelAnimationFrame(frame); frame = 0;
geometry = layoutImages(state.items, grid.clientWidth, layout);
grid.style.height = `${geometry.height}px`;
renderVisible();
}
const resize = new ResizeObserver(updateLayout); resize.observe(grid);
scope.listen(window, 'scroll', scheduleVisible);
scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); });
function renderState(next) {
const changed = state?.items !== next.items;
if (changed) {
if (!next.items.length) itemIndexes.clear();
for (let index = state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);
}
state = next;
count.textContent = `${next.loaded.toLocaleString()} of ${next.total.toLocaleString()} images`;
if (changed) updateLayout();
message.textContent = next.error ? 'Images could not be loaded. Retry or refresh the collection.' : next.loading ? 'Loading images…' : '';
if (next.error) message.setAttribute('role', 'alert'); else message.setAttribute('role', 'status');
retry.hidden = !next.error;
more.hidden = !next.hasMore || !!next.error;
more.setAttribute('aria-disabled', String(next.loading));
end.textContent = !next.loading && !next.error && !next.hasMore ? (next.loaded ? 'You’re all caught up.' : 'No matching images or media. Try changing the filters.') : '';
if (!selection && next.items.length) inspect(next.items[0]);
scheduleLoad();
}
const stream = createImageStream({ api, onChange: renderState, restore: visit ? {filters, state:visit.state} : undefined });
scope.cleanup(() => {
stream.dispose();
for (const video of body.querySelectorAll('video')) releaseVideo(video);
});
scope.listen(more, 'click', () => stream.loadMore());
scope.listen(retry, 'click', () => stream.loadMore());
function maybeLoad() {
if (!observer || scope.signal.aborted || !state?.hasMore || state.loading || state.error || dialog.open) return;
const bounds = continuation.getBoundingClientRect();
if (bounds.top <= window.innerHeight + 250 && bounds.bottom >= 0) stream.loadMore();
}
function scheduleLoad() {
if (!loadFrame) loadFrame = requestAnimationFrame(() => { loadFrame = 0; maybeLoad(); });
}
if (typeof IntersectionObserver === 'function') {
observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) maybeLoad();
}, { rootMargin: '250px' });
observer.observe(continuation);
}
function reset() {
selection = null;
for (const video of details.querySelectorAll('video')) releaseVideo(video);
details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
stream.reset(filters);
}
async function loadOptions() {
optionsRequest?.abort();
const request = new AbortController(); optionsRequest = request;
try {
const params = new URLSearchParams({mode:'workspace', include_ancestors:'true'});
if (filters.root !== 'all') params.set('root', filters.root);
const data = await api.get(`/prompt_manager/gallery/subfolders?${params}`, {signal:request.signal});
if (scope.signal.aborted || request !== optionsRequest) return;
source.replaceChildren(new Option('All sources', 'all'), ...data.roots.map(root => new Option(root.label, String(root.index))));
source.value = filters.root;
folder.replaceChildren(new Option('All folders', ''), ...data.subfolders.map(path => new Option(path, path)));
folder.value = filters.subfolder;
optionsStatus.hidden = true;
} catch {
if (scope.signal.aborted || request !== optionsRequest) return;
optionsStatus.textContent = 'Sources and folders could not be loaded. Refresh to retry.';
optionsStatus.hidden = false;
}
}
scope.cleanup(() => optionsRequest?.abort());
scope.listen(search, 'input', scope.debounce(() => { filters.q = search.value.trim(); reset(); }, 200));
for (const [input, key] of [[source, 'root'], [folder, 'subfolder'], [media, 'media'], [sort, 'sort']]) {
scope.listen(input, 'change', () => {
filters[key] = input.value;
if (key === 'root') { filters.subfolder = ''; folder.value = ''; loadOptions(); }
reset();
});
}
loadOptions();
if (visit) {
renderState(stream.state);
const selected = stream.state.items.find(image => imageKey(image) === selection);
if (selected) inspect(selected);
restoreFrame = requestAnimationFrame(() => {
if (!scope.signal.aborted) window.scrollTo(0, visit.scrollY);
});
if (visit.state.loading || visit.state.error) stream.loadMore();
} else reset();
}
+6
View File
@@ -2,6 +2,7 @@ import '../api-client.js';
import { createButton, createField, createScope, createSelect } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountImages } from './images.mjs';
const { createApiClient, resolveUrl } = window.ApiClient;
@@ -56,6 +57,11 @@ function mountWorkspace() {
main.replaceChildren(element('p', 'YOUR CREATIVE LIBRARY', 'eyebrow'), heading);
if (!firstRender) main.focus();
firstRender = false;
document.getElementById('workspace-breadcrumb').textContent = route.label;
if (route.id === 'images') {
mountImages(main, api, view);
return;
}
if (route.id !== 'overview') {
main.append(element('p', route.description, 'intro'));
const panel = element('section', undefined, 'panel handoff');
+1 -1
View File
@@ -1,5 +1,4 @@
export const routes = [
{ id: 'overview', label: 'Overview' },
{ id: 'images', label: 'Images', href: '/prompt_manager/gallery',
description: 'Browse your images, inspect their metadata and review generated tags.', action: 'Open existing image gallery' },
{ id: 'library', label: 'Prompt library', href: '/prompt_manager/admin',
@@ -8,6 +7,7 @@ export const routes = [
description: 'Explore your vocabulary and find the prompts associated with each tag.', action: 'Open existing tag manager' },
{ id: 'inspect', label: 'Inspect a file', href: '/prompt_manager/gallery.html',
description: 'Inspect generation metadata from an image or video file.', action: 'Open existing metadata inspector' },
{ id: 'overview', label: 'Overview' },
{ id: 'system', label: 'System', href: '/prompt_manager/admin',
description: 'Open the current dashboard, then choose Settings or Maintenance for configuration, scans, backups and diagnostics.', action: 'Open existing administration' },
];
+5 -2
View File
@@ -7,19 +7,22 @@
<title>Prompt Manager · Workspace</title>
<link rel="stylesheet" href="js/shared/toolkit/controls.css">
<link rel="stylesheet" href="js/workspace/style.css">
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
<link rel="stylesheet" href="js/workspace/images.css">
<script src="lib/viewerjs/viewer.min.js" defer></script>
<script type="module" src="js/workspace/index.mjs"></script>
</head>
<body class="ca-tools">
<a class="skip" href="#workspace-main">Skip to content</a>
<div class="workspace-shell">
<aside class="sidebar">
<a class="brand" href="#/overview"><span class="brand-mark" aria-hidden="true">P</span><span>Prompt Manager<small>ComfyAssets</small></span></a>
<a class="brand" href="#/overview"><span class="brand-mark" aria-hidden="true">P<small>±</small></span><span>Prompt Manager<small>ComfyAssets</small></span></a>
<p class="eyebrow">Your workspace</p>
<nav aria-label="Workspace"></nav>
<div class="sidebar-footer"><span class="connection-dot" aria-hidden="true"></span> Your library, on your server</div>
</aside>
<div class="workspace-body">
<header class="topbar"><span>Prompt Manager <span class="muted">/ Workspace preview</span></span><div id="appearance-control"></div></header>
<header class="topbar"><span>ComfyAssets <span class="muted">/ <span id="workspace-breadcrumb">Images</span></span></span><div id="appearance-control"></div></header>
<main id="workspace-main" tabindex="-1"></main>
<footer class="page-footer">ComfyAssets <span>Local tools. Your library.</span></footer>
</div>