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

View File

@@ -331,7 +331,7 @@ const {
const {
downloadMultipleAssets,
deleteMultipleAssets,
deleteAssets,
addMultipleToWorkflow,
openMultipleWorkflows,
exportMultipleWorkflows
@@ -495,8 +495,9 @@ const handleBulkDownload = (assets: AssetItem[]) => {
}
const handleBulkDelete = async (assets: AssetItem[]) => {
await deleteMultipleAssets(assets)
clearSelection()
if (await deleteAssets(assets)) {
clearSelection()
}
}
const handleClearQueue = async () => {
@@ -524,6 +525,17 @@ const handleBulkExportWorkflow = async (assets: AssetItem[]) => {
clearSelection()
}
const handleDownloadSelected = () => {
downloadMultipleAssets(selectedAssets.value)
clearSelection()
}
const handleDeleteSelected = async () => {
if (await deleteAssets(selectedAssets.value)) {
clearSelection()
}
}
const handleZoomClick = (asset: AssetItem) => {
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 () => {
if (
activeTab.value === 'output' &&

View File

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

View File

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

View File

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

View File

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

View File

@@ -144,7 +144,7 @@ async function rerun(e: Event) {
{
icon: 'icon-[lucide--trash-2]',
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 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
const historyOffset = ref(0)
const hasMoreHistory = ref(true)
@@ -618,6 +633,11 @@ export const useAssetsStore = defineStore('assets', () => {
hasMoreHistory,
isLoadingMore,
// Deletion tracking
deletingAssetIds,
setAssetDeleting,
isAssetDeleting,
// Actions
updateInputs,
updateHistory,