Compare commits

..

8 Commits

Author SHA1 Message Date
jaeone94
4dc67b375b fix: node replacement fails after execution and modal sync
- Detect missing nodes by unregistered type instead of has_errors flag,
  which gets cleared by clearAllNodeErrorFlags during execution
- Sync modal replace action with executionErrorStore so Errors Tab
  updates immediately when nodes are replaced from the dialog
2026-02-27 10:37:47 +09:00
jaeone94
1d8a01cdf8 fix: ensure node replacement data loads before workflow processing
Await nodeReplacementStore.load() before collectMissingNodesAndModels
to prevent race condition where replacement mappings are not yet
available when determining isReplaceable flag.
2026-02-27 00:14:43 +09:00
jaeone94
b585dfa4fc fix: address review feedback for handleReplaceAll
- Remove redundant parameter that shadowed composable ref
- Only remove actually replaced types from error list on partial success
2026-02-26 23:05:12 +09:00
jaeone94
1be6d27024 refactor: Destructure defineProps in SwapNodesCard.vue 2026-02-26 22:03:42 +09:00
jaeone94
5aa4baf116 fix: address review feedback for node replacement
- Use i18n key for 'Swap Nodes' group title
- Preserve partial replacement results on error instead of returning empty array
2026-02-26 21:53:00 +09:00
jaeone94
7d69a0db5b fix: remove unused export from scanMissingNodes 2026-02-26 20:28:10 +09:00
jaeone94
83bb4300e3 fix: address code review feedback on node replacement
- Add error toast in replaceNodesInPlace for user-visible failure
  feedback, returning empty array on error instead of throwing
- Guard removeMissingNodesByType behind replacement success check
  (replaced.length > 0) to prevent stale error list updates
- Sort buildMissingNodeGroups by priority for deterministic UI order
  (Swap Nodes 0 → Missing Node Packs 1 → Execution Errors)
- Add aux_id fallback and cnr_id precedence tests for getCnrIdFromNode
- Split replaceAllWarning from replaceWarning to fix i18n key mismatch
  between TabErrors tooltip and MissingNodesContent dialog
2026-02-26 20:24:56 +09:00
jaeone94
0d58a92e34 feat: add node replacement UI to Errors Tab
Integrate the existing node replacement functionality into the Errors
Tab, allowing users to replace missing nodes directly from the side
panel without opening the modal dialog.
New components:
- SwapNodesCard: container with guidance label and grouped rows
- SwapNodeGroupRow: per-type replacement row with expand/collapse,
  node instance list, locate button, and replace action
Bug fixes discovered during implementation:
- Fix stale canvas rendering after replacement by calling onNodeAdded
  to refresh VueNodeData (bypassed by replaceWithMapping)
- Guard initializeVueNodeLayout against duplicate layout creation
- Fix missing node list being overwritten by incomplete server 400
  response — replaced with full graph rescan via useMissingNodeScan
- Add removeMissingNodesByType to prune replaced types from error list
Cleanup:
- Remove dead code: buildMissingNodeHint, createMissingNodeTypeFromError
2026-02-26 20:24:44 +09:00
279 changed files with 2340 additions and 16729 deletions

View File

@@ -5,10 +5,3 @@ reviews:
high_level_summary: false
auto_review:
drafts: true
ignore_title_keywords:
- '[release]'
- '[backport'
ignore_usernames:
- comfy-pr-bot
- github-actions
- github-actions[bot]

View File

@@ -1,45 +0,0 @@
---
# Dispatches a frontend-asset-build event to the cloud repo on push to
# cloud/* branches and main. The cloud repo handles the actual build,
# GCS upload, and secret management (Sentry, Algolia, GCS creds).
#
# This is fire-and-forget — it does NOT wait for the cloud workflow to
# complete. Status is visible in the cloud repo's Actions tab.
name: Cloud Frontend Build Dispatch
on:
push:
branches:
- 'cloud/*'
- 'main'
workflow_dispatch:
permissions: {}
concurrency:
group: cloud-dispatch-${{ github.ref }}
cancel-in-progress: true
jobs:
dispatch:
# Fork guard: prevent forks from dispatching to the cloud repo
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
steps:
- name: Build client payload
id: payload
run: |
payload="$(jq -nc \
--arg ref "${GITHUB_SHA}" \
--arg branch "${GITHUB_REF_NAME}" \
'{ref: $ref, branch: $branch}')"
echo "json=${payload}" >> "${GITHUB_OUTPUT}"
- name: Dispatch to cloud repo
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.CLOUD_DISPATCH_TOKEN }}
repository: Comfy-Org/cloud
event-type: frontend-asset-build
client-payload: ${{ steps.payload.outputs.json }}

View File

@@ -1,760 +0,0 @@
{
"id": "9a37f747-e96b-4304-9212-7abcaad7bdac",
"revision": 0,
"last_node_id": 11,
"last_link_id": 18,
"nodes": [
{
"id": 2,
"type": "PreviewAny",
"pos": [1031, 434],
"size": [250, 178],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 5
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": [null, null, null]
},
{
"id": 5,
"type": "1e38d8ea-45e1-48a5-aa20-966584201867",
"pos": [788, 433.5],
"size": [225, 380],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [5]
}
],
"properties": {
"proxyWidgets": [
["3", "string_a"],
["4", "value"],
["6", "value"],
["6", "value_1"]
]
},
"widgets_values": []
},
{
"id": 1,
"type": "PrimitiveStringMultiline",
"pos": [548, 451],
"size": [225, 142],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [4]
}
],
"title": "Outer",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Outer\n"]
}
],
"links": [
[4, 1, 0, 5, 0, "STRING"],
[5, 5, 0, 2, 0, "STRING"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "1e38d8ea-45e1-48a5-aa20-966584201867",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 0",
"inputNode": {
"id": -10,
"bounding": [351, 432.5, 120, 120]
},
"outputNode": {
"id": -20,
"bounding": [1352, 294.5, 120, 60]
},
"inputs": [
{
"id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4",
"name": "string_a",
"type": "STRING",
"linkIds": [1],
"localized_name": "string_a",
"pos": [451, 452.5]
},
{
"id": "5fb3dcf7-9bfd-4b3c-a1b9-750b4f3edf19",
"name": "value",
"type": "STRING",
"linkIds": [13],
"pos": [451, 472.5]
},
{
"id": "55d24b8a-7c82-4b02-8e3d-ff31ffb8aa13",
"name": "value_1",
"type": "STRING",
"linkIds": [16],
"pos": [451, 492.5]
},
{
"id": "c1fe7cc3-547e-4fb0-b763-61888558d4bd",
"name": "value_1_1",
"type": "STRING",
"linkIds": [18],
"pos": [451, 512.5]
}
],
"outputs": [
{
"id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d",
"name": "STRING",
"type": "STRING",
"linkIds": [9],
"localized_name": "STRING",
"pos": [1372, 314.5]
}
],
"widgets": [],
"nodes": [
{
"id": 4,
"type": "PrimitiveStringMultiline",
"pos": [504, 437],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 13
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [2]
}
],
"title": "Inner 1",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 1\n"]
},
{
"id": 3,
"type": "StringConcatenate",
"pos": [743, 325],
"size": [347, 231],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 1
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 6,
"type": "9be42452-056b-4c99-9f9f-7381d11c4454",
"pos": [1115, 301],
"size": [210, 196],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 7
},
{
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 16
},
{
"name": "value_1",
"type": "STRING",
"widget": {
"name": "value_1"
},
"link": 18
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [9]
}
],
"properties": {
"proxyWidgets": [
["5", "string_a"],
["11", "value"],
["9", "value"],
["10", "string_a"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 1,
"type": "STRING"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "STRING"
},
{
"id": 6,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": 6,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 1,
"target_id": 4,
"target_slot": 0,
"type": "STRING"
},
{
"id": 16,
"origin_id": -10,
"origin_slot": 2,
"target_id": 6,
"target_slot": 1,
"type": "STRING"
},
{
"id": 18,
"origin_id": -10,
"origin_slot": 3,
"target_id": 6,
"target_slot": 2,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "9be42452-056b-4c99-9f9f-7381d11c4454",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 1",
"inputNode": {
"id": -10,
"bounding": [180, 739, 120, 100]
},
"outputNode": {
"id": -20,
"bounding": [1246, 612, 120, 60]
},
"inputs": [
{
"id": "01c05c51-86b5-4bad-b32f-9c911683a13d",
"name": "string_a",
"type": "STRING",
"linkIds": [4],
"localized_name": "string_a",
"pos": [280, 759]
},
{
"id": "d50f6a62-0185-43d4-a174-a8a94bd8f6e7",
"name": "value",
"type": "STRING",
"linkIds": [14],
"pos": [280, 779]
},
{
"id": "6b78450e-5986-49cd-b743-c933e5a34a69",
"name": "value_1",
"type": "STRING",
"linkIds": [17],
"pos": [280, 799]
}
],
"outputs": [
{
"id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686",
"name": "STRING",
"type": "STRING",
"linkIds": [12],
"localized_name": "STRING",
"pos": [1266, 632]
}
],
"widgets": [],
"nodes": [
{
"id": 11,
"type": "PrimitiveStringMultiline",
"pos": [334, 742],
"size": [210, 88],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 14
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [7]
}
],
"title": "Inner 2",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 2\n"]
},
{
"id": 10,
"type": "StringConcatenate",
"pos": [581, 637],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 4
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 7
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [11]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
},
{
"id": 9,
"type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"pos": [1004, 613],
"size": [210, 142],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 11
},
{
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 17
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [12]
}
],
"properties": {
"proxyWidgets": [
["7", "string_a"],
["8", "value"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 4,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 0,
"type": "STRING"
},
{
"id": 7,
"origin_id": 11,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": 10,
"origin_slot": 0,
"target_id": 9,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 12,
"origin_id": 9,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 1,
"target_id": 11,
"target_slot": 0,
"type": "STRING"
},
{
"id": 17,
"origin_id": -10,
"origin_slot": 2,
"target_id": 9,
"target_slot": 1,
"type": "STRING"
}
],
"extra": {}
},
{
"id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 18,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Sub 2",
"inputNode": {
"id": -10,
"bounding": [262, 1222, 120, 80]
},
"outputNode": {
"id": -20,
"bounding": [1123.089999999999, 1125.1999999999998, 120, 60]
},
"inputs": [
{
"id": "934a8baa-d79c-428c-8ec9-814ad437d7c7",
"name": "string_a",
"type": "STRING",
"linkIds": [9],
"localized_name": "string_a",
"pos": [362, 1242]
},
{
"id": "3a545207-7202-42a9-a82f-3b62e1b0f459",
"name": "value",
"type": "STRING",
"linkIds": [15],
"pos": [362, 1262]
}
],
"outputs": [
{
"id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879",
"name": "STRING",
"type": "STRING",
"linkIds": [10],
"localized_name": "STRING",
"pos": [1143.089999999999, 1145.1999999999998]
}
],
"widgets": [],
"nodes": [
{
"id": 8,
"type": "PrimitiveStringMultiline",
"pos": [412.96000000000004, 1228.2399999999996],
"size": [210, 88],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [8]
}
],
"title": "Inner 3",
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["Inner 3\n"]
},
{
"id": 7,
"type": "StringConcatenate",
"pos": [686.08, 1132.38],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "string_a",
"name": "string_a",
"type": "STRING",
"widget": {
"name": "string_a"
},
"link": 9
},
{
"localized_name": "string_b",
"name": "string_b",
"type": "STRING",
"widget": {
"name": "string_b"
},
"link": 8
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [10]
}
],
"properties": {
"Node name for S&R": "StringConcatenate"
},
"widgets_values": ["", "", ""]
}
],
"groups": [],
"links": [
{
"id": 8,
"origin_id": 8,
"origin_slot": 0,
"target_id": 7,
"target_slot": 1,
"type": "STRING"
},
{
"id": 9,
"origin_id": -10,
"origin_slot": 0,
"target_id": 7,
"target_slot": 0,
"type": "STRING"
},
{
"id": 10,
"origin_id": 7,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 1,
"target_id": 8,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [-412, 11]
},
"frontendVersion": "1.41.7"
},
"version": 0.4
}

View File

@@ -4,17 +4,6 @@ import type { Page } from '@playwright/test'
import type { Position } from '../types'
function getFileType(fileName: string): string {
if (fileName.endsWith('.png')) return 'image/png'
if (fileName.endsWith('.svg')) return 'image/svg+xml'
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
if (fileName.endsWith('.avif')) return 'image/avif'
return 'application/octet-stream'
}
export class DragDropHelper {
constructor(
private readonly page: Page,
@@ -59,6 +48,17 @@ export class DragDropHelper {
const filePath = this.assetPath(fileName)
const buffer = readFileSync(filePath)
const getFileType = (fileName: string) => {
if (fileName.endsWith('.png')) return 'image/png'
if (fileName.endsWith('.svg')) return 'image/svg+xml'
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
if (fileName.endsWith('.avif')) return 'image/avif'
return 'application/octet-stream'
}
evaluateParams.fileName = fileName
evaluateParams.fileType = getFileType(fileName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
@@ -155,104 +155,6 @@ export class DragDropHelper {
await this.nextFrame()
}
async dragAndDropFiles(
fileNames: string[],
options: {
dropPosition?: Position
waitForUploadCount?: number
} = {}
): Promise<void> {
const { dropPosition = { x: 100, y: 100 }, waitForUploadCount = 0 } =
options
const files = fileNames.map((fileName) => {
const filePath = this.assetPath(fileName)
const buffer = readFileSync(filePath)
return {
fileName,
fileType: getFileType(fileName),
buffer: [...new Uint8Array(buffer)]
}
})
let uploadResponsePromise: Promise<unknown> | null = null
if (waitForUploadCount > 0) {
let uploadCount = 0
uploadResponsePromise = new Promise<void>((resolve) => {
const handler = (resp: { url(): string; status(): number }) => {
if (resp.url().includes('/upload/') && resp.status() === 200) {
uploadCount++
if (uploadCount >= waitForUploadCount) {
this.page.off('response', handler)
resolve()
}
}
}
this.page.on('response', handler)
})
}
await this.page.evaluate(
async (params) => {
const dataTransfer = new DataTransfer()
for (const f of params.files) {
const file = new File([new Uint8Array(f.buffer)], f.fileName, {
type: f.fileType
})
dataTransfer.items.add(file)
}
const targetElement = document.elementFromPoint(
params.dropPosition.x,
params.dropPosition.y
)
if (!targetElement) {
throw new Error(
`No element found at drop position: (${params.dropPosition.x}, ${params.dropPosition.y}).`
)
}
const eventOptions = {
bubbles: true,
cancelable: true,
dataTransfer,
clientX: params.dropPosition.x,
clientY: params.dropPosition.y
}
const graphCanvasElement = document.querySelector('#graph-canvas')
if (graphCanvasElement && !graphCanvasElement.contains(targetElement)) {
graphCanvasElement.dispatchEvent(
new DragEvent('dragover', eventOptions)
)
}
const dropEvent = new DragEvent('drop', eventOptions)
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
targetElement.dispatchEvent(new DragEvent('dragover', eventOptions))
targetElement.dispatchEvent(dropEvent)
},
{ files, dropPosition }
)
if (uploadResponsePromise) {
await uploadResponsePromise
}
await this.nextFrame()
}
async dragAndDropFile(
fileName: string,
options: { dropPosition?: Position; waitForUpload?: boolean } = {}

View File

@@ -1,90 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { WorkspaceStore } from '../types/globals'
test.describe('Batch Image Import', () => {
test('Dropping multiple images creates LoadImage nodes and a BatchImagesNode', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.dragDrop.dragAndDropFiles(
['image32x32.webp', 'image64x64.webp'],
{ waitForUploadCount: 2 }
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
.toBe(initialCount + 3)
const batchNodes =
await comfyPage.nodeOps.getNodeRefsByType('BatchImagesNode')
expect(batchNodes).toHaveLength(1)
})
test('Dropping a single image does not create a BatchImagesNode', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.dragDrop.dragAndDropFile('image32x32.webp', {
waitForUpload: true
})
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
.toBe(initialCount + 1)
const batchNodes =
await comfyPage.nodeOps.getNodeRefsByType('BatchImagesNode')
expect(batchNodes).toHaveLength(0)
})
test('Batch image import produces a single undo entry', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
const initialUndoSize = await comfyPage.workflow.getUndoQueueSize()
await comfyPage.dragDrop.dragAndDropFiles(
['image32x32.webp', 'image64x64.webp'],
{ waitForUploadCount: 2 }
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
.toBe(initialCount + 3)
await expect
.poll(() => comfyPage.workflow.getUndoQueueSize(), { timeout: 5000 })
.toBe((initialUndoSize ?? 0) + 1)
})
test('Batch image import can be undone as a single action', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await comfyPage.dragDrop.dragAndDropFiles(
['image32x32.webp', 'image64x64.webp'],
{ waitForUploadCount: 2 }
)
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
.toBe(initialCount + 3)
// Call undo directly on the change tracker to avoid keyboard focus issues
await comfyPage.page.evaluate(async () => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
await workflow?.changeTracker.undo()
})
await comfyPage.nextFrame()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 10000 })
.toBe(initialCount)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -171,7 +171,6 @@ test.describe('Node Interaction', () => {
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.dragTextEncodeNode2()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -555,74 +555,6 @@ test.describe(
})
})
test.describe('Nested Promoted Widget Disabled State', () => {
test('Externally linked promoted widget is disabled, unlinked ones are not', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
// Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its
// slot connected externally from the Outer node, so it should be
// disabled. The remaining promoted textarea widgets (value, value_1)
// are unlinked and should be enabled.
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames).toContain('string_a')
expect(promotedNames).toContain('value')
const disabledState = await comfyPage.page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('5')
return (node?.widgets ?? []).map((w) => ({
name: w.name,
disabled: !!w.computedDisabled
}))
})
const linkedWidget = disabledState.find((w) => w.name === 'string_a')
expect(linkedWidget?.disabled).toBe(true)
const unlinkedWidgets = disabledState.filter(
(w) => w.name !== 'string_a'
)
for (const w of unlinkedWidgets) {
expect(w.disabled).toBe(false)
}
})
test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
// The promoted textareas that are NOT externally linked should be
// fully opaque and interactive.
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
const count = await textareas.count()
for (let i = 0; i < count; i++) {
const textarea = textareas.nth(i)
const wrapper = textarea.locator('..')
const opacity = await wrapper.evaluate(
(el) => getComputedStyle(el).opacity
)
if (opacity === '1' && (await textarea.isEditable())) {
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
}
}
})
})
test.describe('Promotion Cleanup', () => {
test('Removing subgraph node clears promotion store entries', async ({
comfyPage

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -41,9 +41,7 @@ const config: KnipConfig = {
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Workflow files contain license names that knip misinterprets as binaries
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue'
'.github/workflows/ci-oss-assets-validation.yaml'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.10",
"version": "1.41.6",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -16,7 +16,7 @@
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,clip-text-encode,get-video-components,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
@custom-variant touch (@media (hover: none));
@@ -199,7 +199,7 @@
#3e1ffc 65.17%,
#009dff 103.86%
),
linear-gradient(var(--color-button-surface, #2d2e32));
var(--color-button-surface, #2d2e32);
/* Code styling colors for help menu*/
--code-text-color: rgb(0 122 255 / 1);
@@ -358,6 +358,26 @@
--button-active-surface: var(--color-charcoal-600);
--button-icon: var(--color-smoke-800);
--subscription-button-gradient:
linear-gradient(
315deg,
rgb(105 230 255 / 0.15) 0%,
rgb(99 73 233 / 0.5) 100%
),
radial-gradient(
70.71% 70.71% at 50% 50%,
rgb(62 99 222 / 0.15) 0.01%,
rgb(66 0 123 / 0.5) 100%
),
linear-gradient(
92deg,
#d000ff 0.38%,
#b009fe 37.07%,
#3e1ffc 65.17%,
#009dff 103.86%
),
var(--color-button-surface, #2d2e32);
--dialog-surface: var(--color-neutral-700);
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
@@ -470,6 +490,7 @@
--color-button-icon: var(--button-icon);
--color-button-surface: var(--button-surface);
--color-button-surface-contrast: var(--button-surface-contrast);
--color-subscription-button-gradient: var(--subscription-button-gradient);
--color-modal-card-background: var(--modal-card-background);
--color-modal-card-background-hovered: var(--modal-card-background-hovered);
@@ -613,14 +634,6 @@
}
}
@utility highlight {
background-color: color-mix(in srgb, currentColor 20%, transparent);
font-weight: 700;
border-radius: 0.25rem;
padding: 0 0.125rem;
margin: -0.125rem 0.125rem;
}
@utility scrollbar-hide {
scrollbar-width: none;
&::-webkit-scrollbar {

View File

@@ -1,3 +0,0 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 9C12.7761 9 13 9.22386 13 9.5V20C13 20.2761 13.2239 20.5 13.5 20.5H28C28.2761 20.5 28.5 20.7239 28.5 21C28.5 21.2761 28.2761 21.5 28 21.5H13.5C12.6716 21.5 12 20.8284 12 20V9.5C12 9.22386 12.2239 9 12.5 9ZM14.5 7C14.7761 7 15 7.22386 15 7.5V18C15 18.2761 15.2239 18.5 15.5 18.5H30C30.2761 18.5 30.5 18.7239 30.5 19C30.5 19.2761 30.2761 19.5 30 19.5H15.5C14.6716 19.5 14 18.8284 14 18V7.5C14 7.22386 14.2239 7 14.5 7ZM16.5 5C16.7761 5 17 5.22386 17 5.5V16C17 16.2761 17.2239 16.5 17.5 16.5H32C32.2761 16.5 32.5 16.7239 32.5 17C32.5 17.2761 32.2761 17.5 32 17.5H17.5C16.6716 17.5 16 16.8284 16 16V5.5C16 5.22386 16.2239 5 16.5 5ZM33.7061 2.5C34.4126 2.5 34.9999 3.08968 35 3.7998V14.2002C34.9999 14.9103 34.4126 15.5 33.7061 15.5H19.2939C18.5874 15.5 18.0001 14.9103 18 14.2002V3.7998C18.0001 3.08968 18.5874 2.5 19.2939 2.5H33.7061ZM19.1084 12.2676V14.2002C19.1085 14.3124 19.1814 14.3856 19.293 14.3857H33.7061C33.8179 14.3857 33.8915 14.3125 33.8916 14.2002V12.6094L30.7207 10.0615L28.1055 11.873C27.9107 12.005 27.6299 11.9923 27.4473 11.8438L23.8896 8.95312L19.1084 12.2676ZM19.2939 3.61426C19.1821 3.61426 19.1085 3.68744 19.1084 3.7998V10.9092L23.5957 7.79883C23.6707 7.74519 23.7587 7.71107 23.8496 7.7002C23.9954 7.68428 24.1465 7.72944 24.2598 7.82227L27.8164 10.7178L30.4385 8.90723C30.6334 8.7753 30.9141 8.78784 31.0967 8.93652L33.8916 11.1826V3.7998C33.8915 3.68747 33.8179 3.61426 33.7061 3.61426H19.2939ZM27.7939 5.09961C28.7054 5.09987 29.4561 5.8554 29.4561 6.77148C29.456 7.68754 28.7054 8.44213 27.7939 8.44238C26.8823 8.44238 26.1309 7.6877 26.1309 6.77148C26.1309 5.85524 26.8823 5.09961 27.7939 5.09961ZM27.7939 6.21387C27.4814 6.21387 27.2393 6.45737 27.2393 6.77148C27.2393 7.08557 27.4814 7.32812 27.7939 7.32812C28.1062 7.32788 28.3476 7.08542 28.3477 6.77148C28.3477 6.45752 28.1063 6.21411 27.7939 6.21387Z" fill="#8A8A8A"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 652 B

After

Width:  |  Height:  |  Size: 652 B

View File

@@ -1,5 +0,0 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.12 4.65979C26.0073 3.75304 27.4985 3.73975 28.3787 4.65979L31.3397 7.62073H31.3387C32.2523 8.49397 32.253 10.0028 31.3182 10.8785L31.3192 10.8795L23.62 18.5797L22.5096 19.6891V7.27112L22.7 7.08069L25.12 4.65979Z" stroke="#8A8A8A" stroke-width="1.3"/>
<path d="M32.3396 13.8499C33.6177 13.8499 34.65 14.8804 34.6501 16.1594V20.3401C34.6501 21.6199 33.618 22.6506 32.3396 22.6506H20.3503L21.4597 21.5403L29.1501 13.8499H32.3396Z" stroke="#8A8A8A" stroke-width="1.3"/>
<path d="M17.7604 17.2496C17.1991 17.2496 16.7498 17.6986 16.7497 18.2594C16.7497 18.8208 17.1995 19.2701 17.7604 19.2701C18.3065 19.2699 18.7702 18.8157 18.7702 18.2594C18.7701 17.6982 18.3211 17.2499 17.7604 17.2496ZM22.1706 18.2399C22.1706 20.6987 20.2192 22.6498 17.7604 22.65C15.2992 22.65 13.3493 20.677 13.3493 18.2399V3.65979C13.3494 2.38005 14.3815 1.34933 15.6598 1.34924H19.8405C21.1222 1.34934 22.1421 2.38132 22.1706 3.64514V18.2399Z" stroke="#8A8A8A" stroke-width="1.3"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.5693 3L9.67677 14.099C9.59169 14.292 9.33927 14.3393 9.19012 14.1901L5.5 10.5M14.5693 3L1.65457 8.23468C1.40927 8.33411 1.40355 8.67936 1.64543 8.78686L5.5 10.5M14.5693 3L5.5 10.5M5.5 10.5C5.66712 10.5669 5.37259 10.3728 5.5 10.5ZM5.5 10.5C5.62741 10.6272 5.43279 10.333 5.5 10.5ZM5.5 10.5V13.5L7 12" stroke="white" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 500 B

View File

@@ -1,3 +0,0 @@
<svg width="48" height="24" viewBox="0 0 48 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.8593 13C35.4827 13 36.0007 13.4988 36.0009 14.0996V22.9004C36.0007 23.5012 35.4827 24 34.8593 24H22.1425C21.5191 24 21.0011 23.5012 21.0009 22.9004V14.0996C21.0011 13.4988 21.5191 13 22.1425 13H34.8593ZM21.9794 21.2646V22.9004C21.9796 22.9953 22.0439 23.0566 22.1425 23.0566H34.8593C34.9579 23.0566 35.0222 22.9953 35.0224 22.9004V21.5547L32.2255 19.3984L29.9179 20.9307C29.746 21.0424 29.498 21.032 29.3369 20.9062L26.1982 18.4609L21.9794 21.2646ZM16.5009 10.5C16.777 10.5001 17.0009 10.7239 17.0009 11V17.5C17.001 18.3283 17.6727 18.9998 18.5009 19H18.7089L18.0615 18.3535C17.8665 18.1583 17.8665 17.8417 18.0615 17.6465C18.2567 17.4512 18.5742 17.4512 18.7695 17.6465L20.1835 19.0605C20.3785 19.2557 20.3784 19.5723 20.1835 19.7676L18.7695 21.1816C18.5742 21.3769 18.2567 21.3768 18.0615 21.1816C17.8666 20.9864 17.8664 20.6697 18.0615 20.4746L18.5361 20H18.5009C17.1204 19.9998 16.001 18.8806 16.0009 17.5V11C16.001 10.724 16.2249 10.5002 16.5009 10.5ZM22.1425 13.9424C22.0439 13.9424 21.9796 14.0047 21.9794 14.0996V20.1152L25.9384 17.4834C26.0045 17.4381 26.082 17.4096 26.162 17.4004C26.2907 17.3869 26.4244 17.4244 26.5244 17.5029L29.663 19.9531L31.9755 18.4219C32.1475 18.3102 32.3954 18.3204 32.5566 18.4463L35.0224 20.3467V14.0996C35.0222 14.0047 34.9579 13.9424 34.8593 13.9424H22.1425ZM29.6425 15.2002C30.4468 15.2003 31.1093 15.839 31.1093 16.6143C31.1093 17.3895 30.4469 18.0283 29.6425 18.0283C28.8381 18.0283 28.1747 17.3895 28.1747 16.6143C28.1748 15.839 28.8381 15.2002 29.6425 15.2002ZM29.6425 16.1426C29.3668 16.1426 29.1533 16.3485 29.1533 16.6143C29.1533 16.8801 29.3667 17.0859 29.6425 17.0859C29.9182 17.0859 30.1318 16.88 30.1318 16.6143C30.1318 16.3485 29.9182 16.1426 29.6425 16.1426ZM22.0917 0C23.6924 0.000102997 25.0009 1.29808 25.0009 2.91016V7.08984C25.0009 8.70192 23.6924 9.9999 22.0917 10H14.9111C13.3103 10 12.0009 8.70198 12.0009 7.08984V2.91016C12.0009 1.29802 13.3103 0 14.9111 0H22.0917ZM14.9111 1.04199C13.8598 1.04199 13.0331 1.87561 13.0331 2.91016V7.08984C13.0331 8.12439 13.8598 8.95801 14.9111 8.95801H22.0917C23.1429 8.95791 23.9697 8.12432 23.9697 7.08984V2.91016C23.9697 1.87568 23.1429 1.04209 22.0917 1.04199H14.9111ZM17.0146 2.36523C17.1026 2.36806 17.189 2.39596 17.2636 2.44531L20.5556 4.53613C20.7284 4.64278 20.7919 4.83988 20.7919 5C20.7919 5.16007 20.7283 5.35719 20.5556 5.46387L17.2646 7.55469C17.1075 7.65858 16.9024 7.66034 16.7441 7.56055L16.7431 7.55957C16.5867 7.45933 16.4941 7.27149 16.499 7.08398V2.91016C16.4953 2.64423 16.6989 2.38047 16.9755 2.36621L17.0146 2.36523ZM17.5068 6.1416L19.3095 5L17.5068 3.85449V6.1416ZM20.4999 5.22559L20.5234 5.19434C20.5303 5.1833 20.5364 5.17121 20.5419 5.15918C20.5308 5.1833 20.5167 5.20593 20.4999 5.22559Z" fill="#8A8A8A"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1,11 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
getFilenameDetails,
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
isPreviewableMediaType,
truncateFilename
@@ -202,147 +198,6 @@ describe('formatUtil', () => {
})
})
describe('getFilenameDetails', () => {
it('splits simple filenames into name and suffix', () => {
expect(getFilenameDetails('file.txt')).toEqual({
filename: 'file',
suffix: 'txt'
})
})
it('handles filenames with multiple dots', () => {
expect(getFilenameDetails('my.file.name.png')).toEqual({
filename: 'my.file.name',
suffix: 'png'
})
})
it('handles filenames without extension', () => {
expect(getFilenameDetails('README')).toEqual({
filename: 'README',
suffix: null
})
})
it('recognises .app.json as a compound extension', () => {
expect(getFilenameDetails('workflow.app.json')).toEqual({
filename: 'workflow',
suffix: 'app.json'
})
})
it('recognises .app.json case-insensitively', () => {
expect(getFilenameDetails('Workflow.APP.JSON')).toEqual({
filename: 'Workflow',
suffix: 'app.json'
})
})
it('handles regular .json files normally', () => {
expect(getFilenameDetails('workflow.json')).toEqual({
filename: 'workflow',
suffix: 'json'
})
})
it('treats bare .app.json as a dotfile without basename', () => {
expect(getFilenameDetails('.app.json')).toEqual({
filename: '.app',
suffix: 'json'
})
})
})
describe('getPathDetails', () => {
it('splits a path with .app.json extension', () => {
const result = getPathDetails('workflows/test.app.json')
expect(result).toEqual({
directory: 'workflows',
fullFilename: 'test.app.json',
filename: 'test',
suffix: 'app.json'
})
})
it('splits a path with .json extension', () => {
const result = getPathDetails('workflows/test.json')
expect(result).toEqual({
directory: 'workflows',
fullFilename: 'test.json',
filename: 'test',
suffix: 'json'
})
})
})
describe('appendWorkflowJsonExt', () => {
it('appends .app.json when isApp is true', () => {
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')
})
it('appends .json when isApp is false', () => {
expect(appendWorkflowJsonExt('test', false)).toBe('test.json')
})
it('replaces .json with .app.json when isApp is true', () => {
expect(appendWorkflowJsonExt('test.json', true)).toBe('test.app.json')
})
it('replaces .app.json with .json when isApp is false', () => {
expect(appendWorkflowJsonExt('test.app.json', false)).toBe('test.json')
})
it('leaves .app.json unchanged when isApp is true', () => {
expect(appendWorkflowJsonExt('test.app.json', true)).toBe('test.app.json')
})
it('leaves .json unchanged when isApp is false', () => {
expect(appendWorkflowJsonExt('test.json', false)).toBe('test.json')
})
it('handles case-insensitive extensions', () => {
expect(appendWorkflowJsonExt('test.JSON', true)).toBe('test.app.json')
expect(appendWorkflowJsonExt('test.APP.JSON', false)).toBe('test.json')
})
})
describe('ensureWorkflowSuffix', () => {
it('appends suffix when missing', () => {
expect(ensureWorkflowSuffix('file', 'json')).toBe('file.json')
})
it('does not double-append when suffix already present', () => {
expect(ensureWorkflowSuffix('file.json', 'json')).toBe('file.json')
})
it('appends compound suffix when missing', () => {
expect(ensureWorkflowSuffix('file', 'app.json')).toBe('file.app.json')
})
it('does not double-append compound suffix', () => {
expect(ensureWorkflowSuffix('file.app.json', 'app.json')).toBe(
'file.app.json'
)
})
it('replaces .json with .app.json when suffix is app.json', () => {
expect(ensureWorkflowSuffix('file.json', 'app.json')).toBe(
'file.app.json'
)
})
it('replaces .app.json with .json when suffix is json', () => {
expect(ensureWorkflowSuffix('file.app.json', 'json')).toBe('file.json')
})
it('handles case-insensitive extension detection', () => {
expect(ensureWorkflowSuffix('file.JSON', 'json')).toBe('file.json')
expect(ensureWorkflowSuffix('file.APP.JSON', 'app.json')).toBe(
'file.app.json'
)
})
})
describe('isPreviewableMediaType', () => {
it('returns true for image/video/audio/3D', () => {
expect(isPreviewableMediaType('image')).toBe(true)

View File

@@ -26,44 +26,13 @@ export function formatCamelCase(str: string): string {
return processedWords.join(' ')
}
// Metadata cannot be associated with workflows, so extension encodes the mode.
const JSON_SUFFIX = 'json'
const APP_JSON_SUFFIX = `app.${JSON_SUFFIX}`
const JSON_EXT = `.${JSON_SUFFIX}`
const APP_JSON_EXT = `.${APP_JSON_SUFFIX}`
export function appendJsonExt(path: string) {
if (!path.toLowerCase().endsWith(JSON_EXT)) {
path += JSON_EXT
if (!path.toLowerCase().endsWith('.json')) {
path += '.json'
}
return path
}
export type WorkflowSuffix = typeof JSON_SUFFIX | typeof APP_JSON_SUFFIX
export function getWorkflowSuffix(
suffix: string | null | undefined
): WorkflowSuffix {
return suffix === APP_JSON_SUFFIX ? APP_JSON_SUFFIX : JSON_SUFFIX
}
export function appendWorkflowJsonExt(path: string, isApp: boolean): string {
return ensureWorkflowSuffix(path, isApp ? APP_JSON_SUFFIX : JSON_SUFFIX)
}
export function ensureWorkflowSuffix(
name: string,
suffix: WorkflowSuffix
): string {
const lower = name.toLowerCase()
if (lower.endsWith(APP_JSON_EXT)) {
name = name.slice(0, -APP_JSON_EXT.length)
} else if (lower.endsWith(JSON_EXT)) {
name = name.slice(0, -JSON_EXT.length)
}
return name + '.' + suffix
}
export function highlightQuery(
text: string,
query: string,
@@ -127,27 +96,19 @@ export function formatCommitHash(value: string): string {
/**
* Returns various filename components.
* Recognises compound extensions like `.app.json`.
* Example:
* - fullFilename: 'file.txt' → { filename: 'file', suffix: 'txt' }
* - fullFilename: 'file.app.json' → { filename: 'file', suffix: 'app.json' }
* - fullFilename: 'file.txt'
* - filename: 'file'
* - suffix: 'txt'
*/
export function getFilenameDetails(fullFilename: string) {
const lower = fullFilename.toLowerCase()
if (
lower.endsWith(APP_JSON_EXT) &&
fullFilename.length > APP_JSON_EXT.length
) {
if (fullFilename.includes('.')) {
return {
filename: fullFilename.slice(0, -APP_JSON_EXT.length),
suffix: APP_JSON_SUFFIX
filename: fullFilename.split('.').slice(0, -1).join('.'),
suffix: fullFilename.split('.').pop() ?? null
}
}
const dotIndex = fullFilename.lastIndexOf('.')
if (dotIndex <= 0) return { filename: fullFilename, suffix: null }
return {
filename: fullFilename.slice(0, dotIndex),
suffix: fullFilename.slice(dotIndex + 1)
} else {
return { filename: fullFilename, suffix: null }
}
}

View File

@@ -18,7 +18,7 @@
<Splitter
:key="splitterRefreshKey"
class="bg-transparent pointer-events-none border-none flex-1 overflow-hidden"
:state-key="isSelectMode ? 'builder-splitter' : sidebarStateKey"
:state-key="sidebarStateKey"
state-storage="local"
@resizestart="onResizestart"
>
@@ -35,10 +35,8 @@
)
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:min-size="sidebarLocation === 'left' ? 10 : 15"
:size="20"
:style="firstPanelStyle"
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
:aria-label="
@@ -56,7 +54,7 @@
</SplitterPanel>
<!-- Main panel (always present) -->
<SplitterPanel :size="CENTER_PANEL_SIZE" class="flex flex-col">
<SplitterPanel :size="80" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible />
<Splitter
@@ -97,10 +95,8 @@
)
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:min-size="sidebarLocation === 'right' ? 10 : 15"
:size="20"
:style="lastPanelStyle"
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
:aria-label="
@@ -127,14 +123,8 @@ import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import {
BUILDER_MIN_SIZE,
CENTER_PANEL_SIZE,
SIDEBAR_MIN_SIZE,
SIDE_PANEL_SIZE
} from '@/constants/splitterConstants'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -155,17 +145,15 @@ const unifiedWidth = computed(() =>
const { focusMode } = storeToRefs(workspaceStore)
const { isSelectMode, isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
const showOffsideSplitter = computed(
() => rightSidePanelVisible.value || isSelectMode.value
() => rightSidePanelVisible.value || appModeStore.mode === 'builder:select'
)
const sidebarPanelVisible = computed(
() => activeSidebarTab.value !== null && !isBuilderMode.value
)
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
const sidebarStateKey = computed(() => {
return unifiedWidth.value
@@ -186,7 +174,7 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
* to recalculate the width and panel order
*/
const splitterRefreshKey = computed(() => {
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}${isSelectMode.value ? '-builder' : ''}-${sidebarLocation.value}`
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
})
const firstPanelStyle = computed(() => {

View File

@@ -56,6 +56,43 @@
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'job-history'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="relative px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu
ref="queueContextMenu"
:model="queueContextMenuItems"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
@@ -111,11 +148,14 @@
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
@@ -129,9 +169,12 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueUIStore } from '@/stores/queueStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isDesktop } from '@/platform/distribution/types'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
@@ -144,11 +187,17 @@ const workspaceStore = useWorkspaceStore()
const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const { isLoggedIn } = useCurrentUser()
const { t } = useI18n()
const { t, n } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const queueUIStore = useQueueUIStore()
const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
@@ -161,6 +210,14 @@ const isActionbarEnabled = computed(
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobsShort',
{ count: n(count) },
count
)
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
@@ -189,9 +246,24 @@ const inlineProgressSummaryTarget = computed(() => {
const shouldHideInlineProgressSummary = computed(
() => isQueueProgressOverlayEnabled.value && isQueueOverlayExpanded.value
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.manageExtensions'))
)
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
const queueContextMenuItems = computed<MenuItem[]>(() => [
{
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
icon: 'icon-[lucide--list-x] text-destructive-background',
class: '*:text-destructive-background',
disabled: queueStore.pendingTasks.length === 0,
command: () => {
void handleClearQueue()
}
}
])
const shouldShowRedDot = computed((): boolean => {
return shouldShowConflictRedDot.value
@@ -214,6 +286,27 @@ onMounted(() => {
}
})
const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('job-history')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay')
}
const showQueueContextMenu = (event: MouseEvent) => {
queueContextMenu.value?.show(event)
}
const handleClearQueue = async () => {
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByJobIds(pendingJobIds)
}
const openCustomNodeManager = async () => {
try {
await managerState.openManager({

View File

@@ -42,38 +42,6 @@
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
variant="secondary"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'job-history'
: queueOverlayExpanded
"
class="relative px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
</div>
</Panel>
@@ -97,14 +65,11 @@ import {
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import Panel from 'primevue/panel'
import { computed, nextTick, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
@@ -112,8 +77,6 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
@@ -129,13 +92,8 @@ const emit = defineEmits<{
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const queueStore = useQueueStore()
const sidebarTabStore = useSidebarTabStore()
const { t, n } = useI18n()
const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
const { activeJobsCount } = storeToRefs(queueStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const { t } = useI18n()
const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
@@ -360,58 +318,11 @@ watch(isDragging, (dragging) => {
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(
t(
isQueuePanelV2Enabled.value
? 'sideToolbar.queueProgressOverlay.viewJobHistory'
: 'sideToolbar.queueProgressOverlay.expandCollapsedQueue'
)
)
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobsShort',
{ count: n(count) },
count
)
})
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
const queueContextMenuItems = computed<MenuItem[]>(() => [
{
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
icon: 'icon-[lucide--list-x] text-destructive-background',
class: '*:text-destructive-background',
disabled: queueStore.pendingTasks.length === 0,
command: () => {
void handleClearQueue()
}
}
])
const cancelCurrentJob = async () => {
if (isExecutionIdle.value) return
await commandStore.execute('Comfy.Interrupt')
}
const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('job-history')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay')
}
const showQueueContextMenu = (event: MouseEvent) => {
queueContextMenu.value?.show(event)
}
const handleClearQueue = async () => {
const pendingJobIds = queueStore.pendingTasks
.map((task) => task.jobId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByJobIds(pendingJobIds)
}
const actionbarClass = computed(() =>
cn(

View File

@@ -8,29 +8,31 @@ import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemp
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const workspaceStore = useWorkspaceStore()
const { enableAppBuilder } = useAppMode()
const { enterBuilder } = useAppModeStore()
const appModeStore = useAppModeStore()
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
const isAssetsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'assets'
)
const isAppsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'apps'
const isWorkflowsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'workflows'
)
function enterBuilderMode() {
appModeStore.setMode('builder:select')
}
function openAssets() {
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
}
function showApps() {
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
void commandStore.execute('Workspace.ToggleSidebarTab.workflows')
}
function openTemplates() {
@@ -41,7 +43,7 @@ function openTemplates() {
<template>
<div class="flex flex-col gap-2 pointer-events-auto">
<WorkflowActionsDropdown source="app_mode_toolbar">
<template #button="{ hasUnseenItems }">
<template #button>
<Button
v-tooltip.right="{
value: t('sideToolbar.labels.menu'),
@@ -50,21 +52,16 @@ function openTemplates() {
variant="secondary"
size="unset"
:aria-label="t('sideToolbar.labels.menu')"
class="relative h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
class="h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i class="icon-[lucide--panels-top-left] size-4" />
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</template>
</WorkflowActionsDropdown>
<Button
v-if="enableAppBuilder"
v-if="appModeStore.enableAppBuilder"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions
@@ -73,7 +70,7 @@ function openTemplates() {
size="unset"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
class="size-10 rounded-lg"
@click="enterBuilder"
@click="enterBuilderMode"
>
<i class="icon-[lucide--hammer] size-4" />
</Button>
@@ -104,7 +101,9 @@ function openTemplates() {
variant="textonly"
size="unset"
:aria-label="t('linearMode.appModeToolbar.apps')"
:class="cn('size-10', isAppsActive && 'bg-secondary-background-hover')"
:class="
cn('size-10', isWorkflowsActive && 'bg-secondary-background-hover')
"
@click="showApps"
>
<i class="icon-[lucide--panels-top-left] size-4" />

View File

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

View File

@@ -60,7 +60,6 @@ import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
ComfyWorkflow,
@@ -71,6 +70,7 @@ import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { appendJsonExt } from '@/utils/formatUtil'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
interface Props {
@@ -107,10 +107,9 @@ const rename = async (
workflowStore.activeSubgraph.name = newName
} else if (workflowStore.activeWorkflow) {
try {
const suffix = getWorkflowSuffix(workflowStore.activeWorkflow.suffix)
await workflowService.renameWorkflow(
workflowStore.activeWorkflow,
ComfyWorkflow.basePath + ensureWorkflowSuffix(newName, suffix)
ComfyWorkflow.basePath + appendJsonExt(newName)
)
} catch (error) {
console.error(error)

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed, provide, ref, toValue } from 'vue'
import { computed, ref, toValue } from 'vue'
import type { MaybeRef } from 'vue'
import { useI18n } from 'vue-i18n'
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 Button from '@/components/ui/button/Button.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
@@ -23,11 +23,8 @@ 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 { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
type BoundStyle = { top: string; left: string; width: string; height: string }
@@ -39,35 +36,15 @@ const workflowStore = useWorkflowStore()
const { t } = useI18n()
const canvas: LGraphCanvas = canvasStore.getCanvas()
const { isSelectMode, isArrangeMode } = useAppMode()
const hoveringSelectable = ref(false)
provide(HideLayoutFieldKey, true)
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 }
})
.filter((item): item is NonNullable<typeof item> => item !== null)
)
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const node = resolveNode(nodeId)
const node = app.rootGraph.getNodeById(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) {
return {
nodeId,
widgetName,
subLabel: t('linearMode.builder.unknownWidget')
}
}
if (!node || !widget) return { nodeId, widgetName }
const input = node.inputs.find((i) => i.widget?.name === widget.name)
const rename = input && (() => renameWidget(widget, input))
@@ -163,14 +140,14 @@ function handleClick(e: MouseEvent) {
if (!widget) {
if (!node.constructor.nodeData?.output_node)
return canvasInteractions.forwardEventToCanvas(e)
const index = appModeStore.selectedOutputs.findIndex((id) => id == node.id)
const index = appModeStore.selectedOutputs.findIndex((id) => id === node.id)
if (index === -1) appModeStore.selectedOutputs.push(node.id)
else appModeStore.selectedOutputs.splice(index, 1)
return
}
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
([nodeId, widgetName]) => node.id === nodeId && widget.name === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
else appModeStore.selectedInputs.splice(index, 1)
@@ -202,45 +179,17 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</script>
<template>
<div class="flex font-bold p-2 border-border-subtle border-b items-center">
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
{{ t('linearMode.builder.title') }}
<Button class="ml-auto" @click="appModeStore.exitBuilder">
{{ t('linearMode.builder.exit') }}
</Button>
</div>
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'p-2 my-2 pointer-events-auto')"
:aria-label="`${widget?.label ?? widgetName} ${node.title}`"
>
<div v-if="widget" class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
</div>
<div v-else class="text-muted-foreground text-sm p-1 pointer-events-none">
{{ widgetName }}
<p class="text-xs italic">
({{ t('linearMode.builder.unknownWidget') }})
</p>
</div>
</div>
</DraggableList>
<PropertiesAccordionItem
v-else
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
class="border-border-subtle border-b"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
@@ -276,19 +225,17 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
([id, name]) => nodeId === id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="!isArrangeMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
@@ -322,15 +269,12 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
"
:title
:sub-title="String(key)"
:remove="() => remove(appModeStore.selectedOutputs, (k) => k == key)"
:remove="() => remove(appModeStore.selectedOutputs, (k) => k === key)"
/>
</DraggableList>
</PropertiesAccordionItem>
<Teleport
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"
to="body"
>
<Teleport to="body">
<div
:class="
cn(
@@ -364,19 +308,13 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="
remove(appModeStore.selectedOutputs, (k) => k == key)
"
@pointerdown.stop
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg"
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="appModeStore.selectedOutputs.push(key)"
@pointerdown.stop
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg"
/>
</div>
</div>

View File

@@ -1,62 +0,0 @@
<template>
<BuilderDialog @close="$emit('close')">
<template #title>
<span class="inline-flex items-center gap-2">
{{ $t('builderToolbar.defaultModeAppliedTitle') }}
<i
aria-hidden="true"
class="icon-[lucide--circle-check-big] size-4 text-green-500"
/>
</span>
</template>
<p class="m-0 text-sm text-muted-foreground">
{{
appliedAsApp
? $t('builderToolbar.defaultModeAppliedAppBody')
: $t('builderToolbar.defaultModeAppliedGraphBody')
}}
</p>
<p class="m-0 text-sm text-muted-foreground">
{{
appliedAsApp
? $t('builderToolbar.defaultModeAppliedAppPrompt')
: $t('builderToolbar.defaultModeAppliedGraphPrompt')
}}
</p>
<template #footer>
<template v-if="appliedAsApp">
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
{{ $t('g.close') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('viewApp')">
{{ $t('builderToolbar.viewApp') }}
</Button>
</template>
<template v-else>
<Button variant="muted-textonly" size="lg" @click="$emit('viewApp')">
{{ $t('builderToolbar.viewApp') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('close')">
{{ $t('g.close') }}
</Button>
</template>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
defineProps<{
appliedAsApp: boolean
}>()
defineEmits<{
viewApp: []
close: []
}>()
</script>

View File

@@ -1,7 +1,5 @@
<template>
<div
class="flex min-h-80 w-full min-w-116 flex-col rounded-2xl bg-base-background"
>
<div class="flex w-full min-w-96 flex-col rounded-2xl bg-base-background">
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
@@ -13,7 +11,6 @@
</h2>
</div>
<Button
v-if="showClose"
variant="muted-textonly"
class="-mr-1"
:aria-label="$t('g.close')"
@@ -24,7 +21,7 @@
</div>
<!-- Body -->
<div class="flex flex-1 flex-col gap-4 px-4 py-4">
<div class="flex flex-col gap-4 px-4 py-4">
<slot />
</div>
@@ -38,10 +35,6 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const { showClose = true } = defineProps<{
showClose?: boolean
}>()
defineEmits<{
close: []
}>()

View File

@@ -1,147 +0,0 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { AppMode } from '@/composables/useAppMode'
import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'
const mockSetMode = vi.hoisted(() => vi.fn())
const mockExitBuilder = vi.hoisted(() => vi.fn())
const mockShowDialog = vi.hoisted(() => vi.fn())
const mockState = {
mode: 'builder:select' as AppMode,
settingView: false
}
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: computed(() => mockState.mode),
isBuilderMode: ref(true),
setMode: mockSetMode
})
}))
const mockHasOutputs = ref(true)
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => ({
exitBuilder: mockExitBuilder,
hasOutputs: mockHasOutputs,
$id: 'appMode'
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
dialogStack: []
})
}))
vi.mock('@/components/builder/useAppSetDefaultView', () => ({
useAppSetDefaultView: () => ({
settingView: computed(() => mockState.settingView),
showDialog: mockShowDialog
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
builderMenu: { exitAppBuilder: 'Exit app builder' },
g: { back: 'Back', next: 'Next' }
}
}
})
describe('BuilderFooterToolbar', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockState.mode = 'builder:select'
mockHasOutputs.value = true
mockState.settingView = false
})
function mountComponent() {
return mount(BuilderFooterToolbar, {
global: {
plugins: [i18n],
stubs: { Button: false }
}
})
}
function getButtons(wrapper: ReturnType<typeof mountComponent>) {
const buttons = wrapper.findAll('button')
return {
exit: buttons[0],
back: buttons[1],
next: buttons[2]
}
}
it('disables back on the first step', () => {
mockState.mode = 'builder:select'
const { back } = getButtons(mountComponent())
expect(back.attributes('disabled')).toBeDefined()
})
it('enables back on the second step', () => {
mockState.mode = 'builder:arrange'
const { back } = getButtons(mountComponent())
expect(back.attributes('disabled')).toBeUndefined()
})
it('disables next on the setDefaultView step', () => {
mockState.settingView = true
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeDefined()
})
it('disables next on arrange step when no outputs', () => {
mockState.mode = 'builder:arrange'
mockHasOutputs.value = false
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeDefined()
})
it('enables next on select step', () => {
mockState.mode = 'builder:select'
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeUndefined()
})
it('calls setMode on back click', async () => {
mockState.mode = 'builder:arrange'
const { back } = getButtons(mountComponent())
await back.trigger('click')
expect(mockSetMode).toHaveBeenCalledWith('builder:select')
})
it('calls setMode on next click from select step', async () => {
mockState.mode = 'builder:select'
const { next } = getButtons(mountComponent())
await next.trigger('click')
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
})
it('opens default view dialog on next click from arrange step', async () => {
mockState.mode = 'builder:arrange'
const { next } = getButtons(mountComponent())
await next.trigger('click')
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
expect(mockShowDialog).toHaveBeenCalledOnce()
})
it('calls exitBuilder on exit button click', async () => {
const { exit } = getButtons(mountComponent())
await exit.trigger('click')
expect(mockExitBuilder).toHaveBeenCalledOnce()
})
})

View File

@@ -1,63 +0,0 @@
<template>
<nav
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 items-center gap-2 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<Button variant="textonly" size="lg" @click="onExitBuilder">
{{ t('builderMenu.exitAppBuilder') }}
</Button>
<Button
variant="textonly"
size="lg"
:disabled="isFirstStep"
@click="goBack"
>
<i class="icon-[lucide--chevron-left]" aria-hidden="true" />
{{ t('g.back') }}
</Button>
<Button size="lg" :disabled="isLastStep" @click="goNext">
{{ t('g.next') }}
<i class="icon-[lucide--chevron-right]" aria-hidden="true" />
</Button>
</nav>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useBuilderSteps } from './useBuilderSteps'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
const { isBuilderMode } = useAppMode()
const { hasOutputs } = storeToRefs(appModeStore)
const { isFirstStep, isLastStep, goBack, goNext } = useBuilderSteps({
hasOutputs
})
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
if (
e.key === 'Escape' &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
dialogStore.dialogStack.length === 0 &&
isBuilderMode.value
) {
e.preventDefault()
e.stopPropagation()
onExitBuilder()
}
})
function onExitBuilder() {
void appModeStore.exitBuilder()
}
</script>

View File

@@ -1,82 +0,0 @@
<template>
<Popover :show-arrow="false" class="min-w-56 p-3">
<template #button>
<button
:class="
cn(
'absolute left-4 top-[calc(var(--workflow-tabs-height)+16px)] z-1000 inline-flex h-10 cursor-pointer items-center gap-2.5 rounded-lg py-2 pr-2 pl-3 shadow-interface transition-colors border-none',
'bg-secondary-background hover:bg-secondary-background-hover',
'data-[state=open]:bg-secondary-background-hover'
)
"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
>
<i class="icon-[lucide--hammer] size-4" />
<span class="text-sm font-medium">
{{ t('linearMode.appModeToolbar.appBuilder') }}
</span>
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
</button>
</template>
<template #default="{ close }">
<button
:class="
cn(
'flex w-full items-center gap-3 rounded-md bg-transparent px-3 py-2 text-sm border-none',
hasOutputs
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'opacity-50 pointer-events-none'
)
"
:disabled="!hasOutputs"
@click="onSave(close)"
>
<i class="icon-[lucide--save] size-4" />
{{ t('g.save') }}
</button>
<div class="my-1 border-t border-border-default" />
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-md bg-transparent px-3 py-2 text-sm border-none hover:bg-secondary-background-hover"
@click="onExitBuilder(close)"
>
<i class="icon-[lucide--square-pen] size-4" />
{{ t('builderMenu.exitAppBuilder') }}
</button>
</template>
</Popover>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const { toastErrorHandler } = useErrorHandling()
async function onSave(close: () => void) {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
try {
await workflowService.saveWorkflow(workflow)
close()
} catch (error) {
toastErrorHandler(error)
}
}
function onExitBuilder(close: () => void) {
void appModeStore.exitBuilder()
close()
}
</script>

View File

@@ -1,16 +1,32 @@
<template>
<BuilderDialog @close="$emit('close')">
<BuilderDialog @close="onClose">
<template #title>
{{ $t('builderToolbar.defaultViewTitle') }}
{{ $t('builderToolbar.saveAs') }}
</template>
<!-- Filename -->
<div class="flex flex-col gap-2">
<label :for="inputId" class="text-sm text-muted-foreground">
{{ $t('builderToolbar.filename') }}
</label>
<input
:id="inputId"
v-model="filename"
autofocus
type="text"
class="flex h-10 min-h-8 items-center self-stretch rounded-lg border-none bg-secondary-background pl-4 text-sm text-base-foreground focus:outline-none"
@keydown.enter="filename.trim() && onSave(filename.trim(), openAsApp)"
/>
</div>
<!-- Save as type -->
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground">
{{ $t('builderToolbar.defaultViewLabel') }}
{{ $t('builderToolbar.saveAsLabel') }}
</label>
<div role="radiogroup" class="flex flex-col gap-2">
<Button
v-for="option in viewTypeOptions"
v-for="option in saveTypeOptions"
:key="option.value.toString()"
role="radio"
:aria-checked="openAsApp === option.value"
@@ -45,18 +61,23 @@
</div>
<template #footer>
<Button variant="muted-textonly" size="lg" @click="$emit('close')">
<Button variant="muted-textonly" size="lg" @click="onClose">
{{ $t('g.cancel') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('apply', openAsApp)">
{{ $t('g.apply') }}
<Button
variant="secondary"
size="lg"
:disabled="!filename.trim()"
@click="onSave(filename.trim(), openAsApp)"
>
{{ $t('g.save') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, useId } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -66,18 +87,17 @@ import BuilderDialog from './BuilderDialog.vue'
const { t } = useI18n()
const { initialOpenAsApp = true } = defineProps<{
initialOpenAsApp?: boolean
const { defaultFilename, onSave, onClose } = defineProps<{
defaultFilename: string
onSave: (filename: string, openAsApp: boolean) => void
onClose: () => void
}>()
defineEmits<{
apply: [openAsApp: boolean]
close: []
}>()
const inputId = useId()
const filename = ref(defaultFilename)
const openAsApp = ref(true)
const openAsApp = ref(initialOpenAsApp)
const viewTypeOptions = [
const saveTypeOptions = [
{
value: true,
icon: 'icon-[lucide--app-window]',

View File

@@ -0,0 +1,51 @@
<template>
<BuilderDialog @close="onClose">
<template #header-icon>
<i class="icon-[lucide--circle-check-big] size-4 text-green-500" />
</template>
<template #title>
{{ $t('builderToolbar.saveSuccess') }}
</template>
<p v-if="savedAsApp" class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.saveSuccessAppMessage', { name: workflowName }) }}
</p>
<p v-if="savedAsApp" class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.saveSuccessAppPrompt') }}
</p>
<p v-else class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.saveSuccessGraphMessage', { name: workflowName }) }}
</p>
<template #footer>
<Button
:variant="savedAsApp ? 'muted-textonly' : 'secondary'"
size="lg"
@click="onClose"
>
{{ $t('g.close') }}
</Button>
<Button
v-if="savedAsApp && onViewApp"
variant="primary"
size="lg"
@click="onViewApp"
>
{{ $t('builderToolbar.viewApp') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
defineProps<{
workflowName: string
savedAsApp: boolean
onViewApp?: () => void
onClose: () => void
}>()
</script>

View File

@@ -1,6 +1,6 @@
<template>
<nav
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-1000 -translate-x-1/2"
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-[1000] -translate-x-1/2"
:aria-label="t('builderToolbar.label')"
>
<div
@@ -20,7 +20,7 @@
)
"
:aria-current="activeStep === step.id ? 'step' : undefined"
@click="navigateToStep(step.id)"
@click="appModeStore.setMode(step.id)"
>
<StepBadge :step :index :model-value="activeStep" />
<StepLabel :step />
@@ -29,19 +29,15 @@
<div class="mx-1 h-px w-4 bg-border-default" role="separator" />
</template>
<!-- Default view -->
<!-- Save -->
<ConnectOutputPopover
v-if="!hasOutputs"
v-if="!appModeStore.hasOutputs"
:is-select-active="activeStep === 'builder:select'"
@switch="navigateToStep('builder:select')"
@switch="appModeStore.setMode('builder:select')"
>
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
<StepBadge
:step="defaultViewStep"
:index="2"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
<StepLabel :step="saveStep" />
</button>
</ConnectOutputPopover>
<button
@@ -49,64 +45,70 @@
:class="
cn(
stepClasses,
activeStep === 'setDefaultView'
activeStep === 'save'
? 'bg-interface-builder-mode-background'
: 'hover:bg-secondary-background bg-transparent'
)
"
@click="navigateToStep('setDefaultView')"
@click="appModeStore.setBuilderSaving(true)"
>
<StepBadge
:step="defaultViewStep"
:index="2"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
<StepLabel :step="saveStep" />
</button>
</div>
</nav>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useEventListener } from '@vueuse/core'
import { useAppModeStore } from '@/stores/appModeStore'
import type { AppMode } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
import ConnectOutputPopover from './ConnectOutputPopover.vue'
import StepBadge from './StepBadge.vue'
import StepLabel from './StepLabel.vue'
import type { BuilderToolbarStep } from './types'
import type { BuilderStepId } from './useBuilderSteps'
import { useBuilderSteps } from './useBuilderSteps'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { activeStep, navigateToStep } = useBuilderSteps()
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.preventDefault()
e.stopPropagation()
void appModeStore.exitBuilder()
})
const activeStep = computed(() =>
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
)
const stepClasses =
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
const selectStep: BuilderToolbarStep<BuilderStepId> = {
const selectStep: BuilderToolbarStep<AppMode> = {
id: 'builder:select',
title: t('builderToolbar.select'),
subtitle: t('builderToolbar.selectDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
}
const arrangeStep: BuilderToolbarStep<BuilderStepId> = {
const arrangeStep: BuilderToolbarStep<AppMode> = {
id: 'builder:arrange',
title: t('builderToolbar.arrange'),
subtitle: t('builderToolbar.arrangeDescription'),
icon: 'icon-[lucide--layout-panel-left]'
}
const defaultViewStep: BuilderToolbarStep<BuilderStepId> = {
id: 'setDefaultView',
title: t('builderToolbar.defaultView'),
subtitle: t('builderToolbar.defaultViewDescription'),
icon: 'icon-[lucide--eye]'
const saveStep: BuilderToolbarStep<'save'> = {
id: 'save',
title: t('builderToolbar.save'),
subtitle: t('builderToolbar.saveDescription'),
icon: 'icon-[lucide--cloud-upload]'
}
</script>

View File

@@ -1,40 +0,0 @@
<template>
<BuilderDialog :show-close="false">
<template #title>
{{ $t('builderToolbar.emptyWorkflowTitle') }}
</template>
<div class="flex flex-col gap-2">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.emptyWorkflowExplanation') }}
</p>
<p class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.emptyWorkflowPrompt') }}
</p>
</div>
<template #footer>
<Button
variant="muted-textonly"
size="lg"
@click="$emit('backToWorkflow')"
>
{{ $t('builderToolbar.backToWorkflow') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('loadTemplate')">
{{ $t('builderToolbar.loadTemplate') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
defineEmits<{
backToWorkflow: []
loadTemplate: []
}>()
</script>

View File

@@ -32,15 +32,9 @@ const entries = computed(() => {
})
</script>
<template>
<div class="p-2 my-2 rounded-lg flex items-center-safe gap-2">
<div
class="mr-auto flex-[4_1_0%] max-w-max min-w-0 truncate drag-handle inline"
v-text="title"
/>
<div
class="flex-[2_1_0%] max-w-max min-w-0 truncate text-muted-foreground text-end drag-handle inline"
v-text="subTitle"
/>
<div class="p-2 my-2 rounded-lg flex items-center-safe">
<span class="mr-auto" v-text="title" />
<span class="text-muted-foreground mr-2 text-end" v-text="subTitle" />
<Popover :entries>
<template #button>
<Button variant="muted-textonly">

View File

@@ -1,222 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockDialogService = vi.hoisted(() => ({
showLayoutDialog: vi.fn()
}))
const mockDialogStore = vi.hoisted(() => ({
closeDialog: vi.fn(),
isDialogOpen: vi.fn<(key: string) => boolean>().mockReturnValue(false)
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: null as {
initialMode?: string | null
changeTracker?: { checkState: () => void }
} | null
}))
const mockApp = vi.hoisted(() => ({
rootGraph: { extra: {} as Record<string, unknown> }
}))
const mockSetMode = vi.hoisted(() => vi.fn())
vi.mock('@/services/dialogService', () => ({
useDialogService: () => mockDialogService
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => mockDialogStore
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => mockWorkflowStore
}))
vi.mock('@/scripts/app', () => ({
app: mockApp
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ setMode: mockSetMode })
}))
vi.mock('./DefaultViewDialogContent.vue', () => ({
default: { name: 'MockDefaultViewDialogContent' }
}))
vi.mock('./BuilderDefaultModeAppliedDialogContent.vue', () => ({
default: { name: 'MockBuilderDefaultModeAppliedDialogContent' }
}))
import { useAppSetDefaultView } from './useAppSetDefaultView'
describe('useAppSetDefaultView', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStore.activeWorkflow = null
mockApp.rootGraph.extra = {}
})
describe('settingView', () => {
it('reflects dialogStore.isDialogOpen', () => {
mockDialogStore.isDialogOpen.mockReturnValue(true)
const { settingView } = useAppSetDefaultView()
expect(settingView.value).toBe(true)
})
})
describe('showDialog', () => {
it('opens dialog via dialogService', () => {
const { showDialog } = useAppSetDefaultView()
showDialog()
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledOnce()
})
it('passes initialOpenAsApp true when initialMode is not graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: 'app' }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(true)
})
it('passes initialOpenAsApp false when initialMode is graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: 'graph' }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(false)
})
it('passes initialOpenAsApp true when no active workflow', () => {
mockWorkflowStore.activeWorkflow = null
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
expect(call.props.initialOpenAsApp).toBe(true)
})
})
describe('handleApply', () => {
it('sets initialMode to app when openAsApp is true', () => {
const workflow = { initialMode: null as string | null }
mockWorkflowStore.activeWorkflow = workflow
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(workflow.initialMode).toBe('app')
})
it('sets initialMode to graph when openAsApp is false', () => {
const workflow = { initialMode: null as string | null }
mockWorkflowStore.activeWorkflow = workflow
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(false)
expect(workflow.initialMode).toBe('graph')
})
it('sets linearMode on rootGraph.extra', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockApp.rootGraph.extra.linearMode).toBe(true)
})
it('closes dialog after applying', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view'
})
})
it('shows confirmation dialog after applying', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(true)
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledTimes(2)
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
expect(confirmCall.key).toBe('builder-default-view-applied')
expect(confirmCall.props.appliedAsApp).toBe(true)
})
it('passes appliedAsApp false to confirmation dialog when graph', () => {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const call = mockDialogService.showLayoutDialog.mock.calls[0][0]
call.props.onApply(false)
const confirmCall = mockDialogService.showLayoutDialog.mock.calls[1][0]
expect(confirmCall.props.appliedAsApp).toBe(false)
})
})
describe('applied dialog', () => {
function applyAndGetConfirmDialog(openAsApp: boolean) {
mockWorkflowStore.activeWorkflow = { initialMode: null }
const { showDialog } = useAppSetDefaultView()
showDialog()
const applyCall = mockDialogService.showLayoutDialog.mock.calls[0][0]
applyCall.props.onApply(openAsApp)
return mockDialogService.showLayoutDialog.mock.calls[1][0]
}
it('onViewApp sets mode to app and closes dialog', () => {
const confirmCall = applyAndGetConfirmDialog(true)
confirmCall.props.onViewApp()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view-applied'
})
expect(mockSetMode).toHaveBeenCalledWith('app')
})
it('onClose closes confirmation dialog', () => {
const confirmCall = applyAndGetConfirmDialog(true)
mockDialogStore.closeDialog.mockClear()
confirmCall.props.onClose()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'builder-default-view-applied'
})
})
})
})

View File

@@ -1,71 +0,0 @@
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import BuilderDefaultModeAppliedDialogContent from './BuilderDefaultModeAppliedDialogContent.vue'
import DefaultViewDialogContent from './DefaultViewDialogContent.vue'
const DIALOG_KEY = 'builder-default-view'
const APPLIED_DIALOG_KEY = 'builder-default-view-applied'
export function useAppSetDefaultView() {
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const { setMode } = useAppMode()
const settingView = computed(() => dialogStore.isDialogOpen(DIALOG_KEY))
function showDialog() {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: DefaultViewDialogContent,
props: {
initialOpenAsApp: workflowStore.activeWorkflow?.initialMode !== 'graph',
onApply: handleApply,
onClose: closeDialog
}
})
}
function handleApply(openAsApp: boolean) {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
workflow.initialMode = openAsApp ? 'app' : 'graph'
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.checkState()
closeDialog()
showAppliedDialog(openAsApp)
}
function showAppliedDialog(appliedAsApp: boolean) {
dialogService.showLayoutDialog({
key: APPLIED_DIALOG_KEY,
component: BuilderDefaultModeAppliedDialogContent,
props: {
appliedAsApp,
onViewApp: () => {
closeAppliedDialog()
setMode('app')
},
onClose: closeAppliedDialog
}
})
}
function closeDialog() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function closeAppliedDialog() {
dialogStore.closeDialog({ key: APPLIED_DIALOG_KEY })
}
return { settingView, showDialog }
}

View File

@@ -0,0 +1,123 @@
import { watch } from 'vue'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
const SAVE_DIALOG_KEY = 'builder-save'
const SUCCESS_DIALOG_KEY = 'builder-save-success'
export function useBuilderSave() {
const appModeStore = useAppModeStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
watch(
() => appModeStore.isBuilderSaving,
(saving) => {
if (saving) void onBuilderSave()
}
)
async function onBuilderSave() {
const workflow = workflowStore.activeWorkflow
if (!workflow) {
resetSaving()
return
}
if (!workflow.isTemporary && workflow.activeState.extra?.linearMode) {
try {
workflow.changeTracker?.checkState()
appModeStore.saveSelectedToWorkflow()
await workflowService.saveWorkflow(workflow)
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
} catch {
resetSaving()
}
return
}
showSaveDialog(workflow.filename)
}
function showSaveDialog(defaultFilename: string) {
dialogService.showLayoutDialog({
key: SAVE_DIALOG_KEY,
component: BuilderSaveDialogContent,
props: {
defaultFilename,
onSave: handleSave,
onClose: handleCancelSave
},
dialogComponentProps: {
onClose: resetSaving
}
})
}
function handleCancelSave() {
closeSaveDialog()
resetSaving()
}
async function handleSave(filename: string, openAsApp: boolean) {
try {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
appModeStore.saveSelectedToWorkflow()
const saved = await workflowService.saveWorkflowAs(workflow, {
filename,
openAsApp
})
if (!saved) return
closeSaveDialog()
showSuccessDialog(filename, openAsApp)
} catch {
closeSaveDialog()
resetSaving()
}
}
function showSuccessDialog(workflowName: string, savedAsApp: boolean) {
dialogService.showLayoutDialog({
key: SUCCESS_DIALOG_KEY,
component: BuilderSaveSuccessDialogContent,
props: {
workflowName,
savedAsApp,
onViewApp: () => {
appModeStore.setMode('app')
closeSuccessDialog()
},
onClose: closeSuccessDialog
},
dialogComponentProps: {
onClose: resetSaving
}
})
}
function closeSaveDialog() {
dialogStore.closeDialog({ key: SAVE_DIALOG_KEY })
}
function closeSuccessDialog() {
dialogStore.closeDialog({ key: SUCCESS_DIALOG_KEY })
resetSaving()
}
function resetSaving() {
appModeStore.setBuilderSaving(false)
}
}

View File

@@ -1,71 +0,0 @@
import type { Ref } from 'vue'
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useAppSetDefaultView } from './useAppSetDefaultView'
const BUILDER_STEPS = [
'builder:select',
'builder:arrange',
'setDefaultView'
] as const
export type BuilderStepId = (typeof BUILDER_STEPS)[number]
const ARRANGE_INDEX = BUILDER_STEPS.indexOf('builder:arrange')
export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
const { mode, isBuilderMode, setMode } = useAppMode()
const { settingView, showDialog } = useAppSetDefaultView()
const activeStep = computed<BuilderStepId>(() => {
if (settingView.value) return 'setDefaultView'
if (isBuilderMode.value) {
return mode.value as BuilderStepId
}
return 'builder:select'
})
const activeStepIndex = computed(() =>
BUILDER_STEPS.indexOf(activeStep.value)
)
const isFirstStep = computed(() => activeStepIndex.value === 0)
const isLastStep = computed(() => {
if (!options?.hasOutputs?.value)
return activeStepIndex.value >= ARRANGE_INDEX
return activeStepIndex.value >= BUILDER_STEPS.length - 1
})
function navigateToStep(stepId: BuilderStepId) {
if (stepId === 'setDefaultView') {
setMode('builder:arrange')
showDialog()
} else {
setMode(stepId)
}
}
function goBack() {
if (isFirstStep.value) return
navigateToStep(BUILDER_STEPS[activeStepIndex.value - 1])
}
function goNext() {
if (isLastStep.value) return
navigateToStep(BUILDER_STEPS[activeStepIndex.value + 1])
}
return {
activeStep,
activeStepIndex,
isFirstStep,
isLastStep,
navigateToStep,
goBack,
goNext
}
}

View File

@@ -1,44 +0,0 @@
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import EmptyWorkflowDialogContent from './EmptyWorkflowDialogContent.vue'
const DIALOG_KEY = 'builder-empty-workflow'
export function useEmptyWorkflowDialog() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
function show(options: {
onEnterBuilder: () => void
onDismiss: () => void
}) {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: EmptyWorkflowDialogContent,
props: {
onBackToWorkflow: () => {
closeDialog()
options.onDismiss()
},
onLoadTemplate: () => {
closeDialog()
templateSelectorDialog.show('appbuilder', {
afterClose: () => {
if (app.rootGraph?.nodes?.length) options.onEnterBuilder()
}
})
}
}
})
}
function closeDialog() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
return { show }
}

View File

@@ -40,7 +40,6 @@ import InputText from 'primevue/inputtext'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
@@ -89,7 +88,6 @@ const handleFileUpload = async (event: Event) => {
type: 'input',
subfolder: 'backgrounds'
})
appendCloudResParam(params, file.name)
modelValue.value = `/api/view?${params.toString()}`
}
} catch (error) {

View File

@@ -1,11 +1,7 @@
<template>
<span
:class="
cn(
'flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs',
textColorClass
)
"
class="flex items-center gap-1 rounded border px-1.5 py-0.5 text-xxs"
:class="textColorClass"
:style="customStyle"
>
<i v-if="icon" :class="cn(icon, 'size-2.5', iconClass)" />

View File

@@ -1,65 +0,0 @@
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import { toValue } from 'vue'
const { t } = useI18n()
defineOptions({
inheritAttrs: false
})
defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
</script>
<template>
<DropdownMenuSeparator
v-if="item.separator"
class="h-[1px] bg-border-subtle m-1"
/>
<DropdownMenuSub v-else-if="item.items">
<DropdownMenuSubTrigger
:class="itemClass"
:disabled="toValue(item.disabled) ?? !item.items?.length"
>
{{ item.label }}
<i class="ml-auto icon-[lucide--chevron-right]" />
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent
:class="contentClass"
:side-offset="2"
:align-offset="-5"
>
<DropdownItem
v-for="(subitem, index) in item.items"
:key="toValue(subitem.label) ?? index"
:item="subitem"
:item-class
:content-class
/>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuItem
v-else
:class="itemClass"
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<i class="size-5" :class="item.icon" />
{{ item.label }}
<div
v-if="item.new"
class="ml-auto bg-primary-background rounded-full text-xxs font-bold px-1 flex leading-none items-center"
v-text="t('contextMenu.new')"
/>
</DropdownMenuItem>
</template>

View File

@@ -1,74 +0,0 @@
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
DropdownMenuContent,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, toValue } from 'vue'
import DropdownItem from '@/components/common/DropdownItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
defineOptions({
inheritAttrs: false
})
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
entries?: MenuItem[]
icon?: string
to?: string | HTMLElement
itemClass?: string
contentClass?: string
}>()
const itemClass = computed(() =>
cn(
'data-[highlighted]:bg-secondary-background-hover data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground flex p-2 leading-none rounded-lg gap-1 cursor-pointer m-1',
itemProp
)
)
const contentClass = computed(() =>
cn(
'z-1700 rounded-lg p-2 bg-base-background border border-border-subtle min-w-[220px] shadow-sm will-change-[opacity,transform] data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade',
contentProp
)
)
</script>
<template>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<slot name="button">
<Button size="icon">
<i :class="icon ?? 'icon-[lucide--menu]'" />
</Button>
</slot>
</DropdownMenuTrigger>
<DropdownMenuPortal :to>
<DropdownMenuContent
side="bottom"
:side-offset="5"
:collision-padding="10"
v-bind="$attrs"
:class="contentClass"
>
<slot :item-class>
<DropdownItem
v-for="(item, index) in entries ?? []"
:key="toValue(item.label) ?? index"
:item-class
:content-class
:item
/>
</slot>
<DropdownMenuArrow class="fill-base-background stroke-border-subtle" />
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>

View File

@@ -3,18 +3,14 @@
<Card>
<template #content>
<div class="flex flex-col items-center">
<i
v-if="icon"
:class="icon"
style="font-size: 3rem; margin-bottom: 1rem"
/>
<h3 v-if="title">{{ title }}</h3>
<i :class="icon" style="font-size: 3rem; margin-bottom: 1rem" />
<h3>{{ title }}</h3>
<p :class="textClass" class="text-center whitespace-pre-line">
{{ message }}
</p>
<Button
v-if="buttonLabel"
:variant="buttonVariant ?? 'textonly'"
variant="textonly"
@click="$emit('action')"
>
{{ buttonLabel }}
@@ -29,16 +25,14 @@
import Card from 'primevue/card'
import Button from '@/components/ui/button/Button.vue'
import type { ButtonVariants } from '../ui/button/button.variants'
const props = defineProps<{
class?: string
icon?: string
title?: string
title: string
message: string
textClass?: string
buttonLabel?: string
buttonVariant?: ButtonVariants['variant']
}>()
defineEmits(['action'])
@@ -57,6 +51,7 @@ defineEmits(['action'])
}
.no-results-placeholder p {
color: var(--text-color-secondary);
margin-bottom: 1rem;
}
</style>

View File

@@ -8,7 +8,7 @@
v-if="!hideButtons"
:aria-label="t('g.decrement')"
data-testid="decrement"
class="h-full aspect-8/7 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canDecrement"
tabindex="-1"
@@ -53,7 +53,7 @@
v-if="!hideButtons"
:aria-label="t('g.increment')"
data-testid="increment"
class="h-full aspect-8/7 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canIncrement"
tabindex="-1"

View File

@@ -2,7 +2,6 @@ import { mount } from '@vue/test-utils'
import type { FlattenedItem } from 'reka-ui'
import { ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -10,25 +9,12 @@ import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn().mockReturnValue('left')
})
}))
vi.mock('@/stores/nodeBookmarkStore', () => ({
useNodeBookmarkStore: () => ({
isBookmarked: vi.fn().mockReturnValue(false),
toggleBookmark: vi.fn()
})
}))
vi.mock('@/components/node/NodePreviewCard.vue', () => ({
default: { template: '<div />' }
}))
@@ -92,7 +78,6 @@ describe('TreeExplorerV2Node', () => {
return {
wrapper: mount(TreeExplorerV2Node, {
global: {
plugins: [i18n],
stubs: {
TreeItem: treeItemStub.stub,
Teleport: { template: '<div />' }

View File

@@ -24,25 +24,6 @@
{{ item.value.label }}
</slot>
</span>
<button
:class="
cn(
'flex size-6 shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent text-muted-foreground hover:text-foreground',
'opacity-0 group-hover/tree-node:opacity-100'
)
"
:aria-label="$t('icon.bookmark')"
@click.stop="toggleBookmark"
>
<i
:class="
cn(
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
'text-xs'
)
"
/>
</button>
</div>
<!-- Folder -->
@@ -52,15 +33,6 @@
:style="rowStyle"
@click.stop="handleClick($event, handleToggle, handleSelect)"
>
<i
v-if="item.hasChildren"
:class="
cn(
'icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground transition-transform',
!isExpanded && '-rotate-90'
)
"
/>
<i
:class="cn(item.value.icon, 'size-4 shrink-0 text-muted-foreground')"
/>
@@ -69,6 +41,15 @@
{{ item.value.label }}
</slot>
</span>
<i
v-if="item.hasChildren"
:class="
cn(
'icon-[lucide--chevron-down] mr-4 size-4 shrink-0 text-muted-foreground transition-transform',
!isExpanded && '-rotate-90'
)
"
/>
</div>
</TreeItem>
@@ -92,7 +73,6 @@ import { computed, inject } from 'vue'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
@@ -113,21 +93,9 @@ const emit = defineEmits<{
}>()
const contextMenuNode = inject(InjectKeyContextMenuNode)
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeDef = computed(() => item.value.data)
const isBookmarked = computed(() => {
if (!nodeDef.value) return false
return nodeBookmarkStore.isBookmarked(nodeDef.value)
})
function toggleBookmark() {
if (nodeDef.value) {
nodeBookmarkStore.toggleBookmark(nodeDef.value)
}
}
const {
previewRef,
showPreview,

View File

@@ -9,7 +9,6 @@ import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -28,13 +27,8 @@ const { menuItems } = useWorkflowActionsMenu(
{ isRoot: true }
)
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
() => menuItems.value
)
function handleOpen(open: boolean) {
if (open) {
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source
})
@@ -45,7 +39,7 @@ function handleOpen(open: boolean) {
<template>
<DropdownMenuRoot @update:open="handleOpen">
<DropdownMenuTrigger as-child>
<slot name="button" :has-unseen-items="hasUnseenItems">
<slot name="button">
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
@@ -55,7 +49,7 @@ function handleOpen(open: boolean) {
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="relative h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
class="h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i
class="size-4"
@@ -66,11 +60,6 @@ function handleOpen(open: boolean) {
"
/>
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</slot>
</DropdownMenuTrigger>

View File

@@ -17,7 +17,7 @@ function createWrapper(items: WorkflowMenuItem[]) {
describe('WorkflowActionsList', () => {
it('renders action items with label and icon', () => {
const items: WorkflowMenuItem[] = [
{ id: 'save', label: 'Save', icon: 'pi pi-save', command: vi.fn() }
{ label: 'Save', icon: 'pi pi-save', command: vi.fn() }
]
const wrapper = createWrapper(items)
@@ -28,9 +28,9 @@ describe('WorkflowActionsList', () => {
it('renders separator items', () => {
const items: WorkflowMenuItem[] = [
{ id: 'before', label: 'Before', icon: 'pi pi-a', command: vi.fn() },
{ label: 'Before', icon: 'pi pi-a', command: vi.fn() },
{ separator: true },
{ id: 'after', label: 'After', icon: 'pi pi-b', command: vi.fn() }
{ label: 'After', icon: 'pi pi-b', command: vi.fn() }
]
const wrapper = createWrapper(items)
@@ -44,7 +44,7 @@ describe('WorkflowActionsList', () => {
it('dispatches command on select', async () => {
const command = vi.fn()
const items: WorkflowMenuItem[] = [
{ id: 'action', label: 'Action', icon: 'pi pi-play', command }
{ label: 'Action', icon: 'pi pi-play', command }
]
const wrapper = createWrapper(items)
@@ -57,7 +57,6 @@ describe('WorkflowActionsList', () => {
it('renders badge when present', () => {
const items: WorkflowMenuItem[] = [
{
id: 'new-feature',
label: 'New Feature',
icon: 'pi pi-star',
command: vi.fn(),
@@ -70,27 +69,9 @@ describe('WorkflowActionsList', () => {
expect(wrapper.text()).toContain('NEW')
})
it('does not render items with visible set to false', () => {
const items: WorkflowMenuItem[] = [
{
id: 'hidden',
label: 'Hidden Item',
icon: 'pi pi-eye-slash',
command: vi.fn(),
visible: false
},
{ id: 'shown', label: 'Shown Item', icon: 'pi pi-eye', command: vi.fn() }
]
const wrapper = createWrapper(items)
expect(wrapper.text()).not.toContain('Hidden Item')
expect(wrapper.text()).toContain('Shown Item')
})
it('does not render badge when absent', () => {
const items: WorkflowMenuAction[] = [
{ id: 'plain', label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
{ label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
]
const wrapper = createWrapper(items)

View File

@@ -26,7 +26,7 @@ const {
/>
<component
:is="itemComponent"
v-else-if="item.visible !== false"
v-else
:disabled="item.disabled"
:class="
cn(

View File

@@ -341,10 +341,7 @@ function handleReplaceSelected() {
replacedTypes.value = nextReplaced
selectedTypes.value = nextSelected
// replaceNodesInPlace() handles canvas rendering via onNodeAdded(),
// but the modal only updates its own local UI state above.
// Without this call the Errors Tab would still list the replaced nodes
// as missing because executionErrorStore is not aware of the replacement.
// Sync with execution error store so the Errors Tab updates immediately
if (result.length > 0) {
executionErrorStore.removeMissingNodesByType(result)
}

View File

@@ -18,11 +18,10 @@
>
<WorkflowTabs />
<TopbarBadges />
<TopbarSubscribeButton />
</div>
</div>
</template>
<template v-if="showUI && !isBuilderMode" #side-toolbar>
<template v-if="showUI && !appModeStore.isBuilderMode" #side-toolbar>
<SideToolbar />
</template>
<template v-if="showUI" #side-bar-panel>
@@ -32,24 +31,27 @@
<ExtensionSlot v-if="activeSidebarTab" :extension="activeSidebarTab" />
</div>
</template>
<template v-if="showUI && !isBuilderMode" #topmenu>
<template v-if="showUI && !appModeStore.isBuilderMode" #topmenu>
<TopMenuSection />
</template>
<template v-if="showUI" #bottom-panel>
<BottomPanel />
</template>
<template v-if="showUI" #right-side-panel>
<AppBuilder v-if="mode === 'builder:select'" />
<NodePropertiesPanel v-else-if="!isBuilderMode" />
<AppBuilder v-if="appModeStore.mode === 'builder:select'" />
<NodePropertiesPanel v-else-if="!appModeStore.isBuilderMode" />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu
v-if="canvasMenuEnabled && !isBuilderMode"
v-if="canvasMenuEnabled && !appModeStore.isBuilderMode"
class="pointer-events-auto"
/>
<MiniMap
v-if="
comfyAppReady && minimapEnabled && betaMenuEnabled && !isBuilderMode
comfyAppReady &&
minimapEnabled &&
betaMenuEnabled &&
!appModeStore.isBuilderMode
"
class="pointer-events-auto"
/>
@@ -125,10 +127,10 @@ import {
import { useI18n } from 'vue-i18n'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
@@ -141,7 +143,6 @@ import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
@@ -183,7 +184,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -204,7 +205,7 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const { mode, isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()

View File

@@ -1,116 +0,0 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import type { DomWidgetState } from '@/stores/domWidgetStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import DomWidget from './DomWidget.vue'
const mockUpdatePosition = vi.fn()
const mockUpdateClipPath = vi.fn()
const mockCanvasElement = document.createElement('canvas')
const mockCanvasStore = {
canvas: {
graph: {
getNodeById: vi.fn(() => true)
},
ds: {
offset: [0, 0],
scale: 1
},
canvas: mockCanvasElement,
selected_nodes: {}
},
getCanvas: () => ({ canvas: mockCanvasElement }),
linearMode: false
}
vi.mock('@/composables/element/useAbsolutePosition', () => ({
useAbsolutePosition: () => ({
style: reactive<Record<string, string>>({}),
updatePosition: mockUpdatePosition
})
}))
vi.mock('@/composables/element/useDomClipping', () => ({
useDomClipping: () => ({
style: reactive<Record<string, string>>({}),
updateClipPath: mockUpdateClipPath
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(() => false)
})
}))
function createWidgetState(overrideDisabled: boolean): DomWidgetState {
const domWidgetStore = useDomWidgetStore()
const node = createMockLGraphNode({
id: 1,
constructor: {
nodeData: {}
}
})
const widget = {
id: 'dom-widget-id',
name: 'test_widget',
type: 'custom',
value: '',
options: {},
node,
computedDisabled: false
} as unknown as BaseDOMWidget<object | string>
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: createMockLGraphNode({ id: 2 }),
widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget']
})
const state = domWidgetStore.widgetStates.get(widget.id)
if (!state) throw new Error('Expected registered DomWidgetState')
state.zIndex = 2
state.size = [100, 40]
return reactive(state)
}
describe('DomWidget disabled style', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
useDomWidgetStore().clear()
vi.clearAllMocks()
})
it('uses disabled style when promoted override widget is computedDisabled', async () => {
const widgetState = createWidgetState(true)
const wrapper = mount(DomWidget, {
props: {
widgetState
}
})
widgetState.zIndex = 3
await wrapper.vm.$nextTick()
const root = wrapper.get('.dom-widget').element as HTMLElement
expect(root.style.pointerEvents).toBe('none')
expect(root.style.opacity).toBe('0.5')
})
})

View File

@@ -110,17 +110,13 @@ watch(
updateDomClipping()
}
const override = widgetState.positionOverride
const isDisabled = override
? (override.widget.computedDisabled ?? widget.computedDisabled)
: widget.computedDisabled
style.value = {
...positionStyle.value,
...(enableDomClipping.value ? clippingStyle.value : {}),
zIndex: widgetState.zIndex,
pointerEvents: widgetState.readonly || isDisabled ? 'none' : 'auto',
opacity: isDisabled ? 0.5 : 1
pointerEvents:
widgetState.readonly || widget.computedDisabled ? 'none' : 'auto',
opacity: widget.computedDisabled ? 0.5 : 1
}
},
{ deep: true }

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex w-50 flex-col overflow-hidden rounded-2xl bg-base-background border border-border-default"
class="flex w-50 flex-col overflow-hidden rounded-2xl bg-(--base-background) border border-(--border-default)"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div ref="previewWrapperRef" class="origin-top-left scale-50">
@@ -100,7 +100,7 @@ import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphN
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const SCALE_FACTOR = 0.5
const PREVIEW_CONTAINER_PADDING_PX = 24
const PREVIEW_CONTAINER_PADDING_PX = 24 // p-3 top + bottom (12px × 2)
const {
nodeDef,

View File

@@ -28,9 +28,8 @@
/>
<div
v-show="cursorVisible"
ref="cursorEl"
class="pointer-events-none absolute left-0 top-0 rounded-full border border-black/60 shadow-[0_0_0_1px_rgba(255,255,255,0.8)] will-change-transform"
:style="cursorSizeStyle"
class="pointer-events-none absolute left-0 top-0 rounded-full border border-black/60 shadow-[0_0_0_1px_rgba(255,255,255,0.8)]"
:style="cursorStyle"
/>
</div>
</div>
@@ -142,7 +141,7 @@
max="100"
step="1"
class="w-7 appearance-none border-0 bg-transparent text-right text-xs text-node-text-muted outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
@click.stop
@click.prevent
@change="
(e) => {
const val = Math.min(
@@ -282,7 +281,6 @@ const { nodeId } = defineProps<{
const modelValue = defineModel<string>({ default: '' })
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
const cursorEl = useTemplateRef<HTMLElement>('cursorEl')
const controlsEl = useTemplateRef<HTMLDivElement>('controlsEl')
const { width: controlsWidth } = useElementSize(controlsEl)
const compact = computed(
@@ -298,6 +296,8 @@ const {
backgroundColor,
canvasWidth,
canvasHeight,
cursorX,
cursorY,
cursorVisible,
displayBrushSize,
inputImageUrl,
@@ -309,7 +309,7 @@ const {
handlePointerLeave,
handleInputImageLoad,
handleClear
} = usePainter(nodeId, { canvasEl, cursorEl, modelValue })
} = usePainter(nodeId, { canvasEl, modelValue })
const canvasContainerStyle = computed(() => ({
aspectRatio: `${canvasWidth.value} / ${canvasHeight.value}`,
@@ -318,10 +318,16 @@ const canvasContainerStyle = computed(() => ({
: backgroundColor.value
}))
const cursorSizeStyle = computed(() => ({
width: `${displayBrushSize.value}px`,
height: `${displayBrushSize.value}px`
}))
const cursorStyle = computed(() => {
const size = displayBrushSize.value
const x = cursorX.value - size / 2
const y = cursorY.value - size / 2
return {
width: `${size}px`,
height: `${size}px`,
transform: `translate(${x}px, ${y}px)`
}
})
const brushOpacityPercent = computed({
get: () => Math.round(brushOpacity.value * 100),

View File

@@ -20,7 +20,7 @@
class="w-full justify-between text-sm font-light"
variant="textonly"
size="sm"
@click="onToggleDockedJobHistory(close)"
@click="onToggleDockedJobHistory"
>
<span class="flex items-center gap-2">
<i
@@ -79,7 +79,6 @@ import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const emit = defineEmits<{
(e: 'clearHistory'): void
@@ -87,7 +86,6 @@ const emit = defineEmits<{
const { t } = useI18n()
const settingStore = useSettingStore()
const sidebarTabStore = useSidebarTabStore()
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const isQueuePanelV2Enabled = computed(() =>
@@ -100,22 +98,7 @@ const onClearHistoryFromMenu = (close: () => void) => {
emit('clearHistory')
}
const onToggleDockedJobHistory = async (close: () => void) => {
close()
try {
if (isQueuePanelV2Enabled.value) {
await settingStore.setMany({
'Comfy.Queue.QPOV2': false,
'Comfy.Queue.History.Expanded': true
})
return
}
sidebarTabStore.activeSidebarTabId = 'job-history'
await settingStore.set('Comfy.Queue.QPOV2', true)
} catch {
return
}
const onToggleDockedJobHistory = async () => {
await settingStore.set('Comfy.Queue.QPOV2', !isQueuePanelV2Enabled.value)
}
</script>

View File

@@ -23,27 +23,18 @@ vi.mock('@/components/ui/Popover.vue', () => {
return { default: PopoverStub }
})
const mockGetSetting = vi.fn<(key: string) => boolean | undefined>((key) =>
const mockGetSetting = vi.fn((key: string) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const mockSetSetting = vi.fn()
const mockSetMany = vi.fn()
const mockSidebarTabStore = {
activeSidebarTabId: null as string | null
}
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockGetSetting,
set: mockSetSetting,
setMany: mockSetMany
set: mockSetSetting
})
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => mockSidebarTabStore
}))
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
@@ -90,11 +81,6 @@ describe('QueueOverlayHeader', () => {
beforeEach(() => {
popoverCloseSpy.mockClear()
mockSetSetting.mockClear()
mockSetMany.mockClear()
mockSidebarTabStore.activeSidebarTabId = null
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
})
it('renders header title', () => {
@@ -139,7 +125,7 @@ describe('QueueOverlayHeader', () => {
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
it('opens floating queue progress overlay when disabling from the menu', async () => {
it('toggles docked job history setting from the menu', async () => {
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
@@ -147,64 +133,7 @@ describe('QueueOverlayHeader', () => {
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetMany).toHaveBeenCalledTimes(1)
expect(mockSetMany).toHaveBeenCalledWith({
'Comfy.Queue.QPOV2': false,
'Comfy.Queue.History.Expanded': true
})
expect(mockSetSetting).not.toHaveBeenCalled()
expect(mockSidebarTabStore.activeSidebarTabId).toBe(null)
})
it('opens docked job history sidebar when enabling from the menu', async () => {
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
expect(mockSetMany).not.toHaveBeenCalled()
expect(mockSidebarTabStore.activeSidebarTabId).toBe('job-history')
})
it('keeps docked target open even when enabling persistence fails', async () => {
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
mockSetSetting.mockRejectedValueOnce(new Error('persistence failed'))
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
expect(mockSidebarTabStore.activeSidebarTabId).toBe('job-history')
})
it('closes the menu when disabling persistence fails', async () => {
mockSetMany.mockRejectedValueOnce(new Error('persistence failed'))
const wrapper = mountHeader()
const dockedJobHistoryButton = wrapper.get(
'[data-testid="docked-job-history-action"]'
)
await dockedJobHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(mockSetMany).toHaveBeenCalledWith({
'Comfy.Queue.QPOV2': false,
'Comfy.Queue.History.Expanded': true
})
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', false)
})
})

View File

@@ -166,13 +166,13 @@ const queuedAtValue = computed(() =>
: ''
)
const currentJobPriority = computed<number | null>(() => {
const currentQueueIndex = computed<number | null>(() => {
const task = taskForJob.value
return task ? Number(task.job.priority) : null
})
const jobsAhead = computed<number | null>(() => {
const idx = currentJobPriority.value
const idx = currentQueueIndex.value
if (idx == null) return null
const ahead = queueStore.pendingTasks.filter(
(t: TaskItemImpl) => Number(t.job.priority) < idx

View File

@@ -5,7 +5,7 @@
v-for="tab in visibleJobTabs"
:key="tab"
:variant="selectedJobTab === tab ? 'secondary' : 'muted-textonly'"
size="md"
size="sm"
@click="$emit('update:selectedJobTab', tab)"
>
{{ tabLabel(tab) }}

View File

@@ -1,214 +0,0 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const mockIsCloud = { value: false }
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
}
}))
const mockApplyChanges = vi.fn()
const mockIsRestarting = ref(false)
vi.mock('@/workbench/extensions/manager/composables/useApplyChanges', () => ({
useApplyChanges: () => ({
isRestarting: mockIsRestarting,
applyChanges: mockApplyChanges
})
}))
const mockIsPackInstalled = vi.fn(() => false)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
isPackInstalled: mockIsPackInstalled
})
}))
const mockShouldShowManagerButtons = { value: false }
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: mockShouldShowManagerButtons
})
}))
vi.mock('./MissingPackGroupRow.vue', () => ({
default: {
name: 'MissingPackGroupRow',
template: '<div class="pack-row" />',
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
emits: ['locate-node', 'open-manager-info']
}
}))
import MissingNodeCard from './MissingNodeCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
missingNodePacks: {
ossMessage: 'Missing node packs detected. Install them.',
cloudMessage: 'Unsupported node packs detected.',
applyChanges: 'Apply Changes'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
function makePackGroups(count = 2): MissingPackGroup[] {
return Array.from({ length: count }, (_, i) => ({
packId: `pack-${i}`,
nodeTypes: [
{ type: `MissingNode${i}`, nodeId: String(i), isReplaceable: false }
],
isResolving: false
}))
}
function mountCard(
props: Partial<{
showInfoButton: boolean
showNodeIdBadge: boolean
missingPackGroups: MissingPackGroup[]
}> = {}
) {
return mount(MissingNodeCard, {
props: {
showInfoButton: false,
showNodeIdBadge: false,
missingPackGroups: makePackGroups(),
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
DotSpinner: { template: '<span role="status" aria-label="loading" />' }
}
}
})
}
describe('MissingNodeCard', () => {
beforeEach(() => {
mockApplyChanges.mockClear()
mockIsPackInstalled.mockReset()
mockIsPackInstalled.mockReturnValue(false)
mockIsCloud.value = false
mockShouldShowManagerButtons.value = false
mockIsRestarting.value = false
})
describe('Rendering & Props', () => {
it('renders cloud message when isCloud is true', () => {
mockIsCloud.value = true
const wrapper = mountCard()
expect(wrapper.text()).toContain('Unsupported node packs detected')
})
it('renders OSS message when isCloud is false', () => {
const wrapper = mountCard()
expect(wrapper.text()).toContain('Missing node packs detected')
})
it('renders correct number of MissingPackGroupRow components', () => {
const wrapper = mountCard({ missingPackGroups: makePackGroups(3) })
expect(
wrapper.findAllComponents({ name: 'MissingPackGroupRow' })
).toHaveLength(3)
})
it('renders zero rows when missingPackGroups is empty', () => {
const wrapper = mountCard({ missingPackGroups: [] })
expect(
wrapper.findAllComponents({ name: 'MissingPackGroupRow' })
).toHaveLength(0)
})
it('passes props correctly to MissingPackGroupRow children', () => {
const wrapper = mountCard({
showInfoButton: true,
showNodeIdBadge: true
})
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
expect(row.props('showInfoButton')).toBe(true)
expect(row.props('showNodeIdBadge')).toBe(true)
})
})
describe('Apply Changes Section', () => {
it('hides Apply Changes when manager is not enabled', () => {
mockShouldShowManagerButtons.value = false
const wrapper = mountCard()
expect(wrapper.text()).not.toContain('Apply Changes')
})
it('hides Apply Changes when manager enabled but no packs pending', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
const wrapper = mountCard()
expect(wrapper.text()).not.toContain('Apply Changes')
})
it('shows Apply Changes when at least one pack is pending restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
const wrapper = mountCard()
expect(wrapper.text()).toContain('Apply Changes')
})
it('displays spinner during restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockIsRestarting.value = true
const wrapper = mountCard()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
it('disables button during restart', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockIsRestarting.value = true
const wrapper = mountCard()
const btn = wrapper.find('button')
expect(btn.attributes('disabled')).toBeDefined()
})
it('calls applyChanges when Apply Changes button is clicked', async () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
const wrapper = mountCard()
const btn = wrapper.find('button')
await btn.trigger('click')
expect(mockApplyChanges).toHaveBeenCalledOnce()
})
})
describe('Event Handling', () => {
it('emits locateNode when child emits locate-node', async () => {
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
await row.vm.$emit('locate-node', '42')
expect(wrapper.emitted('locateNode')).toBeTruthy()
expect(wrapper.emitted('locateNode')?.[0]).toEqual(['42'])
})
it('emits openManagerInfo when child emits open-manager-info', async () => {
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
await row.vm.$emit('open-manager-info', 'pack-0')
expect(wrapper.emitted('openManagerInfo')).toBeTruthy()
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['pack-0'])
})
})
})

View File

@@ -1,368 +0,0 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const mockInstallAllPacks = vi.fn()
const mockIsInstalling = ref(false)
const mockIsPackInstalled = vi.fn(() => false)
const mockShouldShowManagerButtons = { value: false }
const mockOpenManager = vi.fn()
const mockMissingNodePacks = ref<Array<{ id: string; name: string }>>([])
const mockIsLoading = ref(false)
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/useMissingNodes',
() => ({
useMissingNodes: () => ({
missingNodePacks: mockMissingNodePacks,
isLoading: mockIsLoading
})
})
)
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/usePackInstall',
() => ({
usePackInstall: () => ({
isInstalling: mockIsInstalling,
installAllPacks: mockInstallAllPacks
})
})
)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: () => ({
isPackInstalled: mockIsPackInstalled
})
}))
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: mockShouldShowManagerButtons,
openManager: mockOpenManager
})
}))
vi.mock('@/workbench/extensions/manager/types/comfyManagerTypes', () => ({
ManagerTab: { Missing: 'missing', All: 'all' }
}))
import MissingPackGroupRow from './MissingPackGroupRow.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
loading: 'Loading'
},
rightSidePanel: {
locateNode: 'Locate node on canvas',
missingNodePacks: {
unknownPack: 'Unknown pack',
installNodePack: 'Install node pack',
installing: 'Installing...',
installed: 'Installed',
searchInManager: 'Search in Node Manager',
viewInManager: 'View in Manager',
collapse: 'Collapse',
expand: 'Expand'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
function makeGroup(
overrides: Partial<MissingPackGroup> = {}
): MissingPackGroup {
return {
packId: 'my-pack',
nodeTypes: [
{ type: 'MissingA', nodeId: '10', isReplaceable: false },
{ type: 'MissingB', nodeId: '11', isReplaceable: false }
],
isResolving: false,
...overrides
}
}
function mountRow(
props: Partial<{
group: MissingPackGroup
showInfoButton: boolean
showNodeIdBadge: boolean
}> = {}
) {
return mount(MissingPackGroupRow, {
props: {
group: makeGroup(),
showInfoButton: false,
showNodeIdBadge: false,
...props
},
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' },
DotSpinner: {
template: '<span role="status" aria-label="loading" />'
}
}
}
})
}
describe('MissingPackGroupRow', () => {
beforeEach(() => {
mockInstallAllPacks.mockClear()
mockOpenManager.mockClear()
mockIsPackInstalled.mockReset()
mockIsPackInstalled.mockReturnValue(false)
mockShouldShowManagerButtons.value = false
mockIsInstalling.value = false
mockMissingNodePacks.value = []
mockIsLoading.value = false
})
describe('Basic Rendering', () => {
it('renders pack name from packId', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('my-pack')
})
it('renders "Unknown pack" when packId is null', () => {
const wrapper = mountRow({ group: makeGroup({ packId: null }) })
expect(wrapper.text()).toContain('Unknown pack')
})
it('renders loading text when isResolving is true', () => {
const wrapper = mountRow({ group: makeGroup({ isResolving: true }) })
expect(wrapper.text()).toContain('Loading')
})
it('renders node count', () => {
const wrapper = mountRow()
expect(wrapper.text()).toContain('(2)')
})
it('renders count of 5 for 5 nodeTypes', () => {
const wrapper = mountRow({
group: makeGroup({
nodeTypes: Array.from({ length: 5 }, (_, i) => ({
type: `Node${i}`,
nodeId: String(i),
isReplaceable: false
}))
})
})
expect(wrapper.text()).toContain('(5)')
})
})
describe('Expand / Collapse', () => {
it('starts collapsed', () => {
const wrapper = mountRow()
expect(wrapper.text()).not.toContain('MissingA')
})
it('expands when chevron is clicked', async () => {
const wrapper = mountRow()
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('MissingA')
expect(wrapper.text()).toContain('MissingB')
})
it('collapses when chevron is clicked again', async () => {
const wrapper = mountRow()
await wrapper.get('button[aria-label="Expand"]').trigger('click')
expect(wrapper.text()).toContain('MissingA')
await wrapper.get('button[aria-label="Collapse"]').trigger('click')
expect(wrapper.text()).not.toContain('MissingA')
})
})
describe('Node Type List', () => {
async function expand(wrapper: ReturnType<typeof mountRow>) {
await wrapper.get('button[aria-label="Expand"]').trigger('click')
}
it('renders all nodeTypes when expanded', async () => {
const wrapper = mountRow({
group: makeGroup({
nodeTypes: [
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
{ type: 'NodeC', nodeId: '3', isReplaceable: false }
]
})
})
await expand(wrapper)
expect(wrapper.text()).toContain('NodeA')
expect(wrapper.text()).toContain('NodeB')
expect(wrapper.text()).toContain('NodeC')
})
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await expand(wrapper)
expect(wrapper.text()).toContain('#10')
})
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const wrapper = mountRow({ showNodeIdBadge: false })
await expand(wrapper)
expect(wrapper.text()).not.toContain('#10')
})
it('emits locateNode when Locate button is clicked', async () => {
const wrapper = mountRow({ showNodeIdBadge: true })
await expand(wrapper)
await wrapper
.get('button[aria-label="Locate node on canvas"]')
.trigger('click')
expect(wrapper.emitted('locateNode')).toBeTruthy()
expect(wrapper.emitted('locateNode')?.[0]).toEqual(['10'])
})
it('does not show Locate for nodeType without nodeId', async () => {
const wrapper = mountRow({
group: makeGroup({
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
})
})
await expand(wrapper)
expect(
wrapper.find('button[aria-label="Locate node on canvas"]').exists()
).toBe(false)
})
it('handles mixed nodeTypes with and without nodeId', async () => {
const wrapper = mountRow({
showNodeIdBadge: true,
group: makeGroup({
nodeTypes: [
{ type: 'WithId', nodeId: '100', isReplaceable: false },
{ type: 'WithoutId', isReplaceable: false } as never
]
})
})
await expand(wrapper)
expect(wrapper.text()).toContain('WithId')
expect(wrapper.text()).toContain('WithoutId')
expect(
wrapper.findAll('button[aria-label="Locate node on canvas"]')
).toHaveLength(1)
})
})
describe('Manager Integration', () => {
it('hides install UI when shouldShowManagerButtons is false', () => {
mockShouldShowManagerButtons.value = false
const wrapper = mountRow()
expect(wrapper.text()).not.toContain('Install node pack')
})
it('hides install UI when packId is null', () => {
mockShouldShowManagerButtons.value = true
const wrapper = mountRow({ group: makeGroup({ packId: null }) })
expect(wrapper.text()).not.toContain('Install node pack')
})
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = []
const wrapper = mountRow()
expect(wrapper.text()).toContain('Search in Node Manager')
})
it('shows "Installed" state when pack is installed', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(true)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
expect(wrapper.text()).toContain('Installed')
})
it('shows spinner when installing', () => {
mockShouldShowManagerButtons.value = true
mockIsInstalling.value = true
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
it('shows install button when not installed and pack found', () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
expect(wrapper.text()).toContain('Install node pack')
})
it('calls installAllPacks when Install button is clicked', async () => {
mockShouldShowManagerButtons.value = true
mockIsPackInstalled.mockReturnValue(false)
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
const wrapper = mountRow()
await wrapper.get('button:not([aria-label])').trigger('click')
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
})
it('shows loading spinner when registry is loading', () => {
mockShouldShowManagerButtons.value = true
mockIsLoading.value = true
const wrapper = mountRow()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
})
describe('Info Button', () => {
it('shows Info button when showInfoButton true and packId not null', () => {
const wrapper = mountRow({ showInfoButton: true })
expect(
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(true)
})
it('hides Info button when showInfoButton is false', () => {
const wrapper = mountRow({ showInfoButton: false })
expect(
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(false)
})
it('hides Info button when packId is null', () => {
const wrapper = mountRow({
showInfoButton: true,
group: makeGroup({ packId: null })
})
expect(
wrapper.find('button[aria-label="View in Manager"]').exists()
).toBe(false)
})
it('emits openManagerInfo when Info button is clicked', async () => {
const wrapper = mountRow({ showInfoButton: true })
await wrapper.get('button[aria-label="View in Manager"]').trigger('click')
expect(wrapper.emitted('openManagerInfo')).toBeTruthy()
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['my-pack'])
})
})
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
const wrapper = mountRow({ group: makeGroup({ nodeTypes: [] }) })
expect(wrapper.text()).toContain('(0)')
})
})
})

View File

@@ -102,7 +102,9 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import type { MissingNodeType } from '@/types/comfy'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const props = defineProps<{
group: SwapNodeGroup
@@ -111,10 +113,11 @@ const props = defineProps<{
const emit = defineEmits<{
'locate-node': [nodeId: string]
replace: [group: SwapNodeGroup]
}>()
const { t } = useI18n()
const { replaceNodesInPlace } = useNodeReplacement()
const executionErrorStore = useExecutionErrorStore()
const expanded = ref(false)
@@ -139,6 +142,9 @@ function handleLocateNode(nodeType: MissingNodeType) {
}
function handleReplaceNode() {
emit('replace', props.group)
const replaced = replaceNodesInPlace(props.group.nodeTypes)
if (replaced.length > 0) {
executionErrorStore.removeMissingNodesByType([props.group.type])
}
}
</script>

View File

@@ -16,15 +16,14 @@
:group="group"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locate-node', $event)"
@replace="emit('replace', $event)"
/>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import SwapNodeGroupRow from '@/platform/nodeReplacement/components/SwapNodeGroupRow.vue'
import type { SwapNodeGroup } from './useErrorGroups'
import SwapNodeGroupRow from './SwapNodeGroupRow.vue'
const { t } = useI18n()
@@ -35,6 +34,5 @@ const { swapNodeGroups, showNodeIdBadge } = defineProps<{
const emit = defineEmits<{
'locate-node': [nodeId: string]
replace: [group: SwapNodeGroup]
}>()
</script>

View File

@@ -109,7 +109,6 @@
:swap-node-groups="swapNodeGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
<!-- Execution Errors -->
@@ -180,14 +179,14 @@ import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import SwapNodesCard from './SwapNodesCard.vue'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
@@ -200,7 +199,8 @@ const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
const { missingNodePacks } = useMissingNodes()
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
usePackInstall(() => missingNodePacks.value)
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const { replaceNodesInPlace } = useNodeReplacement()
const executionErrorStore = useExecutionErrorStore()
const searchQuery = ref('')
@@ -264,12 +264,12 @@ function handleOpenManagerInfo(packId: string) {
}
}
function handleReplaceGroup(group: SwapNodeGroup) {
replaceGroup(group)
}
function handleReplaceAll() {
replaceAllGroups(swapNodeGroups.value)
const allNodeTypes = swapNodeGroups.value.flatMap((g) => g.nodeTypes)
const replaced = replaceNodesInPlace(allNodeTypes)
if (replaced.length > 0) {
executionErrorStore.removeMissingNodesByType(replaced)
}
}
function handleEnterSubgraph(nodeId: string) {

View File

@@ -1,187 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getExecutionIdByNode: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/stores/comfyRegistryStore', () => ({
useComfyRegistryStore: () => ({
inferPackFromNodeName: vi.fn()
})
}))
vi.mock('@/utils/nodeTitleUtil', () => ({
resolveNodeDisplayName: vi.fn(() => '')
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => false)
}))
vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
type: string,
opts: {
nodeId?: string
isReplaceable?: boolean
replacement?: { new_node_id: string }
} = {}
): MissingNodeType {
return {
type,
nodeId: opts.nodeId ?? '1',
isReplaceable: opts.isReplaceable ?? false,
replacement: opts.replacement
? {
old_node_id: type,
new_node_id: opts.replacement.new_node_id,
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
: undefined
}
}
describe('swapNodeGroups computed', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
function getSwapNodeGroups(nodeTypes: MissingNodeType[]) {
const store = useExecutionErrorStore()
store.surfaceMissingNodes(nodeTypes)
const searchQuery = ref('')
const t = (key: string) => key
const { swapNodeGroups } = useErrorGroups(searchQuery, t)
return swapNodeGroups
}
it('returns empty array when no missing nodes', () => {
const swap = getSwapNodeGroups([])
expect(swap.value).toEqual([])
})
it('returns empty array when no nodes are replaceable', () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('NodeA', { isReplaceable: false }),
makeMissingNodeType('NodeB', { isReplaceable: false })
])
expect(swap.value).toEqual([])
})
it('groups replaceable nodes by type', async () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('OldNode', {
nodeId: '1',
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('OldNode', {
nodeId: '2',
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
})
])
await nextTick()
expect(swap.value).toHaveLength(1)
expect(swap.value[0].type).toBe('OldNode')
expect(swap.value[0].newNodeId).toBe('NewNode')
expect(swap.value[0].nodeTypes).toHaveLength(2)
})
it('creates separate groups for different types', async () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('TypeA', {
nodeId: '1',
isReplaceable: true,
replacement: { new_node_id: 'NewA' }
}),
makeMissingNodeType('TypeB', {
nodeId: '2',
isReplaceable: true,
replacement: { new_node_id: 'NewB' }
})
])
await nextTick()
expect(swap.value).toHaveLength(2)
expect(swap.value.map((g) => g.type)).toEqual(['TypeA', 'TypeB'])
})
it('sorts groups alphabetically by type', async () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('Zebra', {
nodeId: '1',
isReplaceable: true,
replacement: { new_node_id: 'NewZ' }
}),
makeMissingNodeType('Alpha', {
nodeId: '2',
isReplaceable: true,
replacement: { new_node_id: 'NewA' }
})
])
await nextTick()
expect(swap.value[0].type).toBe('Alpha')
expect(swap.value[1].type).toBe('Zebra')
})
it('excludes string nodeType entries', async () => {
const swap = getSwapNodeGroups([
'StringGroupNode' as unknown as MissingNodeType,
makeMissingNodeType('OldNode', {
nodeId: '1',
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
})
])
await nextTick()
expect(swap.value).toHaveLength(1)
expect(swap.value[0].type).toBe('OldNode')
})
it('sets newNodeId to undefined when replacement is missing', async () => {
const swap = getSwapNodeGroups([
makeMissingNodeType('OldNode', {
nodeId: '1',
isReplaceable: true
// no replacement
})
])
await nextTick()
expect(swap.value).toHaveLength(1)
expect(swap.value[0].newNodeId).toBeUndefined()
})
})

View File

@@ -1,439 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getExecutionIdByNode: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/i18n', () => ({
st: vi.fn((_key: string, fallback: string) => fallback)
}))
vi.mock('@/stores/comfyRegistryStore', () => ({
useComfyRegistryStore: () => ({
inferPackFromNodeName: vi.fn()
})
}))
vi.mock('@/utils/nodeTitleUtil', () => ({
resolveNodeDisplayName: vi.fn(() => '')
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => false)
}))
vi.mock('@/utils/executableGroupNodeDto', () => ({
isGroupNode: vi.fn(() => false)
}))
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useErrorGroups } from './useErrorGroups'
function makeMissingNodeType(
type: string,
opts: {
nodeId?: string
isReplaceable?: boolean
cnrId?: string
replacement?: { new_node_id: string }
} = {}
): MissingNodeType {
return {
type,
nodeId: opts.nodeId ?? '1',
isReplaceable: opts.isReplaceable ?? false,
cnrId: opts.cnrId,
replacement: opts.replacement
? {
old_node_id: type,
new_node_id: opts.replacement.new_node_id,
old_widget_ids: null,
input_mapping: null,
output_mapping: null
}
: undefined
}
}
function createErrorGroups() {
const store = useExecutionErrorStore()
const searchQuery = ref('')
const t = (key: string) => key
const groups = useErrorGroups(searchQuery, t)
return { store, searchQuery, groups }
}
describe('useErrorGroups', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('missingPackGroups', () => {
it('returns empty array when no missing nodes', () => {
const { groups } = createErrorGroups()
expect(groups.missingPackGroups.value).toEqual([])
})
it('groups non-replaceable nodes by cnrId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' }),
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(2)
const pack1 = groups.missingPackGroups.value.find(
(g) => g.packId === 'pack-1'
)
expect(pack1?.nodeTypes).toHaveLength(2)
const pack2 = groups.missingPackGroups.value.find(
(g) => g.packId === 'pack-2'
)
expect(pack2?.nodeTypes).toHaveLength(1)
})
it('excludes replaceable nodes from missingPackGroups', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('MissingNode', {
nodeId: '2',
cnrId: 'some-pack'
})
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(1)
expect(groups.missingPackGroups.value[0].packId).toBe('some-pack')
})
it('groups nodes without cnrId under null packId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('UnknownNode', { nodeId: '1' }),
makeMissingNodeType('AnotherUnknown', { nodeId: '2' })
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(1)
expect(groups.missingPackGroups.value[0].packId).toBeNull()
expect(groups.missingPackGroups.value[0].nodeTypes).toHaveLength(2)
})
it('sorts groups alphabetically with null packId last', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'zebra-pack' }),
makeMissingNodeType('NodeB', { nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'alpha-pack', nodeId: '3' })
])
await nextTick()
const packIds = groups.missingPackGroups.value.map((g) => g.packId)
expect(packIds).toEqual(['alpha-pack', 'zebra-pack', null])
})
it('sorts nodeTypes within each group alphabetically by type then nodeId', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '3' }),
makeMissingNodeType('NodeA', { cnrId: 'pack-1', nodeId: '1' })
])
await nextTick()
const group = groups.missingPackGroups.value[0]
const types = group.nodeTypes.map((n) =>
typeof n === 'string' ? n : `${n.type}:${n.nodeId}`
)
expect(types).toEqual(['NodeA:1', 'NodeA:3', 'NodeB:2'])
})
it('handles string nodeType entries', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
'StringGroupNode' as unknown as MissingNodeType
])
await nextTick()
expect(groups.missingPackGroups.value).toHaveLength(1)
expect(groups.missingPackGroups.value[0].packId).toBeNull()
})
})
describe('allErrorGroups', () => {
it('returns empty array when no errors', () => {
const { groups } = createErrorGroups()
expect(groups.allErrorGroups.value).toEqual([])
})
it('includes missing_node group when missing nodes exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
const missingGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingGroup).toBeDefined()
})
it('includes swap_nodes group when replaceable nodes exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
})
])
await nextTick()
const swapGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'swap_nodes'
)
expect(swapGroup).toBeDefined()
})
it('includes both swap_nodes and missing_node when both exist', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('MissingNode', {
nodeId: '2',
cnrId: 'some-pack'
})
])
await nextTick()
const types = groups.allErrorGroups.value.map((g) => g.type)
expect(types).toContain('swap_nodes')
expect(types).toContain('missing_node')
})
it('swap_nodes has lower priority than missing_node', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('OldNode', {
isReplaceable: true,
replacement: { new_node_id: 'NewNode' }
}),
makeMissingNodeType('MissingNode', {
nodeId: '2',
cnrId: 'some-pack'
})
])
await nextTick()
const swapIdx = groups.allErrorGroups.value.findIndex(
(g) => g.type === 'swap_nodes'
)
const missingIdx = groups.allErrorGroups.value.findIndex(
(g) => g.type === 'missing_node'
)
expect(swapIdx).toBeLessThan(missingIdx)
})
it('includes execution error groups from node errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'value_not_valid',
message: 'Value not valid',
details: 'some detail'
}
]
}
}
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
})
it('includes execution error from runtime errors', async () => {
const { store, groups } = createErrorGroups()
store.lastExecutionError = {
prompt_id: 'test-prompt',
timestamp: Date.now(),
node_id: 5,
node_type: 'KSampler',
executed: [],
exception_type: 'RuntimeError',
exception_message: 'CUDA out of memory',
traceback: ['line 1', 'line 2'],
current_inputs: {},
current_outputs: {}
}
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
})
it('includes prompt error when present', async () => {
const { store, groups } = createErrorGroups()
store.lastPromptError = {
type: 'prompt_no_outputs',
message: 'No outputs',
details: ''
}
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution' && g.title === 'No outputs'
)
expect(promptGroup).toBeDefined()
})
})
describe('filteredGroups', () => {
it('returns all groups when search query is empty', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
await nextTick()
expect(groups.filteredGroups.value.length).toBeGreaterThan(0)
})
it('filters groups based on search query', async () => {
const { store, groups, searchQuery } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'value_error',
message: 'Value error in sampler',
details: ''
}
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{
type: 'file_not_found',
message: 'File not found',
details: ''
}
]
}
}
await nextTick()
searchQuery.value = 'sampler'
await nextTick()
const executionGroups = groups.filteredGroups.value.filter(
(g) => g.type === 'execution'
)
for (const group of executionGroups) {
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(c) =>
c.title.toLowerCase().includes('sampler') ||
c.errors.some((e) => e.message.toLowerCase().includes('sampler'))
)
expect(hasMatch).toBe(true)
}
})
})
describe('groupedErrorMessages', () => {
it('returns empty array when no errors', () => {
const { groups } = createErrorGroups()
expect(groups.groupedErrorMessages.value).toEqual([])
})
it('collects unique error messages from node errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{ type: 'err_a', message: 'Error A', details: '' },
{ type: 'err_b', message: 'Error B', details: '' }
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [{ type: 'err_a', message: 'Error A', details: '' }]
}
}
await nextTick()
const messages = groups.groupedErrorMessages.value
expect(messages).toContain('Error A')
expect(messages).toContain('Error B')
// Deduplication: Error A appears twice but should only be listed once
expect(messages.filter((m) => m === 'Error A')).toHaveLength(1)
})
it('includes missing node group title as message', async () => {
const { store, groups } = createErrorGroups()
store.setMissingNodeTypes([
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
])
await nextTick()
expect(groups.groupedErrorMessages.value.length).toBeGreaterThan(0)
})
})
describe('collapseState', () => {
it('returns a reactive object', () => {
const { groups } = createErrorGroups()
expect(groups.collapseState).toBeDefined()
expect(typeof groups.collapseState).toBe('object')
})
})
})

View File

@@ -11,7 +11,6 @@ const {
enableEmptyState,
tooltip,
size = 'default',
tooltipDelay = 1000,
class: className
} = defineProps<{
disabled?: boolean
@@ -19,7 +18,6 @@ const {
enableEmptyState?: boolean
tooltip?: string
size?: 'default' | 'lg'
tooltipDelay?: number
class?: string
}>()
@@ -29,7 +27,7 @@ const isExpanded = computed(() => !isCollapse.value && !disabled)
const tooltipConfig = computed(() => {
if (!tooltip) return undefined
return { value: tooltip, showDelay: tooltipDelay }
return { value: tooltip, showDelay: 1000 }
})
</script>

View File

@@ -34,7 +34,6 @@ const {
node,
isDraggable = false,
hiddenFavoriteIndicator = false,
hiddenWidgetActions = false,
showNodeName = false,
parents = [],
isShownOnParents = false
@@ -43,7 +42,6 @@ const {
node: LGraphNode
isDraggable?: boolean
hiddenFavoriteIndicator?: boolean
hiddenWidgetActions?: boolean
showNodeName?: boolean
parents?: SubgraphNode[]
isShownOnParents?: boolean
@@ -172,10 +170,7 @@ const displayLabel = customRef((track, trigger) => {
>
{{ sourceNodeName }}
</span>
<div
v-if="!hiddenWidgetActions"
class="flex items-center gap-1 shrink-0 pointer-events-auto"
>
<div class="flex items-center gap-1 shrink-0 pointer-events-auto">
<WidgetActions
v-model:label="displayLabel"
:widget="widget"

View File

@@ -50,8 +50,7 @@ describe('NodeSearchCategorySidebar', () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
essentials_category: 'basic',
python_module: 'comfy_essentials'
essentials_category: 'basic'
})
])
await nextTick()
@@ -59,13 +58,9 @@ describe('NodeSearchCategorySidebar', () => {
const wrapper = await createWrapper()
expect(wrapper.text()).toContain('Most relevant')
expect(wrapper.text()).toContain('Recents')
expect(wrapper.text()).toContain('Favorites')
expect(wrapper.text()).toContain('Essentials')
expect(wrapper.text()).toContain('Blueprints')
expect(wrapper.text()).toContain('Partner')
expect(wrapper.text()).toContain('Comfy')
expect(wrapper.text()).toContain('Extensions')
expect(wrapper.text()).toContain('Custom')
})
it('should mark the selected preset category as selected', async () => {

View File

@@ -53,6 +53,7 @@ import NodeSearchCategoryTreeNode, {
CATEGORY_UNSELECTED_CLASS
} from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import type { CategoryNode } from '@/components/searchbox/v2/NodeSearchCategoryTreeNode.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
@@ -64,11 +65,11 @@ const selectedCategory = defineModel<string>('selectedCategory', {
})
const { t } = useI18n()
const { flags } = useFeatureFlags()
const nodeDefStore = useNodeDefStore()
const topCategories = computed(() => [
{ id: 'most-relevant', label: t('g.mostRelevant') },
{ id: 'recents', label: t('g.recents') },
{ id: 'favorites', label: t('g.favorites') }
])
@@ -80,18 +81,10 @@ const hasEssentialNodes = computed(() =>
const sourceCategories = computed(() => {
const categories = []
if (hasEssentialNodes.value) {
if (flags.nodeLibraryEssentialsEnabled && hasEssentialNodes.value) {
categories.push({ id: 'essentials', label: t('g.essentials') })
}
categories.push(
{
id: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
},
{ id: 'partner', label: t('g.partner') },
{ id: 'comfy', label: t('g.comfy') },
{ id: 'extensions', label: t('g.extensions') }
)
categories.push({ id: 'custom', label: t('g.custom') })
return categories
})

View File

@@ -132,7 +132,7 @@ describe('NodeSearchContent', () => {
expect(wrapper.text()).toContain('No results')
})
it('should show only CustomNodes when Extensions is selected', async () => {
it('should show only non-Core nodes when Custom is selected', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'CoreNode',
@@ -155,7 +155,7 @@ describe('NodeSearchContent', () => {
).toBe(NodeSourceType.CustomNodes)
const wrapper = await createWrapper()
await wrapper.find('[data-testid="category-extensions"]').trigger('click')
await wrapper.find('[data-testid="category-custom"]').trigger('click')
await nextTick()
const items = getNodeItems(wrapper)

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