Files
DemonGatanjieuandClaude Opus 5.5 a063628866 Count this computer at its own LAN address as local; say why a remote save was refused
ComfyUI listening on its LAN address only (#26): the browser on the same computer
comes from that very address, so it was taken for another device. A request whose
peer is the server's own end of the connection, naming that same IP, is local now;
DNS rebinding and proxy headers are still refused.

Opened from another device (#25), the settings page said the proxy's format was
wrong and that Civitai could not be reached, when the server had refused the
request. It now says so, and refusals point to the View only chip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:34:22 +08:00

581 lines
27 KiB
JavaScript

/**
* The settings page (the rail's gear), in tabs (`owner.settingsTab`, kept while the page is
* away): Look (how the browser looks, model cards and what they cost in memory with the card
* image cache, how the browser opens), Models (model folders — a view of its own,
* ui_folder_manager.js, `owner.settingsView === 'folders'` —, the network: the proxy the plugin's
* own requests use and a Civitai connection test, where downloads and imported models go, and where
* the files only this plugin uses sit beside each model, sidecar_layout.js), Workflows,
* Node packs (those not used lately, ui_pack_usage.js), Prompts (translation with a DeepL key), Backup (ui_backup.js) and
* Help. A tool page like the scan page, with a way back to the page you came from. Display
* preferences go through `owner.displayPrefs` (ui_settings_hub.js); language, theme and opening
* mode are ComfyUI settings.
*/
import { app } from "../../../scripts/app.js";
import { translate } from './locales.js';
import { LANGUAGE_SETTING_ID, applyLanguagePreference, setThemePreference, themePreference } from './interface_settings.js';
import { ENTRY_MODE_SETTING_ID } from './browser_entry.js';
import { showUpdateGuide } from './ui_update_guide.js';
import { startSpotlightTour } from './ui_spotlight_tour.js';
import { copyDiagnostics, openRepoPage } from './feedback.js';
import { renderFolderPage } from './ui_folder_manager.js';
import { openFeedbackDialog } from './ui_feedback_dialog.js';
import { checkOnOpen, setCheckOnOpen } from './ui_doctor_banner.js';
import { fetchDownloadSettings, hfMirrorOn, saveDownloadSettings } from './model_download.js';
import { saveApiKey } from './ui_scan_settings.js';
import { exportBackup, importBackup } from './ui_backup.js';
import { followsSelection, setFollowsSelection } from './shell_open_rules.js';
import { FOLDER_PRESETS, expandFolder } from './download_places.js';
import { createGalleryFormatSettings } from './ui_gallery_filter.js';
import { failureWords } from './net_failure.js';
import { applySidecars, planSidecars } from './sidecar_layout.js';
import { anomalousConfirm } from './ui_dialog.js';
import { renderPackUsage } from './ui_pack_usage.js';
import { canvasMenuRows } from './ui_canvas_menu_settings.js';
const t = (key, params) => translate(key, params);
// The full written guide: the README, at its Chinese half for Chinese.
const README_URL = 'https://github.com/DemonGatanjieu/Anomalous_Model_Browser#readme';
const README_ZH_URL = 'https://github.com/DemonGatanjieu/Anomalous_Model_Browser#-视频演示与教程';
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) {
const node = el('button', className, label);
node.type = 'button';
node.onclick = onClick;
return node;
}
function comfySetting(id) {
try { return app.extensionManager?.setting?.get(id); } catch (_) { return undefined; }
}
async function setComfySetting(id, value) {
const settings = app.extensionManager?.setting;
if (typeof settings?.set !== 'function') return false;
try {
await settings.set(id, value);
return true;
} catch (_) {
return false;
}
}
/** A row: name and what it does on the left, its control on the right. */
function row(titleKey, helpKey, control, { help = null } = {}) {
const line = el('div', 'anomalous-scan-setting');
const copy = el('span', 'anomalous-scan-setting-copy');
copy.append(el('span', 'anomalous-scan-setting-title', t(titleKey)));
const helpText = help ?? (helpKey ? t(helpKey) : '');
if (helpText) copy.append(el('small', 'anomalous-scan-setting-help', helpText));
line.append(copy, control);
return line;
}
/** Choices side by side; `onPick(value)` when one is clicked. */
function segment(choices, current, onPick) {
const box = el('div', 'anomalous-scan-segment');
box.setAttribute('role', 'radiogroup');
for (const [value, labelKey] of choices) {
const choice = button('anomalous-scan-segment-btn', t(labelKey), () => onPick(value));
choice.setAttribute('role', 'radio');
choice.setAttribute('aria-checked', String(value === current));
box.append(choice);
}
return box;
}
function toggleSwitch(checked, onChange) {
const input = el('input', 'anomalous-scan-switch');
input.type = 'checkbox';
input.checked = checked;
input.onchange = () => onChange(input.checked);
return input;
}
/** A slider with its value shown as a percentage. */
function slider(value, { min, max, step }, onCommit) {
const box = el('label', 'anomalous-settings-slider');
const input = el('input');
input.type = 'range';
Object.assign(input, { min: String(min), max: String(max), step: String(step), value: String(value) });
const shown = el('span', 'anomalous-settings-slider-value', `${Math.round(value * 100)}%`);
input.oninput = () => { shown.textContent = `${Math.round(Number(input.value) * 100)}%`; onCommit(Number(input.value)); };
box.append(input, shown);
return box;
}
function group(titleKey, ...rows) {
const box = el('section', 'anomalous-scan-card anomalous-scan-group');
box.append(el('h3', 'anomalous-scan-group-title', t(titleKey)), ...rows);
return box;
}
function formatBytes(bytes) {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
/** The card image cache: how much it holds, and a way to empty it. */
function cacheRow(owner) {
const clear = button('anomalous-scan-secondary anomalous-scan-small-btn', t('settingsCacheClear'), async () => {
clear.disabled = true;
try {
await fetch('/anomalous/card_cache/clear', { method: 'POST' });
} finally {
clear.disabled = false;
refresh();
}
});
const line = row('settingsCacheTitle', null, clear, { help: t('settingsCacheLoading') });
const help = line.querySelector('.anomalous-scan-setting-help');
const refresh = async () => {
try {
const data = await (await fetch('/anomalous/card_cache')).json();
help.textContent = t('settingsCacheHelp', { count: data.count, size: formatBytes(data.bytes), limit: formatBytes(data.limit) });
clear.disabled = !data.count;
} catch (_) {
help.textContent = t('settingsCacheUnknown');
}
};
refresh();
return line;
}
function appearanceGroup(owner, redraw) {
const prefs = owner.displayPrefs.get();
const language = localStorage.getItem('anomalous_lang') || 'auto';
const rows = [
row('sidebarViewMode', 'settingsViewModeHelp', segment(
[['compact', 'sidebarViewModeCompact'], ['standard', 'sidebarViewModeStandard'], ['aesthetic', 'sidebarViewModeAesthetic']],
prefs.viewMode, mode => { owner.displayPrefs.setViewMode(mode); redraw(); })),
];
if (prefs.viewMode === 'aesthetic') {
rows.push(row('sidebarBgAtmosphere', 'settingsAtmosphereHelp',
slider(prefs.bgOpacity, { min: 0, max: 1, step: 0.05 }, value => owner.displayPrefs.setBgOpacity(value))));
}
rows.push(
row('sidebarUiScale', 'settingsScaleHelp', slider(prefs.scale, { min: 0.5, max: 1.5, step: 0.1 }, value => owner.displayPrefs.setScale(value))),
row('settingsTheme', 'settingsThemeHelp', segment(
[['auto', 'settingsThemeAuto'], ['dark', 'settingsThemeDark'], ['light', 'settingsThemeLight'], ['abyssal', 'settingsThemeAbyssal']],
themePreference(), value => { setThemePreference(value, true); redraw(); })),
row('mainLanguageSetting', null, segment([['auto', 'mainLanguageAuto'], ['zh', 'mainLanguageChinese'], ['en', 'mainLanguageEnglish']],
language, async value => {
// The ComfyUI setting's own change handler switches the language; without it, switch here.
if (!await setComfySetting(LANGUAGE_SETTING_ID, value)) applyLanguagePreference(value);
})),
);
return group('settingsAppearance', ...rows);
}
function cardsGroup(owner, redraw) {
const reload = () => {
owner.galleryLoaded = false; // the gallery's video cards follow Video covers too
owner.loadModels();
redraw();
};
return group('settingsCards',
row('sidebarCardQuality', 'settingsCardQualityHelp', segment(
[['balanced', 'sidebarOptimizedThumbnail'], ['original', 'sidebarOriginalCover']], owner.cardThumbnailMode, value => {
owner.cardThumbnailMode = value === 'original' ? 'original' : 'balanced';
localStorage.setItem('anomalous_card_thumbnail_mode', owner.cardThumbnailMode);
reload();
})),
row('sidebarVideoPlayback', 'settingsVideoHelp', segment(
[['hover', 'sidebarHoverPlay'], ['always', 'sidebarAlwaysPlay']], owner.energySaving ? 'hover' : 'always', value => {
owner.energySaving = value === 'hover';
localStorage.setItem('anomalous_energy_saving', String(owner.energySaving));
reload();
})),
cacheRow(owner),
);
}
function galleryGroup(owner) {
return group('settingsGallery',
// The gallery lists again when it is next shown.
row('galleryFormatsTitle', 'galleryFormatsHelp', createGalleryFormatSettings(() => { owner.galleryLoaded = false; })),
);
}
function foldersGroup(owner) {
return group('settingsFolders',
row('sidebarManageFolders', 'settingsFoldersHelp',
button('anomalous-scan-secondary anomalous-scan-small-btn', t('settingsOpen'), () => {
owner.settingsView = 'folders';
render(owner);
})));
}
function workflowGroup(owner) {
return group('settingsWorkflows',
row('settingsCheckOnOpen', 'settingsCheckOnOpenHelp', toggleSwitch(checkOnOpen(), on => setCheckOnOpen(owner, on))),
row('sidebarProvenance', 'sidebarProvenanceDesc', toggleSwitch(localStorage.getItem('anomalous_inject_hash') !== 'false', on => {
// Read by hash_resolver.js when a workflow is saved.
localStorage.setItem('anomalous_inject_hash', on ? 'true' : 'false');
})));
}
/** The download folder: a ready-made choice, or one typed by hand; an example path below. */
function folderChoice(current) {
const box = el('div', 'anomalous-download-folder-choice');
const select = el('select', 'anomalous-download-root');
FOLDER_PRESETS.forEach((value, index) => {
const option = el('option', '', t(`settingsDownloadFolderPreset${index}`));
option.value = value;
select.append(option);
});
const custom = el('option', '', t('settingsDownloadFolderCustom'));
custom.value = '*'; // never a folder name
select.append(custom);
const input = el('input', 'anomalous-download-folder');
input.type = 'text';
input.spellcheck = false;
input.placeholder = t('settingsDownloadFolderRoot');
const example = el('small', 'anomalous-download-folder-example');
const show = (folder) => {
const preset = FOLDER_PRESETS.includes(folder);
select.value = preset && !input.dataset.typing ? folder : custom.value;
input.hidden = select.value !== custom.value;
input.value = folder;
const sub = expandFolder(folder, 'SDXL 1.0', []);
example.textContent = t('settingsDownloadFolderExample', { path: `loras/${sub ? `${sub}/` : ''}model.safetensors` });
};
const save = (folder) => saveDownloadSettings({ folder })
.then(saved => { input.classList.remove('is-bad'); show(saved.folder); })
.catch(() => input.classList.add('is-bad'));
select.onchange = () => {
if (select.value === custom.value) {
input.dataset.typing = '1';
input.hidden = false;
input.focus();
} else {
delete input.dataset.typing;
save(select.value);
}
};
input.onchange = () => save(input.value);
show(current);
box.append(select, input, example);
return box;
}
/** The proxy the plugin's own requests use (net_proxy.py; empty = the system's), and a test that Civitai answers. */
function networkGroup() {
const input = el('input', 'anomalous-download-folder');
input.type = 'text';
input.spellcheck = false;
input.placeholder = t('settingsProxyPlaceholder');
const proxyRow = row('settingsProxy', 'settingsProxyHelp', input);
const proxyHelp = proxyRow.querySelector('.anomalous-scan-setting-help');
fetch('/anomalous/config').then(res => res.json()).then(config => { input.value = config.proxy || ''; })
.catch(error => console.warn('[AMB] Settings: proxy unavailable.', error));
input.onchange = async () => {
let data = null;
try {
const res = await fetch('/anomalous/save_config', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ proxy: input.value }),
});
data = await res.json();
} catch (_) { /* shown below as not saved */ }
const saved = data?.status === 'ok';
input.classList.toggle('is-bad', !saved);
// Opened from another device, the server refuses the save (local_guard.py): not the address's fault.
const why = { socks: 'settingsProxySocks', local_only: 'settingsProxyLocalOnly' }[data?.code] || 'settingsProxyBad';
proxyHelp.textContent = saved ? t('settingsProxyHelp') : t(why);
if (saved) input.value = (await fetch('/anomalous/config').then(res => res.json()).catch(() => ({}))).proxy ?? input.value;
};
const test = button('anomalous-scan-secondary anomalous-scan-small-btn', t('settingsNetworkTest'), async () => {
test.disabled = true;
testHelp.textContent = t('settingsNetworkTesting');
try {
const data = await (await fetch('/anomalous/civitai_check')).json();
testHelp.textContent = data.code === 'local_only' ? t('settingsNetworkLocalOnly')
: !data.reachable ? t('settingsNetworkFail', { why: failureWords(data.failure) })
: data.key === 'refused' ? t('settingsNetworkKeyRefused')
: t(data.host === 'civitai.red' ? 'settingsNetworkOkRed' : 'settingsNetworkOk', { ms: data.ms });
} catch (error) {
testHelp.textContent = t('settingsNetworkFail', { why: String(error.message || error) });
} finally {
test.disabled = false;
}
});
const testRow = row('settingsNetworkTest', 'settingsNetworkTestHelp', test);
const testHelp = testRow.querySelector('.anomalous-scan-setting-help');
return group('settingsNetwork', proxyRow, testRow);
}
/** Where the files only this plugin uses sit beside each model, and putting them there (sidecar_layout.js). */
function sidecarGroup() {
const box = group('settingsSidecars');
const status = el('small', 'anomalous-scan-setting-help');
let current = 'beside';
let busy = false;
const run = async (layout) => {
if (busy) return;
busy = true;
status.textContent = t('settingsSidecarsCounting');
try {
const plan = await planSidecars(layout);
const words = { moves: plan.moves, duplicates: plan.duplicates };
if ((plan.moves || plan.duplicates) && !await anomalousConfirm(t(layout === 'hidden' ? 'settingsSidecarsConfirmHidden' : 'settingsSidecarsConfirmBeside', words))) {
status.textContent = '';
return;
}
status.textContent = t('settingsSidecarsMoving');
const done = await applySidecars(layout);
current = done.layout;
status.textContent = done.failed.length
? t('settingsSidecarsPartly', { moved: done.moved, deduped: done.deduped, failed: done.failed.length + done.more })
: t('settingsSidecarsDone', { moved: done.moved, deduped: done.deduped });
if (done.failed.length) console.warn('[AMB] Settings: side files that did not move.', done.failed);
draw();
} catch (error) {
console.warn('[AMB] Settings: side files.', error);
status.textContent = t('settingsSidecarsFailed');
} finally {
busy = false;
}
};
const draw = () => {
const where = row('settingsSidecarsWhere', 'settingsSidecarsWhereHelp', segment(
[['beside', 'settingsSidecarsBeside'], ['hidden', 'settingsSidecarsHidden']], current,
layout => { if (layout !== current) run(layout); }));
const tidy = row('settingsSidecarsTidy', 'settingsSidecarsTidyHelp',
button('anomalous-scan-secondary anomalous-scan-small-btn', t('settingsSidecarsTidyButton'), () => run(current)));
box.replaceChildren(box.firstChild, where, tidy, status);
};
draw();
fetch('/anomalous/config').then(res => res.json()).then(config => {
current = config.sidecar_layout === 'hidden' ? 'hidden' : 'beside';
draw();
}).catch(error => console.warn('[AMB] Settings: side file place unavailable.', error));
return box;
}
/** Where downloads and imported models go (model_download.js); filled once the settings are read. */
function downloadGroup(owner, redraw) {
const box = group('settingsDownloads');
fetchDownloadSettings().then(settings => {
box.append(
row('settingsDownloadPlace', 'settingsDownloadPlaceHelp', segment(
[['workflow', 'settingsDownloadPlaceWorkflow'], ['folder', 'settingsDownloadPlaceFolder']], settings.place,
value => saveDownloadSettings({ place: value }).then(redraw).catch(() => {}))),
row('settingsDownloadFolder', 'settingsDownloadFolderHelp', folderChoice(settings.folder)),
row('settingsHfMirror', 'settingsHfMirrorHelp', toggleSwitch(hfMirrorOn(settings),
on => saveDownloadSettings({ hf_mirror: on }).catch(() => redraw()))),
row('settingsDownloadKey', 'settingsDownloadKeyHelp', button('anomalous-scan-secondary anomalous-scan-small-btn',
t('settingsDownloadKeySet'), () => saveApiKey())),
);
}).catch(error => console.warn('[AMB] Settings: download settings unavailable.', error));
return box;
}
/** Why DeepL did not translate (translation_routes.py `deepl_error`), in the user's words. */
function deeplReason(error) {
const known = { bad_key: 'settingsDeeplBadKey', quota: 'settingsDeeplQuota', busy: 'settingsDeeplBusy' };
return known[error] ? t(known[error]) : String(error || '?');
}
/**
* Asks for the DeepL key and saves it, then translates one sentence to show whether it works.
* Resolves to the saved key ("" when removed), or null when nothing was saved.
*/
async function saveDeeplKey() {
const value = prompt(t('settingsDeeplKeyPrompt'), '');
if (value === null) return null;
const key = value.trim();
try {
const res = await fetch('/anomalous/save_config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deepl_key: key }),
});
const data = await res.json();
if (data.status !== 'ok') throw new Error(data.message || `HTTP ${res.status}`);
} catch (error) {
alert(t('settingsDeeplKeySaveFailed', { error: String(error.message || error) }));
return null;
}
if (!key) {
alert(t('settingsDeeplKeyCleared'));
return key;
}
try {
const res = await fetch('/anomalous/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: 'A girl reading under a cherry tree.', target_lang: 'zh-CN' }),
});
const data = await res.json();
alert(data.engine === 'deepl'
? t('settingsDeeplKeyWorks', { text: data.translated })
: t('settingsDeeplKeyFailed', { why: deeplReason(data.deepl_error || data.error) }));
} catch (error) {
alert(t('settingsDeeplKeyFailed', { why: String(error.message || error) }));
}
return key;
}
/** Prompt translation: DeepL with the user's own key, else the free services (translation_routes.py). */
function translationGroup() {
const box = group('settingsTranslation');
fetch('/anomalous/config').then(res => res.json()).then(config => {
const tag = el('span');
const show = (hasKey) => {
tag.className = `anomalous-scan-tag ${hasKey ? 'is-on' : 'is-off'}`;
tag.textContent = t(hasKey ? 'settingsDeeplKeyOn' : 'settingsDeeplKeyOff');
};
show(Boolean(config.has_deepl_key));
// Only the tag changes: redrawing the page would lose its scroll place.
const line = row('settingsDeeplKey', 'settingsDeeplKeyHelp', button('anomalous-scan-secondary anomalous-scan-small-btn',
t('settingsDownloadKeySet'), async () => {
const key = await saveDeeplKey();
if (key !== null) show(Boolean(key));
}));
line.querySelector('.anomalous-scan-setting-title').append(tag);
box.append(line);
}).catch(error => console.warn('[AMB] Settings: translation settings unavailable.', error));
return box;
}
/** One .zip of the user's data to keep anywhere, and putting one back (ui_backup.js). */
function backupGroup(owner) {
const small = (label, onClick) => button('anomalous-scan-secondary anomalous-scan-small-btn', label, onClick);
return group('settingsBackup',
row('settingsBackupExport', 'settingsBackupExportHelp', small(t('settingsBackupExportButton'), () => exportBackup())),
row('settingsBackupImport', 'settingsBackupImportHelp', small(t('settingsBackupImportButton'), () => importBackup(owner))));
}
function openingGroup(owner, redraw) {
const mode = comfySetting(ENTRY_MODE_SETTING_ID) || 'floating';
return group('settingsOpening',
row('mainEntryModeSetting', 'settingsEntryHelp', segment(
[['floating', 'mainEntryModeFloating'], ['topbar', 'mainEntryModeTopbar'], ['menu', 'mainEntryModeMenu']], mode, async value => {
await setComfySetting(ENTRY_MODE_SETTING_ID, value);
redraw();
})),
row('settingsFollowNode', 'settingsFollowNodeHelp', toggleSwitch(followsSelection(), setFollowsSelection)),
row('settingsLayout', 'settingsLayoutHelp', button('anomalous-scan-secondary anomalous-scan-small-btn', t('settingsReset'), () => {
if (!confirm(t('sidebarResetConfirm'))) return;
owner.displayPrefs.resetLayout();
redraw();
})),
);
}
function helpGroup(owner) {
const small = (label, onClick) => button('anomalous-scan-secondary anomalous-scan-small-btn', label, onClick);
return group('settingsHelp',
row('settingsTour', 'settingsTourHelp', small(t('settingsStart'), () => startSpotlightTour(owner))),
row('updateGuideReplay', 'settingsNewsHelp', small(t('settingsOpen'), () => showUpdateGuide(owner, { force: true }))),
row('settingsGuide', 'settingsGuideHelp', small(t('settingsOpenGuide'), () => {
window.open(window.anomalous_browser_lang === 'zh' ? README_ZH_URL : README_URL, '_blank', 'noopener');
})),
row('feedbackReport', 'settingsReportHelp', small(t('feedbackWrite'), () => openFeedbackDialog(owner, 'bug'))),
row('feedbackSuggest', 'settingsSuggestHelp', small(t('feedbackWrite'), () => openFeedbackDialog(owner, 'idea'))),
row('feedbackCopy', 'feedbackCopyHint', (() => {
const copy = small(t('feedbackCopy'), async () => {
copy.textContent = t(await copyDiagnostics(owner) ? 'feedbackCopied' : 'feedbackCopyFailed');
setTimeout(() => { copy.textContent = t('feedbackCopy'); }, 1600);
});
return copy;
})()),
row('settingsStar', 'settingsStarHelp', small(t('settingsStarOpen'), openRepoPage)),
);
}
// Tab -> its title and its cards, in order.
const TABS = [
['look', 'settingsTabLook', (owner, redraw) => [appearanceGroup(owner, redraw), cardsGroup(owner, redraw), galleryGroup(owner), openingGroup(owner, redraw)]],
['models', 'settingsTabModels', (owner, redraw) => [foldersGroup(owner), networkGroup(), downloadGroup(owner, redraw), sidecarGroup()]],
['workflows', 'settingsTabWorkflows', (owner) => [workflowGroup(owner)]],
['canvas', 'settingsTabCanvas', (owner, redraw) => [group('canvasMenuGroup', ...canvasMenuRows({ row, redraw }))]],
['packs', 'settingsTabPacks', (owner, redraw) => [renderPackUsage(owner, redraw)]],
['prompts', 'settingsTabPrompts', () => [translationGroup()]],
['backup', 'settingsTabBackup', (owner) => [backupGroup(owner)]],
['help', 'settingsTabHelp', (owner) => [helpGroup(owner)]],
];
function tabBar(owner, current) {
const bar = el('div', 'anomalous-settings-tabs');
bar.setAttribute('role', 'tablist');
for (const [id, labelKey] of TABS) {
const tab = button('anomalous-settings-tab', t(labelKey), () => {
if (owner.settingsTab === id) return;
owner.settingsTab = id;
render(owner);
});
tab.setAttribute('role', 'tab');
tab.setAttribute('aria-selected', String(id === current));
bar.append(tab);
}
return bar;
}
function render(owner) {
const panel = owner.settingsPanel;
const tab = TABS.find(([id]) => id === owner.settingsTab) || TABS[0];
owner.settingsTab = tab[0];
const view = `${owner.settingsView || ''}|${tab[0]}`;
const top = panel._settingsView === view ? panel.scrollTop : 0;
panel._settingsView = view;
const redraw = () => render(owner);
const page = el('div', 'anomalous-scan-page');
if (owner.settingsView === 'folders') {
panel.replaceChildren(page);
panel.scrollTop = 0;
renderFolderPage(owner, page, () => {
owner.settingsView = '';
render(owner);
});
return;
}
page.append(
button('anomalous-scan-back', t('settingsBack'), () => leaveSettingsPage(owner)),
el('h1', 'anomalous-scan-title', t('sidebarSettings')),
tabBar(owner, tab[0]),
...tab[2](owner, redraw),
);
panel.replaceChildren(page);
panel.scrollTop = top;
}
export function isSettingsPageOpen(owner) {
return Boolean(owner.settingsPanel && owner.settingsPanel.style.display !== 'none');
}
/** Opens the page (at `tab`, else the tab seen last); `keepReturn`: drawn again in place
* (language change), Back still goes where it went. */
export function openSettingsPage(owner, { keepReturn = false, tab = '' } = {}) {
if (!keepReturn && !isSettingsPageOpen(owner)) {
owner.settingsReturn = owner.currentShellPage?.() || 'home';
owner.settingsView = '';
}
if (tab) {
owner.settingsTab = tab;
owner.settingsView = '';
}
owner.enterToolPage?.('settings');
owner.hideAllPanels();
owner.settingsPanel.style.display = 'flex';
document.getElementById('anomalous-global-settings-btn')?.classList.add('is-active');
render(owner);
}
/** Back to the page the gear was pressed on. */
export function leaveSettingsPage(owner) {
const back = owner.settingsReturn || 'home';
owner.settingsReturn = null;
if (back === 'scan') owner.openScanPage();
else if (back === 'doctor') owner.openDoctorPage();
else owner.goTo(['assistant', 'settings'].includes(back) ? 'home' : back);
}