diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index c6b0fb8..27dd51b 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -285,12 +285,15 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); await expect(await request.post('/__test__/reset',{data:{full_inspector:true,extra_images:200}})).toBeOK(); await page.goto('/prompt_manager/workspace#/images'); - await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('0'); - await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector'); - await page.getByRole('combobox',{name:'Sort',exact:true}).selectOption('name'); + // #249 C2: Source and Folder live in the concept's "Refine this view"; sort is the toolbar's. + await page.getByRole('button',{name:'Filters',exact:true}).click(); + const refine=page.getByRole('dialog',{name:'Refine this view',exact:true}); + await refine.getByLabel('Source',{exact:true}).selectOption('0');await refine.getByLabel('Folder',{exact:true}).selectOption('inspector'); + await refine.getByRole('button',{name:'Apply filters',exact:true}).click(); + await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('name'); const more=page.getByRole('button',{name:'Load more images',exact:true}); - await expect(page.locator('.image-toolbar')).toContainText('40 of'); - for(const n of [80,120,160]) {await more.click();await expect(page.locator('.image-toolbar')).toContainText(`${n} of`);} + await expect(page.locator('.section-caption')).toContainText('40 of'); + for(const n of [80,120,160]) {await more.click();await expect(page.locator('.section-caption')).toContainText(`${n} of`);} const listed=await(await request.get('/prompt_manager/images/output?mode=workspace&root=0&subfolder=inspector&sort=name&limit=200')).json(); const loaded=listed.images.slice(0,160);expect(loaded).toHaveLength(160); await page.evaluate(()=>scrollTo(0,0));await expect.poll(()=>page.locator('article[data-image-key]').count()).toBeLessThan(120); @@ -299,7 +302,7 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn await page.route(url=>url.pathname.endsWith('/images/output') && url.searchParams.get('limit')!=='1',async route=>{started();await held;await route.continue();},{times:1}); await more.click();await hit;await page.evaluate(()=>scrollTo(0,0)); const opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-card-footer button`); - await opener.click();release();await expect(page.locator('.image-toolbar')).toContainText('200 of'); + await opener.click();release();await expect(page.locator('.section-caption')).toContainText('200 of'); const modal=mediaDialog(page);await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`); let pages=0;page.on('request',req=>{if(req.url().includes('/images/output?'))pages++;}); await modal.getByRole('button',{name:'Previous media',exact:true}).click(); @@ -311,8 +314,9 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn await mediaStage(page).focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(`2 of 160 loaded: ${loaded[1].filename}`); expect(pages).toBe(0);await modal.getByRole('button',{name:'Close preview',exact:true}).click(); await expect(opener).toBeFocused(); - await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('1'); - await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector'); + await page.getByRole('button',{name:'Filters',exact:true}).click(); + await refine.getByLabel('Source',{exact:true}).selectOption('1');await refine.getByLabel('Folder',{exact:true}).selectOption('inspector'); + await refine.getByRole('button',{name:'Apply filters',exact:true}).click(); await selectFile(page,'complete.png',1);await openCollection(page);await expect(position(page)).toHaveText('1 of 1 loaded: complete.png'); for(const name of ['Previous media','Next media'])await expect(mediaDialog(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true'); const onePlay=mediaDialog(page).getByRole('button',{name:'Play slideshow',exact:true}); @@ -438,7 +442,7 @@ test('slideshow production query and route disposal cancel the active deadline', await openCollection(page);await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); await page.clock.pauseAt(new Date(Date.now()+1000));await slideshowButton(page).click();expect(await timerCount(page)).toBe(1); await page.clock.runFor(3499); - const pendingAtReset=await page.evaluate(()=>{const folder=document.getElementById('image-folder');folder.value='inspector';folder.dispatchEvent(new Event('change'));return window.slideshowTimers.size;}); + const pendingAtReset=await page.evaluate(()=>{const sort=document.getElementById('sort');sort.value='name';sort.dispatchEvent(new Event('change'));return window.slideshowTimers.size;}); expect(pendingAtReset).toBe(0);await page.clock.runFor(1); await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0); await selectFile(page,'complete.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await readyClockImage(page);await expect(slideshowButton(page)).toHaveText('Play'); diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs index aad76dd..3f928fb 100644 --- a/tests/browser/prompt-inspector.spec.mjs +++ b/tests/browser/prompt-inspector.spec.mjs @@ -227,8 +227,8 @@ test('filtering away a selected image aborts its pending metadata request',async try { await page.goto(workspace);await select(page,'0:shared/same.png');await requested; const baseline=await page.evaluate(()=>window.inspectionAborts); - await page.getByRole('searchbox',{name:'Search filenames',exact:true}).fill('no-such-file'); - await expect(page.getByText('No matching images or media. Try changing the filters.',{exact:true})).toBeVisible(); + await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('no-such-file'); + await expect(page.getByText('No matching images · 0 images',{exact:true})).toBeVisible(); await expect.poll(()=>page.evaluate(()=>window.inspectionAborts),{timeout:1500}).toBeGreaterThan(baseline); } finally {release();} }); diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 83109df..5ce9547 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -6,6 +6,27 @@ const cards = page => page.locator('article[data-image-key]'); // Grid pages only: the sidebar's Images count is its own `limit=1` read of the same listing. const imagePages = url => url.pathname === '/prompt_manager/images/output' && url.search !== '' && url.searchParams.get('limit') !== '1'; const details = page => page.getByRole('region', { name: 'Image details', exact: true }); +// The concept toolbar, filter row and caption (#249 C2); Source, Folder, Media and Tags live in "Refine this view". +const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true }); +const sortBy = page => page.getByRole('combobox', { name: 'Sort results', exact: true }); +const caption = page => page.locator('.section-caption'); +const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true }); +const importDialog = page => page.getByRole('dialog', { name: 'Import & inspect', exact: true }); +async function refine(page, values) { + await page.getByRole('button', { name: 'Filters', exact: true }).click(); + const dialog = refineDialog(page); + for (const [label, value] of Object.entries(values)) { + const control = dialog.getByLabel(label, { exact: true }); + if (label === 'Tags') await control.fill(value); + else await control.selectOption(value); + } + await dialog.getByRole('button', { name: 'Apply filters', exact: true }).click(); + await expect(dialog).toBeHidden(); +} +async function rescan(page) { + await page.getByRole('button', { name: 'Import', exact: true }).click(); + await importDialog(page).getByRole('button', { name: 'Rescan', exact: true }).click(); +} test.beforeEach(async ({ page, request, browserName }) => { await startCoverage(page, browserName); @@ -129,7 +150,7 @@ for (const embedded of [false, true]) { }, { images: data.images, embedded }); await expect(cards(page)).toHaveCount(40); expect(await page.evaluate(() => window.continuationFixture.reads)).toEqual([null]); - await page.locator('.image-continuation').scrollIntoViewIfNeeded(); + await page.locator('.infinite-continuation').scrollIntoViewIfNeeded(); await expect.poll(() => page.evaluate(() => window.continuationFixture.reads)).toEqual([null, 'next-0']); const key = await cards(page).last().getAttribute('data-image-key'); const preview = page.locator(`article[data-image-key="${key}"]`).getByRole('button', { name: /^Preview / }); @@ -139,7 +160,7 @@ for (const embedded of [false, true]) { await page.evaluate(() => window.continuationFixture.finishPage()); await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toHaveAttribute('aria-disabled', 'false'); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); - expect(await page.evaluate(() => ({ reads: window.continuationFixture.reads, nativeObserver: typeof IntersectionObserver, visible: document.querySelector('.image-continuation').getBoundingClientRect().top <= innerHeight }))) + expect(await page.evaluate(() => ({ reads: window.continuationFixture.reads, nativeObserver: typeof IntersectionObserver, visible: document.querySelector('.infinite-continuation').getBoundingClientRect().top <= innerHeight }))) .toEqual({ reads: [null, 'next-0'], nativeObserver: 'function', visible: true }); if (close === 'parent abort') await page.evaluate(() => window.continuationFixture.scope.dispose()); else if (close === 'Close') await viewer.getByRole('button', { name: 'Close preview', exact: true }).click(); @@ -148,7 +169,7 @@ for (const embedded of [false, true]) { if (close !== 'parent abort') { await expect(preview).toBeFocused(); await expect.poll(() => page.evaluate(() => window.continuationFixture.reads)).toEqual([null, 'next-0', 'next-1']); - await expect(page.locator('.image-toolbar')).toContainText(embedded ? '80 loaded' : '80 of 80'); + await expect(caption(page)).toContainText(embedded ? '80 loaded' : '80 of 80'); } await page.evaluate(() => window.continuationFixture.scope.dispose()); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); @@ -163,19 +184,18 @@ test('source, folder and media filters operate on real backend records', async ( await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK(); await page.goto(workspace); await expect(cards(page)).toHaveCount(5); - await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('1'); + await refine(page, { Source: '1' }); await expect(cards(page)).toHaveCount(1); await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png'); - await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('all'); - await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption('bulk'); + await refine(page, { Source: 'all', Folder: 'bulk' }); await expect(cards(page)).toHaveCount(3); await expect(cards(page).first()).toHaveAttribute('data-image-key', /^0:bulk\//); - await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('video'); + await refine(page, { Media: 'video' }); await expect(cards(page)).toHaveCount(0); await expect(page.locator('#workspace-main')).toContainText(/no (matching )?(images|media)/i); - await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('image'); + await refine(page, { Media: 'image' }); await expect(cards(page)).toHaveCount(3); - await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption(''); + await refine(page, { Folder: '' }); await expect(cards(page)).toHaveCount(5); }); @@ -183,16 +203,16 @@ test('filename search finds records beyond the initial batch and sort orders the await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK(); await page.goto(workspace); await expect(cards(page)).toHaveCount(40); - await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('same.png'); + await search(page).fill('same.png'); await expect(cards(page)).toHaveCount(2); - await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('0099'); + await search(page).fill('0099'); await expect(cards(page)).toHaveCount(1); await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0099.png'); - await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('same.png'); + await search(page).fill('same.png'); await expect(cards(page)).toHaveCount(2); - await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('oldest'); + await sortBy(page).selectOption('oldest'); await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png'); - await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('newest'); + await sortBy(page).selectOption('newest'); await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png'); }); @@ -218,13 +238,13 @@ test('returning to Images restores the filtered stream, layout, selection and sc await page.addInitScript(() => { window.IntersectionObserver = undefined; }); await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK(); await page.goto(workspace); - const search = page.getByRole('searchbox', { name: 'Search filenames', exact: true }); - await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('0'); - await search.fill('00'); - await expect(page.locator('.image-toolbar')).toContainText('40 of 100 images'); + const box = search(page); + await refine(page, { Source: '0' }); + await box.fill('00'); + await expect(caption(page)).toContainText('40 of 100 images'); await page.getByRole('button', { name: 'List', exact: true }).click(); await page.getByRole('button', { name: 'Load more images', exact: true }).click(); - await expect(page.locator('.image-toolbar')).toContainText('80 of 100 images'); + await expect(caption(page)).toContainText('80 of 100 images'); await cards(page).first().getByRole('button', { name: /^Open / }).click(); const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent(); await cards(page).nth(30).scrollIntoViewIfNeeded(); @@ -236,13 +256,18 @@ test('returning to Images restores the filtered stream, layout, selection and sc await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); await page.evaluate(() => { window.location.hash = '/images'; }); await expect(page.locator('#workspace-main h1')).toHaveText('Images'); - await expect(search).toHaveValue('00'); - await expect(page.getByRole('combobox', { name: 'Source', exact: true })).toHaveValue('0'); + await expect(box).toHaveValue('00'); + await expect(page.locator('.filter-row span.chip')).toHaveText(/ · All folders$/); await expect(page.getByRole('button', { name: 'List', exact: true })).toHaveAttribute('aria-pressed', 'true'); - await expect(page.locator('.image-toolbar')).toContainText('80 of 100 images'); + await expect(caption(page)).toContainText('80 of 100 images'); await expect(cards(page)).toHaveCount(80); await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle); await expect.poll(async () => Math.abs((await page.evaluate(() => window.scrollY)) - previousScroll)).toBeLessThan(120); + // Checked last: closing the dialog focuses Filters, which scrolls the toolbar into view. + await page.getByRole('button', { name: 'Filters', exact: true }).click(); + await expect(refineDialog(page).getByLabel('Source', { exact: true })).toHaveValue('0'); + await page.keyboard.press('Escape'); + await expect(refineDialog(page)).toBeHidden(); }); test('continuous loading appends without replacing cards or losing selected details', async ({ page, request }) => { @@ -292,7 +317,7 @@ test('large continuous collections bound the mounted cards and restore earlier i const more = page.getByRole('button', { name: 'Load more images', exact: true }); for (const loaded of [80, 120, 160, 200, 202]) { await more.click(); - await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`); + await expect(caption(page)).toContainText(`${loaded} of 202 images`); } await expect(more).toBeHidden(); await expect.poll(() => cards(page).count()).toBeLessThan(120); @@ -321,7 +346,7 @@ test('virtual gallery keyboard navigation reaches loaded endpoints and announces const more = page.getByRole('button', { name: 'Load more images', exact: true }); for (const loaded of [80, 120, 160, 200, 202]) { await more.click(); - await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`); + await expect(caption(page)).toContainText(`${loaded} of 202 images`); } await page.evaluate(() => window.scrollTo(0, 0)); const first = page.locator(`article[data-image-key="${firstKey}"]`); @@ -364,7 +389,7 @@ test('real video media has a usable card and native playback cleans up on close const media = await (await request.get('/prompt_manager/images/output?limit=40')).json(); expect(media.images.find(image => image.filename === 'clip.webm').thumbnail_url).toBeNull(); await page.goto(workspace); - await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('video'); + await refine(page, { Media: 'video' }); await expect(cards(page)).toHaveCount(1); const card = cards(page).first(); await expect(card).toHaveAttribute('data-image-key', '0:clip.webm'); @@ -454,7 +479,7 @@ test('failed image requests offer retry while stale results cannot replace a new await pending; await route.fulfill({ response }).catch(() => {}); }); - await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('nothing'); + await search(page).fill('nothing'); await received; await page.getByRole('navigation', { name: 'Tools and system', exact: true }) .getByRole('link', { name: 'Overview', exact: true }).click(); @@ -464,19 +489,19 @@ test('failed image requests offer retry while stale results cannot replace a new await expect(details(page)).toHaveCount(0); }); -test('source and folder discovery failure stays visible and Refresh restores usable filters', async ({ page }) => { +test('source and folder discovery failure stays visible and Import Rescan restores usable filters', async ({ page }) => { await page.route('**/prompt_manager/gallery/subfolders?*', route => route.fulfill({ status: 503, json: { success: false, error: 'Sources unavailable' }, }), { times: 1 }); await page.goto(workspace); await expect(cards(page)).toHaveCount(2); - await expect(page.getByRole('alert')).toHaveText('Sources and folders could not be loaded. Refresh to retry.'); - await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await expect(page.getByRole('alert')).toHaveText('Sources and folders could not be loaded. Rescan from Import to retry.'); + await rescan(page); await expect(page.getByRole('alert')).toHaveCount(0); - await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('1'); + await refine(page, { Source: '1' }); await expect(cards(page)).toHaveCount(1); await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png'); - await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption('shared'); + await refine(page, { Folder: 'shared' }); await expect(cards(page)).toHaveCount(1); }); @@ -484,15 +509,17 @@ async function staleSourceRecovery(page, request, roots) { await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK(); await page.setViewportSize({ width: 1600, height: 1100 }); await page.goto(workspace); - const source = page.getByRole('combobox', { name: 'Source', exact: true }); - const folder = page.getByRole('combobox', { name: 'Folder', exact: true }); - await source.selectOption('0');await expect(folder.locator('option[value="bulk"]')).toHaveCount(1);await folder.selectOption('bulk'); + const source = refineDialog(page).getByLabel('Source', { exact: true }); + const folder = refineDialog(page).getByLabel('Folder', { exact: true }); + const filters = page.getByRole('button', { name: 'Filters', exact: true }); + await filters.click();await expect(folder.locator('option[value="bulk"]')).toHaveCount(1);await page.keyboard.press('Escape'); + await refine(page, { Source: '0', Folder: 'bulk' }); const configured = page.waitForResponse(response => { const url = new URL(response.url());return url.pathname.endsWith('/images/output') && url.searchParams.get('root') === '0' && url.searchParams.get('subfolder') === 'bulk' && url.searchParams.get('q') === 'png' && url.searchParams.get('media') === 'image' && url.searchParams.get('sort') === 'name'; }); - await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('png'); - await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('image');await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('name'); - const initial = await (await configured).json();await expect(page.locator('.image-toolbar')).toContainText('40 of 80'); + await search(page).fill('png'); + await refine(page, { Media: 'image' });await sortBy(page).selectOption('name'); + const initial = await (await configured).json();await expect(caption(page)).toContainText('40 of 80'); let oldPage; await page.route(imagePages, async route => { if (new URL(route.request().url()).searchParams.has('cursor') && !oldPage) { @@ -519,7 +546,7 @@ async function staleSourceRecovery(page, request, roots) { await expect(viewer).toHaveCount(0);await expect.poll(() => options.length).toBe(1); await oldPage.route.fulfill({ json: oldPage.data }); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); - return { source, folder, pages, inspections, options, mutations }; + return { source, folder, filters, pages, inspections, options, mutations }; } for (const [name, roots] of [['reordered', ['input', 'output']], ['removed', ['input']]]) { @@ -527,11 +554,13 @@ for (const [name, roots] of [['reordered', ['input', 'output']], ['removed', ['i const recovery = await staleSourceRecovery(page, request, roots); try { expect(recovery.pages.map(url => url.search)).toEqual([]);expect(recovery.inspections).toEqual([]); - await expect(recovery.source).toHaveValue('all');await expect(recovery.source.locator('option')).toHaveText(['All sources']);await expect(recovery.folder).toHaveValue(''); - for (const name of ['Source', 'Folder', 'Media', 'Sort']) await expect(page.getByRole('combobox', { name, exact: true })).toBeDisabled(); - await expect(page.getByRole('searchbox', { name: 'Search filenames', exact: true })).toBeDisabled(); + await expect(page.locator('.filter-row span.chip')).toHaveText('All output folders'); + for (const name of ['Filter by category', 'Sort results']) await expect(page.getByRole('combobox', { name, exact: true })).toBeDisabled(); + for (const name of ['Filters', 'Import']) await expect(page.getByRole('button', { name, exact: true })).toBeDisabled(); + await expect(search(page)).toBeDisabled(); await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeDisabled(); - await page.getByRole('button', { name: 'Load more images', exact: true }).evaluate(node => node.click());await recovery.source.dispatchEvent('change'); + await page.getByRole('button', { name: 'Load more images', exact: true }).evaluate(node => node.click());await recovery.filters.evaluate(node => node.click());await sortBy(page).dispatchEvent('change'); + await expect(refineDialog(page)).toBeHidden(); await recovery.options[0].fulfill({ status: 503, json: { success: false, error: 'Discovery unavailable' } }); await expect(page.getByRole('alert').filter({ hasText: 'Sources and folders could not be loaded.' })).toBeVisible(); expect(recovery.pages).toEqual([]);expect(recovery.inspections).toEqual([]);await expect(cards(page)).toHaveCount(0); @@ -543,8 +572,8 @@ for (const [name, roots] of [['reordered', ['input', 'output']], ['removed', ['i await expect.poll(() => recovery.pages.length).toBe(1); const query = recovery.pages[0].searchParams;expect(query.has('root')).toBe(false);expect(query.has('subfolder')).toBe(false); expect(query.get('q')).toBe('png');expect(query.get('media')).toBe('image');expect(query.get('sort')).toBe('name'); - await expect(recovery.source).toBeEnabled();await expect(recovery.source).toHaveValue('all'); - await expect(recovery.source.locator('option')).toHaveText(['All sources', ...roots]); + await expect(recovery.filters).toBeEnabled();await recovery.filters.click();await expect(recovery.source).toHaveValue('all'); + await expect(recovery.source.locator('option')).toHaveText(['All sources', ...roots]);await page.keyboard.press('Escape'); await expect.poll(() => recovery.inspections.length).toBe(1);expect(recovery.inspections[0].searchParams.get('root')).toBe(name === 'reordered' ? '1' : '0'); expect(recovery.mutations).toEqual([]); if (testInfo.project.name === 'chromium' && name === 'reordered') await page.screenshot({ path: testInfo.outputPath('safe-source-recovery-success.png') }); @@ -558,7 +587,7 @@ test('source-change recovery ignores superseded discovery and route disposal', a expect(recovery.pages).toEqual([]);await details(page).getByRole('button', { name: 'Refresh image collection', exact: true }).click();await expect.poll(() => recovery.options.length).toBe(2); await recovery.options[0].fulfill({ json: { roots: [{ index: 0, label: 'Obsolete source label' }], subfolders: ['obsolete'] } }); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); - expect(recovery.pages).toEqual([]);await expect(recovery.source.locator('option')).toHaveText(['All sources']); + expect(recovery.pages).toEqual([]);await expect(page.locator('.filter-row span.chip')).toHaveText('All output folders');await expect(recovery.filters).toBeDisabled(); await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click(); await recovery.options[1].fulfill({ json: { roots: [{ index: 0, label: 'Late source label' }], subfolders: ['late'] } }); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); @@ -576,7 +605,7 @@ test('mobile Images keeps filters, layouts and selected details within the viewp await expect(details(page)).toContainText(/input/i); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); } - await expect(page.getByRole('searchbox', { name: 'Search filenames', exact: true })).toBeVisible(); + await expect(search(page)).toBeVisible(); }); test('a delayed search cannot replace results for a newer filename query', async ({ page, request }) => { @@ -599,10 +628,10 @@ test('a delayed search cannot replace results for a newer filename query', async await route.fulfill({ response }).catch(() => {}); complete(); }); - const search = page.getByRole('searchbox', { name: 'Search filenames', exact: true }); - await search.fill('same.png'); + const box = search(page); + await box.fill('same.png'); await received; - await search.fill('0001'); + await box.fill('0001'); await expect(cards(page)).toHaveCount(1); await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0001.png'); release(); @@ -630,22 +659,22 @@ test('refresh releases the previous inspector video source', async ({ page, requ await card.getByRole('button', { name: 'Open clip.webm', exact: true }).click(); await expect(details(page).locator('video')).toBeVisible(); await details(page).locator('video').evaluate(element => { window.__oldInspectorVideo = element; }); - await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await rescan(page); await expect.poll(() => page.evaluate(() => !window.__oldInspectorVideo.isConnected)).toBe(true); expect(await page.evaluate(() => window.__oldInspectorVideo.getAttribute('src'))).toBeNull(); }); -test('Refresh rescans cached files and preserves the collection when rescan fails', async ({ page, request }) => { +test('Import Rescan rescans cached files and preserves the collection when rescan fails', async ({ page, request }) => { await page.goto(workspace); await expect(cards(page)).toHaveCount(2); await expect(await request.post('/__test__/add-nested-image')).toBeOK(); await page.route('**/prompt_manager/gallery/rescan', route => route.fulfill({ status: 503, json: { success: false, error: 'Rescan unavailable' }, }), { times: 1 }); - await page.getByRole('button', { name: 'Refresh', exact: true }).click(); - await expect(page.getByRole('alert')).toContainText('could not be refreshed'); + await rescan(page); + await expect(page.getByRole('alert')).toContainText('could not be rescanned'); await expect(cards(page)).toHaveCount(2); - await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await rescan(page); await expect(cards(page)).toHaveCount(3); await expect(cards(page).filter({ hasText: 'new-output.png' })).toHaveCount(1); await expect(page.getByRole('alert')).toBeHidden(); @@ -655,14 +684,15 @@ test('folder discovery offers ancestors that filter nested images', async ({ pag await expect(await request.post('/__test__/add-nested-image')).toBeOK(); await page.goto(workspace); await expect(cards(page)).toHaveCount(3); - const folder = page.getByRole('combobox', { name: 'Folder', exact: true }); - await expect(folder.locator('option[value="project"]')).toHaveCount(1); - await folder.selectOption('project'); + await page.getByRole('button', { name: 'Filters', exact: true }).click(); + await expect(refineDialog(page).getByLabel('Folder', { exact: true }).locator('option[value="project"]')).toHaveCount(1); + await page.keyboard.press('Escape'); + await refine(page, { Folder: 'project' }); await expect(cards(page)).toHaveCount(1); await expect(cards(page)).toContainText('new-output.png'); }); -test('leaving during Refresh cancels it without replacing the next workspace', async ({ page }) => { +test('leaving during Rescan cancels it without replacing the next workspace', async ({ page }) => { let release; const pending = new Promise(resolve => { release = resolve; }); await page.route('**/prompt_manager/gallery/rescan', async route => { @@ -672,14 +702,14 @@ test('leaving during Refresh cancels it without replacing the next workspace', a await page.goto(workspace); await expect(cards(page)).toHaveCount(2); const requested = page.waitForRequest('**/prompt_manager/gallery/rescan'); - await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await rescan(page); await requested; await page.getByRole('navigation', { name: 'Tools and system', exact: true }) .getByRole('link', { name: 'Overview', exact: true }).click(); await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); release(); await expect(page.getByRole('heading', { name: 'Continue creating', exact: true })).toBeVisible(); - await expect(page.getByText('Images could not be refreshed. Try Refresh again.', { exact: true })).toHaveCount(0); + await expect(page.getByText('Output folders could not be rescanned. Try Rescan again.', { exact: true })).toHaveCount(0); }); @@ -688,12 +718,12 @@ test('Images uses approved geometry while keeping real controls', async ({page,r await page.setViewportSize({width:1600,height:1100}); await page.goto(workspace); await expect(cards(page)).toHaveCount(24); - await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Browse your images, explore the details, keep your creative context.'); + await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Your generations, their prompts, and the next idea.'); await expect(details(page)).toHaveCSS('width','310px'); - await expect(page.locator('.image-workspace')).toHaveCSS('column-gap','22px'); - for (const label of ['Source','Folder','Media','Sort','Thumbnail size']) await expect(page.getByRole('combobox',{name:label,exact:true})).toBeVisible(); - await expect(page.getByRole('searchbox',{name:'Search filenames',exact:true})).toHaveAttribute('placeholder',/filename or folder/); - await expect(page.getByRole('link',{name:'Group by positive prompt',exact:true})).toHaveAttribute('href','#/library'); + await expect(page.locator('.gallery-layout')).toHaveCSS('column-gap','22px'); + for (const label of ['Filter by category','Sort results','Thumbnail size']) await expect(page.getByRole('combobox',{name:label,exact:true})).toBeVisible(); + await expect(search(page)).toHaveAttribute('placeholder','Search prompts, tags, filenames…'); + await expect(page.getByRole('button',{name:'Group by positive prompt →',exact:true})).toBeVisible(); const geometry = await cards(page).evaluateAll(nodes=>nodes.slice(0,4).map(node=>{ const box=node.getBoundingClientRect(),media=node.querySelector('.image-open').getBoundingClientRect(); return {x:box.x,y:box.y,right:box.right,bottom:box.bottom,width:box.width,mediaWidth:media.width,mediaHeight:media.height}; @@ -702,7 +732,7 @@ test('Images uses approved geometry while keeping real controls', async ({page,r expect(geometry[3].y-geometry[0].bottom).toBeCloseTo(13,0); expect(geometry[3].x).toBeCloseTo(geometry[0].x,0); expect(geometry[0].mediaWidth/geometry[0].mediaHeight).toBeCloseTo(4/3,2); - expect((await page.locator('.image-toolbar').boundingBox()).y).toBeLessThan(geometry[0].y); + expect((await caption(page).boundingBox()).y).toBeLessThan(geometry[0].y); await expect(cards(page).first()).toHaveCSS('outline-style','solid'); const cover=details(page).getByRole('button',{name:'Open full preview',exact:true}); await expect(cover).toHaveCSS('height','150px'); @@ -743,3 +773,160 @@ test('density changes and resize preserve identity focus and selection', async ( expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); } }); + +test('images toolbar and filter row match the concept controls', async ({ page, request }) => { + // Old → new: Search filenames → toolbar search; Source/Folder/Media → Filters; Refresh → Import → Rescan (D15). + await expect(await request.post('/prompt_manager/save', { data: { text: 'Dog landscape', category: 'Landscapes', tags: ['dogs'], rating: 5 } })).toBeOK(); + const { roots } = await (await request.get('/prompt_manager/gallery/subfolders?mode=workspace')).json(); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + const main = page.locator('#workspace-main'); + await expect(main.locator(':scope > .heading p.muted')).toHaveText('Your generations, their prompts, and the next idea.'); + await expect(main.locator(':scope > .heading .heading-actions > button')).toHaveText(['Import', 'New prompt']); + await expect(main.locator(':scope > .heading .heading-actions > button').nth(1)).toHaveClass(/ca-primary/); + await expect(page.getByRole('searchbox', { name: 'Search filenames' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Refresh', exact: true })).toHaveCount(0); + await expect(page.getByRole('link', { name: 'Current gallery' })).toHaveCount(0); + await expect(main.locator('h2', { hasText: /^All images$/ })).toHaveCount(0); + await expect(main.locator('search.toolbar > *')).toHaveCount(4); + await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Name Z–A']); + const row = main.locator('.filter-row'); + await expect(row.locator(':scope > *')).toHaveCount(5); + await expect(row.locator(':scope > .segmented[role="group"][aria-label="Image layout"] > button')).toHaveText(['Grid', 'List', 'Masonry']); + await expect(row.locator(':scope > label.small[for="image-size"]')).toHaveText('Thumbnail size'); + await expect(row.locator(':scope > select#image-size option')).toHaveText(['Comfortable', 'Large', 'Compact']); + await expect(row.locator(':scope > span.chip')).toHaveText('All output folders'); + await expect(row.locator(':scope > span.chip svg.icon')).toHaveCount(1); + await expect(row.locator(':scope > button.chip')).toHaveText('Group by positive prompt →'); + + await search(page).fill('cat'); + await expect(cards(page)).toHaveCount(1); + await expect(cards(page)).toHaveAttribute('data-image-key', '0:shared/same.png'); + await search(page).fill('dogs'); + await expect(cards(page)).toHaveCount(1); + await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png'); + await search(page).fill(''); + await expect(cards(page)).toHaveCount(2); + await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png'); + await sortBy(page).selectOption('rating'); + await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png'); + await sortBy(page).selectOption('newest'); + await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes'); + await expect(cards(page)).toHaveCount(1); + await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png'); + await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption(''); + await expect(cards(page)).toHaveCount(2); + + await refine(page, { Source: '1', Folder: 'shared', Media: 'image' }); + await expect(cards(page)).toHaveCount(1); + await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png'); + await expect(row.locator(':scope > span.chip')).toHaveText(`${roots[1].label} · shared`); + await refine(page, { Media: 'video' }); + await expect(cards(page)).toHaveCount(0); + await refine(page, { Source: 'all', Folder: '', Media: 'all', Tags: 'cats' }); + await expect(cards(page)).toHaveCount(1); + await expect(cards(page)).toHaveAttribute('data-image-key', '0:shared/same.png'); + await expect(row.locator(':scope > span.chip')).toHaveText('All output folders'); + await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes'); + await expect(cards(page)).toHaveCount(0); + await page.getByRole('button', { name: 'Filters', exact: true }).click(); + await expect(refineDialog(page).getByLabel('Tags', { exact: true })).toHaveValue('cats'); + await refineDialog(page).getByRole('button', { name: 'Clear filters', exact: true }).click(); + await expect(page.getByRole('combobox', { name: 'Filter by category', exact: true })).toHaveValue(''); + await expect(cards(page)).toHaveCount(2); + + const rescans = []; + page.on('request', request => { if (request.url().includes('/prompt_manager/gallery/rescan')) rescans.push(request.method()); }); + await expect(await request.post('/__test__/add-nested-image')).toBeOK(); + await rescan(page); + await expect(cards(page)).toHaveCount(3); + await expect(caption(page)).toContainText('3 of 3 images'); + expect(rescans).toEqual(['POST']); + await page.getByRole('button', { name: 'Import', exact: true }).click(); + await importDialog(page).getByRole('button', { name: 'Open inspector', exact: true }).click(); + await expect(page).toHaveURL(/#\/inspect$/); + await page.goBack(); + await expect(page.locator('#workspace-main h1')).toHaveText('Images'); + await row.locator(':scope > button.chip').click(); + await expect(page).toHaveURL(/#\/library$/); +}); + +test('browser Back closes the open Images dialog and leaves the next page usable', async ({ page }) => { + await page.goto('/prompt_manager/workspace#/overview'); + await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + for (const [button, dialog] of [['Filters', refineDialog], ['Import', importDialog]]) { + await page.evaluate(() => { location.hash = '/images'; }); + await expect(cards(page)).toHaveCount(2); + await page.getByRole('button', { name: button, exact: true }).click(); + await expect(dialog(page)).toBeVisible(); + await page.goBack(); + await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + await expect(page.locator('dialog#modal[open]')).toHaveCount(0); + await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Images(?:\s*[\d,]+)?$/ }).click(); + await expect(cards(page)).toHaveCount(2); + await search(page).fill('cat'); + await expect(cards(page)).toHaveCount(1); + await search(page).fill(''); + await expect(cards(page)).toHaveCount(2); + await page.evaluate(() => { location.hash = '/overview'; }); + await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + } +}); + +test('count line shows images and positive prompts', async ({ page, request }) => { + await page.addInitScript(() => { window.IntersectionObserver = undefined; }); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + await expect(caption(page).locator('#result-count')).toHaveText('2 of 2 images · 2 positive prompts'); + await expect(caption(page).locator(':scope > span')).toHaveCount(2); + await expect(caption(page).locator(':scope > span').nth(1)).toHaveText(/^Today · [A-Z][a-z]+ \d{1,2}$/); + await sortBy(page).selectOption('name'); + await expect(cards(page).first()).toHaveAttribute('data-image-key', /same\.png$/); + await expect(caption(page).locator(':scope > span').nth(1)).toBeHidden(); + await search(page).fill('cat'); + await expect(caption(page).locator('#result-count')).toHaveText('1 of 1 image · 1 positive prompt'); + + await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK(); + // A plain cursor page leaves positive_count out; the first page's count stays. + const cursorPages = []; + await page.route(url => imagePages(url) && url.searchParams.has('cursor'), async route => { + const response = await route.fetch(); + const json = await response.json(); + cursorPages.push('positive_count' in json); + await route.fulfill({ response, json }); + }); + // A fresh document: a same-page visit would restore the earlier search. + await page.goto('about:blank'); + await page.goto(workspace); + await expect(caption(page).locator('#result-count')).toHaveText('40 of 102 images · 2 positive prompts'); + await page.getByRole('button', { name: 'Load more images', exact: true }).click(); + await expect(caption(page).locator('#result-count')).toHaveText('80 of 102 images · 2 positive prompts'); + expect(cursorPages).toEqual([false]); + + // A failed count read is null: the count line keeps only the images. + await page.unrouteAll({ behavior: 'wait' }); + await page.route(imagePages, async route => { + const response = await route.fetch(); + await route.fulfill({ response, json: { ...(await response.json()), positive_count: null } }); + }); + await page.goto('about:blank'); + await page.goto(workspace); + await expect(caption(page).locator('#result-count')).toHaveText('40 of 102 images'); +}); + +test('Load more toast reports the new total', async ({ page, request }) => { + await page.addInitScript(() => { window.IntersectionObserver = undefined; }); + await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK(); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(40); + await expect(page.locator('.infinite-continuation p.small')).toHaveText('40 of 102 images · more load as you scroll'); + await expect(page.locator('#workspace-toast')).toHaveText(''); + await page.getByRole('button', { name: 'Load more images', exact: true }).click(); + await expect(page.locator('#workspace-toast')).toHaveText('40 more images loaded. 80 of 102.'); + await page.getByRole('button', { name: 'Load more images', exact: true }).click(); + await expect(page.locator('#workspace-toast')).toHaveText('22 more images loaded. 102 of 102.'); + await expect(page.locator('.infinite-continuation p.small')).toHaveText('You’re all caught up · 102 images'); + await search(page).fill('no-such-image'); + await expect(cards(page)).toHaveCount(0); + await expect(page.locator('.infinite-continuation p.small')).toHaveText('No matching images · 0 images'); +}); diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs index 1efc62b..50dda65 100644 --- a/tests/browser/workspace-library-editor.spec.mjs +++ b/tests/browser/workspace-library-editor.spec.mjs @@ -478,8 +478,9 @@ test('library heading offers Export then + New prompt, and New opens the saved p test('new from Images cancel preserves browse',async({page})=>{ await page.goto(`${workspace}#/images`);await expect(page.locator('#workspace-main h1')).toHaveText('Images'); - const actions=headingActions(page).locator('button');expect(await visibleNames(actions)).toEqual(['New prompt']); - const trigger=actions.first();await expect(trigger).toHaveClass(/\bca-primary\b/);await expect(trigger.locator('svg')).toHaveCount(1); + // #249 C2: the concept's Images heading puts Import before New prompt. + const actions=headingActions(page).locator('button');expect(await visibleNames(actions)).toEqual(['Import','New prompt']); + const trigger=actions.last();await expect(trigger).toHaveClass(/\bca-primary\b/);await expect(trigger.locator('svg')).toHaveCount(1); await trigger.click();let dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog); await field(dialog,'Title').fill('Discarded');await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0); await expect(trigger).toBeFocused();await expect(page).toHaveURL(/#\/images$/);await expect(page.locator('.library-shelf, .library-history')).toHaveCount(0); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 7662960..ddfe62a 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -374,7 +374,7 @@ test('zoomed virtual Images keeps the visible real media mounted during native s const stride = await cards.first().evaluate(node => node.getBoundingClientRect().height + 26); const more = page.getByRole('button', { name: 'Load more images', exact: true }); for (const loaded of [80, 120, 160, 200, 202]) { - await more.click(); await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`); + await more.click(); await expect(page.locator('.section-caption')).toContainText(`${loaded} of 202 images`); } await expect.poll(() => cards.count()).toBeLessThan(120); await page.evaluate(stride => { @@ -454,10 +454,10 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor await page.getByRole('button', { name: 'List', exact: true }).click(); await expect.poll(() => containedImageContent(page)).toEqual([]); expect(await cards.first().evaluate(node => node.offsetHeight)).toBeGreaterThan(97); - await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('no-real-media-match'); + await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('no-real-media-match'); await expect(cards).toHaveCount(0); expect(await grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBe(54); - await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill(''); + await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill(''); await expect(cards).toHaveCount(24); await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54); const extremeFooter = await grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height'))); @@ -498,7 +498,7 @@ test('supplemental wrapped real footer reservation survives append without movin return node ? { key: node.dataset.imageKey, top: node.getBoundingClientRect().top - top } : null; }); expect(anchor).not.toBeNull(); - release(); await expect(page.locator('.image-toolbar')).toContainText('80 of 202 images'); + release(); await expect(page.locator('.section-caption')).toContainText('80 of 202 images'); await expect(cards).toHaveCount(80); await expect.poll(async () => Math.abs(await page.locator(`article[data-image-key="${anchor.key}"]`).evaluate(node => node.getBoundingClientRect().top - (visualViewport?.offsetTop || 0)) - anchor.top)).toBeLessThanOrEqual(1); await expect(page.locator(`article[data-image-key="${selectedKey}"]`)).toHaveClass(/selected/); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index c22f6a0..49f251f 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -11,7 +11,8 @@ const sidebarLink = (page, label) => page.locator('aside.sidebar nav').getByRole const sidebarLinks = page => page.locator('aside.sidebar nav a'); const routes = [ ['overview', 'Overview', null], - ['images', 'Images', '/prompt_manager/gallery'], + // D29: Images no longer links the legacy gallery; Overview's tool cards still do. + ['images', 'Images', null], ['library', 'Prompt library', '/prompt_manager/admin'], ['tags', 'Tags & categories', null], ['inspect', 'Inspect a file', '/prompt_manager/gallery.html'], @@ -308,7 +309,7 @@ test('leaving a loading overview prevents its delayed result from replacing the await expect(page.locator('#primary-nav')).toHaveCount(1); await expect(page.locator('#system-nav')).toHaveCount(1); await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toHaveCount(1); - await expect(page.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toBeVisible(); + await expect(page.locator('#workspace-main .section-caption')).toBeVisible(); await countThemeWrites(page); await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption('light'); expect(await page.evaluate(() => window.__themeChanges)).toBe(1); diff --git a/tests/comfyui/prompt-sender.spec.mjs b/tests/comfyui/prompt-sender.spec.mjs index b015a5a..ad4fe98 100644 --- a/tests/comfyui/prompt-sender.spec.mjs +++ b/tests/comfyui/prompt-sender.spec.mjs @@ -41,7 +41,9 @@ test('native image inspector previews and sends a pair into real visible widgets const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message)); try { await workspace.goto('/prompt_manager/workspace#/images'); - await workspace.getByRole('button',{name:'Refresh',exact:true}).click(); + // D15: Refresh is Import → Rescan. + await workspace.getByRole('button',{name:'Import',exact:true}).click(); + await workspace.getByRole('dialog',{name:'Import & inspect',exact:true}).getByRole('button',{name:'Rescan',exact:true}).click(); const card=workspace.locator('article[data-image-key]').filter({has:workspace.getByRole('button',{name:`Open ${filename}`,exact:true})}); await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click(); const inspector=workspace.getByRole('region',{name:'Image details',exact:true}); diff --git a/tests/comfyui/workspace.spec.mjs b/tests/comfyui/workspace.spec.mjs index d860b65..56656ad 100644 --- a/tests/comfyui/workspace.spec.mjs +++ b/tests/comfyui/workspace.spec.mjs @@ -34,9 +34,10 @@ test('workspace serves real library data beside either ComfyUI renderer', async if (collection.images.length) { await expect(workspace.locator('article[data-image-key]').first()).toBeVisible(); } else { - await expect(workspace.getByText('No matching images or media. Try changing the filters.', { exact: true })).toBeVisible(); + await expect(workspace.getByText('No matching images · 0 images', { exact: true })).toBeVisible(); } - await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveAttribute('href', '/prompt_manager/gallery'); + // D29: Images no longer links the legacy gallery; Overview's tool cards do. + await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveCount(0); await workspace.reload(); await expect(workspace.locator('#workspace-main h1')).toHaveText('Images'); await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light'); diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js index e7bcbaf..8071811 100644 --- a/tests/js/workspace-images-state.test.js +++ b/tests/js/workspace-images-state.test.js @@ -183,3 +183,65 @@ test('custom collection pages reuse image-stream cancellation, cursors and ident assert.equal(await stream.loadMore(),1);assert.equal(calls[1].parameters.get('cursor'),'next');assert.equal(stream.state.loaded,2);stream.dispose(); assert.throws(()=>createImageStream({api:{get:async()=>{}},fetchPage:'invalid'}),/page/i); }); + +test('stream sends category, tags and the rating sort and validates them', async () => { + const {createImageStream}=await source;const calls=[]; + const stream=createImageStream({api:{get:async url=>{calls.push(new URL(url,'http://local').searchParams);return page([]);}}}); + await stream.reset({q:'cat',category:'Animals',tags:['cats','studio'],sort:'rating'}); + assert.equal(calls[0].get('category'),'Animals');assert.equal(calls[0].get('tags'),'cats,studio');assert.equal(calls[0].get('sort'),'rating'); + await stream.reset({category:'',tags:[]}); + assert.equal(calls[1].has('category'),false);assert.equal(calls[1].has('tags'),false); + for(const filters of [{category:3},{tags:'cats'},{tags:[1]},{tags:['a,b']},{tags:['']}]) await assert.rejects(stream.reset(filters),/filter/i); +}); + +test('positive count comes from the first page, survives plain cursor pages and hides when unknown', async () => { + const {createImageStream}=await source; + const replies=[{...page([image('a')],3,'one'),positive_count:2},page([image('b')],3,'two'),{...page([image('c')],3),positive_count:null}]; + const stream=createImageStream({api:{get:async()=>replies.shift()}}); + assert.equal(stream.state.positiveCount,null); + await stream.reset();assert.equal(stream.state.positiveCount,2); + await stream.loadMore();assert.equal(stream.state.positiveCount,2); + await stream.loadMore();assert.equal(stream.state.positiveCount,2); + const failed=createImageStream({api:{get:async()=>({...page([image('a')]),positive_count:null})}}); + await failed.reset();assert.equal(failed.state.positiveCount,null); + const absent=createImageStream({api:{get:async()=>page([image('a')])}}); + await absent.reset();assert.equal(absent.state.positiveCount,null); + for(const positive_count of [-1,'2',1.5]) { + const invalid=createImageStream({api:{get:async()=>({...page([image('a')]),positive_count})}}); + await invalid.reset();assert.match(invalid.state.error.message,/page/i); + } + const restored=createImageStream({api:{get(){assert.fail('unexpected request');}},restore:{filters:{},state:{...stream.state}}}); + assert.equal(restored.state.positiveCount,2); + assert.throws(()=>createImageStream({api:{get(){}},restore:{filters:{},state:{...stream.state,positiveCount:-3}}}),TypeError); +}); + +test('the count line reads N of M images and K positive prompts only when known', async () => { + const {countLine}=await source; + assert.equal(countLine({loaded:40,total:102,positiveCount:2}),'40 of 102 images · 2 positive prompts'); + assert.equal(countLine({loaded:1,total:1,positiveCount:1}),'1 of 1 image · 1 positive prompt'); + assert.equal(countLine({loaded:2,total:2,positiveCount:null}),'2 of 2 images'); + assert.equal(countLine({loaded:1200,total:3400,positiveCount:0}),'1,200 of 3,400 images · 0 positive prompts'); +}); + +test('the caption date names the newest result day under a date sort only', async () => { + const {captionDate}=await source; + const now=new Date(2026,9,17,15,0); + const at=(...parts)=>({modified_time:new Date(...parts).getTime()/1000}); + assert.equal(captionDate([at(2026,9,17,9),at(2026,9,10)],'newest',now),'Today · October 17'); + assert.equal(captionDate([at(2026,9,10),at(2026,9,16,23)],'oldest',now),'Yesterday · October 16'); + assert.equal(captionDate([at(2026,9,13)],'newest',now),'Tuesday · October 13'); + assert.equal(captionDate([at(2025,2,3)],'newest',now),'Monday · March 3, 2025'); + for(const sort of ['name','name-desc','rating']) assert.equal(captionDate([at(2026,9,17)],sort,now),''); + assert.equal(captionDate([],'newest',now),''); + assert.equal(captionDate([{modified_time:'bad'}],'newest',now),''); +}); + +test('the scope chip summarizes source and folder', async () => { + const {scopeLabel}=await source; + const roots=[{index:0,label:'output'},{index:1,label:'input'}]; + assert.equal(scopeLabel({root:'all',subfolder:''},roots),'All output folders'); + assert.equal(scopeLabel({root:'1',subfolder:'shared'},roots),'input · shared'); + assert.equal(scopeLabel({root:'0',subfolder:''},roots),'output · All folders'); + assert.equal(scopeLabel({root:'all',subfolder:'bulk'},roots),'All sources · bulk'); + assert.equal(scopeLabel({root:'7',subfolder:''},roots),'Source 7 · All folders'); +}); diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs index 25cfdfe..2489800 100644 --- a/web/js/workspace/images-state.mjs +++ b/web/js/workspace/images-state.mjs @@ -1,15 +1,29 @@ +import { noun } from './plural.mjs'; + /** Identity is the source and relative path; content hashes may be shared by files. */ export function imageKey(image) { return `${image.root_index}:${image.path}`; } +/** The workspace listing's sorts: the concept's Recently added, Name A–Z and Highest rated, plus Oldest and Z–A (D14). */ +export const IMAGE_SORTS = Object.freeze(['newest', 'oldest', 'name', 'name-desc', 'rating']); +const DATE_SORTS = new Set(['newest', 'oldest']); + +const isCount = value => Number.isSafeInteger(value) && value >= 0; + function queryFor(filters, batchSize) { const query = new URLSearchParams({ mode: 'workspace', limit: String(batchSize) }); - for (const key of ['q', 'subfolder']) { + for (const key of ['q', 'subfolder', 'category']) { const value = filters[key]; if (value !== undefined && typeof value !== 'string') throw new TypeError('Invalid image filter'); if (value) query.set(key, value); } + const tags = filters.tags ?? []; + // The listing takes a comma list that must all match, so a tag can neither be empty nor hold a comma. + if (!Array.isArray(tags) || tags.some(tag => typeof tag !== 'string' || !tag.trim() || tag.includes(','))) { + throw new TypeError('Invalid tags filter'); + } + if (tags.length) query.set('tags', tags.map(tag => tag.trim()).join(',')); const root = filters.root; if (root !== undefined && root !== 'all' && root !== '') { if (!/^(0|[1-9]\d*)$/.test(String(root)) || !Number.isSafeInteger(Number(root))) { @@ -21,7 +35,7 @@ function queryFor(filters, batchSize) { if (!['all', 'image', 'video'].includes(media)) throw new TypeError('Invalid media filter'); if (media !== 'all') query.set('media', media); const sort = filters.sort ?? 'newest'; - if (!['newest', 'oldest', 'name', 'name-desc'].includes(sort)) throw new TypeError('Invalid sort filter'); + if (!IMAGE_SORTS.includes(sort)) throw new TypeError('Invalid sort filter'); query.set('sort', sort); return query.toString(); } @@ -30,7 +44,8 @@ function validatePage(page, cursor) { if (!page || !Array.isArray(page.images) || !Number.isSafeInteger(page.total) || page.total < 0 || typeof page.has_more !== 'boolean' || !(page.next_cursor === null || (typeof page.next_cursor === 'string' && page.next_cursor.length > 0)) || - page.has_more !== (page.next_cursor !== null)) { + page.has_more !== (page.next_cursor !== null) || + !(page.positive_count === undefined || page.positive_count === null || isCount(page.positive_count))) { throw new TypeError('Invalid image page'); } if (page.has_more && page.next_cursor === cursor) throw new TypeError('Image cursor did not advance'); @@ -50,8 +65,9 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 200) { throw new TypeError('Invalid image batch size'); } + // positiveCount: distinct linked positives over the whole matching set, or null while unknown (PR B contract). let state = Object.freeze({ items: Object.freeze([]), total: 0, cursor: null, - hasMore: true, loading: false, error: null, loaded: 0 }); + hasMore: true, loading: false, error: null, loaded: 0, positiveCount: null }); let query = queryFor({}, batchSize); let controller = null; let disposed = false; @@ -65,13 +81,13 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re const pristine = Array.isArray(saved.items) && saved.items.length === 0 && saved.total === 0 && saved.cursor === null && saved.hasMore === true; validatePage({ images: saved.items, total: saved.total, next_cursor: saved.cursor, - has_more: pristine ? false : saved.hasMore }); + has_more: pristine ? false : saved.hasMore, positive_count: saved.positiveCount }); seen = new Set(saved.items.map(imageKey)); if (seen.size !== saved.items.length) throw new TypeError('Duplicate restored image identity'); const items = Object.isFrozen(saved.items) && saved.items.every(Object.isFrozen) ? saved.items : Object.freeze(saved.items.map(item => Object.freeze({ ...item }))); state = Object.freeze({ items, total: saved.total, cursor: saved.cursor, hasMore: saved.hasMore, - loading: false, error: null, loaded: items.length }); + loading: false, error: null, loaded: items.length, positiveCount: saved.positiveCount ?? null }); } function publish(update) { @@ -101,9 +117,12 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re } for (const key of batchKeys) seen.add(key); const items = incoming.length ? Object.freeze([...state.items, ...incoming]) : state.items; + // A plain cursor page leaves the count out and a failed count read is null: a later page keeps the + // first page's count, and a first page without one leaves it unknown. + const positiveCount = isCount(page.positive_count) ? page.positive_count : cursor === null ? null : state.positiveCount; controller = null; publish({ items, total: page.total, cursor: page.next_cursor, hasMore: page.has_more, - loading: false, error: null, loaded: items.length }); + loading: false, error: null, loaded: items.length, positiveCount }); return incoming.length; } catch (error) { if (!disposed && request === controller) { @@ -122,7 +141,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re query = nextQuery; seen = new Set(); publish({ items: Object.freeze([]), total: 0, cursor: null, hasMore: true, - loading: false, error: null, loaded: 0 }); + loading: false, error: null, loaded: 0, positiveCount: null }); return load ? loadMore() : 0; } @@ -138,3 +157,35 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re }, }; } + +/** The concept's count line (app.js a446): "N of M images · K positive prompts", without the count while unknown. */ +export function countLine({ loaded, total, positiveCount }) { + const images = `${loaded.toLocaleString('en-US')} of ${total.toLocaleString('en-US')} ${noun(total, 'image')}`; + return isCount(positiveCount) ? `${images} · ${positiveCount.toLocaleString('en-US')} ${noun(positiveCount, 'positive prompt')}` : images; +} + +const DAY_MS = 86400000; +const startOfDay = date => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + +/** + * D17: the caption's right-hand date, " · " of the newest loaded result under a + * date sort, and '' under name or rating sorts. A day outside the current year adds the year so it stays unambiguous. + */ +export function captionDate(items, sort, now = new Date()) { + if (!DATE_SORTS.has(sort)) return ''; + const newest = items.reduce((latest, item) => Number.isFinite(item.modified_time) ? Math.max(latest, item.modified_time) : latest, -Infinity); + if (!Number.isFinite(newest)) return ''; + const date = new Date(newest * 1000); + const days = Math.round((startOfDay(now) - startOfDay(date)) / DAY_MS); + const day = days === 0 ? 'Today' : days === 1 ? 'Yesterday' : date.toLocaleDateString('en-US', { weekday: 'long' }); + const options = { month: 'long', day: 'numeric', ...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }) }; + return `${day} · ${date.toLocaleDateString('en-US', options)}`; +} + +/** D14: the filter row's scope chip, "All output folders" or " · ". */ +export function scopeLabel({ root = 'all', subfolder = '' }, roots = []) { + if ((root === 'all' || root === '') && !subfolder) return 'All output folders'; + const source = root === 'all' || root === '' ? 'All sources' : + roots.find(item => String(item.index) === String(root))?.label ?? `Source ${root}`; + return `${source} · ${subfolder || 'All folders'}`; +} diff --git a/web/js/workspace/images.css b/web/js/workspace/images.css index 3cff41b..f5923bf 100644 --- a/web/js/workspace/images.css +++ b/web/js/workspace/images.css @@ -1,24 +1,4 @@ -.image-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; } -.image-filters .ca-field { display: flex; flex: 1 1 130px; min-inline-size: 0; flex-direction: column; align-items: stretch; gap: 5px; } -.image-filters .ca-field:first-child { flex: 3 1 260px; } -.image-filters .ca-field-label { font-size: 10px; color: var(--ca-muted); } -.ca-tools .image-filters :where(input, select) { inline-size: 100%; min-inline-size: 0; block-size: 36px; min-block-size: 36px; font-size: 11px; } -.image-view-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-block: 14px 22px; } -.ca-tools .image-view-controls button { min-block-size: 36px; font-size: 11px; } -.image-view-controls > .workspace-link { gap: 7px; min-block-size: 36px; font-size: 11px; margin-inline-start: auto; } -.image-view-controls .ca-field { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-inline-size: 0; } -.image-view-controls .ca-field-label { font-size: 10px; color: var(--ca-muted); } -.ca-tools .image-view-controls select { min-block-size: 36px; font-size: 11px; } -.image-toolbar { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; margin-block: 0 13px; } -.image-toolbar h2 { font-size: 14px; font-weight: 600; margin: 0; } -.image-toolbar p { margin: 0; font-size: 11px; } -.image-layouts { display: inline-flex; flex-wrap: wrap; gap: 3px; } -.image-layouts button { display: inline-flex; align-items: center; gap: 6px; } -.image-layouts .icon { inline-size: 14px; block-size: 14px; } -.ca-tools .image-layouts button[aria-pressed="true"] { background: var(--ca-accent); color: white; border-color: var(--ca-accent); } .images-main { container: images-page / inline-size; } -.image-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 310px; gap: 22px; align-items: start; } -.image-collection { min-inline-size: 0; } .image-grid { position: relative; inline-size: 100%; } .image-card { position: absolute; min-inline-size: 0; border: 1px solid var(--ca-line); border-radius: 7px; background: var(--ca-surface); display: grid; grid-template-rows: minmax(0, 1fr) var(--image-footer-height, 54px); } .image-card.selected { border-color: var(--ca-focus); outline: 1px solid var(--ca-focus); outline-offset: 0; } @@ -41,8 +21,6 @@ .image-facts dt { color: var(--ca-muted); margin-block-end: 4px; } .image-facts dd { margin: 0; overflow-wrap: anywhere; } .image-details .workspace-link { display: flex; font-size: 11px; } -.image-continuation { text-align: center; padding-block: 24px; font-size: 12px; } -.image-continuation [role="alert"] { color: var(--ca-error-text); } .image-preview { box-sizing: border-box; background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-border); border-radius: 9px; inline-size: min(1500px, 96%); block-size: min(940px, 94dvh); max-block-size: 94%; padding: 18px; overflow: hidden; overscroll-behavior: contain; } .image-preview[open] { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 20px; } .image-preview::backdrop { background: #080b12b8; overflow: hidden; overscroll-behavior: contain; } @@ -68,22 +46,14 @@ .image-preview-details { min-inline-size: 0; min-block-size: 0; overflow: auto; overscroll-behavior: contain; padding: 0 6px 18px; } .image-preview :where(button, a, .image-preview-stage):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: -2px; } @media (max-width: 1250px) { - .image-workspace { grid-template-columns: minmax(0, 1fr) 275px; gap: 15px; } .image-preview-body { grid-template-columns: minmax(0, 1fr) 300px; } } @media (max-width: 950px) { - .image-workspace { grid-template-columns: minmax(0, 1fr) 280px; } .image-preview-body { grid-template-columns: minmax(0, 1fr) 270px; } } @media (max-width: 700px) { - .image-workspace { grid-template-columns: minmax(0, 1fr); } .image-details { position: static; max-block-size: none; padding: 17px; } .image-details > :where(img, video) { block-size: 200px; } - .image-filters { gap: 8px; } - .image-filters .ca-field { flex-basis: 140px; } - .image-filters .ca-field:first-child { flex-basis: 100%; } - .image-view-controls { gap: 8px; } - .image-view-controls > .workspace-link { margin-inline-start: 0; } .image-preview { block-size: 94dvh; overflow: auto; } .image-preview[open] { grid-template-rows: auto auto; } .image-preview-body { grid-template-columns: minmax(0, 1fr); } @@ -95,7 +65,6 @@ .image-workspace { grid-template-columns: minmax(0, 1fr); } .image-details { position: static; max-block-size: none; } } -.image-continuation button[aria-disabled="true"] { opacity: .55; cursor: wait; } .options-status { font-size: 12px; padding-inline: 4px; } .prompt-inspector { margin-block-start: 22px; min-inline-size: 0; } diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 8ce578d..cbab9c8 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -1,5 +1,5 @@ -import { createScope, createButton, createField, createSelect } from '../shared/toolkit/core.js'; -import { createImageStream, imageKey } from './images-state.mjs'; +import { createScope, createButton } from '../shared/toolkit/core.js'; +import { createImageStream, imageKey, countLine, captionDate, scopeLabel } from './images-state.mjs'; import { layoutImages, visibleImages, workspaceImageMetrics } from './image-layout.mjs'; import { mountFileInspector } from './file-inspector.mjs'; import { createInspectionController } from './inspection-state.mjs'; @@ -9,6 +9,9 @@ import { createWorkspaceIcon } from './icons.mjs'; import { createMediaViewer } from './media-viewer.mjs'; import { noun } from './plural.mjs'; import { pageHeading } from './heading.mjs'; +import { mountToolbar } from './toolbar.mjs'; +import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs'; +import { showWorkspaceToast } from './library-editor.mjs'; function el(tag, text, className) { const element = document.createElement(tag); @@ -21,6 +24,26 @@ function formatBytes(value) { return value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`; } +function selectOf(id, options, value) { + const select = el('select'); + select.id = id; + select.replaceChildren(...options.map(([optionValue, label]) => { + const node = el('option', label); + node.value = optionValue; + return node; + })); + select.value = value; + return select; +} + +// The concept's three sorts first, then the production ones it lacks (D14); values are the listing's sorts (B3). +const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'], + ['oldest', 'Oldest first'], ['name-desc', 'Name Z–A']]); +const MEDIA_OPTIONS = Object.freeze([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']]); +const SOURCES_FAILED = 'Sources and folders could not be loaded. Rescan from Import to retry.'; +const CATEGORIES_FAILED = 'Categories could not be loaded. Rescan from Import to retry.'; +const RESCAN_FAILED = 'Output folders could not be rescanned. Try Rescan again.'; + let lastVisit = null; export function mountImages(main, api, parentScope, {embedded = false, fetchPage} = {}) { @@ -40,38 +63,48 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage let density = visit?.density || 'comfortable'; let metrics = workspaceImageMetrics(window.innerWidth, density); let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer; - let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, recovering = false; + let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false; + // Discovered sources and folders for "Refine this view" and the scope chip. + let roots = [], folders = []; const rendered = new Map(); const itemIndexes = new Map(); - const filters = { root: 'all', q: '', subfolder: '', media: 'all', sort: 'newest', ...visit?.filters }; + const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters }; + filters.tags = [...filters.tags]; scope.cleanup(() => { - if (!embedded) lastVisit = state ? { filters: {...filters}, state, layout, density, selection, scrollY: window.scrollY } : null; + if (!embedded) lastVisit = state ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, scrollY: window.scrollY } : null; cancelAnimationFrame(restoreFrame); }); main.classList.add('images-main'); scope.cleanup(() => main.classList.remove('images-main')); - const description = embedded ? null : pageHeading(main)?.description; - if (description) description.textContent = 'Browse your images, explore the details, keep your creative context.'; - const filtersBar = el('div', undefined, 'image-filters'); - const search = el('input'); - search.value = filters.q; - search.type = 'search'; search.placeholder = 'Find an image by filename or folder…'; search.maxLength = 256; - const source = createSelect([['all', 'All sources']], ''); - const folder = createSelect([['', 'All folders']], ''); - const media = createSelect([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']], ''); - const sort = createSelect([['newest', 'Newest first'], ['oldest', 'Oldest first'], ['name', 'Filename A–Z'], ['name-desc', 'Filename Z–A']], ''); - media.value = filters.media; sort.value = filters.sort; - filtersBar.append(createField('Search filenames', search, 'image-search'), createField('Source', source, 'image-source'), - createField('Folder', folder, 'image-folder'), createField('Media', media, 'image-media'), createField('Sort', sort, 'image-sort')); + // The concept's Images heading (app.js renderImages): description, then Import before the shell's New prompt. + const heading = embedded ? null : pageHeading(main); + let importButton = null; + if (heading) { + heading.description.textContent = 'Your generations, their prompts, and the next idea.'; + importButton = createButton(''); + importButton.append(createWorkspaceIcon('upload'), el('span', 'Import')); + heading.actions.prepend(importButton); + scope.cleanup(() => importButton.remove()); + scope.listen(importButton, 'click', () => openImportDialog({scope, trigger:importButton, + onInspect:() => { window.location.hash = '#/inspect'; }, onRescan:rescan})); + } + // The shared concept toolbar: search (filename, linked prompt text, title and tags), category and sort (D12–D14). + const toolbar = embedded ? null : mountToolbar(main, {scope, categories:filters.category ? [filters.category] : [], + sorts:IMAGE_SORT_OPTIONS, value:{q:filters.q, category:filters.category, sort:filters.sort}, + onChange:value => { + if (recovering) return; + Object.assign(filters, {q:value.q.trim(), category:value.category, sort:value.sort}); + reset(); + }, onFilters:openFilters}); + const toolbarControls = toolbar ? [...toolbar.element.querySelectorAll('input, select, button')] : []; const optionsStatus = el('p', '', 'options-status muted'); optionsStatus.hidden = true; optionsStatus.setAttribute('role', 'alert'); - if (!embedded) main.append(filtersBar, optionsStatus); - const controls = el('div', undefined, 'image-view-controls'); - const toolbar = el('div', undefined, 'image-toolbar'); - const collectionHeading = el('h2', embedded ? 'Linked images' : 'All images');collectionHeading.tabIndex = -1; - toolbar.append(collectionHeading); - const count = el('p', 'Loading images…', 'muted'); - const layouts = el('div', undefined, 'image-layouts'); + const categoryStatus = el('p', '', 'options-status muted'); + categoryStatus.hidden = true; categoryStatus.setAttribute('role', 'alert'); + if (!embedded) main.append(optionsStatus, categoryStatus); + // The concept's filter row: layouts, thumbnail size, the scope chip and Group by positive prompt (D14, D15). + const filterRow = el('div', undefined, 'filter-row'); + const layouts = el('div', undefined, 'segmented'); layouts.setAttribute('role', 'group'); layouts.setAttribute('aria-label', 'Image layout'); for (const mode of ['grid', 'list', 'masonry']) { const button = createButton(mode[0].toUpperCase() + mode.slice(1)); @@ -84,50 +117,42 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage }); layouts.append(button); } - const refresh = createButton('Refresh'); - scope.listen(refresh, 'click', async () => { - if (refresh.disabled) return; - refresh.disabled = true; - optionsRequest?.abort(); optionsRequest = null; - optionsStatus.hidden = true; - try { - await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal}); - if (scope.signal.aborted) return; - if (await loadOptions() && !scope.signal.aborted) reset(); - } catch { - if (scope.signal.aborted) return; - optionsStatus.textContent = 'Images could not be refreshed. Try Refresh again.'; - optionsStatus.hidden = false; - } finally { - refresh.disabled = recovering; - } - }); - const currentGallery = el('a', 'Current gallery', 'workspace-link'); - currentGallery.href = resolveUrl('/prompt_manager/gallery'); - const thumbnails = createSelect([['comfortable', 'Comfortable'], ['large', 'Large'], ['compact', 'Compact']], ''); - thumbnails.value = density; + const sizeLabel = el('label', 'Thumbnail size', 'small'); + sizeLabel.htmlFor = 'image-size'; + const thumbnails = selectOf('image-size', [['comfortable', 'Comfortable'], ['large', 'Large'], ['compact', 'Compact']], density); scope.listen(thumbnails, 'change', () => { density = thumbnails.value; updateLayout(); }); - controls.append(layouts, createField('Thumbnail size', thumbnails, 'image-density')); + filterRow.append(layouts, sizeLabel, thumbnails); + const scopeText = document.createTextNode(''); if (!embedded) { - const group = el('a', 'Group by positive prompt', 'workspace-link'); - group.href = '#/library'; group.prepend(createWorkspaceIcon('branch')); - controls.append(group, refresh); + const chip = el('span', undefined, 'chip'); + chip.append(createWorkspaceIcon('folder'), scopeText); + const group = createButton('Group by positive prompt →', 'chip'); + scope.listen(group, 'click', () => { window.location.hash = '#/library'; }); + filterRow.append(chip, group); } - main.append(controls); - toolbar.append(count); - const body = el('div', undefined, 'image-workspace'); - const collection = el('section', undefined, 'image-collection'); + main.append(filterRow); + const body = el('div', undefined, 'gallery-layout image-workspace'); + const collection = el('section', undefined, 'gallery-body'); collection.setAttribute('aria-label', 'Images'); + // The concept's caption replaces the old "All images" heading; the embedded gallery keeps its own heading. + const caption = el('div', undefined, 'section-caption'); + const collectionHeading = embedded ? el('h2', 'Linked images') : null; + if (collectionHeading) { collectionHeading.tabIndex = -1; caption.append(collectionHeading); } + const count = el('span', 'Loading images…'); + if (!embedded) count.id = 'result-count'; + const captionDay = el('span'); + captionDay.hidden = true; + caption.append(count); + if (!embedded) caption.append(captionDay); + const focusFallback = collectionHeading || heading?.h1 || main; const grid = el('div', undefined, 'image-grid'); grid.setAttribute('role', 'list'); grid.setAttribute('aria-label', 'Image collection'); - const continuation = el('div', undefined, 'image-continuation'); - const message = el('p'); + const continuation = el('div', undefined, 'infinite-continuation'); + const message = el('p', '', 'small'); const more = createButton('Load more images'); const retry = createButton('Retry'); - const end = el('p', '', 'muted'); - continuation.append(message, more, retry, end); - collection.append(toolbar, grid, continuation); - if (!embedded) collection.append(currentGallery); + continuation.append(message, more, retry); + collection.append(caption, grid, continuation); const details = el('section', undefined, 'image-details panel'); details.setAttribute('aria-label', 'Image details'); details.append(el('h2', 'Image details'), el('p', 'Select an image to inspect it.')); @@ -137,7 +162,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);}, onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onRestoreFocus:image => { const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image)); - if (index >= 0) focusImage(index);else collectionHeading.focus({preventScroll:true}); + if (index >= 0) focusImage(index);else focusFallback.focus({preventScroll:true}); }}); scope.cleanup(() => mediaViewer.dispose()); @@ -315,21 +340,39 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (!resizeFrame) resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; updateLayout(); }); }); scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(resizeFrame); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); }); + function continuationText(next) { + if (recovering) return 'Images paused until sources and folders are available.'; + if (next.error) return 'Images could not be loaded. Retry or rescan the collection.'; + if (next.loading && !next.loaded) return 'Loading images…'; + if (next.hasMore) return `${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')} · more load as you scroll`; + if (embedded) return next.loaded ? 'You’re all caught up.' : 'No linked media available for this selection.'; + // The concept's end of the collection (app.js updateImageContinuation). + return `${next.loaded ? 'You’re all caught up' : 'No matching images'} · ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')}`; + } function renderState(next) { + const previous = state; const changed = state?.items !== next.items; if (changed) { if (!next.items.length) itemIndexes.clear(); for (let index = state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index); } state = next; - count.textContent = embedded ? `${next.loaded.toLocaleString()} loaded · ${next.total.toLocaleString()} ${noun(next.total, 'linked image')}` : `${next.loaded.toLocaleString()} of ${next.total.toLocaleString()} ${noun(next.total, 'image')}`; + count.textContent = embedded ? `${next.loaded.toLocaleString()} loaded · ${next.total.toLocaleString()} ${noun(next.total, 'linked image')}` : countLine(next); + if (!embedded) { + captionDay.textContent = captionDate(next.items, filters.sort); + captionDay.hidden = !captionDay.textContent; + } if (changed) updateLayout(); - message.textContent = recovering ? 'Images paused until sources and folders are available.' : next.error ? 'Images could not be loaded. Retry or refresh the collection.' : next.loading ? 'Loading images…' : ''; + // The concept's Load more toast (app.js loadMoreImages) for every batch appended to a loaded collection. + if (!embedded && changed && previous?.loaded && next.loaded > previous.loaded) { + const added = next.loaded - previous.loaded; + showWorkspaceToast(`${added.toLocaleString('en-US')} more ${noun(added, 'image')} loaded. ${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')}.`); + } + message.textContent = continuationText(next); if (next.error) message.setAttribute('role', 'alert'); else message.setAttribute('role', 'status'); retry.hidden = !next.error; more.hidden = !next.hasMore || !!next.error; more.setAttribute('aria-disabled', String(next.loading || recovering)); - end.textContent = !recovering && !next.loading && !next.error && !next.hasMore ? (next.loaded ? 'You’re all caught up.' : embedded ? 'No linked media available for this selection.' : 'No matching images or media. Try changing the filters.') : ''; if (!selection && next.items.length) inspect(next.items[0]); scheduleLoad(); } @@ -354,6 +397,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage }, { rootMargin: '250px' }); observer.observe(continuation); } + function updateScope() { + scopeText.textContent = ` ${scopeLabel(filters, roots)}`; + } function reset() { mediaViewer.stopSlideshow(); main.querySelector('.image-preview[open]')?.close(); @@ -364,55 +410,110 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage } selection = null; for (const video of details.querySelectorAll('video')) releaseVideo(video); + updateScope(); stream.reset(filters, {load:!recovering}); } + /** "Refine this view": Source, Folder, Media and Tags, the filters the old field row held (D14). */ + function openFilters(trigger) { + if (recovering) return; + openRefineDialog({scope, trigger, fields:[ + {key:'source', options:[['all', 'All sources'], ...roots.map(root => [String(root.index), root.label])]}, + {key:'folder', options:[['', 'All folders'], ...folders.map(path => [path, path])]}, + {key:'media', options:MEDIA_OPTIONS}, + {key:'tags'}, + ], values:{source:filters.root, folder:filters.subfolder, media:filters.media, tags:filters.tags.join(', ')}, + onApply:applyRefinement, onClear:cleared => { + toolbar.setValue({category:cleared.category}); + filters.category = cleared.category; + applyRefinement(cleared); + }}); + } + function applyRefinement({source, folder, media, tags}) { + if (recovering) return; + Object.assign(filters, {root:source, subfolder:folder, media, + tags:[...new Set(tags.split(',').map(tag => tag.trim()).filter(Boolean))]}); + reset(); + } + function setControlsDisabled(disabled) { + for (const control of [...toolbarControls, more, retry]) control.disabled = disabled; + if (importButton) importButton.disabled = disabled; + } + /** Import → "Scan an output folder" (D15): rescan the output roots, then reload the options and the listing. */ + async function rescan() { + if (rescanning || recovering || scope.signal.aborted) return; + rescanning = true; + optionsRequest?.abort(); optionsRequest = null; + optionsStatus.hidden = true; + try { + await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal}); + if (scope.signal.aborted) return; + loadCategories(); + if (await loadOptions() && !scope.signal.aborted) reset(); + } catch { + if (scope.signal.aborted) return; + optionsStatus.textContent = RESCAN_FAILED; + optionsStatus.hidden = false; + } finally { + rescanning = false; + } + } async function refreshInspectionCollection() { if (scope.signal.aborted) return; // Numeric roots may now name another source; retire the old stream first. recovering = true; - filters.root = 'all';filters.subfolder = '';filters.q = search.value.trim(); - source.replaceChildren(new Option('All sources', 'all'));folder.replaceChildren(new Option('All folders', '')); - for (const control of [search,source,folder,media,sort,refresh,more,retry]) control.disabled = true; + filters.root = 'all';filters.subfolder = ''; + if (toolbar) filters.q = toolbar.value().q.trim(); + roots = [];folders = []; + setControlsDisabled(true); optionsStatus.hidden = true; reset(); const discovery = loadOptions(), request = optionsRequest; if (!await discovery || scope.signal.aborted || request !== optionsRequest) return; recovering = false; - for (const control of [search,source,folder,media,sort,refresh,more,retry]) control.disabled = false; + setControlsDisabled(false); reset(); } async function loadOptions() { optionsRequest?.abort(); const request = new AbortController(); optionsRequest = request; try { + // Every source's folders (with ancestors), so any Source and Folder pair can be chosen in one dialog. const params = new URLSearchParams({mode:'workspace', include_ancestors:'true'}); - if (filters.root !== 'all') params.set('root', filters.root); const data = await api.get(`/prompt_manager/gallery/subfolders?${params}`, {signal:request.signal}); if (scope.signal.aborted || request !== optionsRequest) return false; - source.replaceChildren(new Option('All sources', 'all'), ...data.roots.map(root => new Option(root.label, String(root.index)))); - source.value = filters.root; - folder.replaceChildren(new Option('All folders', ''), ...data.subfolders.map(path => new Option(path, path))); - folder.value = filters.subfolder; + if (!Array.isArray(data?.roots) || !Array.isArray(data.subfolders)) throw new TypeError('Invalid image sources'); + roots = data.roots.filter(root => Number.isSafeInteger(root?.index) && typeof root.label === 'string'); + folders = data.subfolders.filter(path => typeof path === 'string' && path); + updateScope(); optionsStatus.hidden = true; return true; } catch { if (scope.signal.aborted || request !== optionsRequest) return false; - optionsStatus.textContent = recovering ? 'Sources and folders could not be loaded. Refresh image collection to retry.' : 'Sources and folders could not be loaded. Refresh to retry.'; + optionsStatus.textContent = recovering ? 'Sources and folders could not be loaded. Refresh image collection to retry.' : SOURCES_FAILED; optionsStatus.hidden = false; return false; } } - scope.cleanup(() => optionsRequest?.abort()); - scope.listen(search, 'input', scope.debounce(() => { if (!recovering) { filters.q = search.value.trim(); reset(); } }, 200)); - for (const [input, key] of [[source, 'root'], [folder, 'subfolder'], [media, 'media'], [sort, 'sort']]) { - scope.listen(input, 'change', () => { - if (recovering) return; - filters[key] = input.value; - if (key === 'root') { filters.subfolder = ''; folder.value = ''; loadOptions(); } - reset(); - }); + /** The toolbar's categories are the Library's (the linked positive's category, D13). */ + async function loadCategories() { + if (!toolbar) return; + categoryRequest?.abort(); + const request = new AbortController(); categoryRequest = request; + try { + const data = await api.get('/prompt_manager/library/options', {signal:request.signal}); + if (scope.signal.aborted || request !== categoryRequest) return; + if (!Array.isArray(data?.categories)) throw new TypeError('Invalid categories'); + toolbar.setCategories([...data.categories.filter(value => typeof value === 'string'), filters.category]); + categoryStatus.hidden = true; + } catch { + if (scope.signal.aborted || request !== categoryRequest) return; + categoryStatus.textContent = CATEGORIES_FAILED; + categoryStatus.hidden = false; + } } - if (!embedded) loadOptions(); + scope.cleanup(() => { optionsRequest?.abort(); categoryRequest?.abort(); }); + updateScope(); + if (!embedded) { loadOptions(); loadCategories(); } if (visit) { renderState(stream.state); const selected = stream.state.items.find(image => imageKey(image) === selection); diff --git a/web/js/workspace/toolbar.mjs b/web/js/workspace/toolbar.mjs index 8fb1fa1..d1cd2b5 100644 --- a/web/js/workspace/toolbar.mjs +++ b/web/js/workspace/toolbar.mjs @@ -88,12 +88,12 @@ export function mountToolbar(container, { scope, categories = [], sorts, value = function setValue(next) { if (isText(next?.q)) search.value = next.q; if (isText(next?.category)) category.value = next.category; - if (isText(next?.sort)) sort.value = sort.options.some(node => node.value === next.sort) ? next.sort : sort.options[0].value; + if (isText(next?.sort)) sort.value = [...sort.options].some(node => node.value === next.sort) ? next.sort : sort.options[0].value; } function setCategories(next) { const selected = category.value; category.replaceChildren(...categoryOptions(next)); - category.value = category.options.some(node => node.value === selected) ? selected : ''; + category.value = [...category.options].some(node => node.value === selected) ? selected : ''; } setValue(value); diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index d888b57..fb6e94c 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -54,6 +54,15 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } .vocabulary-main td .row-actions { width: min-content; margin-inline-start: auto; } } +/* Images (#249 C2). Production-only continuation states the concept never shows: a failed page and a load in flight. */ +.infinite-continuation [role="alert"] { color: var(--ca-error-text); } +.infinite-continuation button[aria-disabled="true"] { opacity: .55; cursor: wait; } +/* #249 D14: the scope chip names real sources and folders, which can be far longer than "All output folders". */ +.filter-row span.chip { max-width: 100%; min-width: 0; overflow-wrap: anywhere; } +/* #249 D31: at extreme zoom the 390px shell is narrower than the concept's three layout buttons; they wrap instead of + scrolling the page sideways (they never wrap while they fit). */ +.segmented { flex-wrap: wrap; max-width: 100%; } + /* Interim page guards: concept element rules (`.ca-tools dialog`, `pre`) now reach production dialogs and prompt text that keep their own design until their page PR rebuilds them in concept markup. */ .panel { background: var(--ca-surface); border: 1px solid var(--line); } /* PR D: Images side panel (padding and radius in images.css) becomes `.detail-panel` */