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' &&