Skip to content

Commit 99f7720

Browse files
author
adobeluo
committed
feat: add local asset file actions
1 parent db147c0 commit 99f7720

13 files changed

Lines changed: 726 additions & 193 deletions

src/components/sidebar/tabs/AssetsSidebarTab.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@
139139
<MediaAssetSelectionBar
140140
v-if="hasSelection"
141141
:count="totalOutputCount"
142-
:show-delete="shouldShowDeleteButton"
142+
:show-delete="shouldShowSelectionDeleteButton"
143143
@deselect="handleDeselectAll"
144144
@download="handleDownloadSelected"
145145
@delete="handleDeleteSelected"
@@ -163,7 +163,7 @@
163163
:asset="contextMenuAsset"
164164
:asset-type="contextMenuAssetType"
165165
:file-kind="contextMenuFileKind"
166-
:show-delete-button="shouldShowDeleteButton"
166+
:show-delete-button="true"
167167
:selected-assets="selectedAssets"
168168
:is-bulk-mode="isBulkMode"
169169
@zoom="handleZoomClick(contextMenuAsset)"
@@ -268,7 +268,7 @@ const contextMenuAsset = ref<AssetItem | null>(null)
268268
269269
// Determine if delete button should be shown
270270
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
271-
const shouldShowDeleteButton = computed(() => {
271+
const shouldShowSelectionDeleteButton = computed(() => {
272272
if (activeTab.value === 'input' && !isCloud) return false
273273
return true
274274
})

src/locales/en/main.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3687,11 +3687,23 @@
36873687
"mediaAsset": {
36883688
"deleteAssetTitle": "Delete this asset?",
36893689
"deleteAssetDescription": "This asset will be permanently removed.",
3690+
"deleteGeneratedSourceFileTitle": "Delete this source file?",
3691+
"deleteGeneratedSourceFileDescription": "This file will be permanently deleted from disk and removed from Generated. This action cannot be undone.",
3692+
"deleteSelectedGeneratedSourceFilesTitle": "Delete selected source files?",
3693+
"deleteSelectedGeneratedSourceFilesDescription": "{count} files will be permanently deleted from disk and removed from Generated. This action cannot be undone.",
36903694
"deleteSelectedTitle": "Delete selected assets?",
36913695
"deleteSelectedDescription": "{count} asset(s) will be permanently removed.",
3696+
"removeGeneratedAssetTitle": "Remove this asset from Generated?",
3697+
"removeGeneratedAssetDescription": "This asset will be removed from Generated. The file on disk will be kept.",
3698+
"removeSelectedGeneratedAssetsTitle": "Remove selected assets from Generated?",
3699+
"removeSelectedGeneratedAssetsDescription": "{count} asset(s) will be removed from Generated. The files on disk will be kept.",
3700+
"generatedAssetRemovedSuccessfully": "Removed from Generated; file kept on disk",
3701+
"selectedGeneratedAssetsRemovedSuccessfully": "{count} asset(s) removed from Generated; files kept on disk",
36923702
"assetDeletedSuccessfully": "Asset deleted successfully",
36933703
"deletingImportedFilesCloudOnly": "Deleting imported files is only supported in cloud version",
36943704
"failedToDeleteAsset": "Failed to delete asset",
3705+
"fileLocationOpened": "Opened the file location",
3706+
"failedToOpenFileLocation": "Failed to open the file location. Use ComfyUI from this computer and try again.",
36953707
"actions": {
36963708
"inspect": "Inspect asset",
36973709
"more": "More options",
@@ -3700,10 +3712,12 @@
37003712
"seeMoreOutputs": "See more outputs",
37013713
"insertAsNodeInWorkflow": "Insert as node in workflow",
37023714
"download": "Download",
3715+
"openFileLocation": "Open file location",
37033716
"openWorkflow": "Open as workflow in new tab",
37043717
"exportWorkflow": "Export workflow",
37053718
"copyJobId": "Copy job ID",
3706-
"delete": "Delete"
3719+
"delete": "Delete",
3720+
"deleteSourceFile": "Delete source file"
37073721
},
37083722
"jobIdToast": {
37093723
"jobIdCopied": "Job ID copied to clipboard",

src/platform/assets/components/MediaAssetContextMenu.test.ts

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ vi.mock('@/utils/formatUtil', async (importOriginal) => ({
3030
const mediaAssetActions = {
3131
addWorkflow: vi.fn(),
3232
downloadAssets: vi.fn(),
33+
openAssetLocation: vi.fn(),
3334
openWorkflow: vi.fn(),
3435
exportWorkflow: vi.fn(),
3536
copyJobId: vi.fn(),
@@ -103,7 +104,10 @@ interface MediaAssetContextMenuExposed {
103104

104105
let capturedRef: MediaAssetContextMenuExposed | null = null
105106

106-
function mountComponent(targetAsset: AssetItem = asset) {
107+
function mountComponent(
108+
targetAsset: AssetItem = asset,
109+
assetType: 'input' | 'output' = 'output'
110+
) {
107111
const onHide = vi.fn()
108112
const { container, unmount } = render(
109113
defineComponent({
@@ -113,10 +117,10 @@ function mountComponent(targetAsset: AssetItem = asset) {
113117
onMounted(() => {
114118
capturedRef = menuRef.value
115119
})
116-
return { menuRef, asset: targetAsset, onHide }
120+
return { menuRef, asset: targetAsset, assetType, onHide }
117121
},
118122
template:
119-
'<MediaAssetContextMenu ref="menuRef" :asset="asset" asset-type="output" file-kind="image" @hide="onHide" />'
123+
'<MediaAssetContextMenu ref="menuRef" :asset="asset" :asset-type="assetType" :show-delete-button="true" file-kind="image" @hide="onHide" />'
120124
}),
121125
{
122126
global: {
@@ -223,4 +227,115 @@ describe('MediaAssetContextMenu', () => {
223227

224228
unmount()
225229
})
230+
231+
it('hides Copy Job ID for persistent outputs without provenance', async () => {
232+
const { container, unmount } = mountComponent({
233+
...asset,
234+
tags: ['output'],
235+
loader_path: 'video/old-output.mp4'
236+
})
237+
await showMenu(container)
238+
239+
expect(findMenuItem('mediaAsset.actions.copyJobId')).toBeUndefined()
240+
241+
unmount()
242+
})
243+
244+
it('shows Copy Job ID for persistent outputs with provenance', async () => {
245+
const { container, unmount } = mountComponent({
246+
...asset,
247+
tags: ['output'],
248+
loader_path: 'video/new-output.mp4',
249+
job_id: 'prompt-123'
250+
})
251+
await showMenu(container)
252+
253+
expect(findMenuItem('mediaAsset.actions.copyJobId')).toBeDefined()
254+
255+
unmount()
256+
})
257+
258+
it('puts local input deletion directly after download', async () => {
259+
const { container, unmount } = mountComponent(asset, 'input')
260+
await showMenu(container)
261+
262+
const downloadIndex = capturedMenu.model.findIndex(
263+
(item) => item.label === 'mediaAsset.actions.download'
264+
)
265+
const deleteIndex = capturedMenu.model.findIndex(
266+
(item) => item.label === 'mediaAsset.actions.delete'
267+
)
268+
expect(deleteIndex).toBe(downloadIndex + 1)
269+
270+
const deleteItem = findMenuItem('mediaAsset.actions.delete')
271+
if (!deleteItem?.command) throw new Error('Delete command is missing')
272+
await deleteItem.command({
273+
originalEvent: new MouseEvent('click'),
274+
item: deleteItem
275+
})
276+
expect(mediaAssetActions.deleteAssets).toHaveBeenCalledWith(asset, {
277+
skipConfirmation: true
278+
})
279+
280+
unmount()
281+
})
282+
283+
it('orders local generated file actions after download', async () => {
284+
const persistentOutput = {
285+
...asset,
286+
tags: ['output'],
287+
loader_path: 'video/render.mp4'
288+
}
289+
const { container, unmount } = mountComponent(persistentOutput)
290+
await showMenu(container)
291+
292+
const labels = capturedMenu.model.map((item) => item.label)
293+
const downloadIndex = labels.indexOf('mediaAsset.actions.download')
294+
expect(labels.slice(downloadIndex, downloadIndex + 4)).toEqual([
295+
'mediaAsset.actions.download',
296+
'mediaAsset.actions.openFileLocation',
297+
'mediaAsset.actions.delete',
298+
'mediaAsset.actions.deleteSourceFile'
299+
])
300+
301+
const openLocationItem = findMenuItem('mediaAsset.actions.openFileLocation')
302+
if (!openLocationItem?.command) {
303+
throw new Error('Open-location command is missing')
304+
}
305+
openLocationItem.command({
306+
originalEvent: new MouseEvent('click'),
307+
item: openLocationItem
308+
})
309+
expect(mediaAssetActions.openAssetLocation).toHaveBeenCalledWith(
310+
persistentOutput
311+
)
312+
313+
mediaAssetActions.deleteAssets.mockResolvedValueOnce(true)
314+
const deleteItem = findMenuItem('mediaAsset.actions.delete')
315+
if (!deleteItem?.command) throw new Error('Delete command is missing')
316+
await deleteItem.command({
317+
originalEvent: new MouseEvent('click'),
318+
item: deleteItem
319+
})
320+
expect(mediaAssetActions.deleteAssets).toHaveBeenLastCalledWith(
321+
persistentOutput,
322+
{ skipConfirmation: true }
323+
)
324+
325+
mediaAssetActions.deleteAssets.mockResolvedValueOnce(true)
326+
const deleteSourceItem = findMenuItem('mediaAsset.actions.deleteSourceFile')
327+
if (!deleteSourceItem?.command) {
328+
throw new Error('Delete-source command is missing')
329+
}
330+
await deleteSourceItem.command({
331+
originalEvent: new MouseEvent('click'),
332+
item: deleteSourceItem
333+
})
334+
expect(mediaAssetActions.deleteAssets).toHaveBeenLastCalledWith(
335+
persistentOutput,
336+
{ deleteContent: true }
337+
)
338+
339+
unmount()
340+
})
226341
})

src/platform/assets/components/MediaAssetContextMenu.vue

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
4040
import { isCloud } from '@/platform/distribution/types'
4141
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
4242
import { isPreviewableMediaType } from '@/utils/formatUtil'
43+
import { isLoopbackHost } from '@/utils/hostWhitelist'
4344
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
4445
import { cn } from '@comfyorg/tailwind-utils'
4546
@@ -112,17 +113,35 @@ const showWorkflowActions = computed(() => {
112113
})
113114
114115
const showCopyJobId = computed(() => {
115-
return assetType !== 'input'
116+
if (assetType === 'input') return false
117+
if (asset?.loader_path) return Boolean(asset.job_id || asset.prompt_id)
118+
return true
116119
})
117120
118121
const shouldShowDeleteButton = computed(() => {
119122
const propAllows = showDeleteButton ?? true
120-
const typeAllows =
121-
assetType === 'output' || (assetType === 'input' && isCloud)
123+
const typeAllows = assetType === 'output' || assetType === 'input'
122124
123125
return propAllows && typeAllows
124126
})
125127
128+
const shouldShowDeleteSourceFileButton = computed(
129+
() =>
130+
shouldShowDeleteButton.value &&
131+
assetType === 'output' &&
132+
!isCloud &&
133+
Boolean(asset?.loader_path)
134+
)
135+
136+
const shouldShowOpenLocationButton = computed(
137+
() =>
138+
assetType === 'output' &&
139+
!isCloud &&
140+
Boolean(asset?.loader_path) &&
141+
typeof window !== 'undefined' &&
142+
isLoopbackHost(window.location.hostname)
143+
)
144+
126145
// Context menu items
127146
const contextMenuItems = computed<MenuItem[]>(() => {
128147
if (!asset) return []
@@ -214,6 +233,43 @@ const contextMenuItems = computed<MenuItem[]>(() => {
214233
command: () => actions.downloadAssets([asset])
215234
})
216235
236+
if (shouldShowOpenLocationButton.value) {
237+
items.push({
238+
label: t('mediaAsset.actions.openFileLocation'),
239+
icon: 'icon-[lucide--folder-open]',
240+
command: () => actions.openAssetLocation(asset)
241+
})
242+
}
243+
244+
if (shouldShowDeleteButton.value) {
245+
items.push({
246+
label: t('mediaAsset.actions.delete'),
247+
icon: 'icon-[lucide--trash-2]',
248+
command: async () => {
249+
const deleted = await actions.deleteAssets(asset, {
250+
skipConfirmation:
251+
!isCloud &&
252+
(assetType === 'input' ||
253+
(assetType === 'output' && Boolean(asset.loader_path)))
254+
})
255+
if (deleted) emit('asset-deleted')
256+
}
257+
})
258+
}
259+
260+
if (shouldShowDeleteSourceFileButton.value) {
261+
items.push({
262+
label: t('mediaAsset.actions.deleteSourceFile'),
263+
icon: 'icon-[lucide--file-x-2]',
264+
command: async () => {
265+
const deleted = await actions.deleteAssets(asset, {
266+
deleteContent: true
267+
})
268+
if (deleted) emit('asset-deleted')
269+
}
270+
})
271+
}
272+
217273
// Separator before workflow actions (only if there are workflow actions)
218274
if (showWorkflowActions.value) {
219275
items.push({ separator: true })
@@ -241,23 +297,6 @@ const contextMenuItems = computed<MenuItem[]>(() => {
241297
})
242298
}
243299
244-
// Delete
245-
if (shouldShowDeleteButton.value) {
246-
items.push({ separator: true })
247-
items.push({
248-
label: t('mediaAsset.actions.delete'),
249-
icon: 'icon-[lucide--trash-2]',
250-
command: async () => {
251-
if (asset) {
252-
const confirmed = await actions.deleteAssets(asset)
253-
if (confirmed) {
254-
emit('asset-deleted')
255-
}
256-
}
257-
}
258-
})
259-
}
260-
261300
return items
262301
})
263302

0 commit comments

Comments
 (0)