diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 5b9a1a3..3f6a54b 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -609,6 +609,15 @@ .mr-missing-list-row.is-selected.is-batch-selected { background: linear-gradient(90deg, rgba(114,211,138,0.13) 0%, rgba(78,161,255,0.065) 58%, rgba(78,161,255,0.025) 100%); } +#model-resolver-modal .mr-missing-list-row.is-menu-preview { + background: linear-gradient(90deg, rgba(255,204,102,0.11) 0%, rgba(255,204,102,0.052) 56%, rgba(255,204,102,0.018) 100%); + box-shadow: inset 3px 0 0 rgba(255,204,102,0.74), inset 0 0 0 1px rgba(255,204,102,0.12); +} +#model-resolver-modal .mr-missing-list-row.is-selected.is-menu-preview, +#model-resolver-modal .mr-missing-list-row.is-batch-selected.is-menu-preview { + background: linear-gradient(90deg, rgba(255,204,102,0.13) 0%, rgba(78,161,255,0.07) 58%, rgba(114,211,138,0.028) 100%); + box-shadow: inset 3px 0 0 rgba(255,204,102,0.82), inset 0 0 0 1px rgba(255,204,102,0.14); +} .mr-missing-row-select { display: flex; justify-content: center; diff --git a/web/resolver/actions/queue_methods.js b/web/resolver/actions/queue_methods.js index f0378c4..88f2561 100644 --- a/web/resolver/actions/queue_methods.js +++ b/web/resolver/actions/queue_methods.js @@ -604,6 +604,62 @@ export const queueMethods = { this.updateFooterMenuItemVisibility?.(); }, + getMissingModelsForPendingResolutions() { + const pending = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : []; + if (!pending.length) return []; + + const pendingKeys = new Set(pending.map(resolution => this.getMissingModelKey(resolution))); + return (this.missingModels || []).filter(missing => pendingKeys.has(this.getMissingModelKey(missing))); + }, + + getQueueExactLocalMatchPreviewModels() { + const selected = this.getSelectedMissingModels(); + const targets = selected.length ? selected : (this.missingModels || []); + return targets.filter(missing => this.getBestLocalMatch(missing, 100)?.model); + }, + + getDownloadableBatchPreviewModels(mode = 'all') { + const targets = mode === 'selected' + ? this.getSelectedMissingModels() + : (this.missingModels || []); + + return targets.filter(missing => { + if (this.getBestLocalMatch(missing, 100)) return false; + return Boolean(this.getBestDownloadSourceForMissing(missing)?.url); + }); + }, + + getActiveDownloadMissingModels() { + const activeKeys = new Set( + Object.values(this.activeDownloads || {}) + .map(info => info?.missing) + .filter(Boolean) + .map(missing => this.getMissingModelKey(missing)) + ); + if (!activeKeys.size) return []; + + return (this.missingModels || []).filter(missing => activeKeys.has(this.getMissingModelKey(missing))); + }, + + previewFooterMenuModels(models = []) { + const keys = new Set( + (Array.isArray(models) ? models : []) + .filter(Boolean) + .map(missing => this.getMissingModelKey(missing)) + ); + + this.contentElement?.querySelectorAll?.('.mr-missing-list-row')?.forEach(row => { + const key = row.getAttribute('data-missing-key'); + row.classList.toggle('is-menu-preview', Boolean(key && keys.has(key))); + }); + }, + + clearFooterMenuPreview() { + this.contentElement?.querySelectorAll?.('.mr-missing-list-row.is-menu-preview')?.forEach(row => { + row.classList.remove('is-menu-preview'); + }); + }, + createFooterMenu(name, label, items = [], buttonClass = 'mr-btn-secondary') { const button = $el(`button.mr-btn.${buttonClass}.mr-footer-btn.mr-footer-menu-button`, { type: 'button', @@ -650,6 +706,24 @@ export const queueMethods = { if (item?.refName) { this[item.refName] = element; } + if (!isDivider && typeof item?.previewModels === 'function') { + const showPreview = () => { + if (element.disabled || element.classList.contains('mr-btn-is-disabled')) { + this.clearFooterMenuPreview(); + return; + } + try { + this.previewFooterMenuModels(item.previewModels()); + } catch (error) { + console.warn('Model Resolver: footer menu preview failed:', error); + this.clearFooterMenuPreview(); + } + }; + element.addEventListener('mouseenter', showPreview); + element.addEventListener('focus', showPreview); + element.addEventListener('mouseleave', () => this.clearFooterMenuPreview()); + element.addEventListener('blur', () => this.clearFooterMenuPreview()); + } if (typeof item?.visibleWhen === 'function' || typeof item?.disabledWhen === 'function') { if (!this.footerMenuItems) this.footerMenuItems = new Map(); if (!this.footerMenuItems.has(name)) this.footerMenuItems.set(name, []); @@ -710,20 +784,27 @@ export const queueMethods = { this.downloadAllButton = null; const selectMenu = this.createFooterMenu('select', 'Select (0/0)', [ - { label: 'Select All', action: () => this.selectBatchMissingModels('all') }, - { label: 'Select None', action: () => this.selectBatchMissingModels('none') }, - { label: 'Invert Selection', action: () => this.selectBatchMissingModels('invert') }, + { label: 'Select All', previewModels: () => this.missingModels || [], action: () => this.selectBatchMissingModels('all') }, + { label: 'Select None', previewModels: () => this.getSelectedMissingModels(), action: () => this.selectBatchMissingModels('none') }, + { + label: 'Invert Selection', + previewModels: () => { + const selectedKeys = this.batchSelectedMissingKeys || new Set(); + return (this.missingModels || []).filter(missing => !selectedKeys.has(this.getMissingModelKey(missing))); + }, + action: () => this.selectBatchMissingModels('invert') + }, 'divider', - { label: 'Select Exact Local', action: () => this.selectBatchMissingModels('exact') }, - { label: 'Select No Exact Local', action: () => this.selectBatchMissingModels('no_exact') }, - { label: 'Select Partial Local', action: () => this.selectBatchMissingModels('partial') }, - { label: 'Select No Local Match', action: () => this.selectBatchMissingModels('no_local') }, + { label: 'Select Exact Local', previewModels: () => this.getMissingWithExactLocalMatches(), action: () => this.selectBatchMissingModels('exact') }, + { label: 'Select No Exact Local', previewModels: () => this.getMissingWithoutExactLocalMatches(), action: () => this.selectBatchMissingModels('no_exact') }, + { label: 'Select Partial Local', previewModels: () => this.getMissingWithPartialLocalMatches(), action: () => this.selectBatchMissingModels('partial') }, + { label: 'Select No Local Match', previewModels: () => this.getMissingWithoutLocalMatches(), action: () => this.selectBatchMissingModels('no_local') }, 'divider', - { label: 'Select With Download Source', action: () => this.selectBatchMissingModels('downloadable') }, - { label: 'Select Without Download Source', action: () => this.selectBatchMissingModels('no_download') }, + { label: 'Select With Download Source', previewModels: () => this.getMissingWithDownloadSources(), action: () => this.selectBatchMissingModels('downloadable') }, + { label: 'Select Without Download Source', previewModels: () => this.getMissingWithoutDownloadSources(), action: () => this.selectBatchMissingModels('no_download') }, 'divider', - { label: 'Select Searched', action: () => this.selectBatchMissingModels('searched') }, - { label: 'Select Unsearched', action: () => this.selectBatchMissingModels('unsearched') } + { label: 'Select Searched', previewModels: () => this.getSearchedMissingModels(), action: () => this.selectBatchMissingModels('searched') }, + { label: 'Select Unsearched', previewModels: () => this.getUnsearchedMissingModels(), action: () => this.selectBatchMissingModels('unsearched') } ]); this.selectMenuButton = this.footerMenuButtons.get('select'); this.selectMenuWrap = selectMenu; @@ -732,15 +813,15 @@ export const queueMethods = { const searchMenu = this.createFooterMenu('search', 'Search', [ { label: 'Stop Searching', className: 'mr-footer-menu-danger', visibleWhen: () => this.batchSearchRunning, action: () => this.stopBatchSearch() }, { type: 'divider', visibleWhen: () => this.batchSearchRunning }, - { label: 'Search Selected', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'all') }, - { label: 'Search All Missing', visibleWhen: () => !this.batchSearchRunning, action: () => this.searchMissingBatch('all', 'all') }, - { label: 'Search Unsearched', visibleWhen: () => !this.batchSearchRunning, action: () => this.searchMissingBatch('unsearched', 'all') }, + { label: 'Search Selected', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'all') }, + { label: 'Search All Missing', visibleWhen: () => !this.batchSearchRunning, previewModels: () => this.missingModels || [], action: () => this.searchMissingBatch('all', 'all') }, + { label: 'Search Unsearched', visibleWhen: () => !this.batchSearchRunning, previewModels: () => this.getUnsearchedMissingModels(), action: () => this.searchMissingBatch('unsearched', 'all') }, { type: 'divider', visibleWhen: () => !this.batchSearchRunning }, - { label: 'Selected: Local Database', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'local') }, - { label: 'Selected: CivitAI', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'civitai') }, - { label: 'Selected: HuggingFace', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'huggingface') }, - { label: 'Selected: CivArchive', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'civarchive') }, - { label: 'Selected: LoRA Manager Archive', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'lora_manager_archive') } + { label: 'Selected: Local Database', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'local') }, + { label: 'Selected: CivitAI', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'civitai') }, + { label: 'Selected: HuggingFace', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'huggingface') }, + { label: 'Selected: CivArchive', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'civarchive') }, + { label: 'Selected: LoRA Manager Archive', visibleWhen: () => !this.batchSearchRunning, disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getSelectedMissingModels(), action: () => this.searchMissingBatch('selected', 'lora_manager_archive') } ]); this.searchMenuButton = this.footerMenuButtons.get('search'); this.searchMenuWrap = searchMenu; @@ -751,6 +832,7 @@ export const queueMethods = { label: 'Queue Exact', refName: 'queueExactButton', ariaLabel: 'Queue exact local matches', + previewModels: () => this.getQueueExactLocalMatchPreviewModels(), action: () => this.queueExactLocalMatchesBatch('selected') }, { @@ -759,6 +841,7 @@ export const queueMethods = { refName: 'applyPendingBtn', ariaLabel: 'Apply selected model links', disabledWhen: () => (this.pendingResolutions?.length || 0) === 0, + previewModels: () => this.getMissingModelsForPendingResolutions(), action: () => { if ((this.pendingResolutions?.length || 0) === 0) return; this.applyPendingResolutions(); @@ -769,6 +852,7 @@ export const queueMethods = { refName: 'clearSelectedButton', ariaLabel: 'Clear all queued selected model links', disabledWhen: () => (this.pendingResolutions?.length || 0) === 0, + previewModels: () => this.getMissingModelsForPendingResolutions(), action: () => { this.clearAllQueued(); this.closeFooterMenus(); @@ -780,6 +864,7 @@ export const queueMethods = { refName: 'autoResolveButton', ariaLabel: 'Auto-link all 100 percent local matches', visibleWhen: hasExactLocalMatches, + previewModels: () => this.getMissingWithExactLocalMatches(), action: () => this.autoResolve100Percent() } ]); @@ -794,10 +879,10 @@ export const queueMethods = { this.updateApplyPendingButton(); const downloadMenu = this.createFooterMenu('download', 'Download', [ - { label: 'Download Selected', disabledWhen: () => !hasSelectedMissingModels(), action: () => this.downloadMissingBatch('selected') }, - { label: 'Download All With Sources', action: () => this.downloadMissingBatch('all') }, + { label: 'Download Selected', disabledWhen: () => !hasSelectedMissingModels(), previewModels: () => this.getDownloadableBatchPreviewModels('selected'), action: () => this.downloadMissingBatch('selected') }, + { label: 'Download All With Sources', previewModels: () => this.getDownloadableBatchPreviewModels('all'), action: () => this.downloadMissingBatch('all') }, 'divider', - { label: 'Cancel Downloads', action: () => { this.closeFooterMenus(); this.cancelAllDownloads(); } } + { label: 'Cancel Downloads', previewModels: () => this.getActiveDownloadMissingModels(), action: () => { this.closeFooterMenus(); this.cancelAllDownloads(); } } ], 'mr-btn-download'); this.downloadMenuButton = this.footerMenuButtons.get('download'); this.downloadMenuWrap = downloadMenu; diff --git a/web/resolver/actions/selection_methods.js b/web/resolver/actions/selection_methods.js index 3e02a20..15b74ac 100644 --- a/web/resolver/actions/selection_methods.js +++ b/web/resolver/actions/selection_methods.js @@ -20,6 +20,7 @@ export const selectionMethods = { }, closeFooterMenus() { + this.clearFooterMenuPreview?.(); this.activeFooterMenu = null; this.footerMenus?.forEach?.((menu, name) => { menu.classList.remove('is-open');