Add removable model search results and restore re-added links

This commit is contained in:
Dariusz L
2026-10-05 17:57:22 +02:00
parent a1935b89e4
commit b0b466cfc0
6 changed files with 165 additions and 6 deletions
+74 -1
View File
@@ -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 => `<p>${message}</p>`,
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 = {
+27
View File
@@ -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;
@@ -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);
+22 -3
View File
@@ -1965,15 +1965,26 @@ export const searchPanelMethods = {
html += `</div>`;
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 += `<div id="search-results-${this.getMissingModelDomKey(missing)}" class="mr-search-results mr-is-visible">`;
html += this.renderSearchResultsTable(downloadSourceRow ? [downloadSourceRow] : []);
html += this.renderSearchResultsTable(visibleRow ? [downloadSourceRow] : [], {
removalEnabled: true, showRemoval: Boolean(searchState.showSearchResultRemoval)
});
html += `</div>`;
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 = {
<th>Model</th>
<th>Match</th>
<th>Size</th>
<th>Actions</th>
<th><div class="mr-search-actions-header">Actions${removalEnabled ? `
<button type="button" class="search-toggle-remove-btn mr-search-result-action-btn"
aria-label="Toggle result removal" aria-pressed="${showRemoval}"
data-tooltip="${showRemoval ? 'Hide remove buttons' : 'Show remove buttons'}">${getSvgIcon('pencil')}</button>
` : ''}</div></th>
</tr>
</thead>
<tbody>
@@ -2087,6 +2102,10 @@ export const searchPanelMethods = {
if (!actions) {
actions = '<span class="mr-search-result-empty">-</span>';
}
if (removalEnabled && showRemoval) {
actions += `<button type="button" class="search-remove-result-btn mr-search-result-action-btn"
data-tooltip="Remove from results" aria-label="Remove ${modelTitle} from results">${getSvgIcon('trash2')}</button>`;
}
const resultKey = row.__searchResultKey || `${row.sourceKey}:${row.downloadUrl || row.openUrl || `${row.model}:${row.filename}`}`;
html += `
@@ -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;
+1
View File
@@ -2,6 +2,7 @@
import { app } from "../../../scripts/app.js";
const SVG_ICONS = {
trash2: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M5 6l1 14a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1l1-14M10 10v7M14 10v7"></path></svg>`,
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg>`,
circleCheckBig: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.801 10A10 10 0 1 1 17 3.335"></path><path d="m9 11 3 3L22 4"></path></svg>`,
circlePlus: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M8 12h8"></path><path d="M12 8v8"></path></svg>`,