diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1f6428b..61fdac2 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -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
diff --git a/api/model_folder.py b/api/model_folder.py
index cd32bee..fb5c6b8 100644
--- a/api/model_folder.py
+++ b/api/model_folder.py
@@ -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"})
diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md
index b7abfb5..fe6474e 100644
--- a/docs/architecture/frontend.md
+++ b/docs/architecture/frontend.md
@@ -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
diff --git a/tests/test_model_folder.py b/tests/test_model_folder.py
new file mode 100644
index 0000000..71b111c
--- /dev/null
+++ b/tests/test_model_folder.py
@@ -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()
diff --git a/web/modules/browser.js b/web/modules/browser.js
index dd4ebb8..eb5b981 100644
--- a/web/modules/browser.js
+++ b/web/modules/browser.js
@@ -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'
diff --git a/web/modules/locales.js b/web/modules/locales.js
index f7073e9..0e7c575 100644
--- a/web/modules/locales.js
+++ b/web/modules/locales.js
@@ -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',
diff --git a/web/modules/model_location.js b/web/modules/model_location.js
index 4a1f592..46dd176 100644
--- a/web/modules/model_location.js
+++ b/web/modules/model_location.js
@@ -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);
diff --git a/web/modules/ui_folder_list.js b/web/modules/ui_folder_list.js
new file mode 100644
index 0000000..ccd70a3
--- /dev/null
+++ b/web/modules/ui_folder_list.js
@@ -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;
+}
diff --git a/web/modules/ui_folder_manager.js b/web/modules/ui_folder_manager.js
index 41a2c3a..5547cb6 100644
--- a/web/modules/ui_folder_manager.js
+++ b/web/modules/ui_folder_manager.js
@@ -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) {
diff --git a/web/modules/ui_model_types.js b/web/modules/ui_model_types.js
index e456987..f1d6d30 100644
--- a/web/modules/ui_model_types.js
+++ b/web/modules/ui_model_types.js
@@ -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);
diff --git a/web/modules/ui_shell_nav.js b/web/modules/ui_shell_nav.js
index da46a6a..48f5583 100644
--- a/web/modules/ui_shell_nav.js
+++ b/web/modules/ui_shell_nav.js
@@ -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) => {
diff --git a/web/modules/ui_sidebar.js b/web/modules/ui_sidebar.js
index 76c5c29..a7e7e82 100644
--- a/web/modules/ui_sidebar.js
+++ b/web/modules/ui_sidebar.js
@@ -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: ``,
LIST: ``,
- HELP: ``,
SETTINGS: ``,
- FOLDER: ``,
- CHEVRON_UP: ``,
- CHEVRON_DOWN: ``
};
/** 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}${t('folders')}`;
- 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}${t(isAllCollapsed ? 'sidebarExpandAll' : 'sidebarCollapseAll')}`;
- 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 = `${escapeHtml(typeGroup.label)} ${isTypeExpanded ? '▼' : '▶'}`;
-
- 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 = `${isExpanded ? '▼' : '▶'}`;
- } else {
- toggleIcon = ``;
- }
-
- item.innerHTML = `${toggleIcon}${escapeHtml(info.name)} ${escapeHtml(info.model_count)}`;
-
- 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) { }
diff --git a/web/styles.css b/web/styles.css
index 08935cc..d56c92f 100644
--- a/web/styles.css
+++ b/web/styles.css
@@ -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");
diff --git a/web/styles/00-foundation-models.css b/web/styles/00-foundation-models.css
index 07387d7..1190480 100644
--- a/web/styles/00-foundation-models.css
+++ b/web/styles/00-foundation-models.css
@@ -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));
diff --git a/web/styles/01-shell-tools.css b/web/styles/01-shell-tools.css
index b429cc0..e0f28ec 100644
--- a/web/styles/01-shell-tools.css
+++ b/web/styles/01-shell-tools.css
@@ -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);