Files
comfyuiattic-989-ComfyUI-Ex…/web/ext.js
T
ComfyUI AtticandClaude Sonnet 4.6 a024644dcf Add Comfy Registry publish files; update docs and clean repo
- Add pyproject.toml for Comfy Registry (publisher: comfyui-attic)
- Rewrite README to reflect tree-style file browser UI
- Fix api.py docstrings: route count (4 → 6), list_files error values
- ext.js: make cache_stats truly non-interactive (pointer-events:none),
  show available MB instead of max, update on max_cache_mb change,
  remove stale D3 comment
- Add .gitignore; untrack dev artifacts (api_spec.yaml, state.json,
  docs/superpowers/)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 16:44:21 -04:00

919 lines
38 KiB
JavaScript

import { app } from "../../scripts/app.js";
// Collapse a widget to zero height so it's invisible but still serialized
function hideWidget(node, widget) {
widget.computeSize = () => [0, -4];
widget.type = "hidden";
node.setSize(node.computeSize());
}
// Inject modal CSS once into document.head
let _stylesInjected = false;
function injectStyles() {
if (_stylesInjected) return;
_stylesInjected = true;
const style = document.createElement("style");
style.textContent = `
.ell-modal-overlay {
position: fixed; inset: 0;
z-index: 9999;
}
.ell-modal-box {
position: fixed;
background: var(--comfy-menu-bg, #353535);
border-radius: 8px;
box-shadow: 0 4px 32px rgba(0,0,0,0.6);
display: flex; flex-direction: column;
overflow: hidden;
}
.ell-modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 14px;
border-bottom: 1px solid var(--border-color, #4e4e4e);
font-weight: bold; color: var(--fg-color, #fff);
cursor: move;
flex-shrink: 0;
user-select: none;
}
.ell-modal-close {
background: none; border: none; color: var(--fg-color, #fff);
cursor: pointer; font-size: 18px; line-height: 1; padding: 2px 6px;
}
.ell-modal-body {
display: flex; flex: 1; min-height: 0; overflow: hidden;
}
.ell-tree-container {
flex: 1; overflow: auto; padding: 4px 0;
background: var(--comfy-input-bg, #1a1a1a);
font-family: system-ui, sans-serif;
font-size: 13px;
}
.ell-row {
display: flex; align-items: center;
height: 22px; line-height: 22px;
cursor: pointer;
white-space: nowrap;
user-select: none;
color: var(--fg-color, #ddd);
border: 1px solid transparent;
box-sizing: border-box;
}
.ell-row:hover {
background: rgba(255,255,255,0.07);
}
.ell-row-selected {
background: #0078d4;
color: #fff;
}
.ell-row-selected:hover {
background: #1084d8;
}
.ell-row-arrow {
display: inline-block;
width: 14px; text-align: center;
flex-shrink: 0;
font-size: 9px;
color: var(--input-text, #aaa);
}
.ell-row-selected .ell-row-arrow { color: #fff; }
.ell-row-icon {
display: inline-block;
width: 20px; text-align: center;
flex-shrink: 0;
}
.ell-row-label {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 8px;
}
.ell-modal-footer {
display: flex; align-items: center; gap: 8px;
padding: 8px 14px;
border-top: 1px solid var(--border-color, #4e4e4e);
flex-shrink: 0;
}
.ell-status-text {
flex: 1; min-width: 0;
font-size: 12px; color: var(--input-text, #aaa);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ell-select-btn {
padding: 5px 18px; border-radius: 4px;
background: var(--comfy-input-bg, #444); color: var(--fg-color, #fff);
border: 1px solid var(--border-color, #4e4e4e); cursor: pointer;
flex-shrink: 0;
}
.ell-select-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.ell-select-btn-active {
background: #0078d4 !important;
border-color: #006cbd !important;
color: #fff !important;
}
.ell-ext-filter {
background: var(--comfy-input-bg, #1a1a1a);
color: var(--fg-color, #ddd);
border: 1px solid var(--border-color, #4e4e4e);
border-radius: 4px;
font-size: 12px;
padding: 3px 6px;
cursor: pointer;
flex-shrink: 0;
}
.ell-row-inaccessible {
opacity: 0.45;
cursor: not-allowed;
}
.ell-row-inaccessible:hover {
background: none !important;
}
.ell-resize-handle {
position: absolute; right: 0; bottom: 0;
width: 16px; height: 16px; cursor: se-resize;
background: linear-gradient(135deg,
transparent 50%,
var(--border-color, #666) 50%, var(--border-color, #666) 60%,
transparent 60%, transparent 70%,
var(--border-color, #666) 70%, var(--border-color, #666) 80%,
transparent 80%);
opacity: 0.6;
}
`;
document.head.appendChild(style);
}
// ---------------------------------------------------------------------------
// Module-level state shared across all node instances
// ---------------------------------------------------------------------------
// Cached result of GET /external_lora/loras_folder (undefined = not fetched yet)
let _lorasFolderCache = undefined;
// Last extension filter the user chose — persists across opens and nodes
let _lastExtFilter = ".safetensors"; // default: Safetensors only
async function _fetchLorasFolder() {
if (_lorasFolderCache) return _lorasFolderCache; // only cache successes; failures retry next open
try {
const resp = await fetch("/external_lora/loras_folder");
if (!resp.ok) return null;
const data = await resp.json();
if (data.drive) {
_lorasFolderCache = { drive: data.drive, sub_path: data.sub_path || "" };
return _lorasFolderCache;
}
} catch {}
return null;
}
// Convert the filter select value to the API `extensions` param
function _extParam(filter) {
if (!filter || filter === "") return null; // backend uses _SUPPORTED_EXTENSIONS
if (filter === "*") return ["*"]; // all files
return filter.split(","); // e.g. ".pt,.pth"
}
app.registerExtension({
name: "ExternalLoraLoader",
async nodeCreated(node) {
// Only apply to ExternalLoraLoader nodes
if (node.comfyClass !== "ExternalLoraLoader") return;
// --- Widget references ---
const driveWidget = node.widgets.find(w => w.name === "drive");
const subPathWidget = node.widgets.find(w => w.name === "sub_path");
const loraNameWidget = node.widgets.find(w => w.name === "lora_name");
if (!driveWidget || !subPathWidget || !loraNameWidget) return;
// Render the backend STRING input as a combo so the UI keeps the same
// dropdown behavior without triggering backend combo-list validation.
loraNameWidget.type = "combo";
loraNameWidget.options = { ...(loraNameWidget.options || {}), values: ["none"] };
loraNameWidget.value = loraNameWidget.value || "none";
// nodeCreated fires BEFORE node.configure() writes saved widget values,
// so loraNameWidget.value is still the default here. onConfigure fires
// at the END of configure(), after widgets_values are applied — that is
// the first moment we can see the real saved filename and seed options
// so ComfyUI's missing-models validator finds the value in the list.
const _origOnConfigure = node.onConfigure;
node.onConfigure = function(info) {
if (_origOnConfigure) _origOnConfigure.call(this, info);
const v = loraNameWidget.value || "none";
if (v !== "none" && !loraNameWidget.options.values.includes(v)) {
loraNameWidget.options.values = ["none", v];
}
};
// --- Debounce helper ---
let debounceTimer = null;
let pathLabelWidget = null;
function scheduleRefresh() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => refreshFiles(), 500);
}
// --- refreshFiles: POST to list_files API ---
async function refreshFiles() {
const drive = driveWidget.value;
const path = (subPathWidget.value || "").replace(/\\/g, "/");
try {
const resp = await fetch("/external_lora/list_files", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ drive, path }),
});
const data = await resp.json();
if (!resp.ok) {
loraNameWidget.options.values = ["none"];
loraNameWidget.value = "none";
showTempLabel(loraNameWidget, `Server error (${resp.status})`, 2000);
} else if (data.files && data.files.length > 0) {
const prev = loraNameWidget.value;
loraNameWidget.options.values = data.files;
loraNameWidget.value = data.files.includes(prev) ? prev : "none";
} else {
loraNameWidget.options.values = ["none"];
loraNameWidget.value = "none";
pathLabelWidget.value = "No file selected";
showTempLabel(loraNameWidget, "Path not found", 2000);
}
} catch (err) {
loraNameWidget.options.values = ["none"];
loraNameWidget.value = "none";
pathLabelWidget.value = "No file selected";
showTempLabel(loraNameWidget, "Error fetching files", 2000);
} finally {
node.setDirtyCanvas(true, true);
}
}
// --- Temporary label helper ---
const _labelTimers = new WeakMap();
function showTempLabel(widget, message, durationMs) {
clearTimeout(_labelTimers.get(widget));
const origLabel = widget.label;
widget.label = message;
node.setDirtyCanvas(true, true);
_labelTimers.set(widget, setTimeout(() => {
widget.label = origLabel;
node.setDirtyCanvas(true, true);
}, durationMs));
}
// 1. Hide drive and sub_path from the canvas
hideWidget(node, driveWidget);
hideWidget(node, subPathWidget);
// 2. Inject CSS (idempotent)
injectStyles();
// 3. Path label (read-only display)
pathLabelWidget = node.addWidget("text", "selected_path", "No file selected", () => {});
setTimeout(() => {
if (pathLabelWidget.inputEl) pathLabelWidget.inputEl.readOnly = true;
}, 0);
// 4. Browse button
const browseBtn = node.addWidget("button", "Browse\u2026", null, () => openFileBrowser());
// 5. Browser navigation state
const browserState = {
drive: "", pathSegments: [], selectedFile: null,
};
// 6. Modal DOM (built lazily on first open)
let modalOverlay = null;
let treeContainerEl = null;
let statusEl = null;
let selectBtn = null;
let _boxPositioned = false;
// Last folder the user browsed to in THIS node's modal (null = not opened yet)
let _lastBrowsePath = null; // { drive: string, segments: string[] }
// Per-node extension filter — inherits module-level default on first open
let _extFilter = _lastExtFilter;
// DOM reference to the extension filter <select> (set once by buildModal)
let extFilterEl = null;
// Shared keydown handler for Escape key
let onKeyDown = null;
// Tree state (per node instance)
let treeRootData = null;
let _idCounter = 0;
function _nextId() { return ++_idCounter; }
// --- _normDrive: normalize drive strings for comparison ---
function _normDrive(d) {
return (d || "").replace(/\\/g, "/").replace(/\/+$/, "") + "/";
}
// --- clearFileSelection: recursively clears _selected on all nodes ---
function clearFileSelection(n) {
if (!n) return;
n._selected = false;
[...(n.children || []), ...(n._children || [])].forEach(clearFileSelection);
}
// --- renderTree: rebuild the Explorer-style tree DOM ---
function renderTree() {
if (!treeContainerEl || !treeRootData) return;
treeContainerEl.innerHTML = "";
function renderNode(data, depth) {
const isFile = data.type === "file";
const isDrive = data.type === "drive";
const isInaccessible = !isFile && data.accessible === false;
// Client-side extension filter (applied to files only)
if (isFile && _extFilter && _extFilter !== "*") {
const allowed = _extFilter.split(",");
const dot = data.name.lastIndexOf(".");
const ext = dot >= 0 ? data.name.slice(dot).toLowerCase() : "";
if (!allowed.includes(ext)) return;
}
const row = document.createElement("div");
row.className = "ell-row"
+ (data._selected ? " ell-row-selected" : "")
+ (isInaccessible ? " ell-row-inaccessible" : "");
row.style.paddingLeft = (depth * 16 + 4) + "px";
if (isInaccessible && data.reason) row.title = data.reason;
// Arrow: ▶ collapsed, ▼ expanded, space for files
const arrow = document.createElement("span");
arrow.className = "ell-row-arrow";
if (isFile) {
arrow.textContent = "\u00a0";
} else {
arrow.textContent = data.children !== null ? "\u25bc" : "\u25b6";
}
// Icon
const icon = document.createElement("span");
icon.className = "ell-row-icon";
if (isDrive) icon.textContent = "\uD83D\uDDB4"; // 🖴
else if (isFile) icon.textContent = "\uD83D\uDCC4"; // 📄
else icon.textContent = data.children !== null ? "\uD83D\uDCC2" : "\uD83D\uDCC1"; // 📂 / 📁
// Label
const label = document.createElement("span");
label.className = "ell-row-label";
label.textContent = data.name;
row.appendChild(arrow);
row.appendChild(icon);
row.appendChild(label);
row.addEventListener("click", (e) => { if (!isInaccessible) onNodeClick(e, data); });
if (isFile) {
row.addEventListener("dblclick", (e) => {
e.stopPropagation();
commitSelection();
});
}
treeContainerEl.appendChild(row);
// Recurse into expanded children
if (data.children) {
for (const child of data.children) {
renderNode(child, depth + 1);
}
}
}
for (const drive of (treeRootData.children || [])) {
renderNode(drive, 0);
}
}
// --- onNodeClick: file select or folder lazy-load + toggle ---
async function onNodeClick(event, data) {
if (data.type === "file") {
clearFileSelection(treeRootData);
data._selected = true;
browserState.drive = data.drive;
browserState.pathSegments = data.segments.slice(0, -1);
browserState.selectedFile = data.name;
if (selectBtn) {
selectBtn.disabled = false;
selectBtn.textContent = "Select";
selectBtn.classList.add("ell-select-btn-active");
}
if (statusEl) statusEl.textContent = data.name;
renderTree();
return;
}
// Folder / drive — lazy load on first expand, then toggle
if (!data._loaded) {
if (statusEl) statusEl.textContent = "Loading\u2026";
const path = data.segments.join("/");
try {
const resp = await fetch("/external_lora/browse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ drive: data.drive, path, extensions: _extParam(_extFilter) }),
});
if (!resp.ok) throw new Error(resp.statusText);
const json = await resp.json();
const dirs = json.dirs || [];
const files = json.files || [];
data.children = [
...dirs.map(d => ({
name: d.name, type: "dir", drive: data.drive,
segments: [...data.segments, d.name],
_loaded: false, children: null, _children: null,
_id: _nextId(), _selected: false,
accessible: d.accessible, reason: d.reason,
})),
...files.map(name => ({
name, type: "file", drive: data.drive,
segments: [...data.segments, name],
_loaded: true, children: null, _children: null,
_id: _nextId(), _selected: false
})),
];
data._children = null;
data._loaded = true;
if (statusEl) statusEl.textContent = "";
// Remember this folder as the last browsed location
_lastBrowsePath = { drive: data.drive, segments: [...data.segments] };
} catch {
if (statusEl) statusEl.textContent = "Error loading";
return;
}
} else {
// Toggle expand/collapse
if (data.children !== null) {
data._children = data.children;
data.children = null;
} else {
data.children = data._children || [];
data._children = null;
}
}
renderTree();
}
// --- autoExpandToPath: traverse tree data to saved path ---
async function autoExpandToPath(drive, segments, savedFile) {
if (!treeRootData) return;
let cur = (treeRootData.children || []).find(c => _normDrive(c.drive) === _normDrive(drive));
if (!cur) return;
for (let i = 0; i < segments.length; i++) {
if (!cur._loaded) {
const path = cur.segments.join("/");
try {
const resp = await fetch("/external_lora/browse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ drive: cur.drive, path, extensions: _extParam(_extFilter) }),
});
if (!resp.ok) throw new Error(resp.statusText);
const json = await resp.json();
cur._loaded = true;
cur.children = [
...(json.dirs || []).map(d => ({
name: d.name, type: "dir", drive: cur.drive,
segments: [...cur.segments, d.name],
_loaded: false, children: null, _children: null,
_id: _nextId(), _selected: false,
accessible: d.accessible, reason: d.reason,
})),
...(json.files || []).map(name => ({
name, type: "file", drive: cur.drive,
segments: [...cur.segments, name],
_loaded: true, children: null, _children: null,
_id: _nextId(), _selected: false
})),
];
} catch {
break;
}
}
const next = (cur.children || []).find(c => c.name === segments[i]);
if (!next) break;
cur = next;
}
// Highlight saved file
if (savedFile && cur.children) {
const fileNode = cur.children.find(c => c.type === "file" && c.name === savedFile);
if (fileNode) {
clearFileSelection(treeRootData);
fileNode._selected = true;
browserState.drive = fileNode.drive;
browserState.pathSegments = fileNode.segments.slice(0, -1);
browserState.selectedFile = fileNode.name;
if (selectBtn) {
selectBtn.disabled = false;
selectBtn.textContent = "Select";
}
if (statusEl) statusEl.textContent = savedFile;
}
}
renderTree();
}
// --- positionModal() ---
function positionModal() {
const box = modalOverlay ? modalOverlay.querySelector(".ell-modal-box") : null;
if (_boxPositioned || !box) return;
const initW = Math.min(700, window.innerWidth * 0.88);
const initH = Math.min(520, window.innerHeight * 0.80);
box.style.width = initW + "px";
box.style.height = initH + "px";
box.style.left = Math.round((window.innerWidth - initW) / 2) + "px";
box.style.top = Math.round((window.innerHeight - initH) / 2) + "px";
_boxPositioned = true;
}
// --- makeDraggable(box, header) ---
function makeDraggable(box, header) {
header.addEventListener("pointerdown", (e) => {
if (e.target.closest(".ell-modal-close")) return;
const startX = e.clientX - box.offsetLeft;
const startY = e.clientY - box.offsetTop;
header.setPointerCapture(e.pointerId);
document.body.style.userSelect = "none";
function onMove(ev) {
const minV = 40;
let left = Math.max(minV - box.offsetWidth, Math.min(window.innerWidth - minV, ev.clientX - startX));
let top = Math.max(0, Math.min(window.innerHeight - minV, ev.clientY - startY));
box.style.left = left + "px";
box.style.top = top + "px";
}
function onUp() {
header.removeEventListener("pointermove", onMove);
header.removeEventListener("pointerup", onUp);
document.body.style.userSelect = "";
}
header.addEventListener("pointermove", onMove);
header.addEventListener("pointerup", onUp);
});
}
// --- makeResizable(box, handle) ---
function makeResizable(box, handle) {
handle.addEventListener("pointerdown", (e) => {
e.stopPropagation();
const startX = e.clientX, startY = e.clientY;
const initW = box.offsetWidth, initH = box.offsetHeight;
handle.setPointerCapture(e.pointerId);
document.body.style.userSelect = "none";
function onMove(ev) {
const newW = Math.min(window.innerWidth * 0.95, Math.max(480, initW + (ev.clientX - startX)));
const newH = Math.min(window.innerHeight * 0.92, Math.max(350, initH + (ev.clientY - startY)));
box.style.width = newW + "px";
box.style.height = newH + "px";
}
function onUp() {
handle.removeEventListener("pointermove", onMove);
handle.removeEventListener("pointerup", onUp);
document.body.style.userSelect = "";
}
handle.addEventListener("pointermove", onMove);
handle.addEventListener("pointerup", onUp);
});
}
// --- buildModal() ---
function buildModal() {
if (modalOverlay) return; // already built
modalOverlay = document.createElement("div");
modalOverlay.className = "ell-modal-overlay";
modalOverlay.style.display = "none";
const box = document.createElement("div");
box.className = "ell-modal-box";
// Header
const header = document.createElement("div");
header.className = "ell-modal-header";
const title = document.createElement("span");
title.textContent = "Browse for LoRA";
const closeBtn = document.createElement("button");
closeBtn.className = "ell-modal-close";
closeBtn.textContent = "\u2715";
closeBtn.onclick = closeModal;
header.appendChild(title);
header.appendChild(closeBtn);
// Body — scrollable tree container
const body = document.createElement("div");
body.className = "ell-modal-body";
treeContainerEl = document.createElement("div");
treeContainerEl.className = "ell-tree-container";
body.appendChild(treeContainerEl);
// Footer
const footer = document.createElement("div");
footer.className = "ell-modal-footer";
// Extension filter select
extFilterEl = document.createElement("select");
extFilterEl.className = "ell-ext-filter";
[
{ value: "", label: "All LoRA types" },
{ value: ".safetensors", label: "Safetensors" },
{ value: ".ckpt", label: "Checkpoint" },
{ value: ".pt,.pth", label: "PyTorch" },
{ value: "*", label: "All files" },
].forEach(({ value, label }) => {
const opt = document.createElement("option");
opt.value = value;
opt.textContent = label;
extFilterEl.appendChild(opt);
});
statusEl = document.createElement("span");
statusEl.className = "ell-status-text";
selectBtn = document.createElement("button");
selectBtn.className = "ell-select-btn";
selectBtn.textContent = "Select";
selectBtn.disabled = true;
selectBtn.onclick = commitSelection;
footer.appendChild(extFilterEl);
footer.appendChild(statusEl);
footer.appendChild(selectBtn);
// Resize handle
const resizeHandle = document.createElement("div");
resizeHandle.className = "ell-resize-handle";
box.appendChild(header);
box.appendChild(body);
box.appendChild(footer);
box.appendChild(resizeHandle);
modalOverlay.appendChild(box);
document.body.appendChild(modalOverlay);
// Wire up drag and resize
makeDraggable(box, header);
makeResizable(box, resizeHandle);
}
// --- openFileBrowser() ---
async function openFileBrowser() {
buildModal();
modalOverlay.style.display = "block";
positionModal();
if (onKeyDown) { document.removeEventListener("keydown", onKeyDown); onKeyDown = null; }
onKeyDown = (e) => {
if (e.key === "Escape") closeModal();
else if (e.key === "Enter" && browserState.selectedFile) commitSelection();
};
document.addEventListener("keydown", onKeyDown);
browserState.selectedFile = null;
if (selectBtn) {
selectBtn.disabled = true;
selectBtn.textContent = "Select";
selectBtn.classList.remove("ell-select-btn-active");
}
if (statusEl) statusEl.textContent = "Loading drives\u2026";
// Sync filter select to current per-node value
if (extFilterEl) {
extFilterEl.value = _extFilter;
// Wire change handler (safe to re-attach; handler is idempotent via replace)
extFilterEl.onchange = async () => {
_extFilter = extFilterEl.value;
_lastExtFilter = _extFilter;
// Re-init the tree so new folders load with the updated filter
treeRootData = null;
await _initTree();
};
}
await _initTree();
}
// Fetch drives, rebuild treeRootData, then navigate to the best-known path.
// Called on first open and on filter change.
async function _initTree() {
if (statusEl) statusEl.textContent = "Loading drives\u2026";
try {
const resp = await fetch("/external_lora/browse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!resp.ok) throw new Error(resp.statusText);
const data = await resp.json();
const drives = data.dirs || [];
treeRootData = {
name: "root", type: "root", drive: "", segments: [],
_loaded: true, _id: _nextId(), _selected: false,
children: drives.map(d => ({
name: d.name, type: "drive", drive: d.name, segments: [],
_loaded: false, children: null, _children: null,
_id: _nextId(), _selected: false,
accessible: d.accessible, reason: d.reason,
}))
};
if (statusEl) statusEl.textContent = "";
renderTree();
// Navigation priority:
// 1. Last folder the user manually browsed to (within this session)
// 2. The folder of the currently-selected LoRA (from widget values)
// 3. ComfyUI's loras folder (first open, nothing saved)
const savedDrive = driveWidget.value || "";
const savedPath = subPathWidget.value || "";
const savedFile = loraNameWidget.value !== "none" ? loraNameWidget.value : null;
if (_lastBrowsePath && _lastBrowsePath.drive) {
await autoExpandToPath(_lastBrowsePath.drive, _lastBrowsePath.segments, savedFile);
} else if (savedDrive && (savedFile || savedPath)) {
// Navigate to the saved location even when no file is selected yet
const segs = savedPath ? savedPath.split(/[/\\]/).filter(Boolean) : [];
await autoExpandToPath(savedDrive, segs, savedFile);
} else {
const lf = await _fetchLorasFolder();
if (lf && lf.drive) {
const segs = lf.sub_path ? lf.sub_path.split("/").filter(Boolean) : [];
await autoExpandToPath(lf.drive, segs, null);
}
}
} catch {
if (statusEl) statusEl.textContent = "Failed to load drives";
}
}
function closeModal() {
if (modalOverlay) modalOverlay.style.display = "none";
// Remove Escape key handler to avoid memory leak
if (onKeyDown) {
document.removeEventListener("keydown", onKeyDown);
onKeyDown = null;
}
}
function commitSelection() {
if (!browserState.selectedFile) return;
const drive = browserState.drive;
const subPath = browserState.pathSegments.join("/");
const loraName = browserState.selectedFile;
// Remember this location for the next modal open
_lastBrowsePath = { drive, segments: [...browserState.pathSegments] };
// Write back to hidden widgets
driveWidget.value = drive;
subPathWidget.value = subPath;
loraNameWidget.value = loraName;
// Ensure selected file is in options so refreshFiles preserves it even on transient error
if (!loraNameWidget.options.values || !loraNameWidget.options.values.includes(loraName)) {
loraNameWidget.options.values = ["none", loraName];
}
// Update visible path label
const displayPath = subPath
? drive + subPath + "/" + loraName
: drive + loraName;
pathLabelWidget.value = displayPath;
closeModal();
// Sync lora_name dropdown options so ComfyUI serializes it correctly
refreshFiles();
node.setDirtyCanvas(true, true);
}
// --- Attach listeners ---
driveWidget.callback = () => scheduleRefresh();
const origSubPathCallback = subPathWidget.callback;
subPathWidget.callback = (...args) => {
if (origSubPathCallback) origSubPathCallback(...args);
scheduleRefresh();
};
// --- Clear Cache button ---
const clearCacheBtn = node.addWidget("button", "Clear Cache", null, async () => {
try {
const resp = await fetch("/external_lora/clear_cache", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const data = await resp.json();
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const mb = Number(data.freed_mb || 0);
const label = mb >= 1024
? `Freed ${(mb / 1024).toFixed(1)} GB`
: `Freed ${mb.toFixed(1)} MB`;
showTempLabel(clearCacheBtn, label, 2000);
refreshCacheStats(); // update stats line after clearing
} catch (err) {
console.error("[ExternalLoraLoader] Clear cache failed:", err);
showTempLabel(clearCacheBtn, "Cache clear failed", 2000);
}
});
// --- Cache stats display (sits below Clear Cache button) ---
const cacheStatsWidget = node.addWidget("text", "cache_stats", "\u2014", () => {});
setTimeout(() => {
if (cacheStatsWidget.inputEl) {
cacheStatsWidget.inputEl.readOnly = true;
cacheStatsWidget.inputEl.style.color = "#888";
cacheStatsWidget.inputEl.style.fontSize = "11px";
cacheStatsWidget.inputEl.style.textAlign = "center";
cacheStatsWidget.inputEl.style.cursor = "default";
cacheStatsWidget.inputEl.style.userSelect = "none";
cacheStatsWidget.inputEl.style.pointerEvents = "none";
}
}, 0);
const maxCacheMbWidget = node.widgets.find(w => w.name === "max_cache_mb");
async function refreshCacheStats() {
try {
const resp = await fetch("/external_lora/cache_stats");
if (!resp.ok) return;
const d = await resp.json();
const maxMb = maxCacheMbWidget ? maxCacheMbWidget.value : d.max_mb;
const availMb = maxMb - d.used_mb;
const fmtUsed = mb => mb >= 1024
? `${(mb / 1024).toFixed(1)} GB`
: `${Number(mb).toFixed(0)} MB`;
cacheStatsWidget.value =
`${fmtUsed(d.used_mb)} used \u00b7 ${Number(availMb).toFixed(0)} MB avail`;
node.setDirtyCanvas(true, true);
} catch {}
}
if (maxCacheMbWidget) {
const _origMaxCacheCallback = maxCacheMbWidget.callback;
maxCacheMbWidget.callback = (...args) => {
if (_origMaxCacheCallback) _origMaxCacheCallback(...args);
refreshCacheStats();
};
}
refreshCacheStats();
// Poll cache stats every 10 s so the display stays current as LoRAs are loaded
const _statsInterval = setInterval(refreshCacheStats, 10_000);
const _origOnRemoved = node.onRemoved;
node.onRemoved = function() {
clearInterval(_statsInterval);
if (_origOnRemoved) _origOnRemoved.call(this);
};
// --- Widget order splice ---
// Final canvas order: drive(hidden), sub_path(hidden), selected_path, Browse…, lora_name, …, Clear Cache, cache_stats
{
const _loraIdx = node.widgets.indexOf(loraNameWidget);
const _btnIdx = node.widgets.indexOf(browseBtn);
const _pathIdx = node.widgets.indexOf(pathLabelWidget);
node.widgets.splice(_btnIdx, 1);
node.widgets.splice(_pathIdx, 1);
node.widgets.splice(_loraIdx, 0, pathLabelWidget);
node.widgets.splice(_loraIdx + 1, 0, browseBtn);
node.setSize(node.computeSize());
}
// Restore path label from persisted widget values on workflow load
setTimeout(() => {
const d = driveWidget.value || "";
const p = subPathWidget.value || "";
const f = loraNameWidget.value || "none";
if (d && f !== "none") {
const displayPath = p ? d + p + "/" + f : d + f;
pathLabelWidget.value = displayPath;
browserState.drive = d;
browserState.pathSegments = p ? p.split(/[/\\]/).filter(Boolean) : [];
browserState.selectedFile = f;
}
}, 0);
// --- Initial load ---
scheduleRefresh();
}
});