Merge feature/model-import: folder column follows the type, opens folders

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/styles.css
This commit is contained in:
DemonGatanjieu
2026-10-13 20:28:15 +08:00
15 changed files with 280 additions and 231 deletions
+5 -3
View File
@@ -83,8 +83,9 @@ covered by the `styles.css` manifest.
and no scan replaces or deletes it.
- `api/model_catalog.py`, `api/model_resolution.py`, `api/model_metadata.py`, and
`api/model_media.py` own model listing, identity recovery, mutation, and covers;
`api/model_folder.py` shows a model file in Explorer / Finder (by type, folder index and
file name, never a path; a POST, so only this computer may ask);
`api/model_folder.py` shows a model file in Explorer / Finder, or without a file name opens
the models folder (by type, folder index, subfolder and file name, never a path; a POST, so
only this computer may ask);
`api/models.py` is a compatibility facade; `api/model_constants.py` holds the
model, media and sidecar extensions they share. `api/model_type_listing.py` lists a
whole models folder with its subfolders for the models page's type chips.
@@ -349,7 +350,8 @@ covered by the `styles.css` manifest.
copied into `output/audio/`.
- `ui_sidebar.js` assembles the browser window (rail, list column, header, page panels;
`remote_view.js` adds the header's "view only" chip when opened from another device)
and renders the model folder list. `ui_shell_rail.js` owns the left icon rail: one
and puts the model folder column in the list column (`ui_folder_list.js`: the folders of the
type the chips show, counts with subfolders, 📂 to open one in Explorer). `ui_shell_rail.js` owns the left icon rail: one
entry per page, the tool slots and the settings slot. `ui_shell_nav.js` owns page
navigation (`goTo`): the domain each page needs, which pages have a list and whether
it is open (remembered per page; closed and overlaying below 760 px), the header's
+21 -8
View File
@@ -1,7 +1,7 @@
"""Shows a model file in this computer's file manager: Explorer or Finder with the file
selected, the folder on Linux. The request names the model the way the browser lists it
(type, folder index, subfolder, file name), never a path, and local_guard lets only this
computer ask (it is a POST)."""
selected, the folder on Linux; or, without a file name, opens a models folder (a type's root
or a subfolder). The request names it the way the browser lists it (type, folder index,
subfolder, file name), never a path, and local_guard lets only this computer ask (a POST)."""
import os
import subprocess
import sys
@@ -90,7 +90,20 @@ def _reveal(path):
subprocess.Popen(["xdg-open", os.path.dirname(path)])
def _open_folder(folder):
if sys.platform == "win32":
before = set(_explorer_windows())
subprocess.Popen(f'explorer "{folder}"')
threading.Thread(target=_raise_explorer, args=(folder, before), daemon=True).start()
elif sys.platform == "darwin":
subprocess.Popen(["open", folder])
else:
subprocess.Popen(["xdg-open", folder])
async def api_open_model_folder(request):
"""{type, path_idx, subfolder, filename}: Explorer with the model selected; without a
filename, the folder (type root or subfolder) itself."""
try:
data = await request.json()
except ValueError:
@@ -98,15 +111,15 @@ async def api_open_model_folder(request):
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
try:
filename = require_filename(data.get("filename", ""))
_, folder = resolve_folder_subdir(data.get("type", ""), int(data.get("path_idx", 0)), data.get("subfolder", "/"))
path = resolve_within(folder, filename)
filename = data.get("filename")
path = resolve_within(folder, require_filename(filename)) if filename else None
except (ValueError, KeyError, TypeError):
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
if not os.path.isfile(path):
return web.json_response({"status": "error", "message": "Model file not found"}, status=404)
if not os.path.isfile(path) if path else not os.path.isdir(folder):
return web.json_response({"status": "error", "message": "Not found"}, status=404)
try:
_reveal(path)
_reveal(path) if path else _open_folder(folder)
except OSError as error:
return web.json_response({"status": "error", "message": str(error)}, status=500)
return web.json_response({"status": "success"})
+2 -1
View File
@@ -45,7 +45,8 @@ Major UI panels live in `web/modules/ui_*.js`. Shared browser state remains on
the `AnomalousBrowser` instance. Pure parsing, normalization, comparison, and
transaction helpers remain in focused modules rather than acquiring DOM state.
`ui_sidebar.js` assembles the browser window and the model folder list; the left
`ui_sidebar.js` assembles the browser window and places the model folder column
(`ui_folder_list.js`, the current type's folders only); the left
icon rail (`ui_shell_rail.js`), page navigation and the list column's open state
(`ui_shell_nav.js`), the home page (`ui_home.js`) and the window frame
(`ui_shell_frame.js`) are their own modules. Every page change goes through
+62
View File
@@ -0,0 +1,62 @@
"""POST /anomalous/open_model_folder: a model file shown selected, or a models folder opened."""
import asyncio
import json
import shutil
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
from api import model_folder
class _Request:
def __init__(self, body):
self.body = body
async def json(self):
return self.body
def ask(body, root):
with mock.patch.object(model_folder, "resolve_folder_subdir",
side_effect=lambda kind, idx, sub: (root, model_folder.resolve_within(root, sub.strip("/")))), \
mock.patch.object(model_folder, "_reveal") as reveal, \
mock.patch.object(model_folder, "_open_folder") as open_folder:
response = asyncio.run(model_folder.api_open_model_folder(_Request(body)))
return response.status, json.loads(response.text), reveal, open_folder
class OpenModelFolderTests(unittest.TestCase):
def setUp(self):
self.root = os.path.realpath(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.root, True)
Path(self.root, "Anime").mkdir()
Path(self.root, "Anime", "a.safetensors").write_bytes(b"x")
def test_a_file_is_shown_selected(self):
status, _, reveal, open_folder = ask({"type": "loras", "subfolder": "/Anime", "filename": "a.safetensors"}, self.root)
self.assertEqual(status, 200)
reveal.assert_called_once_with(str(Path(self.root, "Anime", "a.safetensors")))
open_folder.assert_not_called()
def test_without_a_file_name_the_folder_opens(self):
status, _, reveal, open_folder = ask({"type": "loras", "subfolder": "/Anime"}, self.root)
self.assertEqual(status, 200)
open_folder.assert_called_once_with(str(Path(self.root, "Anime")))
reveal.assert_not_called()
def test_missing_and_outside_are_refused(self):
self.assertEqual(ask({"type": "loras", "subfolder": "/Nope"}, self.root)[0], 404)
self.assertEqual(ask({"type": "loras", "subfolder": "/Anime", "filename": "b.safetensors"}, self.root)[0], 404)
self.assertEqual(ask({"type": "loras", "subfolder": "/../.."}, self.root)[0], 400)
self.assertEqual(ask({"type": "loras", "subfolder": "/", "filename": "../x"}, self.root)[0], 400)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -48,7 +48,7 @@ export class AnomalousBrowser {
this.currentPathIdx = 0;
this.currentSubfolder = '/';
this.foldersData = null;
this.expandedFolders = new Set(['/', 'checkpoints', 'loras', 'unet', 'diffusion_models']);
this.collapsedFolders = new Set(); // folders folded in the folder column (type + path), this session
// Video covers play on hover unless you chose "always": fewer videos decoding at once.
this.energySaving = localStorage.getItem('anomalous_energy_saving') !== 'false';
this.cardThumbnailMode = localStorage.getItem('anomalous_card_thumbnail_mode') === 'original'
+8 -10
View File
@@ -216,9 +216,10 @@ export const i18n = {
files: '个文件。',
cleanFail: '❌ 清理失败: ',
cleanErr: '❌ 清理出错: ',
folders: '文件夹',
collapseAll: '收起全部',
expandAll: '展开全部',
folderOpen: '打开',
folderOpenHint: '在资源管理器里打开这个文件夹',
folderAll: '全部',
folderTopOnly: '根目录里的',
scanPageTitle: "扫描模型",
scanPageLead: "扫描就是给模型配上封面和说明:插件算出每个模型文件的“指纹”,拿去 Civitai(最大的 AI 绘图模型网站)查它是哪个模型,查到就下载封面、触发词,并记下它配合哪个大模型用(底模)。查不到的,就从文件本身猜它的底模。扫过的不会重复下载。",
scanPageMatched: "Civitai 上查到了",
@@ -1747,8 +1748,6 @@ export const i18n = {
sidebarOptimizedThumbnail: "缩略图(推荐)",
sidebarOriginalCover: "原图",
sidebarModuleNotLoaded: '模块尚未加载,请稍等或刷新页面!',
sidebarExpandAll: '展开全部',
sidebarCollapseAll: '收起全部',
sidebarSearchModels: '搜索模型...',
delSure: '确认删除?',
dockTitle: '侧边停靠',
@@ -2676,9 +2675,10 @@ export const i18n = {
files: 'files.',
cleanFail: '❌ Failed: ',
cleanErr: '❌ Error: ',
folders: 'Folders',
collapseAll: 'Collapse All',
expandAll: 'Expand All',
folderOpen: 'Open',
folderOpenHint: 'Open this folder in the file manager',
folderAll: 'All',
folderTopOnly: 'In the top folder',
scanPageTitle: "Scan models",
scanPageLead: "Scanning gives your models a cover and a description: the plugin takes each model file's fingerprint (its hash) to Civitai, the largest site for AI image models, to find which model it is; when found, it downloads the cover and trigger words and notes which base model it works with. For files Civitai does not know, the base model is guessed from the file. Models already scanned are not downloaded again.",
scanPageMatched: "Found on Civitai",
@@ -4207,8 +4207,6 @@ export const i18n = {
sidebarOptimizedThumbnail: "Thumbnails (recommended)",
sidebarOriginalCover: "Originals",
sidebarModuleNotLoaded: 'Module not loaded, please refresh!',
sidebarExpandAll: 'Expand All',
sidebarCollapseAll: 'Collapse All',
sidebarSearchModels: 'Search models...',
delSure: 'Confirm Delete?',
dockTitle: 'Dock to Left',
+11 -6
View File
@@ -1,20 +1,21 @@
/**
* Where a model's file is: the details' path line (the full path, or the file name, with Copy)
* and Show in folder (Explorer / Finder opens with the file selected; only on the computer
* ComfyUI runs on), which the details' Folder button calls.
* Where a model's file is: the details' path line (the full path, or the file name, with Copy),
* and the file manager (only on the computer ComfyUI runs on): showInFolder for the details'
* Folder button (Explorer / Finder with the file selected), openModelFolder for the folder
* column's 📂 (a type's folder or a subfolder).
*/
import { translate as t } from './locales.js';
import { anomalousAlert } from './ui_dialog.js';
import { showWorkbenchToast } from './ui_prompt_toast.js';
/** `where`: the model's { type, path_idx, subfolder } in the browser. */
export async function showInFolder(model, where) {
/** `where`: { type, path_idx, subfolder } as the browser lists it; no `filename`: the folder itself. */
export async function openModelFolder(where, filename = '') {
try {
const response = await fetch('/anomalous/open_model_folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...where, filename: model.filename }),
body: JSON.stringify({ ...where, filename }),
});
const data = await response.json().catch(() => ({}));
if (data.status !== 'success') throw new Error(data.message || `HTTP ${response.status}`);
@@ -23,6 +24,10 @@ export async function showInFolder(model, where) {
}
}
export function showInFolder(model, where) {
return openModelFolder(where, model.filename);
}
export async function copyToClipboard(text, doneKey) {
try {
await navigator.clipboard.writeText(text);
+98
View File
@@ -0,0 +1,98 @@
/**
* The models page's folder column: the folders of the type the chips show, not every type. Its
* heading names the type with 📂 Open (Explorer opens the type's folder); then All (the whole
* type), the subfolders as a tree whose numbers count their subfolders too (empty ones dimmed,
* ▸ folds one away for this session), and "In the top folder" for models lying directly in the
* type's folder when it has subfolders. The listed folder is highlighted; a subfolder's 📂
* shows on hover.
*/
import { translate as t } from './locales.js';
import { openType, typeLabel } from './ui_model_types.js';
import { openModelFolder } from './model_location.js';
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function openButton(where, label = '') {
const button = el('button', 'anomalous-folder-open', `📂${label ? ` ${label}` : ''}`);
button.type = 'button';
button.title = t('folderOpenHint');
button.onclick = (event) => {
event.stopPropagation();
openModelFolder(where);
};
return button;
}
function listFolder(owner, group, subfolder) {
owner.modelScope = { type: group.type, path_idx: group.path_idx, subfolder };
owner.modelView = '';
owner.currentType = group.type;
owner.currentPathIdx = group.path_idx;
owner.currentSubfolder = subfolder;
owner.hideAllPanels();
owner.grid.style.display = 'grid';
owner.renderSidebar();
owner.loadModels();
}
export function renderFolderColumn(owner) {
const box = el('div', 'anomalous-folder-column');
const scope = owner.modelScope;
const group = (owner.foldersData || []).find(item => item.type === scope?.type && item.path_idx === scope?.path_idx);
if (!group) return box;
const where = (subfolder) => ({ type: group.type, path_idx: group.path_idx, subfolder });
const folders = group.folders || {};
const paths = Object.keys(folders);
const subfolders = paths.filter(path => path !== '/').sort((a, b) => a.localeCompare(b));
const count = (path) => paths
.filter(item => path === '/' || item === path || item.startsWith(`${path}/`))
.reduce((sum, item) => sum + (folders[item].model_count || 0), 0);
const folded = owner.collapsedFolders;
const row = ({ label, number, depth, active, onPick, path, toggle }) => {
const item = el('div', 'anomalous-folder-item');
item.style.paddingLeft = `${12 + depth * 14}px`;
item.classList.toggle('active', active);
item.classList.toggle('is-empty', !number);
const fold = el('span', 'anomalous-folder-toggle', toggle ? (folded.has(toggle) ? '▸' : '▾') : '');
if (toggle) {
fold.onclick = (event) => {
event.stopPropagation();
if (folded.has(toggle)) folded.delete(toggle);
else folded.add(toggle);
owner.renderSidebar();
};
}
const name = el('span', 'anomalous-folder-name', label);
name.title = label;
item.append(fold, name, el('span', 'anomalous-folder-count', String(number)));
if (path) item.append(openButton(where(path)));
item.onclick = onPick;
return item;
};
const head = el('div', 'anomalous-folder-head');
head.append(el('span', 'anomalous-folder-head-name', typeLabel(group)), openButton(where('/'), t('folderOpen')));
box.append(head, row({ label: t('folderAll'), number: count('/'), depth: 0, active: !scope.subfolder, onPick: () => openType(owner, group) }));
for (const path of subfolders) {
const parts = path.split('/').filter(Boolean);
const hidden = parts.slice(0, -1).some((_, index) => folded.has(`${group.type}/${parts.slice(0, index + 1).join('/')}`));
if (hidden) continue;
const hasChildren = subfolders.some(item => item.startsWith(`${path}/`));
box.append(row({
label: folders[path].name, number: count(path), depth: parts.length, path,
active: scope.subfolder === path, toggle: hasChildren ? group.type + path : '',
onPick: () => listFolder(owner, group, path),
}));
}
if (subfolders.length && folders['/']?.model_count) {
box.append(row({ label: t('folderTopOnly'), number: folders['/'].model_count, depth: 0, path: '/', active: scope.subfolder === '/', onPick: () => listFolder(owner, group, '/') }));
}
return box;
}
+1 -1
View File
@@ -85,7 +85,7 @@ export async function renderFolderPage(owner, page, onBack) {
if (mode === state.mode) return;
try {
await postConfig({ folder_view_mode: mode });
owner.expandedFolders?.clear();
owner.collapsedFolders?.clear();
reloadFolders();
await load();
} catch (error) {
+1 -1
View File
@@ -60,7 +60,7 @@ function showType(owner, group) {
} catch { /* remembering the chip is a convenience */ }
}
function openType(owner, group) {
export function openType(owner, group) {
owner.modelView = '';
owner.modelBase = null; // another type has other base models
showType(owner, group);
+1 -1
View File
@@ -83,7 +83,7 @@ export function installShellNavigation(owner, { container, rail, listToggle, tit
scrim.onclick = closeDrawer;
container.insertBefore(scrim, owner.sidebarWrapper.nextSibling);
owner.sidebarWrapper.addEventListener('click', (e) => {
if (!drawerOpen() || e.target.classList.contains('anomalous-folder-toggle')) return;
if (!drawerOpen() || e.target.closest('.anomalous-folder-toggle, .anomalous-folder-open')) return;
if (e.target.closest('.anomalous-folder-item, .anomalous-audio-nav-item')) closeDrawer();
});
window.addEventListener('keydown', (e) => {
+2 -145
View File
@@ -5,7 +5,6 @@
import { markRemoteView } from './remote_view.js';
import { translate } from './locales.js';
import { escapeHtml } from './safe_dom.js';
import { showUpdateGuide } from './ui_update_guide.js';
import { AUDIO_USAGE_GUIDE, CURRENT_UPDATE_GUIDE } from './update_guide_data.js';
import { createSettingsHub } from './ui_settings_hub.js';
@@ -21,6 +20,7 @@ import { watchScans } from './ui_scan_watch.js';
import { watchCanvasChanges } from './activity_canvas.js';
import { watchWorkflowLoads } from './ui_doctor_banner.js';
import { settleModelScope } from './ui_model_types.js';
import { renderFolderColumn } from './ui_folder_list.js';
import { createModelSearch } from './ui_model_search.js';
import { attachModelDrop } from './ui_model_import.js';
@@ -29,11 +29,7 @@ const t = (key, params) => translate(key, params);
const SIDEBAR_ICONS = {
DOCK: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>`,
LIST: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="m16 15-3-3 3-3"/></svg>`,
HELP: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-1px;margin-right:7px;flex-shrink:0;"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
SETTINGS: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="display:block;"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
FOLDER: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-3px;margin-right:7px;"><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/></svg>`,
CHEVRON_UP: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px;margin-right:4px;"><polyline points="18 15 12 9 6 15"/></svg>`,
CHEVRON_DOWN: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline-block;vertical-align:-2px;margin-right:4px;"><polyline points="6 9 12 15 18 9"/></svg>`
};
/** Five quick clicks on the rail's logo toggle the Abyssal Scarlet theme. */
@@ -254,155 +250,16 @@ export function renderSidebar() {
renderAudioSidebar(this);
return;
}
this.sidebar.innerHTML = '';
const topBar = document.createElement('div');
topBar.style.display = 'flex';
topBar.style.justifyContent = 'space-between';
topBar.style.alignItems = 'center';
topBar.style.padding = '10px 15px 15px 15px';
const title = document.createElement('h3');
title.innerHTML = `${SIDEBAR_ICONS.FOLDER}<span>${t('folders')}</span>`;
title.style.color = 'var(--amb-lt-text, #fff)';
title.style.margin = '0';
title.style.display = 'inline-flex';
title.style.alignItems = 'center';
title.style.fontSize = '1.05em';
const isAllCollapsed = this.expandedFolders.size === 0;
const collapseAllBtn = document.createElement('button');
const collapseIcon = isAllCollapsed ? SIDEBAR_ICONS.CHEVRON_DOWN : SIDEBAR_ICONS.CHEVRON_UP;
collapseAllBtn.innerHTML = `${collapseIcon}<span>${t(isAllCollapsed ? 'sidebarExpandAll' : 'sidebarCollapseAll')}</span>`;
collapseAllBtn.style.display = 'inline-flex';
collapseAllBtn.style.alignItems = 'center';
collapseAllBtn.style.padding = '4px 9px';
collapseAllBtn.style.background = 'rgba(var(--amb-ink-rgb), 0.08)';
collapseAllBtn.style.color = 'var(--amb-lt-soft, #e2e8f0)';
collapseAllBtn.style.border = '1px solid var(--amb-border-strong)';
collapseAllBtn.style.borderRadius = '3px 8px 3px 8px';
collapseAllBtn.style.cursor = 'pointer';
collapseAllBtn.style.fontSize = '0.82em';
collapseAllBtn.style.transition = 'all 0.2s ease';
collapseAllBtn.onmouseover = () => { collapseAllBtn.style.background = 'rgba(var(--amb-ink-rgb), 0.15)'; collapseAllBtn.style.borderColor = 'var(--amb-border-strong)'; };
collapseAllBtn.onmouseout = () => { collapseAllBtn.style.background = 'rgba(var(--amb-ink-rgb), 0.08)'; collapseAllBtn.style.borderColor = 'var(--amb-border-strong)'; };
collapseAllBtn.onclick = () => {
if (isAllCollapsed) {
(this.foldersData || []).forEach(typeGroup => {
this.expandedFolders.add(typeGroup.type);
Object.keys(typeGroup.folders).forEach(path => {
this.expandedFolders.add(typeGroup.type + path);
});
});
} else {
this.expandedFolders.clear();
}
this.renderSidebar();
};
topBar.appendChild(title);
topBar.appendChild(collapseAllBtn);
this.sidebar.appendChild(topBar);
(this.foldersData || []).forEach(typeGroup => {
const header = document.createElement('div');
header.className = 'anomalous-type-header';
header.style.display = 'flex';
header.style.justifyContent = 'space-between';
header.style.cursor = 'pointer';
const isTypeExpanded = this.expandedFolders.has(typeGroup.type);
header.innerHTML = `<span>${escapeHtml(typeGroup.label)}</span> <span>${isTypeExpanded ? '▼' : '▶'}</span>`;
header.onclick = () => {
if (isTypeExpanded) this.expandedFolders.delete(typeGroup.type);
else this.expandedFolders.add(typeGroup.type);
this.renderSidebar();
};
this.sidebar.appendChild(header);
if (!isTypeExpanded) return;
const sortedPaths = Object.keys(typeGroup.folders).sort();
sortedPaths.forEach(path => {
const info = typeGroup.folders[path];
const parts = path.split('/').filter(p => p);
const parentPath = parts.length > 1 ? '/' + parts.slice(0, -1).join('/') : '/';
if (path !== '/' && parentPath !== '/') {
let parentId = typeGroup.type + parentPath;
if (!this.expandedFolders.has(parentId)) return;
}
const hasChildren = sortedPaths.some(p => p !== path && p.startsWith(path === '/' ? '/' : path + '/'));
const item = document.createElement('div');
item.className = 'anomalous-folder-item';
const depth = path === '/' ? 0 : parts.length;
item.style.paddingLeft = (15 + depth * 15) + 'px';
const myId = typeGroup.type + path;
const isExpanded = this.expandedFolders.has(myId);
let toggleIcon = '';
if (hasChildren) {
toggleIcon = `<span class="anomalous-folder-toggle" style="margin-right: 8px; width: 12px; display: inline-block; font-size: 0.8em; color: var(--amb-text-muted);">${isExpanded ? '▼' : '▶'}</span>`;
} else {
toggleIcon = `<span style="margin-right: 8px; width: 12px; display: inline-block;"></span>`;
}
item.innerHTML = `${toggleIcon}<span class="anomalous-folder-name" style="color: var(--amb-text-soft);">${escapeHtml(info.name)}</span> <span style="color: var(--amb-text-dim); font-size:0.8em; margin-left: 5px;">${escapeHtml(info.model_count)}</span>`;
const scope = this.modelScope;
if (scope?.subfolder === path && scope.type === typeGroup.type && scope.path_idx === typeGroup.path_idx) {
item.classList.add('active');
}
item.onclick = (e) => {
if (e.target.classList.contains('anomalous-folder-toggle')) {
if (isExpanded) this.expandedFolders.delete(myId);
else this.expandedFolders.add(myId);
this.renderSidebar();
return;
}
this.modelScope = { type: typeGroup.type, path_idx: typeGroup.path_idx, subfolder: path };
this.modelView = '';
this.currentType = typeGroup.type;
this.currentPathIdx = typeGroup.path_idx;
this.currentSubfolder = path;
this.hideAllPanels();
this.grid.style.display = 'grid';
this.renderSidebar();
this.loadModels();
};
this.sidebar.appendChild(item);
});
});
this.sidebar.replaceChildren(renderFolderColumn(this));
}
export async function loadFolders() {
try {
const res = await fetch('/anomalous/folders');
const data = await res.json();
this.foldersData = data.folders || [];
settleModelScope(this);
// Auto expand all
(this.foldersData || []).forEach(typeGroup => {
this.expandedFolders.add(typeGroup.type);
Object.keys(typeGroup.folders).forEach(path => {
this.expandedFolders.add(typeGroup.type + path);
});
});
this.renderSidebar();
this.loadModels();
} catch (e) { }
+2 -2
View File
@@ -1,5 +1,5 @@
@import url("./styles/00-foundation-models.css?v=20261011-wfd-3");
@import url("./styles/01-shell-tools.css?v=20261007-theme-2");
@import url("./styles/00-foundation-models.css?v=20261014-folders-1");
@import url("./styles/01-shell-tools.css?v=20261014-folders-1");
@import url("./styles/02-notebooks.css?v=20261012-click-2");
@import url("./styles/03-gallery-recipes.css?v=20261013-detail-1");
@import url("./styles/04-dialogs-materials.css?v=20261011-wfd-3");
-12
View File
@@ -787,18 +787,6 @@ html:not(.anomalous-floating-entry-enabled) [data-setting-id="Anomalous.ModelBro
right: 62px !important;
}
#anomalous-container.view-mode-compact .anomalous-folder-item,
#anomalous-container:not(.view-mode-standard):not(.view-mode-aesthetic) .anomalous-folder-item {
padding: 5px 12px !important;
font-size: 0.86em !important;
}
#anomalous-container.view-mode-compact .anomalous-type-header,
#anomalous-container:not(.view-mode-standard):not(.view-mode-aesthetic) .anomalous-type-header {
padding: 6px 12px !important;
font-size: 0.85em !important;
}
/* Mode 1: Standard - Clean, balanced, 8px architectural geometry */
#anomalous-container.view-mode-standard #anomalous-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+65 -40
View File
@@ -84,65 +84,95 @@ button:active::after,
margin-bottom: 15px;
}
.anomalous-type-header {
padding: 10px 15px;
background: rgba(var(--amb-ink-rgb), 0.03);
color: var(--fg-color, var(--amb-text-main));
font-weight: bold;
font-size: 0.9em;
border-top: 1px solid var(--amb-border);
border-bottom: 1px solid var(--amb-border);
/* The models page's folder column (ui_folder_list.js): the type's heading with 📂 Open, then its
folders; a folder's 📂 shows on hover, empty folders are dimmed, the listed one is marked. */
.anomalous-folder-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 12px 8px;
color: var(--amb-text-main);
font-weight: 700;
}
.anomalous-folder-head-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.anomalous-folder-item {
padding: 8px 15px;
color: var(--fg-color, var(--amb-text-soft));
cursor: pointer;
font-size: 0.9em;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
align-items: center;
gap: 4px;
min-height: 28px;
padding: 2px 8px 2px 12px;
border-left: 2px solid transparent;
color: var(--amb-text-soft);
font-size: 0.88em;
cursor: pointer;
}
.anomalous-folder-item:hover {
background: rgba(var(--amb-ink-rgb), 0.06);
color: var(--amb-text-main);
}
.anomalous-folder-item:hover { background: rgba(var(--amb-ink-rgb), 0.06); color: var(--amb-text-main); }
.anomalous-folder-item.is-empty { opacity: 0.45; }
/* Clean Neutral Active Indicator on Selected Menu (Normal Mode) */
.anomalous-folder-item.active {
background: rgba(var(--amb-ink-rgb), 0.08);
border-left-color: var(--amb-link);
background: color-mix(in srgb, var(--amb-link) 14%, transparent);
color: var(--amb-text-main);
font-weight: 600;
border-left: 2px solid #e5e7eb;
box-shadow: inset 3px 0 6px rgba(var(--amb-ink-rgb), 0.03);
border-radius: 0 6px 6px 0;
}
.anomalous-folder-item.active .anomalous-folder-name {
color: var(--amb-text-main) !important;
text-shadow: none;
.anomalous-folder-toggle {
flex: none;
width: 12px;
color: var(--amb-text-muted);
font-size: 0.85em;
text-align: center;
user-select: none;
}
.theme-abyssal-scarlet .anomalous-folder-item.active {
background: rgba(55, 12, 22, 0.85);
border-left: 2px solid #dc143c;
border-left-color: var(--c-crimson, #dc143c);
box-shadow: inset 2px 0 6px rgba(0, 0, 0, 0.8);
.anomalous-folder-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.theme-abyssal-scarlet .anomalous-folder-item.active .anomalous-folder-name {
color: #e0d8db;
text-shadow: none;
.anomalous-folder-count {
flex: none;
color: var(--amb-text-muted);
font-size: 0.85em;
font-variant-numeric: tabular-nums;
}
.anomalous-folder-open {
flex: none;
padding: 2px 7px;
border: 1px solid var(--amb-border-strong);
border-radius: 6px;
background: rgba(var(--amb-ink-rgb), 0.06);
color: var(--amb-text-main);
font: inherit;
font-size: 0.82em;
font-weight: 600;
cursor: pointer;
}
.anomalous-folder-open:hover { background: rgba(var(--amb-ink-rgb), 0.14); }
.anomalous-folder-item .anomalous-folder-open { display: none; padding: 0 5px; }
.anomalous-folder-item:hover .anomalous-folder-open { display: inline-block; }
.anomalous-folder-item:hover .anomalous-folder-count { display: none; }
/* Sidebar Icons & Actions - Normal Mode (Neutral & Clean) */
.anomalous-folder-item svg,
#anomalous-sidebar h3 svg {
color: var(--amb-text-muted) !important;
filter: none;
}
.theme-abyssal-scarlet .anomalous-folder-item svg,
.theme-abyssal-scarlet #anomalous-sidebar h3 svg {
color: #d4af37 !important;
filter: none;
@@ -262,11 +292,6 @@ button:active::after,
cursor: pointer;
}
.anomalous-folder-toggle {
cursor: pointer;
user-select: none;
}
#anomalous-api-key {
background: var(--amb-bg-card-hover);
border: 1px solid var(--amb-border-strong);