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:
+11
-6
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Vendored
+14
@@ -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.
|
||||
Vendored
BIN
Binary file not shown.
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user