Compare commits

..

16 Commits

Author SHA1 Message Date
Deep Mehta
d785a49320 Merge branch 'main' into refactor/model-node-mappings-data 2026-03-20 14:54:55 -07:00
pythongosssss
c90a5402b4 feat: App mode - double click to rename widget (#10341)
## Summary

Allows users to rename widgets by double clicking the label

## Changes

- **What**: Uses EditableText component to allow inline renaming

## Screenshots (if applicable)


https://github.com/user-attachments/assets/f5cbb908-14cf-4dfa-8eb2-1024284effef

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10341-feat-App-mode-double-click-to-rename-widget-3296d73d36508146bbccf8c29f56dc96)
by [Unito](https://www.unito.io)
2026-03-20 14:35:09 -07:00
pythongosssss
7501a3eefc fix: App mode - Widget dropdowns clipped in sidebar (#10338)
## Summary

Popover components for graph mode are appendTo self so scale/translate
works, however in the sidebar this causes them to be clipped by the
parent overflow. This adds a provide/inject flag to change these to be
appended to the body.

## Changes

- **What**: 
- add append to injection for overriding where popovers are mounted
- ensure dropdowns respect this flag
- extract enterAppModeWithInputs helper
- tests

Before:  
<img width="225" height="140" alt="image"
src="https://github.com/user-attachments/assets/bd83b0cd-49a9-45dd-8344-4c10221444fc"
/>

After:  
<img width="238" height="225" alt="image"
src="https://github.com/user-attachments/assets/286e28e9-b37d-4ffc-91a9-7c340757d3fc"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10338-fix-App-mode-Widget-dropdowns-clipped-in-sidebar-3296d73d365081e2ba38e3e82006d65e)
by [Unito](https://www.unito.io)
2026-03-20 14:18:54 -07:00
Deep Mehta
f15476e33f Merge branch 'refactor/model-node-mappings-data' of https://github.com/Comfy-Org/ComfyUI_frontend into refactor/model-node-mappings-data 2026-03-18 16:44:12 -07:00
Deep Mehta
114eeb3d3d refactor: move modelNodeMappings to src/platform/assets/mappings/
Move the data file to the assets platform directory per review feedback.
Update import path and CODEOWNERS entry accordingly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:43:35 -07:00
Deep Mehta
cb3a88a9e2 Merge branch 'main' into refactor/model-node-mappings-data 2026-03-18 16:40:59 -07:00
Deep Mehta
08845025c0 Merge branch 'refactor/model-node-mappings-data' of https://github.com/Comfy-Org/ComfyUI_frontend into refactor/model-node-mappings-data 2026-03-18 16:40:12 -07:00
Deep Mehta
9d9b3784a0 chore: add CODEOWNERS entry for model-to-node mappings
Add @deepme987 as code owner of modelNodeMappings.ts so model
backlink PRs can be reviewed and merged by the cloud team without
requiring frontend team approval.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:39:23 -07:00
Deep Mehta
18023c0ed1 Merge branch 'main' into refactor/model-node-mappings-data 2026-03-18 12:12:57 -07:00
GitHub Action
cc05ad2d34 [automated] Apply ESLint and Oxfmt fixes 2026-03-18 19:06:16 +00:00
Deep Mehta
b0f8b4c56a Update src/stores/modelNodeMappings.ts
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-18 12:03:29 -07:00
Deep Mehta
b3f01ac565 Merge branch 'main' into refactor/model-node-mappings-data 2026-03-18 11:13:40 -07:00
Deep Mehta
941620f485 Merge branch 'refactor/model-node-mappings-data' of https://github.com/Comfy-Org/ComfyUI_frontend into refactor/model-node-mappings-data 2026-03-18 10:45:05 -07:00
Deep Mehta
7ea5ea581b Merge remote-tracking branch 'origin/main' into refactor/model-node-mappings-data
# Conflicts:
#	src/stores/modelToNodeStore.ts
2026-03-18 10:44:18 -07:00
Deep Mehta
d92b9912a2 Merge branch 'main' into refactor/model-node-mappings-data 2026-03-18 09:56:06 -07:00
Deep Mehta
57c21d9467 refactor: extract model-to-node mappings into data file
Move all quickRegister() mapping data from modelToNodeStore.ts into a
separate modelNodeMappings.ts constants file. The store now iterates
over this data array instead of having 75 inline quickRegister() calls.

This makes adding new model-to-node mappings a pure data change (no
store logic touched), enabling separate CODEOWNERS for the data file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 21:15:52 -07:00
47 changed files with 2048 additions and 2150 deletions

View File

@@ -51,6 +51,9 @@
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Model-to-node mappings (cloud team)
/src/platform/assets/mappings/ @deepme987
# LLM Instructions (blank on purpose)
.claude/
.cursor/

View File

@@ -68,6 +68,41 @@ export class AppModeHelper {
await this.comfyPage.nextFrame()
}
/**
* Inject linearData into the current graph and enter app mode.
*
* Serializes the graph, injects linearData with the given inputs and
* auto-detected output node IDs, then reloads so the appModeStore
* picks up the data via its activeWorkflow watcher.
*
* @param inputs - Widget selections as [nodeId, widgetName] tuples
*/
async enterAppModeWithInputs(inputs: [string, string][]) {
await this.page.evaluate(async (inputTuples) => {
const graph = window.app!.graph
if (!graph) return
const outputNodeIds = graph.nodes
.filter(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)
.map((n: { id: number | string }) => String(n.id))
const workflow = graph.serialize() as unknown as Record<string, unknown>
const extra = (workflow.extra ?? {}) as Record<string, unknown>
extra.linearData = { inputs: inputTuples, outputs: outputNodeIds }
workflow.extra = extra
await window.app!.loadGraphData(
workflow as unknown as Parameters<
NonNullable<typeof window.app>['loadGraphData']
>[0]
)
}, inputs)
await this.comfyPage.nextFrame()
await this.toggleAppMode()
}
/** The linear-mode widget list container (visible in app mode). */
get linearWidgets(): Locator {
return this.page.locator('[data-testid="linear-widgets"]')
@@ -125,4 +160,42 @@ export class AppModeHelper {
await dialogInput.waitFor({ state: 'hidden' })
await this.comfyPage.nextFrame()
}
/**
* Rename a builder IoItem via the popover menu "Rename" action.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameBuilderInputViaMenu(title: string, newName: string) {
const menu = this.getBuilderInputItemMenu(title)
await menu.click()
await this.page.getByText('Rename', { exact: true }).click()
const input = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByRole('textbox')
await input.fill(newName)
await this.page.keyboard.press('Enter')
await this.comfyPage.nextFrame()
}
/**
* Rename a builder IoItem by double-clicking its title to trigger
* inline editing.
* @param title The current widget title shown in the IoItem.
* @param newName The new name to assign.
*/
async renameBuilderInput(title: string, newName: string) {
const titleEl = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.filter({ hasText: title })
await titleEl.dblclick()
const input = this.page
.getByTestId(TestIds.builder.ioItemTitle)
.getByRole('textbox')
await input.fill(newName)
await this.page.keyboard.press('Enter')
await this.comfyPage.nextFrame()
}
}

View File

@@ -3,9 +3,6 @@ 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) {}
@@ -16,26 +13,6 @@ 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,
@@ -58,7 +35,6 @@ export class QueueHelper {
})
})
await this.page.route('**/api/queue', this.queueRouteHandler)
await this.installJobsRoute()
}
/**
@@ -67,30 +43,6 @@ 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] = {
@@ -109,44 +61,6 @@ 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)
}
/**
@@ -161,9 +75,5 @@ 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

@@ -64,6 +64,7 @@ export const TestIds = {
},
builder: {
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',
widgetActionsMenu: 'widget-actions-menu'
},
breadcrumb: {

View File

@@ -0,0 +1,168 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
/**
* Default workflow widget inputs as [nodeId, widgetName] tuples.
* All widgets from the default graph are selected so the panel scrolls,
* pushing the last widget's dropdown to the clipping boundary.
*/
const DEFAULT_INPUTS: [string, string][] = [
['4', 'ckpt_name'],
['6', 'text'],
['7', 'text'],
['5', 'width'],
['5', 'height'],
['5', 'batch_size'],
['3', 'seed'],
['3', 'steps'],
['3', 'cfg'],
['3', 'sampler_name'],
['3', 'scheduler'],
['3', 'denoise'],
['9', 'filename_prefix']
]
function isClippedByAnyAncestor(el: Element): boolean {
const child = el.getBoundingClientRect()
let parent = el.parentElement
while (parent) {
const overflow = getComputedStyle(parent).overflow
if (overflow !== 'visible') {
const p = parent.getBoundingClientRect()
if (
child.top < p.top ||
child.bottom > p.bottom ||
child.left < p.left ||
child.right > p.right
) {
return true
}
}
parent = parent.parentElement
}
return false
}
/** Add a node to the graph by type and return its ID. */
async function addNode(page: Page, nodeType: string): Promise<string> {
return page.evaluate((type) => {
const node = window.app!.graph.add(
window.LiteGraph!.createNode(type, undefined, {})
)
return String(node!.id)
}, nodeType)
}
test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Select dropdown is not clipped in app mode panel', async ({
comfyPage
}) => {
const saveVideoId = await addNode(comfyPage.page, 'SaveVideo')
await comfyPage.nextFrame()
const inputs: [string, string][] = [
...DEFAULT_INPUTS,
[saveVideoId, 'codec']
]
await comfyPage.appMode.enterAppModeWithInputs(inputs)
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
timeout: 5000
})
// Scroll to bottom so the codec widget is at the clipping edge
const widgetList = comfyPage.appMode.linearWidgets
await widgetList.evaluate((el) =>
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
// Click the codec select (combobox role with aria-label from WidgetSelectDefault)
const codecSelect = widgetList.getByRole('combobox', { name: 'codec' })
await codecSelect.click()
const overlay = comfyPage.page.locator('.p-select-overlay').first()
await expect(overlay).toBeVisible({ timeout: 5000 })
const isInViewport = await overlay.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
)
})
expect(isInViewport).toBe(true)
const isClipped = await overlay.evaluate(isClippedByAnyAncestor)
expect(isClipped).toBe(false)
})
test('FormDropdown popup is not clipped in app mode panel', async ({
comfyPage
}) => {
const loadImageId = await addNode(comfyPage.page, 'LoadImage')
await comfyPage.nextFrame()
const inputs: [string, string][] = [
...DEFAULT_INPUTS,
[loadImageId, 'image']
]
await comfyPage.appMode.enterAppModeWithInputs(inputs)
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
timeout: 5000
})
// Scroll to bottom so the image widget is at the clipping edge
const widgetList = comfyPage.appMode.linearWidgets
await widgetList.evaluate((el) =>
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
// Click the FormDropdown trigger button for the image widget.
// The button emits 'select-click' which toggles the Popover.
const imageRow = widgetList.locator(
'div:has(> div > span:text-is("image"))'
)
const dropdownButton = imageRow.locator('button:has(> span)').first()
await dropdownButton.click()
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
const popover = comfyPage.page
.getByRole('dialog')
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
.first()
await expect(popover).toBeVisible({ timeout: 5000 })
const isInViewport = await popover.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
)
})
expect(isInViewport).toBe(true)
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
expect(isClipped).toBe(false)
})
})

View File

@@ -82,7 +82,9 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Rename from builder input-select sidebar', async ({ comfyPage }) => {
test('Rename from builder input-select sidebar via menu', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
@@ -91,11 +93,11 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
const menu = appMode.getBuilderInputItemMenu('seed')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.renameWidget(menu, 'Builder Input Seed')
await appMode.renameBuilderInputViaMenu('seed', 'Builder Input Seed')
// Verify in app mode after save/reload
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-input`
const workflowName = `${new Date().getTime()} builder-input-menu`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
@@ -104,6 +106,24 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
).toBeVisible()
})
test('Rename from builder input-select sidebar via double-click', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToInputs()
await appMode.renameBuilderInput('seed', 'Dblclick Seed')
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-input-dblclick`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('Dblclick Seed')).toBeVisible()
})
test('Rename from builder preview sidebar', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)

View File

@@ -1,5 +1,3 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
@@ -11,58 +9,10 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
await comfyPage.setup()
})
async function enterAppMode(comfyPage: {
page: Page
nextFrame: () => Promise<void>
}) {
// LinearControls requires hasOutputs to be true. Serialize the current
// graph, inject linearData with output node IDs, then reload so the
// appModeStore picks up the outputs via its activeWorkflow watcher.
await comfyPage.page.evaluate(async () => {
const graph = window.app!.graph
if (!graph) return
const outputNodeIds = graph.nodes
.filter(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)
.map((n: { id: number | string }) => String(n.id))
// Serialize, inject linearData, and reload to sync stores
const workflow = graph.serialize() as unknown as Record<string, unknown>
const extra = (workflow.extra ?? {}) as Record<string, unknown>
extra.linearData = { inputs: [], outputs: outputNodeIds }
workflow.extra = extra
await window.app!.loadGraphData(
workflow as unknown as Parameters<
NonNullable<typeof window.app>['loadGraphData']
>[0]
)
})
await comfyPage.nextFrame()
// Toggle to app mode via the command which sets canvasStore.linearMode
await comfyPage.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await comfyPage.nextFrame()
}
async function enterGraphMode(comfyPage: {
page: Page
nextFrame: () => Promise<void>
}) {
await comfyPage.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await comfyPage.nextFrame()
}
test('Displays linear controls when app mode active', async ({
comfyPage
}) => {
await enterAppMode(comfyPage)
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
@@ -70,7 +20,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
})
test('Run button visible in linear mode', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.locator('[data-testid="linear-run-button"]')
@@ -78,7 +28,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
})
test('Workflow info section visible', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
@@ -86,13 +36,13 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
})
test('Returns to graph mode', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible({ timeout: 5000 })
await enterGraphMode(comfyPage)
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
await expect(
@@ -101,7 +51,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
})
test('Canvas not visible in app mode', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')

View File

@@ -1,55 +0,0 @@
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

@@ -1,70 +0,0 @@
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

@@ -25,7 +25,7 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { promptRenameWidget } from '@/utils/widgetUtil'
import { renameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
@@ -63,7 +63,7 @@ const inputsWithState = computed(() =>
widgetName,
label: widget.label,
subLabel: node.title,
rename: () => promptRenameWidget(widget, node, t)
canRename: true
}
})
)
@@ -74,6 +74,16 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
])
)
function inlineRenameInput(
nodeId: NodeId,
widgetName: string,
newLabel: string
) {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node || !widget) return
renameWidget(widget, node, newLabel)
}
function getHovered(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
@@ -234,7 +244,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
widgetName,
label,
subLabel,
rename
canRename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="
@@ -242,7 +252,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:can-rename="canRename"
:remove="
() =>
remove(
@@ -250,6 +260,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
([id, name]) => nodeId == id && widgetName === name
)
"
@rename="inlineRenameInput(nodeId, widgetName, $event)"
/>
</DraggableList>
</PropertiesAccordionItem>

View File

@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
@@ -44,6 +45,7 @@ const appModeStore = useAppModeStore()
const maskEditor = useMaskEditor()
provide(HideLayoutFieldKey, true)
provide(OverlayAppendToKey, 'body')
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
useEventListener(

View File

@@ -2,31 +2,43 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const titleTooltip = ref<string | null>(null)
const subTitleTooltip = ref<string | null>(null)
const isEditing = ref(false)
function isTruncated(e: MouseEvent): boolean {
const el = e.currentTarget as HTMLElement
return el.scrollWidth > el.clientWidth
}
const { rename, remove } = defineProps<{
const { title, canRename, remove } = defineProps<{
title: string
subTitle?: string
rename?: () => void
canRename?: boolean
remove?: () => void
}>()
const emit = defineEmits<{
rename: [newName: string]
}>()
function onEditComplete(newName: string) {
isEditing.value = false
const trimmed = newName.trim()
if (trimmed && trimmed !== title) emit('rename', trimmed)
}
const entries = computed(() => {
const items = []
if (rename)
if (canRename)
items.push({
label: t('g.rename'),
command: rename,
command: () => setTimeout(() => (isEditing.value = true)),
icon: 'icon-[lucide--pencil]'
})
if (remove)
@@ -43,13 +55,24 @@ const entries = computed(() => {
class="my-2 flex items-center-safe gap-2 rounded-lg p-2"
data-testid="builder-io-item"
>
<div class="drag-handle mr-auto flex min-w-0 flex-col gap-1">
<div
v-tooltip.left="{ value: titleTooltip, showDelay: 300 }"
class="drag-handle truncate text-sm"
<div class="drag-handle mr-auto flex w-full min-w-0 flex-col gap-1">
<EditableText
:model-value="title"
:is-editing="isEditing"
:input-attrs="{ class: 'p-1' }"
:class="
cn(
'drag-handle h-5 text-sm',
isEditing && 'relative -top-0.5 -left-1 -mt-px mb-px -ml-px',
!isEditing && 'truncate'
)
"
data-testid="builder-io-item-title"
@mouseenter="titleTooltip = isTruncated($event) ? title : null"
v-text="title"
label-class="drag-handle"
label-type="div"
@dblclick="canRename && (isEditing = true)"
@edit="onEditComplete"
@cancel="isEditing = false"
/>
<div
v-tooltip.left="{ value: subTitleTooltip, showDelay: 300 }"

View File

@@ -1,81 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
const mountComponent = ({ closeOnScroll = false } = {}) =>
mount(ContextMenu, {
attachTo: document.body,
props: {
closeOnScroll,
contentClass: 'context-menu-content'
},
slots: {
default:
'<div class="scroll-container" style="max-height: 80px; overflow: auto"><button class="context-trigger" type="button">Trigger</button><div style="height: 200px" /></div>',
content:
'<div class="context-menu-content-inner" role="menuitem">Action</div>'
}
})
async function openMenu() {
const trigger = document.body.querySelector('.context-trigger')
if (!(trigger instanceof HTMLElement)) {
throw new Error('Context trigger element not found')
}
trigger.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
button: 2
})
)
await waitForMenuUpdate()
}
const isMenuVisible = () =>
document.body.querySelector('.context-menu-content-inner') !== null
const waitForMenuUpdate = async () => {
await nextTick()
await nextTick()
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('ContextMenu', () => {
it('opens from the slotted context-menu trigger', async () => {
const wrapper = mountComponent()
await openMenu()
expect(isMenuVisible()).toBe(true)
expect(
document.body.querySelectorAll('[role="menuitem"]').length
).toBeGreaterThan(0)
wrapper.unmount()
})
it('closes on descendant scroll when enabled', async () => {
const wrapper = mountComponent({ closeOnScroll: true })
await openMenu()
const scrollContainer = document.body.querySelector('.scroll-container')
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error('Scroll container not found')
}
scrollContainer.dispatchEvent(new Event('scroll'))
await waitForMenuUpdate()
expect(isMenuVisible()).toBe(false)
wrapper.unmount()
})
})

View File

@@ -1,88 +0,0 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import {
ContextMenuContent,
ContextMenuItem,
injectContextMenuRootContext,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuSeparator,
ContextMenuTrigger
} from 'reka-ui'
import { defineComponent } from 'vue'
defineOptions({
inheritAttrs: false
})
const {
contentClass,
collisionPadding = 8,
closeOnScroll = false
} = defineProps<{
contentClass?: string
collisionPadding?: number
closeOnScroll?: boolean
}>()
const ContextMenuContentProvider = defineComponent({
name: 'ContextMenuContentProvider',
props: {
closeOnScroll: {
type: Boolean,
default: false
}
},
setup(providerProps, { slots }) {
const rootContext = injectContextMenuRootContext()
function closeMenu() {
rootContext.onOpenChange(false)
}
useEventListener(
window,
'scroll',
() => {
if (providerProps.closeOnScroll) {
closeMenu()
}
},
{ capture: true, passive: true }
)
return () =>
slots.default?.({
close: closeMenu,
itemComponent: ContextMenuItem,
separatorComponent: ContextMenuSeparator
})
}
})
</script>
<template>
<ContextMenuRoot>
<ContextMenuTrigger as-child>
<slot />
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
:collision-padding="collisionPadding"
v-bind="$attrs"
:class="contentClass"
>
<ContextMenuContentProvider :close-on-scroll="closeOnScroll">
<template #default="{ close, itemComponent, separatorComponent }">
<slot
name="content"
:close="close"
:item-component="itemComponent"
:separator-component="separatorComponent"
/>
</template>
</ContextMenuContentProvider>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
</template>

View File

@@ -1,75 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
const mountComponent = ({ closeOnScroll = false } = {}) =>
mount(DropdownMenu, {
attachTo: document.body,
props: {
closeOnScroll,
contentClass: 'dropdown-menu-content'
},
slots: {
button:
'<div class="scroll-container" style="max-height: 80px; overflow: auto"><button class="dropdown-trigger" type="button">Trigger</button><div style="height: 200px" /></div>',
content:
'<div class="dropdown-menu-content-inner" role="menuitem">Action</div>'
}
})
async function openMenu() {
const trigger = document.body.querySelector('.dropdown-trigger')
if (!(trigger instanceof HTMLElement)) {
throw new Error('Dropdown trigger element not found')
}
trigger.click()
await waitForMenuUpdate()
}
const isMenuVisible = () =>
document.body.querySelector('.dropdown-menu-content-inner') !== null
const waitForMenuUpdate = async () => {
await nextTick()
await nextTick()
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('DropdownMenu', () => {
it('opens from the slotted trigger button', async () => {
const wrapper = mountComponent()
await openMenu()
expect(isMenuVisible()).toBe(true)
expect(
document.body.querySelectorAll('[role="menuitem"]').length
).toBeGreaterThan(0)
wrapper.unmount()
})
it('closes on descendant scroll when enabled', async () => {
const wrapper = mountComponent({ closeOnScroll: true })
await openMenu()
const scrollContainer = document.body.querySelector('.scroll-container')
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error('Scroll container not found')
}
scrollContainer.dispatchEvent(new Event('scroll'))
await waitForMenuUpdate()
expect(isMenuVisible()).toBe(false)
wrapper.unmount()
})
})

View File

@@ -1,13 +1,10 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, toValue } from 'vue'
@@ -21,23 +18,7 @@ defineOptions({
inheritAttrs: false
})
const open = defineModel<boolean>('open', { default: false })
const {
entries,
icon,
to,
itemClass: itemProp,
contentClass: contentProp,
buttonSize,
buttonClass,
align,
showArrow = true,
side = 'bottom',
sideOffset = 5,
collisionPadding = 10,
closeOnScroll = false
} = defineProps<{
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
entries?: MenuItem[]
icon?: string
to?: string | HTMLElement
@@ -45,12 +26,6 @@ const {
contentClass?: string
buttonSize?: ButtonVariants['size']
buttonClass?: string
align?: 'start' | 'center' | 'end'
showArrow?: boolean
side?: 'top' | 'right' | 'bottom' | 'left'
sideOffset?: number
collisionPadding?: number
closeOnScroll?: boolean
}>()
const itemClass = computed(() =>
@@ -66,25 +41,10 @@ const contentClass = computed(() =>
contentProp
)
)
function closeMenu() {
open.value = false
}
useEventListener(
window,
'scroll',
() => {
if (closeOnScroll) {
closeMenu()
}
},
{ capture: true, passive: true }
)
</script>
<template>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<slot name="button">
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
@@ -95,39 +55,22 @@ useEventListener(
<DropdownMenuPortal :to>
<DropdownMenuContent
:align
:side
:side-offset="sideOffset"
:collision-padding="collisionPadding"
side="bottom"
:side-offset="5"
:collision-padding="10"
v-bind="$attrs"
:class="contentClass"
>
<slot
name="content"
:close="closeMenu"
:item-class="itemClass"
:item-component="DropdownMenuItem"
:separator-component="DropdownMenuSeparator"
>
<slot
:close="closeMenu"
:item-class="itemClass"
:item-component="DropdownMenuItem"
:separator-component="DropdownMenuSeparator"
>
<DropdownItem
v-for="(item, index) in entries ?? []"
:key="toValue(item.label) ?? index"
:item-class
:content-class
:item
/>
</slot>
<slot :item-class>
<DropdownItem
v-for="(item, index) in entries ?? []"
:key="toValue(item.label) ?? index"
:item-class
:content-class
:item
/>
</slot>
<DropdownMenuArrow
v-if="showArrow"
class="fill-base-background stroke-border-subtle"
/>
<DropdownMenuArrow class="fill-base-background stroke-border-subtle" />
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>

View File

@@ -1,8 +1,8 @@
<template>
<div class="editable-text">
<span v-if="!isEditing">
<component :is="labelType" v-if="!isEditing" :class="labelClass">
{{ modelValue }}
</span>
</component>
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
<InputText
v-else
@@ -35,11 +35,15 @@ import { nextTick, ref, watch } from 'vue'
const {
modelValue,
isEditing = false,
inputAttrs = {}
inputAttrs = {},
labelClass = '',
labelType = 'span'
} = defineProps<{
modelValue: string
isEditing?: boolean
inputAttrs?: Record<string, string>
labelClass?: string
labelType?: string
}>()
const emit = defineEmits(['edit', 'cancel'])

View File

@@ -1,70 +0,0 @@
<template>
<div :class="panelClass">
<template v-for="entry in entries" :key="entry.key">
<component
:is="separatorComponent"
v-if="entry.kind === 'divider'"
:class="separatorWrapperClass"
>
<div :class="separatorClass" />
</component>
<component
:is="itemComponent"
v-else
as-child
:disabled="entry.disabled"
:text-value="entry.label"
@select="emit('action', entry)"
>
<Button
:variant="buttonVariant"
:size="buttonSize"
:class="buttonClass"
:disabled="entry.disabled"
>
<i v-if="entry.icon" :class="cn(entry.icon, iconClass)" />
<span :class="labelClass">{{ entry.label }}</span>
</Button>
</component>
</template>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import type { ButtonVariants } from '@/components/ui/button/button.variants'
import Button from '@/components/ui/button/Button.vue'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import { cn } from '@/utils/tailwindUtil'
const {
entries,
itemComponent,
separatorComponent,
panelClass,
separatorWrapperClass,
separatorClass,
buttonClass,
iconClass,
labelClass,
buttonVariant = 'secondary',
buttonSize = 'sm'
} = defineProps<{
entries: MenuEntry[]
itemComponent: Component
separatorComponent: Component
panelClass: string
separatorWrapperClass: string
separatorClass: string
buttonClass: string
iconClass: string
labelClass?: string
buttonVariant?: ButtonVariants['variant']
buttonSize?: ButtonVariants['size']
}>()
const emit = defineEmits<{
action: [entry: MenuActionEntry]
}>()
</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: () => ({ getJobMenuEntries: () => [] })
useJobMenu: () => ({ jobMenuEntries: [] })
}))
vi.mock('@/composables/useErrorHandling', () => ({
@@ -30,6 +30,10 @@ const JobAssetsListStub = {
template: '<div class="job-assets-list-stub" />'
}
const JobContextMenuStub = {
template: '<div />'
}
const createJob = (): JobListItem => ({
id: 'job-1',
title: 'Job 1',
@@ -52,7 +56,8 @@ const mountComponent = () =>
stubs: {
QueueOverlayHeader: QueueOverlayHeaderStub,
JobFiltersBar: JobFiltersBarStub,
JobAssetsList: JobAssetsListStub
JobAssetsList: JobAssetsListStub,
JobContextMenu: JobContextMenuStub
}
}
})

View File

@@ -23,28 +23,36 @@
<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 { MenuActionEntry } from '@/types/menuTypes'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
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'
@@ -70,9 +78,14 @@ 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 { getJobMenuEntries } = useJobMenu((item) => emit('viewItem', item))
const { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
(item) => emit('viewItem', item)
)
const onCancelItemEvent = (item: JobListItem) => {
emit('cancelItem', item)
@@ -82,9 +95,14 @@ const onDeleteItemEvent = (item: JobListItem) => {
emit('deleteItem', item)
}
const onJobMenuAction = wrapWithErrorHandlingAsync(
async (entry: MenuActionEntry) => {
if (entry.onClick) await entry.onClick()
}
)
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()
})
</script>

View File

@@ -3,20 +3,11 @@ 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: {
@@ -41,32 +32,43 @@ vi.mock('vue-i18n', () => {
}
})
const createPreviewOutput = (
const createResultItem = (
filename: string,
mediaType: string = 'images'
): JobPreviewOutput =>
({
): ResultItemImpl => {
const item = new ResultItemImpl({
filename,
mediaType,
isImage: mediaType === 'images',
isVideo: mediaType === 'video',
isAudio: mediaType === 'audio',
is3D: mediaType === 'model',
url: `/api/view/${filename}`
}) as JobPreviewOutput
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType
})
Object.defineProperty(item, 'url', {
get: () => `/api/view/${filename}`
})
return item
}
const createTaskRef = (preview?: JobPreviewOutput): JobListItem['taskRef'] =>
({
workflowId: 'workflow-1',
previewOutput: preview
}) as JobListItem['taskRef']
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 buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: createTaskRef(createPreviewOutput('job-1.png')),
taskRef: createTaskRef(createResultItem('job-1.png')),
...overrides
})
@@ -80,10 +82,7 @@ const mountJobAssetsList = (jobs: JobListItem[]) => {
]
return mount(JobAssetsList, {
props: {
displayedJobGroups,
getMenuEntries: () => []
},
props: { displayedJobGroups },
global: {
stubs: {
teleport: true,
@@ -148,7 +147,7 @@ describe('JobAssetsList', () => {
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createPreviewOutput('job-1.webm', 'video'))
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
})
const wrapper = mountJobAssetsList([job])
@@ -165,7 +164,7 @@ describe('JobAssetsList', () => {
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createPreviewOutput('job-1.glb', 'model'))
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
})
const wrapper = mountJobAssetsList([job])
@@ -180,7 +179,7 @@ describe('JobAssetsList', () => {
it('does not emit viewItem on double-click for non-completed jobs', async () => {
const job = buildJob({
state: 'running',
taskRef: createTaskRef(createPreviewOutput('job-1.png'))
taskRef: createTaskRef(createResultItem('job-1.png'))
})
const wrapper = mountJobAssetsList([job])

View File

@@ -15,98 +15,64 @@
@mouseenter="onJobEnter(job, $event)"
@mouseleave="onJobLeave(job.id)"
>
<ContextMenu
close-on-scroll
content-class="z-1700 bg-transparent p-0 font-inter shadow-lg"
<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
>
<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
>
<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"
:show-arrow="false"
content-class="z-1700 bg-transparent p-0 shadow-lg"
:side-offset="4"
:collision-padding="8"
close-on-scroll
@update:open="onActionsMenuOpenChange(job.id, $event)"
>
<template #button>
<Button
class="job-actions-menu-trigger"
variant="secondary"
size="icon"
:aria-label="t('g.more')"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
<template #content="{ itemComponent, separatorComponent }">
<MenuPanel
:entries="getMenuEntries(job)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="jobMenuPanelProps"
@action="onMenuAction($event)"
/>
</template>
</DropdownMenu>
</template>
</AssetsListItem>
<template #content="{ itemComponent, separatorComponent }">
<MenuPanel
:entries="getMenuEntries(job)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="jobMenuPanelProps"
@action="onMenuAction($event)"
/>
<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)"
>
<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>
</ContextMenu>
</AssetsListItem>
</div>
</div>
</div>
@@ -114,7 +80,7 @@
<Teleport to="body">
<div
v-if="activeDetails && popoverPosition"
class="job-details-popover fixed z-1700"
class="job-details-popover fixed z-50"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
@@ -134,13 +100,9 @@
import { useI18n } from 'vue-i18n'
import { nextTick, ref } from 'vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import MenuPanel from '@/components/common/MenuPanel.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 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'
@@ -148,32 +110,17 @@ import { cn } from '@/utils/tailwindUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { isActiveJobState } from '@/utils/queueUtil'
const { displayedJobGroups, getMenuEntries } = defineProps<{
displayedJobGroups: JobGroup[]
getMenuEntries: (item: JobListItem) => MenuEntry[]
}>()
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu-action', entry: MenuActionEntry): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
(e: 'viewItem', item: JobListItem): void
}>()
const jobMenuPanelProps = {
panelClass:
'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',
separatorWrapperClass: 'px-2 py-1',
separatorClass: 'h-px bg-interface-stroke',
buttonVariant: 'textonly',
buttonClass:
'w-full justify-start bg-transparent data-highlighted:bg-secondary-background-hover',
iconClass: 'block size-4 shrink-0 leading-none text-text-secondary'
} as const
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 {
@@ -205,7 +152,7 @@ function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
scheduleDetailsHide(jobId)
scheduleDetailsHide(jobId, clearPopoverAnchor)
}
function onJobEnter(job: JobListItem, event: MouseEvent) {
@@ -241,25 +188,6 @@ 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) {
emit('menu-action', entry)
}
function getPreviewOutput(job: JobListItem) {
return job.taskRef?.previewOutput
}
@@ -307,7 +235,7 @@ function onPopoverEnter() {
}
function onPopoverLeave() {
scheduleDetailsHide(activeDetails.value?.jobId)
scheduleDetailsHide(activeDetails.value?.jobId, clearPopoverAnchor)
}
function getJobIconClass(job: JobListItem): string | undefined {

View File

@@ -0,0 +1,195 @@
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

@@ -0,0 +1,118 @@
<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

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

View File

@@ -8,38 +8,16 @@
@approach-end="emit('approach-end')"
>
<template #item="{ item }">
<ContextMenu
close-on-scroll
content-class="z-1700 bg-transparent p-0 shadow-lg"
>
<MediaAssetCard
:asset="item.asset"
:selected="isSelected(item.asset.id)"
:show-output-count="showOutputCount(item.asset)"
:output-count="getOutputCount(item.asset)"
:show-delete-button
:selected-assets
:is-bulk-mode
@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)"
/>
<template #content="{ itemComponent, separatorComponent }">
<MenuPanel
:entries="getAssetMenuEntries(item.asset)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="mediaAssetMenuPanelProps"
@action="void onAssetMenuAction($event)"
/>
</template>
</ContextMenu>
<MediaAssetCard
:asset="item.asset"
:selected="isSelected(item.asset.id)"
:show-output-count="showOutputCount(item.asset)"
:output-count="getOutputCount(item.asset)"
@click="emit('select-asset', item.asset)"
@context-menu="emit('context-menu', $event, item.asset)"
@zoom="emit('zoom', item.asset)"
@output-count-click="emit('output-count-click', item.asset)"
/>
</template>
</VirtualGrid>
</div>
@@ -48,56 +26,24 @@
<script setup lang="ts">
import { computed } from 'vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
import MenuPanel from '@/components/common/MenuPanel.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import { useMediaAssetMenu } from '@/platform/assets/composables/useMediaAssetMenu'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { mediaAssetMenuPanelProps } from '@/platform/assets/components/mediaAssetMenuPanelConfig'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
const {
assets,
isSelected,
showOutputCount,
getOutputCount,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
const { assets, isSelected, showOutputCount, getOutputCount } = 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 { 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 }
@@ -108,21 +54,6 @@ 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,6 +1,5 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { defineComponent } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
@@ -8,9 +7,9 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import AssetsSidebarListView from './AssetsSidebarListView.vue'
vi.mock('@/platform/assets/composables/useMediaAssetMenu', () => ({
useMediaAssetMenu: () => ({
getMenuEntries: () => []
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
@@ -20,18 +19,7 @@ vi.mock('@/stores/assetsStore', () => ({
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: {
en: {}
}
})
const VirtualGridStub = {
const VirtualGridStub = defineComponent({
name: 'VirtualGrid',
props: {
items: {
@@ -41,41 +29,7 @@ const VirtualGridStub = {
},
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 /><slot name="content" v-bind="{ close: () => {}, itemComponent: \'div\', separatorComponent: \'div\' }" /></div>'
}
const DropdownMenuStub = {
name: 'DropdownMenu',
props: {
open: {
type: Boolean,
default: false
}
},
template:
'<div class="dropdown-menu-stub"><slot name="button" /><slot name="content" v-bind="{ close: () => {}, itemComponent: \'div\', separatorComponent: \'div\' }" /></div>'
}
const ButtonComponentStub = {
name: 'AppButton',
template: '<button class="button-stub" type="button"><slot /></button>'
}
const MenuPanelStub = {
name: 'MenuPanel',
template: '<div class="menu-panel-stub" />'
}
})
const buildAsset = (id: string, name: string): AssetItem =>
({
@@ -99,35 +53,12 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
toggleStack: async () => {}
},
global: {
plugins: [i18n],
stubs: {
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,
DropdownMenu: DropdownMenuStub,
MenuPanel: MenuPanelStub,
VirtualGrid: VirtualGridStub
}
}
})
describe('AssetsSidebarListView', () => {
it('marks mp4 assets as video previews', () => {
const videoAsset = {
@@ -200,46 +131,4 @@ 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,83 +16,49 @@
>
<i class="pi pi-trash text-xs" />
</LoadingOverlay>
<ContextMenu
close-on-scroll
content-class="z-1700 bg-transparent p-0 shadow-lg"
<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)"
>
<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)"
>
<template v-if="shouldShowActionsMenu(item.asset.id)" #actions>
<DropdownMenu
:open="openActionsAssetId === item.asset.id"
:show-arrow="false"
content-class="z-1700 bg-transparent p-0 shadow-lg"
:side-offset="4"
:collision-padding="8"
close-on-scroll
@update:open="onActionsMenuOpenChange(item.asset.id, $event)"
>
<template #button>
<Button
variant="secondary"
size="icon"
:aria-label="t('mediaAsset.actions.moreOptions')"
@click.stop
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
<template #content="{ itemComponent, separatorComponent }">
<MenuPanel
:entries="getAssetMenuEntries(item.asset)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="mediaAssetMenuPanelProps"
@action="void onAssetMenuAction($event)"
/>
</template>
</DropdownMenu>
</template>
</AssetsListItem>
<template #content="{ itemComponent, separatorComponent }">
<MenuPanel
:entries="getAssetMenuEntries(item.asset)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="mediaAssetMenuPanelProps"
@action="void onAssetMenuAction($event)"
/>
<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)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</ContextMenu>
</AssetsListItem>
</div>
</template>
</VirtualGrid>
@@ -103,23 +69,16 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContextMenu from '@/components/common/ContextMenu.vue'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import MenuPanel from '@/components/common/MenuPanel.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.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 { mediaAssetMenuPanelProps } from '@/platform/assets/components/mediaAssetMenuPanelConfig'
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,
@@ -133,19 +92,13 @@ const {
selectableAssets,
isSelected,
isStackExpanded,
toggleStack,
showDeleteButton,
selectedAssets,
isBulkMode
toggleStack
} = 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()
@@ -153,27 +106,12 @@ 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',
@@ -190,17 +128,6 @@ 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'
}
@@ -253,27 +180,6 @@ 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,18 +94,10 @@
: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
@@ -114,16 +106,8 @@
: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"
@@ -190,6 +174,24 @@
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">
@@ -198,12 +200,14 @@ import {
useDebounceFn,
useElementHover,
useResizeObserver,
useStorage
useStorage,
useTimeoutFn
} from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import {
computed,
defineAsyncComponent,
nextTick,
onMounted,
onUnmounted,
ref,
@@ -220,7 +224,9 @@ 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'
@@ -230,6 +236,7 @@ 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'
@@ -260,6 +267,9 @@ 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(() => {
@@ -267,6 +277,14 @@ 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
@@ -484,6 +502,26 @@ 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,76 +1,78 @@
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { computed, defineComponent, ref } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import JobHistorySidebarTab from './JobHistorySidebarTab.vue'
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',
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {
displayedJobGroups: {
type: Array,
required: true
},
getMenuEntries: {
type: Function,
required: true
}
jobId: { type: String, required: true },
workflowId: { type: String, default: undefined }
},
template: '<div class="job-assets-list-stub" />'
template: '<div class="job-details-popover-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/useJobList', async () => {
const { ref } = await import('vue')
const jobHistoryItem = {
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: {
workflowId: 'workflow-1',
previewOutput: {
isImage: true,
isVideo: false,
url: '/api/view/job-1.png'
}
}
}
return {
useJobList: () => ({
selectedJobTab: ref('All'),
selectedWorkflowFilter: ref('all'),
selectedSortMode: ref('mostRecent'),
searchQuery: ref(''),
hasFailedJobs: ref(false),
filteredTasks: ref([]),
groupedJobItems: ref([
{
key: 'group-1',
label: 'Group 1',
items: [jobHistoryItem]
}
])
})
}
})
vi.mock('@/composables/queue/useJobMenu', () => ({
useJobMenu: () => ({
getJobMenuEntries: testState.getJobMenuEntries,
cancelJob: testState.cancelJob
jobMenuEntries: [],
cancelJob: vi.fn()
})
}))
vi.mock('@/composables/queue/useQueueClearHistoryDialog', () => ({
useQueueClearHistoryDialog: () => ({
showQueueClearHistoryDialog: testState.showQueueClearHistoryDialog
showQueueClearHistoryDialog: vi.fn()
})
}))
vi.mock('@/composables/queue/useResultGallery', () => ({
useResultGallery: () => ({
galleryActiveIndex: ref(-1),
galleryItems: ref([]),
onViewItem: testState.openResultGallery
})
}))
vi.mock('@/composables/queue/useResultGallery', async () => {
const { ref } = await import('vue')
return {
useResultGallery: () => ({
galleryActiveIndex: ref(-1),
galleryItems: ref([]),
onViewItem: vi.fn()
})
}
})
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
@@ -82,19 +84,19 @@ vi.mock('@/composables/useErrorHandling', () => ({
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: testState.commandExecute
execute: vi.fn()
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
showDialog: testState.showDialog
showDialog: vi.fn()
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
clearInitializationByJobIds: testState.clearInitializationByJobIds
clearInitializationByJobIds: vi.fn()
})
}))
@@ -102,7 +104,7 @@ vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => ({
runningTasks: [],
pendingTasks: [],
delete: testState.queueDelete
delete: vi.fn()
})
}))
@@ -112,33 +114,11 @@ const i18n = createI18n({
messages: { en: {} }
})
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
}
]
const SidebarTabTemplateStub = {
name: 'SidebarTabTemplate',
props: ['title'],
template:
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
}
function mountComponent() {
@@ -146,106 +126,38 @@ function mountComponent() {
global: {
plugins: [i18n],
stubs: {
SidebarTabTemplate: {
name: 'SidebarTabTemplate',
template:
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
},
SidebarTabTemplate: SidebarTabTemplateStub,
JobFilterTabs: true,
JobFilterActions: true,
JobHistoryActionsMenu: true,
MediaLightbox: true,
JobAssetsList: JobAssetsListStub,
teleport: true
JobContextMenu: true,
ResultGallery: true,
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub
}
}
})
}
afterEach(() => {
vi.useRealTimers()
})
describe('JobHistorySidebarTab', () => {
beforeEach(() => {
vi.clearAllMocks()
setDisplayedJobs([buildJob()])
})
it('passes grouped jobs and menu getter to JobAssetsList', () => {
it('shows the job details popover for jobs in the history panel', async () => {
vi.useFakeTimers()
const wrapper = mountComponent()
const jobAssetsList = wrapper.findComponent(JobAssetsListStub)
const jobRow = wrapper.find('[data-job-id="job-1"]')
expect(jobAssetsList.props('displayedJobGroups')).toEqual(
testState.groupedJobItems
)
expect(jobAssetsList.props('getMenuEntries')).toBe(
testState.getJobMenuEntries
)
})
await jobRow.trigger('mouseenter')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
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']
const popover = wrapper.findComponent(JobDetailsPopoverStub)
expect(popover.exists()).toBe(true)
expect(popover.props()).toMatchObject({
jobId: 'job-1',
workflowId: 'workflow-1'
})
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,11 +48,15 @@
<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"
@@ -63,14 +67,15 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent } from 'vue'
import { computed, defineAsyncComponent, ref } 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 { MenuActionEntry } from '@/types/menuTypes'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
@@ -177,7 +182,13 @@ const onInspectAsset = (item: JobListItem) => {
void onViewItem(item)
}
const { getJobMenuEntries, cancelJob } = useJobMenu(onInspectAsset)
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { jobMenuEntries, cancelJob } = useJobMenu(
() => currentMenuItem.value,
onInspectAsset
)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
await cancelJob(item)
@@ -188,9 +199,14 @@ const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
await queueStore.delete(item.taskRef)
})
const onJobMenuAction = wrapWithErrorHandlingAsync(
async (entry: MenuActionEntry) => {
if (entry.onClick) await entry.onClick()
}
)
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()
})
</script>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,108 @@
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

@@ -0,0 +1,60 @@
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

@@ -1,5 +1,6 @@
import type { HintedString } from '@primevue/core'
import { computed } from 'vue'
import type { InjectionKey } from 'vue'
import { computed, inject } from 'vue'
/**
* Options for configuring transform-compatible overlay props
@@ -15,6 +16,10 @@ interface TransformCompatOverlayOptions {
// autoZIndex?: boolean
}
export const OverlayAppendToKey: InjectionKey<
HintedString<'body' | 'self'> | undefined | HTMLElement
> = Symbol('OverlayAppendTo')
/**
* Composable that provides props to make PrimeVue overlay components
* compatible with CSS-transformed parent elements.
@@ -41,8 +46,10 @@ interface TransformCompatOverlayOptions {
export function useTransformCompatOverlayProps(
overrides: TransformCompatOverlayOptions = {}
) {
const injectedAppendTo = inject(OverlayAppendToKey, undefined)
return computed(() => ({
appendTo: 'self' as const,
appendTo: injectedAppendTo ?? ('self' as const),
...overrides
}))
}

View File

@@ -23,6 +23,9 @@
: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 -->
@@ -66,35 +69,16 @@
>
<i class="icon-[lucide--zoom-in] size-4" />
</Button>
<DropdownMenu
v-if="asset"
v-model:open="isActionsMenuOpen"
:show-arrow="false"
content-class="z-1700 bg-transparent p-0 shadow-lg"
:side-offset="4"
:collision-padding="8"
close-on-scroll
<Button
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.moreOptions')"
@click.stop="
asset ? emit('context-menu', $event, asset) : undefined
"
>
<template #button>
<Button
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.moreOptions')"
@click.stop
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
<template #content="{ itemComponent, separatorComponent }">
<MenuPanel
:entries="getAssetMenuEntries()"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="mediaAssetMenuPanelProps"
@action="void onAssetMenuAction($event)"
/>
</template>
</DropdownMenu>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</IconGroup>
</div>
</div>
@@ -155,12 +139,9 @@ import { useElementHover } from '@vueuse/core'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.vue'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import MenuPanel from '@/components/common/MenuPanel.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAssetsStore } from '@/stores/assetsStore'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import {
formatDuration,
formatSize,
@@ -173,13 +154,11 @@ 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 MediaTitle from './MediaTitle.vue'
import { mediaAssetMenuPanelProps } from './mediaAssetMenuPanelConfig'
type PreviewKind = ReturnType<typeof getMediaTypeFromFilename>
@@ -198,24 +177,12 @@ function getTopComponent(kind: PreviewKind) {
return mediaComponents.top[kind] || mediaComponents.top.other
}
const {
asset,
loading,
selected,
showOutputCount,
outputCount,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
asset?: AssetItem
loading?: boolean
selected?: boolean
showOutputCount?: boolean
outputCount?: number
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const assetsStore = useAssetsStore()
@@ -229,19 +196,13 @@ const emit = defineEmits<{
click: []
zoom: [asset: AssetItem]
'output-count-click': []
'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[]]
'context-menu': [event: MouseEvent, asset: 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>()
@@ -249,15 +210,6 @@ 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(() => {
@@ -338,12 +290,7 @@ const metaInfo = computed(() => {
const showActionsOverlay = computed(() => {
if (loading || !asset || isDeleting.value) return false
return (
isHovered.value ||
selected ||
isVideoPlaying.value ||
isActionsMenuOpen.value
)
return isHovered.value || selected || isVideoPlaying.value
})
const handleZoomClick = () => {
@@ -359,26 +306,6 @@ 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

@@ -0,0 +1,143 @@
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

@@ -0,0 +1,286 @@
<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

@@ -1,8 +0,0 @@
export const mediaAssetMenuPanelProps = {
panelClass:
'media-asset-menu-panel flex min-w-56 flex-col rounded-lg border border-border-subtle bg-secondary-background p-2 text-base-foreground',
separatorWrapperClass: 'm-1',
separatorClass: 'h-px bg-border-subtle',
buttonClass: 'w-full justify-start',
iconClass: 'size-4'
} as const

View File

@@ -1,260 +0,0 @@
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 { 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, assetType)
)
const allSelectedSupportWorkflowActions = selectedAssets.every(
(selectedAsset) => canShowWorkflowActions(selectedAsset, assetType)
)
const bulkDeleteEnabled = canDeleteAsset(assetType, 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

@@ -0,0 +1,174 @@
/**
* Default mappings from model directories to loader nodes.
*
* Each entry maps a model folder (as it appears in the model browser)
* to the node class that loads models from that folder and the
* input key where the model name is inserted.
*
* An empty key ('') means the node auto-loads models without a widget
* selector (createModelNodeFromAsset skips widget assignment).
*
* Hierarchical fallback is handled by the store: "a/b/c" tries
* "a/b/c" → "a/b" → "a", so registering a parent directory covers
* all its children unless a more specific entry exists.
*
* Format: [modelDirectory, nodeClass, inputKey]
*/
export const MODEL_NODE_MAPPINGS: ReadonlyArray<
readonly [string, string, string]
> = [
// ---- ComfyUI core loaders ----
['checkpoints', 'CheckpointLoaderSimple', 'ckpt_name'],
['checkpoints', 'ImageOnlyCheckpointLoader', 'ckpt_name'],
['loras', 'LoraLoader', 'lora_name'],
['loras', 'LoraLoaderModelOnly', 'lora_name'],
['vae', 'VAELoader', 'vae_name'],
['controlnet', 'ControlNetLoader', 'control_net_name'],
['diffusion_models', 'UNETLoader', 'unet_name'],
['upscale_models', 'UpscaleModelLoader', 'model_name'],
['style_models', 'StyleModelLoader', 'style_model_name'],
['gligen', 'GLIGENLoader', 'gligen_name'],
['clip_vision', 'CLIPVisionLoader', 'clip_name'],
['text_encoders', 'CLIPLoader', 'clip_name'],
['audio_encoders', 'AudioEncoderLoader', 'audio_encoder_name'],
['model_patches', 'ModelPatchLoader', 'name'],
['latent_upscale_models', 'LatentUpscaleModelLoader', 'model_name'],
['clip', 'CLIPVisionLoader', 'clip_name'],
// ---- AnimateDiff (comfyui-animatediff-evolved) ----
['animatediff_models', 'ADE_LoadAnimateDiffModel', 'model_name'],
['animatediff_motion_lora', 'ADE_AnimateDiffLoRALoader', 'name'],
// ---- Chatterbox TTS (ComfyUI-Fill-Nodes) ----
['chatterbox/chatterbox', 'FL_ChatterboxTTS', ''],
['chatterbox/chatterbox_turbo', 'FL_ChatterboxTurboTTS', ''],
['chatterbox/chatterbox_multilingual', 'FL_ChatterboxMultilingualTTS', ''],
['chatterbox/chatterbox_vc', 'FL_ChatterboxVC', ''],
// ---- SAM / SAM2 (comfyui-segment-anything-2, comfyui-impact-pack) ----
['sam2', 'DownloadAndLoadSAM2Model', 'model'],
['sams', 'SAMLoader', 'model_name'],
// ---- SAM3 3D segmentation (comfyui-sam3) ----
['sam3', 'LoadSAM3Model', 'model_path'],
// ---- Ultralytics detection (comfyui-impact-subpack) ----
['ultralytics', 'UltralyticsDetectorProvider', 'model_name'],
// ---- DepthAnything (comfyui-depthanythingv2, comfyui-depthanythingv3) ----
['depthanything', 'DownloadAndLoadDepthAnythingV2Model', 'model'],
['depthanything3', 'DownloadAndLoadDepthAnythingV3Model', 'model'],
// ---- IP-Adapter (comfyui_ipadapter_plus) ----
['ipadapter', 'IPAdapterModelLoader', 'ipadapter_file'],
// ---- Segformer (comfyui_layerstyle) ----
['segformer_b2_clothes', 'LS_LoadSegformerModel', 'model_name'],
['segformer_b3_clothes', 'LS_LoadSegformerModel', 'model_name'],
['segformer_b3_fashion', 'LS_LoadSegformerModel', 'model_name'],
// ---- NLF pose estimation (ComfyUI-WanVideoWrapper) ----
['nlf', 'LoadNLFModel', 'nlf_model'],
// ---- FlashVSR video super-resolution (ComfyUI-FlashVSR_Ultra_Fast) ----
['FlashVSR', 'FlashVSRNode', ''],
['FlashVSR-v1.1', 'FlashVSRNode', ''],
// ---- SEEDVR2 video upscaling (comfyui-seedvr2) ----
['SEEDVR2', 'SeedVR2LoadDiTModel', 'model'],
// ---- Qwen VL vision-language (comfyui-qwen-vl) ----
['LLM/Qwen-VL/Qwen2.5-VL-3B-Instruct', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen2.5-VL-7B-Instruct', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-VL-2B-Instruct', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-VL-2B-Thinking', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-VL-4B-Instruct', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-VL-4B-Thinking', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-VL-8B-Instruct', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-VL-8B-Thinking', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-VL-32B-Instruct', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-VL-32B-Thinking', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-0.6B', 'AILab_QwenVL_PromptEnhancer', 'model_name'],
[
'LLM/Qwen-VL/Qwen3-4B-Instruct-2507',
'AILab_QwenVL_PromptEnhancer',
'model_name'
],
['LLM/checkpoints', 'LoadChatGLM3', 'chatglm3_checkpoint'],
// ---- Qwen3 TTS (ComfyUI-FunBox) ----
['qwen-tts', 'FB_Qwen3TTSVoiceClone', 'model_choice'],
// ---- LivePortrait (comfyui-liveportrait) ----
['liveportrait', 'DownloadAndLoadLivePortraitModels', ''],
// ---- MimicMotion (ComfyUI-MimicMotionWrapper) ----
['mimicmotion', 'DownloadAndLoadMimicMotionModel', 'model'],
['dwpose', 'MimicMotionGetPoses', ''],
// ---- Face parsing (comfyui_face_parsing) ----
['face_parsing', 'FaceParsingModelLoader(FaceParsing)', ''],
// ---- Kolors (ComfyUI-KolorsWrapper) ----
['diffusers', 'DownloadAndLoadKolorsModel', 'model'],
// ---- RIFE video frame interpolation (ComfyUI-RIFE) ----
['rife', 'RIFE VFI', 'ckpt_name'],
// ---- UltraShape 3D model generation ----
['UltraShape', 'UltraShapeLoadModel', 'checkpoint'],
// ---- SHaRP depth estimation ----
['sharp', 'LoadSharpModel', 'checkpoint_path'],
// ---- ONNX upscale models ----
['onnx', 'UpscaleModelLoader', 'model_name'],
// ---- Detection models (vitpose, yolo) ----
['detection', 'OnnxDetectionModelLoader', 'yolo_model'],
// ---- HunyuanVideo text encoders (ComfyUI-HunyuanVideoWrapper) ----
[
'LLM/llava-llama-3-8b-text-encoder-tokenizer',
'DownloadAndLoadHyVideoTextEncoder',
'llm_model'
],
[
'LLM/llava-llama-3-8b-v1_1-transformers',
'DownloadAndLoadHyVideoTextEncoder',
'llm_model'
],
// ---- CogVideoX (comfyui-cogvideoxwrapper) ----
['CogVideo', 'DownloadAndLoadCogVideoModel', ''],
['CogVideo/GGUF', 'DownloadAndLoadCogVideoGGUFModel', 'model'],
['CogVideo/ControlNet', 'DownloadAndLoadCogVideoControlNet', ''],
// ---- DynamiCrafter (ComfyUI-DynamiCrafterWrapper) ----
['checkpoints/dynamicrafter', 'DownloadAndLoadDynamiCrafterModel', 'model'],
[
'checkpoints/dynamicrafter/controlnet',
'DownloadAndLoadDynamiCrafterCNModel',
'model'
],
// ---- LayerStyle (ComfyUI_LayerStyle_Advance) ----
['BEN', 'LS_LoadBenModel', 'model'],
['BiRefNet/pth', 'LS_LoadBiRefNetModel', 'model'],
['onnx/human-parts', 'LS_HumanPartsUltra', ''],
['lama', 'LaMa', 'lama_model'],
// ---- Inpaint (comfyui-inpaint-nodes) ----
['inpaint', 'INPAINT_LoadInpaintModel', 'model_name'],
// ---- LayerDiffuse (comfyui-layerdiffuse) ----
['layer_model', 'LayeredDiffusionApply', 'config'],
// ---- LTX Video prompt enhancer (ComfyUI-LTXTricks) ----
['LLM/Llama-3.2-3B-Instruct', 'LTXVPromptEnhancerLoader', 'llm_name'],
[
'LLM/Florence-2-large-PromptGen-v2.0',
'LTXVPromptEnhancerLoader',
'image_captioner_name'
]
] as const satisfies ReadonlyArray<readonly [string, string, string]>

View File

@@ -4,6 +4,7 @@ import Popover from 'primevue/popover'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type {
@@ -50,6 +51,7 @@ interface Props {
}
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
const {
placeholder,
@@ -209,6 +211,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
ref="popoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {

View File

@@ -4,6 +4,7 @@ import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type {
FilterOption,
OwnershipFilterOption,
@@ -15,6 +16,7 @@ import FormSearchInput from '../FormSearchInput.vue'
import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
defineProps<{
sortOptions: SortOption[]
@@ -132,6 +134,7 @@ function toggleBaseModelSelection(item: FilterOption) {
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {
@@ -194,6 +197,7 @@ function toggleBaseModelSelection(item: FilterOption) {
ref="ownershipPopoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {
@@ -256,6 +260,7 @@ function toggleBaseModelSelection(item: FilterOption) {
ref="baseModelPopoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { MODEL_NODE_MAPPINGS } from '@/platform/assets/mappings/modelNodeMappings'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -156,254 +157,9 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
}
haveDefaultsLoaded.value = true
quickRegister('checkpoints', 'CheckpointLoaderSimple', 'ckpt_name')
quickRegister('checkpoints', 'ImageOnlyCheckpointLoader', 'ckpt_name')
quickRegister('loras', 'LoraLoader', 'lora_name')
quickRegister('loras', 'LoraLoaderModelOnly', 'lora_name')
quickRegister('vae', 'VAELoader', 'vae_name')
quickRegister('controlnet', 'ControlNetLoader', 'control_net_name')
quickRegister('diffusion_models', 'UNETLoader', 'unet_name')
quickRegister('upscale_models', 'UpscaleModelLoader', 'model_name')
quickRegister('style_models', 'StyleModelLoader', 'style_model_name')
quickRegister('gligen', 'GLIGENLoader', 'gligen_name')
quickRegister('clip_vision', 'CLIPVisionLoader', 'clip_name')
quickRegister('text_encoders', 'CLIPLoader', 'clip_name')
quickRegister('audio_encoders', 'AudioEncoderLoader', 'audio_encoder_name')
quickRegister('model_patches', 'ModelPatchLoader', 'name')
quickRegister(
'animatediff_models',
'ADE_LoadAnimateDiffModel',
'model_name'
)
quickRegister(
'animatediff_motion_lora',
'ADE_AnimateDiffLoRALoader',
'name'
)
// Chatterbox TTS nodes: empty key means the node auto-loads models without
// a widget selector (createModelNodeFromAsset skips widget assignment)
quickRegister('chatterbox/chatterbox', 'FL_ChatterboxTTS', '')
quickRegister('chatterbox/chatterbox_turbo', 'FL_ChatterboxTurboTTS', '')
quickRegister(
'chatterbox/chatterbox_multilingual',
'FL_ChatterboxMultilingualTTS',
''
)
quickRegister('chatterbox/chatterbox_vc', 'FL_ChatterboxVC', '')
// Latent upscale models (ComfyUI core - nodes_hunyuan.py)
quickRegister(
'latent_upscale_models',
'LatentUpscaleModelLoader',
'model_name'
)
// SAM/SAM2 segmentation models (comfyui-segment-anything-2, comfyui-impact-pack)
quickRegister('sam2', 'DownloadAndLoadSAM2Model', 'model')
quickRegister('sams', 'SAMLoader', 'model_name')
// Ultralytics detection models (comfyui-impact-subpack)
// Note: ultralytics/bbox and ultralytics/segm fall back to this via hierarchical lookup
quickRegister('ultralytics', 'UltralyticsDetectorProvider', 'model_name')
// DepthAnything models (comfyui-depthanythingv2)
quickRegister(
'depthanything',
'DownloadAndLoadDepthAnythingV2Model',
'model'
)
// IP-Adapter models (comfyui_ipadapter_plus)
quickRegister('ipadapter', 'IPAdapterModelLoader', 'ipadapter_file')
// Segformer clothing/fashion segmentation models (comfyui_layerstyle)
quickRegister('segformer_b2_clothes', 'LS_LoadSegformerModel', 'model_name')
quickRegister('segformer_b3_clothes', 'LS_LoadSegformerModel', 'model_name')
quickRegister('segformer_b3_fashion', 'LS_LoadSegformerModel', 'model_name')
// NLF pose estimation models (ComfyUI-WanVideoWrapper)
quickRegister('nlf', 'LoadNLFModel', 'nlf_model')
// FlashVSR video super-resolution (ComfyUI-FlashVSR_Ultra_Fast)
// Empty key means the node auto-loads models without a widget selector
quickRegister('FlashVSR', 'FlashVSRNode', '')
quickRegister('FlashVSR-v1.1', 'FlashVSRNode', '')
// SEEDVR2 video upscaling (comfyui-seedvr2)
quickRegister('SEEDVR2', 'SeedVR2LoadDiTModel', 'model')
// Qwen VL vision-language models (comfyui-qwen-vl)
// Register each specific path to avoid LLM fallback catching unrelated models
// (e.g., LLM/llava-* should NOT map to AILab_QwenVL)
quickRegister(
'LLM/Qwen-VL/Qwen2.5-VL-3B-Instruct',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen2.5-VL-7B-Instruct',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-VL-2B-Instruct',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-VL-2B-Thinking',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-VL-4B-Instruct',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-VL-4B-Thinking',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-VL-8B-Instruct',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-VL-8B-Thinking',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-VL-32B-Instruct',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-VL-32B-Thinking',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-0.6B',
'AILab_QwenVL_PromptEnhancer',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-4B-Instruct-2507',
'AILab_QwenVL_PromptEnhancer',
'model_name'
)
quickRegister('LLM/checkpoints', 'LoadChatGLM3', 'chatglm3_checkpoint')
// Qwen3 TTS speech models (ComfyUI-FunBox)
// Top-level 'qwen-tts' catches all qwen-tts/* subdirs via hierarchical fallback
quickRegister('qwen-tts', 'FB_Qwen3TTSVoiceClone', 'model_choice')
// DepthAnything V3 models (comfyui-depthanythingv2)
quickRegister(
'depthanything3',
'DownloadAndLoadDepthAnythingV3Model',
'model'
)
// LivePortrait face animation models (comfyui-liveportrait)
quickRegister('liveportrait', 'DownloadAndLoadLivePortraitModels', '')
// MimicMotion video generation models (ComfyUI-MimicMotionWrapper)
quickRegister('mimicmotion', 'DownloadAndLoadMimicMotionModel', 'model')
quickRegister('dwpose', 'MimicMotionGetPoses', '')
// Face parsing segmentation models (comfyui_face_parsing)
quickRegister('face_parsing', 'FaceParsingModelLoader(FaceParsing)', '')
// Kolors image generation models (ComfyUI-KolorsWrapper)
// Top-level 'diffusers' catches diffusers/Kolors/* subdirs
quickRegister('diffusers', 'DownloadAndLoadKolorsModel', 'model')
// CLIP models for HunyuanVideo (clip/clip-vit-large-patch14 subdir)
quickRegister('clip', 'CLIPVisionLoader', 'clip_name')
// RIFE video frame interpolation (ComfyUI-RIFE)
quickRegister('rife', 'RIFE VFI', 'ckpt_name')
// SAM3 3D segmentation models (comfyui-sam3)
quickRegister('sam3', 'LoadSAM3Model', 'model_path')
// UltraShape 3D model generation
quickRegister('UltraShape', 'UltraShapeLoadModel', 'checkpoint')
// SHaRP depth estimation
quickRegister('sharp', 'LoadSharpModel', 'checkpoint_path')
// ONNX upscale models (used by OnnxDetectionModelLoader and upscale nodes)
quickRegister('onnx', 'UpscaleModelLoader', 'model_name')
// Detection models (vitpose, yolo)
quickRegister('detection', 'OnnxDetectionModelLoader', 'yolo_model')
// HunyuanVideo text encoders (ComfyUI-HunyuanVideoWrapper)
quickRegister(
'LLM/llava-llama-3-8b-text-encoder-tokenizer',
'DownloadAndLoadHyVideoTextEncoder',
'llm_model'
)
quickRegister(
'LLM/llava-llama-3-8b-v1_1-transformers',
'DownloadAndLoadHyVideoTextEncoder',
'llm_model'
)
// CogVideoX models (comfyui-cogvideoxwrapper)
quickRegister('CogVideo/GGUF', 'DownloadAndLoadCogVideoGGUFModel', 'model')
// Empty key: HF-download node — don't activate asset browser for the combo widget
quickRegister(
'CogVideo/ControlNet',
'DownloadAndLoadCogVideoControlNet',
''
)
// DynamiCrafter models (ComfyUI-DynamiCrafterWrapper)
quickRegister(
'checkpoints/dynamicrafter',
'DownloadAndLoadDynamiCrafterModel',
'model'
)
quickRegister(
'checkpoints/dynamicrafter/controlnet',
'DownloadAndLoadDynamiCrafterCNModel',
'model'
)
// LayerStyle models (ComfyUI_LayerStyle_Advance)
quickRegister('BEN', 'LS_LoadBenModel', 'model')
quickRegister('BiRefNet/pth', 'LS_LoadBiRefNetModel', 'model')
quickRegister('onnx/human-parts', 'LS_HumanPartsUltra', '')
quickRegister('lama', 'LaMa', 'lama_model')
// CogVideoX video generation models (comfyui-cogvideoxwrapper)
// Empty key: HF-download node — don't activate asset browser for the combo widget
quickRegister('CogVideo', 'DownloadAndLoadCogVideoModel', '')
// Inpaint models (comfyui-inpaint-nodes)
quickRegister('inpaint', 'INPAINT_LoadInpaintModel', 'model_name')
// LayerDiffuse transparent image generation (comfyui-layerdiffuse)
quickRegister('layer_model', 'LayeredDiffusionApply', 'config')
// LTX Video prompt enhancer models (ComfyUI-LTXTricks)
quickRegister(
'LLM/Llama-3.2-3B-Instruct',
'LTXVPromptEnhancerLoader',
'llm_name'
)
quickRegister(
'LLM/Florence-2-large-PromptGen-v2.0',
'LTXVPromptEnhancerLoader',
'image_captioner_name'
)
for (const [modelType, nodeClass, key] of MODEL_NODE_MAPPINGS) {
quickRegister(modelType, nodeClass, key)
}
}
return {

View File

@@ -1,15 +0,0 @@
export type MenuActionEntry = {
kind?: 'item'
key: string
label: string
icon?: string
disabled?: boolean
onClick?: () => void | Promise<void>
}
type MenuDividerEntry = {
kind: 'divider'
key: string
}
export type MenuEntry = MenuActionEntry | MenuDividerEntry