Compare commits

..

9 Commits

Author SHA1 Message Date
dante01yoon
c749a077fc feat: add Edit Expression context menu and / key toggle for number widgets
Add getContextMenuOptions to NumberWidget for right-click "Edit Expression"
that opens the prompt in text mode, allowing math expressions like 2+3.
Add / key handler in prompt dialog to switch from number to text mode.
Wire up widget context menu items in LGraphCanvas processContextMenu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:07:29 +09:00
dante01yoon
1b879a5ac2 fix: omit unsafe min/max from number input prompt to prevent step grid lock
The browser's stepUp/stepDown algorithm uses min as the step base.
When min exceeds Number.MAX_SAFE_INTEGER (e.g. Python sys.maxsize),
the subtraction silently loses precision, snapping the value back to 0.
safeMinMax() omits these attributes so the input works correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 17:06:11 +09:00
dante01yoon
1f84d6409b Merge remote-tracking branch 'origin/main' into feat/4768-number-input-type 2026-03-03 11:45:01 +09:00
dante01yoon
7b8d6869d2 fix(test): use type-agnostic selector for prompt dialog input
E2E test used input[type="text"] selector which fails now that
number widgets use type="number". Use class-based selector instead.
2026-02-22 21:30:52 +09:00
dante01yoon
f9db1ce8da test: align NumberWidget suite title with class name 2026-02-22 20:15:13 +09:00
dante01yoon
cd257f8aa0 fix: restrict prompt input type to number or text 2026-02-22 20:14:45 +09:00
dante01yoon
098150acc3 fix(litegraph): forward prompt options through changeTracker override 2026-02-19 19:25:33 +09:00
dante01yoon
543fa23151 feat(litegraph): pass number input options from NumberWidget to prompt
Pass inputType, min, max, and step options when NumberWidget opens the
canvas prompt dialog so the browser renders a native number input with
proper constraints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:53:48 +09:00
dante01yoon
ec544e21f0 feat(litegraph): support number input type in canvas prompt dialog
Add PromptOptions interface to allow specifying input type, min, max,
and step attributes for the prompt dialog input element. Refactor the
prompt method to accept PromptOptions instead of a plain boolean,
while maintaining backward compatibility.

Fixes #4768

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:53:26 +09:00
187 changed files with 1848 additions and 5121 deletions

View File

@@ -45,7 +45,7 @@ jobs:
- name: Run performance tests
id: perf
continue-on-error: true
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
run: pnpm exec playwright test --project=performance --workers=1
- name: Upload perf metrics
if: always()
@@ -61,7 +61,6 @@ jobs:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: write
@@ -91,31 +90,6 @@ jobs:
path: temp/perf-baseline/
if_no_artifact_found: warn
- name: Download historical perf baselines
continue-on-error: true
run: |
RUNS=$(gh api \
"/repos/${{ github.repository }}/actions/workflows/ci-perf-report.yaml/runs?branch=${{ github.event.pull_request.base.ref }}&event=push&status=success&per_page=5" \
--jq '.workflow_runs[].id' || true)
if [ -z "$RUNS" ]; then
echo "No historical runs available"
exit 0
fi
mkdir -p temp/perf-history
INDEX=0
for RUN_ID in $RUNS; do
DIR="temp/perf-history/$INDEX"
mkdir -p "$DIR"
gh run download "$RUN_ID" -n perf-metrics -D "$DIR/" 2>/dev/null || true
INDEX=$((INDEX + 1))
done
echo "Downloaded $(ls temp/perf-history/*/perf-metrics.json 2>/dev/null | wc -l) historical baselines"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Generate perf report
run: npx --yes tsx scripts/perf-report.ts > perf-report.md

View File

@@ -35,7 +35,7 @@
}
],
"no-control-regex": "off",
"no-eval": "error",
"no-eval": "off",
"no-redeclare": "error",
"no-restricted-imports": [
"error",

View File

@@ -177,7 +177,7 @@ export class NodeOperationsHelper {
await this.page.locator('#graph-canvas').click({
position: DefaultGraphPositions.emptyLatentWidgetClick
})
const dialogInput = this.page.locator('.graphdialog input[type="text"]')
const dialogInput = this.page.locator('.graphdialog input.value')
await dialogInput.click()
await dialogInput.fill('128')
await dialogInput.press('Enter')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 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

View File

@@ -67,66 +67,4 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
recordMeasurement(m)
console.log(`Clipping: ${m.layouts} forced layouts`)
})
test('subgraph idle style recalculations', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.perf.startMeasuring()
for (let i = 0; i < 120; i++) {
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('subgraph-idle')
recordMeasurement(m)
console.log(
`Subgraph idle: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('subgraph mouse interaction style recalculations', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.perf.startMeasuring()
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
for (let i = 0; i < 100; i++) {
await comfyPage.page.mouse.move(
box.x + (box.width * i) / 100,
box.y + (box.height * (i % 3)) / 3
)
}
const m = await comfyPage.perf.stopMeasuring('subgraph-mouse-sweep')
recordMeasurement(m)
console.log(
`Subgraph mouse sweep: ${m.styleRecalcs} style recalcs, ${m.layouts} layouts`
)
})
test('subgraph DOM widget clipping during node selection', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/nested-subgraph')
await comfyPage.perf.startMeasuring()
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
for (let i = 0; i < 20; i++) {
await comfyPage.page.mouse.click(
box.x + box.width / 3 + (i % 5) * 30,
box.y + box.height / 3 + (i % 4) * 30
)
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('subgraph-dom-widget-clipping')
recordMeasurement(m)
console.log(`Subgraph clipping: ${m.layouts} forced layouts`)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 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: 93 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

@@ -1,7 +1,6 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import pluginJs from '@eslint/js'
import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
import { importX } from 'eslint-plugin-import-x'
import oxlint from 'eslint-plugin-oxlint'
@@ -112,28 +111,6 @@ export default defineConfig([
tseslintConfigs.recommended,
// Difference in typecheck on CI vs Local
pluginVue.configs['flat/recommended'],
// Tailwind CSS v4 linting (class ordering, duplicates, conflicts, etc.)
betterTailwindcss.configs.recommended,
{
settings: {
'better-tailwindcss': {
entryPoint: 'packages/design-system/src/css/style.css'
}
},
rules: {
// Off: requires whitelisting non-Tailwind classes (PrimeIcons, custom CSS)
'better-tailwindcss/no-unknown-classes': 'off',
// Off: may conflict with oxfmt formatting
'better-tailwindcss/enforce-consistent-line-wrapping': 'off',
// Off: large batch change, enable and apply with `eslint --fix`
'better-tailwindcss/enforce-consistent-class-order': 'off',
// Off: large batch change (v3→v4 renames like rounded→rounded-sm),
// enable and apply with `eslint --fix` in a follow-up PR
'better-tailwindcss/enforce-canonical-classes': 'off',
// Off: large batch change, enable and apply with `eslint --fix`
'better-tailwindcss/no-deprecated-classes': 'off'
}
},
// Disables ESLint rules that conflict with formatters
eslintConfigPrettier,
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.41.11",
"version": "1.41.10",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -146,7 +146,6 @@
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-import-resolver-typescript": "catalog:",
"eslint-plugin-better-tailwindcss": "catalog:",
"eslint-plugin-import-x": "catalog:",
"eslint-plugin-oxlint": "catalog:",
"eslint-plugin-storybook": "catalog:",

View File

@@ -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,6 +634,10 @@
}
}
@utility bg-subscription-gradient {
background: var(--color-subscription-button-gradient);
}
@utility highlight {
background-color: color-mix(in srgb, currentColor 20%, transparent);
font-weight: 700;

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,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 }
}
}

75
pnpm-lock.yaml generated
View File

@@ -180,9 +180,6 @@ catalogs:
eslint-import-resolver-typescript:
specifier: ^4.4.4
version: 4.4.4
eslint-plugin-better-tailwindcss:
specifier: ^4.3.1
version: 4.3.1
eslint-plugin-import-x:
specifier: ^4.16.1
version: 4.16.1
@@ -639,9 +636,6 @@ importers:
eslint-import-resolver-typescript:
specifier: 'catalog:'
version: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-better-tailwindcss:
specifier: 'catalog:'
version: 4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.49.0(oxlint-tsgolint@0.14.2))(tailwindcss@4.2.0)(typescript@5.9.3)
eslint-plugin-import-x:
specifier: 'catalog:'
version: 4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1))
@@ -1922,10 +1916,6 @@ packages:
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/css-tree@3.6.9':
resolution: {integrity: sha512-3D5/OHibNEGk+wKwNwMbz63NMf367EoR4mVNNpxddCHKEb2Nez7z62J2U6YjtErSsZDoY0CsccmoUpdEbkogNA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
'@eslint/eslintrc@3.3.3':
resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -5324,19 +5314,6 @@ packages:
eslint-import-resolver-webpack:
optional: true
eslint-plugin-better-tailwindcss@4.3.1:
resolution: {integrity: sha512-b6xM31GukKz0WlgMD0tQdY/rLjf/9mWIk8EcA45ngOKJPPQf1C482xZtBlT357jyunQE2mOk4NlPcL4i9Pr85A==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23.0.0}
peerDependencies:
eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
oxlint: ^1.35.0
tailwindcss: ^3.3.0 || ^4.1.17
peerDependenciesMeta:
eslint:
optional: true
oxlint:
optional: true
eslint-plugin-import-x@4.16.1:
resolution: {integrity: sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -6581,9 +6558,6 @@ packages:
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
mdn-data@2.23.0:
resolution: {integrity: sha512-786vq1+4079JSeu2XdcDjrhi/Ry7BWtjDl9WtGPWLiIHb2T66GvIVflZTBoSNZ5JqTtJGYEVMuFA/lbQlMOyDQ==}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
@@ -7806,10 +7780,6 @@ packages:
resolution: {integrity: sha512-2SG1TnJGjMkD4+gblONMGYSrwAzYi+ymOitD+Jb/iMYm57nH20PlkVeMQRah3yDMKEa0QQYUF/QPWpdW7C6zNg==}
engines: {node: ^14.18.0 || >=16.0.0}
synckit@0.11.12:
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
engines: {node: ^14.18.0 || >=16.0.0}
table@6.9.0:
resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
engines: {node: '>=10.0.0'}
@@ -7818,10 +7788,6 @@ packages:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwind-csstree@0.1.4:
resolution: {integrity: sha512-FzD187HuFIZEyeR7Xy6sJbJll2d4SybS90satC8SKIuaNRC05CxMvdzN7BUsfDQffcnabckRM5OIcfArjsZ0mg==}
engines: {node: '>=18.18'}
tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
@@ -7952,10 +7918,6 @@ packages:
ts-map@1.0.3:
resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==}
tsconfig-paths-webpack-plugin@4.2.0:
resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==}
engines: {node: '>=10.13.0'}
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
@@ -9746,11 +9708,6 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
'@eslint/css-tree@3.6.9':
dependencies:
mdn-data: 2.23.0
source-map-js: 1.2.1
'@eslint/eslintrc@3.3.3':
dependencies:
ajv: 6.14.0
@@ -13500,23 +13457,6 @@ snapshots:
- supports-color
optional: true
eslint-plugin-better-tailwindcss@4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.49.0(oxlint-tsgolint@0.14.2))(tailwindcss@4.2.0)(typescript@5.9.3):
dependencies:
'@eslint/css-tree': 3.6.9
'@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3))
enhanced-resolve: 5.19.0
jiti: 2.6.1
synckit: 0.11.12
tailwind-csstree: 0.1.4
tailwindcss: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
valibot: 1.2.0(typescript@5.9.3)
optionalDependencies:
eslint: 9.39.1(jiti@2.6.1)
oxlint: 1.49.0(oxlint-tsgolint@0.14.2)
transitivePeerDependencies:
- typescript
eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)):
dependencies:
'@typescript-eslint/types': 8.56.0
@@ -14953,8 +14893,6 @@ snapshots:
mdn-data@2.12.2: {}
mdn-data@2.23.0: {}
mdurl@2.0.0: {}
media-encoder-host-broker@8.0.19:
@@ -16633,10 +16571,6 @@ snapshots:
'@pkgr/core': 0.2.9
tslib: 2.8.1
synckit@0.11.12:
dependencies:
'@pkgr/core': 0.2.9
table@6.9.0:
dependencies:
ajv: 8.18.0
@@ -16647,8 +16581,6 @@ snapshots:
tagged-tag@1.0.0: {}
tailwind-csstree@0.1.4: {}
tailwind-merge@2.6.0: {}
tailwindcss-primeui@0.6.1(tailwindcss@4.2.0):
@@ -16759,13 +16691,6 @@ snapshots:
ts-map@1.0.3: {}
tsconfig-paths-webpack-plugin@4.2.0:
dependencies:
chalk: 4.1.2
enhanced-resolve: 5.19.0
tapable: 2.3.0
tsconfig-paths: 4.2.0
tsconfig-paths@3.15.0:
dependencies:
'@types/json5': 0.0.29

View File

@@ -61,7 +61,6 @@ catalog:
eslint: ^9.39.1
eslint-config-prettier: ^10.1.8
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.25.0
eslint-plugin-storybook: ^10.2.10

View File

@@ -1,14 +1,4 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import type { MetricStats } from './perf-stats'
import {
classifyChange,
computeStats,
formatSignificance,
isNoteworthy,
zScore
} from './perf-stats'
import { existsSync, readFileSync } from 'node:fs'
interface PerfMeasurement {
name: string
@@ -30,76 +20,12 @@ interface PerfReport {
const CURRENT_PATH = 'test-results/perf-metrics.json'
const BASELINE_PATH = 'temp/perf-baseline/perf-metrics.json'
const HISTORY_DIR = 'temp/perf-history'
type MetricKey = 'styleRecalcs' | 'layouts' | 'taskDurationMs'
const REPORTED_METRICS: { key: MetricKey; label: string; unit: string }[] = [
{ key: 'styleRecalcs', label: 'style recalcs', unit: '' },
{ key: 'layouts', label: 'layouts', unit: '' },
{ key: 'taskDurationMs', label: 'task duration', unit: 'ms' }
]
function groupByName(
measurements: PerfMeasurement[]
): Map<string, PerfMeasurement[]> {
const map = new Map<string, PerfMeasurement[]>()
for (const m of measurements) {
const list = map.get(m.name) ?? []
list.push(m)
map.set(m.name, list)
}
return map
}
function loadHistoricalReports(): PerfReport[] {
if (!existsSync(HISTORY_DIR)) return []
const reports: PerfReport[] = []
for (const dir of readdirSync(HISTORY_DIR)) {
const filePath = join(HISTORY_DIR, dir, 'perf-metrics.json')
if (!existsSync(filePath)) continue
try {
reports.push(JSON.parse(readFileSync(filePath, 'utf-8')) as PerfReport)
} catch {
console.warn(`Skipping malformed perf history: ${filePath}`)
}
}
return reports
}
function getHistoricalStats(
reports: PerfReport[],
testName: string,
metric: MetricKey
): MetricStats {
const values: number[] = []
for (const r of reports) {
const group = groupByName(r.measurements)
const samples = group.get(testName)
if (samples) {
const mean =
samples.reduce((sum, s) => sum + s[metric], 0) / samples.length
values.push(mean)
}
}
return computeStats(values)
}
function computeCV(stats: MetricStats): number {
return stats.mean > 0 ? (stats.stddev / stats.mean) * 100 : 0
}
function formatValue(value: number, unit: string): string {
return unit === 'ms' ? `${value.toFixed(0)}ms` : `${value.toFixed(0)}`
}
function formatDelta(pct: number | null): string {
if (pct === null) return '—'
const sign = pct >= 0 ? '+' : ''
return `${sign}${pct.toFixed(0)}%`
}
function meanMetric(samples: PerfMeasurement[], key: MetricKey): number {
return samples.reduce((sum, s) => sum + s[key], 0) / samples.length
function formatDelta(pct: number): string {
if (pct >= 20) return `+${pct.toFixed(0)}% 🔴`
if (pct >= 10) return `+${pct.toFixed(0)}% 🟠`
if (pct > -10) return `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}% ⚪`
return `${pct.toFixed(0)}% 🟢`
}
function formatBytes(bytes: number): string {
@@ -108,167 +34,18 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function renderFullReport(
prGroups: Map<string, PerfMeasurement[]>,
baseline: PerfReport,
historical: PerfReport[]
): string[] {
const lines: string[] = []
const baselineGroups = groupByName(baseline.measurements)
const tableHeader = [
'| Metric | Baseline | PR (n=3) | Δ | Sig |',
'|--------|----------|----------|---|-----|'
]
const flaggedRows: string[] = []
const allRows: string[] = []
for (const [testName, prSamples] of prGroups) {
const baseSamples = baselineGroups.get(testName)
for (const { key, label, unit } of REPORTED_METRICS) {
const prValues = prSamples.map((s) => s[key])
const prMean = prValues.reduce((a, b) => a + b, 0) / prValues.length
const histStats = getHistoricalStats(historical, testName, key)
const cv = computeCV(histStats)
if (!baseSamples?.length) {
allRows.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new | — |`
)
continue
}
const baseVal = meanMetric(baseSamples, key)
const deltaPct =
baseVal === 0
? prMean === 0
? 0
: null
: ((prMean - baseVal) / baseVal) * 100
const z = zScore(prMean, histStats)
const sig = classifyChange(z, cv)
const row = `| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} | ${formatSignificance(sig, z)} |`
allRows.push(row)
if (isNoteworthy(sig)) {
flaggedRows.push(row)
}
}
function calcDelta(
baseline: number,
current: number
): { pct: number; isNew: boolean } {
if (baseline > 0) {
return { pct: ((current - baseline) / baseline) * 100, isNew: false }
}
if (flaggedRows.length > 0) {
lines.push(
`⚠️ **${flaggedRows.length} regression${flaggedRows.length > 1 ? 's' : ''} detected**`,
'',
...tableHeader,
...flaggedRows,
''
)
} else {
lines.push('No regressions detected.', '')
}
lines.push(
`<details><summary>All metrics</summary>`,
'',
...tableHeader,
...allRows,
'',
'</details>',
''
)
lines.push(
`<details><summary>Historical variance (last ${historical.length} runs)</summary>`,
'',
'| Metric | μ | σ | CV |',
'|--------|---|---|-----|'
)
for (const [testName] of prGroups) {
for (const { key, label, unit } of REPORTED_METRICS) {
const stats = getHistoricalStats(historical, testName, key)
if (stats.n < 2) continue
const cv = computeCV(stats)
lines.push(
`| ${testName}: ${label} | ${formatValue(stats.mean, unit)} | ${formatValue(stats.stddev, unit)} | ${cv.toFixed(1)}% |`
)
}
}
lines.push('', '</details>')
return lines
return current > 0 ? { pct: Infinity, isNew: true } : { pct: 0, isNew: false }
}
function renderColdStartReport(
prGroups: Map<string, PerfMeasurement[]>,
baseline: PerfReport,
historicalCount: number
): string[] {
const lines: string[] = []
const baselineGroups = groupByName(baseline.measurements)
lines.push(
`> Collecting baseline variance data (${historicalCount}/5 runs). Significance will appear after 2 main branch runs.`,
'',
'| Metric | Baseline | PR | Δ |',
'|--------|----------|-----|---|'
)
for (const [testName, prSamples] of prGroups) {
const baseSamples = baselineGroups.get(testName)
for (const { key, label, unit } of REPORTED_METRICS) {
const prValues = prSamples.map((s) => s[key])
const prMean = prValues.reduce((a, b) => a + b, 0) / prValues.length
if (!baseSamples?.length) {
lines.push(
`| ${testName}: ${label} | — | ${formatValue(prMean, unit)} | new |`
)
continue
}
const baseVal = meanMetric(baseSamples, key)
const deltaPct =
baseVal === 0
? prMean === 0
? 0
: null
: ((prMean - baseVal) / baseVal) * 100
lines.push(
`| ${testName}: ${label} | ${formatValue(baseVal, unit)} | ${formatValue(prMean, unit)} | ${formatDelta(deltaPct)} |`
)
}
}
return lines
}
function renderNoBaselineReport(
prGroups: Map<string, PerfMeasurement[]>
): string[] {
const lines: string[] = []
lines.push(
'No baseline found — showing absolute values.\n',
'| Metric | Value |',
'|--------|-------|'
)
for (const [testName, prSamples] of prGroups) {
const prMean = (key: MetricKey) =>
prSamples.reduce((sum, s) => sum + s[key], 0) / prSamples.length
lines.push(
`| ${testName}: style recalcs | ${prMean('styleRecalcs').toFixed(0)} |`
)
lines.push(`| ${testName}: layouts | ${prMean('layouts').toFixed(0)} |`)
lines.push(
`| ${testName}: task duration | ${prMean('taskDurationMs').toFixed(0)}ms |`
)
const heapMean =
prSamples.reduce((sum, s) => sum + s.heapDeltaBytes, 0) / prSamples.length
lines.push(`| ${testName}: heap delta | ${formatBytes(heapMean)} |`)
}
return lines
function formatDeltaCell(delta: { pct: number; isNew: boolean }): string {
return delta.isNew ? 'new 🔴' : formatDelta(delta.pct)
}
function main() {
@@ -285,18 +62,55 @@ function main() {
? JSON.parse(readFileSync(BASELINE_PATH, 'utf-8'))
: null
const historical = loadHistoricalReports()
const prGroups = groupByName(current.measurements)
const lines: string[] = []
lines.push('## ⚡ Performance Report\n')
if (baseline && historical.length >= 2) {
lines.push(...renderFullReport(prGroups, baseline, historical))
} else if (baseline) {
lines.push(...renderColdStartReport(prGroups, baseline, historical.length))
if (baseline) {
lines.push(
'| Metric | Baseline | PR | Δ |',
'|--------|----------|-----|---|'
)
for (const m of current.measurements) {
const base = baseline.measurements.find((b) => b.name === m.name)
if (!base) {
lines.push(`| ${m.name}: style recalcs | — | ${m.styleRecalcs} | new |`)
lines.push(`| ${m.name}: layouts | — | ${m.layouts} | new |`)
lines.push(
`| ${m.name}: task duration | — | ${m.taskDurationMs.toFixed(0)}ms | new |`
)
continue
}
const recalcDelta = calcDelta(base.styleRecalcs, m.styleRecalcs)
lines.push(
`| ${m.name}: style recalcs | ${base.styleRecalcs} | ${m.styleRecalcs} | ${formatDeltaCell(recalcDelta)} |`
)
const layoutDelta = calcDelta(base.layouts, m.layouts)
lines.push(
`| ${m.name}: layouts | ${base.layouts} | ${m.layouts} | ${formatDeltaCell(layoutDelta)} |`
)
const taskDelta = calcDelta(base.taskDurationMs, m.taskDurationMs)
lines.push(
`| ${m.name}: task duration | ${base.taskDurationMs.toFixed(0)}ms | ${m.taskDurationMs.toFixed(0)}ms | ${formatDeltaCell(taskDelta)} |`
)
}
} else {
lines.push(...renderNoBaselineReport(prGroups))
lines.push(
'No baseline found — showing absolute values.\n',
'| Metric | Value |',
'|--------|-------|'
)
for (const m of current.measurements) {
lines.push(`| ${m.name}: style recalcs | ${m.styleRecalcs} |`)
lines.push(`| ${m.name}: layouts | ${m.layouts} |`)
lines.push(
`| ${m.name}: task duration | ${m.taskDurationMs.toFixed(0)}ms |`
)
lines.push(`| ${m.name}: heap delta | ${formatBytes(m.heapDeltaBytes)} |`)
}
}
lines.push('\n<details><summary>Raw data</summary>\n')

View File

@@ -1,133 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
classifyChange,
computeStats,
formatSignificance,
isNoteworthy,
zScore
} from './perf-stats'
describe('computeStats', () => {
it('returns zeros for empty array', () => {
const stats = computeStats([])
expect(stats).toEqual({ mean: 0, stddev: 0, min: 0, max: 0, n: 0 })
})
it('returns value with zero stddev for single element', () => {
const stats = computeStats([42])
expect(stats).toEqual({ mean: 42, stddev: 0, min: 42, max: 42, n: 1 })
})
it('computes correct stats for known values', () => {
// Values: [2, 4, 4, 4, 5, 5, 7, 9]
// Mean = 5, sample variance ≈ 4.57, sample stddev ≈ 2.14
const stats = computeStats([2, 4, 4, 4, 5, 5, 7, 9])
expect(stats.mean).toBe(5)
expect(stats.stddev).toBeCloseTo(2.138, 2)
expect(stats.min).toBe(2)
expect(stats.max).toBe(9)
expect(stats.n).toBe(8)
})
it('uses sample stddev (n-1 denominator)', () => {
// [10, 20] → mean=15, variance=(25+25)/1=50, stddev≈7.07
const stats = computeStats([10, 20])
expect(stats.mean).toBe(15)
expect(stats.stddev).toBeCloseTo(7.071, 2)
expect(stats.n).toBe(2)
})
it('handles identical values', () => {
const stats = computeStats([5, 5, 5, 5])
expect(stats.mean).toBe(5)
expect(stats.stddev).toBe(0)
})
})
describe('zScore', () => {
it('returns null when stddev is 0', () => {
const stats = computeStats([5, 5, 5])
expect(zScore(10, stats)).toBeNull()
})
it('returns null when n < 2', () => {
const stats = computeStats([5])
expect(zScore(10, stats)).toBeNull()
})
it('computes correct z-score', () => {
const stats = { mean: 100, stddev: 10, min: 80, max: 120, n: 5 }
expect(zScore(120, stats)).toBe(2)
expect(zScore(80, stats)).toBe(-2)
expect(zScore(100, stats)).toBe(0)
})
})
describe('classifyChange', () => {
it('returns noisy when CV > 50%', () => {
expect(classifyChange(3, 60)).toBe('noisy')
expect(classifyChange(-3, 51)).toBe('noisy')
})
it('does not classify as noisy when CV is exactly 50%', () => {
expect(classifyChange(3, 50)).toBe('regression')
expect(classifyChange(-3, 50)).toBe('improvement')
})
it('returns neutral when z is null', () => {
expect(classifyChange(null, 10)).toBe('neutral')
})
it('returns regression when z > 2', () => {
expect(classifyChange(2.1, 10)).toBe('regression')
expect(classifyChange(5, 10)).toBe('regression')
})
it('returns improvement when z < -2', () => {
expect(classifyChange(-2.1, 10)).toBe('improvement')
expect(classifyChange(-5, 10)).toBe('improvement')
})
it('returns neutral when z is within [-2, 2]', () => {
expect(classifyChange(0, 10)).toBe('neutral')
expect(classifyChange(1.9, 10)).toBe('neutral')
expect(classifyChange(-1.9, 10)).toBe('neutral')
expect(classifyChange(2, 10)).toBe('neutral')
expect(classifyChange(-2, 10)).toBe('neutral')
})
})
describe('formatSignificance', () => {
it('formats regression with z-score and emoji', () => {
expect(formatSignificance('regression', 3.2)).toBe('⚠️ z=3.2')
})
it('formats improvement with z-score without emoji', () => {
expect(formatSignificance('improvement', -2.5)).toBe('z=-2.5')
})
it('formats noisy as descriptive text', () => {
expect(formatSignificance('noisy', null)).toBe('variance too high')
})
it('formats neutral with z-score without emoji', () => {
expect(formatSignificance('neutral', 0.5)).toBe('z=0.5')
})
it('formats neutral without z-score as dash', () => {
expect(formatSignificance('neutral', null)).toBe('—')
})
})
describe('isNoteworthy', () => {
it('returns true for regressions', () => {
expect(isNoteworthy('regression')).toBe(true)
})
it('returns false for non-regressions', () => {
expect(isNoteworthy('improvement')).toBe(false)
expect(isNoteworthy('neutral')).toBe(false)
expect(isNoteworthy('noisy')).toBe(false)
})
})

View File

@@ -1,63 +0,0 @@
export interface MetricStats {
mean: number
stddev: number
min: number
max: number
n: number
}
export function computeStats(values: number[]): MetricStats {
const n = values.length
if (n === 0) return { mean: 0, stddev: 0, min: 0, max: 0, n: 0 }
if (n === 1)
return { mean: values[0], stddev: 0, min: values[0], max: values[0], n: 1 }
const mean = values.reduce((a, b) => a + b, 0) / n
const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (n - 1)
return {
mean,
stddev: Math.sqrt(variance),
min: Math.min(...values),
max: Math.max(...values),
n
}
}
export function zScore(value: number, stats: MetricStats): number | null {
if (stats.stddev === 0 || stats.n < 2) return null
return (value - stats.mean) / stats.stddev
}
export type Significance = 'regression' | 'improvement' | 'neutral' | 'noisy'
export function classifyChange(
z: number | null,
historicalCV: number
): Significance {
if (historicalCV > 50) return 'noisy'
if (z === null) return 'neutral'
if (z > 2) return 'regression'
if (z < -2) return 'improvement'
return 'neutral'
}
export function formatSignificance(
sig: Significance,
z: number | null
): string {
switch (sig) {
case 'regression':
return `⚠️ z=${z!.toFixed(1)}`
case 'improvement':
return `z=${z!.toFixed(1)}`
case 'noisy':
return 'variance too high'
case 'neutral':
return z !== null ? `z=${z.toFixed(1)}` : '—'
}
}
export function isNoteworthy(sig: Significance): boolean {
return sig === 'regression'
}

View File

@@ -64,7 +64,7 @@
layout="vertical"
:pt:gutter="
cn(
'rounded-tl-lg rounded-tr-lg',
'rounded-tl-lg rounded-tr-lg ',
!(bottomPanelVisible && !focusMode) && 'hidden'
)
"

View File

@@ -296,13 +296,11 @@ describe('TopMenuSection', () => {
describe('inline progress summary', () => {
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
qpoV2Enabled: boolean,
showRunProgressBar = true
qpoV2Enabled: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.Queue.ShowRunProgressBar') return showRunProgressBar
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
})
@@ -334,19 +332,6 @@ describe('TopMenuSection', () => {
).toBe(false)
})
it('does not render inline progress summary when run progress bar is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true, false)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
})
it('teleports inline progress summary when actionbar is floating', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const actionbarTarget = document.createElement('div')

View File

@@ -125,7 +125,6 @@ import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -165,16 +164,14 @@ const isActionbarFloating = computed(
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const isQueueProgressOverlayEnabled = computed(
() => !isQueuePanelV2Enabled.value
)
const shouldShowInlineProgressSummary = computed(
() =>
isQueuePanelV2Enabled.value &&
isActionbarEnabled.value &&
isRunProgressBarEnabled.value
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
)
const shouldShowQueueNotificationBanners = computed(
() => isActionbarEnabled.value

View File

@@ -1,101 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import { i18n } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
showRunProgressBar: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.UseNewMenu') return 'Top'
if (key === 'Comfy.Queue.QPOV2') return true
if (key === 'Comfy.Queue.ShowRunProgressBar') return showRunProgressBar
return undefined
})
}
const mountActionbar = (showRunProgressBar: boolean) => {
const topMenuContainer = document.createElement('div')
document.body.appendChild(topMenuContainer)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, showRunProgressBar)
const wrapper = mount(ComfyActionbar, {
attachTo: document.body,
props: {
topMenuContainer,
queueOverlayExpanded: false
},
global: {
plugins: [pinia, i18n],
stubs: {
ContextMenu: {
name: 'ContextMenu',
template: '<div />'
},
Panel: {
name: 'Panel',
template: '<div><slot /></div>'
},
StatusBadge: true,
ComfyRunButton: {
name: 'ComfyRunButton',
template: '<button type="button">Run</button>'
},
QueueInlineProgress: true
},
directives: {
tooltip: () => {}
}
}
})
return {
wrapper,
topMenuContainer
}
}
describe('ComfyActionbar', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
localStorage.clear()
})
it('teleports inline progress when run progress bar is enabled', async () => {
const { wrapper, topMenuContainer } = mountActionbar(true)
try {
await nextTick()
expect(
topMenuContainer.querySelector('[data-testid="queue-inline-progress"]')
).not.toBeNull()
} finally {
wrapper.unmount()
topMenuContainer.remove()
}
})
it('does not teleport inline progress when run progress bar is disabled', async () => {
const { wrapper, topMenuContainer } = mountActionbar(false)
try {
await nextTick()
expect(
topMenuContainer.querySelector('[data-testid="queue-inline-progress"]')
).toBeNull()
} finally {
wrapper.unmount()
topMenuContainer.remove()
}
})
})

View File

@@ -107,7 +107,6 @@ 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 { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -128,7 +127,7 @@ const emit = defineEmits<{
(event: 'update:progressTarget', target: HTMLElement | null): void
}>()
const settingStore = useSettingStore()
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const queueStore = useQueueStore()
@@ -138,10 +137,11 @@ const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
const { activeJobsCount } = storeToRefs(queueStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const position = computed(() => settingStore.get('Comfy.UseNewMenu'))
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
const isQueuePanelV2Enabled = computed(() =>
settingsStore.get('Comfy.Queue.QPOV2')
)
const panelRef = ref<ComponentPublicInstance | null>(null)
const panelElement = computed<HTMLElement | null>(() => {
@@ -325,13 +325,7 @@ const onMouseLeaveDropZone = () => {
}
const inlineProgressTarget = computed(() => {
if (
!visible.value ||
!isQueuePanelV2Enabled.value ||
!isRunProgressBarEnabled.value
) {
return null
}
if (!visible.value || !isQueuePanelV2Enabled.value) return null
if (isDocked.value) return topMenuContainer ?? null
return panelElement.value
})
@@ -367,13 +361,7 @@ const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(
t(
isQueuePanelV2Enabled.value
? 'sideToolbar.queueProgressOverlay.viewJobHistory'
: 'sideToolbar.queueProgressOverlay.expandCollapsedQueue'
)
)
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value

View File

@@ -21,8 +21,8 @@ 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 openAssets() {
@@ -30,7 +30,7 @@ function openAssets() {
}
function showApps() {
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
void commandStore.execute('Workspace.ToggleSidebarTab.workflows')
}
function openTemplates() {
@@ -104,7 +104,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

@@ -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,6 +1,6 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed, provide, ref, toValue } from 'vue'
import { computed, provide, ref, toValue, watchEffect } from 'vue'
import type { MaybeRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -25,7 +25,6 @@ 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'
@@ -39,20 +38,39 @@ const workflowStore = useWorkflowStore()
const { t } = useI18n()
const canvas: LGraphCanvas = canvasStore.getCanvas()
const { isSelectMode, isSelectInputsMode, isSelectOutputsMode, isArrangeMode } =
useAppMode()
const { isSelectMode, isArrangeMode } = useAppMode()
const hoveringSelectable = ref(false)
provide(HideLayoutFieldKey, true)
workflowStore.activeWorkflow?.changeTracker?.reset()
function resolveNode(nodeId: NodeId) {
return (
app.rootGraph.getNodeById(nodeId) ??
[...app.rootGraph.subgraphs.values()]
.flatMap((sg) => sg.nodes)
.find((n) => n.id == nodeId)
)
}
// Prune stale entries whose node/widget no longer exists, so the
// DraggableList model always matches the rendered items.
watchEffect(() => {
const valid = appModeStore.selectedInputs.filter(([nodeId, widgetName]) =>
resolveNode(nodeId)?.widgets?.some((w) => w.name === widgetName)
)
if (valid.length < appModeStore.selectedInputs.length) {
appModeStore.selectedInputs = valid
}
})
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)
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) return null
return { nodeId, widgetName, node, widget }
})
.filter((item): item is NonNullable<typeof item> => item !== null)
@@ -62,13 +80,7 @@ const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const node = resolveNode(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))
@@ -162,7 +174,6 @@ function handleClick(e: MouseEvent) {
if (!node) return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
if (!isSelectOutputsMode.value) return
if (!node.constructor.nodeData?.output_node)
return canvasInteractions.forwardEventToCanvas(e)
const index = appModeStore.selectedOutputs.findIndex((id) => id == node.id)
@@ -170,7 +181,6 @@ function handleClick(e: MouseEvent) {
else appModeStore.selectedOutputs.splice(index, 1)
return
}
if (!isSelectInputsMode.value) return
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
@@ -218,9 +228,9 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
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}`"
:aria-label="`${widget.label ?? widgetName} ${node.title}`"
>
<div v-if="widget" class="pointer-events-none" inert>
<div class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
@@ -228,16 +238,10 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
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-if="isSelectInputsMode"
v-else
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
@@ -286,7 +290,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="isSelectOutputsMode"
v-if="!isArrangeMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
@@ -347,46 +351,42 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
@wheel="canvasInteractions.forwardEventToCanvas"
>
<TransformPane :canvas="canvasStore.getCanvas()">
<template v-if="isSelectInputsMode">
<div
v-for="[key, style] in renderedInputs"
:key
:style="toValue(style)"
class="fixed bg-primary-background/30 rounded-lg"
/>
</template>
<template v-else>
<div
v-for="[key, style, isSelected] in renderedOutputs"
:key
:style="toValue(style)"
:class="
cn(
'fixed ring-warning-background ring-5 rounded-2xl',
!isSelected && 'ring-warning-background/50'
)
"
>
<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
>
<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
/>
<div
v-for="[key, style] in renderedInputs"
:key
:style="toValue(style)"
class="fixed bg-primary-background/30 rounded-lg"
/>
<div
v-for="[key, style, isSelected] in renderedOutputs"
:key
:style="toValue(style)"
:class="
cn(
'fixed ring-warning-background ring-5 rounded-2xl',
!isSelected && 'ring-warning-background/50'
)
"
>
<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
>
<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
/>
</div>
</template>
</div>
</TransformPane>
</div>
</Teleport>

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,29 +1,15 @@
<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"
<div
class="fixed bottom-4 left-1/2 z-1000 flex -translate-x-1/2 items-center rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<Button variant="textonly" size="lg" @click="onExitBuilder">
<Button 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>
</div>
</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'
@@ -31,16 +17,10 @@ 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 (

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:inputs'
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:inputs'
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 inputs step', () => {
mockState.mode = 'builder:inputs'
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:outputs')
})
it('calls setMode on next click from inputs step', async () => {
mockState.mode = 'builder:inputs'
const { next } = getButtons(mountComponent())
await next.trigger('click')
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
})
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

@@ -32,7 +32,7 @@
@click="onSave(close)"
>
<i class="icon-[lucide--save] size-4" />
{{ t('g.save') }}
{{ t('builderMenu.saveApp') }}
</button>
<div class="my-1 border-t border-border-default" />
<button
@@ -51,28 +51,19 @@ 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'
import { useBuilderSave } from './useBuilderSave'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const { toastErrorHandler } = useErrorHandling()
const { setSaving } = useBuilderSave()
async function onSave(close: () => void) {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
try {
await workflowService.saveWorkflow(workflow)
close()
} catch (error) {
toastErrorHandler(error)
}
function onSave(close: () => void) {
setSaving(true)
close()
}
function onExitBuilder(close: () => void) {

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

@@ -6,18 +6,21 @@
<div
class="inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<template v-for="(step, index) in steps" :key="step.id">
<template
v-for="(step, index) in [selectStep, arrangeStep]"
:key="step.id"
>
<button
:class="
cn(
stepClasses,
activeStep === step.id
? 'bg-interface-builder-mode-background'
: 'hover:bg-secondary-background bg-transparent'
activeStep === step.id && 'bg-interface-builder-mode-background',
activeStep !== step.id &&
'hover:bg-secondary-background bg-transparent'
)
"
:aria-current="activeStep === step.id ? 'step' : undefined"
@click="navigateToStep(step.id)"
@click="setMode(step.id)"
>
<StepBadge :step :index :model-value="activeStep" />
<StepLabel :step />
@@ -26,19 +29,15 @@
<div class="mx-1 h-px w-4 bg-border-default" role="separator" />
</template>
<!-- Default view -->
<!-- Save -->
<ConnectOutputPopover
v-if="!hasOutputs"
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
:is-select-active="activeStep === 'builder:select'"
@switch="setMode('builder:select')"
>
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
<StepBadge
:step="defaultViewStep"
:index="steps.length"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
<StepLabel :step="saveStep" />
</button>
</ConnectOutputPopover>
<button
@@ -46,72 +45,64 @@
:class="
cn(
stepClasses,
activeStep === 'setDefaultView'
activeStep === 'save'
? 'bg-interface-builder-mode-background'
: 'hover:bg-secondary-background bg-transparent'
)
"
@click="navigateToStep('setDefaultView')"
@click="setSaving(true)"
>
<StepBadge
:step="defaultViewStep"
:index="steps.length"
: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 { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
import { useBuilderSave } from './useBuilderSave'
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'
import { storeToRefs } from 'pinia'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { activeStep, isSelectStep, navigateToStep } = useBuilderSteps()
const { mode, setMode } = useAppMode()
const { hasOutputs } = storeToRefs(useAppModeStore())
const { saving, setSaving } = useBuilderSave()
const activeStep = computed(() => (saving.value ? 'save' : mode.value))
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 selectInputsStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:inputs',
title: t('builderToolbar.inputs'),
subtitle: t('builderToolbar.inputsDescription'),
const selectStep: BuilderToolbarStep<AppMode> = {
id: 'builder:select',
title: t('builderToolbar.select'),
subtitle: t('builderToolbar.selectDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
}
const selectOutputsStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:outputs',
title: t('builderToolbar.outputs'),
subtitle: t('builderToolbar.outputsDescription'),
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]'
}
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
</script>

View File

@@ -46,7 +46,7 @@
</PopoverClose>
<PopoverClose as-child>
<Button variant="secondary" size="md" @click="emit('switch')">
{{ t('builderToolbar.switchToOutputs') }}
{{ t('builderToolbar.switchToSelect') }}
</Button>
</PopoverClose>
</template>

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,13 +32,10 @@ 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"
<div class="p-2 my-2 rounded-lg flex items-center-safe">
<span class="mr-auto truncate shrink-1" v-text="title" />
<span
class="text-muted-foreground mr-2 text-end truncate shrink-3"
v-text="subTitle"
/>
<Popover :entries>

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,134 @@
import { ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
import { whenever } from '@vueuse/core'
const SAVE_DIALOG_KEY = 'builder-save'
const SUCCESS_DIALOG_KEY = 'builder-save-success'
export function useBuilderSave() {
const { setMode } = useAppMode()
const { toastErrorHandler } = useErrorHandling()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const dialogService = useDialogService()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
const saving = ref(false)
whenever(saving, onBuilderSave)
function setSaving(value: boolean) {
saving.value = value
}
async function onBuilderSave() {
const workflow = workflowStore.activeWorkflow
if (!workflow) {
resetSaving()
return
}
if (!workflow.isTemporary && workflow.initialMode != null) {
// Re-save with the previously chosen mode — no dialog needed.
try {
appModeStore.flushSelections()
await workflowService.saveWorkflow(workflow)
showSuccessDialog(workflow.filename, workflow.initialMode === 'app')
} catch (e) {
toastErrorHandler(e)
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.flushSelections()
const mode = openAsApp ? 'app' : 'graph'
const saved = await workflowService.saveWorkflowAs(workflow, {
filename,
initialMode: mode
})
if (!saved) return
closeSaveDialog()
showSuccessDialog(filename, openAsApp)
} catch (e) {
toastErrorHandler(e)
closeSaveDialog()
resetSaving()
}
}
function showSuccessDialog(workflowName: string, savedAsApp: boolean) {
dialogService.showLayoutDialog({
key: SUCCESS_DIALOG_KEY,
component: BuilderSaveSuccessDialogContent,
props: {
workflowName,
savedAsApp,
onViewApp: () => {
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() {
saving.value = false
}
return { saving, setSaving }
}

View File

@@ -1,79 +0,0 @@
import type { Ref } from 'vue'
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useAppSetDefaultView } from './useAppSetDefaultView'
const BUILDER_STEPS = [
'builder:inputs',
'builder:outputs',
'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:inputs'
})
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
})
const isSelectStep = computed(
() =>
activeStep.value === 'builder:inputs' ||
activeStep.value === 'builder:outputs'
)
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,
isSelectStep,
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,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

@@ -1,6 +1,4 @@
<script setup lang="ts">
import { computed } from 'vue'
import { statusBadgeVariants } from './statusBadge.variants'
import type { StatusBadgeVariants } from './statusBadge.variants'
@@ -13,17 +11,17 @@ const {
severity?: StatusBadgeVariants['severity']
variant?: StatusBadgeVariants['variant']
}>()
const badgeClass = computed(() =>
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
})
)
</script>
<template>
<span :class="badgeClass">
<span
:class="
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
})
"
>
{{ label }}
</span>
</template>

View File

@@ -70,24 +70,6 @@ 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() }

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

@@ -39,8 +39,8 @@
<BottomPanel />
</template>
<template v-if="showUI" #right-side-panel>
<AppBuilder v-if="isBuilderMode" />
<NodePropertiesPanel v-else />
<AppBuilder v-if="mode === 'builder:select'" />
<NodePropertiesPanel v-else-if="!isBuilderMode" />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu
@@ -204,7 +204,7 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const { isBuilderMode } = useAppMode()
const { mode, isBuilderMode } = useAppMode()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()

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

@@ -1,123 +0,0 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { i18n } from '@/i18n'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
const popoverCloseSpy = vi.fn()
vi.mock('@/components/ui/Popover.vue', () => {
const PopoverStub = defineComponent({
name: 'Popover',
setup(_, { slots }) {
return () =>
h('div', [
slots.button?.(),
slots.default?.({
close: () => {
popoverCloseSpy()
}
})
])
}
})
return { default: PopoverStub }
})
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const mockGetSetting = vi.fn<(key: string) => boolean | undefined>((key) =>
key === 'Comfy.Queue.QPOV2' || key === 'Comfy.Queue.ShowRunProgressBar'
? 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
})
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => mockSidebarTabStore
}))
const mountMenu = () =>
mount(JobHistoryActionsMenu, {
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
describe('JobHistoryActionsMenu', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
popoverCloseSpy.mockClear()
mockSetSetting.mockClear()
mockSetMany.mockClear()
mockSidebarTabStore.activeSidebarTabId = null
mockGetSetting.mockImplementation((key: string) =>
key === 'Comfy.Queue.QPOV2' || key === 'Comfy.Queue.ShowRunProgressBar'
? true
: undefined
)
})
it('toggles show run progress bar setting from the menu', async () => {
const wrapper = mountMenu()
const showRunProgressBarButton = wrapper.get(
'[data-testid="show-run-progress-bar-action"]'
)
await showRunProgressBarButton.trigger('click')
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith(
'Comfy.Queue.ShowRunProgressBar',
false
)
})
it('opens docked job history sidebar when enabling from the menu', async () => {
mockGetSetting.mockImplementation((key: string) => {
if (key === 'Comfy.Queue.QPOV2') return false
if (key === 'Comfy.Queue.ShowRunProgressBar') return true
return undefined
})
const wrapper = mountMenu()
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('emits clear history from the menu', async () => {
const wrapper = mountMenu()
const clearHistoryButton = wrapper.get(
'[data-testid="clear-history-action"]'
)
await clearHistoryButton.trigger('click')
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
})

View File

@@ -19,7 +19,7 @@
data-testid="docked-job-history-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="md"
size="sm"
@click="onToggleDockedJobHistory(close)"
>
<span class="flex items-center gap-2">
@@ -35,32 +35,14 @@
class="icon-[lucide--check] size-4"
/>
</Button>
<Button
data-testid="show-run-progress-bar-action"
class="w-full justify-between text-sm font-light"
variant="textonly"
size="md"
@click="onToggleRunProgressBar"
>
<span class="flex items-center gap-2">
<i class="icon-[lucide--hourglass] size-4 text-text-secondary" />
<span>{{
t('sideToolbar.queueProgressOverlay.showRunProgressBar')
}}</span>
</span>
<i
v-if="isRunProgressBarEnabled"
class="icon-[lucide--check] size-4"
/>
</Button>
<!-- TODO: Bug in assets sidebar panel derives assets from history, so despite this not deleting the assets, it still effectively shows to the user as deleted -->
<template v-if="showClearHistoryAction">
<div class="my-1 border-t border-interface-stroke" />
<Button
data-testid="clear-history-action"
class="h-auto min-h-8 w-full items-start justify-start whitespace-normal"
class="h-auto min-h-0 w-full items-start justify-start whitespace-normal"
variant="textonly"
size="md"
size="sm"
@click="onClearHistoryFromMenu(close)"
>
<i
@@ -94,7 +76,6 @@ import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -109,8 +90,9 @@ const settingStore = useSettingStore()
const sidebarTabStore = useSidebarTabStore()
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const showClearHistoryAction = computed(() => !isCloud)
const onClearHistoryFromMenu = (close: () => void) => {
@@ -136,11 +118,4 @@ const onToggleDockedJobHistory = async (close: () => void) => {
return
}
}
const onToggleRunProgressBar = async () => {
await settingStore.set(
'Comfy.Queue.ShowRunProgressBar',
!isRunProgressBarEnabled.value
)
}
</script>

View File

@@ -1,9 +1,8 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { defineComponent, h } from 'vue'
import { i18n } from '@/i18n'
const popoverCloseSpy = vi.fn()
vi.mock('@/components/ui/Popover.vue', () => {
@@ -25,9 +24,7 @@ vi.mock('@/components/ui/Popover.vue', () => {
})
const mockGetSetting = vi.fn<(key: string) => boolean | undefined>((key) =>
key === 'Comfy.Queue.QPOV2' || key === 'Comfy.Queue.ShowRunProgressBar'
? true
: undefined
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const mockSetSetting = vi.fn()
const mockSetMany = vi.fn()
@@ -55,6 +52,27 @@ const tooltipDirectiveStub = {
updated: vi.fn()
}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { more: 'More' },
sideToolbar: {
queueProgressOverlay: {
queuedSuffix: 'queued',
clearQueued: 'Clear queued',
clearQueueTooltip: 'Clear queue',
clearAllJobsTooltip: 'Cancel all running jobs',
moreOptions: 'More options',
clearHistory: 'Clear history',
dockedJobHistory: 'Docked Job History'
}
}
}
}
})
const mountHeader = (props = {}) =>
mount(QueueOverlayHeader, {
props: {
@@ -70,7 +88,6 @@ const mountHeader = (props = {}) =>
describe('QueueOverlayHeader', () => {
beforeEach(() => {
i18n.global.locale.value = 'en'
popoverCloseSpy.mockClear()
mockSetSetting.mockClear()
mockSetMany.mockClear()
@@ -190,19 +207,4 @@ describe('QueueOverlayHeader', () => {
'Comfy.Queue.History.Expanded': true
})
})
it('toggles show run progress bar setting from the menu', async () => {
const wrapper = mountHeader()
const showRunProgressBarButton = wrapper.get(
'[data-testid="show-run-progress-bar-action"]'
)
await showRunProgressBarButton.trigger('click')
expect(mockSetSetting).toHaveBeenCalledTimes(1)
expect(mockSetSetting).toHaveBeenCalledWith(
'Comfy.Queue.ShowRunProgressBar',
false
)
})
})

View File

@@ -323,90 +323,6 @@ describe('useErrorGroups', () => {
)
expect(promptGroup).toBeDefined()
})
it('sorts cards within an execution group by nodeId numerically', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'10': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'2': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
const nodeIds = execGroup?.cards.map((c) => c.nodeId)
expect(nodeIds).toEqual(['1', '2', '10'])
})
it('sorts cards with subpath nodeIds before higher root IDs', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'2': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1:20': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
const nodeIds = execGroup?.cards.map((c) => c.nodeId)
expect(nodeIds).toEqual(['1', '1:20', '2'])
})
it('sorts deeply nested nodeIds by each segment numerically', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'10:11:99': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'10:11:12': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
},
'10:2': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'err', message: 'Error', details: '' }]
}
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
const nodeIds = execGroup?.cards.map((c) => c.nodeId)
expect(nodeIds).toEqual(['10:2', '10:11:12', '10:11:99'])
})
})
describe('filteredGroups', () => {

View File

@@ -23,10 +23,7 @@ import { st } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import {
isNodeExecutionId,
compareExecutionId
} from '@/types/nodeIdentification'
import { isNodeExecutionId } from '@/types/nodeIdentification'
const PROMPT_CARD_ID = '__prompt__'
const SINGLE_GROUP_KEY = '__single__'
@@ -154,16 +151,12 @@ function addCardErrorToGroup(
group.get(card.id)?.errors.push(error)
}
function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
return compareExecutionId(a.nodeId, b.nodeId)
}
function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
return Array.from(groupsMap.entries())
.map(([title, groupData]) => ({
type: 'execution' as const,
title,
cards: Array.from(groupData.cards.values()).sort(compareNodeId),
cards: Array.from(groupData.cards.values()),
priority: groupData.priority
}))
.sort((a, b) => {

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
const { t } = useI18n()
const canvasStore = useCanvasStore()
const keybindingStore = useKeybindingStore()
const keybindingSuffix = computed(() => {
const shortcut = keybindingStore
.getKeybindingByCommandId('Comfy.ToggleLinear')
?.combo.toString()
return shortcut ? t('g.shortcutSuffix', { shortcut }) : ''
})
function toggleLinearMode() {
useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source: 'button' }
})
}
</script>
<template>
<div
data-testid="mode-toggle"
class="p-1 bg-secondary-background rounded-lg w-10"
>
<Button
v-tooltip="{
value: t('linearMode.linearMode') + keybindingSuffix,
showDelay: 300,
hideDelay: 300
}"
size="icon"
:variant="canvasStore.linearMode ? 'inverted' : 'secondary'"
@click="toggleLinearMode"
>
<i class="icon-[lucide--panels-top-left]" />
</Button>
<Button
v-tooltip="{
value: t('linearMode.graphMode') + keybindingSuffix,
showDelay: 300,
hideDelay: 300
}"
size="icon"
:variant="canvasStore.linearMode ? 'secondary' : 'inverted'"
@click="toggleLinearMode"
>
<i class="icon-[comfy--workflow]" />
</Button>
</div>
</template>

View File

@@ -1,48 +0,0 @@
<template>
<BaseWorkflowsSidebarTab
:title="$t('linearMode.appModeToolbar.apps')"
:filter="isAppWorkflow"
:label-transform="stripAppJsonSuffix"
hide-leaf-icon
:search-subject="$t('linearMode.appModeToolbar.apps')"
data-testid="apps-sidebar"
>
<template #alt-title>
<span
class="ml-2 flex items-center rounded-full bg-primary-background px-1.5 py-0.5 text-xxs uppercase text-base-foreground"
>
{{ $t('g.beta') }}
</span>
</template>
<template #empty-state>
<NoResultsPlaceholder
button-variant="secondary"
text-class="text-muted-foreground text-sm"
:message="$t('linearMode.appModeToolbar.appsEmptyMessage')"
:button-label="$t('linearMode.appModeToolbar.enterAppMode')"
@action="enterAppMode"
/>
</template>
</BaseWorkflowsSidebarTab>
</template>
<script setup lang="ts">
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue'
import { useAppMode } from '@/composables/useAppMode'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
const { setMode } = useAppMode()
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
return workflow.suffix === 'app.json'
}
function stripAppJsonSuffix(label: string): string {
return label.replace(/\.app\.json$/i, '')
}
function enterAppMode() {
setMode('app')
}
</script>

View File

@@ -81,7 +81,7 @@ const assetItems = computed<AssetGridItem[]>(() =>
const gridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(min(200px, 30vw), 1fr))',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0 0.5rem',
gap: '0.5rem'
}

View File

@@ -1,352 +0,0 @@
<template>
<SidebarTabTemplate
:title="title"
v-bind="$attrs"
:data-testid="dataTestid"
class="workflows-sidebar-tab"
>
<template #alt-title>
<slot name="alt-title" />
</template>
<template #tool-buttons>
<Button
v-tooltip.bottom="$t('g.refresh')"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.refresh')"
@click="workflowStore.syncWorkflows()"
>
<i class="icon-[lucide--refresh-cw] size-4" />
</Button>
</template>
<template #header>
<div class="px-2 2xl:px-4">
<SearchBox
ref="searchBoxRef"
v-model:model-value="searchQuery"
class="workflows-search-box"
:placeholder="$t('g.searchPlaceholder', { subject: searchSubject })"
@search="handleSearch"
/>
</div>
</template>
<template #body>
<div v-if="!isSearching" class="comfyui-workflows-panel">
<div
v-if="workflowTabsPosition === 'Sidebar'"
class="comfyui-workflows-open"
>
<TextDivider
:text="t('sideToolbar.workflowTab.workflowTreeType.open')"
type="dashed"
class="ml-2"
/>
<TreeExplorer
v-model:expanded-keys="dummyExpandedKeys"
:root="renderTreeNode(openWorkflowsTree, WorkflowTreeType.Open)"
:selection-keys="selectionKeys"
>
<template #node="{ node }">
<TreeExplorerTreeNode :node="node">
<template #before-label="{ node: treeNode }">
<span
v-if="
(treeNode.data as ComfyWorkflow)?.isModified ||
!(treeNode.data as ComfyWorkflow)?.isPersisted
"
>*</span
>
</template>
<template #actions="{ node: treeNode }">
<Button
class="close-workflow-button"
:variant="
workspaceStore.shiftDown ? 'destructive' : 'textonly'
"
size="icon-sm"
:aria-label="$t('g.close')"
@click.stop="
handleCloseWorkflow(treeNode.data as ComfyWorkflow)
"
>
<i class="icon-[lucide--x] size-3" />
</Button>
</template>
</TreeExplorerTreeNode>
</template>
</TreeExplorer>
</div>
<div
v-show="filteredBookmarkedWorkflows.length > 0"
class="comfyui-workflows-bookmarks"
>
<TextDivider
:text="t('sideToolbar.workflowTab.workflowTreeType.bookmarks')"
type="dashed"
class="ml-2"
/>
<TreeExplorer
v-model:expanded-keys="dummyExpandedKeys"
:root="
renderTreeNode(
bookmarkedWorkflowsTree,
WorkflowTreeType.Bookmarks
)
"
:selection-keys="selectionKeys"
>
<template #node="{ node }">
<WorkflowTreeLeaf :node="node" />
</template>
</TreeExplorer>
</div>
<div class="comfyui-workflows-browse">
<TextDivider
:text="t('sideToolbar.workflowTab.workflowTreeType.browse')"
type="dashed"
class="ml-2"
/>
<TreeExplorer
v-if="filteredPersistedWorkflows.length > 0"
v-model:expanded-keys="expandedKeys"
:root="renderTreeNode(workflowsTree, WorkflowTreeType.Browse)"
:selection-keys="selectionKeys"
>
<template #node="{ node }">
<WorkflowTreeLeaf :node="node" />
</template>
</TreeExplorer>
<slot v-else name="empty-state">
<NoResultsPlaceholder
icon="pi pi-folder"
:title="$t('g.empty')"
:message="$t('g.noWorkflowsFound')"
/>
</slot>
</div>
</div>
<div v-else class="comfyui-workflows-search-panel">
<TreeExplorer
v-model:expanded-keys="expandedKeys"
:root="renderTreeNode(filteredRoot, WorkflowTreeType.Browse)"
>
<template #node="{ node }">
<WorkflowTreeLeaf :node="node" />
</template>
</TreeExplorer>
</div>
</template>
</SidebarTabTemplate>
<ConfirmDialog />
</template>
<script setup lang="ts">
import ConfirmDialog from 'primevue/confirmdialog'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import TextDivider from '@/components/common/TextDivider.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue'
import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
ComfyWorkflow,
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
import { buildTree, sortedTree } from '@/utils/treeUtil'
const {
title,
filter,
searchSubject,
dataTestid,
labelTransform,
hideLeafIcon
} = defineProps<{
title: string
filter?: (workflow: ComfyWorkflow) => boolean
searchSubject: string
dataTestid: string
labelTransform?: (label: string) => string
hideLeafIcon?: boolean
}>()
const { t } = useI18n()
const applyFilter = (workflows: ComfyWorkflow[]) =>
filter ? workflows.filter(filter) : workflows
const settingStore = useSettingStore()
const workflowTabsPosition = computed(() =>
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
)
const searchBoxRef = ref()
const searchQuery = ref('')
const isSearching = computed(() => searchQuery.value.length > 0)
const filteredWorkflows = ref<ComfyWorkflow[]>([])
const filteredRoot = computed<TreeNode>(() => {
return buildWorkflowTree(filteredWorkflows.value as ComfyWorkflow[])
})
const handleSearch = async (query: string) => {
if (query.length === 0) {
filteredWorkflows.value = []
expandedKeys.value = {}
return
}
const lowerQuery = query.toLocaleLowerCase()
filteredWorkflows.value = applyFilter(workflowStore.workflows).filter(
(workflow) => {
return workflow.path.toLocaleLowerCase().includes(lowerQuery)
}
)
await nextTick()
expandNode(filteredRoot.value)
}
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const workspaceStore = useWorkspaceStore()
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const dummyExpandedKeys = ref<Record<string, boolean>>({})
const handleCloseWorkflow = async (workflow?: ComfyWorkflow) => {
if (workflow) {
await workflowService.closeWorkflow(workflow, {
warnIfUnsaved: !workspaceStore.shiftDown
})
}
}
enum WorkflowTreeType {
Open = 'Open',
Bookmarks = 'Bookmarks',
Browse = 'Browse'
}
const buildWorkflowTree = (workflows: ComfyWorkflow[]) => {
return buildTree(workflows, (workflow: ComfyWorkflow) =>
workflow.key.split('/')
)
}
const filteredPersistedWorkflows = computed(() =>
applyFilter(workflowStore.persistedWorkflows)
)
const filteredBookmarkedWorkflows = computed(() =>
applyFilter(workflowStore.bookmarkedWorkflows)
)
const workflowsTree = computed(() =>
sortedTree(buildWorkflowTree(filteredPersistedWorkflows.value), {
groupLeaf: true
})
)
// Bookmarked workflows tree is flat.
const bookmarkedWorkflowsTree = computed(() =>
buildTree(filteredBookmarkedWorkflows.value, (workflow) => [workflow.key])
)
// Open workflows tree is flat.
const openWorkflowsTree = computed(() =>
buildTree(applyFilter(workflowStore.openWorkflows), (workflow) => [
workflow.key
])
)
const renderTreeNode = (
node: TreeNode,
type: WorkflowTreeType
): TreeExplorerNode<ComfyWorkflow> => {
const children = node.children?.map((child) => renderTreeNode(child, type))
const workflow: ComfyWorkflow = node.data
async function handleClick(
this: TreeExplorerNode<ComfyWorkflow>,
e: MouseEvent
) {
if (this.leaf) {
await workflowService.openWorkflow(workflow)
} else {
toggleNodeOnEvent(e, this)
}
}
const actions = node.leaf
? {
handleClick,
async handleRename(newName: string) {
const suffix = getWorkflowSuffix(workflow.suffix)
const newPath =
type === WorkflowTreeType.Browse
? workflow.directory + '/' + ensureWorkflowSuffix(newName, suffix)
: ComfyWorkflow.basePath + ensureWorkflowSuffix(newName, suffix)
await workflowService.renameWorkflow(workflow, newPath)
},
handleDelete: workflow.isTemporary
? undefined
: async function () {
await workflowService.deleteWorkflow(workflow)
},
contextMenuItems() {
return [
{
label: t('g.insert'),
icon: 'pi pi-file-export',
command: async () => {
const workflow = node.data
await workflowService.insertWorkflow(workflow)
}
},
{
label: t('g.duplicate'),
icon: 'pi pi-file-export',
command: async () => {
const workflow = node.data
await workflowService.duplicateWorkflow(workflow)
}
}
]
},
draggable: true
}
: { handleClick }
const label =
node.leaf && labelTransform ? labelTransform(node.label) : node.label
return {
key: node.key,
label,
leaf: node.leaf,
icon: node.leaf && hideLeafIcon ? 'hidden' : undefined,
data: node.data,
children,
...actions
}
}
const selectionKeys = computed(() => ({
[`root/${workflowStore.activeWorkflow?.key}`]: true
}))
const workflowBookmarkStore = useWorkflowBookmarkStore()
onMounted(async () => {
searchBoxRef.value?.focus()
await workflowBookmarkStore.loadBookmarks()
})
</script>

View File

@@ -1,11 +1,314 @@
<template>
<BaseWorkflowsSidebarTab
<SidebarTabTemplate
:title="$t('sideToolbar.workflows')"
:search-subject="$t('g.workflow')"
v-bind="$attrs"
data-testid="workflows-sidebar"
/>
class="workflows-sidebar-tab"
>
<template #tool-buttons>
<Button
v-tooltip.bottom="$t('g.refresh')"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.refresh')"
@click="workflowStore.syncWorkflows()"
>
<i class="icon-[lucide--refresh-cw] size-4" />
</Button>
</template>
<template #header>
<div class="px-2 2xl:px-4">
<SearchBox
ref="searchBoxRef"
v-model:model-value="searchQuery"
class="workflows-search-box"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.workflow') })
"
@search="handleSearch"
/>
</div>
</template>
<template #body>
<div v-if="!isSearching" class="comfyui-workflows-panel">
<div
v-if="workflowTabsPosition === 'Sidebar'"
class="comfyui-workflows-open"
>
<TextDivider
:text="t('sideToolbar.workflowTab.workflowTreeType.open')"
type="dashed"
class="ml-2"
/>
<TreeExplorer
v-model:expanded-keys="dummyExpandedKeys"
:root="renderTreeNode(openWorkflowsTree, WorkflowTreeType.Open)"
:selection-keys="selectionKeys"
>
<template #node="{ node }">
<TreeExplorerTreeNode :node="node">
<template #before-label="{ node: treeNode }">
<span
v-if="
(treeNode.data as ComfyWorkflow)?.isModified ||
!(treeNode.data as ComfyWorkflow)?.isPersisted
"
>*</span
>
</template>
<template #actions="{ node: treeNode }">
<Button
class="close-workflow-button"
:variant="
workspaceStore.shiftDown ? 'destructive' : 'textonly'
"
size="icon-sm"
:aria-label="$t('g.close')"
@click.stop="
handleCloseWorkflow(treeNode.data as ComfyWorkflow)
"
>
<i class="icon-[lucide--x] size-3" />
</Button>
</template>
</TreeExplorerTreeNode>
</template>
</TreeExplorer>
</div>
<div
v-show="workflowStore.bookmarkedWorkflows.length > 0"
class="comfyui-workflows-bookmarks"
>
<TextDivider
:text="t('sideToolbar.workflowTab.workflowTreeType.bookmarks')"
type="dashed"
class="ml-2"
/>
<TreeExplorer
v-model:expanded-keys="dummyExpandedKeys"
:root="
renderTreeNode(
bookmarkedWorkflowsTree,
WorkflowTreeType.Bookmarks
)
"
:selection-keys="selectionKeys"
>
<template #node="{ node }">
<WorkflowTreeLeaf :node="node" />
</template>
</TreeExplorer>
</div>
<div class="comfyui-workflows-browse">
<TextDivider
:text="t('sideToolbar.workflowTab.workflowTreeType.browse')"
type="dashed"
class="ml-2"
/>
<TreeExplorer
v-if="workflowStore.persistedWorkflows.length > 0"
v-model:expanded-keys="expandedKeys"
:root="renderTreeNode(workflowsTree, WorkflowTreeType.Browse)"
:selection-keys="selectionKeys"
>
<template #node="{ node }">
<WorkflowTreeLeaf :node="node" />
</template>
</TreeExplorer>
<NoResultsPlaceholder
v-else
icon="pi pi-folder"
:title="$t('g.empty')"
:message="$t('g.noWorkflowsFound')"
/>
</div>
</div>
<div v-else class="comfyui-workflows-search-panel">
<TreeExplorer
v-model:expanded-keys="expandedKeys"
:root="renderTreeNode(filteredRoot, WorkflowTreeType.Browse)"
>
<template #node="{ node }">
<WorkflowTreeLeaf :node="node" />
</template>
</TreeExplorer>
</div>
</template>
</SidebarTabTemplate>
<ConfirmDialog />
</template>
<script setup lang="ts">
import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue'
import ConfirmDialog from 'primevue/confirmdialog'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import TextDivider from '@/components/common/TextDivider.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue'
import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
ComfyWorkflow,
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
import { appendJsonExt } from '@/utils/formatUtil'
import { buildTree, sortedTree } from '@/utils/treeUtil'
const settingStore = useSettingStore()
const workflowTabsPosition = computed(() =>
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
)
const searchBoxRef = ref()
const searchQuery = ref('')
const isSearching = computed(() => searchQuery.value.length > 0)
const filteredWorkflows = ref<ComfyWorkflow[]>([])
const filteredRoot = computed<TreeNode>(() => {
return buildWorkflowTree(filteredWorkflows.value as ComfyWorkflow[])
})
const handleSearch = async (query: string) => {
if (query.length === 0) {
filteredWorkflows.value = []
expandedKeys.value = {}
return
}
const lowerQuery = query.toLocaleLowerCase()
filteredWorkflows.value = workflowStore.workflows.filter((workflow) => {
return workflow.path.toLocaleLowerCase().includes(lowerQuery)
})
await nextTick()
expandNode(filteredRoot.value)
}
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const workspaceStore = useWorkspaceStore()
const { t } = useI18n()
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const dummyExpandedKeys = ref<Record<string, boolean>>({})
const handleCloseWorkflow = async (workflow?: ComfyWorkflow) => {
if (workflow) {
await workflowService.closeWorkflow(workflow, {
warnIfUnsaved: !workspaceStore.shiftDown
})
}
}
enum WorkflowTreeType {
Open = 'Open',
Bookmarks = 'Bookmarks',
Browse = 'Browse'
}
const buildWorkflowTree = (workflows: ComfyWorkflow[]) => {
return buildTree(workflows, (workflow: ComfyWorkflow) =>
workflow.key.split('/')
)
}
const workflowsTree = computed(() =>
sortedTree(buildWorkflowTree(workflowStore.persistedWorkflows), {
groupLeaf: true
})
)
// Bookmarked workflows tree is flat.
const bookmarkedWorkflowsTree = computed(() =>
buildTree(workflowStore.bookmarkedWorkflows, (workflow) => [workflow.key])
)
// Open workflows tree is flat.
const openWorkflowsTree = computed(() =>
buildTree(workflowStore.openWorkflows, (workflow) => [workflow.key])
)
const renderTreeNode = (
node: TreeNode,
type: WorkflowTreeType
): TreeExplorerNode<ComfyWorkflow> => {
const children = node.children?.map((child) => renderTreeNode(child, type))
const workflow: ComfyWorkflow = node.data
async function handleClick(
this: TreeExplorerNode<ComfyWorkflow>,
e: MouseEvent
) {
if (this.leaf) {
await workflowService.openWorkflow(workflow)
} else {
toggleNodeOnEvent(e, this)
}
}
const actions = node.leaf
? {
handleClick,
async handleRename(newName: string) {
const newPath =
type === WorkflowTreeType.Browse
? workflow.directory + '/' + appendJsonExt(newName)
: ComfyWorkflow.basePath + appendJsonExt(newName)
await workflowService.renameWorkflow(workflow, newPath)
},
handleDelete: workflow.isTemporary
? undefined
: async function () {
await workflowService.deleteWorkflow(workflow)
},
contextMenuItems() {
return [
{
label: t('g.insert'),
icon: 'pi pi-file-export',
command: async () => {
const workflow = node.data
await workflowService.insertWorkflow(workflow)
}
},
{
label: t('g.duplicate'),
icon: 'pi pi-file-export',
command: async () => {
const workflow = node.data
await workflowService.duplicateWorkflow(workflow)
}
}
]
},
draggable: true
}
: { handleClick }
return {
key: node.key,
label: node.label,
leaf: node.leaf,
data: node.data,
children,
...actions
}
}
const selectionKeys = computed(() => ({
[`root/${workflowStore.activeWorkflow?.key}`]: true
}))
const workflowBookmarkStore = useWorkflowBookmarkStore()
onMounted(async () => {
searchBoxRef.value?.focus()
await workflowBookmarkStore.loadBookmarks()
})
</script>

View File

@@ -12,7 +12,7 @@
:class="
cn(
'flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface justify-center',
compact && 'size-full'
compact && 'size-full '
)
"
>

View File

@@ -21,7 +21,7 @@ export const buttonVariants = cva({
'text-destructive-background bg-transparent hover:bg-destructive-background/10',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
gradient:
'bg-(image:--subscription-button-gradient) text-white border-transparent hover:opacity-90'
'bg-subscription-gradient text-white border-transparent hover:opacity-90'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',

View File

@@ -15,7 +15,7 @@
v-if="collapsible"
:class="
cn(
'pi transition-transform duration-200 text-xs text-text-secondary',
'pi transition-transform duration-200 text-xs text-text-secondary ',
isCollapsed ? 'pi-chevron-right' : 'pi-chevron-down'
)
"

View File

@@ -27,12 +27,11 @@ export const PAINTER_TOOLS: Record<string, PainterTool> = {
interface UsePainterOptions {
canvasEl: Ref<HTMLCanvasElement | null>
cursorEl: Ref<HTMLElement | null>
modelValue: Ref<string>
}
export function usePainter(nodeId: string, options: UsePainterOptions) {
const { canvasEl, cursorEl, modelValue } = options
const { canvasEl, modelValue } = options
const { t } = useI18n()
const nodeOutputStore = useNodeOutputStore()
const toastStore = useToastStore()
@@ -42,6 +41,8 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
const canvasWidth = ref(512)
const canvasHeight = ref(512)
const cursorX = ref(0)
const cursorY = ref(0)
const cursorVisible = ref(false)
const inputImageUrl = ref<string | null>(null)
@@ -517,10 +518,8 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
}
function updateCursorPos(e: PointerEvent) {
const el = cursorEl.value
if (!el) return
const size = displayBrushSize.value
el.style.transform = `translate(${e.offsetX - size / 2}px, ${e.offsetY - size / 2}px)`
cursorX.value = e.offsetX
cursorY.value = e.offsetY
}
function handlePointerDown(e: PointerEvent) {
@@ -761,6 +760,8 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
canvasWidth,
canvasHeight,
cursorX,
cursorY,
cursorVisible,
displayBrushSize,

View File

@@ -1,19 +0,0 @@
import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
export function useQueueFeatureFlags() {
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const isRunProgressBarEnabled = computed(
() => settingStore.get('Comfy.Queue.ShowRunProgressBar') !== false
)
return {
isQueuePanelV2Enabled,
isRunProgressBarEnabled
}
}

View File

@@ -2,12 +2,7 @@ import { computed, ref } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
export type AppMode =
| 'graph'
| 'app'
| 'builder:inputs'
| 'builder:outputs'
| 'builder:arrange'
export type AppMode = 'graph' | 'app' | 'builder:select' | 'builder:arrange'
const enableAppBuilder = ref(true)
@@ -23,17 +18,13 @@ export function useAppMode() {
const isBuilderMode = computed(
() => isSelectMode.value || isArrangeMode.value
)
const isSelectInputsMode = computed(() => mode.value === 'builder:inputs')
const isSelectOutputsMode = computed(() => mode.value === 'builder:outputs')
const isSelectMode = computed(
() => isSelectInputsMode.value || isSelectOutputsMode.value
)
const isSelectMode = computed(() => mode.value === 'builder:select')
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
const isGraphMode = computed(
() => mode.value === 'graph' || isSelectMode.value
() => mode.value === 'graph' || mode.value === 'builder:select'
)
function setMode(newMode: AppMode) {
@@ -48,8 +39,6 @@ export function useAppMode() {
enableAppBuilder,
isBuilderMode,
isSelectMode,
isSelectInputsMode,
isSelectOutputsMode,
isArrangeMode,
isAppMode,
isGraphMode,

View File

@@ -53,7 +53,6 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
import {
getAllNonIoNodesInSubgraph,
getExecutionIdsForSelectedNodes
@@ -208,9 +207,7 @@ export function useCoreCommands(): ComfyCommand[] {
})
if (!newName || newName === workflow.filename) return
const suffix = getWorkflowSuffix(workflow.suffix)
const newPath =
workflow.directory + '/' + ensureWorkflowSuffix(newName, suffix)
const newPath = workflow.directory + '/' + newName + '.json'
await workflowService.renameWorkflow(workflow, newPath)
}
},

View File

@@ -98,80 +98,6 @@ describe('useNewMenuItemIndicator', () => {
)
})
it('does not count hidden items as unseen', () => {
const items: WorkflowMenuItem[] = [
{
id: 'hidden-feature',
label: 'Hidden',
icon: 'pi pi-test',
command: vi.fn(),
isNew: true,
badge: 'BETA',
visible: false
}
]
const { hasUnseenItems } = useNewMenuItemIndicator(() => items)
expect(hasUnseenItems.value).toBe(false)
})
it('markAsSeen does not include never-seen hidden items', () => {
const items: WorkflowMenuItem[] = [
...createItems('feature-a'),
{
id: 'hidden-feature',
label: 'Hidden',
icon: 'pi pi-test',
command: vi.fn(),
isNew: true,
badge: 'BETA',
visible: false
}
]
const { markAsSeen } = useNewMenuItemIndicator(() => items)
markAsSeen()
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.WorkflowActions.SeenItems',
['feature-a']
)
})
it('markAsSeen retains previously-seen hidden items', () => {
mockSettingStore.get.mockReturnValue(['hidden-feature'])
const items: WorkflowMenuItem[] = [
...createItems('feature-a'),
{
id: 'hidden-feature',
label: 'Hidden',
icon: 'pi pi-test',
command: vi.fn(),
isNew: true,
badge: 'BETA',
visible: false
}
]
const { markAsSeen } = useNewMenuItemIndicator(() => items)
markAsSeen()
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.WorkflowActions.SeenItems',
['feature-a', 'hidden-feature']
)
})
it('markAsSeen skips write when stored list already matches', () => {
mockSettingStore.get.mockReturnValue(['feature-a', 'feature-b'])
const items = createItems('feature-a', 'feature-b')
const { markAsSeen } = useNewMenuItemIndicator(() => items)
markAsSeen()
expect(mockSettingStore.set).not.toHaveBeenCalled()
})
it('markAsSeen does nothing when there are no new items', () => {
const items: WorkflowMenuItem[] = [
{ id: 'regular', label: 'Regular', icon: 'pi pi-test', command: vi.fn() }

View File

@@ -7,10 +7,11 @@ import type {
WorkflowMenuItem
} from '@/types/workflowMenuItem'
function getNewActions(items: WorkflowMenuItem[]): WorkflowMenuAction[] {
function getNewItemIds(items: WorkflowMenuItem[]): string[] {
return items
.filter((i): i is WorkflowMenuAction => !('separator' in i && i.separator))
.filter((i) => i.isNew)
.map((i) => i.id)
}
export function useNewMenuItemIndicator(
@@ -18,7 +19,7 @@ export function useNewMenuItemIndicator(
) {
const settingStore = useSettingStore()
const newActions = computed(() => getNewActions(toValue(menuItems)))
const newItemIds = computed(() => getNewItemIds(toValue(menuItems)))
const seenItems = computed<string[]>(
() => settingStore.get('Comfy.WorkflowActions.SeenItems') ?? []
@@ -26,28 +27,14 @@ export function useNewMenuItemIndicator(
const hasUnseenItems = computed(() => {
const seen = new Set(seenItems.value)
return newActions.value
.filter((i) => i.visible !== false)
.some((i) => !seen.has(i.id))
return newItemIds.value.some((id) => !seen.has(id))
})
function markAsSeen() {
const actions = newActions.value
if (!actions.length) return
const seen = new Set(seenItems.value)
const visibleIds = actions
.filter((i) => i.visible !== false)
.map((i) => i.id)
const retainedIds = actions
.filter((i) => i.visible === false && seen.has(i.id))
.map((i) => i.id)
const nextSeen = [...visibleIds, ...retainedIds]
if (nextSeen.length === seen.size && nextSeen.every((id) => seen.has(id)))
return
void settingStore.set('Comfy.WorkflowActions.SeenItems', nextSeen)
if (!newItemIds.value.length) return
void settingStore.set('Comfy.WorkflowActions.SeenItems', [
...newItemIds.value
])
}
return { hasUnseenItems, markAsSeen }

View File

@@ -44,10 +44,6 @@ const mockCanvasStore = vi.hoisted(() => ({
linearMode: false
}))
const mockAppModeStore = vi.hoisted(() => ({
enterBuilder: vi.fn()
}))
const mockFeatureFlags = vi.hoisted(() => ({
flags: { linearToggleEnabled: false }
}))
@@ -77,10 +73,6 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => mockCanvasStore)
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: vi.fn(() => mockAppModeStore)
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => mockFeatureFlags)
}))
@@ -88,9 +80,7 @@ vi.mock('@/composables/useFeatureFlags', () => ({
type MenuItems = ReturnType<typeof useWorkflowActionsMenu>['menuItems']['value']
function actionItems(items: MenuItems): WorkflowMenuAction[] {
return items.filter(
(i): i is WorkflowMenuAction => !i.separator && i.visible !== false
)
return items.filter((i): i is WorkflowMenuAction => !i.separator)
}
function menuLabels(items: MenuItems) {
@@ -298,18 +288,6 @@ describe('useWorkflowActionsMenu', () => {
expect(mockBookmarkStore.toggleBookmarked).toHaveBeenCalledWith('test.json')
})
it('enter builder mode calls enterBuilder', async () => {
mockFeatureFlags.flags.linearToggleEnabled = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
await findItem(
menuItems.value,
'breadcrumbsMenu.enterBuilderMode'
).command?.()
expect(mockAppModeStore.enterBuilder).toHaveBeenCalled()
})
it('app mode toggle executes Comfy.ToggleLinear', async () => {
mockFeatureFlags.flags.linearToggleEnabled = true

View File

@@ -13,7 +13,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useAppModeStore } from '@/stores/appModeStore'
import type {
WorkflowMenuAction,
WorkflowMenuItem
@@ -53,7 +52,6 @@ export function useWorkflowActionsMenu(
const menuItemStore = useMenuItemStore()
const canvasStore = useCanvasStore()
const { flags } = useFeatureFlags()
const { enterBuilder } = useAppModeStore()
const targetWorkflow = computed(
() => workflow?.value ?? workflowStore.activeWorkflow
@@ -83,9 +81,9 @@ export function useWorkflowActionsMenu(
prependSeparator = false,
isNew = false
}: AddItemOptions) => {
if (prependSeparator && visible) items.push({ separator: true })
if (!visible) return
if (prependSeparator) items.push({ separator: true })
const item: WorkflowMenuAction = { id, label, icon, command, disabled }
if (!visible) item.visible = false
if (isNew) {
item.badge = t('g.experimental')
item.isNew = true
@@ -98,11 +96,6 @@ export function useWorkflowActionsMenu(
isRoot && (menuItemStore.hasSeenLinear || flags.linearToggleEnabled)
const isBookmarked = bookmarkStore.isBookmarked(workflow?.path ?? '')
const toggleLinear = async () => {
await commandStore.execute('Comfy.ToggleLinear', {
metadata: { source: 'breadcrumb_menu' }
})
}
addItem({
id: 'rename',
label: t('g.rename'),
@@ -188,40 +181,21 @@ export function useWorkflowActionsMenu(
})
addItem({
id: 'share',
label: t('menuLabels.Share'),
icon: 'icon-[comfy--send]',
command: async () => {},
disabled: true,
visible: isRoot
})
addItem({
id: 'enter-app-mode',
label: t('breadcrumbsMenu.enterAppMode'),
icon: 'icon-[lucide--panels-top-left]',
command: toggleLinear,
visible: showAppModeItems && !isLinearMode,
prependSeparator: true,
isNew: true
})
addItem({
id: 'exit-app-mode',
label: t('breadcrumbsMenu.exitAppMode'),
icon: 'icon-[comfy--workflow]',
command: toggleLinear,
visible: isLinearMode,
prependSeparator: true
})
addItem({
id: 'enter-builder-mode',
label: t('breadcrumbsMenu.enterBuilderMode'),
icon: 'icon-[lucide--hammer]',
command: () => enterBuilder(),
id: 'toggle-app-mode',
label: isLinearMode
? t('breadcrumbsMenu.exitAppMode')
: t('breadcrumbsMenu.enterAppMode'),
icon: isLinearMode
? 'icon-[comfy--workflow]'
: 'icon-[lucide--panels-top-left]',
command: async () => {
await commandStore.execute('Comfy.ToggleLinear', {
metadata: { source: 'breadcrumb_menu' }
})
},
visible: showAppModeItems,
isNew: true
prependSeparator: true,
isNew: !isLinearMode
})
addItem({

View File

@@ -107,32 +107,6 @@ describe('useWorkflowTemplateSelectorDialog', () => {
)
})
it('invokes afterClose callback when dialog is closed', () => {
mockNewUserService.isNewUser.mockReturnValue(false)
const afterClose = vi.fn()
const dialog = useWorkflowTemplateSelectorDialog()
dialog.show('command', { afterClose })
const onClose =
mockDialogService.showLayoutDialog.mock.calls[0][0].props.onClose
onClose()
expect(mockDialogStore.closeDialog).toHaveBeenCalled()
expect(afterClose).toHaveBeenCalled()
})
it('does not fail when afterClose is not provided', () => {
mockNewUserService.isNewUser.mockReturnValue(false)
const dialog = useWorkflowTemplateSelectorDialog()
dialog.show('command')
const onClose =
mockDialogService.showLayoutDialog.mock.calls[0][0].props.onClose
expect(() => onClose()).not.toThrow()
})
it('tracks telemetry with source', () => {
mockNewUserService.isNewUser.mockReturnValue(false)

View File

@@ -1,6 +1,5 @@
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
import { useTelemetry } from '@/platform/telemetry'
import type { TemplateLibraryMetadata } from '@/platform/telemetry/types'
import { useDialogService } from '@/services/dialogService'
import { useNewUserService } from '@/services/useNewUserService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -18,8 +17,8 @@ export const useWorkflowTemplateSelectorDialog = () => {
}
function show(
source: TemplateLibraryMetadata['source'] = 'command',
options?: { initialCategory?: string; afterClose?: () => void }
source: 'sidebar' | 'menu' | 'command' = 'command',
options?: { initialCategory?: string }
) {
useTelemetry()?.trackTemplateLibraryOpened({ source })
@@ -31,10 +30,7 @@ export const useWorkflowTemplateSelectorDialog = () => {
key: DIALOG_KEY,
component: WorkflowTemplateSelectorDialog,
props: {
onClose: () => {
hide()
options?.afterClose?.()
},
onClose: hide,
initialCategory
}
})

View File

@@ -6,7 +6,6 @@ import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) {
if (!this.outputs[0].links?.length || !this.graph) return
@@ -75,25 +74,17 @@ function onCustomComboCreated(this: LGraphNode) {
function addOption(node: LGraphNode) {
if (!node.widgets) return
const newCount = node.widgets.length - 1
const widgetName = `option${newCount}`
const widget = node.addWidget('string', widgetName, '', () => {})
node.addWidget('string', `option${newCount}`, '', () => {})
const widget = node.widgets.at(-1)
if (!widget) return
let value = ''
Object.defineProperty(widget, 'value', {
get() {
return useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,
widgetName
)?.value
return value
},
set(v: string) {
const state = useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,
widgetName
)
if (state) state.value = v
set(v) {
value = v
updateCombo()
if (!node.widgets) return
const lastWidget = node.widgets.at(-1)

View File

@@ -1,6 +1,5 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
@@ -27,11 +26,8 @@ useExtensionService().registerExtension({
const { a_images: aImages, b_images: bImages } = output
const rand = app.getRandParam()
const toUrl = (record: Record<string, string>) => {
const params = new URLSearchParams(record)
appendCloudResParam(params)
return api.apiURL(`/view?${params}${rand}`)
}
const toUrl = (params: Record<string, string>) =>
api.apiURL(`/view?${new URLSearchParams(params)}${rand}`)
const beforeImages =
aImages && aImages.length > 0 ? aImages.map(toUrl) : []

View File

@@ -12,7 +12,6 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
import { CanvasPointer } from './CanvasPointer'
import type { ContextMenu } from './ContextMenu'
import { createCursorCache } from './cursorCache'
import { DragAndScale } from './DragAndScale'
import type { AnimationOptions } from './DragAndScale'
import type { LGraph } from './LGraph'
@@ -184,6 +183,14 @@ interface IDialogOptions {
onclose?(): void
}
export interface PromptOptions {
multiline?: boolean
inputType?: 'text' | 'number'
min?: number
max?: number
step?: number
}
/** @inheritdoc {@link LGraphCanvas.state} */
interface LGraphCanvasState {
/** {@link Positionable} items are being dragged on the canvas. */
@@ -365,8 +372,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.canvas.dispatchEvent(new CustomEvent(type, { detail }))
}
private _setCursor!: ReturnType<typeof createCursorCache>
private _updateCursorStyle() {
if (!this.state.shouldSetCursor) return
@@ -389,7 +394,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
cursor = 'grab'
}
this._setCursor(cursor)
this.canvas.style.cursor = cursor
}
// Whether the canvas was previously being dragged prior to pressing space key.
@@ -1914,7 +1919,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.pointer.element = element
if (!element) return
this._setCursor = createCursorCache(element)
// TODO: classList.add
element.className += ' lgraphcanvas'
@@ -2974,7 +2978,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
// Set appropriate cursor for resize direction
this._setCursor(cursors[resizeDirection])
this.canvas.style.cursor = cursors[resizeDirection]
return
}
}
@@ -6577,7 +6581,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const ySizeFix = opts.posSizeFix[1] * LiteGraph.NODE_SLOT_HEIGHT
const nodeX = opts.position[0] + opts.posAdd[0] + xSizeFix
const nodeY = opts.position[1] + opts.posAdd[1] + ySizeFix
const pos: [number, number] = [nodeX, nodeY]
const pos = [nodeX, nodeY]
const newNode = LiteGraph.createNode(nodeTypeStr, nodeNewOpts?.title, {
pos
})
@@ -6871,17 +6875,23 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
value: string | number,
callback: (value: string) => void,
event: CanvasPointerEvent,
multiline?: boolean
multilineOrOptions?: boolean | PromptOptions
): HTMLDivElement {
const that = this
title = title || ''
const options: PromptOptions =
typeof multilineOrOptions === 'boolean'
? { multiline: multilineOrOptions }
: (multilineOrOptions ?? {})
const inputType = options.inputType === 'number' ? 'number' : 'text'
const customProperties = {
is_modified: false,
className: 'graphdialog rounded',
innerHTML: multiline
innerHTML: options.multiline
? "<span class='name'></span> <textarea autofocus class='value'></textarea><button class='rounded'>OK</button>"
: "<span class='name'></span> <input autofocus type='text' class='value'/><button class='rounded'>OK</button>",
: `<span class='name'></span> <input autofocus type='${inputType}' class='value'/><button class='rounded'>OK</button>`,
close() {
that.prompt_box = null
if (dialog.parentNode) {
@@ -6947,6 +6957,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!value_element) throw new TypeError('value_element was null')
value_element.value = String(value)
if (options.inputType === 'number') {
if (options.min != null)
value_element.setAttribute('min', String(options.min))
if (options.max != null)
value_element.setAttribute('max', String(options.max))
if (options.step != null)
value_element.setAttribute('step', String(options.step))
}
value_element.select()
const input = value_element
@@ -6955,6 +6973,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (e.key == 'Escape') {
// ESC
dialog.close()
} else if (e.key === '/' && this.type === 'number') {
// Switch to text mode for expression editing
this.type = 'text'
this.removeAttribute('min')
this.removeAttribute('max')
this.removeAttribute('step')
} else if (
e.key == 'Enter' &&
(e.target as Element).localName != 'textarea'
@@ -8493,6 +8517,26 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
} else {
// on node
menu_info = this.getNodeMenuOptions(node)
const widget = node.getWidgetOnPos(event.canvasX, event.canvasY)
if (
widget &&
'getContextMenuOptions' in widget &&
typeof widget.getContextMenuOptions === 'function'
) {
const widgetMenuItems = (
widget as {
getContextMenuOptions: (opts: {
e: CanvasPointerEvent
node: LGraphNode
canvas: LGraphCanvas
}) => IContextMenuValue[]
}
).getContextMenuOptions({ e: event, node, canvas: this })
if (widgetMenuItems.length) {
menu_info.unshift(...widgetMenuItems, null)
}
}
}
} else {
menu_info = this.getCanvasMenuOptions()

View File

@@ -10,13 +10,7 @@ import { Reroute } from './Reroute'
import { InputIndicators } from './canvas/InputIndicators'
import { LabelPosition, SlotDirection, SlotShape, SlotType } from './draw'
import { Rectangle } from './infrastructure/Rectangle'
import type {
CreateNodeOptions,
Dictionary,
ISlotType,
Rect,
WhenNullish
} from './interfaces'
import type { Dictionary, ISlotType, Rect, WhenNullish } from './interfaces'
import { distance, isInsideRectangle, overlapBounding } from './measure'
import { SubgraphIONodeBase } from './subgraph/SubgraphIONodeBase'
import { SubgraphSlot } from './subgraph/SubgraphSlotBase'
@@ -531,7 +525,7 @@ export class LiteGraphGlobal {
createNode(
type: string,
title?: string,
options?: CreateNodeOptions
options?: Dictionary<unknown>
): LGraphNode | null {
const base_class = this.registered_node_types[type]
if (!base_class) {
@@ -567,7 +561,10 @@ export class LiteGraphGlobal {
// extra options
if (options) {
Object.assign(node, options)
for (const i in options) {
// @ts-expect-error #577 Requires interface
node[i] = options[i]
}
}
// callback

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