The list column showed every type's tree (raw folder names, [Root] rows with 0, nothing marking where you are), repeating the type chips above it. Now (ui_folder_list.js) it follows the chips: the type's heading with "📂 Open", All, its subfolders as a tree whose numbers count their subfolders (empty ones dimmed, folding per session in owner.collapsedFolders), and "In the top folder" for models directly in the type's folder; the listed one is marked. A Bilibili comment asked for quick ways to open model folders: POST /anomalous/open_model_folder without a file name now opens the folder itself (type root or subfolder; still by type, index and subfolder, never a path, local only). Tests in tests/test_model_folder.py. Gone: the every-type tree, Expand / Collapse all, expandedFolders and their locale keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
7.8 KiB
JavaScript
190 lines
7.8 KiB
JavaScript
/**
|
|
* Model folders, a view of the settings page: how the models are grouped (by ComfyUI's
|
|
* model types or by the folders on disk), which groups show on the Models page and in
|
|
* what order. Folders that are off are neither read nor scanned. Each change is saved at
|
|
* once (POST /anomalous/save_config) and the folder list reloads shortly after.
|
|
*/
|
|
|
|
import { translate } from './locales.js';
|
|
import { typeLabel, isCommonModelType } from './ui_model_types.js';
|
|
|
|
const t = (key, params) => translate(key, params);
|
|
const RELOAD_DELAY_MS = 500;
|
|
|
|
function el(tag, className, text) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function button(className, label, onClick, title) {
|
|
const node = el('button', className, label);
|
|
node.type = 'button';
|
|
node.onclick = onClick;
|
|
if (title) {
|
|
node.title = title;
|
|
node.setAttribute('aria-label', title);
|
|
}
|
|
return node;
|
|
}
|
|
|
|
async function postConfig(body) {
|
|
const res = await fetch('/anomalous/save_config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok || data.status === 'error') throw new Error(data.message || `HTTP ${res.status}`);
|
|
}
|
|
|
|
/**
|
|
* Renders the view into `page` (an emptied settings page). `onBack` returns to the
|
|
* settings. The list lives in this view only; the browser's folder list is reloaded.
|
|
*/
|
|
export async function renderFolderPage(owner, page, onBack) {
|
|
const state = { mode: 'abstract', items: [], error: '' };
|
|
let reloadTimer = null;
|
|
|
|
const reloadFolders = () => {
|
|
clearTimeout(reloadTimer);
|
|
reloadTimer = setTimeout(() => {
|
|
owner.firstLoadDone = false;
|
|
owner.loadFolders?.();
|
|
}, RELOAD_DELAY_MS);
|
|
};
|
|
|
|
const save = async () => {
|
|
const key = state.mode === 'physical' ? 'physical_folders_config' : 'folder_types_config';
|
|
try {
|
|
await postConfig({ [key]: state.items.map(({ type, visible }) => ({ type, visible })) });
|
|
state.error = '';
|
|
reloadFolders();
|
|
} catch (error) {
|
|
state.error = t('foldersSaveFailed', { error: error.message });
|
|
}
|
|
draw();
|
|
};
|
|
|
|
const load = async () => {
|
|
try {
|
|
const data = await (await fetch('/anomalous/all_folder_types')).json();
|
|
state.mode = data.folder_view_mode === 'physical' ? 'physical' : 'abstract';
|
|
// Shown ones first, in their saved order; the rest after.
|
|
const items = data.folder_types || [];
|
|
state.items = [...items.filter(item => item.visible), ...items.filter(item => !item.visible)];
|
|
state.error = '';
|
|
} catch (error) {
|
|
state.error = t('foldersLoadFailed');
|
|
}
|
|
draw();
|
|
};
|
|
|
|
const setMode = async (mode) => {
|
|
if (mode === state.mode) return;
|
|
try {
|
|
await postConfig({ folder_view_mode: mode });
|
|
owner.collapsedFolders?.clear();
|
|
reloadFolders();
|
|
await load();
|
|
} catch (error) {
|
|
state.error = t('foldersSaveFailed', { error: error.message });
|
|
draw();
|
|
}
|
|
};
|
|
|
|
const move = (item, step) => {
|
|
const shown = state.items.filter(entry => entry.visible);
|
|
const at = shown.indexOf(item);
|
|
const other = shown[at + step];
|
|
if (!other) return;
|
|
const a = state.items.indexOf(item);
|
|
const b = state.items.indexOf(other);
|
|
[state.items[a], state.items[b]] = [state.items[b], state.items[a]];
|
|
save();
|
|
};
|
|
|
|
const toggle = (item, on) => {
|
|
item.visible = on;
|
|
// A folder switched on joins the end of the shown ones; one switched off leaves them.
|
|
state.items.splice(state.items.indexOf(item), 1);
|
|
const lastShown = state.items.reduce((last, entry, index) => (entry.visible ? index : last), -1);
|
|
state.items.splice(on ? lastShown + 1 : state.items.length, 0, item);
|
|
save();
|
|
};
|
|
|
|
const row = (item, { first = false, last = false } = {}) => {
|
|
const line = el('div', 'anomalous-scan-setting anomalous-folder-row');
|
|
const copy = el('span', 'anomalous-scan-setting-copy');
|
|
const name = typeLabel({ type: item.type });
|
|
copy.append(el('span', 'anomalous-scan-setting-title', name));
|
|
if (name !== item.type) copy.append(el('code', 'anomalous-folder-raw', item.type));
|
|
const controls = el('span', 'anomalous-folder-controls');
|
|
if (item.visible) {
|
|
const up = button('anomalous-folder-move', '↑', () => move(item, -1), t('foldersMoveUp'));
|
|
const down = button('anomalous-folder-move', '↓', () => move(item, 1), t('foldersMoveDown'));
|
|
up.disabled = first;
|
|
down.disabled = last;
|
|
controls.append(up, down);
|
|
}
|
|
const input = el('input', 'anomalous-scan-switch');
|
|
input.type = 'checkbox';
|
|
input.checked = item.visible;
|
|
input.setAttribute('aria-label', name);
|
|
input.onchange = () => toggle(item, input.checked);
|
|
controls.append(input);
|
|
line.append(copy, controls);
|
|
return line;
|
|
};
|
|
|
|
const group = (titleKey, ...children) => {
|
|
const box = el('section', 'anomalous-scan-card anomalous-scan-group');
|
|
box.append(el('h3', 'anomalous-scan-group-title', t(titleKey)), ...children);
|
|
return box;
|
|
};
|
|
|
|
const modeChoices = () => {
|
|
const segment = el('div', 'anomalous-scan-segment');
|
|
segment.setAttribute('role', 'radiogroup');
|
|
for (const [value, key] of [['abstract', 'foldersByType'], ['physical', 'foldersByDisk']]) {
|
|
const choice = button('anomalous-scan-segment-btn', t(key), () => setMode(value));
|
|
choice.setAttribute('role', 'radio');
|
|
choice.setAttribute('aria-checked', String(state.mode === value));
|
|
segment.append(choice);
|
|
}
|
|
return segment;
|
|
};
|
|
|
|
function draw() {
|
|
const shown = state.items.filter(item => item.visible);
|
|
const hidden = state.items.filter(item => !item.visible);
|
|
// By type, ComfyUI registers many folders few people use: they wait behind a fold.
|
|
const usual = state.mode === 'physical' ? hidden : hidden.filter(item => isCommonModelType(item.type));
|
|
const rare = state.mode === 'physical' ? [] : hidden.filter(item => !isCommonModelType(item.type));
|
|
|
|
const groupingHelp = el('small', 'anomalous-scan-setting-help', t(state.mode === 'physical' ? 'foldersByDiskHelp' : 'foldersByTypeHelp'));
|
|
const shownRows = shown.length
|
|
? shown.map((item, index) => row(item, { first: index === 0, last: index === shown.length - 1 }))
|
|
: [el('p', 'anomalous-scan-muted', t('foldersNoneShown'))];
|
|
const hiddenRows = usual.map(item => row(item));
|
|
if (rare.length) {
|
|
const fold = el('details', 'anomalous-folder-rare');
|
|
fold.append(el('summary', '', t('foldersRare', { count: rare.length })), ...rare.map(item => row(item)));
|
|
hiddenRows.push(fold);
|
|
}
|
|
page.replaceChildren(
|
|
button('anomalous-scan-back', t('foldersBack'), onBack),
|
|
el('h1', 'anomalous-scan-title', t('sidebarManageFolders')),
|
|
el('p', 'anomalous-scan-lead', t('foldersLead')),
|
|
...(state.error ? [el('p', 'anomalous-scan-note is-error', state.error)] : []),
|
|
group('foldersGrouping', modeChoices(), groupingHelp),
|
|
group('foldersShown', ...shownRows),
|
|
...(hiddenRows.length ? [group('foldersHidden', ...hiddenRows)] : []),
|
|
);
|
|
}
|
|
|
|
page.replaceChildren(button('anomalous-scan-back', t('foldersBack'), onBack), el('p', 'anomalous-scan-muted', t('audioLoading')));
|
|
await load();
|
|
}
|