Files
ComfyAssets-ComfyUI_PromptM…/web/js/workspace/images.mjs
T
Vito 40dc0e5417 feat(collections): add to and remove from collections in the selection bar and the inspector (#331, PR 3) (#435)
* feat(saved-views): keep a collection in a saved view

Images saved views gain a collection key (#331 D140): empty or a collection
id of 1 to 9 digits. Writes refuse favorites together with a collection;
older rows read an empty collection, and a stored row with both opens the
collection, as the listing does for a URL with both.

Part of #331

* feat(collections): store collections in the library database

* feat(collections): add, remove and rename collections and keep members through duplicate cleanup

* feat(collections): add collection routes and the Images collection filter

* feat(images): store images' own categories in the library database (#332)

One new table, image_categories, and its category index, created empty
by a forward-only migration that reads and writes only its own objects.
Startup checks the structure; restore also checks the values. The store
sets, clears and reads own categories by file key (the deepest output
source holding the file and the path below it), and copies a removed
duplicate's category to a kept copy that has none.

* feat(images): duplicate cleanup keeps images' own categories (#332)

The kept copy's own category wins; a kept copy without one receives the
removed copy's, keyed by the deepest output source holding each file.
The removed copy's row is never deleted, so a give-back or a failed
delete loses nothing. The job log reports how many were kept.

* feat(images): set, remove and list images' own categories (#332)

PUT /prompt_manager/images/category sets the own category of up to 500
listed files, DELETE removes it (listed or not), and
GET /prompt_manager/images/categories lists the values whose file is
listed. Targets are the Images listing identity of an output source,
checked by media_target (shared with Collections). The routes never
touch prompts, recipes or links; POST /prompt_manager/bulk/category is
unchanged.

* fix(images): key symlinked output folders and keep key work off the event loop (#332)

Review fixes: a source matches by its configured spelling too, so a
detected output folder that is a symlink keys its files (routes and
duplicate cleanup). File keys are computed in the executor. A path no
source holds is a plain 403, a lone surrogate a 400. The install repairs
a table that lacks its category index instead of refusing to open.

* fix(collections): address review findings

* feat(images): set or remove a category on all N matching images (#332)

The category routes also accept the Images listing filter of "Select
all N matching" (#392) instead of explicit items, resolved now and
capped at 500 images like an explicit selection.

* feat(collections): browse a collection from the sidebar

The sidebar gains the concept's COLLECTIONS group (D136): one row per
collection with its folder icon, name and listed-member count, without the
PROPOSED badge; the label shows once a collection exists. A row opens
#/collection/<id>, the Images page filtered to that collection (D137): the
collection's name as h1, Collection as breadcrumb and title, Edit to rename
or delete it through the saved views' Edit dialog with collection nouns, and
a note counting members the sources do not list now. An unknown or deleted
collection shows a page that says so with a link to Images. A saved view on
a deleted collection lists nothing and says so (MC6, D140). The list is read
again after a restore and the counts 300 ms after a change.

Part of #331

* test(collections): compare the COLLECTIONS group with the concept

The concept hides COLLECTIONS at 950 px and below, which would leave a phone
no way to open a collection; production shows the rows there in the wrapped
layout of every other sidebar nav (D136). The shell comparison now compares
the group instead of filtering it out: the concept fixture seeds Landscapes
(12) and Experiments (8), the rows compare by name order, and at phone width
the nav compares against the concept's own sidebar-nav layout. The
collection rows are checked at 1600x1100 and 390x844 in both themes.

Part of #331

* test(vendor): count the #332 host case in the execution matrix

image-attribution.spec.mjs gains the image category route case, so the
execution config lists 92 tests (3 attribution cases per project).

* test(browser): install the slideshow clock before wrapping setTimeout

The slideshow fixture wrapped window.setTimeout before page.clock.install(),
so the viewer's timer ran on real time and clock.runFor() did not drive it;
a late WebKit timer failed 'slideshow exposes exact state labels' on CI.

* feat(collections): add and remove from the selection bar

Add to collection follows Set category in the Images selection bar and
opens the concept's simpleInput ("Add selected images to collection",
"Collection name", Add) with the existing names as a datalist. Remove
from collection follows it while a collection filter is active and
reloads the listing in place, keeping the checked files still listed.
Every outcome uses the design's strings (section 7.4). The routes take
at most 500 explicit files, so an "all N matching" selection is sent
only when every match is loaded, checked and at most 500; otherwise the
action toasts "Select at most 500 images." (D138, #331).

* fix(collections): address review findings

- A list read superseded by a newer one (the 300 ms count read, a restore)
  no longer shows "could not be loaded": the collection and saved-view
  pages wait for the list to settle.
- A listing that finds the open collection gone shows the deleted page once
  the list no longer holds it; when the list read fails or still holds it,
  the page keeps the listing's error and Retry instead of staying blank, and
  each failed read is reported again.
- The missing-members note follows every list read and goes away at zero.
- A rename that settles after its Edit dialog closed leaves any dialog
  opened since alone.
- The deleted-collection, list-failed and saved-view failure pages focus
  their title after a route change, as every page does.

Part of #331

* feat(collections): add and remove from the inspector and the viewer

The shared inspector's footer ends with Collections… (folder icon) after
Prompt history for a listed file, in the Images side panel and the
viewer. Its dialog reads the file's membership, lists every collection
as the concept's checkbox rows, takes an optional new name, and Save
sends one request per change (new name, added, removed). A failure keeps
the dialog with the membership read again. On a collection page a change
to the open collection reloads the listing at once from the side panel
and only when the viewer closes from the viewer. The inspector concept
comparison draws the button into the concept after Prompt history (D139,
#331).

* test(collections): compare the collection page and dialog with the concept

images-concept compares Add to collection in place in the selection bar
(only Remove links… stays omitted) and the collection page's heading,
toolbar and filter row against the concept's #collection-landscapes,
with D137's Edit drawn into the concept. collections-concept compares
the Add to collection dialog with the concept's simpleInput (D138:
"Add", an empty field, the datalist of names), in both themes at 1600
and 390 px, with a mutation check.

TESTING.md gains the Collections contract (database, keys, missing
members, duplicate move, restore, routes, rules, limits, the PR 1
rulings and the all-matching ruling), D136-D140, and amends D28 and D8;
docs/application-redesign.md gets the delivery-status line. The coverage
baseline adds the two new modules; optional collection callbacks are
null rather than no-op defaults so they do not count as uncovered
functions.

* test(coverage): keep main's floors for files this PR does not touch

coverage:update raised nine floors of untouched files from one run;
metadata-autocomplete.js branches then measured 84.84 against 85.

* fix(collections): address review findings

- M1: after a failed Save the Collections dialog shows the membership
  loading and keeps Save disabled until the re-read lands, so a stale
  plan cannot be sent.
- M2: the changes a Save made reach the page only when the dialog
  closes, so a collection page's reload cannot remove the dialog
  before its failure is read.
- L4: a New collection name equal to a listed collection ignoring case
  is that collection's checkbox, checked; never an add then a remove.
- L5: a name that cannot be a collection name is refused on the field;
  nothing is sent, the checkbox edits stay, no re-read.
- L6: focus starts on the field and moves to the first checkbox once
  listed; closing after a re-render replaced the trigger focuses the
  inspector's current Collections… button or its heading
  (openContentDialog takes an optional focusTarget).
- L3, L7: TESTING.md D138/D139 describe the stale-source and deleted
  collection outcomes and the above; the viewer spec asserts no listing
  request while it is open, and a browser test covers M2.

* test(legacy): record the image category routes in the legacy contract snapshot (#332)

#332 adds PUT/DELETE /prompt_manager/images/category and
GET /prompt_manager/images/categories; the legacy pages' routes are
unchanged.

* test(duplicates): a removed copy with a collection and a category moves both (#331, #332)

* test(collections): map the Add to collection suggestions to the shared dialog's datalist (#331)

After the merge with #332 PR 3 the input dialog places its datalist after the
field with the id workspace-input-suggestions; the concept mapping follows.
2026-10-29 01:14:20 +00:00

863 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createScope, createButton } from '../shared/toolkit/core.js';
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, initialImagesView, listingFilter } from './images-state.mjs';
import { createImageCard } from './image-card.mjs';
import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs';
import { mountFileInspector } from './file-inspector.mjs';
import { createInspectionController } from './inspection-state.mjs';
import { mountSelectionBar } from './images-selection.mjs';
import { createPromptSender } from './prompt-sender.mjs';
import { promptRoles } from './prompt-blocks.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { createMediaViewer } from './media-viewer.mjs';
import { noun } from './plural.mjs';
import { pageHeading } from './heading.mjs';
import { mountToolbar } from './toolbar.mjs';
import { RESCAN_FAILED, RESCAN_STARTED, openImportDialog, openRefineDialog, rescanFinished } from './workspace-dialogs.mjs';
import { openMetadataScan } from './activity-scan.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { imagesFilters, imagesViewState } from './saved-views.mjs';
import { VIEW_COLLECTION_DELETED, isCollectionGone } from './collections.mjs';
import { IMAGE_CATEGORIES_URL, mergeCategories } from './images-category.mjs';
import { displayPreferences } from './preferences.mjs';
import { onImageTagsSaved } from './image-tagging.mjs';
import { followImageTagSaves, openCardTagging } from './images-tagging.mjs';
import { favoriteBusy, paintStar, receiveFavorites, setFavoriteVisitTarget, toggleFavorite } from './images-favorites.mjs';
import { createSourceRecovery, recoveredSource } from './images-source-recovery.mjs';
export { onFavoriteChange } from './images-favorites.mjs';
/** The concept's panel without an image (app.js updateImages a456–458). */
function noSelection() {
const inner = el('div', undefined, 'detail-inner');
inner.append(el('h2', 'No image selected'), el('p', 'Clear the filters or choose a matching image.', 'small'));
return inner;
}
function el(tag, text, className) {
const element = document.createElement(tag);
if (text !== undefined) element.textContent = text;
if (className) element.className = className;
return element;
}
/**
* Live regions re-announce replaced text even when it is the same, and a patched publish (a favorite) repeats the
* same status: write text and attributes only when they change.
*/
function setText(element, text) {
if (element.textContent !== text) element.textContent = text;
}
function setAttr(element, name, value) {
if (element.getAttribute(name) !== value) element.setAttribute(name, value);
}
function selectOf(id, options, value) {
const select = el('select');
select.id = id;
select.replaceChildren(...options.map(([optionValue, label]) => {
const node = el('option', label);
node.value = optionValue;
return node;
}));
select.value = value;
return select;
}
// The concept's three sorts first, then the production ones it lacks (D14); values are the listing's sorts (B3).
const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'],
['oldest', 'Oldest first'], ['name-desc', 'Name Z–A']]);
const MEDIA_OPTIONS = Object.freeze([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']]);
const RETRY_RESCAN = 'Rescan from Import to retry.';
// The concept's empty collection (app.js updateImages).
const NO_MATCHES = 'No matching images. Try another search or category.';
const RETRY_REFRESH = 'Refresh image collection to retry.';
const CURSOR_STALE = 'The image collection changed. Refresh image collection to continue.';
/** One alert for whichever of the source discovery and the categories failed, so two failures announce once. */
function loadFailure(sourcesRetry, categoriesFailed) {
if (sourcesRetry && categoriesFailed) return `Sources, folders and categories could not be loaded. ${sourcesRetry}`;
if (sourcesRetry) return `Sources and folders could not be loaded. ${sourcesRetry}`;
return categoriesFailed ? `Categories could not be loaded. ${RETRY_RESCAN}` : '';
}
// The concept card's first `.media-info` reservation and list row (image-layout.mjs); real rows are measured.
const INITIAL_METRICS = Object.freeze({columns:1, gap:13, footerHeight:54, listHeight:97});
let lastVisit = null;
// The star's answers patch the saved visit while no Images page is mounted (D7).
setFavoriteVisitTarget((key, favorite) => {
if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})};
});
// A tag save made while Images is left (Tags › Auto-tag images) may change what a filtered visit lists: it reloads (M6).
onImageTagsSaved(() => { if (lastVisit?.filters.tags.length || lastVisit?.filters.q) lastVisit = {...lastVisit, stale: true}; });
// The filter state a Tags & categories Browse hands to the next Images mount (#332 MC10).
let browseState = null;
/**
* Open Images filtered by `category` only (every other filter at its default), as Browse opens the Library: Tags &
* categories' Browse of a category only images use (#332 MC10, D143).
*/
export function browseImages({category} = {}) {
if (typeof category !== 'string' || !category) throw new TypeError('browseImages needs a category');
browseState = {category};
window.location.hash = '#/images';
}
/** Forget the last Images visit (its listing, scroll and selection): the next visit reads everything again (S9). */
export function forgetImagesVisit() {
lastVisit = null;
}
/** The image selected in Images when it was last left (the concept's `state.selectedImage`, for Tags › Auto-tag images). */
export function selectedImage() {
const key = lastVisit?.selection;
return key ? lastVisit.state?.items.find(item => imageKey(item) === key) ?? null : null;
}
/**
* An embedded collection (the history page's shared gallery and its All images and Repeat run dialogs, D47) has no
* caption: its page heading or dialog title already names it, and `focusTarget` takes a stranded focus.
* `savedState` opens a saved view (#249 D16) instead of the last visit; `onSaveView(trigger, state)` is Save view.
* `favorites` is the Favorites page (#249 D51): only favorites are listed, and it neither restores nor replaces the
* Images last visit. `collection` (an id) is a collection page (#331 D137): only its members are listed, favorites is
* off, and it neither restores nor replaces the last visit either. A listing that answers `collection-not-found` calls
* `onCollectionMissing()` when given (the shell shows its deleted-collection page); otherwise (a saved view on a
* deleted collection, D140) the page lists nothing and says so. `collections` (the shell's client store) adds the
* selection bar's Add to collection, and Remove from collection while a collection is open (D138).
*/
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null,
favorites = false, collection: collectionId = null, onCollectionMissing = null, onSaveView = () => {}, activity = null,
collections = null} = {}) {
const scope = createScope();
const dispose = () => scope.dispose();
parentScope.signal.addEventListener('abort', dispose, {once:true});
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
if (parentScope.signal.aborted) { scope.dispose(); return {dispose}; }
const sender = createPromptSender({scope});
let detailInspector;
// System › Appearance: the display defaults this visit starts from (preferences.mjs); null without gallery-settings.js.
const preferences = displayPreferences();
const inspection = createInspectionController({api, autoLoad:preferences?.autoLoadMetadata !== false, cache:preferences?.cacheMetadata !== false});
// The panel's listed file: a changed source is looked up once, the panel's Refresh stays the fallback (#430).
let selectedImage = null;
scope.cleanup(inspection.subscribe(next => {
detailInspector?.setState(next);
if (next.refreshRequired && next.code === 'source-changed' && selectedImage && imageKey(selectedImage) === selection) {
recovery.recover(selectedImage);
}
}));
scope.cleanup(() => inspection.dispose());
scope.cleanup(() => detailInspector?.dispose());
// A Tags & categories Browse (browseImages) is handed to this mount once: a fresh Images visit filtered by its
// category, which becomes the Images return state like any plain visit (as Browse does for the Library).
const imagesPage = !embedded && !favorites && collectionId === null;
const handed = imagesPage && !savedState ? browseState : null;
if (imagesPage) browseState = null;
const saved = embedded || !savedState ? null : imagesFilters(savedState);
// The open collection: the route's, else a saved view's (D140); '' on every other page.
const openCollection = embedded ? '' : collectionId !== null ? String(collectionId) : saved?.collection || '';
const prior = embedded || saved || favorites || openCollection || handed ? null : lastVisit;
// A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6).
const visit = prior?.stale ? null : prior;
// The layout and size defaults are the Images page's: an embedded gallery (a prompt history's, its dialogs) has no
// toolbar to change them, so it keeps its fixed Grid/Comfortable. The metadata and card defaults apply everywhere.
const start = initialImagesView({prior, saved, preferences:embedded ? null : preferences});
let density = start.density;
let metrics = {...INITIAL_METRICS};
let layout = start.layout, selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
let state, geometry = { positions: [], columns: 1, height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false;
// Discovered sources (each with its own folders) and every folder, for "Refine this view" and the scope chip.
let roots = [], folders = [];
// The toolbar's current category list (loadCategories), also the Set category suggestions.
let categoryNames = [];
// The open "Refine this view", so a discovery that settles after it opened still lists its sources and folders.
let refine = null;
const rendered = new Map();
const itemIndexes = new Map();
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...prior?.filters, ...saved,
...(handed ? {category: handed.category} : {}),
// A collection and favorites are never both the filter (DC10).
favorites: !openCollection && (favorites || saved?.favorites === true), collection: openCollection };
filters.tags = [...filters.tags];
if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest';
// A saved Source or Folder is only listed once discovery confirms it still exists; until then nothing is fetched.
let savedScopePending = Boolean(saved) && (filters.root !== 'all' || filters.subfolder !== '');
// The first discovery settled (found or failed): only then may a pending saved scope be kept or dropped (#249 M3).
let discoverySettled = false;
scope.cleanup(() => {
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
// A favorites-only collection or a saved view is not the plain Images collection, so it never becomes the
// Images return state (#249 D51, and M2 for saved views).
if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
scope.cleanup(() => main.classList.remove('images-main'));
// The concept's Images heading (app.js renderImages): description, then Import before the shell's New prompt.
const heading = embedded ? null : pageHeading(main);
let importButton = null, importDialog = null;
if (heading) {
heading.description.textContent = 'Your generations, their prompts, and the next idea.';
importButton = createButton('');
importButton.append(createWorkspaceIcon('upload'), el('span', 'Import'));
heading.actions.prepend(importButton);
scope.cleanup(() => importButton.remove());
scope.listen(importButton, 'click', () => {
importDialog = openImportDialog({scope, trigger:importButton, rescanBusy:rescanning,
onInspect:() => { window.location.hash = '#/inspect'; }, onRescan:rescan,
// D55: Scan for metadata reviews the session scan job over every discovered source.
onMetadataScan:activity ? trigger => openMetadataScan({scope, store:activity, sources:roots.length, trigger}) : null});
});
}
// The shared concept toolbar: search (filename, linked prompt text, title and tags), category and sort (D12–D14).
const toolbar = embedded ? null : mountToolbar(main, {scope, categories:filters.category ? [filters.category] : [],
sorts:IMAGE_SORT_OPTIONS, value:{q:filters.q, category:filters.category, sort:filters.sort},
activeFilters:activeRefinements(filters),
onChange:value => {
if (recovering) return;
const next = {q:value.q.trim(), category:value.category, sort:value.sort};
// A debounced search report can land after a select change already applied the same values; reloading
// then would refetch the first page and drop the reader's scroll position.
if (Object.entries(next).every(([key, item]) => filters[key] === item)) return;
Object.assign(filters, next);
reset();
}, onFilters:openFilters, onSaveView:trigger => {
const value = toolbar.value();
onSaveView(trigger, imagesViewState({...filters, q:value.q.trim(), category:value.category, sort:value.sort}));
}});
const toolbarControls = toolbar ? [...toolbar.element.querySelectorAll('input, select, button')] : [];
const optionsStatus = el('p', '', 'options-status muted');
optionsStatus.hidden = true; optionsStatus.setAttribute('role', 'alert');
// The failed loads' retry hint (null once sources load) and whether the categories failed.
let sourcesRetry = null, categoriesFailed = false;
if (!embedded) main.append(optionsStatus);
// The concept's filter row: layouts, thumbnail size, the scope chip and Group by positive prompt (D14, D15).
const filterRow = el('div', undefined, 'filter-row');
const layouts = el('div', undefined, 'segmented');
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.prepend(createWorkspaceIcon(mode));
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 sizeLabel = el('label', 'Thumbnail size', 'small');
sizeLabel.htmlFor = 'image-size';
const thumbnails = selectOf('image-size', [['comfortable', 'Comfortable'], ['large', 'Large'], ['compact', 'Compact']], density);
scope.listen(thumbnails, 'change', () => { density = thumbnails.value; updateLayout(); });
filterRow.append(layouts, sizeLabel, thumbnails);
const scopeText = document.createTextNode('');
if (!embedded) {
const chip = el('span', undefined, 'chip');
chip.append(createWorkspaceIcon('folder'), scopeText);
const group = createButton('Group by positive prompt →', 'chip');
scope.listen(group, 'click', () => { window.location.hash = '#/library'; });
filterRow.append(chip, group);
}
main.append(filterRow);
const body = el('div', undefined, 'gallery-layout image-workspace');
const collection = el('section', undefined, 'gallery-body');
collection.setAttribute('aria-label', 'Images');
// The concept's caption replaces the old "All images" heading.
const caption = el('div', undefined, 'section-caption');
const count = el('span', 'Loading images…');
count.id = 'result-count';
const captionDay = el('span');
captionDay.hidden = true;
caption.append(count, captionDay);
const focusFallback = focusTarget || heading?.h1 || main;
const grid = el('div', undefined, 'image-grid');
grid.setAttribute('role', 'list'); grid.setAttribute('aria-label', 'Image collection');
const continuation = el('div', undefined, 'infinite-continuation');
// The concept's ids (app.js renderImages); the Library-embedded gallery has its own page around it.
if (!embedded) { grid.id = 'image-grid'; continuation.id = 'gallery-continuation'; }
const message = el('p', '', 'small');
const more = createButton('Load more images');
const retry = createButton('Retry');
continuation.append(message, more, retry);
// The concept's empty collection (app.js updateImages), beside the grid because the grid only holds positioned cards.
// An embedded gallery shows one only when its page names the concept's copy (the history gallery, app.js a620).
const showsEmpty = !embedded || typeof emptyText === 'string';
const empty = el('div', embedded ? emptyText : NO_MATCHES, 'empty');
empty.hidden = true;
// D47: the history page's shared gallery and dialogs are named by their own heading or title, so they have no caption.
if (!embedded) collection.append(caption);
collection.append(grid);
if (showsEmpty) collection.append(empty);
collection.append(continuation);
// After a Load more or Retry press, the index of the first image that batch appends (concept: focus it).
let focusAfterLoad = null;
// The concept's side panel (app.js a426, D20); the Library-embedded gallery has its own page around it.
const details = el('aside', undefined, 'detail-panel');
if (!embedded) details.id = 'image-inspector';
details.setAttribute('aria-label', 'Selected image details');
details.append(noSelection());
body.append(collection, details);
// The checked cards and the concept's selection bar (#249 D8), between the filter row and the gallery layout. The
// selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none.
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state?.items ?? [], focusFallback,
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys),
// The Set category field suggests the toolbar's categories (#332 MC13).
getCategories:() => categoryNames,
// #392 "Select all N matching": the listing's total and its filter, as the server lists them.
getListing:() => ({total:state?.total ?? 0, hasMore:Boolean(state?.hasMore), positiveCount:state?.positiveCount ?? null}),
// A Source filter carries its root's scope, so a renumbered source is refused (source-changed), not acted on.
getFilter:() => {
const filter = listingFilter(filters), scope = state?.items[0]?.root_scope;
return filter.root !== undefined && typeof scope === 'string' ? {...filter, root_scope:scope} : filter;
},
// #331 D138: Add to collection, and Remove from collection on an open collection, which then lists it again.
collections, collection:filters.collection || null, onCollectionsChanged:collectionChanged,
onSourceChanged:refreshInspectionCollection});
if (bar) main.append(bar.element);
main.append(body);
// #331 D139: a change the inspector or the viewer confirmed that touches the open collection lists it again; the
// viewer reports only once it closes.
const inspectorCollections = embedded ? null : collections;
const collectionsChangedBy = ({added, removed}) => {
if (filters.collection && [...added, ...removed].includes(Number(filters.collection))) collectionChanged();
};
const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender,
collections:inspectorCollections,onCollectionsChanged:collectionsChangedBy,
onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);},
onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onMediaError:image => recovery.recover(image),onRestoreFocus:image => {
const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image));
if (index >= 0) focusImage(index);else focusFallback.focus({preventScroll:true});
}});
scope.cleanup(() => mediaViewer.dispose());
function releaseVideo(video) {
video.pause(); video.removeAttribute('src'); video.load();
}
function inspect(image) {
detailInspector?.dispose();
selection = imageKey(image);
selectedImage = image;
for (const video of details.querySelectorAll('video')) releaseVideo(video);
detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel',
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection,
collections:inspectorCollections, onCollectionsChanged:collectionsChangedBy});
detailInspector.setState(inspection.state);
inspection.select(image);
for (const [key, card] of rendered) markSelected(card, key === selection);
}
/** The concept's `.media-card.selected` (D19); as in the concept, Info carries no pressed state. */
function markSelected(card, selected) {
card.classList.toggle('selected', selected);
}
function previewImage(image) {
if (!image) return;
mediaViewer.open({items:state.items,selectedKey:imageKey(image),trigger:document.activeElement});
}
/** The concept media card (image-card.mjs): the image opens the viewer, Info inspects (D7–D11, D19). */
function cardFor(image) {
const key = imageKey(image);
const card = createImageCard(image, {checked:bar ? bar.isChecked(key) : null, favoriteBusy:favoriteBusy(key),
// D97: "Show image details on cards" off hides the title and file line; Info and Tag stay.
showInfo:preferences?.showImageInfo !== false});
markSelected(card, key === selection);
return card;
}
function setFavorite(key, favorite) {
if (scope.signal.aborted) return;
stream.updateItem(key, {favorite});
const button = rendered.get(key)?.querySelector('.star-button');
if (button) paintStar(button, favorite, favoriteBusy(key));
}
// The Images page and every Library-embedded gallery receive favorite answers (#249 D7, PR E).
receiveFavorites({scope, embedded, setFavorite});
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 (!image) return;
const action = event.target.closest('.image-card-actions button');
if (event.target.closest('.star-button')) toggleFavorite(api, image);
else if (event.target.closest('.image-open')) previewImage(image);
else if (action && !openCardTagging({api, scope, image, action, embedded, onSourceChanged:recovery.recover,
onRefresh:refreshInspectionCollection})) inspect(image);
});
function renderVisible() {
if (!state || scope.signal.aborted) return;
const bounds = grid.getBoundingClientRect();
const scale = bounds.width / Number.parseFloat(getComputedStyle(grid).width) || 1;
const top = Math.max(0, -bounds.top / scale);
const viewportHeight = window.innerHeight / scale;
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, viewportHeight, 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;
}
// Natural footer measurements and absolute reservations share unzoomed CSS units. The fractional height, as the
// concept's own flow lays it out: `offsetHeight` rounds a wrapped 67.5px row up and adds half a pixel per row.
const footerHeight = Math.max(metrics.footerHeight, ...[...rendered.values()].map(card => card.querySelector('.media-info').getBoundingClientRect().height / scale));
// A small epsilon: under zoom `rect / scale` carries float noise that must not relayout and scroll for nothing.
if (footerHeight > metrics.footerHeight + 0.01) {
const anchor = indexes.find(index => geometry.positions[index].y + geometry.positions[index].height >= top && geometry.positions[index].y <= top + viewportHeight);
const anchorTop = anchor === undefined ? 0 : geometry.positions[anchor].y;
metrics = {...metrics, footerHeight, listHeight: Math.max(metrics.listHeight, footerHeight + 2)};
updateGeometry();
for (const index of indexes) positionCard(rendered.get(imageKey(state.items[index])), index);
// A later mounted footer must not move the current visible card.
const correction = anchor === undefined ? 0 : geometry.positions[anchor].y - anchorTop;
if (top > 0 && correction) window.scrollBy(0, correction * scale);
scheduleVisible();
}
}
function positionCard(card, index) {
const image = state.items[index], position = geometry.positions[index];
card.dataset.index = index;
markSelected(card, 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, actions = false, {scroll = true} = {}) {
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(actions ? '.image-card-actions button' : '.image-open').focus({ preventScroll: true });
if (scroll) 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 = geometry.columns;
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();
const next = Math.max(0, Math.min(state.items.length - 1, target));
focusImage(next);
// D135: Shift extends the selection from the anchor (this card when there is none) to the new card.
if (event.shiftKey && bar) bar.extend(card.dataset.imageKey, imageKey(state.items[next]));
} else if (bar && (event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'a') {
event.preventDefault();
bar.selectAll();
} else if (bar && event.key === 'Escape' && bar.hasSelection()) {
event.preventDefault();
bar.clear();
} else if (event.key === 'Tab') {
const backward = event.shiftKey && event.target.matches('.image-open');
const forward = !event.shiftKey && event.target.closest('.image-card-actions');
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 updateGeometry() {
geometry = layoutImages(state.items, galleryWidth, layout, metrics);
// Below the narrowest bounded card (extreme zoom) the info row stacks (the production overlay, #249 D31).
grid.classList.toggle('narrow-cards', stacksCardInfo(galleryWidth, layout));
grid.style.height = `${geometry.height}px`;
grid.style.setProperty('--image-footer-height', `${metrics.footerHeight}px`);
}
function updateLayout() {
if (!state || scope.signal.aborted) return;
cancelAnimationFrame(frame); frame = 0;
// The concept's grid classes (app.js updateImages); the concept stylesheet then owns the columns and gap (D3).
grid.className = ['image-grid', density === 'comfortable' ? '' : density,
layout === 'list' ? 'list-grid' : layout === 'masonry' ? 'masonry-grid' : ''].filter(Boolean).join(' ');
const width = Number.parseFloat(getComputedStyle(grid).width);
const retained = state.items.length && width === galleryWidth && grid.dataset.layout === layout && grid.dataset.density === density ? metrics : null;
galleryWidth = width;
metrics = {...INITIAL_METRICS, ...gridMetrics(grid)};
if (retained) {
metrics.footerHeight = retained.footerHeight;
metrics.listHeight = retained.listHeight;
}
grid.dataset.layout = layout;
grid.dataset.density = density;
updateGeometry();
renderVisible();
}
let resizeFrame = 0, observedWidth;
const resize = new ResizeObserver(entries => {
const width = entries[0].contentRect.width;
if (width === observedWidth) return;
observedWidth = width;
if (!resizeFrame) resizeFrame = requestAnimationFrame(() => {
resizeFrame = 0;
updateLayout();
});
});
resize.observe(grid);
scope.listen(window, 'scroll', scheduleVisible);
scope.listen(window, 'resize', () => {
if (!resizeFrame) resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; updateLayout(); });
});
scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(resizeFrame); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); });
/**
* The listing refused the open collection: it was deleted. A saved view says so in place (MC6, D140); a collection
* page reports it (`onCollectionMissing`) once per failed read and meanwhile shows the listing's error and Retry,
* so a page whose shell cannot confirm the deletion is never left blank.
*/
const collectionGone = next => Boolean(filters.collection) && !onCollectionMissing && isCollectionGone(next.error);
let reportedMissing = null;
function continuationText(next) {
if (recovering) return 'Images paused until sources and folders are available.';
if (collectionGone(next)) return '';
if (next.error?.code === 'cursor-stale') return CURSOR_STALE;
if (next.error) return 'Images could not be loaded. Retry or rescan the collection.';
if (next.loading && !next.loaded) return 'Loading images…';
if (next.hasMore) return `${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')} · more load as you scroll`;
if (embedded) return next.loaded ? 'You’re all caught up.' : 'No linked media available for this selection.';
// The concept's end of the collection (app.js updateImageContinuation).
return `${next.loaded ? 'You’re all caught up' : 'No matching images'} · ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')}`;
}
function renderState(next, {patch, replaced = false} = {}) {
const previous = state;
// A patched item (a favorite) keeps every card's size and place; setFavorite repaints its card.
const changed = !patch && state?.items !== next.items;
if (changed) {
// A refreshed collection (`replaced`) is indexed again from its start; a loaded page only appends.
if (!next.items.length || replaced) itemIndexes.clear();
for (let index = replaced ? 0 : state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);
}
// The selected file left the refreshed result: the panel follows the collection, as after a reload; the
// selection bar keeps only the checked files still listed (I7).
if (replaced && selection !== null && !itemIndexes.has(selection)) dropSelection();
if (replaced) bar?.retain(key => itemIndexes.has(key));
state = next;
bar?.listingChanged();
if (!embedded) {
setText(count, countLine(next));
setText(captionDay, captionDate(next.items, filters.sort));
if (captionDay.hidden !== !captionDay.textContent) captionDay.hidden = !captionDay.textContent;
}
if (changed) updateLayout();
// The concept's Load more toast (app.js loadMoreImages) for every batch appended to a loaded collection.
if (!embedded && changed && !replaced && previous?.loaded && next.loaded > previous.loaded) {
const added = next.loaded - previous.loaded;
showWorkspaceToast(`${added.toLocaleString('en-US')} more ${noun(added, 'image')} loaded. ${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')}.`);
}
const gone = collectionGone(next);
setText(message, continuationText(next));
setAttr(message, 'role', next.error && !gone ? 'alert' : 'status');
retry.hidden = !next.error || gone;
// A cursor the server can no longer verify is never resent: Retry becomes the collection refresh (#430).
setText(retry, next.error?.code === 'cursor-stale' ? 'Refresh image collection' : 'Retry');
more.hidden = !next.hasMore || !!next.error;
more.setAttribute('aria-disabled', String(next.loading || recovering));
if (onCollectionMissing && filters.collection && isCollectionGone(next.error) && reportedMissing !== next.error) {
reportedMissing = next.error;
queueMicrotask(() => { if (!scope.signal.aborted) onCollectionMissing(); });
}
if (showsEmpty) {
setText(empty, gone ? VIEW_COLLECTION_DELETED : embedded ? emptyText : NO_MATCHES);
empty.hidden = !gone && (recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0);
}
if (focusAfterLoad !== null && !next.loading) settleLoadFocus(next);
if (!selection && next.items.length) inspect(next.items[0]);
scheduleLoad();
}
const stream = createImageStream({ api, fetchPage, onChange: renderState, restore: visit ? {filters, state:visit.state} : undefined });
const refreshTagged = followImageTagSaves({scope, stream, inspection, selection:() => selection, filtered:() => !embedded && !recovering && Boolean(filters.tags.length || filters.q),
onRefreshing:busy => more.setAttribute('aria-disabled', String(busy || Boolean(state?.loading) || recovering))});
scope.cleanup(() => {
stream.dispose();
for (const video of body.querySelectorAll('video')) releaseVideo(video);
});
function pressLoad() {
// A refresh re-reads the loaded range: Load more waits for it (and focus stays where it is, M3).
if (recovering || state?.loading || stream.refreshing) return;
focusAfterLoad = state?.loaded ?? 0;
stream.loadMore();
}
/** The concept focuses the first appended image; focus never stays on a control that just hid. */
function settleLoadFocus(next) {
const first = focusAfterLoad;
focusAfterLoad = null;
const active = document.activeElement;
// Only while focus is still on the pressed control: a viewer or another control the user moved to keeps it.
const stranded = !active || active === document.body || active === more || active === retry;
if (!stranded) return;
if (next.error) { retry.focus(); return; }
// Without scrolling: the visible cards keep their place (bounded-DOM anchor); the next arrow key reveals it.
if (next.loaded > first) focusImage(first, false, {scroll:false});
else if (more.hidden) {
if (next.loaded) focusImage(next.loaded - 1, false, {scroll:false});
else focusFallback.focus({preventScroll:true});
}
}
scope.listen(more, 'click', pressLoad);
scope.listen(retry, 'click', () => (state?.error?.code === 'cursor-stale' ? refreshInspectionCollection() : pressLoad()));
function maybeLoad() {
if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('#viewer-dialog[open]')) return;
const bounds = continuation.getBoundingClientRect();
if (bounds.top <= window.innerHeight + 250 && bounds.bottom >= 0) stream.loadMore();
}
function scheduleLoad() {
if (!scope.signal.aborted && !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 updateScope() {
scopeText.textContent = ` ${scopeLabel(filters, roots)}`;
toolbar?.setActiveFilters(activeRefinements(filters));
}
/** No selected file; `keepPanel` (recovering sources) leaves the panel's details shown. */
function dropSelection({keepPanel = false} = {}) {
for (const video of details.querySelectorAll('video')) releaseVideo(video);
if (!keepPanel) {
inspection.clear();
detailInspector?.dispose(); detailInspector = null;
details.replaceChildren(noSelection());
}
selection = null;
selectedImage = null;
}
// Bumped by every reset: a source recovery that began before it leaves the page alone (#430).
let generation = 0;
const recovery = createSourceRecovery({api, signal:scope.signal, generation:() => generation, apply:recoveredSource({
selected:image => selection === imageKey(image), inspect, dropSelection, stream, filters, updateScope,
closeViewer:() => { mediaViewer.stopSlideshow(); main.querySelector('#viewer-dialog[open]')?.close(); },
clearChecks:() => bar?.clear(), listsNow:() => !recovering,
current:started => !scope.signal.aborted && started === generation,
rediscover:() => loadOptions().then(found => { if (found) renderLoadStatus(); })})});
function reset() {
generation += 1;
mediaViewer.stopSlideshow();
main.querySelector('#viewer-dialog[open]')?.close();
dropSelection({keepPanel:recovering});
focusAfterLoad = null;
// The ruled selection lifetime: a replaced collection may no longer hold the checked files.
bar?.clear();
// A toolbar change while discovery is still pending keeps the saved Source and Folder; the settled discovery
// then lists the view with every current value (#249 M3).
if (savedScopePending && !discoverySettled) { updateScope(); return; }
settleSavedScope();
updateScope();
stream.reset(filters, {load:!recovering});
}
/** A saved view's Source and Folder apply only while the discovered sources still list them (#249 D16). */
function settleSavedScope() {
if (!savedScopePending) return;
savedScopePending = false;
if (filters.root !== 'all' && !roots.some(root => String(root.index) === filters.root)) filters.root = 'all';
if (!folderOptions(filters.root).some(([path]) => path === filters.subfolder)) filters.subfolder = '';
}
/** A panel tag chip (D21): the Images Tags filter becomes exactly that tag, as "Refine this view" would set it. */
function filterByTag(tag) {
if (recovering || !tag.trim() || tag.includes(',')) return;
if (filters.tags.length === 1 && filters.tags[0] === tag) return;
filters.tags = [tag];
reset();
}
/** "Refine this view": Source, Folder, Media and Tags, the filters the old field row held (D14). */
function openFilters(trigger) {
if (recovering) return;
refine = openRefineDialog({scope, trigger, fields:[
{key:'source', options:sourceOptions()},
{key:'folder', options:folderOptions('all'), optionsFor:folderOptions},
{key:'media', options:MEDIA_OPTIONS},
{key:'tags'},
], values:{source:filters.root, folder:filters.subfolder, media:filters.media, tags:filters.tags.join(', ')},
onApply:applyRefinement, onClear:cleared => {
toolbar.setValue({category:cleared.category});
applyRefinement(cleared);
}});
}
function sourceOptions() {
return [['all', 'All sources'], ...roots.map(root => [String(root.index), root.label])];
}
/** The chosen Source's folders, as the old field row reloaded them; every folder under All sources. */
function folderOptions(source) {
const root = source === 'all' ? null : roots.find(item => String(item.index) === source);
return [['', 'All output folders'], ...(root?.subfolders ?? folders).map(path => [path, path])];
}
/** Apply the dialog's values (Clear adds `category`); unchanged filters keep the loaded page, as the toolbar does. */
function applyRefinement({source, folder, media, tags, category = filters.category}) {
if (recovering) return;
const next = {root:source, subfolder:folder, media, category,
tags:[...new Set(tags.split(',').map(tag => tag.trim()).filter(Boolean))]};
const same = Object.entries(next).every(([key, value]) => key === 'tags'
? value.length === filters.tags.length && value.every((tag, index) => tag === filters.tags[index])
: filters[key] === value);
if (same) return;
Object.assign(filters, next);
reset();
}
function setControlsDisabled(disabled) {
for (const control of [...toolbarControls, more, retry]) control.disabled = disabled;
if (importButton) importButton.disabled = disabled;
}
/**
* Import → "Scan an output folder" (D15): rescan the output roots, then reload the options and the listing. A toast
* says it started and how it ended; meanwhile a reopened Import dialog shows Rescan as running.
*/
async function rescan() {
if (rescanning || recovering || scope.signal.aborted) return;
rescanning = true;
optionsRequest?.abort(); optionsRequest = null;
showWorkspaceToast(RESCAN_STARTED);
try {
const result = await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal});
if (scope.signal.aborted) return;
const [found] = await Promise.all([loadOptions(), loadCategories()]);
if (scope.signal.aborted) return;
renderLoadStatus();
if (found) reset();
if (!scope.signal.aborted) announceRescan(rescanFinished(result));
} catch {
// A failed rescan leaves the collection and any discovery alert as they were.
if (!scope.signal.aborted) announceRescan(RESCAN_FAILED);
} finally {
rescanning = false;
importDialog?.setRescanBusy(false);
}
}
/**
* D42: the concept's toast stays fixed at the bottom, so a discovery alert the rescan leaves is scrolled clear of
* the toast's band (its `scroll-margin-block-end`) only when it is in that band; elsewhere the page stays put.
*/
function announceRescan(message) {
showWorkspaceToast(message);
if (optionsStatus.hidden) return;
const {top, bottom} = optionsStatus.getBoundingClientRect();
const band = parseFloat(getComputedStyle(optionsStatus).scrollMarginBlockEnd) || 0;
if (bottom > innerHeight - band && top < innerHeight) optionsStatus.scrollIntoView({block:'nearest', inline:'nearest'});
}
/**
* A confirmed collection change that may change this listing (a remove from the open collection): the loaded range
* is read again in place, keeping the scroll and the checked files still listed. A failed re-read lists the page
* again from its start, whose failure shows the usual Retry (or the deleted-collection page).
*/
async function collectionChanged() {
if (scope.signal.aborted || !filters.collection || recovering) return;
try {
await stream.refresh();
} catch {
if (!scope.signal.aborted) reset();
}
}
async function refreshInspectionCollection() {
if (scope.signal.aborted) return;
recovery.reset();
// Numeric roots may now name another source; retire the old stream first.
recovering = true;
filters.root = 'all';filters.subfolder = '';
if (toolbar) filters.q = toolbar.value().q.trim();
roots = [];folders = [];
setControlsDisabled(true);
sourcesRetry = null;
renderLoadStatus();
reset();
const discovery = loadOptions(), request = optionsRequest;
const found = await discovery;
if (scope.signal.aborted || request !== optionsRequest) return;
renderLoadStatus();
if (!found) return;
recovering = false;
setControlsDisabled(false);
reset();
}
function renderLoadStatus() {
const text = loadFailure(sourcesRetry, categoriesFailed);
setText(optionsStatus, text);
optionsStatus.hidden = !text;
}
/** Discover the sources and folders; the caller renders the outcome with renderLoadStatus. */
async function loadOptions() {
optionsRequest?.abort();
const request = new AbortController(); optionsRequest = request;
try {
// Every source's folders (with ancestors), listed per source too, so the dialog can follow its Source.
const params = new URLSearchParams({mode:'workspace', include_ancestors:'true'});
const data = await api.get(`/prompt_manager/gallery/subfolders?${params}`, {signal:request.signal});
if (scope.signal.aborted || request !== optionsRequest) return false;
if (!Array.isArray(data?.roots) || !Array.isArray(data.subfolders)) throw new TypeError('Invalid image sources');
const paths = list => Array.isArray(list) ? list.filter(path => typeof path === 'string' && path) : null;
roots = data.roots.filter(root => Number.isSafeInteger(root?.index) && typeof root.label === 'string')
.map(root => ({index:root.index, label:root.label, subfolders:paths(root.subfolders)}));
folders = paths(data.subfolders);
refine?.setOptions('source', sourceOptions());
updateScope();
sourcesRetry = null;
return true;
} catch {
if (scope.signal.aborted || request !== optionsRequest) return false;
sourcesRetry = recovering ? RETRY_REFRESH : RETRY_RESCAN;
return false;
}
}
/**
* The toolbar's categories: the Library's (prompts and recipes) and the listed images' own ones (D141, #332), read
* in parallel. If only the image list fails, the prompt categories show and the categories alert is raised.
*/
async function loadCategories() {
if (!toolbar) return;
categoryRequest?.abort();
const request = new AbortController(); categoryRequest = request;
const [library, images] = await Promise.allSettled([
api.get('/prompt_manager/library/options', {signal:request.signal}),
api.get(IMAGE_CATEGORIES_URL, {signal:request.signal}),
]);
if (scope.signal.aborted || request !== categoryRequest) return;
const valid = result => result.status === 'fulfilled' && Array.isArray(result.value?.categories);
if (!valid(library)) { categoriesFailed = true; return; }
// D36: alphabetical (case-insensitive) whatever order the server sends, a restored category among them.
const names = new Set(mergeCategories(library.value.categories, valid(images) ? images.value.categories : []));
if (filters.category) names.add(filters.category);
categoryNames = [...names].sort((a, b) => a.localeCompare(b, 'en-US', {sensitivity:'base'}) || (a < b ? -1 : a > b ? 1 : 0));
toolbar.setCategories(categoryNames);
categoriesFailed = !valid(images);
}
scope.cleanup(() => { optionsRequest?.abort(); categoryRequest?.abort(); });
updateScope();
// The page's one discovery alert is written once both first loads settled (#249 final review m6).
if (!embedded) Promise.all([loadOptions(), loadCategories()]).then(() => {
if (scope.signal.aborted) return;
discoverySettled = true;
renderLoadStatus();
if (savedScopePending) reset();
});
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 if (savedScopePending) updateScope();
else reset();
/** D6: the selected file's prompt pair, only once its inspection loaded with both roles resolved. */
function selectedPair() {
const {record, prompts, loading, error, refreshRequired} = inspection.state;
if (scope.signal.aborted || selection === null || loading || error || refreshRequired || record?.identity?.key !== selection) return null;
const roles = promptRoles(prompts);
return Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative') ? roles : null;
}
return {dispose, selectedPair, sender};
}