From e70055e82f66d213c72563fbcc89ea555c87b845 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Fri, 19 Jun 2026 22:21:10 +0200 Subject: [PATCH] feat(queue): add persistent download history with active/history tabs Adds a downloads panel inside the resolve queue UI with two tabs: - Active: live downloads - History: completed or already-downloaded entries History is persisted in localStorage (model_resolver_download_history, capped at 200 entries) via new helper methods on queueMethods. Downloads complete by calling rememberCompletedDownloadHistory(), which records filename, category, workflow, node, target path, size, status and timestamp. CSS additions cover the subtab bar, history item layout, and a small status badge. --- web/css/resolver-shell.css | 33 +++ web/resolver/actions/queue_methods.js | 258 +++++++++++++++++- .../actions/resolve_download_methods.js | 1 + web/resolver/resolver_dialog.js | 4 + 4 files changed, 286 insertions(+), 10 deletions(-) diff --git a/web/css/resolver-shell.css b/web/css/resolver-shell.css index 79c9a62..a712ddc 100644 --- a/web/css/resolver-shell.css +++ b/web/css/resolver-shell.css @@ -393,6 +393,16 @@ font-size: 13px; } +.mr-downloads-panel { + display: grid; + gap: 0; + min-height: 0; +} + +.mr-downloads-subtabs { + margin: 0 0 -1px; +} + .mr-queue-items { display: flex; flex-direction: column; @@ -495,6 +505,29 @@ border-radius: 999px; } +.mr-download-history-toolbar { + display: flex; + justify-content: flex-end; + padding: 0 8px 4px; +} + +.mr-download-history-status { + flex: 0 0 auto; + max-width: 130px; + padding: 2px 6px; + overflow: hidden; + color: #d7d7d7; + font-size: 10px; + font-weight: 800; + line-height: 1.2; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.105); + border-radius: 999px; +} + .mr-download-queue-progress { display: grid; gap: 4px; diff --git a/web/resolver/actions/queue_methods.js b/web/resolver/actions/queue_methods.js index 2be59e1..f4845f7 100644 --- a/web/resolver/actions/queue_methods.js +++ b/web/resolver/actions/queue_methods.js @@ -70,6 +70,8 @@ export const queueMethods = { createQueuePanel() { this.queuePanelActiveTab = this.queuePanelActiveTab || 'queued'; + this.queueDownloadsActiveTab = this.queueDownloadsActiveTab || 'active'; + this.loadDownloadHistory?.(); this.queueClearButton = $el("button", { id: "queue-clear", className: "mr-btn mr-btn-secondary mr-btn-sm", @@ -117,10 +119,18 @@ export const queueMethods = { this.updateQueuePanel(); }, + setQueueDownloadsTab(tab) { + const nextTab = tab === 'history' ? 'history' : 'active'; + if (this.queueDownloadsActiveTab === nextTab) return; + this.queueDownloadsActiveTab = nextTab; + this.updateQueuePanel(); + }, + updateQueuePanel() { if (!this.queueList || !this.queueHeader) return; const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : []; const downloads = this.getActiveQueuePanelDownloads(); + const history = this.getDownloadHistory(); const activeTab = this.queuePanelActiveTab === 'downloads' ? 'downloads' : 'queued'; const clearBtn = this.queueClearButton || this.queueHeader.querySelector('#queue-clear'); const showActions = activeTab === 'queued'; @@ -129,7 +139,7 @@ export const queueMethods = { this.updateQueuePanelTabs(list.length, downloads.length, activeTab); if (activeTab === 'downloads') { - this.renderQueueDownloads(downloads); + this.renderDownloadsPanel(downloads, history); return; } @@ -743,10 +753,191 @@ export const queueMethods = { return this.getActiveQueuePanelDownloads().map(({ downloadId }) => downloadId); }, + loadDownloadHistory() { + if (this._downloadHistoryLoaded) return this.downloadHistory || []; + this._downloadHistoryLoaded = true; + try { + const raw = localStorage.getItem(this.downloadHistoryStorageKey || 'model_resolver_download_history'); + const parsed = raw ? JSON.parse(raw) : []; + this.downloadHistory = Array.isArray(parsed) + ? parsed.filter(item => item && typeof item === 'object') + : []; + } catch (error) { + console.warn('Model Resolver: failed to load download history', error); + this.downloadHistory = []; + } + return this.downloadHistory; + }, + + getDownloadHistory() { + if (!this._downloadHistoryLoaded) { + this.loadDownloadHistory(); + } + return Array.isArray(this.downloadHistory) ? this.downloadHistory : []; + }, + + saveDownloadHistory() { + const history = this.getDownloadHistory().slice(0, this.downloadHistoryLimit || 200); + this.downloadHistory = history; + try { + localStorage.setItem( + this.downloadHistoryStorageKey || 'model_resolver_download_history', + JSON.stringify(history) + ); + } catch (error) { + console.warn('Model Resolver: failed to save download history', error); + } + }, + + getDownloadHistoryIdentity(entry = {}) { + return [ + entry.path || '', + entry.filename || '', + entry.category || '', + entry.sourceUrl || '' + ].map(value => String(value || '').trim().toLowerCase()).join('::'); + }, + + addDownloadHistoryEntry(entry = {}) { + if (!entry || !entry.filename) return null; + + const history = this.getDownloadHistory(); + const identity = this.getDownloadHistoryIdentity(entry); + const filtered = identity + ? history.filter(item => this.getDownloadHistoryIdentity(item) !== identity) + : history; + const nextEntry = { + ...entry, + id: entry.id || `${Date.now()}-${Math.random().toString(36).slice(2)}`, + completedAt: entry.completedAt || new Date().toISOString() + }; + this.downloadHistory = [nextEntry, ...filtered].slice(0, this.downloadHistoryLimit || 200); + this.saveDownloadHistory(); + this.updateQueuePanel?.(); + return nextEntry; + }, + + rememberCompletedDownloadHistory(downloadId, info = {}, progress = {}) { + const missing = info?.missing || {}; + const filename = progress.filename + || info.filename + || missing.download_source?.filename + || missing.original_path?.split(/[\/\\]/).pop() + || ''; + if (!filename) return null; + + const directory = progress.directory || info.downloadDirectory || ''; + const path = progress.path || info.downloadPath || ''; + const category = info.category || missing.category || progress.category || ''; + const workflowLabel = this.getDownloadWorkflowLabel?.(info) || info.workflowLabel || ''; + const nodeLabel = missing.subgraph_name || missing.node_type || (missing.subgraph_id ? 'Subgraph' : 'Node'); + const status = progress.already_exists ? 'already_exists' : 'completed'; + return this.addDownloadHistoryEntry({ + downloadId, + filename, + category, + categoryLabel: this.getCategoryDisplayName?.(category) || category, + nodeLabel, + nodeId: missing.node_id ?? '', + widgetIndex: missing.widget_index ?? '', + workflowLabel, + workflowRouteKey: info.workflowRouteKey || '', + workflowTabId: info.workflowTabId || '', + workflowTabName: info.workflowTabName || '', + path, + directory, + sourceUrl: info.sourceUrl || missing.download_source?.url || '', + totalSize: progress.total_size || progress.size || 0, + status, + statusLabel: status === 'already_exists' ? 'Already downloaded' : 'Downloaded', + message: progress.message || '', + completedAt: new Date().toISOString() + }); + }, + + formatDownloadHistoryTime(value = '') { + if (!value) return ''; + try { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleString(undefined, { + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + } catch (error) { + return ''; + } + }, + + getDownloadHistoryFolderContext(entry = {}) { + const targetPath = entry.directory || entry.path || ''; + if (!targetPath) return null; + return { + context_scope: 'download_folder', + open_folder_label: 'Open Download Folder', + name: entry.filename || 'Download', + path: targetPath, + resolved_path: targetPath, + folder_path: entry.directory || targetPath, + download_directory: entry.directory || '', + download_path: entry.path || '', + category: entry.category || '' + }; + }, + + renderDownloadsPanel(downloads = [], history = this.getDownloadHistory()) { + const activeSubTab = this.queueDownloadsActiveTab === 'history' ? 'history' : 'active'; + const activeSelected = activeSubTab === 'active'; + let html = '
'; + html += '
'; + html += ``; + html += ``; + html += '
'; + html += activeSelected + ? this.renderQueueDownloadsHtml(downloads) + : this.renderDownloadHistoryHtml(history); + html += '
'; + + this.queueList.innerHTML = html; + this.wireDownloadsPanelControls(); + }, + + wireDownloadsPanelControls() { + this.queueList.querySelectorAll('.mr-downloads-subtab').forEach(button => { + button.addEventListener('click', () => { + this.setQueueDownloadsTab(button.dataset.downloadsTab || 'active'); + }); + }); + + this.queueList.querySelectorAll('.mr-download-queue-cancel').forEach(button => { + button.addEventListener('click', () => { + const downloadId = button.dataset.downloadId; + if (downloadId) this.cancelDownload(downloadId); + }); + }); + + const clearHistoryButton = this.queueList.querySelector('.mr-download-history-clear'); + if (clearHistoryButton) { + clearHistoryButton.addEventListener('click', () => this.clearDownloadHistory()); + } + }, + renderQueueDownloads(downloads) { + this.queueList.innerHTML = this.renderQueueDownloadsHtml(downloads); + + this.queueList.querySelectorAll('.mr-download-queue-cancel').forEach(button => { + button.addEventListener('click', () => { + const downloadId = button.dataset.downloadId; + if (downloadId) this.cancelDownload(downloadId); + }); + }); + }, + + renderQueueDownloadsHtml(downloads) { if (!downloads.length) { - this.queueList.innerHTML = '
No active downloads.
'; - return; + return '
No active downloads.
'; } let html = '
'; @@ -801,14 +992,61 @@ export const queueMethods = { html += `
`; } html += ''; - this.queueList.innerHTML = html; + return html; + }, - this.queueList.querySelectorAll('.mr-download-queue-cancel').forEach(button => { - button.addEventListener('click', () => { - const downloadId = button.dataset.downloadId; - if (downloadId) this.cancelDownload(downloadId); - }); - }); + renderDownloadHistoryHtml(history = []) { + if (!history.length) { + return '
No downloaded models in history yet.
'; + } + + let html = '
'; + html += '
'; + for (const entry of history) { + const filename = entry.filename || 'model'; + const workflowLabel = entry.workflowLabel || 'Unknown workflow'; + const category = entry.categoryLabel || this.getCategoryDisplayName?.(entry.category || '') || entry.category || ''; + const nodeText = entry.nodeId !== '' && entry.nodeId !== undefined + ? `${entry.nodeLabel || 'Node'} #${entry.nodeId}` + : (entry.nodeLabel || ''); + const targetPath = entry.directory || entry.path || ''; + const targetLabel = targetPath ? targetPath.split(/[\/\\]/).filter(Boolean).pop() || targetPath : ''; + const timeLabel = this.formatDownloadHistoryTime(entry.completedAt); + const sizeLabel = entry.totalSize ? this.formatBytes(entry.totalSize) : ''; + const statusLabel = entry.statusLabel || (entry.status === 'already_exists' ? 'Already downloaded' : 'Downloaded'); + const contextModel = this.getDownloadHistoryFolderContext(entry); + const contextData = contextModel + ? ` data-model="${this.escapeHtml(encodeURIComponent(JSON.stringify(contextModel)))}" oncontextmenu="window.MLOpenContextMenu(event, this)" data-tooltip="Right-click to open download folder"` + : ''; + + html += `
`; + html += `
`; + html += `${this.escapeHtml(filename)}`; + html += `${this.escapeHtml(statusLabel)}`; + html += `
`; + if (nodeText) { + html += `
Model${this.escapeHtml(nodeText)}
`; + } + html += `
Workflow${this.escapeHtml(workflowLabel)}
`; + if (category) { + html += `
Type${this.escapeHtml(category)}
`; + } + if (targetLabel) { + html += `
Folder${this.escapeHtml(targetLabel)}
`; + } + if (sizeLabel || timeLabel) { + html += `
Done${this.escapeHtml([timeLabel, sizeLabel].filter(Boolean).join(' | '))}
`; + } + html += `
`; + } + html += '
'; + return html; + }, + + clearDownloadHistory() { + this.downloadHistory = []; + this.saveDownloadHistory(); + this.updateQueuePanel(); }, // Remove queued by index diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index 2640b18..94a3201 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -1284,6 +1284,7 @@ export const resolveDownloadMethods = { isActive: false }); this.renderDownloadSnapshot(downloadId, completedSnapshot, { progressDiv, downloadBtn }); + this.rememberCompletedDownloadHistory?.(downloadId, info, progress); delete this.activeDownloads[downloadId]; this.updateDownloadAllButtonState(); this.updateQueuePanel?.(); diff --git a/web/resolver/resolver_dialog.js b/web/resolver/resolver_dialog.js index 509e842..6ab231f 100644 --- a/web/resolver/resolver_dialog.js +++ b/web/resolver/resolver_dialog.js @@ -34,8 +34,12 @@ export class ResolverManagerDialog extends ComfyDialog { this.workflowSearchResultCaches = new Map(); // workflow key -> search results by missing model this.workflowDownloadTargetSelectionCaches = new Map(); // workflow key -> suggested/user download folders this.activeDownloads = {}; // Track active downloads + this.downloadHistoryStorageKey = 'model_resolver_download_history'; + this.downloadHistoryLimit = 200; + this.downloadHistory = []; this.downloadProgressByMissingKey = new Map(); // missing model key -> last known download UI state this.queuePanelActiveTab = 'queued'; + this.queueDownloadsActiveTab = 'active'; this.searchResultCache = new Map(); this.backgroundSearchJobs = new Map(); this.searchProgressTimers = new Map();