refactor(web): unify download action handlers

This commit is contained in:
Dariusz L
2026-08-06 16:52:13 +02:00
parent 25c92761f7
commit 6f3273ad3b
4 changed files with 269 additions and 79 deletions
+161
View File
@@ -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'],
]);
});
+21 -44
View File
@@ -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');
@@ -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);
});
},
});
},
@@ -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)));
});
}
}