Files
Comfyui-Nodes-Stats/docs/plans/2026-06-21-mirror-search.md
T
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

18 KiB
Raw Blame History

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:

// 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:

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

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:

// 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 syntaxnode --check line. Expected OK.

Step 3: Verify the join (browser console) — hard-refresh ComfyUI, open devtools console:

// 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

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:

// 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 syntaxnode --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

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:

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 syntaxnode --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

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:

    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():

    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 syntaxnode --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

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: Verifypython -m pytest -q (unchanged, still green) and the node --check line (OK).

Step 4: Commit

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.