Compare commits

..

3 Commits

Author SHA1 Message Date
Benjamin Lu
f00c5b96b6 fix: prune unused shadcn menu components 2026-03-23 17:56:51 -07:00
Benjamin Lu
47f9b0a20e feat: use shadcn menus for queue and asset actions 2026-03-23 17:52:18 -07:00
Benjamin Lu
97c8be9ce9 feat: add shadcn menu ui components 2026-03-23 17:40:36 -07:00
111 changed files with 3367 additions and 4324 deletions

View File

@@ -91,12 +91,6 @@ export class CanvasHelper {
await this.page.mouse.move(10, 10)
}
async isReadOnly(): Promise<boolean> {
return this.page.evaluate(() => {
return window.app!.canvas.state.readOnly
})
}
async getScale(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.ds.scale

View File

@@ -3,6 +3,9 @@ import type { Page, Route } from '@playwright/test'
export class QueueHelper {
private queueRouteHandler: ((route: Route) => void) | null = null
private historyRouteHandler: ((route: Route) => void) | null = null
private jobsRouteHandler: ((route: Route) => void) | null = null
private queueJobs: Array<Record<string, unknown>> = []
private historyJobs: Array<Record<string, unknown>> = []
constructor(private readonly page: Page) {}
@@ -13,6 +16,26 @@ export class QueueHelper {
running: number = 0,
pending: number = 0
): Promise<void> {
const baseTime = Date.now()
this.queueJobs = [
...Array.from({ length: running }, (_, i) => ({
id: `running-${i}`,
status: 'in_progress',
create_time: baseTime - i * 1000,
execution_start_time: baseTime - 5000 - i * 1000,
execution_end_time: null,
priority: i + 1
})),
...Array.from({ length: pending }, (_, i) => ({
id: `pending-${i}`,
status: 'pending',
create_time: baseTime - (running + i) * 1000,
execution_start_time: null,
execution_end_time: null,
priority: running + i + 1
}))
]
this.queueRouteHandler = (route: Route) =>
route.fulfill({
status: 200,
@@ -35,6 +58,7 @@ export class QueueHelper {
})
})
await this.page.route('**/api/queue', this.queueRouteHandler)
await this.installJobsRoute()
}
/**
@@ -43,6 +67,30 @@ export class QueueHelper {
async mockHistory(
jobs: Array<{ promptId: string; status: 'success' | 'error' }>
): Promise<void> {
const baseTime = Date.now()
this.historyJobs = jobs.map((job, index) => {
const completed = job.status === 'success'
return {
id: job.promptId,
status: completed ? 'completed' : 'failed',
create_time: baseTime - index * 1000,
execution_start_time: baseTime - 5000 - index * 1000,
execution_end_time: baseTime - index * 1000,
outputs_count: completed ? 1 : 0,
workflow_id: `workflow-${job.promptId}`,
preview_output: completed
? {
filename: `${job.promptId}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
: null
}
})
const history: Record<string, unknown> = {}
for (const job of jobs) {
history[job.promptId] = {
@@ -61,6 +109,44 @@ export class QueueHelper {
body: JSON.stringify(history)
})
await this.page.route('**/api/history**', this.historyRouteHandler)
await this.installJobsRoute()
}
private async installJobsRoute() {
if (this.jobsRouteHandler) {
return
}
this.jobsRouteHandler = (route: Route) => {
const url = new URL(route.request().url())
const statuses =
url.searchParams
.get('status')
?.split(',')
.filter((status) => status.length > 0) ?? []
const offset = Number(url.searchParams.get('offset') ?? 0)
const limit = Number(url.searchParams.get('limit') ?? 200)
const jobs = [...this.queueJobs, ...this.historyJobs].filter(
(job) => statuses.length === 0 || statuses.includes(String(job.status))
)
const paginatedJobs = jobs.slice(offset, offset + limit)
void route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: paginatedJobs,
pagination: {
offset,
limit,
total: jobs.length,
has_more: offset + paginatedJobs.length < jobs.length
}
})
})
}
await this.page.route('**/api/jobs**', this.jobsRouteHandler)
}
/**
@@ -75,5 +161,9 @@ export class QueueHelper {
await this.page.unroute('**/api/history**', this.historyRouteHandler)
this.historyRouteHandler = null
}
if (this.jobsRouteHandler) {
await this.page.unroute('**/api/jobs**', this.jobsRouteHandler)
this.jobsRouteHandler = null
}
}
}

View File

@@ -28,15 +28,10 @@ export const TestIds = {
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
errorOverlay: 'error-overlay',
errorOverlaySeeErrors: 'error-overlay-see-errors',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
errorCardFindOnGithub: 'error-card-find-on-github',
errorCardCopy: 'error-card-copy',
about: 'about-panel',
whatsNewSection: 'whats-new-section',
missingNodePacksGroup: 'error-group-missing-node',
missingModelsGroup: 'error-group-missing-model'
whatsNewSection: 'whats-new-section'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
@@ -52,13 +47,6 @@ export const TestIds = {
propertiesPanel: {
root: 'properties-panel'
},
subgraphEditor: {
toggle: 'subgraph-editor-toggle',
shownSection: 'subgraph-editor-shown-section',
hiddenSection: 'subgraph-editor-hidden-section',
widgetToggle: 'subgraph-widget-toggle',
widgetLabel: 'subgraph-widget-label'
},
node: {
titleInput: 'node-title-input'
},
@@ -88,10 +76,6 @@ export const TestIds = {
},
user: {
currentUserIndicator: 'current-user-indicator'
},
errors: {
imageLoadError: 'error-loading-image',
videoLoadError: 'error-loading-video'
}
} as const
@@ -117,4 +101,3 @@ export type TestIdValue =
(id: string) => string
>
| (typeof TestIds.user)[keyof typeof TestIds.user]
| (typeof TestIds.errors)[keyof typeof TestIds.errors]

View File

@@ -1,318 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
async function pressKeyAndExpectRequest(
comfyPage: ComfyPage,
key: string,
urlPattern: string,
method: string = 'POST'
) {
const requestPromise = comfyPage.page.waitForRequest(
(req) => req.url().includes(urlPattern) && req.method() === method,
{ timeout: 5000 }
)
await comfyPage.page.keyboard.press(key)
return requestPromise
}
test.describe('Default Keybindings', { tag: '@keyboard' }, () => {
test.describe('Sidebar Toggle Shortcuts', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
})
const sidebarTabs = [
{ key: 'KeyW', tabId: 'workflows', label: 'workflows' },
{ key: 'KeyN', tabId: 'node-library', label: 'node library' },
{ key: 'KeyM', tabId: 'model-library', label: 'model library' },
{ key: 'KeyA', tabId: 'assets', label: 'assets' }
] as const
for (const { key, tabId, label } of sidebarTabs) {
test(`'${key}' toggles ${label} sidebar`, async ({ comfyPage }) => {
const selectedButton = comfyPage.page.locator(
`.${tabId}-tab-button.side-bar-button-selected`
)
await expect(selectedButton).not.toBeVisible()
await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).toBeVisible()
await comfyPage.canvas.press(key)
await comfyPage.nextFrame()
await expect(selectedButton).not.toBeVisible()
})
}
})
test.describe('Canvas View Controls', () => {
test("'Alt+=' zooms in", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Equal')
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeGreaterThan(initialScale)
})
test("'Alt+-' zooms out", async ({ comfyPage }) => {
const initialScale = await comfyPage.canvasOps.getScale()
await comfyPage.canvas.press('Alt+Minus')
await comfyPage.nextFrame()
const newScale = await comfyPage.canvasOps.getScale()
expect(newScale).toBeLessThan(initialScale)
})
test("'.' fits view to nodes", async ({ comfyPage }) => {
// Set scale very small so fit-view will zoom back to fit nodes
await comfyPage.canvasOps.setScale(0.1)
const scaleBefore = await comfyPage.canvasOps.getScale()
expect(scaleBefore).toBeCloseTo(0.1, 1)
// Click canvas to ensure focus is within graph-canvas-container
await comfyPage.canvas.click({ position: { x: 400, y: 400 } })
await comfyPage.nextFrame()
await comfyPage.canvas.press('Period')
await comfyPage.nextFrame()
const scaleAfter = await comfyPage.canvasOps.getScale()
expect(scaleAfter).toBeGreaterThan(scaleBefore)
})
test("'h' locks canvas", async ({ comfyPage }) => {
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
await comfyPage.canvas.press('KeyH')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
})
test("'v' unlocks canvas", async ({ comfyPage }) => {
// Lock first
await comfyPage.command.executeCommand('Comfy.Canvas.Lock')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(true)
await comfyPage.canvas.press('KeyV')
await comfyPage.nextFrame()
expect(await comfyPage.canvasOps.isReadOnly()).toBe(false)
})
})
test.describe('Node State Toggles', () => {
test("'Alt+c' collapses and expands selected nodes", async ({
comfyPage
}) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes.length).toBeGreaterThan(0)
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(true)
await comfyPage.canvas.press('Alt+KeyC')
await comfyPage.nextFrame()
expect(await node.isCollapsed()).toBe(false)
})
test("'Ctrl+m' mutes and unmutes selected nodes", async ({ comfyPage }) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
expect(nodes.length).toBeGreaterThan(0)
const node = nodes[0]
await node.click('title')
await comfyPage.nextFrame()
// Normal mode is ALWAYS (0)
const getMode = () =>
comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.mode
}, node.id)
expect(await getMode()).toBe(0)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
// NEVER (2) = muted
expect(await getMode()).toBe(2)
await comfyPage.canvas.press('Control+KeyM')
await comfyPage.nextFrame()
expect(await getMode()).toBe(0)
})
})
test.describe('Mode and Panel Toggles', () => {
test("'Alt+m' toggles app mode", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
// Set up linearData so app mode has something to show
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
// Toggle off with Alt+m
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).not.toBeVisible()
// Toggle on again
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
test("'Alt+Shift+m' toggles minimap", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', true)
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
await comfyPage.workflow.loadWorkflow('default')
const minimap = comfyPage.page.locator('.litegraph-minimap')
await expect(minimap).toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).not.toBeVisible()
await comfyPage.page.keyboard.press('Alt+Shift+KeyM')
await comfyPage.nextFrame()
await expect(minimap).toBeVisible()
})
test("'Ctrl+`' toggles terminal/logs panel", async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).toBeVisible()
await comfyPage.page.keyboard.press('Control+Backquote')
await comfyPage.nextFrame()
await expect(comfyPage.bottomPanel.root).not.toBeVisible()
})
})
test.describe('Queue and Execution', () => {
test("'Ctrl+Enter' queues prompt", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Enter',
'/prompt',
'POST'
)
expect(request.url()).toContain('/prompt')
})
test("'Ctrl+Shift+Enter' queues prompt to front", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Shift+Enter',
'/prompt',
'POST'
)
const body = request.postDataJSON()
expect(body.front).toBe(true)
})
test("'Ctrl+Alt+Enter' interrupts execution", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'Control+Alt+Enter',
'/interrupt',
'POST'
)
expect(request.url()).toContain('/interrupt')
})
})
test.describe('File Operations', () => {
test("'Ctrl+s' triggers save workflow", async ({ comfyPage }) => {
// On a new unsaved workflow, Ctrl+s triggers Save As dialog.
// The dialog appearing proves the keybinding was intercepted by the app.
await comfyPage.page.keyboard.press('Control+s')
await comfyPage.nextFrame()
// The Save As dialog should appear (p-dialog overlay)
const dialogOverlay = comfyPage.page.locator('.p-dialog-mask')
await expect(dialogOverlay).toBeVisible({ timeout: 3000 })
// Dismiss the dialog
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
})
test("'Ctrl+o' triggers open workflow", async ({ comfyPage }) => {
// Ctrl+o calls app.ui.loadFile() which clicks a hidden file input.
// Detect the file input click via an event listener.
await comfyPage.page.evaluate(() => {
window.TestCommand = false
const fileInputs =
document.querySelectorAll<HTMLInputElement>('input[type="file"]')
for (const input of fileInputs) {
input.addEventListener('click', () => {
window.TestCommand = true
})
}
})
await comfyPage.page.keyboard.press('Control+o')
await comfyPage.nextFrame()
expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true)
})
})
test.describe('Graph Operations', () => {
test("'Ctrl+Shift+e' converts selection to subgraph", async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(initialCount).toBeGreaterThan(1)
// Select all nodes
await comfyPage.canvas.press('Control+a')
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Control+Shift+KeyE')
await comfyPage.nextFrame()
// After conversion, node count should decrease
// (multiple nodes replaced by single subgraph node)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
timeout: 5000
})
.toBeLessThan(initialCount)
})
test("'r' refreshes node definitions", async ({ comfyPage }) => {
const request = await pressKeyAndExpectRequest(
comfyPage,
'KeyR',
'/object_info',
'GET'
)
expect(request.url()).toContain('/object_info')
})
})
})

View File

@@ -28,7 +28,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
})
@@ -42,13 +42,11 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
)
await expect(errorOverlay).toBeVisible()
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
const missingNodesTitle = comfyPage.page.getByText(/Missing Node Packs/)
await expect(missingNodesTitle).toBeVisible()
// Click "See Errors" to open the errors tab and verify subgraph node content
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
const missingNodeCard = comfyPage.page.getByTestId(
@@ -77,9 +75,7 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
await expect(errorOverlay).toBeVisible()
// Click "See Errors" to open the right side panel errors tab
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
// Verify MissingNodeCard is rendered in the errors tab
@@ -169,19 +165,17 @@ test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlaySeeErrors)
.click()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
// Verify Find on GitHub button is present in the error card
const findOnGithubButton = comfyPage.page.getByTestId(
TestIds.dialogs.errorCardFindOnGithub
)
const findOnGithubButton = comfyPage.page.getByRole('button', {
name: 'Find on GitHub'
})
await expect(findOnGithubButton).toBeVisible()
// Verify Copy button is present in the error card
const copyButton = comfyPage.page.getByTestId(TestIds.dialogs.errorCardCopy)
const copyButton = comfyPage.page.getByRole('button', { name: 'Copy' })
await expect(copyButton).toBeVisible()
})
})
@@ -210,7 +204,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -226,7 +220,7 @@ test.describe('Missing models in Error Tab', () => {
)
await expect(errorOverlay).toBeVisible()
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).toBeVisible()
})
@@ -237,10 +231,13 @@ test.describe('Missing models in Error Tab', () => {
'missing/model_metadata_widget_mismatch'
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).not.toBeVisible()
await expect(comfyPage.page.getByText(/Missing Models/)).not.toBeVisible()
const missingModelsTitle = comfyPage.page.getByText(/Missing Models/)
await expect(missingModelsTitle).not.toBeVisible()
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).not.toBeVisible()
})
// Flaky test after parallelization

View File

@@ -764,13 +764,13 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
)
})
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.describe('Restore all open workflows on reload', () => {
let workflowA: string
let workflowB: string
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
@@ -829,82 +829,6 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
})
})
test.describe('Restore workflow tabs after browser restart', () => {
let workflowA: string
let workflowB: string
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
workflowB = generateUniqueFilename()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage fallback pointers to be written
await comfyPage.page.waitForFunction(() => {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i)
if (key?.startsWith('Comfy.Workflow.LastOpenPaths:')) {
return true
}
}
return false
})
// Simulate browser restart: clear sessionStorage (lost on close)
// but keep localStorage (survives browser restart)
await comfyPage.page.evaluate(() => {
sessionStorage.clear()
})
await comfyPage.setup({ clearStorage: false })
})
test('Restores topbar workflow tabs after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
// Wait for both restored tabs to render (localStorage fallback is async)
await expect(
comfyPage.page.locator('.workflow-tabs .workflow-label', {
hasText: workflowA
})
).toBeVisible()
const tabs = await comfyPage.menu.topbar.getTabNames()
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
expect(activeWorkflowName).toEqual(workflowB)
})
test('Restores sidebar workflows after browser restart', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
const openWorkflows =
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowA, workflowB])
)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
)
expect(activeWorkflowName).toEqual(workflowB)
})
})
test('Auto fit view after loading workflow', async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.EnableWorkflowViewRestore',

View File

@@ -0,0 +1,55 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../../fixtures/ComfyPage'
test.describe('Assets Sidebar', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.queue.mockHistory([
{ promptId: 'history-asset-1', status: 'success' }
])
await comfyPage.setup({ clearStorage: false })
await comfyPage.page.getByRole('button', { name: /^Assets/ }).click()
await expect(
comfyPage.page.getByRole('button', {
name: /history-asset-1\.png/i
})
).toBeVisible()
})
test('right-click menu can inspect an asset', async ({ comfyPage }) => {
const assetCard = comfyPage.page.getByRole('button', {
name: /history-asset-1\.png/i
})
await assetCard.click({ button: 'right' })
const menuPanel = comfyPage.page.locator('.media-asset-menu-panel')
await expect(menuPanel).toBeVisible()
await menuPanel.getByRole('menuitem', { name: /inspect asset/i }).click()
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(dialog.getByLabel('Close')).toBeVisible()
})
test('actions menu closes on scroll', async ({ comfyPage }) => {
const assetCard = comfyPage.page.getByRole('button', {
name: /history-asset-1\.png/i
})
await assetCard.hover()
await assetCard.getByRole('button', { name: /more options/i }).click()
const menuPanel = comfyPage.page.locator('.media-asset-menu-panel')
await expect(menuPanel).toBeVisible()
await comfyPage.page.evaluate(() => {
window.dispatchEvent(new Event('scroll'))
})
await expect(menuPanel).toBeHidden()
})
})

View File

@@ -0,0 +1,70 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../../fixtures/ComfyPage'
test.describe('Job History Sidebar', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.queue.mockQueueState()
await comfyPage.queue.mockHistory([
{ promptId: 'history-job-1', status: 'success' }
])
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', true)
await comfyPage.setup({ clearStorage: false })
await comfyPage.page.getByTestId('queue-overlay-toggle').click()
await expect(
comfyPage.page.locator('[data-job-id="history-job-1"]')
).toBeVisible()
})
test('right-click menu can inspect a completed job asset', async ({
comfyPage
}) => {
const jobRow = comfyPage.page.locator('[data-job-id="history-job-1"]')
await jobRow.click({ button: 'right' })
const menuPanel = comfyPage.page.locator('.job-menu-panel')
await expect(menuPanel).toBeVisible()
await menuPanel.getByRole('menuitem', { name: /inspect asset/i }).click()
const dialog = comfyPage.page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(dialog.getByLabel('Close')).toBeVisible()
})
test('hover popover and actions menu stay clickable', async ({
comfyPage
}) => {
const jobRow = comfyPage.page.locator('[data-job-id="history-job-1"]')
await jobRow.hover()
const popover = comfyPage.page.locator('.job-details-popover')
await expect(popover).toBeVisible()
await popover.getByRole('button', { name: /^copy$/i }).click()
await jobRow.hover()
const moreButton = jobRow.locator('.job-actions-menu-trigger')
await expect(moreButton).toBeVisible()
await moreButton.click()
const menuPanel = comfyPage.page.locator('.job-menu-panel')
await expect(menuPanel).toBeVisible()
const box = await menuPanel.boundingBox()
if (!box) {
throw new Error('Job actions menu did not render a bounding box')
}
await comfyPage.page.mouse.move(
box.x + box.width / 2,
box.y + Math.min(box.height / 2, 24)
)
await expect(menuPanel).toBeVisible()
await menuPanel.getByRole('menuitem', { name: /copy job id/i }).click()
await expect(menuPanel).toBeHidden()
})
})

View File

@@ -1,146 +0,0 @@
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
async function ensurePropertiesPanel(comfyPage: ComfyPage) {
const panel = comfyPage.menu.propertiesPanel.root
if (!(await panel.isVisible())) {
await comfyPage.actionbar.propertiesButton.click()
}
await expect(panel).toBeVisible()
return panel
}
async function selectSubgraphAndOpenEditor(
comfyPage: ComfyPage,
nodeTitle: string
) {
const subgraphNode = (
await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
)[0]
await subgraphNode.click('title')
await ensurePropertiesPanel(comfyPage)
const editorToggle = comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle)
await expect(editorToggle).toBeVisible()
await editorToggle.click()
const shownSection = comfyPage.page.getByTestId(
TestIds.subgraphEditor.shownSection
)
await expect(shownSection).toBeVisible()
return shownSection
}
async function collectWidgetLabels(shownSection: Locator) {
const labels = shownSection.getByTestId(TestIds.subgraphEditor.widgetLabel)
const count = await labels.count()
const texts: string[] = []
for (let i = 0; i < count; i++) {
const text = await labels.nth(i).textContent()
if (text) texts.push(text.trim())
}
return texts
}
test.describe(
'Subgraph promoted widget panel',
{ tag: ['@node', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test.describe('SubgraphEditor (Settings panel)', () => {
test('linked promoted widgets have hide toggle disabled', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
const shownSection = await selectSubgraphAndOpenEditor(
comfyPage,
'Sub 0'
)
const toggleButtons = shownSection.getByTestId(
TestIds.subgraphEditor.widgetToggle
)
const count = await toggleButtons.count()
expect(count).toBeGreaterThan(0)
for (let i = 0; i < count; i++) {
await expect(toggleButtons.nth(i)).toBeDisabled()
}
})
test('linked promoted widgets show link icon instead of eye icon', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
const shownSection = await selectSubgraphAndOpenEditor(
comfyPage,
'Sub 0'
)
const linkIcons = shownSection.locator('.icon-\\[lucide--link\\]')
await expect(linkIcons.first()).toBeVisible()
const eyeIcons = shownSection.locator('.icon-\\[lucide--eye\\]')
await expect(eyeIcons).toHaveCount(0)
})
test('widget labels display renamed values instead of raw names', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/test-values-input-subgraph'
)
const shownSection = await selectSubgraphAndOpenEditor(
comfyPage,
'Input Test Subgraph'
)
const allTexts = await collectWidgetLabels(shownSection)
expect(allTexts.length).toBeGreaterThan(0)
// The fixture has a widget with name="text" but
// label="renamed_from_sidepanel". The panel should show the
// renamed label, not the raw widget name.
expect(allTexts).toContain('renamed_from_sidepanel')
expect(allTexts).not.toContain('text')
})
})
test.describe('Parameters tab (WidgetActions menu)', () => {
test('linked promoted widget menu should not show Hide/Show input', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
const subgraphNode = (
await comfyPage.nodeOps.getNodeRefsByTitle('Sub 0')
)[0]
await subgraphNode.click('title')
const panel = await ensurePropertiesPanel(comfyPage)
const moreButtons = panel.locator('.icon-\\[lucide--more-vertical\\]')
await expect(moreButtons.first()).toBeVisible()
await moreButtons.first().click()
await expect(comfyPage.page.getByText('Hide input')).toHaveCount(0)
await expect(comfyPage.page.getByText('Show input')).toHaveCount(0)
await expect(comfyPage.page.getByText('Rename')).toBeVisible()
})
})
}
)

View File

@@ -206,31 +206,6 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(nav).toBeVisible() // Nav should be visible at tablet size
})
test(
'select components in filter bar render correctly',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Wait for filter bar select components to render
const dialog = comfyPage.page.getByRole('dialog')
const sortBySelect = dialog.getByRole('combobox', { name: /Sort/ })
await expect(sortBySelect).toBeVisible()
// Screenshot the filter bar containing MultiSelect and SingleSelect
const filterBar = sortBySelect.locator(
'xpath=ancestor::div[contains(@class, "justify-between")]'
)
await expect(filterBar).toHaveScreenshot(
'template-filter-bar-select-components.png',
{
mask: [comfyPage.page.locator('.p-toast')]
}
)
}
)
test(
'template cards descriptions adjust height dynamically',
{ tag: '@screenshot' },

View File

@@ -47,46 +47,6 @@ test.describe('Vue Node Moving', () => {
}
)
test('should not move node when pointer moves less than drag threshold', async ({
comfyPage
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
steps: 5
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
expect(afterPos.x).toBeCloseTo(headerPos.x, 0)
expect(afterPos.y).toBeCloseTo(headerPos.y, 0)
// The small movement should have selected the node, not dragged it
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
})
test('should move node when pointer moves beyond drag threshold', async ({
comfyPage
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move 50px — well beyond the 3px drag threshold
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 50, headerPos.y + 50, {
steps: 20
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(headerPos, afterPos)
})
test(
'@mobile should allow moving nodes by dragging on touch devices',
{ tag: '@screenshot' },

View File

@@ -2,7 +2,6 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { TestIds } from '../../../../fixtures/selectors'
test.describe('Vue Upload Widgets', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -20,14 +19,10 @@ test.describe('Vue Upload Widgets', () => {
).not.toBeVisible()
await expect
.poll(() =>
comfyPage.page.getByTestId(TestIds.errors.imageLoadError).count()
)
.poll(() => comfyPage.page.getByText('Error loading image').count())
.toBeGreaterThan(0)
await expect
.poll(() =>
comfyPage.page.getByTestId(TestIds.errors.videoLoadError).count()
)
.poll(() => comfyPage.page.getByText('Error loading video').count())
.toBeGreaterThan(0)
})
})

View File

@@ -9,10 +9,13 @@ import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
@@ -20,6 +23,7 @@ import { parsePreloadError } from '@/utils/preloadErrorUtil'
import { useDialogService } from '@/services/dialogService'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()
@@ -94,17 +98,12 @@ onMounted(() => {
}
})
}
// Disabled: Third-party custom node extensions frequently trigger this toast
// (e.g., bare "vue" imports, wrong relative paths to scripts/app.js, missing
// core dependencies). These are plugin bugs, not ComfyUI core failures, but
// the generic error message alarms users and offers no actionable guidance.
// The console.error above still logs the details for developers to debug.
// useToastStore().add({
// severity: 'error',
// summary: t('g.preloadErrorTitle'),
// detail: t('g.preloadError'),
// life: 10000
// })
useToastStore().add({
severity: 'error',
summary: t('g.preloadErrorTitle'),
detail: t('g.preloadError'),
life: 10000
})
})
// Capture resource load failures (CSS, scripts) in non-localhost distributions

View File

@@ -19,7 +19,10 @@ const props = defineProps<{
const queryString = computed(() => props.errorMessage + ' is:issue')
function openGitHubIssues() {
/**
* Open GitHub issues search and track telemetry.
*/
const openGitHubIssues = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked'
})

View File

@@ -49,12 +49,7 @@
<Button variant="muted-textonly" size="unset" @click="dismiss">
{{ t('g.dismiss') }}
</Button>
<Button
variant="secondary"
size="lg"
data-testid="error-overlay-see-errors"
@click="seeErrors"
>
<Button variant="secondary" size="lg" @click="seeErrors">
{{
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
}}

View File

@@ -1,60 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import MultiSelect from './MultiSelect.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
multiSelectDropdown: 'Multi-select dropdown',
noResultsFound: 'No results found',
search: 'Search',
clearAll: 'Clear all',
itemsSelected: 'Items selected'
}
}
}
})
describe('MultiSelect', () => {
function createWrapper() {
return mount(MultiSelect, {
attachTo: document.body,
global: {
plugins: [i18n]
},
props: {
modelValue: [],
label: 'Category',
options: [
{ name: 'One', value: 'one' },
{ name: 'Two', value: 'two' }
]
}
})
}
it('keeps open-state border styling available while the dropdown is open', async () => {
const wrapper = createWrapper()
const trigger = wrapper.get('button[aria-haspopup="listbox"]')
expect(trigger.classes()).toContain(
'data-[state=open]:border-node-component-border'
)
expect(trigger.attributes('aria-expanded')).toBe('false')
await trigger.trigger('click')
await nextTick()
expect(trigger.attributes('aria-expanded')).toBe('true')
expect(trigger.attributes('data-state')).toBe('open')
wrapper.unmount()
})
})

View File

@@ -1,215 +1,207 @@
<template>
<ComboboxRoot
<!--
Note: Unlike SingleSelect, we don't need an explicit options prop because:
1. Our value template only shows a static label (not dynamic based on selection)
2. We display a count badge instead of actual selected labels
3. All PrimeVue props (including options) are passed via v-bind="$attrs"
option-label="name" is required because our option template directly accesses option.name
max-selected-labels="0" is required to show count badge instead of selected item labels
-->
<MultiSelect
v-model="selectedItems"
multiple
by="value"
:disabled
ignore-filter
:reset-search-term-on-select="false"
v-bind="{ ...$attrs, options: filteredOptions }"
option-label="name"
unstyled
:max-selected-labels="0"
:pt="{
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'relative inline-flex cursor-pointer select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
selectedCount > 0 ? 'border-base-foreground' : 'border-transparent',
'focus-within:border-base-foreground',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
}),
labelContainer: {
class: cn(
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
label: {
class: 'p-0'
},
dropdown: {
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
},
header: () => ({
class:
showSearchBox || showSelectedCount || showClearButton
? 'block'
: 'hidden'
}),
// Overlay & list visuals unchanged
overlay: {
class: cn(
'mt-2 rounded-lg p-2',
'bg-base-background',
'text-base-foreground',
'border border-solid border-border-default'
)
},
listContainer: () => ({
style: { maxHeight: `min(${listMaxHeight}, 50vh)` },
class: 'scrollbar-custom'
}),
list: {
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
// Option row hover and focus tone
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'flex h-10 cursor-pointer items-center gap-2 rounded-lg px-2',
'hover:bg-secondary-background-hover',
// Add focus/highlight state for keyboard navigation
context?.focused &&
'bg-secondary-background-selected hover:bg-secondary-background-selected'
)
}),
// Hide built-in checkboxes entirely via PT (no :deep)
pcHeaderCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
},
pcOptionCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
},
emptyMessage: {
class: 'px-3 pb-4 text-sm text-muted-foreground'
}
}"
:aria-label="label || t('g.multiSelectDropdown')"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
>
<ComboboxAnchor as-child>
<ComboboxTrigger
v-bind="$attrs"
:aria-label="label || t('g.multiSelectDropdown')"
:class="
cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg bg-secondary-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid border-transparent',
selectedCount > 0
? 'border-base-foreground'
: 'focus-visible:border-node-component-border data-[state=open]:border-node-component-border',
disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
"
>
<template
v-if="showSearchBox || showSelectedCount || showClearButton"
#header
>
<div class="flex flex-col px-2 pt-2 pb-0">
<SearchInput
v-if="showSearchBox"
v-model="searchQuery"
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
:placeholder="searchPlaceholder"
size="sm"
/>
<div
:class="
cn(
'flex flex-1 items-center overflow-hidden py-2 whitespace-nowrap',
size === 'md' ? 'pl-3' : 'pl-4'
)
"
v-if="showSelectedCount || showClearButton"
class="mt-2 flex items-center justify-between"
>
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
v-if="showSelectedCount"
class="px-1 text-sm text-base-foreground"
>
{{ selectedCount }}
{{
selectedCount > 0
? $t('g.itemsSelected', { selectedCount })
: $t('g.itemSelected', { selectedCount })
}}
</span>
<Button
v-if="showClearButton"
variant="textonly"
size="md"
@click.stop="selectedItems = []"
>
{{ $t('g.clearAll') }}
</Button>
</div>
<div
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</div>
</ComboboxTrigger>
</ComboboxAnchor>
<div class="my-4 h-px bg-border-default"></div>
</div>
</template>
<ComboboxPortal>
<ComboboxContent
position="popper"
:side-offset="8"
align="start"
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span :class="size === 'md' ? 'text-xs' : 'text-sm'">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex size-5 items-center justify-center rounded-full bg-base-foreground text-xs font-semibold text-base-background"
>
{{ selectedCount }}
</span>
</template>
<!-- Chevron size identical to current -->
<template #dropdownicon>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</template>
<!-- Custom option row: square checkbox + label (unchanged layout/colors) -->
<template #option="slotProps">
<div
role="button"
class="flex cursor-pointer items-center gap-2"
:style="popoverStyle"
:class="
cn(
'z-3000 overflow-hidden',
'rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
@focus-outside="preventFocusDismiss"
>
<div
v-if="showSearchBox || showSelectedCount || showClearButton"
class="flex flex-col px-2 pt-2 pb-0"
>
<div
v-if="showSearchBox"
:class="
cn(
'flex items-center gap-2 rounded-lg border border-solid border-border-default px-3 py-1.5',
(showSelectedCount || showClearButton) && 'mb-2'
)
"
>
<i
class="icon-[lucide--search] shrink-0 text-sm text-muted-foreground"
/>
<ComboboxInput
v-model="searchQuery"
:placeholder="searchPlaceholder ?? t('g.search')"
class="w-full border-none bg-transparent text-sm outline-none"
/>
</div>
<div
v-if="showSelectedCount || showClearButton"
class="mt-2 flex items-center justify-between"
>
<span
v-if="showSelectedCount"
class="px-1 text-sm text-base-foreground"
>
{{ $t('g.itemsSelected', { count: selectedCount }) }}
</span>
<Button
v-if="showClearButton"
variant="textonly"
size="md"
@click.stop="selectedItems = []"
>
{{ $t('g.clearAll') }}
</Button>
</div>
<div class="my-4 h-px bg-border-default" />
</div>
<ComboboxViewport
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200"
:class="
cn(
'flex flex-col gap-0 p-0 text-sm',
'scrollbar-custom overflow-y-auto',
'min-w-(--reka-combobox-trigger-width)'
)
slotProps.selected
? 'bg-primary-background'
: 'bg-secondary-background'
"
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
>
<ComboboxItem
v-for="opt in filteredOptions"
:key="opt.value"
:value="opt"
:class="
cn(
'group flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-lg px-2 outline-none',
'hover:bg-secondary-background-hover',
'data-highlighted:bg-secondary-background-selected data-highlighted:hover:bg-secondary-background-selected'
)
"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded-sm transition-all duration-200 group-data-[state=checked]:bg-primary-background group-data-[state=unchecked]:bg-secondary-background [&>span]:flex"
>
<ComboboxItemIndicator>
<i
class="icon-[lucide--check] text-xs font-bold text-base-foreground"
/>
</ComboboxItemIndicator>
</div>
<span>{{ opt.name }}</span>
</ComboboxItem>
<ComboboxEmpty class="px-3 pb-4 text-sm text-muted-foreground">
{{ $t('g.noResultsFound') }}
</ComboboxEmpty>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
<i
v-if="slotProps.selected"
class="text-bold icon-[lucide--check] text-xs text-base-foreground"
/>
</div>
<span>
{{ slotProps.option.name }}
</span>
</div>
</template>
</MultiSelect>
</template>
<script setup lang="ts">
import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import type { FocusOutsideEvent } from 'reka-ui'
import {
ComboboxAnchor,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxItemIndicator,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport
} from 'reka-ui'
import { computed } from 'vue'
import type { MultiSelectPassThroughMethodOptions } from 'primevue/multiselect'
import MultiSelect from 'primevue/multiselect'
import { computed, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import type { SelectOption } from './types'
type Option = SelectOption
defineOptions({
inheritAttrs: false
})
const {
label,
options = [],
size = 'lg',
disabled = false,
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,
searchPlaceholder,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
interface Props {
/** Input label shown on the trigger button */
label?: string
/** Available options */
options?: SelectOption[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
/** Disable the select */
disabled?: boolean
/** Show search box in the panel header */
showSearchBox?: boolean
/** Show selected count text in the panel header */
@@ -224,9 +216,22 @@ const {
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
}>()
// Note: options prop is intentionally omitted.
// It's passed via $attrs to maximize PrimeVue API compatibility
}
const {
label,
size = 'lg',
showSearchBox = false,
showSelectedCount = false,
showClearButton = false,
searchPlaceholder = 'Search...',
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<Props>()
const selectedItems = defineModel<SelectOption[]>({
const selectedItems = defineModel<Option[]>({
required: true
})
const searchQuery = defineModel<string>('searchQuery', { default: '' })
@@ -234,16 +239,15 @@ const searchQuery = defineModel<string>('searchQuery', { default: '' })
const { t } = useI18n()
const selectedCount = computed(() => selectedItems.value.length)
function preventFocusDismiss(event: FocusOutsideEvent) {
event.preventDefault()
}
const popoverStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
})
const attrs = useAttrs()
const originalOptions = computed(() => (attrs.options as Option[]) || [])
const fuseOptions: UseFuseOptions<SelectOption> = {
// Use VueUse's useFuse for better reactivity and performance
const fuseOptions: UseFuseOptions<Option> = {
fuseOptions: {
keys: ['name', 'value'],
threshold: 0.3,
@@ -252,20 +256,23 @@ const fuseOptions: UseFuseOptions<SelectOption> = {
matchAllWhenSearchEmpty: true
}
const { results } = useFuse(searchQuery, () => options, fuseOptions)
const { results } = useFuse(searchQuery, originalOptions, fuseOptions)
// Filter options based on search, but always include selected items
const filteredOptions = computed(() => {
if (!searchQuery.value || searchQuery.value.trim() === '') {
return options
return originalOptions.value
}
// results.value already contains the search results from useFuse
const searchResults = results.value.map(
(result: { item: SelectOption }) => result.item
(result: { item: Option }) => result.item
)
// Include selected items that aren't in search results
const selectedButNotInResults = selectedItems.value.filter(
(item) =>
!searchResults.some((result: SelectOption) => result.value === item.value)
!searchResults.some((result: Option) => result.value === item.value)
)
return [...selectedButNotInResults, ...searchResults]

View File

@@ -1,12 +1,21 @@
<template>
<SelectRoot v-model="selectedItem" :disabled>
<SelectTrigger
v-bind="$attrs"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
:class="
cn(
<!--
Note: We explicitly pass options here (not just via $attrs) because:
1. Our custom value template needs options to look up labels from values
2. PrimeVue's value slot only provides 'value' and 'placeholder', not the selected item's label
3. We need to maintain the icon slot functionality in the value template
option-label="name" is required because our option template directly accesses option.name
-->
<Select
v-model="selectedItem"
v-bind="$attrs"
:options="options"
option-label="name"
option-value="value"
unstyled
:pt="{
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
'relative inline-flex cursor-pointer items-center select-none',
size === 'md' ? 'h-8' : 'h-10',
'rounded-lg',
@@ -14,107 +23,121 @@
'transition-all duration-200 ease-in-out',
'hover:bg-secondary-background-hover',
'border-[2.5px] border-solid',
invalid ? 'border-destructive-background' : 'border-transparent',
'focus:border-node-component-border focus:outline-none',
'disabled:cursor-default disabled:opacity-30 disabled:hover:bg-secondary-background'
invalid
? 'border-destructive-background'
: 'border-transparent focus-within:border-node-component-border',
props.disabled &&
'cursor-default opacity-30 hover:bg-secondary-background'
)
"
>
}),
label: {
class: cn(
'flex flex-1 items-center py-2 whitespace-nowrap outline-hidden',
size === 'md' ? 'pl-3' : 'pl-4'
)
},
dropdown: {
class:
// Right chevron touch area
'flex shrink-0 items-center justify-center px-3 py-2'
},
overlay: {
class: cn(
'mt-2 rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default'
)
},
listContainer: () => ({
style: `max-height: min(${listMaxHeight}, 50vh)`,
class: 'scrollbar-custom'
}),
list: {
class:
// Same list tone/size as MultiSelect
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
option: ({ context }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
// Row layout
'flex items-center justify-between gap-3 rounded-sm px-2 py-3',
'hover:bg-secondary-background-hover',
// Add focus state for keyboard navigation
context.focused && 'bg-secondary-background-hover',
// Selected state + check icon
context.selected &&
'bg-secondary-background-selected hover:bg-secondary-background-selected'
)
}),
optionLabel: {
class: 'truncate'
},
optionGroupLabel: {
class: 'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground'
},
emptyMessage: {
class: 'px-3 py-2 text-sm text-muted-foreground'
}
}"
:aria-label="label || t('g.singleSelectDropdown')"
:aria-busy="loading || undefined"
:aria-invalid="invalid || undefined"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
:tabindex="0"
>
<!-- Trigger value -->
<template #value="slotProps">
<div
:class="
cn(
'flex flex-1 items-center gap-2 overflow-hidden py-2',
size === 'md' ? 'pl-3 text-xs' : 'pl-4 text-sm'
)
cn('flex items-center gap-2', size === 'md' ? 'text-xs' : 'text-sm')
"
>
<i
v-if="loading"
class="icon-[lucide--loader-circle] shrink-0 animate-spin text-muted-foreground"
class="icon-[lucide--loader-circle] animate-spin text-muted-foreground"
/>
<slot v-else name="icon" />
<SelectValue :placeholder="label" class="truncate" />
</div>
<div
class="flex shrink-0 cursor-pointer items-center justify-center px-3"
>
<i class="icon-[lucide--chevron-down] text-muted-foreground" />
</div>
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
:side-offset="8"
align="start"
:style="optionStyle"
:class="
cn(
'z-3000 overflow-hidden',
'rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'min-w-(--reka-select-trigger-width)',
'data-[state=closed]:animate-out data-[state=open]:animate-in',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2'
)
"
>
<SelectViewport
:style="{ maxHeight: `min(${listMaxHeight}, 50vh)` }"
class="scrollbar-custom w-full"
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
class="text-base-foreground"
>
<SelectItem
v-for="opt in options"
:key="opt.value"
:value="opt.value"
:class="
cn(
'relative flex w-full cursor-pointer items-center justify-between select-none',
'gap-3 rounded-sm px-2 py-3 text-sm outline-none',
'hover:bg-secondary-background-hover',
'focus:bg-secondary-background-hover',
'data-[state=checked]:bg-secondary-background-selected',
'data-[state=checked]:hover:bg-secondary-background-selected'
)
"
>
<SelectItemText class="truncate">
{{ opt.name }}
</SelectItemText>
<SelectItemIndicator
class="flex shrink-0 items-center justify-center"
>
<i
class="icon-[lucide--check] text-base-foreground"
aria-hidden="true"
/>
</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
{{ getLabel(slotProps.value) }}
</span>
<span v-else class="text-base-foreground">
{{ label }}
</span>
</div>
</template>
<!-- Trigger caret (hidden when loading) -->
<template #dropdownicon>
<i
v-if="!loading"
class="icon-[lucide--chevron-down] text-muted-foreground"
/>
</template>
<!-- Option row -->
<template #option="{ option, selected }">
<div
class="flex w-full items-center justify-between gap-3"
:style="optionStyle"
>
<span class="truncate">{{ option.name }}</span>
<i v-if="selected" class="icon-[lucide--check] text-base-foreground" />
</div>
</template>
</Select>
</template>
<script setup lang="ts">
import {
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport
} from 'reka-ui'
import type { SelectPassThroughMethodOptions } from 'primevue/select'
import Select from 'primevue/select'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'
import type { SelectOption } from './types'
@@ -129,12 +152,16 @@ const {
size = 'lg',
invalid = false,
loading = false,
disabled = false,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
} = defineProps<{
label?: string
/**
* Required for displaying the selected item's label.
* Cannot rely on $attrs alone because we need to access options
* in getLabel() to map values to their display names.
*/
options?: SelectOption[]
/** Trigger size: 'lg' (40px, Interface) or 'md' (32px, Node) */
size?: 'lg' | 'md'
@@ -142,8 +169,6 @@ const {
invalid?: boolean
/** Show loading spinner instead of chevron */
loading?: boolean
/** Disable the select */
disabled?: boolean
/** Maximum height of the dropdown panel (default: 28rem) */
listMaxHeight?: string
/** Minimum width of the popover (default: auto) */
@@ -156,8 +181,26 @@ const selectedItem = defineModel<string | undefined>({ required: true })
const { t } = useI18n()
const optionStyle = usePopoverSizing({
minWidth: popoverMinWidth,
maxWidth: popoverMaxWidth
/**
* Maps a value to its display label.
* Necessary because PrimeVue's value slot doesn't provide the selected item's label,
* only the raw value. We need this to show the correct text when an item is selected.
*/
const getLabel = (val: string | null | undefined) => {
if (val == null) return label ?? ''
if (!options) return label ?? ''
const found = options.find((o) => o.value === val)
return found ? found.name : (label ?? '')
}
// Extract complex style logic from template
const optionStyle = computed(() => {
if (!popoverMinWidth && !popoverMaxWidth) return undefined
const styles: string[] = []
if (popoverMinWidth) styles.push(`min-width: ${popoverMinWidth}`)
if (popoverMaxWidth) styles.push(`max-width: ${popoverMaxWidth}`)
return styles.join('; ')
})
</script>

View File

@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/composables/queue/useJobList'
vi.mock('@/composables/queue/useJobMenu', () => ({
useJobMenu: () => ({ jobMenuEntries: [] })
useJobMenu: () => ({ getJobMenuEntries: () => [] })
}))
vi.mock('@/composables/useErrorHandling', () => ({
@@ -30,10 +30,6 @@ const JobAssetsListStub = {
template: '<div class="job-assets-list-stub" />'
}
const JobContextMenuStub = {
template: '<div />'
}
const createJob = (): JobListItem => ({
id: 'job-1',
title: 'Job 1',
@@ -56,8 +52,7 @@ const mountComponent = () =>
stubs: {
QueueOverlayHeader: QueueOverlayHeaderStub,
JobFiltersBar: JobFiltersBarStub,
JobAssetsList: JobAssetsListStub,
JobContextMenu: JobContextMenuStub
JobAssetsList: JobAssetsListStub
}
}
})

View File

@@ -23,36 +23,28 @@
<div class="min-h-0 flex-1 overflow-y-auto">
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
:get-menu-entries="getJobMenuEntries"
@cancel-item="onCancelItemEvent"
@delete-item="onDeleteItemEvent"
@menu-action="onJobMenuAction"
@view-item="$emit('viewItem', $event)"
@menu="onMenuItem"
/>
</div>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type {
JobGroup,
JobListItem,
JobSortMode,
JobTab
} from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import type { MenuActionEntry } from '@/types/menuTypes'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
import JobAssetsList from './job/JobAssetsList.vue'
import JobFiltersBar from './job/JobFiltersBar.vue'
@@ -78,14 +70,9 @@ const emit = defineEmits<{
(e: 'viewItem', item: JobListItem): void
}>()
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
(item) => emit('viewItem', item)
)
const { getJobMenuEntries } = useJobMenu((item) => emit('viewItem', item))
const onCancelItemEvent = (item: JobListItem) => {
emit('cancelItem', item)
@@ -95,14 +82,9 @@ const onDeleteItemEvent = (item: JobListItem) => {
emit('deleteItem', item)
}
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)
}
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
if (entry.kind === 'divider') return
if (entry.onClick) await entry.onClick()
jobContextMenuRef.value?.hide()
})
const onJobMenuAction = wrapWithErrorHandlingAsync(
async (entry: MenuActionEntry) => {
if (entry.onClick) await entry.onClick()
}
)
</script>

View File

@@ -3,11 +3,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
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'
type JobPreviewOutput = NonNullable<
NonNullable<JobListItem['taskRef']>['previewOutput']
>
vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
default: {
name: 'JobDetailsPopover',
template: '<div class="job-details-popover-stub" />'
}
}))
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {
@@ -32,43 +41,32 @@ vi.mock('vue-i18n', () => {
}
})
const createResultItem = (
const createPreviewOutput = (
filename: string,
mediaType: string = 'images'
): ResultItemImpl => {
const item = new ResultItemImpl({
): JobPreviewOutput =>
({
filename,
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType
})
Object.defineProperty(item, 'url', {
get: () => `/api/view/${filename}`
})
return item
}
mediaType,
isImage: mediaType === 'images',
isVideo: mediaType === 'video',
isAudio: mediaType === 'audio',
is3D: mediaType === 'model',
url: `/api/view/${filename}`
}) as JobPreviewOutput
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
}
const flatOutputs = preview ? [preview] : []
return new TaskItemImpl(job, {}, flatOutputs)
}
const createTaskRef = (preview?: JobPreviewOutput): JobListItem['taskRef'] =>
({
workflowId: 'workflow-1',
previewOutput: preview
}) as JobListItem['taskRef']
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: createTaskRef(createResultItem('job-1.png')),
taskRef: createTaskRef(createPreviewOutput('job-1.png')),
...overrides
})
@@ -82,7 +80,10 @@ const mountJobAssetsList = (jobs: JobListItem[]) => {
]
return mount(JobAssetsList, {
props: { displayedJobGroups },
props: {
displayedJobGroups,
getMenuEntries: () => []
},
global: {
stubs: {
teleport: true,
@@ -147,7 +148,7 @@ 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 wrapper = mountJobAssetsList([job])
@@ -164,7 +165,7 @@ 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 wrapper = mountJobAssetsList([job])
@@ -179,7 +180,7 @@ 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 wrapper = mountJobAssetsList([job])

View File

@@ -15,64 +15,97 @@
@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
>
<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)"
<ContextMenu :modal="false">
<ContextMenuTrigger as-child>
<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"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(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>
<template v-if="shouldShowActionsMenu(job.id)" #actions>
<Button
v-if="isCancelable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="emitCancelItem(job)"
>
<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>
<DropdownMenu
:open="openActionsJobId === job.id"
@update:open="onActionsMenuOpenChange(job.id, $event)"
>
<DropdownMenuTrigger as-child>
<Button
class="job-actions-menu-trigger"
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
close-on-scroll
class="z-1700 bg-transparent p-0 shadow-lg"
:side-offset="4"
:collision-padding="8"
>
<JobMenuItems
:entries="getMenuEntries(job)"
surface="dropdown"
@action="onMenuAction($event)"
/>
</DropdownMenuContent>
</DropdownMenu>
</template>
</AssetsListItem>
</ContextMenuTrigger>
<ContextMenuContent
close-on-scroll
class="z-1700 bg-transparent p-0 font-inter shadow-lg"
>
<JobMenuItems
:entries="getMenuEntries(job)"
surface="context"
@action="onMenuAction($event)"
/>
</ContextMenuContent>
</ContextMenu>
</div>
</div>
</div>
@@ -80,7 +113,7 @@
<Teleport to="body">
<div
v-if="activeDetails && popoverPosition"
class="job-details-popover fixed z-50"
class="job-details-popover fixed z-1700"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
@@ -103,24 +136,36 @@ import { nextTick, ref } from 'vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
import Button from '@/components/ui/button/Button.vue'
import ContextMenu from '@/components/ui/context-menu/ContextMenu.vue'
import ContextMenuContent from '@/components/ui/context-menu/ContextMenuContent.vue'
import ContextMenuTrigger from '@/components/ui/context-menu/ContextMenuTrigger.vue'
import DropdownMenu from '@/components/ui/dropdown-menu/DropdownMenu.vue'
import DropdownMenuContent from '@/components/ui/dropdown-menu/DropdownMenuContent.vue'
import DropdownMenuTrigger from '@/components/ui/dropdown-menu/DropdownMenuTrigger.vue'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { cn } from '@/utils/tailwindUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { isActiveJobState } from '@/utils/queueUtil'
import JobMenuItems from './JobMenuItems.vue'
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
const { displayedJobGroups, getMenuEntries } = defineProps<{
displayedJobGroups: JobGroup[]
getMenuEntries: (item: JobListItem) => MenuEntry[]
}>()
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
(e: 'menu-action', entry: MenuActionEntry): void
(e: 'viewItem', item: JobListItem): void
}>()
const { t } = useI18n()
const hoveredJobId = ref<string | null>(null)
const openActionsJobId = ref<string | null>(null)
const activeRowElement = ref<HTMLElement | null>(null)
const popoverPosition = ref<{ top: number; left: number } | null>(null)
const {
@@ -152,7 +197,7 @@ function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
scheduleDetailsHide(jobId, clearPopoverAnchor)
scheduleDetailsHide(jobId)
}
function onJobEnter(job: JobListItem, event: MouseEvent) {
@@ -188,6 +233,26 @@ function isFailedDeletable(job: JobListItem) {
return job.showClear !== false && job.state === 'failed'
}
function shouldShowActionsMenu(jobId: string) {
return hoveredJobId.value === jobId || openActionsJobId.value === jobId
}
function onActionsMenuOpenChange(jobId: string, isOpen: boolean) {
if (isOpen) {
openActionsJobId.value = jobId
return
}
if (openActionsJobId.value === jobId) {
openActionsJobId.value = null
}
}
function onMenuAction(entry: MenuActionEntry) {
resetActiveDetails()
emit('menu-action', entry)
}
function getPreviewOutput(job: JobListItem) {
return job.taskRef?.previewOutput
}
@@ -235,7 +300,7 @@ function onPopoverEnter() {
}
function onPopoverLeave() {
scheduleDetailsHide(activeDetails.value?.jobId, clearPopoverAnchor)
scheduleDetailsHide(activeDetails.value?.jobId)
}
function getJobIconClass(job: JobListItem): string | undefined {

View File

@@ -1,195 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
const popoverStub = defineComponent({
name: 'Popover',
emits: ['show', 'hide'],
data() {
return {
visible: false,
container: null as HTMLElement | null,
eventTarget: null as EventTarget | null,
target: null as EventTarget | null
}
},
mounted() {
this.container = this.$refs.container as HTMLElement | null
},
updated() {
this.container = this.$refs.container as HTMLElement | null
},
methods: {
toggle(event: Event, target?: EventTarget | null) {
if (this.visible) {
this.hide()
return
}
this.show(event, target)
},
show(event: Event, target?: EventTarget | null) {
this.visible = true
this.eventTarget = event.currentTarget
this.target = target ?? event.currentTarget
this.$emit('show')
},
hide() {
this.visible = false
this.$emit('hide')
}
},
template: `
<div v-if="visible" ref="container" class="popover-stub">
<slot />
</div>
`
})
const buttonStub = {
props: {
disabled: {
type: Boolean,
default: false
}
},
template: `
<div
class="button-stub"
:data-disabled="String(disabled)"
>
<slot />
</div>
`
}
const createEntries = (): MenuEntry[] => [
{ key: 'enabled', label: 'Enabled action', onClick: vi.fn() },
{
key: 'disabled',
label: 'Disabled action',
disabled: true,
onClick: vi.fn()
},
{ kind: 'divider', key: 'divider-1' }
]
const mountComponent = (entries: MenuEntry[]) =>
mount(JobContextMenu, {
props: { entries },
global: {
stubs: {
Popover: popoverStub,
Button: buttonStub
}
}
})
const createTriggerEvent = (type: string, currentTarget: EventTarget) =>
({
type,
currentTarget,
target: currentTarget
}) as Event
const openMenu = async (
wrapper: ReturnType<typeof mountComponent>,
type: string = 'click'
) => {
const trigger = document.createElement('button')
document.body.append(trigger)
await wrapper.vm.open(createTriggerEvent(type, trigger))
await nextTick()
return trigger
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('JobContextMenu', () => {
it('passes disabled state to action buttons', async () => {
const wrapper = mountComponent(createEntries())
await openMenu(wrapper)
const buttons = wrapper.findAll('.button-stub')
expect(buttons).toHaveLength(2)
expect(buttons[0].attributes('data-disabled')).toBe('false')
expect(buttons[1].attributes('data-disabled')).toBe('true')
wrapper.unmount()
})
it('emits action for enabled entries', async () => {
const entries = createEntries()
const wrapper = mountComponent(entries)
await openMenu(wrapper)
await wrapper.findAll('.button-stub')[0].trigger('click')
expect(wrapper.emitted('action')).toEqual([[entries[0]]])
wrapper.unmount()
})
it('does not emit action for disabled entries', async () => {
const wrapper = mountComponent([
{
key: 'disabled',
label: 'Disabled action',
disabled: true,
onClick: vi.fn()
}
])
await openMenu(wrapper)
await wrapper.get('.button-stub').trigger('click')
expect(wrapper.emitted('action')).toBeUndefined()
wrapper.unmount()
})
it('hides on pointerdown outside the popover', async () => {
const wrapper = mountComponent(createEntries())
const trigger = document.createElement('button')
const outside = document.createElement('div')
document.body.append(trigger, outside)
await wrapper.vm.open(createTriggerEvent('contextmenu', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(false)
wrapper.unmount()
})
it('keeps the menu open through trigger pointerdown and closes on same trigger click', async () => {
const wrapper = mountComponent(createEntries())
const trigger = document.createElement('button')
document.body.append(trigger)
await wrapper.vm.open(createTriggerEvent('click', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
trigger.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
await wrapper.vm.open(createTriggerEvent('click', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(false)
wrapper.unmount()
})
})

View File

@@ -1,118 +0,0 @@
<template>
<Popover
ref="jobItemPopoverRef"
:dismissable="false"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
@show="isVisible = true"
@hide="onHide"
>
<div
ref="contentRef"
class="flex min-w-56 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<template v-for="entry in entries" :key="entry.key">
<div v-if="entry.kind === 'divider'" class="px-2 py-1">
<div class="h-px bg-interface-stroke" />
</div>
<Button
v-else
class="w-full justify-start bg-transparent"
variant="textonly"
size="sm"
:aria-label="entry.label"
:disabled="entry.disabled"
@click="onEntry(entry)"
>
<i
v-if="entry.icon"
:class="[
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
]"
/>
<span>{{ entry.label }}</span>
</Button>
</template>
</div>
</Popover>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { nextTick, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
defineProps<{ entries: MenuEntry[] }>()
const emit = defineEmits<{
(e: 'action', entry: MenuEntry): void
}>()
type PopoverHandle = {
hide: () => void
show: (event: Event, target?: EventTarget | null) => void
}
const jobItemPopoverRef = ref<PopoverHandle | null>(null)
const contentRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const isVisible = ref(false)
const openedByClick = ref(false)
useDismissableOverlay({
isOpen: isVisible,
getOverlayEl: () => contentRef.value,
getTriggerEl: () => (openedByClick.value ? triggerRef.value : null),
onDismiss: hide
})
async function open(event: Event) {
const trigger =
event.currentTarget instanceof HTMLElement ? event.currentTarget : null
const isSameClickTrigger =
event.type === 'click' && trigger === triggerRef.value && isVisible.value
if (isSameClickTrigger) {
hide()
return
}
openedByClick.value = event.type === 'click'
triggerRef.value = trigger
if (isVisible.value) {
hide()
await nextTick()
}
jobItemPopoverRef.value?.show(event, trigger)
}
function hide() {
jobItemPopoverRef.value?.hide()
}
function onHide() {
isVisible.value = false
openedByClick.value = false
}
function onEntry(entry: MenuEntry) {
if (entry.kind === 'divider' || entry.disabled) return
emit('action', entry)
}
defineExpose({ open, hide })
</script>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import Button from '@/components/ui/button/Button.vue'
import ContextMenuItem from '@/components/ui/context-menu/ContextMenuItem.vue'
import ContextMenuSeparator from '@/components/ui/context-menu/ContextMenuSeparator.vue'
import DropdownMenuItem from '@/components/ui/dropdown-menu/DropdownMenuItem.vue'
import DropdownMenuSeparator from '@/components/ui/dropdown-menu/DropdownMenuSeparator.vue'
import { cn } from '@/utils/tailwindUtil'
const { entries, surface } = defineProps<{
entries: MenuEntry[]
surface: 'context' | 'dropdown'
}>()
const emit = defineEmits<{
action: [entry: MenuActionEntry]
}>()
function isActionEntry(entry: MenuEntry): entry is MenuActionEntry {
return entry.kind !== 'divider'
}
</script>
<template>
<div
class="job-menu-panel flex min-w-56 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<template v-for="entry in entries" :key="entry.key">
<ContextMenuSeparator
v-if="surface === 'context' && entry.kind === 'divider'"
class="mx-2 my-1 h-px bg-interface-stroke"
/>
<DropdownMenuSeparator
v-else-if="surface === 'dropdown' && entry.kind === 'divider'"
class="mx-2 my-1 h-px bg-interface-stroke"
/>
<ContextMenuItem
v-else-if="surface === 'context' && isActionEntry(entry)"
as-child
:disabled="entry.disabled"
:text-value="entry.label"
@select="emit('action', entry)"
>
<Button
variant="textonly"
size="sm"
class="w-full justify-start bg-transparent data-highlighted:bg-secondary-background-hover"
>
<i
v-if="entry.icon"
:class="
cn(
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
)
"
/>
<span>{{ entry.label }}</span>
</Button>
</ContextMenuItem>
<DropdownMenuItem
v-else-if="isActionEntry(entry)"
as-child
:disabled="entry.disabled"
:text-value="entry.label"
@select="emit('action', entry)"
>
<Button
variant="textonly"
size="sm"
class="w-full justify-start bg-transparent data-highlighted:bg-secondary-background-hover"
>
<i
v-if="entry.icon"
:class="
cn(
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
)
"
/>
<span>{{ entry.label }}</span>
</Button>
</DropdownMenuItem>
</template>
</div>
</template>

View File

@@ -9,7 +9,7 @@
<Teleport to="body">
<div
v-if="!isPreviewVisible && showDetails && popoverPosition"
class="fixed z-50"
class="fixed z-1700"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
@@ -23,7 +23,7 @@
<Teleport to="body">
<div
v-if="isPreviewVisible && canShowPreview && popoverPosition"
class="fixed z-50"
class="fixed z-1700"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`

View File

@@ -9,15 +9,12 @@ import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import { st } from '@/i18n'
import { app } from '@/scripts/app'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
@@ -41,21 +38,12 @@ import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const { hasAnyError, allErrorExecutionIds } = storeToRefs(executionErrorStore)
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
if (!app.isGraphReady) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
canvasStore.currentGraph ?? app.rootGraph,
missingNodesErrorStore.missingAncestorExecutionIds
)
})
const { hasAnyError, allErrorExecutionIds, activeMissingNodeGraphIds } =
storeToRefs(executionErrorStore)
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
@@ -303,7 +291,6 @@ function handleTitleCancel() {
v-if="isSingleSubgraphNode"
variant="secondary"
size="icon"
data-testid="subgraph-editor-toggle"
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
@click="
rightSidePanelStore.openPanel(

View File

@@ -237,11 +237,6 @@ describe('ErrorNodeCard.vue', () => {
// Report is still generated with fallback log message
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
serverLogs: 'Failed to retrieve server logs'
})
)
expect(wrapper.text()).toContain('ComfyUI Error Report')
})

View File

@@ -90,7 +90,6 @@
variant="secondary"
size="sm"
class="h-8 w-2/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-find-on-github"
@click="handleCheckGithub(error)"
>
{{ t('g.findOnGithub') }}
@@ -100,7 +99,6 @@
variant="secondary"
size="sm"
class="h-8 w-1/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
{{ t('g.copy') }}
@@ -127,10 +125,12 @@
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { cn } from '@/utils/tailwindUtil'
import type { ErrorCardData, ErrorItem } from './types'
import { useErrorActions } from './useErrorActions'
import { useErrorReport } from './useErrorReport'
const {
@@ -154,8 +154,10 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const telemetry = useTelemetry()
const { staticUrls } = useExternalLink()
const commandStore = useCommandStore()
const { displayedDetailsMap } = useErrorReport(() => card)
const { findOnGitHub, contactSupport: handleGetHelp } = useErrorActions()
function handleLocateNode() {
if (card.nodeId) {
@@ -176,6 +178,23 @@ function handleCopyError(idx: number) {
}
function handleCheckGithub(error: ErrorItem) {
findOnGitHub(error.message)
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
})
const query = encodeURIComponent(error.message + ' is:issue')
window.open(
`${staticUrls.githubIssues}?q=${query}`,
'_blank',
'noopener,noreferrer'
)
}
function handleGetHelp() {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
commandStore.execute('Comfy.ContactSupport')
}
</script>

View File

@@ -1,5 +1,7 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -40,25 +42,23 @@ vi.mock('@/stores/systemStatsStore', () => ({
})
}))
const mockApplyChanges = vi.hoisted(() => vi.fn())
const mockIsRestarting = vi.hoisted(() => ({ value: false }))
const mockApplyChanges = vi.fn()
const mockIsRestarting = ref(false)
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
useApplyChanges: () => ({
get isRestarting() {
return mockIsRestarting.value
},
isRestarting: mockIsRestarting,
applyChanges: mockApplyChanges
})
}))
const mockIsPackInstalled = vi.hoisted(() => vi.fn(() => false))
const mockIsPackInstalled = vi.fn(() => false)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
isPackInstalled: mockIsPackInstalled
})
}))
const mockShouldShowManagerButtons = vi.hoisted(() => ({ value: false }))
const mockShouldShowManagerButtons = { value: false }
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: mockShouldShowManagerButtons
@@ -128,7 +128,7 @@ function mountCard(
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
DotSpinner: { template: '<span role="status" aria-label="loading" />' }
}

View File

@@ -209,9 +209,12 @@ describe('TabErrors.vue', () => {
}
})
const copyButton = wrapper.find('[data-testid="error-card-copy"]')
expect(copyButton.exists()).toBe(true)
await copyButton.trigger('click')
// Find the copy button by text (rendered inside ErrorNodeCard)
const copyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Copy'))
expect(copyButton).toBeTruthy()
await copyButton!.trigger('click')
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
})
@@ -242,9 +245,5 @@ describe('TabErrors.vue', () => {
// Should render in the dedicated runtime error panel, not inside accordion
const runtimePanel = wrapper.find('[data-testid="runtime-error-panel"]')
expect(runtimePanel.exists()).toBe(true)
// Verify the error message appears exactly once (not duplicated in accordion)
expect(
wrapper.text().match(/RuntimeError: Out of memory/g) ?? []
).toHaveLength(1)
})
})

View File

@@ -53,7 +53,6 @@
<PropertiesAccordionItem
v-for="group in filteredGroups"
:key="group.title"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:collapse="isSectionCollapsed(group.title) && !isSearching"
class="border-b border-interface-stroke"
:size="getGroupSize(group)"
@@ -210,9 +209,12 @@
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
@@ -236,7 +238,6 @@ import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorActions } from './useErrorActions'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
@@ -245,7 +246,7 @@ import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacemen
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode, enterSubgraph } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const { staticUrls } = useExternalLink()
const settingStore = useSettingStore()
const rightSidePanelStore = useRightSidePanelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
@@ -371,13 +372,13 @@ watch(
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
const hasMatch =
group.type === 'execution' &&
group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
setSectionCollapsed(group.title, !hasMatch)
}
rightSidePanelStore.focusedErrorNodeId = null
@@ -417,4 +418,20 @@ function handleReplaceAll() {
function handleEnterSubgraph(nodeId: string) {
enterSubgraph(nodeId, errorNodeCache.value)
}
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
async function contactSupport() {
useTelemetry()?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
useCommandStore().execute('Comfy.ContactSupport')
}
</script>

View File

@@ -47,7 +47,7 @@ vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
@@ -80,7 +80,8 @@ describe('swapNodeGroups computed', () => {
})
function getSwapNodeGroups(nodeTypes: MissingNodeType[]) {
useMissingNodesErrorStore().surfaceMissingNodes(nodeTypes)
const store = useExecutionErrorStore()
store.surfaceMissingNodes(nodeTypes)
const searchQuery = ref('')
const t = (key: string) => key

View File

@@ -1,39 +0,0 @@
import { useCommandStore } from '@/stores/commandStore'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
export function useErrorActions() {
const telemetry = useTelemetry()
const commandStore = useCommandStore()
const { staticUrls } = useExternalLink()
function openGitHubIssues() {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
function contactSupport() {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
void commandStore.execute('Comfy.ContactSupport')
}
function findOnGitHub(errorMessage: string) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
})
const query = encodeURIComponent(errorMessage + ' is:issue')
window.open(
`${staticUrls.githubIssues}?q=${query}`,
'_blank',
'noopener,noreferrer'
)
}
return { openGitHubIssues, contactSupport, findOnGitHub }
}

View File

@@ -58,7 +58,6 @@ vi.mock(
)
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
@@ -127,9 +126,8 @@ describe('useErrorGroups', () => {
})
it('groups non-replaceable nodes by cnrId', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' }),
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
@@ -148,9 +146,8 @@ describe('useErrorGroups', () => {
})
it('excludes replaceable nodes from missingPackGroups', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -167,9 +164,8 @@ describe('useErrorGroups', () => {
})
it('groups nodes without cnrId under null packId', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('UnknownNode', { nodeId: '1' }),
makeMissingNodeType('AnotherUnknown', { nodeId: '2' })
])
@@ -181,9 +177,8 @@ describe('useErrorGroups', () => {
})
it('sorts groups alphabetically with null packId last', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'zebra-pack' }),
makeMissingNodeType('NodeB', { nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'alpha-pack', nodeId: '3' })
@@ -195,9 +190,8 @@ describe('useErrorGroups', () => {
})
it('sorts nodeTypes within each group alphabetically by type then nodeId', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '3' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '1' })
@@ -212,9 +206,8 @@ describe('useErrorGroups', () => {
})
it('handles string nodeType entries', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
'StringGroupNode' as unknown as MissingNodeType
])
await nextTick()
@@ -231,9 +224,8 @@ describe('useErrorGroups', () => {
})
it('includes missing_node group when missing nodes exist', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
@@ -245,9 +237,8 @@ describe('useErrorGroups', () => {
})
it('includes swap_nodes group when replaceable nodes exist', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -262,9 +253,8 @@ describe('useErrorGroups', () => {
})
it('includes both swap_nodes and missing_node when both exist', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -282,9 +272,8 @@ describe('useErrorGroups', () => {
})
it('swap_nodes has lower priority than missing_node', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
@@ -544,18 +533,13 @@ describe('useErrorGroups', () => {
})
it('includes missing node group title as message', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
missingNodesStore.setMissingNodeTypes([
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
const missingGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
expect(groups.groupedErrorMessages.value).toContain(missingGroup!.title)
expect(groups.groupedErrorMessages.value.length).toBeGreaterThan(0)
})
})

View File

@@ -5,7 +5,6 @@ import type { IFuseOptions } from 'fuse.js'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
@@ -196,8 +195,12 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableMessage: card.errors.map((e) => e.message).join(' '),
searchableDetails: card.errors.map((e) => e.details ?? '').join(' ')
searchableMessage: card.errors
.map((e: ErrorItem) => e.message)
.join(' '),
searchableDetails: card.errors
.map((e: ErrorItem) => e.details ?? '')
.join(' ')
})
}
}
@@ -237,7 +240,6 @@ export function useErrorGroups(
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
const missingNodesStore = useMissingNodesErrorStore()
const missingModelStore = useMissingModelStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
@@ -283,7 +285,7 @@ export function useErrorGroups(
const missingNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
for (const nodeType of nodeTypes) {
if (typeof nodeType === 'string') continue
if (nodeType.nodeId == null) continue
@@ -405,7 +407,7 @@ export function useErrorGroups(
const asyncResolvedIds = ref<Map<string, string | null>>(new Map())
const pendingTypes = computed(() =>
(missingNodesStore.missingNodesError?.nodeTypes ?? []).filter(
(executionErrorStore.missingNodesError?.nodeTypes ?? []).filter(
(n): n is Exclude<MissingNodeType, string> =>
typeof n !== 'string' && !n.cnrId
)
@@ -446,8 +448,6 @@ export function useErrorGroups(
for (const r of results) {
if (r.status === 'fulfilled') {
final.set(r.value.type, r.value.packId)
} else {
console.warn('Failed to resolve pack ID:', r.reason)
}
}
// Clear any remaining RESOLVING markers for failed lookups
@@ -459,18 +459,8 @@ export function useErrorGroups(
{ immediate: true }
)
// Evict stale entries when missing nodes are cleared
watch(
() => missingNodesStore.missingNodesError,
(error) => {
if (!error && asyncResolvedIds.value.size > 0) {
asyncResolvedIds.value = new Map()
}
}
)
const missingPackGroups = computed<MissingPackGroup[]>(() => {
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const map = new Map<
string | null,
{ nodeTypes: MissingNodeType[]; isResolving: boolean }
@@ -532,7 +522,7 @@ export function useErrorGroups(
})
const swapNodeGroups = computed<SwapNodeGroup[]>(() => {
const nodeTypes = missingNodesStore.missingNodesError?.nodeTypes ?? []
const nodeTypes = executionErrorStore.missingNodesError?.nodeTypes ?? []
const map = new Map<string, SwapNodeGroup>()
for (const nodeType of nodeTypes) {
@@ -556,7 +546,7 @@ export function useErrorGroups(
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
function buildMissingNodeGroups(): ErrorGroup[] {
const error = missingNodesStore.missingNodesError
const error = executionErrorStore.missingNodesError
if (!error) return []
const groups: ErrorGroup[] = []

View File

@@ -2,8 +2,6 @@ import { computed, onMounted, onUnmounted, reactive, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { until } from '@vueuse/core'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
@@ -42,33 +40,24 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
if (runtimeErrors.length === 0) return
if (!systemStatsStore.systemStats) {
if (systemStatsStore.isLoading) {
await until(systemStatsStore.isLoading).toBe(false)
} else {
try {
await systemStatsStore.refetchSystemStats()
} catch (e) {
console.warn('Failed to fetch system stats for error report:', e)
return
}
try {
await systemStatsStore.refetchSystemStats()
} catch {
return
}
}
if (!systemStatsStore.systemStats || cancelled) return
if (cancelled || !systemStatsStore.systemStats) return
let logs: string
try {
logs = await api.getLogs()
} catch {
logs = 'Failed to retrieve server logs'
}
const logs = await api
.getLogs()
.catch(() => 'Failed to retrieve server logs')
if (cancelled) return
const workflow = (() => {
try {
return app.rootGraph.serialize()
} catch (e) {
console.warn('Failed to serialize workflow for error report:', e)
return null
}
})()
if (!workflow) return
const workflow = app.rootGraph.serialize()
for (const { error, idx } of runtimeErrors) {
try {
@@ -83,8 +72,8 @@ export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
workflow
})
enrichedDetails[idx] = report
} catch (e) {
console.warn('Failed to generate error report:', e)
} catch {
// Fallback: keep original error.details
}
}
})

View File

@@ -223,7 +223,6 @@ onMounted(() => {
<div
v-if="filteredActive.length"
data-testid="subgraph-editor-shown-section"
class="flex flex-col border-b border-interface-stroke"
>
<div
@@ -255,7 +254,6 @@ onMounted(() => {
<div
v-if="filteredCandidates.length"
data-testid="subgraph-editor-hidden-section"
class="flex flex-col border-b border-interface-stroke"
>
<div

View File

@@ -38,14 +38,11 @@ function getIcon() {
<div class="line-clamp-1 text-xs text-text-secondary">
{{ nodeTitle }}
</div>
<div class="line-clamp-1 text-sm/8" data-testid="subgraph-widget-label">
{{ widgetName }}
</div>
<div class="line-clamp-1 text-sm/8">{{ widgetName }}</div>
</div>
<Button
variant="muted-textonly"
size="sm"
data-testid="subgraph-widget-toggle"
:disabled="isPhysical"
@click.stop="$emit('toggleVisibility')"
>

View File

@@ -9,15 +9,56 @@
>
<template #item="{ item }">
<MediaAssetCard
v-if="assetsStore.isAssetDeleting(item.asset.id)"
:asset="item.asset"
:selected="isSelected(item.asset.id)"
:show-output-count="showOutputCount(item.asset)"
:output-count="getOutputCount(item.asset)"
:show-delete-button="showDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
@click="emit('select-asset', item.asset)"
@context-menu="emit('context-menu', $event, item.asset)"
@zoom="emit('zoom', item.asset)"
@asset-deleted="emit('asset-deleted')"
@bulk-download="emit('bulk-download', $event)"
@bulk-delete="emit('bulk-delete', $event)"
@bulk-add-to-workflow="emit('bulk-add-to-workflow', $event)"
@bulk-open-workflow="emit('bulk-open-workflow', $event)"
@bulk-export-workflow="emit('bulk-export-workflow', $event)"
@output-count-click="emit('output-count-click', item.asset)"
/>
<ContextMenu v-else :modal="false">
<ContextMenuTrigger as-child>
<MediaAssetCard
:asset="item.asset"
:selected="isSelected(item.asset.id)"
:show-output-count="showOutputCount(item.asset)"
:output-count="getOutputCount(item.asset)"
:show-delete-button="showDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
@click="emit('select-asset', item.asset)"
@zoom="emit('zoom', item.asset)"
@asset-deleted="emit('asset-deleted')"
@bulk-download="emit('bulk-download', $event)"
@bulk-delete="emit('bulk-delete', $event)"
@bulk-add-to-workflow="emit('bulk-add-to-workflow', $event)"
@bulk-open-workflow="emit('bulk-open-workflow', $event)"
@bulk-export-workflow="emit('bulk-export-workflow', $event)"
@output-count-click="emit('output-count-click', item.asset)"
/>
</ContextMenuTrigger>
<ContextMenuContent
close-on-scroll
class="z-1700 bg-transparent p-0 shadow-lg"
>
<MediaAssetMenuItems
:entries="getAssetMenuEntries(item.asset)"
surface="context"
@action="void onAssetMenuAction($event)"
/>
</ContextMenuContent>
</ContextMenu>
</template>
</VirtualGrid>
</div>
@@ -27,24 +68,60 @@
import { computed } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ContextMenu from '@/components/ui/context-menu/ContextMenu.vue'
import ContextMenuContent from '@/components/ui/context-menu/ContextMenuContent.vue'
import ContextMenuTrigger from '@/components/ui/context-menu/ContextMenuTrigger.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import MediaAssetMenuItems from '@/platform/assets/components/MediaAssetMenuItems.vue'
import { useMediaAssetMenu } from '@/platform/assets/composables/useMediaAssetMenu'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetsStore } from '@/stores/assetsStore'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
const { assets, isSelected, showOutputCount, getOutputCount } = defineProps<{
const {
assets,
isSelected,
showOutputCount,
getOutputCount,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
(e: 'zoom', asset: AssetItem): void
(e: 'asset-deleted'): void
(e: 'bulk-download', assets: AssetItem[]): void
(e: 'bulk-delete', assets: AssetItem[]): void
(e: 'bulk-add-to-workflow', assets: AssetItem[]): void
(e: 'bulk-open-workflow', assets: AssetItem[]): void
(e: 'bulk-export-workflow', assets: AssetItem[]): void
(e: 'output-count-click', asset: AssetItem): void
}>()
const assetsStore = useAssetsStore()
const { getMenuEntries } = useMediaAssetMenu({
inspectAsset: (asset) => emit('zoom', asset),
assetDeleted: () => emit('asset-deleted'),
bulkDownload: (assets) => emit('bulk-download', assets),
bulkDelete: (assets) => emit('bulk-delete', assets),
bulkAddToWorkflow: (assets) => emit('bulk-add-to-workflow', assets),
bulkOpenWorkflow: (assets) => emit('bulk-open-workflow', assets),
bulkExportWorkflow: (assets) => emit('bulk-export-workflow', assets)
})
type AssetGridItem = { key: string; asset: AssetItem }
const assetItems = computed<AssetGridItem[]>(() =>
@@ -54,6 +131,21 @@ const assetItems = computed<AssetGridItem[]>(() =>
}))
)
function getAssetMenuEntries(asset: AssetItem): MenuEntry[] {
return getMenuEntries({
asset,
assetType: getAssetType(asset.tags),
fileKind: getMediaTypeFromFilename(asset.name),
showDeleteButton,
selectedAssets,
isBulkMode
})
}
async function onAssetMenuAction(entry: MenuActionEntry) {
await entry.onClick?.()
}
const gridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(min(200px, 30vw), 1fr))',

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
@@ -7,9 +8,9 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import AssetsSidebarListView from './AssetsSidebarListView.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
vi.mock('@/platform/assets/composables/useMediaAssetMenu', () => ({
useMediaAssetMenu: () => ({
getMenuEntries: () => []
})
}))
@@ -19,7 +20,18 @@ vi.mock('@/stores/assetsStore', () => ({
})
}))
const VirtualGridStub = defineComponent({
const i18n = createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: {
en: {}
}
})
const VirtualGridStub = {
name: 'VirtualGrid',
props: {
items: {
@@ -29,7 +41,59 @@ const VirtualGridStub = defineComponent({
},
template:
'<div><slot v-for="item in items" :key="item.key" name="item" :item="item" /></div>'
})
}
const AssetsListItemStub = {
name: 'AssetsListItem',
template:
'<div class="assets-list-item-stub"><slot /><slot name="actions" /></div>'
}
const ContextMenuStub = {
name: 'ContextMenu',
template: '<div class="context-menu-stub"><slot /></div>'
}
const ContextMenuTriggerStub = {
name: 'ContextMenuTrigger',
template: '<div class="context-menu-trigger-stub"><slot /></div>'
}
const ContextMenuContentStub = {
name: 'ContextMenuContent',
template: '<div class="context-menu-content-stub"><slot /></div>'
}
const DropdownMenuStub = {
name: 'DropdownMenu',
props: {
open: {
type: Boolean,
default: false
}
},
template: '<div class="dropdown-menu-stub"><slot /></div>'
}
const DropdownMenuTriggerStub = {
name: 'DropdownMenuTrigger',
template: '<div class="dropdown-menu-trigger-stub"><slot /></div>'
}
const DropdownMenuContentStub = {
name: 'DropdownMenuContent',
template: '<div class="dropdown-menu-content-stub"><slot /></div>'
}
const ButtonComponentStub = {
name: 'AppButton',
template: '<button class="button-stub" type="button"><slot /></button>'
}
const MediaAssetMenuItemsStub = {
name: 'MediaAssetMenuItems',
template: '<div class="media-asset-menu-items-stub" />'
}
const buildAsset = (id: string, name: string): AssetItem =>
({
@@ -53,7 +117,41 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
toggleStack: async () => {}
},
global: {
plugins: [i18n],
stubs: {
ContextMenu: ContextMenuStub,
ContextMenuTrigger: ContextMenuTriggerStub,
ContextMenuContent: ContextMenuContentStub,
DropdownMenu: DropdownMenuStub,
DropdownMenuTrigger: DropdownMenuTriggerStub,
DropdownMenuContent: DropdownMenuContentStub,
MediaAssetMenuItems: MediaAssetMenuItemsStub,
VirtualGrid: VirtualGridStub
}
}
})
const mountInteractiveListView = (assetItems: OutputStackListItem[] = []) =>
mount(AssetsSidebarListView, {
props: {
assetItems,
selectableAssets: [],
isSelected: () => false,
isStackExpanded: () => false,
toggleStack: async () => {}
},
global: {
plugins: [i18n],
stubs: {
AssetsListItem: AssetsListItemStub,
Button: ButtonComponentStub,
ContextMenu: ContextMenuStub,
ContextMenuTrigger: ContextMenuTriggerStub,
ContextMenuContent: ContextMenuContentStub,
DropdownMenu: DropdownMenuStub,
DropdownMenuTrigger: DropdownMenuTriggerStub,
DropdownMenuContent: DropdownMenuContentStub,
MediaAssetMenuItems: MediaAssetMenuItemsStub,
VirtualGrid: VirtualGridStub
}
}
@@ -131,4 +229,46 @@ describe('AssetsSidebarListView', () => {
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
})
it('keeps the row actions menu available after the row loses hover', async () => {
const imageAsset = {
...buildAsset('image-asset-open', 'image.png'),
user_metadata: {}
} satisfies AssetItem
const wrapper = mountInteractiveListView([buildOutputItem(imageAsset)])
const assetListItem = wrapper.find('.assets-list-item-stub')
await assetListItem.trigger('mouseenter')
const actionsMenu = wrapper.findComponent(DropdownMenuStub)
expect(actionsMenu.exists()).toBe(true)
actionsMenu.vm.$emit('update:open', true)
await nextTick()
await assetListItem.trigger('mouseleave')
await nextTick()
expect(wrapper.findComponent(DropdownMenuStub).exists()).toBe(true)
wrapper.findComponent(DropdownMenuStub).vm.$emit('update:open', false)
await nextTick()
expect(wrapper.findComponent(DropdownMenuStub).exists()).toBe(false)
})
it('does not select the row when clicking the actions trigger', async () => {
const imageAsset = {
...buildAsset('image-asset-actions', 'image.png'),
user_metadata: {}
} satisfies AssetItem
const wrapper = mountInteractiveListView([buildOutputItem(imageAsset)])
const assetListItem = wrapper.find('.assets-list-item-stub')
await assetListItem.trigger('mouseenter')
await wrapper.find('.button-stub').trigger('click')
expect(wrapper.emitted('select-asset')).toBeUndefined()
})
})

View File

@@ -16,49 +16,83 @@
>
<i class="pi pi-trash text-xs" />
</LoadingOverlay>
<AssetsListItem
role="button"
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: getAssetDisplayName(item.asset),
type: getAssetMediaType(item.asset)
})
"
:class="
cn(
getAssetCardClass(isSelected(item.asset.id)),
item.isChild && 'pl-6'
)
"
:preview-url="getAssetPreviewUrl(item.asset)"
:preview-alt="getAssetDisplayName(item.asset)"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
:stack-count="getStackCount(item.asset)"
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
:stack-expanded="isStackExpanded(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
@click.stop="emit('select-asset', item.asset, selectableAssets)"
@dblclick.stop="emit('preview-asset', item.asset)"
@preview-click="emit('preview-asset', item.asset)"
@stack-toggle="void toggleStack(item.asset)"
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
<Button
variant="secondary"
size="icon"
:aria-label="t('mediaAsset.actions.moreOptions')"
@click.stop="emit('context-menu', $event, item.asset)"
<ContextMenu :modal="false">
<ContextMenuTrigger as-child>
<AssetsListItem
role="button"
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: getAssetDisplayName(item.asset),
type: getAssetMediaType(item.asset)
})
"
:class="
cn(
getAssetCardClass(isSelected(item.asset.id)),
item.isChild && 'pl-6'
)
"
:preview-url="getAssetPreviewUrl(item.asset)"
:preview-alt="getAssetDisplayName(item.asset)"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
:stack-count="getStackCount(item.asset)"
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
:stack-expanded="isStackExpanded(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@mouseleave="onAssetLeave(item.asset.id)"
@click.stop="emit('select-asset', item.asset, selectableAssets)"
@dblclick.stop="emit('preview-asset', item.asset)"
@preview-click="emit('preview-asset', item.asset)"
@stack-toggle="void toggleStack(item.asset)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
<template v-if="shouldShowActionsMenu(item.asset.id)" #actions>
<DropdownMenu
:open="openActionsAssetId === item.asset.id"
@update:open="
onActionsMenuOpenChange(item.asset.id, $event)
"
>
<DropdownMenuTrigger as-child>
<Button
variant="secondary"
size="icon"
:aria-label="t('mediaAsset.actions.moreOptions')"
@click.stop
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
close-on-scroll
class="z-1700 bg-transparent p-0 shadow-lg"
:side-offset="4"
:collision-padding="8"
>
<MediaAssetMenuItems
:entries="getAssetMenuEntries(item.asset)"
surface="dropdown"
@action="void onAssetMenuAction($event)"
/>
</DropdownMenuContent>
</DropdownMenu>
</template>
</AssetsListItem>
</ContextMenuTrigger>
<ContextMenuContent
close-on-scroll
class="z-1700 bg-transparent p-0 shadow-lg"
>
<MediaAssetMenuItems
:entries="getAssetMenuEntries(item.asset)"
surface="context"
@action="void onAssetMenuAction($event)"
/>
</ContextMenuContent>
</ContextMenu>
</div>
</template>
</VirtualGrid>
@@ -72,13 +106,23 @@ import { useI18n } from 'vue-i18n'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import ContextMenu from '@/components/ui/context-menu/ContextMenu.vue'
import ContextMenuContent from '@/components/ui/context-menu/ContextMenuContent.vue'
import ContextMenuTrigger from '@/components/ui/context-menu/ContextMenuTrigger.vue'
import DropdownMenu from '@/components/ui/dropdown-menu/DropdownMenu.vue'
import DropdownMenuContent from '@/components/ui/dropdown-menu/DropdownMenuContent.vue'
import DropdownMenuTrigger from '@/components/ui/dropdown-menu/DropdownMenuTrigger.vue'
import { useMediaAssetMenu } from '@/platform/assets/composables/useMediaAssetMenu'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import MediaAssetMenuItems from '@/platform/assets/components/MediaAssetMenuItems.vue'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import { useAssetsStore } from '@/stores/assetsStore'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import {
formatDuration,
formatSize,
@@ -92,13 +136,19 @@ const {
selectableAssets,
isSelected,
isStackExpanded,
toggleStack
toggleStack,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
assetItems: OutputStackListItem[]
selectableAssets: AssetItem[]
isSelected: (assetId: string) => boolean
isStackExpanded: (asset: AssetItem) => boolean
toggleStack: (asset: AssetItem) => Promise<void>
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const assetsStore = useAssetsStore()
@@ -106,12 +156,27 @@ const assetsStore = useAssetsStore()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
(e: 'preview-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
(e: 'asset-deleted'): void
(e: 'bulk-download', assets: AssetItem[]): void
(e: 'bulk-delete', assets: AssetItem[]): void
(e: 'bulk-add-to-workflow', assets: AssetItem[]): void
(e: 'bulk-open-workflow', assets: AssetItem[]): void
(e: 'bulk-export-workflow', assets: AssetItem[]): void
}>()
const { t } = useI18n()
const hoveredAssetId = ref<string | null>(null)
const openActionsAssetId = ref<string | null>(null)
const { getMenuEntries } = useMediaAssetMenu({
inspectAsset: (asset) => emit('preview-asset', asset),
assetDeleted: () => emit('asset-deleted'),
bulkDownload: (assets) => emit('bulk-download', assets),
bulkDelete: (assets) => emit('bulk-delete', assets),
bulkAddToWorkflow: (assets) => emit('bulk-add-to-workflow', assets),
bulkOpenWorkflow: (assets) => emit('bulk-open-workflow', assets),
bulkExportWorkflow: (assets) => emit('bulk-export-workflow', assets)
})
const listGridStyle = {
display: 'grid',
@@ -128,6 +193,17 @@ function getAssetMediaType(asset: AssetItem) {
return getMediaTypeFromFilename(asset.name)
}
function getAssetMenuEntries(asset: AssetItem): MenuEntry[] {
return getMenuEntries({
asset,
assetType: getAssetType(asset.tags),
fileKind: getAssetMediaType(asset),
showDeleteButton,
selectedAssets,
isBulkMode
})
}
function isVideoAsset(asset: AssetItem): boolean {
return getAssetMediaType(asset) === 'video'
}
@@ -180,6 +256,27 @@ function getAssetCardClass(selected: boolean): string {
)
}
function shouldShowActionsMenu(assetId: string): boolean {
return (
hoveredAssetId.value === assetId || openActionsAssetId.value === assetId
)
}
function onActionsMenuOpenChange(assetId: string, isOpen: boolean): void {
if (isOpen) {
openActionsAssetId.value = assetId
return
}
if (openActionsAssetId.value === assetId) {
openActionsAssetId.value = null
}
}
async function onAssetMenuAction(entry: MenuActionEntry) {
await entry.onClick?.()
}
function onAssetEnter(assetId: string) {
hoveredAssetId.value = assetId
}

View File

@@ -94,10 +94,18 @@
:is-selected="isSelected"
:selectable-assets="listViewSelectableAssets"
:is-stack-expanded="isListViewStackExpanded"
:show-delete-button="shouldShowDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
:toggle-stack="toggleListViewStack"
@asset-deleted="refreshAssets"
@bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete"
@bulk-add-to-workflow="handleBulkAddToWorkflow"
@bulk-open-workflow="handleBulkOpenWorkflow"
@bulk-export-workflow="handleBulkExportWorkflow"
@select-asset="handleAssetSelect"
@preview-asset="handleZoomClick"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
<AssetsSidebarGridView
@@ -106,8 +114,16 @@
:is-selected="isSelected"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
:show-delete-button="shouldShowDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
@asset-deleted="refreshAssets"
@bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete"
@bulk-add-to-workflow="handleBulkAddToWorkflow"
@bulk-open-workflow="handleBulkOpenWorkflow"
@bulk-export-workflow="handleBulkExportWorkflow"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
@zoom="handleZoomClick"
@output-count-click="enterFolderView"
@@ -174,24 +190,6 @@
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
<MediaAssetContextMenu
v-if="contextMenuAsset"
ref="contextMenuRef"
:asset="contextMenuAsset"
:asset-type="contextMenuAssetType"
:file-kind="contextMenuFileKind"
:show-delete-button="shouldShowDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
@zoom="handleZoomClick(contextMenuAsset)"
@hide="handleContextMenuHide"
@asset-deleted="refreshAssets"
@bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete"
@bulk-add-to-workflow="handleBulkAddToWorkflow"
@bulk-open-workflow="handleBulkOpenWorkflow"
@bulk-export-workflow="handleBulkExportWorkflow"
/>
</template>
<script setup lang="ts">
@@ -200,14 +198,12 @@ import {
useDebounceFn,
useElementHover,
useResizeObserver,
useStorage,
useTimeoutFn
useStorage
} from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import {
computed,
defineAsyncComponent,
nextTick,
onMounted,
onUnmounted,
ref,
@@ -224,9 +220,7 @@ import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
@@ -236,7 +230,6 @@ import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadat
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
@@ -267,9 +260,6 @@ const viewMode = useStorage<'list' | 'grid'>(
)
const isListView = computed(() => viewMode.value === 'list')
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
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(() => {
@@ -277,14 +267,6 @@ const shouldShowDeleteButton = computed(() => {
return true
})
const contextMenuAssetType = computed(() =>
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
)
const contextMenuFileKind = computed<MediaKind>(() =>
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
)
const shouldShowOutputCount = (item: AssetItem): boolean => {
if (activeTab.value !== 'output' || isInFolderView.value) {
return false
@@ -502,26 +484,6 @@ function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
handleAssetClick(asset, index, assetList)
}
const { start: scheduleCleanup, stop: cancelCleanup } = useTimeoutFn(
() => {
contextMenuAsset.value = null
},
0,
{ immediate: false }
)
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
cancelCleanup()
contextMenuAsset.value = asset
void nextTick(() => {
contextMenuRef.value?.show(event)
})
}
function handleContextMenuHide() {
scheduleCleanup()
}
const handleBulkDownload = (assets: AssetItem[]) => {
downloadMultipleAssets(assets)
clearSelection()

View File

@@ -1,78 +1,76 @@
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 { describe, expect, it, beforeEach, vi } from 'vitest'
import { computed, defineComponent, ref } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import JobHistorySidebarTab from './JobHistorySidebarTab.vue'
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
const testState = vi.hoisted(() => ({
groupedJobItems: [] as Array<{
key: string
label: string
items: JobListItem[]
}>,
filteredTasks: [] as JobListItem[],
getJobMenuEntries: vi.fn(() => []),
cancelJob: vi.fn(),
openResultGallery: vi.fn(),
showQueueClearHistoryDialog: vi.fn(),
commandExecute: vi.fn(),
showDialog: vi.fn(),
clearInitializationByJobIds: vi.fn(),
queueDelete: vi.fn()
}))
const JobAssetsListStub = defineComponent({
name: 'JobAssetsList',
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'
}
displayedJobGroups: {
type: Array,
required: true
},
getMenuEntries: {
type: Function,
required: true
}
}
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]
}
])
})
}
},
template: '<div class="job-assets-list-stub" />'
})
vi.mock('@/composables/queue/useJobList', () => ({
useJobList: () => ({
selectedJobTab: ref('All'),
selectedWorkflowFilter: ref('all'),
selectedSortMode: ref('mostRecent'),
searchQuery: ref(''),
hasFailedJobs: ref(false),
filteredTasks: computed(() => testState.filteredTasks),
groupedJobItems: computed(() => testState.groupedJobItems)
})
}))
vi.mock('@/composables/queue/useJobMenu', () => ({
useJobMenu: () => ({
jobMenuEntries: [],
cancelJob: vi.fn()
getJobMenuEntries: testState.getJobMenuEntries,
cancelJob: testState.cancelJob
})
}))
vi.mock('@/composables/queue/useQueueClearHistoryDialog', () => ({
useQueueClearHistoryDialog: () => ({
showQueueClearHistoryDialog: vi.fn()
showQueueClearHistoryDialog: testState.showQueueClearHistoryDialog
})
}))
vi.mock('@/composables/queue/useResultGallery', async () => {
const { ref } = await import('vue')
return {
useResultGallery: () => ({
galleryActiveIndex: ref(-1),
galleryItems: ref([]),
onViewItem: vi.fn()
})
}
})
vi.mock('@/composables/queue/useResultGallery', () => ({
useResultGallery: () => ({
galleryActiveIndex: ref(-1),
galleryItems: ref([]),
onViewItem: testState.openResultGallery
})
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
@@ -84,19 +82,19 @@ vi.mock('@/composables/useErrorHandling', () => ({
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: vi.fn()
execute: testState.commandExecute
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
showDialog: vi.fn()
showDialog: testState.showDialog
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
clearInitializationByJobIds: vi.fn()
clearInitializationByJobIds: testState.clearInitializationByJobIds
})
}))
@@ -104,7 +102,7 @@ vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => ({
runningTasks: [],
pendingTasks: [],
delete: vi.fn()
delete: testState.queueDelete
})
}))
@@ -114,11 +112,33 @@ const i18n = createI18n({
messages: { en: {} }
})
const SidebarTabTemplateStub = {
name: 'SidebarTabTemplate',
props: ['title'],
template:
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem =>
({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: {
workflowId: 'workflow-1',
previewOutput: {
isImage: true,
isVideo: false,
is3D: false,
url: '/api/view/job-1.png'
}
},
...overrides
}) as JobListItem
const setDisplayedJobs = (items: JobListItem[]) => {
testState.filteredTasks = items
testState.groupedJobItems = [
{
key: 'group-1',
label: 'Group 1',
items
}
]
}
function mountComponent() {
@@ -126,38 +146,106 @@ function mountComponent() {
global: {
plugins: [i18n],
stubs: {
SidebarTabTemplate: SidebarTabTemplateStub,
SidebarTabTemplate: {
name: 'SidebarTabTemplate',
template:
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
},
JobFilterTabs: true,
JobFilterActions: true,
JobHistoryActionsMenu: true,
JobContextMenu: true,
ResultGallery: true,
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub
MediaLightbox: true,
JobAssetsList: JobAssetsListStub,
teleport: true
}
}
})
}
afterEach(() => {
vi.useRealTimers()
})
describe('JobHistorySidebarTab', () => {
it('shows the job details popover for jobs in the history panel', async () => {
vi.useFakeTimers()
beforeEach(() => {
vi.clearAllMocks()
setDisplayedJobs([buildJob()])
})
it('passes grouped jobs and menu getter to JobAssetsList', () => {
const wrapper = mountComponent()
const jobRow = wrapper.find('[data-job-id="job-1"]')
const jobAssetsList = wrapper.findComponent(JobAssetsListStub)
await jobRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
expect(jobAssetsList.props('displayedJobGroups')).toEqual(
testState.groupedJobItems
)
expect(jobAssetsList.props('getMenuEntries')).toBe(
testState.getJobMenuEntries
)
})
const popover = wrapper.findComponent(JobDetailsPopoverStub)
expect(popover.exists()).toBe(true)
expect(popover.props()).toMatchObject({
jobId: 'job-1',
workflowId: 'workflow-1'
it('forwards regular view-item events to the result gallery', async () => {
const job = buildJob()
setDisplayedJobs([job])
const wrapper = mountComponent()
wrapper.findComponent(JobAssetsListStub).vm.$emit('view-item', job)
expect(testState.openResultGallery).toHaveBeenCalledWith(job)
expect(testState.showDialog).not.toHaveBeenCalled()
})
it('opens the 3D viewer dialog for 3D view-item events', async () => {
const job = buildJob({
taskRef: {
workflowId: 'workflow-1',
previewOutput: {
isImage: false,
isVideo: false,
is3D: true,
url: '/api/view/job-1.glb'
}
} as JobListItem['taskRef']
})
setDisplayedJobs([job])
const wrapper = mountComponent()
wrapper.findComponent(JobAssetsListStub).vm.$emit('view-item', job)
expect(testState.showDialog).toHaveBeenCalledWith(
expect.objectContaining({
key: 'asset-3d-viewer',
title: job.title,
props: { modelUrl: '/api/view/job-1.glb' }
})
)
expect(testState.openResultGallery).not.toHaveBeenCalled()
})
it('forwards cancel-item events to useJobMenu.cancelJob', async () => {
const job = buildJob({ state: 'running' })
setDisplayedJobs([job])
const wrapper = mountComponent()
wrapper.findComponent(JobAssetsListStub).vm.$emit('cancel-item', job)
expect(testState.cancelJob).toHaveBeenCalledWith(job)
})
it('forwards delete-item events to queueStore.delete', async () => {
const job = buildJob()
const taskRef = job.taskRef
const wrapper = mountComponent()
wrapper.findComponent(JobAssetsListStub).vm.$emit('delete-item', job)
expect(testState.queueDelete).toHaveBeenCalledWith(taskRef)
})
it('runs menu actions emitted by JobAssetsList', async () => {
const onClick = vi.fn()
const wrapper = mountComponent()
wrapper
.findComponent(JobAssetsListStub)
.vm.$emit('menu-action', { key: 'test', label: 'Test', onClick })
expect(onClick).toHaveBeenCalled()
})
})

View File

@@ -48,15 +48,11 @@
<template #body>
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
:get-menu-entries="getJobMenuEntries"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@menu-action="onJobMenuAction"
@view-item="onViewItem"
@menu="onMenuItem"
/>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<MediaLightbox
v-model:active-index="galleryActiveIndex"
@@ -67,15 +63,14 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, ref } from 'vue'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
import JobFilterTabs from '@/components/queue/job/JobFilterTabs.vue'
import JobAssetsList from '@/components/queue/job/JobAssetsList.vue'
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import type { MenuActionEntry } from '@/types/menuTypes'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
@@ -182,13 +177,7 @@ const onInspectAsset = (item: JobListItem) => {
void onViewItem(item)
}
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { jobMenuEntries, cancelJob } = useJobMenu(
() => currentMenuItem.value,
onInspectAsset
)
const { getJobMenuEntries, cancelJob } = useJobMenu(onInspectAsset)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
await cancelJob(item)
@@ -199,14 +188,9 @@ const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
await queueStore.delete(item.taskRef)
})
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)
}
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
if (entry.kind === 'divider') return
if (entry.onClick) await entry.onClick()
jobContextMenuRef.value?.hide()
})
const onJobMenuAction = wrapWithErrorHandlingAsync(
async (entry: MenuActionEntry) => {
if (entry.onClick) await entry.onClick()
}
)
</script>

View File

@@ -0,0 +1,57 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import ContextMenu from './ContextMenu.vue'
import ContextMenuContent from './ContextMenuContent.vue'
import ContextMenuItem from './ContextMenuItem.vue'
import ContextMenuSeparator from './ContextMenuSeparator.vue'
import ContextMenuTrigger from './ContextMenuTrigger.vue'
const TestContextMenu = defineComponent({
components: {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator
},
setup() {
const open = ref(false)
return { open }
},
template: `
<ContextMenu v-model:open="open" :modal="false">
<ContextMenuTrigger as-child>
<button type="button">Trigger</button>
</ContextMenuTrigger>
<ContextMenuContent close-on-scroll>
<ContextMenuItem text-value="First item">First item</ContextMenuItem>
<ContextMenuSeparator />
</ContextMenuContent>
</ContextMenu>
`
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('ContextMenu', () => {
it('closes the content on scroll when close-on-scroll is enabled', async () => {
const wrapper = mount(TestContextMenu, {
attachTo: document.body
})
await wrapper.find('button').trigger('contextmenu')
await nextTick()
expect(wrapper.vm.open).toBe(true)
window.dispatchEvent(new Event('scroll'))
await nextTick()
expect(wrapper.vm.open).toBe(false)
})
})

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { ContextMenuRootEmits, ContextMenuRootProps } from 'reka-ui'
import { ContextMenuRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<ContextMenuRootProps>()
const emits = defineEmits<ContextMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<ContextMenuRoot v-bind="forwarded">
<slot />
</ContextMenuRoot>
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import { reactiveOmit, useEventListener } from '@vueuse/core'
import type { ContextMenuContentEmits, ContextMenuContentProps } from 'reka-ui'
import {
ContextMenuContent,
ContextMenuPortal,
injectContextMenuRootContext,
useForwardPropsEmits
} from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = withDefaults(
defineProps<
ContextMenuContentProps & {
class?: HTMLAttributes['class']
closeOnScroll?: boolean
}
>(),
{
closeOnScroll: false
}
)
const emits = defineEmits<ContextMenuContentEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'closeOnScroll')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const rootContext = injectContextMenuRootContext()
useEventListener(
window,
'scroll',
() => {
if (props.closeOnScroll) {
rootContext.onOpenChange(false)
}
},
{ capture: true, passive: true }
)
</script>
<template>
<ContextMenuPortal>
<ContextMenuContent
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground z-1700 min-w-32 overflow-hidden rounded-md border p-1 shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
props.class
)
"
>
<slot />
</ContextMenuContent>
</ContextMenuPortal>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { ContextMenuItemEmits, ContextMenuItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ContextMenuItem, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
ContextMenuItemProps & { class?: HTMLAttributes['class']; inset?: boolean }
>()
const emits = defineEmits<ContextMenuItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ContextMenuItem
v-bind="forwarded"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50',
inset && 'pl-8',
props.class
)
"
>
<slot />
</ContextMenuItem>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { ContextMenuSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ContextMenuSeparator } from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
ContextMenuSeparatorProps & { class?: HTMLAttributes['class'] }
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<ContextMenuSeparator
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
/>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { ContextMenuTriggerProps } from 'reka-ui'
import { ContextMenuTrigger, useForwardProps } from 'reka-ui'
const props = defineProps<ContextMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<ContextMenuTrigger v-bind="forwardedProps">
<slot />
</ContextMenuTrigger>
</template>

View File

@@ -0,0 +1,57 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import DropdownMenu from './DropdownMenu.vue'
import DropdownMenuContent from './DropdownMenuContent.vue'
import DropdownMenuItem from './DropdownMenuItem.vue'
import DropdownMenuSeparator from './DropdownMenuSeparator.vue'
import DropdownMenuTrigger from './DropdownMenuTrigger.vue'
const TestDropdownMenu = defineComponent({
components: {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator
},
setup() {
const open = ref(false)
return { open }
},
template: `
<DropdownMenu v-model:open="open">
<DropdownMenuTrigger as-child>
<button type="button">Trigger</button>
</DropdownMenuTrigger>
<DropdownMenuContent close-on-scroll>
<DropdownMenuItem text-value="First item">First item</DropdownMenuItem>
<DropdownMenuSeparator />
</DropdownMenuContent>
</DropdownMenu>
`
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('DropdownMenu', () => {
it('closes the content on scroll when close-on-scroll is enabled', async () => {
const wrapper = mount(TestDropdownMenu, {
attachTo: document.body
})
await wrapper.find('button').trigger('click')
await nextTick()
expect(wrapper.vm.open).toBe(true)
window.dispatchEvent(new Event('scroll'))
await nextTick()
expect(wrapper.vm.open).toBe(false)
})
})

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from 'reka-ui'
import { DropdownMenuRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DropdownMenuRootProps>()
const emits = defineEmits<DropdownMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRoot v-bind="forwarded">
<slot />
</DropdownMenuRoot>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import { reactiveOmit, useEventListener } from '@vueuse/core'
import type {
DropdownMenuContentEmits,
DropdownMenuContentProps
} from 'reka-ui'
import {
DropdownMenuContent,
DropdownMenuPortal,
injectDropdownMenuRootContext,
useForwardPropsEmits
} from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = withDefaults(
defineProps<
DropdownMenuContentProps & {
class?: HTMLAttributes['class']
closeOnScroll?: boolean
}
>(),
{
closeOnScroll: false,
sideOffset: 4
}
)
const emits = defineEmits<DropdownMenuContentEmits>()
const delegatedProps = reactiveOmit(props, 'class', 'closeOnScroll')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const rootContext = injectDropdownMenuRootContext()
useEventListener(
window,
'scroll',
() => {
if (props.closeOnScroll) {
rootContext.onOpenChange(false)
}
},
{ capture: true, passive: true }
)
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground z-1700 min-w-32 overflow-hidden rounded-md border p-1 shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
props.class
)
"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { DropdownMenuItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuItem, useForwardProps } from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
DropdownMenuItemProps & { class?: HTMLAttributes['class']; inset?: boolean }
>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuItem
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
props.class
)
"
>
<slot />
</DropdownMenuItem>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { DropdownMenuSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DropdownMenuSeparator } from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
DropdownMenuSeparatorProps & {
class?: HTMLAttributes['class']
}
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DropdownMenuSeparator
v-bind="delegatedProps"
:class="cn('-mx-1 my-1 h-px bg-muted', props.class)"
/>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
/* eslint-disable vue/no-unused-properties */
import type { DropdownMenuTriggerProps } from 'reka-ui'
import { DropdownMenuTrigger, useForwardProps } from 'reka-ui'
const props = defineProps<DropdownMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<DropdownMenuTrigger class="outline-none" v-bind="forwardedProps">
<slot />
</DropdownMenuTrigger>
</template>

View File

@@ -315,45 +315,6 @@ describe('installErrorClearingHooks lifecycle', () => {
cleanup()
expect(graph.onNodeAdded).toBe(originalHook)
})
it('restores original node callbacks when a node is removed', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('clip', 'CLIP')
node.addWidget('number', 'steps', 20, () => undefined, {})
const originalOnConnectionsChange = vi.fn()
const originalOnWidgetChanged = vi.fn()
node.onConnectionsChange = originalOnConnectionsChange
node.onWidgetChanged = originalOnWidgetChanged
graph.add(node)
installErrorClearingHooks(graph)
// Callbacks should be chained (not the originals)
expect(node.onConnectionsChange).not.toBe(originalOnConnectionsChange)
expect(node.onWidgetChanged).not.toBe(originalOnWidgetChanged)
// Simulate node removal via the graph hook
graph.onNodeRemoved!(node)
// Original callbacks should be restored
expect(node.onConnectionsChange).toBe(originalOnConnectionsChange)
expect(node.onWidgetChanged).toBe(originalOnWidgetChanged)
})
it('does not double-wrap callbacks when installErrorClearingHooks is called twice', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('clip', 'CLIP')
graph.add(node)
installErrorClearingHooks(graph)
const chainedAfterFirst = node.onConnectionsChange
// Install again on the same graph — should be a no-op for existing nodes
installErrorClearingHooks(graph)
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
})
})
describe('clearWidgetRelatedErrors parameter routing', () => {

View File

@@ -35,22 +35,10 @@ function resolvePromotedExecId(
const hookedNodes = new WeakSet<LGraphNode>()
type OriginalCallbacks = {
onConnectionsChange: LGraphNode['onConnectionsChange']
onWidgetChanged: LGraphNode['onWidgetChanged']
}
const originalCallbacks = new WeakMap<LGraphNode, OriginalCallbacks>()
function installNodeHooks(node: LGraphNode): void {
if (hookedNodes.has(node)) return
hookedNodes.add(node)
originalCallbacks.set(node, {
onConnectionsChange: node.onConnectionsChange,
onWidgetChanged: node.onWidgetChanged
})
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,
function (type, slotIndex, isConnected) {
@@ -94,15 +82,6 @@ function installNodeHooks(node: LGraphNode): void {
)
}
function restoreNodeHooks(node: LGraphNode): void {
const originals = originalCallbacks.get(node)
if (!originals) return
node.onConnectionsChange = originals.onConnectionsChange
node.onWidgetChanged = originals.onWidgetChanged
originalCallbacks.delete(node)
hookedNodes.delete(node)
}
function installNodeHooksRecursive(node: LGraphNode): void {
installNodeHooks(node)
if (node.isSubgraphNode?.()) {
@@ -112,15 +91,6 @@ function installNodeHooksRecursive(node: LGraphNode): void {
}
}
function restoreNodeHooksRecursive(node: LGraphNode): void {
restoreNodeHooks(node)
if (node.isSubgraphNode?.()) {
for (const innerNode of node.subgraph._nodes ?? []) {
restoreNodeHooksRecursive(innerNode)
}
}
}
export function installErrorClearingHooks(graph: LGraph): () => void {
for (const node of graph._nodes ?? []) {
installNodeHooksRecursive(node)
@@ -132,17 +102,7 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
originalOnNodeAdded?.call(this, node)
}
const originalOnNodeRemoved = graph.onNodeRemoved
graph.onNodeRemoved = function (node: LGraphNode) {
restoreNodeHooksRecursive(node)
originalOnNodeRemoved?.call(this, node)
}
return () => {
for (const node of graph._nodes ?? []) {
restoreNodeHooksRecursive(node)
}
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
}
}

View File

@@ -1,111 +0,0 @@
import type { Ref } from 'vue'
import { computed, watch } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import type { NodeError } from '@/schemas/apiSchema'
import { getParentExecutionIds } from '@/types/nodeIdentification'
import { forEachNode, getNodeByExecutionId } from '@/utils/graphTraversalUtil'
function setNodeHasErrors(node: LGraphNode, hasErrors: boolean): void {
if (node.has_errors === hasErrors) return
const oldValue = node.has_errors
node.has_errors = hasErrors
node.graph?.trigger('node:property:changed', {
type: 'node:property:changed',
nodeId: node.id,
property: 'has_errors',
oldValue,
newValue: hasErrors
})
}
/**
* Single-pass reconciliation of node error flags.
* Collects the set of nodes that should have errors, then walks all nodes
* once, setting each flag exactly once. This avoids the redundant
* true→false→true transition (and duplicate events) that a clear-then-apply
* approach would cause.
*/
function reconcileNodeErrorFlags(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError> | null,
missingModelExecIds: Set<string>
): void {
// Collect nodes and slot info that should be flagged
// Includes both error-owning nodes and their ancestor containers
const flaggedNodes = new Set<LGraphNode>()
const errorSlots = new Map<LGraphNode, Set<string>>()
if (nodeErrors) {
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
const node = getNodeByExecutionId(rootGraph, executionId)
if (!node) continue
flaggedNodes.add(node)
const slotNames = new Set<string>()
for (const error of nodeError.errors) {
const name = error.extra_info?.input_name
if (name) slotNames.add(name)
}
if (slotNames.size > 0) errorSlots.set(node, slotNames)
for (const parentId of getParentExecutionIds(executionId)) {
const parentNode = getNodeByExecutionId(rootGraph, parentId)
if (parentNode) flaggedNodes.add(parentNode)
}
}
}
for (const execId of missingModelExecIds) {
const node = getNodeByExecutionId(rootGraph, execId)
if (node) flaggedNodes.add(node)
}
forEachNode(rootGraph, (node) => {
setNodeHasErrors(node, flaggedNodes.has(node))
if (node.inputs) {
const nodeSlotNames = errorSlots.get(node)
for (const slot of node.inputs) {
slot.hasErrors = !!nodeSlotNames?.has(slot.name)
}
}
})
}
export function useNodeErrorFlagSync(
lastNodeErrors: Ref<Record<string, NodeError> | null>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): () => void {
const settingStore = useSettingStore()
const showErrorsTab = computed(() =>
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
)
const stop = watch(
[
lastNodeErrors,
() => missingModelStore.missingModelNodeIds,
showErrorsTab
],
() => {
if (!app.isGraphReady) return
// Legacy (LGraphNode) only: suppress missing-model error flags when
// the Errors tab is hidden, since legacy nodes lack the per-widget
// red highlight that Vue nodes use to indicate *why* a node has errors.
// Vue nodes compute hasAnyError independently and are unaffected.
reconcileNodeErrorFlags(
app.rootGraph,
lastNodeErrors.value,
showErrorsTab.value
? missingModelStore.missingModelAncestorExecutionIds
: new Set()
)
},
{ flush: 'post' }
)
return stop
}

View File

@@ -65,7 +65,7 @@ export function useJobDetailsHover<TActive>({
}, DETAILS_SHOW_DELAY_MS)
}
function scheduleDetailsHide(jobId?: string, onHide?: () => void) {
function scheduleDetailsHide(jobId?: string) {
if (!jobId) return
clearShowTimer()
@@ -79,7 +79,7 @@ export function useJobDetailsHover<TActive>({
const currentActive = activeDetails.value
if (currentActive && getActiveId(currentActive) === jobId) {
activeDetails.value = null
onHide?.()
onReset?.()
}
hideTimer.value = null
hideTimerJobId.value = null

View File

@@ -1,9 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import type { MenuEntry } from '@/types/menuTypes'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
@@ -172,10 +170,13 @@ const createJobItem = (
computeHours: overrides.computeHours
})
let currentItem: Ref<JobListItem | null>
const mountJobMenu = (onInspectAsset?: (item: JobListItem) => void) =>
useJobMenu(() => currentItem.value, onInspectAsset)
useJobMenu(onInspectAsset)
const getMenuEntries = (
item: JobListItem | null,
onInspectAsset?: (item: JobListItem) => void
) => mountJobMenu(onInspectAsset).getJobMenuEntries(item)
const findActionEntry = (entries: MenuEntry[], key: string) =>
entries.find(
@@ -186,7 +187,6 @@ const findActionEntry = (entries: MenuEntry[], key: string) =>
describe('useJobMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
currentItem = ref<JobListItem | null>(null)
settingStoreMock.get.mockReturnValue(false)
dialogServiceMock.prompt.mockResolvedValue(undefined)
litegraphServiceMock.getCanvasCenter.mockReturnValue([100, 200])
@@ -212,18 +212,14 @@ describe('useJobMenu', () => {
getJobWorkflowMock.mockResolvedValue(undefined)
})
const setCurrentItem = (item: JobListItem | null) => {
currentItem.value = item
}
it('opens workflow when workflow data exists', async () => {
const { openJobWorkflow } = mountJobMenu()
const workflow = { nodes: [] }
// Mock lazy loading via fetchJobDetail + extractWorkflow
getJobWorkflowMock.mockResolvedValue(workflow)
setCurrentItem(createJobItem({ id: '55' }))
const item = createJobItem({ id: '55' })
await openJobWorkflow()
await openJobWorkflow(item)
expect(getJobWorkflowMock).toHaveBeenCalledWith('55')
expect(workflowStoreMock.createTemporary).toHaveBeenCalledWith(
@@ -238,9 +234,9 @@ describe('useJobMenu', () => {
it('does nothing when workflow is missing', async () => {
const { openJobWorkflow } = mountJobMenu()
setCurrentItem(createJobItem({ taskRef: {} }))
const item = createJobItem({ taskRef: {} })
await openJobWorkflow()
await openJobWorkflow(item)
expect(workflowStoreMock.createTemporary).not.toHaveBeenCalled()
expect(workflowServiceMock.openWorkflow).not.toHaveBeenCalled()
@@ -248,9 +244,9 @@ describe('useJobMenu', () => {
it('copies job id to clipboard', async () => {
const { copyJobId } = mountJobMenu()
setCurrentItem(createJobItem({ id: 'job-99' }))
const item = createJobItem({ id: 'job-99' })
await copyJobId()
await copyJobId(item)
expect(copyToClipboardMock).toHaveBeenCalledWith('job-99')
})
@@ -268,9 +264,9 @@ describe('useJobMenu', () => {
['initialization', interruptMock, deleteItemMock]
])('cancels %s job via interrupt', async (state) => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: state as JobListItem['state'] }))
const item = createJobItem({ state: state as JobListItem['state'] })
await cancelJob()
await cancelJob(item)
expect(interruptMock).toHaveBeenCalledWith('job-1')
expect(deleteItemMock).not.toHaveBeenCalled()
@@ -279,9 +275,9 @@ describe('useJobMenu', () => {
it('cancels pending job via deleteItem', async () => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'pending' }))
const item = createJobItem({ state: 'pending' })
await cancelJob()
await cancelJob(item)
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
expect(queueStoreMock.update).toHaveBeenCalled()
@@ -289,9 +285,9 @@ describe('useJobMenu', () => {
it('still updates queue for uncancellable states', async () => {
const { cancelJob } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'completed' }))
const item = createJobItem({ state: 'completed' })
await cancelJob()
await cancelJob(item)
expect(interruptMock).not.toHaveBeenCalled()
expect(deleteItemMock).not.toHaveBeenCalled()
@@ -299,8 +295,7 @@ describe('useJobMenu', () => {
})
it('copies error message from failed job entry', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'failed',
taskRef: {
@@ -309,8 +304,7 @@ describe('useJobMenu', () => {
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'copy-error')
const entry = findActionEntry(entries, 'copy-error')
await entry?.onClick?.()
expect(copyToClipboardMock).toHaveBeenCalledWith('Something went wrong')
@@ -329,8 +323,7 @@ describe('useJobMenu', () => {
current_inputs: {},
current_outputs: {}
}
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'failed',
taskRef: {
@@ -341,8 +334,7 @@ describe('useJobMenu', () => {
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
const entry = findActionEntry(entries, 'report-error')
await entry?.onClick?.()
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledTimes(1)
@@ -353,8 +345,7 @@ describe('useJobMenu', () => {
})
it('falls back to simple error dialog when no execution_error', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'failed',
taskRef: {
@@ -363,8 +354,7 @@ describe('useJobMenu', () => {
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
const entry = findActionEntry(entries, 'report-error')
await entry?.onClick?.()
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
@@ -377,18 +367,16 @@ describe('useJobMenu', () => {
})
it('ignores error actions when message missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'failed',
taskRef: { errorMessage: undefined } as Partial<TaskItemImpl>
})
)
await nextTick()
const copyEntry = findActionEntry(jobMenuEntries.value, 'copy-error')
const copyEntry = findActionEntry(entries, 'copy-error')
await copyEntry?.onClick?.()
const reportEntry = findActionEntry(jobMenuEntries.value, 'report-error')
const reportEntry = findActionEntry(entries, 'report-error')
await reportEntry?.onClick?.()
expect(copyToClipboardMock).not.toHaveBeenCalled()
@@ -426,7 +414,6 @@ describe('useJobMenu', () => {
graph: { setDirtyCanvas: vi.fn() }
}
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
const { jobMenuEntries } = mountJobMenu()
const preview = {
filename: 'foo.png',
subfolder: 'bar',
@@ -434,15 +421,14 @@ describe('useJobMenu', () => {
url: 'http://asset',
...flags
}
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: { previewOutput: preview }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
const entry = findActionEntry(entries, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).toHaveBeenCalledWith(
@@ -457,8 +443,7 @@ describe('useJobMenu', () => {
it('skips adding node when no loader definition', async () => {
delete nodeDefStoreMock.nodeDefsByName.LoadImage
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: {
@@ -472,16 +457,14 @@ describe('useJobMenu', () => {
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
const entry = findActionEntry(entries, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
})
it('skips adding node when preview output lacks media flags', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: {
@@ -494,8 +477,7 @@ describe('useJobMenu', () => {
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
const entry = findActionEntry(entries, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
@@ -504,8 +486,7 @@ describe('useJobMenu', () => {
it('skips annotating when litegraph node creation fails', async () => {
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(null)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: {
@@ -519,8 +500,7 @@ describe('useJobMenu', () => {
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
const entry = findActionEntry(entries, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).toHaveBeenCalled()
@@ -528,24 +508,21 @@ describe('useJobMenu', () => {
})
it('ignores add-to-current entry when preview missing entirely', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: {} as Partial<TaskItemImpl>
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
const entry = findActionEntry(entries, 'add-to-current')
await entry?.onClick?.()
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
})
it('downloads preview asset when requested', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: {
@@ -554,24 +531,21 @@ describe('useJobMenu', () => {
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'download')
const entry = findActionEntry(entries, 'download')
void entry?.onClick?.()
expect(downloadFileMock).toHaveBeenCalledWith('https://asset')
})
it('ignores download request when preview missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: {} as Partial<TaskItemImpl>
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'download')
const entry = findActionEntry(entries, 'download')
void entry?.onClick?.()
expect(downloadFileMock).not.toHaveBeenCalled()
@@ -580,16 +554,14 @@ describe('useJobMenu', () => {
it('exports workflow with default filename when prompting disabled', async () => {
const workflow = { foo: 'bar' }
getJobWorkflowMock.mockResolvedValue(workflow)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
id: '7',
state: 'completed'
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
const entry = findActionEntry(entries, 'export-workflow')
await entry?.onClick?.()
expect(dialogServiceMock.prompt).not.toHaveBeenCalled()
@@ -605,15 +577,13 @@ describe('useJobMenu', () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('custom-name')
getJobWorkflowMock.mockResolvedValue({})
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed'
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
const entry = findActionEntry(entries, 'export-workflow')
await entry?.onClick?.()
expect(dialogServiceMock.prompt).toHaveBeenCalledWith({
@@ -629,16 +599,14 @@ describe('useJobMenu', () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('existing.json')
getJobWorkflowMock.mockResolvedValue({ foo: 'bar' })
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
id: '42',
state: 'completed'
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
const entry = findActionEntry(entries, 'export-workflow')
await entry?.onClick?.()
expect(appendJsonExtMock).toHaveBeenCalledWith('existing.json')
@@ -650,15 +618,13 @@ describe('useJobMenu', () => {
settingStoreMock.get.mockReturnValue(true)
dialogServiceMock.prompt.mockResolvedValue('')
getJobWorkflowMock.mockResolvedValue({})
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed'
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
const entry = findActionEntry(entries, 'export-workflow')
await entry?.onClick?.()
expect(downloadBlobMock).not.toHaveBeenCalled()
@@ -666,13 +632,13 @@ describe('useJobMenu', () => {
it('deletes preview asset when confirmed', async () => {
mediaAssetActionsMock.deleteAssets.mockResolvedValue(true)
const { jobMenuEntries } = mountJobMenu()
const preview = { filename: 'foo', subfolder: 'bar', type: 'output' }
const taskRef = { previewOutput: preview }
setCurrentItem(createJobItem({ state: 'completed', taskRef }))
const entries = getMenuEntries(
createJobItem({ state: 'completed', taskRef })
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
const entry = findActionEntry(entries, 'delete')
await entry?.onClick?.()
expect(mapTaskOutputToAssetItemMock).toHaveBeenCalledWith(taskRef, preview)
@@ -681,16 +647,14 @@ describe('useJobMenu', () => {
it('does not refresh queue when delete cancelled', async () => {
mediaAssetActionsMock.deleteAssets.mockResolvedValue(false)
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
)
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
const entry = findActionEntry(entries, 'delete')
await entry?.onClick?.()
expect(queueStoreMock.update).not.toHaveBeenCalled()
@@ -698,22 +662,18 @@ describe('useJobMenu', () => {
it('removes failed job via menu entry', async () => {
const taskRef = { id: 'task-1' }
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'failed', taskRef }))
const entries = getMenuEntries(createJobItem({ state: 'failed', taskRef }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
const entry = findActionEntry(entries, 'delete')
await entry?.onClick?.()
expect(queueStoreMock.delete).toHaveBeenCalledWith(taskRef)
})
it('ignores failed job delete when taskRef missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'failed' }))
const entries = getMenuEntries(createJobItem({ state: 'failed' }))
await nextTick()
const entry = findActionEntry(jobMenuEntries.value, 'delete')
const entry = findActionEntry(entries, 'delete')
await entry?.onClick?.()
expect(queueStoreMock.delete).not.toHaveBeenCalled()
@@ -721,16 +681,13 @@ describe('useJobMenu', () => {
it('provides completed menu structure with delete option', async () => {
const inspectSpy = vi.fn()
const { jobMenuEntries } = mountJobMenu(inspectSpy)
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
)
const item = createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
const entries = getMenuEntries(item, inspectSpy)
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
expect(entries.map((entry) => entry.key)).toEqual([
'inspect-asset',
'add-to-current',
'download',
@@ -743,66 +700,48 @@ describe('useJobMenu', () => {
'delete'
])
expect(
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
).toBe(false)
expect(
findActionEntry(jobMenuEntries.value, 'add-to-current')?.disabled
).toBe(false)
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
false
)
expect(findActionEntry(entries, 'inspect-asset')?.disabled).toBe(false)
expect(findActionEntry(entries, 'add-to-current')?.disabled).toBe(false)
expect(findActionEntry(entries, 'download')?.disabled).toBe(false)
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
const inspectEntry = findActionEntry(entries, 'inspect-asset')
await inspectEntry?.onClick?.()
expect(inspectSpy).toHaveBeenCalledWith(currentItem.value)
expect(inspectSpy).toHaveBeenCalledWith(item)
})
it('omits inspect handler when callback missing', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
})
)
await nextTick()
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
const inspectEntry = findActionEntry(entries, 'inspect-asset')
expect(inspectEntry?.onClick).toBeUndefined()
expect(inspectEntry?.disabled).toBe(true)
})
it('omits delete asset entry when no preview exists', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} }))
const entries = getMenuEntries(
createJobItem({ state: 'completed', taskRef: {} })
)
await nextTick()
expect(
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
).toBe(true)
expect(
findActionEntry(jobMenuEntries.value, 'add-to-current')?.disabled
).toBe(true)
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
true
)
expect(jobMenuEntries.value.some((entry) => entry.key === 'delete')).toBe(
false
)
expect(findActionEntry(entries, 'inspect-asset')?.disabled).toBe(true)
expect(findActionEntry(entries, 'add-to-current')?.disabled).toBe(true)
expect(findActionEntry(entries, 'download')?.disabled).toBe(true)
expect(entries.some((entry) => entry.key === 'delete')).toBe(false)
})
it('returns failed menu entries with error actions', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(
const entries = getMenuEntries(
createJobItem({
state: 'failed',
taskRef: { errorMessage: 'Some error' } as Partial<TaskItemImpl>
})
)
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
expect(entries.map((entry) => entry.key)).toEqual([
'open-workflow',
'd1',
'copy-id',
@@ -814,11 +753,9 @@ describe('useJobMenu', () => {
})
it('returns active job entries with cancel option', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'running' }))
const entries = getMenuEntries(createJobItem({ state: 'running' }))
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
expect(entries.map((entry) => entry.key)).toEqual([
'open-workflow',
'd1',
'copy-id',
@@ -828,18 +765,16 @@ describe('useJobMenu', () => {
})
it('provides pending job entries and triggers cancel action', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(createJobItem({ state: 'pending' }))
const entries = getMenuEntries(createJobItem({ state: 'pending' }))
await nextTick()
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
expect(entries.map((entry) => entry.key)).toEqual([
'open-workflow',
'd1',
'copy-id',
'd2',
'cancel-job'
])
const cancelEntry = findActionEntry(jobMenuEntries.value, 'cancel-job')
const cancelEntry = findActionEntry(entries, 'cancel-job')
await cancelEntry?.onClick?.()
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
@@ -847,10 +782,6 @@ describe('useJobMenu', () => {
})
it('returns empty menu when no job selected', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(null)
await nextTick()
expect(jobMenuEntries.value).toEqual([])
expect(getMenuEntries(null)).toEqual([])
})
})

View File

@@ -20,30 +20,16 @@ import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import type { MenuEntry } from '@/types/menuTypes'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { appendJsonExt } from '@/utils/formatUtil'
export type MenuEntry =
| {
kind?: 'item'
key: string
label: string
icon?: string
disabled?: boolean
onClick?: () => void | Promise<void>
}
| { kind: 'divider'; key: string }
/**
* Provides job context menu entries and actions.
*
* @param currentMenuItem Getter for the currently targeted job list item
* @param onInspectAsset Callback to trigger when inspecting a completed job's asset
*/
export function useJobMenu(
currentMenuItem: () => JobListItem | null = () => null,
onInspectAsset?: (item: JobListItem) => void
) {
export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const queueStore = useQueueStore()
@@ -53,11 +39,8 @@ export function useJobMenu(
const nodeDefStore = useNodeDefStore()
const mediaAssetActions = useMediaAssetActions()
const resolveItem = (item?: JobListItem | null): JobListItem | null =>
item ?? currentMenuItem()
const openJobWorkflow = async (item?: JobListItem | null) => {
const target = resolveItem(item)
const target = item
if (!target) return
const data = await getJobWorkflow(target.id)
if (!data) return
@@ -67,13 +50,13 @@ export function useJobMenu(
}
const copyJobId = async (item?: JobListItem | null) => {
const target = resolveItem(item)
const target = item
if (!target) return
await copyToClipboard(target.id)
}
const cancelJob = async (item?: JobListItem | null) => {
const target = resolveItem(item)
const target = item
if (!target) return
if (target.state === 'running' || target.state === 'initialization') {
if (isCloud) {
@@ -89,13 +72,13 @@ export function useJobMenu(
}
const copyErrorMessage = async (item?: JobListItem | null) => {
const target = resolveItem(item)
const target = item
const message = target?.taskRef?.errorMessage
if (message) await copyToClipboard(message)
}
const reportError = (item?: JobListItem | null) => {
const target = resolveItem(item)
const target = item
if (!target) return
// Use execution_error from list response if available
@@ -117,10 +100,10 @@ export function useJobMenu(
// This is very magical only because it matches the respective backend implementation
// There is or will be a better way to do this
const addOutputLoaderNode = async () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
const addOutputLoaderNode = async (item?: JobListItem | null) => {
const target = item
if (!target) return
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
if (!result) return
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
@@ -168,10 +151,10 @@ export function useJobMenu(
/**
* Trigger a download of the job's previewable output asset.
*/
const downloadPreviewAsset = () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
const downloadPreviewAsset = (item?: JobListItem | null) => {
const target = item
if (!target) return
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
if (!result) return
downloadFile(result.url)
}
@@ -179,14 +162,14 @@ export function useJobMenu(
/**
* Export the workflow JSON attached to the job.
*/
const exportJobWorkflow = async () => {
const item = currentMenuItem()
if (!item) return
const data = await getJobWorkflow(item.id)
const exportJobWorkflow = async (item?: JobListItem | null) => {
const target = item
if (!target) return
const data = await getJobWorkflow(target.id)
if (!data) return
const settingStore = useSettingStore()
let filename = `Job ${item.id}.json`
let filename = `Job ${target.id}.json`
if (settingStore.get('Comfy.PromptFilename')) {
const input = await useDialogService().prompt({
@@ -203,10 +186,10 @@ export function useJobMenu(
downloadBlob(filename, blob)
}
const deleteJobAsset = async () => {
const item = currentMenuItem()
if (!item) return
const task = item.taskRef as TaskItemImpl | undefined
const deleteJobAsset = async (item?: JobListItem | null) => {
const target = item
if (!target) return
const task = target.taskRef as TaskItemImpl | undefined
const preview = task?.previewOutput
if (!task || !preview) return
@@ -218,8 +201,7 @@ export function useJobMenu(
}
const removeFailedJob = async (task?: TaskItemImpl | null) => {
const target =
task ?? (currentMenuItem()?.taskRef as TaskItemImpl | undefined)
const target = task
if (!target) return
await queueStore.delete(target)
}
@@ -237,11 +219,11 @@ export function useJobMenu(
st('queue.jobMenu.cancelJob', 'Cancel job')
)
const jobMenuEntries = computed<MenuEntry[]>(() => {
const item = currentMenuItem()
const state = item?.state
const buildJobMenuEntries = (item?: JobListItem | null): MenuEntry[] => {
const target = item
const state = target?.state
if (!state) return []
const hasPreviewAsset = !!item?.taskRef?.previewOutput
const hasPreviewAsset = !!target?.taskRef?.previewOutput
if (state === 'completed') {
return [
{
@@ -251,8 +233,7 @@ export function useJobMenu(
disabled: !hasPreviewAsset || !onInspectAsset,
onClick: onInspectAsset
? () => {
const item = currentMenuItem()
if (item) onInspectAsset(item)
if (target) onInspectAsset(target)
}
: undefined
},
@@ -264,34 +245,34 @@ export function useJobMenu(
),
icon: 'icon-[comfy--node]',
disabled: !hasPreviewAsset,
onClick: addOutputLoaderNode
onClick: () => addOutputLoaderNode(target)
},
{
key: 'download',
label: st('queue.jobMenu.download', 'Download'),
icon: 'icon-[lucide--download]',
disabled: !hasPreviewAsset,
onClick: downloadPreviewAsset
onClick: () => downloadPreviewAsset(target)
},
{ kind: 'divider', key: 'd1' },
{
key: 'open-workflow',
label: jobMenuOpenWorkflowLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
onClick: () => openJobWorkflow(target)
},
{
key: 'export-workflow',
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
icon: 'icon-[comfy--file-output]',
onClick: exportJobWorkflow
onClick: () => exportJobWorkflow(target)
},
{ kind: 'divider', key: 'd2' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
onClick: () => copyJobId(target)
},
{ kind: 'divider', key: 'd3' },
...(hasPreviewAsset
@@ -300,7 +281,7 @@ export function useJobMenu(
key: 'delete',
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
icon: 'icon-[lucide--trash-2]',
onClick: deleteJobAsset
onClick: () => deleteJobAsset(target)
}
]
: [])
@@ -312,33 +293,33 @@ export function useJobMenu(
key: 'open-workflow',
label: jobMenuOpenWorkflowFailedLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
onClick: () => openJobWorkflow(target)
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
onClick: () => copyJobId(target)
},
{
key: 'copy-error',
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
icon: 'icon-[lucide--copy]',
onClick: copyErrorMessage
onClick: () => copyErrorMessage(target)
},
{
key: 'report-error',
label: st('queue.jobMenu.reportError', 'Report error'),
icon: 'icon-[lucide--message-circle-warning]',
onClick: reportError
onClick: () => reportError(target)
},
{ kind: 'divider', key: 'd2' },
{
key: 'delete',
label: st('queue.jobMenu.removeJob', 'Remove job'),
icon: 'icon-[lucide--circle-minus]',
onClick: removeFailedJob
onClick: () => removeFailedJob(target?.taskRef)
}
]
}
@@ -347,27 +328,27 @@ export function useJobMenu(
key: 'open-workflow',
label: jobMenuOpenWorkflowLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
onClick: () => openJobWorkflow(target)
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
onClick: () => copyJobId(target)
},
{ kind: 'divider', key: 'd2' },
{
key: 'cancel-job',
label: jobMenuCancelLabel.value,
icon: 'icon-[lucide--x]',
onClick: cancelJob
onClick: () => cancelJob(target)
}
]
})
}
return {
jobMenuEntries,
getJobMenuEntries: buildJobMenuEntries,
openJobWorkflow,
copyJobId,
cancelJob,

View File

@@ -1,65 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
exceedsClickThreshold,
useClickDragGuard
} from '@/composables/useClickDragGuard'
describe('exceedsClickThreshold', () => {
it('returns false when distance is within threshold', () => {
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 2, y: 2 }, 5)).toBe(false)
})
it('returns true when distance exceeds threshold', () => {
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 3, y: 5 }, 5)).toBe(true)
})
it('returns false when distance exactly equals threshold', () => {
expect(exceedsClickThreshold({ x: 0, y: 0 }, { x: 3, y: 4 }, 5)).toBe(false)
})
it('handles negative deltas', () => {
expect(exceedsClickThreshold({ x: 10, y: 10 }, { x: 4, y: 2 }, 5)).toBe(
true
)
})
})
describe('useClickDragGuard', () => {
it('reports no drag when pointer has not moved', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
expect(guard.wasDragged({ clientX: 100, clientY: 200 })).toBe(false)
})
it('reports no drag when movement is within threshold', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
expect(guard.wasDragged({ clientX: 103, clientY: 204 })).toBe(false)
})
it('reports drag when movement exceeds threshold', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
expect(guard.wasDragged({ clientX: 106, clientY: 200 })).toBe(true)
})
it('returns false when no start has been recorded', () => {
const guard = useClickDragGuard(5)
expect(guard.wasDragged({ clientX: 100, clientY: 200 })).toBe(false)
})
it('returns false after reset', () => {
const guard = useClickDragGuard(5)
guard.recordStart({ clientX: 100, clientY: 200 })
guard.reset()
expect(guard.wasDragged({ clientX: 200, clientY: 300 })).toBe(false)
})
it('respects custom threshold', () => {
const guard = useClickDragGuard(3)
guard.recordStart({ clientX: 0, clientY: 0 })
expect(guard.wasDragged({ clientX: 3, clientY: 0 })).toBe(false)
expect(guard.wasDragged({ clientX: 4, clientY: 0 })).toBe(true)
})
})

View File

@@ -1,41 +0,0 @@
interface PointerPosition {
readonly x: number
readonly y: number
}
function squaredDistance(a: PointerPosition, b: PointerPosition): number {
const dx = a.x - b.x
const dy = a.y - b.y
return dx * dx + dy * dy
}
export function exceedsClickThreshold(
start: PointerPosition,
end: PointerPosition,
threshold: number
): boolean {
return squaredDistance(start, end) > threshold * threshold
}
export function useClickDragGuard(threshold: number = 5) {
let start: PointerPosition | null = null
function recordStart(e: { clientX: number; clientY: number }) {
start = { x: e.clientX, y: e.clientY }
}
function wasDragged(e: { clientX: number; clientY: number }): boolean {
if (!start) return false
return exceedsClickThreshold(
start,
{ x: e.clientX, y: e.clientY },
threshold
)
}
function reset() {
start = null
}
return { recordStart, wasDragged, reset }
}

View File

@@ -1,108 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { effectScope, ref } from 'vue'
import type { EffectScope, Ref } from 'vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
describe('useDismissableOverlay', () => {
let scope: EffectScope | undefined
let isOpen: Ref<boolean>
let overlayEl: HTMLElement
let triggerEl: HTMLElement
let outsideEl: HTMLElement
let dismissCount: number
const mountComposable = ({
dismissOnScroll = false,
getTriggerEl
}: {
dismissOnScroll?: boolean
getTriggerEl?: () => HTMLElement | null
} = {}) => {
scope = effectScope()
scope.run(() =>
useDismissableOverlay({
isOpen,
getOverlayEl: () => overlayEl,
getTriggerEl,
onDismiss: () => {
dismissCount += 1
},
dismissOnScroll
})
)
}
beforeEach(() => {
isOpen = ref(true)
overlayEl = document.createElement('div')
triggerEl = document.createElement('button')
outsideEl = document.createElement('div')
dismissCount = 0
document.body.append(overlayEl, triggerEl, outsideEl)
})
afterEach(() => {
scope?.stop()
scope = undefined
document.body.innerHTML = ''
})
it('dismisses on outside pointerdown', () => {
mountComposable()
outsideEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(dismissCount).toBe(1)
})
it('ignores pointerdown inside the overlay', () => {
mountComposable()
overlayEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(dismissCount).toBe(0)
})
it('ignores pointerdown inside the trigger', () => {
mountComposable({
getTriggerEl: () => triggerEl
})
triggerEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(dismissCount).toBe(0)
})
it('dismisses on scroll when enabled', () => {
mountComposable({
dismissOnScroll: true
})
window.dispatchEvent(new Event('scroll'))
expect(dismissCount).toBe(1)
})
it('ignores scroll inside the overlay', () => {
mountComposable({
dismissOnScroll: true
})
overlayEl.dispatchEvent(new Event('scroll'))
expect(dismissCount).toBe(0)
})
it('does not dismiss when closed', () => {
isOpen.value = false
mountComposable({
dismissOnScroll: true
})
outsideEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
window.dispatchEvent(new Event('scroll'))
expect(dismissCount).toBe(0)
})
})

View File

@@ -1,60 +0,0 @@
import { useEventListener } from '@vueuse/core'
import { toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
interface UseDismissableOverlayOptions {
isOpen: MaybeRefOrGetter<boolean>
getOverlayEl: () => HTMLElement | null
onDismiss: () => void
getTriggerEl?: () => HTMLElement | null
dismissOnScroll?: boolean
}
const isNode = (value: EventTarget | null | undefined): value is Node =>
value instanceof Node
const isInside = (target: Node, element: HTMLElement | null | undefined) =>
!!element?.contains(target)
export function useDismissableOverlay({
isOpen,
getOverlayEl,
onDismiss,
getTriggerEl,
dismissOnScroll = false
}: UseDismissableOverlayOptions) {
const dismissIfOutside = (event: Event) => {
if (!toValue(isOpen)) {
return
}
const overlay = getOverlayEl()
if (!overlay) {
return
}
if (!isNode(event.target)) {
onDismiss()
return
}
if (
isInside(event.target, overlay) ||
isInside(event.target, getTriggerEl?.())
) {
return
}
onDismiss()
}
useEventListener(window, 'pointerdown', dismissIfOutside, { capture: true })
if (dismissOnScroll) {
useEventListener(window, 'scroll', dismissIfOutside, {
capture: true,
passive: true
})
}
}

View File

@@ -107,27 +107,6 @@ export const ESSENTIALS_CATEGORY_CANONICAL: ReadonlyMap<
EssentialsCategory
> = new Map(ESSENTIALS_CATEGORIES.map((c) => [c.toLowerCase(), c]))
/**
* Precomputed rank map: category → display order index.
* Used for sorting essentials folders in their canonical order.
*/
export const ESSENTIALS_CATEGORY_RANK: ReadonlyMap<string, number> = new Map(
ESSENTIALS_CATEGORIES.map((c, i) => [c, i])
)
/**
* Precomputed rank maps: category → (node name → display order index).
* Used for sorting nodes within each essentials folder.
*/
export const ESSENTIALS_NODE_RANK: Partial<
Record<EssentialsCategory, ReadonlyMap<string, number>>
> = Object.fromEntries(
Object.entries(ESSENTIALS_NODES).map(([category, nodes]) => [
category,
new Map(nodes.map((name, i) => [name, i]))
])
)
/**
* "Novel" toolkit nodes for telemetry — basics excluded.
* Derived from ESSENTIALS_NODES minus the 'basics' category.

View File

@@ -1,7 +1,5 @@
import * as THREE from 'three'
import { exceedsClickThreshold } from '@/composables/useClickDragGuard'
import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
import { ControlsManager } from './ControlsManager'
@@ -70,7 +68,9 @@ class Load3d {
targetAspectRatio: number = 1
isViewerMode: boolean = false
private rightMouseStart: { x: number; y: number } = { x: 0, y: 0 }
// Context menu tracking
private rightMouseDownX: number = 0
private rightMouseDownY: number = 0
private rightMouseMoved: boolean = false
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
@@ -197,20 +197,18 @@ class Load3d {
const mousedownHandler = (e: MouseEvent) => {
if (e.button === 2) {
this.rightMouseStart = { x: e.clientX, y: e.clientY }
this.rightMouseDownX = e.clientX
this.rightMouseDownY = e.clientY
this.rightMouseMoved = false
}
}
const mousemoveHandler = (e: MouseEvent) => {
if (e.buttons === 2) {
if (
exceedsClickThreshold(
this.rightMouseStart,
{ x: e.clientX, y: e.clientY },
this.dragThreshold
)
) {
const dx = Math.abs(e.clientX - this.rightMouseDownX)
const dy = Math.abs(e.clientY - this.rightMouseDownY)
if (dx > this.dragThreshold || dy > this.dragThreshold) {
this.rightMouseMoved = true
}
}
@@ -219,13 +217,12 @@ class Load3d {
const contextmenuHandler = (e: MouseEvent) => {
if (this.isViewerMode) return
const dx = Math.abs(e.clientX - this.rightMouseDownX)
const dy = Math.abs(e.clientY - this.rightMouseDownY)
const wasDragging =
this.rightMouseMoved ||
exceedsClickThreshold(
this.rightMouseStart,
{ x: e.clientX, y: e.clientY },
this.dragThreshold
)
dx > this.dragThreshold ||
dy > this.dragThreshold
this.rightMouseMoved = false

View File

@@ -34,8 +34,6 @@
"imageLightbox": "Image preview",
"imagePreview": "Image preview - Use arrow keys to navigate between images",
"videoPreview": "Video preview - Use arrow keys to navigate between videos",
"viewGrid": "Grid view",
"imageGallery": "image gallery",
"galleryImage": "Gallery image",
"galleryThumbnail": "Gallery thumbnail",
"previousImage": "Previous image",
@@ -278,7 +276,8 @@
"clearAll": "Clear all",
"copyURL": "Copy URL",
"releaseTitle": "{package} {version} Release",
"itemsSelected": "No items selected | {count} item selected | {count} items selected",
"itemSelected": "{selectedCount} item selected",
"itemsSelected": "{selectedCount} items selected",
"multiSelectDropdown": "Multi-select dropdown",
"singleSelectDropdown": "Single-select dropdown",
"progressCountOf": "of",
@@ -3706,18 +3705,5 @@
"footer": "ComfyUI stays free and open source. Cloud is optional.",
"continueLocally": "Continue Locally",
"exploreCloud": "Try Cloud for Free"
},
"execution": {
"generating": "Generating…",
"saving": "Saving…",
"loading": "Loading…",
"encoding": "Encoding…",
"decoding": "Decoding…",
"processing": "Processing…",
"resizing": "Resizing…",
"generatingVideo": "Generating video…",
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
}
}

View File

@@ -23,9 +23,6 @@
:data-selected="selected"
:draggable="true"
@click.stop="$emit('click')"
@contextmenu.prevent.stop="
asset ? emit('context-menu', $event, asset) : undefined
"
@dragstart="dragStart"
>
<!-- Top Area: Media Preview -->
@@ -69,16 +66,30 @@
>
<i class="icon-[lucide--zoom-in] size-4" />
</Button>
<Button
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.moreOptions')"
@click.stop="
asset ? emit('context-menu', $event, asset) : undefined
"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
<DropdownMenu v-if="asset" v-model:open="isActionsMenuOpen">
<DropdownMenuTrigger as-child>
<Button
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.moreOptions')"
@click.stop
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
close-on-scroll
class="z-1700 bg-transparent p-0 shadow-lg"
:side-offset="4"
:collision-padding="8"
>
<MediaAssetMenuItems
:entries="getAssetMenuEntries()"
surface="dropdown"
@action="void onAssetMenuAction($event)"
/>
</DropdownMenuContent>
</DropdownMenu>
</IconGroup>
</div>
</div>
@@ -141,7 +152,11 @@ import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import Button from '@/components/ui/button/Button.vue'
import DropdownMenu from '@/components/ui/dropdown-menu/DropdownMenu.vue'
import DropdownMenuContent from '@/components/ui/dropdown-menu/DropdownMenuContent.vue'
import DropdownMenuTrigger from '@/components/ui/dropdown-menu/DropdownMenuTrigger.vue'
import { useAssetsStore } from '@/stores/assetsStore'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import {
formatDuration,
formatSize,
@@ -154,10 +169,12 @@ import { cn } from '@/utils/tailwindUtil'
import { getAssetType } from '../composables/media/assetMappers'
import { getAssetUrl } from '../utils/assetUrlUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { useMediaAssetMenu } from '../composables/useMediaAssetMenu'
import type { AssetItem } from '../schemas/assetSchema'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetMenuItems from './MediaAssetMenuItems.vue'
import MediaTitle from './MediaTitle.vue'
type PreviewKind = ReturnType<typeof getMediaTypeFromFilename>
@@ -177,12 +194,24 @@ function getTopComponent(kind: PreviewKind) {
return mediaComponents.top[kind] || mediaComponents.top.other
}
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
const {
asset,
loading,
selected,
showOutputCount,
outputCount,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
asset?: AssetItem
loading?: boolean
selected?: boolean
showOutputCount?: boolean
outputCount?: number
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const assetsStore = useAssetsStore()
@@ -196,13 +225,19 @@ const emit = defineEmits<{
click: []
zoom: [asset: AssetItem]
'output-count-click': []
'context-menu': [event: MouseEvent, asset: AssetItem]
'asset-deleted': []
'bulk-download': [assets: AssetItem[]]
'bulk-delete': [assets: AssetItem[]]
'bulk-add-to-workflow': [assets: AssetItem[]]
'bulk-open-workflow': [assets: AssetItem[]]
'bulk-export-workflow': [assets: AssetItem[]]
}>()
const cardContainerRef = ref<HTMLElement>()
const isVideoPlaying = ref(false)
const showVideoControls = ref(false)
const isActionsMenuOpen = ref(false)
// Store actual image dimensions
const imageDimensions = ref<{ width: number; height: number } | undefined>()
@@ -210,6 +245,15 @@ const imageDimensions = ref<{ width: number; height: number } | undefined>()
const isHovered = useElementHover(cardContainerRef)
const actions = useMediaAssetActions()
const { getMenuEntries } = useMediaAssetMenu({
inspectAsset: (target) => emit('zoom', target),
assetDeleted: () => emit('asset-deleted'),
bulkDownload: (assets) => emit('bulk-download', assets),
bulkDelete: (assets) => emit('bulk-delete', assets),
bulkAddToWorkflow: (assets) => emit('bulk-add-to-workflow', assets),
bulkOpenWorkflow: (assets) => emit('bulk-open-workflow', assets),
bulkExportWorkflow: (assets) => emit('bulk-export-workflow', assets)
})
// Get asset type from tags
const assetType = computed(() => {
@@ -290,7 +334,12 @@ const metaInfo = computed(() => {
const showActionsOverlay = computed(() => {
if (loading || !asset || isDeleting.value) return false
return isHovered.value || selected || isVideoPlaying.value
return (
isHovered.value ||
selected ||
isVideoPlaying.value ||
isActionsMenuOpen.value
)
})
const handleZoomClick = () => {
@@ -306,6 +355,26 @@ const handleImageLoaded = (width: number, height: number) => {
const handleOutputCountClick = () => {
emit('output-count-click')
}
function getAssetMenuEntries(): MenuEntry[] {
if (!asset) {
return []
}
return getMenuEntries({
asset,
assetType: assetType.value,
fileKind: fileKind.value,
showDeleteButton,
selectedAssets,
isBulkMode
})
}
async function onAssetMenuAction(entry: MenuActionEntry) {
await entry.onClick?.()
}
function dragStart(e: DragEvent) {
if (!asset?.preview_url) return

View File

@@ -1,143 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/platform/workflow/utils/workflowExtractionUtil', () => ({
supportsWorkflowMetadata: () => true
}))
vi.mock('@/utils/formatUtil', () => ({
isPreviewableMediaType: () => true
}))
vi.mock('@/utils/loaderNodeUtil', () => ({
detectNodeTypeFromFilename: () => ({ nodeType: 'LoadImage' })
}))
const mediaAssetActions = {
addWorkflow: vi.fn(),
downloadAsset: vi.fn(),
openWorkflow: vi.fn(),
exportWorkflow: vi.fn(),
copyJobId: vi.fn(),
deleteAssets: vi.fn().mockResolvedValue(false)
}
vi.mock('../composables/useMediaAssetActions', () => ({
useMediaAssetActions: () => mediaAssetActions
}))
const contextMenuStub = defineComponent({
name: 'ContextMenu',
props: {
pt: {
type: Object,
default: undefined
}
},
emits: ['hide'],
data() {
return {
visible: false
}
},
methods: {
show() {
this.visible = true
},
hide() {
this.visible = false
this.$emit('hide')
}
},
template: `
<div
v-if="visible"
class="context-menu-stub"
v-bind="pt?.root"
/>
`
})
const asset: AssetItem = {
id: 'asset-1',
name: 'image.png',
tags: [],
user_metadata: {}
}
const buttonStub = {
template: '<div class="button-stub"><slot /></div>'
}
type MediaAssetContextMenuExposed = ComponentPublicInstance & {
show: (event: MouseEvent) => void
}
const mountComponent = () =>
mount(MediaAssetContextMenu, {
attachTo: document.body,
props: {
asset,
assetType: 'output',
fileKind: 'image'
},
global: {
stubs: {
ContextMenu: contextMenuStub,
Button: buttonStub
}
}
})
async function showMenu(
wrapper: ReturnType<typeof mountComponent>
): Promise<HTMLElement> {
const exposed = wrapper.vm as MediaAssetContextMenuExposed
const event = new MouseEvent('contextmenu', { bubbles: true })
exposed.show(event)
await nextTick()
return wrapper.get('.context-menu-stub').element as HTMLElement
}
afterEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
})
describe('MediaAssetContextMenu', () => {
it('dismisses outside pointerdown using the rendered root id', async () => {
const wrapper = mountComponent()
const outside = document.createElement('div')
document.body.append(outside)
const menu = await showMenu(wrapper)
const menuId = menu.id
expect(menuId).not.toBe('')
expect(document.getElementById(menuId)).toBe(menu)
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(wrapper.find('.context-menu-stub').exists()).toBe(false)
expect(wrapper.emitted('hide')).toEqual([[]])
wrapper.unmount()
})
})

View File

@@ -1,286 +0,0 @@
<template>
<ContextMenu
ref="contextMenu"
:model="contextMenuItems"
:pt="{
root: {
id: contextMenuId,
class: cn(
'rounded-lg',
'bg-secondary-background text-base-foreground',
'shadow-lg'
)
}
}"
@hide="onMenuHide"
>
<template #item="{ item, props }">
<Button
variant="secondary"
class="w-full justify-start"
v-bind="props.action"
>
<i v-if="item.icon" :class="item.icon" class="size-4" />
<span>{{
typeof item.label === 'function' ? item.label() : (item.label ?? '')
}}</span>
</Button>
</template>
</ContextMenu>
</template>
<script setup lang="ts">
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, ref, useId } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import { isCloud } from '@/platform/distribution/types'
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
import { isPreviewableMediaType } from '@/utils/formatUtil'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { cn } from '@/utils/tailwindUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema'
const {
asset,
assetType,
fileKind,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
asset: AssetItem
assetType: AssetContext['type']
fileKind: MediaKind
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const emit = defineEmits<{
zoom: []
hide: []
'asset-deleted': []
'bulk-download': [assets: AssetItem[]]
'bulk-delete': [assets: AssetItem[]]
'bulk-add-to-workflow': [assets: AssetItem[]]
'bulk-open-workflow': [assets: AssetItem[]]
'bulk-export-workflow': [assets: AssetItem[]]
}>()
type ContextMenuHandle = {
show: (event: MouseEvent) => void
hide: () => void
}
const contextMenu = ref<ContextMenuHandle | null>(null)
const contextMenuId = useId()
const isVisible = ref(false)
const actions = useMediaAssetActions()
const { t } = useI18n()
useDismissableOverlay({
isOpen: isVisible,
getOverlayEl: () => document.getElementById(contextMenuId),
onDismiss: hide,
dismissOnScroll: true
})
const showAddToWorkflow = computed(() => {
// Output assets can always be added
if (assetType === 'output') return true
// Input assets: check if file type is supported by loader nodes
if (assetType === 'input' && asset?.name) {
const { nodeType } = detectNodeTypeFromFilename(asset.name)
return nodeType !== null
}
return false
})
const showWorkflowActions = computed(() => {
// Output assets always have workflow metadata
if (assetType === 'output') return true
// Input assets: only formats that support workflow metadata
if (assetType === 'input' && asset?.name) {
return supportsWorkflowMetadata(asset.name)
}
return false
})
const showCopyJobId = computed(() => {
return assetType !== 'input'
})
const shouldShowDeleteButton = computed(() => {
const propAllows = showDeleteButton ?? true
const typeAllows =
assetType === 'output' || (assetType === 'input' && isCloud)
return propAllows && typeAllows
})
// Context menu items
const contextMenuItems = computed<MenuItem[]>(() => {
if (!asset) return []
const items: MenuItem[] = []
// Check if current asset is part of the selection
const isCurrentAssetSelected = selectedAssets?.some(
(selectedAsset) => selectedAsset.id === asset.id
)
// Bulk mode: Show selected count and bulk actions only if current asset is selected
if (
isBulkMode &&
selectedAssets &&
selectedAssets.length > 0 &&
isCurrentAssetSelected
) {
// Header item showing selected count
items.push({
label: t('mediaAsset.selection.multipleSelectedAssets'),
disabled: true
})
// Bulk Add to Workflow
items.push({
label: t('mediaAsset.selection.insertAllAssetsAsNodes'),
icon: 'icon-[comfy--node]',
command: () => emit('bulk-add-to-workflow', selectedAssets)
})
// Bulk Open Workflow
items.push({
label: t('mediaAsset.selection.openWorkflowAll'),
icon: 'icon-[comfy--workflow]',
command: () => emit('bulk-open-workflow', selectedAssets)
})
// Bulk Export Workflow
items.push({
label: t('mediaAsset.selection.exportWorkflowAll'),
icon: 'icon-[lucide--file-output]',
command: () => emit('bulk-export-workflow', selectedAssets)
})
// Bulk Download
items.push({
label: t('mediaAsset.selection.downloadSelectedAll'),
icon: 'icon-[lucide--download]',
command: () => emit('bulk-download', selectedAssets)
})
// Bulk Delete (if allowed)
if (shouldShowDeleteButton.value) {
items.push({
label: t('mediaAsset.selection.deleteSelectedAll'),
icon: 'icon-[lucide--trash-2]',
command: () => emit('bulk-delete', selectedAssets)
})
}
return items
}
// Individual mode: Show all menu options
// Inspect
if (isPreviewableMediaType(fileKind)) {
items.push({
label: t('mediaAsset.actions.inspect'),
icon: 'icon-[lucide--zoom-in]',
command: () => emit('zoom')
})
}
// Add to workflow (conditional)
if (showAddToWorkflow.value) {
items.push({
label: t('mediaAsset.actions.insertAsNodeInWorkflow'),
icon: 'icon-[comfy--node]',
command: () => actions.addWorkflow(asset)
})
}
// Download
items.push({
label: t('mediaAsset.actions.download'),
icon: 'icon-[lucide--download]',
command: () => actions.downloadAsset(asset)
})
// Separator before workflow actions (only if there are workflow actions)
if (showWorkflowActions.value) {
items.push({ separator: true })
items.push({
label: t('mediaAsset.actions.openWorkflow'),
icon: 'icon-[comfy--workflow]',
command: () => actions.openWorkflow(asset)
})
items.push({
label: t('mediaAsset.actions.exportWorkflow'),
icon: 'icon-[lucide--file-output]',
command: () => actions.exportWorkflow(asset)
})
}
// Copy job ID
if (showCopyJobId.value) {
items.push({ separator: true })
items.push({
label: t('mediaAsset.actions.copyJobId'),
icon: 'icon-[lucide--copy]',
command: async () => {
await actions.copyJobId(asset)
}
})
}
// Delete
if (shouldShowDeleteButton.value) {
items.push({ separator: true })
items.push({
label: t('mediaAsset.actions.delete'),
icon: 'icon-[lucide--trash-2]',
command: async () => {
if (asset) {
const confirmed = await actions.deleteAssets(asset)
if (confirmed) {
emit('asset-deleted')
}
}
}
})
}
return items
})
function onMenuHide() {
isVisible.value = false
emit('hide')
}
function show(event: MouseEvent) {
isVisible.value = true
contextMenu.value?.show(event)
}
function hide() {
isVisible.value = false
contextMenu.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import Button from '@/components/ui/button/Button.vue'
import ContextMenuItem from '@/components/ui/context-menu/ContextMenuItem.vue'
import ContextMenuSeparator from '@/components/ui/context-menu/ContextMenuSeparator.vue'
import DropdownMenuItem from '@/components/ui/dropdown-menu/DropdownMenuItem.vue'
import DropdownMenuSeparator from '@/components/ui/dropdown-menu/DropdownMenuSeparator.vue'
import { cn } from '@/utils/tailwindUtil'
const { entries, surface } = defineProps<{
entries: MenuEntry[]
surface: 'context' | 'dropdown'
}>()
const emit = defineEmits<{
action: [entry: MenuActionEntry]
}>()
function isActionEntry(entry: MenuEntry): entry is MenuActionEntry {
return entry.kind !== 'divider'
}
</script>
<template>
<div
class="media-asset-menu-panel flex min-w-56 flex-col rounded-lg border border-border-subtle bg-secondary-background p-2 text-base-foreground"
>
<template v-for="entry in entries" :key="entry.key">
<ContextMenuSeparator
v-if="surface === 'context' && entry.kind === 'divider'"
class="m-1 h-px bg-border-subtle"
/>
<DropdownMenuSeparator
v-else-if="surface === 'dropdown' && entry.kind === 'divider'"
class="m-1 h-px bg-border-subtle"
/>
<ContextMenuItem
v-else-if="surface === 'context' && isActionEntry(entry)"
as-child
:disabled="entry.disabled"
:text-value="entry.label"
@select="emit('action', entry)"
>
<Button variant="secondary" size="sm" class="w-full justify-start">
<i v-if="entry.icon" :class="cn(entry.icon, 'size-4')" />
<span>{{ entry.label }}</span>
</Button>
</ContextMenuItem>
<DropdownMenuItem
v-else-if="isActionEntry(entry)"
as-child
:disabled="entry.disabled"
:text-value="entry.label"
@select="emit('action', entry)"
>
<Button variant="secondary" size="sm" class="w-full justify-start">
<i v-if="entry.icon" :class="cn(entry.icon, 'size-4')" />
<span>{{ entry.label }}</span>
</Button>
</DropdownMenuItem>
</template>
</div>
</template>

View File

@@ -0,0 +1,268 @@
import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types'
import type { MenuEntry } from '@/types/menuTypes'
import { isPreviewableMediaType } from '@/utils/formatUtil'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
import { getAssetType } from './media/assetMappers'
import { useMediaAssetActions } from './useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema'
type MediaAssetMenuContext = {
asset: AssetItem
assetType: AssetContext['type']
fileKind: MediaKind
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}
type MediaAssetMenuHandlers = {
inspectAsset?: (asset: AssetItem) => void | Promise<void>
assetDeleted?: (asset: AssetItem) => void | Promise<void>
bulkDownload?: (assets: AssetItem[]) => void | Promise<void>
bulkDelete?: (assets: AssetItem[]) => void | Promise<void>
bulkAddToWorkflow?: (assets: AssetItem[]) => void | Promise<void>
bulkOpenWorkflow?: (assets: AssetItem[]) => void | Promise<void>
bulkExportWorkflow?: (assets: AssetItem[]) => void | Promise<void>
}
function canAddToWorkflow(
candidate: AssetItem,
assetType: AssetContext['type']
): boolean {
if (assetType === 'output') return true
if (assetType === 'input' && candidate.name) {
return detectNodeTypeFromFilename(candidate.name).nodeType !== null
}
return false
}
function canShowWorkflowActions(
candidate: AssetItem,
assetType: AssetContext['type']
): boolean {
if (assetType === 'output') return true
if (assetType === 'input' && candidate.name) {
return supportsWorkflowMetadata(candidate.name)
}
return false
}
function canDeleteAsset(
assetType: AssetContext['type'],
showDeleteButton?: boolean
): boolean {
const propAllows = showDeleteButton ?? true
const typeAllows =
assetType === 'output' || (assetType === 'input' && isCloud)
return propAllows && typeAllows
}
export function useMediaAssetMenu(handlers: MediaAssetMenuHandlers = {}) {
const { t } = useI18n()
const actions = useMediaAssetActions()
async function deleteAsset(asset: AssetItem) {
const deleted = await actions.deleteAssets(asset)
if (deleted) {
await handlers.assetDeleted?.(asset)
}
}
async function deleteSelectedAssets(selectedAssets: AssetItem[]) {
if (handlers.bulkDelete) {
await handlers.bulkDelete(selectedAssets)
return
}
await actions.deleteAssets(selectedAssets)
}
function getMenuEntries({
asset,
assetType,
fileKind,
showDeleteButton,
selectedAssets,
isBulkMode
}: MediaAssetMenuContext): MenuEntry[] {
const isSelectedAsset = selectedAssets?.some(
(selectedAsset) => selectedAsset.id === asset.id
)
const showBulkActions =
isBulkMode &&
selectedAssets &&
selectedAssets.length > 0 &&
isSelectedAsset
if (showBulkActions) {
const allSelectedCanAddToWorkflow = selectedAssets.every(
(selectedAsset) =>
canAddToWorkflow(selectedAsset, getAssetType(selectedAsset.tags))
)
const allSelectedSupportWorkflowActions = selectedAssets.every(
(selectedAsset) =>
canShowWorkflowActions(
selectedAsset,
getAssetType(selectedAsset.tags)
)
)
const bulkDeleteEnabled = selectedAssets.every((selectedAsset) =>
canDeleteAsset(getAssetType(selectedAsset.tags), showDeleteButton)
)
return [
{
key: 'bulk-selection-header',
label: t('mediaAsset.selection.multipleSelectedAssets'),
disabled: true
},
...(allSelectedCanAddToWorkflow
? [
{
key: 'bulk-add-to-workflow',
label: t('mediaAsset.selection.insertAllAssetsAsNodes'),
icon: 'icon-[comfy--node]',
onClick: () => {
if (handlers.bulkAddToWorkflow) {
return handlers.bulkAddToWorkflow(selectedAssets)
}
return actions.addMultipleToWorkflow(selectedAssets)
}
} satisfies MenuEntry
]
: []),
...(allSelectedSupportWorkflowActions
? [
{
key: 'bulk-open-workflow',
label: t('mediaAsset.selection.openWorkflowAll'),
icon: 'icon-[comfy--workflow]',
onClick: () => {
if (handlers.bulkOpenWorkflow) {
return handlers.bulkOpenWorkflow(selectedAssets)
}
return actions.openMultipleWorkflows(selectedAssets)
}
} satisfies MenuEntry,
{
key: 'bulk-export-workflow',
label: t('mediaAsset.selection.exportWorkflowAll'),
icon: 'icon-[lucide--file-output]',
onClick: () => {
if (handlers.bulkExportWorkflow) {
return handlers.bulkExportWorkflow(selectedAssets)
}
return actions.exportMultipleWorkflows(selectedAssets)
}
} satisfies MenuEntry
]
: []),
{
key: 'bulk-download',
label: t('mediaAsset.selection.downloadSelectedAll'),
icon: 'icon-[lucide--download]',
onClick: () => {
if (handlers.bulkDownload) {
return handlers.bulkDownload(selectedAssets)
}
return actions.downloadMultipleAssets(selectedAssets)
}
},
...(bulkDeleteEnabled
? [
{
key: 'bulk-delete',
label: t('mediaAsset.selection.deleteSelectedAll'),
icon: 'icon-[lucide--trash-2]',
onClick: async () => {
await deleteSelectedAssets(selectedAssets)
}
} satisfies MenuEntry
]
: [])
]
}
const entries: MenuEntry[] = []
const showWorkflowActions = canShowWorkflowActions(asset, assetType)
const deleteEnabled = canDeleteAsset(assetType, showDeleteButton)
if (isPreviewableMediaType(fileKind)) {
entries.push({
key: 'inspect',
label: t('mediaAsset.actions.inspect'),
icon: 'icon-[lucide--zoom-in]',
onClick: () => handlers.inspectAsset?.(asset)
})
}
if (canAddToWorkflow(asset, assetType)) {
entries.push({
key: 'add-to-workflow',
label: t('mediaAsset.actions.insertAsNodeInWorkflow'),
icon: 'icon-[comfy--node]',
onClick: () => actions.addWorkflow(asset)
})
}
entries.push({
key: 'download',
label: t('mediaAsset.actions.download'),
icon: 'icon-[lucide--download]',
onClick: () => actions.downloadAsset(asset)
})
if (showWorkflowActions) {
entries.push({ kind: 'divider', key: 'workflow-divider' })
entries.push({
key: 'open-workflow',
label: t('mediaAsset.actions.openWorkflow'),
icon: 'icon-[comfy--workflow]',
onClick: () => actions.openWorkflow(asset)
})
entries.push({
key: 'export-workflow',
label: t('mediaAsset.actions.exportWorkflow'),
icon: 'icon-[lucide--file-output]',
onClick: () => actions.exportWorkflow(asset)
})
}
if (assetType !== 'input') {
entries.push({ kind: 'divider', key: 'copy-job-id-divider' })
entries.push({
key: 'copy-job-id',
label: t('mediaAsset.actions.copyJobId'),
icon: 'icon-[lucide--copy]',
onClick: async () => {
await actions.copyJobId(asset)
}
})
}
if (deleteEnabled) {
entries.push({ kind: 'divider', key: 'delete-divider' })
entries.push({
key: 'delete',
label: t('mediaAsset.actions.delete'),
icon: 'icon-[lucide--trash-2]',
onClick: async () => deleteAsset(asset)
})
}
return entries
}
return { getMenuEntries }
}

View File

@@ -20,8 +20,8 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
getExecutionIdByNode: vi.fn()
}))
vi.mock('@/platform/nodeReplacement/cnrIdUtil', () => ({
getCnrIdFromNode: vi.fn(() => undefined)
vi.mock('@/workbench/extensions/manager/utils/missingNodeErrorUtil', () => ({
getCnrIdFromNode: vi.fn(() => null)
}))
vi.mock('@/i18n', () => ({
@@ -48,10 +48,11 @@ import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
// eslint-disable-next-line import-x/no-restricted-paths
import { getCnrIdFromNode } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { rescanAndSurfaceMissingNodes } from './missingNodeScan'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
function mockNode(
id: number,
@@ -71,7 +72,7 @@ function mockGraph(): LGraph {
}
function getMissingNodesError(
store: ReturnType<typeof useMissingNodesErrorStore>
store: ReturnType<typeof useExecutionErrorStore>
) {
const error = store.missingNodesError
if (!error) throw new Error('Expected missingNodesError to be defined')
@@ -98,7 +99,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useMissingNodesErrorStore()
const store = useExecutionErrorStore()
expect(store.missingNodesError).toBeNull()
})
@@ -111,7 +112,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useMissingNodesErrorStore()
const store = useExecutionErrorStore()
const error = getMissingNodesError(store)
expect(error.nodeTypes).toHaveLength(2)
})
@@ -128,7 +129,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useMissingNodesErrorStore()
const store = useExecutionErrorStore()
const error = getMissingNodesError(store)
expect(error.nodeTypes).toHaveLength(1)
const missing = error.nodeTypes[0]
@@ -141,7 +142,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useMissingNodesErrorStore()
const store = useExecutionErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.nodeId).toBe('exec-42')
@@ -153,7 +154,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useMissingNodesErrorStore()
const store = useExecutionErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.nodeId).toBe('99')
@@ -166,7 +167,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useMissingNodesErrorStore()
const store = useExecutionErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.cnrId).toBe(
@@ -193,7 +194,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useMissingNodesErrorStore()
const store = useExecutionErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.isReplaceable).toBe(true)
@@ -208,7 +209,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useMissingNodesErrorStore()
const store = useExecutionErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.isReplaceable).toBe(false)
@@ -224,7 +225,7 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
rescanAndSurfaceMissingNodes(mockGraph())
const store = useMissingNodesErrorStore()
const store = useExecutionErrorStore()
const error = getMissingNodesError(store)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.type).toBe('OriginalType')

View File

@@ -2,13 +2,13 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
// eslint-disable-next-line import-x/no-restricted-paths
import { getCnrIdFromNode } from '@/workbench/extensions/manager/utils/missingNodeErrorUtil'
/** Scan the live graph for unregistered node types and build a full MissingNodeType list. */
function scanMissingNodes(rootGraph: LGraph): MissingNodeType[] {
@@ -41,7 +41,5 @@ function scanMissingNodes(rootGraph: LGraph): MissingNodeType[] {
/** Re-scan the graph for missing nodes and update the error store. */
export function rescanAndSurfaceMissingNodes(rootGraph: LGraph): void {
const types = scanMissingNodes(rootGraph)
if (useMissingNodesErrorStore().surfaceMissingNodes(types)) {
useExecutionErrorStore().showErrorOverlay()
}
useExecutionErrorStore().surfaceMissingNodes(types)
}

View File

@@ -1,215 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const mockShowErrorsTab = vi.hoisted(() => ({ value: false }))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => mockShowErrorsTab.value)
}))
}))
import { useMissingNodesErrorStore } from './missingNodesErrorStore'
describe('missingNodesErrorStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('setMissingNodeTypes', () => {
it('sets missingNodesError with provided types', () => {
const store = useMissingNodesErrorStore()
const types: MissingNodeType[] = [
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
]
store.setMissingNodeTypes(types)
expect(store.missingNodesError).not.toBeNull()
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
expect(store.hasMissingNodes).toBe(true)
})
it('clears missingNodesError when given empty array', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
expect(store.missingNodesError).not.toBeNull()
store.setMissingNodeTypes([])
expect(store.missingNodesError).toBeNull()
expect(store.hasMissingNodes).toBe(false)
})
it('deduplicates string entries by value', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
'NodeA',
'NodeA',
'NodeB'
] as MissingNodeType[])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
it('deduplicates object entries by nodeId when present', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '2', isReplaceable: false }
])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
it('deduplicates object entries by type when nodeId is absent', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', isReplaceable: false },
{ type: 'NodeA', isReplaceable: true }
] as MissingNodeType[])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('keeps distinct nodeIds even when type is the same', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '2', isReplaceable: false },
{ type: 'NodeA', nodeId: '3', isReplaceable: false }
])
expect(store.missingNodesError?.nodeTypes).toHaveLength(3)
})
})
describe('surfaceMissingNodes', () => {
beforeEach(() => {
mockShowErrorsTab.value = false
})
it('stores missing node types and returns false when setting disabled', () => {
const store = useMissingNodesErrorStore()
const types: MissingNodeType[] = [
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
]
const shouldShowOverlay = store.surfaceMissingNodes(types)
expect(store.missingNodesError).not.toBeNull()
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
expect(store.hasMissingNodes).toBe(true)
expect(shouldShowOverlay).toBe(false)
})
it('returns true when ShowErrorsTab setting is enabled', () => {
mockShowErrorsTab.value = true
const store = useMissingNodesErrorStore()
const shouldShowOverlay = store.surfaceMissingNodes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
expect(shouldShowOverlay).toBe(true)
})
it('returns false when ShowErrorsTab setting is disabled', () => {
mockShowErrorsTab.value = false
const store = useMissingNodesErrorStore()
const shouldShowOverlay = store.surfaceMissingNodes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
expect(shouldShowOverlay).toBe(false)
})
it('returns false for empty types even when setting is enabled', () => {
mockShowErrorsTab.value = true
const store = useMissingNodesErrorStore()
const shouldShowOverlay = store.surfaceMissingNodes([])
expect(shouldShowOverlay).toBe(false)
})
it('deduplicates node types', () => {
const store = useMissingNodesErrorStore()
store.surfaceMissingNodes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false }
])
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
})
describe('removeMissingNodesByType', () => {
it('removes matching types from the missing nodes list', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
{ type: 'NodeC', nodeId: '3', isReplaceable: false }
])
store.removeMissingNodesByType(['NodeA', 'NodeC'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
const remaining = store.missingNodesError?.nodeTypes[0]
expect(typeof remaining !== 'string' && remaining?.type).toBe('NodeB')
})
it('clears missingNodesError when all types are removed', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
store.removeMissingNodesByType(['NodeA'])
expect(store.missingNodesError).toBeNull()
expect(store.hasMissingNodes).toBe(false)
})
it('does nothing when missingNodesError is null', () => {
const store = useMissingNodesErrorStore()
expect(store.missingNodesError).toBeNull()
store.removeMissingNodesByType(['NodeA'])
expect(store.missingNodesError).toBeNull()
})
it('does nothing when removing non-existent types', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
store.removeMissingNodesByType(['NonExistent'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('handles removing from string entries', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
'StringNodeA',
'StringNodeB'
] as MissingNodeType[])
store.removeMissingNodesByType(['StringNodeA'])
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
})
})

View File

@@ -1,125 +0,0 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import type { MissingNodeType } from '@/types/comfy'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
interface MissingNodesError {
message: string
nodeTypes: MissingNodeType[]
}
export const useMissingNodesErrorStore = defineStore(
'missingNodesError',
() => {
const missingNodesError = ref<MissingNodesError | null>(null)
function setMissingNodeTypes(types: MissingNodeType[]) {
if (!types.length) {
missingNodesError.value = null
return
}
const seen = new Set<string>()
const uniqueTypes = types.filter((node) => {
// For string entries (group nodes), deduplicate by the string itself.
// For object entries, prefer nodeId so multiple instances of the same
// type are kept as separate rows; fall back to type if nodeId is absent.
const isString = typeof node === 'string'
let key: string
if (isString) {
key = node
} else if (node.nodeId != null) {
key = String(node.nodeId)
} else {
key = node.type
}
if (seen.has(key)) return false
seen.add(key)
return true
})
missingNodesError.value = {
message: isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
nodeTypes: uniqueTypes
}
}
/** Set missing node types. Returns true if the Errors tab is enabled and types were set. */
function surfaceMissingNodes(types: MissingNodeType[]): boolean {
setMissingNodeTypes(types)
return (
types.length > 0 &&
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
)
}
/** Remove specific node types from the missing nodes list (e.g. after replacement). */
function removeMissingNodesByType(typesToRemove: string[]) {
if (!missingNodesError.value) return
const removeSet = new Set(typesToRemove)
const remaining = missingNodesError.value.nodeTypes.filter((node) => {
const nodeType = typeof node === 'string' ? node : node.type
return !removeSet.has(nodeType)
})
setMissingNodeTypes(remaining)
}
const hasMissingNodes = computed(() => !!missingNodesError.value)
const missingNodeCount = computed(
() => missingNodesError.value?.nodeTypes.length ?? 0
)
/**
* Set of all execution ID prefixes derived from missing node execution IDs,
* including the missing nodes themselves.
*
* Example: missing node at "65:70:63" → Set { "65", "65:70", "65:70:63" }
*/
const missingAncestorExecutionIds = computed<Set<NodeExecutionId>>(() => {
const ids = new Set<NodeExecutionId>()
const error = missingNodesError.value
if (!error) return ids
for (const nodeType of error.nodeTypes) {
if (typeof nodeType === 'string') continue
if (nodeType.nodeId == null) continue
for (const id of getAncestorExecutionIds(String(nodeType.nodeId))) {
ids.add(id)
}
}
return ids
})
/** True if the node has a missing node inside it at any nesting depth. */
function isContainerWithMissingNode(node: LGraphNode): boolean {
if (!app.isGraphReady) return false
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return false
return missingAncestorExecutionIds.value.has(execId)
}
return {
missingNodesError,
setMissingNodeTypes,
surfaceMissingNodes,
removeMissingNodesByType,
hasMissingNodes,
missingNodeCount,
missingAncestorExecutionIds,
isContainerWithMissingNode
}
}
)

View File

@@ -47,8 +47,8 @@ vi.mock('@/i18n', () => ({
const { mockRemoveMissingNodesByType } = vi.hoisted(() => ({
mockRemoveMissingNodesByType: vi.fn()
}))
vi.mock('@/platform/nodeReplacement/missingNodesErrorStore', () => ({
useMissingNodesErrorStore: vi.fn(() => ({
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: vi.fn(() => ({
removeMissingNodesByType: mockRemoveMissingNodesByType
}))
}))

View File

@@ -7,7 +7,7 @@ import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app, sanitizeNodeName } from '@/scripts/app'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { MissingNodeType } from '@/types/comfy'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
@@ -329,24 +329,24 @@ export function useNodeReplacement() {
/**
* Replaces all nodes in a single swap group and removes successfully
* replaced types from the missing nodes error store.
* replaced types from the execution error store.
*/
function replaceGroup(group: ReplacementGroup): void {
const replaced = replaceNodesInPlace(group.nodeTypes)
if (replaced.length > 0) {
useMissingNodesErrorStore().removeMissingNodesByType(replaced)
useExecutionErrorStore().removeMissingNodesByType(replaced)
}
}
/**
* Replaces every available node across all swap groups and removes
* the succeeded types from the missing nodes error store.
* the succeeded types from the execution error store.
*/
function replaceAllGroups(groups: ReplacementGroup[]): void {
const allNodeTypes = groups.flatMap((g) => g.nodeTypes)
const replaced = replaceNodesInPlace(allNodeTypes)
if (replaced.length > 0) {
useMissingNodesErrorStore().removeMissingNodesByType(replaced)
useExecutionErrorStore().removeMissingNodesByType(replaced)
}
}

View File

@@ -1,5 +1,5 @@
<template>
<BaseModalLayout content-title="" data-testid="settings-dialog" size="sm">
<BaseModalLayout content-title="" data-testid="settings-dialog" size="md">
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--settings]" />
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
@@ -12,7 +12,6 @@
size="md"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"
autofocus
@search="handleSearch"
/>
</div>

View File

@@ -12,7 +12,7 @@ import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { app } from '@/scripts/app'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
@@ -160,7 +160,7 @@ describe('useWorkflowService', () => {
useWorkflowService().showPendingWarnings(workflow)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
useExecutionErrorStore().surfaceMissingNodes
).not.toHaveBeenCalled()
})
@@ -170,9 +170,9 @@ describe('useWorkflowService', () => {
useWorkflowService().showPendingWarnings(workflow)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(missingNodeTypes)
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
missingNodeTypes
)
expect(workflow.pendingWarnings).toBeNull()
})
@@ -185,9 +185,9 @@ describe('useWorkflowService', () => {
useWorkflowService().showPendingWarnings(workflow)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['CustomNode1'])
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
['CustomNode1']
)
expect(workflow.pendingWarnings).toBeNull()
})
@@ -201,7 +201,7 @@ describe('useWorkflowService', () => {
service.showPendingWarnings(workflow)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
useExecutionErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
})
})
@@ -226,7 +226,7 @@ describe('useWorkflowService', () => {
)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
useExecutionErrorStore().surfaceMissingNodes
).not.toHaveBeenCalled()
await useWorkflowService().openWorkflow(workflow)
@@ -238,9 +238,9 @@ describe('useWorkflowService', () => {
workflow,
expect.objectContaining({ deferWarnings: true })
)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['CustomNode1'])
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
['CustomNode1']
)
expect(workflow.pendingWarnings).toBeNull()
})
@@ -258,20 +258,20 @@ describe('useWorkflowService', () => {
await service.openWorkflow(workflow1)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
useExecutionErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['MissingNodeA'])
expect(useExecutionErrorStore().surfaceMissingNodes).toHaveBeenCalledWith(
['MissingNodeA']
)
expect(workflow1.pendingWarnings).toBeNull()
expect(workflow2.pendingWarnings).not.toBeNull()
await service.openWorkflow(workflow2)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
useExecutionErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(2)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
useExecutionErrorStore().surfaceMissingNodes
).toHaveBeenLastCalledWith(['MissingNodeB'])
expect(workflow2.pendingWarnings).toBeNull()
})
@@ -286,12 +286,12 @@ describe('useWorkflowService', () => {
await service.openWorkflow(workflow, { force: true })
expect(
useMissingNodesErrorStore().surfaceMissingNodes
useExecutionErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
await service.openWorkflow(workflow, { force: true })
expect(
useMissingNodesErrorStore().surfaceMissingNodes
useExecutionErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
})
})

View File

@@ -23,7 +23,6 @@ import type { AppMode } from '@/composables/useAppMode'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import {
appendJsonExt,
@@ -44,7 +43,6 @@ export const useWorkflowService = () => {
const workflowThumbnail = useWorkflowThumbnail()
const domWidgetStore = useDomWidgetStore()
const executionErrorStore = useExecutionErrorStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const workflowDraftStore = useWorkflowDraftStore()
function confirmOverwrite(targetPath: string) {
@@ -544,9 +542,7 @@ export const useWorkflowService = () => {
wf.pendingWarnings = null
if (missingNodeTypes?.length) {
if (missingNodesErrorStore.surfaceMissingNodes(missingNodeTypes)) {
executionErrorStore.showErrorOverlay()
}
executionErrorStore.surfaceMissingNodes(missingNodeTypes)
}
}

View File

@@ -250,125 +250,61 @@ function readSessionPointer<T extends { workspaceId: string }>(
/**
* Reads the active path pointer from sessionStorage.
* Falls back to workspace-based search when clientId changes after reload,
* then to localStorage when sessionStorage is empty (browser restart).
* Falls back to workspace-based search when clientId changes after reload.
*/
export function readActivePath(
clientId: string,
targetWorkspaceId?: string
): ActivePathPointer | null {
return (
readSessionPointer<ActivePathPointer>(
StorageKeys.activePath(clientId),
StorageKeys.prefixes.activePath,
targetWorkspaceId
) ??
(targetWorkspaceId
? readLocalPointer<ActivePathPointer>(
StorageKeys.lastActivePath(targetWorkspaceId),
isValidActivePathPointer
)
: null)
return readSessionPointer<ActivePathPointer>(
StorageKeys.activePath(clientId),
StorageKeys.prefixes.activePath,
targetWorkspaceId
)
}
/**
* Writes the active path pointer to both sessionStorage (tab-scoped)
* and localStorage (survives browser restart).
* Writes the active path pointer to sessionStorage.
*/
export function writeActivePath(
clientId: string,
pointer: ActivePathPointer
): void {
const json = JSON.stringify(pointer)
writeStorage(sessionStorage, StorageKeys.activePath(clientId), json)
writeStorage(
localStorage,
StorageKeys.lastActivePath(pointer.workspaceId),
json
)
try {
const key = StorageKeys.activePath(clientId)
sessionStorage.setItem(key, JSON.stringify(pointer))
} catch {
// Best effort - ignore errors
}
}
/**
* Reads the open paths pointer from sessionStorage.
* Falls back to workspace-based search when clientId changes after reload,
* then to localStorage when sessionStorage is empty (browser restart).
* Falls back to workspace-based search when clientId changes after reload.
*/
export function readOpenPaths(
clientId: string,
targetWorkspaceId?: string
): OpenPathsPointer | null {
return (
readSessionPointer<OpenPathsPointer>(
StorageKeys.openPaths(clientId),
StorageKeys.prefixes.openPaths,
targetWorkspaceId
) ??
(targetWorkspaceId
? readLocalPointer<OpenPathsPointer>(
StorageKeys.lastOpenPaths(targetWorkspaceId),
isValidOpenPathsPointer
)
: null)
return readSessionPointer<OpenPathsPointer>(
StorageKeys.openPaths(clientId),
StorageKeys.prefixes.openPaths,
targetWorkspaceId
)
}
/**
* Writes the open paths pointer to both sessionStorage (tab-scoped)
* and localStorage (survives browser restart).
* Writes the open paths pointer to sessionStorage.
*/
export function writeOpenPaths(
clientId: string,
pointer: OpenPathsPointer
): void {
const json = JSON.stringify(pointer)
writeStorage(sessionStorage, StorageKeys.openPaths(clientId), json)
writeStorage(
localStorage,
StorageKeys.lastOpenPaths(pointer.workspaceId),
json
)
}
function hasWorkspaceId(obj: Record<string, unknown>): boolean {
return typeof obj.workspaceId === 'string'
}
function isValidActivePathPointer(value: unknown): value is ActivePathPointer {
if (typeof value !== 'object' || value === null) return false
const obj = value as Record<string, unknown>
return hasWorkspaceId(obj) && typeof obj.path === 'string'
}
function isValidOpenPathsPointer(value: unknown): value is OpenPathsPointer {
if (typeof value !== 'object' || value === null) return false
const obj = value as Record<string, unknown>
return (
hasWorkspaceId(obj) &&
Array.isArray(obj.paths) &&
typeof obj.activeIndex === 'number'
)
}
function readLocalPointer<T>(
key: string,
validate: (value: unknown) => value is T
): T | null {
try {
const json = localStorage.getItem(key)
if (!json) return null
const parsed = JSON.parse(json)
return validate(parsed) ? parsed : null
const key = StorageKeys.openPaths(clientId)
sessionStorage.setItem(key, JSON.stringify(pointer))
} catch {
return null
}
}
function writeStorage(storage: Storage, key: string, value: string): void {
try {
storage.setItem(key, value)
} catch {
// Best effort — silently degrade when storage is full or unavailable
// Best effort - ignore errors
}
}
@@ -381,9 +317,7 @@ export function clearAllV2Storage(): void {
const prefixes = [
StorageKeys.prefixes.draftIndex,
StorageKeys.prefixes.draftPayload,
StorageKeys.prefixes.lastActivePath,
StorageKeys.prefixes.lastOpenPaths
StorageKeys.prefixes.draftPayload
]
try {

View File

@@ -72,19 +72,6 @@ export const StorageKeys = {
return `Comfy.Workflow.OpenPaths:${clientId}`
},
/**
* localStorage copies of tab pointers for cross-session restore.
* sessionStorage is per-tab (correct for in-session use) but lost
* on browser restart; these keys preserve the last-written state.
*/
lastActivePath(workspaceId: string): string {
return `Comfy.Workflow.LastActivePath:${workspaceId}`
},
lastOpenPaths(workspaceId: string): string {
return `Comfy.Workflow.LastOpenPaths:${workspaceId}`
},
/**
* Prefix patterns for cleanup operations.
*/
@@ -92,8 +79,6 @@ export const StorageKeys = {
draftIndex: 'Comfy.Workflow.DraftIndex.v2:',
draftPayload: 'Comfy.Workflow.Draft.v2:',
activePath: 'Comfy.Workflow.ActivePath:',
openPaths: 'Comfy.Workflow.OpenPaths:',
lastActivePath: 'Comfy.Workflow.LastActivePath:',
lastOpenPaths: 'Comfy.Workflow.LastOpenPaths:'
openPaths: 'Comfy.Workflow.OpenPaths:'
}
} as const

View File

@@ -4,7 +4,6 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ImageLightbox from '@/components/common/ImageLightbox.vue'
import { useClickDragGuard } from '@/composables/useClickDragGuard'
import { cn } from '@/utils/tailwindUtil'
defineOptions({ inheritAttrs: false })
@@ -31,17 +30,26 @@ const {
const dropZoneRef = ref<HTMLElement | null>(null)
const canAcceptDrop = ref(false)
const clickGuard = useClickDragGuard(5)
const pointerStart = ref<{ x: number; y: number } | null>(null)
const lightboxOpen = ref(false)
function onPointerDown(e: PointerEvent) {
clickGuard.recordStart(e)
pointerStart.value = { x: e.clientX, y: e.clientY }
}
function onIndicatorClick(e: MouseEvent) {
const dragged = e.detail !== 0 && clickGuard.wasDragged(e)
clickGuard.reset()
if (dragged) return
if (e.detail !== 0) {
const start = pointerStart.value
if (start) {
const dx = e.clientX - start.x
const dy = e.clientY - start.y
if (dx * dx + dy * dy > 25) {
pointerStart.value = null
return
}
}
}
pointerStart.value = null
dropIndicator?.onClick?.(e)
}

View File

@@ -2,29 +2,19 @@
import { ref, useTemplateRef } from 'vue'
import ZoomPane from '@/components/ui/ZoomPane.vue'
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
import { cn } from '@/utils/tailwindUtil'
const { executionStatusMessage } = useExecutionStatus()
defineOptions({ inheritAttrs: false })
const { src, showSize = true } = defineProps<{
const { src } = defineProps<{
src: string
mobile?: boolean
label?: string
showSize?: boolean
}>()
const imageRef = useTemplateRef('imageRef')
const width = ref<number | null>(null)
const height = ref<number | null>(null)
function onImageLoad() {
if (!imageRef.value || !showSize) return
width.value = imageRef.value.naturalWidth
height.value = imageRef.value.naturalHeight
}
const width = ref('')
const height = ref('')
</script>
<template>
<ZoomPane
@@ -37,7 +27,13 @@ function onImageLoad() {
:src
v-bind="slotProps"
class="size-full object-contain"
@load="onImageLoad"
@load="
() => {
if (!imageRef) return
width = `${imageRef.naturalWidth}`
height = `${imageRef.naturalHeight}`
}
"
/>
</ZoomPane>
<img
@@ -45,15 +41,15 @@ function onImageLoad() {
ref="imageRef"
class="grow object-contain contain-size"
:src
@load="onImageLoad"
@load="
() => {
if (!imageRef) return
width = `${imageRef.naturalWidth}`
height = `${imageRef.naturalHeight}`
}
"
/>
<span
v-if="executionStatusMessage"
class="animate-pulse self-center text-muted md:z-10"
>
{{ executionStatusMessage }}
</span>
<span v-else-if="width && height" class="self-center md:z-10">
<span class="self-center md:z-10">
{{ `${width} x ${height}` }}
<template v-if="label"> | {{ label }}</template>
</span>

View File

@@ -1,23 +1,7 @@
<script setup lang="ts">
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
const { executionStatusMessage } = useExecutionStatus()
</script>
<template>
<div
class="sz-full flex min-h-0 flex-1 flex-col items-center justify-center gap-3"
>
<div class="flex h-full items-center justify-center">
<div
class="skeleton-shimmer aspect-square size-[min(50vw,50vh)] rounded-lg"
/>
</div>
<span
v-if="executionStatusMessage"
class="animate-pulse text-sm text-muted"
>
{{ executionStatusMessage }}
</span>
<div class="flex min-h-0 w-full flex-1 items-center justify-center">
<div
class="skeleton-shimmer aspect-square size-[min(50vw,50vh)] rounded-lg"
/>
</div>
</template>

View File

@@ -136,7 +136,6 @@ async function rerun(e: Event) {
v-if="canShowPreview && latentPreview"
:mobile
:src="latentPreview"
:show-size="false"
/>
<MediaOutputPreview
v-else-if="selectedOutput"

View File

@@ -1,100 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { getExecutionStatusMessage } from './getExecutionStatusMessage'
// Pass-through t so we can assert the i18n key
const t = (key: string) => key
describe('getExecutionStatusMessage', () => {
describe('custom messages', () => {
it('returns custom message from properties when set', () => {
expect(
getExecutionStatusMessage(t, 'KSampler', null, {
'Execution Message': 'custom status'
})
).toBe('custom status')
})
it('ignores empty or whitespace-only custom message', () => {
expect(
getExecutionStatusMessage(t, 'KSampler', null, {
'Execution Message': ' '
})
).toBe('execution.generating')
})
})
describe('API nodes', () => {
it('returns processing for API nodes', () => {
const apiDef = { api_node: true } as ComfyNodeDefImpl
expect(getExecutionStatusMessage(t, 'SomeApiNode', apiDef)).toBe(
'execution.processing'
)
})
it('statusMap takes precedence over api_node flag', () => {
const apiDef = { api_node: true } as ComfyNodeDefImpl
expect(getExecutionStatusMessage(t, 'KSampler', apiDef)).toBe(
'execution.generating'
)
})
})
describe('Node type matching', () => {
it('does not match partial PascalCase words', () => {
expect(getExecutionStatusMessage(t, 'Loads')).toBeNull()
})
it('matches identifier mid-string at PascalCase boundary', () => {
expect(getExecutionStatusMessage(t, 'CompositeSaveImage')).toBe(
'execution.saving'
)
})
it('matches identifier followed by non-letter characters', () => {
expect(getExecutionStatusMessage(t, 'Save_V2')).toBe('execution.saving')
expect(getExecutionStatusMessage(t, 'LoadImage🐍')).toBe(
'execution.loading'
)
})
const testNodeTypes: [string, string[]][] = [
['generating', ['KSampler', 'SamplerCustomAdvanced']],
[
'saving',
['SaveImage', 'SaveAnimatedWEBP', 'PreviewImage', 'MaskPreview']
],
['loading', ['LoadImage', 'VAELoader', 'CheckpointLoaderSimple']],
[
'encoding',
['VAEEncode', 'StableCascade_StageC_VAEEncode', 'CLIPTextEncode']
],
['decoding', ['VAEDecode', 'VAEDecodeHunyuan3D']],
[
'resizing',
['ImageUpscaleWithModel', 'LatentUpscale', 'ResizeImageMaskNode']
],
[
'processing',
['TorchCompileModel', 'SVD_img2vid_Conditioning', 'ModelMergeSimple']
],
['generatingVideo', ['WanImageToVideo', 'WanFunControlToVideo']],
['processingVideo', ['Video Slice', 'CreateVideo']],
['training', ['TrainLoraNode']]
]
it.for(
testNodeTypes.flatMap(([status, nodes]) =>
nodes.map((node) => [status, node] as const)
)
)('%s ← %s', ([status, nodeType]) => {
expect(getExecutionStatusMessage(t, nodeType)).toBe(`execution.${status}`)
})
})
it('returns null for nodes matching no pattern', () => {
expect(getExecutionStatusMessage(t, 'PrimitiveString')).toBeNull()
})
})

View File

@@ -1,67 +0,0 @@
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
type ExecutionStatusKey =
| 'generating'
| 'saving'
| 'loading'
| 'encoding'
| 'decoding'
| 'processing'
| 'resizing'
| 'generatingVideo'
| 'processingVideo'
| 'training'
/**
* Specific status messages for nodes that can't be matched by PascalCase
* identifier patterns (e.g. unconventional naming, spaces).
*/
const statusMap: Record<string, ExecutionStatusKey> = {
// Video utility nodes with non-standard naming
'Video Slice': 'processingVideo',
GetVideoComponents: 'processingVideo',
CreateVideo: 'processingVideo',
// Training
TrainLoraNode: 'training'
}
/**
* Matches a PascalCase identifier within a node type name.
*/
function pascalId(...ids: string[]): RegExp {
return new RegExp('(?:' + ids.join('|') + ')(?![a-z])')
}
const identifierRules: [RegExp, ExecutionStatusKey][] = [
[pascalId('Save', 'Preview'), 'saving'],
[pascalId('Load', 'Loader'), 'loading'],
[pascalId('Encode'), 'encoding'],
[pascalId('Decode'), 'decoding'],
[pascalId('Compile', 'Conditioning', 'Merge'), 'processing'],
[pascalId('Upscale', 'Resize'), 'resizing'],
[pascalId('ToVideo'), 'generatingVideo'],
[pascalId('Sampler'), 'generating']
]
export function getExecutionStatusMessage(
t: (key: string) => string,
nodeType: string,
nodeDef?: ComfyNodeDefImpl | null,
properties?: Record<string, unknown>
): string | null {
const customMessage = properties?.['Execution Message']
if (typeof customMessage === 'string' && customMessage.trim()) {
return customMessage.trim()
}
if (nodeType in statusMap) return t(`execution.${statusMap[nodeType]}`)
for (const [pattern, key] of identifierRules) {
if (pattern.test(nodeType)) return t(`execution.${key}`)
}
if (nodeDef?.api_node) return t('execution.processing')
return null
}

View File

@@ -1,41 +0,0 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { getExecutionStatusMessage } from '@/renderer/extensions/linearMode/getExecutionStatusMessage'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
executionIdToNodeLocatorId,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
function resolveStatus(
t: (key: string) => string,
nodeDefStore: ReturnType<typeof useNodeDefStore>,
executionId: string | number
): string | null {
const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
if (!locatorId) return null
const node = getNodeByLocatorId(app.rootGraph, locatorId)
const nodeType = node?.type
if (!nodeType) return null
const nodeDef = nodeDefStore.nodeDefsByName[nodeType] ?? null
return getExecutionStatusMessage(t, nodeType, nodeDef, node.properties)
}
export function useExecutionStatus() {
const { t } = useI18n()
const executionStore = useExecutionStore()
const nodeDefStore = useNodeDefStore()
const executionStatusMessage = computed<string | null>(() => {
const executionId = executionStore.executingNodeId
if (!executionId) return null
return resolveStatus(t, nodeDefStore, executionId) || t('execution.running')
})
return { executionStatusMessage }
}

View File

@@ -101,11 +101,7 @@
<!-- Video Dimensions -->
<div class="mt-2 text-center text-xs text-muted-foreground">
<span
v-if="videoError"
class="text-red-400"
data-testid="error-loading-video"
>
<span v-if="videoError" class="text-red-400">
{{ $t('g.errorLoadingVideo') }}
</span>
<span v-else-if="showLoader" class="text-smoke-400">

View File

@@ -1,7 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -31,9 +31,7 @@ const i18n = createI18n({
imageFailedToLoad: 'Image failed to load',
imageDoesNotExist: 'Image does not exist',
unknownFile: 'Unknown file',
loading: 'Loading',
viewGrid: 'Grid view',
galleryThumbnail: 'Gallery thumbnail'
loading: 'Loading'
}
}
}
@@ -71,17 +69,6 @@ describe('ImagePreview', () => {
return wrapper
}
/** Switch a multi-image wrapper from default grid mode to gallery mode */
async function switchToGallery(wrapper: VueWrapper) {
const thumbnails = wrapper.findAll('button[aria-label^="View image"]')
await thumbnails[0].trigger('click')
await nextTick()
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
wrapperRegistry.forEach((wrapper) => {
wrapper.unmount()
@@ -89,23 +76,30 @@ describe('ImagePreview', () => {
wrapperRegistry.clear()
})
it('renders image preview when imageUrls provided', () => {
const wrapper = mountImagePreview()
expect(wrapper.find('.image-preview').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(true)
expect(wrapper.find('img').attributes('src')).toBe(
defaultProps.imageUrls[0]
)
})
it('does not render when no imageUrls provided', () => {
const wrapper = mountImagePreview({ imageUrls: [] })
expect(wrapper.find('.image-preview').exists()).toBe(false)
})
it('displays calculating dimensions text in gallery mode', async () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
it('displays calculating dimensions text initially', () => {
const wrapper = mountImagePreview()
expect(wrapper.text()).toContain('Calculating dimensions')
})
it('shows navigation dots for multiple images in gallery mode', async () => {
it('shows navigation dots for multiple images', () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
expect(navigationDots).toHaveLength(2)
@@ -120,23 +114,113 @@ describe('ImagePreview', () => {
expect(navigationDots).toHaveLength(0)
})
it('shows mask/edit button only for single images', async () => {
// Multiple images in gallery mode - should not show mask button
const multipleImagesWrapper = mountImagePreview()
await switchToGallery(multipleImagesWrapper)
it('shows action buttons on hover', async () => {
const wrapper = mountImagePreview()
expect(
multipleImagesWrapper.find('[aria-label="Edit or mask image"]').exists()
).toBe(false)
// Initially buttons should not be visible
expect(wrapper.find('.actions').exists()).toBe(false)
// Trigger hover on the image wrapper (the element with role="img" has the hover handlers)
const imageWrapper = wrapper.find('[role="img"]')
await imageWrapper.trigger('mouseenter')
await nextTick()
// Action buttons should now be visible
expect(wrapper.find('.actions').exists()).toBe(true)
// For multiple images: download and remove buttons (no mask button)
expect(wrapper.find('[aria-label="Download image"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="Remove image"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="Edit or mask image"]').exists()).toBe(
false
)
})
it('hides action buttons when not hovering', async () => {
const wrapper = mountImagePreview()
const imageWrapper = wrapper.find('[role="img"]')
// Trigger hover
await imageWrapper.trigger('mouseenter')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(true)
// Trigger mouse leave
await imageWrapper.trigger('mouseleave')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(false)
})
it('shows action buttons on focus', async () => {
const wrapper = mountImagePreview()
// Initially buttons should not be visible
expect(wrapper.find('.actions').exists()).toBe(false)
// Trigger focusin on the image wrapper (useFocusWithin listens to focusin/focusout)
const imageWrapper = wrapper.find('[role="img"]')
await imageWrapper.trigger('focusin')
await nextTick()
// Action buttons should now be visible
expect(wrapper.find('.actions').exists()).toBe(true)
})
it('hides action buttons on blur', async () => {
const wrapper = mountImagePreview()
const imageWrapper = wrapper.find('[role="img"]')
// Trigger focus
await imageWrapper.trigger('focusin')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(true)
// Trigger focusout
await imageWrapper.trigger('focusout')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(false)
})
it('shows mask/edit button only for single images', async () => {
// Multiple images - should not show mask button
const multipleImagesWrapper = mountImagePreview()
await multipleImagesWrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
const maskButtonMultiple = multipleImagesWrapper.find(
'[aria-label="Edit or mask image"]'
)
expect(maskButtonMultiple.exists()).toBe(false)
// Single image - should show mask button
const singleImageWrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await singleImageWrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
expect(
singleImageWrapper.find('[aria-label="Edit or mask image"]').exists()
).toBe(true)
const maskButtonSingle = singleImageWrapper.find(
'[aria-label="Edit or mask image"]'
)
expect(maskButtonSingle.exists()).toBe(true)
})
it('handles action button clicks', async () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await wrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
// Test Edit/Mask button - just verify it can be clicked without errors
const editButton = wrapper.find('[aria-label="Edit or mask image"]')
expect(editButton.exists()).toBe(true)
await editButton.trigger('click')
// Test Remove button - just verify it can be clicked without errors
const removeButton = wrapper.find('[aria-label="Remove image"]')
expect(removeButton.exists()).toBe(true)
await removeButton.trigger('click')
})
it('handles download button click', async () => {
@@ -144,16 +228,20 @@ describe('ImagePreview', () => {
imageUrls: [defaultProps.imageUrls[0]]
})
await wrapper.find('[role="img"]').trigger('mouseenter')
await nextTick()
// Test Download button
const downloadButton = wrapper.find('[aria-label="Download image"]')
expect(downloadButton.exists()).toBe(true)
await downloadButton.trigger('click')
// Verify the mocked downloadFile was called
expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0])
})
it('switches images when navigation dots are clicked', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
// Initially shows first image
expect(wrapper.find('img').attributes('src')).toBe(
@@ -165,14 +253,14 @@ describe('ImagePreview', () => {
await navigationDots[1].trigger('click')
await nextTick()
expect(wrapper.find('img').attributes('src')).toBe(
defaultProps.imageUrls[1]
)
// Now should show second image
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)
expect(imgElement.attributes('src')).toBe(defaultProps.imageUrls[1])
})
it('marks active navigation dot with aria-current', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
@@ -180,6 +268,7 @@ describe('ImagePreview', () => {
expect(navigationDots[0].attributes('aria-current')).toBe('true')
expect(navigationDots[1].attributes('aria-current')).toBeUndefined()
// Switch to second image
await navigationDots[1].trigger('click')
await nextTick()
@@ -188,224 +277,38 @@ describe('ImagePreview', () => {
expect(navigationDots[1].attributes('aria-current')).toBe('true')
})
it('has proper accessibility attributes', () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
it('loads image without errors', async () => {
const wrapper = mountImagePreview()
const img = wrapper.find('img')
expect(img.attributes('alt')).toBe('View image 1 of 1')
expect(img.exists()).toBe(true)
// Just verify the image element is properly set up
expect(img.attributes('src')).toBe(defaultProps.imageUrls[0])
})
it('has proper accessibility attributes', () => {
const wrapper = mountImagePreview()
const img = wrapper.find('img')
expect(img.attributes('alt')).toBe('Node output 1')
})
it('updates alt text when switching images', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
expect(wrapper.find('img').attributes('alt')).toBe('View image 1 of 2')
// Initially first image
expect(wrapper.find('img').attributes('alt')).toBe('Node output 1')
// Switch to second image
const navigationDots = wrapper.findAll('[aria-label*="View image"]')
await navigationDots[1].trigger('click')
await nextTick()
expect(wrapper.find('img').attributes('alt')).toBe('View image 2 of 2')
})
describe('keyboard navigation', () => {
it('navigates to next image with ArrowRight', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
defaultProps.imageUrls[1]
)
})
it('navigates to previous image with ArrowLeft', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
defaultProps.imageUrls[0]
)
})
it('wraps around from last to first with ArrowRight', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
defaultProps.imageUrls[0]
)
})
it('wraps around from first to last with ArrowLeft', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
defaultProps.imageUrls[1]
)
})
it('navigates to first image with Home', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
await wrapper.find('.image-preview').trigger('keydown', { key: 'Home' })
await nextTick()
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
defaultProps.imageUrls[0]
)
})
it('navigates to last image with End', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
await wrapper.find('.image-preview').trigger('keydown', { key: 'End' })
await nextTick()
expect(wrapper.find('[data-testid="main-image"]').attributes('src')).toBe(
defaultProps.imageUrls[1]
)
})
it('ignores arrow keys in grid mode', async () => {
const wrapper = mountImagePreview()
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
expect(gridThumbnails).toHaveLength(2)
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
expect(wrapper.find('[role="region"]').exists()).toBe(false)
})
it('ignores arrow keys for single image', async () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
const initialSrc = wrapper.find('img').attributes('src')
await wrapper
.find('.image-preview')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
expect(wrapper.find('img').attributes('src')).toBe(initialSrc)
})
})
describe('grid view', () => {
it('defaults to grid mode for multiple images', () => {
const wrapper = mountImagePreview()
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
expect(gridThumbnails).toHaveLength(2)
})
it('defaults to gallery mode for single image', () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
expect(wrapper.find('[role="region"]').exists()).toBe(true)
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
expect(gridThumbnails).toHaveLength(0)
})
it('switches to gallery mode when grid thumbnail is clicked', async () => {
const wrapper = mountImagePreview()
const thumbnails = wrapper.findAll('button[aria-label^="View image"]')
await thumbnails[1].trigger('click')
await nextTick()
const mainImg = wrapper.find('[data-testid="main-image"]')
expect(mainImg.exists()).toBe(true)
expect(mainImg.attributes('src')).toBe(defaultProps.imageUrls[1])
})
it('shows back-to-grid button next to navigation dots', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
const gridButton = wrapper.find('[aria-label="Grid view"]')
expect(gridButton.exists()).toBe(true)
})
it('switches back to grid mode via back-to-grid button', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
const gridButton = wrapper.find('[aria-label="Grid view"]')
await gridButton.trigger('click')
await nextTick()
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
expect(gridThumbnails).toHaveLength(2)
})
it('resets to grid mode when URLs change to multiple images', async () => {
const wrapper = mountImagePreview()
await switchToGallery(wrapper)
// Verify we're in gallery mode
expect(wrapper.find('[role="region"]').exists()).toBe(true)
// Change URLs
await wrapper.setProps({
imageUrls: [
'/api/view?filename=new1.png&type=output',
'/api/view?filename=new2.png&type=output',
'/api/view?filename=new3.png&type=output'
]
})
await nextTick()
// Should be back in grid mode
const gridThumbnails = wrapper.findAll('button[aria-label^="View image"]')
expect(gridThumbnails).toHaveLength(3)
})
// Alt text should update
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)
expect(imgElement.attributes('alt')).toBe('Node output 2')
})
describe('batch cycling with identical URLs', () => {
@@ -416,7 +319,6 @@ describe('ImagePreview', () => {
const wrapper = mountImagePreview({
imageUrls: [sameUrl, sameUrl, sameUrl]
})
await switchToGallery(wrapper)
// Simulate initial image load
await wrapper.find('img').trigger('load')
@@ -463,7 +365,8 @@ describe('ImagePreview', () => {
await vi.advanceTimersByTimeAsync(300)
await nextTick()
// Loading state should NOT have been reset
// Loading state should NOT have been reset - aria-busy should still be false
// because the URLs are identical (just a new array reference)
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
} finally {
vi.useRealTimers()
@@ -503,13 +406,16 @@ describe('ImagePreview', () => {
it('should handle empty to non-empty URL transitions correctly', async () => {
const wrapper = mountImagePreview({ imageUrls: [] })
// No preview initially
expect(wrapper.find('.image-preview').exists()).toBe(false)
// Add URLs
await wrapper.setProps({
imageUrls: ['/api/view?filename=test.png&type=output']
})
await nextTick()
// Preview should appear
expect(wrapper.find('.image-preview').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(true)
})

View File

@@ -4,45 +4,18 @@
class="image-preview group relative flex size-full min-h-55 min-w-16 flex-col justify-center px-2"
@keydown="handleKeyDown"
>
<!-- Grid View -->
<!-- Image Wrapper -->
<div
v-if="viewMode === 'grid'"
data-testid="image-grid"
class="group/panel relative grid w-full gap-1 overflow-hidden rounded-sm p-1"
:style="{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }"
>
<button
v-for="(url, index) in imageUrls"
:key="index"
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:outline-none"
:aria-label="
$t('g.viewImageOfTotal', {
index: index + 1,
total: imageUrls.length
})
"
@pointerdown="trackPointerStart"
@click="handleGridThumbnailClick($event, index)"
>
<img
:src="url"
:alt="`${$t('g.galleryThumbnail')} ${index + 1}`"
draggable="false"
class="pointer-events-none size-full object-contain"
/>
</button>
</div>
<!-- Gallery View (Image Wrapper) -->
<div
v-if="viewMode === 'gallery'"
ref="galleryPanelEl"
class="group/panel relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
ref="imageWrapperEl"
class="relative flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-sm bg-transparent"
tabindex="0"
role="region"
:aria-roledescription="$t('g.imageGallery')"
role="img"
:aria-label="$t('g.imagePreview')"
:aria-busy="showLoader"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@focusin="handleFocusIn"
@focusout="handleFocusOut"
>
<!-- Error State -->
<div
@@ -65,18 +38,22 @@
<!-- Main Image -->
<img
v-if="!imageError"
data-testid="main-image"
:src="currentImageUrl"
:alt="imageAltText"
draggable="false"
class="pointer-events-none absolute inset-0 block size-full object-contain"
:class="
cn(
'pointer-events-none absolute inset-0 block size-full object-contain transition-opacity',
(isHovered || isFocused) && 'opacity-60'
)
"
@load="handleImageLoad"
@error="handleImageError"
/>
<!-- Floating Action Buttons (appear on hover and focus) -->
<div
class="actions invisible absolute top-2 right-2 flex gap-1 group-focus-within/panel:visible group-hover/panel:visible"
v-if="isHovered || isFocused"
class="actions absolute top-2 right-2 flex gap-1"
>
<!-- Mask/Edit Button -->
<button
@@ -99,29 +76,21 @@
<i class="icon-[lucide--download] size-4" />
</button>
<!-- Back to Grid Button -->
<!-- Close Button -->
<button
v-if="hasMultipleImages"
:class="actionButtonClass"
:title="$t('g.viewGrid')"
:aria-label="$t('g.viewGrid')"
@click="viewMode = 'grid'"
:title="$t('g.removeImage')"
:aria-label="$t('g.removeImage')"
@click="handleRemove"
>
<i class="icon-[lucide--layout-grid] size-4" />
<i class="icon-[lucide--circle-x] size-4" />
</button>
</div>
</div>
<!-- Image Dimensions (gallery mode only) -->
<div
v-if="viewMode === 'gallery'"
class="pt-2 text-center text-xs text-base-foreground"
>
<span
v-if="imageError"
class="text-error"
data-testid="error-loading-image"
>
<!-- Image Dimensions -->
<div class="pt-2 text-center text-xs text-base-foreground">
<span v-if="imageError" class="text-red-400">
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="showLoader" class="text-base-foreground">
@@ -131,23 +100,11 @@
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
<!-- Multiple Images Navigation (gallery mode only) -->
<!-- Multiple Images Navigation -->
<div
v-if="viewMode === 'gallery' && hasMultipleImages"
class="flex flex-wrap items-center justify-center gap-1 pt-4"
v-if="hasMultipleImages"
class="flex flex-wrap justify-center gap-1 pt-4"
>
<!-- Back to Grid button -->
<button
class="mr-1 flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0.5 text-base-foreground/50 transition-colors hover:text-base-foreground"
:title="$t('g.viewGrid')"
:aria-label="$t('g.viewGrid')"
@click="viewMode = 'grid'"
>
<i class="icon-[lucide--layout-grid] size-3.5" />
</button>
<!-- Navigation Dots -->
<button
v-for="(_, index) in imageUrls"
:key="index"
@@ -167,7 +124,7 @@
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
@@ -185,7 +142,7 @@ interface ImagePreviewProps {
readonly nodeId?: string
}
const { imageUrls, nodeId } = defineProps<ImagePreviewProps>()
const props = defineProps<ImagePreviewProps>()
const { t } = useI18n()
const maskEditor = useMaskEditor()
@@ -195,19 +152,16 @@ const toastStore = useToastStore()
const actionButtonClass =
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
type ViewMode = 'gallery' | 'grid'
function defaultViewMode(urls: readonly string[]): ViewMode {
return urls.length > 1 ? 'grid' : 'gallery'
}
// Component state
const currentIndex = ref(0)
const viewMode = ref<ViewMode>(defaultViewMode(imageUrls))
const galleryPanelEl = ref<HTMLDivElement>()
const isHovered = ref(false)
const isFocused = ref(false)
const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const showLoader = ref(false)
const imageWrapperEl = ref<HTMLDivElement>()
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
() => {
showLoader.value = true
@@ -217,23 +171,14 @@ const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
{ immediate: false }
)
const currentImageUrl = computed(() => imageUrls[currentIndex.value] ?? '')
const hasMultipleImages = computed(() => imageUrls.length > 1)
const imageAltText = computed(() =>
t('g.viewImageOfTotal', {
index: currentIndex.value + 1,
total: imageUrls.length
})
)
const gridCols = computed(() => {
const count = imageUrls.length
if (count <= 4) return 2
if (count <= 9) return 3
return 4
})
// Computed values
const currentImageUrl = computed(() => props.imageUrls[currentIndex.value])
const hasMultipleImages = computed(() => props.imageUrls.length > 1)
const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
// Watch for URL changes and reset state
watch(
() => imageUrls,
() => props.imageUrls,
(newUrls, oldUrls) => {
// Only reset state if URLs actually changed (not just array reference)
const urlsChanged =
@@ -251,14 +196,14 @@ watch(
// Reset loading and error states when URLs change
actualDimensions.value = null
viewMode.value = defaultViewMode(newUrls)
imageError.value = false
if (newUrls.length > 0) startDelayedLoader()
},
{ immediate: true }
)
function handleImageLoad(event: Event) {
// Event handlers
const handleImageLoad = (event: Event) => {
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
stopDelayedLoader()
@@ -268,29 +213,29 @@ function handleImageLoad(event: Event) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
if (nodeId) {
nodeOutputStore.syncLegacyNodeImgs(nodeId, img, currentIndex.value)
if (props.nodeId) {
nodeOutputStore.syncLegacyNodeImgs(props.nodeId, img, currentIndex.value)
}
}
function handleImageError() {
const handleImageError = () => {
stopDelayedLoader()
showLoader.value = false
imageError.value = true
actualDimensions.value = null
}
function handleEditMask() {
if (!nodeId) return
const node = resolveNode(Number(nodeId))
const handleEditMask = () => {
if (!props.nodeId) return
const node = resolveNode(Number(props.nodeId))
if (!node) return
maskEditor.openMaskEditor(node)
}
function handleDownload() {
const handleDownload = () => {
try {
downloadFile(currentImageUrl.value)
} catch {
} catch (error) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
@@ -299,35 +244,46 @@ function handleDownload() {
}
}
function setCurrentIndex(index: number) {
const handleRemove = () => {
if (!props.nodeId) return
const node = resolveNode(Number(props.nodeId))
nodeOutputStore.removeNodeOutputs(props.nodeId)
if (node) {
node.imgs = undefined
const imageWidget = node.widgets?.find((w) => w.name === 'image')
if (imageWidget) {
imageWidget.value = ''
}
}
}
const setCurrentIndex = (index: number) => {
if (currentIndex.value === index) return
if (index >= 0 && index < imageUrls.length) {
const urlChanged = imageUrls[index] !== currentImageUrl.value
if (index >= 0 && index < props.imageUrls.length) {
const urlChanged = props.imageUrls[index] !== currentImageUrl.value
currentIndex.value = index
imageError.value = false
if (urlChanged) startDelayedLoader()
}
}
const CLICK_THRESHOLD = 3
let pointerStartPos = { x: 0, y: 0 }
function trackPointerStart(event: PointerEvent) {
pointerStartPos = { x: event.clientX, y: event.clientY }
const handleMouseEnter = () => {
isHovered.value = true
}
function handleGridThumbnailClick(event: MouseEvent, index: number) {
const dx = event.clientX - pointerStartPos.x
const dy = event.clientY - pointerStartPos.y
if (Math.abs(dx) > CLICK_THRESHOLD || Math.abs(dy) > CLICK_THRESHOLD) return
openImageInGallery(index)
const handleMouseLeave = () => {
isHovered.value = false
}
async function openImageInGallery(index: number) {
setCurrentIndex(index)
viewMode.value = 'gallery'
await nextTick()
galleryPanelEl.value?.focus()
const handleFocusIn = () => {
isFocused.value = true
}
const handleFocusOut = (event: FocusEvent) => {
// Only unfocus if focus is leaving the wrapper entirely
if (!imageWrapperEl.value?.contains(event.relatedTarget as Node)) {
isFocused.value = false
}
}
function getNavigationDotClass(index: number) {
@@ -339,30 +295,24 @@ function getNavigationDotClass(index: number) {
)
}
function handleKeyDown(event: KeyboardEvent) {
if (
event.key === 'Escape' &&
viewMode.value === 'gallery' &&
hasMultipleImages.value
) {
event.preventDefault()
viewMode.value = 'grid'
return
}
if (imageUrls.length <= 1 || viewMode.value === 'grid') return
const handleKeyDown = (event: KeyboardEvent) => {
if (props.imageUrls.length <= 1) return
switch (event.key) {
case 'ArrowLeft':
event.preventDefault()
setCurrentIndex(
currentIndex.value > 0 ? currentIndex.value - 1 : imageUrls.length - 1
currentIndex.value > 0
? currentIndex.value - 1
: props.imageUrls.length - 1
)
break
case 'ArrowRight':
event.preventDefault()
setCurrentIndex(
currentIndex.value < imageUrls.length - 1 ? currentIndex.value + 1 : 0
currentIndex.value < props.imageUrls.length - 1
? currentIndex.value + 1
: 0
)
break
case 'Home':
@@ -371,12 +321,12 @@ function handleKeyDown(event: KeyboardEvent) {
break
case 'End':
event.preventDefault()
setCurrentIndex(imageUrls.length - 1)
setCurrentIndex(props.imageUrls.length - 1)
break
}
}
function getImageFilename(url: string): string {
const getImageFilename = (url: string): string => {
if (!url) return t('g.imageDoesNotExist')
try {
return new URL(url).searchParams.get('filename') || t('g.unknownFile')

View File

@@ -304,7 +304,6 @@ import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeS
import { app } from '@/scripts/app'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isVideoOutput } from '@/utils/litegraphUtil'
@@ -356,7 +355,6 @@ const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
const { executing, progress } = useNodeExecutionState(nodeLocatorId)
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const hasExecutionError = computed(
() => executionErrorStore.lastExecutionErrorNodeId === nodeData.id
)
@@ -370,7 +368,7 @@ const hasAnyError = computed((): boolean => {
missingModelStore.hasMissingModelOnNode(nodeLocatorId.value) ||
(lgraphNode.value &&
(executionErrorStore.isContainerWithInternalError(lgraphNode.value) ||
missingNodesErrorStore.isContainerWithMissingNode(lgraphNode.value) ||
executionErrorStore.isContainerWithMissingNode(lgraphNode.value) ||
missingModelStore.isContainerWithMissingModel(lgraphNode.value)))
)
})

View File

@@ -28,7 +28,7 @@
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
:style="{ backgroundColor: headerColor }"
@click.stop="$emit('enterSubgraph')"
>
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
@@ -73,7 +73,6 @@
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
@@ -125,7 +124,7 @@
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
:style="{ backgroundColor: headerColor }"
@click.stop="$emit('enterSubgraph')"
>
<div class="flex size-full items-center justify-center gap-2">
@@ -146,7 +145,6 @@
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<div class="flex size-full items-center justify-center gap-2">
@@ -235,10 +233,6 @@ const getTabStyles = (isBackground = false) => {
)
}
const headerColorStyle = computed(() =>
props.headerColor ? { backgroundColor: props.headerColor } : undefined
)
// Case 1 context: Split widths
const errorTabWidth = 'w-[calc(50%+4px)]'
const enterTabFullWidth = 'w-[calc(100%+8px)]'

View File

@@ -1,8 +1,7 @@
import { onScopeDispose, toValue } from 'vue'
import { onScopeDispose, ref, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import { useClickDragGuard } from '@/composables/useClickDragGuard'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -29,7 +28,9 @@ export function useNodePointerInteractions(
let hasDraggingStarted = false
const dragGuard = useClickDragGuard(3)
const startPosition = ref({ x: 0, y: 0 })
const DRAG_THRESHOLD = 3 // pixels
function onPointerdown(event: PointerEvent) {
if (forwardMiddlePointerIfNeeded(event)) return
@@ -56,7 +57,7 @@ export function useNodePointerInteractions(
return
}
dragGuard.recordStart(event)
startPosition.value = { x: event.clientX, y: event.clientY }
safeDragStart(event, nodeId)
}
@@ -84,7 +85,11 @@ export function useNodePointerInteractions(
}
// Check if we should start dragging (pointer moved beyond threshold)
if (lmbDown && !layoutStore.isDraggingVueNodes.value) {
if (dragGuard.wasDragged(event)) {
const dx = event.clientX - startPosition.value.x
const dy = event.clientY - startPosition.value.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance > DRAG_THRESHOLD) {
layoutStore.isDraggingVueNodes.value = true
handleNodeSelect(event, nodeId)
}

Some files were not shown because too many files have changed in this diff Show More