From 57656fc29832baebce755edced4b398573f41ab6 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Thu, 22 Oct 2026 16:41:04 -0700 Subject: [PATCH] feat(inspect): copy settings summary in the full inspector The legacy metadata page's COPY ALL (LI-4) becomes "Copy settings summary" after the six facts of the shared full inspector, so the Images viewer, the recorded history's current file and Inspect all offer it; the Images side panel keeps the concept's footer. The text is the legacy copyAllData template with its placeholders, filled from what the inspector shows. A unit test runs the template read from web/metadata.html on the legacy parse of every parity PNG: 22 of 25 are byte-equal, and the other three differ only by the ZeroOut guard (#249) or facts the legacy parse missed. Copying follows the concept's prompt copy: the toast "Settings summary copied.", or the select-to-copy dialog (now titled per copy) without a clipboard. The concept comparisons map the button as D-NEW-1. --- TESTING.md | 28 ++++- tests/browser/full-inspector.spec.mjs | 6 +- tests/browser/inspect-concept.spec.mjs | 15 ++- tests/browser/inspector-concept.spec.mjs | 26 +++-- tests/browser/workspace-history.spec.mjs | 17 ++++ tests/browser/workspace-inspect.spec.mjs | 77 ++++++++++++++ tests/js/workspace-file-inspector.test.js | 118 ++++++++++++++++++++++ tests/js/workspace-toolbar.test.js | 4 + web/js/workspace/file-inspector.mjs | 42 +++++++- web/js/workspace/workspace-dialogs.mjs | 9 +- web/js/workspace/workspace.css | 2 + 11 files changed, 322 insertions(+), 22 deletions(-) diff --git a/TESTING.md b/TESTING.md index c2a82cad..87cdc9a3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1527,6 +1527,29 @@ mapped differences"): Modified with no Path row and no Show in folder, and ends with "Local file; no saved library association was checked." and Download original. There is no Prompt history action and no category or tag chips. +- **D-NEW-1** (Copy settings summary, LI-4; number to be assigned): the legacy metadata + page's COPY ALL, consolidated into the shared full inspector (the viewer's + `#viewer-details`, so Images, the recorded history's current file and Inspect all have + it). The concept has no such action. One plain button "Copy settings summary" follows + the six facts (before "File & provenance"); the Images side panel keeps the concept's + footer and has none. Its text is the legacy `copyAllData` template, line for line: + `Checkpoint`, `Positive Prompt`, `Negative Prompt`, `Steps`, `CFG Scale`, `Sampler`, + `Seed`, with the legacy placeholders ("Unknown", "No prompt found", "No negative prompt + found"). The values are the ones the inspector shows: a confirmed prompt and a known + fact verbatim, anything else (an explicitly empty prompt included, as the legacy parse + treats '') the placeholder. Against the legacy page, for the same file, only these + differ: numbers keep the server's spelling ("8.0" where the legacy page wrote 8); a fact + the legacy parse could not read can be known; and a negative the #249 ZeroOut guard + zeroed reads "No negative prompt found" where the legacy parse read the positive's text + through the zero-out. `workspace-file-inspector.test.js` runs the template taken from + `web/metadata.html` itself on the legacy parse of every parity PNG: 22 of 25 are + byte-equal, f01b and f23 differ by the ZeroOut guard and f17 by facts the legacy parse + missed. Copying works as the concept's prompt copy: the toast "Settings summary + copied.", or, without a clipboard or when it is refused, the select-to-copy dialog + titled "Copy settings summary". `.ca-tools #viewer-details > div > button` wraps + (`white-space: normal`) in the narrow rail at 200% zoom, as D20's buttons do. The concept + comparisons add the button to the concept's viewer at runtime (a copy of its own plain + button) and to the Inspect rail's section order. The chooser keeps naming the chosen file, as the concept's does: it is cleared as the picker opens (so choosing the same file again is a new selection), not after `change`, @@ -1540,9 +1563,8 @@ Legacy floor (`web/metadata.html`), dispositions: - Full text (the old "Show more"): the prompt blocks show the whole text. - Raw workflow view and Download workflow JSON: kept, in "Workflow & raw metadata". - Copy all summary and filename copy (LI-4): the filename is selectable text in the rail - and Download original saves the file under its name. The ruled "Copy settings summary" - button after the six facts of the shared full inspector is not part of PR L2; until it - lands, the legacy page keeps COPY ALL and stays reachable from Overview. + and Download original saves the file under its name. COPY ALL is the shared full + inspector's "Copy settings summary" (D-NEW-1), with the legacy text. - Fully browser-local reading: kept and asserted (privacy above). - Case-variant keys `Workflow|comfy|ComfyUI|Prompt|Parameters` (LI-5): a separate backend issue; Inspect follows the server's readers, so the two never disagree. diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index f1268b11..446a800f 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -237,9 +237,11 @@ const viewerStructure=details=>details.evaluate(node=>{ test('viewer details hold provenance, raw records and downloads',async({page,request})=>{ await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page); await fullFixture(page,request);let details=await viewerDetails(page,'complete.png'); - // The panel content without the cover, then the two disclosures, then the pair send and history buttons (D20). + // The panel content without the cover, D-NEW-1's Copy settings summary after the facts, then the two disclosures, + // then the pair send and history buttons (D20). expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'], - inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','details','details']}); + inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button','details','details']}); + await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary'); await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0); await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG'); await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png'); diff --git a/tests/browser/inspect-concept.spec.mjs b/tests/browser/inspect-concept.spec.mjs index 509cc442..62378356 100644 --- a/tests/browser/inspect-concept.spec.mjs +++ b/tests/browser/inspect-concept.spec.mjs @@ -117,7 +117,7 @@ function measureViewer() { ['dl.meta-grid', node => node.closest('details') ? null : 'facts'], ['details > summary', node => text(node)], ['button.send', node => node.closest('.prompt-section') ? `send ${node.closest('.prompt-section').matches('.positive') ? 'positive' : 'negative'}` : 'send pair'], - ['button', node => /Prompt history/.test(node.textContent) ? 'Prompt history' : null], + ['button', node => /Prompt history/.test(node.textContent) ? 'Prompt history' : text(node) === 'Copy settings summary' ? 'settings summary' : null], ]; const sections = [...rail.querySelectorAll('*')].map(node => { for (const [selector, label] of SECTIONS) if (node.matches(selector)) return label(node); @@ -158,13 +158,18 @@ async function withProductionCopy(page) { }, { COPY, ACCEPT }); } -/** D66: the viewer of a local file is titled by its filename and has no tag chips and no Prompt history. */ +/** + * D66: the viewer of a local file is titled by its filename and has no tag chips and no Prompt history. D-NEW-1 (LI-4): + * "Copy settings summary" follows the six facts. + */ function mapViewerConcept(reference) { + const sections = reference.sections.filter(section => section !== 'tag chips' && section !== 'Prompt history'); + const facts = sections.indexOf('facts'); return { ...reference, title: FILE, railTitle: FILE, - sections: reference.sections.filter(section => section !== 'tag chips' && section !== 'Prompt history'), + sections: [...sections.slice(0, facts + 1), 'settings summary', ...sections.slice(facts + 1)], }; } @@ -269,9 +274,9 @@ for (const [width, height] of SIZES) for (const theme of THEMES) { // The chooser names the chosen file after a real setInputFiles, as the concept's does. expect(production.initial.chosen).toEqual({ name: '', files: [] }); expect(production.selected.chosen).toEqual({ name: FILE, files: [FILE] }); - // D66 in the rail: no tag chips and no Prompt history; the title is the filename. + // D66 in the rail: no tag chips and no Prompt history; the title is the filename. D-NEW-1 follows the facts. expect(production.viewer.sections).toEqual(['heading', 'filename', 'badges', 'positive', 'send positive', 'negative', - 'send negative', 'facts', 'File & provenance', 'Workflow & raw metadata', 'send pair']); + 'send negative', 'facts', 'settings summary', 'File & provenance', 'Workflow & raw metadata', 'send pair']); }); } diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs index 1c8f77ea..643d4b75 100644 --- a/tests/browser/inspector-concept.spec.mjs +++ b/tests/browser/inspector-concept.spec.mjs @@ -45,10 +45,10 @@ function withCategoryScope(inventory) { } /** - * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34 and D38); everything else must equal - * production. `reference.viewerPlain` is the concept's own full details - * with its tag chips made plain (D20) and, under a coarse pointer, D34's targets, both at runtime: the browser lays out - * what follows them, as the Images spec's removed toolbar items do. + * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38 and D-NEW-1); everything else must + * equal production. `reference.viewerPlain` is the concept's own full details + * with its tag chips made plain (D20), D-NEW-1's summary button added and, under a coarse pointer, D34's targets, all at + * runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. */ export function mapInspectorConcept(reference,{viewport}) { const {panel,viewerPlain,fileControls}=reference; @@ -105,7 +105,11 @@ async function openProductionPanel(page,theme) { await settle(page); } -/** The concept's tag chips in the open viewer as plain `span.chip`s (D20) and D34's coarse targets; returns an undo. */ +/** + * The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D-NEW-1's "Copy + * settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON", + * without its action); returns an undo. + */ async function plainViewerChips(page) { const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS}); await page.evaluate(()=>{ @@ -113,10 +117,15 @@ async function plainViewerChips(page) { const span=document.createElement('span');span.className='chip';span.textContent=button.textContent; button.replaceWith(span);return [span,button]; }); + const summary=document.querySelector('#viewer-details [data-action="download-workflow"]').cloneNode(false); + for(const key of Object.keys(summary.dataset))delete summary.dataset[key]; + summary.textContent='Copy settings summary'; + document.querySelector('#viewer-details > div > dl.meta-grid').after(summary);window.__summaryButton=summary; }); await settle(page); return async()=>{ - await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;}); + await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips; + window.__summaryButton.remove();delete window.__summaryButton;}); await restoreTargets(); }; } @@ -342,7 +351,7 @@ for(const [width,height] of SIZES)test(`image tags saved on the concept image ke // The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS // mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5) -// its pair actions removed and D38's Show in folder removed; each applied after an exact match. +// its pair actions removed, D38's Show in folder removed and D-NEW-1's summary removed; each applied after an exact match. test('inspector comparison detects mutations',async({page})=>{ test.setTimeout(120_000); const viewport={width:1600,height:1100}; @@ -366,6 +375,9 @@ test('inspector comparison detects mutations',async({page})=>{ expect(()=>assertInspectorMatch(reference,withoutActions,viewport)).toThrow(/viewerDetails\.actions/); const withoutReveal=await viewerMutated({remove:['#viewer-details button[aria-label$=" in folder"]']}); expect(()=>assertInspectorMatch(reference,withoutReveal,viewport)).toThrow(/fileControls/); + // D-NEW-1: the summary action after the facts is part of the mapped concept, so losing it is a difference. + const withoutSummary=await viewerMutated({remove:['#viewer-details > div > button']}); + expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/); assertInspectorMatch(reference,await viewerMutated(),viewport); await mutate(page,{remove:['#image-inspector > .detail-footer']}); diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index e08a1a9f..cc87cc25 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -494,6 +494,23 @@ test('the inline current file names its prompts apart from the frozen run fields expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before); }); +// LI-4 (D-NEW-1): the recorded history's current-file viewer is the shared full inspector, so it copies the legacy summary. +test('the current file viewer copies its settings summary',async({page})=>{ + await page.addInitScript(()=>{window.__copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.__copied.push(text);}}});}); + await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json(); + body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}}; + await route.fulfill({response,json:body});}); + await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5); + await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click(); + const details=page.locator('dialog#viewer-dialog #viewer-details');await expect(details.locator('.panel-heading')).toBeVisible(); + const facts=await details.locator(':scope > div > dl.meta-grid dd').allTextContents(); + await details.getByRole('button',{name:'Copy settings summary',exact:true}).click(); + await expect(page.locator('#workspace-toast')).toHaveText('Settings summary copied.'); + const [seed,steps,cfg,sampler,,model]=facts; + expect(await page.evaluate(()=>window.__copied)).toEqual([[`Checkpoint: ${model}`,'Positive Prompt: current file positive','Negative Prompt: current file negative', + `Steps: ${steps}`,`CFG Scale: ${cfg}`,`Sampler: ${sampler}`,`Seed: ${seed}`].join('\n').replace(/: (Not recorded|Unresolved|Ambiguous|Unavailable within inspection limits)(?=\n|$)/g,': Unknown')]); +}); + test('the recorded run dialog with a resolved current file has no duplicate button or region names',async({page})=>{ await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json(); body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}}; diff --git a/tests/browser/workspace-inspect.spec.mjs b/tests/browser/workspace-inspect.spec.mjs index 5923259c..50ebeb51 100644 --- a/tests/browser/workspace-inspect.spec.mjs +++ b/tests/browser/workspace-inspect.spec.mjs @@ -685,3 +685,80 @@ test('touch layout at 390 has no horizontal overflow at 200% zoom', async ({ pag expect(area).toBe(true); } }); + +// LI-4 (D-NEW-1): "Copy settings summary" after the six facts is the legacy metadata page's COPY ALL, in the shared full +// inspector, so a file served in Images and the same bytes chosen here copy the same summary of what the rail shows. +/** Records clipboard writes in the page, or (`clipboard: false`) leaves the page without a clipboard. */ +async function stubClipboard(page, { clipboard = true } = {}) { + await page.addInitScript(available => { + window.__copied = []; + Object.defineProperty(navigator, 'clipboard', { configurable: true, + value: available ? { writeText: async text => { window.__copied.push(text); } } : undefined }); + }, clipboard); +} +/** The summary the rail's own prompts and facts make, in the legacy COPY ALL lines and placeholders. */ +const expectedSummary = locator => locator.evaluate(node => { + const facts = Object.fromEntries([...node.querySelectorAll(':scope .meta-grid:not(details .meta-grid) > div')] + .map(item => [item.querySelector('dt').textContent, item.querySelector('dd').textContent])); + // A confirmed prompt has its Copy action; an explicitly empty one takes the legacy placeholder too. + const prompt = role => { + const text = node.querySelector(`.prompt-section.${role} [data-copy-role]`) ? node.querySelector(`.prompt-section.${role} p.prompt-text`).textContent : null; + return text === `No ${role} prompt (explicitly empty)` ? null : text; + }; + return [`Checkpoint: ${facts.Model}`, `Positive Prompt: ${prompt('positive') ?? 'No prompt found'}`, + `Negative Prompt: ${prompt('negative') ?? 'No negative prompt found'}`, `Steps: ${facts.Steps}`, `CFG Scale: ${facts.CFG}`, + `Sampler: ${facts.Sampler}`, `Seed: ${facts.Seed}`].join('\n'); +}); +async function copySummary(page) { + const button = rail(page).getByRole('button', { name: 'Copy settings summary', exact: true }); + // It follows the six facts, before "File & provenance". + expect(await button.evaluate(node => [node.previousElementSibling?.localName, node.nextElementSibling?.localName])).toEqual(['dl', 'details']); + await button.click(); +} + +test('Copy settings summary copies the same legacy summary in Images and Inspect', async ({ page, request }) => { + await stubClipboard(page); + await expect(await request.post('/__test__/add-local-inspect-file', { data: { name: 'f02.png' } })).toBeOK(); + await page.goto(`${workspace}#/images`); + await page.locator('article[data-image-key="0:local-inspect/f02.png"] .image-open').click(); + await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toBeVisible(); + const served = await expectedSummary(rail(page)); + expect(served).toMatch(/^Checkpoint: .+\nPositive Prompt: [\s\S]+\nNegative Prompt: [\s\S]+\nSteps: .+\nCFG Scale: .+\nSampler: .+\nSeed: .+$/); + expect(served).not.toContain('Unknown'); + await copySummary(page); + await expect(page.locator('#workspace-toast')).toHaveText('Settings summary copied.'); + expect(await page.evaluate(() => window.__copied)).toEqual([served]); + await page.keyboard.press('Escape'); + await expect(viewer(page)).toHaveCount(0); + + await openInspect(page); + await choose(page, parityPng('f02.png')); + await openViewer(page); + expect(await expectedSummary(rail(page))).toBe(served); + await copySummary(page); + await expect.poll(() => page.evaluate(() => window.__copied)).toEqual([served, served]); + // The panel variant (the Images side panel) keeps the concept's footer: no summary action there. + await page.keyboard.press('Escape'); + await page.goto(`${workspace}#/images`); + await expect(page.locator('#image-inspector .panel-heading')).toBeVisible(); + await expect(page.locator('#image-inspector').getByRole('button', { name: 'Copy settings summary', exact: true })).toHaveCount(0); +}); + +test('Copy settings summary without a clipboard opens the select-to-copy dialog', async ({ page }) => { + await stubClipboard(page, { clipboard: false }); + await openInspect(page); + await choose(page, sendablePng()); + await openViewer(page); + const summary = await expectedSummary(rail(page)); + expect(summary).toContain(`Positive Prompt: ${POSITIVE_LINES}`); + await copySummary(page); + const fallback = page.getByRole('dialog', { name: 'Copy settings summary', exact: true }); + await expect(fallback).toBeVisible(); + await expect(fallback.getByRole('textbox', { name: 'Select and copy this text', exact: true })).toHaveValue(summary); + await expect(fallback.getByRole('textbox', { name: 'Select and copy this text', exact: true })).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(fallback).toHaveCount(0); + // Escape closes only the copy dialog; focus returns to the action in the still-open viewer. + await expect(viewer(page)).toBeVisible(); + await expect(rail(page).getByRole('button', { name: 'Copy settings summary', exact: true })).toBeFocused(); +}); diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index cb722ff8..c0f87249 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -215,3 +215,121 @@ test('none and unavailable render no image chips', async () => { assert.deepEqual(inspectorTagChips(tagged({state, tags:[]}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), []); } }); + +// LI-4 (D-NEW-1): "Copy settings summary" after the six facts of the full inspector consolidates the legacy metadata +// page's COPY ALL (web/metadata.html copyAllData). The legacy template is read from that page itself and run on the +// legacy parse, so the summary's text is the legacy page's, line for line, wherever the two agree on the values. +const METADATA_PAGE = fs.readFileSync(new URL('../../web/metadata.html', import.meta.url), 'utf8'); +function legacyCopyAll(params) { + const template = METADATA_PAGE.match(/const allData = `([\s\S]*?)`;/)[1]; + return new Function('currentWorkflowData', `return \`${template}\`;`)(params); +} +const legacyParams = record => ComfyMetadata.extractGenerationParams({prompt:record.metadata.prompt_graph.status === 'parsed' ? record.metadata.prompt_graph.value : null, + workflow:record.metadata.workflow_graph.status === 'parsed' ? record.metadata.workflow_graph.value : null}); + +/** + * Each summary line against the legacy page's, allowing only the recorded differences (D-NEW-1): a number keeps the + * inspector's (the server's) spelling ("8.0" where the legacy page wrote 8); a fact the legacy parse left "Unknown" may + * be known; and a negative the #249 ZeroOut guard zeroed reads "No negative prompt found" where the legacy parse read + * the positive's text through the zero-out. + */ +function assertLegacySummary(summary, legacy, record) { + const lines = summary.split('\n'), expected = legacy.split('\n'); + assert.deepEqual(lines.map(line => line.split(':')[0]), expected.map(line => line.split(':')[0]), 'the legacy labels, in order'); + lines.forEach((line, index) => { + if (line === expected[index]) return; + const [label, value] = [line.slice(0, line.indexOf(': ')), line.slice(line.indexOf(': ') + 2)]; + const old = expected[index].slice(label.length + 2); + if (label === 'Negative Prompt' && record.prompts.negative?.source === 'zeroed-conditioning' && value === 'No negative prompt found') return; + if (old === 'Unknown' && !/Prompt$/.test(label)) return; + assert.ok(value.trim() !== '' && Number(value) === Number(old), `${label}: "${value}" differs from the legacy "${old}"`); + }); +} + +test('settings summary is the legacy COPY ALL text for a local file', async () => { + const {settingsSummary} = await load(); + assert.equal(typeof settingsSummary, 'function', 'settingsSummary is exported'); + const record = await localRecord(localPng('fox.png')); + assertLegacySummary(settingsSummary(record), legacyCopyAll(legacyParams(record)), record); + assert.equal(settingsSummary(record), ['Checkpoint: synthetic-v1.safetensors', 'Positive Prompt: a red fox in snow, detailed fur', + 'Negative Prompt: blurry, lowres', 'Steps: 20', 'CFG Scale: 8.0', 'Sampler: euler', 'Seed: 7'].join('\n')); + // Every parity PNG read in this browser: the legacy page's text but for the recorded differences, and exactly the + // legacy text for most of them (f09 among them: the server is ambiguous, so the record shows the legacy fill). + const exact = []; + for (const name of fs.readdirSync(PARITY).filter(item => item.endsWith('.png')).sort()) { + const parity = await localRecord(new File([fs.readFileSync(new URL(name, PARITY))], name)); + const summary = settingsSummary(parity), legacy = legacyCopyAll(legacyParams(parity)); + assertLegacySummary(summary, legacy, parity); + if (summary === legacy) exact.push(name); + } + assert.ok(exact.includes('f09.png')); + assert.deepEqual(fs.readdirSync(PARITY).filter(item => item.endsWith('.png') && !exact.includes(item)).sort(), ['f01b.png', 'f17.png', 'f23.png']); +}); + +test('settings summary uses the legacy placeholders for what the record does not know', async () => { + const {settingsSummary} = await load(); + const unknown = {...record(), prompts:{positive:{status:'unresolved'}, negative:{status:'ambiguous'}}, generation:{}}; + assert.equal(settingsSummary(unknown), legacyCopyAll({}).replace('Positive Prompt: undefined', 'Positive Prompt: No prompt found') + .replace('Negative Prompt: undefined', 'Negative Prompt: No negative prompt found')); + assert.equal(settingsSummary(unknown), ['Checkpoint: Unknown', 'Positive Prompt: No prompt found', 'Negative Prompt: No negative prompt found', + 'Steps: Unknown', 'CFG Scale: Unknown', 'Sampler: Unknown', 'Seed: Unknown'].join('\n')); + // A server answer is shown as the inspector shows it: known facts verbatim, every other state the legacy "Unknown". + const server = {...record(), prompts:{positive:{status:'resolved', text:'a\n猫'}, negative:{status:'resolved', text:''}}, generation:{ + model:{status:'known', value:'m.safetensors'}, steps:{status:'not-recorded', value:null}, cfg:{status:'known', value:'6.50'}, + sampler:{status:'ambiguous', value:null}, seed:{status:'known', value:'9007199254740993'}, scheduler:{status:'known', value:'karras'}}}; + assert.equal(settingsSummary(server), ['Checkpoint: m.safetensors', 'Positive Prompt: a\n猫', 'Negative Prompt: No negative prompt found', + 'Steps: Unknown', 'CFG Scale: 6.50', 'Sampler: Unknown', 'Seed: 9007199254740993'].join('\n')); +}); + +/** The full inspector with a toast node and `clipboard` as `navigator.clipboard`; returns the summary button. */ +function renderSummary(record, {variant = 'full', clipboard} = {}) { + const f = mountedDom(), scope = createScope(), fallbacks = [], toast = new f.Node('div'); + f.doc.getElementById = id => id === 'workspace-toast' ? toast : null; + const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + Object.defineProperty(globalThis, 'navigator', {configurable:true, value:clipboard === undefined ? {} : {clipboard}}); + const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, copyFallback:(text, options) => fallbacks.push({text, ...options})}); + view.setState({loading:false, error:false, record}); + const button = f.walk(f.container, n => n.tagName === 'button' && n.textContent === 'Copy settings summary')[0] ?? null; + return {f, button, toast, fallbacks, scope, done() { + view.dispose(); scope.dispose(); f.restore(); + if (previous) Object.defineProperty(globalThis, 'navigator', previous); else delete globalThis.navigator; + }}; +} + +test('Copy settings summary follows the facts in the full inspector only', async () => { + const full = renderSummary(record()); + try { + assert.ok(full.button, 'the full inspector offers Copy settings summary'); + const inner = full.button.parent, index = inner.children.indexOf(full.button); + assert.equal(inner.children[index - 1].tagName, 'dl', 'it follows the six facts'); + assert.equal(inner.children[index + 1].tagName, 'details', 'File & provenance follows it'); + } finally { full.done(); } + const panel = renderSummary(record(), {variant:'panel'}); + try { assert.equal(panel.button, null, 'the Images side panel keeps the concept footer'); } finally { panel.done(); } +}); + +test('Copy settings summary copies the summary and shows the shared toast', async () => { + const {settingsSummary} = await load(), copied = []; + const view = renderSummary(record(), {clipboard:{writeText:async text => {copied.push(text);}}}); + try { + await view.button.listeners.get('click')[0].callback({target:view.button}); + assert.deepEqual(copied, [settingsSummary(record())]); + assert.equal(view.toast.textContent, 'Settings summary copied.'); + assert.equal(view.fallbacks.length, 0); + } finally { view.done(); } +}); + +test('Copy settings summary without a clipboard opens the select-to-copy fallback', async () => { + const {settingsSummary} = await load(); + for (const clipboard of [undefined, {writeText:async () => {throw new Error('Denied');}}]) { + const view = renderSummary(record(), {clipboard}); + try { + await view.button.listeners.get('click')[0].callback({target:view.button}); + assert.equal(view.fallbacks.length, 1); + assert.equal(view.fallbacks[0].text, settingsSummary(record())); + assert.equal(view.fallbacks[0].title, 'Copy settings summary'); + assert.equal(view.fallbacks[0].trigger, view.button); + assert.notEqual(view.toast.textContent, 'Settings summary copied.'); + } finally { view.done(); } + } +}); diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js index d825d6a2..1a0ebf93 100644 --- a/tests/js/workspace-toolbar.test.js +++ b/tests/js/workspace-toolbar.test.js @@ -521,6 +521,10 @@ test('copy fallback selects the text', async () => { assert.equal(note.className, 'small'); assert.equal(note.textContent, 'Automatic clipboard access requires a secure browser context.'); assert.throws(() => openCopyFallback(42, { scope }), TypeError); + // LI-4 (D-NEW-1): the settings summary names its own dialog; the select-to-copy body is the same. + openCopyFallback('Seed: 7', { scope, title: 'Copy settings summary' }); + assert.equal(byId(dom, 'modal-title').textContent, 'Copy settings summary'); + assert.equal(byId(dom, 'copy-text').value, 'Seed: 7'); } finally { closeWorkspaceDialog(); scope.dispose(); diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 11309cbc..fcb96378 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -5,6 +5,7 @@ import { createSendButton } from './prompt-sender.mjs'; import { createWorkspaceIcon } from './icons.mjs'; import { navigateTo } from './state.mjs'; import { showWorkspaceToast } from './toast.mjs'; +import { openCopyFallback } from './workspace-dialogs.mjs'; const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs']; const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.'; @@ -26,6 +27,26 @@ const REVEAL_BUSY = 'The folder is already opening.'; const REVEAL_FAILED = 'The folder could not be opened. Try again.'; const FACTS = [['seed', 'Seed'], ['steps', 'Steps'], ['cfg', 'CFG'], ['sampler', 'Sampler'], ['scheduler', 'Scheduler'], ['model', 'Model']]; +// D-NEW-1 (LI-4): the legacy metadata page's COPY ALL (web/metadata.html copyAllData), its lines and placeholders. +const SUMMARY_LINES = [['Checkpoint', 'model'], ['Positive Prompt', 'positive'], ['Negative Prompt', 'negative'], ['Steps', 'steps'], + ['CFG Scale', 'cfg'], ['Sampler', 'sampler'], ['Seed', 'seed']]; +const SUMMARY_MISSING = {positive:'No prompt found', negative:'No negative prompt found'}; + +/** + * The one-click settings summary (D-NEW-1): the legacy COPY ALL text for what this inspector shows. A confirmed prompt + * and a known fact are copied verbatim; anything else, an explicitly empty prompt included, takes the legacy page's + * placeholder, as its parse does (`hasValue` treats '' as absent). + */ +export function settingsSummary(record) { + const roles = promptRoles(record?.prompts); + const value = key => { + if (Object.hasOwn(SUMMARY_MISSING, key)) return roles[key] ? roles[key] : SUMMARY_MISSING[key]; + const fact = record?.generation?.[key]; + return fact?.status === 'known' && typeof fact.value === 'string' && fact.value !== '' ? fact.value : 'Unknown'; + }; + return SUMMARY_LINES.map(([label, key]) => `${label}: ${value(key)}`).join('\n'); +} + function safeFilename(value) { return String(value || 'file').replace(/[\\/\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^\.+|[. ]+$/g, '') || 'file'; } @@ -175,7 +196,7 @@ function prefixedChip(prefix, text) { * `variant: 'full'` (the viewer's `#viewer-details`, imageDetails(i, true)) leaves out the cover and adds "File & * provenance" and "Workflow & raw metadata" with the downloads. `inline: true` means `panel`; neither means `full`. */ -export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onInteraction = () => {}}) { +export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onInteraction = () => {}, copyFallback = openCopyFallback}) { if (parentScope.signal.aborted) return {setState() {}, dispose() {}}; const scope = childScope(parentScope); let contentScope; @@ -244,7 +265,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant inner.append(tags); } inner.append(...generationFacts(record, 'meta-grid', 'small')); - if (full) inner.append(provenance(record, badge), rawMetadata(record)); + if (full) inner.append(summaryButton(record), provenance(record, badge), rawMetadata(record)); const footer = full ? container : el('div', undefined, 'detail-footer'), roles = promptRoles(record.prompts); if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) { const pair = createButton('', 'send'); @@ -264,6 +285,23 @@ export function mountFileInspector(container, {api, sender, parentScope, variant } if (!full && footer.childNodes.length) container.append(footer); } + /** + * D-NEW-1 (LI-4): "Copy settings summary" after the six facts, the legacy COPY ALL. It copies as the concept's prompt + * copy does: the shared toast, or the select-to-copy dialog where the clipboard is unavailable or refused. + */ + function summaryButton(record) { + const button = createButton('Copy settings summary'), text = settingsSummary(record); + contentScope.listen(button, 'click', async () => { + try { + if (!navigator.clipboard) throw new Error('Clipboard requires a secure context'); + await navigator.clipboard.writeText(text); + if (!contentScope.signal.aborted) showWorkspaceToast('Settings summary copied.'); + } catch { + if (!contentScope.signal.aborted) copyFallback(text, {scope:contentScope, trigger:button, title:'Copy settings summary'}); + } + }); + return button; + } /** The concept's plain cover; the card image opens the viewer (D19). Video keeps its own element. */ function cover(record) { const {file} = record, video = file?.media_type === 'video'; diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 72b3e860..9d697255 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -280,14 +280,17 @@ export function openImportDialog({ scope, onInspect, onRescan, onMetadataScan = return { setRescanBusy }; } -/** "Copy prompt": the text in a readonly, pre-selected textarea for a manual copy. */ -export function openCopyFallback(text, { scope, trigger = null } = {}) { +/** + * "Copy prompt": the text in a readonly, pre-selected textarea for a manual copy. `title` names another copy's dialog + * (D-NEW-1 "Copy settings summary"). + */ +export function openCopyFallback(text, { scope, trigger = null, title = 'Copy prompt' } = {}) { requireScope(scope); if (typeof text !== 'string') throw new TypeError('Copy text must be a string'); const area = el('textarea'); area.readOnly = true; area.value = text; - show('Copy prompt', [ + show(title, [ field('Select and copy this text', area, 'copy-text'), el('p', 'Automatic clipboard access requires a secure browser context.', 'small'), ], { scope, trigger, focusTarget: area }); diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 7394f31d..95af2388 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -212,6 +212,8 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } /* #249 D20: the viewer details' pair send and history buttons wrap inside the narrow rail at 200% zoom with wider fonts, where the toolkit's `nowrap` would push them past its edge. */ .ca-tools #viewer-details > button { white-space: normal; /* D20 */ } +/* LI-4: "Copy settings summary" after the facts wraps the same way in the narrow rail. */ +.ca-tools #viewer-details > div > button { white-space: normal; /* D-NEW-1 */ } /* The Images page narrows to one column inside a narrow container (the Library-embedded gallery). */ .images-main { container: images-page / inline-size; } @container images-page (inline-size < 600px) {