Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions src/components/sidebar/tabs/AssetsSidebarTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@
<MediaAssetSelectionBar
v-if="hasSelection"
:count="totalOutputCount"
:show-delete="shouldShowDeleteButton"
:show-delete="shouldShowSelectionDeleteButton"
@deselect="handleDeselectAll"
@download="handleDownloadSelected"
@delete="handleDeleteSelected"
Expand All @@ -163,7 +163,6 @@
:asset="contextMenuAsset"
:asset-type="contextMenuAssetType"
:file-kind="contextMenuFileKind"
:show-delete-button="shouldShowDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
@zoom="handleZoomClick(contextMenuAsset)"
Expand Down Expand Up @@ -268,7 +267,7 @@ const contextMenuAsset = ref<AssetItem | null>(null)

// Determine if delete button should be shown
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
const shouldShowDeleteButton = computed(() => {
const shouldShowSelectionDeleteButton = computed(() => {
if (activeTab.value === 'input' && !isCloud) return false
return true
})
Expand Down
24 changes: 21 additions & 3 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -3768,11 +3768,27 @@
"mediaAsset": {
"deleteAssetTitle": "Delete this asset?",
"deleteAssetDescription": "This asset will be permanently removed.",
"deleteGeneratedSourceFileTitle": "Delete this source file?",
"deleteGeneratedSourceFileDescription": "This file will be permanently deleted from disk and removed from Generated. This action cannot be undone.",
"deleteSelectedGeneratedSourceFilesTitle": "Delete selected source files?",
"deleteSelectedGeneratedSourceFilesDescription": "{count} file will be permanently deleted from disk and removed from Generated. This action cannot be undone. | {count} files will be permanently deleted from disk and removed from Generated. This action cannot be undone.",
"deleteSelectedTitle": "Delete selected assets?",
"deleteSelectedDescription": "{count} asset(s) will be permanently removed.",
"deleteSelectedDescription": "{count} asset will be permanently removed. | {count} assets will be permanently removed.",
"removeGeneratedAssetTitle": "Remove this asset from Generated?",
"removeGeneratedAssetDescription": "This asset will be removed from Generated. The file on disk will be kept.",
"removeSelectedGeneratedAssetsTitle": "Remove selected assets from Generated?",
"removeSelectedGeneratedAssetsDescription": "{count} asset will be removed from Generated. The file on disk will be kept. | {count} assets will be removed from Generated. The files on disk will be kept.",
"generatedAssetRemovedSuccessfully": "Removed from Generated; file kept on disk",
"selectedGeneratedAssetsRemovedSuccessfully": "{count} asset removed from Generated; file kept on disk | {count} assets removed from Generated; files kept on disk",
"assetDeletedSuccessfully": "Asset deleted successfully",
"deletingImportedFilesCloudOnly": "Deleting imported files is only supported in cloud version",
"failedToDeleteAsset": "Failed to delete asset",
"fileLocationOpened": "Opened the file location",
"failedToOpenFileLocation": "Failed to open the file location. Use ComfyUI from this computer and try again.",
"errors": {
"invalidLocalInputAssetPath": "Invalid local input asset path: {path}",
"failedToOpenAssetLocation": "Unable to open asset location {id}: Server returned {status}",
"failedToResolveLocalInputAsset": "Unable to resolve local input asset {path}: found {count} matching records"
},
"actions": {
"inspect": "Inspect asset",
"more": "More options",
Expand All @@ -3781,10 +3797,12 @@
"seeMoreOutputs": "See more outputs",
"insertAsNodeInWorkflow": "Insert as node in workflow",
"download": "Download",
"openFileLocation": "Open file location",
"openWorkflow": "Open as workflow in new tab",
"exportWorkflow": "Export workflow",
"copyJobId": "Copy job ID",
"delete": "Delete"
"delete": "Delete",
"deleteSourceFile": "Delete source file"
},
"jobIdToast": {
"jobIdCopied": "Job ID copied to clipboard",
Expand Down
226 changes: 221 additions & 5 deletions src/platform/assets/components/MediaAssetContextMenu.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { render } from '@testing-library/vue'
import type { MenuItem } from 'primevue/menuitem'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PropType } from 'vue'
import { defineComponent, nextTick, onMounted, ref } from 'vue'

import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { api } from '@/scripts/api'
import type * as FormatUtil from '@/utils/formatUtil'

const mockIsLoopbackHost = vi.hoisted(() => vi.fn(() => true))
const mockShouldSkipDeleteConfirmation = vi.hoisted(() => vi.fn(() => true))

vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
Expand All @@ -27,16 +31,22 @@ vi.mock('@/utils/formatUtil', async (importOriginal) => ({
isPreviewableMediaType: () => true
}))

vi.mock('@/utils/hostWhitelist', () => ({
isLoopbackHost: mockIsLoopbackHost
}))

const mediaAssetActions = {
addWorkflow: vi.fn(),
downloadAssets: vi.fn(),
openAssetLocation: vi.fn(),
openWorkflow: vi.fn(),
exportWorkflow: vi.fn(),
copyJobId: vi.fn(),
deleteAssets: vi.fn().mockResolvedValue(false)
}

vi.mock('../composables/useMediaAssetActions', () => ({
shouldSkipDeleteConfirmation: mockShouldSkipDeleteConfirmation,
useMediaAssetActions: () => mediaAssetActions
}))

Expand Down Expand Up @@ -93,6 +103,12 @@ const asset: AssetItem = {
user_metadata: {}
}

const persistentOutput: AssetItem = {
...asset,
tags: ['output'],
loader_path: 'video/render.mp4'
}

const buttonStub = {
template: '<div class="button-stub"><slot /></div>'
}
Expand All @@ -103,8 +119,13 @@ interface MediaAssetContextMenuExposed {

let capturedRef: MediaAssetContextMenuExposed | null = null

function mountComponent(targetAsset: AssetItem = asset) {
function mountComponent(
targetAsset: AssetItem = asset,
assetType: 'input' | 'output' = 'output',
showDeleteButton = true
) {
const onHide = vi.fn()
const onAssetDeleted = vi.fn()
const { container, unmount } = render(
defineComponent({
components: { MediaAssetContextMenu },
Expand All @@ -113,10 +134,17 @@ function mountComponent(targetAsset: AssetItem = asset) {
onMounted(() => {
capturedRef = menuRef.value
})
return { menuRef, asset: targetAsset, onHide }
return {
menuRef,
asset: targetAsset,
assetType,
showDeleteButton,
onHide,
onAssetDeleted
}
},
template:
'<MediaAssetContextMenu ref="menuRef" :asset="asset" asset-type="output" file-kind="image" @hide="onHide" />'
'<MediaAssetContextMenu ref="menuRef" :asset="asset" :asset-type="assetType" :show-delete-button="showDeleteButton" file-kind="image" @hide="onHide" @asset-deleted="onAssetDeleted" />'
}),
{
global: {
Expand All @@ -127,7 +155,7 @@ function mountComponent(targetAsset: AssetItem = asset) {
}
}
)
return { container, unmount, onHide }
return { container, unmount, onHide, onAssetDeleted }
}

async function showMenu(container: Element): Promise<HTMLElement> {
Expand All @@ -138,7 +166,14 @@ async function showMenu(container: Element): Promise<HTMLElement> {
return container.querySelector('.context-menu-stub') as HTMLElement
}

beforeEach(() => {
api.serverFeatureFlags.value = { assets: true }
mockIsLoopbackHost.mockReturnValue(true)
mockShouldSkipDeleteConfirmation.mockReturnValue(true)
})

afterEach(() => {
api.serverFeatureFlags.value = {}
capturedRef = null
capturedMenu.model = []
document.body.innerHTML = ''
Expand Down Expand Up @@ -222,4 +257,185 @@ describe('MediaAssetContextMenu', () => {

unmount()
})

it('hides Copy Job ID for persistent outputs without provenance', async () => {
const { container, unmount } = mountComponent({
...asset,
tags: ['output'],
loader_path: 'video/old-output.mp4'
})
await showMenu(container)

expect(findMenuItem('mediaAsset.actions.copyJobId')).toBeUndefined()

unmount()
})

it('shows Copy Job ID for persistent outputs with provenance', async () => {
const { container, unmount } = mountComponent({
...asset,
tags: ['output'],
loader_path: 'video/new-output.mp4',
job_id: 'prompt-123'
})
await showMenu(container)

expect(findMenuItem('mediaAsset.actions.copyJobId')).toBeDefined()

unmount()
})

it('separates local input deletion from download', async () => {
const { container, unmount } = mountComponent(asset, 'input')
await showMenu(container)

const downloadIndex = capturedMenu.model.findIndex(
(item) => item.label === 'mediaAsset.actions.download'
)
const deleteIndex = capturedMenu.model.findIndex(
(item) => item.label === 'mediaAsset.actions.delete'
)
expect(capturedMenu.model[downloadIndex + 1]?.separator).toBe(true)
expect(deleteIndex).toBe(downloadIndex + 2)

const deleteItem = findMenuItem('mediaAsset.actions.delete')
if (!deleteItem?.command) throw new Error('Delete command is missing')
await deleteItem.command({
originalEvent: new MouseEvent('click'),
item: deleteItem
})
expect(mediaAssetActions.deleteAssets).toHaveBeenCalledWith(asset, {
skipConfirmation: true
})
expect(mockShouldSkipDeleteConfirmation).toHaveBeenCalledWith(asset)

unmount()
})

it('hides local input deletion when the asset API is disabled', async () => {
api.serverFeatureFlags.value = {}
const { container, unmount } = mountComponent(asset, 'input')
await showMenu(container)

expect(findMenuItem('mediaAsset.actions.delete')).toBeUndefined()

unmount()
})

it('shows local input deletion when the asset API flag arrives', async () => {
api.serverFeatureFlags.value = {}
const { container, unmount } = mountComponent(asset, 'input')
await showMenu(container)

expect(findMenuItem('mediaAsset.actions.delete')).toBeUndefined()

api.serverFeatureFlags.value = { assets: true }
await nextTick()

expect(findMenuItem('mediaAsset.actions.delete')).toBeDefined()

unmount()
})

it('hides delete actions when deletion is disabled by the caller', async () => {
const { container, unmount } = mountComponent(asset, 'output', false)
await showMenu(container)

expect(findMenuItem('mediaAsset.actions.delete')).toBeUndefined()
expect(findMenuItem('mediaAsset.actions.deleteSourceFile')).toBeUndefined()

unmount()
})

it('orders local generated file actions after download', async () => {
const { container, unmount } = mountComponent(persistentOutput)
await showMenu(container)

const labels = capturedMenu.model.map((item) =>
item.separator ? 'separator' : item.label
)
const downloadIndex = labels.indexOf('mediaAsset.actions.download')
expect(labels.slice(downloadIndex, downloadIndex + 5)).toEqual([
'mediaAsset.actions.download',
'mediaAsset.actions.openFileLocation',
'separator',
'mediaAsset.actions.delete',
'mediaAsset.actions.deleteSourceFile'
])
Comment thread
coderabbitai[bot] marked this conversation as resolved.

unmount()
})

it('opens the location of a local generated file', async () => {
const { container, unmount } = mountComponent(persistentOutput)
await showMenu(container)

const openLocationItem = findMenuItem('mediaAsset.actions.openFileLocation')
if (!openLocationItem?.command) {
throw new Error('Open-location command is missing')
}
openLocationItem.command({
originalEvent: new MouseEvent('click'),
item: openLocationItem
})
expect(mediaAssetActions.openAssetLocation).toHaveBeenCalledWith(
persistentOutput
)

unmount()
})

it('removes a local generated asset without deleting its source', async () => {
const { container, unmount, onAssetDeleted } =
mountComponent(persistentOutput)
await showMenu(container)

mediaAssetActions.deleteAssets.mockResolvedValueOnce(true)
const deleteItem = findMenuItem('mediaAsset.actions.delete')
if (!deleteItem?.command) throw new Error('Delete command is missing')
await deleteItem.command({
originalEvent: new MouseEvent('click'),
item: deleteItem
})
expect(mediaAssetActions.deleteAssets).toHaveBeenLastCalledWith(
persistentOutput,
{ skipConfirmation: true }
)
expect(onAssetDeleted).toHaveBeenCalledOnce()

unmount()
})

it('deletes the source file of a local generated asset', async () => {
const { container, unmount, onAssetDeleted } =
mountComponent(persistentOutput)
await showMenu(container)

mediaAssetActions.deleteAssets.mockResolvedValueOnce(true)
const deleteSourceItem = findMenuItem('mediaAsset.actions.deleteSourceFile')
if (!deleteSourceItem?.command) {
throw new Error('Delete-source command is missing')
}
await deleteSourceItem.command({
originalEvent: new MouseEvent('click'),
item: deleteSourceItem
})
expect(mediaAssetActions.deleteAssets).toHaveBeenLastCalledWith(
persistentOutput,
{ deleteContent: true }
)
expect(onAssetDeleted).toHaveBeenCalledOnce()

unmount()
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('hides open file location on non-loopback hosts', async () => {
mockIsLoopbackHost.mockReturnValue(false)
const { container, unmount } = mountComponent(persistentOutput)
await showMenu(container)

expect(findMenuItem('mediaAsset.actions.openFileLocation')).toBeUndefined()

unmount()
})
})
Loading
Loading