import { app } from "../../../../../scripts/app.js";
import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
export const queueMethods = {
createContent() {
// Wrap the body in a two-column layout: left = items, right = queued panel
const body = $el("div", {
id: "model-resolver-body"
});
this.contentElement = $el("div.mr-scrollable", {
id: "model-resolver-content"
});
this.queueElement = $el("div", {
id: "model-resolver-queue"
}, [
this.createQueuePanel()
]);
// Splitter between content and queue
this.splitterElement = $el("div", {
id: "model-resolver-splitter",
ondragstart: (e) => e.preventDefault()
});
body.appendChild(this.contentElement);
body.appendChild(this.splitterElement);
body.appendChild(this.queueElement);
// Restore saved queue width and wire splitter
try {
const savedSplit = localStorage.getItem('model_resolver_split_w');
if (savedSplit) {
const w = parseInt(savedSplit, 10);
if (!isNaN(w) && w > 0) {
this.queueElement.style.width = `${w}px`;
}
}
} catch (e) { }
try {
const onSplitMouseDown = (e) => this.startSplitDrag(e);
this.splitterElement.addEventListener('mousedown', onSplitMouseDown);
this._splitterMouseDown = onSplitMouseDown;
} catch (e) { }
// Toggle icon always visible
try {
this.queueToggleIcon = $el("button", {
id: "queue-toggle-icon",
type: "button",
"aria-label": "Toggle queued selections",
onclick: (event) => this.onQueueEdgeClick(event),
onmousedown: (event) => this.startQueueEdgeDrag(event)
});
body.appendChild(this.queueToggleIcon);
this.updateQueueToggleIcon();
} catch (e) { }
// Restore queue collapsed state
try {
const col = localStorage.getItem('model_resolver_queue_collapsed');
if (col === '1') this.setQueueCollapsed(true);
} catch (e) { }
return body;
},
createQueuePanel() {
this.queueTitle = $el("div#queue-title.mr-queue-title", { textContent: "Queued Selections (0)" });
this.queueToggleButton = $el("button", {
id: "queue-toggle",
className: "mr-btn mr-btn-secondary mr-btn-sm",
textContent: "Collapse",
onclick: () => this.toggleQueueCollapsed()
});
this.queueClearButton = $el("button", {
id: "queue-clear",
className: "mr-btn mr-btn-secondary mr-btn-sm",
textContent: "Clear All",
onclick: () => this.clearAllQueued()
});
// Header row with title and clear button
this.queueHeader = $el("div.mr-queue-header", {}, [
this.queueTitle,
$el("div.mr-queue-actions", {}, [
this.queueToggleButton,
this.queueClearButton
])
]);
// Scrollable list
this.queueList = $el("div#queue-list.mr-queue-list");
const panel = $el("div.mr-queue-panel", {}, [
$el("div.mr-queue-stack", {}, [this.queueHeader, this.queueList])
]);
return panel;
},
updateQueuePanel() {
if (!this.queueList || !this.queueHeader) return;
const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : [];
// Update title count
const title = this.queueTitle || this.queueHeader.querySelector('#queue-title');
if (title) title.textContent = `Queued Selections (${list.length})`;
const toggleBtn = this.queueToggleButton || this.queueHeader.querySelector('#queue-toggle');
if (toggleBtn) toggleBtn.textContent = this.queueCollapsed ? 'Expand' : 'Collapse';
if (!list.length) {
this.queueList.innerHTML = '
No selections queued.
';
return;
}
let html = '';
for (let i = 0; i < list.length; i++) {
const r = list[i];
const label = (r.resolved_model?.relative_path || r.resolved_model?.filename || r.resolved_path || '').toString();
const nodeLabel = r.node_label || r.node_type || (r.subgraph_id ? 'Subgraph' : 'Node');
const orig = (r.original_path || '').toString();
const rmId = `queue-remove-${i}`;
html += `
`;
html += `
${this.escapeHtml(nodeLabel)} #${this.escapeHtml(String(r.node_id))}
`;
html += `
Original${this.escapeHtml(orig)}
`;
html += `
Selected${this.escapeHtml(label)}
`;
html += `
`;
html += `
`;
}
html += '
';
this.queueList.innerHTML = html;
// Wire remove buttons
for (let i = 0; i < list.length; i++) {
const rmId = `queue-remove-${i}`;
const btn = this.queueList.querySelector(`#${rmId}`);
if (btn) {
btn.addEventListener('click', () => this.removeQueuedByIndex(i));
}
}
},
// Remove queued by index
removeQueuedByIndex(i) {
const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : [];
if (i < 0 || i >= list.length) return;
const r = list[i];
// Remove
this.pendingResolutions.splice(i, 1);
this.rebuildPendingIndex();
this.savePendingQueueForActiveWorkflow();
// Update per-item selected bar
const m = { node_id: r.node_id, widget_index: r.widget_index, subgraph_id: r.subgraph_id, is_top_level: r.is_top_level };
this.updateSelectedBarForMissing?.(m);
this.updateApplyPendingButton?.();
this.updateQueuePanel();
},
// Clear all queued selections
clearAllQueued() {
this.pendingResolutions = [];
this.pendingIndex = new Map();
this.savePendingQueueForActiveWorkflow();
this.updateApplyPendingButton?.();
this.updateQueuePanel();
try {
document.querySelectorAll('.model-resolver-selected').forEach(el => { el.style.display = 'none'; el.innerHTML = ''; });
} catch (e) { /* ignore */ }
},
// Update selected bar for a specific missing model slot
updateSelectedBarForMissing(missing) {
if (!missing) return;
const nodeId = missing.node_id;
const widgetIndex = missing.widget_index;
const subgraphId = missing.subgraph_id || '';
const isTopLevel = missing.is_top_level !== false;
const key = `${nodeId}:${widgetIndex}:${subgraphId}:${isTopLevel ? 'T' : 'F'}`;
const selectedBar = document.getElementById(`selected-bar-${nodeId}-${widgetIndex}`);
if (!selectedBar) return;
// Find selection for this slot
let selection = null;
let selectionIdx = -1;
if (this.pendingIndex.has(key)) {
const idx = this.pendingIndex.get(key);
if (idx >= 0 && idx < this.pendingResolutions.length) {
selection = this.pendingResolutions[idx];
selectionIdx = idx;
}
}
if (!selection) {
selectedBar.style.display = 'none';
selectedBar.innerHTML = '';
return;
}
// Build selected bar content
const label = selection.resolved_model?.relative_path || selection.resolved_model?.filename || selection.resolved_path || '';
const resolveBtnId = `selected-remove-${nodeId}-${widgetIndex}`;
selectedBar.innerHTML = ``;
selectedBar.innerHTML += `✓ Selected:`;
selectedBar.innerHTML += `${label}`;
selectedBar.innerHTML += ``;
selectedBar.innerHTML += `
`;
selectedBar.style.display = 'block';
// Wire remove button - use key-based removal
const removeBtn = selectedBar.querySelector(`#${resolveBtnId}`);
if (removeBtn) {
removeBtn.addEventListener('click', () => {
this.removeQueuedByKey(key);
});
}
},
// Remove queued by key (more reliable than index)
removeQueuedByKey(key) {
if (!key || !this.pendingIndex.has(key)) return;
// Find and remove the item with this key
const idx = this.pendingIndex.get(key);
if (idx >= 0 && idx < this.pendingResolutions.length) {
const r = this.pendingResolutions[idx];
// Update the selected bar before removing
const m = { node_id: r.node_id, widget_index: r.widget_index, subgraph_id: r.subgraph_id, is_top_level: r.is_top_level };
// Remove from array
this.pendingResolutions.splice(idx, 1);
this.rebuildPendingIndex();
this.savePendingQueueForActiveWorkflow();
this.updateSelectedBarForMissing(m);
this.updateApplyPendingButton?.();
this.updateQueuePanel();
}
},
// Rebuild pending index after modification
rebuildPendingIndex() {
this.pendingIndex = new Map();
for (let i = 0; i < this.pendingResolutions.length; i++) {
const r = this.pendingResolutions[i];
const key = `${r.node_id}:${r.widget_index}:${r.subgraph_id || ''}:${r.is_top_level ? 'T' : 'F'}`;
this.pendingIndex.set(key, i);
}
},
// Collapse/expand queue panel
toggleQueueCollapsed() {
this.setQueueCollapsed(!this.queueCollapsed);
},
setQueueCollapsed(collapsed) {
this.queueCollapsed = !!collapsed;
if (this.queueCollapsed) {
try { localStorage.setItem('model_resolver_queue_collapsed', '1'); } catch (e) { }
} else {
try { localStorage.setItem('model_resolver_queue_collapsed', '0'); } catch (e) { }
}
this.updateQueueVisibility();
this.updateQueuePanel();
},
updateQueueVisibility() {
const isMissingTab = this.activeTab === 'missing';
const showQueuePanel = isMissingTab && !this.queueCollapsed;
if (this.queueElement) {
this.queueElement.style.display = showQueuePanel ? '' : 'none';
}
if (this.splitterElement) {
this.splitterElement.style.display = showQueuePanel ? '' : 'none';
}
if (this.queueToggleIcon) {
this.queueToggleIcon.style.display = isMissingTab ? '' : 'none';
}
this.updateQueueToggleIcon();
},
updateQueueToggleIcon() {
if (!this.queueToggleIcon) return;
this.queueToggleIcon.style.display = this.activeTab === 'missing' ? '' : 'none';
this.queueToggleIcon.textContent = '';
this.queueToggleIcon.removeAttribute('data-tooltip');
this.queueToggleIcon.removeAttribute('title');
if (this.queueCollapsed) {
this.queueToggleIcon.classList.add('is-collapsed');
this.queueToggleIcon.setAttribute('aria-label', 'Show queued selections');
} else {
this.queueToggleIcon.classList.remove('is-collapsed');
this.queueToggleIcon.setAttribute('aria-label', 'Hide queued selections');
}
},
onQueueEdgeClick(event) {
if (this._suppressQueueEdgeClick) {
event?.preventDefault?.();
event?.stopPropagation?.();
this._suppressQueueEdgeClick = false;
return;
}
this.toggleQueueCollapsed();
},
startQueueEdgeDrag(event) {
if (event.button !== 0 || !this.queueCollapsed || !this.queueElement) return;
const startX = event.clientX;
const previousCursor = document.body.style.cursor;
event.preventDefault();
document.body.style.cursor = 'col-resize';
this._suppressQueueEdgeClick = true;
this.splitterElement.style.display = '';
document.getElementById('model-resolver-body')?.classList.add('mr-queue-preview-collapsed');
this.queueElement.style.display = 'none';
this.queueElement.style.width = '0px';
this.startSplitDrag(
{ clientX: startX, preventDefault: () => {} },
{ startWidth: 0, edgeOpen: true, previousCursor, startCollapsedPreview: true }
);
},
// Begin split drag for resizable panels
startSplitDrag(e, { startWidth = null, edgeOpen = false, previousCursor = null, startCollapsedPreview = false } = {}) {
try {
e?.preventDefault?.();
if (!this.queueElement) return;
const rect = this.queueElement.getBoundingClientRect();
const body = document.getElementById('model-resolver-body');
const bodyRect = body ? body.getBoundingClientRect() : { width: window.innerWidth };
this._splitDragging = true;
body?.classList.add('mr-is-resizing-queue');
this._splitStart = {
x: e.clientX,
startWidth: Number.isFinite(startWidth) ? startWidth : rect.width,
containerWidth: bodyRect.width
};
this._lastSplitDragApply = 0;
this._splitPreviewCollapsed = !!startCollapsedPreview;
this._splitEdgeOpen = !!edgeOpen;
this._splitEdgeOpenedPastThreshold = false;
this._splitPreviousCursor = previousCursor;
this._prevUserSelect = document.body.style.userSelect;
document.body.style.userSelect = 'none';
this._onSplitMove = (ev) => this.onSplitDrag(ev);
this._onSplitUp = () => this.endSplitDrag();
document.addEventListener('mousemove', this._onSplitMove);
document.addEventListener('mouseup', this._onSplitUp, { once: true });
} catch (err) { /* ignore */ }
},
onSplitDrag(e) {
if (!this._splitDragging || !this._splitStart || !this.queueElement) return;
const dx = e.clientX - this._splitStart.x;
let newW = this._splitStart.startWidth - dx;
const minW = this._splitEdgeOpen ? 0 : 56;
const maxW = Math.max(minW, Math.floor(this._splitStart.containerWidth - 360));
if (newW < minW) newW = minW;
if (newW > maxW) newW = maxW;
this._pendingSplitWidth = Math.round(newW);
if (this._splitEdgeOpen && this._pendingSplitWidth > 140) {
this._splitEdgeOpenedPastThreshold = true;
if (this.queueCollapsed) {
this.queueCollapsed = false;
try { localStorage.setItem('model_resolver_queue_collapsed', '0'); } catch (error) { }
this.updateQueueToggleIcon();
this.updateQueuePanel();
}
}
const previewCollapsed = this._splitEdgeOpen
? (!this._splitEdgeOpenedPastThreshold || this._pendingSplitWidth <= 140)
: this._pendingSplitWidth <= 140;
if (previewCollapsed !== this._splitPreviewCollapsed) {
this._splitPreviewCollapsed = previewCollapsed;
const body = document.getElementById('model-resolver-body');
body?.classList.toggle('mr-queue-preview-collapsed', previewCollapsed);
this.queueElement.style.display = previewCollapsed ? 'none' : '';
}
const now = performance.now();
if (this._lastSplitDragApply && now - this._lastSplitDragApply < 33) return;
this._lastSplitDragApply = now;
if (this._splitDragFrame) return;
this._splitDragFrame = requestAnimationFrame(() => {
this._splitDragFrame = null;
if (!this._splitDragging || !this.queueElement || !this._pendingSplitWidth || this._splitPreviewCollapsed) return;
this.queueElement.style.width = `${this._pendingSplitWidth}px`;
});
},
endSplitDrag() {
if (!this._splitDragging) return;
this._splitDragging = false;
if (this._splitDragFrame) {
cancelAnimationFrame(this._splitDragFrame);
this._splitDragFrame = null;
}
if (this.queueElement && this._pendingSplitWidth && !this._splitPreviewCollapsed) {
this.queueElement.style.width = `${this._pendingSplitWidth}px`;
}
const body = document.getElementById('model-resolver-body');
body?.classList.remove('mr-is-resizing-queue');
body?.classList.remove('mr-queue-preview-collapsed');
document.removeEventListener('mousemove', this._onSplitMove);
if (this._splitEdgeOpen && !this._pendingSplitWidth) {
try {
const restoreWidth = Math.max(240, Math.round(this._splitStart?.startWidth || 320));
this.queueElement.style.width = `${restoreWidth}px`;
this.queueElement.style.display = '';
if (this.splitterElement) this.splitterElement.style.display = '';
localStorage.setItem('model_resolver_split_w', String(restoreWidth));
} catch (e) { }
this.queueCollapsed = false;
try { localStorage.setItem('model_resolver_queue_collapsed', '0'); } catch (e) { }
this.updateQueueVisibility();
this.updateQueuePanel();
this._splitEdgeOpen = false;
this._pendingSplitWidth = null;
this._lastSplitDragApply = 0;
this._splitPreviewCollapsed = false;
try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { }
try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { }
this._splitPreviousCursor = null;
this._splitEdgeOpenedPastThreshold = false;
setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0);
return;
}
const shouldCollapse = !!this._splitPreviewCollapsed;
if (shouldCollapse) {
try {
const restoreWidth = Math.max(240, Math.round(this._splitStart?.startWidth || 320));
this.queueElement.style.width = `${restoreWidth}px`;
this.queueElement.style.display = '';
localStorage.setItem('model_resolver_split_w', String(restoreWidth));
} catch (e) { }
this._pendingSplitWidth = null;
this._lastSplitDragApply = 0;
this._splitPreviewCollapsed = false;
this._splitEdgeOpen = false;
this._splitEdgeOpenedPastThreshold = false;
try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { }
try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { }
this._splitPreviousCursor = null;
this.setQueueCollapsed(true);
setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0);
return;
}
if (this.queueElement) this.queueElement.style.display = '';
try {
const rect = this.queueElement.getBoundingClientRect();
localStorage.setItem('model_resolver_split_w', String(Math.round(rect.width)));
} catch (e) { }
this._pendingSplitWidth = null;
this._lastSplitDragApply = 0;
this._splitPreviewCollapsed = false;
this._splitEdgeOpen = false;
this._splitEdgeOpenedPastThreshold = false;
try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { }
if (this._splitPreviousCursor !== null) {
try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { }
this._splitPreviousCursor = null;
}
setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0);
},
/**
* Queue a resolution for later batch apply
*/
queueResolution(missing, resolvedModel) {
if (!resolvedModel) {
this.showNotification('No model selected', 'error');
return;
}
const resolution = {
node_id: missing.node_id,
widget_index: missing.widget_index,
resolved_path: resolvedModel.path,
category: missing.category,
resolved_model: resolvedModel,
original_path: missing.original_path,
subgraph_id: missing.subgraph_id,
is_top_level: missing.is_top_level,
node_type: missing.node_type,
node_label: missing.subgraph_name || missing.node_type
};
const key = `${resolution.node_id}:${resolution.widget_index}:${resolution.subgraph_id || ''}:${resolution.is_top_level ? 'T' : 'F'}`;
if (this.pendingIndex.has(key)) {
// replace existing selection for this slot
const idx = this.pendingIndex.get(key);
this.pendingResolutions[idx] = resolution;
} else {
this.pendingIndex.set(key, this.pendingResolutions.length);
this.pendingResolutions.push(resolution);
}
this.savePendingQueueForActiveWorkflow();
// Update selected bar UI
this.updateSelectedBarForMissing?.(missing);
this.updateQueuePanel();
this.updateApplyPendingButton();
},
/**
* Apply all pending resolutions in batch
*/
async applyPendingResolutions() {
const list = this.pendingResolutions || [];
if (!list.length) {
this.showNotification('No selections queued', 'error');
return;
}
try {
const workflow = this.getCurrentWorkflow();
if (!workflow) {
this.showNotification('No workflow loaded', 'error');
return;
}
const response = await api.fetchApi('/model_resolver/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow, resolutions: list })
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
const data = await response.json();
if (data.success) {
await this.updateWorkflowInComfyUI(data.workflow);
this.showNotification(`✓ Linked ${list.length} selection${list.length>1?'s':''}`, 'success');
// Clear queue and refresh analysis
this.pendingResolutions = [];
this.pendingIndex = new Map();
this.savePendingQueueForActiveWorkflow();
this.updateApplyPendingButton();
this.updateQueuePanel();
await this.loadWorkflowData(data.workflow, { force: true });
} else {
this.showNotification('Failed to apply selections: ' + (data.error || 'Unknown error'), 'error');
}
} catch (e) {
console.error('Model Resolver: applyPendingResolutions error', e);
this.showNotification('Error applying selections: ' + e.message, 'error');
}
},
updateApplyPendingButton() {
if (!this.applyPendingBtn) return;
const count = this.pendingResolutions?.length || 0;
const isEmpty = count === 0;
const tooltip = count > 0
? `Apply ${count} queued model link${count > 1 ? 's' : ''} to the current workflow.`
: 'Apply the model links you selected from local matches or search results.';
this.applyPendingBtn.textContent = `Apply Selected (${count})`;
this.setTooltip(this.applyPendingBtn, tooltip);
this.applyPendingBtn.disabled = isEmpty;
this.applyPendingBtn.setAttribute('aria-disabled', String(isEmpty));
this.applyPendingBtn.classList.toggle('mr-btn-is-disabled', isEmpty);
this.updateBatchFooterButtons();
},
updateBatchFooterButtons() {
const missingModels = this.missingModels || [];
const selectedCount = this.getSelectedMissingModels().length;
const totalCount = missingModels.length;
const pendingCount = this.pendingResolutions?.length || 0;
const activeCount = Object.keys(this.activeDownloads || {}).length;
const downloadableSelected = this.getMissingWithDownloadSources(this.getSelectedMissingModels()).length;
const downloadableAll = this.getMissingWithDownloadSources(missingModels).length;
if (this.selectMenuButton) {
this.selectMenuButton.textContent = `Select (${selectedCount}/${totalCount})`;
this.selectMenuButton.classList.toggle('mr-btn-is-disabled', totalCount === 0);
}
if (this.searchMenuButton) {
this.searchMenuButton.textContent = this.batchSearchRunning
? (this.batchSearchCancelRequested ? 'Stopping...' : 'Searching...')
: 'Search';
this.searchMenuButton.classList.toggle('mr-btn-is-disabled', totalCount === 0 && !this.batchSearchRunning);
}
if (this.downloadMenuButton) {
const label = activeCount > 0 ? `Cancel Downloads (${activeCount})` : 'Download';
this.downloadMenuButton.textContent = label;
this.downloadMenuButton.classList.toggle('mr-btn-danger', activeCount > 0);
this.downloadMenuButton.classList.toggle('mr-btn-download', activeCount === 0);
this.downloadMenuButton.classList.toggle('mr-btn-is-disabled', activeCount === 0 && downloadableAll === 0);
this.setTooltip(
this.downloadMenuButton,
activeCount > 0
? `Cancel ${activeCount} active download${activeCount > 1 ? 's' : ''}.`
: `${downloadableSelected || downloadableAll} missing model${(downloadableSelected || downloadableAll) === 1 ? '' : 's'} currently have download sources.`
);
}
if (this.applyPendingBtn) {
this.applyPendingBtn.textContent = `Apply Selected (${pendingCount})`;
}
if (this.linkMenuButton) {
this.linkMenuButton.classList.toggle('mr-btn-is-disabled', totalCount === 0 && pendingCount === 0);
}
this.updateFooterMenuItemVisibility?.();
},
getMissingModelsForPendingResolutions() {
const pending = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : [];
if (!pending.length) return [];
const pendingKeys = new Set(pending.map(resolution => this.getMissingModelKey(resolution)));
return (this.missingModels || []).filter(missing => pendingKeys.has(this.getMissingModelKey(missing)));
},
getQueueExactLocalMatchPreviewModels() {
const selected = this.getSelectedMissingModels();
const targets = selected.length ? selected : (this.missingModels || []);
return targets.filter(missing => this.getBestLocalMatch(missing, 100)?.model);
},
getDownloadableBatchPreviewModels(mode = 'all') {
const targets = mode === 'selected'
? this.getSelectedMissingModels()
: (this.missingModels || []);
return targets.filter(missing => {
if (this.getBestLocalMatch(missing, 100)) return false;
return Boolean(this.getBestDownloadSourceForMissing(missing)?.url);
});
},
getActiveDownloadMissingModels() {
const activeKeys = new Set(
Object.values(this.activeDownloads || {})
.map(info => info?.missing)
.filter(Boolean)
.map(missing => this.getMissingModelKey(missing))
);
if (!activeKeys.size) return [];
return (this.missingModels || []).filter(missing => activeKeys.has(this.getMissingModelKey(missing)));
},
previewFooterMenuModels(models = []) {
const keys = new Set(
(Array.isArray(models) ? models : [])
.filter(Boolean)
.map(missing => this.getMissingModelKey(missing))
);
this.contentElement?.querySelectorAll?.('.mr-missing-list-row')?.forEach(row => {
const key = row.getAttribute('data-missing-key');
row.classList.toggle('is-menu-preview', Boolean(key && keys.has(key)));
});
},
clearFooterMenuPreview() {
this.contentElement?.querySelectorAll?.('.mr-missing-list-row.is-menu-preview')?.forEach(row => {
row.classList.remove('is-menu-preview');
});
},
createFooterMenu(name, label, items = [], buttonClass = 'mr-btn-secondary') {
const button = $el(`button.mr-btn.${buttonClass}.mr-footer-btn.mr-footer-menu-button`, {
type: 'button',
'aria-haspopup': 'menu',
'aria-expanded': 'false',
onclick: (event) => {
event.stopPropagation();
this.hideTooltip?.();
if (button.classList.contains('mr-btn-is-disabled')) {
if (name === 'download') {
this.showNotification('No missing models have downloadable sources yet.', 'info');
} else {
this.showNotification('No missing models available.', 'info');
}
return;
}
this.toggleFooterMenu(name);
}
}, [$el("span", { textContent: label })]);
const menuChildren = items.map(item => {
const isDivider = item === 'divider' || item?.type === 'divider';
let element;
if (isDivider) {
element = $el("div.mr-footer-menu-divider", {});
} else if (item?.type === 'checkbox') {
const input = $el("input.mr-footer-menu-checkbox-input", {
type: 'checkbox',
checked: Boolean(item.checked?.())
});
element = $el("label.mr-footer-menu-item.mr-footer-menu-checkbox", {
role: 'menuitemcheckbox',
'aria-checked': input.checked ? 'true' : 'false',
onclick: (event) => {
event.stopPropagation();
}
}, [
input,
$el("span", { textContent: item.label })
]);
input.addEventListener('change', () => {
item.action?.(input.checked);
element.setAttribute('aria-checked', input.checked ? 'true' : 'false');
});
} else {
const attributes = {
type: 'button',
role: 'menuitem',
onclick: (event) => {
event.stopPropagation();
if (element.disabled || element.classList.contains('mr-btn-is-disabled')) return;
item.action();
}
};
if (item.id) attributes.id = item.id;
if (item.ariaLabel) attributes['aria-label'] = item.ariaLabel;
element = $el("button.mr-footer-menu-item", attributes, [$el("span", { textContent: item.label })]);
}
if (item?.className) {
element.classList.add(...String(item.className).split(/\s+/).filter(Boolean));
}
if (item?.refName) {
this[item.refName] = element;
}
if (!isDivider && typeof item?.previewModels === 'function') {
const showPreview = () => {
if (element.disabled || element.classList.contains('mr-btn-is-disabled')) {
this.clearFooterMenuPreview();
return;
}
try {
this.previewFooterMenuModels(item.previewModels());
} catch (error) {
console.warn('Model Resolver: footer menu preview failed:', error);
this.clearFooterMenuPreview();
}
};
element.addEventListener('mouseenter', showPreview);
element.addEventListener('focus', showPreview);
element.addEventListener('mouseleave', () => this.clearFooterMenuPreview());
element.addEventListener('blur', () => this.clearFooterMenuPreview());
}
if (typeof item?.visibleWhen === 'function' || typeof item?.disabledWhen === 'function') {
if (!this.footerMenuItems) this.footerMenuItems = new Map();
if (!this.footerMenuItems.has(name)) this.footerMenuItems.set(name, []);
this.footerMenuItems.get(name).push({
element,
visibleWhen: item.visibleWhen,
disabledWhen: item.disabledWhen
});
if (typeof item.visibleWhen === 'function') {
element.style.display = item.visibleWhen() ? '' : 'none';
}
if (typeof item.disabledWhen === 'function') {
const disabled = item.disabledWhen();
element.disabled = disabled;
element.setAttribute('aria-disabled', String(disabled));
element.classList.toggle('mr-btn-is-disabled', disabled);
}
}
return element;
});
const menu = $el("div.mr-footer-menu", { role: 'menu' }, menuChildren);
this.footerMenuButtons.set(name, button);
this.footerMenus.set(name, menu);
return $el("div.mr-footer-menu-wrap", {}, [button, menu]);
},
updateFooterMenuItemVisibility() {
if (!this.footerMenuItems) return;
for (const entries of this.footerMenuItems.values()) {
for (const entry of entries) {
if (typeof entry.visibleWhen === 'function') {
entry.element.style.display = entry.visibleWhen() ? '' : 'none';
}
if (typeof entry.disabledWhen === 'function') {
const disabled = entry.disabledWhen();
entry.element.disabled = disabled;
entry.element.setAttribute('aria-disabled', String(disabled));
entry.element.classList.toggle('mr-btn-is-disabled', disabled);
}
}
}
},
createFooter() {
this.footerMenus = new Map();
this.footerMenuButtons = new Map();
this.footerMenuItems = new Map();
this.linkMenuButton = null;
this.linkMenuWrap = null;
this.queueExactButton = null;
this.applyPendingBtn = null;
this.clearSelectedButton = null;
this.autoResolveButton = null;
this.downloadMenuButton = null;
this.downloadAllButton = null;
const selectMenu = this.createFooterMenu('select', 'Select (0/0)', [
{ label: 'Select All', previewModels: () => this.missingModels || [], action: () => this.selectBatchMissingModels('all') },
{ label: 'Select None', previewModels: () => this.getSelectedMissingModels(), action: () => this.selectBatchMissingModels('none') },
{
label: 'Invert Selection',
previewModels: () => {
const selectedKeys = this.batchSelectedMissingKeys || new Set();
return (this.missingModels || []).filter(missing => !selectedKeys.has(this.getMissingModelKey(missing)));
},
action: () => this.selectBatchMissingModels('invert')
},
'divider',
{ label: 'Select Exact Local', previewModels: () => this.getMissingWithExactLocalMatches(), action: () => this.selectBatchMissingModels('exact') },
{ label: 'Select No Exact Local', previewModels: () => this.getMissingWithoutExactLocalMatches(), action: () => this.selectBatchMissingModels('no_exact') },
{ label: 'Select Partial Local', previewModels: () => this.getMissingWithPartialLocalMatches(), action: () => this.selectBatchMissingModels('partial') },
{ label: 'Select No Local Match', previewModels: () => this.getMissingWithoutLocalMatches(), action: () => this.selectBatchMissingModels('no_local') },
'divider',
{ label: 'Select With Download Source', previewModels: () => this.getMissingWithDownloadSources(), action: () => this.selectBatchMissingModels('downloadable') },
{ label: 'Select Without Download Source', previewModels: () => this.getMissingWithoutDownloadSources(), action: () => this.selectBatchMissingModels('no_download') },
'divider',
{ label: 'Select Searched', previewModels: () => this.getSearchedMissingModels(), action: () => this.selectBatchMissingModels('searched') },
{ label: 'Select Unsearched', previewModels: () => this.getUnsearchedMissingModels(), action: () => this.selectBatchMissingModels('unsearched') }
]);
this.selectMenuButton = this.footerMenuButtons.get('select');
this.selectMenuWrap = selectMenu;
const hasSelectedMissingModels = () => this.getSelectedMissingModels().length > 0;
const searchBatchOptions = () => ({ forceSearch: Boolean(this.forceBatchSearch) });
const searchMenu = this.createFooterMenu('search', 'Search', [
{ label: 'Stop Searching', className: 'mr-footer-menu-danger', visibleWhen: () => this.batchSearchRunning, action: () => this.stopBatchSearch() },
{ type: 'divider', visibleWhen: () => this.batchSearchRunning },
{
type: 'checkbox',
label: 'Force search (ignore cache)',
visibleWhen: () => !this.batchSearchRunning,
checked: () => Boolean(this.forceBatchSearch),
action: (checked) => {
this.forceBatchSearch = Boolean(checked);
}
},
{ type: 'divider', visibleWhen: () => !this.batchSearchRunning },
{ label: 'Search Selected', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'all', searchBatchOptions()) },
{ label: 'Search All Missing', visibleWhen: () => !this.batchSearchRunning, previewModels: () => this.missingModels || [], action: () => this.searchMissingBatch('all', 'all', searchBatchOptions()) },
{ label: 'Search Unsearched', visibleWhen: () => !this.batchSearchRunning, previewModels: () => this.getUnsearchedMissingModels(), action: () => this.searchMissingBatch('unsearched', 'all', searchBatchOptions()) },
{ type: 'divider', visibleWhen: () => !this.batchSearchRunning },
{ label: 'Selected: Local Database', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'local', searchBatchOptions()) },
{ label: 'Selected: CivitAI', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'civitai', searchBatchOptions()) },
{ label: 'Selected: HuggingFace', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'huggingface', searchBatchOptions()) },
{ label: 'Selected: CivArchive', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'civarchive', searchBatchOptions()) },
{ label: 'Selected: LoRA Manager Archive', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'lora_manager_archive', searchBatchOptions()) }
]);
this.searchMenuButton = this.footerMenuButtons.get('search');
this.searchMenuWrap = searchMenu;
const hasExactLocalMatches = () => this.getMissingWithExactLocalMatches(this.missingModels || []).length > 0;
const linkMenu = this.createFooterMenu('link', 'Link', [
{
label: 'Queue Exact',
refName: 'queueExactButton',
ariaLabel: 'Queue exact local matches',
previewModels: () => this.getQueueExactLocalMatchPreviewModels(),
action: () => this.queueExactLocalMatchesBatch('selected')
},
{
label: 'Apply Selected (0)',
id: 'apply-pending-resolutions',
refName: 'applyPendingBtn',
ariaLabel: 'Apply selected model links',
disabledWhen: () => (this.pendingResolutions?.length || 0) === 0,
previewModels: () => this.getMissingModelsForPendingResolutions(),
action: () => {
if ((this.pendingResolutions?.length || 0) === 0) return;
this.applyPendingResolutions();
}
},
{
label: 'Clear All Selected',
refName: 'clearSelectedButton',
ariaLabel: 'Clear all queued selected model links',
disabledWhen: () => (this.pendingResolutions?.length || 0) === 0,
previewModels: () => this.getMissingModelsForPendingResolutions(),
action: () => {
this.clearAllQueued();
this.closeFooterMenus();
}
},
{ type: 'divider', visibleWhen: hasExactLocalMatches },
{
label: 'Auto-Link 100%',
refName: 'autoResolveButton',
ariaLabel: 'Auto-link all 100 percent local matches',
visibleWhen: hasExactLocalMatches,
previewModels: () => this.getMissingWithExactLocalMatches(),
action: () => this.autoResolve100Percent()
}
]);
this.linkMenuButton = this.footerMenuButtons.get('link');
this.linkMenuWrap = linkMenu;
this.linkMenuButton.setAttribute('aria-label', 'Local link actions');
this.setTooltip(this.linkMenuButton, 'Queue exact local matches, apply queued links, or auto-link 100% matches.');
this.setTooltip(this.queueExactButton, 'Queue exact local matches for the selected rows, or all exact matches if nothing is selected.');
this.setTooltip(this.applyPendingBtn, 'Apply the model links you selected from local matches or search results.');
this.setTooltip(this.clearSelectedButton, 'Clear all queued model links without changing the workflow.');
this.setTooltip(this.autoResolveButton, 'Automatically link all missing models with a 100% local match.');
this.updateApplyPendingButton();
const downloadMenu = this.createFooterMenu('download', 'Download', [
{ label: 'Download Selected', disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getDownloadableBatchPreviewModels('selected'), action: () => this.downloadMissingBatch('selected') },
{ label: 'Download All With Sources', previewModels: () => this.getDownloadableBatchPreviewModels('all'), action: () => this.downloadMissingBatch('all') },
'divider',
{ label: 'Cancel Downloads', previewModels: () => this.getActiveDownloadMissingModels(), action: () => { this.closeFooterMenus(); this.cancelAllDownloads(); } }
], 'mr-btn-download');
this.downloadMenuButton = this.footerMenuButtons.get('download');
this.downloadMenuWrap = downloadMenu;
this.downloadAllButton = this.downloadMenuButton;
this.downloadAllButton.setAttribute('aria-label', 'Download missing models');
this.setTooltip(this.downloadAllButton, 'Download selected models or all missing models with known download sources.');
return $el("div.mr-footer", {}, [
selectMenu,
searchMenu,
linkMenu,
downloadMenu
]);
},
animateTabContentTransition() {
if (!this.contentElement?.animate) return;
try {
this.contentElement.animate(
[
{ opacity: 0.72, transform: 'translateY(7px)' },
{ opacity: 1, transform: 'translateY(0)' }
],
{
duration: 180,
easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)'
}
);
} catch (error) {
console.warn('Model Resolver: tab content animation failed', error);
}
},
/**
* Handle click on Download All / Cancel All button
*/
handleDownloadAllClick() {
if (Object.keys(this.activeDownloads).length > 0) {
// Cancel all active downloads
this.cancelAllDownloads();
} else {
// Start downloading all missing
this.downloadAllMissing();
}
},
/**
* Cancel all active downloads
*/
async cancelAllDownloads() {
const downloadIds = Object.keys(this.activeDownloads);
if (downloadIds.length === 0) return;
this.showNotification(`Cancelling ${downloadIds.length} download${downloadIds.length > 1 ? 's' : ''}...`, 'info');
for (const downloadId of downloadIds) {
try {
await api.fetchApi(`/model_resolver/cancel/${downloadId}`, {
method: 'POST'
});
} catch (error) {
console.error('Model Resolver: Error cancelling download:', error);
}
}
},
/**
* Update the Download All button state based on active downloads
*/
updateDownloadAllButtonState() {
if (!this.downloadAllButton) return;
if (this.downloadMenuButton && this.downloadAllButton === this.downloadMenuButton) {
this.updateBatchFooterButtons();
return;
}
const activeCount = Object.keys(this.activeDownloads).length;
if (activeCount > 0) {
this.downloadAllButton.innerHTML = `✕ Cancel All (${activeCount})`;
this.downloadAllButton.setAttribute(
'data-tooltip',
`Cancel ${activeCount} active download${activeCount > 1 ? 's' : ''}.`
);
this.downloadAllButton.setAttribute('aria-label', 'Cancel all active downloads');
this.downloadAllButton.classList.remove('mr-btn-download');
this.downloadAllButton.classList.add('mr-btn-danger');
} else {
this.downloadAllButton.innerHTML = `☁ Download All Missing`;
this.downloadAllButton.setAttribute(
'data-tooltip',
'Download every missing model that has a known download source.'
);
this.downloadAllButton.setAttribute('aria-label', 'Download all missing models');
this.downloadAllButton.classList.remove('mr-btn-danger');
this.downloadAllButton.classList.add('mr-btn-download');
}
}
};