Compare commits

..

1 Commits

Author SHA1 Message Date
CodeRabbit Fixer
feadb3b6cb fix: Add component-level test for DomWidget disabled state logic (#9290)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:23:05 +01:00
120 changed files with 1195 additions and 3506 deletions

View File

@@ -13,8 +13,6 @@ on:
branches:
- 'cloud/*'
- 'main'
pull_request:
types: [labeled]
workflow_dispatch:
permissions: {}
@@ -25,31 +23,16 @@ concurrency:
jobs:
dispatch:
# Fork guard: prevent forks from dispatching to the cloud repo.
# For pull_request events, only dispatch when the 'preview' label is added.
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
github.event.label.name == 'preview')
# Fork guard: prevent forks from dispatching to the cloud repo
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
steps:
- name: Build client payload
id: payload
env:
EVENT_NAME: ${{ github.event_name }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
if [ "${EVENT_NAME}" = "pull_request" ]; then
REF="${PR_HEAD_SHA}"
BRANCH="${PR_HEAD_REF}"
else
REF="${GITHUB_SHA}"
BRANCH="${GITHUB_REF_NAME}"
fi
payload="$(jq -nc \
--arg ref "${REF}" \
--arg branch "${BRANCH}" \
--arg ref "${GITHUB_SHA}" \
--arg branch "${GITHUB_REF_NAME}" \
'{ref: $ref, branch: $branch}')"
echo "json=${payload}" >> "${GITHUB_OUTPUT}"

View File

@@ -1,7 +1,10 @@
<template>
<div ref="rootEl" class="relative size-full overflow-hidden bg-neutral-900">
<div class="p-terminal size-full rounded-none p-2">
<div ref="terminalEl" class="terminal-host h-full" />
<div
ref="rootEl"
class="relative overflow-hidden h-full w-full bg-neutral-900"
>
<div class="p-terminal rounded-none h-full w-full p-2">
<div ref="terminalEl" class="h-full terminal-host" />
</div>
<Button
v-tooltip.left="{
@@ -13,7 +16,7 @@
size="small"
:class="
cn('absolute top-2 right-8 transition-opacity', {
'pointer-events-none opacity-0 select-none': !isHovered
'opacity-0 pointer-events-none select-none': !isHovered
})
"
:aria-label="tooltipText"

View File

@@ -1,12 +1,12 @@
<template>
<div class="mx-auto flex w-full max-w-3xl flex-col gap-8 select-none">
<div class="flex flex-col gap-8 w-full max-w-3xl mx-auto select-none">
<!-- Installation Path Section -->
<div class="flex grow flex-col gap-6 text-neutral-300">
<h2 class="text-center font-inter text-3xl font-bold text-neutral-100">
<div class="grow flex flex-col gap-6 text-neutral-300">
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
{{ $t('install.locationPicker.title') }}
</h2>
<p class="px-12 text-center text-neutral-400">
<p class="text-center text-neutral-400 px-12">
{{ $t('install.locationPicker.subtitle') }}
</p>
@@ -15,7 +15,7 @@
<InputText
v-model="installPath"
:placeholder="$t('install.locationPicker.pathPlaceholder')"
class="flex-1 border-neutral-700 bg-neutral-800/50 text-neutral-200 placeholder:text-neutral-500"
class="flex-1 bg-neutral-800/50 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
:class="{ 'p-invalid': pathError }"
@update:model-value="validatePath"
@focus="onFocus"
@@ -23,7 +23,7 @@
<Button
icon="pi pi-folder-open"
severity="secondary"
class="border-0 bg-neutral-700 hover:bg-neutral-600"
class="bg-neutral-700 hover:bg-neutral-600 border-0"
@click="browsePath"
/>
</div>
@@ -33,7 +33,7 @@
<Message
v-if="pathError"
severity="error"
class="w-full whitespace-pre-line"
class="whitespace-pre-line w-full"
>
{{ pathError }}
</Message>

View File

@@ -26,7 +26,7 @@
<img
v-if="task.headerImg"
:src="task.headerImg"
class="size-full object-contain px-4 pt-4 opacity-25"
class="h-full w-full object-contain px-4 pt-4 opacity-25"
/>
</template>
<template #title>
@@ -52,7 +52,7 @@
<i
v-if="!isLoading && runner.state === 'OK'"
class="pi pi-check pointer-events-none absolute -right-4 -bottom-4 z-10 col-span-full row-span-full text-[4rem] text-green-500 opacity-100 transition-opacity [text-shadow:0.25rem_0_0.5rem_black] group-hover/task-card:opacity-20"
class="pi pi-check pointer-events-none absolute -right-4 -bottom-4 col-span-full row-span-full z-10 text-[4rem] text-green-500 opacity-100 transition-opacity group-hover/task-card:opacity-20 [text-shadow:0.25rem_0_0.5rem_black]"
/>
</div>
</template>

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex size-full flex-col justify-between rounded-lg p-6">
<h1 class="m-0 font-inter text-xl font-semibold italic">
<div class="w-full h-full flex flex-col rounded-lg p-6 justify-between">
<h1 class="font-inter font-semibold text-xl m-0 italic">
{{ $t(`desktopDialogs.${id}.title`, title) }}
</h1>
<p class="whitespace-pre-wrap">

View File

@@ -1,7 +1,7 @@
<template>
<BaseViewTemplate dark>
<div
class="grid h-screen w-screen items-center justify-around overflow-y-auto"
class="h-screen w-screen grid items-center justify-around overflow-y-auto"
>
<div class="relative m-8 text-center">
<!-- Header -->
@@ -13,7 +13,7 @@
<span>{{ $t('desktopUpdate.description') }}</span>
</div>
<ProgressSpinner class="m-8 size-48" />
<ProgressSpinner class="m-8 w-48 h-48" />
<!-- Console button -->
<Button

View File

@@ -1,10 +1,10 @@
<template>
<BaseViewTemplate dark>
<!-- Fixed height container with flexbox layout for proper content management -->
<div class="flex size-full flex-col">
<div class="w-full h-full flex flex-col">
<Stepper
v-model:value="currentStep"
class="flex h-full flex-col"
class="flex flex-col h-full"
@update:value="handleStepChange"
>
<!-- Main content area that grows to fill available space -->
@@ -37,7 +37,7 @@
<!-- Install footer with navigation -->
<InstallFooter
class="mx-auto my-6 w-full max-w-2xl"
class="w-full max-w-2xl my-6 mx-auto"
:current-step
:can-proceed
:disable-location-step="noGpu"

View File

@@ -1,21 +1,21 @@
<template>
<BaseViewTemplate dark>
<div
class="dark-theme grid h-screen min-h-full w-screen min-w-full justify-around overflow-y-auto bg-neutral-900 font-sans text-neutral-300"
class="min-w-full min-h-full font-sans w-screen h-screen grid justify-around text-neutral-300 bg-neutral-900 dark-theme overflow-y-auto"
>
<div class="relative m-8 w-screen max-w-(--breakpoint-sm)">
<div class="max-w-(--breakpoint-sm) w-screen m-8 relative">
<!-- Header -->
<h1 class="backspan pi-wrench text-4xl font-bold">
{{ t('maintenance.title') }}
</h1>
<!-- Toolbar -->
<div class="flex w-full flex-wrap items-center gap-4">
<div class="w-full flex flex-wrap gap-4 items-center">
<span class="grow">
{{ t('maintenance.status') }}:
<StatusTag :refreshing="isRefreshing" :error="anyErrors" />
</span>
<div class="flex items-center gap-4">
<div class="flex gap-4 items-center">
<SelectButton
v-model="displayAsList"
:options="[PrimeIcons.LIST, PrimeIcons.TH_LARGE]"
@@ -56,10 +56,10 @@
:value="t('icon.exclamation-triangle')"
/>
<span>
<strong class="mb-1 block">
<strong class="block mb-1">
{{ t('maintenance.unsafeMigration.title') }}
</strong>
<span class="mb-1 block">
<span class="block mb-1">
{{ unsafeReasonText }}
</span>
<span class="block text-sm text-neutral-400">
@@ -71,13 +71,13 @@
<!-- Tasks -->
<TaskListPanel
class="border-x-0 border-y border-solid border-neutral-700"
class="border-neutral-700 border-solid border-x-0 border-y"
:filter
:display-as-list
/>
<!-- Actions -->
<div class="flex flex-row justify-between gap-4">
<div class="flex justify-between gap-4 flex-row">
<Button
:label="t('maintenance.consoleLogs')"
icon="pi pi-desktop"

View File

@@ -9,7 +9,7 @@
/>
<div class="no-drag sad-text flex items-center">
<div class="flex min-w-110 flex-col gap-8 p-8">
<div class="flex flex-col gap-8 p-8 min-w-110">
<!-- Header -->
<h1 class="text-4xl font-bold text-red-500">
{{ $t('notSupported.title') }}
@@ -20,7 +20,7 @@
<p class="text-xl">
{{ $t('notSupported.message') }}
</p>
<ul class="list-inside list-disc space-y-1 text-neutral-800">
<ul class="list-disc list-inside space-y-1 text-neutral-800">
<li>{{ $t('notSupported.supportedDevices.macos') }}</li>
<li>{{ $t('notSupported.supportedDevices.windows') }}</li>
</ul>

View File

@@ -2,14 +2,14 @@
<BaseViewTemplate dark>
<div class="relative min-h-screen">
<!-- Terminal Background Layer (always visible during loading) -->
<div v-if="!isError" class="fixed inset-0 z-0 overflow-hidden">
<div class="size-full">
<div v-if="!isError" class="fixed inset-0 overflow-hidden z-0">
<div class="h-full w-full">
<BaseTerminal @created="terminalCreated" />
</div>
</div>
<!-- Semi-transparent overlay -->
<div v-if="!isError" class="fixed inset-0 z-5 bg-neutral-900/80"></div>
<div v-if="!isError" class="fixed inset-0 bg-neutral-900/80 z-5"></div>
<!-- Smooth radial gradient overlay -->
<div
@@ -45,9 +45,9 @@
<!-- Error Section (positioned at bottom) -->
<div
v-if="isError"
class="absolute inset-x-0 bottom-20 flex flex-col items-center gap-4"
class="absolute bottom-20 left-0 right-0 flex flex-col items-center gap-4"
>
<div class="flex justify-center gap-4">
<div class="flex gap-4 justify-center">
<Button
icon="pi pi-flag"
:label="$t('serverStart.reportIssue')"
@@ -71,10 +71,10 @@
<!-- Terminal Output (positioned at bottom when manually toggled in error state) -->
<div
v-if="terminalVisible && isError"
class="absolute inset-x-4 bottom-4 z-10 mx-auto max-w-4xl"
class="absolute bottom-4 left-4 right-4 max-w-4xl mx-auto z-10"
>
<div
class="h-[300px] rounded-lg border border-neutral-700 bg-neutral-900/95 p-4"
class="bg-neutral-900/95 rounded-lg p-4 border border-neutral-700 h-[300px]"
>
<BaseTerminal @created="terminalCreated" />
</div>

View File

@@ -36,7 +36,14 @@
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"cnr_id": "comfy-core",
"ver": "0.3.65"
"ver": "0.3.65",
"models": [
{
"name": "v1-5-pruned-emaonly-fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
"directory": "checkpoints"
}
]
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},

View File

@@ -206,7 +206,9 @@ export class ComfyPage {
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
this.runButton = page.getByTestId(TestIds.topbar.queueButton)
this.runButton = page
.getByTestId(TestIds.topbar.queueButton)
.getByRole('button', { name: 'Run' })
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
@@ -430,10 +432,7 @@ export const comfyPageFixture = base.extend<{
'Comfy.VueNodes.AutoScaleLayout': false,
// Disable toast warning about version compatibility, as they may or
// may not appear - depending on upstream ComfyUI dependencies
'Comfy.VersionCompatibility.DisableWarnings': true,
// Browser tests should opt into missing-model warnings explicitly so
// workflows do not render differently based on models present on disk.
'Comfy.Workflow.ShowMissingModelsWarning': false
'Comfy.VersionCompatibility.DisableWarnings': true
})
} catch (e) {
console.error(e)

View File

@@ -172,19 +172,6 @@ export class VueNodeHelpers {
async enterSubgraph(nodeId?: string): Promise<void> {
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
// The footer tab button extends below the node body (visible area),
// but its bounding box center overlaps the node body div.
// Click at the bottom 25% of the button which is the genuinely visible
// and unobstructed area outside the node body boundary.
const box = await editButton.boundingBox()
if (!box) {
throw new Error(
'subgraph-enter-button has no bounding box: element may be hidden or not in DOM'
)
}
await editButton.click({
position: { x: box.width / 2, y: box.height * 0.75 }
})
await editButton.click()
}
}

View File

@@ -33,7 +33,6 @@ export const TestIds = {
},
topbar: {
queueButton: 'queue-button',
queueModeMenuTrigger: 'queue-mode-menu-trigger',
saveButton: 'save-workflow-button'
},
nodeLibrary: {

View File

@@ -29,10 +29,8 @@ class ComfyQueueButton {
public readonly dropdownButton: Locator
constructor(public readonly actionbar: ComfyActionbar) {
this.root = actionbar.root.getByTestId(TestIds.topbar.queueButton)
this.primaryButton = this.root
this.dropdownButton = actionbar.root.getByTestId(
TestIds.topbar.queueModeMenuTrigger
)
this.primaryButton = this.root.locator('.p-splitbutton-button')
this.dropdownButton = this.root.locator('.p-splitbutton-dropdown')
}
public async toggleOptions() {

View File

@@ -89,17 +89,6 @@ test.describe('Execution error', () => {
})
test.describe('Missing models warning', () => {
test('Should be disabled by default in browser tests', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
})
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.ShowMissingModelsWarning',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -819,13 +819,16 @@ test.describe('Load workflow', { tag: '@screenshot' }, () => {
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
const workflowPathA = `${workflowA}.json`
const workflowPathB = `${workflowB}.json`
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowA, workflowB])
expect.arrayContaining([workflowPathA, workflowPathB])
)
expect(openWorkflows.indexOf(workflowA)).toBeLessThan(
openWorkflows.indexOf(workflowB)
expect(openWorkflows.indexOf(workflowPathA)).toBeLessThan(
openWorkflows.indexOf(workflowPathB)
)
expect(activeWorkflowName).toEqual(workflowB)
expect(activeWorkflowName).toEqual(workflowPathB)
})
})

View File

@@ -35,21 +35,18 @@ test.describe(
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
comfyPage
}) => {
const shouldUpload = filesWithUpload.has(fileName)
const uploadRequestPromise = shouldUpload
? comfyPage.page.waitForRequest((req) =>
req.url().includes('/upload/')
)
: null
await comfyPage.dragDrop.dragAndDropFile(`workflowInMedia/${fileName}`)
if (uploadRequestPromise) {
const request = await uploadRequestPromise
expect(request.url()).toContain('/upload/')
} else {
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
const waitForUpload = filesWithUpload.has(fileName)
await comfyPage.dragDrop.dragAndDropFile(
`workflowInMedia/${fileName}`,
{ waitForUpload }
)
if (waitForUpload) {
await comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/view') && resp.status() !== 0,
{ timeout: 10000 }
)
}
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -13,9 +13,9 @@ test.describe('Reroute Node', { tag: ['@screenshot', '@node'] }, () => {
})
test('loads from inserted workflow', async ({ comfyPage }) => {
const workflowName = 'single_connected_reroute_node'
const workflowName = 'single_connected_reroute_node.json'
await comfyPage.workflow.setupWorkflowsDirectory({
[`${workflowName}.json`]: `links/${workflowName}.json`
[workflowName]: 'links/single_connected_reroute_node.json'
})
await comfyPage.setup()
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -21,12 +21,14 @@ test.describe('Workflows sidebar', () => {
test('Can create new blank workflow', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
expect(await tab.getOpenedWorkflowNames()).toEqual(['*Unsaved Workflow'])
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
])
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow',
'*Unsaved Workflow (2)'
'*Unsaved Workflow.json',
'*Unsaved Workflow (2).json'
])
})
@@ -39,37 +41,37 @@ test.describe('Workflows sidebar', () => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
expect.arrayContaining(['workflow1', 'workflow2'])
expect.arrayContaining(['workflow1.json', 'workflow2.json'])
)
})
test('Can duplicate workflow', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await comfyPage.menu.topbar.saveWorkflow('workflow1')
await comfyPage.menu.topbar.saveWorkflow('workflow1.json')
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
expect.arrayContaining(['workflow1'])
expect.arrayContaining(['workflow1.json'])
)
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'workflow1',
'*workflow1 (Copy)'
'workflow1.json',
'*workflow1 (Copy).json'
])
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'workflow1',
'*workflow1 (Copy)',
'*workflow1 (Copy) (2)'
'workflow1.json',
'*workflow1 (Copy).json',
'*workflow1 (Copy) (2).json'
])
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'workflow1',
'*workflow1 (Copy)',
'*workflow1 (Copy) (2)',
'*workflow1 (Copy) (3)'
'workflow1.json',
'*workflow1 (Copy).json',
'*workflow1 (Copy) (2).json',
'*workflow1 (Copy) (3).json'
])
})
@@ -83,12 +85,12 @@ test.describe('Workflows sidebar', () => {
await comfyPage.command.executeCommand('Comfy.LoadDefaultWorkflow')
const originalNodeCount = await comfyPage.nodeOps.getNodeCount()
await tab.insertWorkflow(tab.getPersistedItem('workflow1'))
await tab.insertWorkflow(tab.getPersistedItem('workflow1.json'))
await expect
.poll(() => comfyPage.nodeOps.getNodeCount())
.toEqual(originalNodeCount + 1)
await tab.getPersistedItem('workflow1').click()
await tab.getPersistedItem('workflow1.json').click()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toEqual(1)
})
@@ -111,22 +113,22 @@ test.describe('Workflows sidebar', () => {
const openedWorkflow = tab.getOpenedItem('foo/bar')
await tab.renameWorkflow(openedWorkflow, 'foo/baz')
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow',
'foo/baz'
'*Unsaved Workflow.json',
'foo/baz.json'
])
})
test('Can save workflow as', async ({ comfyPage }) => {
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.menu.topbar.saveWorkflowAs('workflow3')
await comfyPage.menu.topbar.saveWorkflowAs('workflow3.json')
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow', 'workflow3'])
.toEqual(['*Unsaved Workflow.json', 'workflow3.json'])
await comfyPage.menu.topbar.saveWorkflowAs('workflow4')
await comfyPage.menu.topbar.saveWorkflowAs('workflow4.json')
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow', 'workflow3', 'workflow4'])
.toEqual(['*Unsaved Workflow.json', 'workflow3.json', 'workflow4.json'])
})
test('Exported workflow does not contain localized slot names', async ({
@@ -182,15 +184,15 @@ test.describe('Workflows sidebar', () => {
})
test('Can save workflow as with same name', async ({ comfyPage }) => {
await comfyPage.menu.topbar.saveWorkflow('workflow5')
await comfyPage.menu.topbar.saveWorkflow('workflow5.json')
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow5'
'workflow5.json'
])
await comfyPage.menu.topbar.saveWorkflowAs('workflow5')
await comfyPage.menu.topbar.saveWorkflowAs('workflow5.json')
await comfyPage.confirmDialog.click('overwrite')
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow5'
'workflow5.json'
])
})
@@ -210,25 +212,25 @@ test.describe('Workflows sidebar', () => {
test('Can overwrite other workflows with save as', async ({ comfyPage }) => {
const topbar = comfyPage.menu.topbar
await topbar.saveWorkflow('workflow1')
await topbar.saveWorkflowAs('workflow2')
await topbar.saveWorkflow('workflow1.json')
await topbar.saveWorkflowAs('workflow2.json')
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['workflow1', 'workflow2'])
.toEqual(['workflow1.json', 'workflow2.json'])
await expect
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
.toEqual('workflow2')
.toEqual('workflow2.json')
await topbar.saveWorkflowAs('workflow1')
await topbar.saveWorkflowAs('workflow1.json')
await comfyPage.confirmDialog.click('overwrite')
// The old workflow1 should be deleted and the new one should be saved.
// The old workflow1.json should be deleted and the new one should be saved.
await expect
.poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames())
.toEqual(['workflow2', 'workflow1'])
.toEqual(['workflow2.json', 'workflow1.json'])
await expect
.poll(() => comfyPage.menu.workflowsTab.getActiveWorkflowName())
.toEqual('workflow1')
.toEqual('workflow1.json')
})
test('Does not report warning when switching between opened workflows', async ({
@@ -264,15 +266,17 @@ test.describe('Workflows sidebar', () => {
)
await closeButton.click()
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow'
'*Unsaved Workflow.json'
])
})
test('Can close saved workflow with command', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
await comfyPage.menu.topbar.saveWorkflow('workflow1')
await comfyPage.menu.topbar.saveWorkflow('workflow1.json')
await comfyPage.command.executeCommand('Workspace.CloseWorkflow')
expect(await tab.getOpenedWorkflowNames()).toEqual(['*Unsaved Workflow'])
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
])
})
test('Can delete workflows (confirm disabled)', async ({ comfyPage }) => {
@@ -280,7 +284,7 @@ test.describe('Workflows sidebar', () => {
const { topbar, workflowsTab } = comfyPage.menu
const filename = 'workflow18'
const filename = 'workflow18.json'
await topbar.saveWorkflow(filename)
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([filename])
@@ -291,14 +295,14 @@ test.describe('Workflows sidebar', () => {
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow'
'*Unsaved Workflow.json'
])
})
test('Can delete workflows', async ({ comfyPage }) => {
const { topbar, workflowsTab } = comfyPage.menu
const filename = 'workflow18'
const filename = 'workflow18.json'
await topbar.saveWorkflow(filename)
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([filename])
@@ -310,7 +314,7 @@ test.describe('Workflows sidebar', () => {
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow'
'*Unsaved Workflow.json'
])
})
@@ -322,11 +326,13 @@ test.describe('Workflows sidebar', () => {
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem('workflow1').click({ button: 'right' })
await workflowsTab
.getPersistedItem('workflow1.json')
.click({ button: 'right' })
await comfyPage.contextMenu.clickMenuItem('Duplicate')
await expect
.poll(() => workflowsTab.getOpenedWorkflowNames())
.toEqual(['*Unsaved Workflow', '*workflow1 (Copy)'])
.toEqual(['*Unsaved Workflow.json', '*workflow1 (Copy).json'])
})
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {
@@ -338,7 +344,7 @@ test.describe('Workflows sidebar', () => {
// Wait for workflow to appear in Browse section after sync
const workflowItem =
comfyPage.menu.workflowsTab.getPersistedItem('workflow1')
comfyPage.menu.workflowsTab.getPersistedItem('workflow1.json')
await expect(workflowItem).toBeVisible({ timeout: 3000 })
const nodeCount = await comfyPage.nodeOps.getGraphNodesCount()
@@ -355,7 +361,7 @@ test.describe('Workflows sidebar', () => {
}
await comfyPage.page.dragAndDrop(
'.comfyui-workflows-browse .node-label:has-text("workflow1")',
'.comfyui-workflows-browse .node-label:has-text("workflow1.json")',
'#graph-canvas',
{ targetPosition }
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -22,10 +22,8 @@ test.describe('Vue Node Bypass', () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
const checkpointNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Load Checkpoint' })
.getByTestId('node-inner-wrapper')
const checkpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
@@ -43,14 +41,8 @@ test.describe('Vue Node Bypass', () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
const checkpointNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Load Checkpoint' })
.getByTestId('node-inner-wrapper')
const ksamplerNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'KSampler' })
.getByTestId('node-inner-wrapper')
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

View File

@@ -3,7 +3,7 @@ import {
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
const ERROR_CLASS = /ring-destructive-background/
const ERROR_CLASS = /border-node-stroke-error/
test.describe('Vue Node Error', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -18,10 +18,9 @@ test.describe('Vue Node Error', () => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
// Expect error state on missing unknown node
const unknownNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'UNKNOWN NODE' })
.getByTestId('node-inner-wrapper')
const unknownNode = comfyPage.page.locator('[data-node-id]').filter({
hasText: 'UNKNOWN NODE'
})
await expect(unknownNode).toHaveClass(ERROR_CLASS)
})
@@ -32,10 +31,7 @@ test.describe('Vue Node Error', () => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.runButton.click()
const raiseErrorNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Raise Error' })
.getByTestId('node-inner-wrapper')
const raiseErrorNode = comfyPage.vueNodes.getNodeByTitle('Raise Error')
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -1,98 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useQueueSettingsStore } from '@/stores/queueStore'
import BatchCountEdit from './BatchCountEdit.vue'
const maxBatchCount = 16
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (settingId: string) =>
settingId === 'Comfy.QueueButton.BatchCountLimit' ? maxBatchCount : 1
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
increment: 'Increment',
decrement: 'Decrement'
},
menu: {
batchCount: 'Batch Count'
}
}
}
})
function createWrapper(initialBatchCount = 1) {
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false,
initialState: {
queueSettingsStore: {
batchCount: initialBatchCount
}
}
})
const wrapper = mount(BatchCountEdit, {
global: {
plugins: [pinia, i18n],
directives: {
tooltip: () => {}
}
}
})
const queueSettingsStore = useQueueSettingsStore()
return { wrapper, queueSettingsStore }
}
describe('BatchCountEdit', () => {
it('doubles the current batch count when increment is clicked', async () => {
const { wrapper, queueSettingsStore } = createWrapper(3)
await wrapper.get('button[aria-label="Increment"]').trigger('click')
expect(queueSettingsStore.batchCount).toBe(6)
})
it('halves the current batch count when decrement is clicked', async () => {
const { wrapper, queueSettingsStore } = createWrapper(9)
await wrapper.get('button[aria-label="Decrement"]').trigger('click')
expect(queueSettingsStore.batchCount).toBe(4)
})
it('clamps typed values to queue limits on blur', async () => {
const { wrapper, queueSettingsStore } = createWrapper(2)
const input = wrapper.get('input')
await input.setValue('999')
await input.trigger('blur')
await nextTick()
expect(queueSettingsStore.batchCount).toBe(maxBatchCount)
expect((input.element as HTMLInputElement).value).toBe(
String(maxBatchCount)
)
await input.setValue('0')
await input.trigger('blur')
await nextTick()
expect(queueSettingsStore.batchCount).toBe(1)
expect((input.element as HTMLInputElement).value).toBe('1')
})
})

View File

@@ -1,129 +1,71 @@
<template>
<div
v-tooltip.bottom="{
value: t('menu.batchCount'),
value: $t('menu.batchCount'),
showDelay: 600
}"
class="batch-count h-full"
:aria-label="t('menu.batchCount')"
class="batch-count"
:aria-label="$t('menu.batchCount')"
>
<div
class="flex h-full w-14 overflow-hidden rounded-l-lg bg-secondary-background"
>
<input
ref="batchCountInputRef"
v-model="batchCountInput"
type="text"
inputmode="numeric"
:aria-label="t('menu.batchCount')"
:class="inputClass"
@focus="onInputFocus"
@input="onInput"
@blur="onInputBlur"
@keydown.enter.prevent="onInputEnter"
/>
<div class="flex h-full w-6 flex-col">
<Button
variant="secondary"
size="unset"
:aria-label="t('g.increment')"
:class="cn(stepButtonClass, incrementButtonClass)"
:disabled="isIncrementDisabled"
@click="incrementBatchCount"
>
<TinyChevronIcon rotate-up />
</Button>
<Button
variant="secondary"
size="unset"
:aria-label="t('g.decrement')"
:class="cn(stepButtonClass, decrementButtonClass)"
:disabled="isDecrementDisabled"
@click="decrementBatchCount"
>
<TinyChevronIcon />
</Button>
</div>
</div>
<InputNumber
v-model="batchCount"
class="w-14"
:min="minQueueCount"
:max="maxQueueCount"
fluid
show-buttons
:pt="{
incrementButton: {
class: 'w-6',
onmousedown: () => {
handleClick(true)
}
},
decrementButton: {
class: 'w-6',
onmousedown: () => {
handleClick(false)
}
}
}"
/>
</div>
</template>
<script lang="ts" setup>
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import InputNumber from 'primevue/inputnumber'
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
import TinyChevronIcon from './TinyChevronIcon.vue'
const { t } = useI18n()
const queueSettingsStore = useQueueSettingsStore()
const { batchCount } = storeToRefs(queueSettingsStore)
const minQueueCount = 1
const settingStore = useSettingStore()
const minQueueCount = 1
const maxQueueCount = computed(() =>
settingStore.get('Comfy.QueueButton.BatchCountLimit')
)
const batchCountInputRef = ref<HTMLInputElement | null>(null)
const batchCountInput = ref(String(batchCount.value))
const isEditing = ref(false)
const isIncrementDisabled = computed(
() => batchCount.value >= maxQueueCount.value
)
const isDecrementDisabled = computed(() => batchCount.value <= minQueueCount)
const inputClass =
'h-full min-w-0 flex-1 border-none bg-secondary-background pl-1 pr-0 text-center text-sm font-normal tabular-nums text-base-foreground outline-none'
const stepButtonClass =
'h-1/2 w-full rounded-none border-none p-0 text-muted-foreground hover:bg-secondary-background-hover disabled:cursor-not-allowed disabled:opacity-50'
const incrementButtonClass = 'rounded-tr-none border-b border-border-subtle'
const decrementButtonClass = 'rounded-br-none'
watch(batchCount, (nextBatchCount) => {
if (!isEditing.value) {
batchCountInput.value = String(nextBatchCount)
const handleClick = (increment: boolean) => {
let newCount: number
if (increment) {
const originalCount = batchCount.value - 1
newCount = Math.min(originalCount * 2, maxQueueCount.value)
} else {
const originalCount = batchCount.value + 1
newCount = Math.floor(originalCount / 2)
}
})
const clampBatchCount = (nextBatchCount: number): number =>
Math.min(Math.max(nextBatchCount, minQueueCount), maxQueueCount.value)
const setBatchCount = (nextBatchCount: number) => {
batchCount.value = clampBatchCount(nextBatchCount)
batchCountInput.value = String(batchCount.value)
}
const incrementBatchCount = () => {
setBatchCount(batchCount.value * 2)
}
const decrementBatchCount = () => {
setBatchCount(Math.floor(batchCount.value / 2))
}
const onInputFocus = () => {
isEditing.value = true
}
const onInput = (event: Event) => {
const input = event.target as HTMLInputElement
batchCountInput.value = input.value.replace(/[^0-9]/g, '')
}
const onInputBlur = () => {
isEditing.value = false
const parsedInput = Number.parseInt(batchCountInput.value, 10)
setBatchCount(Number.isNaN(parsedInput) ? minQueueCount : parsedInput)
}
const onInputEnter = () => {
batchCountInputRef.value?.blur()
batchCount.value = newCount
}
</script>
<style scoped>
:deep(.p-inputtext) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
</style>

View File

@@ -1,7 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type {
@@ -41,9 +41,28 @@ vi.mock('@/stores/workspaceStore', () => ({
})
}))
const BatchCountEditStub = {
template: '<div data-testid="batch-count-edit" />'
}
const SplitButtonStub = defineComponent({
name: 'SplitButton',
props: {
label: {
type: String,
default: ''
},
severity: {
type: String,
default: 'primary'
}
},
template: `
<button
data-testid="split-button"
:data-label="label"
:data-severity="severity"
>
<slot name="icon" />
</button>
`
})
const i18n = createI18n({
legacy: false,
@@ -88,26 +107,14 @@ function createWrapper() {
tooltip: () => {}
},
stubs: {
BatchCountEdit: BatchCountEditStub,
DropdownMenuRoot: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<div><slot /></div>' }
SplitButton: SplitButtonStub,
BatchCountEdit: true
}
}
})
}
describe('ComfyQueueButton', () => {
it('renders the batch count control before the run button', () => {
const wrapper = createWrapper()
const controls = wrapper.get('.queue-button-group').element.children
expect(controls[0]?.getAttribute('data-testid')).toBe('batch-count-edit')
expect(controls[1]?.getAttribute('data-testid')).toBe('queue-button')
})
it('keeps the run instant presentation while idle even with active jobs', async () => {
const wrapper = createWrapper()
const queueSettingsStore = useQueueSettingsStore()
@@ -117,10 +124,10 @@ describe('ComfyQueueButton', () => {
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
await nextTick()
const queueButton = wrapper.get('[data-testid="queue-button"]')
const splitButton = wrapper.get('[data-testid="queue-button"]')
expect(queueButton.text()).toContain('Run (Instant)')
expect(queueButton.attributes('data-variant')).toBe('primary')
expect(splitButton.attributes('data-label')).toBe('Run (Instant)')
expect(splitButton.attributes('data-severity')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
})
@@ -131,10 +138,10 @@ describe('ComfyQueueButton', () => {
queueSettingsStore.mode = 'instant-running'
await nextTick()
const queueButton = wrapper.get('[data-testid="queue-button"]')
const splitButton = wrapper.get('[data-testid="queue-button"]')
expect(queueButton.text()).toContain('Stop Run (Instant)')
expect(queueButton.attributes('data-variant')).toBe('destructive')
expect(splitButton.attributes('data-label')).toBe('Stop Run (Instant)')
expect(splitButton.attributes('data-severity')).toBe('danger')
expect(wrapper.find('.icon-\\[lucide--square\\]').exists()).toBe(true)
})
@@ -152,17 +159,19 @@ describe('ComfyQueueButton', () => {
await nextTick()
expect(queueSettingsStore.mode).toBe('instant-idle')
const queueButtonWhileStopping = wrapper.get('[data-testid="queue-button"]')
expect(queueButtonWhileStopping.text()).toContain('Run (Instant)')
expect(queueButtonWhileStopping.attributes('data-variant')).toBe('primary')
const splitButtonWhileStopping = wrapper.get('[data-testid="queue-button"]')
expect(splitButtonWhileStopping.attributes('data-label')).toBe(
'Run (Instant)'
)
expect(splitButtonWhileStopping.attributes('data-severity')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
expect(commandStore.execute).not.toHaveBeenCalled()
const queueButton = wrapper.get('[data-testid="queue-button"]')
const splitButton = wrapper.get('[data-testid="queue-button"]')
expect(queueSettingsStore.mode).toBe('instant-idle')
expect(queueButton.text()).toContain('Run (Instant)')
expect(queueButton.attributes('data-variant')).toBe('primary')
expect(splitButton.attributes('data-label')).toBe('Run (Instant)')
expect(splitButton.attributes('data-severity')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
})

View File

@@ -1,83 +1,48 @@
<template>
<ButtonGroup
class="queue-button-group h-8 rounded-lg bg-secondary-background"
>
<BatchCountEdit />
<Button
<div class="queue-button-group flex">
<SplitButton
v-tooltip.bottom="{
value: queueButtonTooltip,
showDelay: 600
}"
:variant="queueButtonVariant"
size="unset"
:class="queueActionButtonClass"
class="comfyui-queue-button"
:label="queueButtonLabel"
:severity="queueButtonSeverity"
size="small"
:model="queueModeMenuItems"
data-testid="queue-button"
:data-variant="queueButtonVariant"
@click="queuePrompt"
>
<i :class="cn(iconClass, 'size-4')" />
{{ queueButtonLabel }}
</Button>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<template #icon>
<i :class="iconClass" />
</template>
<template #item="{ item }">
<Button
variant="secondary"
size="unset"
:class="queueMenuTriggerClass"
:aria-label="t('menu.run')"
data-testid="queue-mode-menu-trigger"
v-tooltip="{
value: item.tooltip,
showDelay: 600
}"
:variant="item.key === selectedQueueMode ? 'primary' : 'secondary'"
size="sm"
class="w-full justify-start"
>
<TinyChevronIcon />
<i v-if="item.icon" :class="item.icon" />
{{ String(item.label ?? '') }}
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:side-offset="4"
class="z-1000 min-w-44 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
>
<DropdownMenuItem
v-for="item in queueModeMenuItems"
:key="item.key"
as-child
@select.prevent="item.command"
>
<Button
v-tooltip="{
value: item.tooltip,
showDelay: 600
}"
:variant="
item.key === selectedQueueMode ? 'primary' : 'secondary'
"
size="sm"
:class="queueMenuItemButtonClass"
>
{{ item.label }}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</ButtonGroup>
</template>
</SplitButton>
<BatchCountEdit />
</div>
</template>
<script setup lang="ts">
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { storeToRefs } from 'pinia'
import type { MenuItem } from 'primevue/menuitem'
import SplitButton from 'primevue/splitbutton'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import BatchCountEdit from '@/components/actionbar/BatchCountEdit.vue'
import TinyChevronIcon from '@/components/actionbar/TinyChevronIcon.vue'
import Button from '@/components/ui/button/Button.vue'
import ButtonGroup from '@/components/ui/button-group/ButtonGroup.vue'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
@@ -89,9 +54,10 @@ import {
useQueueSettingsStore
} from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
import BatchCountEdit from '../BatchCountEdit.vue'
const workspaceStore = useWorkspaceStore()
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
@@ -103,60 +69,50 @@ const hasMissingNodes = computed(() =>
const { t } = useI18n()
type QueueModeMenuKey = 'disabled' | 'change' | 'instant-idle'
interface QueueModeMenuItem {
key: QueueModeMenuKey
label: string
tooltip: string
command: () => void
}
const selectedQueueMode = computed<QueueModeMenuKey>(() =>
isInstantMode(queueMode.value) ? 'instant-idle' : queueMode.value
)
const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
() => {
const items: Record<string, QueueModeMenuItem> = {
disabled: {
key: 'disabled',
label: t('menu.run'),
tooltip: t('menu.disabledTooltip'),
command: () => {
queueMode.value = 'disabled'
}
},
change: {
key: 'change',
label: `${t('menu.run')} (${t('menu.onChange')})`,
tooltip: t('menu.onChangeTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_on_change_selected'
})
queueMode.value = 'change'
}
const queueModeMenuItemLookup = computed(() => {
const items: Record<string, MenuItem> = {
disabled: {
key: 'disabled',
label: t('menu.run'),
tooltip: t('menu.disabledTooltip'),
command: () => {
queueMode.value = 'disabled'
}
},
change: {
key: 'change',
label: `${t('menu.run')} (${t('menu.onChange')})`,
tooltip: t('menu.onChangeTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_on_change_selected'
})
queueMode.value = 'change'
}
}
if (!isCloud) {
items['instant-idle'] = {
key: 'instant-idle',
label: `${t('menu.run')} (${t('menu.instant')})`,
tooltip: t('menu.instantTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_instant_selected'
})
queueMode.value = 'instant-idle'
}
}
}
return items
}
)
if (!isCloud) {
items['instant-idle'] = {
key: 'instant-idle',
label: `${t('menu.run')} (${t('menu.instant')})`,
tooltip: t('menu.instantTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_instant_selected'
})
queueMode.value = 'instant-idle'
}
}
}
return items
})
const activeQueueModeMenuItem = computed(() => {
// Fallback to disabled mode if current mode is not available (e.g., instant mode in cloud)
return (
queueModeMenuItemLookup.value[selectedQueueMode.value] ||
queueModeMenuItemLookup.value.disabled
@@ -176,13 +132,9 @@ const queueButtonLabel = computed(() =>
: String(activeQueueModeMenuItem.value?.label ?? '')
)
const queueButtonVariant = computed<'destructive' | 'primary'>(() =>
isStopInstantAction.value ? 'destructive' : 'primary'
const queueButtonSeverity = computed(() =>
isStopInstantAction.value ? 'danger' : 'primary'
)
const queueActionButtonClass = 'h-full rounded-lg gap-1.5 px-4 font-light'
const queueMenuTriggerClass =
'h-full w-6 rounded-l-none rounded-r-lg border-l border-border-subtle p-0 text-muted-foreground data-[state=open]:bg-secondary-background-hover'
const queueMenuItemButtonClass = 'w-full justify-start font-normal'
const iconClass = computed(() => {
if (isStopInstantAction.value) {
@@ -249,3 +201,10 @@ const queuePrompt = async (e: Event) => {
})
}
</script>
<style scoped>
.comfyui-queue-button :deep(.p-splitbutton-dropdown) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
</style>

View File

@@ -1,26 +0,0 @@
<template>
<svg
class="h-[5px] min-h-[5px] w-[8px] min-w-[8px]"
:class="{ 'rotate-180': rotateUp }"
xmlns="http://www.w3.org/2000/svg"
width="8"
height="5"
viewBox="0 0 8 5"
fill="none"
aria-hidden="true"
>
<path
d="M0.650391 0.649902L3.65039 3.6499L6.65039 0.649902"
stroke="currentColor"
stroke-width="1.3"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<script setup lang="ts">
const { rotateUp = false } = defineProps<{
rotateUp?: boolean
}>()
</script>

View File

@@ -3,15 +3,9 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { isCloud } from '@/platform/distribution/types'
import {
openShareDialog,
prefetchShareDialog
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useAppModeStore } from '@/stores/appModeStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -24,8 +18,6 @@ const workspaceStore = useWorkspaceStore()
const { enableAppBuilder } = useAppMode()
const appModeStore = useAppModeStore()
const { enterBuilder } = appModeStore
const { toastErrorHandler } = useErrorHandling()
const { flags } = useFeatureFlags()
const { hasNodes } = storeToRefs(appModeStore)
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
@@ -43,77 +35,97 @@ function openAssets() {
function showApps() {
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
}
function openTemplates() {
useWorkflowTemplateSelectorDialog().show('sidebar')
}
</script>
<template>
<div class="pointer-events-auto flex flex-row items-start gap-2">
<div class="pointer-events-auto flex flex-col gap-2">
<Button
v-if="enableAppBuilder"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:disabled="!hasNodes"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
class="size-10 rounded-lg"
@click="enterBuilder"
>
<i class="icon-[lucide--hammer] size-4" />
</Button>
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.right="{
value: t('actionbar.shareTooltip'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:aria-label="t('actionbar.shareTooltip')"
class="size-10 rounded-lg"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[lucide--send] size-4" />
</Button>
<div
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
>
<div class="pointer-events-auto flex flex-col gap-2">
<WorkflowActionsDropdown source="app_mode_toolbar">
<template #button="{ hasUnseenItems }">
<Button
v-tooltip.right="{
value: t('sideToolbar.mediaAssets.title'),
value: t('sideToolbar.labels.menu'),
...tooltipOptions
}"
variant="textonly"
variant="secondary"
size="unset"
:aria-label="t('sideToolbar.mediaAssets.title')"
:class="
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
"
@click="openAssets"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
<Button
v-tooltip.right="{
value: t('linearMode.appModeToolbar.apps'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('linearMode.appModeToolbar.apps')"
:class="
cn('size-10', isAppsActive && 'bg-secondary-background-hover')
"
@click="showApps"
:aria-label="t('sideToolbar.labels.menu')"
class="relative h-10 gap-1 rounded-lg pr-2 pl-3 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i class="icon-[lucide--panels-top-left] size-4" />
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</div>
</template>
</WorkflowActionsDropdown>
<Button
v-if="enableAppBuilder"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:disabled="!hasNodes"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
class="size-10 rounded-lg"
@click="enterBuilder"
>
<i class="icon-[lucide--hammer] size-4" />
</Button>
<div
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
>
<Button
v-tooltip.right="{
value: t('sideToolbar.mediaAssets.title'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('sideToolbar.mediaAssets.title')"
:class="
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
"
@click="openAssets"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
<Button
v-tooltip.right="{
value: t('linearMode.appModeToolbar.apps'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('linearMode.appModeToolbar.apps')"
:class="cn('size-10', isAppsActive && 'bg-secondary-background-hover')"
@click="showApps"
>
<i class="icon-[lucide--panels-top-left] size-4" />
</Button>
<Button
v-tooltip.right="{
value: t('sideToolbar.templates'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('sideToolbar.templates')"
class="size-10"
@click="openTemplates"
>
<i class="icon-[comfy--template] size-4" />
</Button>
</div>
<WorkflowActionsDropdown source="app_mode_toolbar" />
</div>
</template>

View File

@@ -1,7 +1,7 @@
<template>
<div
data-testid="subgraph-breadcrumb"
class="subgraph-breadcrumb -mt-3 flex w-auto items-center pt-4 pl-1 drop-shadow-(--interface-panel-drop-shadow)"
class="subgraph-breadcrumb -mt-4 flex w-auto items-center pt-4 drop-shadow-(--interface-panel-drop-shadow)"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs

View File

@@ -12,10 +12,7 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import {
LGraphEventMode,
TitleMode
} from '@/lib/litegraph/src/types/globalEnums'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -162,8 +159,7 @@ function handleDown(e: MouseEvent) {
}
function handleClick(e: MouseEvent) {
const [node, widget] = getHovered(e) ?? []
if (node?.mode !== LGraphEventMode.ALWAYS)
return canvasInteractions.forwardEventToCanvas(e)
if (!node) return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
if (!isSelectOutputsMode.value) return
@@ -196,10 +192,7 @@ function nodeToDisplayTuple(
const renderedOutputs = computed(() => {
void appModeStore.selectedOutputs.length
return canvas
.graph!.nodes.filter(
(n) =>
n.constructor.nodeData?.output_node && n.mode === LGraphEventMode.ALWAYS
)
.graph!.nodes.filter((n) => n.constructor.nodeData?.output_node)
.map(nodeToDisplayTuple)
})
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
@@ -211,152 +204,131 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
)
</script>
<template>
<div class="flex h-full flex-col">
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
</div>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
class="overflow-x-clip"
>
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
:aria-label="`${widget?.label ?? widgetName} ${node.title}`"
>
<div v-if="widget" class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
</div>
<div
v-else
class="pointer-events-none p-1 text-sm text-muted-foreground"
>
{{ widgetName }}
<p class="text-xs italic">
({{ t('linearMode.builder.unknownWidget') }})
</p>
</div>
</div>
</DraggableList>
<PropertiesAccordionItem
v-if="isSelectInputsMode"
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="p-4 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
</template>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<div
v-if="isSelectInputsMode && !appModeStore.selectedInputs.length"
class="m-4 flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-primary-background bg-primary-background/20 text-center text-sm text-primary-background"
>
{{ t('linearMode.builder.inputPlaceholder') }}
</div>
<PropertiesAccordionItem
v-if="isSelectOutputsMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="p-4 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
</template>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedOutputs"
>
<IoItem
v-for="([key, title], index) in outputsWithState"
:key
:class="
cn(
dragClass,
'my-2 rounded-lg bg-warning-background/40 p-2',
index === 0 && 'ring-2 ring-warning-background'
)
"
:title
:sub-title="String(key)"
:remove="
() => remove(appModeStore.selectedOutputs, (k) => k == key)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<div
v-if="isSelectOutputsMode && !appModeStore.selectedOutputs.length"
class="m-4 flex flex-1 flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-warning-background bg-warning-background/20 text-center text-sm text-warning-background"
>
{{ t('linearMode.builder.outputPlaceholder') }}
<span class="font-bold">
{{ t('linearMode.builder.outputRequiredPlaceholder') }}
</span>
</div>
</div>
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
</div>
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
:aria-label="`${widget?.label ?? widgetName} ${node.title}`"
>
<div v-if="widget" class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
</div>
<div v-else class="pointer-events-none p-1 text-sm text-muted-foreground">
{{ widgetName }}
<p class="text-xs italic">
({{ t('linearMode.builder.unknownWidget') }})
</p>
</div>
</div>
</DraggableList>
<PropertiesAccordionItem
v-if="isSelectInputsMode"
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
class="border-b border-border-subtle"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddInputs')"
/>
<DraggableList v-slot="{ dragClass }" v-model="appModeStore.selectedInputs">
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="isSelectOutputsMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
</template>
<div
class="w-full p-4 pt-2 text-muted-foreground"
v-text="t('linearMode.builder.promptAddOutputs')"
/>
<DraggableList
v-slot="{ dragClass }"
v-model="appModeStore.selectedOutputs"
>
<IoItem
v-for="([key, title], index) in outputsWithState"
:key
:class="
cn(
dragClass,
'my-2 rounded-lg bg-warning-background/40 p-2',
index === 0 && 'ring-2 ring-warning-background'
)
"
:title
:sub-title="String(key)"
:remove="() => remove(appModeStore.selectedOutputs, (k) => k == key)"
/>
</DraggableList>
</PropertiesAccordionItem>
<Teleport
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"

View File

@@ -38,8 +38,8 @@
<Button variant="muted-textonly" size="lg" @click="$emit('viewApp')">
{{ $t('builderToolbar.viewApp') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('exitToWorkflow')">
{{ $t('builderToolbar.exitToWorkflow') }}
<Button variant="secondary" size="lg" @click="$emit('close')">
{{ $t('g.close') }}
</Button>
</template>
</template>
@@ -58,6 +58,5 @@ defineProps<{
defineEmits<{
viewApp: []
close: []
exitToWorkflow: []
}>()
</script>

View File

@@ -58,6 +58,6 @@ useEventListener(window, 'keydown', (e: KeyboardEvent) => {
})
function onExitBuilder() {
appModeStore.exitBuilder()
void appModeStore.exitBuilder()
}
</script>

View File

@@ -19,31 +19,38 @@
</button>
</template>
<template #default="{ close }">
<template v-for="(item, index) in menuItems" :key="item.label">
<div v-if="index > 0" class="my-1 border-t border-border-default" />
<Button
variant="textonly"
size="unset"
class="flex w-full items-center justify-start gap-3 rounded-md px-3 py-2 text-sm"
:disabled="item.disabled"
@click="item.action(close)"
>
<i :class="cn(item.icon, 'size-4')" />
{{ item.label }}
</Button>
</template>
<button
:class="
cn(
'flex w-full items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-sm',
hasOutputs
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'pointer-events-none opacity-50'
)
"
:disabled="!hasOutputs"
@click="onSave(close)"
>
<i class="icon-[lucide--save] size-4" />
{{ t('g.save') }}
</button>
<div class="my-1 border-t border-border-default" />
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-sm hover:bg-secondary-background-hover"
@click="onExitBuilder(close)"
>
<i class="icon-[lucide--square-pen] size-4" />
{{ t('builderMenu.exitAppBuilder') }}
</button>
</template>
</Popover>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -53,30 +60,10 @@ import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { setMode } = useAppMode()
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const { toastErrorHandler } = useErrorHandling()
const menuItems = computed(() => [
{
label: t('g.save'),
icon: 'icon-[lucide--save]',
disabled: !hasOutputs.value,
action: onSave
},
{
label: t('builderMenu.enterAppMode'),
icon: 'icon-[lucide--panels-top-left]',
action: onEnterAppMode
},
{
label: t('builderMenu.exitAppBuilder'),
icon: 'icon-[lucide--x]',
action: onExitBuilder
}
])
async function onSave(close: () => void) {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
@@ -88,13 +75,8 @@ async function onSave(close: () => void) {
}
}
function onEnterAppMode(close: () => void) {
setMode('app')
close()
}
function onExitBuilder(close: () => void) {
appModeStore.exitBuilder()
void appModeStore.exitBuilder()
close()
}
</script>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
@@ -7,13 +7,6 @@ import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n()
const titleTooltip = ref<string | null>(null)
const subTitleTooltip = ref<string | null>(null)
function isTruncated(e: MouseEvent): boolean {
const el = e.currentTarget as HTMLElement
return el.scrollWidth > el.clientWidth
}
const { rename, remove } = defineProps<{
title: string
subTitle?: string
@@ -39,28 +32,15 @@ const entries = computed(() => {
})
</script>
<template>
<div
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"
data-testid="builder-io-item-title"
@mouseenter="titleTooltip = isTruncated($event) ? title : null"
v-text="title"
/>
<div
v-tooltip.left="{ value: subTitleTooltip, showDelay: 300 }"
class="drag-handle truncate text-xs text-muted-foreground"
data-testid="builder-io-item-subtitle"
@mouseenter="
subTitleTooltip = isTruncated($event) ? (subTitle ?? null) : null
"
v-text="subTitle"
/>
</div>
<div class="my-2 flex items-center-safe gap-2 rounded-lg p-2">
<div
class="drag-handle mr-auto inline max-w-max min-w-0 flex-[4_1_0%] truncate"
v-text="title"
/>
<div
class="drag-handle inline max-w-max min-w-0 flex-[2_1_0%] truncate text-end text-muted-foreground"
v-text="subTitle"
/>
<Popover :entries>
<template #button>
<Button variant="muted-textonly">

View File

@@ -22,10 +22,6 @@ const mockApp = vi.hoisted(() => ({
const mockSetMode = vi.hoisted(() => vi.fn())
const mockAppModeStore = vi.hoisted(() => ({
exitBuilder: vi.fn()
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => mockDialogService
}))
@@ -46,10 +42,6 @@ vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ setMode: mockSetMode })
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => mockAppModeStore
}))
vi.mock('./DefaultViewDialogContent.vue', () => ({
default: { name: 'MockDefaultViewDialogContent' }
}))
@@ -216,16 +208,6 @@ describe('useAppSetDefaultView', () => {
expect(mockSetMode).toHaveBeenCalledWith('app')
})
it('onExitToWorkflow exits builder and closes dialog', () => {
const confirmCall = applyAndGetConfirmDialog(true)
confirmCall.props.onExitToWorkflow()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view-applied'
})
expect(mockAppModeStore.exitBuilder).toHaveBeenCalledOnce()
})
it('onClose closes confirmation dialog', () => {
const confirmCall = applyAndGetConfirmDialog(true)

View File

@@ -8,7 +8,6 @@ import { useDialogStore } from '@/stores/dialogStore'
import BuilderDefaultModeAppliedDialogContent from './BuilderDefaultModeAppliedDialogContent.vue'
import DefaultViewDialogContent from './DefaultViewDialogContent.vue'
import { useAppModeStore } from '@/stores/appModeStore'
const DIALOG_KEY = 'builder-default-view'
const APPLIED_DIALOG_KEY = 'builder-default-view-applied'
@@ -17,7 +16,6 @@ export function useAppSetDefaultView() {
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const appModeStore = useAppModeStore()
const { setMode } = useAppMode()
const settingView = computed(() => dialogStore.isDialogOpen(DIALOG_KEY))
@@ -56,10 +54,6 @@ export function useAppSetDefaultView() {
closeAppliedDialog()
setMode('app')
},
onExitToWorkflow: () => {
closeAppliedDialog()
appModeStore.exitBuilder()
},
onClose: closeAppliedDialog
}
})

View File

@@ -17,7 +17,6 @@
:variant="buttonVariant ?? 'textonly'"
@click="$emit('action')"
>
<i v-if="buttonIcon" :class="buttonIcon" />
{{ buttonLabel }}
</Button>
</div>
@@ -38,7 +37,6 @@ const props = defineProps<{
title?: string
message: string
textClass?: string
buttonIcon?: string
buttonLabel?: string
buttonVariant?: ButtonVariants['variant']
}>()

View File

@@ -5,7 +5,6 @@ import {
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
@@ -23,7 +22,6 @@ const { source, align = 'start' } = defineProps<{
const { t } = useI18n()
const canvasStore = useCanvasStore()
const dropdownOpen = ref(false)
const { menuItems } = useWorkflowActionsMenu(
() => useCommandStore().execute('Comfy.RenameWorkflow'),
@@ -42,48 +40,22 @@ function handleOpen(open: boolean) {
})
}
}
function toggleLinearMode() {
dropdownOpen.value = false
void useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source }
})
}
const tooltipPt = {
root: {
style: { transform: 'translateX(calc(50% - 16px))' }
},
arrow: {
class: '!left-[16px]'
}
}
</script>
<template>
<DropdownMenuRoot v-model:open="dropdownOpen" @update:open="handleOpen">
<slot name="button" :has-unseen-items="hasUnseenItems">
<div
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"
>
<DropdownMenuRoot @update:open="handleOpen">
<DropdownMenuTrigger as-child>
<slot name="button" :has-unseen-items="hasUnseenItems">
<Button
v-tooltip.bottom="{
value: canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode'),
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
showDelay: 300,
hideDelay: 300,
pt: tooltipPt
hideDelay: 300
}"
:aria-label="
canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
"
variant="base"
class="m-1"
@pointerdown.stop
@click="toggleLinearMode"
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="pointer-events-auto relative h-10 gap-1 rounded-lg pr-2 pl-3 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i
class="size-4"
@@ -93,36 +65,15 @@ const tooltipPt = {
: 'icon-[comfy--workflow]'
"
/>
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
<DropdownMenuTrigger as-child>
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
showDelay: 300,
hideDelay: 300
}"
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="relative h-10 gap-1 rounded-lg pr-2 pl-2.5 text-center data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<span>{{
canvasStore.linearMode
? t('breadcrumbsMenu.app')
: t('breadcrumbsMenu.graph')
}}</span>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
/>
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</DropdownMenuTrigger>
</div>
</slot>
</slot>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:align

View File

@@ -2,7 +2,7 @@
<div
class="flex flex-col border-t border-border-default px-4 py-2 text-sm wrap-break-word text-muted-foreground"
>
<p v-if="promptTextReal" :class="preserveNewlines && 'whitespace-pre-line'">
<p v-if="promptTextReal">
{{ promptTextReal }}
</p>
</div>
@@ -11,9 +11,8 @@
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
const { promptText, preserveNewlines = false } = defineProps<{
const { promptText } = defineProps<{
promptText?: MaybeRefOrGetter<string>
preserveNewlines?: boolean
}>()
const promptTextReal = computed(() => toValue(promptText))

View File

@@ -5,7 +5,7 @@
</Button>
<Button
:disabled
:variant="confirmVariant ?? 'textonly'"
variant="textonly"
:class="confirmClass"
@click="$emit('confirm')"
>
@@ -19,21 +19,13 @@ import type { MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { ButtonVariants } from '@/components/ui/button/button.variants'
const { t } = useI18n()
const {
cancelText,
confirmText,
confirmClass,
confirmVariant,
optionsDisabled
} = defineProps<{
const { cancelText, confirmText, confirmClass, optionsDisabled } = defineProps<{
cancelText?: string
confirmText?: string
confirmClass?: string
confirmVariant?: ButtonVariants['variant']
optionsDisabled?: MaybeRefOrGetter<boolean>
}>()

View File

@@ -113,4 +113,62 @@ describe('DomWidget disabled style', () => {
expect(root.style.pointerEvents).toBe('none')
expect(root.style.opacity).toBe('0.5')
})
it('uses enabled style when promoted override widget is not computedDisabled', async () => {
const widgetState = createWidgetState(false)
const wrapper = mount(DomWidget, {
props: {
widgetState
}
})
widgetState.zIndex = 3
await wrapper.vm.$nextTick()
const root = wrapper.get('.dom-widget').element as HTMLElement
expect(root.style.pointerEvents).toBe('auto')
expect(root.style.opacity).toBe('1')
})
it('falls back to widget.computedDisabled when no position override exists', async () => {
const domWidgetStore = useDomWidgetStore()
const node = createMockLGraphNode({
id: 3,
constructor: {
nodeData: {}
}
})
const widget = {
id: 'dom-widget-no-override',
name: 'test_widget',
type: 'custom',
value: '',
options: {},
node,
computedDisabled: true
} as unknown as BaseDOMWidget<object | string>
domWidgetStore.registerWidget(widget)
const state = domWidgetStore.widgetStates.get(widget.id)
if (!state) throw new Error('Expected registered DomWidgetState')
state.zIndex = 2
state.size = [100, 40]
const widgetState = reactive(state)
const wrapper = mount(DomWidget, {
props: {
widgetState
}
})
widgetState.zIndex = 3
await wrapper.vm.$nextTick()
const root = wrapper.get('.dom-widget').element as HTMLElement
expect(root.style.pointerEvents).toBe('none')
expect(root.style.opacity).toBe('0.5')
})
})

View File

@@ -2,6 +2,7 @@
<BaseWorkflowsSidebarTab
:title="$t('linearMode.appModeToolbar.apps')"
:filter="isAppWorkflow"
:label-transform="stripAppJsonSuffix"
hide-leaf-icon
:search-subject="$t('linearMode.appModeToolbar.apps')"
data-testid="apps-sidebar"
@@ -17,13 +18,8 @@
<NoResultsPlaceholder
button-variant="secondary"
text-class="text-muted-foreground text-sm"
:message="
isAppMode
? $t('linearMode.appModeToolbar.appsEmptyMessage')
: `${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`
"
button-icon="icon-[lucide--hammer]"
:button-label="isAppMode ? undefined : $t('linearMode.buildAnApp')"
:message="$t('linearMode.appModeToolbar.appsEmptyMessage')"
:button-label="$t('linearMode.appModeToolbar.enterAppMode')"
@action="enterAppMode"
/>
</template>
@@ -36,12 +32,16 @@ import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSide
import { useAppMode } from '@/composables/useAppMode'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
const { isAppMode, setMode } = useAppMode()
const { setMode } = useAppMode()
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
return workflow.suffix === 'app.json'
}
function stripAppJsonSuffix(label: string): string {
return label.replace(/\.app\.json$/i, '')
}
function enterAppMode() {
setMode('app')
}

View File

@@ -154,7 +154,6 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue'
import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useAppMode } from '@/composables/useAppMode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
@@ -164,23 +163,26 @@ import {
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
import {
ensureWorkflowSuffix,
getFilenameDetails,
getWorkflowSuffix
} from '@/utils/formatUtil'
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
import { buildTree, sortedTree } from '@/utils/treeUtil'
const { title, filter, searchSubject, dataTestid, hideLeafIcon } = defineProps<{
const {
title,
filter,
searchSubject,
dataTestid,
labelTransform,
hideLeafIcon
} = defineProps<{
title: string
filter?: (workflow: ComfyWorkflow) => boolean
searchSubject: string
dataTestid: string
labelTransform?: (label: string) => string
hideLeafIcon?: boolean
}>()
const { t } = useI18n()
const { isAppMode } = useAppMode()
const applyFilter = (workflows: ComfyWorkflow[]) =>
filter ? workflows.filter(filter) : workflows
@@ -302,18 +304,14 @@ const renderTreeNode = (
},
contextMenuItems() {
return [
...(isAppMode.value
? []
: [
{
label: t('g.insert'),
icon: 'pi pi-file-export',
command: async () => {
const workflow = node.data
await workflowService.insertWorkflow(workflow)
}
}
]),
{
label: t('g.insert'),
icon: 'pi pi-file-export',
command: async () => {
const workflow = node.data
await workflowService.insertWorkflow(workflow)
}
},
{
label: t('g.duplicate'),
icon: 'pi pi-file-export',
@@ -328,7 +326,8 @@ const renderTreeNode = (
}
: { handleClick }
const label = node.leaf ? getFilenameDetails(node.label).filename : node.label
const label =
node.leaf && labelTransform ? labelTransform(node.label) : node.label
return {
key: node.key,

View File

@@ -9,8 +9,7 @@ const panY = ref(0.0)
function handleWheel(e: WheelEvent) {
const zoomPaneEl = zoomPane.value
if (!zoomPaneEl || (e.deltaY < 0 ? zoom.value > 1200 : zoom.value < -500))
return
if (!zoomPaneEl) return
zoom.value -= e.deltaY
const { x, y, width, height } = zoomPaneEl.getBoundingClientRect()

View File

@@ -1,26 +0,0 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import { Primitive, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
interface Props extends PrimitiveProps {
class?: HTMLAttributes['class']
}
const { as = 'div', class: className = '', ...restProps } = defineProps<Props>()
const forwardedProps = useForwardProps(restProps)
</script>
<template>
<Primitive
v-bind="forwardedProps"
:as
:class="
cn('inline-flex items-stretch overflow-hidden rounded-md', className)
"
>
<slot />
</Primitive>
</template>

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const buttonVariants = cva({
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0',
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
variants: {
variant: {
secondary:
@@ -20,7 +20,6 @@ export const buttonVariants = cva({
'destructive-textonly':
'bg-transparent text-destructive-background hover:bg-destructive-background/10',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
},
@@ -50,7 +49,6 @@ const variants = [
'textonly',
'muted-textonly',
'destructive-textonly',
'base',
'overlay-white',
'gradient'
] as const satisfies Array<ButtonVariants['variant']>

View File

@@ -905,14 +905,6 @@ export function useCoreCommands(): ComfyCommand[] {
app.canvas.pasteFromClipboard()
}
},
{
id: 'Comfy.Canvas.PasteFromClipboardWithConnect',
icon: 'icon-[lucide--clipboard-paste]',
label: () => t('Paste with Connect'),
function: () => {
app.canvas.pasteFromClipboard({ connectInputs: true })
}
},
{
id: 'Comfy.Canvas.SelectAll',
icon: 'icon-[lucide--lasso-select]',
@@ -927,12 +919,6 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Delete Selected Items',
versionAdded: '1.10.5',
function: () => {
if (app.canvas.selectedItems.size === 0) {
app.canvas.canvas.dispatchEvent(
new CustomEvent('litegraph:no-items-selected', { bubbles: true })
)
return
}
app.canvas.deleteSelected()
app.canvas.setDirty(true, true)
}

View File

@@ -1,133 +0,0 @@
import type { SplitterResizeEndEvent } from 'primevue/splitter'
import { nextTick, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { useStablePrimeVueSplitterSizer } from './useStablePrimeVueSplitterSizer'
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
useStorage: <T>(_key: string, defaultValue: T) => ref(defaultValue)
}
})
function createPanel(width: number) {
const el = document.createElement('div')
Object.defineProperty(el, 'offsetWidth', { value: width })
return ref(el)
}
function resizeEndEvent(): SplitterResizeEndEvent {
return { originalEvent: new Event('mouseup'), sizes: [] }
}
async function flushWatcher() {
await nextTick()
await nextTick()
}
describe('useStablePrimeVueSplitterSizer', () => {
it('captures pixel widths on resize end and applies on trigger', async () => {
const panelRef = createPanel(400)
const trigger = ref(0)
const { onResizeEnd } = useStablePrimeVueSplitterSizer(
[{ ref: panelRef, storageKey: 'test-capture' }],
[trigger]
)
await flushWatcher()
onResizeEnd(resizeEndEvent())
trigger.value++
await flushWatcher()
expect(panelRef.value!.style.flexBasis).toBe('400px')
expect(panelRef.value!.style.flexGrow).toBe('0')
expect(panelRef.value!.style.flexShrink).toBe('0')
})
it('does not apply styles when no stored width exists', async () => {
const panelRef = createPanel(300)
const trigger = ref(0)
useStablePrimeVueSplitterSizer(
[{ ref: panelRef, storageKey: 'test-no-stored' }],
[trigger]
)
await flushWatcher()
expect(panelRef.value!.style.flexBasis).toBe('')
})
it('re-applies stored widths when watch sources change', async () => {
const panelRef = createPanel(500)
const trigger = ref(0)
const { onResizeEnd } = useStablePrimeVueSplitterSizer(
[{ ref: panelRef, storageKey: 'test-reapply' }],
[trigger]
)
await flushWatcher()
onResizeEnd(resizeEndEvent())
panelRef.value!.style.flexBasis = ''
panelRef.value!.style.flexGrow = ''
panelRef.value!.style.flexShrink = ''
trigger.value++
await flushWatcher()
expect(panelRef.value!.style.flexBasis).toBe('500px')
expect(panelRef.value!.style.flexGrow).toBe('0')
expect(panelRef.value!.style.flexShrink).toBe('0')
})
it('handles multiple panels independently', async () => {
const leftRef = createPanel(300)
const rightRef = createPanel(250)
const trigger = ref(0)
const { onResizeEnd } = useStablePrimeVueSplitterSizer(
[
{ ref: leftRef, storageKey: 'test-multi-left' },
{ ref: rightRef, storageKey: 'test-multi-right' }
],
[trigger]
)
await flushWatcher()
onResizeEnd(resizeEndEvent())
trigger.value++
await flushWatcher()
expect(leftRef.value!.style.flexBasis).toBe('300px')
expect(rightRef.value!.style.flexBasis).toBe('250px')
})
it('skips panels with null refs', async () => {
const nullRef = ref(null)
const validRef = createPanel(200)
const trigger = ref(0)
const { onResizeEnd } = useStablePrimeVueSplitterSizer(
[
{ ref: nullRef, storageKey: 'test-null' },
{ ref: validRef, storageKey: 'test-valid' }
],
[trigger]
)
await flushWatcher()
onResizeEnd(resizeEndEvent())
trigger.value++
await flushWatcher()
expect(validRef.value!.style.flexBasis).toBe('200px')
})
})

View File

@@ -1,64 +0,0 @@
import type { SplitterResizeEndEvent } from 'primevue/splitter'
import type { WatchSource } from 'vue'
import { unrefElement, useStorage } from '@vueuse/core'
import type { MaybeComputedElementRef } from '@vueuse/core'
import { nextTick, watch } from 'vue'
interface PanelConfig {
ref: MaybeComputedElementRef
storageKey: string
}
/**
* Works around PrimeVue Splitter not properly initializing flexBasis
* when panels are conditionally rendered. Captures pixel widths on
* resize end and re-applies them as rigid flex values (flex: 0 0 Xpx)
* when watched sources change (e.g. tab switch, panel toggle).
*
* @param panels - array of panel configs with template ref and storage key
* @param watchSources - reactive sources that trigger re-application
*/
export function useStablePrimeVueSplitterSizer(
panels: PanelConfig[],
watchSources: WatchSource[]
) {
const storedWidths = panels.map((panel) => ({
ref: panel.ref,
width: useStorage<number | null>(panel.storageKey, null)
}))
function resolveElement(
ref: MaybeComputedElementRef
): HTMLElement | undefined {
return unrefElement(ref) as HTMLElement | undefined
}
function applyStoredWidths() {
for (const { ref, width } of storedWidths) {
const el = resolveElement(ref)
if (!el || width.value === null) continue
el.style.flexBasis = `${width.value}px`
el.style.flexGrow = '0'
el.style.flexShrink = '0'
}
}
function onResizeEnd(_event: SplitterResizeEndEvent) {
for (const { ref, width } of storedWidths) {
const el = resolveElement(ref)
if (el) width.value = el.offsetWidth
}
}
watch(
watchSources,
async () => {
await nextTick()
applyStoredWidths()
},
{ immediate: true }
)
return { onResizeEnd }
}

View File

@@ -40,21 +40,12 @@ const mockMenuItemStore = vi.hoisted(() => ({
hasSeenLinear: false
}))
const mockCanvasStore = vi.hoisted(() => ({
linearMode: false
}))
const mockAppModeStore = vi.hoisted(() => ({
enterBuilder: vi.fn(),
pruneLinearData: vi.fn(
(
data?: Partial<{
inputs: [number | string, string][]
outputs: (number | string)[]
}>
) => ({
inputs: data?.inputs ?? [],
outputs: data?.outputs ?? []
})
),
selectedInputs: [] as [number | string, string][],
selectedOutputs: [] as (number | string)[]
enterBuilder: vi.fn()
}))
const mockFeatureFlags = vi.hoisted(() => ({
@@ -82,12 +73,14 @@ vi.mock('@/stores/menuItemStore', () => ({
useMenuItemStore: vi.fn(() => mockMenuItemStore)
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => mockCanvasStore)
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: vi.fn(() => mockAppModeStore)
}))
vi.mock('@/composables/useErrorHandling', () => ({}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => mockFeatureFlags)
}))
@@ -117,9 +110,8 @@ describe('useWorkflowActionsMenu', () => {
mockBookmarkStore.isBookmarked.mockReturnValue(false)
mockSubgraphStore.isSubgraphBlueprint.mockReturnValue(false)
mockMenuItemStore.hasSeenLinear = false
mockCanvasStore.linearMode = false
mockFeatureFlags.flags.linearToggleEnabled = false
mockAppModeStore.selectedInputs.length = 0
mockAppModeStore.selectedOutputs.length = 0
mockWorkflowStore.activeWorkflow = {
path: 'test.json',
isPersisted: true
@@ -200,11 +192,7 @@ describe('useWorkflowActionsMenu', () => {
it('shows "go to workflow mode" when in linear mode', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
mockWorkflowStore.activeWorkflow = {
path: 'test.json',
isPersisted: true,
activeMode: 'app'
} as ComfyWorkflow
mockCanvasStore.linearMode = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
@@ -322,22 +310,6 @@ describe('useWorkflowActionsMenu', () => {
expect(mockAppModeStore.enterBuilder).toHaveBeenCalled()
})
it('shows "Edit app" when workflow has linear data', async () => {
mockFeatureFlags.flags.linearToggleEnabled = true
mockWorkflowStore.activeWorkflow = {
path: 'test.json',
isPersisted: true
} as ComfyWorkflow
mockAppModeStore.selectedInputs.push([1, 'widget'])
mockAppModeStore.selectedOutputs.push(2)
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const item = findItem(menuItems.value, 'breadcrumbsMenu.editBuilderMode')
expect(item).toBeDefined()
expect(item.isNew).toBeTruthy()
})
it('app mode toggle executes Comfy.ToggleLinear', async () => {
mockFeatureFlags.flags.linearToggleEnabled = true

View File

@@ -2,16 +2,14 @@ import type { ComputedRef, Ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { openShareDialog } from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
@@ -53,9 +51,9 @@ export function useWorkflowActionsMenu(
const commandStore = useCommandStore()
const subgraphStore = useSubgraphStore()
const menuItemStore = useMenuItemStore()
const canvasStore = useCanvasStore()
const { flags } = useFeatureFlags()
const appModeStore = useAppModeStore()
const { enterBuilder, pruneLinearData } = appModeStore
const { enterBuilder } = useAppModeStore()
const targetWorkflow = computed(
() => workflow?.value ?? workflowStore.activeWorkflow
@@ -95,15 +93,12 @@ export function useWorkflowActionsMenu(
items.push(item)
}
const workflowMode =
workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
const isLinearMode = workflowMode === 'app'
const isLinearMode = canvasStore.linearMode
const showAppModeItems =
isRoot && (menuItemStore.hasSeenLinear || flags.linearToggleEnabled)
const isBookmarked = bookmarkStore.isBookmarked(workflow?.path ?? '')
const toggleLinear = async () => {
await ensureWorkflowActive(targetWorkflow.value)
await commandStore.execute('Comfy.ToggleLinear', {
metadata: { source: 'breadcrumb_menu' }
})
@@ -194,11 +189,11 @@ export function useWorkflowActionsMenu(
addItem({
id: 'share',
label: t('breadcrumbsMenu.share'),
label: t('menuLabels.Share'),
icon: 'icon-[comfy--send]',
command: () =>
openShareDialog().catch(useErrorHandling().toastErrorHandler),
visible: isCloud && flags.workflowSharingEnabled
command: async () => {},
disabled: true,
visible: isRoot
})
addItem({
@@ -220,31 +215,11 @@ export function useWorkflowActionsMenu(
prependSeparator: true
})
const isActive = workflow === workflowStore.activeWorkflow
const rawLd = isActive
? {
inputs: appModeStore.selectedInputs,
outputs: appModeStore.selectedOutputs
}
: workflow?.changeTracker?.activeState?.extra?.linearData
let hasLinearData: boolean
if (rawLd) {
const { inputs, outputs } = pruneLinearData(rawLd)
hasLinearData = inputs.length > 0 || outputs.length > 0
} else {
hasLinearData = workflow?.path?.endsWith('.app.json') ?? false
}
addItem({
id: 'enter-builder-mode',
label: hasLinearData
? t('breadcrumbsMenu.editBuilderMode')
: t('breadcrumbsMenu.enterBuilderMode'),
label: t('breadcrumbsMenu.enterBuilderMode'),
icon: 'icon-[lucide--hammer]',
command: async () => {
await ensureWorkflowActive(targetWorkflow.value)
enterBuilder()
},
command: () => enterBuilder(),
visible: showAppModeItems,
isNew: true
})

View File

@@ -9,7 +9,6 @@ import {
LiteGraph,
LLink
} from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -18,10 +17,6 @@ import {
createTestSubgraphNode
} from './subgraph/__fixtures__/subgraphHelpers'
import { duplicateSubgraphNodeIds } from './__fixtures__/duplicateSubgraphNodeIds'
import { nestedSubgraphProxyWidgets } from './__fixtures__/nestedSubgraphProxyWidgets'
import { nodeIdSpaceExhausted } from './__fixtures__/nodeIdSpaceExhausted'
import { uniqueSubgraphNodeIds } from './__fixtures__/uniqueSubgraphNodeIds'
import { test } from './__fixtures__/testExtensions'
function swapNodes(nodes: LGraphNode[]) {
@@ -661,121 +656,3 @@ describe('Subgraph Unpacking', () => {
expect(definitionIds).toContain(subgraph.id)
})
})
describe('deduplicateSubgraphNodeIds (via configure)', () => {
const SUBGRAPH_A = '11111111-1111-4111-8111-111111111111' as UUID
const SUBGRAPH_B = '22222222-2222-4222-8222-222222222222' as UUID
const SHARED_NODE_IDS = [3, 8, 37]
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
LiteGraph.registerNodeType('dummy', DummyNode)
})
function loadFixture(): SerialisableGraph {
return structuredClone(duplicateSubgraphNodeIds)
}
function configureFromFixture() {
const graphData = loadFixture()
const graph = new LGraph()
graph.configure(graphData)
return { graph, graphData }
}
function nodeIdSet(graph: LGraph, subgraphId: UUID) {
return new Set(graph.subgraphs.get(subgraphId)!.nodes.map((n) => n.id))
}
it('remaps duplicate node IDs so subgraphs have no overlap', () => {
const { graph } = configureFromFixture()
const idsA = nodeIdSet(graph, SUBGRAPH_A)
const idsB = nodeIdSet(graph, SUBGRAPH_B)
for (const id of SHARED_NODE_IDS) {
expect(idsA.has(id as NodeId)).toBe(true)
}
for (const id of idsA) {
expect(idsB.has(id)).toBe(false)
}
})
it('patches link references in remapped subgraph', () => {
const { graph } = configureFromFixture()
const idsB = nodeIdSet(graph, SUBGRAPH_B)
for (const link of graph.subgraphs.get(SUBGRAPH_B)!.links.values()) {
expect(idsB.has(link.origin_id)).toBe(true)
expect(idsB.has(link.target_id)).toBe(true)
}
})
it('patches promoted widget references in remapped subgraph', () => {
const { graph } = configureFromFixture()
const idsB = nodeIdSet(graph, SUBGRAPH_B)
for (const widget of graph.subgraphs.get(SUBGRAPH_B)!.widgets) {
expect(idsB.has(widget.id)).toBe(true)
}
})
it('patches proxyWidgets in root-level nodes referencing remapped IDs', () => {
const { graph } = configureFromFixture()
const idsA = new Set(
graph.subgraphs.get(SUBGRAPH_A)!.nodes.map((n) => String(n.id))
)
const idsB = new Set(
graph.subgraphs.get(SUBGRAPH_B)!.nodes.map((n) => String(n.id))
)
const pw102 = graph.getNodeById(102 as NodeId)?.properties?.proxyWidgets
expect(Array.isArray(pw102)).toBe(true)
for (const entry of pw102 as unknown[][]) {
expect(Array.isArray(entry)).toBe(true)
expect(idsA.has(String(entry[0]))).toBe(true)
}
const pw103 = graph.getNodeById(103 as NodeId)?.properties?.proxyWidgets
expect(Array.isArray(pw103)).toBe(true)
for (const entry of pw103 as unknown[][]) {
expect(Array.isArray(entry)).toBe(true)
expect(idsB.has(String(entry[0]))).toBe(true)
}
})
it('patches proxyWidgets inside nested subgraph nodes', () => {
const graph = new LGraph()
graph.configure(structuredClone(nestedSubgraphProxyWidgets))
const idsB = new Set(
graph.subgraphs.get(SUBGRAPH_B)!.nodes.map((n) => String(n.id))
)
const innerNode = graph.subgraphs
.get(SUBGRAPH_A)!
.nodes.find((n) => n.id === (50 as NodeId))
const pw = innerNode?.properties?.proxyWidgets
expect(Array.isArray(pw)).toBe(true)
for (const entry of pw as unknown[][]) {
expect(Array.isArray(entry)).toBe(true)
expect(idsB.has(String(entry[0]))).toBe(true)
}
})
it('throws when node ID space is exhausted', () => {
expect(() => {
const graph = new LGraph()
graph.configure(structuredClone(nodeIdSpaceExhausted))
}).toThrow('Node ID space exhausted')
})
it('is a no-op when subgraph node IDs are already unique', () => {
const graph = new LGraph()
graph.configure(structuredClone(uniqueSubgraphNodeIds))
expect(nodeIdSet(graph, SUBGRAPH_A)).toEqual(new Set([10, 11, 12]))
expect(nodeIdSet(graph, SUBGRAPH_B)).toEqual(new Set([20, 21, 22]))
})
})

View File

@@ -77,7 +77,6 @@ import type {
SerialisableReroute
} from './types/serialisation'
import { getAllNestedItems } from './utils/collections'
import { deduplicateSubgraphNodeIds } from './utils/subgraphDeduplication'
export type {
LGraphTriggerAction,
@@ -2476,49 +2475,28 @@ export class LGraph
this[i] = data[i]
}
// Subgraph definitions — deduplicate node IDs before configuring.
// deduplicateSubgraphNodeIds clones internally to avoid mutating
// the caller's data (e.g. reactive Pinia state).
// Subgraph definitions
const subgraphs = data.definitions?.subgraphs
let effectiveNodesData = nodesData
if (subgraphs) {
const reservedNodeIds = new Set<number>()
for (const node of this._nodes) {
if (typeof node.id === 'number') reservedNodeIds.add(node.id)
}
for (const sg of this.subgraphs.values()) {
for (const node of sg.nodes) {
if (typeof node.id === 'number') reservedNodeIds.add(node.id)
}
}
for (const n of nodesData ?? []) {
if (typeof n.id === 'number') reservedNodeIds.add(n.id)
}
const deduplicated = this.isRootGraph
? deduplicateSubgraphNodeIds(
subgraphs,
reservedNodeIds,
this.state,
nodesData
)
: undefined
const finalSubgraphs = deduplicated?.subgraphs ?? subgraphs
effectiveNodesData = deduplicated?.rootNodes ?? nodesData
for (const subgraph of finalSubgraphs) this.createSubgraph(subgraph)
for (const subgraph of finalSubgraphs)
for (const subgraph of subgraphs) this.createSubgraph(subgraph)
for (const subgraph of subgraphs)
this.subgraphs.get(subgraph.id)?.configure(subgraph)
}
if (this.isRootGraph) {
const reservedNodeIds = nodesData
?.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
this.ensureGlobalIdUniqueness(reservedNodeIds)
}
let error = false
const nodeDataMap = new Map<NodeId, ISerialisedNode>()
// create nodes
this._nodes = []
if (effectiveNodesData) {
for (const n_info of effectiveNodesData) {
if (nodesData) {
for (const n_info of nodesData) {
// stored info
let node = LiteGraph.createNode(String(n_info.type), n_info.title)
if (!node) {

View File

@@ -3791,6 +3791,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
return
}
private _noItemsSelected(): void {
const event = new CustomEvent('litegraph:no-items-selected', {
bubbles: true
})
this.canvas.dispatchEvent(event)
}
/**
* process a key event
*/
@@ -3835,6 +3842,31 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.node_panel?.close()
this.options_panel?.close()
if (this.node_panel || this.options_panel) block_default = true
} else if (e.keyCode === 65 && e.ctrlKey) {
// select all Control A
this.selectItems()
block_default = true
} else if (e.keyCode === 67 && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
// copy
if (this.selected_nodes) {
this.copyToClipboard()
block_default = true
}
} else if (e.keyCode === 86 && (e.metaKey || e.ctrlKey)) {
// paste
this.pasteFromClipboard({ connectInputs: e.shiftKey })
} else if (e.key === 'Delete' || e.key === 'Backspace') {
// delete or backspace
// @ts-expect-error EventTarget.localName is not in standard types
if (e.target.localName != 'input' && e.target.localName != 'textarea') {
if (this.selectedItems.size === 0) {
this._noItemsSelected()
return
}
this.deleteSelected()
block_default = true
}
}
// TODO

View File

@@ -1,163 +0,0 @@
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
/**
* Workflow with two subgraph definitions whose internal nodes share
* identical IDs [3, 8, 37]. Reproduces the widget-state collision bug
* where copied subgraphs overwrote each other's widget store entries.
*
* SubgraphA (node 102): widgets reference node 3, link 3→8
* SubgraphB (node 103): widgets reference node 8, link 3→37
*/
export const duplicateSubgraphNodeIds = {
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
version: 1,
revision: 0,
state: {
lastNodeId: 100,
lastLinkId: 10,
lastGroupId: 0,
lastRerouteId: 0
},
nodes: [
{
id: 102,
type: '11111111-1111-4111-8111-111111111111',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
properties: { proxyWidgets: [['3', 'seed']] }
},
{
id: 103,
type: '22222222-2222-4222-8222-222222222222',
pos: [300, 0],
size: [200, 100],
flags: {},
order: 1,
mode: 0,
properties: { proxyWidgets: [['8', 'prompt']] }
}
],
definitions: {
subgraphs: [
{
id: '11111111-1111-4111-8111-111111111111',
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
name: 'SubgraphA',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 3, name: 'seed' }],
nodes: [
{
id: 3,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
mode: 0
},
{
id: 8,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 1,
mode: 0
},
{
id: 37,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
}
],
links: [
{
id: 1,
origin_id: 3,
origin_slot: 0,
target_id: 8,
target_slot: 0,
type: 'number'
}
],
groups: []
},
{
id: '22222222-2222-4222-8222-222222222222',
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
name: 'SubgraphB',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 8, name: 'prompt' }],
nodes: [
{
id: 3,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
mode: 0
},
{
id: 8,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 1,
mode: 0
},
{
id: 37,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
}
],
links: [
{
id: 2,
origin_id: 3,
origin_slot: 0,
target_id: 37,
target_slot: 0,
type: 'string'
}
],
groups: []
}
]
}
} as const satisfies SerialisableGraph

View File

@@ -1,177 +0,0 @@
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
/**
* Workflow where SubgraphA contains a nested SubgraphNode referencing
* SubgraphB. Both subgraph definitions share internal node IDs [3, 8, 37].
*
* The nested SubgraphNode (id 50, inside SubgraphA) has proxyWidgets
* pointing at SubgraphB's node 8. After deduplication remaps SubgraphB's
* nodes, the nested proxyWidgets must also be patched.
*
* SubgraphA (node 102): widgets reference node 3, link 3→8,
* contains nested SubgraphNode(50) → SubgraphB with proxyWidget ['8']
* SubgraphB (node 103): widgets reference node 8, link 3→37
*/
export const nestedSubgraphProxyWidgets = {
id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
version: 1,
revision: 0,
state: {
lastNodeId: 100,
lastLinkId: 10,
lastGroupId: 0,
lastRerouteId: 0
},
nodes: [
{
id: 102,
type: '11111111-1111-4111-8111-111111111111',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
properties: { proxyWidgets: [['3', 'seed']] }
},
{
id: 103,
type: '22222222-2222-4222-8222-222222222222',
pos: [300, 0],
size: [200, 100],
flags: {},
order: 1,
mode: 0,
properties: { proxyWidgets: [['8', 'prompt']] }
}
],
definitions: {
subgraphs: [
{
id: '11111111-1111-4111-8111-111111111111',
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
name: 'SubgraphA',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 3, name: 'seed' }],
nodes: [
{
id: 3,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
mode: 0
},
{
id: 8,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 1,
mode: 0
},
{
id: 37,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
},
{
id: 50,
type: '22222222-2222-4222-8222-222222222222',
pos: [200, 0],
size: [100, 50],
flags: {},
order: 3,
mode: 0,
properties: { proxyWidgets: [['8', 'prompt']] }
}
],
links: [
{
id: 1,
origin_id: 3,
origin_slot: 0,
target_id: 8,
target_slot: 0,
type: 'number'
}
],
groups: []
},
{
id: '22222222-2222-4222-8222-222222222222',
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
name: 'SubgraphB',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 8, name: 'prompt' }],
nodes: [
{
id: 3,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
mode: 0
},
{
id: 8,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 1,
mode: 0
},
{
id: 37,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
}
],
links: [
{
id: 2,
origin_id: 3,
origin_slot: 0,
target_id: 37,
target_slot: 0,
type: 'string'
}
],
groups: []
}
]
}
} as const satisfies SerialisableGraph

View File

@@ -1,172 +0,0 @@
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
/**
* Workflow where lastNodeId is near the MAX_NODE_ID ceiling (100_000_000)
* and root node 100_000_000 reserves the only remaining candidate ID.
*
* Both subgraph definitions share node IDs [3, 8, 37]. When SubgraphB's
* duplicates need remapping, candidate 100_000_000 is already reserved,
* so the next candidate (100_000_001) exceeds MAX_NODE_ID and must throw.
*/
export const nodeIdSpaceExhausted = {
id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
version: 1,
revision: 0,
state: {
lastNodeId: 99_999_999,
lastLinkId: 10,
lastGroupId: 0,
lastRerouteId: 0
},
nodes: [
{
id: 102,
type: '11111111-1111-4111-8111-111111111111',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
properties: { proxyWidgets: [['3', 'seed']] }
},
{
id: 103,
type: '22222222-2222-4222-8222-222222222222',
pos: [300, 0],
size: [200, 100],
flags: {},
order: 1,
mode: 0,
properties: { proxyWidgets: [['8', 'prompt']] }
},
{
id: 100_000_000,
type: 'dummy',
pos: [600, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
}
],
definitions: {
subgraphs: [
{
id: '11111111-1111-4111-8111-111111111111',
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
name: 'SubgraphA',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 3, name: 'seed' }],
nodes: [
{
id: 3,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
mode: 0
},
{
id: 8,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 1,
mode: 0
},
{
id: 37,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
}
],
links: [
{
id: 1,
origin_id: 3,
origin_slot: 0,
target_id: 8,
target_slot: 0,
type: 'number'
}
],
groups: []
},
{
id: '22222222-2222-4222-8222-222222222222',
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
name: 'SubgraphB',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 8, name: 'prompt' }],
nodes: [
{
id: 3,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
mode: 0
},
{
id: 8,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 1,
mode: 0
},
{
id: 37,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
}
],
links: [
{
id: 2,
origin_id: 3,
origin_slot: 0,
target_id: 37,
target_slot: 0,
type: 'string'
}
],
groups: []
}
]
}
} as const satisfies SerialisableGraph

View File

@@ -1,163 +0,0 @@
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
/**
* Workflow with two subgraph definitions whose internal nodes already
* have unique IDs. Deduplication should be a no-op — all IDs, links,
* widgets, and proxyWidgets pass through unchanged.
*
* SubgraphA (node 102): nodes [10, 11, 12], link 10→11, widget ref 10
* SubgraphB (node 103): nodes [20, 21, 22], link 20→22, widget ref 21
*/
export const uniqueSubgraphNodeIds = {
id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
version: 1,
revision: 0,
state: {
lastNodeId: 100,
lastLinkId: 10,
lastGroupId: 0,
lastRerouteId: 0
},
nodes: [
{
id: 102,
type: '11111111-1111-4111-8111-111111111111',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
properties: { proxyWidgets: [['10', 'seed']] }
},
{
id: 103,
type: '22222222-2222-4222-8222-222222222222',
pos: [300, 0],
size: [200, 100],
flags: {},
order: 1,
mode: 0,
properties: { proxyWidgets: [['21', 'prompt']] }
}
],
definitions: {
subgraphs: [
{
id: '11111111-1111-4111-8111-111111111111',
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
name: 'SubgraphA',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 10, name: 'seed' }],
nodes: [
{
id: 10,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
mode: 0
},
{
id: 11,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 1,
mode: 0
},
{
id: 12,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
}
],
links: [
{
id: 1,
origin_id: 10,
origin_slot: 0,
target_id: 11,
target_slot: 0,
type: 'number'
}
],
groups: []
},
{
id: '22222222-2222-4222-8222-222222222222',
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
name: 'SubgraphB',
config: {},
inputNode: { id: -10, bounding: [10, 100, 150, 126] },
outputNode: { id: -20, bounding: [400, 100, 140, 126] },
inputs: [],
outputs: [],
widgets: [{ id: 21, name: 'prompt' }],
nodes: [
{
id: 20,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
mode: 0
},
{
id: 21,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 1,
mode: 0
},
{
id: 22,
type: 'dummy',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 2,
mode: 0
}
],
links: [
{
id: 2,
origin_id: 20,
origin_slot: 0,
target_id: 22,
target_slot: 0,
type: 'string'
}
],
groups: []
}
]
}
} as const satisfies SerialisableGraph

View File

@@ -649,52 +649,49 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
this._addSubgraphInputListeners(subgraphInput, input)
this._resolveInputWidget(subgraphInput, input)
// Find the first widget that this slot is connected to
for (const linkId of subgraphInput.linkIds) {
const link = this.subgraph.getLink(linkId)
if (!link) {
console.warn(
`[SubgraphNode.configure] No link found for link ID ${linkId}`,
this
)
continue
}
const { inputNode } = link.resolve(this.subgraph)
if (!inputNode) {
console.warn('Failed to resolve inputNode', link, this)
continue
}
//Manually find input since target_slot can't be trusted
const targetInput = inputNode.inputs.find((inp) => inp.link === linkId)
if (!targetInput) {
console.warn('Failed to find corresponding input', link, inputNode)
continue
}
// No widget - ignore this link
const widget = inputNode.getWidgetFromSlot(targetInput)
if (!widget) continue
this._setWidget(
subgraphInput,
input,
widget,
targetInput.widget,
inputNode
)
break
}
}
this._syncPromotions()
}
private _resolveInputWidget(
subgraphInput: SubgraphInput,
input: INodeInputSlot
) {
for (const linkId of subgraphInput.linkIds) {
const link = this.subgraph.getLink(linkId)
if (!link) {
console.warn(
`[SubgraphNode.configure] No link found for link ID ${linkId}`,
this
)
continue
}
const { inputNode } = link.resolve(this.subgraph)
if (!inputNode) {
console.warn('Failed to resolve inputNode', link, this)
continue
}
const targetInput = inputNode.inputs.find((inp) => inp.link === linkId)
if (!targetInput) {
console.warn('Failed to find corresponding input', link, inputNode)
continue
}
const widget = inputNode.getWidgetFromSlot(targetInput)
if (!widget) continue
this._setWidget(
subgraphInput,
input,
widget,
targetInput.widget,
inputNode
)
break
}
}
private _setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,

View File

@@ -1,164 +0,0 @@
import type { LGraphState } from '../LGraph'
import type { NodeId } from '../LGraphNode'
import type {
ExportedSubgraph,
ExposedWidget,
ISerialisedNode,
SerialisableLLink
} from '../types/serialisation'
const MAX_NODE_ID = 100_000_000
interface DeduplicationResult {
subgraphs: ExportedSubgraph[]
rootNodes: ISerialisedNode[] | undefined
}
/**
* Pre-deduplicates node IDs across serialized subgraph definitions before
* they are configured. This prevents widget store key collisions when
* multiple subgraph copies contain nodes with the same IDs.
*
* Also patches proxyWidgets in root-level nodes that reference the
* remapped inner node IDs.
*
* Returns deep clones of the inputs — the originals are never mutated.
*
* @param subgraphs - Serialized subgraph definitions to deduplicate
* @param reservedNodeIds - Node IDs already in use by root-level nodes
* @param state - Graph state containing the `lastNodeId` counter (mutated)
* @param rootNodes - Optional root-level nodes with proxyWidgets to patch
*/
export function deduplicateSubgraphNodeIds(
subgraphs: ExportedSubgraph[],
reservedNodeIds: Set<number>,
state: LGraphState,
rootNodes?: ISerialisedNode[]
): DeduplicationResult {
const clonedSubgraphs = structuredClone(subgraphs)
const clonedRootNodes = rootNodes ? structuredClone(rootNodes) : undefined
const usedNodeIds = new Set(reservedNodeIds)
const subgraphIdSet = new Set(clonedSubgraphs.map((sg) => sg.id))
const remapBySubgraph = new Map<string, Map<NodeId, NodeId>>()
for (const subgraph of clonedSubgraphs) {
const remappedIds = remapNodeIds(subgraph.nodes ?? [], usedNodeIds, state)
if (remappedIds.size === 0) continue
remapBySubgraph.set(subgraph.id, remappedIds)
patchSerialisedLinks(subgraph.links ?? [], remappedIds)
patchPromotedWidgets(subgraph.widgets ?? [], remappedIds)
}
for (const subgraph of clonedSubgraphs) {
patchProxyWidgets(subgraph.nodes ?? [], subgraphIdSet, remapBySubgraph)
}
if (clonedRootNodes) {
patchProxyWidgets(clonedRootNodes, subgraphIdSet, remapBySubgraph)
}
return { subgraphs: clonedSubgraphs, rootNodes: clonedRootNodes }
}
/**
* Remaps duplicate node IDs to unique values, updating `usedNodeIds`
* and `state.lastNodeId` as new IDs are allocated.
*
* @returns A map of old ID → new ID for nodes that were remapped.
*/
function remapNodeIds(
nodes: ISerialisedNode[],
usedNodeIds: Set<number>,
state: LGraphState
): Map<NodeId, NodeId> {
const remappedIds = new Map<NodeId, NodeId>()
for (const node of nodes) {
const id = node.id
if (typeof id !== 'number') continue
if (usedNodeIds.has(id)) {
const newId = findNextAvailableId(usedNodeIds, state)
remappedIds.set(id, newId)
node.id = newId
usedNodeIds.add(newId as number)
console.warn(
`LiteGraph: duplicate subgraph node ID ${id} remapped to ${newId}`
)
} else {
usedNodeIds.add(id)
if (id > state.lastNodeId) state.lastNodeId = id
}
}
return remappedIds
}
/**
* Finds the next unused node ID by incrementing `state.lastNodeId`.
* Throws if the ID space is exhausted.
*/
function findNextAvailableId(
usedNodeIds: Set<number>,
state: LGraphState
): NodeId {
while (true) {
const nextId = state.lastNodeId + 1
if (nextId > MAX_NODE_ID) {
throw new Error('Node ID space exhausted')
}
state.lastNodeId = nextId
if (!usedNodeIds.has(nextId)) return nextId as NodeId
}
}
/** Patches origin_id / target_id in serialized links. */
function patchSerialisedLinks(
links: SerialisableLLink[],
remappedIds: Map<NodeId, NodeId>
): void {
for (const link of links) {
const newOrigin = remappedIds.get(link.origin_id)
if (newOrigin !== undefined) link.origin_id = newOrigin
const newTarget = remappedIds.get(link.target_id)
if (newTarget !== undefined) link.target_id = newTarget
}
}
/** Patches promoted widget node references. */
function patchPromotedWidgets(
widgets: ExposedWidget[],
remappedIds: Map<NodeId, NodeId>
): void {
for (const widget of widgets) {
const newId = remappedIds.get(widget.id)
if (newId !== undefined) widget.id = newId
}
}
/** Patches proxyWidgets in root-level SubgraphNode instances. */
function patchProxyWidgets(
rootNodes: ISerialisedNode[],
subgraphIdSet: Set<string>,
remapBySubgraph: Map<string, Map<NodeId, NodeId>>
): void {
for (const node of rootNodes) {
if (!subgraphIdSet.has(node.type)) continue
const remappedIds = remapBySubgraph.get(node.type)
if (!remappedIds) continue
const proxyWidgets = node.properties?.proxyWidgets
if (!Array.isArray(proxyWidgets)) continue
for (const entry of proxyWidgets) {
if (!Array.isArray(entry)) continue
const oldId = Number(entry[0]) as NodeId
const newId = remappedIds.get(oldId)
if (newId !== undefined) entry[0] = String(newId)
}
}
}

View File

@@ -1262,7 +1262,6 @@
"Move Selected Nodes Right": "Move Selected Nodes Right",
"Move Selected Nodes Up": "Move Selected Nodes Up",
"Paste": "Paste",
"Paste with Connect": "Paste with Connect",
"Reset View": "Reset View",
"Resize Selected Nodes": "Resize Selected Nodes",
"Select All": "Select All",
@@ -1330,7 +1329,6 @@
"Rename": "Rename",
"Save": "Save",
"Save As": "Save As",
"Share": "Share",
"Show Settings Dialog": "Show Settings Dialog",
"Set Subgraph Description": "Set Subgraph Description",
"Set Subgraph Search Aliases": "Set Subgraph Search Aliases",
@@ -2599,18 +2597,13 @@
"duplicate": "Duplicate",
"enterAppMode": "Enter app mode",
"exitAppMode": "Exit app mode",
"enterBuilderMode": "Build app",
"editBuilderMode": "Edit app",
"enterBuilderMode": "App builder",
"workflowActions": "Workflow actions",
"clearWorkflow": "Clear Workflow",
"deleteWorkflow": "Delete Workflow",
"deleteBlueprint": "Delete Blueprint",
"enterNewName": "Enter new name",
"missingNodesWarning": "Workflow contains unsupported nodes (highlighted red).",
"graph": "Graph",
"app": "App",
"enterNodeGraph": "Enter node graph",
"share": "Share"
"missingNodesWarning": "Workflow contains unsupported nodes (highlighted red)."
},
"shortcuts": {
"shortcuts": "Shortcuts",
@@ -3009,11 +3002,6 @@
"share": "Share",
"shareTooltip": "Share workflow"
},
"shareNoOutputs": {
"title": "App has no outputs",
"message": "You're about to share an app without outputs. It can't be used until an output is connected.\n\nShare anyway?",
"shareAnyway": "Share anyway"
},
"shareWorkflow": {
"shareLinkTab": "Share",
"publishToHubTab": "Publish",
@@ -3163,7 +3151,6 @@
"linearMode": {
"linearMode": "App Mode",
"beta": "App mode in beta",
"buildAnApp": "Build an app",
"giveFeedback": "Give feedback",
"graphMode": "Graph Mode",
"dragAndDropImage": "Click to browse or drag an image",
@@ -3171,29 +3158,25 @@
"runCount": "Number of runs",
"rerun": "Rerun",
"reuseParameters": "Reuse Parameters",
"downloadAll": "Download {count} assets from this run",
"downloadAll": "Download All",
"viewJob": "View Job",
"enterNodeGraph": "Enter node graph",
"emptyWorkflowExplanation": "Your workflow is empty. You need some nodes first to start building an app.",
"backToWorkflow": "Back to workflow",
"loadTemplate": "Load a template",
"cancelThisRun": "Cancel this run",
"deleteAllAssets": "Delete all assets from this run",
"welcome": {
"title": "App Mode",
"message": "A simplified view that hides the node graph so you can focus on creating.",
"controls": "Your outputs appear at the bottom, your controls are on the right. Everything else stays out of the way.",
"sharing": "Share your workflow as a simple tool anyone can use. Export it from the tab menu and when others open it, they'll see App Mode. No node graph knowledge needed.",
"getStarted": "Click {runButton} to get started.",
"buildApp": "Build app",
"noOutputs": "An app needs at least {count} to be usable.",
"oneOutput": "1 output"
"buildApp": "Build app"
},
"appModeToolbar": {
"appBuilder": "App builder",
"apps": "Apps",
"appsEmptyMessage": "Saved apps will show up here.",
"appsEmptyMessageAction": "Click below to build your first app."
"appsEmptyMessage": "Saved apps will show up here.\nClick below to build your first app.",
"enterAppMode": "Enter app mode"
},
"arrange": {
"noOutputs": "No outputs added yet",
@@ -3218,10 +3201,7 @@
"noOutputs": "No output nodes added yet",
"outputsDesc": "Connect at least one output node so users can see results after running.",
"outputsExample": "Examples: “Save Image” or “Save Video”",
"unknownWidget": "Widget not visible",
"inputPlaceholder": "Inputs will show up here",
"outputPlaceholder": "Output nodes will show up here",
"outputRequiredPlaceholder": "At least one node is required"
"unknownWidget": "Widget not visible"
},
"queue": {
"clickToClear": "Click to clear queue",
@@ -3533,12 +3513,10 @@
"defaultModeAppliedGraphBody": "This workflow will open as a node graph by default from now on.",
"defaultModeAppliedGraphPrompt": "Would you like to view the app still?",
"viewApp": "View app",
"exitToWorkflow": "Exit to workflow",
"emptyWorkflowTitle": "This workflow has no nodes",
"emptyWorkflowPrompt": "Do you want to start with a template?"
},
"builderMenu": {
"enterAppMode": "Enter app mode",
"exitAppBuilder": "Exit app builder"
}
}

View File

@@ -208,52 +208,5 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
key: 'Escape'
},
commandId: 'Comfy.Graph.ExitSubgraph'
},
{
combo: {
ctrl: true,
key: 'a'
},
commandId: 'Comfy.Canvas.SelectAll',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
key: 'c'
},
commandId: 'Comfy.Canvas.CopySelected',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
key: 'v'
},
commandId: 'Comfy.Canvas.PasteFromClipboard',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
shift: true,
key: 'v'
},
commandId: 'Comfy.Canvas.PasteFromClipboardWithConnect',
targetElementId: 'graph-canvas-container'
},
{
combo: {
key: 'Delete'
},
commandId: 'Comfy.Canvas.DeleteSelectedItems',
targetElementId: 'graph-canvas-container'
},
{
combo: {
key: 'Backspace'
},
commandId: 'Comfy.Canvas.DeleteSelectedItems',
targetElementId: 'graph-canvas-container'
}
]

View File

@@ -1,11 +1,22 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
vi.mock('@/scripts/app', () => {
return {
app: {
canvas: {
processKey: vi.fn()
}
}
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => [])
@@ -25,15 +36,13 @@ function createTestKeyboardEvent(
ctrlKey?: boolean
altKey?: boolean
metaKey?: boolean
shiftKey?: boolean
} = {}
): KeyboardEvent {
const {
target = document.body,
ctrlKey = false,
altKey = false,
metaKey = false,
shiftKey = false
metaKey = false
} = options
const event = new KeyboardEvent('keydown', {
@@ -41,7 +50,6 @@ function createTestKeyboardEvent(
ctrlKey,
altKey,
metaKey,
shiftKey,
bubbles: true,
cancelable: true
})
@@ -52,10 +60,8 @@ function createTestKeyboardEvent(
return event
}
describe('keybindingService - Canvas Keybindings', () => {
describe('keybindingService - Event Forwarding', () => {
let keybindingService: ReturnType<typeof useKeybindingService>
let canvasContainer: HTMLDivElement
let canvasChild: HTMLCanvasElement
beforeEach(() => {
vi.clearAllMocks()
@@ -70,156 +76,94 @@ describe('keybindingService - Canvas Keybindings', () => {
typeof useDialogStore
>)
canvasContainer = document.createElement('div')
canvasContainer.id = 'graph-canvas-container'
canvasChild = document.createElement('canvas')
canvasContainer.appendChild(canvasChild)
document.body.appendChild(canvasContainer)
keybindingService = useKeybindingService()
keybindingService.registerCoreKeybindings()
})
afterEach(() => {
canvasContainer.remove()
})
it('should execute DeleteSelectedItems for Delete key on canvas', async () => {
const event = createTestKeyboardEvent('Delete', {
target: canvasChild
})
it('should forward Delete key to canvas when no keybinding exists', async () => {
const event = createTestKeyboardEvent('Delete')
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.DeleteSelectedItems'
)
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should execute DeleteSelectedItems for Backspace key on canvas', async () => {
const event = createTestKeyboardEvent('Backspace', {
target: canvasChild
})
it('should forward Backspace key to canvas when no keybinding exists', async () => {
const event = createTestKeyboardEvent('Backspace')
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.DeleteSelectedItems'
)
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should not execute DeleteSelectedItems when typing in input field', async () => {
it('should not forward Delete key when typing in input field', async () => {
const inputElement = document.createElement('input')
const event = createTestKeyboardEvent('Delete', { target: inputElement })
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should not execute DeleteSelectedItems when typing in textarea', async () => {
it('should not forward Delete key when typing in textarea', async () => {
const textareaElement = document.createElement('textarea')
const event = createTestKeyboardEvent('Delete', {
target: textareaElement
})
const event = createTestKeyboardEvent('Delete', { target: textareaElement })
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should execute SelectAll for Ctrl+A on canvas', async () => {
const event = createTestKeyboardEvent('a', {
ctrlKey: true,
target: canvasChild
})
it('should not forward Delete key when canvas processKey is not available', async () => {
// Temporarily replace processKey with undefined - testing edge case
const originalProcessKey = vi.mocked(app.canvas).processKey
vi.mocked(app.canvas).processKey = undefined!
await keybindingService.keybindHandler(event)
const event = createTestKeyboardEvent('Delete')
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.SelectAll'
)
try {
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
} finally {
// Restore processKey for other tests
vi.mocked(app.canvas).processKey = originalProcessKey
}
})
it('should execute CopySelected for Ctrl+C on canvas', async () => {
const event = createTestKeyboardEvent('c', {
ctrlKey: true,
target: canvasChild
})
it('should not forward Delete key when canvas is not available', async () => {
const originalCanvas = vi.mocked(app).canvas
vi.mocked(app).canvas = null!
await keybindingService.keybindHandler(event)
const event = createTestKeyboardEvent('Delete')
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.CopySelected'
)
try {
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
} finally {
// Restore canvas for other tests
vi.mocked(app).canvas = originalCanvas
}
})
it('should execute PasteFromClipboard for Ctrl+V on canvas', async () => {
const event = createTestKeyboardEvent('v', {
ctrlKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.PasteFromClipboard'
)
})
it('should execute PasteFromClipboardWithConnect for Ctrl+Shift+V on canvas', async () => {
const event = createTestKeyboardEvent('v', {
ctrlKey: true,
shiftKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.PasteFromClipboardWithConnect'
)
})
it('should execute graph-canvas bindings by normalizing to graph-canvas-container', async () => {
const event = createTestKeyboardEvent('=', {
altKey: true,
target: canvasChild
})
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.ZoomIn'
)
})
it('should not execute graph-canvas bindings when target is outside canvas', async () => {
const outsideDiv = document.createElement('div')
document.body.appendChild(outsideDiv)
const event = createTestKeyboardEvent('=', {
altKey: true,
target: outsideDiv
})
it('should not forward non-canvas keys', async () => {
const event = createTestKeyboardEvent('Enter')
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
outsideDiv.remove()
})
it('should not execute canvas commands when target is outside canvas container', async () => {
const outsideDiv = document.createElement('div')
document.body.appendChild(outsideDiv)
const event = createTestKeyboardEvent('Delete', {
target: outsideDiv
})
it('should not forward when modifier keys are pressed', async () => {
const event = createTestKeyboardEvent('Delete', { ctrlKey: true })
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
outsideDiv.remove()
})
})

View File

@@ -1,5 +1,6 @@
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -14,6 +15,16 @@ export function useKeybindingService() {
const settingStore = useSettingStore()
const dialogStore = useDialogStore()
function shouldForwardToCanvas(event: KeyboardEvent): boolean {
if (event.ctrlKey || event.altKey || event.metaKey) {
return false
}
const canvasKeys = ['Delete', 'Backspace']
return canvasKeys.includes(event.key)
}
async function keybindHandler(event: KeyboardEvent) {
const keyCombo = KeyComboImpl.fromEvent(event)
if (keyCombo.isModifier) {
@@ -33,17 +44,7 @@ export function useKeybindingService() {
}
const keybinding = keybindingStore.getKeybinding(keyCombo)
if (keybinding) {
const targetElementId =
keybinding.targetElementId === 'graph-canvas'
? 'graph-canvas-container'
: keybinding.targetElementId
if (targetElementId) {
const container = document.getElementById(targetElementId)
if (!container?.contains(target)) {
return
}
}
if (keybinding && keybinding.targetElementId !== 'graph-canvas') {
if (
event.key === 'Escape' &&
!event.ctrlKey &&
@@ -73,6 +74,18 @@ export function useKeybindingService() {
return
}
if (!keybinding && shouldForwardToCanvas(event)) {
const canvas = app.canvas
if (
canvas &&
canvas.processKey &&
typeof canvas.processKey === 'function'
) {
canvas.processKey(event)
return
}
}
if (event.ctrlKey || event.altKey || event.metaKey) {
return
}

View File

@@ -1,57 +1,18 @@
import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { useWorkflowStore } from '../../management/stores/workflowStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import { t } from '@/i18n'
const DIALOG_KEY = 'global-share-workflow'
export function useShareDialog() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const { pruneLinearData } = useAppModeStore()
const workflowStore = useWorkflowStore()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function showNoOutputsDialogIfRequired(share: () => void) {
const wf = workflowStore.activeWorkflow
if (!wf) return share()
const isAppDefault = wf.initialMode === 'app'
const linearData = wf.changeTracker?.activeState?.extra?.linearData
const { outputs } = pruneLinearData(linearData)
if (isAppDefault && outputs.length === 0) {
const dialog = showConfirmDialog({
headerProps: {
title: t('shareNoOutputs.title')
},
props: {
promptText: t('shareNoOutputs.message'),
preserveNewlines: true
},
footerProps: {
confirmText: t('shareNoOutputs.shareAnyway'),
confirmVariant: 'secondary',
onCancel: () => dialogStore.closeDialog(dialog),
onConfirm: () => {
dialogStore.closeDialog(dialog)
share()
}
}
})
return
}
share()
}
function showShareDialog() {
function show() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: ShareWorkflowDialogContent,
@@ -68,10 +29,6 @@ export function useShareDialog() {
})
}
function show() {
showNoOutputsDialogIfRequired(showShareDialog)
}
return {
show,
hide

View File

@@ -8,7 +8,7 @@ import { cn } from '@/utils/tailwindUtil'
const { id, name } = defineProps<{
id: string
enable: boolean
isSelectInputsMode: boolean
name: string
}>()
@@ -25,7 +25,7 @@ function togglePromotion() {
</script>
<template>
<div
v-if="enable"
v-if="isSelectInputsMode"
class="pointer-events-auto relative col-span-2 flex cursor-pointer flex-row gap-1"
@pointerdown.capture.stop.prevent="togglePromotion"
@click.capture.stop.prevent

View File

@@ -2,9 +2,6 @@
import { ref, useTemplateRef } from 'vue'
import ZoomPane from '@/components/ui/ZoomPane.vue'
import { cn } from '@/utils/tailwindUtil'
defineOptions({ inheritAttrs: false })
const { src } = defineProps<{
src: string
@@ -16,11 +13,7 @@ const width = ref('')
const height = ref('')
</script>
<template>
<ZoomPane
v-if="!mobile"
v-slot="slotProps"
:class="cn('w-full flex-1', $attrs.class as string)"
>
<ZoomPane v-if="!mobile" v-slot="slotProps" class="w-full flex-1">
<img
ref="imageRef"
:src

View File

@@ -1,43 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
import { useAppModeStore } from '@/stores/appModeStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import Button from '@/components/ui/button/Button.vue'
import { storeToRefs } from 'pinia'
const { t } = useI18n()
const { setMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const nodeOutputStore = useNodeOutputStore()
const { nodeIdToNodeLocatorId } = useWorkflowStore()
const existingOutput = computed(() => {
for (const nodeId of appModeStore.selectedOutputs) {
const locatorId = nodeIdToNodeLocatorId(nodeId)
const nodeOutput = nodeOutputStore.nodeOutputs[locatorId]
if (!nodeOutput) continue
const results = flattenNodeOutput([nodeId, nodeOutput])
if (results.length > 0) return results[0]
}
return undefined
})
const { hasOutputs } = storeToRefs(useAppModeStore())
</script>
<template>
<MediaOutputPreview
v-if="existingOutput"
:output="existingOutput"
class="px-12 py-24"
/>
<div
v-else-if="hasOutputs"
v-if="hasOutputs"
role="article"
data-testid="arrange-preview"
class="mx-auto flex h-full w-3/4 flex-col items-center justify-center gap-6 p-8"
@@ -48,7 +23,7 @@ const existingOutput = computed(() => {
<p class="mb-0 font-bold text-base-foreground">
{{ t('linearMode.arrange.outputs') }}
</p>
<p class="text-center">{{ t('linearMode.arrange.resultsLabel') }}</p>
<p>{{ t('linearMode.arrange.resultsLabel') }}</p>
</div>
</div>
<div

View File

@@ -11,7 +11,6 @@ import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
@@ -45,7 +44,7 @@ const props = defineProps<{
mobile?: boolean
}>()
defineEmits<{ navigateOutputs: [] }>()
defineEmits<{ navigateAssets: [] }>()
//NOTE: due to batching, will never be greater than 2
const pendingJobQueues = ref(0)
@@ -73,7 +72,7 @@ const mappedSelections = computed(() => {
).map(([, widgetName]) => widgetName)
unprocessedInputs = unprocessedInputs.slice(inputGroup.length)
const node = resolveNode(nodeId)
if (node?.mode !== LGraphEventMode.ALWAYS) continue
if (!node) continue
const nodeData = nodeToNodeData(node)
remove(nodeData.widgets ?? [], (w) => !inputGroup.includes(w.name))
@@ -106,7 +105,6 @@ function getDropIndicator(node: LGraphNode) {
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
const nodeData = extractVueNodeData(node)
remove(nodeData.widgets ?? [], (w) => w.slotMetadata?.linked ?? false)
for (const widget of nodeData.widgets ?? []) widget.slotMetadata = undefined
return {
@@ -263,7 +261,7 @@ defineExpose({ runButtonClick })
<Button
v-if="mobile"
variant="inverted"
@click="$emit('navigateOutputs')"
@click="$emit('navigateAssets')"
>
{{ t('linearMode.viewJob') }}
</Button>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { defineAsyncComponent, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
@@ -15,18 +15,31 @@ import LatentPreview from '@/renderer/extensions/linearMode/LatentPreview.vue'
import LinearWelcome from '@/renderer/extensions/linearMode/LinearWelcome.vue'
import LinearArrange from '@/renderer/extensions/linearMode/LinearArrange.vue'
import LinearFeedback from '@/renderer/extensions/linearMode/LinearFeedback.vue'
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl } from '@/stores/queueStore'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
// Lazy-loaded to avoid pulling THREE.js into the main bundle
const Preview3d = defineAsyncComponent(
() => import('@/renderer/extensions/linearMode/Preview3d.vue')
)
const { t } = useI18n()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const mediaActions = useMediaAssetActions()
const queueStore = useQueueStore()
const { isBuilderMode, isArrangeMode } = useAppMode()
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
useOutputHistory()
const { allOutputs } = useOutputHistory()
const { runButtonClick, mobile, typeformWidgetId } = defineProps<{
runButtonClick?: (e: Event) => void
mobile?: boolean
@@ -37,14 +50,12 @@ const selectedItem = ref<AssetItem>()
const selectedOutput = ref<ResultItemImpl>()
const canShowPreview = ref(true)
const latentPreview = ref<string>()
const showSkeleton = ref(false)
function handleSelection(sel: OutputSelection) {
selectedItem.value = sel.asset
selectedOutput.value = sel.output
canShowPreview.value = sel.canShowPreview
latentPreview.value = sel.latentPreviewUrl
showSkeleton.value = sel.showSkeleton ?? false
}
function downloadAsset(item?: AssetItem) {
@@ -62,18 +73,23 @@ async function loadWorkflow(item: AssetItem | undefined) {
const changeTracker = useWorkflowStore().activeWorkflow?.changeTracker
if (!changeTracker) return app.loadGraphData(workflow)
changeTracker.redoQueue = []
await changeTracker.updateState([workflow], changeTracker.undoQueue)
changeTracker.updateState([workflow], changeTracker.undoQueue)
}
async function rerun(e: Event) {
if (!runButtonClick) return
await loadWorkflow(selectedItem.value)
//FIXME don't use timeouts here
//Currently seeds fail to properly update even with timeouts?
await new Promise((r) => setTimeout(r, 500))
executeWidgetsCallback(collectAllNodes(app.rootGraph), 'afterQueued')
runButtonClick(e)
}
</script>
<template>
<section
v-if="selectedItem || selectedOutput || showSkeleton || isWorkflowActive"
v-if="selectedItem || selectedOutput || !executionStore.isIdle"
data-testid="linear-output-info"
class="flex w-full flex-wrap justify-center gap-2 p-4 text-sm tabular-nums md:z-10"
>
@@ -90,7 +106,6 @@ async function rerun(e: Event) {
</template>
<Button
v-if="selectedOutput"
v-tooltip.top="t('g.download')"
size="icon"
:aria-label="t('g.download')"
@click="
@@ -102,47 +117,60 @@ async function rerun(e: Event) {
<i class="icon-[lucide--download]" />
</Button>
<Button
v-if="isWorkflowActive && !selectedItem"
v-if="!executionStore.isIdle && !selectedItem"
variant="destructive"
@click="cancelActiveWorkflowJobs()"
size="icon"
:aria-label="t('menu.interrupt')"
@click="commandStore.execute('Comfy.Interrupt')"
>
<i class="icon-[lucide--x]" />
{{ t('linearMode.cancelThisRun') }}
</Button>
<Popover
v-if="selectedItem"
:entries="[
...(allOutputs(selectedItem).length > 1
? [
{
icon: 'icon-[lucide--download]',
label: t('linearMode.downloadAll', {
count: allOutputs(selectedItem).length
}),
command: () => downloadAsset(selectedItem)
},
{ separator: true }
]
: []),
{
icon: 'icon-[lucide--download]',
label: t('linearMode.downloadAll'),
command: () => downloadAsset(selectedItem)
},
{ separator: true },
{
icon: 'icon-[lucide--trash-2]',
label: t('linearMode.deleteAllAssets'),
label: t('queue.jobMenu.deleteAsset'),
command: () => mediaActions.deleteAssets(selectedItem!)
}
]"
/>
</section>
<ImagePreview
v-if="canShowPreview && latentPreview"
v-if="
(canShowPreview && latentPreview) ||
getMediaType(selectedOutput) === 'images'
"
:mobile
:src="latentPreview"
:src="(canShowPreview && latentPreview) || selectedOutput!.url"
/>
<MediaOutputPreview
v-else-if="selectedOutput"
:output="selectedOutput"
:mobile
<VideoPreview
v-else-if="getMediaType(selectedOutput) === 'video'"
:src="selectedOutput!.url"
class="flex-1 object-contain md:p-3 md:contain-size"
/>
<LatentPreview v-else-if="showSkeleton || isWorkflowActive" />
<audio
v-else-if="getMediaType(selectedOutput) === 'audio'"
class="m-auto w-full"
controls
:src="selectedOutput!.url"
/>
<article
v-else-if="getMediaType(selectedOutput) === 'text'"
class="m-auto my-12 w-full max-w-lg overflow-y-auto"
v-text="selectedOutput!.url"
/>
<Preview3d
v-else-if="getMediaType(selectedOutput) === '3d'"
:model-url="selectedOutput!.url"
/>
<LatentPreview v-else-if="queueStore.runningTasks.length > 0" />
<LinearArrange v-else-if="isArrangeMode" />
<LinearWelcome v-else />
<div
@@ -156,7 +184,7 @@ async function rerun(e: Event) {
/>
<OutputHistory
v-if="!isBuilderMode"
class="z-10 min-w-0"
class="min-w-0"
@update-selection="handleSelection"
/>
<LinearFeedback

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
defineOptions({ inheritAttrs: false })
@@ -18,14 +18,14 @@ const {
}>()
const { totalPercent, currentNodePercent } = useQueueProgress()
const executionStore = useExecutionStore()
const queueStore = useQueueStore()
</script>
<template>
<div
:class="
cn(
'relative h-2 bg-secondary-background transition-opacity',
!executionStore.isActiveWorkflowRunning && 'opacity-0',
queueStore.runningTasks.length === 0 && 'opacity-0',
rounded && 'rounded-sm',
className
)

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