mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 18:52:19 +00:00
Add expandable output stacks to assets list view (#8283)
Add expandable output stacks to the assets list view. Monolith ver. of https://github.com/Comfy-Org/ComfyUI_frontend/pull/8298 and its children List view currently collapses multi-output jobs into a single row, which makes sibling outputs easy to miss and causes selection/zoom behavior to drift once items are expanded elsewhere. This change adds a stack toggle to list rows, expands child outputs derived from job data, and keeps list-view selection and gallery navigation aligned with the expanded list. Output mapping and “load full outputs” checks are centralized so folder view and stacks share the same helper, and job-detail parsing now yields previewable outputs for the list view. Asset actions now prefer metadata prompt IDs to support the composite IDs used by stacked outputs. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8283-Add-expandable-output-stacks-to-assets-list-view-2f16d73d365081a99fc6f1519ac2e57c) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
This commit is contained in:
@@ -53,7 +53,7 @@
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
/>
|
||||
<div
|
||||
v-if="isQueuePanelV2Enabled"
|
||||
v-if="isQueuePanelV2Enabled && !isInFolderView"
|
||||
class="flex items-center justify-between px-2 py-2 2xl:px-4"
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
@@ -98,8 +98,11 @@
|
||||
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
|
||||
<AssetsSidebarListView
|
||||
v-if="isListView"
|
||||
:assets="displayAssets"
|
||||
:asset-items="listViewAssetItems"
|
||||
:is-selected="isSelected"
|
||||
:selectable-assets="listViewSelectableAssets"
|
||||
:is-stack-expanded="isListViewStackExpanded"
|
||||
:toggle-stack="toggleListViewStack"
|
||||
:asset-type="activeTab"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@@ -109,6 +112,7 @@
|
||||
v-else
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
:is-in-folder-view="isInFolderView"
|
||||
:asset-type="activeTab"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
@@ -230,12 +234,13 @@ import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAsse
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { getJobDetail } from '@/services/jobOutputCache'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -243,12 +248,6 @@ import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface JobOutputItem {
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const { t, n } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
@@ -323,6 +322,7 @@ const {
|
||||
hasSelection,
|
||||
clearSelection,
|
||||
getSelectedAssets,
|
||||
reconcileSelection,
|
||||
getOutputCount,
|
||||
getTotalOutputCount,
|
||||
activate: activateSelection,
|
||||
@@ -392,7 +392,21 @@ const displayAssets = computed(() => {
|
||||
return filteredAssets.value
|
||||
})
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(displayAssets.value))
|
||||
const {
|
||||
assetItems: listViewAssetItems,
|
||||
selectableAssets: listViewSelectableAssets,
|
||||
isStackExpanded: isListViewStackExpanded,
|
||||
toggleStack: toggleListViewStack
|
||||
} = useOutputStacks({
|
||||
assets: computed(() => displayAssets.value)
|
||||
})
|
||||
|
||||
const visibleAssets = computed(() => {
|
||||
if (!isListView.value) return displayAssets.value
|
||||
return listViewSelectableAssets.value
|
||||
})
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
|
||||
|
||||
const isBulkMode = computed(
|
||||
() => hasSelection.value && selectedAssets.value.length > 1
|
||||
@@ -412,7 +426,10 @@ const showEmptyState = computed(
|
||||
activeJobsCount.value === 0
|
||||
)
|
||||
|
||||
watch(displayAssets, (newAssets) => {
|
||||
watch(visibleAssets, (newAssets) => {
|
||||
// Alternative: keep hidden selections and surface them in UI; for now prune
|
||||
// so selection stays consistent with what this view can act on.
|
||||
reconcileSelection(newAssets)
|
||||
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
|
||||
const newIndex = newAssets.findIndex(
|
||||
(asset) => asset.id === currentGalleryAssetId.value
|
||||
@@ -430,7 +447,7 @@ watch(galleryActiveIndex, (index) => {
|
||||
})
|
||||
|
||||
const galleryItems = computed(() => {
|
||||
return displayAssets.value.map((asset) => {
|
||||
return visibleAssets.value.map((asset) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
const resultItem = new ResultItemImpl({
|
||||
filename: asset.name,
|
||||
@@ -470,9 +487,10 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const handleAssetSelect = (asset: AssetItem) => {
|
||||
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
|
||||
handleAssetClick(asset, index, displayAssets.value)
|
||||
function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
|
||||
const assetList = assets ?? visibleAssets.value
|
||||
const index = assetList.findIndex((a) => a.id === asset.id)
|
||||
handleAssetClick(asset, index, assetList)
|
||||
}
|
||||
|
||||
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
|
||||
@@ -557,7 +575,7 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
}
|
||||
|
||||
currentGalleryAssetId.value = asset.id
|
||||
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
|
||||
const index = visibleAssets.value.findIndex((a) => a.id === asset.id)
|
||||
if (index !== -1) {
|
||||
galleryActiveIndex.value = index
|
||||
}
|
||||
@@ -570,7 +588,7 @@ const enterFolderView = async (asset: AssetItem) => {
|
||||
return
|
||||
}
|
||||
|
||||
const { promptId, allOutputs, executionTimeInSeconds, outputCount } = metadata
|
||||
const { promptId, executionTimeInSeconds } = metadata
|
||||
|
||||
if (!promptId) {
|
||||
console.warn('Missing required folder view data')
|
||||
@@ -580,62 +598,21 @@ const enterFolderView = async (asset: AssetItem) => {
|
||||
folderPromptId.value = promptId
|
||||
folderExecutionTime.value = executionTimeInSeconds
|
||||
|
||||
// Determine which outputs to display
|
||||
let outputsToDisplay = allOutputs ?? []
|
||||
|
||||
// If outputCount indicates more outputs than we have, fetch full outputs
|
||||
const needsFullOutputs =
|
||||
typeof outputCount === 'number' &&
|
||||
outputCount > 1 &&
|
||||
outputsToDisplay.length < outputCount
|
||||
|
||||
if (needsFullOutputs) {
|
||||
try {
|
||||
const jobDetail = await getJobDetail(promptId)
|
||||
if (jobDetail?.outputs) {
|
||||
// Convert job outputs to ResultItemImpl array
|
||||
outputsToDisplay = Object.entries(jobDetail.outputs).flatMap(
|
||||
([nodeId, nodeOutputs]) =>
|
||||
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
|
||||
(items as JobOutputItem[])
|
||||
.map(
|
||||
(item) =>
|
||||
new ResultItemImpl({
|
||||
...item,
|
||||
nodeId,
|
||||
mediaType
|
||||
})
|
||||
)
|
||||
.filter((r) => r.supportsPreview)
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch job detail for folder view:', error)
|
||||
outputsToDisplay = []
|
||||
}
|
||||
let folderItems: AssetItem[] = []
|
||||
try {
|
||||
folderItems = await resolveOutputAssetItems(metadata, {
|
||||
createdAt: asset.created_at
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve outputs for folder view:', error)
|
||||
}
|
||||
|
||||
if (outputsToDisplay.length === 0) {
|
||||
if (folderItems.length === 0) {
|
||||
console.warn('No outputs available for folder view')
|
||||
return
|
||||
}
|
||||
|
||||
folderAssets.value = outputsToDisplay.map((output) => ({
|
||||
id: `${output.nodeId}-${output.filename}`,
|
||||
name: output.filename,
|
||||
size: 0,
|
||||
created_at: asset.created_at,
|
||||
tags: ['output'],
|
||||
preview_url: output.url,
|
||||
user_metadata: {
|
||||
promptId,
|
||||
nodeId: output.nodeId,
|
||||
subfolder: output.subfolder,
|
||||
executionTimeInSeconds,
|
||||
workflow: metadata.workflow
|
||||
}
|
||||
}))
|
||||
folderAssets.value = folderItems
|
||||
}
|
||||
|
||||
const exitFolderView = () => {
|
||||
|
||||
Reference in New Issue
Block a user