Compare commits

...

9 Commits

Author SHA1 Message Date
Simon Pinfold
782a8d6923 refactor(assets): use a labeled toggle switch for download previews
Replace the icon toggle button in the action row with a dedicated
"Download previews" switch on its own row below the actions (output tab
only). Uses PrimeVue ToggleSwitch, consistent with existing settings
switches; clearer affordance than a magic icon button.
2026-06-10 12:33:56 +12:00
Simon Pinfold
3789fbfeb8 test(assets): cover include-previews toggle and jobless export toast
Add AssetsSidebarTab component tests for the include-previews toggle:
shown on the generated tab and hidden on imported, forwards
includePreviews to the download action, and does not forward a stale
toggle after switching tabs (pins the canIncludePreviews call-site
guard). Also cover the jobless input export toast (file count) branch.
2026-06-09 11:22:01 +12:00
Simon Pinfold
65de230fff refactor(assets): make include-previews a toggle icon button
Replace the labeled checkbox with an icon toggle (the images icon):
clicking it flips the include-previews state rather than triggering a
download. Active state uses the primary variant; inactive uses
secondary. Keeps aria-pressed + tooltip for accessibility.
2026-06-09 10:25:54 +12:00
Simon Pinfold
6287837ff5 fix(assets): force ZIP export when previews are requested
Previews are only retrievable via the export endpoint, but the ZIP path
was gated on selection size / multi-output jobs. Selecting a single
single-output asset with previews requested fell through to the direct
download branch, silently dropping previews. Requesting previews now
forces the ZIP path regardless of selection size.
2026-06-09 10:25:54 +12:00
Simon Pinfold
eb18c85bb5 refactor(assets): make include-previews a checkbox
Replace the second "Download with previews" button with an "Include
previews" checkbox next to a single Download button (Generated tab only).
The checkbox collapses to a tooltipped box in compact mode and feeds its
state into the existing download action.
2026-06-09 10:25:54 +12:00
Simon Pinfold
3d3fdc50b7 fix(assets): report job count in non-previews export toast
The output file count includes preview/temp assets (the backend's
outputs_count mirrors OSS flatOutputs.length), so for a plain export
(outputs only) it over-counts. Report the number of jobs instead, which
is exact. The with-previews export keeps the file count, which already
matches the archive.
2026-06-09 10:25:54 +12:00
Simon Pinfold
2aff99d307 refactor(assets): show preview export as a second button
Replace the split download menu (which added an extra click) with two
side-by-side footer buttons: "Download" and "Download with previews".
The previews button appears on the Generated tab only and collapses to a
tooltipped icon in compact mode.
2026-06-09 10:25:54 +12:00
Simon Pinfold
4785682953 fix(assets): make export menu items keyboard accessible
Address review feedback: the split download menu options were
non-focusable divs. Use buttons with menu roles for native keyboard
activation, and wait for the menu option to be visible before clicking
it in the e2e helpers to avoid render-timing races.
2026-06-09 10:25:54 +12:00
Simon Pinfold
c10d21819c feat(assets): add "Download with previews" to bulk export
Preview/temp assets generated during a run (e.g. from PreviewImage nodes)
were never included in ZIP exports. The cloud export endpoint already
supports an include_previews flag; this wires it up on the frontend.

The Generated tab's multi-select "Download Selected" button becomes a
split menu offering "Download" and "Download with previews". The flag
only affects job-id expansion, so the option is shown on the output tab
only; the Imported tab keeps the plain download button.
2026-06-09 10:25:53 +12:00
8 changed files with 413 additions and 97 deletions

View File

@@ -318,6 +318,7 @@ export class AssetsSidebarTab extends SidebarTab {
public readonly deselectAllButton: Locator
public readonly deleteSelectedButton: Locator
public readonly downloadSelectedButton: Locator
public readonly includePreviewsToggle: Locator
// --- Folder view ---
public readonly backToAssetsButton: Locator
@@ -366,6 +367,7 @@ export class AssetsSidebarTab extends SidebarTab {
.getByTestId('assets-download-selected')
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
.first()
this.includePreviewsToggle = page.getByTestId('assets-include-previews')
this.backToAssetsButton = page.getByText('Back to all assets')
this.skeletonLoaders = page.locator(
'.sidebar-content-container .animate-pulse'

View File

@@ -751,6 +751,32 @@ cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
expect(payload.job_ids).toEqual(['job-gamma'])
expect(payload.job_asset_name_filters).toBeUndefined()
expect(payload.naming_strategy).toBe('preserve')
expect('include_previews' in payload).toBe(false)
}
)
cloudTest(
'Download with previews sets include_previews on the export request',
async ({ comfyPage, mockCloudAssetSidebarData }) => {
void mockCloudAssetSidebarData
const exportRequests = await comfyPage.assets.captureAssetExportRequests()
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.assetCards.first().click()
await expect(tab.includePreviewsToggle).toBeVisible()
await tab.includePreviewsToggle.click()
await tab.downloadSelectedButton.click()
await expect.poll(() => exportRequests).toHaveLength(1)
const payload = exportRequests[0]
expect(payload.job_ids).toEqual(['job-gamma'])
expect(
'include_previews' in payload ? payload.include_previews : false
).toBe(true)
}
)

View File

@@ -0,0 +1,202 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import AssetsSidebarTab from './AssetsSidebarTab.vue'
const mockDownloadAssets = vi.hoisted(() => vi.fn())
const outputAsset: AssetItem = {
id: 'out-1',
name: 'render.png',
size: 1024,
created_at: '2025-01-01T00:00:00Z',
tags: ['output']
}
function createAssetsApi() {
return {
media: ref<AssetItem[]>([outputAsset]),
loading: ref(false),
error: ref(null),
fetchMediaList: vi.fn(),
refresh: vi.fn(),
loadMore: vi.fn(),
hasMore: ref(false),
isLoadingMore: ref(false)
}
}
vi.mock('@/platform/assets/composables/media/useAssetsApi', () => ({
useAssetsApi: () => createAssetsApi()
}))
vi.mock('@/platform/assets/composables/useMediaAssetFiltering', () => ({
useMediaAssetFiltering: (assets: { value: AssetItem[] }) => ({
searchQuery: ref(''),
sortBy: ref('newest'),
mediaTypeFilters: ref([]),
filteredAssets: computed(() => assets.value)
})
}))
vi.mock('@/platform/assets/composables/useOutputStacks', () => ({
useOutputStacks: () => ({
assetItems: ref([]),
selectableAssets: ref([]),
isStackExpanded: () => false,
toggleStack: vi.fn()
})
}))
vi.mock('@/platform/assets/composables/useAssetSelection', () => ({
useAssetSelection: () => ({
isSelected: () => false,
handleAssetClick: vi.fn(),
hasSelection: ref(true),
clearSelection: vi.fn(),
getSelectedAssets: (assets: AssetItem[]) => assets,
reconcileSelection: vi.fn(),
getOutputCount: () => 1,
getTotalOutputCount: () => 1,
activate: vi.fn(),
deactivate: vi.fn()
})
}))
vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({
useMediaAssetActions: () => ({
downloadAssets: mockDownloadAssets,
deleteAssets: vi.fn().mockResolvedValue(false),
addMultipleToWorkflow: vi.fn(),
openMultipleWorkflows: vi.fn(),
exportMultipleWorkflows: vi.fn()
})
}))
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: vi.fn() })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: {
en: {
sideToolbar: {
mediaAssets: { title: 'Media Assets' },
backToAssets: 'Back to all assets',
labels: { generated: 'Generated', imported: 'Imported' },
noImportedFiles: 'No imported files',
noGeneratedFiles: 'No generated files',
noFilesFoundMessage: 'No files found'
},
assetBrowser: { jobId: 'Job ID' },
mediaAsset: {
selection: {
includePreviews: 'Download previews',
downloadSelected: 'Download',
deleteSelected: 'Delete',
deselectAll: 'Deselect all',
selectedCount: '{count} selected'
}
}
}
}
})
function renderTab() {
return render(AssetsSidebarTab, {
global: {
plugins: [createTestingPinia({ stubActions: false }), i18n],
directives: { tooltip: () => {} },
stubs: {
SidebarTabTemplate: {
template:
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /><slot name="footer" /></div>'
},
MediaAssetFilterBar: true,
AssetsSidebarGridView: true,
AssetsSidebarListView: true,
MediaAssetContextMenu: true,
MediaLightbox: true,
NoResultsPlaceholder: true,
Skeleton: true,
ToggleSwitch: {
props: { modelValue: { type: Boolean, default: false } },
emits: ['update:modelValue'],
template:
'<button type="button" role="switch" :aria-checked="modelValue" @click="$emit(\'update:modelValue\', !modelValue)" />'
}
}
}
})
}
const includePreviewsToggle = () =>
screen.queryByTestId('assets-include-previews')
describe('AssetsSidebarTab include-previews toggle', () => {
beforeEach(() => {
mockDownloadAssets.mockClear()
})
it('shows the toggle on the generated (output) tab and hides it on imported', async () => {
const user = userEvent.setup()
renderTab()
expect(includePreviewsToggle()).toBeInTheDocument()
await user.click(screen.getByRole('tab', { name: 'Imported' }))
expect(includePreviewsToggle()).not.toBeInTheDocument()
})
it('downloads without previews by default', async () => {
const user = userEvent.setup()
renderTab()
await user.click(screen.getByTestId('assets-download-selected'))
expect(mockDownloadAssets).toHaveBeenCalledWith(
[expect.objectContaining({ id: 'out-1' })],
false
)
})
it('forwards include-previews when the toggle is enabled before download', async () => {
const user = userEvent.setup()
renderTab()
const toggle = includePreviewsToggle()!
expect(toggle).toHaveAttribute('aria-checked', 'false')
await user.click(toggle)
expect(toggle).toHaveAttribute('aria-checked', 'true')
await user.click(screen.getByTestId('assets-download-selected'))
expect(mockDownloadAssets).toHaveBeenCalledWith(
[expect.objectContaining({ id: 'out-1' })],
true
)
})
it('does not forward a stale toggle after switching away from the output tab', async () => {
const user = userEvent.setup()
renderTab()
await user.click(includePreviewsToggle()!)
await user.click(screen.getByRole('tab', { name: 'Imported' }))
await user.click(screen.getByTestId('assets-download-selected'))
expect(mockDownloadAssets).toHaveBeenCalledWith(expect.anything(), false)
})
})

View File

@@ -118,64 +118,68 @@
<div
v-if="hasSelection"
ref="footerRef"
class="flex h-18 w-full items-center justify-between gap-1"
class="flex w-full flex-col justify-center gap-2 py-2"
>
<div class="flex-1 pl-4">
<div ref="selectionCountButtonRef" class="inline-flex w-48">
<div class="flex w-full items-center justify-between gap-1">
<div class="flex-1 pl-4">
<div ref="selectionCountButtonRef" class="inline-flex w-48">
<Button
variant="secondary"
:class="cn(isCompact && 'text-left')"
@click="handleDeselectAll"
>
{{
isHoveringSelectionCount
? $t('mediaAsset.selection.deselectAll')
: $t('mediaAsset.selection.selectedCount', {
count: totalOutputCount
})
}}
</Button>
</div>
</div>
<div
class="flex shrink items-center-safe justify-end-safe gap-2 pr-4"
>
<Button
variant="secondary"
:class="cn(isCompact && 'text-left')"
@click="handleDeselectAll"
v-if="shouldShowDeleteButton"
:variant="isCompact ? undefined : 'secondary'"
:size="isCompact ? 'icon' : undefined"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
>
{{
isHoveringSelectionCount
? $t('mediaAsset.selection.deselectAll')
: $t('mediaAsset.selection.selectedCount', {
count: totalOutputCount
})
}}
<span v-if="!isCompact">{{
$t('mediaAsset.selection.deleteSelected')
}}</span>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
:variant="isCompact ? undefined : 'secondary'"
:size="isCompact ? 'icon' : undefined"
data-testid="assets-download-selected"
@click="
handleDownloadSelected(canIncludePreviews && includePreviews)
"
>
<span v-if="!isCompact">{{
$t('mediaAsset.selection.downloadSelected')
}}</span>
<i class="icon-[lucide--download] size-4" />
</Button>
</div>
</div>
<div class="flex shrink items-center-safe justify-end-safe gap-2 pr-4">
<template v-if="isCompact">
<!-- Compact mode: Icon only -->
<Button
v-if="shouldShowDeleteButton"
size="icon"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
size="icon"
data-testid="assets-download-selected"
@click="handleDownloadSelected"
>
<i class="icon-[lucide--download] size-4" />
</Button>
</template>
<template v-else>
<!-- Normal mode: Icon + Text -->
<Button
v-if="shouldShowDeleteButton"
variant="secondary"
data-testid="assets-delete-selected"
@click="handleDeleteSelected"
>
<span>{{ $t('mediaAsset.selection.deleteSelected') }}</span>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
variant="secondary"
data-testid="assets-download-selected"
@click="handleDownloadSelected"
>
<span>{{ $t('mediaAsset.selection.downloadSelected') }}</span>
<i class="icon-[lucide--download] size-4" />
</Button>
</template>
<div
v-if="canIncludePreviews"
class="flex items-center justify-end gap-2 pr-4"
>
<label for="assets-include-previews" class="text-sm">{{
$t('mediaAsset.selection.includePreviews')
}}</label>
<ToggleSwitch
v-model="includePreviews"
input-id="assets-include-previews"
data-testid="assets-include-previews"
/>
</div>
</div>
</template>
@@ -213,6 +217,7 @@ import {
useStorage,
useTimeoutFn
} from '@vueuse/core'
import ToggleSwitch from 'primevue/toggleswitch'
import { useToast } from 'primevue/usetoast'
import {
computed,
@@ -559,11 +564,14 @@ const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
clearSelection()
}
const handleDownloadSelected = () => {
downloadAssets(selectedAssets.value)
const handleDownloadSelected = (includePreviews = false) => {
downloadAssets(selectedAssets.value, includePreviews)
clearSelection()
}
const canIncludePreviews = computed(() => activeTab.value === 'output')
const includePreviews = ref(false)
const handleDeleteSelected = async () => {
if (await deleteAssets(selectedAssets.value)) {
clearSelection()

View File

@@ -3220,6 +3220,7 @@
"multipleSelectedAssets": "Multiple assets selected",
"deselectAll": "Deselect all",
"downloadSelected": "Download",
"includePreviews": "Download previews",
"downloadSelectedAll": "Download all",
"deleteSelected": "Delete",
"deleteSelectedAll": "Delete all",
@@ -3229,6 +3230,7 @@
"downloadStarted": "Downloading {count} file... | Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file | Started downloading {count} files",
"exportStarted": "Preparing ZIP export for {count} file | Preparing ZIP export for {count} files",
"exportStartedJobs": "Downloading assets from {count} job | Downloading assets from {count} jobs",
"assetsDeletedSuccessfully": "{count} asset deleted successfully | {count} assets deleted successfully",
"failedToDeleteAssets": "Failed to delete selected assets",
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed",

View File

@@ -547,6 +547,35 @@ describe('useMediaAssetActions', () => {
expect(mockTrackExport).not.toHaveBeenCalled()
})
it('forces the ZIP export path for a single asset when previews are requested', async () => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('output')
mockGetOutputAssetMetadata.mockReturnValue({
jobId: 'job1',
outputCount: 1
})
const asset = createMockAsset({
id: 'single-output',
name: 'single-output.png',
preview_url: 'https://example.com/single-output.png',
tags: ['output'],
user_metadata: { jobId: 'job1', outputCount: 1 }
})
const actions = useMediaAssetActions()
actions.downloadAssets([asset], true)
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
})
expect(mockDownloadFile).not.toHaveBeenCalled()
const payload = mockCreateAssetExport.mock.calls[0][0]
expect(payload.include_previews).toBe(true)
expect(payload.job_ids).toEqual(['job1'])
})
it('uses ZIP export for an injected single multi-output asset in cloud', async () => {
mockIsCloud.value = true
mockGetAssetType.mockReturnValue('output')
@@ -698,6 +727,34 @@ describe('useMediaAssetActions', () => {
})
expect(payload.naming_strategy).toBe('preserve')
})
it('omits include_previews by default', async () => {
const assets = [createOutputAsset('a1', 'img1.png', 'job1', 3)]
const actions = useMediaAssetActions()
actions.downloadAssets(assets)
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
})
const payload = mockCreateAssetExport.mock.calls[0][0]
expect(payload.include_previews).toBeUndefined()
})
it('sends include_previews when requested', async () => {
const assets = [createOutputAsset('a1', 'img1.png', 'job1', 3)]
const actions = useMediaAssetActions()
actions.downloadAssets(assets, true)
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
})
const payload = mockCreateAssetExport.mock.calls[0][0]
expect(payload.include_previews).toBe(true)
})
})
describe('downloadAssets - export toast file count', () => {
@@ -725,7 +782,7 @@ describe('useMediaAssetActions', () => {
})
}
async function expectExportToastFileCount(count: number) {
async function expectExportToast(key: string, count: number) {
await vi.waitFor(() => {
expect(mockCreateAssetExport).toHaveBeenCalledTimes(1)
})
@@ -733,58 +790,53 @@ describe('useMediaAssetActions', () => {
const { add } = useToast()
await vi.waitFor(() => {
expect(add).toHaveBeenCalledWith(
expect.objectContaining({
detail: 'mediaAsset.selection.exportStarted'
})
expect.objectContaining({ detail: key })
)
})
const { t } = useI18n()
expect(t).toHaveBeenCalledWith(
'mediaAsset.selection.exportStarted',
{ count },
count
)
expect(t).toHaveBeenCalledWith(key, { count }, count)
}
it('should report total file count, not job count, for multi-output jobs', async () => {
it('reports the job count for an output export without previews', async () => {
const j1 = createOutputAsset('a1', 'img1.png', 'job1', 2)
const j2 = createOutputAsset('a2', 'img2.png', 'job2', 4)
const actions = useMediaAssetActions()
actions.downloadAssets([j1, j2])
await expectExportToastFileCount(6)
await expectExportToast('mediaAsset.selection.exportStartedJobs', 2)
})
it('should treat assets without outputCount as a single file', async () => {
const a1 = createOutputAsset('a1', 'img1.png', 'job1')
const a2 = createOutputAsset('a2', 'img2.png', 'job2')
const actions = useMediaAssetActions()
actions.downloadAssets([a1, a2])
await expectExportToastFileCount(2)
})
it('should mix multi-output and single-output assets correctly', async () => {
const j1 = createOutputAsset('a1', 'img1.png', 'job1', 3)
const a2 = createOutputAsset('a2', 'img2.png', 'job2')
const actions = useMediaAssetActions()
actions.downloadAssets([j1, a2])
await expectExportToastFileCount(4)
})
it('should only count duplicate job-level output selections once', async () => {
it('counts distinct jobs, not selected assets', async () => {
const j1 = createOutputAsset('a1', 'img1.png', 'job1', 3)
const j1Duplicate = createOutputAsset('a2', 'img2.png', 'job1', 3)
const actions = useMediaAssetActions()
actions.downloadAssets([j1, j1Duplicate])
await expectExportToastFileCount(3)
await expectExportToast('mediaAsset.selection.exportStartedJobs', 1)
})
it('reports the preview-inclusive file count when exporting with previews', async () => {
const j1 = createOutputAsset('a1', 'img1.png', 'job1', 2)
const j2 = createOutputAsset('a2', 'img2.png', 'job2', 4)
const actions = useMediaAssetActions()
actions.downloadAssets([j1, j2], true)
await expectExportToast('mediaAsset.selection.exportStarted', 6)
})
it('reports the file count for a jobless (input) export without previews', async () => {
mockGetAssetType.mockReturnValue('input')
const a1 = createMockAsset({ id: 'a1', name: 'in1.png', tags: ['input'] })
const a2 = createMockAsset({ id: 'a2', name: 'in2.png', tags: ['input'] })
const actions = useMediaAssetActions()
actions.downloadAssets([a1, a2])
await expectExportToast('mediaAsset.selection.exportStarted', 2)
})
})

View File

@@ -105,11 +105,14 @@ export function useMediaAssetActions() {
/**
* Download one or more assets.
* In cloud mode, creates a ZIP export via the backend when called with
* 2+ assets or with any asset whose job has `outputCount > 1`.
* 2+ assets, with any asset whose job has `outputCount > 1`, or when
* `includePreviews` is set. Previews are only retrievable via the export
* endpoint, so requesting them must force the ZIP path even for a single
* asset; the direct-download branch only fetches visible output URLs.
* Falls back to direct downloads in OSS mode and for single single-output
* assets. With no argument, uses the asset from `MediaAssetKey` context.
*/
const downloadAssets = (assets?: AssetItem[]) => {
const downloadAssets = (assets?: AssetItem[], includePreviews = false) => {
const targetAssets =
assets ?? (mediaContext?.asset.value ? [mediaContext.asset.value] : [])
if (targetAssets.length === 0) return
@@ -119,8 +122,11 @@ export function useMediaAssetActions() {
return typeof count === 'number' && count > 1
})
if (isCloud && (targetAssets.length > 1 || hasMultiOutputJobs)) {
void downloadAssetsAsZip(targetAssets)
if (
isCloud &&
(includePreviews || targetAssets.length > 1 || hasMultiOutputJobs)
) {
void downloadAssetsAsZip(targetAssets, includePreviews)
return
}
@@ -147,7 +153,10 @@ export function useMediaAssetActions() {
}
}
async function downloadAssetsAsZip(assets: AssetItem[]) {
async function downloadAssetsAsZip(
assets: AssetItem[],
includePreviews = false
) {
const assetExportStore = useAssetExportStore()
try {
@@ -201,19 +210,33 @@ export function useMediaAssetActions() {
...(Object.keys(jobAssetNameFilters).length > 0
? { job_asset_name_filters: jobAssetNameFilters }
: {}),
...(includePreviews ? { include_previews: true } : {}),
naming_strategy: namingStrategy
})
assetExportStore.trackExport(result.task_id)
// For output exports the precise file count is unknown up front: the
// job's output count includes preview/temp assets, which are only in
// the archive when include_previews is set. Report the job count
// instead so the number is accurate; with previews the count already
// matches the archive.
const useJobCount = !includePreviews && jobIds.length > 0
toast.add({
severity: 'info',
summary: t('exportToast.exportStarted'),
detail: t(
'mediaAsset.selection.exportStarted',
{ count: fileCount },
fileCount
),
detail: useJobCount
? t(
'mediaAsset.selection.exportStartedJobs',
{ count: jobIds.length },
jobIds.length
)
: t(
'mediaAsset.selection.exportStarted',
{ count: fileCount },
fileCount
),
life: 3000
})
} catch (error) {

View File

@@ -50,6 +50,7 @@ interface AssetExportOptions {
| 'preserve'
| 'asset_id'
job_asset_name_filters?: Record<string, string[]>
include_previews?: boolean
}
/**