diff --git a/static/js/components/shared/ModelVersionsTab.js b/static/js/components/shared/ModelVersionsTab.js index f0d6c3bd..26d31e9f 100644 --- a/static/js/components/shared/ModelVersionsTab.js +++ b/static/js/components/shared/ModelVersionsTab.js @@ -1426,6 +1426,32 @@ export function initVersionsTab({ } } + /** + * True when the downloaded version is the newest version in the model's + * remote version set, i.e. the one whose install flips the backend + * update-available flag off. Unknown version sets fall back to "latest" + * so the post-download in-place reconciliation still runs by default. + * (#1078) + */ + function versionIsLatestAvailable(version) { + if (!controller.record || !Array.isArray(controller.record.versions)) { + return true; + } + const versions = controller.record.versions; + if (versions.length === 0) { + return true; + } + const target = Number(version?.versionId); + if (!Number.isFinite(target)) { + return true; + } + const maxId = versions.reduce( + (max, v) => Math.max(max, Number(v?.versionId) || 0), + 0 + ); + return target >= maxId; + } + async function handleDownloadVersion(button, versionId) { if (!controller.record) { return; @@ -1451,6 +1477,7 @@ export function initVersionsTab({ targetFolder: resolveTemplatePath ? '' : (pathInfo?.targetFolder || ''), useDefaultPaths: resolveTemplatePath ? true : null, useSaveDirAsRoot: resolveTemplatePath, + isLatestVersion: versionIsLatestAvailable(version), }); if (success) { diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index 4de4dac9..adb43a9c 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -1077,6 +1077,7 @@ export class DownloadManager { deferReload = false, suppressSuccessToast = false, suppressFailureSummary = false, + isLatestVersion = null, }) { const config = this.apiClient?.apiConfig?.config; @@ -1085,7 +1086,7 @@ export class DownloadManager { } const displayName = versionName || `#${versionId}`; - const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false, deferReload, suppressSuccessToast, suppressFailureSummary }; + const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false, deferReload, suppressSuccessToast, suppressFailureSummary, isLatestVersion }; this._lastDownloadError = null; let ws = null; let updateProgress = () => { }; @@ -1228,22 +1229,16 @@ export class DownloadManager { } if (!deferReload) { - const pageState = this.apiClient.getPageState(); - - if (!useDefaultPaths && targetFolder) { - pageState.activeFolder = targetFolder; - setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder); - - document.querySelectorAll('.folder-tags .tag').forEach(tag => { - const isActive = tag.dataset.folder === targetFolder; - tag.classList.toggle('active', isActive); - if (isActive && !tag.parentNode.classList.contains('collapsed')) { - tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } - }); - } - - await resetAndReload(true); + // In-place view update instead of a full page reload: the + // download only flips the update flag for one model, so we + // reconcile its cards without resetting the listing, the + // scroll position or the sidebar's active folder (#1078). + // The legacy code hijacked `pageState.activeFolder` here + // whenever a custom target folder was used. + await this._reconcileViewAfterDownload({ + modelId, + isLatestVersion: isLatestVersion ?? this._isDownloadingLatestVersion(versionId), + }); } return true; @@ -1285,6 +1280,175 @@ export class DownloadManager { } } + /** + * Reconcile the current model listing after a successful download, + * without resetting the whole page (#1078). + * + * The legacy behaviour re-loaded page 1 and scrolled to the top after + * every download, and hijacked the sidebar's active folder whenever a + * custom target folder was used. In-place reconciliation only touches + * the cards that can change as a result of the download: + * + * - Updates view: once the newest eligible version is installed the + * model no longer qualifies, so its cards are removed from the list + * (the update flag is model-level, so every visible card of the + * model disappears at once). + * - Normal listing: the card stays; only the update flag is cleared. + * - The model is not in the current view (different folder / filter / + * window): nothing changes, which also covers brand-new models whose + * card did not exist before. + * + * The sidebar folder tree is refreshed separately so folder counts + * stay accurate without touching the model listing or scroll position. + * + * @param {object} opts + * @param {string|number} opts.modelId CivitAI model id of the downloaded model. + * @param {boolean} [opts.isLatestVersion=true] True when the downloaded + * version is the newest known remote version, so the update flag can + * be cleared. When false (user deliberately picked an older version) + * the list is left untouched. + * @param {boolean} [opts.refreshSidebar=true] Whether to refresh the + * sidebar folder tree afterwards (batch callers batch this into a + * single refresh). + * @returns {Promise} True when an in-place update was applied. + */ + async _reconcileViewAfterDownload({ modelId, isLatestVersion = true, refreshSidebar = true } = {}) { + const scroller = state?.virtualScroller; + const items = Array.isArray(scroller?.items) ? scroller.items : []; + + // No virtual scroller (page without one, not on a listing page, + // recipes duplicates mode, ...) — fall back to the legacy reload. + if (!scroller || items.length === 0 || typeof scroller.removeMultipleItemsByFilePath !== 'function') { + await resetAndReload(true); + return false; + } + + if (modelId == null) { + // No CivitAI identity (e.g. HF downloads) — nothing to reconcile. + await this._refreshSidebarAfterReconcile(refreshSidebar); + return false; + } + + const key = String(modelId); + const matches = items.filter(item => { + const civitai = item?.civitai; + return civitai != null && String(civitai.modelId) === key; + }); + + if (matches.length === 0) { + // Downloaded model is not visible in the current view — keep the + // listing untouched, only refresh folder counts. + await this._refreshSidebarAfterReconcile(refreshSidebar); + return false; + } + + const pageState = this.apiClient?.getPageState ? this.apiClient.getPageState() : null; + const updatesView = pageState?.showUpdateAvailableOnly === true; + + if (updatesView && isLatestVersion) { + const paths = matches.map(match => match.file_path).filter(Boolean); + if (paths.length > 0) { + scroller.removeMultipleItemsByFilePath(paths); + } + } else if (!updatesView && isLatestVersion) { + for (const match of matches) { + if (match.file_path) { + scroller.updateSingleItem(match.file_path, { update_available: false }); + } + } + } + // isLatestVersion === false: deliberately downloading an older + // version keeps the update flag — nothing changes in the list. + + await this._refreshSidebarAfterReconcile(refreshSidebar); + return true; + } + + /** + * Reconcile the listing after a batch download. CivitAI models are + * matched card-by-card via `_reconcileViewAfterDownload`; HF + * downloads (no CivitAI identity to match) keep the legacy reload. + */ + async _reconcileBatchViewAfterDownload(completedCivitaiItems = [], hfCompletedCount = 0) { + if (hfCompletedCount > 0) { + await resetAndReload(true); + return; + } + const scroller = state?.virtualScroller; + if (!scroller || !Array.isArray(scroller.items)) { + await resetAndReload(true); + return; + } + const seen = new Set(); + for (const item of completedCivitaiItems) { + const modelId = item?.modelId; + if (modelId == null || seen.has(String(modelId))) { + continue; + } + seen.add(String(modelId)); + await this._reconcileViewAfterDownload({ + modelId, + isLatestVersion: this._isVersionLatest(item.selectedVersion?.id, item.versions), + refreshSidebar: false, + }); + } + await this._refreshSidebarAfterReconcile(true); + } + + /** + * Refresh the sidebar folder tree (counts only — never the model + * listing). Lazy import keeps SidebarManager out of DownloadManager's + * load graph (it transitively imports BulkManager and friends). + */ + async _refreshSidebarAfterReconcile(shouldRefresh) { + if (shouldRefresh === false) { + return; + } + try { + const { sidebarManager } = await import('../components/SidebarManager.js'); + if (sidebarManager && typeof sidebarManager.refresh === 'function') { + await sidebarManager.refresh(); + } + } catch (error) { + console.debug('Failed to refresh sidebar after download:', error); + } + } + + /** + * True when `versionId` is the newest known remote version of the + * versions list. Unknown/missing lists are treated as "latest" so the + * common download-the-update flow reconciles by default; callers that + * know the remote version set pass an explicit flag instead. + */ + _isVersionLatest(versionId, versions) { + if (!Array.isArray(versions) || versions.length === 0) { + return true; + } + let maxId = null; + for (const version of versions) { + const id = Number(version?.id ?? version?.versionId); + if (!Number.isFinite(id)) { + continue; + } + if (maxId === null || id > maxId) { + maxId = id; + } + } + if (maxId === null) { + return true; + } + const target = Number(versionId); + if (!Number.isFinite(target)) { + return true; + } + return target >= maxId; + } + + /** True when the currently selected version is the newest remote one. */ + _isDownloadingLatestVersion(versionId) { + return this._isVersionLatest(versionId, this.versions); + } + /** * Download multiple selected files of the same version sequentially, * reusing the location-step choices for every file. Per-file toasts, @@ -1364,7 +1528,15 @@ export class DownloadManager { }); } - await resetAndReload(true); + // Full success: reconcile the model's cards in place. On partial + // failure keep the listing untouched so the still-outdated version + // flags survive until the user retries the remaining files. + if (failedItems.length === 0) { + await this._reconcileViewAfterDownload({ + modelId: this.modelId, + isLatestVersion: this._isDownloadingLatestVersion(this.currentVersion?.id), + }); + } return failedItems.length === 0; } @@ -1961,6 +2133,11 @@ export class DownloadManager { let failedDownloads = 0; let cancelled = false; const failedItems = []; + // Successful CivitAI items are reconciled in place afterwards + // (their cards can be matched by model id); HF items keep the + // legacy full reload because they have no CivitAI identity (#1078). + const completedCivitaiItems = []; + let hfCompletedCount = 0; loadingManager.showCancelButton(async () => { if (cancelled) return; @@ -2065,6 +2242,11 @@ export class DownloadManager { } else { completedDownloads++; updateProgress(100, completedDownloads, ''); + if (isHf) { + hfCompletedCount++; + } else { + completedCivitaiItems.push(item); + } } } catch (err) { if (!cancelled) { @@ -2095,7 +2277,7 @@ export class DownloadManager { }); } - await resetAndReload(true); + await this._reconcileBatchViewAfterDownload(completedCivitaiItems, hfCompletedCount); } async downloadVersionWithDefaults(modelType, modelId, versionId, { @@ -2104,7 +2286,8 @@ export class DownloadManager { modelRoot = '', targetFolder = '', useDefaultPaths = null, - useSaveDirAsRoot = false + useSaveDirAsRoot = false, + isLatestVersion = null, } = {}) { console.warn('[download] downloadVersionWithDefaults: NO fileParams will be sent — backend will always use primary file. ' + 'modelType=%s, modelId=%s, versionId=%s, versionName="%s"', @@ -2129,6 +2312,7 @@ export class DownloadManager { useSaveDirAsRoot, source, closeModal: false, + isLatestVersion, }); } diff --git a/tests/frontend/managers/downloadManager.reconcileView.test.js b/tests/frontend/managers/downloadManager.reconcileView.test.js new file mode 100644 index 00000000..e2f00fec --- /dev/null +++ b/tests/frontend/managers/downloadManager.reconcileView.test.js @@ -0,0 +1,466 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + DOWNLOAD_MANAGER_MODULE, + MODAL_MANAGER_MODULE, + UI_HELPERS_MODULE, + STATE_MODULE, + LOADING_MANAGER_MODULE, + API_FACTORY_MODULE, + STORAGE_HELPERS_MODULE, + FOLDER_TREE_MANAGER_MODULE, + I18N_HELPERS_MODULE, + SUMMARY_MODULE, + SIDEBAR_MANAGER_MODULE, + mockApiClient, + mockScroller, + stateMock, + mockLoadingManager, + showToastMock, + resetAndReloadMock, + setStorageItemMock, + sidebarRefreshMock, +} = vi.hoisted(() => { + const mockScroller = { + items: [], + removeItemByFilePath: vi.fn(), + removeMultipleItemsByFilePath: vi.fn(), + updateSingleItem: vi.fn(), + }; + + const stateMock = { + currentPageType: 'loras', + global: { settings: {} }, + loadingManager: null, + virtualScroller: mockScroller, + }; + + const mockApiClient = { + apiConfig: { + config: { + displayName: 'LoRA', + singularName: 'lora', + }, + }, + modelType: 'loras', + getPageState: vi.fn(() => ({})), + downloadModel: vi.fn(), + downloadHfModel: vi.fn(), + cancelDownload: vi.fn(), + }; + + const mockLoadingManager = { + showSimpleLoading: vi.fn(), + setStatus: vi.fn(), + hide: vi.fn(), + restoreProgressBar: vi.fn(), + showDownloadProgress: vi.fn(() => vi.fn()), + showCancelButton: vi.fn(), + }; + + return { + DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname, + MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, + LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname, + API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname, + STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname, + FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname, + I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname, + SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname, + mockApiClient, + mockScroller, + stateMock, + mockLoadingManager, + showToastMock: vi.fn(), + resetAndReloadMock: vi.fn(), + setStorageItemMock: vi.fn(), + sidebarRefreshMock: vi.fn(), + }; +}); + +vi.mock(MODAL_MANAGER_MODULE, () => ({ + modalManager: { + showModal: vi.fn(), + closeModal: vi.fn(), + }, +})); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: showToastMock, +})); + +vi.mock(STATE_MODULE, () => ({ + state: stateMock, +})); + +vi.mock(LOADING_MANAGER_MODULE, () => ({ + LoadingManager: vi.fn(() => mockLoadingManager), +})); + +vi.mock(API_FACTORY_MODULE, () => ({ + getModelApiClient: vi.fn(() => mockApiClient), + resetAndReload: resetAndReloadMock, +})); + +vi.mock(STORAGE_HELPERS_MODULE, () => ({ + getStorageItem: vi.fn((_key, defaultValue) => defaultValue), + setStorageItem: setStorageItemMock, +})); + +vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({ + FolderTreeManager: vi.fn(() => ({ + clearSelection: vi.fn(), + init: vi.fn(), + })), +})); + +vi.mock(I18N_HELPERS_MODULE, () => ({ + translate: vi.fn((_, __, fallback) => fallback ?? ''), +})); + +vi.mock(SUMMARY_MODULE, () => ({ + showDownloadBatchSummary: vi.fn(), +})); + +vi.mock(SIDEBAR_MANAGER_MODULE, () => ({ + sidebarManager: { refresh: sidebarRefreshMock }, +})); + +/** Minimal WebSocket stub: executeDownloadWithProgress never awaits open. */ +class FakeWebSocket { + constructor(url) { + this.url = url; + this.readyState = 0; // CONNECTING + } + close() {} +} + +/** Build a card item as returned by the backend listing endpoint. */ +function makeItem(filePath, modelId, base) { + return { + file_path: filePath, + civitai: { modelId, ...(base ? { baseModel: base } : {}) }, + update_available: true, + }; +} + +describe('DownloadManager post-download in-place reconciliation (#1078)', () => { + let DownloadManager; + let manager; + + beforeEach(async () => { + document.body.innerHTML = ''; + stateMock.virtualScroller = mockScroller; + mockScroller.items = []; + mockScroller.removeItemByFilePath.mockReset(); + mockScroller.removeMultipleItemsByFilePath.mockReset(); + mockScroller.updateSingleItem.mockReset(); + mockApiClient.getPageState.mockReset(); + mockApiClient.getPageState.mockReturnValue({}); + mockApiClient.downloadModel.mockReset(); + resetAndReloadMock.mockReset(); + resetAndReloadMock.mockResolvedValue(undefined); + sidebarRefreshMock.mockReset(); + sidebarRefreshMock.mockResolvedValue(undefined); + setStorageItemMock.mockReset(); + showToastMock.mockClear(); + vi.stubGlobal('WebSocket', FakeWebSocket); + + vi.resetModules(); + ({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE)); + manager = new DownloadManager(); + manager.apiClient = mockApiClient; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + stateMock.virtualScroller = mockScroller; + }); + + describe('_isVersionLatest', () => { + it('treats unknown/empty version lists as latest', () => { + expect(manager._isVersionLatest('250', [])).toBe(true); + expect(manager._isVersionLatest('250', null)).toBe(true); + expect(manager._isVersionLatest(undefined, undefined)).toBe(true); + }); + + it('returns true only for the newest known remote version', () => { + const versions = [{ id: 100 }, { id: 250 }, { id: 30 }]; + expect(manager._isVersionLatest(250, versions)).toBe(true); + expect(manager._isVersionLatest('250', versions)).toBe(true); + expect(manager._isVersionLatest(100, versions)).toBe(false); + expect(manager._isVersionLatest(30, versions)).toBe(false); + }); + + it('supports record-style version objects (versionId field)', () => { + const versions = [{ versionId: 10 }, { versionId: 20 }]; + expect(manager._isVersionLatest(20, versions)).toBe(true); + expect(manager._isVersionLatest(10, versions)).toBe(false); + }); + + it('returns true when the target id is unknown', () => { + const versions = [{ id: 100 }]; + expect(manager._isVersionLatest('not-a-number', versions)).toBe(true); + expect(manager._isVersionLatest(undefined, versions)).toBe(true); + }); + }); + + describe('_reconcileViewAfterDownload', () => { + it('removes the model cards in the Updates view when the latest version was installed', async () => { + stateMock.virtualScroller.items = [ + makeItem('/models/loras/old.safetensors', 837884), + makeItem('/models/loras/unrelated.safetensors', 999999), + ]; + mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true }); + + const result = await manager._reconcileViewAfterDownload({ modelId: 837884, isLatestVersion: true }); + + expect(result).toBe(true); + expect(mockScroller.removeMultipleItemsByFilePath).toHaveBeenCalledWith([ + '/models/loras/old.safetensors', + ]); + expect(mockScroller.updateSingleItem).not.toHaveBeenCalled(); + expect(resetAndReloadMock).not.toHaveBeenCalled(); + // Sidebar (folder counts) is refreshed, but never the model listing. + expect(sidebarRefreshMock).toHaveBeenCalledTimes(1); + }); + + it('patches the update flag instead of removing cards in a normal listing', async () => { + stateMock.virtualScroller.items = [ + makeItem('/models/loras/old.safetensors', 837884), + ]; + mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: false }); + + const result = await manager._reconcileViewAfterDownload({ modelId: 837884, isLatestVersion: true }); + + expect(result).toBe(true); + expect(mockScroller.updateSingleItem).toHaveBeenCalledWith( + '/models/loras/old.safetensors', + { update_available: false } + ); + expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled(); + expect(resetAndReloadMock).not.toHaveBeenCalled(); + expect(sidebarRefreshMock).toHaveBeenCalledTimes(1); + }); + + it('leaves the list untouched when an older version was deliberately downloaded', async () => { + stateMock.virtualScroller.items = [makeItem('/models/loras/old.safetensors', 837884)]; + mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true }); + + const result = await manager._reconcileViewAfterDownload({ modelId: 837884, isLatestVersion: false }); + + expect(result).toBe(true); + expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled(); + expect(mockScroller.updateSingleItem).not.toHaveBeenCalled(); + expect(resetAndReloadMock).not.toHaveBeenCalled(); + expect(sidebarRefreshMock).toHaveBeenCalledTimes(1); + }); + + it('keeps the listing untouched when the downloaded model is not in the current view', async () => { + stateMock.virtualScroller.items = [makeItem('/models/loras/other.safetensors', 999999)]; + mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true }); + + const result = await manager._reconcileViewAfterDownload({ modelId: 837884, isLatestVersion: true }); + + expect(result).toBe(false); + expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled(); + expect(mockScroller.updateSingleItem).not.toHaveBeenCalled(); + expect(resetAndReloadMock).not.toHaveBeenCalled(); + expect(sidebarRefreshMock).toHaveBeenCalledTimes(1); + }); + + it('no-ops for models without a CivitAI identity (HF downloads)', async () => { + stateMock.virtualScroller.items = [makeItem('/models/loras/a.safetensors', 123)]; + mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true }); + + const result = await manager._reconcileViewAfterDownload({ modelId: null, isLatestVersion: true }); + + expect(result).toBe(false); + expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled(); + expect(resetAndReloadMock).not.toHaveBeenCalled(); + expect(sidebarRefreshMock).toHaveBeenCalledTimes(1); + }); + + it('falls back to a full reload when no virtual scroller is available', async () => { + stateMock.virtualScroller = undefined; + + const result = await manager._reconcileViewAfterDownload({ modelId: 837884, isLatestVersion: true }); + + expect(result).toBe(false); + expect(resetAndReloadMock).toHaveBeenCalledWith(true); + expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled(); + expect(sidebarRefreshMock).not.toHaveBeenCalled(); + }); + }); + + describe('_reconcileBatchViewAfterDownload', () => { + it('reconciles each distinct successful CivitAI model and refreshes the sidebar once', async () => { + stateMock.virtualScroller.items = [ + makeItem('/models/loras/a.safetensors', 111), + makeItem('/models/loras/b.safetensors', 222), + ]; + mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true }); + + await manager._reconcileBatchViewAfterDownload([ + { modelId: '111', selectedVersion: { id: 40 }, versions: [{ id: 40 }, { id: 10 }] }, + { modelId: '111', selectedVersion: { id: 40 }, versions: [{ id: 40 }, { id: 10 }] }, + { modelId: '222', selectedVersion: { id: 7 }, versions: [{ id: 7 }] }, + ], 0); + + expect(mockScroller.removeMultipleItemsByFilePath).toHaveBeenCalledTimes(2); + expect(mockScroller.removeMultipleItemsByFilePath).toHaveBeenCalledWith(['/models/loras/a.safetensors']); + expect(mockScroller.removeMultipleItemsByFilePath).toHaveBeenCalledWith(['/models/loras/b.safetensors']); + // One sidebar refresh for the whole batch, not one per model. + expect(sidebarRefreshMock).toHaveBeenCalledTimes(1); + expect(resetAndReloadMock).not.toHaveBeenCalled(); + }); + + it('falls back to a full reload when any HF download completed', async () => { + stateMock.virtualScroller.items = [makeItem('/models/loras/a.safetensors', 111)]; + mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true }); + + await manager._reconcileBatchViewAfterDownload([ + { modelId: '111', selectedVersion: { id: 40 }, versions: [{ id: 40 }, { id: 10 }] }, + ], 1); + + expect(resetAndReloadMock).toHaveBeenCalledWith(true); + expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled(); + expect(sidebarRefreshMock).not.toHaveBeenCalled(); + }); + + it('falls back to a full reload when no virtual scroller exists', async () => { + stateMock.virtualScroller = undefined; + + await manager._reconcileBatchViewAfterDownload([ + { modelId: '111', selectedVersion: { id: 40 }, versions: [{ id: 40 }, { id: 10 }] }, + ], 0); + + expect(resetAndReloadMock).toHaveBeenCalledWith(true); + }); + }); + + describe('executeDownloadWithProgress success path', () => { + it('reconciles in place instead of hijacking the active folder or reloading', async () => { + stateMock.virtualScroller.items = [makeItem('/models/loras/old.safetensors', 837884)]; + const pageState = { showUpdateAvailableOnly: true }; + mockApiClient.getPageState.mockReturnValue(pageState); + mockApiClient.downloadModel.mockResolvedValue({ success: true }); + manager.versions = [{ id: 250 }, { id: 100 }]; + + const result = await manager.executeDownloadWithProgress({ + modelId: 837884, + versionId: 250, + versionName: 'v2', + targetFolder: 'Some/SubFolder', + useDefaultPaths: false, + source: 'civitai', + }); + + expect(result).toBe(true); + // The download destination folder must never become the active folder. + expect(pageState).toEqual({ showUpdateAvailableOnly: true }); + expect(setStorageItemMock).not.toHaveBeenCalledWith( + expect.stringContaining('_activeFolder'), + expect.anything() + ); + // Card reconciled in place; no full page reload, no scroll reset. + expect(mockScroller.removeMultipleItemsByFilePath).toHaveBeenCalledWith([ + '/models/loras/old.safetensors', + ]); + expect(resetAndReloadMock).not.toHaveBeenCalled(); + expect(sidebarRefreshMock).toHaveBeenCalledTimes(1); + }); + + it('falls back to a full reload when no virtual scroller is available', async () => { + stateMock.virtualScroller = undefined; + mockApiClient.downloadModel.mockResolvedValue({ success: true }); + + const result = await manager.executeDownloadWithProgress({ + modelId: 837884, + versionId: 250, + source: 'civitai', + }); + + expect(result).toBe(true); + expect(resetAndReloadMock).toHaveBeenCalledWith(true); + }); + + it('passes through an explicit isLatestVersion flag', async () => { + stateMock.virtualScroller.items = [makeItem('/models/loras/old.safetensors', 837884)]; + mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true }); + mockApiClient.downloadModel.mockResolvedValue({ success: true }); + manager.versions = [{ id: 250 }]; + + await manager.executeDownloadWithProgress({ + modelId: 837884, + versionId: 100, + source: 'civitai', + isLatestVersion: false, + }); + + // Deliberately downloading an older version keeps the card. + expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled(); + expect(mockScroller.updateSingleItem).not.toHaveBeenCalled(); + expect(resetAndReloadMock).not.toHaveBeenCalled(); + }); + }); + + describe('_downloadSelectedFilesSequentially success path', () => { + it('reconciles in place once all files of the latest version are downloaded', async () => { + stateMock.virtualScroller.items = [makeItem('/models/loras/old.safetensors', 837884)]; + mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true }); + mockApiClient.downloadModel.mockResolvedValue({ success: true }); + manager.modelId = '837884'; + manager.currentVersion = { id: 201 }; + manager.versions = [{ id: 201 }, { id: 100 }]; + manager.source = 'civitai'; + manager.selectedFiles = [ + { id: 1, name: 'a.safetensors', type: 'Model', sizeKB: 10 }, + { id: 2, name: 'b.safetensors', type: 'Model', sizeKB: 10 }, + ]; + + const result = await manager._downloadSelectedFilesSequentially({ + modelRoot: '/models/loras', + targetFolder: '', + useDefaultPaths: true, + }); + + expect(result).toBe(true); + expect(resetAndReloadMock).not.toHaveBeenCalled(); + expect(mockScroller.removeMultipleItemsByFilePath).toHaveBeenCalledWith([ + '/models/loras/old.safetensors', + ]); + expect(sidebarRefreshMock).toHaveBeenCalledTimes(1); + }); + + it('keeps the listing untouched on partial multi-file failure', async () => { + stateMock.virtualScroller.items = [makeItem('/models/loras/old.safetensors', 837884)]; + mockApiClient.getPageState.mockReturnValue({ showUpdateAvailableOnly: true }); + mockApiClient.downloadModel + .mockResolvedValueOnce({ success: true }) + .mockResolvedValueOnce({ success: false, error: 'rate limited' }); + manager.modelId = '837884'; + manager.currentVersion = { id: 201 }; + manager.versions = [{ id: 201 }, { id: 100 }]; + manager.source = 'civitai'; + manager.selectedFiles = [ + { id: 1, name: 'a.safetensors', type: 'Model', sizeKB: 10 }, + { id: 2, name: 'b.safetensors', type: 'Model', sizeKB: 10 }, + ]; + + const result = await manager._downloadSelectedFilesSequentially({ + modelRoot: '/models/loras', + targetFolder: '', + useDefaultPaths: true, + }); + + expect(result).toBe(false); + expect(mockScroller.removeMultipleItemsByFilePath).not.toHaveBeenCalled(); + expect(resetAndReloadMock).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file