Files

377 lines
18 KiB
JavaScript

import { $el, ComfyDialog } from "../../../../scripts/ui.js";
import { api } from "../../../../scripts/api.js";
import { safeStorage } from "./utils/html_utils.js";
import { showNotification as showNotificationUtils } from "../utils/notification_utils.js";
import { dialogShellMethods } from "./shell/dialog_shell_methods.js";
import { lifecycleGraphMethods } from "./shell/lifecycle_graph_methods.js";
import { workflowStateMethods } from "./shell/workflow_state_methods.js";
import { workflowUpdateMethods } from "./shell/workflow_update_methods.js";
import { modelInfoMethods } from "./views/model_info_methods.js";
import { missingBrowserMethods } from "./views/missing_browser_methods.js";
import { optionsMethods } from "./views/options_methods.js";
import { tabsLoadedMethods } from "./views/tabs_loaded_methods.js";
import { imageMetadataMethods } from "./views/tabs_image_metadata_methods.js";
import { downloadTargetMethods } from "./search/download_target_methods.js";
import { searchPanelMethods } from "./search/search_panel.js";
import { searchSourceMethods } from "./search/search_source_methods.js";
import { baseModelAliasMethods } from "./search/base_model_alias_methods.js";
import { searchHashMethods } from "./search/search_hash_methods.js";
import { searchStateMethods } from "./search/search_state_methods.js";
import { missingModelStateMethods } from "./views/missing_model_state_methods.js";
import { modelHashCompareMethods } from "./views/model_hash_compare_methods.js";
import { workflowIdentityMethods } from "./shell/workflow_identity_methods.js";
import { queueMethods } from "./actions/queue_methods.js";
import { queueStorageMethods } from "./actions/queue_storage_methods.js";
import { downloadProgressMethods } from "./actions/download_progress_methods.js";
import { resolveDownloadMethods } from "./actions/resolve_download_methods.js";
import { selectionMethods } from "./actions/selection_methods.js";
import { renderFormatMethods } from "./utils/render_format_methods.js";
import { fetchJson as fetchJsonRequest } from "./utils/api_client.js";
import { getSvgIcon } from "../utils/icon_utils.js";
export class ResolverManagerDialog extends ComfyDialog {
constructor() {
super();
this.currentWorkflow = null;
this.missingModels = [];
this.allModels = null; // list of all available models for dropdown
this.downloadDirectories = null;
this.downloadRootDirectories = null;
this.capabilities = null;
this.baseModels = null;
this.downloadSubfolders = new Map();
this.downloadTargetSelections = new Map(); // missing model key -> user/suggested download target
this.pendingResolutions = [];
this.pendingIndex = new Map(); // key -> index in pendingResolutions
this.workflowPendingSelections = new Map(); // workflow key -> queued selections
this.workflowAnalysisCaches = new Map(); // workflow key -> analyzed missing models
this.workflowLoadedModelCaches = new Map(); // workflow key -> loaded model inspector data
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.activeDownloadsStorageKey = 'model_resolver_active_downloads';
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();
this.backendSearchProgressTimers = new Map();
this.urnResolvePromises = new Map();
this.urnLocalMatchPromises = new Map();
this.localMatchRefreshTokens = new Map();
this.searchUiRefreshFrames = new Map();
this.cachedAnalysisData = null;
this.cachedWorkflowSignature = null;
this.cachedLoadedModelsData = null;
this.cachedLoadedModelsSignature = null;
this.selectedMissingModelKey = null;
this.pendingWorkflowModelSelection = null;
this.batchSelectedMissingKeys = new Set();
this.lastBatchSelectedMissingKey = null;
this.activeFooterMenu = null;
this.batchSearchRunning = false;
this.batchSearchCancelRequested = false;
this.forceBatchSearch = false;
this.boundHandleOutsideClick = this.handleOutsideClick.bind(this);
this.activeTabStorageKey = 'model_resolver_active_tab';
this.activeTab = this.restoreActiveTab(); // Default tab
this.fullscreen = false;
this.returnToDockedAfterFullscreen = false;
this.docked = false;
this.dockContainer = null;
this.lastDockContainer = null;
this.pendingDockToSidebar = false;
this.sidebarTabId = "comfyui-model-resolver";
this.sidebarOpenModeStorageKey = "model_resolver_sidebar_open_mode";
this.missingBrowserSplitStorageKey = "model_resolver_missing_browser_detail_w";
this._missingBrowserExternalResizeFrame = null;
this._pendingMissingBrowserExternalResizeBrowser = null;
this._pendingMissingBrowserExternalResizeWidth = null;
this._missingBrowserObservedHostWidth = null;
this.showResolvedModelsStorageKey = "model_resolver_show_resolved_models";
this.showAutoDownloadModelsStorageKey = "model_resolver_show_auto_download_models";
this.showInactiveModelsStorageKey = "model_resolver_show_inactive_models";
this.localMatchAlternativesCollapsedStorageKey = "model_resolver_local_match_alternatives_collapsed";
this.showResolvedModels = safeStorage.getItem(this.showResolvedModelsStorageKey) === '1';
this.missingModelsTypeFilter = 'all';
this.missingModelsTypeFilterMenuOpen = false;
const storedShowAutoDownload = safeStorage.getItem(this.showAutoDownloadModelsStorageKey);
this.showAutoDownloadModels = storedShowAutoDownload === null
? true
: storedShowAutoDownload === '1';
this.showInactiveModels = safeStorage.getItem(this.showInactiveModelsStorageKey) === '1';
this.localMatchAlternativesCollapsed = safeStorage.getItem(this.localMatchAlternativesCollapsedStorageKey) === '1';
this.dockButton = null;
this.undockButton = null;
this._floatingRectBeforeDock = null;
this._modalResizeHandles = [];
this._dragging = false;
this._dragStart = null;
this._dragBounds = null;
this._dragPendingPosition = null;
this._dragAnimationFrame = null;
this._dragLayerCleanupTimer = null;
this._dragDockCandidate = false;
this._pendingDragDockRect = null;
this._lastDockContainerWidth = null;
this._dockedDragStart = null;
this._dockedDragPendingRect = null;
this._dockedDragAnimationFrame = null;
this._dragUndockCandidate = false;
this._sidebarSplitterPointerDownHandler = null;
this._sidebarSplitterMouseDownHandler = null;
this._sidebarSplitterDragState = null;
this._analysisProgressToken = null;
this._workflowAnalysisRequest = null;
this._workflowDataLoadToken = null;
this._loadedModelsLoadToken = null;
this._loadedModelsProgressToken = null;
this._locateAnimationFrame = null;
this._viewportClampFrame = null;
this.activeWorkflowRouteKey = this.getActiveWorkflowRouteKey();
this.activeWorkflowSignature = null;
this.activeMissingWorkflowSignature = null;
this._workflowRefreshTimer = null;
this._workflowRefreshRetryTimer = null;
this._workflowRefreshGeneration = 0;
this._workflowRefreshExpectedRoute = null;
this._workflowRefreshPreviousSignature = null;
this._workflowRefreshSuppressionDepth = 0;
this._comfyModelCatalogRefreshPromise = null;
this._contextMenuSourceLookupCache = new Map();
this._contextMenuSourceLookupToken = 0;
this._boundHandleViewportResize = () => this.scheduleModalViewportClamp(true);
// Create backdrop overlay for click-outside-to-close
this.backdrop = $el("div.model-resolver-backdrop", {
parent: document.body
});
this.dockDropPreview = $el("div.mr-dock-drop-preview", {
parent: document.body,
ariaHidden: "true",
innerHTML: `
<span class="mr-dock-drop-preview-icon">${getSvgIcon('dock')}</span>
<strong>Dock to sidebar</strong>
<span>Release to dock Model Resolver</span>
`
});
this.undockDropPreview = $el("div.mr-undock-drop-preview", {
parent: document.body,
ariaHidden: "true",
innerHTML: `
<span class="mr-dock-drop-preview-icon">${getSvgIcon('undock')}</span>
<strong>Undock to floating window</strong>
<span>Release to undock Model Resolver</span>
`
});
// Create context menu for model chips
this.contextMenu = $el("div.mr-context-menu", {
parent: document.body
}, [
$el("div.mr-context-menu-item.mr-context-menu-action-show-info", {
"data-menu-action": "showInfo",
onclick: () => this.handleContextMenuAction('showInfo')
}, [
$el("span.mr-context-menu-item-icon", {
innerHTML: getSvgIcon('eye', 'currentColor', 'mr-context-menu-item-svg')
}),
$el("span", { textContent: "Show Info" })
]),
$el("div.mr-context-menu-item.mr-context-menu-action-show-more", {
"data-menu-action": "showMore",
onclick: () => this.handleContextMenuAction('showMore')
}, [
$el("span.mr-context-menu-item-icon", {
innerHTML: getSvgIcon('database', 'currentColor', 'mr-context-menu-item-svg')
}),
$el("span", { textContent: "Show More" })
]),
$el("div.mr-context-menu-item.mr-context-menu-action-suggest-subfolder", {
"data-menu-action": "suggestSubfolder",
onclick: () => this.handleContextMenuAction('suggestSubfolder')
}, [
$el("span.mr-context-menu-item-icon", {
innerHTML: getSvgIcon('lightbulb', 'currentColor', 'mr-context-menu-item-svg')
}),
$el("span", { textContent: "Suggest Subfolder" })
]),
$el("div.mr-context-menu-divider.mr-context-menu-divider-source", {
"data-menu-divider": "source"
}),
$el("div.mr-context-menu-item.mr-context-menu-action-source", {
"data-menu-action": "source",
onclick: () => this.handleContextMenuAction('source')
}, [
$el("span.mr-context-menu-item-icon", {
innerHTML: getSvgIcon('externalLink', 'currentColor', 'mr-context-menu-item-svg')
}),
$el("span", { textContent: "Open Source" })
]),
$el("div.mr-context-menu-divider.mr-context-menu-divider-workflow", {
"data-menu-divider": "workflow"
}),
$el("div.mr-context-menu-item.mr-context-menu-action-locate-node", {
"data-menu-action": "locateNode",
onclick: () => this.handleContextMenuAction('locateNode')
}, [
$el("span.mr-context-menu-item-icon", {
innerHTML: getSvgIcon('locate', 'currentColor', 'mr-context-menu-item-svg')
}),
$el("span", { textContent: "Locate Node" })
]),
$el("div.mr-context-menu-item.mr-context-menu-action-switch-workflow", {
"data-menu-action": "switchWorkflow",
onclick: () => this.handleContextMenuAction('switchWorkflow')
}, [
$el("span.mr-context-menu-item-icon", {
innerHTML: getSvgIcon('workflowBack', 'currentColor', 'mr-context-menu-item-svg')
}),
$el("span", { textContent: "Switch to Workflow" })
]),
$el("div.mr-context-menu-divider.mr-context-menu-divider-folder", {
"data-menu-divider": "folder"
}),
$el("div.mr-context-menu-item.mr-context-menu-action-compare-hashes", {
"data-menu-action": "compareHashes",
onclick: () => this.handleContextMenuAction('compareHashes')
}, [
$el("span.mr-context-menu-item-icon", {
innerHTML: getSvgIcon('hash', 'currentColor', 'mr-context-menu-item-svg')
}),
$el("span", { textContent: "Compare Hashes" })
]),
$el("div.mr-context-menu-item.mr-context-menu-action-open-folder", {
"data-menu-action": "openFolder",
onclick: () => this.handleContextMenuAction('openFolder')
}, [
$el("span.mr-context-menu-item-icon", {
innerHTML: getSvgIcon('folderOpen', 'currentColor', 'mr-context-menu-item-svg')
}),
$el("span", { textContent: "Open Containing Folder" })
])
]);
this.tooltipElement = $el("div.mr-global-tooltip", { parent: document.body });
// Selected model for context menu
this._contextMenuModel = null;
// Create dialog element using $el
this.element = $el("div.comfy-modal.model-resolver-modal", {
id: "model-resolver-modal",
parent: document.body
}, [
this.createHeader(),
this.createContent(),
this.createFooter()
]);
this.installModalResizeHandle();
this.tooltipObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
this.bindTooltips(node);
}
}
}
});
this.tooltipObserver.observe(this.element, {
childList: true,
subtree: true
});
this.bindTooltips(this.element);
// Add click listener to hide context menu when clicking outside
this.boundHandleContextMenuClick = (e) => this.handleContextMenuOutsideClick(e);
document.addEventListener('click', this.boundHandleContextMenuClick);
this.boundHandleFooterMenuClick = (e) => this.handleFooterMenuOutsideClick(e);
document.addEventListener('click', this.boundHandleFooterMenuClick);
window.addEventListener('resize', this._boundHandleViewportResize);
this._boundSyncSearchProgressAfterResume = () => {
if (document.visibilityState === 'hidden') return;
window.setTimeout(() => this.syncSearchProgressAfterResume?.(), 0);
};
window.addEventListener('focus', this._boundSyncSearchProgressAfterResume);
document.addEventListener('visibilitychange', this._boundSyncSearchProgressAfterResume);
}
async fetchJson(endpoint, options = {}, errorContext = 'API Request') {
return fetchJsonRequest(endpoint, options, errorContext, {
apiClient: api,
notify: typeof this.showNotification === 'function'
? this.showNotification.bind(this)
: null,
});
}
showNotification(message, type = 'success', options = {}) {
const duration = options?.duration || (type === 'success' ? 4000 : (type === 'error' ? 6000 : 3000));
return showNotificationUtils(message, type, {
...options,
duration,
deduplicate: options?.deduplicate || false
});
}
}
/**
* Mixin Architecture Pattern:
*
* To prevent the dialog class from growing to an unmanageable size (e.g. 5,000+ lines),
* the dialog is split into multiple view/behavior-specific mixin files under `views/`, `actions/`, etc.
*
* Each mixin file exports a plain object containing methods that expect `this` to point to the
* active `ResolverManagerDialog` instance (accessing properties like `this.contentElement`, `this.activeTab`).
*
* Rationale:
* - Keeps UI views isolated in separate files.
* - Resolves circular dependency concerns when views trigger global dialog actions.
*
* Future Refactoring / Migration Path:
* - If UI complexity grows, transition these view objects to decoupled Web Components or independent ES6
* Tab classes extending a base component, passing the dialog controller instance as a constructor argument.
*/
function applyDialogMethods(...sources) {
for (const source of sources) {
for (const key of Reflect.ownKeys(source)) {
const descriptor = Object.getOwnPropertyDescriptor(source, key);
descriptor.enumerable = false;
Object.defineProperty(ResolverManagerDialog.prototype, key, descriptor);
}
}
}
applyDialogMethods(
searchSourceMethods,
baseModelAliasMethods,
searchHashMethods,
searchStateMethods,
missingModelStateMethods,
modelHashCompareMethods,
workflowIdentityMethods,
searchPanelMethods,
modelInfoMethods,
renderFormatMethods,
workflowStateMethods,
selectionMethods,
dialogShellMethods,
downloadTargetMethods,
optionsMethods,
tabsLoadedMethods,
imageMetadataMethods,
queueMethods,
queueStorageMethods,
downloadProgressMethods,
lifecycleGraphMethods,
missingBrowserMethods,
resolveDownloadMethods,
workflowUpdateMethods
);