Compare commits

...

7 Commits

Author SHA1 Message Date
Benjamin Lu
45e0e8534d Merge branch 'main' into test/tier2-e2e 2026-04-04 12:34:19 -07:00
Benjamin Lu
aeafff1ead fix: virtualize cloud job queue history list (#10592)
## Summary

Virtualize the shared job queue history list so opening the jobs panel
does not eagerly mount the full history on cloud.

## Changes

- **What**: Virtualize the shared queue history list used by the overlay
and sidebar, flatten date headers plus job rows into a single virtual
stream, and preserve hover/menu behavior with updated queue list tests.
- **Why `@tanstack/vue-virtual` instead of Reka virtualizers**: the
installed `reka-ui@2.5.0` does not expose a generic list virtualizer. It
only exposes `ListboxVirtualizer`, `ComboboxVirtualizer`, and
`TreeVirtualizer`, and those components inject `ListboxRoot`/`TreeRoot`
context and carry listbox or tree selection/keyboard semantics. The job
history UI is a flat grouped action list, not a selectable listbox or
navigable tree, so this uses the same TanStack virtualizer layer
directly without forcing the wrong semantics onto the component.

## Review Focus

Please verify the virtual row sizing and inter-group spacing behavior
across date headers and the last row in each group.

> [!TIP]
> Diff reads much cleaner through vscode's unified view with show
leading/trailing whitespace differences enabled

Linear: COM-304

https://tanstack.com/virtual/latest/docs/api/virtualizer

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10592-fix-virtualize-cloud-job-queue-history-list-3306d73d3650819d956bf4b2d8b59a40)
by [Unito](https://www.unito.io)
2026-04-04 12:33:47 -07:00
Johnpaul Chiwetelu
f4fb7a458e test: add browser tests for selection toolbox button actions (#10764) 2026-04-04 14:03:50 +01:00
Yourz
71a3bd92b4 fix: add delete/bookmark actions for blueprints in V2 node library sidebar (#10827)
## Summary

Add missing delete and bookmark actions for user blueprints in the V2
node library sidebar, fixing parity with the V1 sidebar.

## Changes

- **What**: 
- Add delete button (inline + context menu) for user blueprints in
`TreeExplorerV2Node` and `TreeExplorerV2`
- Extract `isUserBlueprint()` helper in `subgraphStore` for DRY usage
across V1/V2 sidebars


![Kapture 2026-04-03 at 00 12
09](https://github.com/user-attachments/assets/3f1f3f41-ed2b-4250-953f-511d39e54e45)

## Review Focus

- `isUserBlueprint` consolidates logic previously duplicated between
`NodeTreeLeaf` and the new V2 components
- Context menu guard `contextMenuNode?.data` prevents showing empty
menus
- Folder `@contextmenu` handler clears stale `contextMenuNode` to
prevent wrong actions

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10827-fix-add-delete-bookmark-actions-for-blueprints-in-V2-node-library-sidebar-3366d73d36508111afd2c2c7d8ff0220)
by [Unito](https://www.unito.io)
2026-04-04 20:14:32 +08:00
dante01yoon
b6038b071e fix(test): use setupWorkflowsDirectory for faster workflow search setup
Replace saveWorkflow + NewBlankWorkflow (slow UI interactions that
timeout in 15s beforeEach) with setupWorkflowsDirectory which
populates the file system directly before page load.
2026-04-01 22:12:27 +09:00
dante01yoon
0f742ccab9 fix(test): fix workflow search locators and state isolation
- Use .node-label scoped to workflows-sidebar for search results
  (search panel uses different DOM subtree than browse panel)
- Use parallelIndex-suffixed workflow names to avoid overwrite
  dialogs across parallel test workers
2026-04-01 20:43:29 +09:00
dante01yoon
0dbaf533b5 test: add queue overlay and workflow search E2E tests
- Queue overlay: toggle, filter tabs, completed filter, close (5 tests)
- Workflow search: search input, filter by name, clear, no matches (4 tests)
- AssetsHelper: fix timestamps to milliseconds, type response with
  JobsListResponse pagination from @comfyorg/ingest-types
2026-04-01 19:02:10 +09:00
24 changed files with 1050 additions and 390 deletions

View File

@@ -1,5 +1,7 @@
import type { Page, Route } from '@playwright/test'
import type { JobsListResponse } from '@comfyorg/ingest-types'
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
@@ -9,12 +11,12 @@ const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
export function createMockJob(
overrides: Partial<RawJobListItem> & { id: string }
): RawJobListItem {
const now = Date.now() / 1000
const now = Date.now()
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5,
execution_end_time: now + 5000,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
@@ -33,13 +35,13 @@ export function createMockJobs(
count: number,
baseOverrides?: Partial<RawJobListItem>
): RawJobListItem[] {
const now = Date.now() / 1000
const now = Date.now()
return Array.from({ length: count }, (_, i) =>
createMockJob({
id: `job-${String(i + 1).padStart(3, '0')}`,
create_time: now - i * 60,
execution_start_time: now - i * 60,
execution_end_time: now - i * 60 + 5 + i,
create_time: now - i * 60_000,
execution_start_time: now - i * 60_000,
execution_end_time: now - i * 60_000 + 5000 + i * 1000,
preview_output: {
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
subfolder: '',
@@ -143,18 +145,24 @@ export class AssetsHelper {
const limit = parseLimit(url, total)
const visibleJobs = filteredJobs.slice(offset, offset + limit)
// Response shape matches JobsListResponse from @comfyorg/ingest-types
const response = {
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
} satisfies {
jobs: unknown[]
pagination: JobsListResponse['pagination']
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
})
body: JSON.stringify(response)
})
}

View File

@@ -0,0 +1,118 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { createMockJob } from '../../fixtures/helpers/AssetsHelper'
import { TestIds } from '../../fixtures/selectors'
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
const now = Date.now()
const MOCK_JOBS: RawJobListItem[] = [
createMockJob({
id: 'job-completed-1',
status: 'completed',
create_time: now - 60_000,
execution_start_time: now - 60_000,
execution_end_time: now - 50_000,
outputs_count: 2
}),
createMockJob({
id: 'job-completed-2',
status: 'completed',
create_time: now - 120_000,
execution_start_time: now - 120_000,
execution_end_time: now - 115_000,
outputs_count: 1
}),
createMockJob({
id: 'job-failed-1',
status: 'failed',
create_time: now - 30_000,
execution_start_time: now - 30_000,
execution_end_time: now - 28_000,
outputs_count: 0
})
]
test.describe('Queue overlay', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(MOCK_JOBS)
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Toggle button opens expanded queue overlay', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
// Expanded overlay should show job items
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible({
timeout: 5000
})
})
test('Overlay shows filter tabs (All, Completed)', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
await expect(
comfyPage.page.getByRole('button', { name: 'All', exact: true })
).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.getByRole('button', { name: 'Completed', exact: true })
).toBeVisible()
})
test('Overlay shows Failed tab when failed jobs exist', async ({
comfyPage
}) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible({
timeout: 5000
})
await expect(
comfyPage.page.getByRole('button', { name: 'Failed', exact: true })
).toBeVisible()
})
test('Completed filter shows only completed jobs', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible({
timeout: 5000
})
await comfyPage.page
.getByRole('button', { name: 'Completed', exact: true })
.click()
await expect(
comfyPage.page.locator('[data-job-id="job-completed-1"]')
).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.locator('[data-job-id="job-failed-1"]')
).not.toBeVisible()
})
test('Toggling overlay again closes it', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible({
timeout: 5000
})
await toggle.click()
await expect(
comfyPage.page.locator('[data-job-id]').first()
).not.toBeVisible({ timeout: 5000 })
})
})

View File

@@ -1,9 +1,20 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
const BYPASS_CLASS = /before:bg-bypass\/60/
function getNodeWrapper(comfyPage: ComfyPage, nodeTitle: string): Locator {
return comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: nodeTitle })
.getByTestId('node-inner-wrapper')
}
async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
const nodePos = await nodeRef.getPosition()
@@ -36,7 +47,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
() => window.app!.graph!._nodes.length
)
const deleteButton = comfyPage.page.locator('[data-testid="delete-button"]')
const deleteButton = comfyPage.page.getByTestId('delete-button')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await comfyPage.nextFrame()
@@ -51,14 +62,12 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
const infoButton = comfyPage.page.locator('[data-testid="info-button"]')
const infoButton = comfyPage.page.getByTestId('info-button')
await expect(infoButton).toBeVisible()
await infoButton.click({ force: true })
await comfyPage.nextFrame()
await expect(
comfyPage.page.locator('[data-testid="properties-panel"]')
).toBeVisible()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('convert-to-subgraph button visible with multi-select', async ({
@@ -71,7 +80,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
await comfyPage.nextFrame()
await expect(
comfyPage.page.locator('[data-testid="convert-to-subgraph-button"]')
comfyPage.page.getByTestId('convert-to-subgraph-button')
).toBeVisible()
})
@@ -88,7 +97,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
() => window.app!.graph!._nodes.length
)
const deleteButton = comfyPage.page.locator('[data-testid="delete-button"]')
const deleteButton = comfyPage.page.getByTestId('delete-button')
await expect(deleteButton).toBeVisible()
await deleteButton.click({ force: true })
await comfyPage.nextFrame()
@@ -98,4 +107,152 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
)
expect(newCount).toBe(initialCount - 2)
})
test('bypass button toggles bypass on single node', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.vueNodes.waitForNodes()
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
expect(await nodeRef.isBypassed()).toBe(false)
const bypassButton = comfyPage.page.getByTestId('bypass-button')
await expect(bypassButton).toBeVisible()
await bypassButton.click({ force: true })
await comfyPage.nextFrame()
expect(await nodeRef.isBypassed()).toBe(true)
await expect(getNodeWrapper(comfyPage, 'KSampler')).toHaveClass(
BYPASS_CLASS
)
await bypassButton.click({ force: true })
await comfyPage.nextFrame()
expect(await nodeRef.isBypassed()).toBe(false)
await expect(getNodeWrapper(comfyPage, 'KSampler')).not.toHaveClass(
BYPASS_CLASS
)
})
test('convert-to-subgraph button converts node to subgraph', async ({
comfyPage
}) => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
const convertButton = comfyPage.page.getByTestId(
'convert-to-subgraph-button'
)
await expect(convertButton).toBeVisible()
await convertButton.click({ force: true })
await comfyPage.nextFrame()
// KSampler should be gone, replaced by a subgraph node
await expect
.poll(() => comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))
.toHaveLength(0)
await expect
.poll(() => comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph'))
.toHaveLength(1)
})
test('convert-to-subgraph button converts multiple nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
const convertButton = comfyPage.page.getByTestId(
'convert-to-subgraph-button'
)
await expect(convertButton).toBeVisible()
await convertButton.click({ force: true })
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getNodeRefsByTitle('New Subgraph'))
.toHaveLength(1)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount - 1)
})
test('frame nodes button creates group from multiple selected nodes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
const initialGroupCount = await comfyPage.page.evaluate(
() => window.app!.graph.groups.length
)
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
await comfyPage.nextFrame()
const frameButton = comfyPage.page.getByRole('button', {
name: /Frame Nodes/i
})
await expect(frameButton).toBeVisible()
await frameButton.click({ force: true })
await comfyPage.nextFrame()
await expect
.poll(() =>
comfyPage.page.evaluate(() => window.app!.graph.groups.length)
)
.toBe(initialGroupCount + 1)
})
test('frame nodes button is not visible for single selection', async ({
comfyPage
}) => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
const frameButton = comfyPage.page.getByRole('button', {
name: /Frame Nodes/i
})
await expect(frameButton).not.toBeVisible()
})
test('execute button visible when output node selected', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
// Select the SaveImage node by panning to it
const saveImageRef = (
await comfyPage.nodeOps.getNodeRefsByTitle('Save Image')
)[0]
await selectNodeWithPan(comfyPage, saveImageRef)
const executeButton = comfyPage.page.getByRole('button', {
name: /Execute to selected output nodes/i
})
await expect(executeButton).toBeVisible()
})
test('execute button not visible when non-output node selected', async ({
comfyPage
}) => {
const nodeRef = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
await selectNodeWithPan(comfyPage, nodeRef)
const executeButton = comfyPage.page.getByRole('button', {
name: /Execute to selected output nodes/i
})
await expect(executeButton).not.toBeVisible()
})
})

View File

@@ -0,0 +1,77 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
/** Locate a workflow label in whatever panel is visible (browse or search). */
function findWorkflow(page: Page, name: string) {
return page
.getByTestId('workflows-sidebar')
.locator('.node-label', { hasText: name })
}
test.describe('Workflow sidebar - search', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({
'alpha-workflow.json': 'default.json',
'beta-workflow.json': 'default.json'
})
})
test('Search input is visible in workflows tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
await expect(
comfyPage.page.getByPlaceholder('Search Workflow...')
).toBeVisible()
})
test('Search filters saved workflows by name', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
await searchInput.fill('alpha')
await expect(findWorkflow(comfyPage.page, 'alpha-workflow')).toBeVisible({
timeout: 5000
})
await expect(
findWorkflow(comfyPage.page, 'beta-workflow')
).not.toBeVisible()
})
test('Clearing search restores all workflows', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
await searchInput.fill('alpha')
await expect(
findWorkflow(comfyPage.page, 'beta-workflow')
).not.toBeVisible()
await searchInput.fill('')
await expect(tab.getPersistedItem('alpha-workflow')).toBeVisible({
timeout: 5000
})
await expect(tab.getPersistedItem('beta-workflow')).toBeVisible()
})
test('Search with no matches shows empty results', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
const searchInput = comfyPage.page.getByPlaceholder('Search Workflow...')
await searchInput.fill('nonexistent_xyz')
await expect(
findWorkflow(comfyPage.page, 'alpha-workflow')
).not.toBeVisible()
await expect(
findWorkflow(comfyPage.page, 'beta-workflow')
).not.toBeVisible()
})
})

View File

@@ -73,6 +73,7 @@
"@primevue/themes": "catalog:",
"@sentry/vue": "catalog:",
"@sparkjsdev/spark": "catalog:",
"@tanstack/vue-virtual": "catalog:",
"@tiptap/core": "catalog:",
"@tiptap/extension-link": "catalog:",
"@tiptap/extension-table": "catalog:",

View File

@@ -16,7 +16,7 @@
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver-ai}]");
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");

View File

Before

Width:  |  Height:  |  Size: 534 B

After

Width:  |  Height:  |  Size: 534 B

6
pnpm-lock.yaml generated
View File

@@ -102,6 +102,9 @@ catalogs:
'@tailwindcss/vite':
specifier: ^4.2.0
version: 4.2.0
'@tanstack/vue-virtual':
specifier: ^3.13.12
version: 3.13.12
'@testing-library/jest-dom':
specifier: ^6.9.1
version: 6.9.1
@@ -458,6 +461,9 @@ importers:
'@sparkjsdev/spark':
specifier: 'catalog:'
version: 0.1.10
'@tanstack/vue-virtual':
specifier: 'catalog:'
version: 3.13.12(vue@3.5.13(typescript@5.9.3))
'@tiptap/core':
specifier: 'catalog:'
version: 2.27.2(@tiptap/pm@2.27.2)

View File

@@ -30,6 +30,7 @@ catalog:
'@sentry/vite-plugin': ^4.6.0
'@sentry/vue': ^10.32.1
'@sparkjsdev/spark': ^0.1.10
'@tanstack/vue-virtual': ^3.13.12
'@storybook/addon-docs': ^10.2.10
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.2.10

View File

@@ -37,13 +37,13 @@
</TreeRoot>
</ContextMenuTrigger>
<ContextMenuPortal v-if="showContextMenu">
<ContextMenuPortal v-if="showContextMenu && contextMenuNode?.data">
<ContextMenuContent
class="z-9999 min-w-32 overflow-hidden rounded-md border border-border-default bg-comfy-menu-bg p-1 shadow-md"
>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-highlight focus:bg-highlight"
@select="handleAddToFavorites"
@select="handleToggleBookmark"
>
<i
:class="
@@ -59,6 +59,14 @@
: $t('sideToolbar.nodeLibraryTab.sections.favoriteNode')
}}
</ContextMenuItem>
<ContextMenuItem
v-if="isCurrentNodeUserBlueprint"
class="text-destructive flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-highlight focus:bg-highlight"
@select="handleDeleteBlueprint"
>
<i class="icon-[lucide--trash-2] size-4" />
{{ $t('g.delete') }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
@@ -79,6 +87,7 @@ import { computed, provide, ref } from 'vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
@@ -98,7 +107,6 @@ const emit = defineEmits<{
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
event: MouseEvent
]
addToFavorites: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
}>()
const contextMenuNode = ref<RenderedTreeExplorerNode<ComfyNodeDefImpl> | null>(
@@ -107,6 +115,7 @@ const contextMenuNode = ref<RenderedTreeExplorerNode<ComfyNodeDefImpl> | null>(
provide(InjectKeyContextMenuNode, contextMenuNode)
const nodeBookmarkStore = useNodeBookmarkStore()
const subgraphStore = useSubgraphStore()
const isCurrentNodeBookmarked = computed(() => {
const node = contextMenuNode.value
@@ -114,9 +123,21 @@ const isCurrentNodeBookmarked = computed(() => {
return nodeBookmarkStore.isBookmarked(node.data)
})
function handleAddToFavorites() {
if (contextMenuNode.value) {
emit('addToFavorites', contextMenuNode.value)
const isCurrentNodeUserBlueprint = computed(() =>
subgraphStore.isUserBlueprint(contextMenuNode.value?.data?.name)
)
function handleToggleBookmark() {
const node = contextMenuNode.value
if (node?.data) {
nodeBookmarkStore.toggleBookmark(node.data)
}
}
function handleDeleteBlueprint() {
const name = contextMenuNode.value?.data?.name
if (name) {
void subgraphStore.deleteBlueprint(name)
}
}
</script>

View File

@@ -13,7 +13,7 @@ import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
messages: { en: { g: { delete: 'Delete' } } }
})
vi.mock('@/platform/settings/settingStore', () => ({
@@ -29,6 +29,17 @@ vi.mock('@/stores/nodeBookmarkStore', () => ({
})
}))
const mockDeleteBlueprint = vi.fn()
const mockIsUserBlueprint = vi.fn().mockReturnValue(false)
vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: () => ({
isUserBlueprint: mockIsUserBlueprint,
deleteBlueprint: mockDeleteBlueprint,
typePrefix: 'SubgraphBlueprint.'
})
}))
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
default: { template: '<div />' }
}))
@@ -175,8 +186,12 @@ describe('TreeExplorerV2Node', () => {
expect(contextMenuNode.value).toEqual(nodeItem.value)
})
it('does not set contextMenuNode for folder items', async () => {
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
it('clears contextMenuNode when right-clicking a folder', async () => {
const contextMenuNode = ref<RenderedTreeExplorerNode | null>({
key: 'stale',
type: 'node',
label: 'Stale'
} as RenderedTreeExplorerNode)
const { wrapper } = mountComponent(
{ item: createMockItem('folder') },
@@ -194,6 +209,59 @@ describe('TreeExplorerV2Node', () => {
})
})
describe('blueprint actions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows delete button for user blueprints', () => {
mockIsUserBlueprint.mockReturnValue(true)
const { wrapper } = mountComponent({
item: createMockItem('node', {
data: { name: 'SubgraphBlueprint.test' }
})
})
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(true)
})
it('hides delete button for non-blueprint nodes', () => {
mockIsUserBlueprint.mockReturnValue(false)
const { wrapper } = mountComponent({
item: createMockItem('node', {
data: { name: 'KSampler' }
})
})
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(false)
})
it('always shows bookmark button', () => {
mockIsUserBlueprint.mockReturnValue(true)
const { wrapper } = mountComponent({
item: createMockItem('node', {
data: { name: 'SubgraphBlueprint.test' }
})
})
expect(wrapper.find('[aria-label="icon.bookmark"]').exists()).toBe(true)
})
it('calls deleteBlueprint when delete button is clicked', async () => {
mockIsUserBlueprint.mockReturnValue(true)
const nodeName = 'SubgraphBlueprint.test'
const { wrapper } = mountComponent({
item: createMockItem('node', {
data: { name: nodeName }
})
})
await wrapper.find('[aria-label="Delete"]').trigger('click')
expect(mockDeleteBlueprint).toHaveBeenCalledWith(nodeName)
})
})
describe('rendering', () => {
it('renders node icon for node type', () => {
const { wrapper } = mountComponent({

View File

@@ -25,25 +25,30 @@
{{ item.value.label }}
</slot>
</span>
<button
:class="
cn(
'hover:text-foreground flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-muted-foreground',
'opacity-0 group-hover/tree-node:opacity-100'
)
"
:aria-label="$t('icon.bookmark')"
@click.stop="toggleBookmark"
>
<i
:class="
cn(
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
'text-xs'
)
"
/>
</button>
<div class="flex shrink-0 items-center gap-0.5">
<button
v-if="isUserBlueprint"
:class="cn(ACTION_BTN_CLASS, 'text-destructive')"
:aria-label="$t('g.delete')"
@click.stop="deleteBlueprint"
>
<i class="icon-[lucide--trash-2] text-xs" />
</button>
<button
:class="cn(ACTION_BTN_CLASS, 'text-muted-foreground')"
:aria-label="$t('icon.bookmark')"
@click.stop="toggleBookmark"
>
<i
:class="
cn(
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
'text-xs'
)
"
/>
</button>
</div>
</div>
<!-- Folder -->
@@ -53,6 +58,7 @@
:class="cn(ROW_CLASS, isSelected && 'bg-comfy-input')"
:style="rowStyle"
@click.stop="handleClick($event, handleToggle, handleSelect)"
@contextmenu="clearContextMenuNode"
>
<i
v-if="item.hasChildren"
@@ -96,6 +102,7 @@ import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
@@ -107,6 +114,9 @@ defineOptions({
const ROW_CLASS =
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
const ACTION_BTN_CLASS =
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
const { item } = defineProps<{
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
}>()
@@ -120,6 +130,7 @@ const emit = defineEmits<{
const contextMenuNode = inject(InjectKeyContextMenuNode)
const nodeBookmarkStore = useNodeBookmarkStore()
const subgraphStore = useSubgraphStore()
const nodeDef = computed(() => item.value.data)
@@ -128,12 +139,22 @@ const isBookmarked = computed(() => {
return nodeBookmarkStore.isBookmarked(nodeDef.value)
})
const isUserBlueprint = computed(() =>
subgraphStore.isUserBlueprint(nodeDef.value?.name)
)
function toggleBookmark() {
if (nodeDef.value) {
nodeBookmarkStore.toggleBookmark(nodeDef.value)
}
}
function deleteBlueprint() {
if (nodeDef.value) {
void subgraphStore.deleteBlueprint(nodeDef.value.name)
}
}
const {
previewRef,
showPreview,
@@ -166,6 +187,12 @@ function handleContextMenu() {
}
}
function clearContextMenuNode() {
if (contextMenuNode) {
contextMenuNode.value = null
}
}
function handleMouseEnter(e: MouseEvent) {
if (item.value.type !== 'node') return
baseHandleMouseEnter(e)

View File

@@ -20,7 +20,7 @@
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
/>
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="min-h-0 flex-1">
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItemEvent"

View File

@@ -1,4 +1,3 @@
/* eslint-disable vue/one-component-per-file -- test stubs */
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- stubs lack ARIA roles; data attributes for props */
/* eslint-disable testing-library/prefer-user-event -- fireEvent needed: fake timers require fireEvent for mouseEnter/mouseLeave */
import { fireEvent, render, screen } from '@testing-library/vue'
@@ -6,21 +5,27 @@ import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import './testUtils/mockTanstackVirtualizer'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import JobAssetsList from './JobAssetsList.vue'
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined }
},
template:
'<div class="job-details-popover-stub" :data-job-id="jobId" :data-workflow-id="workflowId" />'
})
const hoisted = vi.hoisted(() => ({
jobDetailsPopoverStub: {
name: 'JobDetailsPopover',
props: {
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined }
},
template:
'<div class="job-details-popover-stub" :data-job-id="jobId" :data-workflow-id="workflowId" />'
}
}))
vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
default: hoisted.jobDetailsPopoverStub
}))
const AssetsListItemStub = defineComponent({
name: 'AssetsListItem',
@@ -65,71 +70,81 @@ vi.mock('vue-i18n', () => {
}
})
const createResultItem = (
type TestPreviewOutput = {
url: string
isImage: boolean
isVideo: boolean
}
type TestTaskRef = {
workflowId?: string
previewOutput?: TestPreviewOutput
}
type TestJobListItem = Omit<JobListItem, 'taskRef'> & {
taskRef?: TestTaskRef
}
type TestJobGroup = Omit<JobGroup, 'items'> & {
items: TestJobListItem[]
}
const createPreviewOutput = (
filename: string,
mediaType: string = 'images'
): ResultItemImpl => {
const item = new ResultItemImpl({
filename,
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType
})
Object.defineProperty(item, 'url', {
get: () => `/api/view/${filename}`
})
return item
}
const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
const job: ApiJobListItem = {
id: `task-${Math.random().toString(36).slice(2)}`,
status: 'completed',
create_time: Date.now(),
preview_output: null,
outputs_count: preview ? 1 : 0,
workflow_id: 'workflow-1',
priority: 0
): TestPreviewOutput => {
const url = `/api/view/${filename}`
return {
url,
isImage: mediaType === 'images',
isVideo: mediaType === 'video'
}
const flatOutputs = preview ? [preview] : []
return new TaskItemImpl(job, {}, flatOutputs)
}
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
const createTaskRef = (preview?: TestPreviewOutput): TestTaskRef => ({
workflowId: 'workflow-1',
...(preview && { previewOutput: preview })
})
const buildJob = (
overrides: Partial<TestJobListItem> = {}
): TestJobListItem => ({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: createTaskRef(createResultItem('job-1.png')),
taskRef: createTaskRef(createPreviewOutput('job-1.png')),
...overrides
})
function renderJobAssetsList(
jobs: JobListItem[],
callbacks: {
onViewItem?: (item: JobListItem) => void
} = {}
) {
const displayedJobGroups: JobGroup[] = [
{
key: 'group-1',
label: 'Group 1',
items: jobs
}
]
function renderJobAssetsList({
jobs = [],
displayedJobGroups,
attrs,
onViewItem
}: {
jobs?: TestJobListItem[]
displayedJobGroups?: TestJobGroup[]
attrs?: Record<string, string>
onViewItem?: (item: JobListItem) => void
} = {}) {
const user = userEvent.setup()
const result = render(JobAssetsList, {
props: {
displayedJobGroups,
...(callbacks.onViewItem && { onViewItem: callbacks.onViewItem })
displayedJobGroups: (displayedJobGroups ?? [
{
key: 'group-1',
label: 'Group 1',
items: jobs
}
]) as JobGroup[],
...(onViewItem && { onViewItem })
},
attrs,
global: {
stubs: {
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub,
AssetsListItem: AssetsListItemStub
}
}
@@ -168,10 +183,57 @@ afterEach(() => {
})
describe('JobAssetsList', () => {
it('renders grouped headers alongside job rows', () => {
const displayedJobGroups: TestJobGroup[] = [
{
key: 'today',
label: 'Today',
items: [buildJob({ id: 'job-1' })]
},
{
key: 'yesterday',
label: 'Yesterday',
items: [buildJob({ id: 'job-2', title: 'Job 2' })]
}
]
const { container } = renderJobAssetsList({ displayedJobGroups })
expect(screen.getByText('Today')).toBeTruthy()
expect(screen.getByText('Yesterday')).toBeTruthy()
expect(container.querySelector('[data-job-id="job-1"]')).not.toBeNull()
expect(container.querySelector('[data-job-id="job-2"]')).not.toBeNull()
})
it('forwards parent attrs to the scroll container', () => {
renderJobAssetsList({
attrs: {
class: 'min-h-0 flex-1'
},
displayedJobGroups: [
{
key: 'today',
label: 'Today',
items: [buildJob({ id: 'job-1' })]
}
]
})
expect(screen.getByTestId('job-assets-list').className.split(' ')).toEqual(
expect.arrayContaining([
'min-h-0',
'flex-1',
'h-full',
'overflow-y-auto',
'pb-4'
])
)
})
it('emits viewItem on preview-click for completed jobs with preview', async () => {
const job = buildJob()
const onViewItem = vi.fn()
const { user } = renderJobAssetsList([job], { onViewItem })
const { user } = renderJobAssetsList({ jobs: [job], onViewItem })
await user.click(screen.getByTestId('preview-trigger'))
@@ -181,7 +243,7 @@ describe('JobAssetsList', () => {
it('emits viewItem on double-click for completed jobs with preview', async () => {
const job = buildJob()
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
const stubRoot = container.querySelector('.assets-list-item-stub')!
await user.dblClick(stubRoot)
@@ -192,10 +254,10 @@ describe('JobAssetsList', () => {
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
taskRef: createTaskRef(createPreviewOutput('job-1.webm', 'video'))
})
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
const stubRoot = container.querySelector('.assets-list-item-stub')!
expect(stubRoot.getAttribute('data-preview-url')).toBe(
@@ -211,10 +273,10 @@ describe('JobAssetsList', () => {
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
taskRef: createTaskRef(createPreviewOutput('job-1.glb', 'model'))
})
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
const icon = container.querySelector('.assets-list-item-stub i')!
await user.click(icon)
@@ -225,10 +287,10 @@ describe('JobAssetsList', () => {
it('does not emit viewItem on double-click for non-completed jobs', async () => {
const job = buildJob({
state: 'running',
taskRef: createTaskRef(createResultItem('job-1.png'))
taskRef: createTaskRef(createPreviewOutput('job-1.png'))
})
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
const stubRoot = container.querySelector('.assets-list-item-stub')!
await user.dblClick(stubRoot)
@@ -242,7 +304,7 @@ describe('JobAssetsList', () => {
taskRef: createTaskRef()
})
const onViewItem = vi.fn()
const { container } = renderJobAssetsList([job], { onViewItem })
const { container } = renderJobAssetsList({ jobs: [job], onViewItem })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
await fireEvent.mouseEnter(jobRow)
@@ -256,7 +318,7 @@ describe('JobAssetsList', () => {
it('shows and hides the job details popover with hover delays', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList([job])
const { container } = renderJobAssetsList({ jobs: [job] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
@@ -286,7 +348,7 @@ describe('JobAssetsList', () => {
it('keeps the job details popover open while hovering the popover', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList([job])
const { container } = renderJobAssetsList({ jobs: [job] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
@@ -319,7 +381,7 @@ describe('JobAssetsList', () => {
it('positions the popover to the right of rows near the left viewport edge', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList([job])
const { container } = renderJobAssetsList({ jobs: [job] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
@@ -344,7 +406,7 @@ describe('JobAssetsList', () => {
it('positions the popover to the left of rows near the right viewport edge', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container } = renderJobAssetsList([job])
const { container } = renderJobAssetsList({ jobs: [job] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
@@ -370,7 +432,9 @@ describe('JobAssetsList', () => {
vi.useFakeTimers()
const firstJob = buildJob({ id: 'job-1' })
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
const { container } = renderJobAssetsList([firstJob, secondJob])
const { container } = renderJobAssetsList({
jobs: [firstJob, secondJob]
})
const firstRow = container.querySelector('[data-job-id="job-1"]')!
const secondRow = container.querySelector('[data-job-id="job-2"]')!
@@ -398,7 +462,9 @@ describe('JobAssetsList', () => {
vi.useFakeTimers()
const firstJob = buildJob({ id: 'job-1' })
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
const { container } = renderJobAssetsList([firstJob, secondJob])
const { container } = renderJobAssetsList({
jobs: [firstJob, secondJob]
})
const firstRow = container.querySelector('[data-job-id="job-1"]')!
const secondRow = container.querySelector('[data-job-id="job-2"]')!
@@ -429,7 +495,7 @@ describe('JobAssetsList', () => {
it('does not show details if the hovered row disappears before the show delay ends', async () => {
vi.useFakeTimers()
const job = buildJob()
const { container, rerender } = renderJobAssetsList([job])
const { container, rerender } = renderJobAssetsList({ jobs: [job] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!

View File

@@ -1,79 +1,92 @@
<template>
<div class="flex flex-col gap-4 px-3 pb-4">
<div
v-for="group in displayedJobGroups"
:key="group.key"
class="flex flex-col gap-2"
>
<div class="text-xs leading-none text-text-secondary">
{{ group.label }}
</div>
<div
v-for="job in group.items"
:key="job.id"
:data-job-id="job.id"
@mouseenter="onJobEnter(job, $event)"
@mouseleave="onJobLeave(job.id)"
>
<AssetsListItem
:class="
cn(
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
job.state === 'running' && 'bg-secondary-background'
)
"
:preview-url="getJobPreviewUrl(job)"
:is-video-preview="isVideoPreviewJob(job)"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@contextmenu.prevent.stop="$emit('menu', job, $event)"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(job)"
@click.stop
<div
ref="scrollContainer"
v-bind="$attrs"
data-testid="job-assets-list"
class="h-full overflow-y-auto pb-4"
@scroll="onListScroll"
>
<div :style="virtualWrapperStyle">
<template v-for="{ row, virtualItem } in virtualRows" :key="row.key">
<div
v-if="row.type === 'header'"
class="box-border px-3 pb-2 text-xs leading-none text-text-secondary"
:style="getVirtualRowStyle(virtualItem)"
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="isCancelable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="emitCancelItem(job)"
{{ row.label }}
</div>
<div
v-else-if="row.type === 'job'"
class="box-border px-3"
:style="getVirtualRowStyle(virtualItem)"
>
<div
:data-job-id="row.job.id"
class="h-12"
@mouseenter="onJobEnter(row.job, $event)"
@mouseleave="onJobLeave(row.job.id)"
>
<AssetsListItem
:class="
cn(
'size-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
row.job.state === 'running' && 'bg-secondary-background'
)
"
:preview-url="getJobPreviewUrl(row.job)"
:is-video-preview="isVideoPreviewJob(row.job)"
:preview-alt="row.job.title"
:icon-name="row.job.iconName ?? iconForJobState(row.job.state)"
:icon-class="getJobIconClass(row.job)"
:primary-text="row.job.title"
:secondary-text="row.job.meta"
:progress-total-percent="row.job.progressTotalPercent"
:progress-current-percent="row.job.progressCurrentPercent"
@contextmenu.prevent.stop="$emit('menu', row.job, $event)"
@dblclick.stop="emitViewItem(row.job)"
@preview-click="emitViewItem(row.job)"
@click.stop
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="emitDeleteItem(job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(job)"
>
{{ t('menuLabels.View') }}
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop="$emit('menu', job, $event)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
</div>
<template v-if="hoveredJobId === row.job.id" #actions>
<Button
v-if="isCancelable(row.job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="emitCancelItem(row.job)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(row.job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="emitDeleteItem(row.job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="row.job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(row.job)"
>
{{ t('menuLabels.View') }}
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop="$emit('menu', row.job, $event)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
</div>
</div>
</template>
</div>
</div>
@@ -97,8 +110,11 @@
</template>
<script setup lang="ts">
import type { VirtualItem } from '@tanstack/vue-virtual'
import type { CSSProperties } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useI18n } from 'vue-i18n'
import { nextTick, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
@@ -110,6 +126,17 @@ import { cn } from '@/utils/tailwindUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { isActiveJobState } from '@/utils/queueUtil'
import { buildVirtualJobRows } from './buildVirtualJobRows'
import type { VirtualJobRow } from './buildVirtualJobRows'
const HEADER_ROW_HEIGHT = 20
const GROUP_ROW_GAP = 16
const JOB_ROW_HEIGHT = 48
defineOptions({
inheritAttrs: false
})
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
const emit = defineEmits<{
@@ -120,9 +147,43 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const scrollContainer = ref<HTMLElement | null>(null)
const hoveredJobId = ref<string | null>(null)
const activeRowElement = ref<HTMLElement | null>(null)
const popoverPosition = ref<{ top: number; left: number } | null>(null)
const flatRows = computed(() => buildVirtualJobRows(displayedJobGroups))
const virtualizer = useVirtualizer({
get count(): number {
return flatRows.value.length
},
getItemKey(index: number) {
return flatRows.value[index]?.key ?? index
},
estimateSize(index: number) {
const row = flatRows.value[index]
return row ? getRowHeight(row, index, flatRows.value) : JOB_ROW_HEIGHT
},
getScrollElement() {
return scrollContainer.value
},
overscan: 12
})
const virtualRows = computed(() => {
const rows = flatRows.value
return virtualizer.value
.getVirtualItems()
.flatMap((virtualItem: VirtualItem) => {
const row = rows[virtualItem.index]
return row ? [{ row, virtualItem }] : []
})
})
const virtualWrapperStyle = computed<CSSProperties>(() => ({
position: 'relative',
width: '100%',
...(flatRows.value.length > 0 && {
height: `${virtualizer.value.getTotalSize()}px`
})
}))
const {
activeDetails,
clearHoverTimers,
@@ -135,6 +196,37 @@ const {
onReset: clearPopoverAnchor
})
function getVirtualRowStyle(virtualItem: VirtualItem): CSSProperties {
return {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
overflowAnchor: 'none'
}
}
function getRowHeight(
row: VirtualJobRow,
index: number,
rows: VirtualJobRow[]
): number {
if (row.type === 'header') {
return HEADER_ROW_HEIGHT
}
return (
JOB_ROW_HEIGHT + (rows[index + 1]?.type === 'header' ? GROUP_ROW_GAP : 0)
)
}
function onListScroll() {
hoveredJobId.value = null
resetActiveDetails()
}
function clearPopoverAnchor() {
activeRowElement.value = null
popoverPosition.value = null

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import { buildVirtualJobRows } from './buildVirtualJobRows'
function buildJob(id: string): JobListItem {
return {
id,
title: `Job ${id}`,
meta: 'meta',
state: 'completed'
}
}
describe('buildVirtualJobRows', () => {
it('flattens grouped jobs into headers and rows in display order', () => {
const displayedJobGroups: JobGroup[] = [
{
key: 'today',
label: 'Today',
items: [buildJob('job-1'), buildJob('job-2')]
},
{
key: 'yesterday',
label: 'Yesterday',
items: [buildJob('job-3')]
}
]
expect(buildVirtualJobRows(displayedJobGroups)).toEqual([
{
key: 'header-today',
type: 'header',
label: 'Today'
},
{
key: 'job-job-1',
type: 'job',
job: displayedJobGroups[0].items[0]
},
{
key: 'job-job-2',
type: 'job',
job: displayedJobGroups[0].items[1]
},
{
key: 'header-yesterday',
type: 'header',
label: 'Yesterday'
},
{
key: 'job-job-3',
type: 'job',
job: displayedJobGroups[1].items[0]
}
])
})
it('keeps a single group flattened without extra row metadata', () => {
const displayedJobGroups: JobGroup[] = [
{
key: 'today',
label: 'Today',
items: [buildJob('job-1')]
}
]
expect(buildVirtualJobRows(displayedJobGroups)).toEqual([
{
key: 'header-today',
type: 'header',
label: 'Today'
},
{
key: 'job-job-1',
type: 'job',
job: displayedJobGroups[0].items[0]
}
])
})
})

View File

@@ -0,0 +1,37 @@
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
export type VirtualJobRow =
| {
key: string
type: 'header'
label: string
}
| {
key: string
type: 'job'
job: JobListItem
}
export function buildVirtualJobRows(
displayedJobGroups: JobGroup[]
): VirtualJobRow[] {
const rows: VirtualJobRow[] = []
displayedJobGroups.forEach((group) => {
rows.push({
key: `header-${group.key}`,
type: 'header',
label: group.label
})
group.items.forEach((job) => {
rows.push({
key: `job-${job.id}`,
type: 'job',
job
})
})
})
return rows
}

View File

@@ -0,0 +1,33 @@
import { vi } from 'vitest'
vi.mock('@tanstack/vue-virtual', async () => {
const { computed } = await import('vue')
return {
useVirtualizer: (options: {
count: number
estimateSize: (index: number) => number
getItemKey?: (index: number) => number | string
}) =>
computed(() => {
let start = 0
const items = Array.from({ length: options.count }, (_, index) => {
const size = options.estimateSize(index)
const item = {
key: options.getItemKey?.(index) ?? index,
index,
start,
size
}
start += size
return item
})
return {
getVirtualItems: () => items,
getTotalSize: () => start
}
})
}
})

View File

@@ -1,163 +0,0 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import JobHistorySidebarTab from './JobHistorySidebarTab.vue'
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined }
},
template: '<div class="job-details-popover-stub" />'
})
vi.mock('@/composables/queue/useJobList', async () => {
const { ref } = await import('vue')
const jobHistoryItem = {
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: {
workflowId: 'workflow-1',
previewOutput: {
isImage: true,
isVideo: false,
url: '/api/view/job-1.png'
}
}
}
return {
useJobList: () => ({
selectedJobTab: ref('All'),
selectedWorkflowFilter: ref('all'),
selectedSortMode: ref('mostRecent'),
searchQuery: ref(''),
hasFailedJobs: ref(false),
filteredTasks: ref([]),
groupedJobItems: ref([
{
key: 'group-1',
label: 'Group 1',
items: [jobHistoryItem]
}
])
})
}
})
vi.mock('@/composables/queue/useJobMenu', () => ({
useJobMenu: () => ({
jobMenuEntries: [],
cancelJob: vi.fn()
})
}))
vi.mock('@/composables/queue/useQueueClearHistoryDialog', () => ({
useQueueClearHistoryDialog: () => ({
showQueueClearHistoryDialog: vi.fn()
})
}))
vi.mock('@/composables/queue/useResultGallery', async () => {
const { ref } = await import('vue')
return {
useResultGallery: () => ({
galleryActiveIndex: ref(-1),
galleryItems: ref([]),
onViewItem: vi.fn()
})
}
})
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync: <T extends (...args: never[]) => unknown>(
fn: T
) => fn
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: vi.fn()
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
showDialog: vi.fn()
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
clearInitializationByJobIds: vi.fn()
})
}))
vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => ({
runningTasks: [],
pendingTasks: [],
delete: vi.fn()
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
const SidebarTabTemplateStub = {
name: 'SidebarTabTemplate',
props: ['title'],
template:
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
}
function mountComponent() {
return mount(JobHistorySidebarTab, {
global: {
plugins: [i18n],
stubs: {
SidebarTabTemplate: SidebarTabTemplateStub,
JobFilterTabs: true,
JobFilterActions: true,
JobHistoryActionsMenu: true,
JobContextMenu: true,
ResultGallery: true,
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub
}
}
})
}
afterEach(() => {
vi.useRealTimers()
})
describe('JobHistorySidebarTab', () => {
it('shows the job details popover for jobs in the history panel', async () => {
vi.useFakeTimers()
const wrapper = mountComponent()
const jobRow = wrapper.find('[data-job-id="job-1"]')
await jobRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
const popover = wrapper.findComponent(JobDetailsPopoverStub)
expect(popover.exists()).toBe(true)
expect(popover.props()).toMatchObject({
jobId: 'job-1',
workflowId: 'workflow-1'
})
})
})

View File

@@ -46,22 +46,25 @@
</div>
</template>
<template #body>
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@view-item="onViewItem"
@menu="onMenuItem"
/>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
<div class="flex h-full min-h-0 flex-col">
<JobAssetsList
class="min-h-0 flex-1"
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@view-item="onViewItem"
@menu="onMenuItem"
/>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
</div>
</template>
</SidebarTabTemplate>
</template>

View File

@@ -12,7 +12,6 @@
:root="favoritesRoot"
show-context-menu
@node-click="(node) => emit('nodeClick', node)"
@add-to-favorites="handleAddToFavorites"
/>
<div v-else class="px-6 py-2 text-xs text-muted-background">
{{ $t('sideToolbar.nodeLibraryTab.noBookmarkedNodes') }}
@@ -31,7 +30,6 @@
:root="section.root"
show-context-menu
@node-click="(node) => emit('nodeClick', node)"
@add-to-favorites="handleAddToFavorites"
/>
</div>
</div>
@@ -71,12 +69,4 @@ const hasFavorites = computed(
const favoritesRoot = computed(() =>
fillNodeInfo(nodeBookmarkStore.bookmarkedRoot)
)
function handleAddToFavorites(
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
) {
if (node.data) {
nodeBookmarkStore.toggleBookmark(node.data)
}
}
</script>

View File

@@ -187,6 +187,35 @@ describe('useSubgraphStore', () => {
expect(store.isGlobalBlueprint('nonexistent')).toBe(false)
})
describe('isUserBlueprint', () => {
it('should return true for user blueprints', async () => {
await mockFetch({ 'test.json': mockGraph })
expect(store.isUserBlueprint('SubgraphBlueprint.test')).toBe(true)
})
it('should return false for global blueprints', async () => {
await mockFetch(
{},
{
global_bp: {
name: 'Global Blueprint',
info: { node_pack: 'comfy_essentials' },
data: JSON.stringify(mockGraph)
}
}
)
expect(store.isUserBlueprint('SubgraphBlueprint.global_bp')).toBe(false)
})
it('should return false for non-blueprint node types', () => {
expect(store.isUserBlueprint('KSampler')).toBe(false)
})
it('should return false for undefined', () => {
expect(store.isUserBlueprint(undefined)).toBe(false)
})
})
describe('blueprint badge display', () => {
it('should set isGlobal flag on global blueprints', async () => {
await mockFetch(

View File

@@ -432,6 +432,12 @@ export const useSubgraphStore = defineStore('subgraph', () => {
return nodeDef !== undefined && nodeDef.isGlobal === true
}
function isUserBlueprint(nodeType?: string): boolean {
if (!nodeType?.startsWith(typePrefix)) return false
const name = nodeType.slice(typePrefix.length)
return name in subgraphCache && !isGlobalBlueprint(name)
}
return {
deleteBlueprint,
editBlueprint,
@@ -439,6 +445,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
getBlueprint,
isGlobalBlueprint,
isSubgraphBlueprint,
isUserBlueprint,
publishSubgraph,
subgraphBlueprints,
typePrefix

View File

@@ -73,7 +73,7 @@ const PROVIDER_COLORS: Record<string, string | [string, string]> = {
'moonvalley-marey': '#DAD9C5',
openai: '#B6B6B6',
pixverse: ['#B465E6', '#E8632A'],
'quiver-ai': '#B6B6B6',
quiver: '#B6B6B6',
recraft: '#B6B6B6',
reve: '#B6B6B6',
rodin: '#F7F7F7',