Compare commits
8 Commits
62e4b9df8c
...
d8f94ca371
| Author | SHA1 | Date | |
|---|---|---|---|
| d8f94ca371 | |||
| a8fb5ae8b4 | |||
| 0dfa14384d | |||
| 8c35ac6f09 | |||
| b6635c9f3e | |||
| fb3a785027 | |||
| 8cb0e32739 | |||
| aa2f1f62bf |
@@ -17,6 +17,7 @@ A ComfyUI custom node package that silently tracks which nodes, packages, and mo
|
|||||||
- **Expandable detail** — click any package to see individual node-level stats
|
- **Expandable detail** — click any package to see individual node-level stats
|
||||||
- **One-click disable** — disable unused packages straight from the dialog via ComfyUI Manager (per-package or in bulk), reversible at any time
|
- **One-click disable** — disable unused packages straight from the dialog via ComfyUI Manager (per-package or in bulk), reversible at any time
|
||||||
- **Workflow tab** — on loading a workflow, splits unresolved nodes into *Missing* (install via Manager) and *Disabled*, with a temporary **Enable 7d** trial that auto-disables packages left unused
|
- **Workflow tab** — on loading a workflow, splits unresolved nodes into *Missing* (install via Manager) and *Disabled*, with a temporary **Enable 7d** trial that auto-disables packages left unused
|
||||||
|
- **Mirror search** — a standalone palette (⌕ button / `Ctrl/Cmd+Shift+D`) that searches nodes belonging to currently-disabled packages and re-enables them on the spot
|
||||||
- **Non-blocking** — DB writes happen in a background thread, no impact on workflow execution
|
- **Non-blocking** — DB writes happen in a background thread, no impact on workflow execution
|
||||||
|
|
||||||
## Package Classification
|
## Package Classification
|
||||||
@@ -107,6 +108,27 @@ Re-enabling and auto-disabling both go through ComfyUI Manager, so the whole
|
|||||||
Workflow tab is inert when Manager is not installed (the backend still tracks
|
Workflow tab is inert when Manager is not installed (the backend still tracks
|
||||||
trial state, but no enable/disable actions are offered).
|
trial state, but no enable/disable actions are offered).
|
||||||
|
|
||||||
|
### Mirror search (disabled-pack nodes)
|
||||||
|
|
||||||
|
Sometimes you know the node you want exists in a package you've disabled, but you
|
||||||
|
don't want to dig through ComfyUI Manager to find it. The **mirror search**
|
||||||
|
palette searches across the `class_type` names of *every currently-disabled
|
||||||
|
package* and lets you re-enable the owning package right from the results.
|
||||||
|
|
||||||
|
- Open it with the **⌕** button in the top menu bar, or press
|
||||||
|
**`Ctrl/Cmd+Shift+D`** (ignored while typing in an input).
|
||||||
|
- Type to filter — results are ranked (node-name prefix first, then word-start,
|
||||||
|
substring, finally pack-name matches) and show the `class_type` and its pack.
|
||||||
|
- Each result offers **Enable 7d** (re-enable under a 7-day trial) and **Enable**
|
||||||
|
(re-enable permanently) — the same enable path as the Workflow tab.
|
||||||
|
- Enabling takes effect after a ComfyUI restart; enabled rows mark
|
||||||
|
*"✓ enabled · restart"*.
|
||||||
|
|
||||||
|
The catalog is built once per session by joining ComfyUI Manager's node→pack
|
||||||
|
mappings with the list of disabled packs, and cached; use the **↻** button to
|
||||||
|
rebuild it. The palette is inert (with a clear message) when ComfyUI Manager is
|
||||||
|
absent or there are no disabled packages.
|
||||||
|
|
||||||
**Models tab**
|
**Models tab**
|
||||||
- Summary bar with counts for each tier across all model types
|
- Summary bar with counts for each tier across all model types
|
||||||
- Sections per model type (checkpoints, vae, controlnet, …)
|
- Sections per model type (checkpoints, vae, controlnet, …)
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Design: Mirror search for disabled-pack nodes
|
||||||
|
|
||||||
|
Date: 2026-06-21
|
||||||
|
Status: Approved
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
A standalone "mirror search" palette that lets you find nodes belonging to
|
||||||
|
currently-**disabled** custom-node packages — without loading those packages
|
||||||
|
(which slows ComfyUI boot/runtime). Each result has Enable buttons; enabling
|
||||||
|
re-enables the owning package (temporarily or permanently) and takes effect
|
||||||
|
after a ComfyUI restart, after which the node appears in ComfyUI's native search.
|
||||||
|
|
||||||
|
This runs *alongside* ComfyUI's native node search rather than inside it:
|
||||||
|
ComfyUI's frontend is compiled/bundled and exposes no search-provider or
|
||||||
|
node-def injection hook, so the native search cannot be extended. A separate
|
||||||
|
palette is the only viable design (and matches the request — a "mirror" search).
|
||||||
|
|
||||||
|
## Constraints discovered (reconnaissance)
|
||||||
|
|
||||||
|
- Native search injection is **not feasible**: bundled frontend, no public hook
|
||||||
|
(`app.registerExtension` offers `nodeCreated`/`beforeRegisterNodeDef` only),
|
||||||
|
and disabled packs never enter `NODE_CLASS_MAPPINGS` / `/object_info`.
|
||||||
|
- `/customnode/getmappings?mode=local` returns a registry-wide map keyed by repo
|
||||||
|
URL: `{ <repo_url>: [ [class_type, ...], { title_aux } ] }`. It **does**
|
||||||
|
include the class_type names for every pack, including disabled ones.
|
||||||
|
- `/customnode/getlist?mode=local&skip_update=true` lists packs with `state`
|
||||||
|
(`disabled`/`enabled`/`not-installed`), `id`, `version`, `files`,
|
||||||
|
`repository`. There are 73 disabled packs in the reference install.
|
||||||
|
- Available metadata for an unloaded node: **class_type name + pack name/title
|
||||||
|
only**. No categories, descriptions, or input/output ports (those require the
|
||||||
|
pack to be loaded).
|
||||||
|
|
||||||
|
## Decisions (from brainstorming)
|
||||||
|
|
||||||
|
- **Trigger:** dedicated palette opened by a toolbar button + a keyboard
|
||||||
|
shortcut. Not a tab in the Node Stats dialog.
|
||||||
|
- **Enable actions:** both "Enable 7d" (rolling trial) and "Enable" (permanent),
|
||||||
|
reusing the trial-enable feature. Takes effect after restart.
|
||||||
|
- **Catalog construction:** frontend-only. Fetch getmappings + getlist on first
|
||||||
|
open, join by repo URL, cache in memory for the session, with a refresh
|
||||||
|
affordance. No backend changes.
|
||||||
|
- **Scope:** disabled packs only (not the full not-installed registry — that's
|
||||||
|
Manager's job).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
All in `js/nodes_stats.js`; no backend changes.
|
||||||
|
|
||||||
|
### Catalog
|
||||||
|
- `ensureDisabledCatalog()` (cached in a module variable):
|
||||||
|
1. `fetchManagerInfo()` (existing; getlist) → packs with `state === 'disabled'`.
|
||||||
|
2. `fetch('/customnode/getmappings?mode=local')` → build `repoUrl -> [class_types]`.
|
||||||
|
3. Normalize URLs (lowercase, strip trailing `/` and `.git`) on both sides.
|
||||||
|
4. For each disabled pack, look up class_types by its `files[0]`/`repository`;
|
||||||
|
emit `{ class_type, pack: <dir name>, title, enableInfo }` per node.
|
||||||
|
- A `refresh()` clears the cache and rebuilds.
|
||||||
|
|
||||||
|
### Search/filter (pure functions, for clarity + manual testability)
|
||||||
|
- `scoreEntry(entry, queryLower)` → null if no match; else a rank where
|
||||||
|
class_type prefix < word-start < substring, pack-name match ranked lower.
|
||||||
|
- `filterCatalog(catalog, query, limit=50)` → sorted, capped list + total count.
|
||||||
|
|
||||||
|
### Palette UI
|
||||||
|
- `openMirrorSearch()`: ensure catalog; render a modal overlay (reusing the
|
||||||
|
dialog styling helpers) with a text input (autofocused) and a results list.
|
||||||
|
- Input `keyup` → re-render rows via `filterCatalog`.
|
||||||
|
- Row: `class_type` · `(pack)` · `[Enable 7d]` `[Enable]`.
|
||||||
|
- Footer: "<shown>/<total> from <N> disabled packs · enabling needs a restart" +
|
||||||
|
"↻ refresh".
|
||||||
|
- Empty/error/inert states handled explicitly.
|
||||||
|
|
||||||
|
### Trigger
|
||||||
|
- Toolbar button (like the existing Node Stats button), title "Search disabled
|
||||||
|
nodes".
|
||||||
|
- Keyboard shortcut: prefer ComfyUI's extension command/keybinding API if
|
||||||
|
available; else a guarded `document` `keydown` listener (default
|
||||||
|
`Ctrl/Cmd+Shift+D`), ignored when focus is in an input/textarea.
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
- Reuse `handleEnable(pkg, temporary)` from the trial-enable feature:
|
||||||
|
- `[Enable 7d]` → `temporary=true` (Manager enable → `trials/start`).
|
||||||
|
- `[Enable]` → `temporary=false` (Manager enable → `trials/stop`).
|
||||||
|
- Reuse the restart banner / toast. After enable, mark the row "enabled ·
|
||||||
|
restart".
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
setup() -> add toolbar button + register hotkey
|
||||||
|
trigger -> openMirrorSearch()
|
||||||
|
-> ensureDisabledCatalog() (1st time: getmappings + getlist, join, cache)
|
||||||
|
-> render modal (input + results)
|
||||||
|
type -> filterCatalog() -> render rows (instant, in-memory)
|
||||||
|
Enable -> handleEnable(pkg, temp?) -> Manager enable -> trials/start|stop
|
||||||
|
-> restart banner/toast
|
||||||
|
refresh -> clear cache -> ensureDisabledCatalog() -> re-render
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- ComfyUI Manager absent or getlist/getmappings fails → palette shows a clear
|
||||||
|
message ("ComfyUI Manager not available" / "couldn't load disabled-node
|
||||||
|
list"); the button stays but the palette is inert. No crash.
|
||||||
|
- Zero disabled packs → "No disabled packages — nothing to search."
|
||||||
|
- Enable failure → existing error toast; row left actionable.
|
||||||
|
- URL-join misses for a pack → that pack contributes no rows (logged to
|
||||||
|
console); never throws.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- No backend changes → no new pytest.
|
||||||
|
- Pure helpers (`scoreEntry`, `filterCatalog`, URL normalization, catalog join)
|
||||||
|
written as small standalone functions; `node --check` for syntax.
|
||||||
|
- Manual verification: open palette via button + hotkey; search a known disabled
|
||||||
|
pack's node (e.g. an Inspire-Pack node); Enable 7d → getlist flips to enabled
|
||||||
|
+ `/nodes-stats/trials` shows it + restart banner; Enable (permanent) → enabled,
|
||||||
|
no trial row; refresh rebuilds; Manager-absent path shows the inert message.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
- `js/nodes_stats.js` — catalog, filter, palette UI, trigger, reuse enable.
|
||||||
|
- `README.md`, `pyproject.toml` — docs + version bump.
|
||||||
|
|
||||||
|
## Out of scope (YAGNI)
|
||||||
|
|
||||||
|
- Injecting results into ComfyUI's native search (not feasible).
|
||||||
|
- Rich node metadata (titles/categories/ports) for unloaded nodes (unavailable).
|
||||||
|
- Auto-placing the node into the graph after restart.
|
||||||
|
- Searching not-installed registry packs (Manager already does this).
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
# Disabled-Node Mirror Search Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add a standalone "mirror search" palette (toolbar button + hotkey) that searches nodes belonging to currently-disabled custom-node packages and offers Enable 7d / Enable on each result, reusing the trial-enable code.
|
||||||
|
|
||||||
|
**Architecture:** Frontend-only, all in `js/nodes_stats.js`. Build an in-memory catalog by joining ComfyUI Manager's `getmappings` (repo-URL → class_type names, registry-wide) with `getlist` (packs whose `state === 'disabled'`), cached per session. A separate modal palette filters the catalog live. Enable actions reuse a shared `enablePackage()` core (extracted from the existing `handleEnable`). No backend changes.
|
||||||
|
|
||||||
|
**Tech Stack:** Vanilla JS (ComfyUI frontend extension via `app.registerExtension`), ComfyUI Manager HTTP endpoints, the trial-enable feature already in this file.
|
||||||
|
|
||||||
|
**Design doc:** `docs/plans/2026-06-21-mirror-search-design.md`
|
||||||
|
|
||||||
|
**Testing note:** No JS test harness exists. "Verify" steps use `node --check` for syntax and explicit browser-console / in-app checks. Pure helpers are written standalone so they can be exercised from the console. After every JS edit run:
|
||||||
|
`cp js/nodes_stats.js /tmp/c.mjs && node --check /tmp/c.mjs && echo OK && rm /tmp/c.mjs`
|
||||||
|
|
||||||
|
Reuse points already in `js/nodes_stats.js` (confirmed): `fetchManagerInfo()` (getlist → `{dir:{id,version,files,state}}`), `enablePayload()`, `runManagerEnable()`, `managerIsBusy()`, `handleEnable()`, `notify()`, `escapeHtml()`, `escapeAttr()`, `showRestartBanner()`, the toolbar-button mount in `setup()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Extract shared `enablePackage()` core (refactor, no behavior change)
|
||||||
|
|
||||||
|
**Why:** `handleEnable` is hard-wired to the Workflow tab's `_lastWorkflowScan` and `dialog`. The palette needs the same enable logic without that coupling. Extract the Manager-enable + trial-route + toast into `enablePackage(pkg, info, temporary)`; keep `handleEnable` as the Workflow-tab wrapper.
|
||||||
|
|
||||||
|
**Files:** Modify `js/nodes_stats.js` (around lines 717–746).
|
||||||
|
|
||||||
|
**Step 1: Add the shared core** immediately above `handleEnable`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Shared enable core used by the Workflow tab and the mirror search palette.
|
||||||
|
// Performs the Manager enable + trial bookkeeping + success toast.
|
||||||
|
// Returns true on success, false if Manager was busy. Throws on failure.
|
||||||
|
// Caller owns its own busy UI and restart affordance.
|
||||||
|
async function enablePackage(pkg, info, temporary) {
|
||||||
|
if (!info) throw new Error("no enable info for " + pkg);
|
||||||
|
if (await managerIsBusy()) {
|
||||||
|
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await runManagerEnable(enablePayload(pkg, info));
|
||||||
|
const route = temporary ? "/nodes-stats/trials/start" : "/nodes-stats/trials/stop";
|
||||||
|
await fetch(route, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ package: pkg }),
|
||||||
|
});
|
||||||
|
notify(`Enabled ${pkg}${temporary ? " for a 7-day trial" : ""}. Restart ComfyUI to apply.`, "success");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Replace the body of `handleEnable`** to delegate:
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function handleEnable(pkg, temporary, dialog) {
|
||||||
|
const entry = _lastWorkflowScan.disabled.find((d) => d.pkg === pkg);
|
||||||
|
const info = entry && entry.info;
|
||||||
|
if (!info) return;
|
||||||
|
setWorkflowButtonsBusy(dialog, true);
|
||||||
|
try {
|
||||||
|
if (await enablePackage(pkg, info, temporary)) {
|
||||||
|
entry.info.state = "enabled";
|
||||||
|
showRestartBanner(dialog);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notify("Failed to enable: " + e.message, "error");
|
||||||
|
} finally {
|
||||||
|
setWorkflowButtonsBusy(dialog, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify syntax** — run the `node --check` line above. Expected: `OK`.
|
||||||
|
|
||||||
|
**Step 4: Verify no behavior change (manual)** — hard-refresh ComfyUI, load a workflow with a disabled node, click Enable 7d in the Workflow tab → still enables + restart banner + `/nodes-stats/trials` shows it. (If you don't want to mutate state, just confirm the buttons still render and the console shows no errors on open.)
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add js/nodes_stats.js
|
||||||
|
git commit -m "refactor: extract enablePackage core from handleEnable"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Catalog build — URL normalize + join + cache
|
||||||
|
|
||||||
|
**Files:** Modify `js/nodes_stats.js` (add near `fetchManagerInfo`).
|
||||||
|
|
||||||
|
**Step 1: Add pure helpers + cached loader:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Normalize a repo URL for joining getmappings keys to getlist pack files.
|
||||||
|
function normalizeRepoUrl(url) {
|
||||||
|
return String(url || "").trim().toLowerCase().replace(/\.git$/, "").replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join Manager's node->pack mappings with the disabled packs from getlist.
|
||||||
|
// mappings: { <repoUrl>: [ [class_type,...], {title_aux} ] } (from getmappings)
|
||||||
|
// managerInfo: { <dir>: {id,version,files,state,title?} } (from fetchManagerInfo)
|
||||||
|
// Returns [{ class_type, pack, title, info }] for disabled packs only.
|
||||||
|
function buildDisabledCatalog(mappings, managerInfo) {
|
||||||
|
const byUrl = {};
|
||||||
|
for (const [url, entry] of Object.entries(mappings || {})) {
|
||||||
|
const list = entry && entry[0];
|
||||||
|
if (Array.isArray(list)) byUrl[normalizeRepoUrl(url)] = list;
|
||||||
|
}
|
||||||
|
const catalog = [];
|
||||||
|
for (const [dir, info] of Object.entries(managerInfo || {})) {
|
||||||
|
if (!info || info.state !== "disabled") continue;
|
||||||
|
const urls = (info.files && info.files.length ? info.files : [info.repository]).filter(Boolean);
|
||||||
|
let nodes = null;
|
||||||
|
for (const u of urls) {
|
||||||
|
const hit = byUrl[normalizeRepoUrl(u)];
|
||||||
|
if (hit) { nodes = hit; break; }
|
||||||
|
}
|
||||||
|
if (!nodes) { console.debug("[Node Stats] no node map for disabled pack", dir); continue; }
|
||||||
|
const title = info.title || dir;
|
||||||
|
for (const ct of nodes) catalog.push({ class_type: ct, pack: dir, title, info });
|
||||||
|
}
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _disabledCatalog = null; // cached for the session
|
||||||
|
async function ensureDisabledCatalog(forceRefresh = false) {
|
||||||
|
if (_disabledCatalog && !forceRefresh) return _disabledCatalog;
|
||||||
|
const managerInfo = await fetchManagerInfo();
|
||||||
|
if (!managerInfo) return null; // Manager absent
|
||||||
|
let mappings = {};
|
||||||
|
try {
|
||||||
|
const r = await fetch("/customnode/getmappings?mode=local");
|
||||||
|
if (r.ok) mappings = await r.json();
|
||||||
|
} catch { /* fall through -> empty catalog */ }
|
||||||
|
_disabledCatalog = buildDisabledCatalog(mappings, managerInfo);
|
||||||
|
return _disabledCatalog;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify syntax** — `node --check` line. Expected `OK`.
|
||||||
|
|
||||||
|
**Step 3: Verify the join (browser console)** — hard-refresh ComfyUI, open devtools console:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// paste: pull the two sources and join, then sanity-check
|
||||||
|
const mi = await (await fetch("/customnode/getlist?mode=local&skip_update=true")).json();
|
||||||
|
const mp = await (await fetch("/customnode/getmappings?mode=local")).json();
|
||||||
|
```
|
||||||
|
Then confirm in the app once Task 4 wires it; for now just confirm `getmappings` returns an object and `getlist.node_packs` has `state:'disabled'` entries. Expected: yes (≈73 disabled packs in this install).
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add js/nodes_stats.js
|
||||||
|
git commit -m "feat(search): build disabled-node catalog from getmappings x getlist"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Search filter (pure)
|
||||||
|
|
||||||
|
**Files:** Modify `js/nodes_stats.js`.
|
||||||
|
|
||||||
|
**Step 1: Add ranking + filter:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Rank a catalog entry against a lowercased query. Lower = better; null = no match.
|
||||||
|
// class_type prefix (0) < class_type word-start (1) < class_type substring (2)
|
||||||
|
// < pack-name match (3). No match -> null.
|
||||||
|
function scoreEntry(entry, q) {
|
||||||
|
const name = entry.class_type.toLowerCase();
|
||||||
|
if (name.startsWith(q)) return 0;
|
||||||
|
if (name.split(/[\s_\-./]/).some((w) => w.startsWith(q))) return 1;
|
||||||
|
if (name.includes(q)) return 2;
|
||||||
|
if (entry.pack.toLowerCase().includes(q)) return 3;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter + rank a catalog. Returns { rows, total } where rows is capped at limit.
|
||||||
|
function filterCatalog(catalog, query, limit = 50) {
|
||||||
|
const q = String(query || "").trim().toLowerCase();
|
||||||
|
if (!q) return { rows: [], total: 0 };
|
||||||
|
const scored = [];
|
||||||
|
for (const e of catalog) {
|
||||||
|
const s = scoreEntry(e, q);
|
||||||
|
if (s !== null) scored.push([s, e]);
|
||||||
|
}
|
||||||
|
scored.sort((a, b) => a[0] - b[0] || a[1].class_type.localeCompare(b[1].class_type));
|
||||||
|
return { rows: scored.slice(0, limit).map((x) => x[1]), total: scored.length };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify syntax** — `node --check`. Expected `OK`.
|
||||||
|
|
||||||
|
**Step 3: Verify logic (browser console, after Task 4 exposes catalog, or inline)** — confirm e.g. `filterCatalog([{class_type:"MaskComposite",pack:"masquerade"}], "mask").total === 1` and `scoreEntry({class_type:"MaskComposite",pack:"x"}, "mask") === 0`.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add js/nodes_stats.js
|
||||||
|
git commit -m "feat(search): catalog ranking + filter helpers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Mirror search palette UI
|
||||||
|
|
||||||
|
**Files:** Modify `js/nodes_stats.js`.
|
||||||
|
|
||||||
|
**Step 1: Add the palette open/render:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
async function openMirrorSearch() {
|
||||||
|
const existing = document.getElementById("nodes-stats-mirror");
|
||||||
|
if (existing) { existing.querySelector("#ns-mirror-input")?.focus(); return; }
|
||||||
|
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.id = "nodes-stats-mirror";
|
||||||
|
overlay.style.cssText =
|
||||||
|
"position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10001;display:flex;align-items:flex-start;justify-content:center;";
|
||||||
|
overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
|
||||||
|
overlay.addEventListener("keydown", (e) => { if (e.key === "Escape") overlay.remove(); });
|
||||||
|
|
||||||
|
const box = document.createElement("div");
|
||||||
|
box.style.cssText =
|
||||||
|
"margin-top:10vh;background:#1e1e1e;color:#ddd;border:1px solid #444;border-radius:8px;width:90%;max-width:640px;max-height:70vh;display:flex;flex-direction:column;font-family:monospace;font-size:13px;overflow:hidden;";
|
||||||
|
box.innerHTML = `
|
||||||
|
<style>
|
||||||
|
#nodes-stats-mirror .ns-btn{font-family:monospace;font-size:11px;border:1px solid #555;background:#262626;color:#ddd;border-radius:4px;padding:3px 10px;cursor:pointer;white-space:nowrap;}
|
||||||
|
#nodes-stats-mirror .ns-btn:hover:not(:disabled){background:#203a20;border-color:#4a4;color:#fff;}
|
||||||
|
#nodes-stats-mirror .ns-btn:disabled{opacity:0.5;cursor:default;}
|
||||||
|
#nodes-stats-mirror .ns-mrow:hover{background:#262626;}
|
||||||
|
</style>
|
||||||
|
<div style="padding:12px;border-bottom:1px solid #333;display:flex;gap:8px;align-items:center;">
|
||||||
|
<input id="ns-mirror-input" placeholder="search disabled-pack nodes…" autocomplete="off"
|
||||||
|
style="flex:1;background:#111;border:1px solid #444;border-radius:4px;color:#fff;padding:8px 10px;font-family:monospace;font-size:14px;outline:none;">
|
||||||
|
<button id="ns-mirror-refresh" class="ns-btn" title="Rebuild catalog">↻</button>
|
||||||
|
</div>
|
||||||
|
<div id="ns-mirror-results" style="overflow-y:auto;padding:6px 0;"></div>
|
||||||
|
<div id="ns-mirror-footer" style="padding:8px 12px;border-top:1px solid #333;color:#666;font-size:11px;"></div>`;
|
||||||
|
overlay.appendChild(box);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const input = box.querySelector("#ns-mirror-input");
|
||||||
|
const results = box.querySelector("#ns-mirror-results");
|
||||||
|
const footer = box.querySelector("#ns-mirror-footer");
|
||||||
|
|
||||||
|
footer.textContent = "loading disabled-node catalog…";
|
||||||
|
let catalog = await ensureDisabledCatalog();
|
||||||
|
if (catalog === null) { footer.textContent = "ComfyUI Manager not available."; return; }
|
||||||
|
if (catalog.length === 0) { footer.textContent = "No disabled packages — nothing to search."; return; }
|
||||||
|
const packCount = new Set(catalog.map((e) => e.pack)).size;
|
||||||
|
footer.textContent = `${catalog.length} nodes across ${packCount} disabled packs · enabling needs a restart`;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const { rows, total } = filterCatalog(catalog, input.value);
|
||||||
|
if (!input.value.trim()) {
|
||||||
|
results.innerHTML = `<div style="padding:14px;color:#666;">Type to search ${catalog.length} nodes in ${packCount} disabled packs.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (total === 0) { results.innerHTML = `<div style="padding:14px;color:#666;">No disabled nodes match “${escapeHtml(input.value)}”.</div>`; return; }
|
||||||
|
let html = "";
|
||||||
|
for (const e of rows) {
|
||||||
|
html += `<div class="ns-mrow" style="display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid #222;">
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(e.class_type)}</div>
|
||||||
|
<div style="color:#888;font-size:11px;">${escapeHtml(e.pack)}</div>
|
||||||
|
</div>
|
||||||
|
<button class="ns-btn ns-mirror-temp" data-pkg="${escapeAttr(e.pack)}">Enable 7d</button>
|
||||||
|
<button class="ns-btn ns-mirror-perm" data-pkg="${escapeAttr(e.pack)}">Enable</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (total > rows.length) html += `<div style="padding:8px 12px;color:#666;">+${total - rows.length} more — refine your search.</div>`;
|
||||||
|
results.innerHTML = html;
|
||||||
|
results.querySelectorAll(".ns-mirror-temp").forEach((b) =>
|
||||||
|
b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, true, overlay)));
|
||||||
|
results.querySelectorAll(".ns-mirror-perm").forEach((b) =>
|
||||||
|
b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, false, overlay)));
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener("input", render);
|
||||||
|
box.querySelector("#ns-mirror-refresh").addEventListener("click", async () => {
|
||||||
|
footer.textContent = "refreshing…";
|
||||||
|
catalog = await ensureDisabledCatalog(true) || [];
|
||||||
|
footer.textContent = `${catalog.length} nodes across ${new Set(catalog.map((e)=>e.pack)).size} disabled packs · enabling needs a restart`;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable from the palette. Marks all rows for the pack as enabled on success.
|
||||||
|
async function mirrorEnable(pkg, temporary, overlay) {
|
||||||
|
const entry = (_disabledCatalog || []).find((e) => e.pack === pkg);
|
||||||
|
const info = entry && entry.info;
|
||||||
|
if (!info) return;
|
||||||
|
overlay.querySelectorAll(".ns-btn").forEach((b) => (b.disabled = true));
|
||||||
|
try {
|
||||||
|
if (await enablePackage(pkg, info, temporary)) {
|
||||||
|
(_disabledCatalog || []).forEach((e) => { if (e.pack === pkg) e.info.state = "enabled"; });
|
||||||
|
overlay.querySelectorAll(`.ns-mirror-temp[data-pkg="${cssEscape(pkg)}"], .ns-mirror-perm[data-pkg="${cssEscape(pkg)}"]`)
|
||||||
|
.forEach((b) => { b.replaceWith(Object.assign(document.createElement("span"), { textContent: "✓ enabled · restart", style: "color:#6a6;font-size:11px;" })); });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notify("Failed to enable: " + e.message, "error");
|
||||||
|
} finally {
|
||||||
|
overlay.querySelectorAll(".ns-btn").forEach((b) => (b.disabled = false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> If `cssEscape` does not already exist in the file, add the small helper used elsewhere: `function cssEscape(s){return window.CSS&&CSS.escape?CSS.escape(s):String(s).replace(/["\\]/g,"\\$&");}` (check first — the disable feature may already define it).
|
||||||
|
|
||||||
|
**Step 2: Verify syntax** — `node --check`. Expected `OK`.
|
||||||
|
|
||||||
|
**Step 3: Verify (manual)** — temporarily call `openMirrorSearch()` from the console after hard-refresh. Search a known disabled pack node (e.g. an Inspire-Pack class_type). Expected: results list; clicking Enable 7d enables the pack (verify via `/nodes-stats/trials` and getlist state flip), rows turn into "✓ enabled · restart".
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add js/nodes_stats.js
|
||||||
|
git commit -m "feat(search): mirror search palette UI + enable actions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Toolbar button + keyboard shortcut
|
||||||
|
|
||||||
|
**Files:** Modify `js/nodes_stats.js` — inside the existing `setup()` (where the Node Stats button is mounted).
|
||||||
|
|
||||||
|
**Step 1: Add a second toolbar button** after the existing Node Stats button mount:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const searchBtn = document.createElement("button");
|
||||||
|
searchBtn.textContent = "⌕";
|
||||||
|
searchBtn.title = "Search disabled-pack nodes (Ctrl/Cmd+Shift+D)";
|
||||||
|
searchBtn.className = "comfyui-button comfyui-menu-mobile-collapse";
|
||||||
|
searchBtn.style.cssText = "display:flex;align-items:center;justify-content:center;padding:6px;cursor:pointer;font-size:16px;";
|
||||||
|
searchBtn.onclick = () => openMirrorSearch();
|
||||||
|
if (app.menu?.settingsGroup?.element) app.menu.settingsGroup.element.before(searchBtn);
|
||||||
|
else document.querySelector(".comfy-menu")?.append(searchBtn);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Register the hotkey** (guarded `keydown`, ignores typing contexts) at the end of `setup()`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.addEventListener("keydown", (e) => {
|
||||||
|
if (!(e.shiftKey && (e.ctrlKey || e.metaKey) && (e.key === "D" || e.key === "d"))) return;
|
||||||
|
const t = e.target;
|
||||||
|
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
openMirrorSearch();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
> If `Ctrl/Cmd+Shift+D` conflicts with a ComfyUI binding in this build, change the key here (e.g. to `K`) and update both titles.
|
||||||
|
|
||||||
|
**Step 3: Verify syntax** — `node --check`. Expected `OK`.
|
||||||
|
|
||||||
|
**Step 4: Verify (manual)** — hard-refresh ComfyUI; the ⌕ button appears in the top menu; clicking it and pressing Ctrl/Cmd+Shift+D both open the palette; Esc / click-outside closes it.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add js/nodes_stats.js
|
||||||
|
git commit -m "feat(search): toolbar button + hotkey to open mirror search"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Docs + version bump
|
||||||
|
|
||||||
|
**Files:** Modify `README.md`, `pyproject.toml`.
|
||||||
|
|
||||||
|
**Step 1:** Add a "Mirror search (disabled-pack nodes)" subsection to the README: what it does, how to open it (⌕ button / Ctrl/Cmd+Shift+D), that results come from disabled packs, that Enable 7d/Enable take effect after restart, and that it's inert without ComfyUI Manager. Add a feature bullet.
|
||||||
|
|
||||||
|
**Step 2:** Bump `version` in `pyproject.toml` to `1.4.0`.
|
||||||
|
|
||||||
|
**Step 3: Verify** — `python -m pytest -q` (unchanged, still green) and the `node --check` line (`OK`).
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md pyproject.toml
|
||||||
|
git commit -m "docs: document mirror search; bump to 1.4.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Done criteria
|
||||||
|
|
||||||
|
- ⌕ button + Ctrl/Cmd+Shift+D open a palette that searches nodes of disabled packs (joined from getmappings × getlist), cached per session with a refresh.
|
||||||
|
- Typing filters instantly (ranked); results show `class_type` + pack with Enable 7d / Enable.
|
||||||
|
- Enabling reuses `enablePackage` → Manager enable + trial start/stop + "restart to apply" toast; rows mark "✓ enabled · restart".
|
||||||
|
- Workflow-tab enable still works (shared core, no regression).
|
||||||
|
- Inert + clear message when ComfyUI Manager is absent or there are no disabled packs.
|
||||||
|
- `python -m pytest -q` green; `node --check` clean.
|
||||||
+214
-16
@@ -45,6 +45,15 @@ app.registerExtension({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchBtn = document.createElement("button");
|
||||||
|
searchBtn.textContent = "⌕";
|
||||||
|
searchBtn.title = "Search disabled-pack nodes (Ctrl/Cmd+Shift+D)";
|
||||||
|
searchBtn.className = "comfyui-button comfyui-menu-mobile-collapse";
|
||||||
|
searchBtn.style.cssText = "display:flex;align-items:center;justify-content:center;padding:6px;cursor:pointer;font-size:16px;";
|
||||||
|
searchBtn.onclick = () => openMirrorSearch();
|
||||||
|
if (app.menu?.settingsGroup?.element) app.menu.settingsGroup.element.before(searchBtn);
|
||||||
|
else document.querySelector(".comfy-menu")?.append(searchBtn);
|
||||||
|
|
||||||
// Detect missing/disabled nodes whenever a workflow is loaded.
|
// Detect missing/disabled nodes whenever a workflow is loaded.
|
||||||
const origLoad = app.loadGraphData?.bind(app);
|
const origLoad = app.loadGraphData?.bind(app);
|
||||||
if (origLoad) {
|
if (origLoad) {
|
||||||
@@ -58,6 +67,14 @@ app.registerExtension({
|
|||||||
// Once the app has settled, auto-disable trial packages that went unused for
|
// Once the app has settled, auto-disable trial packages that went unused for
|
||||||
// their full budget of distinct boot-days. Inert when ComfyUI Manager is absent.
|
// their full budget of distinct boot-days. Inert when ComfyUI Manager is absent.
|
||||||
setTimeout(() => { processExpiredTrials().catch(() => {}); }, 3000);
|
setTimeout(() => { processExpiredTrials().catch(() => {}); }, 3000);
|
||||||
|
|
||||||
|
window.addEventListener("keydown", (e) => {
|
||||||
|
if (!(e.shiftKey && (e.ctrlKey || e.metaKey) && (e.key === "D" || e.key === "d"))) return;
|
||||||
|
const t = e.target;
|
||||||
|
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
openMirrorSearch();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -504,6 +521,76 @@ async function fetchManagerInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize a repo URL for joining getmappings keys to getlist pack files.
|
||||||
|
function normalizeRepoUrl(url) {
|
||||||
|
return String(url || "").trim().toLowerCase().replace(/\.git$/, "").replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join Manager's node->pack mappings with the disabled packs from getlist.
|
||||||
|
// mappings: { <repoUrl>: [ [class_type,...], {title_aux} ] } (from getmappings)
|
||||||
|
// managerInfo: { <dir>: {id,version,files,state,title?} } (from fetchManagerInfo)
|
||||||
|
// Returns [{ class_type, pack, title, info }] for disabled packs only.
|
||||||
|
function buildDisabledCatalog(mappings, managerInfo) {
|
||||||
|
const byUrl = {};
|
||||||
|
for (const [url, entry] of Object.entries(mappings || {})) {
|
||||||
|
const list = entry && entry[0];
|
||||||
|
if (Array.isArray(list)) byUrl[normalizeRepoUrl(url)] = list;
|
||||||
|
}
|
||||||
|
const catalog = [];
|
||||||
|
for (const [dir, info] of Object.entries(managerInfo || {})) {
|
||||||
|
if (!info || info.state !== "disabled") continue;
|
||||||
|
const urls = (info.files && info.files.length ? info.files : [info.repository]).filter(Boolean);
|
||||||
|
let nodes = null;
|
||||||
|
for (const u of urls) {
|
||||||
|
const hit = byUrl[normalizeRepoUrl(u)];
|
||||||
|
if (hit) { nodes = hit; break; }
|
||||||
|
}
|
||||||
|
if (!nodes) { console.debug("[Node Stats] no node map for disabled pack", dir); continue; }
|
||||||
|
const title = info.title || dir;
|
||||||
|
for (const ct of nodes) catalog.push({ class_type: ct, pack: dir, title, info });
|
||||||
|
}
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _disabledCatalog = null; // cached for the session
|
||||||
|
async function ensureDisabledCatalog(forceRefresh = false) {
|
||||||
|
if (_disabledCatalog && !forceRefresh) return _disabledCatalog;
|
||||||
|
const managerInfo = await fetchManagerInfo();
|
||||||
|
if (!managerInfo) return null; // Manager absent
|
||||||
|
let mappings = {};
|
||||||
|
try {
|
||||||
|
const r = await fetch("/customnode/getmappings?mode=local");
|
||||||
|
if (r.ok) mappings = await r.json();
|
||||||
|
} catch { /* fall through -> empty catalog */ }
|
||||||
|
_disabledCatalog = buildDisabledCatalog(mappings, managerInfo);
|
||||||
|
return _disabledCatalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank a catalog entry against a lowercased query. Lower = better; null = no match.
|
||||||
|
// class_type prefix (0) < class_type word-start (1) < class_type substring (2)
|
||||||
|
// < pack-name match (3). No match -> null.
|
||||||
|
function scoreEntry(entry, q) {
|
||||||
|
const name = entry.class_type.toLowerCase();
|
||||||
|
if (name.startsWith(q)) return 0;
|
||||||
|
if (name.split(/[\s_\-./]/).some((w) => w.startsWith(q))) return 1;
|
||||||
|
if (name.includes(q)) return 2;
|
||||||
|
if (entry.pack.toLowerCase().includes(q)) return 3;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter + rank a catalog. Returns { rows, total } where rows is capped at limit.
|
||||||
|
function filterCatalog(catalog, query, limit = 50) {
|
||||||
|
const q = String(query || "").trim().toLowerCase();
|
||||||
|
if (!q) return { rows: [], total: 0 };
|
||||||
|
const scored = [];
|
||||||
|
for (const e of catalog) {
|
||||||
|
const s = scoreEntry(e, q);
|
||||||
|
if (s !== null) scored.push([s, e]);
|
||||||
|
}
|
||||||
|
scored.sort((a, b) => a[0] - b[0] || a[1].class_type.localeCompare(b[1].class_type));
|
||||||
|
return { rows: scored.slice(0, limit).map((x) => x[1]), total: scored.length };
|
||||||
|
}
|
||||||
|
|
||||||
// Split unresolved node types into packages that are installed-but-disabled
|
// Split unresolved node types into packages that are installed-but-disabled
|
||||||
// (re-enable to use) vs not installed (install via Manager). Reconciles
|
// (re-enable to use) vs not installed (install via Manager). Reconciles
|
||||||
// ComfyUI Manager's getmappings (class_type -> pack key) against getlist state.
|
// ComfyUI Manager's getmappings (class_type -> pack key) against getlist state.
|
||||||
@@ -714,30 +801,39 @@ async function runManagerEnable(payload) {
|
|||||||
await waitForQueue();
|
await waitForQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared enable core used by the Workflow tab and the mirror search palette.
|
||||||
|
// Performs the Manager enable + trial bookkeeping + success toast.
|
||||||
|
// Returns true on success, false if Manager was busy. Throws on failure.
|
||||||
|
// Caller owns its own busy UI and restart affordance.
|
||||||
|
async function enablePackage(pkg, info, temporary) {
|
||||||
|
if (!info) throw new Error("no enable info for " + pkg);
|
||||||
|
if (await managerIsBusy()) {
|
||||||
|
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await runManagerEnable(enablePayload(pkg, info));
|
||||||
|
const route = temporary ? "/nodes-stats/trials/start" : "/nodes-stats/trials/stop";
|
||||||
|
await fetch(route, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ package: pkg }),
|
||||||
|
});
|
||||||
|
notify(`Enabled ${pkg}${temporary ? " for a 7-day trial" : ""}. Restart ComfyUI to apply.`, "success");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Enable a disabled package, optionally under a temporary trial. A permanent
|
// Enable a disabled package, optionally under a temporary trial. A permanent
|
||||||
// enable clears any existing trial row so the package is never auto-disabled.
|
// enable clears any existing trial row so the package is never auto-disabled.
|
||||||
async function handleEnable(pkg, temporary, dialog) {
|
async function handleEnable(pkg, temporary, dialog) {
|
||||||
const entry = _lastWorkflowScan.disabled.find((d) => d.pkg === pkg);
|
const entry = _lastWorkflowScan.disabled.find((d) => d.pkg === pkg);
|
||||||
const info = entry && entry.info;
|
const info = entry && entry.info;
|
||||||
if (!info) return;
|
if (!info) return;
|
||||||
|
|
||||||
if (await managerIsBusy()) {
|
|
||||||
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setWorkflowButtonsBusy(dialog, true);
|
setWorkflowButtonsBusy(dialog, true);
|
||||||
try {
|
try {
|
||||||
await runManagerEnable(enablePayload(pkg, info));
|
if (await enablePackage(pkg, info, temporary)) {
|
||||||
const route = temporary ? "/nodes-stats/trials/start" : "/nodes-stats/trials/stop";
|
entry.info.state = "enabled";
|
||||||
await fetch(route, {
|
showRestartBanner(dialog);
|
||||||
method: "POST",
|
}
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ package: pkg }),
|
|
||||||
});
|
|
||||||
if (entry.info) entry.info.state = "enabled";
|
|
||||||
showRestartBanner(dialog);
|
|
||||||
notify(`Enabled ${pkg}${temporary ? " for a 7-day trial" : ""}. Restart ComfyUI to apply.`, "success");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notify("Failed to enable: " + e.message, "error");
|
notify("Failed to enable: " + e.message, "error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -745,6 +841,108 @@ async function handleEnable(pkg, temporary, dialog) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mirror search: a standalone palette over nodes of currently-disabled packs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function openMirrorSearch() {
|
||||||
|
const existing = document.getElementById("nodes-stats-mirror");
|
||||||
|
if (existing) { existing.querySelector("#ns-mirror-input")?.focus(); return; }
|
||||||
|
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.id = "nodes-stats-mirror";
|
||||||
|
overlay.style.cssText =
|
||||||
|
"position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10001;display:flex;align-items:flex-start;justify-content:center;";
|
||||||
|
overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });
|
||||||
|
overlay.addEventListener("keydown", (e) => { if (e.key === "Escape") overlay.remove(); });
|
||||||
|
|
||||||
|
const box = document.createElement("div");
|
||||||
|
box.style.cssText =
|
||||||
|
"margin-top:10vh;background:#1e1e1e;color:#ddd;border:1px solid #444;border-radius:8px;width:90%;max-width:640px;max-height:70vh;display:flex;flex-direction:column;font-family:monospace;font-size:13px;overflow:hidden;";
|
||||||
|
box.innerHTML = `
|
||||||
|
<style>
|
||||||
|
#nodes-stats-mirror .ns-btn{font-family:monospace;font-size:11px;border:1px solid #555;background:#262626;color:#ddd;border-radius:4px;padding:3px 10px;cursor:pointer;white-space:nowrap;}
|
||||||
|
#nodes-stats-mirror .ns-btn:hover:not(:disabled){background:#203a20;border-color:#4a4;color:#fff;}
|
||||||
|
#nodes-stats-mirror .ns-btn:disabled{opacity:0.5;cursor:default;}
|
||||||
|
#nodes-stats-mirror .ns-mrow:hover{background:#262626;}
|
||||||
|
</style>
|
||||||
|
<div style="padding:12px;border-bottom:1px solid #333;display:flex;gap:8px;align-items:center;">
|
||||||
|
<input id="ns-mirror-input" placeholder="search disabled-pack nodes…" autocomplete="off"
|
||||||
|
style="flex:1;background:#111;border:1px solid #444;border-radius:4px;color:#fff;padding:8px 10px;font-family:monospace;font-size:14px;outline:none;">
|
||||||
|
<button id="ns-mirror-refresh" class="ns-btn" title="Rebuild catalog">↻</button>
|
||||||
|
</div>
|
||||||
|
<div id="ns-mirror-results" style="overflow-y:auto;padding:6px 0;"></div>
|
||||||
|
<div id="ns-mirror-footer" style="padding:8px 12px;border-top:1px solid #333;color:#666;font-size:11px;"></div>`;
|
||||||
|
overlay.appendChild(box);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const input = box.querySelector("#ns-mirror-input");
|
||||||
|
const results = box.querySelector("#ns-mirror-results");
|
||||||
|
const footer = box.querySelector("#ns-mirror-footer");
|
||||||
|
|
||||||
|
footer.textContent = "loading disabled-node catalog…";
|
||||||
|
let catalog = await ensureDisabledCatalog();
|
||||||
|
if (catalog === null) { footer.textContent = "ComfyUI Manager not available."; return; }
|
||||||
|
if (catalog.length === 0) { footer.textContent = "No disabled packages — nothing to search."; return; }
|
||||||
|
const packCount = new Set(catalog.map((e) => e.pack)).size;
|
||||||
|
footer.textContent = `${catalog.length} nodes across ${packCount} disabled packs · enabling needs a restart`;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const { rows, total } = filterCatalog(catalog, input.value);
|
||||||
|
if (!input.value.trim()) {
|
||||||
|
results.innerHTML = `<div style="padding:14px;color:#666;">Type to search ${catalog.length} nodes in ${packCount} disabled packs.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (total === 0) { results.innerHTML = `<div style="padding:14px;color:#666;">No disabled nodes match “${escapeHtml(input.value)}”.</div>`; return; }
|
||||||
|
let html = "";
|
||||||
|
for (const e of rows) {
|
||||||
|
html += `<div class="ns-mrow" style="display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid #222;">
|
||||||
|
<div style="flex:1;min-width:0;">
|
||||||
|
<div style="color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(e.class_type)}</div>
|
||||||
|
<div style="color:#888;font-size:11px;">${escapeHtml(e.pack)}</div>
|
||||||
|
</div>
|
||||||
|
<button class="ns-btn ns-mirror-temp" data-pkg="${escapeAttr(e.pack)}">Enable 7d</button>
|
||||||
|
<button class="ns-btn ns-mirror-perm" data-pkg="${escapeAttr(e.pack)}">Enable</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if (total > rows.length) html += `<div style="padding:8px 12px;color:#666;">+${total - rows.length} more — refine your search.</div>`;
|
||||||
|
results.innerHTML = html;
|
||||||
|
results.querySelectorAll(".ns-mirror-temp").forEach((b) =>
|
||||||
|
b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, true, overlay)));
|
||||||
|
results.querySelectorAll(".ns-mirror-perm").forEach((b) =>
|
||||||
|
b.addEventListener("click", () => mirrorEnable(b.dataset.pkg, false, overlay)));
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener("input", render);
|
||||||
|
box.querySelector("#ns-mirror-refresh").addEventListener("click", async () => {
|
||||||
|
footer.textContent = "refreshing…";
|
||||||
|
catalog = await ensureDisabledCatalog(true) || [];
|
||||||
|
footer.textContent = `${catalog.length} nodes across ${new Set(catalog.map((e)=>e.pack)).size} disabled packs · enabling needs a restart`;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable from the palette. Marks all rows for the pack as enabled on success.
|
||||||
|
async function mirrorEnable(pkg, temporary, overlay) {
|
||||||
|
const entry = (_disabledCatalog || []).find((e) => e.pack === pkg);
|
||||||
|
const info = entry && entry.info;
|
||||||
|
if (!info) return;
|
||||||
|
overlay.querySelectorAll(".ns-btn").forEach((b) => (b.disabled = true));
|
||||||
|
try {
|
||||||
|
if (await enablePackage(pkg, info, temporary)) {
|
||||||
|
(_disabledCatalog || []).forEach((e) => { if (e.pack === pkg) e.info.state = "enabled"; });
|
||||||
|
overlay.querySelectorAll(`.ns-mirror-temp[data-pkg="${cssEscape(pkg)}"], .ns-mirror-perm[data-pkg="${cssEscape(pkg)}"]`)
|
||||||
|
.forEach((b) => { b.replaceWith(Object.assign(document.createElement("span"), { textContent: "✓ enabled · restart", style: "color:#6a6;font-size:11px;" })); });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notify("Failed to enable: " + e.message, "error");
|
||||||
|
} finally {
|
||||||
|
overlay.querySelectorAll(".ns-btn").forEach((b) => (b.disabled = false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Missing packages are deferred to ComfyUI Manager — the design treats "Missing"
|
// Missing packages are deferred to ComfyUI Manager — the design treats "Missing"
|
||||||
// as handled by Manager like always, and Manager already surfaces missing nodes
|
// as handled by Manager like always, and Manager already surfaces missing nodes
|
||||||
// on workflow load. We intentionally do NOT replicate install: a not-installed
|
// on workflow load. We intentionally do NOT replicate install: a not-installed
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-nodes-stats"
|
name = "comfyui-nodes-stats"
|
||||||
description = "Track usage statistics for all ComfyUI nodes and packages"
|
description = "Track usage statistics for all ComfyUI nodes and packages"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
Reference in New Issue
Block a user