diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index 529afc8..62f7000 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -3,7 +3,7 @@ import path from 'node:path'; import test from 'node:test'; import assert from 'node:assert/strict'; -test('image URL adds every linked version and deduplicates repeated additions', async () => { +test('image URL restores removed linked versions without duplicates or restoring unrelated results', async () => { const methodStart = resolveDownloadMethodsSource.indexOf('async addCustomUrlResult('); const methodEnd = resolveDownloadMethodsSource.indexOf('\n },', methodStart); const addCustomUrlResult = eval(`(${resolveDownloadMethodsSource.slice(methodStart, methodEnd + 6).replace('async addCustomUrlResult', 'async function addCustomUrlResult')})`); @@ -19,6 +19,10 @@ test('image URL adds every linked version and deduplicates repeated additions', getFilenameFromPath: value => value, fetchJson: async () => ({ result: models[0], custom: models }), getSearchResultSignature: result => String(result.version_id), + getCustomUrlResultTableRow: (_missing, result) => ({ + sourceKey: result.source, downloadUrl: `https://civitai.com/api/download/models/${result.version_id}`, + }), + prepareSearchResultRow: (_missing, row) => row, persistSearchStateForWorkflow() {}, refreshSearchUiForMissing() {}, }; @@ -28,6 +32,15 @@ test('image URL adds every linked version and deduplicates repeated additions', input.value = 'https://civitai.com/images/144201545'; await addCustomUrlResult.call(dialog, missing, input, null); assert.deepEqual(state.results.custom.map(result => result.version_id), [3352534, 3356151]); + state.removedSearchResultKeys = [ + 'civitai:https://civitai.com/api/download/models/3352534', + 'civitai:https://civitai.com/api/download/models/3356151', + 'civitai:https://civitai.com/api/download/models/999', + ]; + input.value = 'https://civitai.com/images/144201545'; + await addCustomUrlResult.call(dialog, missing, input, null); + assert.deepEqual(state.results.custom.map(result => result.version_id), [3352534, 3356151]); + assert.deepEqual(state.removedSearchResultKeys, ['civitai:https://civitai.com/api/download/models/999']); assert.equal(input.disabled, false); }); import { Window } from 'happy-dom'; @@ -7940,6 +7953,66 @@ test('search result highlight toggles linked hash result row and badge', () => { assert.deepEqual(rowToggles, [['mr-search-result-hash-highlight', true]]); }); +test('result removal is opt-in, removes only its row, and survives rerendering', () => { + const window = new Window(); + const container = window.document.createElement('div'); + const render = eval(`(${extractMethod(searchPanelMethodsSource, 'renderSearchResultsTable')})`); + const display = eval(`(${extractMethod(resolveDownloadMethodsSource, 'displaySearchResults')})`); + const wire = new Function('bindEventOnce', `return (${extractMethod(resolveDownloadMethodsSource, 'wireSearchDownloadButtons')});`)( + (element, type, handler) => { + if (element.bound) return; + element.bound = true; + element.addEventListener(type, handler); + } + ); + const state = { results: { custom: [ + { sourceKey: 'civitai', model: 'First', downloadUrl: 'first' }, + { sourceKey: 'civitai', model: 'Second', downloadUrl: 'second' }, + ] } }; + let saved; + const dialog = { + getWorkflowScopedQueueKey: () => 'workflow', + getSearchStateForWorkflow: () => state, + persistSearchStateForWorkflow(_key, _missing, value) { saved = structuredClone(value); }, + displaySearchResults: display, + renderSearchResultsTable: render, + wireSearchDownloadButtons: wire, + shouldDisplayKnownDownloadSource: () => false, + renderSearchProgress: () => '', + hasActiveSearchProgress: () => false, + prepareSearchResultRow: (_missing, row) => row, + getCustomUrlResultTableRow: (_missing, result) => result, + patchSearchResultsContainer(element, html) { element.innerHTML = html; }, + renderStatusMessage: message => `

${message}

`, + getSearchResultsTableLayout: () => ({ sourcePx: 100, matchPx: 60, sizePx: 60, actionsPx: 100, tableMinPx: 500 }), + renderSearchSourcePill: () => 'CivitAI', + escapeHtml: value => String(value), + getVersionedModelName: name => name, + renderVersionedModelNameHtml: name => name, + getContextMenuAttrs: () => '', + }; + const missing = {}; + display.call(dialog, missing, state, container); + assert.equal(container.querySelectorAll('.search-remove-result-btn').length, 0); + container.querySelector('.search-toggle-remove-btn').click(); + assert.equal(container.querySelectorAll('.search-remove-result-btn').length, 2); + const actions = container.querySelector('.mr-search-result-actions'); + assert.equal(actions.lastElementChild.classList.contains('search-remove-result-btn'), true); + actions.lastElementChild.click(); + assert.equal(container.querySelectorAll('tbody tr').length, 1); + assert.match(container.textContent, /Second/); + assert.deepEqual(saved.removedSearchResultKeys, ['civitai:first']); + display.call(dialog, missing, structuredClone(saved), container); + assert.equal(container.querySelectorAll('tbody tr').length, 1); + container.querySelector('.search-toggle-remove-btn').click(); + assert.equal(container.querySelectorAll('.search-remove-result-btn').length, 0); + container.querySelector('.search-toggle-remove-btn').click(); + container.querySelector('.search-remove-result-btn').click(); + assert.equal(container.querySelector('table'), null); + assert.match(container.textContent, /No model results remain/); + window.happyDOM.close(); +}); + test('search result table exposes linked local hash targets for exact matches', () => { const renderSearchResultsTable = eval(`(${extractMethod(searchPanelMethodsSource, 'renderSearchResultsTable')})`); const dialog = { diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index ab07bcc..5d2b119 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -3308,6 +3308,33 @@ color: var(--mr-color-error-lighter); } +.mr-search-actions-header { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; +} + +#model-resolver-modal .mr-missing-detail-pane .search-toggle-remove-btn[aria-pressed="true"], +#model-resolver-modal .search-toggle-remove-btn[aria-pressed="true"] { + border-color: #ef4444; + color: #ef4444; +} + +#model-resolver-modal .mr-missing-detail-pane .mr-search-result-action-btn.search-remove-result-btn, +#model-resolver-modal .mr-search-result-action-btn.search-remove-result-btn { + background: #b91c1c; + border-color: #dc2626; + color: #fff; +} + +#model-resolver-modal .mr-missing-detail-pane .mr-search-result-action-btn.search-remove-result-btn:hover:not(:disabled), +#model-resolver-modal .mr-search-result-action-btn.search-remove-result-btn:hover:not(:disabled) { + background: #dc2626; + border-color: #ef4444; + color: #fff; +} + #model-resolver-modal .mr-search-result-action-btn.mr-search-progress-retry, #model-resolver-modal .mr-missing-detail-pane .mr-search-result-action-btn.mr-search-progress-retry { width: 16px; diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index 22699a0..38cf2bb 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -2560,6 +2560,15 @@ export const resolveDownloadMethods = { custom: nextCustom, local_hash_matches: localHashMatches }; + if (state.removedSearchResultKeys?.length) { + const restoredKeys = new Set(customResults.map(item => { + const customRow = this.getCustomUrlResultTableRow(missing, item); + if (!customRow) return ''; + const row = this.prepareSearchResultRow(missing, customRow); + return `${row.sourceKey}:${row.downloadUrl || row.openUrl || `${row.model}:${row.filename}`}`; + })); + state.removedSearchResultKeys = state.removedSearchResultKeys.filter(key => !restoredKeys.has(key)); + } state.lastAttemptSources = Array.from(new Set([ ...(Array.isArray(state.lastAttemptSources) ? state.lastAttemptSources : []), 'custom' @@ -3178,10 +3187,29 @@ export const resolveDownloadMethods = { this.wireSearchHashMatchHighlights?.(container); bindEventOnce(container, 'click', (event) => { const btn = event.target?.closest?.( - '.search-download-btn, .search-open-page-btn, .search-show-details-btn' + '.search-download-btn, .search-open-page-btn, .search-show-details-btn, .search-toggle-remove-btn, .search-remove-result-btn' ); if (!btn || !container.contains(btn)) return; + if (btn.matches('.search-toggle-remove-btn, .search-remove-result-btn')) { + const workflowKey = this.getWorkflowScopedQueueKey(); + const state = this.getSearchStateForWorkflow(workflowKey, missing); + if (btn.classList.contains('search-toggle-remove-btn')) { + state.showSearchResultRemoval = !state.showSearchResultRemoval; + } else { + if (!state.showSearchResultRemoval) return; + const key = btn.closest('tr')?.dataset.searchResultKey; + if (!key) return; + state.removedSearchResultKeys = Array.from(new Set([ + ...(state.removedSearchResultKeys || []), decodeURIComponent(key) + ])); + } + this.persistSearchStateForWorkflow(workflowKey, missing, state); + const resultsContainer = btn.closest('.mr-search-results-table-wrap')?.parentElement; + this.displaySearchResults(missing, state, resultsContainer); + return; + } + if (btn.classList.contains('search-download-btn')) { const url = btn.dataset.url; const filename = btn.dataset.filename; @@ -3587,6 +3615,11 @@ export const resolveDownloadMethods = { this.syncSearchElementAttributes(current, next); this.syncSearchElementAttributes(currentTable, nextTable); + const currentHead = currentTable.querySelector('thead'); + const nextHead = nextTable.querySelector('thead'); + if (currentHead && nextHead && currentHead.innerHTML !== nextHead.innerHTML) { + currentHead.innerHTML = nextHead.innerHTML; + } for (const nextRow of nextRows) { const key = nextRow.dataset.searchResultKey; let currentRow = currentByKey.get(key); @@ -3742,10 +3775,12 @@ export const resolveDownloadMethods = { const rows = []; const rowKeys = new Set(); + const removedRowKeys = new Set(state?.removedSearchResultKeys || []); const addRow = (row) => { if (!row) return; row = this.prepareSearchResultRow(missing, row); const rowKey = `${row.sourceKey}:${row.downloadUrl || row.openUrl || `${row.model}:${row.filename}`}`; + if (removedRowKeys.has(rowKey)) return; if (rowKeys.has(rowKey)) return; rowKeys.add(rowKey); rows.push({ ...row, __searchResultKey: rowKey }); @@ -3974,7 +4009,10 @@ export const resolveDownloadMethods = { )); }); - const html = `${progressHtml}${statusHtml}${this.renderSearchResultsTable(rows)}`; + const tableHtml = rows.length + ? this.renderSearchResultsTable(rows, { removalEnabled: true, showRemoval: Boolean(state?.showSearchResultRemoval) }) + : this.renderStatusMessage('No model results remain in this table.', 'info'); + const html = `${progressHtml}${statusHtml}${tableHtml}`; this.patchSearchResultsContainer(container, html); this.wireSearchProgressCancelButtons?.(container, missing, state); diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js index f07dea7..b2895a6 100644 --- a/web/resolver/search/search_panel.js +++ b/web/resolver/search/search_panel.js @@ -1965,15 +1965,26 @@ export const searchPanelMethods = { html += ``; const downloadSourceRow = this.getDownloadSourceTableRow(missing, downloadSource); + const searchState = this.getSearchState(missing); + const resultKey = downloadSourceRow + ? `${downloadSourceRow.sourceKey}:${downloadSourceRow.downloadUrl || downloadSourceRow.openUrl || `${downloadSourceRow.model}:${downloadSourceRow.filename}`}` + : ''; + const visibleRow = downloadSourceRow && !(searchState.removedSearchResultKeys || []).includes(resultKey); html += `
`; - html += this.renderSearchResultsTable(downloadSourceRow ? [downloadSourceRow] : []); + html += this.renderSearchResultsTable(visibleRow ? [downloadSourceRow] : [], { + removalEnabled: true, showRemoval: Boolean(searchState.showSearchResultRemoval) + }); html += `
`; return html; }, - renderSearchResultsTable(rows = []) { + renderSearchResultsTable(rows = [], { removalEnabled = false, showRemoval = false } = {}) { if (!rows.length) return ''; const layout = this.getSearchResultsTableLayout(rows); + if (removalEnabled && showRemoval) { + layout.actionsPx += 36; + layout.tableMinPx += 36; + } const tableStyle = [ `--mr-source-col:${layout.sourcePx}px`, `--mr-match-col:${layout.matchPx}px`, @@ -1998,7 +2009,11 @@ export const searchPanelMethods = { Model Match Size - Actions +
Actions${removalEnabled ? ` + + ` : ''}
@@ -2087,6 +2102,10 @@ export const searchPanelMethods = { if (!actions) { actions = '-'; } + if (removalEnabled && showRemoval) { + actions += ``; + } const resultKey = row.__searchResultKey || `${row.sourceKey}:${row.downloadUrl || row.openUrl || `${row.model}:${row.filename}`}`; html += ` diff --git a/web/resolver/shell/workflow_state_methods.js b/web/resolver/shell/workflow_state_methods.js index a6e0670..2994e3c 100644 --- a/web/resolver/shell/workflow_state_methods.js +++ b/web/resolver/shell/workflow_state_methods.js @@ -632,6 +632,7 @@ export const workflowStateMethods = { }, isPersistableSearchState(state = {}) { + if (state?.removedSearchResultKeys?.length || state?.showSearchResultRemoval) return true; if ((state?.selectedSource || 'all') !== 'all') return true; const defaultBaseModel = this.getDefaultSearchBaseModel?.() || 'auto'; if ((state?.selectedBaseModel || defaultBaseModel) !== defaultBaseModel) return true; diff --git a/web/utils/icon_utils.js b/web/utils/icon_utils.js index 5a4bc23..e89cee4 100644 --- a/web/utils/icon_utils.js +++ b/web/utils/icon_utils.js @@ -2,6 +2,7 @@ import { app } from "../../../scripts/app.js"; const SVG_ICONS = { + trash2: ``, chevronDown: ``, circleCheckBig: ``, circlePlus: ``,