From 058832073db4f75ee445716456680349dbb921c8 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Tue, 4 Aug 2026 19:40:04 +0200 Subject: [PATCH] fix(frontend): preserve local match rows during refresh --- tests/test_downloads_tab_workflow_route.mjs | 23 ++++ .../actions/resolve_download_methods.js | 125 +++++++++++++++++- web/resolver/search/search_panel.js | 38 ++++-- 3 files changed, 173 insertions(+), 13 deletions(-) diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 6f3cb89..03afc45 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -5182,6 +5182,29 @@ test('local match status labels hash matches distinctly from exact matches', () ); }); +test('local match refreshes preserve keyed rows and button handlers', () => { + const renderLocalMatchesContent = extractMethod(searchPanelMethodsSource, 'renderLocalMatchesContent'); + const patchLocalMatchesContainer = extractMethod( + resolveDownloadMethodsSource, + 'patchLocalMatchesContainer' + ); + const refreshLocalMatchesUiForMissing = extractMethod( + resolveDownloadMethodsSource, + 'refreshLocalMatchesUiForMissing' + ); + const wireLocalMatchButtons = extractMethod(searchPanelMethodsSource, 'wireLocalMatchButtons'); + + assert.match(renderLocalMatchesContent, /data-local-match-key/); + assert.match(renderLocalMatchesContent, /encodeURIComponent/); + assert.match(patchLocalMatchesContainer, /local-match-key|localMatchKey/); + assert.match(patchLocalMatchesContainer, /mr-local-alternatives-toggle/); + assert.match(refreshLocalMatchesUiForMissing, /patchLocalMatchesContainer/); + assert.doesNotMatch(refreshLocalMatchesUiForMissing, /body\.innerHTML\s*=/); + assert.match(wireLocalMatchButtons, /resolveButton\.onclick\s*=/); + assert.match(wireLocalMatchButtons, /altBtn\.onclick\s*=/); + assert.doesNotMatch(wireLocalMatchButtons, /addEventListener\('click'/); +}); + test('local match status group warns when match folder is unsupported by node', () => { const renderLocalMatchStatus = eval(`(${extractMethod(searchPanelMethodsSource, 'renderLocalMatchStatus')})`); const getLocalMatchCategory = eval(`(${extractMethod(searchPanelMethodsSource, 'getLocalMatchCategory')})`); diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index 566e8da..dd3002a 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -1367,6 +1367,116 @@ export const resolveDownloadMethods = { this.renderDownloadSnapshot(snapshot.downloadId, snapshot); }, + patchLocalMatchesContainer(container, renderedHtml = '') { + if (!container) return; + const documentRef = container.ownerDocument || globalThis.document; + if (!documentRef?.createElement) { + container.innerHTML = renderedHtml; + return; + } + + const template = documentRef.createElement('template'); + template.innerHTML = renderedHtml || ''; + const nextRows = Array.from(template.content.querySelectorAll('.mr-match-row[data-local-match-key]')); + const currentRows = Array.from(container.querySelectorAll('.mr-match-row[data-local-match-key]')); + + if ( + !nextRows.length + || !currentRows.length + || currentRows.some(row => !row.dataset.localMatchKey) + ) { + if (container.innerHTML !== renderedHtml) { + container.innerHTML = renderedHtml; + } + return; + } + + const currentGroups = Array.from(container.querySelectorAll('.mr-stack-sm')); + const currentGroupsById = new Map(currentGroups.map(group => [group.id, group])); + const nextGroups = Array.from(template.content.querySelectorAll('.mr-stack-sm')); + const nextGroupsById = new Map(); + const retainedGroups = new Set(); + + for (const nextGroup of nextGroups) { + const groupId = nextGroup.id || ''; + let currentGroup = currentGroupsById.get(groupId); + if (!currentGroup) { + currentGroup = nextGroup.cloneNode(true); + currentGroup.querySelectorAll('.mr-match-row').forEach(row => row.remove()); + } + nextGroupsById.set(groupId, currentGroup); + retainedGroups.add(currentGroup); + } + + const currentRowsByKey = new Map( + currentRows.map(row => [row.dataset.localMatchKey, row]) + ); + const retainedRows = new Set(); + const nextRowsByKey = new Map(); + + for (const nextRow of nextRows) { + const key = nextRow.dataset.localMatchKey; + let currentRow = currentRowsByKey.get(key); + if (!currentRow) { + currentRow = nextRow.cloneNode(true); + } else { + this.syncSearchElementAttributes(currentRow, nextRow); + if (currentRow.innerHTML !== nextRow.innerHTML) { + currentRow.innerHTML = nextRow.innerHTML; + } + } + + const nextGroup = nextRow.closest('.mr-stack-sm'); + const targetParent = nextGroup + ? nextGroupsById.get(nextGroup.id || '') + : container; + if (!targetParent) return; + targetParent.appendChild(currentRow); + retainedRows.add(currentRow); + nextRowsByKey.set(key, currentRow); + } + + for (const currentRow of currentRows) { + if (!retainedRows.has(currentRow)) currentRow.remove(); + } + for (const currentGroup of currentGroups) { + if (!retainedGroups.has(currentGroup)) currentGroup.remove(); + } + + const nextToggle = template.content.querySelector('.mr-local-alternatives-toggle'); + const currentToggle = container.querySelector('.mr-local-alternatives-toggle'); + let nextDomToggle = null; + if (nextToggle) { + nextDomToggle = currentToggle || nextToggle.cloneNode(true); + if (currentToggle) { + this.syncSearchElementAttributes(currentToggle, nextToggle); + } + } else if (currentToggle) { + currentToggle.remove(); + } + + const retainedChildren = new Set(); + const nextDomChildren = []; + for (const nextChild of Array.from(template.content.children)) { + let nextDomChild = null; + if (nextChild.classList.contains('mr-match-row')) { + nextDomChild = nextRowsByKey.get(nextChild.dataset.localMatchKey); + } else if (nextChild.classList.contains('mr-local-alternatives-toggle')) { + nextDomChild = nextDomToggle; + } else if (nextChild.classList.contains('mr-stack-sm')) { + nextDomChild = nextGroupsById.get(nextChild.id || ''); + } + if (!nextDomChild) continue; + retainedChildren.add(nextDomChild); + nextDomChildren.push(nextDomChild); + } + + Array.from(container.children).forEach(child => { + if (!retainedChildren.has(child)) child.remove(); + }); + nextDomChildren.forEach(child => container.appendChild(child)); + }, + async refreshLocalMatchesForDownloadedMissing(missing, downloadedFilename, { progressDiv = null, category = '', downloadPath = '', downloadDirectory = '' } = {}) { if (!missing) return []; @@ -1383,7 +1493,10 @@ export const resolveDownloadMethods = { } const body = this.contentElement?.querySelector(`#local-matches-body-${this.getMissingModelDomKey(missing)}`); if (body) { - body.innerHTML = `
Checking local matches for ${this.escapeHtml(targetFilename)}...
`; + this.patchLocalMatchesContainer( + body, + `
Checking local matches for ${this.escapeHtml(targetFilename)}...
` + ); } const data = await this.fetchLocalMatches(targetFilename, category || missing.category || '', true); @@ -1410,7 +1523,10 @@ export const resolveDownloadMethods = { const body = this.contentElement.querySelector(`#local-matches-body-${this.getMissingModelDomKey(missing)}`); const displayIndex = Number.isFinite(missing.__displayIndex) ? missing.__displayIndex : 0; if (body) { - body.innerHTML = this.renderLocalMatchesContent(missing, displayIndex); + this.patchLocalMatchesContainer( + body, + this.renderLocalMatchesContent(missing, displayIndex) + ); this.wireLocalMatchButtons(body, missing, displayIndex); } @@ -1450,7 +1566,10 @@ export const resolveDownloadMethods = { button.classList.add('mr-btn-is-disabled', 'mr-is-refreshing'); } if (body) { - body.innerHTML = `
Refreshing local matches for ${this.escapeHtml(displayName)}...
`; + this.patchLocalMatchesContainer( + body, + `
Refreshing local matches for ${this.escapeHtml(displayName)}...
` + ); } const data = await this.fetchLocalMatches(targetFilename, missing.category || '', true); diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js index 662b9f1..80e507d 100644 --- a/web/resolver/search/search_panel.js +++ b/web/resolver/search/search_panel.js @@ -2319,9 +2319,14 @@ export const searchPanelMethods = { const identityAttr = localMatchIdentity ? ` data-local-match-identity="${this.escapeHtml(localMatchIdentity)}"` : ''; + const localMatchKey = localMatchIdentity + || matchPath + || match.filename + || `match-${matchIndex}`; + const localMatchKeyAttr = ` data-local-match-key="${this.escapeHtml(encodeURIComponent(String(localMatchKey)))}"`; const rowClass = `mr-match-row${isBestMatch ? ' mr-best-match' : ''}`; - html += `
`; + html += `
`; html += this.getConfidenceBadge(match.confidence); html += `${this.escapeHtml(matchPath)}`; html += this.renderLocalMatchStatusGroup(missing, match, hashLabelMap); @@ -2354,7 +2359,12 @@ export const searchPanelMethods = { const identityAttr = localMatchIdentity ? ` data-local-match-identity="${this.escapeHtml(localMatchIdentity)}"` : ''; - html += `
`; + const localMatchKey = localMatchIdentity + || matchPath + || match.filename + || `alternative-${mIdx}`; + const localMatchKeyAttr = ` data-local-match-key="${this.escapeHtml(encodeURIComponent(String(localMatchKey)))}"`; + html += `
`; html += this.getConfidenceBadge(match.confidence); html += `${this.escapeHtml(matchPath)}`; html += this.renderLocalMatchStatusGroup(missing, match, hashLabelMap); @@ -2393,10 +2403,9 @@ export const searchPanelMethods = { const buttonId = `resolve-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`; const resolveButton = container.querySelector(`#${buttonId}`); if (resolveButton) { - resolveButton.onclick = null; - resolveButton.addEventListener('click', () => { + resolveButton.onclick = () => { this.queueResolution(missing, match.model); - }); + }; } }); @@ -2406,9 +2415,9 @@ export const searchPanelMethods = { const altBtnId = `resolve-alt-${missingIndex}-${missing.node_id}-${missing.widget_index}-${mIdx}`; const altBtn = container.querySelector(`#${altBtnId}`); if (altBtn) { - altBtn.addEventListener('click', () => { + altBtn.onclick = () => { this.queueResolution(missing, match.model); - }); + }; } } } @@ -2654,20 +2663,29 @@ export const searchPanelMethods = { const bodyId = `local-matches-body-${this.getMissingModelDomKey(missing)}`; const container = this.contentElement.querySelector(`#${bodyId}`); if (container) { - container.innerHTML = `
Searching local matches for "${missing.civitai_info.expected_filename}"...
`; + this.patchLocalMatchesContainer( + container, + `
Searching local matches for "${this.escapeHtml(missing.civitai_info.expected_filename)}"...
` + ); } try { await this.fetchUrnLocalMatches(missing); if (container) { - container.innerHTML = this.renderLocalMatchesContent(missing, missing.__displayIndex || 0); + this.patchLocalMatchesContainer( + container, + this.renderLocalMatchesContent(missing, missing.__displayIndex || 0) + ); this.wireLocalMatchButtons(this.contentElement, missing, missing.__displayIndex || 0); } this.refreshMissingListRow(missing, { refreshBaseModels: true }); } catch (error) { console.error('Model Resolver: URN local match refresh error:', error); if (container) { - container.innerHTML = `
Failed to refresh local matches.
`; + this.patchLocalMatchesContainer( + container, + '
Failed to refresh local matches.
' + ); } } },