Files

701 lines
28 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; justify-content: space-between; align-items: center;
padding: 8px 14px;
border-top: 1px solid var(--border-color, #4e4e4e);
flex-shrink: 0;
}
.ell-status-text { font-size: 12px; color: var(--input-text, #aaa); }
.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;
}
.ell-select-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.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);
}
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";
// --- 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;
_labelTimers.set(widget, setTimeout(() => { widget.label = origLabel; }, 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;
// Shared keydown handler for Escape key
let onKeyDown = null;
// D3 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 row = document.createElement("div");
row.className = "ell-row" + (data._selected ? " ell-row-selected" : "");
row.style.paddingLeft = (depth * 16 + 4) + "px";
// 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) => 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"; }
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 }),
});
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(name => ({
name, type: "dir", drive: data.drive,
segments: [...data.segments, name],
_loaded: false, children: null, _children: null,
_id: _nextId(), _selected: false
})),
...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 = "";
} 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 })
});
if (!resp.ok) throw new Error(resp.statusText);
const json = await resp.json();
cur._loaded = true;
cur.children = [
...(json.dirs || []).map(name => ({
name, type: "dir", drive: cur.drive,
segments: [...cur.segments, name],
_loaded: false, children: null, _children: null,
_id: _nextId(), _selected: false
})),
...(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";
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(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"; }
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, type: "drive", drive: d, segments: [],
_loaded: false, children: null, _children: null,
_id: _nextId(), _selected: false
}))
};
if (statusEl) statusEl.textContent = "";
renderTree();
const savedDrive = driveWidget.value || "";
const savedPath = subPathWidget.value || "";
const savedFile = loraNameWidget.value !== "none" ? loraNameWidget.value : null;
if (savedDrive) {
const segs = savedPath ? savedPath.split(/[/\\]/).filter(Boolean) : [];
await autoExpandToPath(savedDrive, segs, savedFile);
}
} 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;
// 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);
} catch (err) {
console.error("[ExternalLoraLoader] Clear cache failed:", err);
showTempLabel(clearCacheBtn, "Cache clear failed", 2000);
}
});
// --- Widget order splice (after all three addWidget calls) ---
// Final canvas order: drive(hidden), sub_path(hidden), selected_path, Browse…, lora_name, Clear Cache
{
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();
}
});