diff --git a/ui/e2e/smoke.spec.ts b/ui/e2e/smoke.spec.ts index 7e331f7..0f3f84b 100644 --- a/ui/e2e/smoke.spec.ts +++ b/ui/e2e/smoke.spec.ts @@ -147,8 +147,11 @@ test('editor validates, saves into a new folder, and deletes', async ({ await expect( page.getByRole('heading', { name: 'e2e-scratch/E2EScratch' }), ).toBeVisible() - page.once('dialog', (dialog) => dialog.accept()) await page.getByRole('button', { name: /delete this workflow/ }).click() + await page + .getByRole('alertdialog') + .getByRole('button', { name: 'Delete', exact: true }) + .click() await expect(page.getByRole('heading', { name: 'Workflows' })).toBeVisible() await expect(page.getByRole('link', { name: /E2EScratch/ })).toHaveCount(0) }) @@ -193,8 +196,11 @@ test('prompts page lists, creates at the root, and deletes', async ({ 'an e2e scratch prompt', { timeout: 15_000 }, ) - page.once('dialog', (dialog) => dialog.accept()) await page.getByRole('button', { name: /Delete/ }).click() + await page + .getByRole('alertdialog') + .getByRole('button', { name: 'Delete', exact: true }) + .click() await expect(page.getByRole('heading', { name: 'Prompts' })).toBeVisible() await expect( page.getByRole('link', { name: 'E2EScratchPrompt' }), diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 05d6414..acc25ce 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -22,6 +22,7 @@ import KeyboardHelp from './lib/KeyboardHelp.svelte' import StatusPopover from './lib/StatusPopover.svelte' import TokenPopover from './lib/TokenPopover.svelte' + import ConfirmDialog from './lib/ConfirmDialog.svelte' import WorkflowsPage from './lib/pages/WorkflowsPage.svelte' import WorkflowPage from './lib/pages/WorkflowPage.svelte' import JobsPage from './lib/pages/JobsPage.svelte' @@ -298,6 +299,7 @@ + diff --git a/ui/src/lib/ConfirmDialog.svelte b/ui/src/lib/ConfirmDialog.svelte new file mode 100644 index 0000000..ea3e9a5 --- /dev/null +++ b/ui/src/lib/ConfirmDialog.svelte @@ -0,0 +1,63 @@ + + + + +{#if confirmState.open} + + +{/if} + + diff --git a/ui/src/lib/confirm.svelte.ts b/ui/src/lib/confirm.svelte.ts new file mode 100644 index 0000000..3bd2e4e --- /dev/null +++ b/ui/src/lib/confirm.svelte.ts @@ -0,0 +1,42 @@ +// A window.confirm replacement that renders through the app's own modal +// styling instead of the browser chrome. One state object plus a single +// mounted in App.svelte, mirroring the notify()/Toaster +// pair in toast.ts - callers just `await confirmDialog(...)` in place of +// `window.confirm(...)`. + +type ConfirmState = { + open: boolean + message: string + confirmLabel: string + cancelLabel: string +} + +export const confirmState = $state({ + open: false, + message: '', + confirmLabel: 'OK', + cancelLabel: 'Cancel', +}) + +// At most one confirm is ever in flight - a second call while one is open +// would have no dialog to land in anyway, so it simply replaces the first. +let resolver: ((value: boolean) => void) | null = null + +export function confirmDialog( + message: string, + options?: { confirmLabel?: string; cancelLabel?: string }, +): Promise { + confirmState.open = true + confirmState.message = message + confirmState.confirmLabel = options?.confirmLabel ?? 'OK' + confirmState.cancelLabel = options?.cancelLabel ?? 'Cancel' + return new Promise((resolve) => { + resolver = resolve + }) +} + +export function resolveConfirm(value: boolean): void { + confirmState.open = false + resolver?.(value) + resolver = null +} diff --git a/ui/src/lib/pages/EditorPage.svelte b/ui/src/lib/pages/EditorPage.svelte index 5ab14a6..fb3f975 100644 --- a/ui/src/lib/pages/EditorPage.svelte +++ b/ui/src/lib/pages/EditorPage.svelte @@ -15,6 +15,7 @@ } from '@lucide/svelte' import { api } from '../api' import { notify } from '../toast' + import { confirmDialog } from '../confirm.svelte' import { go } from '../router.svelte' import { emptyWorkflow, @@ -132,10 +133,16 @@ '#/workflows/' + name.split('/').map(encodeURIComponent).join('/'), ) - // Leaving with unsaved edits used to drop them without a word - function confirmLeave(event: MouseEvent) { - if (dirty && !window.confirm('Discard unsaved changes?')) - event.preventDefault() + // Leaving with unsaved edits used to drop them without a word. The + // confirm is async, so the default navigation is always prevented first + // and replayed by hand once the answer comes back. + async function confirmLeave(event: MouseEvent) { + if (!dirty) return + event.preventDefault() + const target = (event.currentTarget as HTMLAnchorElement).href + if (await confirmDialog('Discard unsaved changes?')) { + window.location.href = target + } } const savePreview = $derived.by(() => { diff --git a/ui/src/lib/pages/GalleryPage.svelte b/ui/src/lib/pages/GalleryPage.svelte index 2ac8375..23ce63a 100644 --- a/ui/src/lib/pages/GalleryPage.svelte +++ b/ui/src/lib/pages/GalleryPage.svelte @@ -14,6 +14,7 @@ import { go } from '../router.svelte' import { SvelteSet } from 'svelte/reactivity' import { notify } from '../toast' + import { confirmDialog } from '../confirm.svelte' import type { GalleryFile } from '../types' import WorkspacePicker from '../WorkspacePicker.svelte' import { workspace } from '../workspace.svelte' @@ -29,6 +30,7 @@ let anchor = $state(null) let busy = $state(false) let metadata = $state | null>(null) + let metadataLoading = $state(false) let sourceJob = $state<{ id: string; status: string } | null>(null) $effect(() => { @@ -64,7 +66,9 @@ // choice to replace belongs to the person, not the button if ( message.includes('already exists') && - window.confirm(`${message}\n\nReplace it?`) + (await confirmDialog(`${message}\n\nReplace it?`, { + confirmLabel: 'Replace', + })) ) { try { const result = await api.keepOutput(selected.name, assetName, true) @@ -145,9 +149,10 @@ async function removePicked() { const names = pickedNames if ( - !window.confirm( + !(await confirmDialog( `Delete ${names.length} file${names.length === 1 ? '' : 's'}? This removes them on disk.`, - ) + { confirmLabel: 'Delete' }, + )) ) return busy = true @@ -179,19 +184,29 @@ function select(file: GalleryFile) { selected = file metadata = null + metadataLoading = true sourceJob = null - api.galleryMetadata(file.name).then((r) => { - if (selected?.name === file.name) { - metadata = r.metadata - sourceJob = r.job - } - }) + api + .galleryMetadata(file.name) + .then((r) => { + if (selected?.name === file.name) { + metadata = r.metadata + sourceJob = r.job + } + }) + .catch(() => {}) + .finally(() => { + if (selected?.name === file.name) metadataLoading = false + }) } async function removeFile() { if (!selected) return if ( - !window.confirm(`Delete ${selected.name}? This removes the file on disk.`) + !(await confirmDialog( + `Delete ${selected.name}? This removes the file on disk.`, + { confirmLabel: 'Delete' }, + )) ) return const name = selected.name @@ -346,7 +361,6 @@
{selected.name} - {mb(selected.size)} · {day(selected.mtime)} {#if embeddedWorkflow} +
{/if}
- {:else if selected.kind === 'image'} + {:else if selected.kind === 'image' && metadataLoading}
reading metadata…
+ {:else if selected.kind === 'image'} +
+ no embedded metadata - enable embed_metadata in the step's result +
{/if} diff --git a/ui/src/lib/pages/GalleryPage.test.ts b/ui/src/lib/pages/GalleryPage.test.ts index f1cc8f9..e4db962 100644 --- a/ui/src/lib/pages/GalleryPage.test.ts +++ b/ui/src/lib/pages/GalleryPage.test.ts @@ -1,9 +1,16 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/svelte' +import { + cleanup, + render, + screen, + waitFor, + within, +} from '@testing-library/svelte' import { afterEach, beforeEach, expect, it, vi } from 'vitest' // Hoisted above the imports so the static import of the component below - // itself hoisted - sees an initialized mock. Importing the component inside // the test instead would charge its (multi-second) compile to the test timeout import GalleryPage from './GalleryPage.svelte' +import ConfirmDialog from '../ConfirmDialog.svelte' import type { GalleryFile } from '../types' const file = (name: string): GalleryFile => ({ @@ -68,12 +75,26 @@ const checkbox = (name: string) => screen.getByRole('checkbox', { name: `select ${name}` }) async function renderGallery(first = 'a.png') { + // ConfirmDialog is normally mounted once in App.svelte and driven through + // the shared confirm.svelte.ts state - render it alongside so a test can + // answer the dialogs GalleryPage's delete/replace flows open. + render(ConfirmDialog) render(GalleryPage) await waitFor(() => expect(screen.getByLabelText(`select ${first}`)).toBeTruthy(), ) } +/** Answers the confirm dialog opened by a delete/replace action - scoped to + * the dialog itself, since its "Delete" button shares a name with whatever + * trigger button opened it. */ +async function answerConfirm(accept: boolean) { + const dialog = await waitFor(() => screen.getByRole('alertdialog')) + within(dialog) + .getByRole('button', { name: accept ? /^delete$/i : /^cancel$/i }) + .click() +} + it('fetches the gallery listing exactly once on mount', async () => { render(GalleryPage) // Let the request settle and any (wrongly) re-triggered effects run @@ -130,13 +151,13 @@ it('archives every selected file in one request', async () => { }) it('drops deleted files from the grid and the selection', async () => { - vi.stubGlobal('confirm', () => true) await renderGallery() checkbox('a.png').click() checkbox('b.png').click() await waitFor(() => expect(screen.getByText('2 selected')).toBeTruthy()) screen.getByRole('button', { name: /^delete/i }).click() + await answerConfirm(true) await waitFor(() => expect(screen.queryByLabelText('select a.png')).toBeNull(), @@ -147,7 +168,6 @@ it('drops deleted files from the grid and the selection', async () => { }) it('keeps a file that failed to delete selected and reports it', async () => { - vi.stubGlobal('confirm', () => true) deleteOutput.mockImplementation((name: string) => name === 'b.png' ? Promise.reject(new Error('busy')) : Promise.resolve(), ) @@ -157,6 +177,7 @@ it('keeps a file that failed to delete selected and reports it', async () => { checkbox('b.png').click() await waitFor(() => expect(screen.getByText('2 selected')).toBeTruthy()) screen.getByRole('button', { name: /^delete/i }).click() + await answerConfirm(true) await waitFor(() => expect(screen.getByText('1 selected')).toBeTruthy()) expect(screen.queryByLabelText('select a.png')).toBeNull() @@ -165,19 +186,18 @@ it('keeps a file that failed to delete selected and reports it', async () => { }) it('does not delete anything when the confirmation is declined', async () => { - vi.stubGlobal('confirm', () => false) await renderGallery() checkbox('a.png').click() await waitFor(() => expect(screen.getByText('1 selected')).toBeTruthy()) screen.getByRole('button', { name: /^delete/i }).click() + await answerConfirm(false) await new Promise((r) => setTimeout(r, 10)) expect(deleteOutput).not.toHaveBeenCalled() }) it('drops a file deleted from the detail panel out of the selection', async () => { - vi.stubGlobal('confirm', () => true) await renderGallery() checkbox('a.png').click() @@ -196,6 +216,7 @@ it('drops a file deleted from the detail panel out of the selection', async () = screen .getByRole('button', { name: 'delete this file from the output directory' }) .click() + await answerConfirm(true) await waitFor(() => expect(screen.getByText('1 selected')).toBeTruthy()) expect(screen.getByLabelText('select b.png')).toBeTruthy() diff --git a/ui/src/lib/pages/JobPage.svelte b/ui/src/lib/pages/JobPage.svelte index a2006cc..73ae051 100644 --- a/ui/src/lib/pages/JobPage.svelte +++ b/ui/src/lib/pages/JobPage.svelte @@ -5,6 +5,7 @@ import { groupResultFiles } from '../results' import { stepProgress } from '../progress' import FlowView from '../editor/FlowView.svelte' + import CopyButton from '../CopyButton.svelte' import type { JobDetail, JobEvent } from '../types' let { jobId }: { jobId: string } = $props() @@ -139,6 +140,8 @@ {#if job}

{job.workflow}

{job.status} + {job.id} + {#if cancelPending}