feat(system): show where the library lives and offer the move
D129. System > Overview's Library health gains a "Library location" row (the database path, why it is there, the "Keep it here" choice and any PROMPT_MANAGER_DATA_DIR problem) with Move library for a node-folder library and Details otherwise. While a node-folder library is not kept there, the workspace shows the concept's .notice above every page: "Keep it here" saves the choice on the server and stops the notice; "Move library" opens the shared review (From, To, Size, what moves, what happens to the old files) and starts the exclusive job in Activity. The notice and the Overview read the location again when the job ends. The Overview comparison maps the inserted row; a new comparison holds the notice to the concept's Sources notice (markup, tokens, alignment, both themes and widths) with a mutation check.
This commit is contained in:
@@ -181,6 +181,17 @@ function mapOverviewConcept(reference, overview) {
|
||||
// D96: the dialog shows runs and ratings by category and library totals, not prompt, tag or date usage.
|
||||
Statistics: row => ({ ...row, description: 'Runs and ratings by category, with library totals.' }),
|
||||
};
|
||||
const surfaces = reference.columns.surfaces.map(surface => ({ ...surface,
|
||||
rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) }));
|
||||
// D129: "Library location" after Backup status, in the backup row's own markup; the fixture's database is configured
|
||||
// explicitly (config.json), so it does not move and its action is Details.
|
||||
const [health] = surfaces;
|
||||
const backup = health.rows.find(row => row.title === 'Backup status');
|
||||
const location = { ...backup, title: 'Library location',
|
||||
description: `${overview.location.path} · The database path set in config.json.`,
|
||||
action: { ...backup.action, text: 'Details' } };
|
||||
surfaces[0] = { ...health, tree: [...health.tree, 'div.operation'],
|
||||
rows: [...health.rows.slice(0, 2), location, ...health.rows.slice(2)] };
|
||||
return {
|
||||
...reference,
|
||||
// D92: no "Sample configuration" chip.
|
||||
@@ -188,8 +199,7 @@ function mapOverviewConcept(reference, overview) {
|
||||
// D122: the concept's literal label is its prototype action name ("system-tab sections").
|
||||
nav: { ...reference.nav, label: 'System sections' },
|
||||
metrics: { ...reference.metrics, items: reference.metrics.items.map(item => ({ ...item, value: relabel[item.label] })) },
|
||||
columns: { ...reference.columns, surfaces: reference.columns.surfaces.map(surface => ({ ...surface,
|
||||
rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) })) },
|
||||
columns: { ...reference.columns, surfaces },
|
||||
save: { ...reference.save,
|
||||
// D92: truthful status copy, and "Save preferences" is aria-disabled (dimmed) while nothing is unsaved.
|
||||
status: { ...reference.save.status, text: 'No unsaved changes.' },
|
||||
@@ -232,13 +242,21 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
|
||||
// The recorded differences, by path: D93 the review row (its real count), the backup row, the
|
||||
// preferences row and the sources row, and metric 4; D96 the statistics row; D92 the chip, the status and the dimmed
|
||||
// Save; D122 the nav label. (The fixture's first three metrics are the concept's own counts.)
|
||||
// Save; D122 the nav label; D129 the inserted "Library location" row, which shifts the thumbnail row to the fourth place
|
||||
// (row 2 takes its title, text and action; row 3 and the surface's fifth child are new). (The fixture's first three
|
||||
// metrics are the concept's own counts.)
|
||||
const D129_ROW_ACTION = ['backgroundColor', 'borderTopColor', 'borderTopLeftRadius', 'borderTopWidth', 'className', 'color',
|
||||
'cursor', 'fontSize', 'opacity', 'paddingLeft', 'paddingTop', 'text'].map(key => `columns.surfaces.0.rows.3.action.${key}`);
|
||||
const UNMAPPED_PATHS = [
|
||||
'columns.surfaces.0.rows.0.title', 'columns.surfaces.0.rows.1.description',
|
||||
'columns.surfaces.0.rows.2.action.text', 'columns.surfaces.0.rows.2.description', 'columns.surfaces.0.rows.2.title',
|
||||
...D129_ROW_ACTION, 'columns.surfaces.0.rows.3.description', 'columns.surfaces.0.rows.3.title',
|
||||
'columns.surfaces.0.rows.3.tree.0', 'columns.surfaces.0.rows.3.tree.1', 'columns.surfaces.0.rows.3.tree.length',
|
||||
'columns.surfaces.0.rows.length', 'columns.surfaces.0.tree.4', 'columns.surfaces.0.tree.length',
|
||||
'columns.surfaces.1.rows.0.description', 'columns.surfaces.1.rows.1.title', 'columns.surfaces.1.rows.2.description',
|
||||
'heading.actions.0', 'heading.actions.length', 'metrics.items.3.value', 'nav.label',
|
||||
'save.button.cursor', 'save.button.opacity', 'save.status.text',
|
||||
];
|
||||
].sort();
|
||||
|
||||
test('system comparison detects mutations', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
@@ -903,3 +921,85 @@ test('thumbnail dialog comparison detects mutations', async ({ page, request })
|
||||
await undoStyle();
|
||||
expect(diffInventory(expected, await page.evaluate(measureThumbnailReview))).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Runs in the page: one `.notice` (the concept's markup: `div > h2 + p`, then `.row-actions` buttons) with its tokens and
|
||||
* geometry, and its left edge against `edgeSelector` (the content it lines up with).
|
||||
*/
|
||||
function measureNoticeOf([selector, edgeSelector]) {
|
||||
const notice = document.querySelector(selector);
|
||||
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 button = node => ({ text: text(node), className: node.className,
|
||||
...css(node, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor', 'borderTopLeftRadius') });
|
||||
const edge = document.querySelector(edgeSelector);
|
||||
return {
|
||||
tree: [...notice.children].map(name),
|
||||
text: [...notice.querySelectorAll(':scope > div:not(.row-actions) > *')].map(node => [node.localName, text(node)]),
|
||||
actions: [...notice.querySelectorAll(':scope > .row-actions > button')].map(button),
|
||||
frame: css(notice, 'display', 'alignItems', 'justifyContent', 'columnGap', 'borderTopWidth', 'borderTopColor',
|
||||
'borderTopLeftRadius', 'backgroundColor', 'paddingTop', 'paddingLeft', 'marginBottom', 'flexDirection'),
|
||||
h2: css(notice.querySelector('h2'), 'fontSize', 'fontWeight', 'color'),
|
||||
p: css(notice.querySelector('p'), 'fontSize', 'color', 'marginTop'),
|
||||
rowActions: css(notice.querySelector(':scope > .row-actions'), 'display', 'columnGap', 'flexShrink'),
|
||||
alignedWith: Math.round(notice.getBoundingClientRect().left - edge.getBoundingClientRect().left),
|
||||
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
};
|
||||
}
|
||||
|
||||
/** D129: the concept's Sources notice as the library notice: its own copy and actions, no bottom margin (main's padding). */
|
||||
function mapLibraryNotice(reference) {
|
||||
return {
|
||||
...reference,
|
||||
text: [['h2', 'Your library is inside the node folder'], ['p', 'Your library is stored inside the node folder, where an '
|
||||
+ 'update or reinstall can delete it. Move it to ComfyUI\'s user folder: it is copied and verified first, and nothing '
|
||||
+ 'is deleted.']],
|
||||
actions: reference.actions.map((action, index) => ({ ...action, text: ['Keep it here', 'Move library'][index] })),
|
||||
frame: { ...reference.frame, marginBottom: '0px' },
|
||||
};
|
||||
}
|
||||
|
||||
async function conceptNotice(page, theme) {
|
||||
await openConcept(page, 'system', theme);
|
||||
await page.locator('#main .section-nav').getByRole('button', { name: 'Sources & scans', exact: true }).click();
|
||||
await expect(page.locator('#main .settings-layout > .notice')).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureNoticeOf, ['#main .settings-layout > .notice', '#main .heading']);
|
||||
}
|
||||
|
||||
async function productionNotice(page, theme) {
|
||||
await openProduction(page, 'system/overview', theme);
|
||||
await expect(page.locator('#library-banner > .notice')).toBeVisible();
|
||||
await expect(page.locator('#workspace-main .summary-metrics')).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureNoticeOf, ['#library-banner > .notice', '#workspace-main .heading']);
|
||||
}
|
||||
|
||||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
test(`library notice matches the concept notice ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await expect(await request.post('/__test__/system/library', { data: { state: 'node_folder' } })).toBeOK();
|
||||
const concept = await conceptNotice(page, theme);
|
||||
const production = await productionNotice(page, theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`production-library-notice-${theme}-${width}.png`), fullPage: true });
|
||||
expect(diffInventory(mapLibraryNotice(concept), production)).toEqual([]);
|
||||
expect(production.alignedWith).toBe(0);
|
||||
expect(production.pageScroll).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
test('library notice comparison detects mutations', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await expect(await request.post('/__test__/system/library', { data: { state: 'node_folder' } })).toBeOK();
|
||||
const expected = mapLibraryNotice(await conceptNotice(page, 'dark'));
|
||||
await productionNotice(page, 'dark');
|
||||
const paths = async () => diffInventory(expected,
|
||||
await page.evaluate(measureNoticeOf, ['#library-banner > .notice', '#workspace-main .heading'])).map(item => item.path);
|
||||
expect(await paths()).toEqual([]);
|
||||
const undoToken = await mutate(page, { css: '#library-banner { --soft-blue: rgb(255, 0, 0); }' });
|
||||
expect(await paths()).toEqual(['frame.backgroundColor']);
|
||||
await undoToken();
|
||||
await page.evaluate(() => { document.querySelector('#library-banner .notice h2').textContent = 'Library'; });
|
||||
expect(await paths()).toEqual(['text.0.1']);
|
||||
});
|
||||
|
||||
@@ -68,6 +68,8 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
|
||||
expect(await rowsOf(layout, 'Library health')).toEqual([
|
||||
['0 ambiguous image links', 'Review metadata before assigning a positive / negative pair.', 'Review metadata'],
|
||||
['Backup status', 'No retained backup record.', 'View backups'],
|
||||
// D129: the fixture's database is configured explicitly, so it reads as config.json's choice and does not move.
|
||||
['Library location', 'browser.db · The database path set in config.json.', 'Details'],
|
||||
['No missing thumbnails', 'Generate only missing previews when necessary.', 'Review thumbnails'],
|
||||
]);
|
||||
expect(await rowsOf(layout, 'Workspace preferences')).toEqual([
|
||||
@@ -78,6 +80,95 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
|
||||
// D129 (#249 S6b): where the library lives. The hook makes the fixture's database read as a node-folder library; its
|
||||
// Move library is a stand-in that touches no file (workspace_system_fixture.py).
|
||||
const banner = page => page.locator('#library-banner');
|
||||
const LIBRARY_URL = '/prompt_manager/system/library';
|
||||
|
||||
test('a node-folder library is offered a move above every page; Keep it here stops the offer', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/library', { data: { state: 'node_folder' } })).toBeOK();
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(banner(page).locator('.notice h2')).toHaveText('Your library is inside the node folder');
|
||||
await expect(banner(page).locator('.notice p')).toHaveText(/^Your library is stored inside the node folder, where an update or reinstall can delete it\./);
|
||||
await expect(banner(page).locator('.notice .row-actions button')).toHaveText(['Keep it here', 'Move library']);
|
||||
await expect(banner(page).getByRole('button', { name: 'Move library', exact: true })).toHaveClass(/ca-primary/);
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(banner(page).locator('.notice')).toBeVisible();
|
||||
await banner(page).getByRole('button', { name: 'Keep it here', exact: true }).click();
|
||||
await expect(banner(page)).toBeHidden();
|
||||
const { location } = await serverJson(request, LIBRARY_URL);
|
||||
expect([location.keep, location.offer, location.movable]).toEqual([true, false, true]);
|
||||
await page.reload();
|
||||
const layout = await openOverview(page);
|
||||
await expect(banner(page)).toBeHidden();
|
||||
const row = layout.locator('.operation').filter({ hasText: 'Library location' });
|
||||
await expect(row.locator('p')).toHaveText('browser.db · The node folder, where an update or reinstall can delete it. '
|
||||
+ 'You chose to keep it here.');
|
||||
// The row stays: the move is still one press away.
|
||||
await expect(row.locator('.row-actions button')).toHaveText('Move library');
|
||||
});
|
||||
|
||||
test('Move library is reviewed, runs as an Activity job and ends the offer', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/library', { data: { state: 'node_folder' } })).toBeOK();
|
||||
const layout = await openOverview(page);
|
||||
const row = layout.locator('.operation').filter({ hasText: 'Library location' });
|
||||
await expect(row.locator('p')).toHaveText('browser.db · The node folder, where an update or reinstall can delete it.');
|
||||
const move = banner(page).getByRole('button', { name: 'Move library', exact: true });
|
||||
await move.click();
|
||||
const dialog = page.locator('dialog#modal[open]');
|
||||
await expect(dialog.locator('#modal-title')).toHaveText('Move library');
|
||||
expect(await dialog.locator('dl.meta-grid > div').evaluateAll(items => items.map(item =>
|
||||
[item.querySelector('dt').textContent, item.querySelector('dd').textContent]))).toEqual([
|
||||
['From', 'browser.db'], ['To', 'user/__promptmanager/prompts.db'], ['Size', expect.stringMatching(/ (KB|MB)$/)],
|
||||
['Moves with it', 'config.json and the database backups'], ['Old files', 'Renamed *.moved-to-…, never deleted'],
|
||||
]);
|
||||
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Cancel', 'Move library']);
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
|
||||
await expect(move).toBeFocused();
|
||||
await row.getByRole('button', { name: 'Move library', exact: true }).click();
|
||||
await dialog.getByRole('button', { name: 'Move library', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/activity\/[^/]+$/);
|
||||
await expect(page.locator('#workspace-main')).toContainText(
|
||||
/Moved the library to user\/__promptmanager: \d+ prompts? and 0 images verified\. The old files were renamed browser\.db\.moved-to-__promptmanager; nothing was deleted\./);
|
||||
// The offer ends with the job; the Overview says where the library lives now.
|
||||
await expect(banner(page)).toBeHidden();
|
||||
const after = await openOverview(page);
|
||||
await expect(after.locator('.operation').filter({ hasText: 'Library location' }).locator('p'))
|
||||
.toHaveText('browser.db · ComfyUI\'s user folder, which updates and reinstalls of the node leave alone.');
|
||||
});
|
||||
|
||||
test('a move that fails says so and keeps the offer; a refused start says why', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/library', { data: { state: 'failing' } })).toBeOK();
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await banner(page).getByRole('button', { name: 'Move library', exact: true }).click();
|
||||
await page.locator('dialog#modal[open]').getByRole('button', { name: 'Move library', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/activity\/[^/]+$/);
|
||||
await expect(page.locator('#workspace-main')).toContainText(
|
||||
'The copy did not pass verification. Nothing was moved. The library is still in the node folder, unchanged.');
|
||||
await expect(banner(page).locator('.notice')).toBeVisible();
|
||||
await expect(await request.post('/__test__/system/fail', { data: { path: `${LIBRARY_URL}/move` } })).toBeOK();
|
||||
await banner(page).getByRole('button', { name: 'Move library', exact: true }).click();
|
||||
await page.locator('dialog#modal[open]').getByRole('button', { name: 'Move library', exact: true }).click();
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('Move library could not start. Try again.');
|
||||
});
|
||||
|
||||
test('a library that does not move shows its details', async ({ page }) => {
|
||||
const layout = await openOverview(page);
|
||||
await expect(banner(page)).toBeHidden();
|
||||
const details = layout.locator('.operation').filter({ hasText: 'Library location' }).getByRole('button', { name: 'Details', exact: true });
|
||||
await details.click();
|
||||
const dialog = page.locator('dialog#modal[open]');
|
||||
await expect(dialog.locator('#modal-title')).toHaveText('Library location');
|
||||
expect(await dialog.locator('dl.meta-grid > div').evaluateAll(items => items.map(item =>
|
||||
[item.querySelector('dt').textContent, item.querySelector('dd').textContent.replace(/[\d.,]+ (KB|MB)$/, 'N')]))).toEqual([
|
||||
['Database', 'browser.db'], ['Why here', 'The database path set in config.json'],
|
||||
['Beside it', 'config.json, the database backups and the logs'], ['Size', 'N'],
|
||||
]);
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await expect(details).toBeFocused();
|
||||
});
|
||||
|
||||
test('Run health checks opens Health report and updates metric 4', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/system/maintenance`);
|
||||
const layout = layoutOf(page);
|
||||
|
||||
@@ -23,6 +23,12 @@ read. These per-server hooks only change what a System test needs on top:
|
||||
image monitor whose observer is ``alive`` (default true) and schedules the
|
||||
named folders of this server's root (``["output"]``), so Sources reads
|
||||
watching, manual scan and restart pending states (S4).
|
||||
- ``POST /__test__/system/library`` ``{state}`` (S6b): ``node_folder`` makes
|
||||
the live database read as a node-folder library that can move to
|
||||
``user/__promptmanager``; Move library then runs a stand-in move that
|
||||
touches no file and ends with the library in the default folder.
|
||||
``failing`` is the same library whose move fails verification. Reset
|
||||
restores the real resolver, the real move and "Keep it here".
|
||||
|
||||
Delays use the shared request holds (``request_hold.py``).
|
||||
"""
|
||||
@@ -54,7 +60,12 @@ def register_system_commands(app, routes, api, root: Path):
|
||||
from py.config import GalleryConfig, PromptManagerConfig
|
||||
from utils import image_monitor
|
||||
|
||||
from database import library_move
|
||||
from utils import library_location
|
||||
|
||||
configured = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
real_move = library_move.move_library
|
||||
keep = PromptManagerConfig.KEEP_LIBRARY_IN_NODE_FOLDER
|
||||
config_bytes = config_path.read_bytes() if config_path.exists() else None
|
||||
host_values = (
|
||||
PromptManagerConfig.RESULT_TIMEOUT,
|
||||
@@ -92,6 +103,9 @@ def register_system_commands(app, routes, api, root: Path):
|
||||
GalleryConfig.MONITORING_ENABLED,
|
||||
) = host_values
|
||||
image_monitor._monitor_instance = monitor
|
||||
api.__dict__.pop("_library_resolver", None)
|
||||
library_move.move_library = real_move
|
||||
PromptManagerConfig.KEEP_LIBRARY_IN_NODE_FOLDER = keep
|
||||
if config_bytes is None:
|
||||
config_path.unlink(missing_ok=True)
|
||||
else:
|
||||
@@ -187,6 +201,43 @@ def register_system_commands(app, routes, api, root: Path):
|
||||
)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/system/library")
|
||||
async def library_state(request):
|
||||
data = await request.json()
|
||||
state = data.get("state")
|
||||
if state not in ("node_folder", "failing"):
|
||||
raise web.HTTPBadRequest()
|
||||
target = str(root / "user" / "__promptmanager")
|
||||
|
||||
def located(source):
|
||||
return library_location.LibraryLocation(
|
||||
source,
|
||||
api.db.model.db_path,
|
||||
str(config_path),
|
||||
str(root),
|
||||
target if source == library_location.SOURCE_NODE_FOLDER else None,
|
||||
target_source=library_location.SOURCE_DEFAULT,
|
||||
)
|
||||
|
||||
current = {"source": library_location.SOURCE_NODE_FOLDER}
|
||||
api._library_resolver = lambda: located(current["source"])
|
||||
|
||||
def stand_in_move(plan, *, progress, **_kwargs):
|
||||
if state == "failing":
|
||||
raise library_move.MoveFailed("verify-failed")
|
||||
progress("Copying the database")
|
||||
counts = api.db.library_counts()
|
||||
current["source"] = library_location.SOURCE_DEFAULT
|
||||
return library_move.MoveResult(
|
||||
located(library_location.SOURCE_DEFAULT),
|
||||
{"prompts": counts["positive_prompts"], "generated_images": 0},
|
||||
["prompts.db"],
|
||||
["browser.db.moved-to-__promptmanager"],
|
||||
)
|
||||
|
||||
library_move.move_library = stand_in_move
|
||||
return web.json_response({"success": True})
|
||||
|
||||
return reset
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Where the library lives (#249 System S6b, D129): the Overview row's copy, the Move library review and its start,
|
||||
// "Keep it here" and the Move library job's end.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const locationModule = import('../../web/js/workspace/library-location.mjs');
|
||||
|
||||
const NODE_FOLDER = Object.freeze({
|
||||
path: 'custom_nodes/ComfyUI_PromptManager/prompts.db', source: 'node_folder', movable: true, keep: false, offer: true,
|
||||
target: 'user/__promptmanager', problem: null, size_bytes: 1_700_000_000,
|
||||
});
|
||||
|
||||
function scope() {
|
||||
const controller = new AbortController();
|
||||
return { signal: controller.signal, abort: () => controller.abort(), listen() {}, cleanup() {} };
|
||||
}
|
||||
|
||||
function activityStore({ running = [] } = {}) {
|
||||
const listeners = new Set();
|
||||
let jobs = [];
|
||||
return {
|
||||
tracked: [], loads: 0,
|
||||
track(job) { this.tracked.push(job); jobs = [...jobs, job]; return job; },
|
||||
async load() { this.loads += 1; },
|
||||
running() { return running; },
|
||||
jobs() { return jobs; },
|
||||
set(next) { jobs = next; for (const listener of listeners) listener(); },
|
||||
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
||||
};
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(status, message, code) { super(message); this.status = status; this.code = code; }
|
||||
}
|
||||
|
||||
test('the row says where the library is and why, the choice and any problem', async () => {
|
||||
const { locationDescription, SOURCE_TEXT } = await locationModule;
|
||||
assert.equal(locationDescription(NODE_FOLDER),
|
||||
'custom_nodes/ComfyUI_PromptManager/prompts.db · The node folder, where an update or reinstall can delete it.');
|
||||
assert.equal(locationDescription({ ...NODE_FOLDER, keep: true }),
|
||||
`${NODE_FOLDER.path} · ${SOURCE_TEXT.node_folder}. You chose to keep it here.`);
|
||||
assert.equal(locationDescription({ path: 'user/__promptmanager/prompts.db', source: 'default', movable: false, keep: true }),
|
||||
'user/__promptmanager/prompts.db · ComfyUI\'s user folder, which updates and reinstalls of the node leave alone.');
|
||||
assert.match(locationDescription({ ...NODE_FOLDER, problem: 'data-dir-empty' }), /PROMPT_MANAGER_DATA_DIR has no library yet/);
|
||||
assert.match(locationDescription({ path: 'prompts.db', source: 'environment', movable: false }), /PROMPT_MANAGER_DATA_DIR names/);
|
||||
assert.match(locationDescription({ path: 'prompts.db', source: 'config', movable: false }), /config\.json/);
|
||||
});
|
||||
|
||||
test('only a well-formed location is shown', async () => {
|
||||
const { isLocation } = await locationModule;
|
||||
assert.equal(isLocation(NODE_FOLDER), true);
|
||||
for (const bad of [null, undefined, 'x', {}, { ...NODE_FOLDER, source: 'elsewhere' }, { ...NODE_FOLDER, path: 3 },
|
||||
{ ...NODE_FOLDER, movable: 'yes' }]) assert.equal(isLocation(bad), false);
|
||||
});
|
||||
|
||||
test('sizes read as the server would write them', async () => {
|
||||
const { sizeText } = await locationModule;
|
||||
assert.equal(sizeText(1_700_000_000), '1.6 GB');
|
||||
assert.equal(sizeText(120 * 2 ** 20), '120.0 MB');
|
||||
assert.equal(sizeText(1500), '2 KB');
|
||||
assert.equal(sizeText(-1), 'Unknown');
|
||||
assert.equal(sizeText(undefined), 'Unknown');
|
||||
});
|
||||
|
||||
test('the review names both folders, the size and what happens to the old files', async () => {
|
||||
const { reviewGrid } = await locationModule;
|
||||
assert.deepEqual(reviewGrid(NODE_FOLDER), [
|
||||
['From', 'custom_nodes/ComfyUI_PromptManager/prompts.db'],
|
||||
['To', 'user/__promptmanager/prompts.db'],
|
||||
['Size', '1.6 GB'],
|
||||
['Moves with it', 'config.json and the database backups'],
|
||||
['Old files', 'Renamed *.moved-to-…, never deleted'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('Move library starts the job, tracks it and opens its view', async () => {
|
||||
const { startLibraryMove, MOVE_URL } = await locationModule;
|
||||
const posts = [];
|
||||
const api = { post: async (url, body) => { posts.push([url, body]); return { job: { id: 'j1', kind: 'library_move' } }; } };
|
||||
const activity = activityStore();
|
||||
const routes = [];
|
||||
const job = await startLibraryMove({ api, activity, scope: scope(), notify: () => assert.fail('no toast'),
|
||||
navigate: hash => routes.push(hash) });
|
||||
assert.equal(job.id, 'j1');
|
||||
assert.deepEqual(posts, [[MOVE_URL, {}]]);
|
||||
assert.deepEqual(activity.tracked.map(item => item.id), ['j1']);
|
||||
assert.deepEqual(routes, ['#/activity/j1']);
|
||||
});
|
||||
|
||||
test('a busy server names the running job; another refusal says the server reason', async () => {
|
||||
const { startLibraryMove, START_FAILED } = await locationModule;
|
||||
const running = { id: 'scan1', title: 'Metadata scan' };
|
||||
const activity = activityStore({ running: [running] });
|
||||
const toasts = [];
|
||||
const busy = { post: async () => { throw new ApiError(409, 'busy', 'busy'); } };
|
||||
assert.equal(await startLibraryMove({ api: busy, activity, scope: scope(), notify: t => toasts.push(t),
|
||||
navigate: () => assert.fail('nothing opens') }), null);
|
||||
assert.equal(activity.loads, 1);
|
||||
assert.deepEqual(toasts.at(-1), ['Another job is running: ', { text: 'Metadata scan', href: '#/activity/scan1' }, '.']);
|
||||
const short = { post: async () => { throw new ApiError(507, 'There is not enough free space for the copy.', 'insufficient-space'); } };
|
||||
await startLibraryMove({ api: short, activity, scope: scope(), notify: t => toasts.push(t), navigate: () => {} });
|
||||
assert.equal(toasts.at(-1), 'There is not enough free space for the copy.');
|
||||
const broken = { post: async () => { throw new TypeError('network'); } };
|
||||
await startLibraryMove({ api: broken, activity, scope: scope(), notify: t => toasts.push(t), navigate: () => {} });
|
||||
assert.equal(toasts.at(-1), START_FAILED);
|
||||
const gone = scope();
|
||||
gone.abort();
|
||||
const before = toasts.length;
|
||||
await startLibraryMove({ api: broken, activity, scope: gone, notify: t => toasts.push(t), navigate: () => {} });
|
||||
assert.equal(toasts.length, before);
|
||||
});
|
||||
|
||||
test('Keep it here posts the choice and resolves the new location, or null with a toast', async () => {
|
||||
const { keepLibrary, KEEP_URL, KEEP_FAILED } = await locationModule;
|
||||
const posts = [];
|
||||
const kept = { ...NODE_FOLDER, keep: true, offer: false };
|
||||
const api = { post: async (url, body) => { posts.push([url, body]); return { location: kept }; } };
|
||||
assert.deepEqual(await keepLibrary({ api, scope: scope() }), kept);
|
||||
assert.deepEqual(posts, [[KEEP_URL, { keep: true }]]);
|
||||
const toasts = [];
|
||||
const failing = { post: async () => { throw new ApiError(500, 'no', 'config-write-failed'); } };
|
||||
assert.equal(await keepLibrary({ api: failing, scope: scope(), notify: t => toasts.push(t) }), null);
|
||||
assert.deepEqual(toasts, [KEEP_FAILED]);
|
||||
const odd = { post: async () => ({ location: { path: 1 } }) };
|
||||
assert.equal(await keepLibrary({ api: odd, scope: scope() }), null);
|
||||
});
|
||||
|
||||
test('the end of a Move library job is reported once; other jobs are ignored', async () => {
|
||||
const { onMoveEnded } = await locationModule;
|
||||
const activity = activityStore();
|
||||
let ended = 0;
|
||||
const unsubscribe = onMoveEnded(activity, () => { ended += 1; });
|
||||
activity.set([{ id: 'm', kind: 'library_move', state: 'running' }, { id: 's', kind: 'scan', state: 'running' }]);
|
||||
assert.equal(ended, 0);
|
||||
activity.set([{ id: 'm', kind: 'library_move', state: 'running' }, { id: 's', kind: 'scan', state: 'finished' }]);
|
||||
assert.equal(ended, 0);
|
||||
activity.set([{ id: 'm', kind: 'library_move', state: 'failed' }]);
|
||||
assert.equal(ended, 1);
|
||||
activity.set([{ id: 'm', kind: 'library_move', state: 'failed' }]);
|
||||
assert.equal(ended, 1);
|
||||
unsubscribe();
|
||||
activity.set([{ id: 'n', kind: 'library_move', state: 'running' }]);
|
||||
activity.set([{ id: 'n', kind: 'library_move', state: 'finished' }]);
|
||||
assert.equal(ended, 1);
|
||||
});
|
||||
@@ -335,6 +335,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/library-location.mjs": {
|
||||
"lines": 94,
|
||||
"branches": 91,
|
||||
"functions": 92,
|
||||
"statements": 94
|
||||
},
|
||||
"workspace/library-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { mountInspect } from './inspect.mjs';
|
||||
import { mountReview } from './review.mjs';
|
||||
import { createPromptSender } from './prompt-sender.mjs';
|
||||
import { mountSystem } from './system.mjs';
|
||||
import { mountLibraryBanner } from './library-location.mjs';
|
||||
|
||||
const { createApiClient, resolveUrl } = window.ApiClient;
|
||||
|
||||
@@ -75,6 +76,8 @@ function mountWorkspace() {
|
||||
const activity = createActivityStore({ api, scope, connect: url => new EventSource(resolveUrl(url)) });
|
||||
mountActivityTopbar({ store: activity, scope });
|
||||
activity.load();
|
||||
// D129: a node-folder library is offered a move (or "Keep it here") above every page.
|
||||
mountLibraryBanner(document.getElementById('library-banner'), { api, activity, scope });
|
||||
const sidebar = document.querySelector('aside.sidebar');
|
||||
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`.
|
||||
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` : route.id;
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Where the library lives (#249 System S6b, D129): the workspace banner, System › Overview's "Library location" row,
|
||||
* the Move library review and its start.
|
||||
*
|
||||
* - The server says where the database is and why (`GET /prompt_manager/system/library` and the Overview's
|
||||
* `location`): `default` (ComfyUI's user folder), `environment` (PROMPT_MANAGER_DATA_DIR), `config`
|
||||
* (config.json's database path) or `node_folder`, where an update or reinstall can delete it.
|
||||
* - D129: while a node-folder library is not kept there, the shell shows the concept's `.notice` (`h2` + `p` +
|
||||
* `.row-actions`) above every page: "Keep it here" saves the choice on the server (the banner stops; the Overview row
|
||||
* stays), "Move library" opens the review. The concept has no such notice; its markup and classes are the Sources
|
||||
* notice's (app.js 680).
|
||||
* - The review is the shared record-changing review (`openReviewDialog`): From, To, Size, what moves with it and what
|
||||
* happens to the old files. Move library starts the server's exclusive "Move library" job, tracks it in Activity and
|
||||
* opens its view; a refusal (another job running, no room, a library already there) says why and nothing starts.
|
||||
* - When a Move library job ends, the banner and the Overview read the location again.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { busyToast } from './activity-scan.mjs';
|
||||
import { closeWorkspaceDialog, openContentDialog, openReviewDialog } from './workspace-dialogs.mjs';
|
||||
import { navigateTo } from './state.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
export const LIBRARY_URL = '/prompt_manager/system/library';
|
||||
export const MOVE_URL = `${LIBRARY_URL}/move`;
|
||||
export const KEEP_URL = `${LIBRARY_URL}/keep`;
|
||||
export const MOVE_KIND = 'library_move';
|
||||
|
||||
export const BANNER_TITLE = 'Your library is inside the node folder';
|
||||
export const BANNER_TEXT = 'Your library is stored inside the node folder, where an update or reinstall can delete it. '
|
||||
+ 'Move it to ComfyUI\'s user folder: it is copied and verified first, and nothing is deleted.';
|
||||
export const KEEP_LABEL = 'Keep it here';
|
||||
export const MOVE_LABEL = 'Move library';
|
||||
export const ROW_TITLE = 'Library location';
|
||||
export const START_FAILED = 'Move library could not start. Try again.';
|
||||
export const KEEP_FAILED = 'Your choice could not be saved. Try again.';
|
||||
|
||||
/** Why the library is where it is, by `source`. */
|
||||
export const SOURCE_TEXT = Object.freeze({
|
||||
default: 'ComfyUI\'s user folder, which updates and reinstalls of the node leave alone',
|
||||
environment: 'The folder PROMPT_MANAGER_DATA_DIR names',
|
||||
config: 'The database path set in config.json',
|
||||
node_folder: 'The node folder, where an update or reinstall can delete it',
|
||||
});
|
||||
|
||||
/** A `problem` code as a sentence. */
|
||||
export const PROBLEM_TEXT = Object.freeze({
|
||||
'data-dir-not-absolute': 'PROMPT_MANAGER_DATA_DIR is not an absolute folder, so it was ignored.',
|
||||
'data-dir-served': 'PROMPT_MANAGER_DATA_DIR names a folder whose files are served over the web, so it was ignored.',
|
||||
'data-dir-empty': 'PROMPT_MANAGER_DATA_DIR has no library yet; Move library puts this one there.',
|
||||
'move-unfinished': 'An earlier move did not finish; this library is still the one in use.',
|
||||
});
|
||||
|
||||
const number = value => Number(value).toLocaleString('en-US');
|
||||
|
||||
/** "1.6 GB" or "120.0 MB" or "512 KB". */
|
||||
export function sizeText(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return 'Unknown';
|
||||
if (bytes >= 2 ** 30) return `${(bytes / 2 ** 30).toLocaleString('en-US', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} GB`;
|
||||
if (bytes >= 2 ** 20) return `${(bytes / 2 ** 20).toLocaleString('en-US', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} MB`;
|
||||
return `${number(Math.ceil(bytes / 1024))} KB`;
|
||||
}
|
||||
|
||||
/** Whether `location` is a well-formed server answer. */
|
||||
export function isLocation(location) {
|
||||
return Boolean(location) && typeof location === 'object' && typeof location.path === 'string'
|
||||
&& Object.hasOwn(SOURCE_TEXT, location.source) && typeof location.movable === 'boolean';
|
||||
}
|
||||
|
||||
/** The Overview row's description: the path, why it is there, the user's choice and any problem. */
|
||||
export function locationDescription(location) {
|
||||
const parts = [`${location.path} · ${SOURCE_TEXT[location.source]}.`];
|
||||
if (location.movable && location.keep) parts.push('You chose to keep it here.');
|
||||
const problem = PROBLEM_TEXT[location.problem];
|
||||
if (problem) parts.push(problem);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/** The review's `[term, value]` rows. */
|
||||
export function reviewGrid(location) {
|
||||
return [
|
||||
['From', location.path],
|
||||
['To', `${location.target}/prompts.db`],
|
||||
['Size', sizeText(location.size_bytes)],
|
||||
['Moves with it', 'config.json and the database backups'],
|
||||
['Old files', 'Renamed *.moved-to-…, never deleted'],
|
||||
];
|
||||
}
|
||||
|
||||
export const REVIEW_SENTENCE = 'The copy is verified before Prompt Manager switches to it. No other job can run until it '
|
||||
+ 'finishes, and closing this page does not stop it.';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Move library job, follow it in Activity and open its view. A busy server toasts the D57 link to the running
|
||||
* job; any other refusal says the server's reason. Nothing is said once `scope` ended.
|
||||
*/
|
||||
export async function startLibraryMove({ api, activity, scope, notify = showWorkspaceToast, navigate = navigateTo }) {
|
||||
try {
|
||||
const { job } = await api.post(MOVE_URL, {}, { signal: scope.signal });
|
||||
if (scope.signal.aborted) return null;
|
||||
const tracked = activity.track(job);
|
||||
navigate(`#/activity/${tracked.id}`);
|
||||
return tracked;
|
||||
} catch (error) {
|
||||
if (scope.signal.aborted) return null;
|
||||
if (error?.status === 409 && error?.code === 'busy') {
|
||||
await activity.load();
|
||||
if (!scope.signal.aborted) notify(busyToast(activity.running()[0] ?? null));
|
||||
return null;
|
||||
}
|
||||
notify(typeof error?.code === 'string' && error.message ? error.message : START_FAILED);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The Move library review over `location`; Move library starts the job. */
|
||||
export function openMoveReview({ api, activity, scope, location, trigger = null, notify = showWorkspaceToast,
|
||||
navigate = navigateTo }) {
|
||||
return openReviewDialog({ scope, trigger, title: 'Move library', grid: reviewGrid(location), sentence: REVIEW_SENTENCE,
|
||||
cancelLabel: 'Cancel', primary: { label: MOVE_LABEL },
|
||||
onStart: () => { startLibraryMove({ api, activity, scope, notify, navigate }); } });
|
||||
}
|
||||
|
||||
/** Save "Keep it here" (`keep` true) or take it back; resolves the new location, or null when it was not saved. */
|
||||
export async function keepLibrary({ api, scope, keep = true, notify = showWorkspaceToast }) {
|
||||
try {
|
||||
const { location } = await api.post(KEEP_URL, { keep }, { signal: scope.signal });
|
||||
return isLocation(location) ? location : null;
|
||||
} catch (error) {
|
||||
if (!scope.signal.aborted) notify(KEEP_FAILED);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The details of a library that does not move: where it is, why, and what lives beside it. */
|
||||
export function openLocationDetails({ scope, location, trigger = null }) {
|
||||
const list = el('dl', undefined, 'meta-grid');
|
||||
for (const [term, value] of [['Database', location.path], ['Why here', SOURCE_TEXT[location.source]],
|
||||
['Beside it', 'config.json, the database backups and the logs'], ['Size', sizeText(location.size_bytes)]]) {
|
||||
const entry = el('div');
|
||||
entry.append(el('dt', term), el('dd', value));
|
||||
list.append(entry);
|
||||
}
|
||||
const close = createButton('Close');
|
||||
scope.listen(close, 'click', () => closeWorkspaceDialog());
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
actions.append(close);
|
||||
const nodes = [list];
|
||||
const problem = PROBLEM_TEXT[location.problem];
|
||||
if (problem) nodes.push(el('p', problem, 'small'));
|
||||
nodes.push(actions);
|
||||
openContentDialog({ scope, title: ROW_TITLE, trigger, nodes });
|
||||
}
|
||||
|
||||
/** D129: the Overview's "Library location" `.operation` row; Move library when it can move, else Details. */
|
||||
export function locationRow(location, { api, activity, scope, notify = showWorkspaceToast, navigate = navigateTo }) {
|
||||
const row = el('div', undefined, 'operation');
|
||||
const text = el('div');
|
||||
text.append(el('h3', ROW_TITLE), el('p', locationDescription(location)));
|
||||
const actions = el('div', undefined, 'row-actions');
|
||||
const action = createButton(location.movable ? MOVE_LABEL : 'Details');
|
||||
scope.listen(action, 'click', () => {
|
||||
if (location.movable) openMoveReview({ api, activity, scope, location, trigger: action, notify, navigate });
|
||||
else openLocationDetails({ scope, location, trigger: action });
|
||||
});
|
||||
actions.append(action);
|
||||
row.append(text, actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** D129: the concept's `.notice` for a node-folder library: Keep it here and Move library. */
|
||||
export function libraryNotice({ scope, onKeep, onMove }) {
|
||||
const notice = el('div', undefined, 'notice');
|
||||
const text = el('div');
|
||||
text.append(el('h2', BANNER_TITLE), el('p', BANNER_TEXT));
|
||||
const keep = createButton(KEEP_LABEL);
|
||||
const move = createButton(MOVE_LABEL, 'ca-primary');
|
||||
scope.listen(keep, 'click', () => onKeep(keep));
|
||||
scope.listen(move, 'click', () => onMove(move));
|
||||
const actions = el('div', undefined, 'row-actions');
|
||||
actions.append(keep, move);
|
||||
notice.append(text, actions);
|
||||
return notice;
|
||||
}
|
||||
|
||||
/** Calls `onEnded()` whenever a Move library job this page sees stops running. Returns the unsubscribe. */
|
||||
export function onMoveEnded(activity, onEnded) {
|
||||
const running = new Set();
|
||||
return activity.subscribe(() => {
|
||||
let ended = false;
|
||||
for (const job of activity.jobs()) {
|
||||
if (job.kind !== MOVE_KIND) continue;
|
||||
if (job.state === 'running') running.add(job.id);
|
||||
else if (running.delete(job.id)) ended = true;
|
||||
}
|
||||
if (ended) onEnded();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the banner in `container` (hidden until the server offers the move). It reads the location once, again when a
|
||||
* Move library job ends, and hides for good this page once the user keeps the library where it is.
|
||||
*/
|
||||
export function mountLibraryBanner(container, { api, activity, scope, notify = showWorkspaceToast, navigate = navigateTo,
|
||||
latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) {
|
||||
const read = latestOnly({ lifetime: scope.signal });
|
||||
const show = location => {
|
||||
container.replaceChildren();
|
||||
container.hidden = !(isLocation(location) && location.offer === true);
|
||||
if (container.hidden) return;
|
||||
container.append(libraryNotice({ scope,
|
||||
onKeep: async button => {
|
||||
button.setAttribute('aria-disabled', 'true');
|
||||
const saved = await keepLibrary({ api, scope, notify });
|
||||
if (scope.signal.aborted) return;
|
||||
button.removeAttribute('aria-disabled');
|
||||
if (saved) show(saved);
|
||||
},
|
||||
onMove: button => openMoveReview({ api, activity, scope, location, trigger: button, notify, navigate }) }));
|
||||
};
|
||||
async function load() {
|
||||
try {
|
||||
const answer = await read(signal => api.get(LIBRARY_URL, { signal, cache: 'no-store' }));
|
||||
if (answer === STALE || scope.signal.aborted) return;
|
||||
show(answer.location);
|
||||
} catch {
|
||||
// The banner is advisory: an unreadable location shows nothing (the Overview row reports it).
|
||||
if (!scope.signal.aborted) show(null);
|
||||
}
|
||||
}
|
||||
container.hidden = true;
|
||||
scope.cleanup(onMoveEnded(activity, load));
|
||||
load();
|
||||
return { reload: load };
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { showWorkspaceToast } from './toast.mjs';
|
||||
import { noun } from './plural.mjs';
|
||||
import { operationRow } from './system-controls.mjs';
|
||||
import { openThumbnailReview } from './thumbnail-review.mjs';
|
||||
import { isLocation, locationRow, onMoveEnded } from './library-location.mjs';
|
||||
|
||||
export const OVERVIEW_URL = '/prompt_manager/system/overview';
|
||||
export const STATISTICS_URL = '/prompt_manager/system/statistics';
|
||||
@@ -319,7 +320,7 @@ function surface(heading, rows) {
|
||||
}
|
||||
|
||||
/** The Overview's nodes, and its thumbnail row (painted by its own read). */
|
||||
function overviewNodes(overview, { scope, navigate, openStatistics, openThumbnails }) {
|
||||
function overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics, openThumbnails }) {
|
||||
const go = section => () => navigate(`#/system/${section}`);
|
||||
const review = operationRow({ scope, title: ambiguousTitle(overview.needs_review),
|
||||
description: Number.isSafeInteger(overview.needs_review?.pending)
|
||||
@@ -332,6 +333,9 @@ function overviewNodes(overview, { scope, navigate, openStatistics, openThumbnai
|
||||
review,
|
||||
operationRow({ scope, title: 'Backup status', description: backupText(overview.backup), label: 'View backups',
|
||||
onClick: go('backups') }),
|
||||
// D129: where the library lives and why; Move library when it is in the node folder.
|
||||
...(isLocation(overview.location)
|
||||
? [locationRow(overview.location, { api, activity, scope, notify, navigate })] : []),
|
||||
// D99: Review thumbnails opens the thumbnail dialog (the concept's row starts a sample job directly).
|
||||
thumbnails,
|
||||
]);
|
||||
@@ -394,7 +398,7 @@ export function mountOverview(container, { api, activity, scope, navigate = navi
|
||||
try {
|
||||
const overview = await overviewRead(signal => api.get(OVERVIEW_URL, { signal, cache: 'no-store' }));
|
||||
if (overview === STALE || scope.signal.aborted) return;
|
||||
const { nodes, thumbnails } = overviewNodes(overview, { scope, navigate, openStatistics,
|
||||
const { nodes, thumbnails } = overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics,
|
||||
openThumbnails: trigger => openThumbnailReview({ api, store: activity, scope, trigger, navigate }) });
|
||||
thumbnailRow = thumbnails;
|
||||
sources = overview.sources;
|
||||
@@ -427,6 +431,8 @@ export function mountOverview(container, { api, activity, scope, navigate = navi
|
||||
paintThumbnails();
|
||||
}
|
||||
const first = swap(loading());
|
||||
// D129: a finished Move library changes where the library lives.
|
||||
scope.cleanup(onMoveEnded(activity, load));
|
||||
load();
|
||||
loadThumbnails();
|
||||
return first;
|
||||
|
||||
@@ -414,3 +414,9 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
field) beside their label, as the concept's own radio rows (s1160 `.compare-item label`). */
|
||||
.field[role="radiogroup"] > label { display: flex; align-items: center; gap: 8px; /* D90 */ }
|
||||
.field[role="radiogroup"] input[type="radio"] { width: auto; margin: 0; /* D90 */ }
|
||||
/* D129: the library-location notice sits above every page, in main's own padding and width, so its `.notice` lines
|
||||
up with the page below; main's top padding then spaces it from the page. */
|
||||
.library-banner { max-width: 1900px; margin: auto; padding: 30px 30px 0; /* D129 */ }
|
||||
.library-banner > .notice { margin-bottom: 0; /* D129 */ }
|
||||
@media (max-width: 1250px) { .library-banner { padding: 24px 24px 0; /* D129 */ } }
|
||||
@media (max-width: 700px) { .library-banner { padding: 20px 14px 0; /* D129 */ } }
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
<option value="system" selected>System</option>
|
||||
</select></label><a class="canvas-link" href="../">ComfyUI <span aria-hidden="true">↗</span></a></div>
|
||||
</header>
|
||||
<!-- D129: the library-location notice, shown above every page while a node-folder library can move. -->
|
||||
<div id="library-banner" class="library-banner" hidden></div>
|
||||
<main id="workspace-main" tabindex="-1"></main>
|
||||
<footer class="app-footer"><span>ComfyAssets · Local tools. Your library.</span></footer>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user