Merge branch 'main' into allow-copy-paste-on-minimap

This commit is contained in:
Christian Byrne
2025-10-18 12:27:06 -07:00
committed by GitHub
65 changed files with 1376 additions and 243 deletions

View File

@@ -95,41 +95,61 @@ jobs:
echo "skip=true" >> $GITHUB_OUTPUT
echo "::warning::Backport PRs already exist for PR #${PR_NUMBER}, skipping to avoid duplicates"
- name: Extract version labels
- name: Collect backport targets
if: steps.check-existing.outputs.skip != 'true'
id: versions
id: targets
run: |
# Extract version labels (e.g., "1.24", "1.22")
VERSIONS=""
TARGETS=()
declare -A SEEN=()
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# For manual triggers, get labels from the PR
LABELS=$(gh pr view ${{ inputs.pr_number }} --json labels | jq -r '.labels[].name')
else
# For automatic triggers, extract from PR event
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
LABELS=$(echo "$LABELS" | jq -r '.[].name')
fi
for label in $LABELS; do
# Match version labels like "1.24" (major.minor only)
if [[ "$label" =~ ^[0-9]+\.[0-9]+$ ]]; then
# Validate the branch exists before adding to list
if git ls-remote --exit-code origin "core/${label}" >/dev/null 2>&1; then
VERSIONS="${VERSIONS}${label} "
else
echo "::warning::Label '${label}' found but branch 'core/${label}' does not exist"
fi
fi
done
if [ -z "$VERSIONS" ]; then
echo "::error::No version labels found (e.g., 1.24, 1.22)"
add_target() {
local label="$1"
local target="$2"
if [ -z "$target" ]; then
return
fi
target=$(echo "$target" | xargs)
if [ -z "$target" ] || [ -n "${SEEN[$target]}" ]; then
return
fi
if git ls-remote --exit-code origin "$target" >/dev/null 2>&1; then
TARGETS+=("$target")
SEEN["$target"]=1
else
echo "::warning::Label '${label}' references missing branch '${target}'"
fi
}
while IFS= read -r label; do
[ -z "$label" ] && continue
if [[ "$label" =~ ^branch:(.+)$ ]]; then
add_target "$label" "${BASH_REMATCH[1]}"
elif [[ "$label" =~ ^backport:(.+)$ ]]; then
add_target "$label" "${BASH_REMATCH[1]}"
elif [[ "$label" =~ ^[0-9]+\.[0-9]+$ ]]; then
add_target "$label" "core/${label}"
fi
done <<< "$LABELS"
if [ "${#TARGETS[@]}" -eq 0 ]; then
echo "::error::No backport targets found (use labels like '1.24' or 'branch:release/hotfix')"
exit 1
fi
echo "versions=${VERSIONS}" >> $GITHUB_OUTPUT
echo "Found version labels: ${VERSIONS}"
echo "targets=${TARGETS[*]}" >> $GITHUB_OUTPUT
echo "Found backport targets: ${TARGETS[*]}"
- name: Backport commits
if: steps.check-existing.outputs.skip != 'true'
@@ -150,16 +170,17 @@ jobs:
MERGE_COMMIT="${{ github.event.pull_request.merge_commit_sha }}"
fi
for version in ${{ steps.versions.outputs.versions }}; do
echo "::group::Backporting to core/${version}"
for target in ${{ steps.targets.outputs.targets }}; do
TARGET_BRANCH="${target}"
SAFE_TARGET=$(echo "$TARGET_BRANCH" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
TARGET_BRANCH="core/${version}"
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${version}"
echo "::group::Backporting to ${TARGET_BRANCH}"
# Fetch target branch (fail if doesn't exist)
if ! git fetch origin "${TARGET_BRANCH}"; then
echo "::error::Target branch ${TARGET_BRANCH} does not exist"
FAILED="${FAILED}${version}:branch-missing "
FAILED="${FAILED}${TARGET_BRANCH}:branch-missing "
echo "::endgroup::"
continue
fi
@@ -170,7 +191,7 @@ jobs:
# Try cherry-pick
if git cherry-pick "${MERGE_COMMIT}"; then
git push origin "${BACKPORT_BRANCH}"
SUCCESS="${SUCCESS}${version}:${BACKPORT_BRANCH} "
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
# Return to main (keep the branch, we need it for PR)
git checkout main
@@ -180,7 +201,7 @@ jobs:
git cherry-pick --abort
echo "::error::Cherry-pick failed due to conflicts"
FAILED="${FAILED}${version}:conflicts:${CONFLICTS} "
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
# Clean up the failed branch
git checkout main
@@ -214,13 +235,13 @@ jobs:
fi
for backport in ${{ steps.backport.outputs.success }}; do
IFS=':' read -r version branch <<< "${backport}"
IFS=':' read -r target branch <<< "${backport}"
if PR_URL=$(gh pr create \
--base "core/${version}" \
--base "${target}" \
--head "${branch}" \
--title "[backport ${version}] ${PR_TITLE}" \
--body "Backport of #${PR_NUMBER} to \`core/${version}\`"$'\n\n'"Automatically created by backport workflow." \
--title "[backport ${target}] ${PR_TITLE}" \
--body "Backport of #${PR_NUMBER} to \`${target}\`"$'\n\n'"Automatically created by backport workflow." \
--label "backport" 2>&1); then
# Extract PR number from URL
@@ -230,9 +251,9 @@ jobs:
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Successfully backported to #${PR_NUM}"
fi
else
echo "::error::Failed to create PR for ${version}: ${PR_URL}"
echo "::error::Failed to create PR for ${target}: ${PR_URL}"
# Still try to comment on the original PR about the failure
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport branch created but PR creation failed for \`core/${version}\`. Please create the PR manually from branch \`${branch}\`"
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport branch created but PR creation failed for \`${target}\`. Please create the PR manually from branch \`${branch}\`"
fi
done
@@ -253,16 +274,16 @@ jobs:
fi
for failure in ${{ steps.backport.outputs.failed }}; do
IFS=':' read -r version reason conflicts <<< "${failure}"
IFS=':' read -r target reason conflicts <<< "${failure}"
if [ "${reason}" = "branch-missing" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`core/${version}\` does not exist"
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
elif [ "${reason}" = "conflicts" ]; then
# Convert comma-separated conflicts back to newlines for display
CONFLICTS_LIST=$(echo "${conflicts}" | tr ',' '\n' | sed 's/^/- /')
COMMENT_BODY="@${PR_AUTHOR} Backport to \`core/${version}\` failed: Merge conflicts detected."$'\n\n'"Please manually cherry-pick commit \`${MERGE_COMMIT}\` to the \`core/${version}\` branch."$'\n\n'"<details><summary>Conflicting files</summary>"$'\n\n'"${CONFLICTS_LIST}"$'\n\n'"</details>"
COMMENT_BODY="@${PR_AUTHOR} Backport to \`${target}\` failed: Merge conflicts detected."$'\n\n'"Please manually cherry-pick commit \`${MERGE_COMMIT}\` to the \`${target}\` branch."$'\n\n'"<details><summary>Conflicting files</summary>"$'\n\n'"${CONFLICTS_LIST}"$'\n\n'"</details>"
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
fi
done

View File

@@ -44,6 +44,7 @@ jobs:
contents: read
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
ENABLE_MINIFY: 'true'
steps:
- name: Validate inputs
env:

View File

@@ -55,6 +55,7 @@ jobs:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
ENABLE_MINIFY: 'true'
USE_PROD_CONFIG: 'true'
run: |
pnpm install --frozen-lockfile

View File

@@ -44,6 +44,7 @@ jobs:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
ENABLE_MINIFY: 'true'
USE_PROD_CONFIG: 'true'
run: |
pnpm install --frozen-lockfile

52
.github/workflows/size-data.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: size data
on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
jobs:
collect:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4.1.0
with:
version: 10
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Build project
run: pnpm build
- name: Collect size data
run: node scripts/size-collect.js
- name: Save PR number & base branch
if: ${{ github.event_name == 'pull_request' }}
run: |
echo ${{ github.event.number }} > ./temp/size/number.txt
echo ${{ github.base_ref }} > ./temp/size/base.txt
- name: Upload size data
uses: actions/upload-artifact@v4
with:
name: size-data
path: temp/size

104
.github/workflows/size-report.yml vendored Normal file
View File

@@ -0,0 +1,104 @@
name: size report
on:
workflow_run:
workflows: ['size data']
types:
- completed
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to report on'
required: true
type: number
run_id:
description: 'Size data workflow run ID'
required: true
type: string
permissions:
contents: read
pull-requests: write
issues: write
jobs:
size-report:
runs-on: ubuntu-latest
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(
(github.event_name == 'workflow_run' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success') ||
github.event_name == 'workflow_dispatch'
)
steps:
- uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4.1.0
with:
version: 10
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Download size data
uses: dawidd6/action-download-artifact@v11
with:
name: size-data
run_id: ${{ github.event_name == 'workflow_dispatch' && inputs.run_id || github.event.workflow_run.id }}
path: temp/size
- name: Set PR number
id: pr-number
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "content=${{ inputs.pr_number }}" >> $GITHUB_OUTPUT
else
echo "content=$(cat temp/size/number.txt)" >> $GITHUB_OUTPUT
fi
- name: Set base branch
id: pr-base
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "content=main" >> $GITHUB_OUTPUT
else
echo "content=$(cat temp/size/base.txt)" >> $GITHUB_OUTPUT
fi
- name: Download previous size data
uses: dawidd6/action-download-artifact@v11
with:
branch: ${{ steps.pr-base.outputs.content }}
workflow: size-data.yml
event: push
name: size-data
path: temp/size-prev
if_no_artifact_found: warn
- name: Generate size report
run: node scripts/size-report.js > size-report.md
- name: Read size report
id: size-report
uses: juliangruber/read-file-action@v1
with:
path: ./size-report.md
- name: Create or update PR comment
uses: actions-cool/maintain-one-comment@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
number: ${{ steps.pr-number.outputs.content }}
body: |
${{ steps.size-report.outputs.content }}
<!-- COMFYUI_FRONTEND_SIZE -->
body-include: '<!-- COMFYUI_FRONTEND_SIZE -->'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -0,0 +1,28 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Record Audio Node', () => {
test('should add a record audio node and take a screenshot', async ({
comfyPage
}) => {
// Open the search box by double clicking on the canvas
await comfyPage.doubleClickCanvas()
await expect(comfyPage.searchBox.input).toHaveCount(1)
// Search for and add the RecordAudio node
await comfyPage.searchBox.fillAndSelectFirstNode('RecordAudio')
await comfyPage.nextFrame()
// Verify the RecordAudio node was added
const recordAudioNodes = await comfyPage.getNodeRefsByType('RecordAudio')
expect(recordAudioNodes.length).toBe(1)
// Take a screenshot of the canvas with the RecordAudio node
await expect(comfyPage.canvas).toHaveScreenshot('record_audio_node.png')
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 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: 64 KiB

After

Width:  |  Height:  |  Size: 64 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: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 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: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -12,21 +12,26 @@ test.describe('Vue Node Bypass', () => {
await comfyPage.vueNodes.waitForNodes()
})
test('should allow toggling bypass on a selected node with hotkey', async ({
comfyPage
}) => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
test.fixme(
'should allow toggling bypass on a selected node with hotkey',
async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-bypassed-state.png'
)
const checkpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await comfyPage.page.mouse.click(400, 300)
await comfyPage.page.waitForTimeout(128)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-bypassed-state.png'
)
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
})
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
}
)
test('should allow toggling bypass on multiple selected nodes with hotkey', async ({
comfyPage

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -254,5 +254,17 @@ export default defineConfig([
rules: {
'no-console': 'off'
}
},
{
files: ['scripts/**/*.js'],
languageOptions: {
globals: {
...globals.node
}
},
rules: {
'@typescript-eslint/no-floating-promises': 'off',
'no-console': 'off'
}
}
])

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.30.0",
"version": "1.30.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -13,6 +13,8 @@
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:desktop": "nx dev @comfyorg/desktop-ui",
"dev:electron": "nx serve --config vite.electron.config.mts",
@@ -86,9 +88,12 @@
"jsdom": "catalog:",
"knip": "catalog:",
"lint-staged": "catalog:",
"markdown-table": "catalog:",
"nx": "catalog:",
"picocolors": "catalog:",
"postcss-html": "catalog:",
"prettier": "catalog:",
"pretty-bytes": "catalog:",
"rollup-plugin-visualizer": "catalog:",
"storybook": "catalog:",
"stylelint": "catalog:",

View File

@@ -159,6 +159,7 @@
--backdrop: var(--color-white);
--button-hover-surface: var(--color-gray-200);
--button-active-surface: var(--color-gray-400);
--button-icon: var(--color-gray-600);
--dialog-surface: var(--color-neutral-200);
--interface-menu-component-surface-hovered: var(--color-gray-200);
--interface-menu-component-surface-selected: var(--color-gray-400);
@@ -209,6 +210,7 @@
--button-surface-contrast: var(--color-pure-white);
--button-hover-surface: var(--color-charcoal-600);
--button-active-surface: var(--color-charcoal-600);
--button-icon: var(--color-gray-800);
--dialog-surface: var(--color-neutral-700);
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
--interface-menu-component-surface-selected: var(--color-charcoal-300);
@@ -250,8 +252,9 @@
@theme inline {
--color-backdrop: var(--backdrop);
--color-button-hover-surface: var(--button-hover-surface);
--color-button-active-surface: var(--button-active-surface);
--color-button-hover-surface: var(--button-hover-surface);
--color-button-icon: var(--button-icon);
--color-button-surface: var(--button-surface);
--color-button-surface-contrast: var(--button-surface-contrast);
--color-dialog-surface: var(--dialog-surface);

69
pnpm-lock.yaml generated
View File

@@ -183,9 +183,15 @@ catalogs:
lint-staged:
specifier: ^15.2.7
version: 15.2.7
markdown-table:
specifier: ^3.0.4
version: 3.0.4
nx:
specifier: 21.4.1
version: 21.4.1
picocolors:
specifier: ^1.1.1
version: 1.1.1
pinia:
specifier: ^2.1.7
version: 2.2.2
@@ -195,6 +201,9 @@ catalogs:
prettier:
specifier: ^3.6.2
version: 3.6.2
pretty-bytes:
specifier: ^7.1.0
version: 7.1.0
primeicons:
specifier: ^7.0.0
version: 7.0.0
@@ -254,7 +263,7 @@ catalogs:
version: 3.5.13
vue-component-type-helpers:
specifier: ^3.0.7
version: 3.1.0
version: 3.1.1
vue-eslint-parser:
specifier: ^10.2.0
version: 10.2.0
@@ -585,15 +594,24 @@ importers:
lint-staged:
specifier: 'catalog:'
version: 15.2.7
markdown-table:
specifier: 'catalog:'
version: 3.0.4
nx:
specifier: 'catalog:'
version: 21.4.1
picocolors:
specifier: 'catalog:'
version: 1.1.1
postcss-html:
specifier: 'catalog:'
version: 1.8.0
prettier:
specifier: 'catalog:'
version: 3.6.2
pretty-bytes:
specifier: 'catalog:'
version: 7.1.0
rollup-plugin-visualizer:
specifier: 'catalog:'
version: 6.0.4(rollup@4.22.4)
@@ -647,7 +665,7 @@ importers:
version: 3.2.4(@types/debug@4.1.12)(@types/node@20.14.10)(@vitest/ui@3.2.4)(happy-dom@15.11.0)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.39.2)
vue-component-type-helpers:
specifier: 'catalog:'
version: 3.1.0
version: 3.1.1
vue-eslint-parser:
specifier: 'catalog:'
version: 10.2.0(eslint@9.35.0(jiti@2.4.2))
@@ -4892,9 +4910,6 @@ packages:
get-tsconfig@4.10.1:
resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
get-tsconfig@4.7.5:
resolution: {integrity: sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -5999,11 +6014,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.8:
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.5:
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
engines: {node: ^18 || >=20}
@@ -6351,10 +6361,6 @@ packages:
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.1:
resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@@ -6372,6 +6378,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
pretty-bytes@7.1.0:
resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==}
engines: {node: '>=20'}
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@@ -7473,9 +7483,6 @@ packages:
vue-component-type-helpers@2.2.12:
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
vue-component-type-helpers@3.1.0:
resolution: {integrity: sha512-cC1pYNRZkSS1iCvdlaMbbg2sjDwxX098FucEjtz9Yig73zYjWzQsnMe5M9H8dRNv55hAIDGUI29hF2BEUA4FMQ==}
vue-component-type-helpers@3.1.1:
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
@@ -12585,10 +12592,6 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
get-tsconfig@4.7.5:
dependencies:
resolve-pkg-maps: 1.0.0
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -13883,8 +13886,6 @@ snapshots:
nanoid@3.3.11: {}
nanoid@3.3.8: {}
nanoid@5.1.5: {}
napi-postinstall@0.3.3: {}
@@ -14264,14 +14265,14 @@ snapshots:
dependencies:
htmlparser2: 8.0.2
js-tokens: 9.0.1
postcss: 8.5.1
postcss-safe-parser: 6.0.0(postcss@8.5.1)
postcss: 8.5.6
postcss-safe-parser: 6.0.0(postcss@8.5.6)
postcss-resolve-nested-selector@0.1.6: {}
postcss-safe-parser@6.0.0(postcss@8.5.1):
postcss-safe-parser@6.0.0(postcss@8.5.6):
dependencies:
postcss: 8.5.1
postcss: 8.5.6
postcss-safe-parser@7.0.1(postcss@8.5.6):
dependencies:
@@ -14289,12 +14290,6 @@ snapshots:
postcss-value-parser@4.2.0: {}
postcss@8.5.1:
dependencies:
nanoid: 3.3.8
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@@ -14309,6 +14304,8 @@ snapshots:
prettier@3.6.2: {}
pretty-bytes@7.1.0: {}
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
@@ -15321,7 +15318,7 @@ snapshots:
tsx@4.19.4:
dependencies:
esbuild: 0.25.5
get-tsconfig: 4.7.5
get-tsconfig: 4.10.1
optionalDependencies:
fsevents: 2.3.3
@@ -15673,7 +15670,7 @@ snapshots:
vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2):
dependencies:
esbuild: 0.21.5
postcss: 8.5.1
postcss: 8.5.6
rollup: 4.22.4
optionalDependencies:
'@types/node': 20.14.10
@@ -15738,8 +15735,6 @@ snapshots:
vue-component-type-helpers@2.2.12: {}
vue-component-type-helpers@3.1.0: {}
vue-component-type-helpers@3.1.1: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):

View File

@@ -62,10 +62,13 @@ catalog:
jsdom: ^26.1.0
knip: ^5.62.0
lint-staged: ^15.2.7
markdown-table: ^3.0.4
nx: 21.4.1
picocolors: ^1.1.1
pinia: ^2.1.7
postcss-html: ^1.8.0
prettier: ^3.6.2
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
rollup-plugin-visualizer: ^6.0.4

View File

@@ -0,0 +1,93 @@
// @ts-check
/**
* Bundle categorization configuration
*
* This file defines how bundles are categorized in size reports.
* Categories help identify which parts of the application are growing.
*/
/**
* @typedef {Object} BundleCategory
* @property {string} name - Display name of the category
* @property {string} description - Description of what this category includes
* @property {RegExp[]} patterns - Regex patterns to match bundle files
* @property {number} order - Sort order for display (lower = first)
*/
/** @type {BundleCategory[]} */
export const BUNDLE_CATEGORIES = [
{
name: 'App Entry Points',
description: 'Main application bundles',
patterns: [/^index-.*\.js$/],
order: 1
},
{
name: 'Core Views',
description: 'Major application views and screens',
patterns: [/GraphView-.*\.js$/, /UserSelectView-.*\.js$/],
order: 2
},
{
name: 'UI Panels',
description: 'Settings and configuration panels',
patterns: [/.*Panel-.*\.js$/],
order: 3
},
{
name: 'UI Components',
description: 'Reusable UI components',
patterns: [/Avatar-.*\.js$/, /Badge-.*\.js$/],
order: 4
},
{
name: 'Services',
description: 'Business logic and services',
patterns: [/.*Service-.*\.js$/, /.*Store-.*\.js$/],
order: 5
},
{
name: 'Utilities',
description: 'Helper functions and utilities',
patterns: [/.*[Uu]til.*\.js$/],
order: 6
},
{
name: 'Other',
description: 'Uncategorized bundles',
patterns: [/.*/], // Catch-all pattern
order: 99
}
]
/**
* Categorize a bundle file based on its name
*
* @param {string} fileName - The bundle file name (e.g., "assets/GraphView-BnV6iF9h.js")
* @returns {string} - The category name
*/
export function categorizeBundle(fileName) {
// Extract just the file name without path
const baseName = fileName.split('/').pop() || fileName
// Find the first matching category
for (const category of BUNDLE_CATEGORIES) {
for (const pattern of category.patterns) {
if (pattern.test(baseName)) {
return category.name
}
}
}
return 'Other'
}
/**
* Get category metadata by name
*
* @param {string} categoryName - The category name
* @returns {BundleCategory | undefined} - The category metadata
*/
export function getCategoryMetadata(categoryName) {
return BUNDLE_CATEGORIES.find((cat) => cat.name === categoryName)
}

90
scripts/size-collect.js Normal file
View File

@@ -0,0 +1,90 @@
// @ts-check
import { existsSync } from 'node:fs'
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { brotliCompressSync, gzipSync } from 'node:zlib'
import pico from 'picocolors'
import prettyBytes from 'pretty-bytes'
import { categorizeBundle } from './bundle-categories.js'
const distDir = path.resolve('dist')
const sizeDir = path.resolve('temp/size')
/**
* @typedef {Object} SizeResult
* @property {string} file
* @property {string} category
* @property {number} size
* @property {number} gzip
* @property {number} brotli
*/
run()
/**
* Main function to collect bundle size data
*/
async function run() {
if (!existsSync(distDir)) {
console.error(pico.red('Error: dist directory does not exist'))
console.error(pico.yellow('Please run "pnpm build" first'))
process.exit(1)
}
console.log(pico.blue('\nCollecting bundle size data...\n'))
// Collect main bundle files from dist/assets
const assetsDir = path.join(distDir, 'assets')
const bundles = []
if (existsSync(assetsDir)) {
const files = await readdir(assetsDir)
const jsFiles = files.filter(
(file) => file.endsWith('.js') && !file.includes('legacy')
)
for (const file of jsFiles) {
const filePath = path.join(assetsDir, file)
const content = await readFile(filePath, 'utf-8')
const size = Buffer.byteLength(content)
const gzip = gzipSync(content).length
const brotli = brotliCompressSync(content).length
const fileName = `assets/${file}`
const category = categorizeBundle(fileName)
bundles.push({
file: fileName,
category,
size,
gzip,
brotli
})
console.log(
`${pico.green(file)} ${pico.dim(`[${category}]`)} - ` +
`Size: ${prettyBytes(size)} / ` +
`Gzip: ${prettyBytes(gzip)} / ` +
`Brotli: ${prettyBytes(brotli)}`
)
}
}
// Create temp/size directory
await mkdir(sizeDir, { recursive: true })
// Write individual bundle files
for (const bundle of bundles) {
const fileName = bundle.file.replace(/[/\\]/g, '_').replace('.js', '.json')
await writeFile(
path.join(sizeDir, fileName),
JSON.stringify(bundle, null, 2),
'utf-8'
)
}
console.log(
pico.green(`\n✓ Collected size data for ${bundles.length} bundles\n`)
)
console.log(pico.blue(`Data saved to: ${sizeDir}\n`))
}

162
scripts/size-report.js Normal file
View File

@@ -0,0 +1,162 @@
// @ts-check
import { markdownTable } from 'markdown-table'
import { existsSync } from 'node:fs'
import { readdir } from 'node:fs/promises'
import path from 'node:path'
import prettyBytes from 'pretty-bytes'
import { getCategoryMetadata } from './bundle-categories.js'
/**
* @typedef {Object} SizeResult
* @property {number} size
* @property {number} gzip
* @property {number} brotli
*/
/**
* @typedef {SizeResult & { file: string, category?: string }} BundleResult
*/
const currDir = path.resolve('temp/size')
const prevDir = path.resolve('temp/size-prev')
let output = '## Bundle Size Report\n\n'
const sizeHeaders = ['Size', 'Gzip', 'Brotli']
run()
/**
* Main function to generate the size report
*/
async function run() {
if (!existsSync(currDir)) {
console.error('Error: temp/size directory does not exist')
console.error('Please run "pnpm size:collect" first')
process.exit(1)
}
await renderFiles()
process.stdout.write(output)
}
/**
* Renders file sizes and diffs between current and previous versions
*/
async function renderFiles() {
/**
* @param {string[]} files
* @returns {string[]}
*/
const filterFiles = (files) => files.filter((file) => file.endsWith('.json'))
const curr = filterFiles(await readdir(currDir))
const prev = existsSync(prevDir) ? filterFiles(await readdir(prevDir)) : []
const fileList = new Set([...curr, ...prev])
// Group bundles by category
/** @type {Map<string, Array<{fileName: string, curr: BundleResult | undefined, prev: BundleResult | undefined}>>} */
const bundlesByCategory = new Map()
for (const file of fileList) {
const currPath = path.resolve(currDir, file)
const prevPath = path.resolve(prevDir, file)
const curr = await importJSON(currPath)
const prev = await importJSON(prevPath)
const fileName = curr?.file || prev?.file || ''
const category = curr?.category || prev?.category || 'Other'
if (!bundlesByCategory.has(category)) {
bundlesByCategory.set(category, [])
}
// @ts-expect-error - get is valid
bundlesByCategory.get(category).push({ fileName, curr, prev })
}
// Sort categories by their order
const sortedCategories = Array.from(bundlesByCategory.keys()).sort((a, b) => {
const metaA = getCategoryMetadata(a)
const metaB = getCategoryMetadata(b)
return (metaA?.order ?? 99) - (metaB?.order ?? 99)
})
let totalSize = 0
let totalCount = 0
// Render each category
for (const category of sortedCategories) {
const bundles = bundlesByCategory.get(category) || []
if (bundles.length === 0) continue
const categoryMeta = getCategoryMetadata(category)
output += `### ${category}\n\n`
if (categoryMeta?.description) {
output += `_${categoryMeta.description}_\n\n`
}
const rows = []
let categorySize = 0
for (const { fileName, curr, prev } of bundles) {
if (!curr) {
// File was deleted
rows.push([`~~${fileName}~~`])
} else {
rows.push([
fileName,
`${prettyBytes(curr.size)}${getDiff(curr.size, prev?.size)}`,
`${prettyBytes(curr.gzip)}${getDiff(curr.gzip, prev?.gzip)}`,
`${prettyBytes(curr.brotli)}${getDiff(curr.brotli, prev?.brotli)}`
])
categorySize += curr.size
totalSize += curr.size
totalCount++
}
}
// Sort rows by file name within category
rows.sort((a, b) => {
const fileA = a[0].replace(/~~/g, '')
const fileB = b[0].replace(/~~/g, '')
return fileA.localeCompare(fileB)
})
output += markdownTable([['File', ...sizeHeaders], ...rows])
output += `\n\n**Category Total:** ${prettyBytes(categorySize)}\n\n`
}
// Add overall summary
if (totalCount > 0) {
output += '---\n\n'
output += `**Overall Total Size:** ${prettyBytes(totalSize)}\n`
output += `**Total Bundle Count:** ${totalCount}\n`
}
}
/**
* Imports JSON data from a specified path
*
* @template T
* @param {string} filePath - Path to the JSON file
* @returns {Promise<T | undefined>} The JSON content or undefined if the file does not exist
*/
async function importJSON(filePath) {
if (!existsSync(filePath)) return undefined
return (await import(filePath, { with: { type: 'json' } })).default
}
/**
* Calculates the difference between the current and previous sizes
*
* @param {number} curr - The current size
* @param {number} [prev] - The previous size
* @returns {string} The difference in pretty format
*/
function getDiff(curr, prev) {
if (prev === undefined) return ''
const diff = curr - prev
if (diff === 0) return ''
const sign = diff > 0 ? '+' : ''
return ` (**${sign}${prettyBytes(diff)}**)`
}

View File

@@ -11,7 +11,7 @@
<ButtonGroup
id="graph-canvas-controls"
class="absolute right-2 bottom-2 z-[1200] flex-row gap-1 border-[1px] border-node-border bg-interface-panel-surface p-2"
class="absolute right-0 bottom-0 z-[1200] flex-row gap-1 border-[1px] border-node-border bg-interface-panel-surface p-2"
:style="stringifiedMinimapStyles.buttonGroupStyles"
@wheel="canvasInteractions.handleWheel"
>

View File

@@ -1,5 +1,6 @@
<template>
<div
ref="containerRef"
class="workflow-tabs-container flex h-full max-w-full flex-auto flex-row overflow-hidden"
:class="{ 'workflow-tabs-container-desktop': isDesktop }"
>
@@ -13,7 +14,6 @@
@mousedown="whileMouseDown($event, () => scroll(-1))"
/>
<ScrollPanel
ref="scrollPanelRef"
class="no-drag overflow-hidden"
:pt:content="{
class: 'p-0 w-full flex',
@@ -74,6 +74,7 @@ import ContextMenu from 'primevue/contextmenu'
import ScrollPanel from 'primevue/scrollpanel'
import SelectButton from 'primevue/selectbutton'
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
@@ -108,7 +109,7 @@ const workflowService = useWorkflowService()
const rightClickedTab = ref<WorkflowOption | undefined>()
const menu = ref()
const scrollPanelRef = ref()
const containerRef = ref<HTMLElement | null>(null)
const showOverflowArrows = ref(false)
const leftArrowEnabled = ref(false)
const rightArrowEnabled = ref(false)
@@ -221,26 +222,34 @@ const handleWheel = (event: WheelEvent) => {
})
}
const scrollContent = computed(
() =>
(containerRef.value?.querySelector(
'.p-scrollpanel-content'
) as HTMLElement | null) ?? null
)
const scroll = (direction: number) => {
const scrollElement = scrollPanelRef.value.$el.querySelector(
'.p-scrollpanel-content'
) as HTMLElement
scrollElement.scrollBy({ left: direction * 20 })
const el = scrollContent.value
if (!el) return
el.scrollBy({ left: direction * 20 })
}
const ensureActiveTabVisible = async () => {
const ensureActiveTabVisible = async (
options: { waitForDom?: boolean } = {}
) => {
if (!selectedWorkflow.value) return
await nextTick()
if (options.waitForDom !== false) {
await nextTick()
}
const scrollPanelElement = scrollPanelRef.value?.$el as
| HTMLElement
| undefined
if (!scrollPanelElement) return
const containerElement = containerRef.value
if (!containerElement) return
const activeTabElement = scrollPanelElement.querySelector(
const activeTabElement = containerElement.querySelector(
'.p-togglebutton-checked'
) as HTMLElement | null
)
if (!activeTabElement) return
activeTabElement.scrollIntoView({ block: 'nearest', inline: 'nearest' })
@@ -255,38 +264,56 @@ watch(
{ immediate: true }
)
const scrollContent = computed(
() =>
scrollPanelRef.value?.$el.querySelector(
'.p-scrollpanel-content'
) as HTMLElement
)
let overflowObserver: ReturnType<typeof useOverflowObserver> | null = null
let overflowWatch: ReturnType<typeof watch> | null = null
watch(scrollContent, (value) => {
const scrollState = useScroll(value)
let stopArrivedWatch: WatchStopHandle | null = null
let stopOverflowWatch: WatchStopHandle | null = null
watch(scrollState.arrivedState, () => {
leftArrowEnabled.value = !scrollState.arrivedState.left
rightArrowEnabled.value = !scrollState.arrivedState.right
})
watch(
scrollContent,
(el, _prev, onCleanup) => {
stopArrivedWatch?.()
stopOverflowWatch?.()
overflowObserver?.dispose()
overflowObserver?.dispose()
overflowWatch?.stop()
overflowObserver = useOverflowObserver(value)
overflowWatch = watch(
overflowObserver.isOverflowing,
(value) => {
showOverflowArrows.value = value
void nextTick(() => {
// Force a new check after arrows are updated
scrollState.measure()
void ensureActiveTabVisible()
})
},
{ immediate: true }
)
})
if (!el) return
const scrollState = useScroll(el)
stopArrivedWatch = watch(
[
() => scrollState.arrivedState.left,
() => scrollState.arrivedState.right
],
([atLeft, atRight]) => {
leftArrowEnabled.value = !atLeft
rightArrowEnabled.value = !atRight
},
{ immediate: true }
)
overflowObserver = useOverflowObserver(el)
stopOverflowWatch = watch(
overflowObserver.isOverflowing,
(isOverflow) => {
showOverflowArrows.value = isOverflow
if (!isOverflow) return
void nextTick(() => {
// Force a new check after arrows are updated
scrollState.measure()
void ensureActiveTabVisible({ waitForDom: false })
})
},
{ immediate: true }
)
onCleanup(() => {
stopArrivedWatch?.()
stopOverflowWatch?.()
overflowObserver?.dispose()
})
},
{ immediate: true }
)
onUpdated(() => {
if (!overflowObserver?.disposed.value) {

View File

@@ -1,9 +1,12 @@
import { FirebaseError } from 'firebase/app'
import { AuthErrorCodes } from 'firebase/auth'
import { ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { usdToMicros } from '@/utils/formatUtil'
@@ -122,6 +125,47 @@ export const useFirebaseAuthActions = () => {
reportError
)
/**
* Recovery strategy for Firebase auth/requires-recent-login errors.
* Prompts user to reauthenticate and retries the operation after successful login.
*/
const createReauthenticationRecovery = <
TArgs extends unknown[],
TReturn
>(): ErrorRecoveryStrategy<TArgs, TReturn> => {
const dialogService = useDialogService()
return {
shouldHandle: (error: unknown) =>
error instanceof FirebaseError &&
error.code === AuthErrorCodes.CREDENTIAL_TOO_OLD_LOGIN_AGAIN,
recover: async (
_error: unknown,
retry: (...args: TArgs) => Promise<TReturn> | TReturn,
args: TArgs
) => {
const confirmed = await dialogService.confirm({
title: t('auth.reauthRequired.title'),
message: t('auth.reauthRequired.message'),
type: 'default'
})
if (!confirmed) {
return
}
await authStore.logout()
const signedIn = await dialogService.showSignInDialog()
if (signedIn) {
await retry(...args)
}
}
}
}
const updatePassword = wrapWithErrorHandlingAsync(
async (newPassword: string) => {
await authStore.updatePassword(newPassword)
@@ -132,18 +176,25 @@ export const useFirebaseAuthActions = () => {
life: 5000
})
},
reportError
reportError,
undefined,
[createReauthenticationRecovery<[string], void>()]
)
const deleteAccount = wrapWithErrorHandlingAsync(async () => {
await authStore.deleteAccount()
toastStore.add({
severity: 'success',
summary: t('auth.deleteAccount.success'),
detail: t('auth.deleteAccount.successDetail'),
life: 5000
})
}, reportError)
const deleteAccount = wrapWithErrorHandlingAsync(
async () => {
await authStore.deleteAccount()
toastStore.add({
severity: 'success',
summary: t('auth.deleteAccount.success'),
detail: t('auth.deleteAccount.successDetail'),
life: 5000
})
},
reportError,
undefined,
[createReauthenticationRecovery<[], void>()]
)
return {
logout,

View File

@@ -5,7 +5,11 @@ import {
DEFAULT_DARK_COLOR_PALETTE,
DEFAULT_LIGHT_COLOR_PALETTE
} from '@/constants/coreColorPalettes'
import { promoteRecommendedWidgets } from '@/core/graph/subgraph/proxyWidgetUtils'
import {
promoteRecommendedWidgets,
tryToggleWidgetPromotion
} from '@/core/graph/subgraph/proxyWidgetUtils'
import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog'
import { t } from '@/i18n'
import {
LGraphEventMode,
@@ -888,7 +892,7 @@ export function useCoreCommands(): ComfyCommand[] {
},
{
id: 'Comfy.Graph.ConvertToSubgraph',
icon: 'pi pi-sitemap',
icon: 'icon-[lucide--shrink]',
label: 'Convert Selection to Subgraph',
versionAdded: '1.20.1',
category: 'essentials' as const,
@@ -916,10 +920,9 @@ export function useCoreCommands(): ComfyCommand[] {
},
{
id: 'Comfy.Graph.UnpackSubgraph',
icon: 'pi pi-sitemap',
icon: 'icon-[lucide--expand]',
label: 'Unpack the selected Subgraph',
versionAdded: '1.20.1',
category: 'essentials' as const,
versionAdded: '1.26.3',
function: () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
@@ -931,6 +934,20 @@ export function useCoreCommands(): ComfyCommand[] {
graph.unpackSubgraph(subgraphNode)
}
},
{
id: 'Comfy.Graph.EditSubgraphWidgets',
label: 'Edit Subgraph Widgets',
icon: 'icon-[lucide--settings-2]',
versionAdded: '1.28.5',
function: showSubgraphNodeDialog
},
{
id: 'Comfy.Graph.ToggleWidgetPromotion',
icon: 'icon-[lucide--arrow-left-right]',
label: 'Toggle promotion of hovered widget',
versionAdded: '1.30.1',
function: tryToggleWidgetPromotion
},
{
id: 'Comfy.OpenManagerDialog',
icon: 'mdi mdi-puzzle-outline',

View File

@@ -1,6 +1,54 @@
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
/**
* Strategy for recovering from specific error conditions.
* Allows operations to be retried after resolving the error condition.
*
* @template TArgs - The argument types of the operation to be retried
* @template TReturn - The return type of the operation
*
* @example
* ```typescript
* const networkRecovery: ErrorRecoveryStrategy = {
* shouldHandle: (error) => error instanceof NetworkError,
* recover: async (error, retry) => {
* await waitForNetwork()
* await retry()
* }
* }
* ```
*/
export interface ErrorRecoveryStrategy<
TArgs extends unknown[] = unknown[],
TReturn = unknown
> {
/**
* Determines if this strategy should handle the given error.
* @param error - The error to check
* @returns true if this strategy can handle the error
*/
shouldHandle: (error: unknown) => boolean
/**
* Attempts to recover from the error and retry the operation.
* This function is responsible for:
* 1. Resolving the error condition (e.g., reauthentication, network reconnect)
* 2. Calling retry() to re-execute the original operation
* 3. Handling the retry result (success or failure)
*
* @param error - The error that occurred
* @param retry - Function to retry the original operation
* @param args - Original arguments passed to the operation
* @returns Promise that resolves when recovery completes (whether successful or not)
*/
recover: (
error: unknown,
retry: (...args: TArgs) => Promise<TReturn> | TReturn,
args: TArgs
) => Promise<void>
}
export function useErrorHandling() {
const toast = useToastStore()
const toastErrorHandler = (error: unknown) => {
@@ -13,9 +61,9 @@ export function useErrorHandling() {
}
const wrapWithErrorHandling =
<TArgs extends any[], TReturn>(
<TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => TReturn,
errorHandler?: (error: any) => void,
errorHandler?: (error: unknown) => void,
finallyHandler?: () => void
) =>
(...args: TArgs): TReturn | undefined => {
@@ -29,15 +77,27 @@ export function useErrorHandling() {
}
const wrapWithErrorHandlingAsync =
<TArgs extends any[], TReturn>(
<TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn,
errorHandler?: (error: any) => void,
finallyHandler?: () => void
errorHandler?: (error: unknown) => void,
finallyHandler?: () => void,
recoveryStrategies: ErrorRecoveryStrategy<TArgs, TReturn>[] = []
) =>
async (...args: TArgs): Promise<TReturn | undefined> => {
try {
return await action(...args)
} catch (e) {
for (const strategy of recoveryStrategies) {
if (strategy.shouldHandle(e)) {
try {
await strategy.recover(e, action, args)
return
} catch (recoveryError) {
console.error('Recovery strategy failed:', recoveryError)
}
}
}
;(errorHandler ?? toastErrorHandler)(e)
} finally {
finallyHandler?.()

View File

@@ -1,11 +1,14 @@
import { parseProxyWidgets } from '@/core/schemas/proxyWidget'
import type { ProxyWidgetsProperty } from '@/core/schemas/proxyWidget'
import { t } from '@/i18n'
import type {
IContextMenuValue,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
@@ -63,7 +66,15 @@ function getParentNodes(): SubgraphNode[] {
//or by adding a new event for parent listeners to collect from
const { navigationStack } = useSubgraphNavigationStore()
const subgraph = navigationStack.at(-1)
if (!subgraph) throw new Error("Can't promote widget when not in subgraph")
if (!subgraph) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('subgraphStore.promoteOutsideSubgraph'),
life: 2000
})
return []
}
const parentGraph = navigationStack.at(-2) ?? subgraph.rootGraph
return parentGraph.nodes.filter(
(node): node is SubgraphNode =>
@@ -96,6 +107,21 @@ export function addWidgetPromotionOptions(
})
}
}
export function tryToggleWidgetPromotion() {
const canvas = useCanvasStore().getCanvas()
const [x, y] = canvas.graph_mouse
const node = canvas.graph?.getNodeOnPos(x, y, canvas.visible_nodes)
if (!node) return
const widget = node.getWidgetOnPos(x, y, true)
const parents = getParentNodes()
if (!parents.length || !widget) return
const promotableParents = parents.filter(
(s) => !getProxyWidgets(s).some(matchesPropertyItem([node, widget]))
)
if (promotableParents.length > 0)
promoteWidget(node, widget, promotableParents)
else demoteWidget(node, widget, parents)
}
const recommendedNodes = [
'CLIPTextEncode',
'LoadImage',

View File

@@ -1,16 +1,12 @@
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.CloudBadge',
// Only show badge when running in cloud environment
topbarBadges: isCloud
? [
{
label: t('g.beta'),
text: 'Comfy Cloud'
}
]
: undefined
topbarBadges: [
{
label: t('g.beta'),
text: 'Comfy Cloud'
}
]
})

View File

@@ -253,7 +253,7 @@ app.registerExtension({
audio.setAttribute('name', 'media')
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
audioUIWidget.options.canvasOnly = true
audioUIWidget.options.canvasOnly = false
let mediaRecorder: MediaRecorder | null = null
let isRecording = false
@@ -376,10 +376,12 @@ app.registerExtension({
mediaRecorder.stop()
}
},
{ serialize: false, canvasOnly: true }
{ serialize: false, canvasOnly: false }
)
recordWidget.label = t('g.startRecording')
// Override the type for Vue rendering while keeping 'button' for LiteGraph
recordWidget.type = 'audiorecord'
const originalOnRemoved = node.onRemoved
node.onRemoved = function () {

View File

@@ -151,7 +151,7 @@ interface DrawTitleTextOptions extends DrawTitleOptions {
default_title_color: string
}
interface DrawTitleBoxOptions extends DrawTitleOptions {
export interface DrawTitleBoxOptions extends DrawTitleOptions {
box_size?: number
}

View File

@@ -2,6 +2,7 @@ import type { BaseLGraph, LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { DrawTitleBoxOptions } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { ResolvedConnection } from '@/lib/litegraph/src/LLink'
import { RecursionError } from '@/lib/litegraph/src/infrastructure/RecursionError'
@@ -9,6 +10,7 @@ import type {
ISubgraphInput,
IWidgetLocator
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
INodeInputSlot,
ISlotType,
@@ -32,6 +34,10 @@ import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
import type { SubgraphInput } from './SubgraphInput'
const workflowSvg = new Image()
workflowSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3E%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-width='1.3' d='M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z'/%3E%3C/svg%3E %3C/svg%3E"
/**
* An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph.
*/
@@ -555,6 +561,31 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
}
}
override drawTitleBox(
ctx: CanvasRenderingContext2D,
{
scale,
low_quality = false,
title_height = LiteGraph.NODE_TITLE_HEIGHT,
box_size = 10
}: DrawTitleBoxOptions
): void {
if (this.onDrawTitleBox) {
this.onDrawTitleBox(ctx, title_height, this.renderingSize, scale)
return
}
ctx.save()
ctx.fillStyle = '#3b82f6'
ctx.beginPath()
ctx.roundRect(6, -24.5, 22, 20, 5)
ctx.fill()
if (!low_quality) {
ctx.translate(25, 23)
ctx.scale(-1.5, 1.5)
ctx.drawImage(workflowSvg, 0, -title_height, box_size, box_size)
}
ctx.restore()
}
/**
* Synchronizes widget values from this SubgraphNode instance to the

View File

@@ -79,7 +79,6 @@ export type IWidget =
| ISelectButtonWidget
| ITextareaWidget
| IAssetWidget
| IAudioRecordWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -228,11 +227,6 @@ export interface ITextareaWidget extends IBaseWidget<string, 'textarea'> {
value: string
}
export interface IAudioRecordWidget extends IBaseWidget<string, 'audiorecord'> {
type: 'audiorecord'
value: string
}
export interface IAssetWidget
extends IBaseWidget<string, 'asset', IWidgetOptions<string[]>> {
type: 'asset'

View File

@@ -128,6 +128,9 @@
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convert Selection to Subgraph"
},
"Comfy_Graph_EditSubgraphWidgets": {
"label": "Edit Subgraph Widgets"
},
"Comfy_Graph_ExitSubgraph": {
"label": "Exit Subgraph"
},
@@ -137,6 +140,9 @@
"Comfy_Graph_GroupSelectedNodes": {
"label": "Group Selected Nodes"
},
"Comfy_Graph_ToggleWidgetPromotion": {
"label": "Toggle promotion of hovered widget"
},
"Comfy_Graph_UnpackSubgraph": {
"label": "Unpack the selected Subgraph"
},

View File

@@ -1102,6 +1102,7 @@
"overwriteBlueprintTitle": "Overwrite existing blueprint?",
"overwriteBlueprint": "Saving will overwrite the current blueprint with your changes",
"blueprintName": "Subgraph name",
"promoteOutsideSubgraph": "Can't promote widget when not in subgraph",
"publish": "Publish Subgraph",
"publishSuccess": "Saved to Nodes Library",
"publishSuccessMessage": "You can find your subgraph blueprint in the nodes library under \"Subgraph Blueprints\"",
@@ -1212,9 +1213,11 @@
"Export": "Export",
"Export (API)": "Export (API)",
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
"Edit Subgraph Widgets": "Edit Subgraph Widgets",
"Exit Subgraph": "Exit Subgraph",
"Fit Group To Contents": "Fit Group To Contents",
"Group Selected Nodes": "Group Selected Nodes",
"Toggle promotion of hovered widget": "Toggle promotion of hovered widget",
"Unpack the selected Subgraph": "Unpack the selected Subgraph",
"Convert selected nodes to group node": "Convert selected nodes to group node",
"Manage group nodes": "Manage group nodes",
@@ -1863,6 +1866,12 @@
"success": "Account Deleted",
"successDetail": "Your account has been successfully deleted."
},
"reauthRequired": {
"title": "Re-authentication Required",
"message": "For security reasons, this action requires you to sign in again. Would you like to proceed?",
"confirm": "Sign In Again",
"cancel": "Cancel"
},
"loginButton": {
"tooltipHelp": "Login to be able to use \"API Nodes\"",
"tooltipLearnMore": "Learn more..."

View File

@@ -1532,10 +1532,12 @@
},
"outputs": {
"0": {
"name": "positive"
"name": "positive",
"tooltip": null
},
"1": {
"name": "negative"
"name": "negative",
"tooltip": null
}
}
},
@@ -10373,6 +10375,11 @@
"type": {
"name": "type"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"SkipLayerGuidanceDiT": {

View File

@@ -17,4 +17,4 @@ const DISTRIBUTION: Distribution = __DISTRIBUTION__
/** Distribution type checks */
export const isDesktop = DISTRIBUTION === 'desktop' || isElectron() // TODO: replace with build var
export const isCloud = DISTRIBUTION === 'cloud'
// export const isLocalhost = !isDesktop && !isCloud
// export const isLocalhost = DISTRIBUTION === 'localhost' || (!isDesktop && !isCloud)

View File

@@ -3,7 +3,7 @@
v-if="visible && initialized"
id="comfy-minimap"
ref="minimapRef"
class="minimap-main-container absolute right-2 bottom-[66px] z-1000 flex"
class="minimap-main-container absolute right-0 bottom-[58px] z-1000 flex"
>
<MiniMapPanel
v-if="showOptionsPanel"

View File

@@ -2,7 +2,11 @@ import { useThrottleFn } from '@vueuse/core'
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
LGraph,
LGraphNode,
LGraphTriggerEvent
} from '@/lib/litegraph/src/litegraph'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { api } from '@/scripts/api'
@@ -14,6 +18,7 @@ interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
onNodeRemoved?: (node: LGraphNode) => void
onConnectionChange?: (node: LGraphNode) => void
onTrigger?: (event: LGraphTriggerEvent) => void
}
export function useMinimapGraph(
@@ -53,7 +58,8 @@ export function useMinimapGraph(
const originalCallbacks: GraphCallbacks = {
onNodeAdded: g.onNodeAdded,
onNodeRemoved: g.onNodeRemoved,
onConnectionChange: g.onConnectionChange
onConnectionChange: g.onConnectionChange,
onTrigger: g.onTrigger
}
originalCallbacksMap.set(g.id, originalCallbacks)
@@ -72,6 +78,22 @@ export function useMinimapGraph(
originalCallbacks.onConnectionChange?.call(this, node)
void handleGraphChangedThrottled()
}
g.onTrigger = function (event: LGraphTriggerEvent) {
originalCallbacks.onTrigger?.call(this, event)
// Listen for visual property changes that affect minimap rendering
if (
event.type === 'node:property:changed' &&
(event.property === 'mode' ||
event.property === 'bgcolor' ||
event.property === 'color')
) {
// Invalidate cache for this node to force redraw
nodeStatesCache.delete(String(event.nodeId))
void handleGraphChangedThrottled()
}
}
}
const cleanupEventListeners = (oldGraph?: LGraph) => {
@@ -89,6 +111,7 @@ export function useMinimapGraph(
g.onNodeAdded = originalCallbacks.onNodeAdded
g.onNodeRemoved = originalCallbacks.onNodeRemoved
g.onConnectionChange = originalCallbacks.onConnectionChange
g.onTrigger = originalCallbacks.onTrigger
originalCallbacksMap.delete(g.id)
}

View File

@@ -37,6 +37,7 @@
</IconButton>
</div>
<div v-if="isSubgraphNode" class="icon-[comfy--workflow] size-4" />
<!-- Node Title -->
<div
v-tooltip.top="tooltipConfig"

View File

@@ -33,16 +33,20 @@ const selectedItems = computed(() => {
})
const chevronClass = computed(() =>
cn('mr-2 size-4 transition-transform duration-200 flex-shrink-0', {
'rotate-180': props.isOpen
})
cn(
'mr-2 size-4 transition-transform duration-200 flex-shrink-0 text-button-icon',
{
'rotate-180': props.isOpen
}
)
)
const theButtonStyle = computed(() =>
cn('bg-transparent border-0 outline-none text-zinc-400', {
cn('bg-transparent border-0 outline-none text-text-secondary', {
'hover:bg-node-component-widget-input-surface/30 cursor-pointer':
!props.disabled,
'cursor-not-allowed': props.disabled
'cursor-not-allowed': props.disabled,
'text-text-primary': selectedItems.value.length > 0
})
)
</script>

View File

@@ -1,24 +0,0 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IAudioRecordWidget } from '@/lib/litegraph/src/types/widgets'
import type {
AudioRecordInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useAudioRecordWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IAudioRecordWidget => {
const {
name,
default: defaultValue = '',
options = {}
} = inputSpec as AudioRecordInputSpec
const widget = node.addWidget('audiorecord', name, defaultValue, () => {}, {
serialize: true,
...options
}) as IAudioRecordWidget
return widget
}
}

View File

@@ -1,5 +1,6 @@
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
/**
* Format time in MM:SS format
@@ -23,10 +24,6 @@ export function getAudioUrlFromPath(
return api.apiURL(getResourceURL(subfolder, filename, type))
}
function getRandParam() {
return '&rand=' + Math.random()
}
export function getResourceURL(
subfolder: string,
filename: string,
@@ -36,7 +33,7 @@ export function getResourceURL(
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder,
getRandParam().substring(1)
app.getRandParam().substring(1)
].join('&')
return `/view?${params}`

View File

@@ -152,13 +152,6 @@ const zTextareaInputSpec = zBaseInputOptions.extend({
.optional()
})
const zAudioRecordInputSpec = zBaseInputOptions.extend({
type: z.literal('AUDIORECORD'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z.record(z.unknown()).optional()
})
const zCustomInputSpec = zBaseInputOptions.extend({
type: z.string(),
name: z.string(),
@@ -174,7 +167,6 @@ const zInputSpec = z.union([
zColorInputSpec,
zFileUploadInputSpec,
zImageInputSpec,
zAudioRecordInputSpec,
zImageCompareInputSpec,
zMarkdownInputSpec,
zTreeSelectInputSpec,
@@ -230,7 +222,6 @@ export type GalleriaInputSpec = z.infer<typeof zGalleriaInputSpec>
export type SelectButtonInputSpec = z.infer<typeof zSelectButtonInputSpec>
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>
export type CustomInputSpec = z.infer<typeof zCustomInputSpec>
export type AudioRecordInputSpec = z.infer<typeof zAudioRecordInputSpec>
export type InputSpec = z.infer<typeof zInputSpec>
export type OutputSpec = z.infer<typeof zOutputSpec>

View File

@@ -16,6 +16,7 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
@@ -336,6 +337,7 @@ export class ComfyApp {
}
getRandParam() {
if (isCloud) return ''
return '&rand=' + Math.random()
}

View File

@@ -6,7 +6,6 @@ import type {
IStringWidget
} from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAudioRecordWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useAudioRecordWidget'
import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget'
import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget'
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
@@ -305,6 +304,5 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
SELECTBUTTON: transformWidgetConstructorV2ToV1(useSelectButtonWidget()),
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
AUDIO_RECORD: transformWidgetConstructorV2ToV1(useAudioRecordWidget())
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget())
}

View File

@@ -8,7 +8,6 @@ import { addWidgetPromotionOptions } from '@/core/graph/subgraph/proxyWidgetUtil
import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog'
import { st, t } from '@/i18n'
import {
LGraphBadge,
LGraphCanvas,
LGraphEventMode,
LGraphNode,
@@ -135,19 +134,6 @@ export const useLitegraphService = () => {
this.#setInitialSize()
this.serialize_widgets = true
void extensionService.invokeExtensionsAsync('nodeCreated', this)
this.badges.push(
new LGraphBadge({
text: '',
iconOptions: {
unicode: '\ue96e',
fontFamily: 'PrimeIcons',
color: '#ffffff',
fontSize: 12
},
fgColor: '#ffffff',
bgColor: '#3b82f6'
})
)
}
/**
@@ -845,7 +831,7 @@ export const useLitegraphService = () => {
)
}
if (this.graph && !this.graph.isRootGraph) {
const [x, y] = canvas.canvas_mouse
const [x, y] = canvas.graph_mouse
const overWidget = this.getWidgetOnPos(x, y, true)
if (overWidget) {
addWidgetPromotionOptions(options, overWidget, this)

View File

@@ -54,7 +54,7 @@ export interface TreeExplorerNode<T = any> extends TreeNode {
event: MouseEvent
) => void | Promise<void>
/** Function to handle errors */
handleError?: (this: TreeExplorerNode<T>, error: Error) => void
handleError?: (this: TreeExplorerNode<T>, error: unknown) => void
/** Extra context menu items */
contextMenuItems?:
| MenuItem[]

View File

@@ -0,0 +1,353 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { useErrorHandling } from '@/composables/useErrorHandling'
describe('useErrorHandling', () => {
let errorHandler: ReturnType<typeof useErrorHandling>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
errorHandler = useErrorHandling()
})
describe('wrapWithErrorHandlingAsync', () => {
it('should execute action successfully', async () => {
const action = vi.fn(async () => 'success')
const wrapped = errorHandler.wrapWithErrorHandlingAsync(action)
const result = await wrapped()
expect(result).toBe('success')
expect(action).toHaveBeenCalledOnce()
})
it('should call error handler when action throws', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const customErrorHandler = vi.fn()
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler
)
await wrapped()
expect(customErrorHandler).toHaveBeenCalledWith(testError)
})
it('should call finally handler after success', async () => {
const action = vi.fn(async () => 'success')
const finallyHandler = vi.fn()
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
undefined,
finallyHandler
)
await wrapped()
expect(finallyHandler).toHaveBeenCalledOnce()
})
it('should call finally handler after error', async () => {
const action = vi.fn(async () => {
throw new Error('test error')
})
const finallyHandler = vi.fn()
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
finallyHandler
)
await wrapped()
expect(finallyHandler).toHaveBeenCalledOnce()
})
describe('error recovery', () => {
it('should not use recovery strategy when no error occurs', async () => {
const action = vi.fn(async () => 'success')
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn()
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
undefined,
undefined,
[recoveryStrategy]
)
await wrapped()
expect(recoveryStrategy.shouldHandle).not.toHaveBeenCalled()
expect(recoveryStrategy.recover).not.toHaveBeenCalled()
})
it('should use recovery strategy when it matches error', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn((error) => error === testError),
recover: vi.fn(async () => {
// Recovery succeeds, does nothing
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[recoveryStrategy]
)
await wrapped()
expect(recoveryStrategy.shouldHandle).toHaveBeenCalledWith(testError)
expect(recoveryStrategy.recover).toHaveBeenCalled()
})
it('should pass action and args to recovery strategy', async () => {
const testError = new Error('test error')
const action = vi.fn(async (_arg1: string, _arg2: number) => {
throw testError
})
const recoveryStrategy: ErrorRecoveryStrategy<[string, number], void> =
{
shouldHandle: vi.fn(() => true),
recover: vi.fn()
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[recoveryStrategy]
)
await wrapped('test', 123)
expect(recoveryStrategy.recover).toHaveBeenCalledWith(
testError,
action,
['test', 123]
)
})
it('should retry operation when recovery succeeds', async () => {
let attemptCount = 0
const action = vi.fn(async (value: string) => {
attemptCount++
if (attemptCount === 1) {
throw new Error('first attempt failed')
}
return `success: ${value}`
})
const recoveryStrategy: ErrorRecoveryStrategy<[string], string> = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async (_error, retry, args) => {
await retry(...args)
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[recoveryStrategy]
)
await wrapped('test-value')
expect(action).toHaveBeenCalledTimes(2)
expect(recoveryStrategy.recover).toHaveBeenCalledOnce()
})
it('should not call error handler when recovery succeeds', async () => {
const action = vi.fn(async () => {
throw new Error('test error')
})
const customErrorHandler = vi.fn()
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async () => {
// Recovery succeeds
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler,
undefined,
[recoveryStrategy]
)
await wrapped()
expect(customErrorHandler).not.toHaveBeenCalled()
})
it('should call error handler when recovery fails', async () => {
const originalError = new Error('original error')
const recoveryError = new Error('recovery error')
const action = vi.fn(async () => {
throw originalError
})
const customErrorHandler = vi.fn()
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async () => {
throw recoveryError
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler,
undefined,
[recoveryStrategy]
)
await wrapped()
expect(customErrorHandler).toHaveBeenCalledWith(originalError)
})
it('should try multiple recovery strategies in order', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const strategy1: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => false),
recover: vi.fn()
}
const strategy2: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async () => {
// Recovery succeeds
})
}
const strategy3: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn()
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[strategy1, strategy2, strategy3]
)
await wrapped()
expect(strategy1.shouldHandle).toHaveBeenCalledWith(testError)
expect(strategy1.recover).not.toHaveBeenCalled()
expect(strategy2.shouldHandle).toHaveBeenCalledWith(testError)
expect(strategy2.recover).toHaveBeenCalled()
// Strategy 3 should not be checked because strategy 2 handled it
expect(strategy3.shouldHandle).not.toHaveBeenCalled()
expect(strategy3.recover).not.toHaveBeenCalled()
})
it('should fall back to error handler when no strategy matches', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const customErrorHandler = vi.fn()
const strategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => false),
recover: vi.fn()
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler,
undefined,
[strategy]
)
await wrapped()
expect(strategy.shouldHandle).toHaveBeenCalledWith(testError)
expect(strategy.recover).not.toHaveBeenCalled()
expect(customErrorHandler).toHaveBeenCalledWith(testError)
})
it('should work with synchronous actions', async () => {
const testError = new Error('test error')
const action = vi.fn(() => {
throw testError
})
const recoveryStrategy: ErrorRecoveryStrategy = {
shouldHandle: vi.fn(() => true),
recover: vi.fn(async () => {
// Recovery succeeds
})
}
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
vi.fn(),
undefined,
[recoveryStrategy]
)
await wrapped()
expect(recoveryStrategy.recover).toHaveBeenCalled()
})
})
describe('backward compatibility', () => {
it('should work without recovery strategies parameter', async () => {
const action = vi.fn(async () => 'success')
const wrapped = errorHandler.wrapWithErrorHandlingAsync(action)
const result = await wrapped()
expect(result).toBe('success')
})
it('should work with empty recovery strategies array', async () => {
const testError = new Error('test error')
const action = vi.fn(async () => {
throw testError
})
const customErrorHandler = vi.fn()
const wrapped = errorHandler.wrapWithErrorHandlingAsync(
action,
customErrorHandler,
undefined,
[]
)
await wrapped()
expect(customErrorHandler).toHaveBeenCalledWith(testError)
})
})
})
})