Files
Azornes-Comfyui-Model-Resolver/web/resolver/views/missing_browser_methods.js
T
Dariusz L 9f9b3da12c Use getMissingModelDomKey for search UI element IDs
Replace inline node_id/widget_index DOM key construction with the
centralized getMissingModelDomKey helper across search panel and
missing browser methods. This keeps search results and button IDs
stable when the same model appears in multiple workflows/widgets.

Also adds a regression test verifying that search progress stays
with its model when switching between shared widgets.
2026-09-10 14:30:33 +02:00

2807 lines
135 KiB
JavaScript

import { getSvgIcon } from "../../utils/icon_utils.js";
import { createFloatingTreePicker } from "../utils/tree_picker.js";
import { startSplitterDrag } from "../utils/splitter_drag.js";
import { getCivitaiModelUrl } from "../globals.js";
import { safeStorage, normalizePathIdentity } from "../utils/html_utils.js";
import { matchesWorkflowModelReference } from "../node_context_menu.js";
import { classifyLocalMatches } from "../utils/local_match_utils.js";
import { joinPathPreservingStyle } from "../utils/path_utils.js";
import { collectNormalizedCategoryValues } from "../utils/category_utils.js";
const MISSING_ROW_FALLBACK_HEIGHT = 70;
const MISSING_VIRTUAL_INITIAL_ROWS = 24;
const MISSING_VIRTUAL_OVERSCAN_ROWS = 6;
export const missingBrowserMethods = {
getMissingFilename(missing = {}) {
return this.getFilenameFromPath(missing.original_path) || missing.name || 'Missing model';
},
renderMissingModelFormatBadges(missing = {}) {
const acceptedTypes = this.getMissingAcceptedModelFileTypes?.(missing) || [];
return acceptedTypes.map(type => {
const label = String(type?.label || type?.extension || '').trim();
if (!label) return '';
const display = String(type?.display || label).trim();
const tooltip = `Accepted file format for this node: ${display}.`;
return `<span class="mr-category-chip mr-model-format-chip" data-tooltip="${this.escapeHtml(tooltip)}">${this.escapeHtml(label)}</span>`;
}).join('');
},
getMissingLocateTarget(missing = {}) {
const hasLocateNode = missing.locate_node_id !== undefined
&& missing.locate_node_id !== null
&& missing.locate_node_id !== '';
const nodeId = hasLocateNode ? missing.locate_node_id : missing.node_id;
return {
nodeId: nodeId ?? '',
nodeType: hasLocateNode ? (missing.locate_node_type || missing.node_type || '') : (missing.node_type || ''),
nodeTitle: hasLocateNode ? (missing.locate_node_title || '') : (missing.node_title || ''),
subgraphId: hasLocateNode ? (missing.locate_subgraph_id || '') : (missing.subgraph_id || ''),
subgraphName: hasLocateNode ? (missing.locate_subgraph_name || missing.subgraph_name || '') : (missing.subgraph_name || ''),
isTopLevel: hasLocateNode ? missing.locate_is_top_level !== false : missing.is_top_level !== false
};
},
getMissingReferenceCount(missing = {}) {
if (Array.isArray(missing.all_node_refs) && missing.all_node_refs.length > 0) {
return missing.all_node_refs.length;
}
const count = Number(missing.reference_count || 0);
return Number.isFinite(count) && count > 0 ? count : 1;
},
getMissingNodeDisplay(missing = {}) {
const locateTarget = this.getMissingLocateTarget(missing);
const isSubgraphNode = locateTarget.nodeType && locateTarget.nodeType.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
let nodeLabel;
if (locateTarget.subgraphName) {
nodeLabel = locateTarget.subgraphName;
} else if (isSubgraphNode) {
nodeLabel = 'Subgraph';
} else {
nodeLabel = locateTarget.nodeType || 'Node';
}
const nodeId = locateTarget.nodeId ?? '';
const promotedDetail = missing.locate_via_promoted_widget
? (missing.node_title || missing.node_type || '')
: (missing.promoted_inner_node_title || missing.promoted_inner_node_type || '');
const customNodeTitle = String(promotedDetail || locateTarget.nodeTitle || missing.node_title || '').trim();
const hasCustomNodeTitle = customNodeTitle && customNodeTitle !== nodeLabel;
const baseText = hasCustomNodeTitle
? `${nodeLabel} #${nodeId} · ${customNodeTitle}`
: `${nodeLabel} #${nodeId}`;
const referenceCount = this.getMissingReferenceCount(missing);
const extraReferenceCount = Math.max(0, referenceCount - 1);
const text = extraReferenceCount > 0
? `${baseText} + ${extraReferenceCount} ref${extraReferenceCount === 1 ? '' : 's'}`
: baseText;
const locateTooltip = missing.locate_via_promoted_widget
? 'Center this subgraph node in the ComfyUI graph.'
: locateTarget.isTopLevel === false
? 'Open this subgraph and center the node in the ComfyUI graph.'
: 'Center this node in the ComfyUI graph.';
return {
label: nodeLabel,
text,
canLocate: nodeId !== '',
locateTarget,
referenceCount,
locateTooltip: extraReferenceCount > 0
? `${locateTooltip} This missing model is used by ${referenceCount} workflow references; this centers the first one.`
: locateTooltip
};
},
getMissingSupportedFolderCategories(missing = {}) {
const normalizeCategory = value => (
this.normalizeDownloadCategory?.(value) || String(value || '').trim()
);
const options = { excludedCategories: ['unknown'] };
const supported = collectNormalizedCategoryValues(
[this.getMissingSupportedDownloadCategories?.(missing) || []],
normalizeCategory,
options,
);
if (supported.length) return supported;
return collectNormalizedCategoryValues([
missing.category_hints,
missing.categoryHints,
missing.model_widget_category_hints,
missing.modelWidgetCategoryHints,
missing.supported_categories,
missing.supportedCategories,
missing.category,
], normalizeCategory, options);
},
getMissingSupportedFolderKeys(missing = {}) {
const keys = [];
const addKey = (value) => {
if (value === undefined || value === null || String(value).trim() === '') return;
if (Array.isArray(value)) {
value.forEach(addKey);
return;
}
String(value).split(/[,|;]/).forEach(part => {
const key = String(part || '').trim();
if (key && key !== 'unknown' && !keys.includes(key)) {
keys.push(key);
}
});
};
addKey(missing.folder_key_hints);
addKey(missing.folderKeyHints);
addKey(missing.model_widget_folder_key_hints);
addKey(missing.modelWidgetFolderKeyHints);
if (keys.length) {
const nodeCategory = this.getMissingNodeTypeDownloadCategory?.(missing) || '';
if (nodeCategory) {
const filteredKeys = keys.filter(key => (
(this.normalizeDownloadCategory?.(key) || key) === nodeCategory
));
if (filteredKeys.length) return filteredKeys;
}
return keys;
}
return this.getMissingSupportedFolderCategories(missing);
},
normalizeMissingFolderRootPath(folderKey = '', path = '') {
const cleanPath = String(path || '').trim();
if (!cleanPath) return '';
const normalizedCategory = this.normalizeDownloadCategory?.(folderKey) || folderKey;
const rawKey = String(folderKey || '').trim();
if (!normalizedCategory || normalizedCategory === rawKey) {
return cleanPath;
}
const usesBackslash = cleanPath.includes('\\');
const normalizedPath = this.normalizePathToForward(cleanPath).replace(/\/+$/g, '');
const parts = normalizedPath.split('/').filter(Boolean);
const leaf = (parts[parts.length - 1] || '').toLowerCase();
const parent = (parts[parts.length - 2] || '').toLowerCase();
const canonicalLeaf = String(normalizedCategory || '').toLowerCase();
const rawSuffix = rawKey.toLowerCase().startsWith(`${canonicalLeaf}_`)
? rawKey.slice(canonicalLeaf.length + 1).toLowerCase()
: '';
if (rawSuffix && leaf === rawSuffix && parent === canonicalLeaf) {
const parentPath = parts.slice(0, -1).join('/');
return usesBackslash ? this.normalizePathToBackward(parentPath) : parentPath;
}
return cleanPath;
},
getMissingSupportedFolderDetails(missing = {}) {
const folderKeys = this.getMissingSupportedFolderKeys(missing);
const seenPaths = new Set();
const details = [];
// Uses imported normalizePathIdentity from html_utils.js
folderKeys.forEach(folderKey => {
const normalizedCategory = this.normalizeDownloadCategory?.(folderKey) || folderKey;
const label = this.getCategoryDisplayName?.(normalizedCategory) || folderKey;
const roots = this.downloadRootDirectories?.[folderKey]
|| this.downloadRootDirectories?.[normalizedCategory];
const paths = Array.isArray(roots)
? roots
: (roots ? [roots] : []);
if (!paths.length) {
details.push({
category: normalizedCategory,
folderKey,
label,
path: '',
display: label && label !== folderKey ? `${label} (${folderKey})` : folderKey
});
return;
}
paths.forEach(path => {
const cleanPath = this.normalizeMissingFolderRootPath(folderKey, path);
if (!cleanPath) return;
const identity = `${normalizedCategory}::${normalizePathIdentity(cleanPath)}`;
if (seenPaths.has(identity)) return;
seenPaths.add(identity);
details.push({
category: normalizedCategory,
folderKey,
label,
path: cleanPath,
display: `${label}: ${cleanPath}`
});
});
});
return details;
},
renderMissingNodeFolderBadge(missing = {}) {
const details = this.getMissingSupportedFolderDetails(missing);
if (!details.length) return '';
const folderCount = details.length;
const tooltip = folderCount === 1
? `This node reads models from 1 folder:\n${details[0].display}`
: `This node reads models from ${folderCount} folders:\n${details.map(detail => detail.display).join('\n')}`;
const ariaLabel = folderCount === 1
? `This node reads models from 1 folder`
: `This node reads models from ${folderCount} folders`;
return `<span class="mr-node-folder-count-badge" data-tooltip="${this.escapeHtml(tooltip)}" tabindex="0" aria-label="${this.escapeHtml(ariaLabel)}">${this.escapeHtml(String(folderCount))}</span>`;
},
renderMissingAutoDownloadBadge(missing = {}, options = {}) {
if (!missing?.auto_download_capable && !missing?.auto_download_candidate) return '';
const source = String(missing.input_choice_source || '').toLowerCase();
const sourceLabel = source === 'hybrid'
? 'hybrid preset/folder list'
: source === 'workflow_schema'
? 'workflow model widget'
: 'preset list';
const isCandidate = Boolean(missing.auto_download_candidate);
const tooltip = isCandidate
? `This value comes from the custom node ${sourceLabel}, but the file was not found in local model folders.\nThe node may download it automatically when the workflow runs.`
: `This value comes from a custom node ${sourceLabel} that can download selected models automatically.\nThe file is already available locally.`;
const compact = Boolean(options.compact);
const content = compact
? getSvgIcon('cloudDownload', 'currentColor', 'mr-auto-download-badge-icon')
: 'Auto download';
const classes = compact
? 'mr-auto-download-badge is-compact'
: 'mr-auto-download-badge';
return `<span class="${classes}" data-tooltip="${this.escapeHtml(tooltip)}" tabindex="0" aria-label="Auto download capable">${content}</span>`;
},
renderMissingSourcesSummary(missing = {}) {
const sourceItems = this.getEnabledSearchSources().map(source => ({ source }));
return sourceItems.map(item => {
const status = this.getMissingSourceStatus(missing, item.source);
const sourceProgress = this.searchResultCache
?.get(this.getMissingSearchKey(missing))
?.sourceProgress?.[item.source];
const statusClass = String(status || 'idle').replace(/[^a-z0-9_-]/gi, '');
const label = this.getSearchSourceLabel(item.source);
const statusLabels = {
pending: 'Queued',
running: 'Searching',
exact: 'Exact match',
partial: 'Partial match',
found: 'Found',
none: 'No match',
unavailable: 'Temporarily unavailable',
rate_limited: 'Rate limited',
not_found: 'Provider page not found',
error: 'Error',
idle: 'Not searched'
};
const statusMessage = sourceProgress?.status === 'error' && (sourceProgress.providerMessage || sourceProgress.message)
? (sourceProgress.providerMessage || sourceProgress.message)
: (statusLabels[status] || status);
const title = `${label}: ${statusMessage}`;
const iconName = this.getSearchSourceIconName(item.source);
const iconHtml = getSvgIcon(iconName, 'currentColor', 'mr-missing-source-icon');
return `<span class="mr-missing-source-dot mr-missing-source-${statusClass}" data-tooltip="${this.escapeHtml(title)}" aria-label="${this.escapeHtml(title)}">${iconHtml}</span>`;
}).join('');
},
getMissingModelsListLayout(missingModels = []) {
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
let modelPx = 180;
let typePx = 66;
for (const missing of missingModels) {
const filename = this.getMissingFilename(missing);
const nodeLabel = this.getMissingNodeDisplay(missing).text;
const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown';
modelPx = Math.max(
modelPx,
this.estimateTextWidth(filename, 7.2, 0, 1000) + 12,
this.estimateTextWidth(nodeLabel, 5.5, 0, 1000) + 34
);
typePx = Math.max(typePx, this.estimateTextWidth(typeLabel, 5.8, 0, 1000) + 18);
}
return {
modelPx: clamp(modelPx, 180, 820),
typePx: clamp(typePx, 66, 120)
};
},
getResolvedWorkflowModels(data = {}) {
return (data.resolved_models || []).map(model => this.normalizeResolvedWorkflowModel(model));
},
normalizeResolvedWorkflowModel(model = {}) {
const originalPath = model.original_path || model.name || model.filename || '';
const filename = this.getFilenameFromPath(originalPath) || originalPath || 'Resolved model';
const fullPath = model.full_path || model.path || '';
const relativePath = model.relative_path || originalPath || filename;
const category = model.category || 'unknown';
const resolvedModel = {
path: fullPath,
relative_path: relativePath,
filename,
category,
resolved_path: fullPath
};
const fallbackMatch = {
confidence: 100,
match_type: 'exact',
filename,
path: fullPath,
model: resolvedModel
};
const backendMatches = Array.isArray(model.matches) ? model.matches : [];
const hasResolvedMatch = backendMatches.some(match => {
const matchPath = match.model?.path || match.model?.resolved_path || match.path || '';
const matchRelativePath = match.model?.relative_path || '';
return (fullPath && matchPath === fullPath)
|| (relativePath && matchRelativePath === relativePath);
});
return {
...model,
__isExistingResolved: true,
name: model.name || filename,
original_path: originalPath || filename,
category,
matches: backendMatches.length
? (hasResolvedMatch ? backendMatches : [fallbackMatch, ...backendMatches])
: [fallbackMatch]
};
},
queueWorkflowModelReferenceSelection(reference = {}) {
if (this.pendingWorkflowModelSelection?.status === 'pending') {
this.pendingWorkflowModelSelection.status = 'superseded';
}
const request = {
id: `workflow-model-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
reference: { ...reference },
status: 'pending',
selected: null
};
this.pendingWorkflowModelSelection = request;
return request;
},
applyPendingWorkflowModelSelection(data = this.cachedAnalysisData || {}) {
const request = this.pendingWorkflowModelSelection;
if (!request || request.status !== 'pending') return null;
const selected = this.selectWorkflowModelReference(request.reference, data);
if (!selected) return null;
request.status = 'selected';
request.selected = selected;
if (this.pendingWorkflowModelSelection === request) {
this.pendingWorkflowModelSelection = null;
}
return selected;
},
selectWorkflowModelReference(
reference = {},
data = this.cachedAnalysisData || {},
options = {}
) {
const selected = this.getResolvedWorkflowModels(data)
.find(model => matchesWorkflowModelReference(model, reference));
if (!selected || !this.contentElement) return null;
const selectedKey = this.getMissingModelKey(selected);
const selectionChanged = selectedKey !== this.selectedMissingModelKey;
const existingRow = Array.from(
this.contentElement.querySelectorAll?.('.mr-missing-list-row') || []
).find(item => item.dataset.missingKey === selectedKey);
const reuseExistingBrowser = Boolean(
options.preferExistingBrowser && existingRow
);
this.selectedMissingModelKey = selectedKey;
if (reuseExistingBrowser) {
if (selectionChanged) {
this.displayMissingModels(
this.contentElement,
data,
{ selectionOnly: true }
);
}
} else {
this.showResolvedModels = true;
this.missingModelsTypeFilter = 'all';
this.missingModelsTypeFilterMenuOpen = false;
safeStorage.setItem(this.showResolvedModelsStorageKey, '1');
this.displayMissingModels(this.contentElement, data);
}
requestAnimationFrame(() => {
let row = Array.from(
this.contentElement?.querySelectorAll?.('.mr-missing-list-row') || []
).find(item => item.dataset.missingKey === this.selectedMissingModelKey);
if (!row) {
row = this.scrollMissingModelIntoView(this.selectedMissingModelKey);
}
row?.scrollIntoView?.({ block: 'center', inline: 'nearest' });
});
return selected;
},
refreshMissingModelsBrowserFromCache() {
if (this.activeTab !== 'missing' || !this.contentElement || !this.cachedAnalysisData) return;
this.displayMissingModels(
this.contentElement,
this.cachedAnalysisData,
{ preserveBrowser: true }
);
},
getMissingBrowserDetailWidthForRerender(browser = null) {
if (!(browser instanceof HTMLElement)) return null;
const detailPane = browser.querySelector('.mr-missing-detail-pane');
if (!(detailPane instanceof HTMLElement)) return null;
const explicitWidth = this.getMissingBrowserDetailTrackWidth(detailPane);
if (Number.isFinite(explicitWidth) && explicitWidth > 0) return explicitWidth;
const browserTrack = String(
browser.style.getPropertyValue('--mr-missing-detail-track') || ''
).trim();
if (browserTrack.endsWith('px')) {
const browserWidth = parseFloat(browserTrack);
if (Number.isFinite(browserWidth) && browserWidth > 0) return browserWidth;
}
const rect = detailPane.getBoundingClientRect?.();
const rectWidth = Number(rect?.width);
if (Number.isFinite(rectWidth) && rectWidth > 0) return rectWidth;
const clientWidth = Number(detailPane.clientWidth || detailPane.offsetWidth);
return Number.isFinite(clientWidth) && clientWidth > 0 ? clientWidth : null;
},
getMissingListScrollSnapshot(container) {
const list = container?.querySelector?.('.mr-missing-list');
if (!(list instanceof HTMLElement)) return null;
return {
top: list.scrollTop,
left: list.scrollLeft
};
},
restoreMissingListScroll(container, snapshot = null) {
if (!snapshot) return;
const list = container?.querySelector?.('.mr-missing-list');
if (!(list instanceof HTMLElement)) return;
const applyScroll = () => {
if (!list.isConnected) return;
const maxTop = Math.max(0, list.scrollHeight - list.clientHeight);
const maxLeft = Math.max(0, list.scrollWidth - list.clientWidth);
list.scrollTop = Math.min(Number(snapshot.top) || 0, maxTop);
list.scrollLeft = Math.min(Number(snapshot.left) || 0, maxLeft);
};
applyScroll();
requestAnimationFrame(applyScroll);
},
refreshMissingModelsVirtualizer(container, force = false) {
const list = container?.querySelector?.('.mr-missing-list');
list?._mrMissingVirtualState?.update?.(force);
},
destroyMissingModelsVirtualizer(container) {
const list = container?.matches?.('.mr-missing-list')
? container
: container?.querySelector?.('.mr-missing-list');
const state = list?._mrMissingVirtualState;
if (!state) return false;
if (state.onScroll) {
list.removeEventListener('scroll', state.onScroll);
}
state.resizeObserver?.disconnect?.();
if (state.animationFrame) {
cancelAnimationFrame(state.animationFrame);
state.animationFrame = null;
}
state.destroyed = true;
list._mrMissingVirtualState = null;
return true;
},
getMissingBrowserVirtualRowHeight(list = null) {
const target = list instanceof HTMLElement
? list
: this.contentElement?.querySelector?.('.mr-missing-list');
if (target instanceof HTMLElement && typeof getComputedStyle === 'function') {
const rawHeight = getComputedStyle(target)
.getPropertyValue('--mr-missing-row-height')
.trim();
const parsedHeight = Number.parseFloat(rawHeight);
if (Number.isFinite(parsedHeight) && parsedHeight > 0) return parsedHeight;
}
return MISSING_ROW_FALLBACK_HEIGHT;
},
getMissingModelRowSlotKeys(missing = {}) {
const knownKeys = this.getMissingModelWorkflowSlotKeys?.(missing);
if (Array.isArray(knownKeys) && knownKeys.length) return knownKeys;
const references = [
missing,
...(Array.isArray(missing.all_node_refs) ? missing.all_node_refs : [])
];
return references
.filter(reference => reference?.node_id !== undefined && reference?.node_id !== null)
.map(reference => [
reference.is_top_level !== false ? 'T' : 'F',
reference.subgraph_id || '',
reference.node_id,
reference.widget_index ?? '',
reference.nested_key || ''
].map(value => encodeURIComponent(String(value))).join(':'));
},
renderMissingModelListRow(missing, index, totalCount, selectedKey) {
const key = this.getMissingModelKey(missing);
const slotKeys = this.getMissingModelRowSlotKeys(missing);
const isSelected = key === selectedKey;
const isBatchSelected = this.batchSelectedMissingKeys?.has(key);
const isResolved = this.isMissingModelResolved(missing);
const filename = this.getMissingFilename(missing);
const { bestMatch, confidence, matchDisplay, matchClass } = this.getLocalMatchDisplayInfo(missing);
const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown';
const typeColorClass = this.getModelTypeColorClass(missing.category || typeLabel);
const nodeDisplay = this.getMissingNodeDisplay(missing);
const locateTarget = nodeDisplay.locateTarget || this.getMissingLocateTarget(missing);
const nodeId = locateTarget.nodeId ?? '';
const rowNodeHtml = nodeDisplay.canLocate
? `<button type="button" class="mr-node-chip is-locatable mr-missing-row-node mr-missing-row-locate" data-node-id="${this.escapeHtml(String(nodeId))}" data-subgraph-id="${this.escapeHtml(String(locateTarget.subgraphId || ''))}" data-is-top-level="${locateTarget.isTopLevel ? 'true' : 'false'}" data-tooltip="${this.escapeHtml(nodeDisplay.locateTooltip)}" aria-label="Center ${this.escapeHtml(nodeDisplay.text)} in the ComfyUI graph">${this.getLocateIconHtml()}<span class="mr-missing-row-node-label">${this.escapeHtml(nodeDisplay.text)}</span></button>`
: `<span class="mr-missing-row-node">${this.escapeHtml(nodeDisplay.text)}</span>`;
return `
<div role="button" tabindex="0"
class="mr-missing-list-row ${isSelected ? 'is-selected' : ''} ${isBatchSelected ? 'is-batch-selected' : ''} ${isResolved ? 'is-resolved' : ''}"
data-missing-key="${this.escapeHtml(key)}"
data-missing-slot-keys="${this.escapeHtml(slotKeys.join('|'))}"
data-missing-index="${index}"
aria-posinset="${index + 1}"
aria-setsize="${totalCount}">
<span class="mr-missing-row-select">
<input type="checkbox" class="mr-missing-row-check" data-ml-no-drag="1" aria-label="Select ${this.escapeHtml(filename)}" ${isBatchSelected ? 'checked' : ''}>
</span>
<span class="mr-missing-row-index">${index + 1}</span>
<span class="mr-missing-row-model ${isResolved ? 'is-resolved' : ''}">
${isResolved ? `<span class="mr-missing-row-resolved-icon" data-tooltip="Model resolved">${getSvgIcon('circleCheckBig')}</span>` : ''}
<span class="mr-missing-row-model-details">
<span class="mr-missing-row-name" data-tooltip="${this.escapeHtml(filename)}">${this.escapeHtml(filename)}</span>
<span class="mr-missing-row-meta-line">
${rowNodeHtml}
${this.renderMissingAutoDownloadBadge(missing, { compact: true })}
</span>
</span>
</span>
<span class="mr-missing-row-type ${typeColorClass}">${this.escapeHtml(typeLabel)}</span>
<span class="mr-missing-row-best" data-tooltip="${this.escapeHtml(matchDisplay)}">
${bestMatch ? this.escapeHtml(matchDisplay) : '<span class="mr-missing-row-none">-- No local match</span>'}
</span>
<span class="mr-missing-row-match mr-missing-row-match-${matchClass}">
<strong>${bestMatch ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` : '--'}</strong>
</span>
<span class="mr-missing-row-sources">${this.renderMissingSourcesSummary(missing)}</span>
</div>
`;
},
setupMissingModelsVirtualizer(container, data, sortedMissingModels) {
const list = container?.querySelector?.('.mr-missing-list');
const virtualScroll = list?.querySelector?.('.mr-missing-list-virtual-scroll');
const rowsHost = virtualScroll?.querySelector?.('.mr-missing-list-virtual-rows');
if (!(list instanceof HTMLElement) || !(virtualScroll instanceof HTMLElement) || !(rowsHost instanceof HTMLElement)) {
return;
}
const existingState = list._mrMissingVirtualState;
if (existingState && existingState.rowsHost !== rowsHost) {
this.destroyMissingModelsVirtualizer(list);
}
const state = existingState && existingState.rowsHost === rowsHost
? existingState
: {
list,
virtualScroll,
rowsHost,
models: sortedMissingModels,
data,
rowHeight: this.getMissingBrowserVirtualRowHeight(list),
start: -1,
end: -1,
resizeObserver: null,
onScroll: null,
animationFrame: null,
destroyed: false,
update: null
};
state.models = sortedMissingModels;
state.data = data;
state.rowHeight = this.getMissingBrowserVirtualRowHeight(list);
state.virtualScroll.style.height = `${Math.max(0, sortedMissingModels.length * state.rowHeight)}px`;
state.update = (force = false) => {
if (state.destroyed || !list.isConnected || !rowsHost.isConnected) return;
const rowHeight = state.rowHeight || MISSING_ROW_FALLBACK_HEIGHT;
const header = list.querySelector('.mr-missing-list-head');
const headerHeight = Number(header?.offsetHeight) || 0;
const viewportHeight = Math.max(
rowHeight,
Number(list.clientHeight) || MISSING_VIRTUAL_INITIAL_ROWS * rowHeight
);
const viewportTop = Math.max(0, Number(list.scrollTop) - headerHeight);
const viewportBottom = Math.max(viewportTop, Number(list.scrollTop) + viewportHeight - headerHeight);
const nextStart = Math.max(
0,
Math.floor(viewportTop / rowHeight) - MISSING_VIRTUAL_OVERSCAN_ROWS
);
const nextEnd = Math.min(
state.models.length,
Math.max(
nextStart,
Math.ceil(viewportBottom / rowHeight) + MISSING_VIRTUAL_OVERSCAN_ROWS
)
);
if (!force && nextStart === state.start && nextEnd === state.end) return;
state.start = nextStart;
state.end = nextEnd;
rowsHost.style.transform = `translateY(${Math.round(nextStart * rowHeight)}px)`;
const nextRowsTemplate = document.createElement('template');
nextRowsTemplate.innerHTML = state.models
.slice(nextStart, nextEnd)
.map((missing, offset) => this.renderMissingModelListRow(
missing,
nextStart + offset,
state.models.length,
this.selectedMissingModelKey
))
.join('');
const nextRows = Array.from(
nextRowsTemplate.content.querySelectorAll('.mr-missing-list-row')
);
this.reconcileMissingModelRows(rowsHost, nextRows);
this.wireVisibleMissingModelRows(container, state.data, state.models);
};
if (!existingState) {
state.onScroll = () => state.update(false);
list.addEventListener('scroll', state.onScroll, { passive: true });
if (typeof ResizeObserver === 'function') {
state.resizeObserver = new ResizeObserver(() => state.update(false));
state.resizeObserver.observe(list);
}
list._mrMissingVirtualState = state;
}
state.update(true);
},
scrollMissingModelIntoView(missingKey) {
const list = this.contentElement?.querySelector?.('.mr-missing-list');
const state = list?._mrMissingVirtualState;
if (!(list instanceof HTMLElement) || !state) return null;
const index = state.models.findIndex(missing => this.getMissingModelKey(missing) === missingKey);
if (index < 0) return null;
const header = list.querySelector('.mr-missing-list-head');
const headerHeight = Number(header?.offsetHeight) || 0;
const rowHeight = state.rowHeight || MISSING_ROW_FALLBACK_HEIGHT;
const targetTop = headerHeight + index * rowHeight;
const targetScrollTop = targetTop - Math.max(0, (list.clientHeight - rowHeight) / 2);
list.scrollTop = Math.max(0, targetScrollTop);
state.update(true);
return Array.from(list.querySelectorAll('.mr-missing-list-row'))
.find(row => row.dataset.missingKey === missingKey) || null;
},
renderMissingModelsBrowser(missingModels, selectedKey, totalMissing, activeCount, hasAny100Match, options = {}) {
const hiddenResolvedCount = Number(options.hiddenResolvedCount || 0);
const hiddenAutoDownloadCount = Number(options.hiddenAutoDownloadCount || 0);
const hiddenInactiveCount = Number(options.hiddenInactiveCount || 0);
const autoDownloadCount = Number(options.autoDownloadCount || 0);
const inactiveCount = Number(options.inactiveCount || 0);
const resolvedCount = Number(options.resolvedCount || 0);
const rawMissingCount = Number(options.rawMissingCount ?? totalMissing);
const missingCount = Number(options.missingCount ?? rawMissingCount);
const typeFilterOptions = Array.isArray(options.typeFilterOptions) ? options.typeFilterOptions : [];
const activeTypeFilter = String(options.activeTypeFilter || 'all');
const activeTypeOption = typeFilterOptions.find(option => option.value === activeTypeFilter);
const activeTypeLabel = activeTypeOption?.label || 'All';
const typeFilterMenuOpen = Boolean(this.missingModelsTypeFilterMenuOpen);
const resolvedToggleCount = this.showResolvedModels ? resolvedCount : hiddenResolvedCount;
const autoDownloadToggleCount = this.showAutoDownloadModels ? autoDownloadCount : hiddenAutoDownloadCount;
const inactiveToggleCount = this.showInactiveModels ? inactiveCount : hiddenInactiveCount;
const stats = this.getMissingModelSummaryStats(missingModels);
const detailIndex = missingModels.findIndex(missing => this.getMissingModelKey(missing) === selectedKey);
const detailMissing = detailIndex >= 0 ? missingModels[detailIndex] : null;
const activeHint = activeCount > 0
? `${activeCount} downloading`
: hiddenResolvedCount > 0
? `${hiddenResolvedCount} resolved hidden`
: hiddenAutoDownloadCount > 0
? `${hiddenAutoDownloadCount} auto-download hidden`
: hiddenInactiveCount > 0
? `${hiddenInactiveCount} inactive hidden`
: (hasAny100Match ? 'Auto-link ready for exact matches' : 'Review matches or search online');
const listLayout = this.getMissingModelsListLayout(missingModels);
const requestedDetailWidth = Number(options.detailWidth);
const savedDetailWidth = Number.isFinite(requestedDetailWidth) && requestedDetailWidth > 0
? Math.round(requestedDetailWidth)
: this.getStoredMissingBrowserSplitWidth();
const splitStyle = Number.isFinite(savedDetailWidth) && savedDetailWidth > 0
? `--mr-missing-detail-track:${savedDetailWidth}px;`
: '';
const listStyle = `--mr-missing-model-col:${listLayout.modelPx}px;--mr-missing-type-col:${listLayout.typePx}px;`;
const browserStyle = `${listStyle}${splitStyle}`;
const typeFilterTotal = Number(typeFilterOptions.find(option => option.value === 'all')?.count || totalMissing);
const titleText = activeTypeFilter !== 'all'
? `${totalMissing} shown of ${typeFilterTotal}`
: this.showResolvedModels && resolvedCount > 0
? `${totalMissing} shown (${missingCount} missing / ${resolvedCount} resolved)`
: `${totalMissing} missing model${totalMissing === 1 ? '' : 's'}`;
const typeFilterMenuOptions = typeFilterOptions.map((option) => {
const isActive = option.value === activeTypeFilter;
const labelClass = option.colorClass
? `mr-missing-type-filter-option-label ${option.colorClass}`
: 'mr-missing-type-filter-option-label';
return `
<button type="button" class="mr-missing-type-filter-option ${isActive ? 'is-active' : ''}" data-missing-type-filter-option="${this.escapeHtml(option.value)}" role="menuitemradio" aria-checked="${isActive ? 'true' : 'false'}">
<span class="${this.escapeHtml(labelClass)}">${this.escapeHtml(option.label)}</span>
<strong>${Number(option.count || 0).toLocaleString()}</strong>
</button>
`;
}).join('');
let html = `
<div class="mr-missing-browser" style="${browserStyle}">
<section class="mr-missing-list-pane" aria-label="Missing model list">
<div class="mr-missing-list-toolbar">
<div>
<div class="mr-missing-list-title">${this.escapeHtml(titleText)}</div>
<div class="mr-missing-list-meta">${this.escapeHtml(activeHint)}</div>
</div>
<div class="mr-missing-list-tools">
<div class="mr-missing-list-stats">
<span class="mr-missing-stat mr-missing-stat-exact">${stats.exact} exact</span>
<span class="mr-missing-stat mr-missing-stat-partial">${stats.partial} partial</span>
<span class="mr-missing-stat mr-missing-stat-none">${stats.none} no match</span>
</div>
<label class="mr-missing-resolved-toggle" data-tooltip="Show resolved models, including models that were already available when the workflow loaded.">
<input id="mr-show-resolved-models" type="checkbox" ${this.showResolvedModels ? 'checked' : ''}>
<span>Show resolved</span>
${resolvedToggleCount > 0 ? `<em>${resolvedToggleCount}</em>` : ''}
</label>
<label class="mr-missing-resolved-toggle" data-tooltip="Show models selected from custom nodes that can download models automatically.">
<input id="mr-show-auto-download-models" type="checkbox" ${this.showAutoDownloadModels ? 'checked' : ''}>
<span>Show auto-download</span>
${autoDownloadToggleCount > 0 ? `<em>${autoDownloadToggleCount}</em>` : ''}
</label>
<label class="mr-missing-resolved-toggle" data-tooltip="Show unresolved models from bypassed, disabled, or disconnected workflow nodes.">
<input id="mr-show-inactive-models" type="checkbox" ${this.showInactiveModels ? 'checked' : ''}>
<span>Show inactive</span>
${inactiveToggleCount > 0 ? `<em>${inactiveToggleCount}</em>` : ''}
</label>
<button id="mr-refresh-missing-analysis" type="button" class="mr-btn mr-btn-secondary mr-btn-sm mr-missing-refresh-btn" data-tooltip="Re-analyze workflow and refresh local matches">
<span class="mr-refresh-spin-target">${getSvgIcon('refreshCw')}</span> Refresh
</button>
</div>
</div>
<div class="mr-missing-list">
<div class="mr-missing-list-head">
<span class="mr-missing-head-select">
<input type="checkbox" class="mr-missing-select-all-check" aria-label="Select or deselect all missing models">
</span>
<span>#</span>
<span>Missing Model</span>
<span class="mr-missing-type-filter-cell">
<span class="mr-missing-type-filter-wrap">
<button type="button" class="mr-missing-type-filter-button ${activeTypeFilter !== 'all' ? 'is-filtered' : ''}" data-missing-type-filter-toggle aria-haspopup="menu" aria-expanded="${typeFilterMenuOpen ? 'true' : 'false'}" title="Filter by type">
<span>Type</span>
<span class="mr-missing-type-filter-chip" ${activeTypeFilter !== 'all' ? '' : 'hidden'}>${this.escapeHtml(activeTypeLabel)}</span>
</button>
<span class="mr-missing-type-filter-menu" role="menu" ${typeFilterMenuOpen ? '' : 'hidden'}>
${typeFilterMenuOptions}
</span>
</span>
</span>
<span>Best Local Match</span>
<span>Match</span>
<span>Sources</span>
</div>
`;
const initialRowCount = Math.min(missingModels.length, MISSING_VIRTUAL_INITIAL_ROWS);
const initialRowsHtml = missingModels
.slice(0, initialRowCount)
.map((missing, index) => this.renderMissingModelListRow(
missing,
index,
missingModels.length,
selectedKey
))
.join('');
html += `
<div class="mr-missing-list-virtual-scroll" data-mr-missing-virtual-scroll style="height:${Math.max(0, missingModels.length * MISSING_ROW_FALLBACK_HEIGHT)}px">
<div class="mr-missing-list-virtual-rows" data-mr-missing-virtual-rows>
${initialRowsHtml}
</div>
</div>
`;
html += `
</div>
</section>
<div class="mr-missing-browser-splitter" role="separator" aria-orientation="vertical" aria-label="Resize missing model panes" tabindex="0"></div>
<section class="mr-missing-detail-pane" aria-label="Missing model details">
${detailMissing ? this.renderMissingModel(detailMissing, detailIndex) : this.renderStatusMessage('Select a missing model to inspect details.', 'info')}
</section>
</div>
`;
return html;
},
reconcileMissingModelRows(currentRowsHost, nextRows, appendTarget = currentRowsHost) {
const currentRows = new Map(
Array.from(currentRowsHost.querySelectorAll('.mr-missing-list-row'))
.map(row => [row.dataset.missingKey || '', row])
.filter(([key]) => key)
);
const currentRowsBySlot = new Map();
const currentRowsByIndex = new Map();
currentRows.forEach((row) => {
const index = row.dataset.missingIndex || '';
if (index && !currentRowsByIndex.has(index)) currentRowsByIndex.set(index, row);
String(row.dataset.missingSlotKeys || '')
.split('|')
.filter(Boolean)
.forEach(slotKey => {
if (!currentRowsBySlot.has(slotKey)) currentRowsBySlot.set(slotKey, row);
});
});
const retainedRows = new Set();
const findCurrentRow = (nextRow) => {
const byKey = currentRows.get(nextRow.dataset.missingKey || '');
if (byKey && !retainedRows.has(byKey)) return byKey;
const bySlot = String(nextRow.dataset.missingSlotKeys || '')
.split('|')
.map(slotKey => currentRowsBySlot.get(slotKey))
.find(row => row && !retainedRows.has(row));
if (bySlot) return bySlot;
const byIndex = currentRowsByIndex.get(nextRow.dataset.missingIndex || '');
return byIndex && !retainedRows.has(byIndex) ? byIndex : null;
};
nextRows.forEach((nextRow) => {
const currentRow = findCurrentRow(nextRow);
const row = currentRow
? this.patchMissingModelRowElement(currentRow, nextRow)
: nextRow;
retainedRows.add(row);
appendTarget.appendChild(row);
});
currentRows.forEach((row) => {
if (!retainedRows.has(row)) row.remove();
});
},
patchMissingModelsBrowserElement(container, html) {
const currentBrowser = container?.querySelector?.('.mr-missing-browser');
if (!currentBrowser || typeof document === 'undefined') return false;
const template = document.createElement('template');
template.innerHTML = String(html || '').trim();
const nextBrowser = template.content.firstElementChild;
const currentList = currentBrowser.querySelector('.mr-missing-list');
const nextList = nextBrowser?.querySelector?.('.mr-missing-list');
if (!nextBrowser || !currentList || !nextList) return false;
currentBrowser.style.cssText = nextBrowser.style.cssText;
const currentToolbar = currentBrowser.querySelector('.mr-missing-list-toolbar');
const nextToolbar = nextBrowser.querySelector('.mr-missing-list-toolbar');
if (currentToolbar && nextToolbar) {
currentToolbar.replaceWith(nextToolbar);
}
const currentHead = currentList.querySelector('.mr-missing-list-head');
const nextHead = nextList.querySelector('.mr-missing-list-head');
if (currentHead && nextHead) {
currentHead.replaceWith(nextHead);
}
const currentVirtualScroll = currentList.querySelector('.mr-missing-list-virtual-scroll');
const nextVirtualScroll = nextList.querySelector('.mr-missing-list-virtual-scroll');
if (currentVirtualScroll && nextVirtualScroll) {
currentVirtualScroll.style.height = nextVirtualScroll.style.height;
}
const currentRowsHost = currentVirtualScroll?.querySelector('.mr-missing-list-virtual-rows') || currentList;
const nextRowsHost = nextVirtualScroll?.querySelector('.mr-missing-list-virtual-rows') || nextList;
this.reconcileMissingModelRows(
currentRowsHost,
Array.from(nextRowsHost.querySelectorAll('.mr-missing-list-row')),
currentRowsHost === currentList ? currentList : currentRowsHost
);
const currentSplitter = currentBrowser.querySelector('.mr-missing-browser-splitter');
const nextSplitter = nextBrowser.querySelector('.mr-missing-browser-splitter');
if (currentSplitter && nextSplitter) {
currentSplitter.replaceWith(nextSplitter);
}
const currentDetail = currentBrowser.querySelector('.mr-missing-detail-pane');
const nextDetail = nextBrowser.querySelector('.mr-missing-detail-pane');
this._missingBrowserDetailPreserved = Boolean(
currentDetail
&& nextDetail
&& currentDetail.outerHTML === nextDetail.outerHTML
);
if (currentDetail && nextDetail && !this._missingBrowserDetailPreserved) {
currentDetail.innerHTML = nextDetail.innerHTML;
}
return true;
},
patchMissingModelRowElement(currentRow, nextRow) {
if (!currentRow || !nextRow || currentRow === nextRow) return currentRow || nextRow;
if (currentRow.outerHTML === nextRow.outerHTML) return currentRow;
const nextAttributeNames = new Set(
Array.from(nextRow.attributes || [], attribute => attribute.name)
);
for (const attribute of Array.from(nextRow.attributes || [])) {
if (currentRow.getAttribute(attribute.name) !== attribute.value) {
currentRow.setAttribute(attribute.name, attribute.value);
}
}
for (const attribute of Array.from(currentRow.attributes || [])) {
if (!nextAttributeNames.has(attribute.name)) {
currentRow.removeAttribute(attribute.name);
}
}
currentRow.innerHTML = nextRow.innerHTML;
this._wiredMissingModelRows?.delete?.(currentRow);
return currentRow;
},
wireVisibleMissingModelRows(container, data, sortedMissingModels) {
const getCurrentData = () => this.cachedAnalysisData || data;
const getCurrentMissingModels = () => (
Array.isArray(this.missingModels)
? this.missingModels
: sortedMissingModels
);
const selectRow = (row) => {
const key = row.dataset.missingKey;
if (!key || key === this.selectedMissingModelKey) return;
this.selectedMissingModelKey = key;
this.displayMissingModels(
container,
getCurrentData(),
{ selectionOnly: true }
);
};
container.querySelectorAll('.mr-missing-list-row').forEach((row) => {
if (this._wiredMissingModelRows.has(row)) return;
this._wiredMissingModelRows.add(row);
const checkbox = row.querySelector('.mr-missing-row-check');
if (checkbox) {
checkbox.addEventListener('click', (event) => {
event.stopPropagation();
checkbox._missingShiftClick = event.shiftKey;
});
checkbox.addEventListener('change', (event) => {
const key = row.dataset.missingKey;
if (!key) return;
const selected = checkbox.checked;
const isShiftRange = event.shiftKey || checkbox._missingShiftClick === true;
const currentMissingModels = getCurrentMissingModels();
if (isShiftRange && this.lastBatchSelectedMissingKey) {
this.applyBatchSelectionRange(
currentMissingModels,
this.lastBatchSelectedMissingKey,
key,
selected
);
} else {
this.setBatchSelectionForKey(key, selected);
}
this.lastBatchSelectedMissingKey = key;
this.refreshBatchSelectionUi();
this.updateBatchFooterButtons();
});
}
row.addEventListener('click', (event) => {
const clickedLocate = event.target instanceof Element && event.target.closest('.mr-missing-row-locate');
if (clickedLocate) return;
selectRow(row);
});
row.addEventListener('keydown', (event) => {
if (event.target !== row || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
selectRow(row);
});
});
container.querySelectorAll('.mr-missing-row-locate').forEach((button) => {
if (this._wiredMissingLocateButtons.has(button)) return;
this._wiredMissingLocateButtons.add(button);
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const rawNodeId = button.dataset.nodeId;
const numericNodeId = Number(rawNodeId);
this.locateNodeInGraph(Number.isNaN(numericNodeId) ? rawNodeId : numericNodeId, {
subgraphId: button.dataset.subgraphId || '',
isTopLevel: button.dataset.isTopLevel !== 'false'
});
});
});
},
bindMissingFilterToggle(toggle, { boundKey, storageKey, setValue, container, data }) {
if (!toggle || toggle.dataset[boundKey] === 'true') return;
toggle.dataset[boundKey] = 'true';
toggle.addEventListener('change', () => {
const value = Boolean(toggle.checked);
setValue(value);
try {
safeStorage.setItem(storageKey, value ? '1' : '0');
} catch (_e) {}
this.displayMissingModels(container, data, { preserveBrowser: true });
});
},
wireMissingModelsBrowser(container, data, sortedMissingModels) {
this.wireMissingBrowserSplitter(container);
const browser = container.querySelector('.mr-missing-browser');
if (!(this._wiredMissingModelRows instanceof WeakSet)) {
this._wiredMissingModelRows = new WeakSet();
}
if (!(this._wiredMissingLocateButtons instanceof WeakSet)) {
this._wiredMissingLocateButtons = new WeakSet();
}
if (!(this._wiredMissingBrowsers instanceof WeakSet)) {
this._wiredMissingBrowsers = new WeakSet();
}
const typeFilterToggle = browser?.querySelector('[data-missing-type-filter-toggle]');
const setTypeFilterMenuOpen = (open) => {
this.missingModelsTypeFilterMenuOpen = Boolean(open);
const activeMenu = browser?.querySelector('.mr-missing-type-filter-menu');
const activeToggle = browser?.querySelector('[data-missing-type-filter-toggle]');
if (activeMenu) {
activeMenu.hidden = !this.missingModelsTypeFilterMenuOpen;
}
if (activeToggle) {
activeToggle.setAttribute(
'aria-expanded',
this.missingModelsTypeFilterMenuOpen ? 'true' : 'false'
);
}
};
typeFilterToggle?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
setTypeFilterMenuOpen(!this.missingModelsTypeFilterMenuOpen);
});
browser?.querySelectorAll('[data-missing-type-filter-option]').forEach((option) => {
option.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
this.missingModelsTypeFilter = option.dataset.missingTypeFilterOption || 'all';
this.missingModelsTypeFilterMenuOpen = false;
this.displayMissingModels(container, data);
});
});
if (browser && !this._wiredMissingBrowsers.has(browser)) {
this._wiredMissingBrowsers.add(browser);
browser.addEventListener('click', (event) => {
if (!this.missingModelsTypeFilterMenuOpen) return;
if (
event.target instanceof Element
&& event.target.closest('.mr-missing-type-filter-wrap')
) {
return;
}
setTypeFilterMenuOpen(false);
});
browser.addEventListener('keydown', (event) => {
if (
event.key !== 'Escape'
|| !this.missingModelsTypeFilterMenuOpen
) {
return;
}
event.preventDefault();
setTypeFilterMenuOpen(false);
browser.querySelector('[data-missing-type-filter-toggle]')?.focus();
});
}
const refreshBtn = container.querySelector('#mr-refresh-missing-analysis');
if (refreshBtn && refreshBtn.dataset.mlRefreshBound !== 'true') {
refreshBtn.dataset.mlRefreshBound = 'true';
refreshBtn.addEventListener('click', () => this.refreshMissingAnalysis(refreshBtn));
}
const showResolvedToggle = container.querySelector('#mr-show-resolved-models');
this.bindMissingFilterToggle(showResolvedToggle, {
boundKey: 'mlResolvedBound',
storageKey: this.showResolvedModelsStorageKey,
setValue: value => { this.showResolvedModels = value; },
container,
data,
});
const showAutoDownloadToggle = container.querySelector('#mr-show-auto-download-models');
this.bindMissingFilterToggle(showAutoDownloadToggle, {
boundKey: 'mlAutoDownloadBound',
storageKey: this.showAutoDownloadModelsStorageKey,
setValue: value => { this.showAutoDownloadModels = value; },
container,
data,
});
const showInactiveToggle = container.querySelector('#mr-show-inactive-models');
this.bindMissingFilterToggle(showInactiveToggle, {
boundKey: 'mlInactiveBound',
storageKey: this.showInactiveModelsStorageKey,
setValue: value => { this.showInactiveModels = value; },
container,
data,
});
const getCurrentMissingModels = () => (
Array.isArray(this.missingModels)
? this.missingModels
: sortedMissingModels
);
const selectAllCheckbox = container.querySelector('.mr-missing-select-all-check');
if (selectAllCheckbox && selectAllCheckbox.dataset.mlSelectAllBound !== 'true') {
selectAllCheckbox.dataset.mlSelectAllBound = 'true';
this.updateBatchSelectAllCheckbox();
selectAllCheckbox.addEventListener('click', (event) => {
event.stopPropagation();
});
selectAllCheckbox.addEventListener('change', () => {
const shouldSelectAll = selectAllCheckbox.checked;
this.batchSelectedMissingKeys = shouldSelectAll
? new Set(getCurrentMissingModels().map(missing => this.getMissingModelKey(missing)))
: new Set();
this.lastBatchSelectedMissingKey = null;
this.refreshBatchSelectionUi();
this.updateBatchFooterButtons();
});
}
this.wireVisibleMissingModelRows(container, data, sortedMissingModels);
this.setupMissingModelsVirtualizer(container, data, sortedMissingModels);
const selectedMissing = sortedMissingModels.find(missing => this.getMissingModelKey(missing) === this.selectedMissingModelKey);
const detailPreserved = this._missingBrowserDetailPreserved === true;
this._missingBrowserDetailPreserved = false;
if (!selectedMissing || detailPreserved) return;
const selectedIndex = sortedMissingModels.indexOf(selectedMissing);
this.wireMissingModelDetail(container, selectedMissing, selectedIndex);
},
wireMissingBrowserSplitter(container) {
const browser = container.querySelector('.mr-missing-browser');
const splitter = browser?.querySelector('.mr-missing-browser-splitter');
if (!(browser instanceof HTMLElement) || !(splitter instanceof HTMLElement)) return;
const listPane = browser.querySelector('.mr-missing-list-pane');
const detailPane = browser.querySelector('.mr-missing-detail-pane');
if (!(listPane instanceof HTMLElement) || !(detailPane instanceof HTMLElement)) return;
const startSplitDrag = (event) => {
if (event.button !== undefined && event.button !== 0) return;
this.startMissingBrowserSplitDrag(event, browser, splitter, { listPane, detailPane });
};
if (typeof PointerEvent === 'function') {
splitter.addEventListener('pointerdown', startSplitDrag);
} else {
splitter.addEventListener('mousedown', startSplitDrag);
}
splitter.addEventListener('dblclick', () => {
this.clearMissingBrowserSplitStyles(browser);
this._pendingMissingBrowserRestoreBrowser = null;
this._pendingMissingBrowserRestoreWidth = null;
this._pendingMissingBrowserRestoreUseDefault = false;
this._missingBrowserStoredSplitWidth = null;
this._missingBrowserStoredSplitWidthLoaded = true;
this._missingBrowserLastDetailWidth = null;
this.cancelMissingBrowserSplitWidthPersist();
try {
safeStorage.removeItem(this.missingBrowserSplitStorageKey);
} catch (_e) {}
});
splitter.addEventListener('keydown', (event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
this.resizeMissingBrowserDetailBy(browser, event.key === 'ArrowLeft' ? 32 : -32);
});
this.cancelMissingBrowserExternalResizeRestore();
this._missingBrowserResizeObserver?.disconnect?.();
const resizeHost = browser.parentElement instanceof HTMLElement
? browser.parentElement
: browser;
this._missingBrowserObservedHostWidth = null;
// Apply the saved split before the browser can paint its first frame.
// The HTML already contains the saved detail track, but a docked panel
// may be narrower than the last layout. In that case the list-pinned
// state must be set synchronously instead of one animation frame later.
const initialWidth = this.getFastMissingBrowserWidthEstimate(resizeHost, { allowMeasure: true });
if (Number.isFinite(initialWidth) && initialWidth > 0) {
this.rememberMissingBrowserWidth(initialWidth);
this.restoreMissingBrowserSplitWidth(browser, {
browserWidth: initialWidth,
useDefault: false
});
}
if (typeof ResizeObserver === 'function') {
this._missingBrowserResizeObserver = new ResizeObserver((entries) => {
const observedWidth = Number(entries?.[0]?.contentRect?.width);
if (!Number.isFinite(observedWidth) || observedWidth <= 0) return;
const previousHostWidth = Number(this._missingBrowserObservedHostWidth);
this._missingBrowserObservedHostWidth = observedWidth;
this.rememberMissingBrowserWidth(observedWidth);
if (!Number.isFinite(previousHostWidth) || previousHostWidth <= 0) {
this.restoreMissingBrowserSplitWidth(browser, {
browserWidth: observedWidth,
useDefault: false
});
return;
}
if (Math.abs(previousHostWidth - observedWidth) < 0.5) return;
const settlingUntil = Number(this._missingBrowserSplitSettlingUntil || 0);
const isSettling = typeof performance === 'object'
&& typeof performance.now === 'function'
&& performance.now() < settlingUntil;
if (
!this._missingBrowserSplitDragging
&& !isSettling
) {
this.scheduleMissingBrowserExternalResizeRestore(browser, observedWidth);
}
});
this._missingBrowserResizeObserver.observe(resizeHost);
} else {
this.cancelMissingBrowserPrewarmFrame();
this._missingBrowserPrewarmFrame = requestAnimationFrame(() => {
this._missingBrowserPrewarmFrame = null;
if (!browser.isConnected) return;
if (this._missingBrowserSplitDragging) return;
const measuredWidth = Number(resizeHost.clientWidth || resizeHost.getBoundingClientRect().width);
this.rememberMissingBrowserWidth(measuredWidth);
this.scheduleMissingBrowserSplitRestore(browser, measuredWidth, { useDefault: false });
});
}
},
cancelMissingBrowserPrewarmFrame() {
if (!this._missingBrowserPrewarmFrame) return;
cancelAnimationFrame(this._missingBrowserPrewarmFrame);
this._missingBrowserPrewarmFrame = null;
},
cancelMissingBrowserExternalResizeRestore() {
if (this._missingBrowserExternalResizeFrame) {
cancelAnimationFrame(this._missingBrowserExternalResizeFrame);
}
this._missingBrowserExternalResizeFrame = null;
this._pendingMissingBrowserExternalResizeBrowser = null;
this._pendingMissingBrowserExternalResizeWidth = null;
},
scheduleMissingBrowserExternalResizeRestore(browser, browserWidth) {
if (!(browser instanceof HTMLElement)) return;
this._pendingMissingBrowserExternalResizeBrowser = browser;
this._pendingMissingBrowserExternalResizeWidth = Number(browserWidth) || null;
if (this._missingBrowserExternalResizeFrame) return;
this._missingBrowserExternalResizeFrame = requestAnimationFrame(() => {
this._missingBrowserExternalResizeFrame = null;
const targetBrowser = this._pendingMissingBrowserExternalResizeBrowser;
const targetWidth = this._pendingMissingBrowserExternalResizeWidth;
this._pendingMissingBrowserExternalResizeBrowser = null;
this._pendingMissingBrowserExternalResizeWidth = null;
if (
!targetBrowser?.isConnected
|| this._missingBrowserSplitDragging
) {
return;
}
this.restoreMissingBrowserSplitWidth(targetBrowser, {
browserWidth: targetWidth,
useDefault: false
});
});
},
getStoredMissingBrowserSplitWidth() {
if (this._missingBrowserStoredSplitWidthLoaded) {
return this._missingBrowserStoredSplitWidth || null;
}
let savedWidth = null;
try {
const storedWidth = parseInt(safeStorage.getItem(this.missingBrowserSplitStorageKey) || '', 10);
savedWidth = Number.isFinite(storedWidth) && storedWidth > 0 ? storedWidth : null;
} catch (_e) {}
this._missingBrowserStoredSplitWidth = savedWidth;
this._missingBrowserStoredSplitWidthLoaded = true;
return savedWidth;
},
rememberMissingBrowserWidth(width) {
const observedWidth = Number(width);
if (!Number.isFinite(observedWidth) || observedWidth <= 0) return null;
this._missingBrowserObservedWidth = observedWidth;
this._missingBrowserLastBrowserWidth = observedWidth;
if (!this._missingBrowserLastDetailWidth) {
this._missingBrowserLastDetailWidth = this.getDefaultMissingBrowserDetailWidth(observedWidth);
}
return observedWidth;
},
getFastMissingBrowserWidthEstimate(browser = null, { allowMeasure = false } = {}) {
if (allowMeasure && browser instanceof HTMLElement) {
const clientWidth = Number(browser.clientWidth || browser.offsetWidth);
if (Number.isFinite(clientWidth) && clientWidth > 0) {
this.rememberMissingBrowserWidth(clientWidth);
return clientWidth;
}
const rectWidth = Number(browser.getBoundingClientRect?.().width);
if (Number.isFinite(rectWidth) && rectWidth > 0) {
this.rememberMissingBrowserWidth(rectWidth);
return rectWidth;
}
}
const observedWidth = Number(this._missingBrowserObservedWidth);
if (Number.isFinite(observedWidth) && observedWidth > 0) return observedWidth;
const lastWidth = Number(this._missingBrowserLastBrowserWidth);
if (Number.isFinite(lastWidth) && lastWidth > 0) return lastWidth;
const fallbackWidth = Number(this._queueSplitLastContainerWidth || window.innerWidth || 720);
return Number.isFinite(fallbackWidth) && fallbackWidth > 0 ? fallbackWidth : 720;
},
getMissingBrowserSplitBoundsForWidth(width) {
const safeWidth = Math.max(1, Number(width) || 1);
const available = Math.max(1, safeWidth - 10);
const minListWidth = Math.min(340, available);
const max = Math.max(1, available - minListWidth);
const min = Math.min(max, Math.min(420, Math.max(300, Math.floor(available * 0.3))));
return { min, max, available };
},
getDefaultMissingBrowserDetailWidth(browserWidth = null) {
const width = Number(browserWidth) > 0
? Number(browserWidth)
: this.getFastMissingBrowserWidthEstimate();
const bounds = this.getMissingBrowserSplitBoundsForWidth(width);
const preferred = Math.round(width * 0.42);
return Math.round(Math.max(bounds.min, Math.min(bounds.max, preferred)));
},
scheduleMissingBrowserSplitRestore(browser, browserWidth = null, { useDefault = false } = {}) {
if (!(browser instanceof HTMLElement)) return;
this._pendingMissingBrowserRestoreBrowser = browser;
this._pendingMissingBrowserRestoreWidth = Number.isFinite(Number(browserWidth)) && Number(browserWidth) > 0
? Number(browserWidth)
: null;
this._pendingMissingBrowserRestoreUseDefault = Boolean(useDefault);
if (this._missingBrowserRestoreFrame) return;
this._missingBrowserRestoreFrame = requestAnimationFrame(() => {
this._missingBrowserRestoreFrame = null;
const targetBrowser = this._pendingMissingBrowserRestoreBrowser;
const targetWidth = this._pendingMissingBrowserRestoreWidth;
const targetUseDefault = this._pendingMissingBrowserRestoreUseDefault;
this._pendingMissingBrowserRestoreBrowser = null;
this._pendingMissingBrowserRestoreWidth = null;
this._pendingMissingBrowserRestoreUseDefault = false;
if (!this._missingBrowserSplitDragging && targetBrowser?.isConnected) {
this.restoreMissingBrowserSplitWidth(targetBrowser, {
browserWidth: targetWidth,
useDefault: targetUseDefault
});
}
});
},
restoreMissingBrowserSplitWidth(browser, { browserWidth = null, useDefault = false } = {}) {
const savedWidth = this.getStoredMissingBrowserSplitWidth();
const targetWidth = savedWidth || (useDefault ? this.getDefaultMissingBrowserDetailWidth(browserWidth) : null);
if (!targetWidth) return;
this.setMissingBrowserDetailWidth(browser, targetWidth, {
persist: false,
browserWidth,
skipIfUnchanged: true
});
},
startMissingBrowserSplitDrag(event, browser, splitter = null, panes = {}) {
if (!(browser instanceof HTMLElement)) return;
event.preventDefault();
event.stopPropagation?.();
this.cancelMissingBrowserPrewarmFrame();
const detailPane = panes.detailPane instanceof HTMLElement
? panes.detailPane
: browser.querySelector('.mr-missing-detail-pane');
const listPane = panes.listPane instanceof HTMLElement
? panes.listPane
: browser.querySelector('.mr-missing-list-pane');
if (!(detailPane instanceof HTMLElement) || !(listPane instanceof HTMLElement)) return;
const browserWidth = this.getFastMissingBrowserWidthEstimate(browser);
const bounds = this.getMissingBrowserSplitBoundsForWidth(browserWidth);
const savedWidth = this.getStoredMissingBrowserSplitWidth();
const detailWidth = Math.round(Math.max(bounds.min, Math.min(bounds.max, this.getMissingBrowserDetailTrackWidth(detailPane)
|| this._missingBrowserLastDetailWidth
|| savedWidth
|| this.getDefaultMissingBrowserDetailWidth(browserWidth))));
if (this._missingBrowserRestoreFrame) {
cancelAnimationFrame(this._missingBrowserRestoreFrame);
this._missingBrowserRestoreFrame = null;
}
this._pendingMissingBrowserRestoreBrowser = null;
this._pendingMissingBrowserRestoreWidth = null;
this._pendingMissingBrowserRestoreUseDefault = false;
this.cancelMissingBrowserSplitWidthPersist();
const releaseSplitter = splitter instanceof HTMLElement
? splitter
: browser.querySelector('.mr-missing-browser-splitter');
this._missingBrowserSplitBrowser = browser;
this._missingBrowserSplitListPane = listPane;
this._missingBrowserSplitDetailPane = detailPane;
this._missingBrowserSplitSplitter = releaseSplitter;
this._missingBrowserSplitDragging = true;
this._missingBrowserSplitStart = {
x: event.clientX,
width: detailWidth,
bounds,
browserWidth,
availableWidth: bounds.available
};
this._appliedMissingBrowserSplitWidth = detailWidth;
this._pendingMissingBrowserSplitWidth = detailWidth;
this._missingBrowserSplitUiActive = false;
this._missingBrowserSplitInteraction = startSplitterDrag(event, {
anchor: 'right',
startWidth: detailWidth,
bounds,
dragThreshold: 4,
layoutFrameStride: 1,
onPreview: (pendingWidth) => {
this._pendingMissingBrowserSplitWidth = pendingWidth;
this.activateMissingBrowserSplitUi();
},
onDrag: (width) => {
this._pendingMissingBrowserSplitWidth = width;
this.activateMissingBrowserSplitUi();
this.applyMissingBrowserDetailWidth(detailPane, width, {
browserWidth,
availableWidth: bounds.available,
splitBounds: bounds,
skipIfUnchanged: true
});
this._appliedMissingBrowserSplitWidth = width;
},
onEnd: (finalWidth, { didDrag = false } = {}) => {
this._missingBrowserSplitDragging = false;
if (didDrag) {
const settlingNow = typeof performance === 'object' && typeof performance.now === 'function'
? performance.now()
: Date.now();
this._missingBrowserSplitSettlingUntil = settlingNow + 350;
}
if (didDrag && detailPane && finalWidth) {
this.applyMissingBrowserDetailWidth(detailPane, finalWidth, {
browserWidth,
availableWidth: bounds.available,
splitBounds: bounds
});
this._appliedMissingBrowserSplitWidth = finalWidth;
}
if (didDrag && finalWidth) {
this.persistMissingBrowserSplitWidth(finalWidth, { delay: 600 });
}
this.applyMissingBrowserSplitReleaseCleanup({
browser,
splitter: releaseSplitter
});
this._missingBrowserSplitBrowser = null;
this._missingBrowserSplitListPane = null;
this._missingBrowserSplitDetailPane = null;
this._missingBrowserSplitSplitter = null;
this._missingBrowserSplitStart = null;
this._missingBrowserSplitUiActive = false;
this._pendingMissingBrowserSplitWidth = null;
this._appliedMissingBrowserSplitWidth = null;
this._missingBrowserSplitInteraction = null;
}
});
},
activateMissingBrowserSplitUi() {
if (this._missingBrowserSplitUiActive) return;
this._missingBrowserSplitUiActive = true;
this._missingBrowserSplitBrowser?.classList?.add('is-resizing');
this._missingBrowserSplitSplitter?.classList?.add('is-resizing');
},
applyMissingBrowserSplitReleaseCleanup(cleanup) {
if (!cleanup) return;
cleanup.browser?.classList?.remove('is-resizing');
cleanup.splitter?.classList?.remove('is-resizing');
if (cleanup.splitter?.style) {
cleanup.splitter.style.transform = '';
cleanup.splitter.style.willChange = '';
}
},
resizeMissingBrowserDetailBy(browser, delta) {
const detailPane = browser.querySelector('.mr-missing-detail-pane');
if (!(detailPane instanceof HTMLElement)) return;
const currentWidth = this.getMissingBrowserDetailTrackWidth(detailPane)
|| detailPane.getBoundingClientRect().width;
this.setMissingBrowserDetailWidth(browser, currentWidth + delta);
},
applyMissingBrowserDetailWidth(target, width, { skipIfUnchanged = false, browserWidth = null, splitBounds = null } = {}) {
const detailPane = target instanceof HTMLElement && target.classList.contains('mr-missing-detail-pane')
? target
: target?.querySelector?.('.mr-missing-detail-pane');
const browser = detailPane instanceof HTMLElement
? detailPane.closest('.mr-missing-browser')
: (target instanceof HTMLElement && target.classList.contains('mr-missing-browser') ? target : null);
if (!(browser instanceof HTMLElement) || !(detailPane instanceof HTMLElement)) return false;
const listPane = this._missingBrowserSplitListPane instanceof HTMLElement && this._missingBrowserSplitListPane.isConnected
? this._missingBrowserSplitListPane
: browser.querySelector('.mr-missing-list-pane');
const measuredWidth = Number(browserWidth);
const bounds = splitBounds && Number.isFinite(Number(splitBounds.min)) && Number.isFinite(Number(splitBounds.max))
? splitBounds
: Number.isFinite(measuredWidth) && measuredWidth > 0
? this.getMissingBrowserSplitBoundsForWidth(measuredWidth)
: this.getMissingBrowserSplitBounds(browser);
const requestedWidth = Number.isFinite(Number(width)) ? Number(width) : bounds.min;
const nextWidth = Math.round(Math.max(bounds.min, Math.min(bounds.max, requestedWidth)));
const shouldPinList = requestedWidth > bounds.max;
const wasListPinned = browser.classList.contains('is-list-pinned');
browser.classList.toggle('is-list-pinned', shouldPinList);
const detailValue = `${Math.round(shouldPinList ? requestedWidth : nextWidth)}px`;
const listValue = '0px';
if (
skipIfUnchanged
&& detailPane.style.flexBasis === detailValue
&& (!(listPane instanceof HTMLElement) || (
listPane.style.flexBasis === listValue
&& listPane.style.flexGrow === '1'
&& listPane.style.flexShrink === '1'
))
) {
return wasListPinned !== shouldPinList;
}
if (listPane instanceof HTMLElement) {
if (listPane.style.flexBasis !== listValue) listPane.style.flexBasis = listValue;
if (listPane.style.flexGrow !== '1') listPane.style.flexGrow = '1';
if (listPane.style.flexShrink !== '1') listPane.style.flexShrink = '1';
}
if (detailPane.style.flexBasis !== detailValue) detailPane.style.flexBasis = detailValue;
if (detailPane.style.flexGrow !== '0') detailPane.style.flexGrow = '0';
if (detailPane.style.flexShrink !== '0') detailPane.style.flexShrink = '0';
this._missingBrowserLastDetailWidth = nextWidth;
return true;
},
clearMissingBrowserSplitStyles(browser) {
if (!(browser instanceof HTMLElement)) return;
const listPane = browser.querySelector('.mr-missing-list-pane');
const detailPane = browser.querySelector('.mr-missing-detail-pane');
[listPane, detailPane].forEach((pane) => {
if (!(pane instanceof HTMLElement)) return;
pane.style.removeProperty('flex-basis');
pane.style.removeProperty('flex-grow');
pane.style.removeProperty('flex-shrink');
});
browser.classList.remove('is-list-pinned');
detailPane?.style?.removeProperty('--mr-missing-detail-track');
},
getMissingBrowserDetailTrackWidth(detailPane) {
if (!(detailPane instanceof HTMLElement)) return null;
const inlineBasis = String(detailPane.style.flexBasis || '').trim();
if (inlineBasis.endsWith('px')) {
const basisWidth = parseFloat(inlineBasis);
if (Number.isFinite(basisWidth) && basisWidth > 0) return basisWidth;
}
const raw = String(detailPane.style.getPropertyValue('--mr-missing-detail-track') || '').trim();
if (!raw.endsWith('px')) return null;
const width = parseFloat(raw);
return Number.isFinite(width) && width > 0 ? width : null;
},
cancelMissingBrowserSplitWidthPersist() {
if (this._missingBrowserSplitPersistIdle && typeof cancelIdleCallback === 'function') {
try {
cancelIdleCallback(this._missingBrowserSplitPersistIdle);
} catch (_e) {}
}
if (this._missingBrowserSplitPersistTimer) {
clearTimeout(this._missingBrowserSplitPersistTimer);
}
this._missingBrowserSplitPersistIdle = null;
this._missingBrowserSplitPersistTimer = null;
},
persistMissingBrowserSplitWidth(width, { delay = 180 } = {}) {
const nextWidth = Math.round(Number(width));
if (!Number.isFinite(nextWidth) || nextWidth <= 0) return;
this._missingBrowserStoredSplitWidth = nextWidth;
this._missingBrowserStoredSplitWidthLoaded = true;
this._missingBrowserLastDetailWidth = nextWidth;
this.cancelMissingBrowserSplitWidthPersist();
const writeWidth = () => {
this._missingBrowserSplitPersistIdle = null;
this._missingBrowserSplitPersistTimer = null;
try {
safeStorage.setItem(this.missingBrowserSplitStorageKey, String(nextWidth));
} catch (_e) {}
};
const writeDelay = Math.max(0, Number(delay) || 0);
this._missingBrowserSplitPersistTimer = setTimeout(writeWidth, writeDelay);
},
setMissingBrowserDetailWidth(browser, width, { persist = true, browserWidth = null, skipIfUnchanged = false } = {}) {
const bounds = this.getMissingBrowserSplitBounds(browser, browserWidth);
const nextWidth = Math.round(Math.max(bounds.min, Math.min(bounds.max, width)));
const detailPane = browser.querySelector('.mr-missing-detail-pane');
const target = detailPane instanceof HTMLElement ? detailPane : browser;
this.applyMissingBrowserDetailWidth(target, width, {
browserWidth: Number.isFinite(Number(browserWidth)) && Number(browserWidth) > 0
? Number(browserWidth)
: null,
availableWidth: bounds.available,
splitBounds: bounds,
skipIfUnchanged
});
if (persist) {
this.persistMissingBrowserSplitWidth(nextWidth);
}
},
getMissingBrowserSplitBounds(browser, browserWidth = null, { allowMeasure = false } = {}) {
const measuredWidth = Number(browserWidth);
const width = Number.isFinite(measuredWidth) && measuredWidth > 0
? measuredWidth
: this.getFastMissingBrowserWidthEstimate(browser, { allowMeasure });
if (Number.isFinite(width) && width > 0) {
this.rememberMissingBrowserWidth(width);
}
return this.getMissingBrowserSplitBoundsForWidth(width);
},
wireMissingModelDetail(container, missing, missingIndex) {
this.wireLocalMatchButtons(container, missing, missingIndex);
this.wireDownloadSearchPanel(container, missing);
this.updateSelectedBarForMissing(missing);
const detailDomKey = this.getMissingModelDomKey(missing);
const localRefreshId = `local-matches-refresh-${detailDomKey}`;
const localRefreshBtn = container.querySelector(`#${localRefreshId}`);
if (localRefreshBtn) {
localRefreshBtn.addEventListener('click', () => this.refreshLocalMatchesForMissing?.(missing, {
button: localRefreshBtn
}));
}
const locateId = `locate-${detailDomKey}`;
const locateBtn = container.querySelector(`#${locateId}`);
const locateTarget = this.getMissingLocateTarget(missing);
if (locateBtn && locateTarget.nodeId !== undefined && locateTarget.nodeId !== null && locateTarget.nodeId !== '') {
locateBtn.addEventListener('click', () => {
this.locateNodeInGraph(locateTarget.nodeId, {
subgraphId: locateTarget.subgraphId || '',
isTopLevel: locateTarget.isTopLevel
});
});
}
const comboId = `combo-${detailDomKey}`;
const comboInput = container.querySelector(`#combo-input-${comboId}`);
const comboList = container.querySelector(`#combo-list-${comboId}`);
const comboRefresh = container.querySelector(`#combo-refresh-${comboId}`);
const getAllModels = () => Array.isArray(this.allModels) ? this.allModels : [];
let localModelLoadToken = 0;
const normalizeModelPath = (value = '') => this.normalizePathToForward(value)
.split('/')
.map(part => part.trim())
.filter(Boolean)
.join('/');
const getModelCategory = (model = {}) => this.normalizeDownloadCategory(model.category || 'unknown') || 'unknown';
const getDownloadCategoryElement = () => container.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`)
|| this.contentElement?.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`);
const getPreferredModelCategory = () => {
const categoryEl = getDownloadCategoryElement();
const selectedCategory = this.normalizeDownloadCategory(this.getDropdownValue?.(categoryEl) || categoryEl?.value || '');
const savedCategory = this.normalizeDownloadCategory(this.getSavedDownloadTargetSelection?.(missing)?.category || '');
const inferredCategory = this.normalizeDownloadCategory(
missing.category || this.getMissingDownloadCategory?.(missing, 'unknown') || 'unknown'
);
return selectedCategory || savedCategory || inferredCategory || 'unknown';
};
const getLocalModelExpandedSet = (preferredCategory = getPreferredModelCategory()) => {
if (!this.localModelBrowserExpandedGroups) {
this.localModelBrowserExpandedGroups = new Map();
}
const key = preferredCategory || 'all';
if (!this.localModelBrowserExpandedGroups.has(key)) {
this.localModelBrowserExpandedGroups.set(key, new Set());
}
return this.localModelBrowserExpandedGroups.get(key);
};
const getCurrentQueuedModelIdentity = () => {
const key = this.getMissingModelKey(missing);
const idx = this.pendingIndex?.get?.(key);
const selection = Number.isInteger(idx) ? this.pendingResolutions?.[idx] : null;
const model = selection?.resolved_model || {};
return [
model.path || selection?.resolved_path || '',
model.relative_path || '',
model.filename || '',
model.category || selection?.category || ''
].map(value => String(value || '').trim().toLowerCase()).join('::');
};
const getModelIdentity = (model = {}) => [
model.path || '',
model.relative_path || '',
model.filename || '',
model.category || ''
].map(value => String(value || '').trim().toLowerCase()).join('::');
const getLocalModelDedupeIdentity = (entry = {}) => {
const path = String(entry.fullPath || entry.model?.path || '').trim();
if (path) {
return normalizePathIdentity(path);
}
return [
entry.baseDirectory || '',
entry.relativePath || entry.model?.relative_path || entry.model?.filename || ''
].map(normalizePathIdentity).join('::');
};
const getLocalModelCategoryRank = (category = '', preferredCategory = '') => {
const normalized = this.normalizeDownloadCategory(category || '');
if (normalized && normalized === preferredCategory) return 0;
const ranks = {
checkpoints: 10,
loras: 10,
vae: 10,
diffusion_models: 10,
text_encoders: 10,
controlnet: 10,
clip: 20,
clip_gguf: 30
};
return ranks[normalized] ?? 40;
};
const dedupeLocalModelEntries = (entries = [], preferredCategory = '') => {
const byIdentity = new Map();
entries.forEach(entry => {
const identity = getLocalModelDedupeIdentity(entry);
if (!identity) return;
const previous = byIdentity.get(identity);
if (!previous) {
byIdentity.set(identity, entry);
return;
}
const currentRank = getLocalModelCategoryRank(entry.category, preferredCategory);
const previousRank = getLocalModelCategoryRank(previous.category, preferredCategory);
if (
currentRank < previousRank
|| (currentRank === previousRank && String(entry.category || '').localeCompare(String(previous.category || '')) < 0)
) {
byIdentity.set(identity, entry);
}
});
return Array.from(byIdentity.values());
};
const buildLocalFolderContext = (folderPath = '', name = 'Folder', category = '') => {
const path = String(folderPath || '').trim();
if (!path) return null;
return {
context_scope: 'download_folder',
open_folder_label: 'Open Folder',
name,
path,
resolved_path: path,
open_path: path,
folder_path: path,
download_directory: path,
category
};
};
const buildLocalModelContext = (entry = {}) => {
const model = entry.model || {};
const path = entry.fullPath || model.path || model.resolved_path || '';
if (!path) return null;
const directory = entry.folderPath
? joinPathPreservingStyle(entry.baseDirectory, entry.folderPath)
: entry.baseDirectory;
return {
...model,
context_scope: model.context_scope || 'local_model',
open_folder_label: 'Containing Folder',
name: entry.filename || model.filename || entry.relativePath || path,
path,
resolved_path: path,
open_path: path,
folder_path: directory || path,
directory: directory || '',
category: entry.category || model.category || ''
};
};
const getLocalPathContextAttrs = (context, tooltip = 'Right-click for options') => {
return this.getContextMenuAttrs(context, tooltip);
};
const makeModelEntry = (model, index, preferredCategory = getPreferredModelCategory()) => {
const category = getModelCategory(model);
const categoryLabel = this.getCategoryDisplayName(category);
const relativePath = normalizeModelPath(model.relative_path || model.filename || '');
const pathParts = relativePath.split('/').filter(Boolean);
const filename = pathParts.pop() || model.filename || relativePath || 'model';
const folderPath = pathParts.join('/');
const baseDirectory = String(model.base_directory || '');
const fullPath = String(model.path || '');
return {
index,
model,
category,
categoryLabel,
relativePath: relativePath || filename,
filename,
folderPath,
baseDirectory,
baseLabel: this.getBaseDirectoryLabel(baseDirectory),
fullPath,
isPreferred: category === preferredCategory,
searchText: [
category,
categoryLabel,
relativePath,
filename,
folderPath,
baseDirectory,
fullPath
].join(' ').toLowerCase()
};
};
const localModelTreePicker = createFloatingTreePicker({
listEl: comboList,
anchorEl: comboInput,
duplicateSelector: '.mr-combo-list[data-ml-floating-portal="true"]',
floatingClass: 'mr-download-target-floating',
browserClass: 'mr-local-model-browser',
scrollSelector: '.mr-local-model-browser-scroll',
minAvailableWidth: 280,
minPopupWidth: 520,
maxPopupWidth: 760,
openAboveThreshold: 280,
minAvailableHeight: 180,
minScrollHeight: 120,
roundValues: true
});
const showComboList = () => localModelTreePicker.show();
const hideComboList = () => localModelTreePicker.hide();
const positionComboList = () => localModelTreePicker.position();
if (comboList) {
this.bindDropdownOutsideDismiss?.(comboList, [comboInput, comboRefresh], hideComboList);
this.enableWheelScrollChaining(comboList);
}
const buildModelTree = (entries = []) => {
const root = { folders: new Map(), models: [] };
entries.forEach(entry => {
const folderParts = entry.folderPath.split('/').filter(Boolean);
let current = root;
let currentPath = '';
folderParts.forEach(part => {
currentPath = currentPath ? `${currentPath}/${part}` : part;
if (!current.folders.has(part)) {
current.folders.set(part, {
name: part,
path: currentPath,
folders: new Map(),
models: []
});
}
current = current.folders.get(part);
});
current.models.push(entry);
});
return root;
};
const countTreeModels = (node) => {
let count = node.models?.length || 0;
node.folders?.forEach(child => {
count += countTreeModels(child);
});
return count;
};
const renderModelRows = (entries = [], selectedIdentity = '') => entries
.sort((a, b) => a.filename.localeCompare(b.filename))
.map(entry => {
const selected = getModelIdentity(entry.model) === selectedIdentity;
const folderText = entry.folderPath || entry.baseLabel || '';
const context = buildLocalModelContext(entry);
const contextAttrs = getLocalPathContextAttrs(context, entry.fullPath || entry.relativePath);
return `
<div class="mr-folder-browser-row mr-local-model-row ${selected ? 'is-selected' : ''}" data-browser-action="select-model" data-model-index="${entry.index}"${contextAttrs}>
<span class="mr-folder-browser-toggle mr-folder-browser-toggle-empty"></span>
<span class="mr-folder-browser-folder-icon mr-local-model-file-icon">${getSvgIcon('file', 'currentColor', 'mr-folder-browser-svg')}</span>
<span class="mr-folder-browser-name">${this.escapeHtml(entry.filename)}</span>
${folderText ? `<span class="mr-local-model-folder-tag">${this.escapeHtml(folderText)}</span>` : ''}
</div>
`;
})
.join('');
const renderModelTreeNodes = (node, group, expandedSet, filter, selectedIdentity) => {
const folderHtml = Array.from(node.folders.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map(folder => {
const stateKey = `node:${group.key}:${folder.path.toLowerCase()}`;
const modelCount = countTreeModels(folder);
const shouldExpand = Boolean(filter) || expandedSet.has(stateKey);
const folderFullPath = group.baseDirectory
? joinPathPreservingStyle(group.baseDirectory, folder.path)
: '';
const context = buildLocalFolderContext(folderFullPath, folder.path, group.category);
const contextAttrs = getLocalPathContextAttrs(context, folderFullPath);
return `
<div class="mr-folder-browser-node">
<div class="mr-folder-browser-row is-expandable mr-local-model-folder-row" data-browser-action="toggle" data-state-key="${encodeURIComponent(stateKey)}"${contextAttrs}>
<button class="mr-folder-browser-toggle ${shouldExpand ? 'is-expanded' : ''}" type="button" aria-label="${shouldExpand ? 'Collapse folder' : 'Expand folder'}"><span class="mr-folder-browser-chevron"></span></button>
<span class="mr-folder-browser-folder-icon">${getSvgIcon('folderOpen', 'currentColor', 'mr-folder-browser-svg')}</span>
<span class="mr-folder-browser-name">${this.escapeHtml(folder.name)}</span>
<span class="mr-folder-browser-count">${modelCount}</span>
</div>
<div class="mr-folder-browser-children ${shouldExpand ? 'is-expanded' : ''}">
${shouldExpand ? renderModelTreeNodes(folder, group, expandedSet, filter, selectedIdentity) : ''}
</div>
</div>
`;
})
.join('');
return `${renderModelRows(node.models || [], selectedIdentity)}${folderHtml}`;
};
const renderLocalModelLoadingState = (message = 'Loading local models...') => {
if (!comboList) return;
comboList.innerHTML = `
<div class="mr-local-model-browser-scroll">
<div class="mr-folder-browser-empty">${this.escapeHtml(message)}</div>
</div>
`;
positionComboList();
};
const populateComboOptions = (filterText, options = {}) => {
if (!comboList) return;
const previousScrollEl = comboList.querySelector('.mr-local-model-browser-scroll');
const previousScrollTop = options.preserveScroll && previousScrollEl ? previousScrollEl.scrollTop : 0;
const allModels = getAllModels();
const activePreferredCategory = getPreferredModelCategory();
const allEntries = dedupeLocalModelEntries(
allModels.map((model, index) => makeModelEntry(model, index, activePreferredCategory)),
activePreferredCategory
);
const rawFilter = String(filterText || '').trim();
const filter = rawFilter.toLowerCase();
const tokens = filter.split(/\s+/).filter(Boolean);
const filteredEntries = allEntries.filter(entry => {
if (!tokens.length) return true;
return tokens.every(token => entry.searchText.includes(token));
});
const grouped = new Map();
filteredEntries.forEach(entry => {
const groupKey = `${entry.category}::${entry.baseDirectory || ''}`;
if (!grouped.has(groupKey)) {
grouped.set(groupKey, {
key: groupKey,
category: entry.category,
label: entry.categoryLabel,
baseDirectory: entry.baseDirectory,
baseLabel: entry.baseLabel,
isPreferred: entry.category === activePreferredCategory,
entries: []
});
}
grouped.get(groupKey).entries.push(entry);
});
const expandedSet = getLocalModelExpandedSet(activePreferredCategory);
const selectedIdentity = getCurrentQueuedModelIdentity();
const groupsHtml = Array.from(grouped.values())
.sort((a, b) => {
if (a.isPreferred !== b.isPreferred) return a.isPreferred ? -1 : 1;
const labelCompare = a.label.localeCompare(b.label);
if (labelCompare !== 0) return labelCompare;
return (a.baseLabel || '').localeCompare(b.baseLabel || '');
})
.map(group => {
const stateKey = `root:${group.key.toLowerCase()}`;
const collapsedStateKey = `collapsed:${stateKey}`;
const isExpanded = Boolean(filter)
|| expandedSet.has(stateKey)
|| (group.isPreferred && !expandedSet.has(collapsedStateKey));
const tree = buildModelTree(group.entries);
const rootContext = buildLocalFolderContext(
group.baseDirectory,
`${group.label} root`,
group.category
);
const rootContextAttrs = getLocalPathContextAttrs(rootContext, group.baseDirectory || group.label);
return `
<div class="mr-folder-browser-root ${group.isPreferred ? 'is-preferred' : ''}">
<button class="mr-folder-browser-root-head ${isExpanded ? 'is-expanded' : ''}" type="button" data-browser-action="toggle" data-state-key="${encodeURIComponent(stateKey)}"${rootContextAttrs}>
<span class="mr-folder-browser-chevron"></span>
<span class="mr-folder-browser-root-title">${this.escapeHtml(group.label)}${group.isPreferred ? ' · recommended' : ''}</span>
<span class="mr-folder-browser-root-count">${group.entries.length}</span>
</button>
${group.baseDirectory ? `<div class="mr-folder-browser-root-path">${this.escapeHtml(group.baseDirectory)}</div>` : ''}
<div class="mr-folder-browser-tree ${isExpanded ? 'is-expanded' : ''}">
${isExpanded ? renderModelTreeNodes(tree, group, expandedSet, filter, selectedIdentity) : ''}
</div>
</div>
`;
})
.join('');
const emptyHtml = tokens.length
? `<div class="mr-folder-browser-empty">No local models match this filter.</div>`
: `<div class="mr-folder-browser-empty">No local models in the enabled categories.</div>`;
comboList.innerHTML = `
<div class="mr-local-model-browser-scroll">
${groupsHtml || emptyHtml}
</div>
`;
comboList.querySelectorAll('[data-browser-action="toggle"]').forEach(row => {
row.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const scrollElBefore = comboList.querySelector('.mr-local-model-browser-scroll');
const scrollTop = scrollElBefore ? scrollElBefore.scrollTop : 0;
const stateKey = decodeURIComponent(row.dataset.stateKey || '');
if (!stateKey) return;
const collapsedStateKey = `collapsed:${stateKey}`;
const isExpanded = row.classList.contains('is-expanded')
|| Boolean(row.querySelector('.mr-folder-browser-toggle.is-expanded'));
if (isExpanded) {
expandedSet.delete(stateKey);
expandedSet.add(collapsedStateKey);
} else {
expandedSet.add(stateKey);
expandedSet.delete(collapsedStateKey);
}
populateComboOptions(comboInput?.value || '', { preserveScroll: true });
const scrollEl = comboList.querySelector('.mr-local-model-browser-scroll');
if (scrollEl) scrollEl.scrollTop = scrollTop;
positionComboList();
});
});
comboList.querySelectorAll('[data-browser-action="select-model"]').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.modelIndex, 10);
if (!Number.isNaN(idx) && idx >= 0 && idx < allModels.length) {
const chosenModel = allModels[idx];
if (chosenModel) {
this.queueResolution(missing, chosenModel);
hideComboList();
}
}
});
});
const scrollEl = comboList.querySelector('.mr-local-model-browser-scroll');
if (scrollEl) {
this.enableWheelScrollChaining(scrollEl);
scrollEl.scrollTop = previousScrollTop;
}
positionComboList();
};
const ensureLocalModelsThenPopulate = async (filterText = '', options = {}) => {
if (Array.isArray(this.allModels) && this.allModels.length > 0) {
populateComboOptions(filterText, options);
return;
}
const token = ++localModelLoadToken;
renderLocalModelLoadingState();
try {
await this.ensureAllModelsLoaded?.();
} catch (error) {
console.warn('Model Resolver: could not load local models for picker', error);
}
if (token !== localModelLoadToken) return;
populateComboOptions(filterText, options);
};
if (comboInput) {
const debouncedFilter = this.debounce(() => {
ensureLocalModelsThenPopulate(comboInput.value);
}, 200);
comboInput.addEventListener('input', debouncedFilter);
const focusOrClick = () => {
showComboList();
ensureLocalModelsThenPopulate(comboInput.value);
};
comboInput.addEventListener('focus', focusOrClick);
comboInput.addEventListener('click', focusOrClick);
}
const categoryPriorityEl = getDownloadCategoryElement();
if (categoryPriorityEl && categoryPriorityEl.dataset.mlLocalModelPriorityBound !== 'true') {
categoryPriorityEl.dataset.mlLocalModelPriorityBound = 'true';
let lastPriorityCategory = getPreferredModelCategory();
const refreshLocalModelPriority = () => {
const nextPriorityCategory = getPreferredModelCategory();
if (nextPriorityCategory === lastPriorityCategory && comboList?.style.display === 'none') {
return;
}
lastPriorityCategory = nextPriorityCategory;
ensureLocalModelsThenPopulate(comboInput?.value || '', { preserveScroll: true });
};
categoryPriorityEl.addEventListener('input', refreshLocalModelPriority);
categoryPriorityEl.addEventListener('change', refreshLocalModelPriority);
categoryPriorityEl.addEventListener('blur', () => setTimeout(refreshLocalModelPriority, 0));
categoryPriorityEl.addEventListener('mr-download-category-change', refreshLocalModelPriority);
}
if (comboRefresh) {
comboRefresh.addEventListener('click', async () => {
const minRefreshFeedback = new Promise(resolve => setTimeout(resolve, 420));
const refreshAnimation = this.startRefreshButtonAnimation(comboRefresh);
try {
comboRefresh.disabled = true;
comboRefresh.classList.add('mr-btn-is-disabled', 'mr-is-refreshing');
this.allModels = null;
await this.ensureAllModelsLoaded({ force: true });
await minRefreshFeedback;
populateComboOptions(comboInput?.value || '');
} catch (error) {
await minRefreshFeedback;
console.warn('Model Resolver: could not refresh local model list', error);
this.showNotification('Failed to refresh local model list', 'error');
} finally {
refreshAnimation?.cancel();
comboRefresh.disabled = false;
comboRefresh.classList.remove('mr-btn-is-disabled', 'mr-is-refreshing');
}
});
}
const state = this.searchResultCache.get(this.getMissingSearchKey(missing));
const searchResultsDiv = container.querySelector(`#search-results-${this.getMissingModelDomKey(missing)}`);
if (state && searchResultsDiv && this.hasRenderableSearchState(state)) {
searchResultsDiv.classList.remove('mr-is-hidden');
searchResultsDiv.classList.add('mr-is-visible');
this.displaySearchResults(missing, state, searchResultsDiv);
}
this.restoreDownloadProgressForMissing?.(missing);
},
startRefreshButtonAnimation(button) {
if (!button) return null;
const target = button.querySelector('.mr-refresh-spin-target') || button.querySelector('svg');
if (!target || typeof target.animate !== 'function') return null;
const existing = target.getAnimations?.() || [];
existing.forEach(animation => animation.cancel());
target.style.transformOrigin = 'center';
return target.animate(
[
{ transform: 'rotate(0deg)' },
{ transform: 'rotate(360deg)' }
],
{
duration: 620,
easing: 'linear',
iterations: Infinity
}
);
},
/**
* Display missing models in the dialog
*/
displayMissingModels(container, data, options = {}) {
const selectionOnly = !!options.selectionOnly;
const preserveBrowser = !!options.preserveBrowser;
const listScrollSnapshot = this.getMissingListScrollSnapshot(container);
const previousMissingModels = Array.isArray(this.missingModels)
? [...this.missingModels]
: [];
const previousSelectedMissingModelKey = this.selectedMissingModelKey || '';
const previousBatchSelectedMissingKeys = new Set(this.batchSelectedMissingKeys || []);
const previousLastBatchSelectedMissingKey = this.lastBatchSelectedMissingKey || '';
// Resolving the download state used to serialize the whole workflow for
// every missing model. Keep the scope stable for this render pass.
const downloadWorkflowScope = this.getCurrentDownloadWorkflowScopeIdentity?.() || '';
const missingModels = (data.missing_models || []).map(missing => {
let restored = this.restoreDownloadedLocalMatchesForMissing?.(missing, downloadWorkflowScope) || missing;
restored = this.restoreSearchLocalHashMatchesForMissing?.(restored) || restored;
restored.matches = this.preserveActiveDownloadLocalMatches?.(
restored,
restored.matches || [],
downloadWorkflowScope
) || restored.matches || [];
return restored;
});
const resolvedModels = this.getResolvedWorkflowModels(data);
const allModelsForDisplay = [...missingModels, ...resolvedModels];
const rawMissingCount = data.total_missing ?? missingModels.length;
const resolvedMissingCount = this.getResolvedMissingCount(missingModels);
const resolvedCount = resolvedMissingCount + resolvedModels.length;
const unresolvedMissingCount = Math.max(0, rawMissingCount - resolvedMissingCount);
const hiddenResolvedCount = this.showResolvedModels ? 0 : resolvedCount;
const resolvedFilteredModels = this.showResolvedModels
? allModelsForDisplay
: allModelsForDisplay.filter(missing => !this.isMissingModelResolved(missing));
const isInactiveUnresolvedModel = missing => (
!this.isMissingModelResolved(missing) && this.isMissingModelInactive(missing)
);
const inactiveCount = resolvedFilteredModels.reduce((count, missing) => (
count + (isInactiveUnresolvedModel(missing) ? 1 : 0)
), 0);
const hiddenInactiveCount = this.showInactiveModels ? 0 : inactiveCount;
const inactiveFilteredModels = this.showInactiveModels
? resolvedFilteredModels
: resolvedFilteredModels.filter(missing => !isInactiveUnresolvedModel(missing));
const autoDownloadCount = resolvedFilteredModels.reduce((count, missing) => (
count + (this.isAutoDownloadModel(missing) ? 1 : 0)
), 0);
const hiddenAutoDownloadCount = this.showAutoDownloadModels ? 0 : autoDownloadCount;
const typeFilterSourceModels = this.showAutoDownloadModels
? inactiveFilteredModels
: inactiveFilteredModels.filter(missing => !this.isAutoDownloadModel(missing));
const getTypeFilterValue = missing => String(missing?.category || 'unknown').trim().toLowerCase() || 'unknown';
const typeFilterCounts = new Map();
const typeFilterLabels = new Map();
typeFilterSourceModels.forEach((missing) => {
const value = getTypeFilterValue(missing);
typeFilterCounts.set(value, (typeFilterCounts.get(value) || 0) + 1);
if (!typeFilterLabels.has(value)) {
typeFilterLabels.set(
value,
missing.category ? this.getCategoryDisplayName(missing.category) : 'Unknown'
);
}
});
let activeTypeFilter = String(this.missingModelsTypeFilter || 'all').toLowerCase();
if (activeTypeFilter !== 'all' && !typeFilterCounts.has(activeTypeFilter)) {
activeTypeFilter = 'all';
this.missingModelsTypeFilter = 'all';
this.missingModelsTypeFilterMenuOpen = false;
}
const typeFilterOptions = [
{ value: 'all', label: 'All', count: typeFilterSourceModels.length },
...Array.from(typeFilterCounts, ([value, count]) => ({
value,
label: typeFilterLabels.get(value) || value,
count,
colorClass: this.getModelTypeColorClass(value)
})).sort((left, right) => left.label.localeCompare(right.label))
];
const visibleMissingModels = activeTypeFilter === 'all'
? typeFilterSourceModels
: typeFilterSourceModels.filter(missing => getTypeFilterValue(missing) === activeTypeFilter);
const currentModelKeys = new Set(
allModelsForDisplay.map(missing => this.getMissingModelKey(missing))
);
const resolveSelectionKey = (previousKey) => {
if (!previousKey) return '';
if (currentModelKeys.has(previousKey)) {
return visibleMissingModels.some(
missing => this.getMissingModelKey(missing) === previousKey
) ? previousKey : '';
}
return this.resolvePreservedMissingModelKey(
visibleMissingModels,
previousMissingModels,
previousKey
);
};
this.selectedMissingModelKey = resolveSelectionKey(previousSelectedMissingModelKey)
|| (visibleMissingModels.length ? this.getMissingModelKey(visibleMissingModels[0]) : null);
this.batchSelectedMissingKeys = this.remapMissingModelKeys(
visibleMissingModels,
previousMissingModels,
previousBatchSelectedMissingKeys,
allModelsForDisplay
);
this.lastBatchSelectedMissingKey = resolveSelectionKey(previousLastBatchSelectedMissingKey) || null;
this.missingModels = visibleMissingModels;
this.syncBatchSelectionForMissingModels(visibleMissingModels);
// Check if there are active downloads
const activeCount = Object.keys(this.activeDownloads).length;
const hasAny100Match = visibleMissingModels.some(missing =>
classifyLocalMatches(missing.matches || []).perfectMatches.length > 0
);
const hasAnyModelsToDisplay = rawMissingCount > 0 || resolvedModels.length > 0;
this.setMissingFooterControlsVisible(hasAnyModelsToDisplay || activeCount > 0);
// Hide download all button if no missing models
if (this.downloadAllButton) {
this.downloadAllButton.style.display = (rawMissingCount > 0 || activeCount > 0) ? 'inline-flex' : 'none';
}
if (!hasAnyModelsToDisplay && activeCount === 0) {
this.destroyMissingModelsVirtualizer(container);
container.innerHTML = this.renderStatusMessage('All models are available! No missing models found.', 'success');
return;
}
// If no missing models but downloads are active, show a waiting message
if (rawMissingCount === 0 && activeCount > 0 && !resolvedModels.length) {
this.destroyMissingModelsVirtualizer(container);
container.innerHTML = this.renderStatusMessage(
`${activeCount} download${activeCount > 1 ? 's' : ''} in progress. Local matches will refresh when complete.`,
'info'
);
return;
}
// Skip rendering if active tab is not "missing"
if (this.activeTab !== 'missing') {
return;
}
const needsDownloadDirectories = !this.downloadDirectories;
const needsDownloadRootDirectories = !this.downloadRootDirectories
&& typeof this.ensureDownloadRootDirectoriesLoaded === 'function';
if (needsDownloadDirectories || needsDownloadRootDirectories) {
const renderToken = `download-dirs-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this._downloadDirectoriesRenderToken = renderToken;
const keepCurrentBrowser = preserveBrowser
&& Boolean(container.querySelector('.mr-missing-browser'));
if (!keepCurrentBrowser) {
this.destroyMissingModelsVirtualizer(container);
container.innerHTML = this.renderStatusMessage('Loading model folders...', 'info');
}
const loaders = [];
if (needsDownloadDirectories) {
loaders.push(this.ensureDownloadDirectoriesLoaded?.());
}
if (needsDownloadRootDirectories) {
loaders.push(this.ensureDownloadRootDirectoriesLoaded());
}
Promise.allSettled(loaders).then(() => {
if (
this._downloadDirectoriesRenderToken === renderToken &&
this.activeTab === 'missing' &&
container.isConnected
) {
this.displayMissingModels(container, data, {
preserveBrowser: keepCurrentBrowser
});
}
});
return;
}
// Sort missing models: those with 100% confidence matches first, then others
const sortedMissingModels = [...visibleMissingModels].sort((a, b) => {
const aMatches = a.matches || [];
const bMatches = b.matches || [];
const aClassification = classifyLocalMatches(aMatches);
const bClassification = classifyLocalMatches(bMatches);
const aFiltered = aClassification.filteredMatches;
const bFiltered = bClassification.filteredMatches;
const aHas100 = aClassification.perfectMatches.length > 0;
const bHas100 = bClassification.perfectMatches.length > 0;
// If one has 100% and the other doesn't, prioritize the one with 100%
if (aHas100 && !bHas100) return -1;
if (!aHas100 && bHas100) return 1;
// If both have 100% or neither has 100%, sort by best confidence
const aBestConf = aFiltered.length > 0 ? Math.max(...aFiltered.map(m => m.confidence)) : 0;
const bBestConf = bFiltered.length > 0 ? Math.max(...bFiltered.map(m => m.confidence)) : 0;
return bBestConf - aBestConf; // Higher confidence first
});
for (let mi = 0; mi < sortedMissingModels.length; mi++) {
sortedMissingModels[mi].__displayIndex = mi;
}
const selectedStillExists = sortedMissingModels.some(missing => (
this.getMissingModelKey(missing) === this.selectedMissingModelKey
));
if (!selectedStillExists) {
this.selectedMissingModelKey = sortedMissingModels.length
? this.getMissingModelKey(sortedMissingModels[0])
: null;
}
this.missingModels = sortedMissingModels;
const existingBrowser = container.querySelector('.mr-missing-browser');
const detailPane = existingBrowser?.querySelector('.mr-missing-detail-pane');
const listContainer = existingBrowser?.querySelector('.mr-missing-list');
const preservedDetailWidth = this.getMissingBrowserDetailWidthForRerender(existingBrowser);
if (selectionOnly && existingBrowser && detailPane && listContainer) {
const rows = listContainer.querySelectorAll('.mr-missing-list-row');
rows.forEach(row => {
const rowKey = row.dataset.missingKey;
const shouldBeSelected = rowKey === this.selectedMissingModelKey;
if (row.classList.contains('is-selected') !== shouldBeSelected) {
row.classList.toggle('is-selected', shouldBeSelected);
}
});
const selectedMissing = sortedMissingModels.find(missing => this.getMissingModelKey(missing) === this.selectedMissingModelKey);
const detailIndex = sortedMissingModels.indexOf(selectedMissing);
detailPane.innerHTML = selectedMissing
? this.renderMissingModel(selectedMissing, detailIndex)
: this.renderStatusMessage('Select a missing model to inspect details.', 'info');
if (selectedMissing) {
this.wireMissingModelDetail(container, selectedMissing, detailIndex);
}
this.reconnectActiveSearchProgress(sortedMissingModels);
this.updateBatchFooterButtons();
return;
}
const browserHtml = this.renderMissingModelsBrowser(
sortedMissingModels,
this.selectedMissingModelKey,
sortedMissingModels.length,
activeCount,
hasAny100Match,
{
hiddenResolvedCount,
hiddenAutoDownloadCount,
hiddenInactiveCount,
autoDownloadCount,
inactiveCount,
resolvedCount,
rawMissingCount,
missingCount: unresolvedMissingCount,
typeFilterOptions,
activeTypeFilter,
detailWidth: preservedDetailWidth,
}
);
this._missingBrowserDetailPreserved = false;
const browserPatched = options.preserveBrowser
&& this.patchMissingModelsBrowserElement(container, browserHtml);
if (!browserPatched) {
this.destroyMissingModelsVirtualizer(container);
container.innerHTML = browserHtml;
}
this.wireMissingModelsBrowser(container, data, sortedMissingModels);
this.restoreMissingListScroll(container, listScrollSnapshot);
this.refreshMissingModelsVirtualizer(container, true);
this.scheduleInitialUrnLocalMatchRefresh(sortedMissingModels, container, data);
this.reconnectActiveSearchProgress(sortedMissingModels);
this.updateBatchFooterButtons();
},
async refreshMissingAnalysis(button = null) {
if (button?.disabled) return;
const minRefreshFeedback = new Promise(resolve => setTimeout(resolve, 420));
const refreshAnimation = this.startRefreshButtonAnimation(button);
try {
if (button) {
button.disabled = true;
button.classList.add('mr-btn-is-disabled', 'mr-is-refreshing');
}
this.showNotification('Refreshing missing models and local matches...', 'info');
this.allModels = null;
this.invalidateLoadedModelsCacheForActiveWorkflow?.();
await minRefreshFeedback;
await this.loadWorkflowData(null, { force: true });
} catch (error) {
console.error('Model Resolver: missing analysis refresh failed:', error);
this.showNotification('Refresh failed: ' + error.message, 'error');
} finally {
if (button) {
refreshAnimation?.cancel();
button.disabled = false;
button.classList.remove('mr-btn-is-disabled', 'mr-is-refreshing');
}
}
},
renderMissingModel(missing, missingIndex = 0) {
const allMatches = missing.matches || [];
const { perfectMatches } = classifyLocalMatches(allMatches);
const missingFilename = this.getMissingFilename(missing);
// Determine node info for the chip
const nodeDisplay = this.getMissingNodeDisplay(missing);
const nodeChipText = this.escapeHtml(nodeDisplay.text);
// Start card
let html = `<div class="mr-card">`;
// Card Header: Filename as headline + node chip
html += `<div class="mr-card-header">`;
html += `<div class="mr-card-title-wrap">`;
const titleMetaParts = [];
let titlePrimaryHtml = `<span class="mr-card-title-primary" data-tooltip="${this.escapeHtml(missingFilename)}">${this.escapeHtml(missingFilename)}</span>`;
let titleSecondaryHtml = '';
const modelId = missing.urn_model_id || missing.urn?.model_id;
const versionId = missing.urn_version_id || missing.urn?.version_id;
const modelUrl = missing.is_urn && modelId ? getCivitaiModelUrl(modelId, versionId) : '';
const urnLoadingId = `urn-loading-${missing.node_id}-${missing.widget_index}`;
if (missing.is_urn) {
titleMetaParts.push(`<span class="mr-card-title-eyebrow" data-tooltip="${this.escapeHtml(missingFilename)}">${this.escapeHtml(missingFilename)}</span>`);
}
if (missing.is_urn && !missing.civitai_info) {
// URN without info - show Loading and fetch async in background
titlePrimaryHtml = `<span class="mr-card-title-primary" id="${urnLoadingId}">Resolving CivitAI model...</span>`;
setTimeout(() => this.resolveUrnAsync(modelId, versionId, urnLoadingId, modelUrl), 10);
} else if (missing.is_urn && missing.civitai_info) {
// URN with resolved info - show model name/version
const civitaiInfo = missing.civitai_info;
const civitaiLabelHtml = this.renderVersionedModelNameHtml(civitaiInfo.model_name, civitaiInfo.version_name);
if (civitaiLabelHtml) {
const linkHtml = modelUrl ? `<a href="${modelUrl}" target="_blank" class="mr-inline-civitai-link">${civitaiLabelHtml}</a>` : `<span class="mr-inline-civitai-link">${civitaiLabelHtml}</span>`;
titlePrimaryHtml = `<span class="mr-card-title-primary">${linkHtml}</span>`;
}
if (civitaiInfo.expected_filename) {
titleSecondaryHtml = `<span class="mr-card-title-secondary">Expected file: ${civitaiInfo.expected_filename}</span>`;
}
}
html += `<div class="mr-card-title-meta">`;
html += titleMetaParts.join('');
html += `<h3 class="mr-card-title">${titlePrimaryHtml}</h3>`;
if (titleSecondaryHtml) {
html += titleSecondaryHtml;
}
html += `</div>`;
const detailDomKey = this.getMissingModelDomKey(missing);
const locateId = `locate-${detailDomKey}`;
const nodeChipClasses = nodeDisplay.canLocate ? 'mr-node-chip is-locatable' : 'mr-node-chip';
const nodeChipTitle = nodeDisplay.canLocate ? nodeDisplay.locateTooltip : '';
html += `<div class="mr-card-subtitle">`;
if (missing.category) {
html += `<span class="mr-category-chip ${this.getModelTypeColorClass(missing.category)}" data-tooltip="${this.escapeHtml(missing.category)}">${this.getCategoryDisplayName(missing.category)}</span>`;
}
html += this.renderMissingModelFormatBadges(missing);
html += `<span id="${locateId}" class="${nodeChipClasses}"${nodeChipTitle ? ` data-tooltip="${this.escapeHtml(nodeChipTitle)}"` : ''}>`;
if (nodeDisplay.canLocate) {
html += this.getLocateIconHtml();
}
html += `${nodeChipText}</span>`;
html += this.renderMissingNodeFolderBadge(missing);
html += this.renderMissingAutoDownloadBadge(missing);
html += `</div>`;
html += `</div>`;
html += `</div>`;
// Selected bar - shows if this slot has a queued selection (BELOW card header)
const selectedBarId = `selected-bar-${detailDomKey}`;
html += `<div id="${selectedBarId}" class="model-resolver-selected"></div>`;
// Two-column layout
html += `<div class="mr-columns">`;
// LEFT COLUMN: Local Matches
html += `<div class="mr-column">`;
const localRefreshId = `local-matches-refresh-${detailDomKey}`;
html += `<div class="mr-column-header mr-local-matches-header">`;
html += `<span>Local Matches</span>`;
html += `<button id="${localRefreshId}" type="button" aria-label="Refresh local matches" data-tooltip="Rescan local model folders and refresh matches for this model" class="mr-btn mr-btn-secondary mr-btn-sm mr-btn-icon-only mr-local-matches-refresh-btn"><span class="mr-refresh-spin-target">${getSvgIcon('refreshCw', 'currentColor', 'mr-combo-refresh-icon')}</span></button>`;
html += `</div>`;
html += `<div id="local-matches-body-${detailDomKey}">`;
html += this.renderLocalMatchesContent(missing, missingIndex);
html += `</div>`;
// Add all-models search picker - combo-style dropdown
const comboId = `combo-${detailDomKey}`;
html += `<div class="mr-combo-section">`;
html += `<div class="mr-combo-row">`;
html += `<label class="mr-combo-label">Model</label>`;
html += `<input id="combo-input-${comboId}" class="mr-combo-input" type="text" placeholder="Type to filter local models...">`;
html += `<button id="combo-refresh-${comboId}" type="button" aria-label="Reload local model list" data-tooltip="Reload local model list" class="mr-btn mr-btn-secondary mr-btn-sm mr-btn-icon-only mr-combo-refresh-btn"><span class="mr-refresh-spin-target">${getSvgIcon('refreshCw', 'currentColor', 'mr-combo-refresh-icon')}</span></button>`;
html += `</div>`;
html += `<div id="combo-list-${comboId}" class="mr-combo-list"></div>`;
html += `</div>`;
html += `</div>`; // End left column
// RIGHT COLUMN: Download Option
html += `<div class="mr-column">`;
html += `<div class="mr-column-header">Download</div>`;
const downloadSource = missing.download_source;
const urnDownloadId = `urn-download-${missing.node_id}-${missing.widget_index}`;
if (this.shouldDisplayKnownDownloadSource(missing, downloadSource)) {
html += this.renderKnownDownloadPanel(missing, downloadSource);
} else if (perfectMatches.length > 0) {
// Has perfect local match - download not needed, but allow online re-check.
html += `<div class="mr-download-section">`;
html += this.renderSearchControls(missing, {
buttonText: this.hasSearchAttemptForMissing?.(missing) ? 'Search Again' : 'Search Online'
});
html += this.renderDownloadTargetControls(missing, missing.category || 'checkpoints');
html += `</div>`;
html += `<div id="search-results-${this.getMissingModelDomKey(missing)}" class="mr-search-results"></div>`;
} else if (missing.is_urn) {
html += `<div id="${urnDownloadId}" class="mr-download-section">`;
html += `<div class="mr-download-info">Resolving CivitAI download for this URN...</div>`;
html += `</div>`;
} else {
// No known download - offer search
html += `<div class="mr-download-section">`;
html += this.renderSearchControls(missing);
html += this.renderDownloadTargetControls(missing, missing.category || 'checkpoints');
html += `</div>`;
html += `<div id="search-results-${this.getMissingModelDomKey(missing)}" class="mr-search-results"></div>`;
}
// Progress container (for downloads). Use the complete model identity,
// because node/widget coordinates alone can repeat in custom nodes.
const downloadProgressId = this.getDownloadProgressElementId?.(missing)
|| `download-progress-${missing.node_id}-${missing.widget_index}`;
html += `<div id="${downloadProgressId}" class="mr-download-progress-slot"></div>`;
html += `</div>`; // End right column
html += `</div>`; // End columns
html += `</div>`; // End card
return html;
},
refreshMissingListRow(missing, options = {}) {
if (!missing || !this.contentElement) return;
const missingKey = this.getMissingModelKey(missing);
const row = Array.from(this.contentElement.querySelectorAll('.mr-missing-list-row'))
.find(item => item.dataset.missingKey === missingKey);
if (!row) return;
const { bestMatch, confidence, matchDisplay, matchClass } = this.getLocalMatchDisplayInfo(missing);
const bestEl = row.querySelector('.mr-missing-row-best');
if (bestEl) {
bestEl.setAttribute('data-tooltip', matchDisplay);
bestEl.innerHTML = bestMatch
? this.escapeHtml(matchDisplay)
: '<span class="mr-missing-row-none">-- No local match</span>';
}
const matchEl = row.querySelector('.mr-missing-row-match');
if (matchEl) {
matchEl.className = `mr-missing-row-match mr-missing-row-match-${matchClass}`;
const valueEl = matchEl.querySelector('strong');
if (valueEl) {
valueEl.textContent = bestMatch ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` : '--';
} else {
matchEl.innerHTML = `<strong>${bestMatch ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` : '--'}</strong>`;
}
}
const sourcesEl = row.querySelector('.mr-missing-row-sources');
if (sourcesEl) {
const renderedSources = this.renderMissingSourcesSummary(missing);
if (sourcesEl.innerHTML !== renderedSources) {
sourcesEl.innerHTML = renderedSources;
}
}
this.refreshMissingListStats?.();
if (options.refreshBaseModels) {
this.refreshSearchBaseModelLabels?.();
}
}
};