feat: add missing models browser UI and logic

- Implement CSS styles for missing models browser interface (.ml-missing-browser and related classes)
- Add JavaScript methods for handling missing models:
  * getMissingModelKey, getMissingFilename, getBestLocalMatch
  * getMissingModelSummaryStats, getMissingSourceStatus
  * renderMissingSourcesSummary, hasRenderableSearchState
- Replace missing models rendering logic with new implementation
- Support for exact/partial match statistics and source status indicators
This commit is contained in:
Dariusz L
2026-05-26 23:53:32 +02:00
parent f3a4730a4e
commit 18b72316be
2 changed files with 612 additions and 127 deletions
+301
View File
@@ -222,6 +222,307 @@
color: var(--ml-text-muted);
}
.ml-missing-browser {
display: grid;
grid-template-columns: minmax(360px, 1.1fr) minmax(320px, 0.9fr);
gap: 10px;
height: 100%;
min-height: 0;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.015);
}
.ml-missing-list-pane,
.ml-missing-detail-pane {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.ml-missing-list-pane {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
border-right: 1px solid rgba(255,255,255,0.08);
background: rgba(20,20,20,0.92);
}
.ml-missing-list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid rgba(255,255,255,0.07);
}
.ml-missing-list-title {
font-size: 14px;
font-weight: 750;
color: var(--ml-text);
}
.ml-missing-list-meta {
margin-top: 3px;
font-size: 11px;
color: var(--ml-text-muted);
}
.ml-missing-list-stats {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
}
.ml-missing-stat {
display: inline-flex;
align-items: center;
min-height: 20px;
padding: 2px 7px;
border-radius: 999px;
background: rgba(255,255,255,0.055);
border: 1px solid rgba(255,255,255,0.08);
color: var(--ml-text-muted);
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
.ml-missing-stat-exact {
color: #72d38a;
}
.ml-missing-stat-partial {
color: #f1cf64;
}
.ml-missing-list-head,
#model-linker-modal .ml-missing-list-row {
display: grid;
grid-template-columns: 18px minmax(220px, 1.7fr) minmax(66px, 112px) minmax(150px, 1fr) 106px;
align-items: center;
gap: 8px;
}
.ml-missing-list-head {
padding: 8px 10px 8px 6px;
border-bottom: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.035);
color: var(--ml-text-muted);
font-size: 10px;
font-weight: 700;
}
.ml-missing-list {
min-height: 0;
overflow-y: auto;
scrollbar-width: auto;
}
#model-linker-modal .ml-missing-list-row {
width: 100%;
min-height: 50px;
padding: 7px 10px 7px 6px;
border: 0;
border-bottom: 1px solid rgba(255,255,255,0.055);
border-radius: 0;
background: transparent;
color: var(--ml-text);
cursor: pointer;
font-family: inherit;
font-size: 11px;
text-align: left;
transition: background 0.14s ease, box-shadow 0.14s ease;
}
#model-linker-modal .ml-missing-list-row:hover {
background: rgba(255,255,255,0.04);
}
#model-linker-modal .ml-missing-list-row.is-selected {
background: rgba(114,211,138,0.09);
box-shadow: inset 3px 0 0 rgba(114,211,138,0.92);
}
.ml-missing-row-index {
display: block;
width: 100%;
color: rgba(255,255,255,0.55);
font-size: 10px;
font-weight: 700;
text-align: center;
justify-self: stretch;
}
.ml-missing-row-model,
.ml-missing-row-match {
min-width: 0;
display: grid;
gap: 2px;
}
.ml-missing-row-model {
grid-template-columns: minmax(0, 1fr);
align-items: center;
}
.ml-missing-row-name,
.ml-missing-row-node,
.ml-missing-row-match span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ml-missing-row-name {
color: var(--ml-text);
font-size: 12px;
font-weight: 700;
}
.ml-missing-row-node {
grid-column: 1;
color: var(--ml-text-muted);
font-size: 10px;
}
.ml-missing-row-type {
justify-self: start;
max-width: 100%;
min-width: 0;
padding: 2px 7px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 999px;
background: rgba(255,255,255,0.065);
color: #d4d4d4;
font-size: 9px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.ml-missing-row-match {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
column-gap: 6px;
row-gap: 2px;
color: var(--ml-text-muted);
}
.ml-missing-row-match strong {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 2px 6px;
border-radius: 999px;
background: rgba(255,255,255,0.065);
font-size: 10px;
font-weight: 800;
line-height: 1;
white-space: nowrap;
}
.ml-missing-row-match span {
white-space: normal;
overflow: visible;
text-overflow: clip;
overflow-wrap: anywhere;
word-break: normal;
line-height: 1.25;
}
.ml-missing-row-match-exact strong {
color: #72d38a;
background: rgba(114,211,138,0.12);
}
.ml-missing-row-match-partial strong {
color: #f1cf64;
background: rgba(241,207,100,0.13);
}
.ml-missing-row-match-none strong {
color: rgba(255,255,255,0.42);
}
.ml-missing-row-sources {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 5px;
min-width: 0;
}
.ml-missing-source-dot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 999px;
background: rgba(255,255,255,0.035);
border: 1px solid rgba(255,255,255,0.12);
color: rgba(255,255,255,0.42);
font-size: 9px;
font-weight: 800;
}
.ml-missing-source-icon {
display: block;
width: 12px;
height: 12px;
flex: 0 0 12px;
}
.ml-missing-source-found {
color: #72d38a;
border-color: rgba(114,211,138,0.55);
background: rgba(114,211,138,0.11);
}
.ml-missing-source-none {
color: rgba(255,255,255,0.48);
}
.ml-missing-source-running,
.ml-missing-source-pending {
color: #8fd0ff;
border-color: rgba(143,208,255,0.55);
background: rgba(143,208,255,0.1);
}
.ml-missing-source-error {
color: #ff8a80;
border-color: rgba(255,138,128,0.55);
background: rgba(255,138,128,0.1);
}
.ml-missing-detail-pane {
overflow-y: auto;
background: rgba(18,18,18,0.9);
}
.ml-missing-detail-pane .ml-card {
margin: 0;
padding: 14px;
background: transparent;
box-shadow: none;
}
.ml-missing-detail-pane .ml-card:hover {
box-shadow: none;
}
.ml-missing-detail-pane .ml-columns {
grid-template-columns: minmax(0, 1fr);
}
.ml-missing-detail-pane .ml-column {
border-radius: 8px;
}
@media (max-width: 980px) {
.ml-missing-browser {
grid-template-columns: 1fr;
overflow-y: auto;
}
.ml-missing-list-pane {
max-height: 46vh;
border-right: 0;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.ml-missing-detail-pane {
overflow: visible;
}
}
@media (max-width: 760px) {
.ml-missing-list-head {
display: none;
}
#model-linker-modal .ml-missing-list-row {
grid-template-columns: 18px minmax(0, 1fr);
gap: 7px;
}
.ml-missing-row-index {
grid-row: 1 / span 4;
align-self: start;
margin-top: 1px;
text-align: center;
}
.ml-missing-row-model,
.ml-missing-row-type,
.ml-missing-row-match,
.ml-missing-row-sources {
grid-column: 2;
}
}
/* Card Styles */
.ml-card {
background: var(--ml-card-bg);
+311 -127
View File
@@ -28,6 +28,7 @@ class LinkerManagerDialog extends ComfyDialog {
this.searchProgressTimers = new Map();
this.cachedAnalysisData = null;
this.cachedWorkflowSignature = null;
this.selectedMissingModelKey = null;
this.boundHandleOutsideClick = this.handleOutsideClick.bind(this);
this.activeTabStorageKey = 'model_linker_active_tab';
this.activeTab = this.restoreActiveTab(); // Default tab
@@ -471,6 +472,7 @@ class LinkerManagerDialog extends ComfyDialog {
huggingface: 'huggingface',
civitai: 'civitai',
'lora-archive': 'archive',
lora_manager_archive: 'archive',
'lora-manager-archive': 'archive',
local: 'database',
'workflow-url': 'link',
@@ -3924,7 +3926,297 @@ class LinkerManagerDialog extends ComfyDialog {
}
}
}
getMissingModelKey(missing = {}) {
const nodeId = missing.node_id ?? '';
const widgetIndex = missing.widget_index ?? '';
const subgraphId = missing.subgraph_id || '';
const scope = missing.is_top_level !== false ? 'T' : 'F';
return `${nodeId}:${widgetIndex}:${subgraphId}:${scope}`;
}
getMissingFilename(missing = {}) {
return missing.original_path?.split('/').pop()?.split('\\').pop() || missing.name || 'Missing model';
}
getBestLocalMatch(missing = {}, minConfidence = 0) {
const matches = Array.isArray(missing.matches) ? missing.matches : [];
return matches
.filter(match => Number(match.confidence) >= minConfidence)
.sort((a, b) => Number(b.confidence || 0) - Number(a.confidence || 0))[0] || null;
}
getMissingModelSummaryStats(missingModels = []) {
return missingModels.reduce((stats, missing) => {
const best = this.getBestLocalMatch(missing, 70);
if (best?.confidence === 100) {
stats.exact += 1;
} else if (best) {
stats.partial += 1;
} else {
stats.none += 1;
}
return stats;
}, { exact: 0, partial: 0, none: 0 });
}
getMissingSourceStatus(missing = {}, source = '') {
const state = this.searchResultCache.get(this.getMissingSearchKey(missing));
const progress = state?.sourceProgress?.[source];
if (progress?.status) return progress.status;
const results = state?.results || {};
const resultKeys = {
local: 'model_list',
huggingface: 'huggingface',
civitai: 'civitai',
lora_manager_archive: 'lora_manager_archive'
};
if (resultKeys[source] && results[resultKeys[source]]) {
return 'found';
}
const downloadSource = missing.download_source || {};
const mappedDownloadSource = {
model_list: 'local',
popular: 'local',
workflow: 'civitai'
}[downloadSource.source] || downloadSource.source;
if (mappedDownloadSource === source && downloadSource.url) {
return 'found';
}
return 'idle';
}
renderMissingSourcesSummary(missing = {}) {
const sourceItems = [
{ source: 'local' },
{ source: 'huggingface' },
{ source: 'civitai' }
];
if (this.isSourceAvailable('lora_manager_archive')) {
sourceItems.push({ source: 'lora_manager_archive' });
}
return sourceItems.map(item => {
const status = this.getMissingSourceStatus(missing, item.source);
const statusClass = String(status || 'idle').replace(/[^a-z0-9_-]/gi, '');
const label = this.getSearchSourceLabel(item.source);
const statusLabels = {
pending: 'Queued',
running: 'Searching',
found: 'Found',
none: 'No match',
error: 'Error',
idle: 'Not searched'
};
const title = `${label}: ${statusLabels[status] || status}`;
const iconName = this.getSearchSourceIconName(item.source);
const iconHtml = getSvgIcon(iconName, 'currentColor', 'ml-missing-source-icon');
return `<span class="ml-missing-source-dot ml-missing-source-${statusClass}" title="${this.escapeHtml(title)}" aria-label="${this.escapeHtml(title)}">${iconHtml}</span>`;
}).join('');
}
hasRenderableSearchState(state = {}) {
return Boolean(
(state.lastAttemptSources || []).length
|| Object.keys(state.sourceProgress || {}).length
|| this.hasSearchResults(state.results || {})
|| state.lastAttemptError
);
}
renderMissingModelsBrowser(missingModels, selectedKey, totalMissing, activeCount, hasAny100Match) {
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`
: (hasAny100Match ? 'Auto-link ready for exact matches' : 'Review matches or search online');
let html = `
<div class="ml-missing-browser">
<section class="ml-missing-list-pane" aria-label="Missing model list">
<div class="ml-missing-list-toolbar">
<div>
<div class="ml-missing-list-title">${totalMissing} missing model${totalMissing === 1 ? '' : 's'}</div>
<div class="ml-missing-list-meta">${this.escapeHtml(activeHint)}</div>
</div>
<div class="ml-missing-list-stats">
<span class="ml-missing-stat ml-missing-stat-exact">${stats.exact} exact</span>
<span class="ml-missing-stat ml-missing-stat-partial">${stats.partial} partial</span>
<span class="ml-missing-stat ml-missing-stat-none">${stats.none} no match</span>
</div>
</div>
<div class="ml-missing-list-head">
<span>ID</span>
<span>Missing Model</span>
<span>Type</span>
<span>Best Local Match</span>
<span>Sources</span>
</div>
<div class="ml-missing-list">
`;
missingModels.forEach((missing, index) => {
const key = this.getMissingModelKey(missing);
const isSelected = key === selectedKey;
const filename = this.getMissingFilename(missing);
const formattedFilename = this.formatFilename(filename, 46);
const bestMatch = this.getBestLocalMatch(missing, 70);
const confidence = bestMatch ? Number(bestMatch.confidence || 0) : 0;
const matchName = bestMatch?.model?.relative_path || bestMatch?.filename || bestMatch?.path || '';
const matchDisplay = matchName || 'Local match';
const matchClass = confidence === 100 ? 'exact' : (bestMatch ? 'partial' : 'none');
const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown';
const nodeLabel = missing.subgraph_name || missing.node_type || 'Node';
html += `
<button type="button"
class="ml-missing-list-row ${isSelected ? 'is-selected' : ''}"
data-missing-key="${this.escapeHtml(key)}">
<span class="ml-missing-row-index">${index + 1}</span>
<span class="ml-missing-row-model">
<span class="ml-missing-row-name" title="${this.escapeHtml(filename)}">${this.escapeHtml(formattedFilename.display)}</span>
<span class="ml-missing-row-node">${this.escapeHtml(nodeLabel)} #${this.escapeHtml(String(missing.node_id || ''))}</span>
</span>
<span class="ml-missing-row-type">${this.escapeHtml(typeLabel)}</span>
<span class="ml-missing-row-match ml-missing-row-match-${matchClass}">
${bestMatch ? `<strong>${confidence.toFixed(confidence % 1 ? 1 : 0)}%</strong><span title="${this.escapeHtml(matchDisplay)}">${this.escapeHtml(matchDisplay)}</span>` : '<strong>--</strong><span>No local match</span>'}
</span>
<span class="ml-missing-row-sources">${this.renderMissingSourcesSummary(missing)}</span>
</button>
`;
});
html += `
</div>
</section>
<section class="ml-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;
}
wireMissingModelsBrowser(container, data, sortedMissingModels) {
container.querySelectorAll('.ml-missing-list-row').forEach(row => {
row.addEventListener('click', () => {
const key = row.dataset.missingKey;
if (!key || key === this.selectedMissingModelKey) return;
this.selectedMissingModelKey = key;
this.displayMissingModels(container, data);
});
});
const selectedMissing = sortedMissingModels.find(missing => this.getMissingModelKey(missing) === this.selectedMissingModelKey);
if (!selectedMissing) return;
const selectedIndex = sortedMissingModels.indexOf(selectedMissing);
this.wireMissingModelDetail(container, selectedMissing, selectedIndex);
}
wireMissingModelDetail(container, missing, missingIndex) {
this.wireLocalMatchButtons(container, missing, missingIndex);
this.wireDownloadSearchPanel(container, missing);
const locateId = `locate-${missing.node_id}-${missing.widget_index}`;
const locateBtn = container.querySelector(`#${locateId}`);
if (locateBtn && missing.is_top_level !== false) {
locateBtn.addEventListener('click', () => {
this.locateNodeInGraph(missing.node_id);
});
}
const comboId = `combo-${missing.node_id}-${missing.widget_index}`;
const comboInput = container.querySelector(`#combo-input-${comboId}`);
const comboList = container.querySelector(`#combo-list-${comboId}`);
const comboRefresh = container.querySelector(`#combo-refresh-${comboId}`);
if (comboList) {
this.enableWheelScrollChaining(comboList);
}
const allModels = Array.isArray(this.allModels) ? this.allModels : [];
const buildLabel = (m) => `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}`;
const getFolder = (m) => m.path || m.base_directory || '';
const populateComboOptions = (filterText, highlightIdx = -1) => {
if (!comboList) return;
const f = (filterText || '').toLowerCase();
const filtered = f
? allModels.filter(m => buildLabel(m).toLowerCase().includes(f))
: allModels.slice();
let html = '';
for (let i = 0; i < filtered.length; i++) {
const m = filtered[i];
const label = buildLabel(m);
const folder = getFolder(m);
const isHighlighted = i === highlightIdx;
const folderDisplay = folder ? folder.replace(/\\/g, '/').replace(/:/, '') : '';
html += `<div data-idx="${allModels.indexOf(m)}" class="ml-combo-option ${isHighlighted ? 'is-highlighted' : ''}">`;
html += `<div class="ml-combo-option-row">`;
html += `<code>${this.escapeHtml(label)}</code>`;
html += `</div>`;
if (folderDisplay) {
html += `<div class="ml-combo-folder" title="${this.escapeHtml(folderDisplay)}">Folder ${this.escapeHtml(folderDisplay)}</div>`;
}
html += `</div>`;
}
comboList.innerHTML = html;
comboList.querySelectorAll('div[data-idx]').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.idx, 10);
if (!Number.isNaN(idx) && idx >= 0 && idx < allModels.length) {
const chosenModel = allModels[idx];
if (chosenModel) {
this.queueResolution(missing, chosenModel);
}
}
});
});
};
if (comboList) {
populateComboOptions('');
}
if (comboInput) {
const debouncedFilter = this.debounce(() => {
populateComboOptions(comboInput.value);
}, 200);
comboInput.addEventListener('input', debouncedFilter);
comboInput.addEventListener('focus', () => {
if (comboList) comboList.style.display = 'block';
populateComboOptions(comboInput.value);
});
comboInput.addEventListener('blur', () => {
setTimeout(() => {
if (comboList) comboList.style.display = 'none';
}, 200);
});
}
if (comboRefresh) {
comboRefresh.addEventListener('click', async () => {
this.allModels = null;
await this.ensureAllModelsLoaded();
populateComboOptions(comboInput?.value || '');
});
}
const state = this.searchResultCache.get(this.getMissingSearchKey(missing));
const searchResultsDiv = container.querySelector(`#search-results-${missing.node_id}-${missing.widget_index}`);
if (state && searchResultsDiv && this.hasRenderableSearchState(state)) {
searchResultsDiv.style.display = 'block';
this.displaySearchResults(missing, state, searchResultsDiv);
}
}
/**
* Display missing models in the dialog
*/
@@ -3965,20 +4257,6 @@ class LinkerManagerDialog extends ComfyDialog {
return;
}
// Summary header with count
let html = `
<div class="ml-missing-summary">
<div class="ml-missing-summary-title">
<span class="ml-missing-summary-count">${totalMissing} Missing Model${totalMissing > 1 ? 's' : ''}</span>
<span class="ml-missing-summary-meta">Compact relinking and download view</span>
</div>
<div class="ml-missing-summary-meta">
${activeCount > 0 ? `${activeCount} downloading` : (hasAny100Match ? 'Auto-Link ready for exact matches' : 'Review matches or search online')}
</div>
</div>
`;
html += '<div class="ml-stack-md">';
// Skip rendering if active tab is not "missing"
if (this.activeTab !== 'missing') {
container.innerHTML = '';
@@ -3986,7 +4264,7 @@ class LinkerManagerDialog extends ComfyDialog {
}
// Sort missing models: those with 100% confidence matches first, then others
const sortedMissingModels = missingModels.sort((a, b) => {
const sortedMissingModels = [...missingModels].sort((a, b) => {
const aMatches = a.matches || [];
const bMatches = b.matches || [];
@@ -4011,122 +4289,28 @@ class LinkerManagerDialog extends ComfyDialog {
for (let mi = 0; mi < sortedMissingModels.length; mi++) {
sortedMissingModels[mi].__displayIndex = mi;
html += this.renderMissingModel(sortedMissingModels[mi], mi);
}
html += '</div>';
container.innerHTML = html;
const selectedStillExists = sortedMissingModels.some(missing => (
this.getMissingModelKey(missing) === this.selectedMissingModelKey
));
if (!selectedStillExists) {
this.selectedMissingModelKey = sortedMissingModels.length
? this.getMissingModelKey(sortedMissingModels[0])
: null;
}
// Attach event listeners for resolve buttons (use sorted order)
// Note: We need to match the exact same logic as renderMissingModel to find which buttons were rendered
sortedMissingModels.forEach((missing, missingIndex) => {
this.wireLocalMatchButtons(container, missing, missingIndex);
this.wireDownloadSearchPanel(container, missing);
// Wire locate chip (only for top-level nodes)
const locateId = `locate-${missing.node_id}-${missing.widget_index}`;
const locateBtn = container.querySelector(`#${locateId}`);
if (locateBtn && missing.is_top_level !== false) {
locateBtn.addEventListener('click', () => {
this.locateNodeInGraph(missing.node_id);
});
}
// Wire up all-models search + dropdown (combo-style)
const comboId = `combo-${missing.node_id}-${missing.widget_index}`;
const comboInput = container.querySelector(`#combo-input-${comboId}`);
const comboList = container.querySelector(`#combo-list-${comboId}`);
const comboRefresh = container.querySelector(`#combo-refresh-${comboId}`);
if (comboList) {
this.enableWheelScrollChaining(comboList);
}
const allModels = Array.isArray(this.allModels) ? this.allModels : [];
const buildLabel = (m) => `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}`;
const getFolder = (m) => m.path || m.base_directory || '';
// Populate dropdown with filtered models
const populateComboOptions = (filterText, highlightIdx = -1) => {
if (!comboList) return;
const f = (filterText || '').toLowerCase();
const filtered = f
? allModels.filter(m => buildLabel(m).toLowerCase().includes(f))
: allModels.slice(); // Copy to avoid mutation
let html = '';
for (let i = 0; i < filtered.length; i++) {
const m = filtered[i];
const label = buildLabel(m);
const folder = getFolder(m);
const isHighlighted = i === highlightIdx;
const folderDisplay = folder ? folder.replace(/\\/g, '/').replace(/:/, '') : '';
html += `<div data-idx="${allModels.indexOf(m)}" class="ml-combo-option ${isHighlighted ? 'is-highlighted' : ''}">`;
html += `<div class="ml-combo-option-row">`;
html += `<code>${label}</code>`;
html += `</div>`;
if (folderDisplay) {
html += `<div class="ml-combo-folder" title="${folderDisplay}">📁 ${folderDisplay}</div>`;
}
html += `</div>`;
}
comboList.innerHTML = html;
// Add click listeners to options
comboList.querySelectorAll('div[data-idx]').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.idx, 10);
if (!isNaN(idx) && idx >= 0 && idx < allModels.length) {
const chosenModel = allModels[idx];
if (chosenModel) {
this.queueResolution(missing, chosenModel);
}
}
});
});
};
// Initial populate
if (comboList) {
populateComboOptions('');
}
// Filter input with debounce
if (comboInput) {
const debouncedFilter = this.debounce(() => {
populateComboOptions(comboInput.value);
}, 200);
comboInput.addEventListener('input', debouncedFilter);
// Show dropdown on focus
comboInput.addEventListener('focus', () => {
if (comboList) comboList.style.display = 'block';
populateComboOptions(comboInput.value);
});
// Close on blur (with delay to allow click)
comboInput.addEventListener('blur', () => {
setTimeout(() => {
if (comboList) comboList.style.display = 'none';
}, 200);
});
}
// Refresh button - reload all models
if (comboRefresh) {
comboRefresh.addEventListener('click', async () => {
this.allModels = null; // Force reload
await this.ensureAllModelsLoaded();
populateComboOptions(comboInput?.value || '');
});
}
});
this.missingModels = sortedMissingModels;
container.innerHTML = this.renderMissingModelsBrowser(
sortedMissingModels,
this.selectedMissingModelKey,
totalMissing,
activeCount,
hasAny100Match
);
this.wireMissingModelsBrowser(container, data, sortedMissingModels);
}
/**
* Render a single missing model entry
*/
renderMissingModel(missing, missingIndex = 0) {
const allMatches = missing.matches || [];