Compare commits

...

8 Commits

Author SHA1 Message Date
Ethanfel d8f94ca371 docs: document mirror search; bump to 1.4.0
Publish to Comfy registry / Publish Custom Node to registry (push) Has been cancelled
2026-06-21 14:28:36 +02:00
Ethanfel a8fb5ae8b4 feat(search): toolbar button + hotkey to open mirror search 2026-06-21 14:27:46 +02:00
Ethanfel 0dfa14384d feat(search): mirror search palette UI + enable actions 2026-06-21 14:27:14 +02:00
Ethanfel 8c35ac6f09 feat(search): catalog ranking + filter helpers 2026-06-21 14:26:20 +02:00
Ethanfel b6635c9f3e feat(search): build disabled-node catalog from getmappings x getlist 2026-06-21 14:25:35 +02:00
Ethanfel fb3a785027 refactor: extract enablePackage core from handleEnable 2026-06-21 14:25:08 +02:00
Ethanfel 8cb0e32739 docs: implementation plan for disabled-node mirror search
6-task frontend-only plan: extract shared enablePackage core, build the
disabled-node catalog (getmappings x getlist), ranking/filter helpers, the
palette UI, toolbar button + hotkey, and docs/version. Manual verification
(no JS harness) + node --check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:15:16 +02:00
Ethanfel aa2f1f62bf docs: design for disabled-node mirror search
Approved design: a standalone search palette (toolbar button + hotkey) over
nodes belonging to disabled packs, built frontend-only by joining Manager's
getmappings x getlist, with Enable 7d / Enable actions reusing the trial
feature. Native-search injection is infeasible (bundled frontend), so this
runs alongside as a mirror.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:12:25 +02:00
5 changed files with 762 additions and 17 deletions
+22
View File
@@ -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).
+395
View File
@@ -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 717746).
**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.
+211 -13
View File
@@ -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,20 +801,16 @@ async function runManagerEnable(payload) {
await waitForQueue(); await waitForQueue();
} }
// Enable a disabled package, optionally under a temporary trial. A permanent // Shared enable core used by the Workflow tab and the mirror search palette.
// enable clears any existing trial row so the package is never auto-disabled. // Performs the Manager enable + trial bookkeeping + success toast.
async function handleEnable(pkg, temporary, dialog) { // Returns true on success, false if Manager was busy. Throws on failure.
const entry = _lastWorkflowScan.disabled.find((d) => d.pkg === pkg); // Caller owns its own busy UI and restart affordance.
const info = entry && entry.info; async function enablePackage(pkg, info, temporary) {
if (!info) return; if (!info) throw new Error("no enable info for " + pkg);
if (await managerIsBusy()) { if (await managerIsBusy()) {
notify("ComfyUI Manager is busy. Please try again in a moment.", "warn"); notify("ComfyUI Manager is busy. Please try again in a moment.", "warn");
return; return false;
} }
setWorkflowButtonsBusy(dialog, true);
try {
await runManagerEnable(enablePayload(pkg, info)); await runManagerEnable(enablePayload(pkg, info));
const route = temporary ? "/nodes-stats/trials/start" : "/nodes-stats/trials/stop"; const route = temporary ? "/nodes-stats/trials/start" : "/nodes-stats/trials/stop";
await fetch(route, { await fetch(route, {
@@ -735,9 +818,22 @@ async function handleEnable(pkg, temporary, dialog) {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: pkg }), 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"); 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 clears any existing trial row so the package is never auto-disabled.
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) { } 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
View File
@@ -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]