Add missing-file detection with click-to-replace

Rows pointing at a lora that's no longer on disk (moved, renamed, or
deleted) now render red with a warning marker instead of only logging
to the console at generation time. The missing row's name is
click-to-replace: clicking it reopens the folder browser to swap in a
working file in place.

Detection reuses the existing /tree endpoint - its full paths are
flattened into a set of valid loras, and a row is flagged when its
lora isn't in that set. Fails safe (flags nothing until the list is
known / if the fetch fails) and tolerates path-separator differences
across OSes.

Documents the feature in the README with a screenshot and promotes it
out of the roadmap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Eric Wilson
2026-07-09 13:58:08 -04:00
co-authored by Claude Opus 4.8
parent 02534c279e
commit 51899bb266
3 changed files with 92 additions and 4 deletions
+5 -1
View File
@@ -20,6 +20,7 @@ The node in its default state, right after adding it to the canvas:
- **Native quick-edit dialogs** — click any strength or priority number to get ComfyUI's own auto-selected value-entry dialog (matches the interaction rgthree's nodes use)
- **Toggle-all / delete-all** — bulk controls in the header row, column-aligned with the per-row toggle and trash icons
- **Auto-growing node** — the node resizes itself to fit your lora list; shrink it manually below that and the list scrolls internally instead of spilling out
- **Missing-file detection** — if a row points at a lora that's no longer on disk (moved, renamed, or deleted since the workflow was saved), the row is flagged in red with a ⚠ marker, and its name becomes click-to-replace so you can swap in a working file without rebuilding the row
A populated stack — drag handle, priority number, toggle, name, strength stepper, and trash icon, all column-aligned with the header row's toggle-all and delete-all controls:
@@ -33,6 +34,10 @@ Connect `clip` and every row gains an independent second stepper — the header
![Clip connected, showing the header split into Model and Clip columns with a second stepper per row](docs/screenshot-clip-optional.png)
If a row's lora file goes missing — moved, renamed, or deleted since the workflow was saved — that row is flagged in red with a ⚠ marker instead of silently failing at generation time. Click the highlighted name to reopen the folder browser and pick a replacement in place:
![A lora stack with one row flagged red because its lora file is missing from disk](docs/screenshot-missing-file.png)
## Install
**Via git clone (recommended):**
@@ -95,7 +100,6 @@ Planned features, roughly in the order they're likely to land:
**Self-contained (no external services needed):**
- Search/filter box in the Add Lora popup — type to filter instead of scrolling through folders
- Duplicate detection — block re-adding a lora that's already in the stack, with a clear message, since there's no real use case for having the same file twice
- Missing-file detection — if a saved workflow references a lora that's been moved or deleted, highlight that row (rather than only printing to the console), and offer a way to search for it
**Trigger words, suggested strength, and CivitAI metadata (one combined feature):**
- A clickable info icon after the lora name, showing trigger words and CivitAI metadata for that lora
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+87 -3
View File
@@ -43,6 +43,21 @@ if (!document.getElementById(STYLE_ID)) {
border-color: #6d5aa8;
}
/* Missing on disk - wins over enabled-highlight's background/border so a
broken row reads as broken whether it's toggled on or off. */
.fll-row.missing,
.fll-row.missing.enabled-highlight {
background: #3a2323;
border-color: #c0392b;
}
.fll-name.fll-name-missing {
color: #ff8a80;
cursor: pointer;
}
.fll-name.fll-name-missing:hover {
text-decoration: underline;
}
/* --- Toggle switch (replaces checkbox) --- */
.fll-toggle-switch {
position: relative;
@@ -397,6 +412,46 @@ let _treeCache = null;
let _treeCacheTime = 0;
const TREE_CACHE_MS = 15000; // refresh at most every 15s so new files show up reasonably fast
// Flat set of every valid lora path the backend reports (the exact strings
// get_filename_list("loras") returns, which is what an entry's `lora` field
// stores). Stays null until the first successful tree fetch so we never flag
// rows as "missing" before we actually know what's on disk.
let validLoraPaths = null;
function _normLora(p) {
// Normalize separators so a workflow saved on one OS doesn't read as
// "missing" on another; the flag is purely cosmetic so being lenient here
// only ever avoids false positives.
return String(p).replace(/\\/g, "/");
}
// Walk the nested tree collecting every value in every __full_paths__ map -
// those values are the full relative paths, i.e. the source of truth for
// what's installed. Rebuilt on each tree fetch so added/removed files show up.
function _rebuildValidLoraPaths(tree) {
const set = new Set();
(function walk(node) {
if (!node || typeof node !== "object") return;
const fp = node.__full_paths__;
if (fp) {
for (const k in fp) set.add(_normLora(fp[k]));
}
for (const key in node) {
if (key === "__files__" || key === "__full_paths__") continue;
walk(node[key]);
}
})(tree);
validLoraPaths = set;
}
// True only when we have a loaded list AND this lora isn't in it. Unknown
// (list not loaded yet / fetch failed) -> false, so we fail safe toward
// "not missing" rather than lighting up every row red on a transient hiccup.
function isLoraMissing(loraName) {
if (!validLoraPaths || !loraName) return false;
return !validLoraPaths.has(_normLora(loraName));
}
async function fetchLoraTree(force = false) {
const now = Date.now();
if (!force && _treeCache && now - _treeCacheTime < TREE_CACHE_MS) {
@@ -407,6 +462,7 @@ async function fetchLoraTree(force = false) {
const data = await res.json();
_treeCache = data;
_treeCacheTime = now;
_rebuildValidLoraPaths(data);
return data;
} catch (e) {
console.error("[UltimateLoraLoader] Failed to fetch lora tree", e);
@@ -571,7 +627,7 @@ function openLoraBrowser(x, y, onSelect) {
// Node widget
// ---------------------------------------------------------------------------
function makeRow(entry, { onChange, onRemove, showClipStrength, onDragStart, onDragOver, onDrop, onDragEnd, onPriorityChange, currentPriority }) {
function makeRow(entry, { onChange, onRemove, onReplace, showClipStrength, onDragStart, onDragOver, onDrop, onDragEnd, onPriorityChange, currentPriority }) {
const row = document.createElement("div");
row.className = rowClassName(entry);
// Row itself isn't draggable - only the dedicated drag handle icon
@@ -660,8 +716,23 @@ function makeRow(entry, { onChange, onRemove, showClipStrength, onDragStart, onD
const name = document.createElement("div");
name.className = "fll-name";
const shortName = entry.lora.split("/").pop();
name.textContent = shortName;
name.title = entry.lora;
const missing = isLoraMissing(entry.lora);
name.textContent = missing ? "⚠ " + shortName : shortName;
if (missing) {
name.classList.add("fll-name-missing");
name.title = `File not found on disk:\n${entry.lora}\n\nClick to choose a replacement.`;
// Click-to-replace: reopen the same folder browser and swap this row's
// lora in place. Cheap stand-in for the roadmap's "click-to-search"
// until a real search box exists.
name.onclick = (e) => {
e.stopPropagation();
if (!onReplace) return;
const rect = name.getBoundingClientRect();
openLoraBrowser(rect.left, rect.bottom + 4, (loraPath) => onReplace(loraPath));
};
} else {
name.title = entry.lora;
}
const STRENGTH_STEP = 0.05;
@@ -855,6 +926,7 @@ function rowClassName(entry) {
let cls = "fll-row";
if (!entry.enabled) cls += " disabled";
if (entry.enabled) cls += " enabled-highlight";
if (isLoraMissing(entry.lora)) cls += " missing";
return cls;
}
@@ -1074,6 +1146,11 @@ app.registerExtension({
render();
resizeNode();
},
onReplace: (loraPath) => {
entry.lora = loraPath;
persist();
render();
},
showClipStrength,
currentPriority: idx + 1,
onPriorityChange: (rawPriority) => {
@@ -1373,6 +1450,13 @@ app.registerExtension({
render();
syncWidth();
// Fetch the on-disk lora list so missing-file highlighting can light up
// once we know what actually exists. The paint above happens first and
// flags nothing (validLoraPaths is null until this resolves); this
// re-render applies red highlighting to any rows pointing at files that
// are no longer present.
fetchLoraTree().then(() => render());
// restore size sanity on load (also re-syncs width once ComfyUI has
// finished laying out the node on the canvas, in case initial
// node.size wasn't final yet at widget-creation time). Also retry