feat(system): Appearance defaults used by Images and the viewer
System > Appearance is the concept's Browsing & display surface on real values: the browser rows edit this browser's display defaults through the shared gallerySettings object, and Open from ComfyUI and the node search auto-hide save to the server settings with their revision. A new Images visit starts from the default layout and size (the current view and saved views keep theirs); metadata off waits for Load metadata; cache off re-reads the shown file; paths off hides the Path row; the viewer reads the video defaults. D97 adds card details, collapsed viewer details and Reset display defaults; storage refused says Saved for this tab only.
This commit is contained in:
+55
-7
@@ -1834,8 +1834,36 @@ browser fields, then the host fields (`POST /prompt_manager/settings` with
|
||||
highlights their controls; `settings-changed` is a conflict and keeps the draft. System
|
||||
reads the host values on mount and again after a conflict; until a read returned a
|
||||
revision, a host save fails without a request, so it never sends a blind
|
||||
`expected_revision: null` (which S1 merges into the file on disk). No delivered section
|
||||
edits a setting yet, so Save stays disabled until Appearance (S3).
|
||||
`expected_revision: null` (which S1 merges into the file on disk). Save stays disabled
|
||||
until a section has an unsaved change.
|
||||
|
||||
Appearance (`system-appearance.mjs`, S3) is the concept's "Browsing & display" surface
|
||||
on real values. Its browser rows are this browser's display defaults in the legacy
|
||||
gallery's own `gallerySettings` object (`web/js/workspace/preferences.mjs`, Q3): Default
|
||||
layout and Thumbnail size (`workspaceLayout` grid/list/masonry, `workspaceDensity`
|
||||
comfortable/large/compact, new keys), Load metadata on selection (`autoLoadMetadata`),
|
||||
Cache metadata in this browser (`cacheMetadata`), Image loading quality (`imageQuality`:
|
||||
Optimized→`medium`, Original→`high`, Fast→`low`; the thumbnail dialog's preselected quality
|
||||
once S5 lands), the three video rows (`videoAutoplay`, `videoMute`, `videoLoop`) and Show
|
||||
file paths (`showFilePaths`). A write re-reads the stored object and writes back only the
|
||||
changed keys, every other key (legacy paging, thumbnail state, unknown keys) as stored;
|
||||
the legacy gallery's Save also keeps the keys it does not edit, so the two pages stay in
|
||||
sync. The workspace never reads the legacy paging keys (`defaultLimit`, `gridColumns`,
|
||||
`infiniteScroll`; README continuous-browsing ruling). A browser that refuses storage
|
||||
(denied, or a full quota that leaves the stored bytes as they were) keeps the change for
|
||||
this tab only and the status says "Saved for this tab only; browser storage is
|
||||
unavailable.", never "Saved in this browser.". Another tab's change (a `storage` event)
|
||||
repaints the clean controls; a draft it overtook stays, highlighted, with the conflict
|
||||
status. Open from ComfyUI (New tab→`newtab`, Popup window→`popup`) and Node search
|
||||
auto-hide (seconds) (a whole number 0–300; anything else is highlighted and never sent)
|
||||
are the server's `webui_display_mode` and `result_timeout`, saved through
|
||||
`POST /prompt_manager/settings` with the revision. The consumers read the defaults on
|
||||
their next visit, never the current view: a new Images visit starts from the layout and
|
||||
size (the last visit keeps its own, a saved view opens at Grid and Comfortable as
|
||||
before); with metadata off a selection reads nothing until "Load metadata"; with the
|
||||
cache off selecting the shown file again re-reads it (on, the one current record is
|
||||
reused); paths off leaves out only the Path row of File & provenance; the viewer reads
|
||||
the video defaults.
|
||||
|
||||
Overview (`system-overview.mjs`) reads `GET /prompt_manager/system/overview` once per
|
||||
visit through `latestOnly()` on the section scope; a late answer after the section left
|
||||
@@ -1864,7 +1892,7 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged
|
||||
`-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no
|
||||
reader can keep it byte-identical (`test_health_check_never_writes`).
|
||||
|
||||
Recorded deviations (plan D60–D64 renumbered D92–D96; D121, D122 and D123 assigned):
|
||||
Recorded deviations (plan D60–D65 renumbered D92–D97; D121, D122 and D123 assigned):
|
||||
|
||||
- **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No
|
||||
unsaved changes.", "Unsaved changes.", "Saved in this browser." / "Saved to the server
|
||||
@@ -1922,6 +1950,18 @@ Recorded deviations (plan D60–D64 renumbered D92–D96; D121, D122 and D123 as
|
||||
the rest of Maintenance, above its D94 hand-off, as the native place that runs the
|
||||
health check. A press shows "Checking…" (`aria-disabled`) and opens the report; a
|
||||
failed check toasts "The health check could not finish. Nothing was changed."
|
||||
- **D97** (Appearance): added rows for the legacy gallery settings the concept leaves
|
||||
out, after its own rows: "Show image details on cards" (`showImageInfo`; off hides a
|
||||
card's title and file line, Info and Tag stay at the end of the row), "Collapse viewer
|
||||
details by default" (`sidebarCollapsedByDefault`; the viewer opens with
|
||||
`#viewer-details` hidden and a "Show details"/"Hide details" toggle with
|
||||
`aria-expanded`, the stage taking the whole width), and the operation "Reset display
|
||||
defaults" ("Restore this browser’s display defaults, then save. Other settings are
|
||||
kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only.
|
||||
The node search timeout shows the server's real value, not the sample 30. CSS under D97.
|
||||
- **D-NEW-S3-1** (inspector): with "Load metadata on selection" off, the Images panel and
|
||||
the viewer's details show "Metadata loads when you ask for it." and a "Load metadata"
|
||||
button instead of reading the file; the concept has no such state.
|
||||
|
||||
`tests/browser/system-concept.spec.mjs` compares the shell and Overview with the concept
|
||||
(dark and light, 1600×1100 and 390×844), mapping only the deviations above; unmapped,
|
||||
@@ -1934,15 +1974,23 @@ source when there are several, replaces only the chips with the fixture's expect
|
||||
results, and asserts the full unmapped path list; it maps D96 and compares the
|
||||
Maintenance notice unchanged. `system comparison detects mutations` removes a row,
|
||||
renames a metric label, swaps the `--line` token, recolours a chip, renames a check,
|
||||
removes the totals table and renames a caption. Paired captures (`{concept,production}-system-{dark,light}-{1600,390}.png`,
|
||||
`{concept,production}-{statistics,health}-{dark,light}.png`) are test artifacts; there
|
||||
removes the totals table and renames a caption. `appearance matches the concept` maps
|
||||
only D97 and the real timeout (unmapped, exactly those paths differ), and `appearance
|
||||
comparison detects mutations` removes a row, renames a label and an option and swaps
|
||||
the `--line` token. Paired captures (`{concept,production}-system-{dark,light}-{1600,390}.png`,
|
||||
`{concept,production}-{statistics,health}-{dark,light}.png`,
|
||||
`{concept,production}-appearance-{dark,light}-{1600,390}.png`) are test artifacts; there
|
||||
are no PNG baselines. `tests/browser/workspace-system.spec.mjs` covers the real counts,
|
||||
the health check and metric 4, a failed check, the thumbnail row against the status
|
||||
endpoint (painted after the rest when its read is held, unavailable when it fails), the review row (Q6), the section links, the Statistics dialog against the
|
||||
route, a load failure with Retry and late answers after leaving. Its hooks are
|
||||
route, a load failure with Retry and late answers after leaving, and Appearance: new
|
||||
visits versus the current view and a saved view, metadata off and on request, the cache,
|
||||
hidden paths, the video defaults, the host fields with their revision and a stale one,
|
||||
reload and a second tab, storage refused, the D97 rows and reset. Its hooks are
|
||||
`tests/browser/workspace_system_fixture.py` (`/__test__/system/*`: reset, injected
|
||||
failures, an absent source, a deleted linked file, unthumbnailed files) on the
|
||||
concept-shaped fixture, with delays through the shared request holds.
|
||||
concept-shaped fixture, with delays through the shared request holds; reset also restores
|
||||
the server's host settings and its `config.json`.
|
||||
|
||||
```sh
|
||||
python3 -m unittest tests.test_database_health tests.test_system_api tests.test_library -v
|
||||
|
||||
@@ -408,3 +408,128 @@ for (const theme of THEMES) test(`statistics and health dialogs match the concep
|
||||
await page.evaluate(() => { document.querySelector('#modal-body caption').textContent = 'Runs per category'; });
|
||||
expect(diffInventory(expectedStatistics, await page.evaluate(measureDialog)).map(item => item.path)).toEqual(['tables.0.caption']);
|
||||
});
|
||||
|
||||
/** Runs in the page: Appearance's surface, every row's text and control, and the concept's row geometry and tokens. */
|
||||
function measureAppearance(mainSelector) {
|
||||
const layout = document.querySelector(`${mainSelector} .settings-layout`);
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const text = node => node?.textContent.trim() ?? null;
|
||||
const surface = layout.querySelector(':scope > section.surface');
|
||||
const control = row => {
|
||||
const select = row.querySelector(':scope > select');
|
||||
if (select) return { kind: 'select', id: select.id, options: [...select.options].map(option => option.textContent),
|
||||
selected: select.selectedOptions[0]?.textContent ?? null };
|
||||
const input = row.querySelector(':scope > input');
|
||||
if (input) return { kind: `input.${input.type}`, id: input.id, value: input.value };
|
||||
const toggle = row.querySelector(':scope > * input[type="checkbox"]');
|
||||
if (toggle) return { kind: 'switch', id: toggle.id, wrapper: name(toggle.parentElement), checked: toggle.checked,
|
||||
...css(toggle.parentElement, 'width', 'height') };
|
||||
const chip = row.querySelector(':scope > .chip');
|
||||
return chip ? { kind: name(chip), text: text(chip), ...css(chip, 'color', 'backgroundColor', 'fontSize') } : null;
|
||||
};
|
||||
const rows = [...surface.querySelectorAll(':scope > .setting-row')];
|
||||
const operations = [...surface.querySelectorAll(':scope > .operation')];
|
||||
const firstRow = rows[0];
|
||||
return {
|
||||
layout: [...layout.children].map(name),
|
||||
surface: { tree: [...surface.children].map(name), heading: text(surface.querySelector(':scope > h2')),
|
||||
intro: text(surface.querySelector(':scope > p')),
|
||||
...css(surface, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius') },
|
||||
rows: rows.map(row => ({ tree: [...row.children].map(name), text: [...row.firstElementChild.children].map(node => [node.localName, text(node),
|
||||
node.localName === 'label' ? node.htmlFor : null]), control: control(row) })),
|
||||
operations: operations.map(row => ({ tree: [...row.children].map(name), title: text(row.querySelector('h3')),
|
||||
description: text(row.querySelector('p')), action: text(row.querySelector('.row-actions > button')) })),
|
||||
row: css(firstRow, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom', 'borderTopWidth',
|
||||
'borderTopColor', 'marginTop'),
|
||||
label: css(firstRow.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
|
||||
description: css(firstRow.querySelector('p'), 'fontSize', 'color', 'marginTop', 'maxWidth'),
|
||||
select: css(firstRow.querySelector('select'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor', 'color', 'borderTopColor'),
|
||||
heading3: css(surface.querySelector('.setting-row h3'), 'fontSize', 'fontWeight', 'color'),
|
||||
number: css(surface.querySelector('.setting-row input[type="number"]'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor'),
|
||||
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
};
|
||||
}
|
||||
|
||||
// D97: the rows Appearance adds for the legacy gallery settings, after the concept's own, as production renders them.
|
||||
const ADDED_APPEARANCE_ROWS = [
|
||||
['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', true],
|
||||
['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', false],
|
||||
];
|
||||
|
||||
/** The concept's Appearance with the recorded deviations applied, given the server's real settings. */
|
||||
function mapAppearanceConcept(reference, settings) {
|
||||
const toggleRow = reference.rows.find(row => row.control?.kind === 'switch');
|
||||
const added = ADDED_APPEARANCE_ROWS.map(([id, label, description, checked]) => ({
|
||||
tree: toggleRow.tree, text: [['label', label, id], ['p', description, null]],
|
||||
control: { ...toggleRow.control, id, checked } }));
|
||||
return {
|
||||
...reference,
|
||||
// D97: two added toggles and the "Reset display defaults" operation close the surface.
|
||||
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.setting-row', 'div.operation'] },
|
||||
rows: [...reference.rows.map(row => row.control?.id === 'timeout'
|
||||
// The node search timeout is the server's real `result_timeout`, not the concept's sample 30.
|
||||
? { ...row, control: { ...row.control, value: String(settings.result_timeout) } } : row), ...added],
|
||||
operations: [{ tree: ['div', 'div.row-actions'], title: 'Reset display defaults',
|
||||
description: 'Restore this browser’s display defaults, then save. Other settings are kept.', action: 'Reset' }],
|
||||
};
|
||||
}
|
||||
|
||||
async function conceptAppearance(page, theme) {
|
||||
await openConcept(page, 'system', theme);
|
||||
await page.locator('#main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
|
||||
await expect(page.locator('#main .settings-layout > section.surface > h2')).toHaveText('Browsing & display');
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureAppearance, '#main');
|
||||
}
|
||||
|
||||
async function productionAppearance(page, theme) {
|
||||
await openProduction(page, 'system/appearance', theme);
|
||||
await expect(page.locator('#workspace-main #timeout')).toBeEnabled();
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureAppearance, '#workspace-main');
|
||||
}
|
||||
|
||||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
test(`appearance matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
const { settings } = await serverJson(request, '/prompt_manager/settings');
|
||||
const concept = await conceptAppearance(page, theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`concept-appearance-${theme}-${width}.png`), fullPage: true });
|
||||
const production = await productionAppearance(page, theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`production-appearance-${theme}-${width}.png`), fullPage: true });
|
||||
expect(diffInventory(mapAppearanceConcept(concept, settings), production)).toEqual([]);
|
||||
expect(production.pageScroll).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
test('appearance comparison detects mutations', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const { settings } = await serverJson(request, '/prompt_manager/settings');
|
||||
const concept = await conceptAppearance(page, 'dark');
|
||||
const expected = mapAppearanceConcept(concept, settings);
|
||||
const production = await productionAppearance(page, 'dark');
|
||||
expect(diffInventory(expected, production)).toEqual([]);
|
||||
// Unmapped, the concept differs only where D97 and the real timeout are recorded.
|
||||
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.'))
|
||||
.filter((path, index, all) => all.indexOf(path) === index).sort())
|
||||
.toEqual(['operations.0', 'operations.length', 'rows.11', 'rows.12', 'rows.13', 'rows.length', 'surface.tree'].sort());
|
||||
const measure = () => page.evaluate(measureAppearance, '#workspace-main');
|
||||
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
|
||||
// A removed row.
|
||||
const undoRow = await mutate(page, { remove: ['#workspace-main .setting-row:nth-of-type(3)'] });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['rows.length', 'surface.tree.length']));
|
||||
await undoRow();
|
||||
// A changed label and a changed option.
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main label[for="density"]').textContent = 'Thumbnail sizes'; });
|
||||
expect(await paths()).toEqual(['rows.1.text.0.1']);
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main label[for="density"]').textContent = 'Thumbnail size'; });
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main #image-quality option').textContent = 'Medium'; });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['rows.5.control.options.0']));
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main #image-quality option').textContent = 'Optimized'; });
|
||||
// A swapped token.
|
||||
const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['row.borderTopColor']));
|
||||
await undoToken();
|
||||
expect(diffInventory(expected, await measure())).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -250,3 +250,303 @@ test('out-of-order overview responses never repaint a newer section', async ({ p
|
||||
await expect(layout.locator('.summary-metrics')).toHaveCount(1);
|
||||
await expect(layout.locator('.two-col')).toHaveCount(1);
|
||||
});
|
||||
|
||||
// System › Appearance (#249 System S3 Task 8): the browser display defaults (`gallerySettings`, preferences.mjs) and the
|
||||
// two host fields behind the one Save row, and the consumers that read them on their next visit.
|
||||
const SETTINGS_URL = '/prompt_manager/settings';
|
||||
const INSPECT_PATH = '/prompt_manager/images/inspect/';
|
||||
const statusOf = page => page.locator('#workspace-main #settings-status');
|
||||
const saveOf = page => page.locator('#workspace-main .save-row > button');
|
||||
const storedSettings = page => page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings') || 'null'));
|
||||
const imageCards = page => page.locator('#workspace-main article.media-card');
|
||||
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||||
|
||||
async function openAppearance(page) {
|
||||
await page.goto(`${workspace}#/system/appearance`);
|
||||
await expect(page.locator('#workspace-main .settings-layout > section.surface > h2')).toHaveText('Browsing & display');
|
||||
// The host rows are enabled once the server settings answered.
|
||||
await expect(page.locator('#timeout')).toBeEnabled();
|
||||
}
|
||||
|
||||
async function saveAppearance(page, text) {
|
||||
await expect(saveOf(page)).not.toHaveAttribute('aria-disabled', 'true');
|
||||
await saveOf(page).click();
|
||||
await expect(statusOf(page)).toHaveText(text);
|
||||
}
|
||||
|
||||
/** Set toggles (by row id) through their Toolkit switches. */
|
||||
async function toggle(page, values) {
|
||||
for (const [id, checked] of Object.entries(values)) await page.locator(`#${id}`).setChecked(checked, { force: true });
|
||||
}
|
||||
|
||||
async function openImages(page) {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
}
|
||||
|
||||
test('defaults apply to a new visit but not to the current view or a saved view', async ({ page, request }) => {
|
||||
const created = await request.post('/prompt_manager/saved-views', { data: { page: 'images', name: 'Everything',
|
||||
state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false } } });
|
||||
expect(created.status()).toBe(201);
|
||||
const view = (await created.json()).view;
|
||||
try {
|
||||
await openImages(page);
|
||||
// The current view: the toolbar sets List.
|
||||
await page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'List', exact: true }).click();
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
|
||||
await openAppearance(page);
|
||||
await expect(page.locator('#default-view')).toHaveValue('grid');
|
||||
await page.locator('#default-view').selectOption('masonry');
|
||||
await page.locator('#density').selectOption('compact');
|
||||
await expect(statusOf(page)).toHaveText('Unsaved changes.');
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
const stored = await storedSettings(page);
|
||||
expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['masonry', 'compact']);
|
||||
// Back in Images (the page session's last visit): the current view keeps its own layout and size.
|
||||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'comfortable');
|
||||
// The saved view opens as before.
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'grid');
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'comfortable');
|
||||
// A new visit starts from the defaults.
|
||||
await openImages(page);
|
||||
await page.reload();
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'masonry');
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
|
||||
await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true }))
|
||||
.toHaveAttribute('aria-pressed', 'true');
|
||||
} finally {
|
||||
await request.delete(`/prompt_manager/saved-views/${view.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('metadata off shows an explicit load and loads once on request', async ({ page }) => {
|
||||
await openAppearance(page);
|
||||
await toggle(page, { 'metadata-auto': false });
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
const reads = [];
|
||||
page.on('request', item => { if (new URL(item.url()).pathname.startsWith(INSPECT_PATH)) reads.push(item.url()); });
|
||||
await openImages(page);
|
||||
const first = imageCards(page).first();
|
||||
await first.locator('.image-card-actions button').first().click();
|
||||
await expect(panel(page).getByRole('status')).toHaveText('Metadata loads when you ask for it.');
|
||||
const load = panel(page).getByRole('button', { name: 'Load metadata', exact: true });
|
||||
await expect(load).toBeVisible();
|
||||
expect(reads).toEqual([]);
|
||||
await load.click();
|
||||
await expect(panel(page).locator('.panel-heading h2')).toBeVisible();
|
||||
expect(reads).toHaveLength(1);
|
||||
// The same file again reads nothing more (the cache is on).
|
||||
await first.locator('.image-card-actions button').first().click();
|
||||
await expect(panel(page).locator('.panel-heading h2')).toBeVisible();
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
expect(reads).toHaveLength(1);
|
||||
// The viewer's details use the same inspector: another file waits there too, and loads on request.
|
||||
await imageCards(page).nth(1).locator('.image-open').click();
|
||||
const viewerDetails = page.locator('#viewer-details');
|
||||
await expect(viewerDetails.getByRole('status')).toHaveText('Metadata loads when you ask for it.');
|
||||
expect(reads).toHaveLength(1);
|
||||
await viewerDetails.getByRole('button', { name: 'Load metadata', exact: true }).click();
|
||||
await expect(viewerDetails.locator('.panel-heading h2')).toBeVisible();
|
||||
expect(reads).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('cache off rereads the current selection; on reuses the one current record', async ({ page }) => {
|
||||
const reads = [];
|
||||
page.on('request', item => { if (new URL(item.url()).pathname.startsWith(INSPECT_PATH)) reads.push(item.url()); });
|
||||
const settle = () => page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
for (const cache of [true, false]) {
|
||||
if (!cache) {
|
||||
await openAppearance(page);
|
||||
await toggle(page, { 'metadata-cache': false });
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
await page.reload();
|
||||
}
|
||||
await openImages(page);
|
||||
const info = imageCards(page).first().locator('.image-card-actions button').first();
|
||||
await info.click();
|
||||
await expect(panel(page).locator('.panel-heading h2')).toBeVisible();
|
||||
await expect.poll(() => reads.length).toBeGreaterThan(0);
|
||||
await settle();
|
||||
reads.length = 0;
|
||||
// Inspect the selected file twice more.
|
||||
for (const _ of [1, 2]) {
|
||||
const answered = cache ? null : page.waitForResponse(response => new URL(response.url()).pathname.startsWith(INSPECT_PATH));
|
||||
await info.click();
|
||||
if (answered) await answered;
|
||||
await expect(panel(page).locator('.panel-heading h2')).toBeVisible();
|
||||
}
|
||||
await settle();
|
||||
expect(reads, `cache ${cache}`).toHaveLength(cache ? 0 : 2);
|
||||
}
|
||||
});
|
||||
|
||||
test('paths hidden only changes display', async ({ page }) => {
|
||||
const rows = async () => page.locator('#viewer-details details').filter({ hasText: 'File & provenance' }).locator('dt')
|
||||
.evaluateAll(terms => terms.map(term => term.textContent));
|
||||
const openViewer = async () => {
|
||||
await openImages(page);
|
||||
await imageCards(page).first().locator('.image-open').click();
|
||||
const provenance = page.locator('#viewer-details details').filter({ hasText: 'File & provenance' });
|
||||
await provenance.locator('summary').click();
|
||||
await expect(provenance.locator('dl')).toBeVisible();
|
||||
return provenance;
|
||||
};
|
||||
let provenance = await openViewer();
|
||||
expect(await rows()).toContain('Path');
|
||||
const before = { show: await provenance.getByRole('button', { name: /^Show .* in folder$/ }).count(),
|
||||
download: await provenance.getByRole('link', { name: 'Download original', exact: true }).getAttribute('href') };
|
||||
await page.keyboard.press('Escape');
|
||||
await openAppearance(page);
|
||||
await toggle(page, { paths: false });
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
provenance = await openViewer();
|
||||
const shown = await rows();
|
||||
expect(shown).not.toContain('Path');
|
||||
expect(shown).toEqual(expect.arrayContaining(['Source', 'Size']));
|
||||
// Everything else is as it was: the folder action and the original download stay.
|
||||
expect(await provenance.getByRole('button', { name: /^Show .* in folder$/ }).count()).toBe(before.show);
|
||||
await expect(provenance.getByRole('link', { name: 'Download original', exact: true })).toHaveAttribute('href', before.download);
|
||||
});
|
||||
|
||||
test('video defaults reach the viewer', async ({ page }) => {
|
||||
await openAppearance(page);
|
||||
await toggle(page, { 'video-auto': true, 'video-muted': false, 'video-loop': false });
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
expect(await page.evaluate(async () => (await import('/prompt_manager/js/workspace/media-viewer.mjs')).readViewerPreferences()))
|
||||
.toEqual({ videoAutoplay: true, videoMute: false, videoLoop: false });
|
||||
const stored = await storedSettings(page);
|
||||
expect([stored.videoAutoplay, stored.videoMute, stored.videoLoop]).toEqual([true, false, false]);
|
||||
});
|
||||
|
||||
test('host fields round-trip through /settings with revision', async ({ page, request }) => {
|
||||
const posts = [];
|
||||
page.on('request', item => { if (item.method() === 'POST' && new URL(item.url()).pathname === SETTINGS_URL) posts.push(item.postDataJSON()); });
|
||||
const before = await serverJson(request, SETTINGS_URL);
|
||||
await openAppearance(page);
|
||||
await expect(page.locator('#open-mode')).toHaveValue(before.settings.webui_display_mode);
|
||||
await expect(page.locator('#timeout')).toHaveValue(String(before.settings.result_timeout));
|
||||
// Only whole seconds from 0 to 300: anything else is highlighted and never sent.
|
||||
for (const bad of ['301', '-1', '2.5']) {
|
||||
await page.locator('#timeout').fill(bad);
|
||||
await page.locator('#timeout').press('Tab');
|
||||
await expect(page.locator('#timeout')).toHaveAttribute('aria-invalid', 'true');
|
||||
await expect(saveOf(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
}
|
||||
await page.locator('#timeout').fill('0');
|
||||
await page.locator('#timeout').press('Tab');
|
||||
await expect(page.locator('#timeout')).not.toHaveAttribute('aria-invalid');
|
||||
await page.locator('#open-mode').selectOption('popup');
|
||||
await saveAppearance(page, 'Saved to the server configuration.');
|
||||
expect(posts).toEqual([{ webui_display_mode: 'popup', result_timeout: 0, expected_revision: before.revision }]);
|
||||
const after = await serverJson(request, SETTINGS_URL);
|
||||
expect([after.settings.webui_display_mode, after.settings.result_timeout]).toEqual(['popup', 0]);
|
||||
expect(after.revision).not.toBe(before.revision);
|
||||
// No browser key was written for a host field.
|
||||
expect(await storedSettings(page)).toBeNull();
|
||||
});
|
||||
|
||||
test('stale revision shows the conflict status', async ({ page, request }) => {
|
||||
await openAppearance(page);
|
||||
// Another page saves after this one read the settings.
|
||||
const current = await serverJson(request, SETTINGS_URL);
|
||||
await expect(await request.post(SETTINGS_URL, { data: { result_timeout: 42, expected_revision: current.revision } })).toBeOK();
|
||||
await page.locator('#open-mode').selectOption('popup');
|
||||
await saveAppearance(page, 'Settings changed elsewhere. Reload this section to see them.');
|
||||
await expect(page.locator('#open-mode')).toHaveAttribute('aria-invalid', 'true');
|
||||
// The draft is kept and the section now shows the other page's value.
|
||||
await expect(page.locator('#open-mode')).toHaveValue('popup');
|
||||
await expect(page.locator('#timeout')).toHaveValue('42');
|
||||
expect((await serverJson(request, SETTINGS_URL)).settings.webui_display_mode).toBe('newtab');
|
||||
// With the reloaded revision, Save succeeds.
|
||||
await saveAppearance(page, 'Saved to the server configuration.');
|
||||
expect((await serverJson(request, SETTINGS_URL)).settings.webui_display_mode).toBe('popup');
|
||||
});
|
||||
|
||||
test('reload keeps saved values; a new tab reads them', async ({ page, context }) => {
|
||||
await openAppearance(page);
|
||||
await page.locator('#image-quality').selectOption('high');
|
||||
await toggle(page, { 'card-details': false });
|
||||
await page.locator('#open-mode').selectOption('popup');
|
||||
await saveAppearance(page, 'Saved in this browser. Saved to the server configuration.');
|
||||
await page.reload();
|
||||
await expect(page.locator('#timeout')).toBeEnabled();
|
||||
await expect(page.locator('#image-quality')).toHaveValue('high');
|
||||
await expect(page.locator('#card-details')).not.toBeChecked();
|
||||
await expect(page.locator('#open-mode')).toHaveValue('popup');
|
||||
const other = await context.newPage();
|
||||
await openAppearance(other);
|
||||
await expect(other.locator('#image-quality')).toHaveValue('high');
|
||||
await expect(other.locator('#card-details')).not.toBeChecked();
|
||||
// A change saved in one tab reaches the other's clean controls; a draft it overtakes is flagged and kept.
|
||||
await other.locator('#density').selectOption('large');
|
||||
await page.locator('#density').selectOption('compact');
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
await expect(other.locator('#density')).toHaveValue('large');
|
||||
await expect(other.locator('#settings-status')).toHaveText('Settings changed elsewhere. Reload this section to see them.');
|
||||
await expect(other.locator('#density')).toHaveAttribute('aria-invalid', 'true');
|
||||
await page.locator('#image-quality').selectOption('low');
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
await expect(other.locator('#image-quality')).toHaveValue('low');
|
||||
await other.close();
|
||||
});
|
||||
|
||||
test('a browser that refuses storage is saved for this tab only, never "Saved"', async ({ page }) => {
|
||||
await openAppearance(page);
|
||||
await page.evaluate(() => {
|
||||
Storage.prototype.setItem = function setItem() { throw new DOMException('Quota exceeded', 'QuotaExceededError'); };
|
||||
});
|
||||
await page.locator('#default-view').selectOption('list');
|
||||
await saveAppearance(page, 'Saved for this tab only; browser storage is unavailable.');
|
||||
expect(await storedSettings(page)).toBeNull();
|
||||
// This tab's next Images visit still uses it.
|
||||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
|
||||
});
|
||||
|
||||
test('added rows reach their consumers', async ({ page }) => {
|
||||
await openImages(page);
|
||||
await expect(imageCards(page).first().locator('.media-info h3')).toBeVisible();
|
||||
await openAppearance(page);
|
||||
await toggle(page, { 'card-details': false, 'viewer-details': true });
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
await page.reload();
|
||||
await openImages(page);
|
||||
// Show image details on cards off: no title or file line; Info and Tag stay.
|
||||
const card = imageCards(page).first();
|
||||
await expect(card.locator('.media-info h3')).toBeHidden();
|
||||
await expect(card.locator('.image-card-actions button')).toHaveCount(2);
|
||||
// Collapse viewer details by default: the viewer opens with its details hidden behind one toggle.
|
||||
await card.locator('.image-open').click();
|
||||
const details = page.locator('#viewer-details');
|
||||
await expect(page.locator('#viewer-dialog')).toBeVisible();
|
||||
await expect(details).toBeHidden();
|
||||
const show = page.locator('#viewer-toolbar').getByRole('button', { name: 'Show details', exact: true });
|
||||
await expect(show).toHaveAttribute('aria-expanded', 'false');
|
||||
await show.click();
|
||||
await expect(details).toBeVisible();
|
||||
await expect(page.locator('#viewer-toolbar').getByRole('button', { name: 'Hide details', exact: true })).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
test('reset restores defaults without touching other keys', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/system/appearance`);
|
||||
await page.evaluate(() => localStorage.setItem('gallerySettings', JSON.stringify({ gridColumns: 5, defaultLimit: 200,
|
||||
thumbnailPromptDismissed: 'kept', workspaceLayout: 'list', videoMute: false, showImageInfo: false })));
|
||||
await page.reload();
|
||||
await expect(page.locator('#timeout')).toBeEnabled();
|
||||
await expect(page.locator('#default-view')).toHaveValue('list');
|
||||
const reset = page.locator('#workspace-main .operation').filter({ hasText: 'Reset display defaults' }).getByRole('button', { name: 'Reset', exact: true });
|
||||
await reset.click();
|
||||
await expect(page.locator('#default-view')).toHaveValue('grid');
|
||||
await expect(page.locator('#video-muted')).toBeChecked();
|
||||
await expect(statusOf(page)).toHaveText('Unsaved changes.');
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
const stored = await storedSettings(page);
|
||||
expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'grid',
|
||||
videoMute: true, showImageInfo: true });
|
||||
});
|
||||
|
||||
@@ -425,7 +425,7 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept
|
||||
const SYSTEM_SECTION_LABELS = ['Overview', 'Appearance', 'Sources & scans', 'Integrations', 'Auto-tagging', 'Maintenance',
|
||||
'Backup & export', 'Diagnostics & logs'];
|
||||
const SYSTEM_HASHES = ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics'];
|
||||
const SYSTEM_HANDOFFS = { appearance: ['/prompt_manager/gallery', '/prompt_manager/admin'], sources: ['/prompt_manager/admin', '/prompt_manager/gallery'],
|
||||
const SYSTEM_HANDOFFS = { sources: ['/prompt_manager/admin', '/prompt_manager/gallery'],
|
||||
maintenance: ['/prompt_manager/admin', '/prompt_manager/gallery'] };
|
||||
|
||||
test('System is native: the concept shell, section navigation, sections, hand-offs and the Save row', async ({ page }) => {
|
||||
@@ -462,14 +462,14 @@ test('System is native: the concept shell, section navigation, sections, hand-of
|
||||
await expect(nav.locator('[aria-pressed="true"]')).toHaveCount(1);
|
||||
// The section changes in place: focus stays on the pressed section, not the page title.
|
||||
await expect(button).toBeFocused();
|
||||
// S2 Task 6: Overview is native (workspace-system.spec.mjs); Maintenance shows its health-check notice above
|
||||
// the hand-off (D123); every other section hands off.
|
||||
// S2 Task 6: Overview is native (workspace-system.spec.mjs), and so is Appearance (S3 Task 8); Maintenance shows
|
||||
// its health-check notice above the hand-off (D123); every other section hands off.
|
||||
if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible();
|
||||
expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
|
||||
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'],
|
||||
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'div.save-row'],
|
||||
maintenance: ['div.notice', 'section.surface handoff', 'div.save-row'] }[hash]
|
||||
?? ['section.surface handoff', 'div.save-row']);
|
||||
if (hash === 'overview') continue;
|
||||
if (hash === 'overview' || hash === 'appearance') continue;
|
||||
const handoff = layout.locator('section.handoff');
|
||||
await expect(handoff.locator('h2')).toHaveText('Your current tools are ready');
|
||||
expect(await handoff.locator('a.workspace-link').evaluateAll(links => links.map(link => link.getAttribute('href'))))
|
||||
|
||||
@@ -31,9 +31,17 @@ MAX_UNTHUMBNAILED = 50
|
||||
|
||||
|
||||
def register_system_commands(app, routes, api, root: Path):
|
||||
from py.config import GalleryConfig
|
||||
from py.config import GalleryConfig, PromptManagerConfig
|
||||
|
||||
configured = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
# System › Appearance saves the host fields to this server's config.json (S3);
|
||||
# reset restores both.
|
||||
config_path = Path(os.environ["PROMPT_MANAGER_CONFIG_PATH"])
|
||||
config_bytes = config_path.read_bytes() if config_path.exists() else None
|
||||
host_values = (
|
||||
PromptManagerConfig.RESULT_TIMEOUT,
|
||||
PromptManagerConfig.WEBUI_DISPLAY_MODE,
|
||||
)
|
||||
failures: dict[str, int] = {}
|
||||
|
||||
@web.middleware
|
||||
@@ -52,6 +60,13 @@ def register_system_commands(app, routes, api, root: Path):
|
||||
failures.clear()
|
||||
GalleryConfig.MONITORING_DIRECTORIES = list(configured)
|
||||
api._health_report = None
|
||||
PromptManagerConfig.RESULT_TIMEOUT, PromptManagerConfig.WEBUI_DISPLAY_MODE = (
|
||||
host_values
|
||||
)
|
||||
if config_bytes is None:
|
||||
config_path.unlink(missing_ok=True)
|
||||
else:
|
||||
config_path.write_bytes(config_bytes)
|
||||
|
||||
@routes.post("/__test__/system/reset")
|
||||
async def system_reset(request):
|
||||
|
||||
@@ -478,3 +478,19 @@ test('a refresh deferred behind a page load is dropped when the stream is dispos
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(requests.length,2,'no refresh runs after dispose');
|
||||
});
|
||||
|
||||
test('defaults apply to a new visit but not to the current view or a saved view', async () => {
|
||||
const { initialImagesView } = await source;
|
||||
assert.equal(typeof initialImagesView, 'function', 'initialImagesView is exported');
|
||||
const preferences = { workspaceLayout: 'masonry', workspaceDensity: 'compact' };
|
||||
// A new visit starts from System › Appearance.
|
||||
assert.deepEqual(initialImagesView({ preferences }), { layout: 'masonry', density: 'compact' });
|
||||
// The current view (the last visit) keeps its own toolbar choices.
|
||||
assert.deepEqual(initialImagesView({ prior: { layout: 'list', density: 'large' }, preferences }), { layout: 'list', density: 'large' });
|
||||
assert.deepEqual(initialImagesView({ prior: {}, preferences }), { layout: 'grid', density: 'comfortable' });
|
||||
// A saved view opens as it always did.
|
||||
assert.deepEqual(initialImagesView({ saved: { q: '' }, preferences }), { layout: 'grid', density: 'comfortable' });
|
||||
// Without readable preferences nothing changes.
|
||||
assert.deepEqual(initialImagesView({}), { layout: 'grid', density: 'comfortable' });
|
||||
assert.deepEqual(initialImagesView({ preferences: {} }), { layout: 'grid', density: 'comfortable' });
|
||||
});
|
||||
|
||||
@@ -825,3 +825,56 @@ test('refresh re-reads the selected file and keeps the shown record until the ne
|
||||
assert.equal(controller.state.record.identity.path, 'folder/b.png');
|
||||
controller.dispose(); assert.equal(await controller.refresh(), undefined);
|
||||
});
|
||||
|
||||
test('metadata off shows an explicit load and loads once on request', async () => {
|
||||
const { api, requests } = pendingApi(), updates = [];
|
||||
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts, autoLoad: false });
|
||||
controller.subscribe(state => updates.push(state));
|
||||
// Selecting reads nothing: the state says the metadata waits for a request.
|
||||
assert.equal(controller.select(image()), undefined);
|
||||
assert.equal(requests.length, 0);
|
||||
assert.equal(controller.state.deferred, true);
|
||||
assert.equal(controller.state.loading, false);
|
||||
assert.equal(controller.state.record, null);
|
||||
// Selecting the same file again still reads nothing.
|
||||
controller.select({ ...image() });
|
||||
assert.equal(requests.length, 0);
|
||||
const load = controller.load();
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(controller.state.loading, true);
|
||||
assert.equal(controller.state.deferred, false);
|
||||
// A second press while it reads, and the same file selected again once it loaded, read nothing more.
|
||||
controller.load();
|
||||
assert.equal(requests.length, 1);
|
||||
requests[0].resolve(envelope()); await load;
|
||||
assert.equal(controller.state.record.generation.seed.value, '9007199254740993');
|
||||
controller.load(); controller.select(image());
|
||||
assert.equal(requests.length, 1);
|
||||
// Another file waits for its own request again.
|
||||
controller.select({ ...image(), path: 'folder/b.png', filename: 'b.png', url: image().url.replace('a%20%23%25', 'b') });
|
||||
assert.equal(controller.state.deferred, true);
|
||||
assert.equal(requests.length, 1);
|
||||
// The default controller loads on selection, as before.
|
||||
const eager = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
|
||||
eager.select(image());
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(eager.state.deferred, false);
|
||||
eager.dispose(); controller.dispose();
|
||||
assert.equal(controller.load(), undefined);
|
||||
});
|
||||
|
||||
test('cache off rereads the current selection; on reuses the one current record', async () => {
|
||||
for (const cache of [true, false]) {
|
||||
const { api, requests } = pendingApi();
|
||||
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts, cache });
|
||||
const first = controller.select(image());
|
||||
// A read in flight is shared either way.
|
||||
controller.select(image());
|
||||
assert.equal(requests.length, 1);
|
||||
requests[0].resolve(envelope()); await first;
|
||||
const again = controller.select(image());
|
||||
assert.equal(requests.length, cache ? 1 : 2, `cache ${cache}`);
|
||||
if (!cache) { requests[1].resolve(envelope()); await again; assert.equal(controller.state.record.identity.path, 'folder/a #%.png'); }
|
||||
controller.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// System › Appearance (#249 System S3 Task 8): the concept's "Browsing & display" rows (app.js 676–678) on the drafts
|
||||
// of the one Save row, their stored values (Q3 mappings), the host timeout's 0–300 whole seconds and the D97 rows.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const gallerySettings = require('../../web/js/gallery-settings.js');
|
||||
|
||||
const appearanceModule = import('../../web/js/workspace/system-appearance.mjs');
|
||||
const stateModule = import('../../web/js/workspace/system-state.mjs');
|
||||
const coreModule = import('../../web/js/shared/toolkit/core.js');
|
||||
globalThis.GallerySettings = gallerySettings;
|
||||
|
||||
/** A minimal DOM whose elements count their live listeners (a listener's signal aborts it, as in a browser). */
|
||||
function fakeDOM() {
|
||||
const previous = global.document;
|
||||
const elements = [];
|
||||
class Element {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.attributes = {};
|
||||
this.listeners = [];
|
||||
this.dataset = {};
|
||||
this.className = '';
|
||||
this.id = '';
|
||||
this.value = '';
|
||||
this._text = '';
|
||||
elements.push(this);
|
||||
}
|
||||
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
|
||||
set textContent(text) { this.children = []; this._text = String(text); }
|
||||
append(...nodes) {
|
||||
for (const node of nodes) {
|
||||
const child = typeof node === 'string' ? Object.assign(new Element('#text'), { _text: node }) : node;
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
}
|
||||
}
|
||||
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
|
||||
removeAttribute(name) { delete this.attributes[name]; }
|
||||
addEventListener(type, callback, options = {}) {
|
||||
if (options.signal?.aborted) return;
|
||||
const entry = { type, callback };
|
||||
this.listeners.push(entry);
|
||||
options.signal?.addEventListener('abort', () => { this.listeners = this.listeners.filter(item => item !== entry); });
|
||||
}
|
||||
removeEventListener(type, callback) { this.listeners = this.listeners.filter(item => item.type !== type || item.callback !== callback); }
|
||||
dispatch(type) { for (const { callback } of this.listeners.filter(item => item.type === type)) callback({ type, target: this }); }
|
||||
click() { this.dispatch('click'); }
|
||||
}
|
||||
global.document = {
|
||||
createElement: tag => new Element(tag),
|
||||
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
|
||||
};
|
||||
const walk = (node, found = []) => { found.push(node); node.children.forEach(child => walk(child, found)); return found; };
|
||||
return {
|
||||
listenerCount: () => elements.reduce((sum, element) => sum + element.listeners.length, 0),
|
||||
find: (root, predicate) => walk(root).find(predicate),
|
||||
tags: node => node.children.map(child => child.tagName.toLowerCase()),
|
||||
restore() { global.document = previous; },
|
||||
};
|
||||
}
|
||||
|
||||
const BROWSER = { workspaceLayout: 'grid', workspaceDensity: 'comfortable', autoLoadMetadata: true, cacheMetadata: true,
|
||||
imageQuality: 'medium', videoAutoplay: false, videoMute: true, videoLoop: true, showFilePaths: true, showImageInfo: true,
|
||||
sidebarCollapsedByDefault: false };
|
||||
|
||||
async function mounted({ host = { webui_display_mode: 'newtab', result_timeout: 5 }, browser = BROWSER } = {}) {
|
||||
const { mountAppearance } = await appearanceModule;
|
||||
const { createSystemDrafts } = await stateModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
let hostValues = null;
|
||||
const drafts = createSystemDrafts({
|
||||
readBrowser: () => ({ ...browser }), readRuntime: () => ({}),
|
||||
readHost: async () => ({ values: hostValues, revision: 'sha256:one' }),
|
||||
writeBrowser: async () => ({ persisted: true }), writeHost: async () => ({ success: true, persisted: true }), writeRuntime: async () => ({}),
|
||||
});
|
||||
await drafts.load('browser');
|
||||
const scope = createScope();
|
||||
const [surface] = mountAppearance(null, { scope, drafts });
|
||||
const byId = id => dom.find(surface, node => node.dataset?.setting === id);
|
||||
return { dom, drafts, scope, surface, byId, loadHost: async () => { hostValues = host; await drafts.load('host'); } };
|
||||
}
|
||||
|
||||
test('the surface is the concept section with the concept rows in order, then the D97 rows', async () => {
|
||||
const { APPEARANCE_ROWS } = await appearanceModule;
|
||||
const { dom, surface, scope } = await mounted();
|
||||
try {
|
||||
assert.equal(surface.className, 'surface');
|
||||
assert.equal(surface.children[0].textContent, 'Browsing & display');
|
||||
assert.equal(surface.children[1].textContent, 'One set of defaults; toolbar changes apply to the current view.');
|
||||
const rows = surface.children.slice(2);
|
||||
const titles = rows.map(row => row.children[0].children[0].textContent);
|
||||
assert.deepEqual(titles, ['Default layout', 'Thumbnail size', 'Continuous browsing', 'Load metadata on selection',
|
||||
'Cache metadata in this browser', 'Image loading quality', 'Autoplay videos', 'Mute videos by default', 'Loop videos',
|
||||
'Show file paths', 'Open from ComfyUI', 'Node search auto-hide (seconds)', 'Show image details on cards',
|
||||
'Collapse viewer details by default', 'Reset display defaults']);
|
||||
const continuous = rows[2];
|
||||
assert.deepEqual(dom.tags(continuous.children[0]), ['h3', 'p']);
|
||||
assert.equal(continuous.children[1].className, 'chip blue');
|
||||
assert.equal(continuous.children[1].textContent, 'Infinite scroll');
|
||||
assert.equal(rows.at(-1).className, 'operation');
|
||||
assert.equal(APPEARANCE_ROWS.filter(Array.isArray).length, 13);
|
||||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||||
});
|
||||
|
||||
test('added rows reach their consumers: each control edits its stored key with the Q3 values', async () => {
|
||||
const { dom, drafts, scope, byId, loadHost } = await mounted();
|
||||
try {
|
||||
// Host rows stay disabled until the server settings answered.
|
||||
assert.equal(byId('open-mode').disabled, true);
|
||||
assert.equal(byId('timeout').disabled, true);
|
||||
await loadHost();
|
||||
assert.equal(byId('open-mode').disabled, false);
|
||||
assert.equal(byId('timeout').value, '5');
|
||||
assert.deepEqual(byId('image-quality').children.map(option => [option.value, option.textContent]),
|
||||
[['medium', 'Optimized'], ['high', 'Original'], ['low', 'Fast']]);
|
||||
assert.deepEqual(byId('open-mode').children.map(option => [option.value, option.textContent]),
|
||||
[['newtab', 'New tab'], ['popup', 'Popup window']]);
|
||||
const edits = [['default-view', 'masonry'], ['density', 'large'], ['image-quality', 'low'], ['open-mode', 'popup']];
|
||||
for (const [id, value] of edits) { byId(id).value = value; byId(id).dispatch('change'); }
|
||||
for (const id of ['metadata-auto', 'metadata-cache', 'video-muted', 'video-loop', 'paths', 'card-details']) {
|
||||
byId(id).checked = false; byId(id).dispatch('change');
|
||||
}
|
||||
for (const id of ['video-auto', 'viewer-details']) { byId(id).checked = true; byId(id).dispatch('change'); }
|
||||
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', workspaceDensity: 'large', imageQuality: 'low',
|
||||
autoLoadMetadata: false, cacheMetadata: false, videoMute: false, videoLoop: false, showFilePaths: false, showImageInfo: false,
|
||||
videoAutoplay: true, sidebarCollapsedByDefault: true });
|
||||
assert.deepEqual(drafts.changes('host'), { webui_display_mode: 'popup' });
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('the auto-hide timeout takes whole seconds from 0 to 300 only', async () => {
|
||||
const { resultTimeout } = await appearanceModule;
|
||||
assert.deepEqual(['0', '30', ' 300 ', '301', '-1', '2.5', '', 'abc', '1e2'].map(resultTimeout), [0, 30, 300, null, null, null, null, null, null]);
|
||||
const { dom, drafts, scope, byId, loadHost } = await mounted();
|
||||
try {
|
||||
await loadHost();
|
||||
const timeout = byId('timeout');
|
||||
timeout.value = '45'; timeout.dispatch('change');
|
||||
assert.deepEqual(drafts.changes('host'), { result_timeout: 45 });
|
||||
timeout.value = '301'; timeout.dispatch('change');
|
||||
assert.deepEqual(drafts.changes('host'), {});
|
||||
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
|
||||
timeout.value = '0'; timeout.dispatch('change');
|
||||
assert.deepEqual(drafts.changes('host'), { result_timeout: 0 });
|
||||
assert.deepEqual([...drafts.failed('host')], []);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('reset restores defaults without touching other keys', async () => {
|
||||
const { DISPLAY_KEYS } = await appearanceModule;
|
||||
const changed = { ...BROWSER, workspaceLayout: 'list', videoMute: false, showImageInfo: false, sidebarCollapsedByDefault: true };
|
||||
const { dom, drafts, scope, surface, loadHost } = await mounted({ browser: changed });
|
||||
try {
|
||||
await loadHost();
|
||||
drafts.set('host', 'result_timeout', 9);
|
||||
const reset = dom.find(surface.children.at(-1), node => node.tagName === 'BUTTON');
|
||||
assert.equal(reset.textContent, 'Reset');
|
||||
reset.click();
|
||||
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'grid', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false });
|
||||
// The host fields and every key outside the display defaults are left as they are.
|
||||
assert.deepEqual(drafts.changes('host'), { result_timeout: 9 });
|
||||
assert.deepEqual([...DISPLAY_KEYS].sort(), Object.keys(BROWSER).sort());
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
@@ -259,3 +259,27 @@ test('a failed read keeps the store usable and drafts survive a reload (Q10)', a
|
||||
assert.equal(failing.dirty(), false);
|
||||
assert.throws(() => createSystemDrafts({ ...domains, writeHost: null }), TypeError);
|
||||
});
|
||||
|
||||
test('a rejected value drops its draft and is highlighted; a conflict elsewhere keeps the drafts', async () => {
|
||||
const domains = adapters({ browser: { videoMute: true, videoLoop: true }, host: { result_timeout: 5 } });
|
||||
const drafts = await loaded(domains);
|
||||
assert.deepEqual(drafts.saved('browser'), { videoMute: true, videoLoop: true });
|
||||
drafts.set('host', 'result_timeout', 12);
|
||||
assert.equal(drafts.dirty('host'), true);
|
||||
drafts.reject('host', 'result_timeout');
|
||||
assert.equal(drafts.dirty('host'), false);
|
||||
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
|
||||
assert.equal(drafts.get('host', 'result_timeout'), 5);
|
||||
// A valid value clears the highlight.
|
||||
drafts.set('host', 'result_timeout', 7);
|
||||
assert.deepEqual([...drafts.failed('host')], []);
|
||||
drafts.discard();
|
||||
drafts.set('browser', 'videoMute', false);
|
||||
drafts.conflict('browser', []);
|
||||
assert.equal(drafts.statusText(), 'Unsaved changes.');
|
||||
drafts.conflict('browser', ['videoMute']);
|
||||
assert.equal(drafts.statusText(), 'Settings changed elsewhere. Reload this section to see them.');
|
||||
assert.deepEqual([...drafts.failed('browser')], ['videoMute']);
|
||||
assert.equal(drafts.get('browser', 'videoMute'), false);
|
||||
assert.throws(() => drafts.reject('nowhere', 'x'), TypeError);
|
||||
});
|
||||
|
||||
+14
-8
@@ -403,8 +403,8 @@
|
||||
},
|
||||
"workspace/preferences.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
"functions": 91,
|
||||
"branches": 96,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/prompt-blocks.mjs": {
|
||||
@@ -449,6 +449,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/system-appearance.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/system-controls.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 87,
|
||||
@@ -463,15 +469,15 @@
|
||||
},
|
||||
"workspace/system-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
"functions": 92,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/system.mjs": {
|
||||
"lines": 93,
|
||||
"branches": 85,
|
||||
"functions": 69,
|
||||
"statements": 93
|
||||
"lines": 96,
|
||||
"branches": 92,
|
||||
"functions": 84,
|
||||
"statements": 96
|
||||
},
|
||||
"workspace/theme.mjs": {
|
||||
"lines": 98,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { navigateTo } from './state.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { openCopyFallback } from './workspace-dialogs.mjs';
|
||||
import { displayPreferences } from './preferences.mjs';
|
||||
|
||||
const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs'];
|
||||
const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.';
|
||||
@@ -196,7 +197,10 @@ function prefixedChip(prefix, text) {
|
||||
* `variant: 'full'` (the viewer's `#viewer-details`, imageDetails(i, true)) leaves out the cover and adds "File &
|
||||
* provenance" and "Workflow & raw metadata" with the downloads. `inline: true` means `panel`; neither means `full`.
|
||||
*/
|
||||
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onInteraction = () => {}, copyFallback = openCopyFallback}) {
|
||||
/** D-NEW-S3-1: the inspector's line while "Load metadata on selection" is off. */
|
||||
export const DEFERRED_TEXT = 'Metadata loads when you ask for it.';
|
||||
|
||||
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback}) {
|
||||
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
|
||||
const scope = childScope(parentScope);
|
||||
let contentScope;
|
||||
@@ -235,6 +239,13 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
function render(state) {
|
||||
const inner = el('div', undefined, full ? undefined : 'detail-inner');
|
||||
if (state.loading || state.error) {container.append(inner); statusView(state, inner); return;}
|
||||
// D-NEW-S3-1: "Load metadata on selection" is off, so the file's details wait for an explicit request.
|
||||
if (state.deferred) {
|
||||
container.append(inner);
|
||||
const status = el('p', DEFERRED_TEXT, 'small'); status.setAttribute('role', 'status'); inner.append(status);
|
||||
if (onLoad) {const load = createButton('Load metadata'); contentScope.listen(load, 'click', onLoad); inner.append(load);}
|
||||
return;
|
||||
}
|
||||
const record = state.record;
|
||||
if (!record) return;
|
||||
const {file, library} = record;
|
||||
@@ -326,8 +337,10 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
section.append(el('p', reported ? 'Current file at the reported location · identity unverified' : local ? 'Selected local file · read in this browser' :
|
||||
'Current file at this source; historical bytes are not verified.', 'small'));
|
||||
const path = el('dd', record.identity.path ?? 'Not available');
|
||||
// System › Appearance "Show file paths" off: the Path row is left out; nothing else changes.
|
||||
const showPath = !local && displayPreferences()?.showFilePaths !== false;
|
||||
const facts = file && definitionList([
|
||||
['Source', file.source_label], ...(local ? [] : [['Path', path]]), ['Size', formatBytes(file.size)],
|
||||
['Source', file.source_label], ...(showPath ? [['Path', path]] : []), ['Size', formatBytes(file.size)],
|
||||
['Dimensions', file.width && file.height ? `${file.width} × ${file.height}` : null],
|
||||
['Format', file.format || `${extensionOf(record)} extension`], ['Media type', file.media_type],
|
||||
['Modified', file.modified_time !== null ? new Date(file.modified_time * 1000).toLocaleString() : null],
|
||||
@@ -335,8 +348,9 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
if (facts) {
|
||||
// D38: "Show in folder" follows the Path value.
|
||||
const target = api ? revealTarget(record.identity) : null;
|
||||
if (target) path.append(' ', revealButton(target));
|
||||
section.append(facts);
|
||||
if (target && showPath) path.append(' ', revealButton(target));
|
||||
else if (target) section.append(revealButton(target));
|
||||
} else section.append(el('p', 'Current file observations are unavailable for this inspection response.', 'small'));
|
||||
// The concept's "Prompt evidence: execution recorded" line, in the D21 badge's words; no badge, no line.
|
||||
if (badge) section.append(el('p', `Prompt evidence: ${badge.toLowerCase()}`, 'small'));
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { noun } from './plural.mjs';
|
||||
|
||||
/** Identity is the source and relative path; content hashes may be shared by files. */
|
||||
/**
|
||||
* The layout and thumbnail size an Images visit starts with (#249 System S3). The last visit (the current view) keeps
|
||||
* its own; a saved view starts as before, at Grid and Comfortable; any other new visit starts from System ›
|
||||
* Appearance's defaults (`preferences`, preferences.mjs values, or null when unavailable).
|
||||
*/
|
||||
export function initialImagesView({ prior = null, saved = null, preferences = null } = {}) {
|
||||
if (prior) return { layout: prior.layout || 'grid', density: prior.density || 'comfortable' };
|
||||
if (saved || !preferences) return { layout: 'grid', density: 'comfortable' };
|
||||
return { layout: preferences.workspaceLayout || 'grid', density: preferences.workspaceDensity || 'comfortable' };
|
||||
}
|
||||
|
||||
export function imageKey(image) {
|
||||
return `${image.root_index}:${image.path}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createScope, createButton } from '../shared/toolkit/core.js';
|
||||
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
|
||||
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio, initialImagesView } from './images-state.mjs';
|
||||
import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs';
|
||||
import { mountFileInspector } from './file-inspector.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
@@ -15,6 +15,7 @@ import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
|
||||
import { openMetadataScan } from './activity-scan.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { imagesFilters, imagesViewState } from './saved-views.mjs';
|
||||
import { displayPreferences } from './preferences.mjs';
|
||||
import { createTagButton, onImageTagsSaved } from './image-tagging.mjs';
|
||||
import { followImageTagSaves, openCardTagging } from './images-tagging.mjs';
|
||||
import { favoriteBusy, paintStar, receiveFavorites, setFavoriteVisitTarget, toggleFavorite } from './images-favorites.mjs';
|
||||
@@ -111,7 +112,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const { resolveUrl } = window.ApiClient;
|
||||
const sender = createPromptSender({scope});
|
||||
let detailInspector;
|
||||
const inspection = createInspectionController({api});
|
||||
// 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});
|
||||
scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next)));
|
||||
scope.cleanup(() => inspection.dispose());
|
||||
scope.cleanup(() => detailInspector?.dispose());
|
||||
@@ -119,9 +122,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const prior = embedded || saved || favorites ? null : lastVisit;
|
||||
// A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6).
|
||||
const visit = prior?.stale ? null : prior;
|
||||
let density = prior?.density || 'comfortable';
|
||||
const start = initialImagesView({prior, saved, preferences});
|
||||
let density = start.density;
|
||||
let metrics = {...INITIAL_METRICS};
|
||||
let layout = prior?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
|
||||
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 = [];
|
||||
@@ -287,7 +291,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
selection = imageKey(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(), onRefresh:refreshInspectionCollection});
|
||||
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection});
|
||||
detailInspector.setState(inspection.state);
|
||||
inspection.select(image);
|
||||
for (const [key, card] of rendered) markSelected(card, key === selection);
|
||||
@@ -331,6 +335,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const text = el('div');
|
||||
const title = el('h3', cardTitle(image)); title.title = image.path;
|
||||
text.append(title, el('p', cardSubtitle(image)));
|
||||
// D97: "Show image details on cards" off hides the title and file line; Info and Tag stay.
|
||||
if (preferences?.showImageInfo === false) text.hidden = true;
|
||||
const actions = el('div', undefined, 'image-card-actions');
|
||||
const inspectButton = createButton('', 'icon-btn');
|
||||
inspectButton.append(createWorkspaceIcon('info'));
|
||||
|
||||
@@ -344,9 +344,15 @@ export function createInspectionLoader(api, onChange, { extractFacts, adapter =
|
||||
};
|
||||
}
|
||||
|
||||
const emptyState = () => ({ loading: false, error: false, record: null, prompts: undefined, message: '', refreshRequired: false, code: null, recovery: null });
|
||||
const emptyState = () => ({ loading: false, error: false, deferred: false, record: null, prompts: undefined, message: '', refreshRequired: false, code: null, recovery: null });
|
||||
|
||||
export function createInspectionController({ api, extractFacts, adapter = rootedFileAdapter }) {
|
||||
/**
|
||||
* One selection's inspection. #249 System S3 (Appearance): `autoLoad: false` ("Load metadata on selection" off) makes a
|
||||
* selection publish `deferred` instead of reading, and `load()` reads it once on request; `cache: false` ("Cache
|
||||
* metadata in this browser" off) re-reads the selected file when it is selected again, where the default reuses the one
|
||||
* current record. A read in flight is shared either way.
|
||||
*/
|
||||
export function createInspectionController({ api, extractFacts, adapter = rootedFileAdapter, autoLoad = true, cache = true }) {
|
||||
let state = Object.freeze(emptyState()), current = null, pending = null, disposed = false;
|
||||
let attempted, hasAttempt = false;
|
||||
const listeners = new Set();
|
||||
@@ -369,13 +375,19 @@ export function createInspectionController({ api, extractFacts, adapter = rooted
|
||||
try {
|
||||
adapter.validate(image);
|
||||
const scope = adapter.scope(image), key = adapter.key(image), path = adapter.inspectionPath(image);
|
||||
if (current?.key === key && current.scope === scope && current.path === path) return pending;
|
||||
if (current?.key === key && current.scope === scope && current.path === path && (cache || state.loading || state.deferred)) return pending;
|
||||
selected = { key, scope, path };
|
||||
} catch {
|
||||
// The loader publishes validation errors after invalidating the previous source.
|
||||
}
|
||||
loader.dispose(); loader = makeLoader(); current = selected;
|
||||
attempted = object(image) ? { ...image, ...(adapter !== rootedFileAdapter ? {occurrence:{...image.occurrence}, reference:{...image.reference}} : {}) } : image; hasAttempt = true;
|
||||
if (!autoLoad && selected) { pending = null; publish({ deferred: true }); return undefined; }
|
||||
return read(attempted);
|
||||
},
|
||||
/** Read a deferred selection (the inspector's "Load metadata"); a read already made or running is reused. */
|
||||
load() {
|
||||
if (disposed || !hasAttempt || !state.deferred) return pending ?? undefined;
|
||||
return read(attempted);
|
||||
},
|
||||
retry() {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {createScope, createButton} from '../shared/toolkit/core.js';
|
||||
import {createViewerSession} from './viewer-state.mjs';
|
||||
import {rootedFileAdapter} from './inspection-state.mjs';
|
||||
import {mountFileInspector} from './file-inspector.mjs';
|
||||
import {readPreferences} from './preferences.mjs';
|
||||
import {displayPreferences, readPreferences} from './preferences.mjs';
|
||||
|
||||
let viewerId = 0;
|
||||
// Upper bound on waiting for a closed Viewer's in-flight image view before destroying it.
|
||||
@@ -74,15 +74,31 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();});
|
||||
controls.append(download);
|
||||
const next = button('Next media','Next →',() => session.navigate(1));
|
||||
// D97: "Collapse viewer details by default" opens the viewer with its details hidden behind one toggle.
|
||||
const collapsed = displayPreferences()?.sidebarCollapsedByDefault === true;
|
||||
let detailsToggle = null;
|
||||
if (collapsed) {
|
||||
detailsToggle = createButton('Show details');detailsToggle.setAttribute('aria-expanded','false');
|
||||
detailsToggle.setAttribute('aria-controls','viewer-details');controls.append(detailsToggle);
|
||||
}
|
||||
const position = element('p','media-position muted');position.id = `media-viewer-position-${++viewerId}`;
|
||||
position.setAttribute('role','status');
|
||||
const reason = element('p','media-reason muted');reason.id = `media-viewer-reason-${viewerId}`;
|
||||
const slideshowReason = element('p','media-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`;
|
||||
controls.append(position,reason,slideshowReason);left.append(stage,controls);
|
||||
const details = element('aside');details.id = 'viewer-details';details.setAttribute('aria-label','Image metadata');
|
||||
if (detailsToggle) {
|
||||
details.hidden = true;
|
||||
local.listen(detailsToggle,'click',() => {
|
||||
details.hidden = !details.hidden;
|
||||
detailsToggle.textContent = details.hidden ? 'Show details' : 'Hide details';
|
||||
detailsToggle.setAttribute('aria-expanded',String(!details.hidden));
|
||||
});
|
||||
}
|
||||
body.append(left,details);dialog.append(header,body);container.append(dialog);
|
||||
const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}};
|
||||
const inspector = mountFileInspector(details,{api,sender:localSender,parentScope:local,variant:'full',onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(),
|
||||
onLoad:() => inspectionController.load?.(),
|
||||
onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined});
|
||||
function closeViewer(restore = true) {
|
||||
if (closed) return;
|
||||
|
||||
@@ -91,6 +91,11 @@ export function writePreferences(dirty, getStorage = defaultStorage, { baseline
|
||||
return { persisted: true, conflicts };
|
||||
}
|
||||
|
||||
/** The current values for a display consumer, or null on a page without gallery-settings.js (a unit test). */
|
||||
export function displayPreferences(getStorage = defaultStorage) {
|
||||
try { return readPreferences(getStorage).values; } catch { return null; }
|
||||
}
|
||||
|
||||
export function subscribePreferences(win, onChange, getStorage = defaultStorage) {
|
||||
const listener = event => {
|
||||
if (event.key !== null && event.key !== PREFERENCES_KEY) return;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* System › Appearance (#249 System S3 Task 8): the concept's `renderSystem` "appearance" body (app.js 676–678), one
|
||||
* `section.surface` "Browsing & display" of `.setting-row`s whose values are the drafts of the one Save row.
|
||||
*
|
||||
* - The browser rows are the display defaults this browser keeps in the shared `gallerySettings` object
|
||||
* (preferences.mjs, Q3): Default layout and Thumbnail size (`workspaceLayout`, `workspaceDensity`), the metadata,
|
||||
* quality (Optimized→`medium`, Original→`high`, Fast→`low`), video and path rows. Images, the inspector and the
|
||||
* viewer read them on their next visit; a toolbar change still applies to the current view only.
|
||||
* - "Open from ComfyUI" (New tab→`newtab`, Popup window→`popup`) and "Node search auto-hide (seconds)" (a whole number
|
||||
* 0–300) are the server's `webui_display_mode` and `result_timeout`, saved with the settings revision (#220).
|
||||
* - "Continuous browsing" is the concept's fixed row: there is no paging or column control (README ruling).
|
||||
* - D97: added rows for the legacy gallery settings the concept leaves out: "Show image details on cards"
|
||||
* (`showImageInfo`), "Collapse viewer details by default" (`sidebarCollapsedByDefault`) and the operation "Reset
|
||||
* display defaults", which drafts the gallery's defaults for these browser rows and touches no other key.
|
||||
*
|
||||
* Controls follow the store: a value read later (the host settings, another tab's change) repaints them.
|
||||
*/
|
||||
import { settingRow, operationRow } from './system-controls.mjs';
|
||||
import { preferenceDefaults } from './preferences.mjs';
|
||||
|
||||
export const RESULT_TIMEOUT_MAX = 300;
|
||||
const BOOLEAN = Object.freeze({ type: 'toggle' });
|
||||
|
||||
/**
|
||||
* The concept's rows in its order, then the D97 rows: `[id, label, description, domain, key, control]`. A select's
|
||||
* options are `[stored value, concept label]`.
|
||||
*/
|
||||
export const APPEARANCE_ROWS = Object.freeze([
|
||||
['default-view', 'Default layout', 'Choose your starting image layout.', 'browser', 'workspaceLayout',
|
||||
{ type: 'select', options: [['grid', 'Grid'], ['list', 'List'], ['masonry', 'Masonry']] }],
|
||||
['density', 'Thumbnail size', 'Readable previews by default; no fixed column-count setting.', 'browser', 'workspaceDensity',
|
||||
{ type: 'select', options: [['comfortable', 'Comfortable'], ['large', 'Large'], ['compact', 'Compact']] }],
|
||||
'continuous',
|
||||
['metadata-auto', 'Load metadata on selection', 'Use the same inspector in the gallery and image viewer.', 'browser', 'autoLoadMetadata', BOOLEAN],
|
||||
['metadata-cache', 'Cache metadata in this browser', 'Reuse fetched metadata until it changes or you clear this cache.', 'browser', 'cacheMetadata', BOOLEAN],
|
||||
['image-quality', 'Image loading quality', 'Preserve original / optimized / fast image-loading preferences.', 'browser', 'imageQuality',
|
||||
{ type: 'select', options: [['medium', 'Optimized'], ['high', 'Original'], ['low', 'Fast']] }],
|
||||
['video-auto', 'Autoplay videos', 'Videos stay within the same gallery and use native playback controls.', 'browser', 'videoAutoplay', BOOLEAN],
|
||||
['video-muted', 'Mute videos by default', 'Keep playback quiet while browsing.', 'browser', 'videoMute', BOOLEAN],
|
||||
['video-loop', 'Loop videos', 'Repeat a selected clip until you navigate away.', 'browser', 'videoLoop', BOOLEAN],
|
||||
['paths', 'Show file paths', 'Paths appear under File & provenance, not across every card.', 'browser', 'showFilePaths', BOOLEAN],
|
||||
['open-mode', 'Open from ComfyUI', 'Keep the node pack’s popup / new-tab preference.', 'host', 'webui_display_mode',
|
||||
{ type: 'select', options: [['newtab', 'New tab'], ['popup', 'Popup window']] }],
|
||||
['timeout', 'Node search auto-hide (seconds)', 'Node search behavior only. Use 0 to keep results visible.', 'host', 'result_timeout',
|
||||
{ type: 'number' }],
|
||||
// D97: the legacy gallery's card and viewer settings.
|
||||
['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', 'browser', 'showImageInfo', BOOLEAN],
|
||||
['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', 'browser',
|
||||
'sidebarCollapsedByDefault', BOOLEAN],
|
||||
]);
|
||||
|
||||
/** The browser keys "Reset display defaults" restores (D97). */
|
||||
export const DISPLAY_KEYS = Object.freeze(APPEARANCE_ROWS.filter(row => Array.isArray(row) && row[3] === 'browser').map(row => row[4]));
|
||||
|
||||
/** A whole number of seconds from 0 to 300 (the server's `validate_result_timeout`), else null. */
|
||||
export function resultTimeout(text) {
|
||||
const value = String(text).trim();
|
||||
if (!/^\d+$/.test(value)) return null;
|
||||
const seconds = Number(value);
|
||||
return seconds <= RESULT_TIMEOUT_MAX ? seconds : null;
|
||||
}
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The concept's fixed "Continuous browsing" row: `div > h3 + p` and the "Infinite scroll" chip. */
|
||||
function continuousRow() {
|
||||
const row = el('div', undefined, 'setting-row');
|
||||
const text = el('div');
|
||||
text.append(el('h3', 'Continuous browsing'),
|
||||
el('p', 'Images load automatically as you scroll in Grid, List and Masonry. Batch sizing is handled internally.'));
|
||||
row.append(text, el('span', 'Infinite scroll', 'chip blue'));
|
||||
return row;
|
||||
}
|
||||
|
||||
/** The row's control (`data-setting`), wherever the Toolkit switch nests it. */
|
||||
function controlOf(node, id) {
|
||||
if (node.dataset?.setting === id) return node;
|
||||
for (const child of node.children ?? []) {
|
||||
const found = controlOf(child, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Show a stored value in its control; a value not read yet leaves the control disabled. */
|
||||
function paint(input, control, value) {
|
||||
input.disabled = value === undefined;
|
||||
if (value === undefined) return;
|
||||
if (control.type === 'toggle') input.checked = value === true;
|
||||
else if (document.activeElement !== input || control.type === 'select') input.value = String(value);
|
||||
}
|
||||
|
||||
/** Mount Appearance in `container` (System's `.settings-layout`); returns the section's nodes. */
|
||||
export function mountAppearance(container, { scope, drafts }) {
|
||||
const surface = el('section', undefined, 'surface');
|
||||
surface.append(el('h2', 'Browsing & display'), el('p', 'One set of defaults; toolbar changes apply to the current view.'));
|
||||
const controls = [];
|
||||
for (const row of APPEARANCE_ROWS) {
|
||||
if (row === 'continuous') { surface.append(continuousRow()); continue; }
|
||||
const [id, label, description, domain, key, control] = row;
|
||||
const onChange = value => {
|
||||
if (key === 'result_timeout') {
|
||||
const seconds = resultTimeout(input.value);
|
||||
if (seconds === null) { drafts.reject(domain, key); return; }
|
||||
value = seconds;
|
||||
}
|
||||
drafts.set(domain, key, value);
|
||||
};
|
||||
const node = settingRow({ id, label, description, control, scope, onChange });
|
||||
surface.append(node);
|
||||
const input = controlOf(node, id);
|
||||
input.dataset.key = key;
|
||||
controls.push({ input, control, domain, key });
|
||||
}
|
||||
surface.append(operationRow({ title: 'Reset display defaults',
|
||||
description: 'Restore this browser’s display defaults, then save. Other settings are kept.', label: 'Reset', scope,
|
||||
onClick: () => {
|
||||
const defaults = preferenceDefaults();
|
||||
for (const key of DISPLAY_KEYS) drafts.set('browser', key, defaults[key]);
|
||||
} }));
|
||||
const repaint = () => { for (const { input, control, domain, key } of controls) paint(input, control, drafts.get(domain, key)); };
|
||||
scope.cleanup(drafts.subscribe(repaint));
|
||||
repaint();
|
||||
return [surface];
|
||||
}
|
||||
@@ -180,9 +180,33 @@ export function createSystemDrafts(adapters) {
|
||||
return saving;
|
||||
}
|
||||
|
||||
/** A value the control could not take (System S3: a timeout outside 0–300): no draft, and the control is highlighted. */
|
||||
function reject(domain, key) {
|
||||
requireDomain(domain);
|
||||
drafts[domain].delete(key);
|
||||
failedKeys[domain].add(key);
|
||||
notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Another page changed saved values this tab has drafts for (a browser `storage` event, System S3): the drafts stay
|
||||
* and are highlighted, and the status says the settings changed elsewhere.
|
||||
*/
|
||||
function conflict(domain, keys) {
|
||||
requireDomain(domain);
|
||||
if (!keys.length) return;
|
||||
for (const key of keys) failedKeys[domain].add(key);
|
||||
outcome = { [domain]: 'conflict' };
|
||||
notify();
|
||||
}
|
||||
|
||||
return {
|
||||
load,
|
||||
reloadHost,
|
||||
reject,
|
||||
conflict,
|
||||
/** The saved values of a domain (its last read or save), not the drafts. */
|
||||
saved: domain => { requireDomain(domain); return { ...baseline[domain] }; },
|
||||
get,
|
||||
set,
|
||||
dirty,
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* `aria-pressed`; a press navigates to its hash), then `.settings-layout` with the section's body and the Save row
|
||||
* (`span.small#settings-status` + "Save preferences"). D92: no "Sample configuration" chip, the status copy is
|
||||
* truthful and "Save preferences" is aria-disabled while nothing is unsaved. A section not delivered yet shows the
|
||||
* per-section hand-off to its legacy tool (D94), never mock placeholders. Overview is native (system-overview.mjs).
|
||||
* per-section hand-off to its legacy tool (D94), never mock placeholders. Overview (system-overview.mjs) and
|
||||
* Appearance (system-appearance.mjs) are native.
|
||||
*
|
||||
* Switching sections keeps the shell (and focus on the pressed button) and disposes the old section's scope, so its
|
||||
* listeners and timers never outlive it. The drafts are module-level (Q10): they live for the page session.
|
||||
@@ -14,6 +15,8 @@ import { SYSTEM_SECTIONS, navigateTo } from './state.mjs';
|
||||
import { createSystemDrafts, DRAFT_DOMAINS } from './system-state.mjs';
|
||||
import { sectionNav } from './system-controls.mjs';
|
||||
import { healthNotice, mountOverview } from './system-overview.mjs';
|
||||
import { mountAppearance } from './system-appearance.mjs';
|
||||
import { readPreferences, reconcilePreferences, subscribePreferences, writePreferences } from './preferences.mjs';
|
||||
|
||||
const SETTINGS_URL = '/prompt_manager/settings';
|
||||
const ADMIN = Object.freeze({ href: '/prompt_manager/admin', label: 'Open existing administration' });
|
||||
@@ -24,7 +27,6 @@ const GALLERY = Object.freeze({ href: '/prompt_manager/gallery', label: 'Open ex
|
||||
* settings, thumbnails, duplicates and cache tools.
|
||||
*/
|
||||
export const SECTION_HANDOFFS = Object.freeze({
|
||||
appearance: [GALLERY, ADMIN],
|
||||
sources: [ADMIN, GALLERY],
|
||||
integrations: [ADMIN],
|
||||
tagging: [ADMIN],
|
||||
@@ -46,12 +48,16 @@ function el(tag, text, className) {
|
||||
/** A domain whose settings no delivered section edits yet: nothing can be drafted, and a write would say it failed. */
|
||||
const undelivered = async () => { throw new Error(NOT_DELIVERED); };
|
||||
|
||||
/** The page session's drafts (Q10); the host domain reads and saves the server settings with their revision (#220). */
|
||||
/**
|
||||
* The page session's drafts (Q10). The browser domain is this browser's display defaults (preferences.mjs): a write
|
||||
* says whether storage kept it, so the status never claims "Saved in this browser." for a change only this tab holds.
|
||||
* The host domain reads and saves the server settings with their revision (#220).
|
||||
*/
|
||||
export function systemDrafts(api) {
|
||||
drafts ??= createSystemDrafts({
|
||||
readBrowser: () => ({}),
|
||||
readBrowser: () => readPreferences().values,
|
||||
readRuntime: () => ({}),
|
||||
writeBrowser: undelivered,
|
||||
writeBrowser: changes => ({ persisted: writePreferences(changes).persisted }),
|
||||
writeRuntime: undelivered,
|
||||
async readHost() {
|
||||
const result = await api.get(SETTINGS_URL, { cache: 'no-store' });
|
||||
@@ -80,17 +86,19 @@ function handoff(section) {
|
||||
function markFailed(layout, store) {
|
||||
const failed = new Set(DRAFT_DOMAINS.flatMap(domain => [...store.failed(domain)]));
|
||||
for (const control of layout.querySelectorAll('[data-setting]')) {
|
||||
if (failed.has(control.dataset.setting)) control.setAttribute('aria-invalid', 'true');
|
||||
// A row's control names its stored key when that differs from the row id (Appearance's `data-key`).
|
||||
if (failed.has(control.dataset.key ?? control.dataset.setting)) control.setAttribute('aria-invalid', 'true');
|
||||
else control.removeAttribute('aria-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The sections delivered so far: Overview (S2 Task 6), and Maintenance's notice with "Run health checks" above the
|
||||
* The sections delivered so far: Overview (S2 Task 6), Appearance (S3 Task 8), and Maintenance's notice with "Run health checks" above the
|
||||
* hand-off to the maintenance tools that are not native yet (D123).
|
||||
*/
|
||||
export const DELIVERED_SECTIONS = Object.freeze({
|
||||
overview: mountOverview,
|
||||
appearance: mountAppearance,
|
||||
maintenance: (container, { api, activity, scope }) => [healthNotice({ api, activity, scope }), ...handoff('maintenance')],
|
||||
});
|
||||
|
||||
@@ -101,11 +109,19 @@ export const DELIVERED_SECTIONS = Object.freeze({
|
||||
* Activity store, which follows the jobs a section starts.
|
||||
*
|
||||
* The host values are read on mount (and by the store again after a conflict), so a save carries the current revision;
|
||||
* until a read returned one, a host save fails without a request (system-state.mjs).
|
||||
* until a read returned one, a host save fails without a request (system-state.mjs). The browser values are read on
|
||||
* mount and again whenever another page changes them (a `storage` event).
|
||||
*/
|
||||
export function mountSystem(main, api, scope, { section, activity, sections = DELIVERED_SECTIONS, navigate = navigateTo }) {
|
||||
const store = systemDrafts(api);
|
||||
store.reloadHost();
|
||||
store.load('browser');
|
||||
// Another tab or the legacy gallery changed the display defaults: clean values follow it; a draft it overtook stays
|
||||
// and is flagged (D92's conflict status).
|
||||
scope.cleanup(subscribePreferences(window, next => {
|
||||
const { conflicts } = reconcilePreferences({ baseline: store.saved('browser'), drafts: store.changes('browser'), next: next.values });
|
||||
store.load('browser').then(() => store.conflict('browser', conflicts));
|
||||
}));
|
||||
const nav = sectionNav({ label: 'System sections', sections: SYSTEM_SECTIONS, active: section, scope,
|
||||
onSelect: id => navigate(`#/system/${id}`) });
|
||||
const layout = el('div', undefined, 'settings-layout');
|
||||
|
||||
@@ -91,6 +91,10 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.setting-row [aria-invalid="true"] { outline: 2px solid var(--red); outline-offset: 2px; /* D92 */ }
|
||||
/* D93: an Overview row whose action is not available yet (the review queue, Q6) keeps its button, disabled. */
|
||||
.operation .row-actions button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; /* D93 */ }
|
||||
/* D97: Appearance's legacy display rows. "Show image details on cards" off keeps a card's Info and Tag at the end of
|
||||
its row; "Collapse viewer details by default" gives the viewer's stage the whole width while its details are hidden. */
|
||||
.media-info > div[hidden] + .image-card-actions { margin-inline-start: auto; /* D97 */ }
|
||||
.viewer-layout:has(> #viewer-details[hidden]) { grid-template-columns: minmax(0, 1fr); /* D97 */ }
|
||||
@media (max-width: 700px) {
|
||||
.stat-grid { gap: 8px; }
|
||||
.stat { padding: 16px; }
|
||||
|
||||
Reference in New Issue
Block a user