frontend: preserve missing models browser state during apply and avoid duplicate workflow refreshes

This commit is contained in:
Dariusz L
2026-08-04 19:13:13 +02:00
parent b5518f0d57
commit f1620a7659
6 changed files with 296 additions and 281 deletions
+84 -68
View File
@@ -1757,91 +1757,83 @@ test('Apply defers model catalog refresh until after the linking path returns',
assert.deepEqual(calls, ['catalog', 'refresh:node-widget-change']);
});
test('background Apply validation uses the Missing Models workflow signature', async () => {
const refreshAnalysisInBackground = eval(
`(${extractMethod(queueMethodsSource, 'refreshAnalysisInBackground')})`
test('Apply does not schedule a duplicate workflow refresh when analysis is already current', async () => {
const scheduleComfyModelCatalogRefreshAfterApply = eval(
`(${extractMethod(workflowUpdateMethodsSource, 'scheduleComfyModelCatalogRefreshAfterApply')})`
);
const workflow = { nodes: [{ id: 7, widgets_values: ['linked.safetensors', 1.25] }] };
const returnedData = { missing_models: [], resolved_models: [{ node_id: 7 }] };
const calls = [];
let finish;
const finished = new Promise(resolve => {
finish = resolve;
});
const workflow = { nodes: [{ id: 7, widgets_values: ['linked.safetensors'] }] };
const dialog = {
activeWorkflowSignature: 'different-full-signature',
activeMissingWorkflowSignature: 'linked-missing-signature',
cachedWorkflowSignature: null,
cachedAnalysisData: null,
activeTab: 'loaded',
runWithWorkflowRefreshSuppressed: async callback => callback(),
cachedWorkflowSignature: 'current-signature',
cachedAnalysisData: { missing_models: [], resolved_models: [] },
getMissingWorkflowSignature(value) {
assert.equal(value, workflow);
return 'linked-missing-signature';
return 'current-signature';
},
async fetchJson(url) {
calls.push(url);
return returnedData;
async refreshComfyModelCatalogAfterApply() {
calls.push('catalog');
return true;
},
applyResolvedSelectionAliasesToAnalysisData(data) {
calls.push(data);
},
saveAnalysisCacheForActiveWorkflow() {
calls.push('save');
scheduleActiveWorkflowRefresh() {
calls.push('refresh');
},
};
await refreshAnalysisInBackground.call(dialog, workflow);
assert.equal(dialog.cachedWorkflowSignature, 'linked-missing-signature');
assert.equal(dialog.cachedAnalysisData, returnedData);
assert.deepEqual(calls, [
'/model_resolver/analyze',
returnedData,
'save',
]);
scheduleComfyModelCatalogRefreshAfterApply.call(dialog, workflow, []);
setTimeout(() => finish(), 0);
await finished;
assert.deepEqual(calls, ['catalog']);
});
test('Apply selected model preserves Missing Models browser geometry during both refresh stages', async () => {
const applyOptimisticAnalysisData = eval(
`(${extractMethod(queueMethodsSource, 'applyOptimisticAnalysisData')})`
test('Apply reuses the content-preserving workflow analysis path', () => {
const applyPendingResolutionList = extractMethod(
queueMethodsSource,
'applyPendingResolutionList'
);
const refreshAnalysisInBackground = eval(
`(${extractMethod(queueMethodsSource, 'refreshAnalysisInBackground')})`
assert.match(
applyPendingResolutionList,
/loadWorkflowData\?\.\(data\.workflow,\s*\{\s*preserveContent: true/
);
const contentElement = {};
const workflow = { nodes: [{ id: 7, widgets_values: ['linked.safetensors'] }] };
const optimisticData = { missing_models: [], resolved_models: [{ node_id: 7, optimistic: true }] };
const analyzedData = { missing_models: [], resolved_models: [{ node_id: 7, optimistic: false }] };
const renderCalls = [];
assert.doesNotMatch(applyPendingResolutionList, /applyOptimisticAnalysisData\(/);
assert.doesNotMatch(applyPendingResolutionList, /refreshAnalysisInBackground\(/);
});
test('Apply keeps the workflow slot key when resolved path identity changes', () => {
const doesResolvedModelMatchAlias = eval(
`(${extractMethod(queueMethodsSource, 'doesResolvedModelMatchAlias')})`
);
const applyResolvedSelectionAliasesToAnalysisData = eval(
`(${extractMethod(queueMethodsSource, 'applyResolvedSelectionAliasesToAnalysisData')})`
);
const data = {
resolved_models: [{
node_id: 7,
widget_index: 0,
category: 'loras',
missing_key: 'backend-path-key',
original_path: 'different/backend/path.safetensors',
}],
};
const dialog = {
activeTab: 'missing',
contentElement,
activeMissingWorkflowSignature: 'linked-missing-signature',
cachedWorkflowSignature: null,
cachedAnalysisData: null,
applyResolvedSelectionAliasesToAnalysisData() {},
getMissingWorkflowSignature() {
return 'linked-missing-signature';
},
getCurrentWorkflow() {
return workflow;
},
cloneAnalysisData(data) {
return data;
},
saveAnalysisCacheForActiveWorkflow() {},
displayMissingModels(...args) {
renderCalls.push(args);
},
reconnectActiveDownloads() {},
async fetchJson() {
return analyzedData;
},
appliedResolvedSelectionAliases: new Map([
['stable-slot-key', {
category: 'loras',
refs: [{ node_id: 7, widget_index: 0 }],
}],
]),
doesResolvedModelMatchAlias,
};
applyOptimisticAnalysisData.call(dialog, optimisticData, workflow);
await refreshAnalysisInBackground.call(dialog, workflow);
applyResolvedSelectionAliasesToAnalysisData.call(dialog, data);
assert.deepEqual(renderCalls, [
[contentElement, optimisticData, { preserveBrowser: true }],
[contentElement, analyzedData, { preserveBrowser: true }],
]);
assert.equal(data.resolved_models[0].missing_key, 'stable-slot-key');
assert.equal(dialog.appliedResolvedSelectionAliases.size, 0);
});
test('background Loaded Models refresh keeps the current view until new data is ready', async () => {
@@ -2150,6 +2142,14 @@ test('content-preserving Missing Models refresh patches the browser instead of c
missingBrowserMethodsSource,
'patchMissingModelsBrowserElement'
);
const patchMissingModelRowElement = extractMethod(
missingBrowserMethodsSource,
'patchMissingModelRowElement'
);
const setupMissingModelsVirtualizer = extractMethod(
missingBrowserMethodsSource,
'setupMissingModelsVirtualizer'
);
const displayMissingModels = extractMethod(
missingBrowserMethodsSource,
'displayMissingModels'
@@ -2157,7 +2157,23 @@ test('content-preserving Missing Models refresh patches the browser instead of c
assert.match(
patchMissingModelsBrowserElement,
/currentRow\?\.outerHTML === nextRow\.outerHTML/
/patchMissingModelRowElement\(currentRow, nextRow\)/
);
assert.match(
patchMissingModelRowElement,
/currentRow\.innerHTML = nextRow\.innerHTML/
);
assert.match(
patchMissingModelRowElement,
/_wiredMissingModelRows\?\.delete\?\./
);
assert.match(
setupMissingModelsVirtualizer,
/patchMissingModelRowElement\(currentRow, nextRow\)/
);
assert.doesNotMatch(
setupMissingModelsVirtualizer,
/rowsHost\.innerHTML/
);
assert.doesNotMatch(
patchMissingModelsBrowserElement,
+42 -203
View File
@@ -2114,6 +2114,7 @@ export const queueMethods = {
return null;
}
let applyRefreshSuppressionStarted = false;
try {
const appliedSelections = this.clonePendingResolutions?.(list) || JSON.parse(JSON.stringify(list));
const applyResolutions = this.expandPendingResolutionsForApply(appliedSelections);
@@ -2132,7 +2133,8 @@ export const queueMethods = {
body: JSON.stringify({ workflow, resolutions: applyResolutions })
}, 'Apply resolutions');
if (data.success) {
const optimisticData = this.getOptimisticAnalysisDataAfterApply(appliedSelections);
this.beginApplyWorkflowRefreshSuppression?.();
applyRefreshSuppressionStarted = true;
await this.updateWorkflowInComfyUI(data.workflow, applyResolutions);
this.rememberAppliedResolvedSelections(appliedSelections);
@@ -2153,21 +2155,34 @@ export const queueMethods = {
this.updateApplyPendingButton();
this.updateQueuePanel();
this.applyOptimisticAnalysisData(optimisticData, data.workflow);
const selectionCount = appliedSelections.length;
const refText = applyResolutions.length > selectionCount
? ` (${applyResolutions.length} references)`
: '';
this.showNotification(`✓ Linked ${selectionCount} selection${selectionCount > 1 ? 's' : ''}${refText}`, 'success');
this.scheduleComfyModelCatalogRefreshAfterApply?.(data.workflow, applyResolutions);
this.refreshAnalysisInBackground(data.workflow, this.getMissingWorkflowSignature(data.workflow));
const releaseApplyRefreshSuppression = () => {
if (!applyRefreshSuppressionStarted) return;
applyRefreshSuppressionStarted = false;
this.endApplyWorkflowRefreshSuppression?.();
};
const workflowRefresh = this.loadWorkflowData?.(data.workflow, {
preserveContent: true
});
Promise.resolve(workflowRefresh).then(
releaseApplyRefreshSuppression,
releaseApplyRefreshSuppression
);
return data.workflow || null;
} else {
this.showNotification('Failed to apply selections: ' + (data.error || 'Unknown error'), 'error');
return null;
}
} catch (e) {
if (applyRefreshSuppressionStarted) {
applyRefreshSuppressionStarted = false;
this.endApplyWorkflowRefreshSuppression?.();
}
console.error('Model Resolver: applyPendingResolutions error', e);
this.showNotification('Error applying selections: ' + e.message, 'error');
return null;
@@ -2275,43 +2290,6 @@ export const queueMethods = {
return (this.missingModels || []).filter(missing => activeKeys.has(this.getMissingModelKey(missing)));
},
getResolvedSelectionPathCandidates(selection = {}) {
const model = selection.resolved_model || {};
return [
model.relative_path,
model.filename,
model.name,
model.path,
model.resolved_path,
selection.resolved_path
].filter(Boolean);
},
normalizeResolvedSelectionToken(value = '', { basename = false, stem = false } = {}) {
let text = this.normalizePathToForward(value).toLowerCase();
if (!text) return '';
if (basename) {
text = text.split('/').pop() || text;
}
if (stem) {
text = this.stripModelExtension(text);
}
return text;
},
getResolvedSelectionTokenSet(values = []) {
const tokens = new Set();
for (const value of values) {
const normalized = this.normalizeResolvedSelectionToken(value);
const basename = this.normalizeResolvedSelectionToken(value, { basename: true });
const stem = this.normalizeResolvedSelectionToken(value, { basename: true, stem: true });
if (normalized) tokens.add(normalized);
if (basename) tokens.add(basename);
if (stem) tokens.add(stem);
}
return tokens;
},
getResolvedSelectionRefs(selection = {}) {
const refs = Array.isArray(selection.node_refs) && selection.node_refs.length
? selection.node_refs
@@ -2337,44 +2315,41 @@ export const queueMethods = {
key,
category: selection.category || '',
searchKey: selection.missing_search_key || selection.search_key || '',
refs: this.getResolvedSelectionRefs(selection),
tokens: this.getResolvedSelectionTokenSet(this.getResolvedSelectionPathCandidates(selection))
refs: this.getResolvedSelectionRefs(selection)
});
}
},
doesResolvedModelMatchAlias(model = {}, alias = {}) {
const refs = Array.isArray(alias.refs) ? alias.refs : [];
const modelNodeId = model.node_id ?? '';
const modelWidgetIndex = model.widget_index ?? '';
const modelSubgraphId = model.subgraph_id ?? '';
const modelScope = model.is_top_level !== false;
const modelNestedKey = model.nested_key || '';
const refMatch = refs.some(ref => (
String(ref.node_id ?? '') === String(modelNodeId)
&& String(ref.widget_index ?? '') === String(modelWidgetIndex)
&& String(ref.subgraph_id ?? '') === String(modelSubgraphId)
&& (ref.is_top_level !== false) === modelScope
&& String(ref.nested_key || '') === String(modelNestedKey)
));
const modelRefs = [
model,
...(Array.isArray(model.all_node_refs) ? model.all_node_refs : [])
];
const refMatch = modelRefs.some(modelRef => {
const modelNodeId = modelRef?.node_id ?? '';
const modelWidgetIndex = modelRef?.widget_index ?? '';
const modelSubgraphId = modelRef?.subgraph_id ?? '';
const modelScope = modelRef?.is_top_level !== false;
const modelNestedKey = modelRef?.nested_key || '';
return refs.some(ref => (
String(ref.node_id ?? '') === String(modelNodeId)
&& String(ref.widget_index ?? '') === String(modelWidgetIndex)
&& String(ref.subgraph_id ?? '') === String(modelSubgraphId)
&& (ref.is_top_level !== false) === modelScope
&& String(ref.nested_key || '') === String(modelNestedKey)
));
});
if (!refMatch) return false;
const aliasCategory = String(alias.category || '').toLowerCase();
const modelCategory = String(model.category || '').toLowerCase();
if (aliasCategory && modelCategory && aliasCategory !== modelCategory) return false;
const modelTokens = this.getResolvedSelectionTokenSet([
model.original_path,
model.name,
model.filename,
model.relative_path,
model.full_path,
model.path
]);
for (const token of alias.tokens || []) {
if (modelTokens.has(token)) return true;
}
return false;
// Workflow position is the stable identity after Apply. The backend may
// return a resolved model with a different path representation, so the
// matching slot is sufficient to restore the optimistic missing_key.
return true;
},
applyResolvedSelectionAliasesToAnalysisData(data = null) {
@@ -2386,7 +2361,6 @@ export const queueMethods = {
const matchedKeys = new Set();
for (const model of resolvedModels) {
if (model?.missing_key) continue;
for (const [key, alias] of this.appliedResolvedSelectionAliases.entries()) {
if (matchedKeys.has(key)) continue;
if (!this.doesResolvedModelMatchAlias(model, alias)) continue;
@@ -2405,141 +2379,6 @@ export const queueMethods = {
return data;
},
buildOptimisticResolvedModel(selection = {}, sourceMissing = null) {
const resolvedModel = selection.resolved_model || {};
const resolvedRelativePath = resolvedModel.relative_path
|| resolvedModel.filename
|| selection.resolved_path
|| sourceMissing?.original_path
|| selection.original_path
|| '';
return {
...(sourceMissing || {}),
missing_key: this.getResolutionQueueKey(selection),
missing_search_key: selection.missing_search_key
|| sourceMissing?.missing_search_key
|| (sourceMissing ? this.getMissingSearchKey?.(sourceMissing) : ''),
__isOptimisticResolved: true,
node_id: selection.node_id ?? sourceMissing?.node_id,
widget_index: selection.widget_index ?? sourceMissing?.widget_index,
category: selection.category || sourceMissing?.category || resolvedModel.category || 'unknown',
original_path: resolvedRelativePath,
name: resolvedRelativePath,
filename: resolvedModel.filename || this.getFilenameFromPath(resolvedRelativePath) || resolvedRelativePath,
relative_path: resolvedRelativePath,
full_path: resolvedModel.path || selection.resolved_path || sourceMissing?.full_path || '',
path: resolvedModel.path || selection.resolved_path || '',
matches: [{
confidence: 100,
match_type: 'exact',
filename: resolvedModel.filename || resolvedRelativePath,
path: resolvedModel.path || selection.resolved_path || '',
model: {
...resolvedModel,
path: resolvedModel.path || selection.resolved_path || '',
relative_path: resolvedRelativePath,
filename: resolvedModel.filename || resolvedRelativePath
}
}]
};
},
getOptimisticAnalysisDataAfterApply(appliedSelections = []) {
const missingModels = Array.isArray(this.missingModels) ? this.missingModels : [];
const appliedKeys = new Set(
(Array.isArray(appliedSelections) ? appliedSelections : [])
.map(selection => this.getResolutionQueueKey(selection))
);
if (!appliedKeys.size) return null;
const sourceData = this.cachedAnalysisData || {
missing_models: missingModels,
total_missing: missingModels.length
};
const sourceMissing = Array.isArray(sourceData.missing_models)
? sourceData.missing_models
: missingModels;
const nextMissing = sourceMissing.filter(missing => !appliedKeys.has(this.getMissingModelKey(missing)));
const currentResolved = Array.isArray(sourceData.resolved_models) ? sourceData.resolved_models : [];
const optimisticResolved = [];
for (const selection of Array.isArray(appliedSelections) ? appliedSelections : []) {
const key = this.getResolutionQueueKey(selection);
const source = sourceMissing.find(missing => this.getMissingModelKey(missing) === key)
|| missingModels.find(missing => this.getMissingModelKey(missing) === key)
|| null;
optimisticResolved.push(this.buildOptimisticResolvedModel(selection, source));
}
const optimisticKeys = new Set(optimisticResolved.map(model => model.missing_key).filter(Boolean));
const nextResolved = [
...currentResolved.filter(model => !optimisticKeys.has(this.getMissingModelKey(model))),
...optimisticResolved
];
return {
...sourceData,
missing_models: nextMissing,
resolved_models: nextResolved,
total_missing: nextMissing.length
};
},
applyOptimisticAnalysisData(data, workflow = null) {
if (!data) return false;
this.applyResolvedSelectionAliasesToAnalysisData(data);
const workflowSignature = this.getMissingWorkflowSignature(workflow || this.getCurrentWorkflow());
if (workflowSignature) {
this.cachedWorkflowSignature = workflowSignature;
}
this.cachedAnalysisData = this.cloneAnalysisData?.(data) || data;
this.saveAnalysisCacheForActiveWorkflow?.();
if (this.activeTab === 'missing' && this.contentElement) {
this.displayMissingModels(
this.contentElement,
data,
{ preserveBrowser: true }
);
this.reconnectActiveDownloads?.();
} else {
this.missingModels = Array.isArray(data.missing_models) ? data.missing_models : [];
this.updateBatchFooterButtons?.();
}
return true;
},
async refreshAnalysisInBackground(workflow, expectedSignature = null) {
if (!workflow) return;
const signature = expectedSignature || this.getMissingWorkflowSignature(workflow);
try {
const data = await this.fetchJson('/model_resolver/analyze', {
method: 'POST',
body: JSON.stringify({ workflow }),
silent: true
}, 'Background analysis');
if (signature && this.activeMissingWorkflowSignature !== signature) return;
this.applyResolvedSelectionAliasesToAnalysisData(data);
this.cachedWorkflowSignature = signature || this.cachedWorkflowSignature;
this.cachedAnalysisData = data;
this.saveAnalysisCacheForActiveWorkflow?.();
if (this.activeTab === 'missing' && this.contentElement && !this._analysisProgressToken) {
this.displayMissingModels(
this.contentElement,
data,
{ preserveBrowser: true }
);
this.reconnectActiveDownloads?.();
}
} catch (error) {
console.warn('Model Resolver: background analysis refresh failed:', error);
}
},
previewFooterMenuModels(models = []) {
const keys = new Set(
(Array.isArray(models) ? models : [])
+5 -1
View File
@@ -2695,7 +2695,11 @@ export const searchPanelMethods = {
Promise.allSettled(refreshTasks).then(() => {
if (!container.isConnected || this.activeTab !== 'missing') return;
this.displayMissingModels(container, data);
this.displayMissingModels(
container,
this.cachedAnalysisData || data,
{ preserveBrowser: true }
);
});
}
};
+29 -2
View File
@@ -770,8 +770,32 @@ export const workflowStateMethods = {
this.updateQueueVisibility();
},
beginApplyWorkflowRefreshSuppression() {
this._applyWorkflowRefreshSuppressionDepth = Number(
this._applyWorkflowRefreshSuppressionDepth || 0
) + 1;
this._workflowRefreshGeneration = (this._workflowRefreshGeneration || 0) + 1;
if (this._workflowRefreshTimer) {
clearTimeout(this._workflowRefreshTimer);
this._workflowRefreshTimer = null;
}
if (this._workflowRefreshRetryTimer) {
clearTimeout(this._workflowRefreshRetryTimer);
this._workflowRefreshRetryTimer = null;
}
this._workflowDataLoadToken = null;
this._analysisProgressToken = null;
},
endApplyWorkflowRefreshSuppression() {
this._applyWorkflowRefreshSuppressionDepth = Math.max(
0,
Number(this._applyWorkflowRefreshSuppressionDepth || 0) - 1
);
},
scheduleActiveWorkflowRefresh(reason = 'workflow-change') {
if (!this.isVisible()) return;
if (!this.isVisible() || Number(this._applyWorkflowRefreshSuppressionDepth || 0) > 0) return;
const generation = (this._workflowRefreshGeneration || 0) + 1;
this._workflowRefreshGeneration = generation;
@@ -808,7 +832,10 @@ export const workflowStateMethods = {
candidateRoute = null,
candidateSignature = null
} = {}) {
if (!this.isVisible()) return;
if (
!this.isVisible()
|| Number(this._applyWorkflowRefreshSuppressionDepth || 0) > 0
) return;
if (generation !== this._workflowRefreshGeneration) return;
const currentRoute = this.getActiveWorkflowRouteKey();
@@ -22,7 +22,13 @@ export const workflowUpdateMethods = {
this.runWithWorkflowRefreshSuppressed(
() => this.refreshComfyModelCatalogAfterApply(workflow, resolutions)
).then(refreshed => {
if (refreshed) {
const workflowSignature = this.getMissingWorkflowSignature?.(workflow);
const hasCurrentAnalysis = Boolean(
workflowSignature
&& this.cachedWorkflowSignature === workflowSignature
&& this.cachedAnalysisData
);
if (refreshed && !hasCurrentAnalysis) {
this.scheduleActiveWorkflowRefresh?.('node-widget-change');
}
}).catch(error => {
+129 -6
View File
@@ -528,8 +528,28 @@ export const missingBrowserMethods = {
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);
@@ -548,6 +568,7 @@ export const missingBrowserMethods = {
<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}">
@@ -636,7 +657,39 @@ export const missingBrowserMethods = {
state.start = nextStart;
state.end = nextEnd;
rowsHost.style.transform = `translateY(${Math.round(nextStart * rowHeight)}px)`;
rowsHost.innerHTML = state.models
const currentRows = new Map(
Array.from(rowsHost.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;
};
const nextRowsTemplate = document.createElement('template');
nextRowsTemplate.innerHTML = state.models
.slice(nextStart, nextEnd)
.map((missing, offset) => this.renderMissingModelListRow(
missing,
@@ -645,6 +698,21 @@ export const missingBrowserMethods = {
this.selectedMissingModelKey
))
.join('');
const nextRows = Array.from(
nextRowsTemplate.content.querySelectorAll('.mr-missing-list-row')
);
nextRows.forEach((nextRow) => {
const currentRow = findCurrentRow(nextRow);
const row = currentRow
? this.patchMissingModelRowElement(currentRow, nextRow)
: nextRow;
retainedRows.add(row);
rowsHost.appendChild(row);
});
currentRows.forEach((row) => {
if (!retainedRows.has(row)) row.remove();
});
this.wireVisibleMissingModelRows(container, state.data, state.models);
};
@@ -862,11 +930,36 @@ export const missingBrowserMethods = {
.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;
};
nextRowsHost.querySelectorAll('.mr-missing-list-row').forEach((nextRow) => {
const currentRow = currentRows.get(nextRow.dataset.missingKey || '');
const row = currentRow?.outerHTML === nextRow.outerHTML
? currentRow
const currentRow = findCurrentRow(nextRow);
const row = currentRow
? this.patchMissingModelRowElement(currentRow, nextRow)
: nextRow;
retainedRows.add(row);
if (currentRowsHost === currentList) {
@@ -898,6 +991,29 @@ export const missingBrowserMethods = {
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 = () => (
@@ -2190,6 +2306,7 @@ export const missingBrowserMethods = {
*/
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]
@@ -2335,7 +2452,11 @@ export const missingBrowserMethods = {
if (needsDownloadDirectories || needsDownloadRootDirectories) {
const renderToken = `download-dirs-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this._downloadDirectoriesRenderToken = renderToken;
container.innerHTML = this.renderStatusMessage('Loading model folders...', 'info');
const keepCurrentBrowser = preserveBrowser
&& Boolean(container.querySelector('.mr-missing-browser'));
if (!keepCurrentBrowser) {
container.innerHTML = this.renderStatusMessage('Loading model folders...', 'info');
}
const loaders = [];
if (needsDownloadDirectories) {
@@ -2351,7 +2472,9 @@ export const missingBrowserMethods = {
this.activeTab === 'missing' &&
container.isConnected
) {
this.displayMissingModels(container, data);
this.displayMissingModels(container, data, {
preserveBrowser: keepCurrentBrowser
});
}
});
return;