diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 96244dc..7cff334 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -1899,6 +1899,25 @@ test('background Loaded Models refresh keeps the current view until new data is assert.equal(contentElement.scrollTop, 48); }); +test('Loaded Models progress patches the stable progress container', () => { + const patchLoadedModelsProgress = extractMethod( + renderFormatMethodsSource, + 'patchLoadedModelsProgress' + ); + const pollLoadedModelsProgress = extractMethod( + renderFormatMethodsSource, + 'pollLoadedModelsProgress' + ); + const loadLoadedModels = extractMethod(tabsLoadedMethodsSource, 'loadLoadedModels'); + + assert.match(patchLoadedModelsProgress, /mr-download-section/); + assert.match(patchLoadedModelsProgress, /mr-progress-fill/); + assert.match(patchLoadedModelsProgress, /currentInfo/); + assert.match(pollLoadedModelsProgress, /patchLoadedModelsProgress\(/); + assert.doesNotMatch(pollLoadedModelsProgress, /contentElement\.innerHTML\s*=/); + assert.match(loadLoadedModels, /patchLoadedModelsProgress\(/); +}); + test('Power Lora Loader strength updates only its Loaded Models chip and cache', () => { const updateLoadedModelStrengthsFromNode = eval( `(${extractMethod(tabsLoadedMethodsSource, 'updateLoadedModelStrengthsFromNode')})` diff --git a/web/resolver/utils/render_format_methods.js b/web/resolver/utils/render_format_methods.js index a2bbd98..022f073 100644 --- a/web/resolver/utils/render_format_methods.js +++ b/web/resolver/utils/render_format_methods.js @@ -213,6 +213,77 @@ export const renderFormatMethods = { `; }, + patchLoadedModelsProgress(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 nextRoot = template.content.firstElementChild; + const currentRoot = container.firstElementChild; + if ( + !nextRoot + || !currentRoot + || !currentRoot.classList.contains('mr-download-section') + || !nextRoot.classList.contains('mr-download-section') + ) { + if (container.innerHTML !== renderedHtml) { + container.innerHTML = renderedHtml; + } + return; + } + + const syncAttributes = (current, next) => { + for (const attribute of Array.from(current.attributes || [])) { + if (!next.hasAttribute(attribute.name)) current.removeAttribute(attribute.name); + } + for (const attribute of Array.from(next.attributes || [])) { + if (current.getAttribute(attribute.name) !== attribute.value) { + current.setAttribute(attribute.name, attribute.value); + } + } + }; + const currentInfo = Array.from(currentRoot.querySelectorAll('.mr-download-info')); + const nextInfo = Array.from(nextRoot.querySelectorAll('.mr-download-info')); + const currentProgressBar = currentRoot.querySelector('.mr-progress-bar'); + const nextProgressBar = nextRoot.querySelector('.mr-progress-bar'); + const currentProgressFill = currentRoot.querySelector('.mr-progress-fill'); + const nextProgressFill = nextRoot.querySelector('.mr-progress-fill'); + const currentProgressText = Array.from(currentRoot.querySelectorAll('.mr-progress-text span')); + const nextProgressText = Array.from(nextRoot.querySelectorAll('.mr-progress-text span')); + + if ( + currentInfo.length !== nextInfo.length + || !currentProgressBar + || !nextProgressBar + || !currentProgressFill + || !nextProgressFill + || currentProgressText.length !== nextProgressText.length + ) { + currentRoot.innerHTML = nextRoot.innerHTML; + syncAttributes(currentRoot, nextRoot); + return; + } + + syncAttributes(currentRoot, nextRoot); + currentInfo.forEach((element, index) => { + if (element.textContent !== nextInfo[index].textContent) { + element.textContent = nextInfo[index].textContent; + } + }); + syncAttributes(currentProgressBar, nextProgressBar); + syncAttributes(currentProgressFill, nextProgressFill); + currentProgressText.forEach((element, index) => { + if (element.textContent !== nextProgressText[index].textContent) { + element.textContent = nextProgressText[index].textContent; + } + }); + }, + async pollAnalysisProgress(analysisId, token) { await pollBackgroundTask({ endpoint: `/model_resolver/analyze-progress/${analysisId}`, @@ -243,7 +314,10 @@ export const renderFormatMethods = { filterIgnoredStatus: (progress) => progress?.status === 'unknown', onProgress: (progress) => { if (this.contentElement && this.activeTab === 'loaded') { - this.contentElement.innerHTML = this.renderLoadedModelsProgress(progress); + this.patchLoadedModelsProgress( + this.contentElement, + this.renderLoadedModelsProgress(progress) + ); } }, isTerminal: (progress) => progress.status === 'completed' || progress.status === 'error', diff --git a/web/resolver/views/tabs_loaded_methods.js b/web/resolver/views/tabs_loaded_methods.js index 82b1d8d..9a166e7 100644 --- a/web/resolver/views/tabs_loaded_methods.js +++ b/web/resolver/views/tabs_loaded_methods.js @@ -127,14 +127,17 @@ export const tabsLoadedMethods = { const loadedProgressId = `loaded-progress-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; this._loadedModelsProgressToken = loadedProgressId; if (!preserveContent && shouldRenderLoadedModels()) { - this.contentElement.innerHTML = this.renderLoadedModelsProgress({ - status: 'starting', - stage: 'starting', - message: 'Preparing loaded model scan...', - percent: 0, - current: 0, - total: 0 - }); + this.patchLoadedModelsProgress( + this.contentElement, + this.renderLoadedModelsProgress({ + status: 'starting', + stage: 'starting', + message: 'Preparing loaded model scan...', + percent: 0, + current: 0, + total: 0 + }) + ); } const progressPromise = preserveContent