diff --git a/tests/test_download_action_handlers.mjs b/tests/test_download_action_handlers.mjs new file mode 100644 index 0000000..6b0ad48 --- /dev/null +++ b/tests/test_download_action_handlers.mjs @@ -0,0 +1,161 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Window } from 'happy-dom'; +import { bindDownloadActionHandlers } from '../web/resolver/utils/download_action_handlers.js'; + +const projectRoot = path.resolve(import.meta.dirname, '..'); +const queueMethodsSource = fs.readFileSync( + path.join(projectRoot, 'web/resolver/actions/queue_methods.js'), + 'utf8' +); +const resolveDownloadMethodsSource = fs.readFileSync( + path.join(projectRoot, 'web/resolver/actions/resolve_download_methods.js'), + 'utf8' +); + +function extractMethod(source, methodName, paramsPattern = '[^)]*') { + const signatureRegex = new RegExp(`\\n\\s+${methodName}\\s*\\(${paramsPattern}\\)\\s*\\{`); + const match = signatureRegex.exec(source); + assert.ok(match, `Could not find ${methodName}`); + const parenStart = source.indexOf('(', match.index); + const parenEnd = source.indexOf(')', parenStart); + const braceStart = source.indexOf('{', parenEnd); + let depth = 0; + for (let index = braceStart; index < source.length; index += 1) { + const char = source[index]; + if (char === '{') depth += 1; + if (char === '}') depth -= 1; + if (depth === 0) { + const params = source.slice(parenStart + 1, parenEnd); + return `function ${methodName}(${params}) ${source.slice(braceStart, index + 1)}`; + } + } + throw new Error(`Could not parse ${methodName}`); +} + +const wireDownloadsPanelControls = eval(`(${extractMethod(queueMethodsSource, 'wireDownloadsPanelControls')})`); +const attachDownloadActionHandlers = eval(`(${extractMethod(resolveDownloadMethodsSource, 'attachDownloadActionHandlers')})`); + +function createActionMarkup(window, classes) { + const root = window.document.createElement('div'); + for (const className of classes) { + const button = window.document.createElement('button'); + button.className = className; + button.dataset.downloadId = 'download-1'; + root.appendChild(button); + } + return root; +} + +function createDownloadDialog(queueList, calls) { + return { + queueList, + activeDownloads: { + 'download-1': { + lastProgress: { status: 'downloading' }, + }, + }, + getDownloadWorkflowLabel: () => 'Test workflow', + getDownloadQueueContext: () => ({ widget_name: 'test-model' }), + cancelDownload: id => calls.push(['cancel', id]), + pauseDownload: id => calls.push(['pause', id]), + resumeDownload: id => calls.push(['resume', id]), + openContainingFolder: context => calls.push(['folder', context.widget_name]), + switchToDownloadWorkflow: context => calls.push(['workflow', context.widget_name]), + setQueueDownloadsTab: () => {}, + clearDownloadHistory: () => {}, + removeDownloadHistoryEntry: () => {}, + }; +} + +function dispatchPointerAndClick(window, button) { + button.dispatchEvent(new window.Event('pointerdown', { bubbles: true, cancelable: true })); + button.dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); +} + +test('queue download controls trigger each action once and keep more as click-only', () => { + const window = new Window(); + globalThis.window = window; + const calls = []; + const queueList = createActionMarkup(window, [ + 'mr-download-queue-cancel', + 'mr-download-queue-pause', + 'mr-download-queue-resume', + 'mr-download-queue-open-folder', + 'mr-download-queue-switch-workflow', + 'mr-download-queue-more', + ]); + const contextMenuCalls = []; + window.MLOpenContextMenu = (event, item) => contextMenuCalls.push([event.type, item]); + const item = window.document.createElement('div'); + item.className = 'mr-download-queue-item'; + item.appendChild(queueList); + + const dialog = createDownloadDialog(item, calls); + dialog.queueList = item; + wireDownloadsPanelControls.call(dialog); + + const buttons = item.querySelectorAll('button'); + dispatchPointerAndClick(window, buttons[0]); + dispatchPointerAndClick(window, buttons[1]); + dispatchPointerAndClick(window, buttons[2]); + dispatchPointerAndClick(window, buttons[3]); + dispatchPointerAndClick(window, buttons[4]); + buttons[5].dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); + + assert.deepEqual(calls, [ + ['cancel', 'download-1'], + ['pause', 'download-1'], + ['resume', 'download-1'], + ['folder', 'test-model'], + ['workflow', 'test-model'], + ]); + assert.equal(contextMenuCalls.length, 1); + assert.equal(contextMenuCalls[0][0], 'click'); + assert.equal(contextMenuCalls[0][1], item); +}); + +test('progress download controls support pending buttons and fallback download ids', () => { + const window = new Window(); + const calls = []; + const progressDiv = createActionMarkup(window, [ + 'cancel-download-btn-pending', + 'pause-download-btn', + 'resume-download-btn', + 'mr-download-queue-open-folder', + 'mr-download-queue-switch-workflow', + 'mr-download-queue-more', + ]); + const item = window.document.createElement('div'); + item.className = 'mr-download-queue-item'; + item.appendChild(progressDiv); + + const dialog = createDownloadDialog(progressDiv, calls); + dialog.activeDownloads['fallback-download'] = dialog.activeDownloads['download-1']; + dialog.getDownloadFolderContext = () => ({ widget_name: 'folder-fallback' }); + globalThis.window = window; + attachDownloadActionHandlers.call(dialog, item, 'fallback-download'); + + const buttons = item.querySelectorAll('button'); + buttons[0].dataset.downloadId = ''; + buttons[1].dataset.downloadId = ''; + buttons[2].dataset.downloadId = ''; + buttons[3].dataset.downloadId = ''; + buttons[4].dataset.downloadId = ''; + buttons[5].dataset.downloadId = ''; + dispatchPointerAndClick(window, buttons[0]); + dispatchPointerAndClick(window, buttons[1]); + dispatchPointerAndClick(window, buttons[2]); + dispatchPointerAndClick(window, buttons[3]); + dispatchPointerAndClick(window, buttons[4]); + + assert.deepEqual(calls, [ + ['cancel', 'fallback-download'], + ['pause', 'fallback-download'], + ['resume', 'fallback-download'], + ['folder', 'test-model'], + ['workflow', 'test-model'], + ]); +}); diff --git a/web/resolver/actions/queue_methods.js b/web/resolver/actions/queue_methods.js index 49cde47..8ad38fc 100644 --- a/web/resolver/actions/queue_methods.js +++ b/web/resolver/actions/queue_methods.js @@ -2,7 +2,8 @@ import { app } from "../../../../../scripts/app.js"; import { $el } from "../../../../../scripts/ui.js"; import { getSvgIcon } from "../../utils/icon_utils.js"; import { startSplitterDrag } from "../utils/splitter_drag.js"; -import { bindInstantAction, syncElementAttributes } from "../utils/dom_patch_utils.js"; +import { syncElementAttributes } from "../utils/dom_patch_utils.js"; +import { bindDownloadActionHandlers } from "../utils/download_action_handlers.js"; const FOOTER_VERSION_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; const FOOTER_VERSION_FAILURE_REFRESH_INTERVAL_MS = 5 * 60 * 1000; @@ -1135,51 +1136,27 @@ export const queueMethods = { }); }); - this.queueList.querySelectorAll('.mr-download-queue-cancel').forEach(button => { - bindInstantAction(button, () => { - const downloadId = button.dataset.downloadId; - if (downloadId) this.cancelDownload(downloadId); - }); - }); - - this.queueList.querySelectorAll('.mr-download-queue-pause').forEach(button => { - bindInstantAction(button, () => { - const downloadId = button.dataset.downloadId; - if (downloadId) this.pauseDownload(downloadId); - }); - }); - - this.queueList.querySelectorAll('.mr-download-queue-resume').forEach(button => { - bindInstantAction(button, () => { - const downloadId = button.dataset.downloadId; - if (downloadId) this.resumeDownload(downloadId); - }); - }); - - this.queueList.querySelectorAll('.mr-download-queue-open-folder').forEach(button => { - bindInstantAction(button, () => { - const context = getDownloadContext(button); - if (context) this.openContainingFolder?.(context); - }); - }); - - this.queueList.querySelectorAll('.mr-download-queue-switch-workflow').forEach(button => { - bindInstantAction(button, () => { - const context = getDownloadContext(button); - if (context) this.switchToDownloadWorkflow?.(context); - }); - }); - - this.queueList.querySelectorAll('.mr-download-queue-more').forEach(button => { - if (button._hasListener) return; - button._hasListener = true; - button.addEventListener('click', (event) => { - event.preventDefault(); - event.stopPropagation(); - if (button.disabled) return; + bindDownloadActionHandlers(this.queueList, { + selectors: { + cancel: '.mr-download-queue-cancel', + pause: '.mr-download-queue-pause', + resume: '.mr-download-queue-resume', + openFolder: '.mr-download-queue-open-folder', + switchWorkflow: '.mr-download-queue-switch-workflow', + more: '.mr-download-queue-more', + }, + getDownloadId: button => button?.dataset?.downloadId || '', + getContext: getDownloadContext, + onCancel: downloadId => this.cancelDownload(downloadId), + onPause: downloadId => this.pauseDownload(downloadId), + onResume: downloadId => this.resumeDownload(downloadId), + onOpenFolder: context => this.openContainingFolder?.(context), + onSwitchWorkflow: context => this.switchToDownloadWorkflow?.(context), + moreBinding: 'click', + onMore: (event, button) => { const item = button.closest('.mr-download-queue-item'); if (item) window.MLOpenContextMenu?.(event, item); - }); + }, }); const clearHistoryButton = this.queueList.querySelector('.mr-download-history-clear'); diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index 330d7eb..5bf8a59 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -4,7 +4,8 @@ import { getModelCardUrl, parseHuggingFaceFileUrl } from "../utils/url_utils.js" import { getCivitaiModelUrl } from "../globals.js"; import { normalizeSha256 } from "../utils/hash_utils.js"; import { normalizePathIdentity } from "../utils/html_utils.js"; -import { bindEventOnce, bindInstantAction, syncElementAttributes } from "../utils/dom_patch_utils.js"; +import { bindEventOnce, syncElementAttributes } from "../utils/dom_patch_utils.js"; +import { bindDownloadActionHandlers } from "../utils/download_action_handlers.js"; import { normalizeSourceKey } from "../utils/source_labels.js"; const log = createModuleLogger('resolve_download_methods'); @@ -1132,24 +1133,6 @@ export const resolveDownloadMethods = { attachDownloadActionHandlers(progressDiv, downloadId) { if (!progressDiv) return; - progressDiv.querySelectorAll('.cancel-download-btn, .cancel-download-btn-pending, .mr-download-queue-cancel').forEach(cancelBtn => { - bindInstantAction(cancelBtn, () => { - const targetDownloadId = cancelBtn.dataset.downloadId || downloadId; - if (targetDownloadId) this.cancelDownload(targetDownloadId); - }); - }); - progressDiv.querySelectorAll('.pause-download-btn, .mr-download-queue-pause').forEach(pauseBtn => { - bindInstantAction(pauseBtn, () => { - const targetDownloadId = pauseBtn.dataset.downloadId || downloadId; - if (targetDownloadId) this.pauseDownload(targetDownloadId); - }); - }); - progressDiv.querySelectorAll('.resume-download-btn, .mr-download-queue-resume').forEach(resumeBtn => { - bindInstantAction(resumeBtn, () => { - const targetDownloadId = resumeBtn.dataset.downloadId || downloadId; - if (targetDownloadId) this.resumeDownload(targetDownloadId); - }); - }); const getDownloadContext = (button) => { const targetDownloadId = button?.dataset?.downloadId || downloadId; const info = this.activeDownloads?.[targetDownloadId]; @@ -1162,23 +1145,26 @@ export const resolveDownloadMethods = { || this.getDownloadFolderContext?.(progress, info) || null; }; - progressDiv.querySelectorAll('.mr-download-queue-open-folder').forEach(folderBtn => { - bindInstantAction(folderBtn, () => { - const context = getDownloadContext(folderBtn); - if (context) this.openContainingFolder?.(context); - }); - }); - progressDiv.querySelectorAll('.mr-download-queue-switch-workflow').forEach(workflowBtn => { - bindInstantAction(workflowBtn, () => { - const context = getDownloadContext(workflowBtn); - if (context) this.switchToDownloadWorkflow?.(context); - }); - }); - progressDiv.querySelectorAll('.mr-download-queue-more').forEach(moreBtn => { - bindInstantAction(moreBtn, (event) => { - const item = moreBtn.closest('.mr-download-queue-item'); + bindDownloadActionHandlers(progressDiv, { + fallbackDownloadId: downloadId, + selectors: { + cancel: '.cancel-download-btn, .cancel-download-btn-pending, .mr-download-queue-cancel', + pause: '.pause-download-btn, .mr-download-queue-pause', + resume: '.resume-download-btn, .mr-download-queue-resume', + openFolder: '.mr-download-queue-open-folder', + switchWorkflow: '.mr-download-queue-switch-workflow', + more: '.mr-download-queue-more', + }, + getContext: getDownloadContext, + onCancel: targetDownloadId => this.cancelDownload(targetDownloadId), + onPause: targetDownloadId => this.pauseDownload(targetDownloadId), + onResume: targetDownloadId => this.resumeDownload(targetDownloadId), + onOpenFolder: context => this.openContainingFolder?.(context), + onSwitchWorkflow: context => this.switchToDownloadWorkflow?.(context), + onMore: (event, button) => { + const item = button.closest('.mr-download-queue-item'); if (item) window.MLOpenContextMenu?.(event, item); - }); + }, }); }, diff --git a/web/resolver/utils/download_action_handlers.js b/web/resolver/utils/download_action_handlers.js new file mode 100644 index 0000000..de67168 --- /dev/null +++ b/web/resolver/utils/download_action_handlers.js @@ -0,0 +1,66 @@ +import { bindInstantAction } from "./dom_patch_utils.js"; + +function bindClickAction(button, handler) { + if (!button || button._hasListener) return false; + button._hasListener = true; + button.addEventListener('click', (event) => { + event?.preventDefault?.(); + event?.stopPropagation?.(); + if (button.disabled) return; + handler(event); + }); + return true; +} + +function bindActionButtons(root, selector, handler) { + if (!selector) return; + root.querySelectorAll(selector).forEach((button) => { + bindInstantAction(button, (event) => handler(button, event)); + }); +} + +export function bindDownloadActionHandlers(root, { + fallbackDownloadId = '', + getDownloadId = button => button?.dataset?.downloadId || fallbackDownloadId, + getContext = null, + selectors = {}, + onCancel = null, + onPause = null, + onResume = null, + onOpenFolder = null, + onSwitchWorkflow = null, + onMore = null, + moreBinding = 'instant', +} = {}) { + if (!root?.querySelectorAll) return; + + const resolveDownloadId = button => getDownloadId(button) || ''; + const resolveContext = button => getContext?.(button, resolveDownloadId(button)) || null; + const bindIdAction = (selector, action) => { + if (!action) return; + bindActionButtons(root, selector, (button) => { + const downloadId = resolveDownloadId(button); + if (downloadId) action(downloadId); + }); + }; + + bindIdAction(selectors.cancel, onCancel); + bindIdAction(selectors.pause, onPause); + bindIdAction(selectors.resume, onResume); + + bindActionButtons(root, selectors.openFolder, (button) => { + const context = resolveContext(button); + if (context) onOpenFolder?.(context); + }); + bindActionButtons(root, selectors.switchWorkflow, (button) => { + const context = resolveContext(button); + if (context) onSwitchWorkflow?.(context); + }); + + if (selectors.more && onMore) { + const bindMore = moreBinding === 'click' ? bindClickAction : bindInstantAction; + root.querySelectorAll(selectors.more).forEach((button) => { + bindMore(button, event => onMore(event, button, resolveDownloadId(button))); + }); + } +}