diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 32bec14..6d0bbb1 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -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, diff --git a/web/resolver/actions/queue_methods.js b/web/resolver/actions/queue_methods.js index b14e4f1..918232b 100644 --- a/web/resolver/actions/queue_methods.js +++ b/web/resolver/actions/queue_methods.js @@ -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 : []) diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js index 2340efd..cb60217 100644 --- a/web/resolver/search/search_panel.js +++ b/web/resolver/search/search_panel.js @@ -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 } + ); }); } }; diff --git a/web/resolver/shell/workflow_state_methods.js b/web/resolver/shell/workflow_state_methods.js index 8546012..2dbc9cc 100644 --- a/web/resolver/shell/workflow_state_methods.js +++ b/web/resolver/shell/workflow_state_methods.js @@ -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(); diff --git a/web/resolver/shell/workflow_update_methods.js b/web/resolver/shell/workflow_update_methods.js index 3fff9a7..8122c34 100644 --- a/web/resolver/shell/workflow_update_methods.js +++ b/web/resolver/shell/workflow_update_methods.js @@ -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 => { diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index 6f712c2..0d319b0 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -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 = {
@@ -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;