Compare commits

..
Author SHA1 Message Date
DemonGatanjieu e788306291 docs(changelog): clean up version entry description in update guide 2026-09-23 22:58:07 +08:00
DemonGatanjieuandClaude Opus 5.5 eabff31c1c fix(ui): show the version in the update guide instead of the sidebar footer
The footer version sat right under the tool dock and invited misclicks.
The "!" update guide now opens with the current version and a
"Check for updates / switch version" button that closes the guide and
opens the version panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 14:53:49 +00:00
DemonGatanjieu c79b88d464 docs(changelog): fold sidebar footer version chip into v1.57.1 Beta 2026-09-23 22:34:28 +08:00
DemonGatanjieuandClaude Opus 5.5 61b828c716 fix(ui): move the version number to the sidebar footer
In the header it pushed the dock and close buttons out of view when the
browser is docked or the display is scaled. It now sits under the tool
dock and hides with the collapsed sidebar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 14:28:16 +00:00
DemonGatanjieu 58e49cc635 docs: align version references to v1.57.1-beta 2026-09-23 22:04:10 +08:00
DemonGatanjieu 585a745683 docs: update Changelog badge to v1.58_Beta 2026-09-23 21:58:14 +08:00
DemonGatanjieuandClaude Opus 5.5 0fd50b1a18 docs: changelog and README for the v1.58 version panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 13:49:27 +00:00
DemonGatanjieuandClaude Opus 5.5 e622850349 feat: version panel with manual update check, switching and rollback
- api/version_manager.py: installed version (local only), published
  releases on request (GitHub API, git tag fallback), switch to a
  published tag, return to the default branch (fast-forward only), undo
  the last switch; refuses to overwrite modified tracked files
- header version chip and "Version & updates" panel; nothing goes online
  until "Check for updates" is clicked; restart via ComfyUI Manager
- zh/en strings, styles, architecture notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 13:42:58 +00:00
11 changed files with 1167 additions and 6 deletions
+9
View File
@@ -92,6 +92,15 @@ DOM or live LiteGraph state.
local model files with that hash. Terms arrive as repeated `term` params, one
phrase each. `ui_search_chips.js` is the reusable search-block input (Enter or a
comma commits a block); `ui_gallery.js` places it above the gallery.
- `api/version_manager.py` owns the plugin's own version: the installed tag/branch
(`/anomalous/version`, local only), published releases fetched only on request
(GitHub releases API, falling back to `git ls-remote` tags; drafts skipped,
pre-releases never counted as latest), switching to a published tag (detached
checkout), returning to the default branch (fast-forward only), and undoing the
last switch (recorded in the ignored `.anomalous_version.local.json`). Every
switch refuses to run over modified tracked files. `ui_version_manager.js` is
the version line (shown in the update guide opened by the header "!" button) and panel; nothing goes online until "check for updates"
is clicked, and restarts go through ComfyUI Manager's reboot route when present.
- `web/main.js` coordinates extension registration (with `?v=...` versioned module imports busting aggressive browser ES Module caching and unconditional legacy storage key purging). `browser.js` owns the shared
browser class and extracted-method wiring; `browser_entry.js` owns the single
+15 -2
View File
@@ -1,6 +1,15 @@
# 📈 Anomalous Model Browser Changelog
## Unreleased
## v1.57.1 Beta (Version Panel: Update, Switch & Roll Back) — 2026-09-23
### 🏷️ Version & Updates Panel (版本号与一键更新 / 版本切换)
- **Version entry in the update guide (更新引导顶部的版本号与更新入口)**: the installed version (e.g. `v1.57.1-beta`) and a **Check for updates / switch version** button now appear at the top of the update guide (opened via the header **!** button), avoiding accidental clicks near the tool dock or crowding the header in docked/scaled layouts. Reading it is local only; nothing goes online.
- **Manual "Check for updates" (手动检查更新)**: the plugin never checks by itself. Clicking **Check for updates / switch version** opens the panel, and only the **Check for updates** button contacts GitHub. Each published release shows its notes, date and a GitHub link; pre-releases are hidden by default and are never offered as the latest version.
- **One-click update to published releases (一键更新到正式发布版本)**: updates only target releases published on GitHub, not in-progress commits, so what you install is a released build.
- **Switch or roll back (切换 / 回退版本)**: any published release can be installed from the list. Rolling back warns that older versions may not read settings or recipes saved by newer ones; nothing is deleted, and it all works again after returning.
- **Back to latest & undo (回到最新版 / 撤销上次切换)**: **Back to latest** returns to the default branch and pulls the newest code; **Undo last switch** returns to exactly where you were before. Versions older than v1.57.1 have no panel, so from those use **Update** for this plugin in ComfyUI Manager to come back.
- **Safe by default (安全保护)**: every switch is refused while plugin files have local modifications (the files are listed), and a local branch with unpublished commits is never overwritten.
- **Restart (重启生效)**: after a switch the panel offers to restart ComfyUI through ComfyUI Manager; without Manager it asks you to restart by hand.
## v1.57.0 Beta (Toolbox Architecture, Gallery Search & Polymorphic Drag) — 2026-09-23
@@ -238,7 +247,11 @@
- **Offline Base Model Inference**: Added a zero-API local inference engine! For models downloaded purely from HuggingFace (or private unreleased models) that return a 404 on Civitai, the scanner no longer gives up. It now forcibly parses the .safetensors structure and uses **Tensor Fingerprinting** (e.g., detecting double_blocks.0.img_attn for Flux) to accurately deduce the underlying base architecture with 100% precision.
- **Universal UI Integration**: Successfully inferred offline models are dynamically assigned a virtual .info payload (ID: -1). This instantly grants them full VIP access to the frontend ecosystem—they seamlessly appear in the Cross-Folder Radar, interact perfectly with the bilingual Notebook, and support one-click Auto-Inject loaders, all completely completely offline!
# 📈 Anomalous Model Browser Changelog
## v2.0.0 (The Workflow & UI Evolution Update)
### 🚀 Major Features
+3 -1
View File
@@ -9,7 +9,7 @@
[![ComfyUI Manager](https://img.shields.io/badge/ComfyUI-Manager-green?style=for-the-badge&logo=comfyui)](https://github.com/ltdrdata/ComfyUI-Manager)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](LICENSE)
[![Changelog](https://img.shields.io/badge/📖_Changelog-v1.57_Beta-blue?style=for-the-badge)](CHANGELOG.md)
[![Changelog](https://img.shields.io/badge/📖_Changelog-v1.57.1_Beta-blue?style=for-the-badge)](CHANGELOG.md)
[![Bilibili Video](https://img.shields.io/badge/Bilibili-视频演示-00A1D6?style=for-the-badge&logo=bilibili)](https://www.bilibili.com/video/BV1a1bv68EuA/)
[![YouTube Video](https://img.shields.io/badge/YouTube-Video_Demo-red?style=for-the-badge&logo=youtube)](https://youtu.be/hAvsj7uiaCw)
@@ -49,6 +49,7 @@
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
2. Restart ComfyUI. *(Alternatively, install via **ComfyUI Manager** by searching for `Anomalous Model Browser`)*
3. **Updating**: click the **!** button in the browser header; the current version is shown at the top, and **Check for updates / switch version** opens the version panel. From there you can update to the newest published release, roll back to an earlier one, or return to the latest version, then restart ComfyUI. The plugin never checks for updates on its own.
Open the browser via **Ctrl + Shift + M**, the floating canvas orb (**📦**), or **Extensions → Anomalous Model Browser** in ComfyUI's menu. **ComfyUI Settings → Anomalous Model Browser → Interface** lets you customize the shortcut, configure the UI language, and select an entry mode (floating button, action-bar button, or Extensions menu only).
@@ -139,6 +140,7 @@ The header provides 3 primary workspaces:
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
2. 重启 ComfyUI 即可使用。(*也可以直接在 **ComfyUI Manager** 搜索 `Anomalous Model Browser` 点击安装*)
3. **更新插件**:点击顶栏的 **!** 按钮,顶部会显示当前版本,点 **检查更新 / 切换版本** 进入版本面板。可以一键更新到最新发布版本、回退到以前的版本,或回到最新版,之后重启 ComfyUI 即可。插件不会自己联网检查更新。
可以按 **Ctrl + Shift + M**、点击画布上的悬浮 **📦**,或从 ComfyUI 顶部菜单 **扩展 → Anomalous Model Browser** 打开。**ComfyUI 设置 → Anomalous Model Browser → 界面** 会显示当前快捷键,并可直接打开 ComfyUI 原生录入窗口修改,因此冲突与保留按键检查仍然只有一套。该页面还可以让插件语言跟随 ComfyUI 或单独固定为中文/English,并严格三选一显示悬浮入口、运行按钮旁的原生顶部入口或仅使用扩展菜单。只有选择悬浮入口时才显示大小和样式选项。顶部栏重绘不会擅自改变所选模式,扩展菜单命令则始终保留,便于恢复设置。
+2 -1
View File
@@ -1,7 +1,7 @@
from . import (
folder_types, gallery_routes, materials, media_routes, model_catalog,
model_media, model_metadata, model_resolution, recipe_packages, recipes,
translation_routes,
translation_routes, version_manager,
)
from .scanner import *
from .config import *
@@ -97,3 +97,4 @@ def setup_routes(app):
app.router.add_get('/anomalous/model_images', media_routes.api_get_model_images)
app.router.add_post('/anomalous/resolve_paths_to_previews', model_catalog.api_resolve_paths_to_previews)
app.router.add_post('/anomalous/scan_missing_models', api_scan_missing_models)
version_manager.register_routes(app)
+364
View File
@@ -0,0 +1,364 @@
"""Plugin version display, published-release listing and version switching.
The plugin is normally installed as a git clone (ComfyUI Manager), so the
installed version is read from git and switching checks out a published
release tag. Nothing runs automatically: every network call starts from a
button in the UI. Switching refuses when tracked files have local changes, and
only tags that are published releases (or at least exist on the remote) can be
checked out.
"""
import asyncio
import json
import os
import re
import shutil
import subprocess
import threading
import time
import urllib.request
from aiohttp import web
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATE_FILE = os.path.join(PLUGIN_DIR, ".anomalous_version.local.json")
# A version whose tree contains this file has the version panel; older ones do not.
SWITCHER_MARKER = "api/version_manager.py"
TAG_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,99}$")
GITHUB_REMOTE = re.compile(r"github\.com[:/]+([^/]+)/([^/]+?)(?:\.git)?/?$", re.IGNORECASE)
RELEASES_CACHE_SECONDS = 300
NETWORK_TIMEOUT = 10
FETCH_TIMEOUT = 180
_lock = threading.Lock()
_releases_cache = {"expires": 0, "data": None}
class VersionError(Exception):
"""A user-facing failure with a stable code for the UI."""
def __init__(self, code, message="", **details):
super().__init__(message or code)
self.code = code
self.details = details
# ---------- git helpers ----------
def _git_executable():
found = shutil.which("git")
if not found:
raise VersionError("git_missing", "git executable not found")
return found
def _git(*args, timeout=30, check=True):
kwargs = {}
if os.name == "nt":
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
try:
result = subprocess.run(
[_git_executable(), "-C", PLUGIN_DIR, *args],
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=timeout, env=env, **kwargs,
)
except subprocess.TimeoutExpired:
raise VersionError("git_timeout", f"git {' '.join(args[:2])} timed out")
if check and result.returncode != 0:
raise VersionError("git_failed", (result.stderr or result.stdout).strip()[-500:])
return result
def _git_out(*args, **kwargs):
result = _git(*args, check=False, **kwargs)
return result.stdout.strip() if result.returncode == 0 else ""
def version_key(tag):
"""Sortable key for tags such as v1.4.2, v1.57-beta, v1.58.0-rc1 (stable after pre-release)."""
match = re.match(r"^v?(\d+(?:\.\d+)*)(.*)$", str(tag or ""))
if not match:
return ((), 0, str(tag))
numbers = tuple(int(part) for part in match.group(1).split("."))
suffix = match.group(2).lstrip("-_.")
return (numbers, 1 if not suffix else 0, suffix)
def current_state():
"""Local only (no network): what is installed and whether it can be switched."""
if not os.path.isdir(os.path.join(PLUGIN_DIR, ".git")) and _git_out("rev-parse", "--is-inside-work-tree") != "true":
return {"is_git": False}
commit = _git_out("rev-parse", "HEAD")
branch = _git_out("symbolic-ref", "--short", "-q", "HEAD") or None
exact_tag = _git_out("describe", "--tags", "--exact-match", "HEAD") or None
described = _git_out("describe", "--tags", "--always")
base_tag = _git_out("describe", "--tags", "--abbrev=0") or None
# Porcelain lines are "XY path"; keep the leading status column (no strip before slicing).
status = _git("status", "--porcelain", "--untracked-files=no", check=False).stdout
changed = [line[3:] for line in status.splitlines() if line.strip()]
return {
"is_git": True,
"commit": commit[:12],
"branch": branch,
"tag": exact_tag,
"base_tag": base_tag,
"label": exact_tag or described or commit[:12],
"dirty": bool(changed),
"changed_files": changed[:20],
"remote": _git_out("remote", "get-url", "origin") or None,
"previous": _read_state().get("previous"),
}
# ---------- releases ----------
def _github_repo(remote_url):
match = GITHUB_REMOTE.search(remote_url or "")
return (match.group(1), match.group(2)) if match else None
def _fetch_github_releases(owner, repo):
url = f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=50"
request = urllib.request.Request(url, headers={
"Accept": "application/vnd.github+json",
"User-Agent": "Anomalous-Model-Browser",
})
with urllib.request.urlopen(request, timeout=NETWORK_TIMEOUT) as response:
payload = json.loads(response.read().decode("utf-8"))
releases = []
for item in payload if isinstance(payload, list) else []:
if item.get("draft") or not item.get("tag_name"):
continue
releases.append({
"tag": item["tag_name"],
"name": item.get("name") or item["tag_name"],
"published_at": item.get("published_at"),
"prerelease": bool(item.get("prerelease")),
"notes": (item.get("body") or "")[:8000],
"url": item.get("html_url"),
})
return releases
def _remote_tags():
output = _git_out("ls-remote", "--tags", "--refs", "origin", timeout=NETWORK_TIMEOUT * 3)
tags = [line.split("refs/tags/", 1)[1] for line in output.splitlines() if "refs/tags/" in line]
return [{"tag": tag, "name": tag, "published_at": None, "prerelease": False, "notes": "", "url": None} for tag in tags]
def list_releases(force=False):
"""Published releases, newest first. GitHub API first, `git ls-remote` (uses git's proxy) as fallback."""
state = current_state()
if not state.get("is_git"):
raise VersionError("not_git", "The plugin is not a git checkout")
now = time.monotonic()
if not force and _releases_cache["data"] and now < _releases_cache["expires"]:
releases, source = _releases_cache["data"]
else:
releases, source = None, None
repo = _github_repo(state.get("remote"))
if repo:
try:
releases, source = _fetch_github_releases(*repo), "github"
except Exception:
releases = None
if releases is None:
releases, source = _remote_tags(), "git"
if not releases:
raise VersionError("offline", "Could not reach GitHub or the git remote")
releases.sort(key=lambda item: (item["published_at"] or "", version_key(item["tag"])), reverse=True)
if source == "git":
releases.sort(key=lambda item: version_key(item["tag"]), reverse=True)
_releases_cache.update(expires=now + RELEASES_CACHE_SECONDS, data=(releases, source))
current_key = version_key(state.get("tag") or state.get("base_tag"))
stable = [item for item in releases if not item["prerelease"]]
latest = stable[0]["tag"] if stable else None
result = []
for item in releases:
key = version_key(item["tag"])
entry = dict(item)
entry["current"] = item["tag"] == state.get("tag")
entry["latest"] = item["tag"] == latest
entry["direction"] = "same" if entry["current"] else ("newer" if key > current_key else "older")
result.append(entry)
return {"state": state, "releases": result, "latest": latest, "source": source}
# ---------- switching ----------
def _read_state():
try:
with open(STATE_FILE, "r", encoding="utf-8") as handle:
data = json.load(handle)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def _write_state(data):
temporary = STATE_FILE + ".tmp"
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
os.replace(temporary, STATE_FILE)
def _require_switchable():
state = current_state()
if not state.get("is_git"):
raise VersionError("not_git", "The plugin is not a git checkout")
if state["dirty"]:
raise VersionError("dirty", "Tracked files have local changes", files=state["changed_files"])
return state
def _known_tag(tag):
if not TAG_NAME.match(str(tag or "")):
raise VersionError("bad_tag", "Invalid version name")
tags = {item["tag"] for item in list_releases()["releases"]}
if tag not in tags:
raise VersionError("unknown_tag", f"{tag} is not a published version")
return tag
def _fetch_tag(tag):
_git("fetch", "--no-tags", "--force", "origin", f"+refs/tags/{tag}:refs/tags/{tag}", timeout=FETCH_TIMEOUT)
def preview_switch(tag):
"""Fetch the target and report what switching would mean, without changing anything."""
state = _require_switchable()
_known_tag(tag)
_fetch_tag(tag)
has_switcher = _git("cat-file", "-e", f"refs/tags/{tag}:{SWITCHER_MARKER}", check=False).returncode == 0
target_key = version_key(tag)
current_key = version_key(state.get("tag") or state.get("base_tag"))
return {
"tag": tag,
"direction": "same" if tag == state.get("tag") else ("newer" if target_key > current_key else "older"),
"has_switcher": has_switcher,
"from": state["label"],
"from_branch": state["branch"],
}
def switch_to(tag):
with _lock:
state = _require_switchable()
_known_tag(tag)
_fetch_tag(tag)
previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", f"refs/tags/{tag}")
_write_state({"previous": previous, "switched_at": time.time()})
return {"state": current_state(), "restart_required": True}
def _default_branch():
ref = _git_out("symbolic-ref", "--short", "refs/remotes/origin/HEAD")
if ref.startswith("origin/"):
return ref.split("/", 1)[1]
for name in ("main", "master"):
if _git("ls-remote", "--exit-code", "--heads", "origin", name, check=False, timeout=NETWORK_TIMEOUT * 3).returncode == 0:
return name
raise VersionError("no_default_branch", "Could not determine the default branch")
def return_to_latest():
"""Back to the default branch at its newest commit; the same path ComfyUI Manager's update takes."""
with _lock:
state = _require_switchable()
branch = _default_branch()
_git("fetch", "--no-tags", "origin", f"+refs/heads/{branch}:refs/remotes/origin/{branch}", timeout=FETCH_TIMEOUT)
previous = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
if _git("rev-parse", "--verify", "--quiet", f"refs/heads/{branch}", check=False).returncode == 0:
_git("checkout", "--quiet", branch)
else:
_git("checkout", "--quiet", "-b", branch, f"origin/{branch}")
merge = _git("merge", "--ff-only", "--quiet", f"origin/{branch}", check=False)
if merge.returncode != 0:
raise VersionError("diverged", f"Local {branch} has commits that are not on the remote")
_write_state({"previous": previous, "switched_at": time.time()})
return {"state": current_state(), "restart_required": True}
def undo_last_switch():
with _lock:
previous = _read_state().get("previous")
if not previous or not previous.get("commit"):
raise VersionError("no_previous", "There is no switch to undo")
state = _require_switchable()
here = {"commit": _git_out("rev-parse", "HEAD"), "branch": state["branch"], "label": state["label"]}
if previous.get("branch"):
# Going back to a branch means its current tip, even if it moved since (e.g. a Manager update).
_git("checkout", "--quiet", previous["branch"])
else:
_git("-c", "advice.detachedHead=false", "checkout", "--quiet", "--detach", previous["commit"])
_write_state({"previous": here, "switched_at": time.time()})
return {"state": current_state(), "restart_required": True}
# ---------- routes ----------
def _error_response(error):
status = 409 if error.code in {"dirty", "diverged"} else 400
if error.code in {"offline", "git_timeout", "git_failed"}:
status = 502
return web.json_response({"success": False, "code": error.code, "error": str(error), **error.details}, status=status)
async def _run(handler, *args):
try:
return web.json_response({"success": True, **(await asyncio.to_thread(handler, *args))})
except VersionError as error:
return _error_response(error)
async def _json_body(request):
try:
data = await request.json()
except Exception:
return {}
return data if isinstance(data, dict) else {}
async def api_get_version(request):
"""GET /anomalous/version - Installed version (local, no network)."""
try:
return web.json_response({"success": True, "state": await asyncio.to_thread(current_state)})
except VersionError as error:
return _error_response(error)
async def api_get_releases(request):
"""GET /anomalous/version/releases?refresh=1 - Published releases (network, only when asked)."""
return await _run(list_releases, request.query.get("refresh") == "1")
async def api_preview_switch(request):
"""POST /anomalous/version/preview {tag}"""
return await _run(preview_switch, str((await _json_body(request)).get("tag", "")))
async def api_switch_version(request):
"""POST /anomalous/version/switch {tag}"""
return await _run(switch_to, str((await _json_body(request)).get("tag", "")))
async def api_return_to_latest(request):
"""POST /anomalous/version/latest"""
return await _run(return_to_latest)
async def api_undo_switch(request):
"""POST /anomalous/version/undo"""
return await _run(undo_last_switch)
def register_routes(app):
app.router.add_get('/anomalous/version', api_get_version)
app.router.add_get('/anomalous/version/releases', api_get_releases)
app.router.add_post('/anomalous/version/preview', api_preview_switch)
app.router.add_post('/anomalous/version/switch', api_switch_version)
app.router.add_post('/anomalous/version/latest', api_return_to_latest)
app.router.add_post('/anomalous/version/undo', api_undo_switch)
+7
View File
@@ -67,6 +67,13 @@ output snapshot and deletion (searched through `image_search.py`, which reads
PNG text chunks only and caches per-image records by mtime), `translation_routes.py`
owns provider fallback, and `folder_types.py` owns configured visibility and scan scope.
`version_manager.py` runs git against the plugin checkout only (no shell, no
prompts, `CREATE_NO_WINDOW` on Windows) and accepts only tags that appear in the
published release list. It refuses switches while tracked files are modified,
never rewrites a local branch that has diverged from the remote, and reports
stable error codes (`dirty`, `diverged`, `offline`, `unknown_tag`, ...) that the UI
localizes. Switching changes code only; a ComfyUI restart applies it.
Offline inference sidecars use non-positive Civitai IDs as sentinels. Metadata
normalization must not expose those values as release-page URLs or resolved
model/version identities; only positive IDs may form a Civitai source link.
+114 -2
View File
@@ -20,7 +20,63 @@ export const i18n = {
updateGuideDone: "知道了",
updateGuideProgress: "第 {current} / {total} 步",
updateGuideNoticeLabel: "新指引",
updateGuideNoticeTooltip: "查看更新引导",
updateGuideNoticeTooltip: "更新引导与版本",
versionCheckOpen: "检查更新 / 切换版本 ›",
versionPanelTitle: "版本与更新",
versionChipUnknown: "版本未知",
versionCurrent: "当前版本",
versionUnknown: "无法识别(不是 git 安装)",
versionDevBuild: "开发版(基于 {base},分支 {branch})",
versionBranch: "分支 {branch}",
versionDetached: "固定在发布版本",
versionDirtyWarning: "插件文件有本地修改,切换版本前请先提交或还原这些修改。",
versionCheck: "检查更新",
versionCheckHint: "点击“检查更新”获取已发布的版本列表。只有点击时才会联网。",
versionChecking: "正在获取发布版本…",
versionReturnLatest: "回到最新版",
versionReturnLatestHint: "切回默认分支并拉取最新代码",
versionUndo: "撤销上次切换(回到 {label})",
versionShowPrerelease: "显示预发布版本",
versionNoReleases: "没有找到已发布的版本。",
versionBadgeCurrent: "当前",
versionBadgeLatest: "最新稳定版",
versionBadgePrerelease: "预发布",
versionNotes: "更新说明",
versionOpenGithub: "在 GitHub 查看",
versionUpdateTo: "更新到此版本",
versionRollbackTo: "回退到此版本",
versionUpToDate: "已是最新稳定版。",
versionNewAvailable: "有新版本:{tag}",
versionSourceGit: "(GitHub 接口不可用,列表来自 git 标签,没有更新说明)",
versionPreparing: "正在准备 {tag}…",
versionSwitching: "正在切换到 {tag}…",
versionConfirmUpdate: "从 {from} 更新到 {tag}?",
versionConfirmRollback: "从 {from} 回退到 {tag}?",
versionConfirmLatest: "切回默认分支并更新到最新代码?",
versionConfirmUndo: "回到切换前的版本 {label}?",
versionRollbackDataWarning: "旧版本可能读不懂新版本保存的设置或配方。你的数据不会被删除,回到新版后即可恢复正常。",
versionNoPanelWarning: "{tag} 还没有版本面板。想回到新版时,请在 ComfyUI Manager 里点“更新”这个插件。",
versionRestartNote: "切换后需要重启 ComfyUI 才会生效。",
versionSwitched: "已切换到 {tag}。",
versionReturnedLatest: "已回到最新版。",
versionPendingRestart: "待重启",
versionRestartQuestion: "现在重启 ComfyUI 吗?",
versionRestartNow: "立即重启",
versionRestartLater: "稍后",
versionRestarting: "ComfyUI 正在重启,稍后请刷新页面。",
versionRestartManual: "无法自动重启(需要 ComfyUI Manager)。请手动重启 ComfyUI。",
versionErrorGeneric: "操作失败:{error}",
versionError_not_git: "插件不是通过 git 安装的,无法检查或切换版本。",
versionError_dirty: "以下插件文件有本地修改,为了不覆盖它们,已取消切换:",
versionError_diverged: "本地分支有远程没有的提交,已取消以免覆盖。",
versionError_offline: "无法连接 GitHub,请检查网络后重试。",
versionError_git_missing: "找不到 git 程序。",
versionError_git_timeout: "git 操作超时,请稍后重试。",
versionError_git_failed: "git 操作失败:{error}",
versionError_bad_tag: "版本名无效。",
versionError_unknown_tag: "这不是已发布的版本。",
versionError_no_previous: "没有可以撤销的切换。",
versionError_no_default_branch: "无法确定默认分支。",
shortcutBarPinned: "已固定",
shortcutBarPin: "固定到快捷栏",
shortcutBarUnpin: "从快捷栏移除",
@@ -1273,7 +1329,63 @@ export const i18n = {
updateGuideDone: "Got it",
updateGuideProgress: "Step {current} of {total}",
updateGuideNoticeLabel: "Guide",
updateGuideNoticeTooltip: "View update guide",
updateGuideNoticeTooltip: "Update guide & version",
versionCheckOpen: "Check for updates / switch version ›",
versionPanelTitle: "Version & updates",
versionChipUnknown: "Unknown version",
versionCurrent: "Installed",
versionUnknown: "Unknown (not a git install)",
versionDevBuild: "Development build (based on {base}, branch {branch})",
versionBranch: "Branch {branch}",
versionDetached: "Pinned to a release",
versionDirtyWarning: "Plugin files have local changes. Commit or revert them before switching versions.",
versionCheck: "Check for updates",
versionCheckHint: "Click \"Check for updates\" to list published releases. Nothing goes online until you click.",
versionChecking: "Fetching releases…",
versionReturnLatest: "Back to latest",
versionReturnLatestHint: "Switch to the default branch and pull the newest code",
versionUndo: "Undo last switch (back to {label})",
versionShowPrerelease: "Show pre-releases",
versionNoReleases: "No published releases found.",
versionBadgeCurrent: "Installed",
versionBadgeLatest: "Latest stable",
versionBadgePrerelease: "Pre-release",
versionNotes: "Release notes",
versionOpenGithub: "View on GitHub",
versionUpdateTo: "Update to this version",
versionRollbackTo: "Roll back to this version",
versionUpToDate: "You're on the latest stable release.",
versionNewAvailable: "New version available: {tag}",
versionSourceGit: "(GitHub API unavailable; list comes from git tags, without notes)",
versionPreparing: "Preparing {tag}…",
versionSwitching: "Switching to {tag}…",
versionConfirmUpdate: "Update from {from} to {tag}?",
versionConfirmRollback: "Roll back from {from} to {tag}?",
versionConfirmLatest: "Switch to the default branch and update to the newest code?",
versionConfirmUndo: "Go back to {label}, the version before the last switch?",
versionRollbackDataWarning: "Older versions may not understand settings or recipes saved by newer ones. Nothing is deleted; it all works again once you return to the newer version.",
versionNoPanelWarning: "{tag} does not have this version panel yet. To come back, use \"Update\" for this plugin in ComfyUI Manager.",
versionRestartNote: "Restart ComfyUI afterwards for the change to take effect.",
versionSwitched: "Switched to {tag}.",
versionReturnedLatest: "Back on the latest version.",
versionPendingRestart: "restart pending",
versionRestartQuestion: "Restart ComfyUI now?",
versionRestartNow: "Restart now",
versionRestartLater: "Later",
versionRestarting: "ComfyUI is restarting. Refresh the page in a moment.",
versionRestartManual: "Could not restart automatically (needs ComfyUI Manager). Please restart ComfyUI yourself.",
versionErrorGeneric: "Something went wrong: {error}",
versionError_not_git: "The plugin was not installed with git, so versions can't be checked or switched.",
versionError_dirty: "These plugin files have local changes, so the switch was cancelled to avoid overwriting them:",
versionError_diverged: "The local branch has commits the remote doesn't, so nothing was changed.",
versionError_offline: "Couldn't reach GitHub. Check your connection and try again.",
versionError_git_missing: "The git program was not found.",
versionError_git_timeout: "A git command timed out. Please try again.",
versionError_git_failed: "git failed: {error}",
versionError_bad_tag: "Invalid version name.",
versionError_unknown_tag: "That is not a published version.",
versionError_no_previous: "There is no switch to undo.",
versionError_no_default_branch: "Couldn't determine the default branch.",
shortcutBarPinned: "Pinned",
shortcutBarPin: "Pin to shortcuts",
shortcutBarUnpin: "Remove from shortcuts",
+2
View File
@@ -3,6 +3,7 @@ import { i18n, translate as t } from './locales.js';
import { createViewScope } from './ui_lifecycle.js';
import { text } from './ui_dom.js';
import { startSpotlightTour, ensureTourStyles } from './ui_spotlight_tour.js';
import { createVersionLine } from './ui_version_manager.js';
const acknowledgedThisSession = new Set();
let activeGuide = null;
@@ -58,6 +59,7 @@ export function showUpdateGuide(owner, { force = false, guide = CURRENT_UPDATE_G
dismiss.type = 'button';
dismiss.setAttribute('aria-label', t('close'));
dismiss.onclick = () => closeUpdateGuide(owner, true);
dialog.appendChild(createVersionLine(owner, { beforeOpen: () => closeUpdateGuide(owner, true) }));
const icon = text(dialog, 'div', '', 'anomalous-update-guide-icon');
icon.setAttribute('aria-hidden', 'true');
const title = text(dialog, 'h2', '');
+324
View File
@@ -0,0 +1,324 @@
import { translate } from './locales.js';
import { createViewScope } from './ui_lifecycle.js';
import { anomalousAlert, anomalousConfirm } from './ui_dialog.js';
/**
* Version line in the update guide ("!" in the header) and the "Version & updates" panel:
* shows the installed version, lists published releases only when asked,
* and switches, rolls back, returns to latest or undoes the last switch.
*/
const t = (key, params) => translate(key, params);
let activeScope = null;
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 btn = el('button', className, label);
btn.type = 'button';
btn.onclick = onClick;
return btn;
}
async function request(url, options = {}) {
let resp;
try {
resp = await fetch(url, options);
} catch (e) {
return { success: false, code: 'offline', error: e.message };
}
const data = await resp.json().catch(() => ({ success: false, error: `HTTP ${resp.status}` }));
return data;
}
const post = (url, body = {}) => request(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
function errorText(result) {
const key = `versionError_${result.code}`;
const text = t(key, { error: result.error || '' });
const message = text === key ? t('versionErrorGeneric', { error: result.error || result.code || '' }) : text;
return result.files?.length ? `${message}\n\n${result.files.join('\n')}` : message;
}
function formatDate(value) {
if (!value) return '';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '' : date.toLocaleDateString();
}
function stateLabel(state) {
if (!state?.is_git) return t('versionUnknown');
if (state.tag) return state.tag;
return state.branch ? t('versionDevBuild', { base: state.base_tag || state.commit, branch: state.branch }) : state.label;
}
// ---------- version line ----------
/**
* "Current version · Check for updates" row for the update guide. Reads the installed
* version locally (no network). `beforeOpen` lets the host close itself first, since a
* modal <dialog> would stay above the panel.
*/
export function createVersionLine(owner, { beforeOpen } = {}) {
const line = el('div', 'anomalous-version-line');
const label = el('span', 'anomalous-version-line-label', t('versionCurrent'));
const value = el('strong', 'anomalous-version-line-value', '…');
const open = button('anomalous-version-line-open', t('versionCheckOpen'), () => {
beforeOpen?.();
openVersionPanel(owner);
});
line.append(label, value, open);
request('/anomalous/version').then(data => {
value.textContent = data.success && data.state?.is_git ? (data.state.tag || data.state.base_tag || data.state.label) : t('versionChipUnknown');
});
return line;
}
// ---------- panel ----------
export function openVersionPanel(owner, chip = null) {
activeScope?.dispose();
const scope = createViewScope();
activeScope = scope;
const overlay = el('div', 'anomalous-version-overlay');
const panel = el('div', 'anomalous-version-panel');
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-modal', 'true');
const close = () => scope.dispose();
scope.onDispose(() => {
overlay.remove();
if (activeScope === scope) activeScope = null;
});
overlay.onclick = (e) => { if (e.target === overlay) close(); };
scope.listen(window, 'keydown', (e) => {
if (e.key !== 'Escape' || document.querySelector('.anomalous-dialog-overlay')) return;
e.stopPropagation();
close();
}, true);
const header = el('div', 'anomalous-version-header');
header.append(el('div', 'anomalous-version-title', t('versionPanelTitle')), button('anomalous-version-close', '×', close));
const current = el('div', 'anomalous-version-current');
const actions = el('div', 'anomalous-version-actions');
const status = el('div', 'anomalous-version-status');
const listBar = el('label', 'anomalous-version-prerelease');
const prereleaseToggle = document.createElement('input');
prereleaseToggle.type = 'checkbox';
listBar.append(prereleaseToggle, el('span', '', t('versionShowPrerelease')));
listBar.hidden = true;
const list = el('div', 'anomalous-version-list');
const view = { state: null, listing: null, busy: false };
const setBusy = (busy, message = '') => {
view.busy = busy;
panel.classList.toggle('is-busy', busy);
status.textContent = message;
status.classList.remove('is-error');
panel.querySelectorAll('button.anomalous-version-action').forEach(btn => { btn.disabled = busy || btn.dataset.blocked === '1'; });
};
const showError = (result) => {
status.textContent = errorText(result);
status.classList.add('is-error');
};
const renderCurrent = () => {
const state = view.state;
current.replaceChildren();
const line = el('div', 'anomalous-version-current-line');
line.append(el('span', 'anomalous-version-current-label', t('versionCurrent')), el('strong', '', stateLabel(state)));
current.appendChild(line);
if (state?.is_git) {
const meta = [state.branch ? t('versionBranch', { branch: state.branch }) : t('versionDetached'), state.commit].filter(Boolean).join(' · ');
current.appendChild(el('div', 'anomalous-version-meta', meta));
}
if (state?.dirty) {
current.appendChild(el('div', 'anomalous-version-warning', t('versionDirtyWarning')));
} else if (state && !state.is_git) {
current.appendChild(el('div', 'anomalous-version-warning', t('versionError_not_git')));
}
};
const blocked = () => !view.state?.is_git || view.state.dirty;
const renderActions = () => {
const check = button('anomalous-version-action primary', t('versionCheck'), () => loadReleases(true));
const latest = button('anomalous-version-action', t('versionReturnLatest'), returnToLatest);
latest.title = t('versionReturnLatestHint');
const items = [check, latest];
const previous = view.state?.previous;
if (previous?.label) items.push(button('anomalous-version-action', t('versionUndo', { label: previous.label }), undoSwitch));
for (const item of items.slice(1)) item.dataset.blocked = blocked() ? '1' : '0';
check.dataset.blocked = view.state?.is_git ? '0' : '1';
items.forEach(item => { item.disabled = view.busy || item.dataset.blocked === '1'; });
actions.replaceChildren(...items);
};
const renderList = () => {
const listing = view.listing;
if (!listing) {
list.replaceChildren(el('div', 'anomalous-version-empty', t('versionCheckHint')));
return;
}
listBar.hidden = !listing.releases.some(item => item.prerelease);
const releases = listing.releases.filter(item => prereleaseToggle.checked || !item.prerelease || item.current);
list.replaceChildren(...releases.map(renderRelease));
if (!releases.length) list.appendChild(el('div', 'anomalous-version-empty', t('versionNoReleases')));
};
const renderRelease = (release) => {
const card = el('div', `anomalous-version-release${release.current ? ' is-current' : ''}`);
const top = el('div', 'anomalous-version-release-top');
const titleBox = el('div', 'anomalous-version-release-title');
titleBox.append(el('strong', '', release.tag));
if (release.name && release.name !== release.tag) titleBox.append(el('span', 'anomalous-version-release-name', release.name));
const badges = el('div', 'anomalous-version-badges');
if (release.current) badges.appendChild(el('span', 'anomalous-version-badge is-current', t('versionBadgeCurrent')));
if (release.latest) badges.appendChild(el('span', 'anomalous-version-badge is-latest', t('versionBadgeLatest')));
if (release.prerelease) badges.appendChild(el('span', 'anomalous-version-badge is-pre', t('versionBadgePrerelease')));
const date = formatDate(release.published_at);
if (date) badges.appendChild(el('span', 'anomalous-version-date', date));
top.append(titleBox, badges);
card.appendChild(top);
const footer = el('div', 'anomalous-version-release-footer');
if (release.notes) {
const notes = el('pre', 'anomalous-version-notes', release.notes);
notes.hidden = true;
const toggle = button('anomalous-version-link', t('versionNotes'), () => {
notes.hidden = !notes.hidden;
});
footer.appendChild(toggle);
card.appendChild(notes);
}
if (release.url) {
const link = el('a', 'anomalous-version-link', t('versionOpenGithub'));
link.href = release.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
footer.appendChild(link);
}
if (!release.current) {
const label = release.direction === 'newer' ? t('versionUpdateTo') : t('versionRollbackTo');
const switchBtn = button(`anomalous-version-action${release.direction === 'newer' ? ' primary' : ''}`, label, () => switchTo(release));
switchBtn.dataset.blocked = blocked() ? '1' : '0';
switchBtn.disabled = view.busy || blocked();
footer.appendChild(switchBtn);
}
card.appendChild(footer);
return card;
};
const renderAll = () => {
renderCurrent();
renderActions();
renderList();
};
const refreshState = async () => {
const data = await request('/anomalous/version');
if (data.success) view.state = data.state;
if (chip && view.state) chip.textContent = view.state.tag || view.state.base_tag || view.state.label || chip.textContent;
};
const loadReleases = async (force) => {
setBusy(true, t('versionChecking'));
const data = await request(`/anomalous/version/releases${force ? '?refresh=1' : ''}`);
if (scope.signal.aborted) return;
setBusy(false);
if (!data.success) {
showError(data);
renderAll();
return;
}
view.state = data.state;
view.listing = data;
renderAll();
const latest = data.releases.find(item => item.latest);
status.textContent = latest?.current ? t('versionUpToDate') : (latest ? t('versionNewAvailable', { tag: latest.tag }) : '');
if (data.source === 'git') status.textContent += ` ${t('versionSourceGit')}`;
};
/** Shared ending for every switch: tell the user to restart, offer Manager's restart. */
const afterSwitch = async (result, successKey, params) => {
await refreshState();
view.listing = null;
renderAll();
if (!result.success) {
showError(result);
return;
}
if (chip) chip.textContent = `${chip.textContent} · ${t('versionPendingRestart')}`;
status.textContent = t(successKey, params);
const restart = await anomalousConfirm(`${t(successKey, params)}\n\n${t('versionRestartQuestion')}`, t('versionPanelTitle'), {
okLabel: t('versionRestartNow'), cancelLabel: t('versionRestartLater'),
});
if (!restart) return;
// ComfyUI Manager's reboot exits the server, so a dropped connection means it is restarting.
// 404 (no Manager) or 403 (Manager security level) means the user restarts by hand.
const reboot = await post('/api/manager/reboot');
if (reboot.code === 'offline' || reboot.success !== false) {
status.textContent = t('versionRestarting');
} else {
await anomalousAlert(t('versionRestartManual'));
}
};
const switchTo = async (release) => {
if (view.busy) return;
setBusy(true, t('versionPreparing', { tag: release.tag }));
const preview = await post('/anomalous/version/preview', { tag: release.tag });
setBusy(false);
if (scope.signal.aborted) return;
if (!preview.success) {
showError(preview);
return;
}
const lines = [t(preview.direction === 'newer' ? 'versionConfirmUpdate' : 'versionConfirmRollback', { tag: release.tag, from: preview.from })];
if (preview.direction !== 'newer') lines.push(t('versionRollbackDataWarning'));
if (!preview.has_switcher) lines.push(t('versionNoPanelWarning', { tag: release.tag }));
lines.push(t('versionRestartNote'));
if (!await anomalousConfirm(lines.join('\n\n'), t('versionPanelTitle'))) return;
setBusy(true, t('versionSwitching', { tag: release.tag }));
const result = await post('/anomalous/version/switch', { tag: release.tag });
setBusy(false);
await afterSwitch(result, 'versionSwitched', { tag: release.tag });
};
const returnToLatest = async () => {
if (view.busy) return;
if (!await anomalousConfirm(`${t('versionConfirmLatest')}\n\n${t('versionRestartNote')}`, t('versionPanelTitle'))) return;
setBusy(true, t('versionSwitching', { tag: 'main' }));
const result = await post('/anomalous/version/latest');
setBusy(false);
await afterSwitch(result, 'versionReturnedLatest', {});
};
const undoSwitch = async () => {
if (view.busy) return;
const label = view.state?.previous?.label || '';
if (!await anomalousConfirm(`${t('versionConfirmUndo', { label })}\n\n${t('versionRestartNote')}`, t('versionPanelTitle'))) return;
setBusy(true, t('versionSwitching', { tag: label }));
const result = await post('/anomalous/version/undo');
setBusy(false);
await afterSwitch(result, 'versionSwitched', { tag: label });
};
prereleaseToggle.onchange = renderList;
panel.append(header, current, actions, status, listBar, list);
overlay.appendChild(panel);
document.body.appendChild(overlay);
renderAll();
refreshState().then(() => { if (!scope.signal.aborted) renderAll(); });
return close;
}
+1
View File
@@ -9,3 +9,4 @@
@import url("./styles/08-theme-gallery-overrides.css?v=20260918-model-sources-1");
@import url("./styles/09-prompt-drawers.css?v=20260918-model-sources-1");
@import url("./styles/10-model-sources.css?v=20260918-model-sources-1");
@import url("./styles/14-version-manager.css");
+326
View File
@@ -0,0 +1,326 @@
/* =============================================================================
Version line (update guide) and the "Version & updates" panel
============================================================================= */
/* Row under the update guide header ("!" button). */
.anomalous-version-line {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
font-size: 12px;
color: #94a3b8;
}
.anomalous-version-line-value {
color: #e2e8f0;
font: 600 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.anomalous-version-line-open {
margin-left: auto;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: #e2e8f0;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.anomalous-version-line-open:hover {
background: rgba(255, 255, 255, 0.1);
}
.anomalous-version-overlay {
position: fixed;
inset: 0;
z-index: 1000000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(3px);
}
.anomalous-version-panel {
display: flex;
flex-direction: column;
gap: 12px;
width: min(620px, 100%);
max-height: min(760px, 100%);
padding: 18px 20px;
box-sizing: border-box;
overflow: hidden;
background: var(--amb-bg-panel, #1a1a1d);
color: var(--amb-text-main, #f3f4f6);
border: 1px solid var(--amb-border-hover, rgba(255, 255, 255, 0.16));
border-radius: var(--amb-radius-panel, 10px);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
font-size: 13px;
}
.anomalous-version-panel.is-busy {
cursor: progress;
}
.anomalous-version-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.anomalous-version-title {
font-size: 16px;
font-weight: 700;
}
.anomalous-version-close {
width: 28px;
height: 28px;
padding: 0;
background: transparent;
color: var(--amb-text-muted, #9ca3af);
border: 1px solid transparent;
border-radius: var(--amb-radius-control, 6px);
font-size: 18px;
cursor: pointer;
}
.anomalous-version-close:hover {
color: var(--amb-text-main, #f3f4f6);
border-color: var(--amb-border-hover, rgba(255, 255, 255, 0.16));
}
.anomalous-version-current {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
background: var(--amb-bg-card, #202024);
border: 1px solid var(--amb-border, rgba(255, 255, 255, 0.08));
border-radius: var(--amb-radius-card, 8px);
}
.anomalous-version-current-line {
display: flex;
align-items: baseline;
gap: 10px;
}
.anomalous-version-current-line strong {
font-size: 15px;
}
.anomalous-version-current-label,
.anomalous-version-meta,
.anomalous-version-date,
.anomalous-version-release-name,
.anomalous-version-empty {
color: var(--amb-text-muted, #9ca3af);
}
.anomalous-version-meta {
font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.anomalous-version-warning {
padding: 8px 10px;
color: #fcd34d;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.35);
border-radius: var(--amb-radius-control, 6px);
line-height: 1.45;
}
.anomalous-version-actions,
.anomalous-version-release-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.anomalous-version-action {
height: 30px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.04);
color: var(--amb-text-main, #f3f4f6);
border: 1px solid var(--amb-border-hover, rgba(255, 255, 255, 0.16));
border-radius: var(--amb-radius-control, 6px);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.anomalous-version-action:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
}
.anomalous-version-action.primary {
background: var(--amb-btn-primary-bg, #e5e7eb);
color: var(--amb-btn-primary-text, #111827);
border-color: transparent;
font-weight: 600;
}
.anomalous-version-action.primary:hover:not(:disabled) {
background: var(--amb-btn-primary-hover, #ffffff);
}
.anomalous-version-action:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.anomalous-version-status {
min-height: 18px;
color: var(--amb-text-muted, #9ca3af);
line-height: 1.45;
white-space: pre-wrap;
}
.anomalous-version-status:empty {
display: none;
}
.anomalous-version-status.is-error {
color: #fca5a5;
}
.anomalous-version-prerelease {
display: flex;
align-items: center;
gap: 6px;
color: var(--amb-text-muted, #9ca3af);
cursor: pointer;
}
.anomalous-version-prerelease[hidden] {
display: none;
}
.anomalous-version-list {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow-y: auto;
padding-right: 2px;
}
.anomalous-version-empty {
padding: 22px 10px;
text-align: center;
line-height: 1.5;
}
.anomalous-version-release {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
background: var(--amb-bg-card, #202024);
border: 1px solid var(--amb-border, rgba(255, 255, 255, 0.08));
border-radius: var(--amb-radius-card, 8px);
}
.anomalous-version-release.is-current {
border-color: var(--amb-accent-line, rgba(229, 231, 235, 0.35));
}
.anomalous-version-release-top {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.anomalous-version-release-title {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.anomalous-version-release-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.anomalous-version-badges {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
}
.anomalous-version-badge {
padding: 2px 7px;
border-radius: 999px;
border: 1px solid var(--amb-border-hover, rgba(255, 255, 255, 0.16));
}
.anomalous-version-badge.is-current {
color: var(--amb-btn-primary-text, #111827);
background: var(--amb-btn-primary-bg, #e5e7eb);
border-color: transparent;
}
.anomalous-version-badge.is-latest {
color: #86efac;
border-color: rgba(34, 197, 94, 0.45);
}
.anomalous-version-badge.is-pre {
color: #fcd34d;
border-color: rgba(245, 158, 11, 0.45);
}
.anomalous-version-link {
padding: 0;
background: none;
border: none;
color: var(--amb-text-muted, #9ca3af);
font-size: 12px;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.anomalous-version-link:hover {
color: var(--amb-text-main, #f3f4f6);
}
.anomalous-version-release-footer .anomalous-version-action {
margin-left: auto;
}
.anomalous-version-notes {
max-height: 220px;
margin: 0;
padding: 8px 10px;
overflow: auto;
background: var(--amb-bg-input, #151517);
border-radius: var(--amb-radius-control, 6px);
font: 12px/1.5 system-ui, sans-serif;
white-space: pre-wrap;
word-break: break-word;
}
.anomalous-version-notes[hidden] {
display: none;
}
.anomalous-version-panel button:focus-visible,
.anomalous-version-panel a:focus-visible,
.anomalous-version-line-open:focus-visible {
outline: 2px solid var(--amb-accent, #f59e0b);
outline-offset: 2px;
}