Add asset deletion progress indicator (#7906)

## Summary

Currently the delay between user action and visual response is too long
and it looks like it hasn't worked.
This adds a visual indicator that the action is processing.

## Changes

- add loading indicator while asset is deleting

## Screenshots (if applicable)
(Artifically delayed deletion)


https://github.com/user-attachments/assets/a9a8b9fe-896d-4666-b643-ec8b990f6444

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7906-Add-asset-deletion-progress-indicator-2e26d73d365081ed82b8e770ba3d0615)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
This commit is contained in:
pythongosssss
2026-01-27 20:21:50 -08:00
committed by GitHub
parent 2c54b0dab0
commit d890e7568a
12 changed files with 202 additions and 192 deletions

View File

@@ -0,0 +1,49 @@
<template>
<Transition name="fade">
<div
v-if="loading"
class="absolute inset-0 z-50 flex items-center justify-center bg-backdrop/50"
>
<div class="flex flex-col items-center">
<div class="grid place-items-center">
<div
:class="
cn(
'col-start-1 row-start-1 animate-spin rounded-full border-muted-foreground border-t-base-foreground',
spinnerSizeClass
)
"
/>
<div class="col-start-1 row-start-1">
<slot />
</div>
</div>
<div v-if="loadingMessage" class="mt-4 text-lg text-base-foreground">
{{ loadingMessage }}
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { size = 'md' } = defineProps<{
loading: boolean
loadingMessage?: string
size?: 'sm' | 'md'
}>()
const spinnerSizeClass = computed(() => {
switch (size) {
case 'sm':
return 'h-6 w-6 border-2'
case 'md':
default:
return 'h-12 w-12 border-4'
}
})
</script>

View File

@@ -31,7 +31,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import LoadingOverlay from '@/components/load3d/LoadingOverlay.vue' import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag' import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
const props = defineProps<{ const props = defineProps<{

View File

@@ -1,42 +0,0 @@
<template>
<Transition name="fade">
<div
v-if="loading"
class="absolute inset-0 z-50 flex items-center justify-center bg-backdrop/50"
>
<div class="flex flex-col items-center">
<div class="spinner" />
<div class="mt-4 text-lg text-base-foreground">
{{ loadingMessage }}
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
defineProps<{
loading: boolean
loadingMessage: string
}>()
</script>
<style scoped>
.spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -63,39 +63,47 @@
@approach-end="emit('approach-end')" @approach-end="emit('approach-end')"
> >
<template #item="{ item }"> <template #item="{ item }">
<AssetsListItem <div class="relative">
role="button" <LoadingOverlay
tabindex="0" :loading="assetsStore.isAssetDeleting(item.asset.id)"
:aria-label=" size="sm"
t('assetBrowser.ariaLabel.assetCard', { >
name: item.asset.name, <i class="pi pi-trash text-xs" />
type: getMediaTypeFromFilename(item.asset.name) </LoadingOverlay>
}) <AssetsListItem
" role="button"
:class="getAssetCardClass(isSelected(item.asset.id))" tabindex="0"
:preview-url="item.asset.preview_url" :aria-label="
:preview-alt="item.asset.name" t('assetBrowser.ariaLabel.assetCard', {
:icon-name=" name: item.asset.name,
iconForMediaType(getMediaTypeFromFilename(item.asset.name)) type: getMediaTypeFromFilename(item.asset.name)
" })
:primary-text="getAssetPrimaryText(item.asset)" "
:secondary-text="getAssetSecondaryText(item.asset)" :class="getAssetCardClass(isSelected(item.asset.id))"
@mouseenter="onAssetEnter(item.asset.id)" :preview-url="item.asset.preview_url"
@mouseleave="onAssetLeave(item.asset.id)" :preview-alt="item.asset.name"
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)" :icon-name="
@click.stop="emit('select-asset', item.asset)" iconForMediaType(getMediaTypeFromFilename(item.asset.name))
> "
<template v-if="hoveredAssetId === item.asset.id" #actions> :primary-text="getAssetPrimaryText(item.asset)"
<Button :secondary-text="getAssetSecondaryText(item.asset)"
variant="secondary" @mouseenter="onAssetEnter(item.asset.id)"
size="icon" @mouseleave="onAssetLeave(item.asset.id)"
:aria-label="t('mediaAsset.actions.moreOptions')" @contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
@click.stop="emit('context-menu', $event, item.asset)" @click.stop="emit('select-asset', item.asset)"
> >
<i class="icon-[lucide--ellipsis] size-4" /> <template v-if="hoveredAssetId === item.asset.id" #actions>
</Button> <Button
</template> variant="secondary"
</AssetsListItem> size="icon"
:aria-label="t('mediaAsset.actions.moreOptions')"
@click.stop="emit('context-menu', $event, item.asset)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
</div>
</template> </template>
</VirtualGrid> </VirtualGrid>
</div> </div>
@@ -105,6 +113,7 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue' import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import { useJobActions } from '@/composables/queue/useJobActions' import { useJobActions } from '@/composables/queue/useJobActions'
@@ -114,6 +123,7 @@ import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema' import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil' import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import { useAssetsStore } from '@/stores/assetsStore'
import { isActiveJobState } from '@/utils/queueUtil' import { isActiveJobState } from '@/utils/queueUtil'
import { import {
formatDuration, formatDuration,
@@ -134,6 +144,8 @@ const {
assetType?: 'input' | 'output' assetType?: 'input' | 'output'
}>() }>()
const assetsStore = useAssetsStore()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void (e: 'select-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void (e: 'context-menu', event: MouseEvent, asset: AssetItem): void

View File

@@ -331,7 +331,7 @@ const {
const { const {
downloadMultipleAssets, downloadMultipleAssets,
deleteMultipleAssets, deleteAssets,
addMultipleToWorkflow, addMultipleToWorkflow,
openMultipleWorkflows, openMultipleWorkflows,
exportMultipleWorkflows exportMultipleWorkflows
@@ -495,8 +495,9 @@ const handleBulkDownload = (assets: AssetItem[]) => {
} }
const handleBulkDelete = async (assets: AssetItem[]) => { const handleBulkDelete = async (assets: AssetItem[]) => {
await deleteMultipleAssets(assets) if (await deleteAssets(assets)) {
clearSelection() clearSelection()
}
} }
const handleClearQueue = async () => { const handleClearQueue = async () => {
@@ -524,6 +525,17 @@ const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
clearSelection() clearSelection()
} }
const handleDownloadSelected = () => {
downloadMultipleAssets(selectedAssets.value)
clearSelection()
}
const handleDeleteSelected = async () => {
if (await deleteAssets(selectedAssets.value)) {
clearSelection()
}
}
const handleZoomClick = (asset: AssetItem) => { const handleZoomClick = (asset: AssetItem) => {
const mediaType = getMediaTypeFromFilename(asset.name) const mediaType = getMediaTypeFromFilename(asset.name)
@@ -672,16 +684,6 @@ const copyJobId = async () => {
} }
} }
const handleDownloadSelected = () => {
downloadMultipleAssets(selectedAssets.value)
clearSelection()
}
const handleDeleteSelected = async () => {
await deleteMultipleAssets(selectedAssets.value)
clearSelection()
}
const handleApproachEnd = useDebounceFn(async () => { const handleApproachEnd = useDebounceFn(async () => {
if ( if (
activeTab.value === 'output' && activeTab.value === 'output' &&

View File

@@ -40,7 +40,7 @@ vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
})) }))
const mediaAssetActionsMock = { const mediaAssetActionsMock = {
confirmDelete: vi.fn() deleteAssets: vi.fn()
} }
vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({ vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({
useMediaAssetActions: () => mediaAssetActionsMock useMediaAssetActions: () => mediaAssetActionsMock
@@ -198,7 +198,7 @@ describe('useJobMenu', () => {
})) }))
queueStoreMock.update.mockResolvedValue(undefined) queueStoreMock.update.mockResolvedValue(undefined)
queueStoreMock.delete.mockResolvedValue(undefined) queueStoreMock.delete.mockResolvedValue(undefined)
mediaAssetActionsMock.confirmDelete.mockResolvedValue(false) mediaAssetActionsMock.deleteAssets.mockResolvedValue(false)
mapTaskOutputToAssetItemMock.mockImplementation((task, output) => ({ mapTaskOutputToAssetItemMock.mockImplementation((task, output) => ({
task, task,
output output
@@ -666,7 +666,7 @@ describe('useJobMenu', () => {
}) })
it('deletes preview asset when confirmed', async () => { it('deletes preview asset when confirmed', async () => {
mediaAssetActionsMock.confirmDelete.mockResolvedValue(true) mediaAssetActionsMock.deleteAssets.mockResolvedValue(true)
const { jobMenuEntries } = mountJobMenu() const { jobMenuEntries } = mountJobMenu()
const preview = { filename: 'foo', subfolder: 'bar', type: 'output' } const preview = { filename: 'foo', subfolder: 'bar', type: 'output' }
const taskRef = { previewOutput: preview } const taskRef = { previewOutput: preview }
@@ -681,7 +681,7 @@ describe('useJobMenu', () => {
}) })
it('does not refresh queue when delete cancelled', async () => { it('does not refresh queue when delete cancelled', async () => {
mediaAssetActionsMock.confirmDelete.mockResolvedValue(false) mediaAssetActionsMock.deleteAssets.mockResolvedValue(false)
const { jobMenuEntries } = mountJobMenu() const { jobMenuEntries } = mountJobMenu()
setCurrentItem( setCurrentItem(
createJobItem({ createJobItem({

View File

@@ -210,8 +210,8 @@ export function useJobMenu(
if (!task || !preview) return if (!task || !preview) return
const asset = mapTaskOutputToAssetItem(task, preview) const asset = mapTaskOutputToAssetItem(task, preview)
const success = await mediaAssetActions.confirmDelete(asset) const confirmed = await mediaAssetActions.deleteAssets(asset)
if (success) { if (confirmed) {
await queueStore.update() await queueStore.update()
} }
} }

View File

@@ -48,6 +48,10 @@
@image-loaded="handleImageLoaded" @image-loaded="handleImageLoaded"
/> />
<LoadingOverlay :loading="isDeleting">
<i class="icon-[lucide--trash-2] size-5" />
</LoadingOverlay>
<!-- Action buttons overlay (top-left) --> <!-- Action buttons overlay (top-left) -->
<div <div
v-if="showActionsOverlay" v-if="showActionsOverlay"
@@ -130,7 +134,9 @@ import { useElementHover } from '@vueuse/core'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue' import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.vue' import IconGroup from '@/components/button/IconGroup.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import { useAssetsStore } from '@/stores/assetsStore'
import { import {
formatDuration, formatDuration,
formatSize, formatSize,
@@ -167,6 +173,13 @@ const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
outputCount?: number outputCount?: number
}>() }>()
const assetsStore = useAssetsStore()
// Get deletion state from store
const isDeleting = computed(() =>
asset ? assetsStore.isAssetDeleting(asset.id) : false
)
const emit = defineEmits<{ const emit = defineEmits<{
click: [] click: []
zoom: [asset: AssetItem] zoom: [asset: AssetItem]
@@ -252,7 +265,7 @@ const metaInfo = computed(() => {
}) })
const showActionsOverlay = computed(() => { const showActionsOverlay = computed(() => {
if (loading || !asset) return false if (loading || !asset || isDeleting.value) return false
return isHovered.value || selected || isVideoPlaying.value return isHovered.value || selected || isVideoPlaying.value
}) })

View File

@@ -247,8 +247,8 @@ const contextMenuItems = computed<MenuItem[]>(() => {
icon: 'icon-[lucide--trash-2]', icon: 'icon-[lucide--trash-2]',
command: async () => { command: async () => {
if (asset) { if (asset) {
const success = await actions.confirmDelete(asset) const confirmed = await actions.deleteAssets(asset)
if (success) { if (confirmed) {
emit('asset-deleted') emit('asset-deleted')
} }
} }

View File

@@ -134,71 +134,6 @@ export function useMediaAssetActions() {
} }
} }
/**
* Show confirmation dialog and delete asset if confirmed
* @param asset The asset to delete
* @returns true if the asset was deleted, false otherwise
*/
const confirmDelete = async (asset: AssetItem): Promise<boolean> => {
const assetType = getAssetType(asset)
return new Promise((resolve) => {
dialogStore.showDialog({
key: 'delete-asset-confirmation',
title: t('mediaAsset.deleteAssetTitle'),
component: ConfirmationDialogContent,
props: {
message: t('mediaAsset.deleteAssetDescription'),
type: 'delete',
itemList: [asset.name],
onConfirm: async () => {
const success = await deleteAsset(asset, assetType)
resolve(success)
},
onCancel: () => {
resolve(false)
}
}
})
})
}
const deleteAsset = async (asset: AssetItem, assetType: string) => {
const assetsStore = useAssetsStore()
try {
// Perform the deletion
await deleteAssetApi(asset, assetType)
// Update the appropriate store based on asset type
if (assetType === 'output') {
await assetsStore.updateHistory()
} else {
await assetsStore.updateInputs()
}
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.assetDeletedSuccessfully'),
life: 2000
})
return true
} catch (error) {
console.error('Failed to delete asset:', error)
const errorMessage = error instanceof Error ? error.message : ''
const isCloudWarning = errorMessage.includes('Cloud')
toast.add({
severity: isCloudWarning ? 'warn' : 'error',
summary: isCloudWarning ? t('g.warning') : t('g.error'),
detail: errorMessage || t('mediaAsset.failedToDeleteAsset'),
life: 3000
})
return false
}
}
const copyJobId = async (asset?: AssetItem) => { const copyJobId = async (asset?: AssetItem) => {
const targetAsset = asset ?? mediaContext?.asset.value const targetAsset = asset ?? mediaContext?.asset.value
if (!targetAsset) return if (!targetAsset) return
@@ -580,30 +515,44 @@ export function useMediaAssetActions() {
} }
/** /**
* Delete multiple assets with confirmation dialog * Show confirmation dialog and delete asset(s) if confirmed
* @param assets Array of assets to delete * @param assets Single asset or array of assets to delete
* @returns true if user confirmed and deletion was attempted, false if cancelled
*/ */
const deleteMultipleAssets = async (assets: AssetItem[]) => { const deleteAssets = async (
if (!assets || assets.length === 0) return assets: AssetItem | AssetItem[]
): Promise<boolean> => {
const assetArray = Array.isArray(assets) ? assets : [assets]
if (assetArray.length === 0) return false
const assetsStore = useAssetsStore() const assetsStore = useAssetsStore()
const isSingle = assetArray.length === 1
return new Promise<void>((resolve) => { return new Promise((resolve) => {
dialogStore.showDialog({ dialogStore.showDialog({
key: 'delete-multiple-assets-confirmation', key: 'delete-assets-confirmation',
title: t('mediaAsset.deleteSelectedTitle'), title: isSingle
? t('mediaAsset.deleteAssetTitle')
: t('mediaAsset.deleteSelectedTitle'),
component: ConfirmationDialogContent, component: ConfirmationDialogContent,
props: { props: {
message: t('mediaAsset.deleteSelectedDescription', { message: isSingle
count: assets.length ? t('mediaAsset.deleteAssetDescription')
}), : t('mediaAsset.deleteSelectedDescription', {
count: assetArray.length
}),
type: 'delete', type: 'delete',
itemList: assets.map((asset) => asset.name), itemList: assetArray.map((asset) => asset.name),
onConfirm: async () => { onConfirm: async () => {
// Show loading overlay for all assets being deleted
assetArray.forEach((asset) =>
assetsStore.setAssetDeleting(asset.id, true)
)
try { try {
// Delete all assets using Promise.allSettled to track individual results // Delete all assets using Promise.allSettled to track individual results
const results = await Promise.allSettled( const results = await Promise.allSettled(
assets.map((asset) => assetArray.map((asset) =>
deleteAssetApi(asset, getAssetType(asset)) deleteAssetApi(asset, getAssetType(asset))
) )
) )
@@ -617,16 +566,16 @@ export function useMediaAssetActions() {
// Log failed deletions for debugging // Log failed deletions for debugging
failed.forEach((result, index) => { failed.forEach((result, index) => {
console.warn( console.warn(
`Failed to delete asset ${assets[index].name}:`, `Failed to delete asset ${assetArray[index].name}:`,
result.reason result.reason
) )
}) })
// Update stores after deletions // Update stores after deletions
const hasOutputAssets = assets.some( const hasOutputAssets = assetArray.some(
(a) => getAssetType(a) === 'output' (a) => getAssetType(a) === 'output'
) )
const hasInputAssets = assets.some( const hasInputAssets = assetArray.some(
(a) => getAssetType(a) === 'input' (a) => getAssetType(a) === 'input'
) )
@@ -639,25 +588,27 @@ export function useMediaAssetActions() {
// Show appropriate feedback based on results // Show appropriate feedback based on results
if (failed.length === 0) { if (failed.length === 0) {
// All succeeded
toast.add({ toast.add({
severity: 'success', severity: 'success',
summary: t('g.success'), summary: t('g.success'),
detail: t('mediaAsset.selection.assetsDeletedSuccessfully', { detail: isSingle
count: succeeded ? t('mediaAsset.assetDeletedSuccessfully')
}), : t('mediaAsset.selection.assetsDeletedSuccessfully', {
count: succeeded
}),
life: 2000 life: 2000
}) })
} else if (succeeded === 0) { } else if (succeeded === 0) {
// All failed
toast.add({ toast.add({
severity: 'error', severity: 'error',
summary: t('g.error'), summary: t('g.error'),
detail: t('mediaAsset.selection.failedToDeleteAssets'), detail: isSingle
? t('mediaAsset.failedToDeleteAsset')
: t('mediaAsset.selection.failedToDeleteAssets'),
life: 3000 life: 3000
}) })
} else { } else {
// Partial success // Partial success (only possible with multiple assets)
toast.add({ toast.add({
severity: 'warn', severity: 'warn',
summary: t('g.warning'), summary: t('g.warning'),
@@ -673,15 +624,22 @@ export function useMediaAssetActions() {
toast.add({ toast.add({
severity: 'error', severity: 'error',
summary: t('g.error'), summary: t('g.error'),
detail: t('mediaAsset.selection.failedToDeleteAssets'), detail: isSingle
? t('mediaAsset.failedToDeleteAsset')
: t('mediaAsset.selection.failedToDeleteAssets'),
life: 3000 life: 3000
}) })
} finally {
// Hide loading overlay for all assets
assetArray.forEach((asset) =>
assetsStore.setAssetDeleting(asset.id, false)
)
} }
resolve() resolve(true)
}, },
onCancel: () => { onCancel: () => {
resolve() resolve(false)
} }
} }
}) })
@@ -691,9 +649,7 @@ export function useMediaAssetActions() {
return { return {
downloadAsset, downloadAsset,
downloadMultipleAssets, downloadMultipleAssets,
confirmDelete, deleteAssets,
deleteAsset,
deleteMultipleAssets,
copyJobId, copyJobId,
addWorkflow, addWorkflow,
addMultipleToWorkflow, addMultipleToWorkflow,

View File

@@ -144,7 +144,7 @@ async function rerun(e: Event) {
{ {
icon: 'icon-[lucide--trash-2]', icon: 'icon-[lucide--trash-2]',
label: t('queue.jobMenu.deleteAsset'), label: t('queue.jobMenu.deleteAsset'),
action: () => mediaActions.confirmDelete(selectedItem!) action: () => mediaActions.deleteAssets(selectedItem!)
} }
] ]
]" ]"

View File

@@ -91,6 +91,21 @@ export const useAssetsStore = defineStore('assets', () => {
const assetDownloadStore = useAssetDownloadStore() const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore() const modelToNodeStore = useModelToNodeStore()
// Track assets currently being deleted (for loading overlay)
const deletingAssetIds = shallowReactive(new Set<string>())
const setAssetDeleting = (assetId: string, isDeleting: boolean) => {
if (isDeleting) {
deletingAssetIds.add(assetId)
} else {
deletingAssetIds.delete(assetId)
}
}
const isAssetDeleting = (assetId: string): boolean => {
return deletingAssetIds.has(assetId)
}
// Pagination state // Pagination state
const historyOffset = ref(0) const historyOffset = ref(0)
const hasMoreHistory = ref(true) const hasMoreHistory = ref(true)
@@ -618,6 +633,11 @@ export const useAssetsStore = defineStore('assets', () => {
hasMoreHistory, hasMoreHistory,
isLoadingMore, isLoadingMore,
// Deletion tracking
deletingAssetIds,
setAssetDeleting,
isAssetDeleting,
// Actions // Actions
updateInputs, updateInputs,
updateHistory, updateHistory,