mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-19 03:47:31 +00:00
Compare commits
38 Commits
fix/load-a
...
v1.41.20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2254852b2 | ||
|
|
49bec80f12 | ||
|
|
2d3de22150 | ||
|
|
65079e51ca | ||
|
|
c068f3b9e6 | ||
|
|
804bfd3c38 | ||
|
|
d245752882 | ||
|
|
1daa6909a3 | ||
|
|
8801002a23 | ||
|
|
73fd0e9700 | ||
|
|
3a0d7fbaa8 | ||
|
|
84bca7581c | ||
|
|
ff375e0f50 | ||
|
|
8f9625bb91 | ||
|
|
0d60b07260 | ||
|
|
e4be6af9ca | ||
|
|
7f5737bd7d | ||
|
|
30ff10f8b6 | ||
|
|
e9445610aa | ||
|
|
5397017e49 | ||
|
|
d2e1993c8f | ||
|
|
7a7f6380f0 | ||
|
|
782599995e | ||
|
|
078c414cdf | ||
|
|
7c69c5f4c6 | ||
|
|
a1af7e454c | ||
|
|
0596e36202 | ||
|
|
bca61c75f2 | ||
|
|
441ffec3bc | ||
|
|
be14ce3348 | ||
|
|
ffd334cdaa | ||
|
|
98ad2a9672 | ||
|
|
95e5981f2f | ||
|
|
e3287d4c95 | ||
|
|
d65d8ec06e | ||
|
|
648a964531 | ||
|
|
0f889a95d9 | ||
|
|
1fc437bb41 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,6 +26,7 @@ dist-ssr
|
||||
.claude/*.local.json
|
||||
.claude/*.local.md
|
||||
.claude/*.local.txt
|
||||
.claude/worktrees
|
||||
CLAUDE.local.md
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<template v-if="filter.tasks.length === 0">
|
||||
<!-- Empty filter -->
|
||||
<Divider />
|
||||
<p class="text-neutral-400 w-full text-center">
|
||||
<p class="w-full text-center text-neutral-400">
|
||||
{{ $t('maintenance.allOk') }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
<!-- Display: Cards -->
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
|
||||
<div class="pad-y my-4 flex flex-wrap justify-evenly gap-8">
|
||||
<TaskCard
|
||||
v-for="task in filter.tasks"
|
||||
:key="task.id"
|
||||
@@ -45,7 +45,8 @@ import { useConfirm, useToast } from 'primevue'
|
||||
import ConfirmPopup from 'primevue/confirmpopup'
|
||||
import Divider from 'primevue/divider'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type {
|
||||
MaintenanceFilter,
|
||||
@@ -55,6 +56,7 @@ import type {
|
||||
import TaskCard from './TaskCard.vue'
|
||||
import TaskListItem from './TaskListItem.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
@@ -80,8 +82,7 @@ const executeTask = async (task: MaintenanceTask) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('maintenance.error.toastTitle'),
|
||||
detail: message ?? t('maintenance.error.defaultDescription'),
|
||||
life: 10_000
|
||||
detail: message ?? t('maintenance.error.defaultDescription')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -189,8 +189,7 @@ const completeValidation = async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('maintenance.error.cannotContinue'),
|
||||
life: 5_000
|
||||
detail: t('maintenance.error.cannotContinue')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark hide-language-selector>
|
||||
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
|
||||
<div class="flex h-full flex-col items-center justify-center p-8 2xl:p-16">
|
||||
<div
|
||||
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"
|
||||
class="flex w-full max-w-[600px] flex-col gap-6 rounded-lg bg-neutral-800 p-6 shadow-lg"
|
||||
>
|
||||
<h2 class="text-3xl font-semibold text-neutral-100">
|
||||
{{ $t('install.helpImprove') }}
|
||||
@@ -15,7 +15,7 @@
|
||||
<a
|
||||
href="https://comfy.org/privacy"
|
||||
target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 underline"
|
||||
class="text-blue-400 underline hover:text-blue-300"
|
||||
>
|
||||
{{ $t('install.privacyPolicy') }} </a
|
||||
>.
|
||||
@@ -33,7 +33,7 @@
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex pt-6 justify-end">
|
||||
<div class="flex justify-end pt-6">
|
||||
<Button
|
||||
:label="$t('g.ok')"
|
||||
icon="pi pi-check"
|
||||
@@ -72,8 +72,7 @@ const updateConsent = async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('install.settings.errorUpdatingConsent'),
|
||||
detail: t('install.settings.errorUpdatingConsentDetail'),
|
||||
life: 3000
|
||||
detail: t('install.settings.errorUpdatingConsentDetail')
|
||||
})
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
|
||||
47
browser_tests/assets/nodes/load_image_with_ksampler.json
Normal file
47
browser_tests/assets/nodes/load_image_with_ksampler.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": null },
|
||||
{ "name": "MASK", "type": "MASK", "links": null }
|
||||
],
|
||||
"properties": { "Node name for S&R": "LoadImage" },
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "KSampler",
|
||||
"pos": [500, 50],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": null }],
|
||||
"properties": { "Node name for S&R": "KSampler" },
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "normal", 1]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "offset": [0, 0], "scale": 1 }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
58
browser_tests/tests/imagePastePriority.spec.ts
Normal file
58
browser_tests/tests/imagePastePriority.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Image paste priority over stale node metadata',
|
||||
{ tag: ['@node'] },
|
||||
() => {
|
||||
test('Should not paste copied node when a LoadImage node is selected and clipboard has stale node metadata', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
|
||||
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(initialCount).toBe(2)
|
||||
|
||||
// Copy the KSampler node (puts data-metadata in clipboard)
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
await ksamplerNodes[0].copy()
|
||||
|
||||
// Select the LoadImage node
|
||||
const loadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
await loadImageNodes[0].click('title')
|
||||
|
||||
// Simulate pasting when clipboard has stale node metadata (text/html
|
||||
// with data-metadata) but no image file items. This replicates the bug
|
||||
// scenario: user copied a node, then copied a web image (which replaces
|
||||
// clipboard files but may leave stale text/html with node metadata).
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const nodeData = { nodes: [{ type: 'KSampler', id: 99 }] }
|
||||
const base64 = btoa(JSON.stringify(nodeData))
|
||||
const html =
|
||||
'<meta charset="utf-8"><div><span data-metadata="' +
|
||||
base64 +
|
||||
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/html', html)
|
||||
|
||||
const event = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Node count should remain the same — stale node metadata should NOT
|
||||
// be deserialized when a media node is selected.
|
||||
const finalCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(finalCount).toBe(initialCount)
|
||||
})
|
||||
}
|
||||
)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 119 KiB |
102
browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts
Normal file
102
browser_tests/tests/vueNodes/widgets/advancedWidgets.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Advanced Widget Visibility', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets',
|
||||
false
|
||||
)
|
||||
|
||||
// Add a ModelSamplingFlux node which has both advanced (max_shift,
|
||||
// base_shift) and non-advanced (width, height) widgets.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('ModelSamplingFlux')!
|
||||
node.pos = [500, 200]
|
||||
window.app!.graph.add(node)
|
||||
})
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
function getNode(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
return comfyPage.vueNodes.getNodeByTitle('ModelSamplingFlux')
|
||||
}
|
||||
|
||||
function getWidgets(
|
||||
comfyPage: Parameters<Parameters<typeof test>[2]>[0]['comfyPage']
|
||||
) {
|
||||
return getNode(comfyPage).locator('.lg-node-widget')
|
||||
}
|
||||
|
||||
test('should hide advanced widgets by default', async ({ comfyPage }) => {
|
||||
const node = getNode(comfyPage)
|
||||
const widgets = getWidgets(comfyPage)
|
||||
|
||||
// Non-advanced widgets (width, height) should be visible
|
||||
await expect(widgets).toHaveCount(2)
|
||||
await expect(node.getByLabel('width', { exact: true })).toBeVisible()
|
||||
await expect(node.getByLabel('height', { exact: true })).toBeVisible()
|
||||
|
||||
// Advanced widgets should not be rendered
|
||||
await expect(
|
||||
node.getByLabel('max_shift', { exact: true })
|
||||
).not.toBeVisible()
|
||||
await expect(
|
||||
node.getByLabel('base_shift', { exact: true })
|
||||
).not.toBeVisible()
|
||||
|
||||
// "Show advanced inputs" button should be present
|
||||
await expect(node.getByText('Show advanced inputs')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show advanced widgets when per-node toggle is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = getNode(comfyPage)
|
||||
const widgets = getWidgets(comfyPage)
|
||||
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
// Click the toggle button to show advanced widgets
|
||||
await node.getByText('Show advanced inputs').click()
|
||||
|
||||
await expect(widgets).toHaveCount(4)
|
||||
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
|
||||
|
||||
// Button text should change to "Hide advanced inputs"
|
||||
await expect(node.getByText('Hide advanced inputs')).toBeVisible()
|
||||
|
||||
// Click again to hide
|
||||
await node.getByText('Hide advanced inputs').click()
|
||||
await expect(widgets).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('should show advanced widgets when global setting is enabled', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = getNode(comfyPage)
|
||||
const widgets = getWidgets(comfyPage)
|
||||
|
||||
await expect(widgets).toHaveCount(2)
|
||||
|
||||
// Enable the global setting
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Node.AlwaysShowAdvancedWidgets',
|
||||
true
|
||||
)
|
||||
|
||||
// All 4 widgets should now be visible
|
||||
await expect(widgets).toHaveCount(4)
|
||||
await expect(node.getByLabel('max_shift', { exact: true })).toBeVisible()
|
||||
await expect(node.getByLabel('base_shift', { exact: true })).toBeVisible()
|
||||
|
||||
// The toggle button should not be shown when global setting is active
|
||||
await expect(node.getByText('Show advanced inputs')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.13",
|
||||
"version": "1.41.20",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -46,71 +46,74 @@ function showApps() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-auto flex flex-col gap-2">
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar" />
|
||||
|
||||
<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-row items-start gap-2">
|
||||
<div class="pointer-events-auto flex flex-col gap-2">
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.mediaAssets.title'),
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.mediaAssets.title')"
|
||||
:class="
|
||||
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="openAssets"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.apps'),
|
||||
value: t('actionbar.shareTooltip'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.apps')"
|
||||
:class="cn('size-10', isAppsActive && 'bg-secondary-background-hover')"
|
||||
@click="showApps"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
<i class="icon-[lucide--send] 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>
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -8,9 +8,9 @@ import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import IoItem from '@/components/builder/IoItem.vue'
|
||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
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,
|
||||
@@ -25,10 +25,10 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
@@ -53,18 +53,15 @@ workflowStore.activeWorkflow?.changeTracker?.reset()
|
||||
const arrangeInputs = computed(() =>
|
||||
appModeStore.selectedInputs
|
||||
.map(([nodeId, widgetName]) => {
|
||||
const node = resolveNode(nodeId)
|
||||
if (!node) return null
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
return { nodeId, widgetName, node, widget }
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
return node ? { nodeId, widgetName, node, widget } : null
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null)
|
||||
)
|
||||
|
||||
const inputsWithState = computed(() =>
|
||||
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
||||
const node = resolveNode(nodeId)
|
||||
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node || !widget) {
|
||||
return {
|
||||
nodeId,
|
||||
@@ -73,15 +70,12 @@ const inputsWithState = computed(() =>
|
||||
}
|
||||
}
|
||||
|
||||
const input = node.inputs.find((i) => i.widget?.name === widget.name)
|
||||
const rename = input && (() => renameWidget(widget, input))
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
label: widget.label,
|
||||
subLabel: node.title,
|
||||
rename
|
||||
rename: () => promptRenameWidget(widget, node, t)
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -92,20 +86,6 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
])
|
||||
)
|
||||
|
||||
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
|
||||
const newLabel = await useDialogService().prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewNamePrompt'),
|
||||
defaultValue: widget.label,
|
||||
placeholder: widget.name
|
||||
})
|
||||
if (newLabel === null) return
|
||||
widget.label = newLabel || undefined
|
||||
input.label = newLabel || undefined
|
||||
widget.callback?.(widget.value)
|
||||
useCanvasStore().canvas?.setDirty(true)
|
||||
}
|
||||
|
||||
function getHovered(
|
||||
e: MouseEvent
|
||||
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
|
||||
@@ -126,7 +106,7 @@ function getHovered(
|
||||
|
||||
function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
|
||||
const node = app.rootGraph.getNodeById(nodeId)
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node) return
|
||||
|
||||
const titleOffset =
|
||||
@@ -139,7 +119,6 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
|
||||
left: `${node.pos[0]}px`,
|
||||
top: `${node.pos[1] - titleOffset}px`
|
||||
}
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (!widget) return
|
||||
|
||||
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
|
||||
@@ -162,7 +141,11 @@ function handleDown(e: MouseEvent) {
|
||||
}
|
||||
function handleClick(e: MouseEvent) {
|
||||
const [node, widget] = getHovered(e) ?? []
|
||||
if (node?.mode !== LGraphEventMode.ALWAYS)
|
||||
if (
|
||||
node?.mode !== LGraphEventMode.ALWAYS ||
|
||||
!nodeTypeValidForApp(node.type) ||
|
||||
node.has_errors
|
||||
)
|
||||
return canvasInteractions.forwardEventToCanvas(e)
|
||||
|
||||
if (!widget) {
|
||||
@@ -174,12 +157,16 @@ function handleClick(e: MouseEvent) {
|
||||
else appModeStore.selectedOutputs.splice(index, 1)
|
||||
return
|
||||
}
|
||||
if (!isSelectInputsMode.value) return
|
||||
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
|
||||
|
||||
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
||||
const storeName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
const index = appModeStore.selectedInputs.findIndex(
|
||||
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
|
||||
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
|
||||
)
|
||||
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
|
||||
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
|
||||
else appModeStore.selectedInputs.splice(index, 1)
|
||||
}
|
||||
|
||||
@@ -198,7 +185,9 @@ const renderedOutputs = computed(() => {
|
||||
return canvas
|
||||
.graph!.nodes.filter(
|
||||
(n) =>
|
||||
n.constructor.nodeData?.output_node && n.mode === LGraphEventMode.ALWAYS
|
||||
n.constructor.nodeData?.output_node &&
|
||||
n.mode === LGraphEventMode.ALWAYS &&
|
||||
!n.has_errors
|
||||
)
|
||||
.map(nodeToDisplayTuple)
|
||||
})
|
||||
@@ -260,7 +249,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.inputs') }}
|
||||
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
|
||||
<i class="icon-[lucide--info] bg-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
@@ -315,7 +304,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.outputs') }}
|
||||
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
|
||||
<i class="icon-[lucide--info] bg-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
|
||||
@@ -72,7 +72,7 @@ const menuItems = computed(() => [
|
||||
},
|
||||
{
|
||||
label: t('builderMenu.exitAppBuilder'),
|
||||
icon: 'icon-[lucide--square-pen]',
|
||||
icon: 'icon-[lucide--x]',
|
||||
action: onExitBuilder
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
@@ -7,6 +7,13 @@ 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
|
||||
@@ -32,15 +39,28 @@ const entries = computed(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
<Popover :entries>
|
||||
<template #button>
|
||||
<Button variant="muted-textonly">
|
||||
|
||||
51
src/components/common/Dialogue.vue
Normal file
51
src/components/common/Dialogue.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{ title?: string; to?: string | HTMLElement }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
<template>
|
||||
<DialogRoot v-slot="{ close }">
|
||||
<DialogTrigger as-child>
|
||||
<slot name="button" />
|
||||
</DialogTrigger>
|
||||
<DialogPortal :to>
|
||||
<DialogOverlay
|
||||
class="data-[state=open]:animate-overlayShow fixed inset-0 z-30 bg-black/70"
|
||||
/>
|
||||
<DialogContent
|
||||
v-bind="$attrs"
|
||||
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
|
||||
>
|
||||
<div
|
||||
v-if="title"
|
||||
class="flex w-full items-center justify-between border-b border-border-subtle px-4"
|
||||
>
|
||||
<DialogTitle class="text-sm">{{ title }}</DialogTitle>
|
||||
<DialogClose as-child>
|
||||
<Button
|
||||
:aria-label="t('g.close')"
|
||||
size="icon"
|
||||
variant="muted-textonly"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<slot :close />
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -54,11 +54,12 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
:disabled="toValue(item.disabled) ?? !item.command"
|
||||
@select="item.command?.({ originalEvent: $event, item })"
|
||||
>
|
||||
<i class="size-5" :class="item.icon" />
|
||||
{{ item.label }}
|
||||
<i class="size-5 shrink-0" :class="item.icon" />
|
||||
<div class="mr-auto truncate" v-text="item.label" />
|
||||
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
|
||||
<div
|
||||
v-if="item.new"
|
||||
class="ml-auto flex items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
|
||||
v-else-if="item.new"
|
||||
class="flex shrink-0 items-center rounded-full bg-primary-background px-1 text-xxs leading-none font-bold"
|
||||
v-text="t('contextMenu.new')"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -27,7 +27,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
|
||||
const itemClass = computed(() =>
|
||||
cn(
|
||||
'm-1 flex cursor-pointer gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
|
||||
'm-1 flex cursor-pointer items-center-safe gap-1 rounded-lg p-2 leading-none data-disabled:pointer-events-none data-disabled:text-muted-foreground data-highlighted:bg-secondary-background-hover',
|
||||
itemProp
|
||||
)
|
||||
)
|
||||
|
||||
@@ -33,19 +33,20 @@
|
||||
spellcheck="false"
|
||||
@blur="handleBlur"
|
||||
@keyup.enter="handleBlur"
|
||||
@dragstart.prevent
|
||||
@keydown.up.prevent="updateValueBy(step)"
|
||||
@keydown.down.prevent="updateValueBy(-step)"
|
||||
@keydown.page-up.prevent="updateValueBy(10 * step)"
|
||||
@keydown.page-down.prevent="updateValueBy(-10 * step)"
|
||||
/>
|
||||
<div
|
||||
ref="swipeElement"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 z-10 cursor-ew-resize',
|
||||
'absolute inset-0 z-10 cursor-ew-resize touch-pan-y',
|
||||
textEdit && 'pointer-events-none hidden'
|
||||
)
|
||||
"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointercancel="resetDrag"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
@@ -65,7 +66,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { onClickOutside, usePointerSwipe, whenever } from '@vueuse/core'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -73,8 +74,8 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
min,
|
||||
max,
|
||||
min = -Number.MAX_VALUE,
|
||||
max = Number.MAX_VALUE,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
hideButtons = false,
|
||||
@@ -96,6 +97,7 @@ const modelValue = defineModel<number>({ default: 0 })
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const inputField = useTemplateRef<HTMLInputElement>('inputField')
|
||||
const swipeElement = useTemplateRef('swipeElement')
|
||||
const textEdit = ref(false)
|
||||
|
||||
onClickOutside(container, () => {
|
||||
@@ -103,21 +105,11 @@ onClickOutside(container, () => {
|
||||
})
|
||||
|
||||
function clamp(value: number): number {
|
||||
const lo = min ?? -Infinity
|
||||
const hi = max ?? Infinity
|
||||
return Math.min(hi, Math.max(lo, value))
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
const canDecrement = computed(
|
||||
() => modelValue.value > (min ?? -Infinity) && !disabled
|
||||
)
|
||||
const canIncrement = computed(
|
||||
() => modelValue.value < (max ?? Infinity) && !disabled
|
||||
)
|
||||
|
||||
const dragging = ref(false)
|
||||
const dragDelta = ref(0)
|
||||
const hasDragged = ref(false)
|
||||
const canDecrement = computed(() => modelValue.value > min && !disabled)
|
||||
const canIncrement = computed(() => modelValue.value < max && !disabled)
|
||||
|
||||
function handleBlur(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
@@ -135,41 +127,27 @@ function handleBlur(e: Event) {
|
||||
textEdit.value = false
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (disabled) return
|
||||
const target = e.target as HTMLElement
|
||||
target.setPointerCapture(e.pointerId)
|
||||
dragging.value = true
|
||||
dragDelta.value = 0
|
||||
hasDragged.value = false
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!dragging.value) return
|
||||
dragDelta.value += e.movementX
|
||||
const steps = (dragDelta.value / 10) | 0
|
||||
if (steps === 0) return
|
||||
hasDragged.value = true
|
||||
const unclipped = modelValue.value + steps * step
|
||||
dragDelta.value %= 10
|
||||
modelValue.value = clamp(unclipped)
|
||||
}
|
||||
|
||||
let dragDelta = 0
|
||||
function handlePointerUp() {
|
||||
if (!dragging.value) return
|
||||
if (isSwiping.value) return
|
||||
|
||||
if (!hasDragged.value) {
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.select()
|
||||
}
|
||||
|
||||
resetDrag()
|
||||
textEdit.value = true
|
||||
inputField.value?.focus()
|
||||
inputField.value?.select()
|
||||
}
|
||||
|
||||
function resetDrag() {
|
||||
dragging.value = false
|
||||
dragDelta.value = 0
|
||||
const { distanceX, isSwiping } = usePointerSwipe(swipeElement, {
|
||||
onSwipeEnd: () => (dragDelta = 0)
|
||||
})
|
||||
|
||||
whenever(distanceX, () => {
|
||||
if (disabled) return
|
||||
const delta = ((distanceX.value - dragDelta) / 10) | 0
|
||||
dragDelta += delta * 10
|
||||
modelValue.value = clamp(modelValue.value - delta * step)
|
||||
})
|
||||
|
||||
function updateValueBy(delta: number) {
|
||||
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
v-show="isTemplateVisibleOnDistribution(template)"
|
||||
:key="template.name"
|
||||
ref="cardRefs"
|
||||
size="compact"
|
||||
size="tall"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
@@ -318,6 +318,20 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span
|
||||
class="text-neutral flex items-center gap-1.5 text-xs font-bold"
|
||||
>
|
||||
<template v-if="isAppTemplate(template)">
|
||||
<i class="icon-[lucide--panels-top-left]" />
|
||||
{{ $t('builderToolbar.app', 'App') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--workflow]" />
|
||||
{{ $t('builderToolbar.nodeGraph', 'Node Graph') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardBottom>
|
||||
</template>
|
||||
@@ -483,6 +497,8 @@ const {
|
||||
const getEffectiveSourceModule = (template: TemplateInfo) =>
|
||||
template.sourceModule || 'default'
|
||||
|
||||
const isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
|
||||
|
||||
const getBaseThumbnailSrc = (template: TemplateInfo) => {
|
||||
const sm = getEffectiveSourceModule(template)
|
||||
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
|
||||
|
||||
@@ -138,8 +138,7 @@ onMounted(async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('toastMessages.failedToFetchLogs'),
|
||||
life: 5000
|
||||
detail: t('toastMessages.failedToFetchLogs')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -275,8 +275,7 @@ async function handleBuy() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
|
||||
life: 5000
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage })
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -98,8 +98,7 @@ async function onConfirmCancel() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.cancelDialog.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
|
||||
@@ -50,7 +50,9 @@
|
||||
{{ t('g.dismiss') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="seeErrors">
|
||||
{{ t('errorOverlay.seeErrors') }}
|
||||
{{
|
||||
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,6 +71,8 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
defineProps<{ appMode?: boolean }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
@@ -94,6 +98,7 @@ function dismiss() {
|
||||
}
|
||||
|
||||
function seeErrors() {
|
||||
canvasStore.linearMode = false
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.deselectAll()
|
||||
canvasStore.updateSelectedItems()
|
||||
|
||||
@@ -535,7 +535,7 @@ onMounted(async () => {
|
||||
|
||||
// Restore saved workflow and workflow tabs state
|
||||
await workflowPersistence.initializeWorkflow()
|
||||
workflowPersistence.restoreWorkflowTabsState()
|
||||
await workflowPersistence.restoreWorkflowTabsState()
|
||||
|
||||
const sharedWorkflowLoadStatus =
|
||||
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
|
||||
|
||||
@@ -579,8 +579,7 @@ const onUpdateComfyUI = async (): Promise<void> => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: error.value || t('helpCenter.updateComfyUIFailed'),
|
||||
life: 5000
|
||||
detail: error.value || t('helpCenter.updateComfyUIFailed')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -597,8 +596,7 @@ const onUpdateComfyUI = async (): Promise<void> => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: err instanceof Error ? err.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
detail: err instanceof Error ? err.message : t('g.unknownError')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, reactive, ref, toValue, watch } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
@@ -227,7 +227,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
}
|
||||
|
||||
export function useErrorGroups(
|
||||
searchQuery: Ref<string>,
|
||||
searchQuery: MaybeRefOrGetter<string>,
|
||||
t: (key: string) => string
|
||||
) {
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
@@ -584,7 +584,7 @@ export function useErrorGroups(
|
||||
})
|
||||
|
||||
const filteredGroups = computed<ErrorGroup[]>(() => {
|
||||
const query = searchQuery.value.trim()
|
||||
const query = toValue(searchQuery).trim()
|
||||
return searchErrorGroups(tabErrorGroups.value, query)
|
||||
})
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
import { GetNodeParentGroupKey } from '../shared'
|
||||
import WidgetItem from './WidgetItem.vue'
|
||||
import { getStableWidgetRenderKey } from '@/core/graph/subgraph/widgetRenderKey'
|
||||
|
||||
const {
|
||||
label,
|
||||
@@ -272,7 +273,7 @@ defineExpose({
|
||||
<TransitionGroup name="list-scale">
|
||||
<WidgetItem
|
||||
v-for="{ widget, node } in widgets"
|
||||
:key="`${node.id}-${widget.name}-${widget.type}`"
|
||||
:key="getStableWidgetRenderKey(widget)"
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
:is-draggable="isDraggable"
|
||||
|
||||
@@ -15,10 +15,9 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
const {
|
||||
@@ -42,7 +41,6 @@ const label = defineModel<string>('label', { required: true })
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const dialogService = useDialogService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
@@ -67,15 +65,8 @@ const isCurrentValueDefault = computed(() => {
|
||||
})
|
||||
|
||||
async function handleRename() {
|
||||
const newLabel = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewNamePrompt'),
|
||||
defaultValue: widget.label,
|
||||
placeholder: widget.name
|
||||
})
|
||||
|
||||
if (newLabel === null) return
|
||||
label.value = newLabel
|
||||
const newLabel = await promptWidgetLabel(widget, t)
|
||||
if (newLabel !== null) label.value = newLabel
|
||||
}
|
||||
|
||||
function handleHideInput() {
|
||||
|
||||
@@ -615,8 +615,7 @@ const enterFolderView = async (asset: AssetItem) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('sideToolbar.folderView.errorSummary'),
|
||||
detail: t('sideToolbar.folderView.errorDetail'),
|
||||
life: 5000
|
||||
detail: t('sideToolbar.folderView.errorDetail')
|
||||
})
|
||||
exitFolderView()
|
||||
}
|
||||
@@ -662,8 +661,7 @@ const copyJobId = async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('mediaAsset.jobIdToast.error'),
|
||||
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
|
||||
life: 3000
|
||||
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
@mouseup="handleMouseUp"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i v-if="isBuilderState" class="bg-text-subtle icon-[lucide--hammer]" />
|
||||
<i
|
||||
v-if="workflowOption.workflow.initialMode === 'app'"
|
||||
v-else-if="workflowOption.workflow.initialMode === 'app'"
|
||||
class="icon-[lucide--panels-top-left] bg-primary-background"
|
||||
/>
|
||||
<span
|
||||
@@ -149,6 +150,11 @@ const shouldShowStatusIndicator = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
const isBuilderState = computed(() => {
|
||||
const currentMode = props.workflowOption.workflow.activeMode
|
||||
return typeof currentMode === 'string' && currentMode.startsWith('builder:')
|
||||
})
|
||||
|
||||
const isActiveTab = computed(() => {
|
||||
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ function handleWheel(e: WheelEvent) {
|
||||
|
||||
let dragging = false
|
||||
function handleDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (e.button !== 0 && e.button !== 1) return
|
||||
|
||||
const zoomPaneEl = zoomPane.value
|
||||
if (!zoomPaneEl) return
|
||||
|
||||
@@ -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 touch-manipulation 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',
|
||||
variants: {
|
||||
variant: {
|
||||
secondary:
|
||||
|
||||
@@ -238,6 +238,159 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
|
||||
expect(widgetData?.slotMetadata?.linked).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('number', 'seed', 1, () => undefined, {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('number', 'seed', 2, () => undefined, {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
|
||||
const graph = subgraphNode.graph
|
||||
if (!graph) throw new Error('Expected subgraph node graph')
|
||||
graph.add(subgraphNode)
|
||||
|
||||
const promotedViews = subgraphNode.widgets
|
||||
const secondPromotedView = promotedViews[1]
|
||||
if (!secondPromotedView) throw new Error('Expected second promoted view')
|
||||
|
||||
;(
|
||||
secondPromotedView as unknown as {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
).sourceNodeId = '9999'
|
||||
;(
|
||||
secondPromotedView as unknown as {
|
||||
sourceNodeId: string
|
||||
sourceWidgetName: string
|
||||
}
|
||||
).sourceWidgetName = 'stale_widget'
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const secondMappedWidget = nodeData?.widgets?.find(
|
||||
(widget) => widget.slotMetadata?.index === 1
|
||||
)
|
||||
if (!secondMappedWidget)
|
||||
throw new Error('Expected mapped widget for slot 1')
|
||||
|
||||
expect(secondMappedWidget.name).not.toBe('stale_widget')
|
||||
})
|
||||
it('clears stale slotMetadata when input no longer matches widget', async () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = vueNodeData.get(String(node.id))!
|
||||
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
|
||||
|
||||
expect(widgetData.slotMetadata?.linked).toBe(true)
|
||||
|
||||
node.inputs[0].name = 'other'
|
||||
node.inputs[0].widget = { name: 'other' }
|
||||
node.inputs[0].link = null
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
linkId: 42
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(widgetData.slotMetadata).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph output slot label reactivity', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('updates output slot labels when node:slot-label:changed is triggered', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addOutput('original_name', 'STRING')
|
||||
node.addOutput('other_name', 'STRING')
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeId = String(node.id)
|
||||
const nodeData = vueNodeData.get(nodeId)
|
||||
if (!nodeData?.outputs) throw new Error('Expected output data to exist')
|
||||
|
||||
expect(nodeData.outputs[0].label).toBeUndefined()
|
||||
expect(nodeData.outputs[1].label).toBeUndefined()
|
||||
|
||||
// Simulate what SubgraphNode does: set the label, then fire the trigger
|
||||
node.outputs[0].label = 'custom_label'
|
||||
graph.trigger('node:slot-label:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.OUTPUT
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const updatedData = vueNodeData.get(nodeId)
|
||||
expect(updatedData?.outputs?.[0]?.label).toBe('custom_label')
|
||||
expect(updatedData?.outputs?.[1]?.label).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updates input slot labels when node:slot-label:changed is triggered', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addInput('original_name', 'STRING')
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeId = String(node.id)
|
||||
const nodeData = vueNodeData.get(nodeId)
|
||||
if (!nodeData?.inputs) throw new Error('Expected input data to exist')
|
||||
|
||||
expect(nodeData.inputs[0].label).toBeUndefined()
|
||||
|
||||
node.inputs[0].label = 'custom_label'
|
||||
graph.trigger('node:slot-label:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const updatedData = vueNodeData.get(nodeId)
|
||||
expect(updatedData?.inputs?.[0]?.label).toBe('custom_label')
|
||||
})
|
||||
|
||||
it('ignores node:slot-label:changed for unknown node ids', () => {
|
||||
const graph = new LGraph()
|
||||
useGraphNodeManager(graph)
|
||||
|
||||
expect(() =>
|
||||
graph.trigger('node:slot-label:changed', {
|
||||
nodeId: 'missing-node',
|
||||
slotType: NodeSlotType.OUTPUT
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Promoted Pseudo Widgets', () => {
|
||||
@@ -315,4 +468,54 @@ describe('Nested promoted widget mapping', () => {
|
||||
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps linked and independent same-name promotions as distinct sources', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('string_a', '*')
|
||||
linkedNode.addWidget('text', 'string_a', 'linked', () => undefined, {})
|
||||
linkedInput.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNode)
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
|
||||
const independentNode = new LGraphNode('IndependentNode')
|
||||
independentNode.addWidget(
|
||||
'text',
|
||||
'string_a',
|
||||
'independent',
|
||||
() => undefined,
|
||||
{}
|
||||
)
|
||||
subgraph.add(independentNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(independentNode.id),
|
||||
'string_a'
|
||||
)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
||||
const promotedWidgets = nodeData?.widgets?.filter(
|
||||
(widget) => widget.name === 'string_a'
|
||||
)
|
||||
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
expect(
|
||||
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
||||
).toEqual(
|
||||
new Set([
|
||||
`${subgraph.id}:${linkedNode.id}`,
|
||||
`${subgraph.id}:${independentNode.id}`
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { reactive, shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
@@ -204,7 +205,7 @@ function safeWidgetMapper(
|
||||
|
||||
return {
|
||||
canvasOnly: widget.options.canvasOnly,
|
||||
advanced: widget.advanced,
|
||||
advanced: widget.options?.advanced ?? widget.advanced,
|
||||
hidden: widget.options.hidden,
|
||||
read_only: widget.options.read_only
|
||||
}
|
||||
@@ -234,16 +235,17 @@ function safeWidgetMapper(
|
||||
}
|
||||
}
|
||||
|
||||
const promotedInputName = node.inputs?.find((input) => {
|
||||
if (input.name === widget.name) return true
|
||||
if (input._widget === widget) return true
|
||||
return false
|
||||
})?.name
|
||||
const matchedInput = matchPromotedInput(node.inputs, widget)
|
||||
const promotedInputName = matchedInput?.name
|
||||
const displayName = promotedInputName ?? widget.name
|
||||
const promotedSource = resolvePromotedSourceByInputName(displayName) ?? {
|
||||
const directSource = {
|
||||
sourceNodeId: widget.sourceNodeId,
|
||||
sourceWidgetName: widget.sourceWidgetName
|
||||
}
|
||||
const promotedSource =
|
||||
matchedInput?._widget === widget
|
||||
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
|
||||
: directSource
|
||||
|
||||
return {
|
||||
displayName,
|
||||
@@ -467,8 +469,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
|
||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||
for (const widget of currentData.widgets ?? []) {
|
||||
const slotInfo = slotMetadata.get(widget.slotName ?? widget.name)
|
||||
if (slotInfo) widget.slotMetadata = slotInfo
|
||||
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,8 +80,12 @@ export function showNodeOptions(
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the node options popover
|
||||
* Check if the node options menu is currently open
|
||||
*/
|
||||
export function isNodeOptionsOpen(): boolean {
|
||||
return nodeOptionsInstance?.isOpen.value ?? false
|
||||
}
|
||||
|
||||
interface NodeOptionsInstance {
|
||||
toggle: (event: Event) => void
|
||||
show: (event: MouseEvent) => void
|
||||
|
||||
@@ -397,8 +397,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
if (app.canvas.empty) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.emptyCanvas'),
|
||||
life: 3000
|
||||
summary: t('toastMessages.emptyCanvas')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -557,8 +556,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.nothingToQueue'),
|
||||
detail: t('toastMessages.pleaseSelectOutputNodes'),
|
||||
life: 3000
|
||||
detail: t('toastMessages.pleaseSelectOutputNodes')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -571,8 +569,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.failedToQueue'),
|
||||
detail: t('toastMessages.failedExecutionPathResolution'),
|
||||
life: 3000
|
||||
detail: t('toastMessages.failedExecutionPathResolution')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -602,8 +599,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.nothingToGroup'),
|
||||
detail: t('toastMessages.pleaseSelectNodesToGroup'),
|
||||
life: 3000
|
||||
detail: t('toastMessages.pleaseSelectNodesToGroup')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -962,8 +958,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('manager.notAvailable'),
|
||||
life: 3000
|
||||
detail: t('manager.notAvailable')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1048,8 +1043,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.cannotCreateSubgraph'),
|
||||
detail: t('toastMessages.failedToConvertToSubgraph'),
|
||||
life: 3000
|
||||
detail: t('toastMessages.failedToConvertToSubgraph')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1258,8 +1252,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
summary: t('g.error'),
|
||||
detail: t('g.commandProhibited', {
|
||||
command: 'Comfy.Memory.UnloadModels'
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1278,8 +1271,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
summary: t('g.error'),
|
||||
detail: t('g.commandProhibited', {
|
||||
command: 'Comfy.Memory.UnloadModelsAndExecutionCache'
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -595,6 +595,34 @@ describe('usePaste', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip node metadata paste when a media node is selected', async () => {
|
||||
const mockNode = createMockLGraphNode({
|
||||
is_selected: true,
|
||||
pasteFile: vi.fn(),
|
||||
pasteFiles: vi.fn()
|
||||
})
|
||||
mockCanvas.current_node = mockNode
|
||||
vi.mocked(isImageNode).mockReturnValue(true)
|
||||
|
||||
usePaste()
|
||||
|
||||
const nodeData = { nodes: [{ type: 'KSampler' }] }
|
||||
const encoded = btoa(JSON.stringify(nodeData))
|
||||
const html = `<div data-metadata="${encoded}"></div>`
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('text/html', html)
|
||||
dataTransfer.setData('text/plain', 'some text')
|
||||
|
||||
const event = new ClipboardEvent('paste', { clipboardData: dataTransfer })
|
||||
document.dispatchEvent(event)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCanvas._deserializeItems).not.toHaveBeenCalled()
|
||||
expect(mockCanvas.pasteFromClipboard).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloneDataTransfer', () => {
|
||||
|
||||
@@ -229,7 +229,10 @@ export const usePaste = () => {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (pasteClipboardItems(data)) return
|
||||
|
||||
const isMediaNodeSelected =
|
||||
isImageNodeSelected || isVideoNodeSelected || isAudioNodeSelected
|
||||
if (!isMediaNodeSelected && pasteClipboardItems(data)) return
|
||||
|
||||
// No image found. Look for node data
|
||||
data = data.getData('text/plain')
|
||||
|
||||
77
src/core/graph/subgraph/matchPromotedInput.test.ts
Normal file
77
src/core/graph/subgraph/matchPromotedInput.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { matchPromotedInput } from './matchPromotedInput'
|
||||
|
||||
type MockInput = {
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}
|
||||
|
||||
function createWidget(name: string): IBaseWidget {
|
||||
return {
|
||||
name,
|
||||
type: 'text'
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
describe(matchPromotedInput, () => {
|
||||
it('prefers exact _widget matches before same-name inputs', () => {
|
||||
const targetWidget = createWidget('seed')
|
||||
const aliasWidget = createWidget('seed')
|
||||
|
||||
const aliasInput: MockInput = {
|
||||
name: 'seed',
|
||||
_widget: aliasWidget
|
||||
}
|
||||
const exactInput: MockInput = {
|
||||
name: 'seed',
|
||||
_widget: targetWidget
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
[aliasInput, exactInput] as unknown as Array<{
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}>,
|
||||
targetWidget
|
||||
)
|
||||
|
||||
expect(matched).toBe(exactInput)
|
||||
})
|
||||
|
||||
it('falls back to same-name matching when no exact widget match exists', () => {
|
||||
const targetWidget = createWidget('seed')
|
||||
const aliasInput: MockInput = {
|
||||
name: 'seed'
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
[aliasInput] as unknown as Array<{ name: string; _widget?: IBaseWidget }>,
|
||||
targetWidget
|
||||
)
|
||||
|
||||
expect(matched).toBe(aliasInput)
|
||||
})
|
||||
|
||||
it('does not guess when multiple same-name inputs exist without an exact match', () => {
|
||||
const targetWidget = createWidget('seed')
|
||||
const firstAliasInput: MockInput = {
|
||||
name: 'seed'
|
||||
}
|
||||
const secondAliasInput: MockInput = {
|
||||
name: 'seed'
|
||||
}
|
||||
|
||||
const matched = matchPromotedInput(
|
||||
[firstAliasInput, secondAliasInput] as unknown as Array<{
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}>,
|
||||
targetWidget
|
||||
)
|
||||
|
||||
expect(matched).toBeUndefined()
|
||||
})
|
||||
})
|
||||
19
src/core/graph/subgraph/matchPromotedInput.ts
Normal file
19
src/core/graph/subgraph/matchPromotedInput.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
type PromotedInputLike = {
|
||||
name: string
|
||||
_widget?: IBaseWidget
|
||||
}
|
||||
|
||||
export function matchPromotedInput(
|
||||
inputs: PromotedInputLike[] | undefined,
|
||||
widget: IBaseWidget
|
||||
): PromotedInputLike | undefined {
|
||||
if (!inputs) return undefined
|
||||
|
||||
const exactMatch = inputs.find((input) => input._widget === widget)
|
||||
if (exactMatch) return exactMatch
|
||||
|
||||
const sameNameMatches = inputs.filter((input) => input.name === widget.name)
|
||||
return sameNameMatches.length === 1 ? sameNameMatches[0] : undefined
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
// Barrel import must come first to avoid circular dependency
|
||||
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
|
||||
@@ -11,19 +11,26 @@ import {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
CanvasPointerEvent,
|
||||
LGraphCanvas,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
|
||||
import {
|
||||
cleanupComplexPromotionFixtureNodeType,
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
createTestSubgraphNode,
|
||||
setupComplexPromotionFixture
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
@@ -78,6 +85,22 @@ function firstInnerNode(innerNodes: LGraphNode[]): LGraphNode {
|
||||
return innerNode
|
||||
}
|
||||
|
||||
function promotedWidgets(node: SubgraphNode): PromotedWidgetView[] {
|
||||
return node.widgets as PromotedWidgetView[]
|
||||
}
|
||||
|
||||
function callSyncPromotions(node: SubgraphNode) {
|
||||
;(
|
||||
node as unknown as {
|
||||
_syncPromotions: () => void
|
||||
}
|
||||
)._syncPromotions()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanupComplexPromotionFixtureNodeType()
|
||||
})
|
||||
|
||||
describe(createPromotedWidgetView, () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -263,6 +286,31 @@ describe(createPromotedWidgetView, () => {
|
||||
expect(fallbackWidget.value).toBe('updated')
|
||||
})
|
||||
|
||||
test('value setter falls back to host widget when linked states are unavailable', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('string_a', '*')
|
||||
linkedNode.addWidget('text', 'string_a', 'initial', () => {})
|
||||
linkedInput.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNode)
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
|
||||
const linkedView = promotedWidgets(subgraphNode)[0]
|
||||
if (!linkedView) throw new Error('Expected a linked promoted widget')
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
|
||||
|
||||
linkedView.value = 'updated'
|
||||
|
||||
expect(linkedNode.widgets?.[0].value).toBe('updated')
|
||||
})
|
||||
|
||||
test('label falls back to displayName then widgetName', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
@@ -495,6 +543,185 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('renders all promoted widgets when duplicate input names are connected to different nodes', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 94 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const firstNode = new LGraphNode('FirstSeedNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('number', 'seed', 1, () => {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondSeedNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('number', 'seed', 2, () => {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(widgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
String(firstNode.id),
|
||||
String(secondNode.id)
|
||||
])
|
||||
})
|
||||
|
||||
test('input-linked same-name widgets share value state while store-promoted peer stays independent', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 95 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
const promotedNode = new LGraphNode('PromotedNode')
|
||||
promotedNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(promotedNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(promotedNode.id),
|
||||
'string_a'
|
||||
)
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(linkedNodeA.id),
|
||||
'string_a'
|
||||
)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
expect(widgets).toHaveLength(2)
|
||||
|
||||
const linkedView = widgets.find(
|
||||
(widget) => widget.sourceNodeId === String(linkedNodeA.id)
|
||||
)
|
||||
const promotedView = widgets.find(
|
||||
(widget) => widget.sourceNodeId === String(promotedNode.id)
|
||||
)
|
||||
if (!linkedView || !promotedView)
|
||||
throw new Error(
|
||||
'Expected linked and store-promoted widgets to be present'
|
||||
)
|
||||
|
||||
linkedView.value = 'shared-value'
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const graphId = subgraphNode.rootGraph.id
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeA.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeB.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(promotedNode.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('independent')
|
||||
|
||||
promotedView.value = 'independent-updated'
|
||||
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeA.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(linkedNodeB.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('shared-value')
|
||||
expect(
|
||||
widgetStore.getWidget(
|
||||
graphId,
|
||||
stripGraphPrefix(String(promotedNode.id)),
|
||||
'string_a'
|
||||
)?.value
|
||||
).toBe('independent-updated')
|
||||
})
|
||||
|
||||
test('duplicate-name promoted views map slot linkage by view identity', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('string_a', '*')
|
||||
linkedNode.addWidget('text', 'string_a', 'linked', () => {})
|
||||
linkedInput.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNode)
|
||||
|
||||
const independentNode = new LGraphNode('IndependentNode')
|
||||
independentNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(independentNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
usePromotionStore().promote(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id,
|
||||
String(independentNode.id),
|
||||
'string_a'
|
||||
)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
const linkedView = widgets.find(
|
||||
(widget) => widget.sourceNodeId === String(linkedNode.id)
|
||||
)
|
||||
const independentView = widgets.find(
|
||||
(widget) => widget.sourceNodeId === String(independentNode.id)
|
||||
)
|
||||
if (!linkedView || !independentView)
|
||||
throw new Error('Expected linked and independent promoted views')
|
||||
|
||||
const linkedSlot = subgraphNode.getSlotFromWidget(linkedView)
|
||||
const independentSlot = subgraphNode.getSlotFromWidget(independentView)
|
||||
|
||||
expect(linkedSlot).toBeDefined()
|
||||
expect(independentSlot).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns empty array when no proxyWidgets', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
expect(subgraphNode.widgets).toEqual([])
|
||||
@@ -558,6 +785,273 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('full linked coverage does not prune unresolved independent fallback promotions', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'widgetA', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 125 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const liveNode = new LGraphNode('LiveNode')
|
||||
const liveInput = liveNode.addInput('widgetA', '*')
|
||||
liveNode.addWidget('text', 'widgetA', 'a', () => {})
|
||||
liveInput.widget = { name: 'widgetA' }
|
||||
subgraph.add(liveNode)
|
||||
subgraph.inputNode.slots[0].connect(liveInput, liveNode)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(liveNode.id), 'widgetA'],
|
||||
['9999', 'widgetA']
|
||||
])
|
||||
|
||||
callSyncPromotions(subgraphNode)
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
|
||||
{ interiorNodeId: '9999', widgetName: 'widgetA' }
|
||||
])
|
||||
})
|
||||
|
||||
test('input-added existing-input path tolerates missing link metadata', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'widgetA', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 126 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const existingSlot = subgraph.inputNode.slots[0]
|
||||
if (!existingSlot) throw new Error('Expected subgraph input slot')
|
||||
|
||||
expect(() => {
|
||||
subgraph.events.dispatch('input-added', { input: existingSlot })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
test('syncPromotions prunes stale connected entries but keeps independent promotions', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 96 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
const independentNode = new LGraphNode('IndependentNode')
|
||||
independentNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(independentNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(independentNode.id), 'string_a'],
|
||||
[String(linkedNodeA.id), 'string_a'],
|
||||
[String(linkedNodeB.id), 'string_a']
|
||||
])
|
||||
|
||||
callSyncPromotions(subgraphNode)
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: String(linkedNodeA.id), widgetName: 'string_a' },
|
||||
{ interiorNodeId: String(independentNode.id), widgetName: 'string_a' }
|
||||
])
|
||||
})
|
||||
|
||||
test('syncPromotions prunes stale deep-alias entries for nested linked promotions', () => {
|
||||
const { subgraphNodeB } = createTwoLevelNestedSubgraph()
|
||||
const linkedView = promotedWidgets(subgraphNodeB)[0]
|
||||
if (!linkedView)
|
||||
throw new Error(
|
||||
'Expected nested subgraph to expose a linked promoted view'
|
||||
)
|
||||
|
||||
const concrete = resolveConcretePromotedWidget(
|
||||
subgraphNodeB,
|
||||
linkedView.sourceNodeId,
|
||||
linkedView.sourceWidgetName
|
||||
)
|
||||
if (concrete.status !== 'resolved')
|
||||
throw new Error(
|
||||
'Expected nested promoted view to resolve to concrete widget'
|
||||
)
|
||||
|
||||
const linkedEntry = [
|
||||
linkedView.sourceNodeId,
|
||||
linkedView.sourceWidgetName
|
||||
] as [string, string]
|
||||
const deepAliasEntry = [
|
||||
String(concrete.resolved.node.id),
|
||||
concrete.resolved.widget.name
|
||||
] as [string, string]
|
||||
|
||||
// Guardrail: this test specifically validates host/deep alias cleanup.
|
||||
expect(deepAliasEntry).not.toStrictEqual(linkedEntry)
|
||||
|
||||
setPromotions(subgraphNodeB, [linkedEntry, deepAliasEntry])
|
||||
|
||||
callSyncPromotions(subgraphNodeB)
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(
|
||||
subgraphNodeB.rootGraph.id,
|
||||
subgraphNodeB.id
|
||||
)
|
||||
expect(promotions).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: linkedEntry[0],
|
||||
widgetName: linkedEntry[1]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('configure prunes stale disconnected host aliases that resolve to the active linked concrete widget', () => {
|
||||
const nestedSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
|
||||
const concreteNode = new LGraphNode('ConcreteNode')
|
||||
const concreteInput = concreteNode.addInput('string_a', '*')
|
||||
concreteNode.addWidget('text', 'string_a', 'value', () => {})
|
||||
concreteInput.widget = { name: 'string_a' }
|
||||
nestedSubgraph.add(concreteNode)
|
||||
nestedSubgraph.inputNode.slots[0].connect(concreteInput, concreteNode)
|
||||
|
||||
const hostSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
|
||||
const activeAliasNode = createTestSubgraphNode(nestedSubgraph, { id: 118 })
|
||||
const staleAliasNode = createTestSubgraphNode(nestedSubgraph, { id: 119 })
|
||||
hostSubgraph.add(activeAliasNode)
|
||||
hostSubgraph.add(staleAliasNode)
|
||||
|
||||
activeAliasNode._internalConfigureAfterSlots()
|
||||
staleAliasNode._internalConfigureAfterSlots()
|
||||
hostSubgraph.inputNode.slots[0].connect(
|
||||
activeAliasNode.inputs[0],
|
||||
activeAliasNode
|
||||
)
|
||||
|
||||
const hostSubgraphNode = createTestSubgraphNode(hostSubgraph, { id: 120 })
|
||||
hostSubgraphNode.graph?.add(hostSubgraphNode)
|
||||
|
||||
setPromotions(hostSubgraphNode, [
|
||||
[String(activeAliasNode.id), 'string_a'],
|
||||
[String(staleAliasNode.id), 'string_a']
|
||||
])
|
||||
|
||||
const serialized = hostSubgraphNode.serialize()
|
||||
const restoredNode = createTestSubgraphNode(hostSubgraph, { id: 121 })
|
||||
restoredNode.configure({
|
||||
...serialized,
|
||||
id: restoredNode.id,
|
||||
type: hostSubgraph.id,
|
||||
inputs: []
|
||||
})
|
||||
|
||||
const restoredPromotions = usePromotionStore().getPromotions(
|
||||
restoredNode.rootGraph.id,
|
||||
restoredNode.id
|
||||
)
|
||||
expect(restoredPromotions).toStrictEqual([
|
||||
{
|
||||
interiorNodeId: String(activeAliasNode.id),
|
||||
widgetName: 'string_a'
|
||||
}
|
||||
])
|
||||
|
||||
const restoredWidgets = promotedWidgets(restoredNode)
|
||||
expect(restoredWidgets).toHaveLength(1)
|
||||
expect(restoredWidgets[0].sourceNodeId).toBe(String(activeAliasNode.id))
|
||||
})
|
||||
|
||||
test('serialize syncs duplicate-name linked inputs by subgraph slot identity', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'seed', type: '*' },
|
||||
{ name: 'seed', type: '*' }
|
||||
]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 127 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const firstNode = new LGraphNode('FirstNode')
|
||||
const firstInput = firstNode.addInput('seed', '*')
|
||||
firstNode.addWidget('text', 'seed', 'first-initial', () => {})
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('SecondNode')
|
||||
const secondInput = secondNode.addInput('seed', '*')
|
||||
secondNode.addWidget('text', 'seed', 'second-initial', () => {})
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const widgets = promotedWidgets(subgraphNode)
|
||||
const firstView = widgets[0]
|
||||
const secondView = widgets[1]
|
||||
if (!firstView || !secondView)
|
||||
throw new Error('Expected two linked promoted views')
|
||||
|
||||
firstView.value = 'first-updated'
|
||||
secondView.value = 'second-updated'
|
||||
|
||||
expect(firstNode.widgets?.[0].value).toBe('first-updated')
|
||||
expect(secondNode.widgets?.[0].value).toBe('second-updated')
|
||||
|
||||
subgraphNode.serialize()
|
||||
|
||||
expect(firstNode.widgets?.[0].value).toBe('first-updated')
|
||||
expect(secondNode.widgets?.[0].value).toBe('second-updated')
|
||||
})
|
||||
|
||||
test('renaming an input updates linked promoted view display names', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'seed', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 128 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNode = new LGraphNode('LinkedNode')
|
||||
const linkedInput = linkedNode.addInput('seed', '*')
|
||||
linkedNode.addWidget('text', 'seed', 'value', () => {})
|
||||
linkedInput.widget = { name: 'seed' }
|
||||
subgraph.add(linkedNode)
|
||||
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
||||
|
||||
const beforeRename = promotedWidgets(subgraphNode)[0]
|
||||
if (!beforeRename) throw new Error('Expected linked promoted view')
|
||||
expect(beforeRename.name).toBe('seed')
|
||||
|
||||
const inputToRename = subgraph.inputs[0]
|
||||
if (!inputToRename) throw new Error('Expected input to rename')
|
||||
subgraph.renameInput(inputToRename, 'seed_renamed')
|
||||
|
||||
const afterRename = promotedWidgets(subgraphNode)[0]
|
||||
if (!afterRename) throw new Error('Expected linked promoted view')
|
||||
expect(afterRename.name).toBe('seed_renamed')
|
||||
})
|
||||
|
||||
test('caches view objects across getter calls (stable references)', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
@@ -701,6 +1195,236 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('configure with empty serialized inputs keeps linked filtering active', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 97 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
const storeOnlyNode = new LGraphNode('StoreOnlyNode')
|
||||
storeOnlyNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(storeOnlyNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(linkedNodeA.id), 'string_a'],
|
||||
[String(linkedNodeB.id), 'string_a'],
|
||||
[String(storeOnlyNode.id), 'string_a']
|
||||
])
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
const restoredNode = createTestSubgraphNode(subgraph, { id: 98 })
|
||||
restoredNode.configure({
|
||||
...serialized,
|
||||
id: restoredNode.id,
|
||||
type: subgraph.id,
|
||||
inputs: []
|
||||
})
|
||||
|
||||
const restoredWidgets = promotedWidgets(restoredNode)
|
||||
expect(restoredWidgets).toHaveLength(2)
|
||||
|
||||
const linkedViewCount = restoredWidgets.filter((widget) =>
|
||||
[String(linkedNodeA.id), String(linkedNodeB.id)].includes(
|
||||
widget.sourceNodeId
|
||||
)
|
||||
).length
|
||||
expect(linkedViewCount).toBe(1)
|
||||
expect(
|
||||
restoredWidgets.some(
|
||||
(widget) => widget.sourceNodeId === String(storeOnlyNode.id)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('configure with serialized inputs rebinds subgraph slots for linked filtering', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 107 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
const storeOnlyNode = new LGraphNode('StoreOnlyNode')
|
||||
storeOnlyNode.addWidget('text', 'string_a', 'independent', () => {})
|
||||
subgraph.add(storeOnlyNode)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
setPromotions(subgraphNode, [
|
||||
[String(linkedNodeA.id), 'string_a'],
|
||||
[String(linkedNodeB.id), 'string_a'],
|
||||
[String(storeOnlyNode.id), 'string_a']
|
||||
])
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
const restoredNode = createTestSubgraphNode(subgraph, { id: 108 })
|
||||
restoredNode.configure({
|
||||
...serialized,
|
||||
id: restoredNode.id,
|
||||
type: subgraph.id,
|
||||
inputs: [
|
||||
{
|
||||
name: 'string_a',
|
||||
type: '*',
|
||||
link: null
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const restoredWidgets = promotedWidgets(restoredNode)
|
||||
expect(restoredWidgets).toHaveLength(2)
|
||||
|
||||
const linkedViewCount = restoredWidgets.filter((widget) =>
|
||||
[String(linkedNodeA.id), String(linkedNodeB.id)].includes(
|
||||
widget.sourceNodeId
|
||||
)
|
||||
).length
|
||||
expect(linkedViewCount).toBe(1)
|
||||
expect(
|
||||
restoredWidgets.some(
|
||||
(widget) => widget.sourceNodeId === String(storeOnlyNode.id)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('fixture keeps earliest linked representative and independent promotion only', () => {
|
||||
const { graph, hostNode } = setupComplexPromotionFixture()
|
||||
|
||||
const hostWidgets = promotedWidgets(hostNode)
|
||||
expect(hostWidgets).toHaveLength(2)
|
||||
expect(hostWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
'20',
|
||||
'19'
|
||||
])
|
||||
|
||||
const promotions = usePromotionStore().getPromotions(graph.id, hostNode.id)
|
||||
expect(promotions).toStrictEqual([
|
||||
{ interiorNodeId: '20', widgetName: 'string_a' },
|
||||
{ interiorNodeId: '19', widgetName: 'string_a' }
|
||||
])
|
||||
|
||||
const linkedView = hostWidgets[0]
|
||||
const independentView = hostWidgets[1]
|
||||
if (!linkedView || !independentView)
|
||||
throw new Error('Expected linked and independent promoted widgets')
|
||||
|
||||
independentView.value = 'independent-value'
|
||||
linkedView.value = 'shared-linked'
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const getValue = (nodeId: string) =>
|
||||
widgetStore.getWidget(graph.id, stripGraphPrefix(nodeId), 'string_a')
|
||||
?.value
|
||||
|
||||
expect(getValue('20')).toBe('shared-linked')
|
||||
expect(getValue('18')).toBe('shared-linked')
|
||||
expect(getValue('19')).toBe('independent-value')
|
||||
})
|
||||
|
||||
test('fixture refreshes duplicate fallback after linked representative recovers', () => {
|
||||
const { subgraph, hostNode } = setupComplexPromotionFixture()
|
||||
|
||||
const earliestLinkedNode = subgraph.getNodeById(20)
|
||||
if (!earliestLinkedNode?.widgets)
|
||||
throw new Error('Expected fixture to contain node 20 with widgets')
|
||||
|
||||
const originalWidgets = earliestLinkedNode.widgets
|
||||
earliestLinkedNode.widgets = originalWidgets.filter(
|
||||
(widget) => widget.name !== 'string_a'
|
||||
)
|
||||
|
||||
const unresolvedWidgets = promotedWidgets(hostNode)
|
||||
expect(
|
||||
unresolvedWidgets.map((widget) => widget.sourceNodeId)
|
||||
).toStrictEqual(['18', '20', '19'])
|
||||
|
||||
earliestLinkedNode.widgets = originalWidgets
|
||||
|
||||
const restoredWidgets = promotedWidgets(hostNode)
|
||||
expect(restoredWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
'20',
|
||||
'19'
|
||||
])
|
||||
})
|
||||
|
||||
test('fixture converges external widgets and keeps rendered value isolation after transient linked fallback churn', () => {
|
||||
const { subgraph, hostNode } = setupComplexPromotionFixture()
|
||||
|
||||
const initialWidgets = promotedWidgets(hostNode)
|
||||
expect(initialWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
'20',
|
||||
'19'
|
||||
])
|
||||
|
||||
const earliestLinkedNode = subgraph.getNodeById(20)
|
||||
if (!earliestLinkedNode?.widgets)
|
||||
throw new Error('Expected fixture to contain node 20 with widgets')
|
||||
|
||||
const originalWidgets = earliestLinkedNode.widgets
|
||||
earliestLinkedNode.widgets = originalWidgets.filter(
|
||||
(widget) => widget.name !== 'string_a'
|
||||
)
|
||||
|
||||
const transientWidgets = promotedWidgets(hostNode)
|
||||
expect(transientWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual(
|
||||
['18', '20', '19']
|
||||
)
|
||||
|
||||
earliestLinkedNode.widgets = originalWidgets
|
||||
|
||||
const finalWidgets = promotedWidgets(hostNode)
|
||||
expect(finalWidgets).toHaveLength(2)
|
||||
expect(finalWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
|
||||
'20',
|
||||
'19'
|
||||
])
|
||||
|
||||
const finalLinkedView = finalWidgets.find(
|
||||
(widget) => widget.sourceNodeId === '20'
|
||||
)
|
||||
const finalIndependentView = finalWidgets.find(
|
||||
(widget) => widget.sourceNodeId === '19'
|
||||
)
|
||||
if (!finalLinkedView || !finalIndependentView)
|
||||
throw new Error('Expected final rendered linked and independent views')
|
||||
|
||||
finalIndependentView.value = 'independent-final'
|
||||
expect(finalIndependentView.value).toBe('independent-final')
|
||||
expect(finalLinkedView.value).not.toBe('independent-final')
|
||||
|
||||
finalLinkedView.value = 'linked-final'
|
||||
expect(finalLinkedView.value).toBe('linked-final')
|
||||
expect(finalIndependentView.value).toBe('independent-final')
|
||||
})
|
||||
|
||||
test('clone output preserves proxyWidgets for promotion hydration', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
const innerNode = firstInnerNode(innerNodes)
|
||||
@@ -751,6 +1475,103 @@ describe('widgets getter caching', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
test('reconciles at most once per canvas frame across repeated widgets reads', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
|
||||
const fakeCanvas = { frame: 12 } as Pick<LGraphCanvas, 'frame'>
|
||||
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
|
||||
|
||||
const reconcileSpy = vi.spyOn(
|
||||
subgraphNode as unknown as {
|
||||
_buildPromotionReconcileState: (
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
linkedEntries: Array<{
|
||||
inputName: string
|
||||
inputKey: string
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}>
|
||||
) => unknown
|
||||
},
|
||||
'_buildPromotionReconcileState'
|
||||
)
|
||||
|
||||
void subgraphNode.widgets
|
||||
void subgraphNode.widgets
|
||||
void subgraphNode.widgets
|
||||
|
||||
expect(reconcileSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('does not re-run reconciliation when only canvas frame advances', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
setPromotions(subgraphNode, [['1', 'widgetA']])
|
||||
|
||||
const fakeCanvas = { frame: 24 } as Pick<LGraphCanvas, 'frame'>
|
||||
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
|
||||
|
||||
const reconcileSpy = vi.spyOn(
|
||||
subgraphNode as unknown as {
|
||||
_buildPromotionReconcileState: (
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
linkedEntries: Array<{
|
||||
inputName: string
|
||||
inputKey: string
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}>
|
||||
) => unknown
|
||||
},
|
||||
'_buildPromotionReconcileState'
|
||||
)
|
||||
|
||||
void subgraphNode.widgets
|
||||
fakeCanvas.frame += 1
|
||||
void subgraphNode.widgets
|
||||
|
||||
expect(reconcileSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('does not re-resolve linked entries when linked input state is unchanged', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'string_a', type: '*' }]
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 97 })
|
||||
subgraphNode.graph?.add(subgraphNode)
|
||||
|
||||
const linkedNodeA = new LGraphNode('LinkedNodeA')
|
||||
const linkedInputA = linkedNodeA.addInput('string_a', '*')
|
||||
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
|
||||
linkedInputA.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeA)
|
||||
|
||||
const linkedNodeB = new LGraphNode('LinkedNodeB')
|
||||
const linkedInputB = linkedNodeB.addInput('string_a', '*')
|
||||
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
|
||||
linkedInputB.widget = { name: 'string_a' }
|
||||
subgraph.add(linkedNodeB)
|
||||
|
||||
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
|
||||
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
|
||||
|
||||
const resolveSpy = vi.spyOn(
|
||||
subgraphNode as unknown as {
|
||||
_resolveLinkedPromotionBySubgraphInput: (...args: unknown[]) => unknown
|
||||
},
|
||||
'_resolveLinkedPromotionBySubgraphInput'
|
||||
)
|
||||
|
||||
void subgraphNode.widgets
|
||||
const initialResolveCount = resolveSpy.mock.calls.length
|
||||
expect(initialResolveCount).toBeLessThanOrEqual(1)
|
||||
|
||||
void subgraphNode.widgets
|
||||
expect(resolveSpy).toHaveBeenCalledTimes(initialResolveCount)
|
||||
})
|
||||
|
||||
test('preserves view identities when promotion order changes', () => {
|
||||
const [subgraphNode, innerNodes] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
@@ -13,11 +13,15 @@ import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetAtHost
|
||||
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
export type { PromotedWidgetView } from './promotedWidgetTypes'
|
||||
@@ -131,6 +135,38 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
const linkedWidgets = this.getLinkedInputWidgets()
|
||||
if (linkedWidgets.length > 0) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
let didUpdateState = false
|
||||
for (const linkedWidget of linkedWidgets) {
|
||||
const state = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
linkedWidget.nodeId,
|
||||
linkedWidget.widgetName
|
||||
)
|
||||
if (state) {
|
||||
state.value = value
|
||||
didUpdateState = true
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (resolved) {
|
||||
const resolvedState = widgetStore.getWidget(
|
||||
this.graphId,
|
||||
stripGraphPrefix(String(resolved.node.id)),
|
||||
resolved.widget.name
|
||||
)
|
||||
if (resolvedState) {
|
||||
resolvedState.value = value
|
||||
didUpdateState = true
|
||||
}
|
||||
}
|
||||
|
||||
if (didUpdateState) return
|
||||
}
|
||||
|
||||
const state = this.getWidgetState()
|
||||
if (state) {
|
||||
state.value = value
|
||||
@@ -278,6 +314,9 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
private getWidgetState() {
|
||||
const linkedState = this.getLinkedInputWidgetStates()[0]
|
||||
if (linkedState) return linkedState
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) return undefined
|
||||
return useWidgetValueStore().getWidget(
|
||||
@@ -287,6 +326,57 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
)
|
||||
}
|
||||
|
||||
private getLinkedInputWidgets(): Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
widget: IBaseWidget
|
||||
}> {
|
||||
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
|
||||
if (!input._subgraphSlot) return false
|
||||
if (matchPromotedInput([input], this) !== input) return false
|
||||
|
||||
const boundWidget = input._widget
|
||||
if (boundWidget === this) return true
|
||||
|
||||
if (boundWidget && isPromotedWidgetView(boundWidget)) {
|
||||
return (
|
||||
boundWidget.sourceNodeId === this.sourceNodeId &&
|
||||
boundWidget.sourceWidgetName === this.sourceWidgetName
|
||||
)
|
||||
}
|
||||
|
||||
return input._subgraphSlot
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.some(
|
||||
(widget) =>
|
||||
String(widget.node.id) === this.sourceNodeId &&
|
||||
widget.name === this.sourceWidgetName
|
||||
)
|
||||
})
|
||||
const linkedInput = linkedInputSlot?._subgraphSlot
|
||||
if (!linkedInput) return []
|
||||
|
||||
return linkedInput
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.map((widget) => ({
|
||||
nodeId: stripGraphPrefix(String(widget.node.id)),
|
||||
widgetName: widget.name,
|
||||
widget
|
||||
}))
|
||||
}
|
||||
|
||||
private getLinkedInputWidgetStates(): WidgetState[] {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
|
||||
return this.getLinkedInputWidgets()
|
||||
.map(({ nodeId, widgetName }) =>
|
||||
widgetStore.getWidget(this.graphId, nodeId, widgetName)
|
||||
)
|
||||
.filter((state): state is WidgetState => state !== undefined)
|
||||
}
|
||||
|
||||
private getProjectedWidget(resolved: {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
|
||||
@@ -81,8 +81,7 @@ function getParentNodes(): SubgraphNode[] {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('subgraphStore.promoteOutsideSubgraph'),
|
||||
life: 2000
|
||||
detail: t('subgraphStore.promoteOutsideSubgraph')
|
||||
})
|
||||
return []
|
||||
}
|
||||
|
||||
161
src/core/graph/subgraph/resolveSubgraphInputTarget.test.ts
Normal file
161
src/core/graph/subgraph/resolveSubgraphInputTarget.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({ widgetStates: new Map() })
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
function createOuterSubgraphSetup(inputNames: string[]): {
|
||||
outerSubgraph: Subgraph
|
||||
outerSubgraphNode: SubgraphNode
|
||||
} {
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
inputs: inputNames.map((name) => ({ name, type: '*' }))
|
||||
})
|
||||
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 1 })
|
||||
return { outerSubgraph, outerSubgraphNode }
|
||||
}
|
||||
|
||||
function addLinkedNestedSubgraphNode(
|
||||
outerSubgraph: Subgraph,
|
||||
inputName: string,
|
||||
linkedInputName: string,
|
||||
options: { widget?: string } = {}
|
||||
): { innerSubgraphNode: SubgraphNode } {
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: linkedInputName, type: '*' }]
|
||||
})
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, { id: 819 })
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const inputSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === inputName
|
||||
)
|
||||
if (!inputSlot) throw new Error(`Missing subgraph input slot: ${inputName}`)
|
||||
|
||||
const input = innerSubgraphNode.addInput(linkedInputName, '*')
|
||||
if (options.widget) {
|
||||
innerSubgraphNode.addWidget('number', options.widget, 0, () => undefined)
|
||||
input.widget = { name: options.widget }
|
||||
}
|
||||
inputSlot.connect(input, innerSubgraphNode)
|
||||
|
||||
if (input.link == null) {
|
||||
throw new Error(`Expected link to be created for input ${linkedInputName}`)
|
||||
}
|
||||
|
||||
return { innerSubgraphNode }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('resolveSubgraphInputTarget', () => {
|
||||
test('returns target for widget-backed input on nested SubgraphNode', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'width'
|
||||
])
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
|
||||
widget: 'width'
|
||||
})
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'width')
|
||||
|
||||
expect(result).toMatchObject({
|
||||
nodeId: '819',
|
||||
widgetName: 'width'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns undefined for non-widget input on nested SubgraphNode', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'audio'
|
||||
])
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test('resolves widget inputs but not non-widget inputs on the same nested SubgraphNode', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'width',
|
||||
'audio'
|
||||
])
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
|
||||
widget: 'width'
|
||||
})
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
|
||||
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'width')
|
||||
).toMatchObject({
|
||||
nodeId: '819',
|
||||
widgetName: 'width'
|
||||
})
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns target for widget-backed input on plain interior node', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'seed'
|
||||
])
|
||||
|
||||
const inputSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === 'seed'
|
||||
)!
|
||||
const node = new LGraphNode('Interior-seed')
|
||||
node.id = 42
|
||||
const input = node.addInput('seed_input', '*')
|
||||
node.addWidget('number', 'seed', 0, () => undefined)
|
||||
input.widget = { name: 'seed' }
|
||||
outerSubgraph.add(node)
|
||||
inputSlot.connect(input, node)
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'seed')
|
||||
|
||||
expect(result).toMatchObject({
|
||||
nodeId: '42',
|
||||
widgetName: 'seed'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns undefined for non-widget input on plain interior node', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'image'
|
||||
])
|
||||
|
||||
const inputSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === 'image'
|
||||
)!
|
||||
const node = new LGraphNode('Interior-image')
|
||||
const input = node.addInput('image_input', '*')
|
||||
outerSubgraph.add(node)
|
||||
inputSlot.connect(input, node)
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'image')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,9 @@ export function resolveSubgraphInputTarget(
|
||||
inputName,
|
||||
({ inputNode, targetInput, getTargetWidget }) => {
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
const targetWidget = getTargetWidget()
|
||||
if (!targetWidget) return undefined
|
||||
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetInput.name
|
||||
|
||||
8
src/core/graph/subgraph/widgetNodeTypeGuard.ts
Normal file
8
src/core/graph/subgraph/widgetNodeTypeGuard.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export function hasWidgetNode(
|
||||
widget: IBaseWidget
|
||||
): widget is IBaseWidget & { node: LGraphNode } {
|
||||
return 'node' in widget && !!widget.node
|
||||
}
|
||||
34
src/core/graph/subgraph/widgetRenderKey.test.ts
Normal file
34
src/core/graph/subgraph/widgetRenderKey.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { getStableWidgetRenderKey } from './widgetRenderKey'
|
||||
|
||||
function createWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
return {
|
||||
name: 'seed',
|
||||
type: 'number',
|
||||
...overrides
|
||||
} as IBaseWidget
|
||||
}
|
||||
|
||||
describe(getStableWidgetRenderKey, () => {
|
||||
it('returns a stable key for the same widget instance', () => {
|
||||
const widget = createWidget()
|
||||
|
||||
const first = getStableWidgetRenderKey(widget)
|
||||
const second = getStableWidgetRenderKey(widget)
|
||||
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('returns distinct keys for distinct widget instances', () => {
|
||||
const firstWidget = createWidget()
|
||||
const secondWidget = createWidget()
|
||||
|
||||
const firstKey = getStableWidgetRenderKey(firstWidget)
|
||||
const secondKey = getStableWidgetRenderKey(secondWidget)
|
||||
|
||||
expect(secondKey).not.toBe(firstKey)
|
||||
})
|
||||
})
|
||||
17
src/core/graph/subgraph/widgetRenderKey.ts
Normal file
17
src/core/graph/subgraph/widgetRenderKey.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
|
||||
const widgetRenderKeys = new WeakMap<IBaseWidget, string>()
|
||||
let nextWidgetRenderKeyId = 0
|
||||
|
||||
export function getStableWidgetRenderKey(widget: IBaseWidget): string {
|
||||
const cachedKey = widgetRenderKeys.get(widget)
|
||||
if (cachedKey) return cachedKey
|
||||
|
||||
const prefix = isPromotedWidgetView(widget) ? 'promoted' : 'widget'
|
||||
const key = `${prefix}:${nextWidgetRenderKeyId++}`
|
||||
|
||||
widgetRenderKeys.set(widget, key)
|
||||
return key
|
||||
}
|
||||
@@ -204,8 +204,7 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('desktopUpdate.errorInstallingUpdate'),
|
||||
life: 10_000
|
||||
detail: t('desktopUpdate.errorInstallingUpdate')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -214,8 +213,7 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('desktopUpdate.errorCheckingUpdate'),
|
||||
life: 10_000
|
||||
detail: t('desktopUpdate.errorCheckingUpdate')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
@@ -29,7 +28,6 @@ useExtensionService().registerExtension({
|
||||
|
||||
const toUrl = (record: Record<string, string>) => {
|
||||
const params = new URLSearchParams(record)
|
||||
appendCloudResParam(params, record.filename)
|
||||
return api.apiURL(`/view?${params}${rand}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ export function parseSlotTypes(type: ISlotType): string[] {
|
||||
* @param name The name to make unique
|
||||
* @param existingNames The names that already exist. Default: an empty array
|
||||
* @returns The name, or a unique name if it already exists.
|
||||
* @remark Used by SubgraphInputNode to deduplicate input names when promoting
|
||||
* the same widget name from multiple node instances (e.g. `seed` → `seed_1`).
|
||||
* Extensions matching by slot name should account for the `_N` suffix.
|
||||
*/
|
||||
export function nextUniqueName(
|
||||
name: string,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// TODO: Fix these tests after migration
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LGraphEventMode,
|
||||
ExecutableNodeDTO
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
@@ -249,6 +252,136 @@ describe.skip('ExecutableNodeDTO Output Resolution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Muted node output resolution', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('should return undefined for NEVER mode nodes', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Muted Node')
|
||||
node.addOutput('out', 'string')
|
||||
node.mode = LGraphEventMode.NEVER
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
const resolved = dto.resolveOutput(0, 'string', new Set())
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for muted subgraph nodes without throwing', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'out', type: 'IMAGE' }],
|
||||
nodeCount: 1
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
subgraphNode.mode = LGraphEventMode.NEVER
|
||||
|
||||
// Empty map simulates executionUtil skipping getInnerNodes() for muted nodes
|
||||
const nodesByExecutionId = new Map()
|
||||
const dto = new ExecutableNodeDTO(
|
||||
subgraphNode,
|
||||
[],
|
||||
nodesByExecutionId,
|
||||
undefined
|
||||
)
|
||||
nodesByExecutionId.set(dto.id, dto)
|
||||
|
||||
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should resolve undefined when input is connected to a muted node', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const mutedNode = new LGraphNode('Muted Node')
|
||||
mutedNode.addOutput('result', 'IMAGE')
|
||||
mutedNode.mode = LGraphEventMode.NEVER
|
||||
graph.add(mutedNode)
|
||||
|
||||
const downstreamNode = new LGraphNode('Downstream')
|
||||
downstreamNode.addInput('input', 'IMAGE')
|
||||
graph.add(downstreamNode)
|
||||
|
||||
mutedNode.connect(0, downstreamNode, 0)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const mutedDto = new ExecutableNodeDTO(mutedNode, [], nodeDtoMap, undefined)
|
||||
nodeDtoMap.set(mutedDto.id, mutedDto)
|
||||
|
||||
const downstreamDto = new ExecutableNodeDTO(
|
||||
downstreamNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(downstreamDto.id, downstreamDto)
|
||||
|
||||
const resolved = downstreamDto.resolveInput(0)
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bypass node output resolution', () => {
|
||||
it('should still resolve bypass for BYPASS mode nodes', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const upstreamNode = new LGraphNode('Upstream')
|
||||
upstreamNode.addOutput('out', 'IMAGE')
|
||||
graph.add(upstreamNode)
|
||||
|
||||
const bypassedNode = new LGraphNode('Bypassed')
|
||||
bypassedNode.addInput('in', 'IMAGE')
|
||||
bypassedNode.addOutput('out', 'IMAGE')
|
||||
bypassedNode.mode = LGraphEventMode.BYPASS
|
||||
graph.add(bypassedNode)
|
||||
|
||||
upstreamNode.connect(0, bypassedNode, 0)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const upstreamDto = new ExecutableNodeDTO(
|
||||
upstreamNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(upstreamDto.id, upstreamDto)
|
||||
|
||||
const bypassedDto = new ExecutableNodeDTO(
|
||||
bypassedNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(bypassedDto.id, bypassedDto)
|
||||
|
||||
const resolved = bypassedDto.resolveOutput(0, 'IMAGE', new Set())
|
||||
expect(resolved).toBeDefined()
|
||||
expect(resolved?.node).toBe(upstreamDto)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ALWAYS mode node output resolution', () => {
|
||||
it('should attempt normal resolution for ALWAYS mode nodes', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Normal Node')
|
||||
node.addOutput('out', 'IMAGE')
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.add(node)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const dto = new ExecutableNodeDTO(node, [], nodeDtoMap, undefined)
|
||||
nodeDtoMap.set(dto.id, dto)
|
||||
|
||||
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
|
||||
expect(resolved).toBeDefined()
|
||||
expect(resolved?.node).toBe(dto)
|
||||
expect(resolved?.origin_slot).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('ExecutableNodeDTO Properties', () => {
|
||||
it('should provide access to basic properties', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
@@ -266,6 +266,9 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
}
|
||||
visited.add(uniqueId)
|
||||
|
||||
// Muted nodes produce no output
|
||||
if (this.mode === LGraphEventMode.NEVER) return
|
||||
|
||||
// Upstreamed: Bypass nodes are bypassed using the first input with matching type
|
||||
if (this.mode === LGraphEventMode.BYPASS) {
|
||||
// Bypass nodes by finding first input with matching type
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { ToInputFromIoNodeLink } from '@/lib/litegraph/src/canvas/ToInputFromIoNodeLink'
|
||||
import { LinkDirection } from '@/lib/litegraph/src//types/globalEnums'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import { subgraphTest } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
@@ -456,4 +457,50 @@ describe('SubgraphIO - Empty Slot Connection', () => {
|
||||
expect(link.origin_slot).toBe(1) // Should be the second slot
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'creates distinct named inputs when promoting same widget name from multiple node instances',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraph, subgraphNode } = subgraphWithNode
|
||||
|
||||
const firstNode = new LGraphNode('First Seed Node')
|
||||
const firstInput = firstNode.addInput('seed', 'number')
|
||||
firstNode.addWidget('number', 'seed', 1, () => undefined)
|
||||
firstInput.widget = { name: 'seed' }
|
||||
subgraph.add(firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('Second Seed Node')
|
||||
const secondInput = secondNode.addInput('seed', 'number')
|
||||
secondNode.addWidget('number', 'seed', 2, () => undefined)
|
||||
secondInput.widget = { name: 'seed' }
|
||||
subgraph.add(secondNode)
|
||||
|
||||
subgraph.inputNode.connectByType(-1, firstNode, 'number')
|
||||
subgraph.inputNode.connectByType(-1, secondNode, 'number')
|
||||
|
||||
expect(subgraph.inputs.map((input) => input.name)).toStrictEqual([
|
||||
'input',
|
||||
'seed',
|
||||
'seed_1'
|
||||
])
|
||||
expect(subgraphNode.inputs.map((input) => input.name)).toStrictEqual([
|
||||
'input',
|
||||
'seed',
|
||||
'seed_1'
|
||||
])
|
||||
expect(subgraphNode.widgets.map((widget) => widget.name)).toStrictEqual([
|
||||
'seed',
|
||||
'seed_1'
|
||||
])
|
||||
expect(
|
||||
usePromotionStore().getPromotions(
|
||||
subgraphNode.rootGraph.id,
|
||||
subgraphNode.id
|
||||
)
|
||||
).toStrictEqual([
|
||||
{ interiorNodeId: String(firstNode.id), widgetName: 'seed' },
|
||||
{ interiorNodeId: String(secondNode.id), widgetName: 'seed' }
|
||||
])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { findFreeSlotOfType } from '@/lib/litegraph/src/utils/collections'
|
||||
import { nextUniqueName } from '@/lib/litegraph/src/strings'
|
||||
|
||||
import { EmptySubgraphInput } from './EmptySubgraphInput'
|
||||
import { SubgraphIONodeBase } from './SubgraphIONodeBase'
|
||||
@@ -130,8 +131,10 @@ export class SubgraphInputNode
|
||||
if (slot === -1) {
|
||||
// This indicates a connection is being made from the "Empty" slot.
|
||||
// We need to create a new, concrete input on the subgraph that matches the target.
|
||||
const existingNames = this.subgraph.inputs.map((input) => input.name)
|
||||
const uniqueName = nextUniqueName(inputSlot.slot.name, existingNames)
|
||||
const newSubgraphInput = this.subgraph.addInput(
|
||||
inputSlot.slot.name,
|
||||
uniqueName,
|
||||
String(inputSlot.slot.type ?? '')
|
||||
)
|
||||
const newSlotIndex = this.slots.indexOf(newSubgraphInput)
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* IO synchronization, and edge cases.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -615,3 +617,35 @@ describe.skip('SubgraphNode Cleanup', () => {
|
||||
expect(abortSpy2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubgraphNode promotion view keys', () => {
|
||||
it('distinguishes tuples that differ only by colon placement', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
const subgraph = createTestSubgraph()
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
const nodeWithKeyBuilder = subgraphNode as unknown as {
|
||||
_makePromotionViewKey: (
|
||||
inputKey: string,
|
||||
interiorNodeId: string,
|
||||
widgetName: string,
|
||||
inputName?: string
|
||||
) => string
|
||||
}
|
||||
|
||||
const firstKey = nodeWithKeyBuilder._makePromotionViewKey(
|
||||
'65',
|
||||
'18',
|
||||
'a:b',
|
||||
'c'
|
||||
)
|
||||
const secondKey = nodeWithKeyBuilder._makePromotionViewKey(
|
||||
'65',
|
||||
'18',
|
||||
'a',
|
||||
'b:c'
|
||||
)
|
||||
|
||||
expect(firstKey).not.toBe(secondKey)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
ExportedSubgraphInstance,
|
||||
ISerialisedNode
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import {
|
||||
@@ -34,7 +35,9 @@ import {
|
||||
isPromotedWidgetView
|
||||
} from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
|
||||
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
@@ -51,6 +54,12 @@ workflowSvg.src =
|
||||
|
||||
type LinkedPromotionEntry = {
|
||||
inputName: string
|
||||
inputKey: string
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
type PromotionEntry = {
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}
|
||||
@@ -90,46 +99,113 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
* `onAdded()`, so construction-time promotions require normal add-to-graph
|
||||
* lifecycle to persist.
|
||||
*/
|
||||
private _pendingPromotions: Array<{
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}> = []
|
||||
private _pendingPromotions: PromotionEntry[] = []
|
||||
private _cacheVersion = 0
|
||||
private _linkedEntriesCache?: {
|
||||
version: number
|
||||
hasMissingBoundSourceWidget: boolean
|
||||
entries: LinkedPromotionEntry[]
|
||||
}
|
||||
private _promotedViewsCache?: {
|
||||
version: number
|
||||
entriesRef: PromotionEntry[]
|
||||
hasMissingBoundSourceWidget: boolean
|
||||
views: PromotedWidgetView[]
|
||||
}
|
||||
|
||||
// Declared as accessor via Object.defineProperty in constructor.
|
||||
// TypeScript doesn't allow overriding a property with get/set syntax,
|
||||
// so we use declare + defineProperty instead.
|
||||
declare widgets: IBaseWidget[]
|
||||
|
||||
private _resolveLinkedPromotionByInputName(
|
||||
inputName: string
|
||||
private _resolveLinkedPromotionBySubgraphInput(
|
||||
subgraphInput: SubgraphInput
|
||||
): { interiorNodeId: string; widgetName: string } | undefined {
|
||||
const resolvedTarget = resolveSubgraphInputTarget(this, inputName)
|
||||
if (!resolvedTarget) return undefined
|
||||
// Preserve deterministic representative selection for multi-linked inputs:
|
||||
// the first connected source remains the promoted linked view.
|
||||
for (const linkId of subgraphInput.linkIds) {
|
||||
const link = this.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
|
||||
return {
|
||||
interiorNodeId: resolvedTarget.nodeId,
|
||||
widgetName: resolvedTarget.widgetName
|
||||
const { inputNode } = link.resolve(this.subgraph)
|
||||
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
|
||||
|
||||
const targetInput = inputNode.inputs.find(
|
||||
(entry) => entry.link === linkId
|
||||
)
|
||||
if (!targetInput) continue
|
||||
|
||||
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
|
||||
if (!targetWidget) continue
|
||||
|
||||
if (inputNode.isSubgraphNode())
|
||||
return {
|
||||
interiorNodeId: String(inputNode.id),
|
||||
widgetName: targetInput.name
|
||||
}
|
||||
|
||||
return {
|
||||
interiorNodeId: String(inputNode.id),
|
||||
widgetName: targetWidget.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _getLinkedPromotionEntries(): LinkedPromotionEntry[] {
|
||||
private _getLinkedPromotionEntries(cache = true): LinkedPromotionEntry[] {
|
||||
const hasMissingBoundSourceWidget = this._hasMissingBoundSourceWidget()
|
||||
const cached = this._linkedEntriesCache
|
||||
if (
|
||||
cache &&
|
||||
cached?.version === this._cacheVersion &&
|
||||
cached.hasMissingBoundSourceWidget === hasMissingBoundSourceWidget
|
||||
)
|
||||
return cached.entries
|
||||
|
||||
const linkedEntries: LinkedPromotionEntry[] = []
|
||||
|
||||
// TODO(pr9282): Optimization target. This path runs on widgets getter reads
|
||||
// and resolves each input link chain eagerly.
|
||||
for (const input of this.inputs) {
|
||||
const resolved = this._resolveLinkedPromotionByInputName(input.name)
|
||||
const subgraphInput = input._subgraphSlot
|
||||
if (!subgraphInput) continue
|
||||
|
||||
const boundWidget =
|
||||
input._widget && isPromotedWidgetView(input._widget)
|
||||
? input._widget
|
||||
: undefined
|
||||
if (boundWidget) {
|
||||
const boundNode = this.subgraph.getNodeById(boundWidget.sourceNodeId)
|
||||
const hasBoundSourceWidget =
|
||||
boundNode?.widgets?.some(
|
||||
(widget) => widget.name === boundWidget.sourceWidgetName
|
||||
) === true
|
||||
if (hasBoundSourceWidget) {
|
||||
linkedEntries.push({
|
||||
inputName: input.label ?? input.name,
|
||||
inputKey: String(subgraphInput.id),
|
||||
interiorNodeId: boundWidget.sourceNodeId,
|
||||
widgetName: boundWidget.sourceWidgetName
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const resolved =
|
||||
this._resolveLinkedPromotionBySubgraphInput(subgraphInput)
|
||||
if (!resolved) continue
|
||||
|
||||
linkedEntries.push({ inputName: input.name, ...resolved })
|
||||
linkedEntries.push({
|
||||
inputName: input.label ?? input.name,
|
||||
inputKey: String(subgraphInput.id),
|
||||
...resolved
|
||||
})
|
||||
}
|
||||
|
||||
const seenEntryKeys = new Set<string>()
|
||||
const deduplicatedEntries = linkedEntries.filter((entry) => {
|
||||
const entryKey = this._makePromotionViewKey(
|
||||
entry.inputName,
|
||||
entry.inputKey,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName
|
||||
entry.widgetName,
|
||||
entry.inputName
|
||||
)
|
||||
if (seenEntryKeys.has(entryKey)) return false
|
||||
|
||||
@@ -137,24 +213,73 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
return true
|
||||
})
|
||||
|
||||
if (cache)
|
||||
this._linkedEntriesCache = {
|
||||
version: this._cacheVersion,
|
||||
hasMissingBoundSourceWidget,
|
||||
entries: deduplicatedEntries
|
||||
}
|
||||
|
||||
return deduplicatedEntries
|
||||
}
|
||||
|
||||
private _hasMissingBoundSourceWidget(): boolean {
|
||||
return this.inputs.some((input) => {
|
||||
const boundWidget =
|
||||
input._widget && isPromotedWidgetView(input._widget)
|
||||
? input._widget
|
||||
: undefined
|
||||
if (!boundWidget) return false
|
||||
|
||||
const boundNode = this.subgraph.getNodeById(boundWidget.sourceNodeId)
|
||||
return (
|
||||
boundNode?.widgets?.some(
|
||||
(widget) => widget.name === boundWidget.sourceWidgetName
|
||||
) !== true
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private _getPromotedViews(): PromotedWidgetView[] {
|
||||
const store = usePromotionStore()
|
||||
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
|
||||
const hasMissingBoundSourceWidget = this._hasMissingBoundSourceWidget()
|
||||
const cachedViews = this._promotedViewsCache
|
||||
if (
|
||||
cachedViews?.version === this._cacheVersion &&
|
||||
cachedViews.entriesRef === entries &&
|
||||
cachedViews.hasMissingBoundSourceWidget === hasMissingBoundSourceWidget
|
||||
)
|
||||
return cachedViews.views
|
||||
|
||||
const linkedEntries = this._getLinkedPromotionEntries()
|
||||
|
||||
const { displayNameByViewKey, reconcileEntries } =
|
||||
this._buildPromotionReconcileState(entries, linkedEntries)
|
||||
|
||||
return this._promotedViewManager.reconcile(reconcileEntries, (entry) =>
|
||||
createPromotedWidgetView(
|
||||
this,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName,
|
||||
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined
|
||||
)
|
||||
const views = this._promotedViewManager.reconcile(
|
||||
reconcileEntries,
|
||||
(entry) =>
|
||||
createPromotedWidgetView(
|
||||
this,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName,
|
||||
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined
|
||||
)
|
||||
)
|
||||
|
||||
this._promotedViewsCache = {
|
||||
version: this._cacheVersion,
|
||||
entriesRef: entries,
|
||||
hasMissingBoundSourceWidget,
|
||||
views
|
||||
}
|
||||
|
||||
return views
|
||||
}
|
||||
|
||||
private _invalidatePromotedViewsCache(): void {
|
||||
this._cacheVersion++
|
||||
}
|
||||
|
||||
private _syncPromotions(): void {
|
||||
@@ -162,10 +287,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
const store = usePromotionStore()
|
||||
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
|
||||
const linkedEntries = this._getLinkedPromotionEntries()
|
||||
const { mergedEntries, shouldPersistLinkedOnly } =
|
||||
this._buildPromotionPersistenceState(entries, linkedEntries)
|
||||
if (!shouldPersistLinkedOnly) return
|
||||
const linkedEntries = this._getLinkedPromotionEntries(false)
|
||||
// Intentionally preserve independent store promotions when linked coverage is partial;
|
||||
// tests assert that mixed linked/independent states must not collapse to linked-only.
|
||||
const { mergedEntries } = this._buildPromotionPersistenceState(
|
||||
entries,
|
||||
linkedEntries
|
||||
)
|
||||
|
||||
const hasChanged =
|
||||
mergedEntries.length !== entries.length ||
|
||||
@@ -180,7 +308,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
private _buildPromotionReconcileState(
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
entries: PromotionEntry[],
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): {
|
||||
displayNameByViewKey: Map<string, string>
|
||||
@@ -196,48 +324,64 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
)
|
||||
const linkedReconcileEntries =
|
||||
this._buildLinkedReconcileEntries(linkedEntries)
|
||||
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries)
|
||||
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(
|
||||
linkedEntries,
|
||||
fallbackStoredEntries
|
||||
)
|
||||
const reconcileEntries = shouldPersistLinkedOnly
|
||||
? linkedReconcileEntries
|
||||
: [...linkedReconcileEntries, ...fallbackStoredEntries]
|
||||
|
||||
return {
|
||||
displayNameByViewKey: this._buildDisplayNameByViewKey(linkedEntries),
|
||||
reconcileEntries: shouldPersistLinkedOnly
|
||||
? linkedReconcileEntries
|
||||
: [...linkedReconcileEntries, ...fallbackStoredEntries]
|
||||
reconcileEntries
|
||||
}
|
||||
}
|
||||
|
||||
private _buildPromotionPersistenceState(
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
entries: PromotionEntry[],
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): {
|
||||
mergedEntries: Array<{ interiorNodeId: string; widgetName: string }>
|
||||
shouldPersistLinkedOnly: boolean
|
||||
mergedEntries: PromotionEntry[]
|
||||
} {
|
||||
const { linkedPromotionEntries, fallbackStoredEntries } =
|
||||
this._collectLinkedAndFallbackEntries(entries, linkedEntries)
|
||||
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries)
|
||||
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(
|
||||
linkedEntries,
|
||||
fallbackStoredEntries
|
||||
)
|
||||
|
||||
return {
|
||||
mergedEntries: shouldPersistLinkedOnly
|
||||
? linkedPromotionEntries
|
||||
: [...linkedPromotionEntries, ...fallbackStoredEntries],
|
||||
shouldPersistLinkedOnly
|
||||
: [...linkedPromotionEntries, ...fallbackStoredEntries]
|
||||
}
|
||||
}
|
||||
|
||||
private _collectLinkedAndFallbackEntries(
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
entries: PromotionEntry[],
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): {
|
||||
linkedPromotionEntries: Array<{
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}>
|
||||
fallbackStoredEntries: Array<{ interiorNodeId: string; widgetName: string }>
|
||||
linkedPromotionEntries: PromotionEntry[]
|
||||
fallbackStoredEntries: PromotionEntry[]
|
||||
} {
|
||||
const linkedPromotionEntries = this._toPromotionEntries(linkedEntries)
|
||||
const fallbackStoredEntries = this._getFallbackStoredEntries(
|
||||
const excludedEntryKeys = new Set(
|
||||
linkedPromotionEntries.map((entry) =>
|
||||
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
|
||||
)
|
||||
)
|
||||
const connectedEntryKeys = this._getConnectedPromotionEntryKeys()
|
||||
for (const key of connectedEntryKeys) {
|
||||
excludedEntryKeys.add(key)
|
||||
}
|
||||
|
||||
const prePruneFallbackStoredEntries = this._getFallbackStoredEntries(
|
||||
entries,
|
||||
excludedEntryKeys
|
||||
)
|
||||
const fallbackStoredEntries = this._pruneStaleAliasFallbackEntries(
|
||||
prePruneFallbackStoredEntries,
|
||||
linkedPromotionEntries
|
||||
)
|
||||
|
||||
@@ -248,14 +392,37 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
private _shouldPersistLinkedOnly(
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
linkedEntries: LinkedPromotionEntry[],
|
||||
fallbackStoredEntries: PromotionEntry[]
|
||||
): boolean {
|
||||
return this.inputs.length > 0 && linkedEntries.length === this.inputs.length
|
||||
if (
|
||||
!(this.inputs.length > 0 && linkedEntries.length === this.inputs.length)
|
||||
)
|
||||
return false
|
||||
|
||||
const linkedWidgetNames = new Set(
|
||||
linkedEntries.map((entry) => entry.widgetName)
|
||||
)
|
||||
|
||||
const hasFallbackToKeep = fallbackStoredEntries.some((entry) => {
|
||||
const sourceNode = this.subgraph.getNodeById(entry.interiorNodeId)
|
||||
const hasSourceWidget =
|
||||
sourceNode?.widgets?.some(
|
||||
(widget) => widget.name === entry.widgetName
|
||||
) === true
|
||||
if (hasSourceWidget) return true
|
||||
|
||||
// If the fallback widget name overlaps a linked widget name, keep it
|
||||
// until aliasing can be positively proven.
|
||||
return linkedWidgetNames.has(entry.widgetName)
|
||||
})
|
||||
|
||||
return !hasFallbackToKeep
|
||||
}
|
||||
|
||||
private _toPromotionEntries(
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): Array<{ interiorNodeId: string; widgetName: string }> {
|
||||
): PromotionEntry[] {
|
||||
return linkedEntries.map(({ interiorNodeId, widgetName }) => ({
|
||||
interiorNodeId,
|
||||
widgetName
|
||||
@@ -263,33 +430,98 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
private _getFallbackStoredEntries(
|
||||
entries: Array<{ interiorNodeId: string; widgetName: string }>,
|
||||
linkedPromotionEntries: Array<{
|
||||
interiorNodeId: string
|
||||
widgetName: string
|
||||
}>
|
||||
): Array<{ interiorNodeId: string; widgetName: string }> {
|
||||
const linkedKeys = new Set(
|
||||
linkedPromotionEntries.map((entry) =>
|
||||
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
|
||||
)
|
||||
)
|
||||
entries: PromotionEntry[],
|
||||
excludedEntryKeys: Set<string>
|
||||
): PromotionEntry[] {
|
||||
return entries.filter(
|
||||
(entry) =>
|
||||
!linkedKeys.has(
|
||||
!excludedEntryKeys.has(
|
||||
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private _pruneStaleAliasFallbackEntries(
|
||||
fallbackStoredEntries: PromotionEntry[],
|
||||
linkedPromotionEntries: PromotionEntry[]
|
||||
): PromotionEntry[] {
|
||||
if (
|
||||
fallbackStoredEntries.length === 0 ||
|
||||
linkedPromotionEntries.length === 0
|
||||
)
|
||||
return fallbackStoredEntries
|
||||
|
||||
const linkedConcreteKeys = new Set(
|
||||
linkedPromotionEntries
|
||||
.map((entry) => this._resolveConcretePromotionEntryKey(entry))
|
||||
.filter((key): key is string => key !== undefined)
|
||||
)
|
||||
if (linkedConcreteKeys.size === 0) return fallbackStoredEntries
|
||||
|
||||
const prunedEntries: PromotionEntry[] = []
|
||||
|
||||
for (const entry of fallbackStoredEntries) {
|
||||
const concreteKey = this._resolveConcretePromotionEntryKey(entry)
|
||||
if (concreteKey && linkedConcreteKeys.has(concreteKey)) continue
|
||||
|
||||
prunedEntries.push(entry)
|
||||
}
|
||||
|
||||
return prunedEntries
|
||||
}
|
||||
|
||||
private _resolveConcretePromotionEntryKey(
|
||||
entry: PromotionEntry
|
||||
): string | undefined {
|
||||
const result = resolveConcretePromotedWidget(
|
||||
this,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName
|
||||
)
|
||||
if (result.status !== 'resolved') return undefined
|
||||
|
||||
return this._makePromotionEntryKey(
|
||||
String(result.resolved.node.id),
|
||||
result.resolved.widget.name
|
||||
)
|
||||
}
|
||||
|
||||
private _getConnectedPromotionEntryKeys(): Set<string> {
|
||||
const connectedEntryKeys = new Set<string>()
|
||||
|
||||
for (const input of this.inputs) {
|
||||
const subgraphInput = input._subgraphSlot
|
||||
if (!subgraphInput) continue
|
||||
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
|
||||
for (const widget of connectedWidgets) {
|
||||
if (!hasWidgetNode(widget)) continue
|
||||
|
||||
connectedEntryKeys.add(
|
||||
this._makePromotionEntryKey(String(widget.node.id), widget.name)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return connectedEntryKeys
|
||||
}
|
||||
|
||||
private _buildLinkedReconcileEntries(
|
||||
linkedEntries: LinkedPromotionEntry[]
|
||||
): Array<{ interiorNodeId: string; widgetName: string; viewKey: string }> {
|
||||
return linkedEntries.map(({ inputName, interiorNodeId, widgetName }) => ({
|
||||
interiorNodeId,
|
||||
widgetName,
|
||||
viewKey: this._makePromotionViewKey(inputName, interiorNodeId, widgetName)
|
||||
}))
|
||||
return linkedEntries.map(
|
||||
({ inputKey, inputName, interiorNodeId, widgetName }) => ({
|
||||
interiorNodeId,
|
||||
widgetName,
|
||||
viewKey: this._makePromotionViewKey(
|
||||
inputKey,
|
||||
interiorNodeId,
|
||||
widgetName,
|
||||
inputName
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private _buildDisplayNameByViewKey(
|
||||
@@ -298,9 +530,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
return new Map(
|
||||
linkedEntries.map((entry) => [
|
||||
this._makePromotionViewKey(
|
||||
entry.inputName,
|
||||
entry.inputKey,
|
||||
entry.interiorNodeId,
|
||||
entry.widgetName
|
||||
entry.widgetName,
|
||||
entry.inputName
|
||||
),
|
||||
entry.inputName
|
||||
])
|
||||
@@ -315,11 +548,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
private _makePromotionViewKey(
|
||||
inputName: string,
|
||||
inputKey: string,
|
||||
interiorNodeId: string,
|
||||
widgetName: string
|
||||
widgetName: string,
|
||||
inputName = ''
|
||||
): string {
|
||||
return `${inputName}:${interiorNodeId}:${widgetName}`
|
||||
return JSON.stringify([inputKey, interiorNodeId, widgetName, inputName])
|
||||
}
|
||||
|
||||
private _resolveLegacyEntry(
|
||||
@@ -377,22 +611,34 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
(e) => {
|
||||
const subgraphInput = e.detail.input
|
||||
const { name, type } = subgraphInput
|
||||
const existingInput = this.inputs.find((i) => i.name === name)
|
||||
const existingInput = this.inputs.find(
|
||||
(input) => input._subgraphSlot === subgraphInput
|
||||
)
|
||||
if (existingInput) {
|
||||
const linkId = subgraphInput.linkIds[0]
|
||||
const { inputNode, input } = subgraph.links[linkId].resolve(subgraph)
|
||||
const widget = inputNode?.widgets?.find?.((w) => w.name === name)
|
||||
if (widget && inputNode)
|
||||
if (linkId === undefined) return
|
||||
|
||||
const link = this.subgraph.getLink(linkId)
|
||||
if (!link) return
|
||||
|
||||
const { inputNode, input } = link.resolve(subgraph)
|
||||
if (!inputNode || !input) return
|
||||
|
||||
const widget = inputNode.getWidgetFromSlot(input)
|
||||
if (widget)
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
existingInput,
|
||||
widget,
|
||||
input?.widget,
|
||||
input.widget,
|
||||
inputNode
|
||||
)
|
||||
return
|
||||
}
|
||||
const input = this.addInput(name, type)
|
||||
const input = this.addInput(name, type, {
|
||||
_subgraphSlot: subgraphInput
|
||||
})
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
this._addSubgraphInputListeners(subgraphInput, input)
|
||||
},
|
||||
@@ -406,6 +652,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (widget) this.ensureWidgetRemoved(widget)
|
||||
|
||||
this.removeInput(e.detail.index)
|
||||
this._invalidatePromotedViewsCache()
|
||||
this._syncPromotions()
|
||||
this.setDirtyCanvas(true, true)
|
||||
},
|
||||
@@ -441,6 +688,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (input._widget) {
|
||||
input._widget.label = newName
|
||||
}
|
||||
this._invalidatePromotedViewsCache()
|
||||
this.graph?.trigger('node:slot-label:changed', {
|
||||
nodeId: this.id,
|
||||
slotType: NodeSlotType.INPUT
|
||||
})
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
@@ -484,6 +736,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
subgraphInput: SubgraphInput,
|
||||
input: INodeInputSlot & Partial<ISubgraphInput>
|
||||
) {
|
||||
input._subgraphSlot = subgraphInput
|
||||
|
||||
if (
|
||||
input._listenerController &&
|
||||
typeof input._listenerController.abort === 'function'
|
||||
@@ -496,36 +750,39 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
subgraphInput.events.addEventListener(
|
||||
'input-connected',
|
||||
(e) => {
|
||||
const widget = subgraphInput._widget
|
||||
if (!widget) return
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
// If this widget is already promoted, demote it first
|
||||
// so it transitions cleanly to being linked via SubgraphInput.
|
||||
// `SubgraphInput.connect()` dispatches before appending to `linkIds`,
|
||||
// so resolve by current links would miss this new connection.
|
||||
// Keep the earliest bound view once present, and only bind from event
|
||||
// payload when this input has no representative yet.
|
||||
const nodeId = String(e.detail.node.id)
|
||||
if (
|
||||
usePromotionStore().isPromoted(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
nodeId,
|
||||
widget.name
|
||||
e.detail.widget.name
|
||||
)
|
||||
) {
|
||||
usePromotionStore().demote(
|
||||
this.rootGraph.id,
|
||||
this.id,
|
||||
nodeId,
|
||||
widget.name
|
||||
e.detail.widget.name
|
||||
)
|
||||
}
|
||||
|
||||
const widgetLocator = e.detail.input.widget
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
input,
|
||||
widget,
|
||||
widgetLocator,
|
||||
e.detail.node
|
||||
)
|
||||
const didSetWidgetFromEvent = !input._widget
|
||||
if (didSetWidgetFromEvent)
|
||||
this._setWidget(
|
||||
subgraphInput,
|
||||
input,
|
||||
e.detail.widget,
|
||||
e.detail.input.widget,
|
||||
e.detail.node
|
||||
)
|
||||
|
||||
this._syncPromotions()
|
||||
},
|
||||
{ signal }
|
||||
@@ -534,9 +791,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
subgraphInput.events.addEventListener(
|
||||
'input-disconnected',
|
||||
() => {
|
||||
// If the input is connected to more than one widget, don't remove the widget
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
// If links remain, rebind to the current representative.
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
if (connectedWidgets.length > 0) return
|
||||
if (connectedWidgets.length > 0) {
|
||||
this._resolveInputWidget(subgraphInput, input)
|
||||
this._syncPromotions()
|
||||
return
|
||||
}
|
||||
|
||||
if (input._widget) this.ensureWidgetRemoved(input._widget)
|
||||
|
||||
@@ -549,6 +812,62 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
)
|
||||
}
|
||||
|
||||
private _rebindInputSubgraphSlots(): void {
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
const subgraphSlots = [...this.subgraph.inputNode.slots]
|
||||
const slotsBySignature = new Map<string, SubgraphInput[]>()
|
||||
const slotsByName = new Map<string, SubgraphInput[]>()
|
||||
|
||||
for (const slot of subgraphSlots) {
|
||||
const signature = `${slot.name}:${String(slot.type)}`
|
||||
const signatureSlots = slotsBySignature.get(signature)
|
||||
if (signatureSlots) {
|
||||
signatureSlots.push(slot)
|
||||
} else {
|
||||
slotsBySignature.set(signature, [slot])
|
||||
}
|
||||
|
||||
const nameSlots = slotsByName.get(slot.name)
|
||||
if (nameSlots) {
|
||||
nameSlots.push(slot)
|
||||
} else {
|
||||
slotsByName.set(slot.name, [slot])
|
||||
}
|
||||
}
|
||||
|
||||
const assignedSlotIds = new Set<string>()
|
||||
const takeUnassignedSlot = (
|
||||
slots: SubgraphInput[] | undefined
|
||||
): SubgraphInput | undefined => {
|
||||
if (!slots) return undefined
|
||||
return slots.find((slot) => !assignedSlotIds.has(String(slot.id)))
|
||||
}
|
||||
|
||||
for (const input of this.inputs) {
|
||||
const existingSlot = input._subgraphSlot
|
||||
if (
|
||||
existingSlot &&
|
||||
this.subgraph.inputNode.slots.some((slot) => slot === existingSlot)
|
||||
) {
|
||||
assignedSlotIds.add(String(existingSlot.id))
|
||||
continue
|
||||
}
|
||||
|
||||
const signature = `${input.name}:${String(input.type)}`
|
||||
const matchedSlot =
|
||||
takeUnassignedSlot(slotsBySignature.get(signature)) ??
|
||||
takeUnassignedSlot(slotsByName.get(input.name))
|
||||
|
||||
if (matchedSlot) {
|
||||
input._subgraphSlot = matchedSlot
|
||||
assignedSlotIds.add(String(matchedSlot.id))
|
||||
} else {
|
||||
delete input._subgraphSlot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override configure(info: ExportedSubgraphInstance): void {
|
||||
for (const input of this.inputs) {
|
||||
if (
|
||||
@@ -561,8 +880,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
this.inputs.length = 0
|
||||
this.inputs.push(
|
||||
...this.subgraph.inputNode.slots.map(
|
||||
(slot) =>
|
||||
...this.subgraph.inputNode.slots.map((slot) =>
|
||||
Object.assign(
|
||||
new NodeInputSlot(
|
||||
{
|
||||
name: slot.name,
|
||||
@@ -572,7 +891,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
link: null
|
||||
},
|
||||
this
|
||||
)
|
||||
),
|
||||
{
|
||||
_subgraphSlot: slot
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -597,6 +920,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
override _internalConfigureAfterSlots() {
|
||||
this._rebindInputSubgraphSlots()
|
||||
|
||||
// Ensure proxyWidgets is initialized so it serializes
|
||||
this.properties.proxyWidgets ??= []
|
||||
|
||||
@@ -604,10 +929,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
// Do NOT clear properties.proxyWidgets — it was already populated
|
||||
// from serialized data by super.configure(info) before this runs.
|
||||
this._promotedViewManager.clear()
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
// Hydrate the store from serialized properties.proxyWidgets
|
||||
const raw = parseProxyWidgets(this.properties.proxyWidgets)
|
||||
const store = usePromotionStore()
|
||||
|
||||
const entries = raw
|
||||
.map(([nodeId, widgetName]) => {
|
||||
if (nodeId === '-1') {
|
||||
@@ -624,6 +951,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
return { interiorNodeId: nodeId, widgetName }
|
||||
})
|
||||
.filter((e): e is NonNullable<typeof e> => e !== null)
|
||||
|
||||
store.setPromotions(this.rootGraph.id, this.id, entries)
|
||||
|
||||
// Write back resolved entries so legacy -1 format doesn't persist
|
||||
@@ -636,9 +964,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
// Check all inputs for connected widgets
|
||||
for (const input of this.inputs) {
|
||||
const subgraphInput = this.subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === input.name
|
||||
)
|
||||
const subgraphInput = input._subgraphSlot
|
||||
if (!subgraphInput) {
|
||||
// Skip inputs that don't exist in the subgraph definition
|
||||
// This can happen when loading workflows with dynamically added inputs
|
||||
@@ -702,6 +1028,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
inputWidget: IWidgetLocator | undefined,
|
||||
interiorNode: LGraphNode
|
||||
) {
|
||||
this._invalidatePromotedViewsCache()
|
||||
this._flushPendingPromotions()
|
||||
|
||||
const nodeId = String(interiorNode.id)
|
||||
@@ -751,8 +1078,18 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
nodeId,
|
||||
widgetName,
|
||||
() =>
|
||||
createPromotedWidgetView(this, nodeId, widgetName, subgraphInput.name),
|
||||
this._makePromotionViewKey(subgraphInput.name, nodeId, widgetName)
|
||||
createPromotedWidgetView(
|
||||
this,
|
||||
nodeId,
|
||||
widgetName,
|
||||
input.label ?? subgraphInput.name
|
||||
),
|
||||
this._makePromotionViewKey(
|
||||
String(subgraphInput.id),
|
||||
nodeId,
|
||||
widgetName,
|
||||
input.label ?? input.name
|
||||
)
|
||||
)
|
||||
|
||||
// NOTE: This code creates linked chains of prototypes for passing across
|
||||
@@ -808,6 +1145,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
return super.addInput(name, type, inputProperties)
|
||||
}
|
||||
|
||||
override getSlotFromWidget(
|
||||
widget: IBaseWidget | undefined
|
||||
): INodeInputSlot | undefined {
|
||||
if (!widget || !isPromotedWidgetView(widget))
|
||||
return super.getSlotFromWidget(widget)
|
||||
|
||||
return this.inputs.find((input) => input._widget === widget)
|
||||
}
|
||||
|
||||
override getWidgetFromSlot(slot: INodeInputSlot): IBaseWidget | undefined {
|
||||
if (slot._widget) return slot._widget
|
||||
return super.getWidgetFromSlot(slot)
|
||||
}
|
||||
|
||||
override getInputLink(slot: number): LLink | null {
|
||||
// Output side: the link from inside the subgraph
|
||||
const innerLink = this.subgraph.outputNode.slots[slot].getLinks().at(0)
|
||||
@@ -937,18 +1288,24 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
|
||||
private _removePromotedView(view: PromotedWidgetView): void {
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
this._promotedViewManager.remove(view.sourceNodeId, view.sourceWidgetName)
|
||||
// Reconciled views can also be keyed by inputName-scoped view keys.
|
||||
// Remove both key shapes to avoid stale cache entries across promote/rebind flows.
|
||||
this._promotedViewManager.removeByViewKey(
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName,
|
||||
this._makePromotionViewKey(
|
||||
view.name,
|
||||
for (const input of this.inputs) {
|
||||
if (input._widget !== view || !input._subgraphSlot) continue
|
||||
const inputName = input.label ?? input.name
|
||||
|
||||
this._promotedViewManager.removeByViewKey(
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName
|
||||
view.sourceWidgetName,
|
||||
this._makePromotionViewKey(
|
||||
String(input._subgraphSlot.id),
|
||||
view.sourceNodeId,
|
||||
view.sourceWidgetName,
|
||||
inputName
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override removeWidget(widget: IBaseWidget): void {
|
||||
@@ -987,6 +1344,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
|
||||
override onRemoved(): void {
|
||||
this._eventAbortController.abort()
|
||||
this._invalidatePromotedViewsCache()
|
||||
|
||||
for (const widget of this.widgets) {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
@@ -1053,9 +1411,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
for (const input of this.inputs) {
|
||||
if (!input._widget) continue
|
||||
|
||||
const subgraphInput = this.subgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === input.name
|
||||
)
|
||||
const subgraphInput =
|
||||
input._subgraphSlot ??
|
||||
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
|
||||
if (!subgraphInput) continue
|
||||
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
export const subgraphComplexPromotion1 = {
|
||||
id: 'e49902fa-ee3e-40e6-a59e-c8931888ad0e',
|
||||
revision: 0,
|
||||
last_node_id: 21,
|
||||
last_link_id: 23,
|
||||
nodes: [
|
||||
{
|
||||
id: 12,
|
||||
type: 'PreviewAny',
|
||||
pos: [1367.8236034435063, 305.51100163315823],
|
||||
size: [225, 166],
|
||||
flags: {},
|
||||
order: 3,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
name: 'source',
|
||||
type: '*',
|
||||
link: 21
|
||||
}
|
||||
],
|
||||
outputs: [],
|
||||
properties: {
|
||||
'Node name for S&R': 'PreviewAny'
|
||||
},
|
||||
widgets_values: [null, null, null]
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
type: 'PreviewAny',
|
||||
pos: [1271.9742739655217, 551.9124470179938],
|
||||
size: [225, 166],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
name: 'source',
|
||||
type: '*',
|
||||
link: 19
|
||||
}
|
||||
],
|
||||
outputs: [],
|
||||
properties: {
|
||||
'Node name for S&R': 'PreviewAny'
|
||||
},
|
||||
widgets_values: [null, null, null]
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
type: 'PreviewAny',
|
||||
pos: [1414.8695925586444, 847.9456885036253],
|
||||
size: [225, 166],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
name: 'source',
|
||||
type: '*',
|
||||
link: 20
|
||||
}
|
||||
],
|
||||
outputs: [],
|
||||
properties: {
|
||||
'Node name for S&R': 'PreviewAny'
|
||||
},
|
||||
widgets_values: [null, null, null]
|
||||
},
|
||||
{
|
||||
id: 21,
|
||||
type: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f',
|
||||
pos: [741.0375276545419, 560.8496560588814],
|
||||
size: [225, 305.3333435058594],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
name: 'STRING',
|
||||
type: 'STRING',
|
||||
links: [19]
|
||||
},
|
||||
{
|
||||
name: 'STRING_1',
|
||||
type: 'STRING',
|
||||
links: [20]
|
||||
},
|
||||
{
|
||||
name: 'STRING_2',
|
||||
type: 'STRING',
|
||||
links: [21]
|
||||
}
|
||||
],
|
||||
properties: {
|
||||
proxyWidgets: [
|
||||
['20', 'string_a'],
|
||||
['19', 'string_a'],
|
||||
['18', 'string_a']
|
||||
]
|
||||
},
|
||||
widgets_values: []
|
||||
}
|
||||
],
|
||||
links: [
|
||||
[19, 21, 0, 13, 0, 'STRING'],
|
||||
[20, 21, 1, 14, 0, 'STRING'],
|
||||
[21, 21, 2, 12, 0, 'STRING']
|
||||
],
|
||||
groups: [],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f',
|
||||
version: 1,
|
||||
state: {
|
||||
lastGroupId: 0,
|
||||
lastNodeId: 21,
|
||||
lastLinkId: 23,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
revision: 0,
|
||||
config: {},
|
||||
name: 'New Subgraph',
|
||||
inputNode: {
|
||||
id: -10,
|
||||
bounding: [596.9206067268835, 805.5404332481304, 120, 60]
|
||||
},
|
||||
outputNode: {
|
||||
id: -20,
|
||||
bounding: [1376.7286067268833, 769.5404332481304, 120, 100]
|
||||
},
|
||||
inputs: [
|
||||
{
|
||||
id: '78479bf4-8145-41d5-9d11-c38e3149fc59',
|
||||
name: 'string_a',
|
||||
type: 'STRING',
|
||||
linkIds: [22, 23],
|
||||
pos: [696.9206067268835, 825.5404332481304]
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
id: 'aa263e4e-b558-4dbf-bcb9-ff0c1c72cbef',
|
||||
name: 'STRING',
|
||||
type: 'STRING',
|
||||
linkIds: [16],
|
||||
localized_name: 'STRING',
|
||||
pos: [1396.7286067268833, 789.5404332481304]
|
||||
},
|
||||
{
|
||||
id: '8eee6fe3-dc2f-491a-9e01-04ef83309dad',
|
||||
name: 'STRING_1',
|
||||
type: 'STRING',
|
||||
linkIds: [17],
|
||||
localized_name: 'STRING_1',
|
||||
pos: [1396.7286067268833, 809.5404332481304]
|
||||
},
|
||||
{
|
||||
id: 'a446d5b9-6042-434d-848a-5d3af5e8e0d4',
|
||||
name: 'STRING_2',
|
||||
type: 'STRING',
|
||||
linkIds: [18],
|
||||
localized_name: 'STRING_2',
|
||||
pos: [1396.7286067268833, 829.5404332481304]
|
||||
}
|
||||
],
|
||||
widgets: [],
|
||||
nodes: [
|
||||
{
|
||||
id: 18,
|
||||
type: 'StringConcatenate',
|
||||
pos: [818.5102631756379, 706.4562049408103],
|
||||
size: [480, 268],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'string_a',
|
||||
name: 'string_a',
|
||||
type: 'STRING',
|
||||
widget: {
|
||||
name: 'string_a'
|
||||
},
|
||||
link: 23
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'STRING',
|
||||
name: 'STRING',
|
||||
type: 'STRING',
|
||||
links: [16]
|
||||
}
|
||||
],
|
||||
title: 'InnerCatB',
|
||||
properties: {
|
||||
'Node name for S&R': 'StringConcatenate'
|
||||
},
|
||||
widgets_values: ['Poop', '_B', '']
|
||||
},
|
||||
{
|
||||
id: 19,
|
||||
type: 'StringConcatenate',
|
||||
pos: [812.9370280206649, 1040.648423402667],
|
||||
size: [480, 268],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'STRING',
|
||||
name: 'STRING',
|
||||
type: 'STRING',
|
||||
links: [17]
|
||||
}
|
||||
],
|
||||
title: 'InnerCatC',
|
||||
properties: {
|
||||
'Node name for S&R': 'StringConcatenate'
|
||||
},
|
||||
widgets_values: ['', '_C', '']
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
type: 'StringConcatenate',
|
||||
pos: [824.7110975088726, 386.4230523609899],
|
||||
size: [480, 268],
|
||||
flags: {},
|
||||
order: 2,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{
|
||||
localized_name: 'string_a',
|
||||
name: 'string_a',
|
||||
type: 'STRING',
|
||||
widget: {
|
||||
name: 'string_a'
|
||||
},
|
||||
link: 22
|
||||
}
|
||||
],
|
||||
outputs: [
|
||||
{
|
||||
localized_name: 'STRING',
|
||||
name: 'STRING',
|
||||
type: 'STRING',
|
||||
links: [18]
|
||||
}
|
||||
],
|
||||
title: 'InnerCatA',
|
||||
properties: {
|
||||
'Node name for S&R': 'StringConcatenate'
|
||||
},
|
||||
widgets_values: ['Poop', '_A', '']
|
||||
}
|
||||
],
|
||||
groups: [],
|
||||
links: [
|
||||
{
|
||||
id: 16,
|
||||
origin_id: 18,
|
||||
origin_slot: 0,
|
||||
target_id: -20,
|
||||
target_slot: 0,
|
||||
type: 'STRING'
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
origin_id: 19,
|
||||
origin_slot: 0,
|
||||
target_id: -20,
|
||||
target_slot: 1,
|
||||
type: 'STRING'
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
origin_id: 20,
|
||||
origin_slot: 0,
|
||||
target_id: -20,
|
||||
target_slot: 2,
|
||||
type: 'STRING'
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
origin_id: -10,
|
||||
origin_slot: 0,
|
||||
target_id: 20,
|
||||
target_slot: 0,
|
||||
type: 'STRING'
|
||||
},
|
||||
{
|
||||
id: 23,
|
||||
origin_id: -10,
|
||||
origin_slot: 0,
|
||||
target_id: 18,
|
||||
target_slot: 0,
|
||||
type: 'STRING'
|
||||
}
|
||||
],
|
||||
extra: {
|
||||
workflowRendererVersion: 'Vue'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
config: {},
|
||||
extra: {
|
||||
ds: {
|
||||
scale: 0.6638894832438259,
|
||||
offset: [-408.2009703049473, -183.8039508449224]
|
||||
},
|
||||
workflowRendererVersion: 'Vue',
|
||||
frontendVersion: '1.42.3'
|
||||
},
|
||||
version: 0.4
|
||||
} as const as unknown as ISerialisedGraph
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
cleanupComplexPromotionFixtureNodeType,
|
||||
setupComplexPromotionFixture
|
||||
} from './subgraphHelpers'
|
||||
|
||||
const FIXTURE_STRING_CONCAT_TYPE = 'Fixture/StringConcatenate'
|
||||
|
||||
describe('setupComplexPromotionFixture', () => {
|
||||
afterEach(() => {
|
||||
cleanupComplexPromotionFixtureNodeType()
|
||||
})
|
||||
|
||||
it('can clean up the globally registered fixture node type', () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
setupComplexPromotionFixture()
|
||||
expect(
|
||||
LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE]
|
||||
).toBeDefined()
|
||||
|
||||
cleanupComplexPromotionFixtureNodeType()
|
||||
expect(
|
||||
LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE]
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,12 @@
|
||||
import { expect } from 'vitest'
|
||||
|
||||
import type { ISlotType, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraph, LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
@@ -17,6 +22,27 @@ import type {
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
import { subgraphComplexPromotion1 } from './subgraphComplexPromotion1'
|
||||
|
||||
const FIXTURE_STRING_CONCAT_TYPE = 'Fixture/StringConcatenate'
|
||||
|
||||
class FixtureStringConcatenateNode extends LGraphNode {
|
||||
constructor() {
|
||||
super('StringConcatenate')
|
||||
const input = this.addInput('string_a', 'STRING')
|
||||
input.widget = { name: 'string_a' }
|
||||
this.addOutput('STRING', 'STRING')
|
||||
this.addWidget('text', 'string_a', '', () => {})
|
||||
this.addWidget('text', 'string_b', '', () => {})
|
||||
this.addWidget('text', 'delimiter', '', () => {})
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupComplexPromotionFixtureNodeType(): void {
|
||||
if (!LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE]) return
|
||||
LiteGraph.unregisterNodeType(FIXTURE_STRING_CONCAT_TYPE)
|
||||
}
|
||||
|
||||
interface TestSubgraphOptions {
|
||||
id?: UUID
|
||||
name?: string
|
||||
@@ -209,6 +235,48 @@ export function createTestSubgraphNode(
|
||||
return new SubgraphNode(parentGraph, subgraph, instanceData)
|
||||
}
|
||||
|
||||
export function setupComplexPromotionFixture(): {
|
||||
graph: LGraph
|
||||
subgraph: Subgraph
|
||||
hostNode: SubgraphNode
|
||||
} {
|
||||
const fixture = structuredClone(subgraphComplexPromotion1)
|
||||
const subgraphData = fixture.definitions?.subgraphs?.[0]
|
||||
if (!subgraphData)
|
||||
throw new Error('Expected fixture to contain one subgraph definition')
|
||||
|
||||
cleanupComplexPromotionFixtureNodeType()
|
||||
LiteGraph.registerNodeType(
|
||||
FIXTURE_STRING_CONCAT_TYPE,
|
||||
FixtureStringConcatenateNode
|
||||
)
|
||||
|
||||
for (const node of subgraphData.nodes as Array<{ type: string }>) {
|
||||
if (node.type === 'StringConcatenate')
|
||||
node.type = FIXTURE_STRING_CONCAT_TYPE
|
||||
}
|
||||
|
||||
const hostNodeData = fixture.nodes.find((node) => node.id === 21)
|
||||
if (!hostNodeData)
|
||||
throw new Error('Expected fixture to contain subgraph instance node id 21')
|
||||
|
||||
const graph = new LGraph()
|
||||
const subgraph = graph.createSubgraph(subgraphData as ExportedSubgraph)
|
||||
subgraph.configure(subgraphData as ExportedSubgraph)
|
||||
const hostNode = new SubgraphNode(
|
||||
graph,
|
||||
subgraph,
|
||||
hostNodeData as ExportedSubgraphInstance
|
||||
)
|
||||
graph.add(hostNode)
|
||||
|
||||
return {
|
||||
graph,
|
||||
subgraph,
|
||||
hostNode
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a nested hierarchy of subgraphs for testing deep nesting scenarios.
|
||||
* @param options Configuration for the nested structure
|
||||
|
||||
@@ -3229,10 +3229,6 @@
|
||||
"addedToWorkspace": "تمت إضافتك إلى {workspaceName}",
|
||||
"inviteAccepted": "تم قبول الدعوة",
|
||||
"inviteFailed": "فشل في قبول الدعوة",
|
||||
"unsavedChanges": {
|
||||
"message": "لديك تغييرات غير محفوظة. هل تريد تجاهلها والانتقال إلى مساحة عمل أخرى؟",
|
||||
"title": "تغييرات غير محفوظة"
|
||||
},
|
||||
"viewWorkspace": "عرض مساحة العمل"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
|
||||
@@ -3178,6 +3178,10 @@
|
||||
"backToWorkflow": "Back to workflow",
|
||||
"loadTemplate": "Load a template",
|
||||
"cancelThisRun": "Cancel this run",
|
||||
"deleteAllAssets": "Delete all assets from this run",
|
||||
"hasCreditCost": "Requires additional credits",
|
||||
"viewGraph": "View node graph",
|
||||
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
|
||||
"welcome": {
|
||||
"title": "App Mode",
|
||||
"message": "A simplified view that hides the node graph so you can focus on creating.",
|
||||
@@ -3222,6 +3226,19 @@
|
||||
"outputPlaceholder": "Output nodes will show up here",
|
||||
"outputRequiredPlaceholder": "At least one node is required"
|
||||
},
|
||||
"error": {
|
||||
"header": "This app encountered an error",
|
||||
"log": "Error Logs",
|
||||
"mobileFixable": "Check {0} for errors",
|
||||
"requiresGraph": "Something went wrong during generation. This could be due to invalid hidden inputs, missing resources, or workflow configuration issues.",
|
||||
"promptVisitGraph": "View the node graph to see the full error.",
|
||||
"getHelp": "For help, view our {0}, {1}, or {2} with the copied error.",
|
||||
"goto": "Show errors in graph",
|
||||
"github": "submit a GitHub issue",
|
||||
"guide": "troubleshooting guide",
|
||||
"support": "contact our support",
|
||||
"promptShow": "Show error report"
|
||||
},
|
||||
"queue": {
|
||||
"clickToClear": "Click to clear queue",
|
||||
"clear": "Clear queue"
|
||||
@@ -3404,14 +3421,11 @@
|
||||
"retryDownload": "Retry download"
|
||||
},
|
||||
"workspace": {
|
||||
"unsavedChanges": {
|
||||
"title": "Unsaved Changes",
|
||||
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
|
||||
},
|
||||
"inviteAccepted": "Invite Accepted",
|
||||
"addedToWorkspace": "You have been added to:",
|
||||
"inviteFailed": "Failed to Accept Invite",
|
||||
"viewWorkspace": "View workspace"
|
||||
"viewWorkspace": "View workspace",
|
||||
"switchFailed": "Failed to switch workspace. Please try again."
|
||||
},
|
||||
"workspaceAuth": {
|
||||
"errors": {
|
||||
|
||||
@@ -3229,10 +3229,6 @@
|
||||
"addedToWorkspace": "Has sido añadido a {workspaceName}",
|
||||
"inviteAccepted": "Invitación aceptada",
|
||||
"inviteFailed": "No se pudo aceptar la invitación",
|
||||
"unsavedChanges": {
|
||||
"message": "Tienes cambios no guardados. ¿Quieres descartarlos y cambiar de espacio de trabajo?",
|
||||
"title": "Cambios no guardados"
|
||||
},
|
||||
"viewWorkspace": "Ver espacio de trabajo"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
|
||||
@@ -3241,10 +3241,6 @@
|
||||
"addedToWorkspace": "شما به {workspaceName} اضافه شدید",
|
||||
"inviteAccepted": "دعوت پذیرفته شد",
|
||||
"inviteFailed": "پذیرش دعوت ناموفق بود",
|
||||
"unsavedChanges": {
|
||||
"message": "شما تغییرات ذخیرهنشده دارید. آیا میخواهید آنها را رها کرده و فضای کاری را تغییر دهید؟",
|
||||
"title": "تغییرات ذخیرهنشده"
|
||||
},
|
||||
"viewWorkspace": "مشاهده workspace"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
|
||||
@@ -3229,10 +3229,6 @@
|
||||
"addedToWorkspace": "Vous avez été ajouté à {workspaceName}",
|
||||
"inviteAccepted": "Invitation acceptée",
|
||||
"inviteFailed": "Échec de l'acceptation de l'invitation",
|
||||
"unsavedChanges": {
|
||||
"message": "Vous avez des modifications non enregistrées. Voulez-vous les abandonner et changer d’espace de travail ?",
|
||||
"title": "Modifications non enregistrées"
|
||||
},
|
||||
"viewWorkspace": "Voir l’espace de travail"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
|
||||
@@ -3229,10 +3229,6 @@
|
||||
"addedToWorkspace": "{workspaceName}に追加されました",
|
||||
"inviteAccepted": "招待を承諾しました",
|
||||
"inviteFailed": "招待の承諾に失敗しました",
|
||||
"unsavedChanges": {
|
||||
"message": "未保存の変更があります。破棄してワークスペースを切り替えますか?",
|
||||
"title": "未保存の変更"
|
||||
},
|
||||
"viewWorkspace": "ワークスペースを見る"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
|
||||
@@ -3229,10 +3229,6 @@
|
||||
"addedToWorkspace": "{workspaceName} 워크스페이스에 추가되었습니다",
|
||||
"inviteAccepted": "초대 수락됨",
|
||||
"inviteFailed": "초대 수락에 실패했습니다",
|
||||
"unsavedChanges": {
|
||||
"message": "저장되지 않은 변경 사항이 있습니다. 변경 사항을 취소하고 워크스페이스를 전환하시겠습니까?",
|
||||
"title": "저장되지 않은 변경 사항"
|
||||
},
|
||||
"viewWorkspace": "워크스페이스 보기"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
|
||||
@@ -3241,10 +3241,6 @@
|
||||
"addedToWorkspace": "Você foi adicionado ao {workspaceName}",
|
||||
"inviteAccepted": "Convite aceito",
|
||||
"inviteFailed": "Falha ao aceitar convite",
|
||||
"unsavedChanges": {
|
||||
"message": "Você tem alterações não salvas. Deseja descartá-las e trocar de espaço de trabalho?",
|
||||
"title": "Alterações não salvas"
|
||||
},
|
||||
"viewWorkspace": "Ver workspace"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
|
||||
@@ -3229,10 +3229,6 @@
|
||||
"addedToWorkspace": "Вы были добавлены в {workspaceName}",
|
||||
"inviteAccepted": "Приглашение принято",
|
||||
"inviteFailed": "Не удалось принять приглашение",
|
||||
"unsavedChanges": {
|
||||
"message": "У вас есть несохранённые изменения. Хотите их отменить и переключиться на другое рабочее пространство?",
|
||||
"title": "Несохранённые изменения"
|
||||
},
|
||||
"viewWorkspace": "Просмотреть рабочее пространство"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
|
||||
@@ -3229,10 +3229,6 @@
|
||||
"addedToWorkspace": "{workspaceName} çalışma alanına eklendiniz",
|
||||
"inviteAccepted": "Davet kabul edildi",
|
||||
"inviteFailed": "Davet kabul edilemedi",
|
||||
"unsavedChanges": {
|
||||
"message": "Kaydedilmemiş değişiklikleriniz var. Bunları iptal edip çalışma alanlarını değiştirmek istiyor musunuz?",
|
||||
"title": "Kaydedilmemiş Değişiklikler"
|
||||
},
|
||||
"viewWorkspace": "Çalışma alanını görüntüle"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
|
||||
@@ -3229,10 +3229,6 @@
|
||||
"addedToWorkspace": "你已被加入 {workspaceName}",
|
||||
"inviteAccepted": "已接受邀請",
|
||||
"inviteFailed": "接受邀請失敗",
|
||||
"unsavedChanges": {
|
||||
"message": "您有未儲存的變更。是否要捨棄這些變更並切換工作區?",
|
||||
"title": "未儲存的變更"
|
||||
},
|
||||
"viewWorkspace": "檢視工作區"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
|
||||
@@ -3241,10 +3241,6 @@
|
||||
"addedToWorkspace": "您已被加入 {workspaceName}",
|
||||
"inviteAccepted": "邀请已接受",
|
||||
"inviteFailed": "接受邀请失败",
|
||||
"unsavedChanges": {
|
||||
"message": "您有未保存的更改。是否要放弃这些更改并切换工作区?",
|
||||
"title": "未保存的更改"
|
||||
},
|
||||
"viewWorkspace": "查看工作区"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
|
||||
@@ -235,7 +235,7 @@ const adaptedAsset = computed(() => {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
kind: fileKind.value,
|
||||
src: asset.preview_url || '',
|
||||
src: asset.thumbnail_url || asset.preview_url || '',
|
||||
size: asset.size,
|
||||
tags: asset.tags || [],
|
||||
created_at: asset.created_at,
|
||||
|
||||
@@ -44,7 +44,8 @@ export function mapTaskOutputToAssetItem(
|
||||
? new Date(taskItem.executionStartTimestamp).toISOString()
|
||||
: new Date().toISOString(),
|
||||
tags: ['output'],
|
||||
preview_url: output.previewUrl,
|
||||
thumbnail_url: output.previewUrl,
|
||||
preview_url: output.url,
|
||||
user_metadata: metadata
|
||||
}
|
||||
}
|
||||
@@ -62,6 +63,7 @@ export function mapInputFileToAssetItem(
|
||||
directory: 'input' | 'output' = 'input'
|
||||
): AssetItem {
|
||||
const params = new URLSearchParams({ filename, type: directory })
|
||||
const preview_url = api.apiURL(`/view?${params}`)
|
||||
appendCloudResParam(params, filename)
|
||||
|
||||
return {
|
||||
@@ -70,6 +72,7 @@ export function mapInputFileToAssetItem(
|
||||
size: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
tags: [directory],
|
||||
preview_url: api.apiURL(`/view?${params}`)
|
||||
thumbnail_url: api.apiURL(`/view?${params}`),
|
||||
preview_url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,8 +84,7 @@ export function useMediaAssetActions() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage'),
|
||||
life: 3000
|
||||
detail: t('g.failedToDownloadImage')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -126,8 +125,7 @@ export function useMediaAssetActions() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.failedToDownloadImage'),
|
||||
life: 3000
|
||||
detail: t('g.failedToDownloadImage')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -182,8 +180,7 @@ export function useMediaAssetActions() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('exportToast.exportFailedSingle'),
|
||||
life: 3000
|
||||
detail: t('exportToast.exportFailedSingle')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -238,8 +235,7 @@ export function useMediaAssetActions() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('mediaAsset.nodeTypeNotFound', { nodeType }),
|
||||
life: 3000
|
||||
detail: t('mediaAsset.nodeTypeNotFound', { nodeType })
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -252,8 +248,7 @@ export function useMediaAssetActions() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('mediaAsset.failedToCreateNode'),
|
||||
life: 3000
|
||||
detail: t('mediaAsset.failedToCreateNode')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -443,8 +438,7 @@ export function useMediaAssetActions() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('mediaAsset.selection.failedToAddNodes'),
|
||||
life: 3000
|
||||
detail: t('mediaAsset.selection.failedToAddNodes')
|
||||
})
|
||||
} else {
|
||||
toast.add({
|
||||
@@ -676,8 +670,7 @@ export function useMediaAssetActions() {
|
||||
summary: t('g.error'),
|
||||
detail: isSingle
|
||||
? t('mediaAsset.failedToDeleteAsset')
|
||||
: t('mediaAsset.selection.failedToDeleteAssets'),
|
||||
life: 3000
|
||||
: t('mediaAsset.selection.failedToDeleteAssets')
|
||||
})
|
||||
} else {
|
||||
// Partial success (only possible with multiple assets)
|
||||
@@ -698,8 +691,7 @@ export function useMediaAssetActions() {
|
||||
summary: t('g.error'),
|
||||
detail: isSingle
|
||||
? t('mediaAsset.failedToDeleteAsset')
|
||||
: t('mediaAsset.selection.failedToDeleteAssets'),
|
||||
life: 3000
|
||||
: t('mediaAsset.selection.failedToDeleteAssets')
|
||||
})
|
||||
} finally {
|
||||
// Hide loading overlay for all assets
|
||||
|
||||
@@ -10,6 +10,7 @@ const zAsset = z.object({
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
preview_id: z.string().nullable().optional(),
|
||||
preview_url: z.string().optional(),
|
||||
thumbnail_url: z.string().optional(),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
is_immutable: z.boolean().optional(),
|
||||
|
||||
@@ -73,8 +73,7 @@ export function createAssetWidget(
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('assetBrowser.invalidAsset'),
|
||||
detail: t('assetBrowser.invalidAssetDetail'),
|
||||
life: 5000
|
||||
detail: t('assetBrowser.invalidAssetDetail')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -92,8 +91,7 @@ export function createAssetWidget(
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('assetBrowser.invalidFilename'),
|
||||
detail: t('assetBrowser.invalidFilenameDetail'),
|
||||
life: 5000
|
||||
detail: t('assetBrowser.invalidFilenameDetail')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,7 +72,8 @@ function mapOutputsToAssetItems({
|
||||
size: 0,
|
||||
created_at: createdAtValue,
|
||||
tags: ['output'],
|
||||
preview_url: output.previewUrl,
|
||||
thumbnail_url: output.previewUrl,
|
||||
preview_url: output.url,
|
||||
user_metadata: {
|
||||
jobId,
|
||||
nodeId: output.nodeId,
|
||||
|
||||
@@ -217,22 +217,6 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
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,
|
||||
|
||||
@@ -141,7 +141,7 @@ describe('keybindingService - Canvas Keybindings', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute CopySelected for Ctrl+C on canvas', async () => {
|
||||
it('should not intercept Ctrl+C to allow native copy event', async () => {
|
||||
const event = createTestKeyboardEvent('c', {
|
||||
ctrlKey: true,
|
||||
target: canvasChild
|
||||
@@ -149,12 +149,10 @@ describe('keybindingService - Canvas Keybindings', () => {
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.CopySelected'
|
||||
)
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should execute PasteFromClipboard for Ctrl+V on canvas', async () => {
|
||||
it('should not intercept Ctrl+V to allow native paste event', async () => {
|
||||
const event = createTestKeyboardEvent('v', {
|
||||
ctrlKey: true,
|
||||
target: canvasChild
|
||||
@@ -162,9 +160,7 @@ describe('keybindingService - Canvas Keybindings', () => {
|
||||
|
||||
await keybindingService.keybindHandler(event)
|
||||
|
||||
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
|
||||
'Comfy.Canvas.PasteFromClipboard'
|
||||
)
|
||||
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should execute PasteFromClipboardWithConnect for Ctrl+Shift+V on canvas', async () => {
|
||||
|
||||
@@ -317,8 +317,7 @@ export function useNodeReplacement() {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error', 'Error'),
|
||||
detail: t('nodeReplacement.replaceFailed', 'Failed to replace nodes'),
|
||||
life: 5000
|
||||
detail: t('nodeReplacement.replaceFailed', 'Failed to replace nodes')
|
||||
})
|
||||
return replacedTypes
|
||||
} finally {
|
||||
|
||||
@@ -150,6 +150,41 @@ describe('fetchJobs', () => {
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('parses batch containing text-only preview outputs', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve(
|
||||
createMockResponse([
|
||||
createMockJob('image-job', 'completed', {
|
||||
preview_output: {
|
||||
filename: 'output.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
}),
|
||||
createMockJob('text-job', 'completed', {
|
||||
preview_output: {
|
||||
content: 'some generated text',
|
||||
nodeId: '5',
|
||||
mediaType: 'text'
|
||||
}
|
||||
}),
|
||||
createMockJob('no-preview-job', 'completed')
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
const result = await fetchHistory(mockFetch)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0].id).toBe('image-job')
|
||||
expect(result[1].id).toBe('text-job')
|
||||
expect(result[2].id).toBe('no-preview-job')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchQueue', () => {
|
||||
|
||||
@@ -18,13 +18,16 @@ const zJobStatus = z.enum([
|
||||
'cancelled'
|
||||
])
|
||||
|
||||
const zPreviewOutput = z.object({
|
||||
filename: z.string(),
|
||||
subfolder: z.string(),
|
||||
type: resultItemType,
|
||||
nodeId: z.string(),
|
||||
mediaType: z.string()
|
||||
})
|
||||
const zPreviewOutput = z
|
||||
.object({
|
||||
filename: z.string().optional(),
|
||||
subfolder: z.string().optional(),
|
||||
type: resultItemType.optional(),
|
||||
nodeId: z.string(),
|
||||
mediaType: z.string(),
|
||||
display_name: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
/**
|
||||
* Execution error from Jobs API.
|
||||
|
||||
@@ -83,8 +83,7 @@ describe('useSecrets', () => {
|
||||
expect(mockAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'Network error',
|
||||
life: 5000
|
||||
detail: 'Network error'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -130,8 +129,7 @@ describe('useSecrets', () => {
|
||||
expect(mockAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'g.error',
|
||||
detail: 'Delete failed',
|
||||
life: 5000
|
||||
detail: 'Delete failed'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,16 +33,14 @@ export function useSecrets() {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: err.message,
|
||||
life: 5000
|
||||
detail: err.message
|
||||
})
|
||||
} else {
|
||||
console.error('Unexpected error fetching secrets:', err)
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.unknownError'),
|
||||
life: 5000
|
||||
detail: t('g.unknownError')
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
@@ -60,16 +58,14 @@ export function useSecrets() {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: err.message,
|
||||
life: 5000
|
||||
detail: err.message
|
||||
})
|
||||
} else {
|
||||
console.error('Unexpected error deleting secret:', err)
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.unknownError'),
|
||||
life: 5000
|
||||
detail: t('g.unknownError')
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -370,8 +370,7 @@ export const useWorkflowService = () => {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('toastMessages.failedToSaveDraft'),
|
||||
life: 3000
|
||||
detail: t('toastMessages.failedToSaveDraft')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as I18n from 'vue-i18n'
|
||||
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
|
||||
import { useWorkflowPersistenceV2 } from './useWorkflowPersistenceV2'
|
||||
|
||||
const settingMocks = vi.hoisted(() => ({
|
||||
persistRef: null as { value: boolean } | null
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
settingMocks.persistRef = ref(true)
|
||||
return {
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.Workflow.Persist')
|
||||
return settingMocks.persistRef!.value
|
||||
return undefined
|
||||
}),
|
||||
set: vi.fn()
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
const mockToastAdd = vi.fn()
|
||||
vi.mock('primevue', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader',
|
||||
() => ({
|
||||
useSharedWorkflowUrlLoader: () => ({
|
||||
loadSharedWorkflowFromUrl: vi.fn().mockResolvedValue('not-present')
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof I18n>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const openWorkflowMock = vi.fn()
|
||||
const loadBlankWorkflowMock = vi.fn()
|
||||
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||
useWorkflowService: () => ({
|
||||
openWorkflow: openWorkflowMock,
|
||||
loadBlankWorkflow: loadBlankWorkflowMock
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
|
||||
() => ({
|
||||
useTemplateUrlLoader: () => ({
|
||||
loadTemplateFromUrl: vi.fn()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
query: {}
|
||||
}),
|
||||
useRouter: () => ({
|
||||
replace: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
onUserLogout: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/navigation/preservedQueryManager', () => ({
|
||||
hydratePreservedQuery: vi.fn(),
|
||||
mergePreservedQueryIntoQuery: vi.fn(() => null)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
|
||||
PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template' }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('../migration/migrateV1toV2', () => ({
|
||||
migrateV1toV2: vi.fn()
|
||||
}))
|
||||
|
||||
type GraphChangedHandler = (() => void) | null
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
graphChangedHandler: null as GraphChangedHandler,
|
||||
currentGraph: {} as Record<string, unknown>
|
||||
}
|
||||
const serializeMock = vi.fn(() => state.currentGraph)
|
||||
const loadGraphDataMock = vi.fn()
|
||||
const apiMock = {
|
||||
clientId: 'test-client',
|
||||
initialClientId: 'test-client',
|
||||
addEventListener: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === 'graphChanged') {
|
||||
state.graphChangedHandler = handler
|
||||
}
|
||||
}),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
return { state, serializeMock, loadGraphDataMock, apiMock }
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {
|
||||
serialize: () => mocks.serializeMock()
|
||||
},
|
||||
rootGraph: {
|
||||
serialize: () => mocks.serializeMock()
|
||||
},
|
||||
loadGraphData: (...args: unknown[]) => mocks.loadGraphDataMock(...args),
|
||||
canvas: {}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: mocks.apiMock
|
||||
}))
|
||||
|
||||
describe('useWorkflowPersistenceV2', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'))
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
settingMocks.persistRef!.value = true
|
||||
mocks.state.graphChangedHandler = null
|
||||
mocks.state.currentGraph = { initial: true }
|
||||
mocks.serializeMock.mockImplementation(() => mocks.state.currentGraph)
|
||||
mocks.loadGraphDataMock.mockReset()
|
||||
mocks.apiMock.clientId = 'test-client'
|
||||
mocks.apiMock.initialClientId = 'test-client'
|
||||
mocks.apiMock.addEventListener.mockImplementation(
|
||||
(event: string, handler: () => void) => {
|
||||
if (event === 'graphChanged') {
|
||||
mocks.state.graphChangedHandler = handler
|
||||
}
|
||||
}
|
||||
)
|
||||
mocks.apiMock.removeEventListener.mockImplementation(() => {})
|
||||
openWorkflowMock.mockReset()
|
||||
loadBlankWorkflowMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function writeTabState(paths: string[], activeIndex: number) {
|
||||
const pointer = {
|
||||
workspaceId: 'personal',
|
||||
paths,
|
||||
activeIndex
|
||||
}
|
||||
sessionStorage.setItem(
|
||||
`Comfy.Workflow.OpenPaths:test-client`,
|
||||
JSON.stringify(pointer)
|
||||
)
|
||||
}
|
||||
|
||||
function writeActivePath(path: string) {
|
||||
const pointer = {
|
||||
workspaceId: 'personal',
|
||||
path
|
||||
}
|
||||
sessionStorage.setItem(
|
||||
`Comfy.Workflow.ActivePath:test-client`,
|
||||
JSON.stringify(pointer)
|
||||
)
|
||||
}
|
||||
|
||||
describe('loadPreviousWorkflowFromStorage', () => {
|
||||
it('loads saved workflow when draft is missing for session path', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const savedWorkflow = workflowStore.createTemporary('SavedWorkflow.json')
|
||||
|
||||
// Set session path to the saved workflow but do NOT create a draft
|
||||
writeActivePath(savedWorkflow.path)
|
||||
|
||||
const { initializeWorkflow } = useWorkflowPersistenceV2()
|
||||
await initializeWorkflow()
|
||||
|
||||
// Should call workflowService.openWorkflow with the saved workflow
|
||||
expect(openWorkflowMock).toHaveBeenCalledWith(savedWorkflow)
|
||||
// Should NOT fall through to loadGraphData (fallbackToLatestDraft)
|
||||
expect(mocks.loadGraphDataMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefers draft over saved workflow when draft exists', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
|
||||
const workflow = workflowStore.createTemporary('DraftWorkflow.json')
|
||||
const draftData = JSON.stringify({ nodes: [], title: 'draft' })
|
||||
draftStore.saveDraft(workflow.path, draftData, {
|
||||
name: 'DraftWorkflow.json',
|
||||
isTemporary: true
|
||||
})
|
||||
writeActivePath(workflow.path)
|
||||
|
||||
mocks.loadGraphDataMock.mockResolvedValue(undefined)
|
||||
|
||||
const { initializeWorkflow } = useWorkflowPersistenceV2()
|
||||
await initializeWorkflow()
|
||||
|
||||
// Should load draft via loadGraphData, not via workflowService.openWorkflow
|
||||
expect(mocks.loadGraphDataMock).toHaveBeenCalled()
|
||||
expect(openWorkflowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to latest draft only when no session path exists', async () => {
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
|
||||
// No session path set, but a draft exists
|
||||
const draftData = JSON.stringify({ nodes: [], title: 'latest' })
|
||||
draftStore.saveDraft('workflows/Other.json', draftData, {
|
||||
name: 'Other.json',
|
||||
isTemporary: true
|
||||
})
|
||||
|
||||
mocks.loadGraphDataMock.mockResolvedValue(undefined)
|
||||
|
||||
const { initializeWorkflow } = useWorkflowPersistenceV2()
|
||||
await initializeWorkflow()
|
||||
|
||||
// Should load via fallbackToLatestDraft
|
||||
expect(mocks.loadGraphDataMock).toHaveBeenCalled()
|
||||
expect(openWorkflowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreWorkflowTabsState', () => {
|
||||
it('activates the correct workflow at storedActiveIndex', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
|
||||
// Create two temporary workflows with drafts
|
||||
const workflowA = workflowStore.createTemporary('WorkflowA.json')
|
||||
const workflowB = workflowStore.createTemporary('WorkflowB.json')
|
||||
|
||||
draftStore.saveDraft(workflowA.path, JSON.stringify({ title: 'A' }), {
|
||||
name: 'WorkflowA.json',
|
||||
isTemporary: true
|
||||
})
|
||||
draftStore.saveDraft(workflowB.path, JSON.stringify({ title: 'B' }), {
|
||||
name: 'WorkflowB.json',
|
||||
isTemporary: true
|
||||
})
|
||||
|
||||
// storedActiveIndex = 1 → WorkflowB should be activated
|
||||
writeTabState([workflowA.path, workflowB.path], 1)
|
||||
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
|
||||
await restoreWorkflowTabsState()
|
||||
|
||||
expect(openWorkflowMock).toHaveBeenCalledWith(workflowB)
|
||||
})
|
||||
|
||||
it('activates first tab when storedActiveIndex is 0', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
|
||||
const workflowA = workflowStore.createTemporary('WorkflowA.json')
|
||||
const workflowB = workflowStore.createTemporary('WorkflowB.json')
|
||||
|
||||
draftStore.saveDraft(workflowA.path, JSON.stringify({ title: 'A' }), {
|
||||
name: 'WorkflowA.json',
|
||||
isTemporary: true
|
||||
})
|
||||
draftStore.saveDraft(workflowB.path, JSON.stringify({ title: 'B' }), {
|
||||
name: 'WorkflowB.json',
|
||||
isTemporary: true
|
||||
})
|
||||
|
||||
writeTabState([workflowA.path, workflowB.path], 0)
|
||||
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
|
||||
await restoreWorkflowTabsState()
|
||||
|
||||
expect(openWorkflowMock).toHaveBeenCalledWith(workflowA)
|
||||
})
|
||||
|
||||
it('does not call openWorkflow when no restorable state', async () => {
|
||||
// No tab state written to sessionStorage
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
|
||||
await restoreWorkflowTabsState()
|
||||
|
||||
expect(openWorkflowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores temporary workflows and adds them to tabs', async () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const draftStore = useWorkflowDraftStoreV2()
|
||||
|
||||
// Save a draft for a workflow that doesn't exist in the store yet
|
||||
const path = 'workflows/Unsaved.json'
|
||||
draftStore.saveDraft(path, JSON.stringify({ title: 'Unsaved' }), {
|
||||
name: 'Unsaved.json',
|
||||
isTemporary: true
|
||||
})
|
||||
|
||||
writeTabState([path], 0)
|
||||
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
|
||||
await restoreWorkflowTabsState()
|
||||
|
||||
const restored = workflowStore.getWorkflowByPath(path)
|
||||
expect(restored).toBeTruthy()
|
||||
expect(restored?.isTemporary).toBe(true)
|
||||
expect(workflowStore.openWorkflows.map((w) => w?.path)).toContain(path)
|
||||
})
|
||||
|
||||
it('skips activation when persistence is disabled', async () => {
|
||||
settingMocks.persistRef!.value = false
|
||||
|
||||
const { restoreWorkflowTabsState } = useWorkflowPersistenceV2()
|
||||
await restoreWorkflowTabsState()
|
||||
|
||||
expect(openWorkflowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -112,8 +112,7 @@ export function useWorkflowPersistenceV2() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('toastMessages.failedToSaveDraft'),
|
||||
life: 3000
|
||||
detail: t('toastMessages.failedToSaveDraft')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -133,19 +132,28 @@ export function useWorkflowPersistenceV2() {
|
||||
const debouncedPersist = debounce(persistCurrentWorkflow, PERSIST_DEBOUNCE_MS)
|
||||
|
||||
const loadPreviousWorkflowFromStorage = async () => {
|
||||
// 1. Try session pointer (for tab restoration)
|
||||
const sessionPath = tabState.getActivePath()
|
||||
|
||||
// 1. Try draft for session path
|
||||
if (
|
||||
sessionPath &&
|
||||
(await draftStore.loadPersistedWorkflow({
|
||||
workflowName: null,
|
||||
preferredPath: sessionPath
|
||||
}))
|
||||
) {
|
||||
)
|
||||
return true
|
||||
|
||||
// 2. Try saved workflow by path (draft may not exist for saved+unmodified workflows)
|
||||
if (sessionPath) {
|
||||
const saved = workflowStore.getWorkflowByPath(sessionPath)
|
||||
if (saved) {
|
||||
await useWorkflowService().openWorkflow(saved)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fall back to most recent draft
|
||||
// 3. Fall back to most recent draft
|
||||
return await draftStore.loadPersistedWorkflow({
|
||||
workflowName: null,
|
||||
fallbackToLatestDraft: true
|
||||
@@ -243,7 +251,7 @@ export function useWorkflowPersistenceV2() {
|
||||
}
|
||||
})
|
||||
|
||||
const restoreWorkflowTabsState = () => {
|
||||
const restoreWorkflowTabsState = async () => {
|
||||
if (!workflowPersistenceEnabled.value) {
|
||||
tabStateRestored = true
|
||||
return
|
||||
@@ -255,10 +263,11 @@ export function useWorkflowPersistenceV2() {
|
||||
const storedWorkflows = storedTabState?.paths ?? []
|
||||
const storedActiveIndex = storedTabState?.activeIndex ?? -1
|
||||
|
||||
tabStateRestored = true
|
||||
|
||||
const isRestorable = storedWorkflows.length > 0 && storedActiveIndex >= 0
|
||||
if (!isRestorable) return
|
||||
if (!isRestorable) {
|
||||
tabStateRestored = true
|
||||
return
|
||||
}
|
||||
|
||||
storedWorkflows.forEach((path: string) => {
|
||||
if (workflowStore.getWorkflowByPath(path)) return
|
||||
@@ -281,6 +290,17 @@ export function useWorkflowPersistenceV2() {
|
||||
left: storedWorkflows.slice(0, storedActiveIndex),
|
||||
right: storedWorkflows.slice(storedActiveIndex)
|
||||
})
|
||||
|
||||
tabStateRestored = true
|
||||
|
||||
// Activate the correct workflow at storedActiveIndex
|
||||
const activePath = storedWorkflows[storedActiveIndex]
|
||||
const workflow = activePath
|
||||
? workflowStore.getWorkflowByPath(activePath)
|
||||
: null
|
||||
if (workflow) {
|
||||
await useWorkflowService().openWorkflow(workflow)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -484,8 +484,7 @@ describe('ShareWorkflowDialogContent', () => {
|
||||
expect(mockToast.add).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Publish failed',
|
||||
life: 5000
|
||||
detail: 'Publish failed'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -352,8 +352,7 @@ const { isLoading: isSaving, execute: handleSave } = useAsyncState(
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('shareWorkflow.saveFailedTitle'),
|
||||
detail: t('shareWorkflow.saveFailedDescription'),
|
||||
life: 5000
|
||||
detail: t('shareWorkflow.saveFailedDescription')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -391,8 +390,7 @@ const {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: error instanceof Error ? error.message : t('g.error'),
|
||||
life: 5000
|
||||
detail: error instanceof Error ? error.message : t('g.error')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,8 +183,7 @@ async function handleCreate() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: error instanceof Error ? error.message : t('g.error'),
|
||||
life: 5000
|
||||
detail: error instanceof Error ? error.message : t('g.error')
|
||||
})
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
|
||||
@@ -338,8 +338,7 @@ describe('useSharedWorkflowUrlLoader', () => {
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to load shared workflow',
|
||||
life: 3000
|
||||
detail: 'Failed to load shared workflow'
|
||||
})
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
|
||||
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
|
||||
|
||||
@@ -118,8 +118,7 @@ export function useSharedWorkflowUrlLoader() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('shareWorkflow.loadFailed'),
|
||||
life: 3000
|
||||
detail: t('shareWorkflow.loadFailed')
|
||||
})
|
||||
cleanupUrlParams()
|
||||
clearPreservedQuery(SHARE_NAMESPACE)
|
||||
@@ -148,8 +147,7 @@ export function useSharedWorkflowUrlLoader() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('shareWorkflow.loadFailed'),
|
||||
life: 5000
|
||||
detail: t('shareWorkflow.loadFailed')
|
||||
})
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
@@ -145,8 +145,7 @@ describe('useTemplateUrlLoader', () => {
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Template "invalid-template" not found',
|
||||
life: 3000
|
||||
detail: 'Template "invalid-template" not found'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -239,8 +238,7 @@ describe('useTemplateUrlLoader', () => {
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to load template',
|
||||
life: 3000
|
||||
detail: 'Failed to load template'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -117,8 +117,7 @@ export function useTemplateUrlLoader() {
|
||||
summary: t('g.error'),
|
||||
detail: t('templateWorkflows.error.templateNotFound', {
|
||||
templateName: templateParam
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
})
|
||||
} else if (modeParam === 'linear') {
|
||||
// Set linear mode after successful template load
|
||||
@@ -132,8 +131,7 @@ export function useTemplateUrlLoader() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('g.errorLoadingTemplate'),
|
||||
life: 3000
|
||||
detail: t('g.errorLoadingTemplate')
|
||||
})
|
||||
} finally {
|
||||
cleanupUrlParams()
|
||||
|
||||
@@ -428,8 +428,7 @@ async function handleResubscribe() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: message,
|
||||
life: 5000
|
||||
detail: message
|
||||
})
|
||||
} finally {
|
||||
isResubscribing.value = false
|
||||
|
||||
@@ -148,8 +148,7 @@ async function handleSubscribeClick(payload: {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Unable to subscribe',
|
||||
detail: 'This plan is not available',
|
||||
life: 5000
|
||||
detail: 'This plan is not available'
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -159,8 +158,7 @@ async function handleSubscribeClick(payload: {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Unable to subscribe',
|
||||
detail: response?.reason || 'This plan is not available',
|
||||
life: 5000
|
||||
detail: response?.reason || 'This plan is not available'
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -175,8 +173,7 @@ async function handleSubscribeClick(payload: {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: message,
|
||||
life: 5000
|
||||
detail: message
|
||||
})
|
||||
} finally {
|
||||
isLoadingPreview.value = false
|
||||
@@ -236,8 +233,7 @@ async function handleAddCreditCard() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: message,
|
||||
life: 5000
|
||||
detail: message
|
||||
})
|
||||
} finally {
|
||||
isSubscribing.value = false
|
||||
@@ -291,8 +287,7 @@ async function handleConfirmTransition() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: message,
|
||||
life: 5000
|
||||
detail: message
|
||||
})
|
||||
} finally {
|
||||
isSubscribing.value = false
|
||||
@@ -316,8 +311,7 @@ async function handleResubscribe() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: message,
|
||||
life: 5000
|
||||
detail: message
|
||||
})
|
||||
} finally {
|
||||
isResubscribing.value = false
|
||||
|
||||
@@ -273,8 +273,7 @@ async function handleBuy() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.unknownError'),
|
||||
life: 5000
|
||||
detail: t('credits.topUp.unknownError')
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -285,8 +284,7 @@ async function handleBuy() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
|
||||
life: 5000
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage })
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -139,7 +139,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { switchWithConfirmation } = useWorkspaceSwitch()
|
||||
const { switchWorkspace } = useWorkspaceSwitch()
|
||||
const { subscription } = useBillingContext()
|
||||
|
||||
const tierKeyMap: Record<string, string> = {
|
||||
@@ -226,7 +226,7 @@ function getTierLabel(workspace: AvailableWorkspace): string | null {
|
||||
}
|
||||
|
||||
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
|
||||
const success = await switchWithConfirmation(workspace.id)
|
||||
const success = await switchWorkspace(workspace.id)
|
||||
if (success) {
|
||||
emit('select', workspace)
|
||||
}
|
||||
|
||||
@@ -102,8 +102,7 @@ async function onCreate() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -79,8 +79,7 @@ async function onDelete() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToDeleteWorkspace'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user