Compare commits

..

26 Commits

Author SHA1 Message Date
Benjamin Lu
16e54b7b01 fix: drop unused menu type exports 2026-03-21 12:00:09 -07:00
Benjamin Lu
d07c8750e6 refactor: consolidate job and asset menus 2026-03-21 11:59:23 -07:00
Alexander Brown
02322a13d7 Merge branch 'main' into codex/trigger-owned-context-menus 2026-03-20 11:31:16 -07:00
Matt Miller
3b85227089 fix: hide API key login option on Cloud (#10343)
## Summary
- Hide the "Comfy API Key" login button and help text from the sign-in
modal when running on Cloud
- API key auth is only used on local ComfyUI; Cloud has
Firebase-whitelisted Google/GitHub auth
- The button was appearing on Cloud as a relic of shared code between
Cloud and local ComfyUI

## Context
[Discussion with Robin in
#proj-cloud-frontend](https://comfy-organization.slack.com/archives/C09FY39CC3V/p1773977756997559)
— API key auth only works on local because Firebase requires whitelisted
domains. On Cloud, SSO (Google/GitHub) works natively, so the API key
option is unnecessary and confusing.

<img width="470" height="865" alt="Screenshot 2026-03-20 at 9 53 20 AM"
src="https://github.com/user-attachments/assets/5bbdcbaf-243c-48c6-9bd0-aaae815925ea"
/>

## Test plan
- [ ] Verify login modal on local ComfyUI still shows the "Comfy API
Key" button
- [ ] Verify login modal on cloud.comfy.org no longer shows the "Comfy
API Key" button
2026-03-20 10:14:36 -07:00
Johnpaul Chiwetelu
944f78adf4 feat: import/export keybinding presets (#9681)
## Summary
- Add keybinding preset system: save, load, switch, import, export, and
delete named keybinding sets stored via `/api/userdata/keybindings/`
- Preset selector dropdown with "Save Changes" button for modified
custom presets, and "Import keybinding preset" action
- More-options menu in header row with save as new, reset, delete,
import, and export actions
- Search box and menu teleported to settings dialog header (matching
templates modal layout)
- 11 unit tests for preset service CRUD operations

Fixes #1084
Fixes #1085

## Test plan
- [ ] Open Settings > Keybinding, verify search box and "..." menu
appear in header
- [ ] Modify a keybinding, verify "Default *" shows modified indicator
- [ ] Use "Save as new preset" from menu, verify preset appears in
dropdown
- [ ] Switch between presets, verify unsaved changes prompt
- [ ] Export preset, import it back, verify bindings restored
- [ ] Delete a custom preset, verify reset to default
- [ ] Verify "Save Changes" button appears only on modified custom
presets
- [ ] Run `pnpm vitest run
src/platform/keybindings/presetService.test.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9681-feat-import-export-keybinding-presets-31e6d73d3650810f88e4d21b3df3e2dd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-20 12:24:31 +01:00
jaeone94
0106372201 feat: enhance error tab with full error report and GitHub search (#10135) 2026-03-20 18:21:16 +09:00
Yourz
e2491fe32e fix: search media assets by display_name in addition to name (#10254)
## Description

On cloud, asset filenames are content hashes (e.g. `16bcbbe5...png`)
while the UI shows `display_name` (e.g. `ComfyUI_00011_`). Fuse.js was
only searching the `name` field, so typing the visible name in the Media
Assets search returned no results.

## Changes

### What
- Add `display_name` to the Fuse.js search keys in
`useMediaAssetFiltering`, so users can search by the name they actually
see in the panel.


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10254-fix-search-media-assets-by-display_name-in-addition-to-name-3276d73d3650813dbaf0ccd9d57ccfa3)
by [Unito](https://www.unito.io/)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-20 16:02:23 +08:00
Benjamin Lu
bb4abb599e test: use real i18n in menu tests 2026-03-19 21:42:17 -07:00
Benjamin Lu
39fcc230e3 test: fix job history menu item selector 2026-03-19 20:20:31 -07:00
Benjamin Lu
e8e18f0213 fix: restore reka menu item semantics 2026-03-19 20:01:30 -07:00
Benjamin Lu
8bc72145b3 merge: resolve origin/main into codex/trigger-owned-context-menus 2026-03-19 19:31:23 -07:00
Benjamin Lu
69b3930dd6 fix: keep sidebar menus usable 2026-03-19 19:24:21 -07:00
Johnpaul Chiwetelu
8aab2889b7 test: add copy/paste node and image paste e2e tests (#10233)
## Summary
- Add e2e test covering node copy/paste (Ctrl+C/V), image paste onto a
selected LoadImage node, and image paste on empty canvas creating a new
LoadImage node
- Extract `simulateImagePaste` into reusable
`ClipboardHelper.pasteFile()` with auto-detected filename and MIME type
- Add test workflow asset `load_image_with_ksampler.json` and
`image32x32.webp` image asset

## Test plan
- [ ] `pnpm typecheck:browser` passes
- [ ] `pnpm exec eslint browser_tests/tests/copyPaste.spec.ts` passes
- [ ] New test passes in CI: `Copy paste node, image paste onto
LoadImage, image paste on empty canvas`
- [ ] Existing copy/paste tests unaffected

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10233-test-add-copy-paste-node-and-image-paste-e2e-tests-3276d73d365081e78bb9f3f1bf34389f)
by [Unito](https://www.unito.io)
2026-03-20 03:24:09 +01:00
Alexander Brown
4d57c41fdb test: subgraph integration contracts and expanded Playwright coverage (#10123)
## Summary

Add integration contract tests (unit) and expanded Playwright coverage
for subgraph promotion, hydration, navigation, and lifecycle edge
behaviors.

## Changes

- **What**: 22 unit/integration tests across 9 files covering promotion
store sync, widget view lifecycle, input link resolution, pseudo-widget
cache, navigation viewport restore, and subgraph operations. 13
Playwright E2E tests covering proxyWidgets hydration stability, promoted
source removal cleanup, pseudo-preview unpack/remove, multi-link
representative round-trip, nested promotion retarget, and navigation
state on workflow switch.
- **Helpers**: Added `isPseudoPreviewEntry`, `getPseudoPreviewWidgets`,
`getNonPreviewPromotedWidgets` to promotedWidgets helper. Added
`SubgraphHelper.getNodeCount()`.

## Review Focus

- Test-only PR — no production code changes
- Validates existing subgraph behaviors are covered by regression tests
before further feature work
- Phase 4 (unit/integration contracts) and Phase 5 (Playwright
expansion) of the subgraph test coverage plan

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10123-test-subgraph-integration-contracts-and-expanded-Playwright-coverage-3256d73d365081258023e3a763859e00)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-19 23:54:15 +00:00
Alexander Brown
6dfc7b4306 perf: eliminate double page navigation in Playwright test setup (#10313)
## Summary

Eliminate the double `goto()` in `ComfyPage.setup()` by using
`addInitScript` to seed localStorage before the first navigation.

## Changes

- **What**: Move route mocking and localStorage seeding before `goto()`,
replacing `page.evaluate` with `page.addInitScript` so values are set
before app JS executes on first load. Removes the second `goto()` call
entirely.

## Review Focus

- Verify all Playwright E2E shards pass — the `clearStorage: false` and
`mockReleases: false` paths should be unaffected.
- Note: `addInitScript` persists for the page lifetime (like
`FeatureFlagHelper.seedFlags` already does), so any subsequent
`page.reload()` or `goto()` in tests will also clear storage. This
should be fine since tests that use `clearStorage: false` skip this
block.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10313-perf-eliminate-double-page-navigation-in-Playwright-test-setup-3286d73d36508158a8edeefb27fcae20)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-19 16:53:07 -07:00
Alexander Brown
28a91fa8b6 fix: configure nested subgraph definitions in dependency order (#10314)
## Summary

Fix workflow loading for nested subgraphs with duplicate node IDs by
configuring subgraph definitions in topological (leaf-first) order.

## Changes

- **What**: Three pre-existing bugs that surface when loading nested
subgraphs with colliding node IDs:
1. Subgraph definitions configured in serialization order — a parent
subgraph's `SubgraphNode.configure` would run before its referenced
child subgraph was populated, causing link/widget resolution failures.
2. `_resolveLegacyEntry` returned `undefined` when `input._widget`
wasn't set yet, instead of falling back to `resolveSubgraphInputTarget`.
3. `_removeDuplicateLinks` removed duplicate links without updating
`SubgraphOutput.linkIds`, leaving stale references that broke prompt
execution.
- **What (housekeeping)**: Moved `subgraphDeduplication.ts` from
`utils/` to `subgraph/` directory where it belongs.

## Review Focus

- Topological sort correctness: Kahn's algorithm with edges from
dependency→dependent ensures leaves configure first. Cycle fallback
returns original order.
- IO slot link repair: `_repairIOSlotLinkIds` runs after
`_configureSubgraph` creates IO slots, patching any `linkIds` that point
to links removed by `_removeDuplicateLinks` during `super.configure()`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10314-fix-configure-nested-subgraph-definitions-in-dependency-order-3286d73d36508171b149e238b8de84c2)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-19 15:36:26 -07:00
AustinMroz
4eded7c82b App Mode dragAndDrop, text output, and scroll shadows (#10122)
- When in app mode, workflows can be loaded by dragging and dropping as
elsewhere.
- Dragging a file which is supported by a selected app input to the
center panel will apply drop effects on the specific input
  - This overrides the loading of workflows
- There's not currently an indicator for where the image will go. This
is being considered for a followup PR
- Outputs can be dragged from the assets panel onto nodes
  - This fixes behaviour outside of app mode as well
  - Has some thorny implementation specifics
- Non-core nodes may not be able to accept these inputs without an
update
- Node DragOver filtering has reduced functionality when dragging from
the assets pane. Nodes may have the blue border without being able to
accept a drag operation.
- When dropped onto the canvas, the workflow will load (a fix), but the
workflow name will be the url of the image preview
  -  The entire card is used for the drag preview
<img width="329" height="380" alt="image"
src="https://github.com/user-attachments/assets/2945f9a3-3e77-4e14-a812-4a361976390d"
/>
- Adds a new scroll-shadows tailwind util as an indicator that more
content is available by scrolling.
- Since a primary goal was preventing API costs overflowing, I've made
the indicator fairly strong. This can be tuned later if needed

![scroll-shadow_00001](https://github.com/user-attachments/assets/e683d329-4283-4d06-aa29-5eee48030f27)
- Initial support for text outputs in App Mode
- Also causes jobs with text outputs to incorrectly display in the
assets panel with a generic 'check' icon instead of a text specific
icon. This will need a dedicated pass, but shouldn't be overly onerous
in the interim.
<img width="1209" height="735" alt="text output"
src="https://github.com/user-attachments/assets/fcd1cf9f-5d5c-434c-acd0-58d248237b99"
/>
  
NOTE: Displaying text outputs conflicted with the changes in #9622. I'll
leave text output still disabled in this PR and open a new PR for
reconciling text as an output so it can go through dedicated review.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10122-App-Mode-dragAndDrop-text-output-and-scroll-shadows-3256d73d3650810caaf8d75de94388c9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-19 14:20:35 -07:00
Benjamin Lu
ac06a08d92 Merge branch 'main' into codex/trigger-owned-context-menus 2026-03-19 11:51:59 -07:00
Benjamin Lu
ef546e3da8 fix: address context menu review feedback 2026-03-19 11:49:45 -07:00
pythongosssss
57783fffcf feat: App mode - add lightbox to view image in drop zone (#9888)
## Summary

Adds a button to view image in lightbox in app mode

## Changes

- **What**: 
- Add generic image lightbox component
- Add zoom button to dropzone
- Move buttons to outside image layer to top right of drop zone

## Screenshots (if applicable)

<img width="1164" height="838" alt="image"
src="https://github.com/user-attachments/assets/c92f2227-9dc0-49bd-bb27-5211e22060be"
/>
<img width="290" height="199" alt="image"
src="https://github.com/user-attachments/assets/90424b8e-c502-4d65-ad21-545d5add6d0b"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9888-feat-App-mode-add-lightbox-to-view-image-in-drop-zone-3226d73d365081c387a6c2dd24dc4100)
by [Unito](https://www.unito.io)
2026-03-19 09:22:06 -07:00
pythongosssss
be6c64c75b feat: Add edge panning to ghost node placement (#10308)
## Summary

Enables edge panning when placing a ghost-node from the search

## Changes

- **What**: 
- registers document level listeners so dragging over UI elements isnt
blocked

## Screenshots (if applicable)


https://github.com/user-attachments/assets/c3bda3c5-8255-4dda-a6be-6ef306af2244

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10308-feat-Add-edge-panning-to-ghost-node-placement-3286d73d36508151b645ec3e860bc017)
by [Unito](https://www.unito.io)
2026-03-19 09:21:01 -07:00
Yourz
43ba0a9a14 fix: set topbar menus to non-modal so they dismiss on canvas interaction (#10310)
## Summary

Set topbar menus to non-modal so they dismiss when clicking
inputs/textareas inside nodes with Nodes 2.0 enabled.

## Changes

- **What**: Add `:modal="false"` to `ContextMenuRoot` in WorkflowTab and
`DropdownMenuRoot` in WorkflowActionsDropdown.

![Kapture 2026-03-19 at 23 50
43](https://github.com/user-attachments/assets/f423f3df-48d0-42c4-b2bc-d19feffb9028)


## Review Focus

Modal reka-ui menus set `body.pointer-events: none` and prevent
`focusOutside` dismissal. With Nodes 2.0, widget components use
`@pointerdown.capture.stop` to prevent node dragging, which also blocks
reka-ui's document-level outside-click detection. Non-modal menus allow
`focusin`-based dismissal, which is unaffected by pointerdown stopping.

## Testing

An E2E regression test for this fix requires Nodes 2.0 to be explicitly
enabled (feature-flag guarded), opening a specific topbar menu, and then
clicking inside a canvas node's textarea — an interaction sequence that
has no existing Playwright fixture/helper pattern in the codebase; the
fix itself is a one-line :modal="false" attribute change on reka-ui
primitives whose behavior is documented and tested upstream.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10310-fix-set-topbar-menus-to-non-modal-so-they-dismiss-on-canvas-interaction-3286d73d3650815287d1c66c6ffd4814)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-20 00:17:28 +08:00
AustinMroz
157f0598d4 Display optional indicator on subgraphNode inputs (#8772)
Display an optional input on subgraphNodes when all links to the
subgraph input are optional.
<img width="1132" height="625" alt="image"
src="https://github.com/user-attachments/assets/e87d9d33-4b95-4ec9-afb8-bc1fb8cc0638"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8772-Display-optional-indicator-on-subgraphNode-inputs-3036d73d365081cc83e8d6034302665f)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-03-19 09:03:22 -07:00
pythongosssss
3ed88fbe68 Autopan canvas when dragging nodes/links to edges (#8773)
## Summary

Adds autopanning so the canvas moves when you drag a node/link to the
side of the canvas

## Changes

- **What**: 
- adds autopan controller that runs on animation frame timer to check
autopan speed
- extracts updateNodePositions for reuse
- specific handling for vue vs litegraph modes
- adds max speed setting, allowing user to set 0 for disabling

## Screenshots (if applicable)

https://github.com/user-attachments/assets/1290ae6d-b2f0-4d63-8955-39b933526874

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8773-Autopan-canvas-when-dragging-nodes-links-to-edges-3036d73d365081869a58ca5978f15f80)
by [Unito](https://www.unito.io)

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-19 04:12:06 -07:00
pythongosssss
77ddda9d3c fix: App mode - renaming widgets on subgraphs (#10245)
## Summary

Fixes renaming of widgets from subgraph nodes in app builder/app mode.

## Changes

- **What**: If the widget is from a subgraph node and no parents passed,
use the node as the subgraph parent.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10245-fix-App-mode-renaming-widgets-on-subgraphs-3276d73d3650815bb131c840df43cdf2)
by [Unito](https://www.unito.io)
2026-03-19 04:00:31 -07:00
Benjamin Lu
3af52bd4e8 refactor: use trigger-owned context menus 2026-03-19 03:27:01 -07:00
249 changed files with 10073 additions and 3473 deletions

View File

@@ -0,0 +1,172 @@
{
"id": "9efdcc44-6372-4b4a-b6f9-789c67f052e1",
"revision": 0,
"last_node_id": 4,
"last_link_id": 0,
"nodes": [
{
"id": 4,
"type": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
"pos": [689.0083557128902, 467.9999999999997],
"size": [431.8999938964844, 206.60000610351562],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [["3", "text", "2"]]
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "9a3f232c-da11-4725-8927-b11e46d0cee4",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Inner Subgraph",
"inputNode": {
"id": -10,
"bounding": [330, 367, 120, 40]
},
"outputNode": {
"id": -20,
"bounding": [983, 367, 120, 40]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CLIPTextEncode",
"pos": [510, 166],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["11111111111"]
},
{
"id": 2,
"type": "CLIPTextEncode",
"pos": [523, 438],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["22222222222"]
}
],
"groups": [],
"links": [],
"extra": {}
},
{
"id": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Outer Subgraph",
"inputNode": {
"id": -10,
"bounding": [467, 446, 120, 40]
},
"outputNode": {
"id": -20,
"bounding": [932, 446, 120, 40]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "9a3f232c-da11-4725-8927-b11e46d0cee4",
"pos": [647, 389],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "text"],
["2", "text"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 2.0975,
"offset": [-581.4780189305006, -356.3000030517576]
},
"frontendVersion": "1.43.2"
},
"version": 0.4
}

View File

@@ -33,6 +33,7 @@ import { FeatureFlagHelper } from './helpers/FeatureFlagHelper'
import { KeyboardHelper } from './helpers/KeyboardHelper'
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
import { SettingsHelper } from './helpers/SettingsHelper'
import { AppModeHelper } from './helpers/AppModeHelper'
import { SubgraphHelper } from './helpers/SubgraphHelper'
import { ToastHelper } from './helpers/ToastHelper'
import { WorkflowHelper } from './helpers/WorkflowHelper'
@@ -176,6 +177,7 @@ export class ComfyPage {
public readonly settingDialog: SettingDialog
public readonly confirmDialog: ConfirmDialog
public readonly vueNodes: VueNodeHelpers
public readonly appMode: AppModeHelper
public readonly subgraph: SubgraphHelper
public readonly canvasOps: CanvasHelper
public readonly nodeOps: NodeOperationsHelper
@@ -221,12 +223,13 @@ export class ComfyPage {
this.settingDialog = new SettingDialog(page, this)
this.confirmDialog = new ConfirmDialog(page)
this.vueNodes = new VueNodeHelpers(page)
this.appMode = new AppModeHelper(this)
this.subgraph = new SubgraphHelper(this)
this.canvasOps = new CanvasHelper(page, this.canvas, this.resetViewButton)
this.nodeOps = new NodeOperationsHelper(this)
this.settings = new SettingsHelper(page)
this.keyboard = new KeyboardHelper(page, this.canvas)
this.clipboard = new ClipboardHelper(this.keyboard)
this.clipboard = new ClipboardHelper(this.keyboard, page)
this.workflow = new WorkflowHelper(this)
this.contextMenu = new ContextMenu(page)
this.toast = new ToastHelper(page)
@@ -287,9 +290,7 @@ export class ComfyPage {
clearStorage?: boolean
mockReleases?: boolean
} = {}) {
await this.goto()
// Mock release endpoint to prevent changelog popups
// Mock release endpoint to prevent changelog popups (before navigation)
if (mockReleases) {
await this.page.route('**/releases**', async (route) => {
const url = route.request().url()
@@ -309,12 +310,16 @@ export class ComfyPage {
}
if (clearStorage) {
// Navigate to a lightweight same-origin endpoint to obtain a page
// context for clearing storage without loading the full frontend app.
await this.page.goto(`${this.url}/api/users`)
await this.page.evaluate((id) => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, this.id)
}
await this.goto()
await this.page.waitForFunction(() => document.fonts.ready)

View File

@@ -0,0 +1,128 @@
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
export class AppModeHelper {
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
}
private get builderToolbar(): Locator {
return this.page.getByRole('navigation', { name: 'App Builder' })
}
/** Enter builder mode via the "Workflow actions" dropdown → "Build app". */
async enterBuilder() {
await this.page
.getByRole('button', { name: 'Workflow actions' })
.first()
.click()
await this.page.getByRole('menuitem', { name: 'Build app' }).click()
await this.comfyPage.nextFrame()
}
/** Exit builder mode via the footer "Exit app builder" button. */
async exitBuilder() {
await this.page.getByRole('button', { name: 'Exit app builder' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Inputs" step in the builder toolbar. */
async goToInputs() {
await this.builderToolbar.getByRole('button', { name: 'Inputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Outputs" step in the builder toolbar. */
async goToOutputs() {
await this.builderToolbar.getByRole('button', { name: 'Outputs' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Preview" step in the builder toolbar. */
async goToPreview() {
await this.builderToolbar.getByRole('button', { name: 'Preview' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Next" button in the builder footer. */
async next() {
await this.page.getByRole('button', { name: 'Next' }).click()
await this.comfyPage.nextFrame()
}
/** Click the "Back" button in the builder footer. */
async back() {
await this.page.getByRole('button', { name: 'Back' }).click()
await this.comfyPage.nextFrame()
}
/** Toggle app mode (linear view) on/off. */
async toggleAppMode() {
await this.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await this.comfyPage.nextFrame()
}
/** The linear-mode widget list container (visible in app mode). */
get linearWidgets(): Locator {
return this.page.locator('[data-testid="linear-widgets"]')
}
/**
* Get the actions menu trigger for a widget in the app mode widget list.
* @param widgetName Text shown in the widget label (e.g. "seed").
*/
getAppModeWidgetMenu(widgetName: string): Locator {
return this.linearWidgets
.locator(`div:has(> div > span:text-is("${widgetName}"))`)
.getByTestId(TestIds.builder.widgetActionsMenu)
.first()
}
/**
* Get the actions menu trigger for a widget in the builder input-select
* sidebar (IoItem).
* @param title The widget title shown in the IoItem.
*/
getBuilderInputItemMenu(title: string): Locator {
return this.page
.getByTestId(TestIds.builder.ioItem)
.filter({ hasText: title })
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Get the actions menu trigger for a widget in the builder preview/arrange
* sidebar (AppModeWidgetList with builderMode).
* @param ariaLabel The aria-label on the widget row, e.g. "seed — KSampler".
*/
getBuilderPreviewWidgetMenu(ariaLabel: string): Locator {
return this.page
.locator(`[aria-label="${ariaLabel}"]`)
.getByTestId(TestIds.builder.widgetActionsMenu)
}
/**
* Rename a widget by clicking its popover trigger, selecting "Rename",
* and filling in the dialog.
* @param popoverTrigger The button that opens the widget's actions popover.
* @param newName The new name to assign.
*/
async renameWidget(popoverTrigger: Locator, newName: string) {
await popoverTrigger.click()
await this.page.getByText('Rename', { exact: true }).click()
const dialogInput = this.page.locator(
'.p-dialog-content input[type="text"]'
)
await dialogInput.fill(newName)
await this.page.keyboard.press('Enter')
await dialogInput.waitFor({ state: 'hidden' })
await this.comfyPage.nextFrame()
}
}

View File

@@ -1,9 +1,16 @@
import type { Locator } from '@playwright/test'
import { readFileSync } from 'fs'
import { basename } from 'path'
import type { Locator, Page } from '@playwright/test'
import type { KeyboardHelper } from './KeyboardHelper'
import { getMimeType } from './mimeTypeUtil'
export class ClipboardHelper {
constructor(private readonly keyboard: KeyboardHelper) {}
constructor(
private readonly keyboard: KeyboardHelper,
private readonly page: Page
) {}
async copy(locator?: Locator | null): Promise<void> {
await this.keyboard.ctrlSend('KeyC', locator ?? null)
@@ -12,4 +19,44 @@ export class ClipboardHelper {
async paste(locator?: Locator | null): Promise<void> {
await this.keyboard.ctrlSend('KeyV', locator ?? null)
}
async pasteFile(filePath: string): Promise<void> {
const buffer = readFileSync(filePath)
const bufferArray = [...new Uint8Array(buffer)]
const fileName = basename(filePath)
const fileType = getMimeType(fileName)
// Register a one-time capturing-phase listener that intercepts the next
// paste event and injects file data onto clipboardData.
await this.page.evaluate(
({ bufferArray, fileName, fileType }) => {
document.addEventListener(
'paste',
(e: ClipboardEvent) => {
e.preventDefault()
e.stopImmediatePropagation()
const file = new File([new Uint8Array(bufferArray)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const syntheticEvent = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
document.dispatchEvent(syntheticEvent)
},
{ capture: true, once: true }
)
},
{ bufferArray, fileName, fileType }
)
// Trigger a real Ctrl+V keystroke — the capturing listener above will
// intercept it and re-dispatch with file data attached.
await this.paste()
}
}

View File

@@ -3,6 +3,7 @@ import { readFileSync } from 'fs'
import type { Page } from '@playwright/test'
import type { Position } from '../types'
import { getMimeType } from './mimeTypeUtil'
export class DragDropHelper {
constructor(
@@ -48,19 +49,8 @@ export class DragDropHelper {
const filePath = this.assetPath(fileName)
const buffer = readFileSync(filePath)
const getFileType = (fileName: string) => {
if (fileName.endsWith('.png')) return 'image/png'
if (fileName.endsWith('.svg')) return 'image/svg+xml'
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
if (fileName.endsWith('.avif')) return 'image/avif'
return 'application/octet-stream'
}
evaluateParams.fileName = fileName
evaluateParams.fileType = getFileType(fileName)
evaluateParams.fileType = getMimeType(fileName)
evaluateParams.buffer = [...new Uint8Array(buffer)]
}

View File

@@ -33,6 +33,7 @@ export class NodeOperationsHelper {
})
}
/** Reads from `window.app.graph` (the root workflow graph). */
async getNodeCount(): Promise<number> {
return await this.page.evaluate(() => window.app!.graph.nodes.length)
}

View File

@@ -3,6 +3,9 @@ import type { Page, Route } from '@playwright/test'
export class QueueHelper {
private queueRouteHandler: ((route: Route) => void) | null = null
private historyRouteHandler: ((route: Route) => void) | null = null
private jobsRouteHandler: ((route: Route) => void) | null = null
private queueJobs: Array<Record<string, unknown>> = []
private historyJobs: Array<Record<string, unknown>> = []
constructor(private readonly page: Page) {}
@@ -13,6 +16,26 @@ export class QueueHelper {
running: number = 0,
pending: number = 0
): Promise<void> {
const baseTime = Date.now()
this.queueJobs = [
...Array.from({ length: running }, (_, i) => ({
id: `running-${i}`,
status: 'in_progress',
create_time: baseTime - i * 1000,
execution_start_time: baseTime - 5000 - i * 1000,
execution_end_time: null,
priority: i + 1
})),
...Array.from({ length: pending }, (_, i) => ({
id: `pending-${i}`,
status: 'pending',
create_time: baseTime - (running + i) * 1000,
execution_start_time: null,
execution_end_time: null,
priority: running + i + 1
}))
]
this.queueRouteHandler = (route: Route) =>
route.fulfill({
status: 200,
@@ -35,6 +58,7 @@ export class QueueHelper {
})
})
await this.page.route('**/api/queue', this.queueRouteHandler)
await this.installJobsRoute()
}
/**
@@ -43,6 +67,30 @@ export class QueueHelper {
async mockHistory(
jobs: Array<{ promptId: string; status: 'success' | 'error' }>
): Promise<void> {
const baseTime = Date.now()
this.historyJobs = jobs.map((job, index) => {
const completed = job.status === 'success'
return {
id: job.promptId,
status: completed ? 'completed' : 'failed',
create_time: baseTime - index * 1000,
execution_start_time: baseTime - 5000 - index * 1000,
execution_end_time: baseTime - index * 1000,
outputs_count: completed ? 1 : 0,
workflow_id: `workflow-${job.promptId}`,
preview_output: completed
? {
filename: `${job.promptId}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
: null
}
})
const history: Record<string, unknown> = {}
for (const job of jobs) {
history[job.promptId] = {
@@ -61,6 +109,44 @@ export class QueueHelper {
body: JSON.stringify(history)
})
await this.page.route('**/api/history**', this.historyRouteHandler)
await this.installJobsRoute()
}
private async installJobsRoute() {
if (this.jobsRouteHandler) {
return
}
this.jobsRouteHandler = (route: Route) => {
const url = new URL(route.request().url())
const statuses =
url.searchParams
.get('status')
?.split(',')
.filter((status) => status.length > 0) ?? []
const offset = Number(url.searchParams.get('offset') ?? 0)
const limit = Number(url.searchParams.get('limit') ?? 200)
const jobs = [...this.queueJobs, ...this.historyJobs].filter(
(job) => statuses.length === 0 || statuses.includes(String(job.status))
)
const paginatedJobs = jobs.slice(offset, offset + limit)
void route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
jobs: paginatedJobs,
pagination: {
offset,
limit,
total: jobs.length,
has_more: offset + paginatedJobs.length < jobs.length
}
})
})
}
await this.page.route('**/api/jobs**', this.jobsRouteHandler)
}
/**
@@ -75,5 +161,9 @@ export class QueueHelper {
await this.page.unroute('**/api/history**', this.historyRouteHandler)
this.historyRouteHandler = null
}
if (this.jobsRouteHandler) {
await this.page.unroute('**/api/jobs**', this.jobsRouteHandler)
this.jobsRouteHandler = null
}
}
}

View File

@@ -1,3 +1,4 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
@@ -6,6 +7,7 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
import type { NodeReference } from '../utils/litegraphUtils'
import { SubgraphSlotReference } from '../utils/litegraphUtils'
@@ -322,4 +324,93 @@ export class SubgraphHelper {
)
await this.comfyPage.nextFrame()
}
async isInSubgraph(): Promise<boolean> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
}
async exitViaBreadcrumb(): Promise<void> {
const breadcrumb = this.page.getByTestId(TestIds.breadcrumb.subgraph)
const parentLink = breadcrumb.getByRole('link').first()
if (await parentLink.isVisible()) {
await parentLink.click()
} else {
await this.page.evaluate(() => {
const canvas = window.app!.canvas
const graph = canvas.graph
if (!graph) return
canvas.setGraph(graph.rootGraph)
})
}
await this.comfyPage.nextFrame()
await expect.poll(async () => this.isInSubgraph()).toBe(false)
}
async countGraphPseudoPreviewEntries(): Promise<number> {
return this.page.evaluate(() => {
const graph = window.app!.graph!
return graph.nodes.reduce((count, node) => {
const proxyWidgets = node.properties?.proxyWidgets
if (!Array.isArray(proxyWidgets)) return count
return (
count +
proxyWidgets.filter(
(entry) =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[1] === 'string' &&
entry[1].startsWith('$$')
).length
)
}, 0)
})
}
async getHostPromotedTupleSnapshot(): Promise<
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.filter(
(node) =>
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
)
.map((node) => {
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
? node.properties.proxyWidgets
: []
const promotedWidgets = proxyWidgets
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
.map(
([interiorNodeId, widgetName]) =>
[interiorNodeId, widgetName] as [string, string]
)
return {
hostNodeId: String(node.id),
promotedWidgets
}
})
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
})
}
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */
async getNodeCount(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.graph!.nodes?.length || 0
})
}
}

View File

@@ -0,0 +1,13 @@
export function getMimeType(fileName: string): string {
const name = fileName.toLowerCase()
if (name.endsWith('.png')) return 'image/png'
if (name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'image/jpeg'
if (name.endsWith('.webp')) return 'image/webp'
if (name.endsWith('.svg')) return 'image/svg+xml'
if (name.endsWith('.avif')) return 'image/avif'
if (name.endsWith('.webm')) return 'video/webm'
if (name.endsWith('.mp4')) return 'video/mp4'
if (name.endsWith('.json')) return 'application/json'
if (name.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'
}

View File

@@ -28,10 +28,14 @@ export const TestIds = {
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
errorOverlay: 'error-overlay',
runtimeErrorPanel: 'runtime-error-panel',
missingNodeCard: 'missing-node-card',
about: 'about-panel',
whatsNewSection: 'whats-new-section'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
},
topbar: {
queueButton: 'queue-button',
queueModeMenuTrigger: 'queue-mode-menu-trigger',
@@ -58,6 +62,10 @@ export const TestIds = {
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
builder: {
ioItem: 'builder-io-item',
widgetActionsMenu: 'widget-actions-menu'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'
},
@@ -78,12 +86,14 @@ export type TestIdValue =
| (typeof TestIds.tree)[keyof typeof TestIds.tree]
| (typeof TestIds.canvas)[keyof typeof TestIds.canvas]
| (typeof TestIds.dialogs)[keyof typeof TestIds.dialogs]
| (typeof TestIds.keybindings)[keyof typeof TestIds.keybindings]
| (typeof TestIds.topbar)[keyof typeof TestIds.topbar]
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
| (typeof TestIds.node)[keyof typeof TestIds.node]
| (typeof TestIds.selectionToolbox)[keyof typeof TestIds.selectionToolbox]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.builder)[keyof typeof TestIds.builder]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<
(typeof TestIds.templates)[keyof typeof TestIds.templates],

View File

@@ -75,6 +75,26 @@ export async function getPromotedWidgetCount(
return promotedWidgets.length
}
export function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
return entry[1].startsWith('$$')
}
export async function getPseudoPreviewWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter(isPseudoPreviewEntry)
}
export async function getNonPreviewPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter((entry) => !isPseudoPreviewEntry(entry))
}
export async function getPromotedWidgetCountByName(
comfyPage: ComfyPage,
nodeId: string,

View File

@@ -0,0 +1,149 @@
import type { ComfyPage } from '../fixtures/ComfyPage'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { fitToViewInstant } from '../helpers/fitToView'
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
/**
* Convert the KSampler (id 3) in the default workflow to a subgraph,
* enter builder, select the promoted seed widget as input and
* SaveImage/PreviewImage as output.
*
* Returns the subgraph node reference for further interaction.
*/
async function setupSubgraphBuilder(comfyPage: ComfyPage) {
const { page, appMode } = comfyPage
await comfyPage.workflow.loadWorkflow('default')
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
const subgraphNodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, subgraphNodeId)
expect(promotedNames).toContain('seed')
await fitToViewInstant(comfyPage)
await appMode.enterBuilder()
await appMode.goToInputs()
// Click the promoted seed widget on the canvas to select it
const seedWidgetRef = await subgraphNode.getWidget(0)
const seedPos = await seedWidgetRef.getPosition()
await page.mouse.click(seedPos.x, seedPos.y)
await comfyPage.nextFrame()
// Select an output node
await appMode.goToOutputs()
const saveImageNodeId = await page.evaluate(() =>
String(
window.app!.rootGraph.nodes.find(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)?.id
)
)
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
const saveImagePos = await saveImageRef.getPosition()
// Click left edge — the right side is hidden by the builder panel
await page.mouse.click(saveImagePos.x + 10, saveImagePos.y - 10)
await comfyPage.nextFrame()
return subgraphNode
}
/** Save the workflow, reopen it, and enter app mode. */
async function saveAndReopenInAppMode(
comfyPage: ComfyPage,
workflowName: string
) {
await comfyPage.menu.topbar.saveWorkflow(workflowName)
const { workflowsTab } = comfyPage.menu
await workflowsTab.open()
await workflowsTab.getPersistedItem(workflowName).dblclick()
await comfyPage.nextFrame()
await comfyPage.appMode.toggleAppMode()
}
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Rename from builder input-select sidebar', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Go back to inputs step where IoItems are shown
await appMode.goToInputs()
const menu = appMode.getBuilderInputItemMenu('seed')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.renameWidget(menu, 'Builder Input Seed')
// Verify in app mode after save/reload
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-input`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(
appMode.linearWidgets.getByText('Builder Input Seed')
).toBeVisible()
})
test('Rename from builder preview sidebar', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
const menu = appMode.getBuilderPreviewWidgetMenu('seed — New Subgraph')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.renameWidget(menu, 'Preview Seed')
// Verify in app mode after save/reload
await appMode.exitBuilder()
const workflowName = `${new Date().getTime()} builder-preview`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('Preview Seed')).toBeVisible()
})
test('Rename from app mode', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Enter app mode from builder
await appMode.exitBuilder()
await appMode.toggleAppMode()
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
const menu = appMode.getAppModeWidgetMenu('seed')
await appMode.renameWidget(menu, 'App Mode Seed')
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
// Verify persistence after save/reload
await appMode.toggleAppMode()
const workflowName = `${new Date().getTime()} app-mode`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
})
})

View File

@@ -129,4 +129,74 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
const undoCount = await comfyPage.nodeOps.getGraphNodesCount()
expect(undoCount).toBe(initialCount)
})
test(
'Copy paste node, image paste onto LoadImage, image paste on empty canvas',
{ tag: ['@node'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/load_image_with_ksampler')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
// Step 1: Copy a KSampler node with Ctrl+C and paste with Ctrl+V
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
await ksamplerNodes[0].copy()
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
await comfyPage.nextFrame()
await comfyPage.clipboard.paste()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
timeout: 5_000
})
.toBe(3)
// Step 2: Paste image onto selected LoadImage node
const loadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
await loadImageNodes[0].click('title')
await comfyPage.nextFrame()
const uploadPromise = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10_000 }
)
await comfyPage.clipboard.pasteFile(
comfyPage.assetPath('image32x32.webp')
)
await uploadPromise
await expect
.poll(
async () => {
const fileWidget = await loadImageNodes[0].getWidget(0)
return fileWidget.getValue()
},
{ timeout: 5_000 }
)
.toContain('image32x32')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
// Step 3: Click empty canvas area, paste image → creates new LoadImage
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
await comfyPage.nextFrame()
const uploadPromise2 = comfyPage.page.waitForResponse(
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
{ timeout: 10_000 }
)
await comfyPage.clipboard.pasteFile(
comfyPage.assetPath('image32x32.webp')
)
await uploadPromise2
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), {
timeout: 5_000
})
.toBe(4)
const allLoadImageNodes =
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
expect(allLoadImageNodes).toHaveLength(2)
}
)
})

View File

@@ -144,6 +144,42 @@ test.describe('Execution error', () => {
})
})
test.describe('Error actions in Errors Tab', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test('Should show Find on GitHub and Copy buttons in error card after execution error', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
await comfyPage.nextFrame()
// Wait for error overlay and click "See Errors"
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay.getByRole('button', { name: 'See Errors' }).click()
await expect(errorOverlay).not.toBeVisible()
// Verify Find on GitHub button is present in the error card
const findOnGithubButton = comfyPage.page.getByRole('button', {
name: 'Find on GitHub'
})
await expect(findOnGithubButton).toBeVisible()
// Verify Copy button is present in the error card
const copyButton = comfyPage.page.getByRole('button', { name: 'Copy' })
await expect(copyButton).toBeVisible()
})
})
test.describe('Missing models in Error Tab', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')

View File

@@ -0,0 +1,257 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
const TEST_PRESET = {
name: 'test-preset',
newBindings: [
{
commandId: 'Comfy.Canvas.SelectAll',
combo: { key: 'a', ctrl: true, shift: true },
targetElementId: 'graph-canvas-container'
}
],
unsetBindings: [
{
commandId: 'Comfy.Canvas.SelectAll',
combo: { key: 'a', ctrl: true },
targetElementId: 'graph-canvas-container'
}
]
}
async function importPreset(page: Page, preset: typeof TEST_PRESET) {
const menuButton = page.getByTestId('keybinding-preset-menu')
await menuButton.click()
const fileChooserPromise = page.waitForEvent('filechooser')
await page.getByRole('menuitem', { name: /Import preset/i }).click()
const fileChooser = await fileChooserPromise
const presetPath = path.join(os.tmpdir(), 'test-preset.json')
fs.writeFileSync(presetPath, JSON.stringify(preset))
await fileChooser.setFiles(presetPath)
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.request.fetch(
`${comfyPage.url}/api/userdata/keybindings%2Ftest-preset.json`,
{ method: 'DELETE' }
)
await comfyPage.settings.setSetting(
'Comfy.Keybinding.CurrentPreset',
'default'
)
})
test.describe('Keybinding Presets', { tag: '@keyboard' }, () => {
test('Can import a preset, use remapped keybinding, and switch back to default', async ({
comfyPage
}) => {
test.setTimeout(30000)
const { page } = comfyPage
// Verify default Ctrl+A select-all works
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.canvas.press('Control+a')
await comfyPage.canvas.press('Delete')
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// Open keybinding settings panel
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
await importPreset(page, TEST_PRESET)
// Verify active preset switched to test-preset
const presetTrigger = page
.locator('#keybinding-panel-actions')
.locator('button[role="combobox"]')
await expect(presetTrigger).toContainText('test-preset')
// Wait for toast to auto-dismiss, then close settings via Escape
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
await page.keyboard.press('Escape')
await comfyPage.settingDialog.waitForHidden()
// Load workflow again, use new keybind Ctrl+Shift+A
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.canvas.press('Control+Shift+a')
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBeGreaterThan(0)
await comfyPage.canvas.press('Delete')
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// Switch back to default preset
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
await presetTrigger.click()
await page.getByRole('option', { name: /Default Preset/i }).click()
// Handle unsaved changes dialog if the preset was marked as modified
const discardButton = page.getByRole('button', {
name: /Discard and Switch/i
})
if (await discardButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await discardButton.click()
}
await expect(presetTrigger).toContainText('Default Preset')
await page.keyboard.press('Escape')
await comfyPage.settingDialog.waitForHidden()
})
test('Can export a preset and re-import it', async ({ comfyPage }) => {
test.setTimeout(30000)
const { page } = comfyPage
const menuButton = page.getByTestId('keybinding-preset-menu')
// Open keybinding settings panel
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
await importPreset(page, TEST_PRESET)
// Verify active preset switched to test-preset
const presetTrigger = page
.locator('#keybinding-panel-actions')
.locator('button[role="combobox"]')
await expect(presetTrigger).toContainText('test-preset')
// Wait for toast to auto-dismiss
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
// Export via ellipsis menu
await menuButton.click()
const downloadPromise = page.waitForEvent('download')
await page.getByRole('menuitem', { name: /Export preset/i }).click()
const download = await downloadPromise
// Verify filename contains test-preset
expect(download.suggestedFilename()).toContain('test-preset')
// Close settings
await page.keyboard.press('Escape')
await comfyPage.settingDialog.waitForHidden()
// Verify the downloaded file is valid JSON with correct structure
const downloadPath = await download.path()
expect(downloadPath).toBeTruthy()
const content = fs.readFileSync(downloadPath!, 'utf-8')
const parsed = JSON.parse(content) as {
name: string
newBindings: unknown[]
unsetBindings: unknown[]
}
expect(parsed).toHaveProperty('name')
expect(parsed).toHaveProperty('newBindings')
expect(parsed).toHaveProperty('unsetBindings')
expect(parsed.name).toBe('test-preset')
})
test('Can delete an imported preset', async ({ comfyPage }) => {
test.setTimeout(30000)
const { page } = comfyPage
const menuButton = page.getByTestId('keybinding-preset-menu')
// Open keybinding settings panel
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
await importPreset(page, TEST_PRESET)
// Verify active preset switched to test-preset
const presetTrigger = page
.locator('#keybinding-panel-actions')
.locator('button[role="combobox"]')
await expect(presetTrigger).toContainText('test-preset')
// Wait for toast to auto-dismiss
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
// Delete via ellipsis menu
await menuButton.click()
await page.getByRole('menuitem', { name: /Delete preset/i }).click()
// Confirm deletion in the dialog
const confirmDialog = page.getByRole('dialog', {
name: /Delete the current preset/i
})
await confirmDialog.getByRole('button', { name: /Delete/i }).click()
// Verify preset trigger now shows Default Preset
await expect(presetTrigger).toContainText('Default Preset')
// Close settings
await page.keyboard.press('Escape')
await comfyPage.settingDialog.waitForHidden()
})
test('Can save modifications as a new preset', async ({ comfyPage }) => {
test.setTimeout(30000)
const { page } = comfyPage
const menuButton = page.getByTestId('keybinding-preset-menu')
// Open keybinding settings panel
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
await importPreset(page, TEST_PRESET)
// Verify active preset switched to test-preset
const presetTrigger = page
.locator('#keybinding-panel-actions')
.locator('button[role="combobox"]')
await expect(presetTrigger).toContainText('test-preset')
// Wait for toast to auto-dismiss
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
// Save as new preset via ellipsis menu
await menuButton.click()
await page.getByRole('menuitem', { name: /Save as new preset/i }).click()
// Fill in the preset name in the prompt dialog
const promptInput = page.locator('.prompt-dialog-content input')
await promptInput.fill('my-custom-preset')
await promptInput.press('Enter')
// Wait for toast to auto-dismiss
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
// Verify preset trigger shows my-custom-preset
await expect(presetTrigger).toContainText('my-custom-preset')
// Close settings
await page.keyboard.press('Escape')
await comfyPage.settingDialog.waitForHidden()
// Cleanup: delete the my-custom-preset file
await comfyPage.request.fetch(
`${comfyPage.url}/api/userdata/keybindings%2Fmy-custom-preset.json`,
{ method: 'DELETE' }
)
})
})

View File

@@ -79,6 +79,7 @@ test.describe('Node search box', { tag: '@node' }, () => {
'Can auto link batch moved node',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.AutoPanSpeed', 0)
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
// Get the CLIP output slot (index 1) from the first CheckpointLoaderSimple node (id: 4)

View File

@@ -123,6 +123,7 @@ test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
})
test('Can pin and unpin', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Graph.AutoPanSpeed', 0)
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick,
button: 'right'

View File

@@ -0,0 +1,38 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../../fixtures/ComfyPage'
test.describe('Assets Sidebar', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.queue.mockHistory([
{ promptId: 'history-asset-1', status: 'success' }
])
await comfyPage.setup({ clearStorage: false })
await comfyPage.page.getByRole('button', { name: /^Assets/ }).click()
await expect(
comfyPage.page.getByRole('button', {
name: /history-asset-1\.png/i
})
).toBeVisible()
})
test('actions menu closes on scroll', async ({ comfyPage }) => {
const assetCard = comfyPage.page.getByRole('button', {
name: /history-asset-1\.png/i
})
await assetCard.hover()
await assetCard.getByRole('button', { name: /more options/i }).click()
const menuPanel = comfyPage.page.locator('.media-asset-menu-panel')
await expect(menuPanel).toBeVisible()
await comfyPage.page.evaluate(() => {
window.dispatchEvent(new Event('scroll'))
})
await expect(menuPanel).toBeHidden()
})
})

View File

@@ -0,0 +1,53 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../../fixtures/ComfyPage'
test.describe('Job History Sidebar', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.queue.mockQueueState()
await comfyPage.queue.mockHistory([
{ promptId: 'history-job-1', status: 'success' }
])
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', true)
await comfyPage.setup({ clearStorage: false })
await comfyPage.page.getByTestId('queue-overlay-toggle').click()
await expect(
comfyPage.page.locator('[data-job-id="history-job-1"]')
).toBeVisible()
})
test('hover popover and actions menu stay clickable', async ({
comfyPage
}) => {
const jobRow = comfyPage.page.locator('[data-job-id="history-job-1"]')
await jobRow.hover()
const popover = comfyPage.page.locator('.job-details-popover')
await expect(popover).toBeVisible()
await popover.getByRole('button', { name: /^copy$/i }).click()
await jobRow.hover()
const moreButton = jobRow.locator('.job-actions-menu-trigger')
await expect(moreButton).toBeVisible()
await moreButton.click()
const menuPanel = comfyPage.page.locator('.job-menu-panel')
await expect(menuPanel).toBeVisible()
const box = await menuPanel.boundingBox()
if (!box) {
throw new Error('Job actions menu did not render a bounding box')
}
await comfyPage.page.mouse.move(
box.x + box.width / 2,
box.y + Math.min(box.height / 2, 24)
)
await expect(menuPanel).toBeVisible()
await menuPanel.getByRole('menuitem', { name: /copy job id/i }).click()
await expect(menuPanel).toBeHidden()
})
})

View File

@@ -43,6 +43,31 @@ test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
expect(rootIds).toEqual([1, 2, 5])
})
test('Promoted widget tuples are stable after full page reload boot path', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const beforeSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(beforeSnapshot.length).toBeGreaterThan(0)
expect(
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
).toBe(true)
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
await expect(async () => {
const afterSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(afterSnapshot).toEqual(beforeSnapshot)
}).toPass({ timeout: 5_000 })
})
test('All links reference valid nodes in their graph', async ({
comfyPage
}) => {

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
// Constants
const RENAMED_INPUT_NAME = 'renamed_input'
@@ -654,6 +655,28 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.locator('.p-breadcrumb')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
await expect(breadcrumb).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect(breadcrumb).toBeHidden()
})
})
test.describe('DOM Widget Promotion', () => {

View File

@@ -1,160 +1,347 @@
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { PromotedWidgetEntry } from '../helpers/promotedWidgets'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import {
getPromotedWidgetSnapshot,
getPromotedWidgets
getPromotedWidgets,
getPseudoPreviewWidgets,
getNonPreviewPromotedWidgets
} from '../helpers/promotedWidgets'
test.describe('Subgraph Lifecycle', { tag: ['@subgraph', '@widget'] }, () => {
test('hydrates legacy proxyWidgets deterministically across reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-duplicate-ids'
)
await comfyPage.nextFrame()
const domPreviewSelector = '.image-preview'
const firstSnapshot = await getPromotedWidgetSnapshot(comfyPage, '5')
expect(firstSnapshot.proxyWidgets.length).toBeGreaterThan(0)
expect(
firstSnapshot.proxyWidgets.every(([nodeId]) => nodeId !== '-1')
).toBe(true)
await comfyPage.page.reload()
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-duplicate-ids'
)
await comfyPage.nextFrame()
const secondSnapshot = await getPromotedWidgetSnapshot(comfyPage, '5')
expect(secondSnapshot.proxyWidgets).toEqual(firstSnapshot.proxyWidgets)
expect(secondSnapshot.widgetNames).toEqual(firstSnapshot.widgetNames)
})
test('promoted view falls back to disconnected placeholder after source widget removal', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const projection = await comfyPage.page.evaluate(() => {
const expectPromotedWidgetsToResolveToInteriorNodes = async (
comfyPage: ComfyPage,
hostSubgraphNodeId: string,
widgets: PromotedWidgetEntry[]
) => {
const interiorNodeIds = widgets.map(([id]) => id)
const results = await comfyPage.page.evaluate(
([hostId, ids]) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById('11')
if (
!hostNode ||
typeof hostNode.isSubgraphNode !== 'function' ||
!hostNode.isSubgraphNode()
)
throw new Error('Expected host subgraph node 11')
const hostNode = graph.getNodeById(Number(hostId))
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
const beforeType = hostNode.widgets?.[0]?.type
const proxyWidgets = Array.isArray(hostNode.properties?.proxyWidgets)
? hostNode.properties.proxyWidgets.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
: []
const firstPromotion = proxyWidgets[0]
if (!firstPromotion)
throw new Error('Expected at least one promoted widget entry')
return ids.map((id) => {
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
return interiorNode !== null && interiorNode !== undefined
})
},
[hostSubgraphNodeId, interiorNodeIds] as const
)
const [sourceNodeId, sourceWidgetName] = firstPromotion
const subgraph = graph.subgraphs.get(hostNode.type)
const sourceNode = subgraph?.getNodeById(Number(sourceNodeId))
if (!sourceNode?.widgets)
throw new Error('Expected promoted source node widget list')
for (const exists of results) {
expect(exists).toBe(true)
}
}
sourceNode.widgets = sourceNode.widgets.filter(
(widget) => widget.name !== sourceWidgetName
)
test.describe(
'Subgraph Lifecycle Edge Behaviors',
{ tag: ['@subgraph'] },
() => {
test.describe('Deterministic Hydrate from Serialized proxyWidgets', () => {
test('proxyWidgets entries map to real interior node IDs after load', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
return {
beforeType,
afterType: hostNode.widgets?.[0]?.type
}
})
const widgets = await getPromotedWidgets(comfyPage, '11')
expect(widgets.length).toBeGreaterThan(0)
expect(projection.beforeType).toBe('customtext')
expect(projection.afterType).toBe('button')
})
test('unpacking one preview host keeps remaining pseudo-preview promotions resolvable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.nextFrame()
const beforeNode8 = await getPromotedWidgets(comfyPage, '8')
expect(beforeNode8).toEqual([['6', '$$canvas-image-preview']])
const cleanupResult = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const invalidPseudoEntries = () => {
const invalid: string[] = []
for (const node of graph.nodes) {
if (
typeof node.isSubgraphNode !== 'function' ||
!node.isSubgraphNode()
)
continue
const subgraph = graph.subgraphs.get(node.type)
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
? node.properties.proxyWidgets.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
: []
for (const entry of proxyWidgets) {
if (entry[1] !== '$$canvas-image-preview') continue
const sourceNodeId = Number(entry[0])
const sourceNode = subgraph?.getNodeById(sourceNodeId)
if (!sourceNode) invalid.push(`${node.id}:${entry[0]}`)
}
for (const [interiorNodeId] of widgets) {
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
return invalid
}
const before = invalidPseudoEntries()
const hostNode = graph.getNodeById('7')
if (
!hostNode ||
typeof hostNode.isSubgraphNode !== 'function' ||
!hostNode.isSubgraphNode()
)
throw new Error('Expected preview host subgraph node 7')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
widgets
)
})
;(
graph as unknown as { unpackSubgraph: (node: unknown) => void }
).unpackSubgraph(hostNode)
test('proxyWidgets entries survive double round-trip without drift', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
return {
before,
after: invalidPseudoEntries(),
hasNode7: Boolean(graph.getNodeById('7')),
hasNode8: Boolean(graph.getNodeById('8'))
}
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
initialWidgets
)
const serialized1 = await comfyPage.page.evaluate(() =>
window.app!.graph!.serialize()
)
await comfyPage.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized1 as ComfyWorkflowJSON
)
await comfyPage.nextFrame()
const afterFirst = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
afterFirst
)
const serialized2 = await comfyPage.page.evaluate(() =>
window.app!.graph!.serialize()
)
await comfyPage.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized2 as ComfyWorkflowJSON
)
await comfyPage.nextFrame()
const afterSecond = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
afterSecond
)
expect(afterFirst).toEqual(initialWidgets)
expect(afterSecond).toEqual(initialWidgets)
})
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
const widgets = await getPromotedWidgets(comfyPage, '2')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(interiorNodeId).not.toBe('-1')
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'2',
widgets
)
})
})
expect(cleanupResult.before).toEqual([])
expect(cleanupResult.after).toEqual([])
expect(cleanupResult.hasNode7).toBe(false)
expect(cleanupResult.hasNode8).toBe(true)
test.describe('Placeholder Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
const afterNode8 = await getPromotedWidgets(comfyPage, '8')
expect(afterNode8).toEqual([['6', '$$canvas-image-preview']])
})
})
test('Removing promoted source node inside subgraph falls back to disconnected placeholder on exterior', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const hostNode = window.app!.canvas.graph!.getNodeById('11')
const proxyWidgets = hostNode?.properties?.proxyWidgets
return {
proxyWidgetCount: Array.isArray(proxyWidgets)
? proxyWidgets.length
: 0,
firstWidgetType: hostNode?.widgets?.[0]?.type
}
})
})
.toEqual({
proxyWidgetCount: initialWidgets.length,
firstWidgetType: 'button'
})
})
test('Promoted widget disappears from DOM after interior node deletion', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
).toHaveCount(0)
})
})
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
test('Pseudo-preview entries exist in proxyWidgets for preview subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(
pseudoWidgets.some(([, name]) => name === '$$canvas-image-preview')
).toBe(true)
})
test('Non-preview widgets coexist with pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
const nonPreviewWidgets = await getNonPreviewPromotedWidgets(
comfyPage,
'5'
)
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(nonPreviewWidgets.length).toBeGreaterThan(0)
expect(
nonPreviewWidgets.some(([, name]) => name === 'filename_prefix')
).toBe(true)
})
test('Unpacking subgraph clears pseudo-preview entries from graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const subgraphNodeCount = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
return graph.nodes.filter((n) => n.isSubgraphNode()).length
})
expect(subgraphNodeCount).toBe(0)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
})
test('Removing subgraph node clears pseudo-preview DOM elements', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const nodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.canvas.graph!.getNodeById('5')
})
expect(nodeExists).toBe(false)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
test('Unpacking one subgraph does not clear sibling pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.nextFrame()
const firstNodeBefore = await getPseudoPreviewWidgets(comfyPage, '7')
const secondNodeBefore = await getPseudoPreviewWidgets(comfyPage, '8')
expect(firstNodeBefore.length).toBeGreaterThan(0)
expect(secondNodeBefore.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.getNodeById('7')
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const firstNodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.graph!.getNodeById('7')
})
expect(firstNodeExists).toBe(false)
const secondNodeAfter = await getPseudoPreviewWidgets(comfyPage, '8')
expect(secondNodeAfter).toEqual(secondNodeBefore)
})
})
}
)

View File

@@ -0,0 +1,110 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Nested subgraph configure order', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test('Loads without "No link found" or "Failed to resolve legacy -1" console warnings', async ({
comfyPage
}) => {
const warnings: string[] = []
comfyPage.page.on('console', (msg) => {
const text = msg.text()
if (
text.includes('No link found') ||
text.includes('Failed to resolve legacy -1') ||
text.includes('No inner link found')
) {
warnings.push(text)
}
})
await comfyPage.workflow.loadWorkflow(WORKFLOW)
expect(warnings).toEqual([])
})
test('All three subgraph levels resolve promoted widgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const results = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const allGraphs = [graph, ...graph.subgraphs.values()]
return allGraphs.flatMap((g) =>
g._nodes
.filter(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((hostNode) => {
const proxyWidgets = Array.isArray(
hostNode.properties?.proxyWidgets
)
? hostNode.properties.proxyWidgets
: []
const widgetEntries = proxyWidgets
.filter(
(e: unknown): e is [string, string] =>
Array.isArray(e) &&
e.length >= 2 &&
typeof e[0] === 'string' &&
typeof e[1] === 'string'
)
.map(([interiorNodeId, widgetName]: [string, string]) => {
const sg = hostNode.isSubgraphNode() ? hostNode.subgraph : null
const interiorNode = sg?.getNodeById(Number(interiorNodeId))
return {
interiorNodeId,
widgetName,
resolved: interiorNode !== null && interiorNode !== undefined
}
})
return {
hostNodeId: String(hostNode.id),
widgetEntries
}
})
)
})
expect(
results.length,
'Should have subgraph host nodes at multiple nesting levels'
).toBeGreaterThanOrEqual(2)
for (const { hostNodeId, widgetEntries } of results) {
expect(
widgetEntries.length,
`Host node ${hostNodeId} should have promoted widgets`
).toBeGreaterThan(0)
for (const { interiorNodeId, widgetName, resolved } of widgetEntries) {
expect(interiorNodeId).not.toBe('-1')
expect(Number(interiorNodeId)).toBeGreaterThan(0)
expect(widgetName).toBeTruthy()
expect(
resolved,
`Widget "${widgetName}" (interior node ${interiorNodeId}) on host ${hostNodeId} should resolve`
).toBe(true)
}
}
})
test('Prompt execution succeeds without 400 error', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
const response = await responsePromise
expect(response.status()).not.toBe(400)
})
})

View File

@@ -0,0 +1,141 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
/**
* Regression tests for nested subgraph promotion where multiple interior
* nodes share the same widget name (e.g. two CLIPTextEncode nodes both
* with a "text" widget).
*
* The inner subgraph (node 3) promotes both ["1","text"] and ["2","text"].
* The outer subgraph (node 4) promotes through node 3 using identity
* disambiguation (optional sourceNodeId in the promotion entry).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10123#discussion_r2956230977
*/
test.describe(
'Nested subgraph duplicate widget names',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Inner subgraph node has both text widgets promoted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const nonPreview = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
return ((innerSubgraphNode.properties?.proxyWidgets ?? []) as unknown[])
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string' &&
!entry[1].startsWith('$$')
)
.map(
([nodeId, widgetName]) => [nodeId, widgetName] as [string, string]
)
})
expect(nonPreview).toEqual([
['1', 'text'],
['2', 'text']
])
})
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const widgetValues = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
return (innerSubgraphNode.widgets ?? []).map((w) => ({
name: w.name,
value: w.value
}))
})
const textWidgets = widgetValues.filter((w) => w.name.startsWith('text'))
expect(textWidgets).toHaveLength(2)
const values = textWidgets.map((w) => w.value)
expect(values).toContain('11111111111')
expect(values).toContain('22222222222')
})
test.describe('Promoted border styling in Vue mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Intermediate subgraph widgets get promoted border, outermost does not', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Node 4 is the outer SubgraphNode at root level.
// Its widgets are not promoted further (no parent subgraph),
// so none of its widget wrappers should carry the promoted ring.
const outerNode = comfyPage.vueNodes.getNodeLocator('4')
await expect(outerNode).toBeVisible()
const outerPromotedRings = outerNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await expect(outerPromotedRings).toHaveCount(0)
// Navigate into the outer subgraph (node 4) to reach node 3
await comfyPage.vueNodes.enterSubgraph('4')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
// Node 3 is the intermediate SubgraphNode whose "text" widgets
// are promoted up to the outer subgraph (node 4).
// Its widget wrappers should carry the promoted border ring.
const intermediateNode = comfyPage.vueNodes.getNodeLocator('3')
await expect(intermediateNode).toBeVisible()
const intermediatePromotedRings = intermediateNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await expect(intermediatePromotedRings).toHaveCount(1)
})
})
}
)

View File

@@ -73,5 +73,59 @@ test.describe(
expect(progressAfter).toBeUndefined()
}).toPass({ timeout: 2_000 })
})
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
return subgraphNode ? String(subgraphNode.id) : null
})
expect(subgraphNodeId).not.toBeNull()
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.7
}, subgraphNodeId!)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
subgraphNodeId!
)
await subgraphNode.navigateIntoSubgraph()
const inSubgraph = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
expect(inSubgraph).toBe(true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect(async () => {
const subgraphProgressState = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
if (!subgraphNode) {
return { exists: false, progress: null }
}
return { exists: true, progress: subgraphNode.progress }
})
expect(subgraphProgressState.exists).toBe(true)
expect(subgraphProgressState.progress).toBeUndefined()
}).toPass({ timeout: 5_000 })
})
}
)

View File

@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import { fitToViewInstant } from '../helpers/fitToView'
@@ -12,25 +11,6 @@ import {
getPromotedWidgets
} from '../helpers/promotedWidgets'
/**
* Check whether we're currently in a subgraph.
*/
async function isInSubgraph(comfyPage: ComfyPage): Promise<boolean> {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
}
async function exitSubgraphToParent(comfyPage: ComfyPage): Promise<void> {
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
if (!canvas.graph) return
canvas.setGraph(canvas.graph.rootGraph)
})
await comfyPage.nextFrame()
}
test.describe(
'Subgraph Widget Promotion',
{ tag: ['@subgraph', '@widget'] },
@@ -190,7 +170,7 @@ test.describe(
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(true)
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
})
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
@@ -262,7 +242,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent graph
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// Promoted textarea on SubgraphNode should have the same value
const promotedTextarea = comfyPage.page.getByTestId(
@@ -296,7 +276,7 @@ test.describe(
)
await expect(interiorTextarea).toHaveValue(testContent)
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
@@ -342,7 +322,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should now have the promoted widget
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
@@ -377,7 +357,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back and verify promotion took effect
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
await fitToViewInstant(comfyPage)
await comfyPage.nextFrame()
@@ -408,7 +388,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should have fewer widgets
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
@@ -564,6 +544,30 @@ test.describe(
expect(widgetCount).toBeGreaterThan(0)
})
test('Multi-link input representative stays stable through save/reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(beforeSnapshot.length).toBeGreaterThan(0)
const serialized = await comfyPage.page.evaluate(() => {
return window.app!.graph!.serialize()
})
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
return window.app!.loadGraphData(workflow)
}, serialized as ComfyWorkflowJSON)
await comfyPage.nextFrame()
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(afterSnapshot).toEqual(beforeSnapshot)
})
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
comfyPage
}) => {
@@ -721,6 +725,44 @@ test.describe(
expect(nodeExists).toBe(false)
})
test('Nested promoted widget entries reflect interior changes after slot removal', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
const initialNames = await getPromotedWidgetNames(comfyPage, '5')
expect(initialNames.length).toBeGreaterThan(0)
const outerSubgraph = await comfyPage.nodeOps.getNodeRefById('5')
await outerSubgraph.navigateIntoSubgraph()
const removedSlotName = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.name ?? null
})
expect(removedSlotName).not.toBeNull()
await comfyPage.subgraph.rightClickInputSlot()
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
const finalNames = await getPromotedWidgetNames(comfyPage, '5')
const expectedNames = [...initialNames]
const removedIndex = expectedNames.indexOf(removedSlotName!)
expect(removedIndex).toBeGreaterThanOrEqual(0)
expectedNames.splice(removedIndex, 1)
expect(finalNames).toEqual(expectedNames)
})
test('Removing I/O slot removes associated promoted widget', async ({
comfyPage
}) => {
@@ -743,7 +785,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back via breadcrumb
await exitSubgraphToParent(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// Widget count should be reduced
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -9,10 +9,7 @@ const config: KnipConfig = {
'src/main.ts',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts',
'src/storybook/mocks/**/*.ts',
// Discovered at runtime via import.meta.glob in src/extensions/core/index.ts
'src/extensions/core/extensions/*/index.ts',
'src/extensions/core/extensions/*/comfy.ext.config.ts'
'src/storybook/mocks/**/*.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
},
@@ -64,16 +61,6 @@ const config: KnipConfig = {
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Pending migration to extensions/*/index.ts glob pattern
'src/extensions/core/customWidgets.ts',
'src/extensions/core/imageCompare.ts',
'src/extensions/core/imageCrop.ts',
'src/extensions/core/nightlyBadges.ts',
'src/extensions/core/painter.ts',
'src/extensions/core/load3dLazy.ts',
// Duplicates of old-path files, pending import path migration
'src/extensions/core/extensions/maskeditor/constants.ts',
'src/extensions/core/extensions/maskeditor/types.ts',
// Loaded via @plugin directive in CSS, not detected by knip
'packages/design-system/src/css/lucideStrokePlugin.js'
],

View File

@@ -1901,3 +1901,37 @@ audio.comfy-audio.empty-audio-widget {
background-position: 0 0;
}
}
@utility scroll-shadows-* {
overflow: auto;
background:
/* Shadow Cover TOP */
linear-gradient(--value(--color-*) 30%, transparent) center top,
/* Shadow Cover BOTTOM */
linear-gradient(transparent, --value(--color-*) 70%) center bottom,
/* Shadow TOP */
radial-gradient(
farthest-side at 50% 0,
color-mix(in oklab, --value(--color-*), #777777 35%),
60%,
transparent
)
center top,
/* Shadow BOTTOM */
radial-gradient(
farthest-side at 50% 100%,
color-mix(in oklab, --value(--color-*), #777777 35%),
60%,
transparent
)
center bottom;
background-repeat: no-repeat;
background-size:
300% 40px,
300% 40px,
300% 14px,
300% 14px;
background-attachment: local, local, scroll, scroll;
}

View File

@@ -137,6 +137,21 @@ function nodeToNodeData(node: LGraphNode) {
onDragOver: node.onDragOver
}
}
async function handleDragDrop(e: DragEvent) {
for (const { nodeData } of mappedSelections.value) {
if (!nodeData?.onDragOver?.(e)) continue
const rawResult = nodeData?.onDragDrop?.(e)
if (rawResult === false) continue
e.stopPropagation()
e.preventDefault()
if ((await rawResult) === true) return
}
}
defineExpose({ handleDragDrop })
</script>
<template>
<div
@@ -191,7 +206,11 @@ function nodeToNodeData(node: LGraphNode) {
]"
>
<template #button>
<Button variant="textonly" size="icon">
<Button
variant="textonly"
size="icon"
data-testid="widget-actions-menu"
>
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>

View File

@@ -63,7 +63,7 @@ const entries = computed(() => {
</div>
<Popover :entries>
<template #button>
<Button variant="muted-textonly">
<Button variant="muted-textonly" data-testid="widget-actions-menu">
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>

View File

@@ -0,0 +1,74 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
const mountComponent = ({ closeOnScroll = false } = {}) =>
mount(ContextMenu, {
attachTo: document.body,
props: {
closeOnScroll,
contentClass: 'context-menu-content'
},
slots: {
default: '<button class="context-trigger" type="button">Trigger</button>',
content:
'<div class="context-menu-content-inner" role="menuitem">Action</div>'
}
})
async function openMenu() {
const trigger = document.body.querySelector('.context-trigger')
if (!(trigger instanceof HTMLElement)) {
throw new Error('Context trigger element not found')
}
trigger.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
button: 2
})
)
await waitForMenuUpdate()
}
const isMenuVisible = () =>
document.body.querySelector('.context-menu-content-inner') !== null
const waitForMenuUpdate = async () => {
await nextTick()
await nextTick()
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('ContextMenu', () => {
it('opens from the slotted context-menu trigger', async () => {
const wrapper = mountComponent()
await openMenu()
expect(isMenuVisible()).toBe(true)
expect(
document.body.querySelectorAll('[role="menuitem"]').length
).toBeGreaterThan(0)
wrapper.unmount()
})
it('closes on scroll when enabled', async () => {
const wrapper = mountComponent({ closeOnScroll: true })
await openMenu()
window.dispatchEvent(new Event('scroll'))
await waitForMenuUpdate()
expect(isMenuVisible()).toBe(false)
wrapper.unmount()
})
})

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import {
ContextMenuContent,
ContextMenuItem,
injectContextMenuRootContext,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuSeparator,
ContextMenuTrigger
} from 'reka-ui'
import { defineComponent } from 'vue'
defineOptions({
inheritAttrs: false
})
const {
contentClass,
collisionPadding = 8,
closeOnScroll = false
} = defineProps<{
contentClass?: string
collisionPadding?: number
closeOnScroll?: boolean
}>()
const ContextMenuContentProvider = defineComponent({
name: 'ContextMenuContentProvider',
props: {
closeOnScroll: {
type: Boolean,
default: false
}
},
setup(providerProps, { slots }) {
const rootContext = injectContextMenuRootContext()
function closeMenu() {
rootContext.onOpenChange(false)
}
useEventListener(
window,
'scroll',
() => {
if (providerProps.closeOnScroll) {
closeMenu()
}
},
{ capture: true, passive: true }
)
return () =>
slots.default?.({
close: closeMenu,
itemComponent: ContextMenuItem,
separatorComponent: ContextMenuSeparator
})
}
})
</script>
<template>
<ContextMenuRoot>
<ContextMenuTrigger as-child>
<slot />
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
:collision-padding="collisionPadding"
v-bind="$attrs"
:class="contentClass"
>
<ContextMenuContentProvider :close-on-scroll="closeOnScroll">
<template #default="{ close, itemComponent, separatorComponent }">
<slot
name="content"
:close="close"
:item-component="itemComponent"
:separator-component="separatorComponent"
/>
</template>
</ContextMenuContentProvider>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
</template>

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, toValue } from 'vue'
@@ -12,17 +15,42 @@ 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'
import type { ButtonVariants } from '../ui/button/button.variants'
defineOptions({
inheritAttrs: false
})
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
const open = defineModel<boolean>('open', { default: false })
const {
entries,
icon,
to,
itemClass: itemProp,
contentClass: contentProp,
buttonSize,
buttonClass,
align,
showArrow = true,
side = 'bottom',
sideOffset = 5,
collisionPadding = 10,
closeOnScroll = false
} = defineProps<{
entries?: MenuItem[]
icon?: string
to?: string | HTMLElement
itemClass?: string
contentClass?: string
buttonSize?: ButtonVariants['size']
buttonClass?: string
align?: 'start' | 'center' | 'end'
showArrow?: boolean
side?: 'top' | 'right' | 'bottom' | 'left'
sideOffset?: number
collisionPadding?: number
closeOnScroll?: boolean
}>()
const itemClass = computed(() =>
@@ -38,13 +66,28 @@ const contentClass = computed(() =>
contentProp
)
)
function closeMenu() {
open.value = false
}
useEventListener(
window,
'scroll',
() => {
if (closeOnScroll) {
closeMenu()
}
},
{ capture: true, passive: true }
)
</script>
<template>
<DropdownMenuRoot>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuTrigger as-child>
<slot name="button">
<Button size="icon">
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
<i :class="icon ?? 'icon-[lucide--menu]'" />
</Button>
</slot>
@@ -52,22 +95,39 @@ const contentClass = computed(() =>
<DropdownMenuPortal :to>
<DropdownMenuContent
side="bottom"
:side-offset="5"
:collision-padding="10"
:align
:side
:side-offset="sideOffset"
:collision-padding="collisionPadding"
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
name="content"
:close="closeMenu"
:item-class="itemClass"
:item-component="DropdownMenuItem"
:separator-component="DropdownMenuSeparator"
>
<slot
:close="closeMenu"
:item-class="itemClass"
:item-component="DropdownMenuItem"
:separator-component="DropdownMenuSeparator"
>
<DropdownItem
v-for="(item, index) in entries ?? []"
:key="toValue(item.label) ?? index"
:item-class
:content-class
:item
/>
</slot>
</slot>
<DropdownMenuArrow class="fill-base-background stroke-border-subtle" />
<DropdownMenuArrow
v-if="showArrow"
class="fill-base-background stroke-border-subtle"
/>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>

View File

@@ -0,0 +1,61 @@
import { DOMWrapper, flushPromises, mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import ImageLightbox from './ImageLightbox.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function findCloseButton() {
const el = document.body.querySelector('[aria-label="g.close"]')
return el ? new DOMWrapper(el) : null
}
describe(ImageLightbox, () => {
let wrapper: VueWrapper
afterEach(() => {
wrapper.unmount()
})
function mountComponent(props: { src: string; alt?: string }, open = true) {
wrapper = mount(ImageLightbox, {
global: { plugins: [i18n] },
props: { ...props, modelValue: open },
attachTo: document.body
})
return wrapper
}
it('renders the image with correct src and alt when open', async () => {
mountComponent({ src: '/test.png', alt: 'Test image' })
await flushPromises()
const img = document.body.querySelector('img')
expect(img).toBeTruthy()
expect(img?.getAttribute('src')).toBe('/test.png')
expect(img?.getAttribute('alt')).toBe('Test image')
})
it('does not render dialog content when closed', async () => {
mountComponent({ src: '/test.png' }, false)
await flushPromises()
expect(document.body.querySelector('img')).toBeNull()
})
it('emits update:modelValue false when close button is clicked', async () => {
mountComponent({ src: '/test.png' })
await flushPromises()
const closeButton = findCloseButton()
expect(closeButton).toBeTruthy()
await closeButton!.trigger('click')
await flushPromises()
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([false])
})
})

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
VisuallyHidden
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const open = defineModel<boolean>({ default: false })
const { src, alt = '' } = defineProps<{
src: string
alt?: string
}>()
const { t } = useI18n()
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-30 bg-black/60 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<DialogContent
class="fixed top-1/2 left-1/2 z-1700 -translate-1/2 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-50 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-50"
@escape-key-down="open = false"
>
<VisuallyHidden>
<DialogTitle>{{ alt || t('g.imageLightbox') }}</DialogTitle>
<DialogDescription v-if="alt">{{ alt }}</DialogDescription>
</VisuallyHidden>
<DialogClose as-child>
<Button
:aria-label="t('g.close')"
size="icon"
variant="muted-textonly"
class="absolute -top-2 -right-2 z-10 translate-x-full text-white hover:text-white/80"
>
<i class="icon-[lucide--x] size-5" />
</Button>
</DialogClose>
<img
:src
:alt
class="max-h-[90vh] max-w-[90vw] rounded-sm object-contain"
/>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@@ -0,0 +1,70 @@
<template>
<div :class="panelClass">
<template v-for="entry in entries" :key="entry.key">
<component
:is="separatorComponent"
v-if="entry.kind === 'divider'"
:class="separatorWrapperClass"
>
<div :class="separatorClass" />
</component>
<component
:is="itemComponent"
v-else
as-child
:disabled="entry.disabled"
:text-value="entry.label"
@select="emit('action', entry)"
>
<Button
:variant="buttonVariant"
:size="buttonSize"
:class="buttonClass"
:disabled="entry.disabled"
>
<i v-if="entry.icon" :class="cn(entry.icon, iconClass)" />
<span :class="labelClass">{{ entry.label }}</span>
</Button>
</component>
</template>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import type { ButtonVariants } from '@/components/ui/button/button.variants'
import Button from '@/components/ui/button/Button.vue'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import { cn } from '@/utils/tailwindUtil'
const {
entries,
itemComponent,
separatorComponent,
panelClass,
separatorWrapperClass,
separatorClass,
buttonClass,
iconClass,
labelClass,
buttonVariant = 'secondary',
buttonSize = 'sm'
} = defineProps<{
entries: MenuEntry[]
itemComponent: Component
separatorComponent: Component
panelClass: string
separatorWrapperClass: string
separatorClass: string
buttonClass: string
iconClass: string
labelClass?: string
buttonVariant?: ButtonVariants['variant']
buttonSize?: ButtonVariants['size']
}>()
const emit = defineEmits<{
action: [entry: MenuActionEntry]
}>()
</script>

View File

@@ -80,7 +80,11 @@ const tooltipPt = {
</script>
<template>
<DropdownMenuRoot v-model:open="dropdownOpen" @update:open="handleOpen">
<DropdownMenuRoot
v-model:open="dropdownOpen"
:modal="false"
@update:open="handleOpen"
>
<slot name="button" :has-unseen-items="hasUnseenItems">
<div
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"

View File

@@ -77,29 +77,31 @@
</Button>
</template>
<Button
type="button"
class="h-10"
variant="secondary"
@click="showApiKeyForm = true"
>
<img
src="/assets/images/comfy-logo-mono.svg"
class="mr-2 size-5"
:alt="$t('g.comfy')"
/>
{{ t('auth.login.useApiKey') }}
</Button>
<small class="text-center text-muted">
{{ t('auth.apiKey.helpText') }}
<a
:href="`${comfyPlatformBaseUrl}/login`"
target="_blank"
class="cursor-pointer text-blue-500"
<template v-if="!isCloud">
<Button
type="button"
class="h-10"
variant="secondary"
@click="showApiKeyForm = true"
>
{{ t('auth.apiKey.generateKey') }}
</a>
</small>
<img
src="/assets/images/comfy-logo-mono.svg"
class="mr-2 size-5"
:alt="$t('g.comfy')"
/>
{{ t('auth.login.useApiKey') }}
</Button>
<small class="text-center text-muted">
{{ t('auth.apiKey.helpText') }}
<a
:href="`${comfyPlatformBaseUrl}/login`"
target="_blank"
class="cursor-pointer text-blue-500"
>
{{ t('auth.apiKey.generateKey') }}
</a>
</small>
</template>
<Message
v-if="authActions.accessError.value"
severity="info"
@@ -152,6 +154,7 @@ import {
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import type { SignInData, SignUpData } from '@/schemas/signInSchema'
import { isCloud } from '@/platform/distribution/types'
import { isHostWhitelisted, normalizeHost } from '@/utils/hostWhitelist'
import { isInChina } from '@/utils/networkUtil'

View File

@@ -1,9 +1,41 @@
<template>
<div class="keybinding-panel flex flex-col gap-2">
<SearchInput
v-model="filters['global'].value"
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
/>
<Teleport defer to="#keybinding-panel-header">
<SearchInput
v-model="filters['global'].value"
class="max-w-96"
size="lg"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
"
/>
</Teleport>
<Teleport defer to="#keybinding-panel-actions">
<div class="flex items-center gap-2">
<KeybindingPresetToolbar
:preset-names="presetNames"
@presets-changed="refreshPresetList"
/>
<DropdownMenu
:entries="menuEntries"
icon="icon-[lucide--ellipsis]"
item-class="text-sm gap-2"
button-size="unset"
button-class="size-10"
>
<template #button>
<Button
size="unset"
class="size-10"
data-testid="keybinding-preset-menu"
>
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>
</DropdownMenu>
</div>
</Teleport>
<ContextMenuRoot>
<ContextMenuTrigger as-child>
@@ -15,6 +47,9 @@
data-key="id"
:global-filter-fields="['id', 'label']"
:filters="filters"
:paginator="true"
:rows="50"
:rows-per-page-options="[25, 50, 100]"
selection-mode="single"
context-menu
striped-rows
@@ -77,11 +112,7 @@
<span v-if="idx > 0" class="text-muted-foreground">,</span>
<KeyComboDisplay
:key-combo="binding.combo"
:is-modified="
keybindingStore.isCommandKeybindingModified(
slotProps.data.id
)
"
:is-modified="slotProps.data.isModified"
/>
</template>
<span
@@ -141,11 +172,7 @@
variant="textonly"
size="icon"
:aria-label="$t('g.reset')"
:disabled="
!keybindingStore.isCommandKeybindingModified(
slotProps.data.id
)
"
:disabled="!slotProps.data.isModified"
@click="resetKeybinding(slotProps.data)"
>
<i class="icon-[lucide--rotate-ccw]" />
@@ -177,11 +204,7 @@
}}</span>
<KeyComboDisplay
:key-combo="binding.combo"
:is-modified="
keybindingStore.isCommandKeybindingModified(
slotProps.data.id
)
"
:is-modified="slotProps.data.isModified"
/>
</div>
<div class="flex flex-row">
@@ -234,10 +257,7 @@
<ContextMenuSeparator class="my-1 h-px bg-border-subtle" />
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
:disabled="
!contextMenuTarget ||
!keybindingStore.isCommandKeybindingModified(contextMenuTarget.id)
"
:disabled="!contextMenuTarget?.isModified"
@select="ctxResetToDefault"
>
<i class="icon-[lucide--rotate-ccw] size-4" />
@@ -270,6 +290,7 @@
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import { FilterMatchMode } from '@primevue/core/api'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
@@ -282,9 +303,10 @@ import {
ContextMenuSeparator,
ContextMenuTrigger
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
@@ -292,10 +314,13 @@ import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import KeybindingPresetToolbar from './keybinding/KeybindingPresetToolbar.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
const filters = ref({
@@ -304,15 +329,97 @@ const filters = ref({
const keybindingStore = useKeybindingStore()
const keybindingService = useKeybindingService()
const presetService = useKeybindingPresetService()
const settingStore = useSettingStore()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
const presetNames = ref<string[]>([])
async function refreshPresetList() {
presetNames.value = (await presetService.listPresets()) ?? []
}
async function initPresets() {
await refreshPresetList()
const currentName = settingStore.get('Comfy.Keybinding.CurrentPreset')
if (currentName !== 'default') {
const preset = await presetService.loadPreset(currentName)
if (preset) {
keybindingStore.savedPresetData = preset
keybindingStore.currentPresetName = currentName
} else {
await presetService.switchToDefaultPreset()
}
}
}
onMounted(() => initPresets())
// "..." menu entries (teleported to header)
async function saveAsNewPreset() {
await presetService.promptAndSaveNewPreset()
refreshPresetList()
}
async function handleDeletePreset() {
await presetService.deletePreset(keybindingStore.currentPresetName)
refreshPresetList()
}
async function handleImportPreset() {
await presetService.importPreset()
refreshPresetList()
}
const showSaveAsNew = computed(
() =>
keybindingStore.currentPresetName !== 'default' ||
keybindingStore.isCurrentPresetModified
)
const menuEntries = computed<MenuItem[]>(() => [
...(showSaveAsNew.value
? [
{
label: t('g.keybindingPresets.saveAsNewPreset'),
icon: 'icon-[lucide--save]',
command: saveAsNewPreset
}
]
: []),
{
label: t('g.keybindingPresets.resetToDefault'),
icon: 'icon-[lucide--rotate-cw]',
command: () =>
presetService.switchPreset('default').then(() => refreshPresetList())
},
{
label: t('g.keybindingPresets.deletePreset'),
icon: 'icon-[lucide--trash-2]',
disabled: keybindingStore.currentPresetName === 'default',
command: handleDeletePreset
},
{
label: t('g.keybindingPresets.importPreset'),
icon: 'icon-[lucide--file-input]',
command: handleImportPreset
},
{
label: t('g.keybindingPresets.exportPreset'),
icon: 'icon-[lucide--file-output]',
command: () => presetService.exportPreset()
}
])
// Keybinding table logic
interface ICommandData {
id: string
keybindings: KeybindingImpl[]
label: string
source?: string
isModified: boolean
}
const commandsData = computed<ICommandData[]>(() => {
@@ -323,7 +430,8 @@ const commandsData = computed<ICommandData[]>(() => {
command.label ?? ''
),
keybindings: keybindingStore.getKeybindingsByCommandId(command.id),
source: command.source
source: command.source,
isModified: keybindingStore.isCommandKeybindingModified(command.id)
}))
})

View File

@@ -0,0 +1,111 @@
<template>
<div class="flex items-center gap-2">
<Button v-if="showSaveButton" size="lg" @click="handleSavePreset">
{{ $t('g.keybindingPresets.saveChanges') }}
</Button>
<Select v-model="selectedPreset">
<SelectTrigger class="w-64">
<SelectValue :placeholder="$t('g.keybindingPresets.default')">
{{ displayLabel }}
</SelectValue>
</SelectTrigger>
<SelectContent class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1">
<div class="max-w-60">
<SelectItem
value="default"
class="max-w-60 p-2 data-[state=checked]:bg-transparent"
>
{{ $t('g.keybindingPresets.default') }}
</SelectItem>
<SelectItem
v-for="name in presetNames"
:key="name"
:value="name"
class="max-w-60 p-2 data-[state=checked]:bg-transparent"
>
{{ name }}
</SelectItem>
<hr class="h-px max-w-60 border border-border-default" />
<button
class="relative flex w-full max-w-60 cursor-pointer items-center justify-between gap-3 rounded-sm border-none bg-transparent p-2 text-sm outline-none select-none hover:bg-secondary-background-hover focus:bg-secondary-background-hover"
@click.stop="handleImportFromDropdown"
>
<span class="truncate">
{{ $t('g.keybindingPresets.importKeybindingPreset') }}
</span>
<i
class="icon-[lucide--file-input] shrink-0 text-base-foreground"
aria-hidden="true"
/>
</button>
</div>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
const { presetNames } = defineProps<{
presetNames: string[]
}>()
const emit = defineEmits<{
'presets-changed': []
}>()
const { t } = useI18n()
const keybindingStore = useKeybindingStore()
const presetService = useKeybindingPresetService()
const selectedPreset = ref(keybindingStore.currentPresetName)
const displayLabel = computed(() => {
const name =
selectedPreset.value === 'default'
? t('g.keybindingPresets.default')
: selectedPreset.value
return keybindingStore.isCurrentPresetModified ? `${name} *` : name
})
watch(selectedPreset, async (newValue) => {
if (newValue !== keybindingStore.currentPresetName) {
await presetService.switchPreset(newValue)
selectedPreset.value = keybindingStore.currentPresetName
emit('presets-changed')
}
})
watch(
() => keybindingStore.currentPresetName,
(name) => {
selectedPreset.value = name
}
)
const showSaveButton = computed(
() =>
keybindingStore.currentPresetName !== 'default' &&
keybindingStore.isCurrentPresetModified
)
async function handleSavePreset() {
await presetService.savePreset(keybindingStore.currentPresetName)
}
async function handleImportFromDropdown() {
await presetService.importPreset()
emit('presets-changed')
}
</script>

View File

@@ -0,0 +1,42 @@
<template>
<div
class="flex w-full max-w-[420px] flex-col border-t border-border-default"
>
<div class="flex flex-col gap-4 p-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('g.keybindingPresets.unsavedChangesMessage') }}
</p>
<div class="flex justify-end gap-2">
<Button
variant="textonly"
class="text-muted-foreground"
@click="onResult(null)"
>
{{ $t('g.cancel') }}
</Button>
<Button
variant="secondary"
class="bg-secondary-background"
@click="onResult(false)"
>
{{ $t('g.keybindingPresets.discardAndSwitch') }}
</Button>
<Button
variant="secondary"
class="bg-base-foreground text-base-background"
@click="onResult(true)"
>
{{ $t('g.keybindingPresets.saveAndSwitch') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const { onResult } = defineProps<{
onResult: (result: boolean | null) => void
}>()
</script>

View File

@@ -0,0 +1,13 @@
<template>
<div class="flex w-full items-center p-4">
<p class="m-0 text-sm font-medium">
{{ $t('g.keybindingPresets.unsavedChangesTo', { name: presetName }) }}
</p>
</div>
</template>
<script setup lang="ts">
const { presetName } = defineProps<{
presetName: string
}>()
</script>

View File

@@ -98,7 +98,7 @@ import type {
LightConfig,
ModelConfig,
SceneConfig
} from '@/extensions/core/extensions/load3d/interfaces'
} from '@/extensions/core/load3d/interfaces'
import { cn } from '@/utils/tailwindUtil'
const {

View File

@@ -26,7 +26,7 @@ import { computed } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
import Button from '@/components/ui/button/Button.vue'
import type { CameraType } from '@/extensions/core/extensions/load3d/interfaces'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
const cameraType = defineModel<CameraType>('cameraType')
const fov = defineModel<number>('fov')

View File

@@ -36,7 +36,7 @@ import Slider from 'primevue/slider'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { MaterialMode } from '@/extensions/core/extensions/load3d/interfaces'
import type { MaterialMode } from '@/extensions/core/load3d/interfaces'
import { useSettingStore } from '@/platform/settings/settingStore'
const lightIntensity = defineModel<number>('lightIntensity')

View File

@@ -100,7 +100,7 @@ import Button from '@/components/ui/button/Button.vue'
import type {
MaterialMode,
UpDirection
} from '@/extensions/core/extensions/load3d/interfaces'
} from '@/extensions/core/load3d/interfaces'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()

View File

@@ -109,7 +109,7 @@ import { computed, ref } from 'vue'
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
import Button from '@/components/ui/button/Button.vue'
import type { BackgroundRenderModeType } from '@/extensions/core/extensions/load3d/interfaces'
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@/utils/tailwindUtil'
const emit = defineEmits<{

View File

@@ -32,7 +32,7 @@ import Slider from 'primevue/slider'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { CameraType } from '@/extensions/core/extensions/load3d/interfaces'
import type { CameraType } from '@/extensions/core/load3d/interfaces'
const { t } = useI18n()
const cameras = [

View File

@@ -30,7 +30,7 @@ import { useI18n } from 'vue-i18n'
import type {
MaterialMode,
UpDirection
} from '@/extensions/core/extensions/load3d/interfaces'
} from '@/extensions/core/load3d/interfaces'
const { t } = useI18n()
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{

View File

@@ -1,6 +1,16 @@
<template>
<span role="status" class="inline-flex">
<i
v-if="variant === 'loader'"
aria-hidden="true"
:class="cn('icon-[lucide--loader]', sizeClass)"
>
<div
class="size-full animate-spin bg-conic from-base-foreground from-10% to-muted-foreground to-10%"
/>
</i>
<i
v-else
aria-hidden="true"
:class="cn('icon-[lucide--loader-circle] animate-spin', sizeClass)"
/>
@@ -15,6 +25,7 @@ import { cn } from '@/utils/tailwindUtil'
const { size } = defineProps<{
size?: 'sm' | 'md' | 'lg'
variant?: 'loader-circle' | 'loader'
}>()
const { t } = useI18n()

View File

@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/composables/queue/useJobList'
vi.mock('@/composables/queue/useJobMenu', () => ({
useJobMenu: () => ({ jobMenuEntries: [] })
useJobMenu: () => ({ getJobMenuEntries: () => [] })
}))
vi.mock('@/composables/useErrorHandling', () => ({
@@ -30,10 +30,6 @@ const JobAssetsListStub = {
template: '<div class="job-assets-list-stub" />'
}
const JobContextMenuStub = {
template: '<div />'
}
const createJob = (): JobListItem => ({
id: 'job-1',
title: 'Job 1',
@@ -56,8 +52,7 @@ const mountComponent = () =>
stubs: {
QueueOverlayHeader: QueueOverlayHeaderStub,
JobFiltersBar: JobFiltersBarStub,
JobAssetsList: JobAssetsListStub,
JobContextMenu: JobContextMenuStub
JobAssetsList: JobAssetsListStub
}
}
})

View File

@@ -23,36 +23,28 @@
<div class="min-h-0 flex-1 overflow-y-auto">
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
:get-menu-entries="getJobMenuEntries"
@cancel-item="onCancelItemEvent"
@delete-item="onDeleteItemEvent"
@menu-action="onJobMenuAction"
@view-item="$emit('viewItem', $event)"
@menu="onMenuItem"
/>
</div>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type {
JobGroup,
JobListItem,
JobSortMode,
JobTab
} from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import type { MenuActionEntry } from '@/types/menuTypes'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import JobContextMenu from './job/JobContextMenu.vue'
import JobAssetsList from './job/JobAssetsList.vue'
import JobFiltersBar from './job/JobFiltersBar.vue'
@@ -78,13 +70,10 @@ const emit = defineEmits<{
(e: 'viewItem', item: JobListItem): void
}>()
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
(item) => emit('viewItem', item)
const { getJobMenuEntries } = useJobMenu(undefined, (item) =>
emit('viewItem', item)
)
const onCancelItemEvent = (item: JobListItem) => {
@@ -95,14 +84,9 @@ const onDeleteItemEvent = (item: JobListItem) => {
emit('deleteItem', item)
}
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)
}
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
if (entry.kind === 'divider') return
if (entry.onClick) await entry.onClick()
jobContextMenuRef.value?.hide()
})
const onJobMenuAction = wrapWithErrorHandlingAsync(
async (entry: MenuActionEntry) => {
if (entry.onClick) await entry.onClick()
}
)
</script>

View File

@@ -3,11 +3,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import JobAssetsList from './JobAssetsList.vue'
vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
default: {
name: 'JobDetailsPopover',
template: '<div class="job-details-popover-stub" />'
}
}))
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {
@@ -32,43 +37,34 @@ vi.mock('vue-i18n', () => {
}
})
const createResultItem = (
const createPreviewOutput = (
filename: string,
mediaType: string = 'images'
): ResultItemImpl => {
const item = new ResultItemImpl({
): NonNullable<NonNullable<JobListItem['taskRef']>['previewOutput']> =>
({
filename,
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType
})
Object.defineProperty(item, 'url', {
get: () => `/api/view/${filename}`
})
return item
}
mediaType,
isImage: mediaType === 'images',
isVideo: mediaType === 'video',
isAudio: mediaType === 'audio',
is3D: mediaType === 'model',
url: `/api/view/${filename}`
}) as NonNullable<NonNullable<JobListItem['taskRef']>['previewOutput']>
const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
const job: ApiJobListItem = {
id: `task-${Math.random().toString(36).slice(2)}`,
status: 'completed',
create_time: Date.now(),
preview_output: null,
outputs_count: preview ? 1 : 0,
workflow_id: 'workflow-1',
priority: 0
}
const flatOutputs = preview ? [preview] : []
return new TaskItemImpl(job, {}, flatOutputs)
}
const createTaskRef = (
preview?: NonNullable<NonNullable<JobListItem['taskRef']>['previewOutput']>
): JobListItem['taskRef'] =>
({
workflowId: 'workflow-1',
previewOutput: preview
}) as JobListItem['taskRef']
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: createTaskRef(createResultItem('job-1.png')),
taskRef: createTaskRef(createPreviewOutput('job-1.png')),
...overrides
})
@@ -82,7 +78,10 @@ const mountJobAssetsList = (jobs: JobListItem[]) => {
]
return mount(JobAssetsList, {
props: { displayedJobGroups },
props: {
displayedJobGroups,
getMenuEntries: () => []
},
global: {
stubs: {
teleport: true,
@@ -147,7 +146,7 @@ describe('JobAssetsList', () => {
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
taskRef: createTaskRef(createPreviewOutput('job-1.webm', 'video'))
})
const wrapper = mountJobAssetsList([job])
@@ -164,7 +163,7 @@ describe('JobAssetsList', () => {
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
taskRef: createTaskRef(createPreviewOutput('job-1.glb', 'model'))
})
const wrapper = mountJobAssetsList([job])
@@ -179,7 +178,7 @@ describe('JobAssetsList', () => {
it('does not emit viewItem on double-click for non-completed jobs', async () => {
const job = buildJob({
state: 'running',
taskRef: createTaskRef(createResultItem('job-1.png'))
taskRef: createTaskRef(createPreviewOutput('job-1.png'))
})
const wrapper = mountJobAssetsList([job])

View File

@@ -15,64 +15,98 @@
@mouseenter="onJobEnter(job, $event)"
@mouseleave="onJobLeave(job.id)"
>
<AssetsListItem
:class="
cn(
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
job.state === 'running' && 'bg-secondary-background'
)
"
:preview-url="getJobPreviewUrl(job)"
:is-video-preview="isVideoPreviewJob(job)"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@contextmenu.prevent.stop="$emit('menu', job, $event)"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(job)"
@click.stop
<ContextMenu
content-class="z-1700 bg-transparent p-0 font-inter shadow-lg"
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="isCancelable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="emitCancelItem(job)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="emitDeleteItem(job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(job)"
>
{{ t('menuLabels.View') }}
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop="$emit('menu', job, $event)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
<AssetsListItem
:class="
cn(
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
job.state === 'running' && 'bg-secondary-background'
)
"
:preview-url="getJobPreviewUrl(job)"
:is-video-preview="isVideoPreviewJob(job)"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(job)"
@click.stop
>
<template v-if="shouldShowActionsMenu(job.id)" #actions>
<Button
v-if="isCancelable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="emitCancelItem(job)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="emitDeleteItem(job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(job)"
>
{{ t('menuLabels.View') }}
</Button>
<DropdownMenu
:open="openActionsJobId === job.id"
:show-arrow="false"
content-class="z-1700 bg-transparent p-0 shadow-lg"
:side-offset="4"
:collision-padding="8"
@update:open="onActionsMenuOpenChange(job.id, $event)"
>
<template #button>
<Button
class="job-actions-menu-trigger"
variant="secondary"
size="icon"
:aria-label="t('g.more')"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
<template
#content="{ close, itemComponent, separatorComponent }"
>
<MenuPanel
:entries="getMenuEntries(job)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="jobMenuPanelProps"
@action="onMenuAction($event, close)"
/>
</template>
</DropdownMenu>
</template>
</AssetsListItem>
<template #content="{ close, itemComponent, separatorComponent }">
<MenuPanel
:entries="getMenuEntries(job)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="jobMenuPanelProps"
@action="onMenuAction($event, close)"
/>
</template>
</AssetsListItem>
</ContextMenu>
</div>
</div>
</div>
@@ -80,7 +114,7 @@
<Teleport to="body">
<div
v-if="activeDetails && popoverPosition"
class="job-details-popover fixed z-50"
class="job-details-popover fixed z-1700"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
@@ -100,9 +134,13 @@
import { useI18n } from 'vue-i18n'
import { nextTick, ref } from 'vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import MenuPanel from '@/components/common/MenuPanel.vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
import Button from '@/components/ui/button/Button.vue'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
@@ -110,17 +148,32 @@ import { cn } from '@/utils/tailwindUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { isActiveJobState } from '@/utils/queueUtil'
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
const { displayedJobGroups, getMenuEntries } = defineProps<{
displayedJobGroups: JobGroup[]
getMenuEntries: (item: JobListItem) => MenuEntry[]
}>()
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
(e: 'menu-action', entry: MenuActionEntry): void
(e: 'viewItem', item: JobListItem): void
}>()
const jobMenuPanelProps = {
panelClass:
'job-menu-panel flex min-w-56 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter',
separatorWrapperClass: 'px-2 py-1',
separatorClass: 'h-px bg-interface-stroke',
buttonVariant: 'textonly',
buttonClass:
'w-full justify-start bg-transparent data-highlighted:bg-secondary-background-hover',
iconClass: 'block size-4 shrink-0 leading-none text-text-secondary'
} as const
const { t } = useI18n()
const hoveredJobId = ref<string | null>(null)
const openActionsJobId = ref<string | null>(null)
const activeRowElement = ref<HTMLElement | null>(null)
const popoverPosition = ref<{ top: number; left: number } | null>(null)
const {
@@ -188,6 +241,26 @@ function isFailedDeletable(job: JobListItem) {
return job.showClear !== false && job.state === 'failed'
}
function shouldShowActionsMenu(jobId: string) {
return hoveredJobId.value === jobId || openActionsJobId.value === jobId
}
function onActionsMenuOpenChange(jobId: string, isOpen: boolean) {
if (isOpen) {
openActionsJobId.value = jobId
return
}
if (openActionsJobId.value === jobId) {
openActionsJobId.value = null
}
}
function onMenuAction(entry: MenuActionEntry, close: () => void) {
close()
emit('menu-action', entry)
}
function getPreviewOutput(job: JobListItem) {
return job.taskRef?.previewOutput
}

View File

@@ -1,195 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
const popoverStub = defineComponent({
name: 'Popover',
emits: ['show', 'hide'],
data() {
return {
visible: false,
container: null as HTMLElement | null,
eventTarget: null as EventTarget | null,
target: null as EventTarget | null
}
},
mounted() {
this.container = this.$refs.container as HTMLElement | null
},
updated() {
this.container = this.$refs.container as HTMLElement | null
},
methods: {
toggle(event: Event, target?: EventTarget | null) {
if (this.visible) {
this.hide()
return
}
this.show(event, target)
},
show(event: Event, target?: EventTarget | null) {
this.visible = true
this.eventTarget = event.currentTarget
this.target = target ?? event.currentTarget
this.$emit('show')
},
hide() {
this.visible = false
this.$emit('hide')
}
},
template: `
<div v-if="visible" ref="container" class="popover-stub">
<slot />
</div>
`
})
const buttonStub = {
props: {
disabled: {
type: Boolean,
default: false
}
},
template: `
<div
class="button-stub"
:data-disabled="String(disabled)"
>
<slot />
</div>
`
}
const createEntries = (): MenuEntry[] => [
{ key: 'enabled', label: 'Enabled action', onClick: vi.fn() },
{
key: 'disabled',
label: 'Disabled action',
disabled: true,
onClick: vi.fn()
},
{ kind: 'divider', key: 'divider-1' }
]
const mountComponent = (entries: MenuEntry[]) =>
mount(JobContextMenu, {
props: { entries },
global: {
stubs: {
Popover: popoverStub,
Button: buttonStub
}
}
})
const createTriggerEvent = (type: string, currentTarget: EventTarget) =>
({
type,
currentTarget,
target: currentTarget
}) as Event
const openMenu = async (
wrapper: ReturnType<typeof mountComponent>,
type: string = 'click'
) => {
const trigger = document.createElement('button')
document.body.append(trigger)
await wrapper.vm.open(createTriggerEvent(type, trigger))
await nextTick()
return trigger
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('JobContextMenu', () => {
it('passes disabled state to action buttons', async () => {
const wrapper = mountComponent(createEntries())
await openMenu(wrapper)
const buttons = wrapper.findAll('.button-stub')
expect(buttons).toHaveLength(2)
expect(buttons[0].attributes('data-disabled')).toBe('false')
expect(buttons[1].attributes('data-disabled')).toBe('true')
wrapper.unmount()
})
it('emits action for enabled entries', async () => {
const entries = createEntries()
const wrapper = mountComponent(entries)
await openMenu(wrapper)
await wrapper.findAll('.button-stub')[0].trigger('click')
expect(wrapper.emitted('action')).toEqual([[entries[0]]])
wrapper.unmount()
})
it('does not emit action for disabled entries', async () => {
const wrapper = mountComponent([
{
key: 'disabled',
label: 'Disabled action',
disabled: true,
onClick: vi.fn()
}
])
await openMenu(wrapper)
await wrapper.get('.button-stub').trigger('click')
expect(wrapper.emitted('action')).toBeUndefined()
wrapper.unmount()
})
it('hides on pointerdown outside the popover', async () => {
const wrapper = mountComponent(createEntries())
const trigger = document.createElement('button')
const outside = document.createElement('div')
document.body.append(trigger, outside)
await wrapper.vm.open(createTriggerEvent('contextmenu', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(false)
wrapper.unmount()
})
it('keeps the menu open through trigger pointerdown and closes on same trigger click', async () => {
const wrapper = mountComponent(createEntries())
const trigger = document.createElement('button')
document.body.append(trigger)
await wrapper.vm.open(createTriggerEvent('click', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
trigger.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(true)
await wrapper.vm.open(createTriggerEvent('click', trigger))
await nextTick()
expect(wrapper.find('.popover-stub').exists()).toBe(false)
wrapper.unmount()
})
})

View File

@@ -1,118 +0,0 @@
<template>
<Popover
ref="jobItemPopoverRef"
:dismissable="false"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class: [
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
]
}
}"
@show="isVisible = true"
@hide="onHide"
>
<div
ref="contentRef"
class="flex min-w-56 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
>
<template v-for="entry in entries" :key="entry.key">
<div v-if="entry.kind === 'divider'" class="px-2 py-1">
<div class="h-px bg-interface-stroke" />
</div>
<Button
v-else
class="w-full justify-start bg-transparent"
variant="textonly"
size="sm"
:aria-label="entry.label"
:disabled="entry.disabled"
@click="onEntry(entry)"
>
<i
v-if="entry.icon"
:class="[
entry.icon,
'block size-4 shrink-0 leading-none text-text-secondary'
]"
/>
<span>{{ entry.label }}</span>
</Button>
</template>
</div>
</Popover>
</template>
<script setup lang="ts">
import Popover from 'primevue/popover'
import { nextTick, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
defineProps<{ entries: MenuEntry[] }>()
const emit = defineEmits<{
(e: 'action', entry: MenuEntry): void
}>()
type PopoverHandle = {
hide: () => void
show: (event: Event, target?: EventTarget | null) => void
}
const jobItemPopoverRef = ref<PopoverHandle | null>(null)
const contentRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const isVisible = ref(false)
const openedByClick = ref(false)
useDismissableOverlay({
isOpen: isVisible,
getOverlayEl: () => contentRef.value,
getTriggerEl: () => (openedByClick.value ? triggerRef.value : null),
onDismiss: hide
})
async function open(event: Event) {
const trigger =
event.currentTarget instanceof HTMLElement ? event.currentTarget : null
const isSameClickTrigger =
event.type === 'click' && trigger === triggerRef.value && isVisible.value
if (isSameClickTrigger) {
hide()
return
}
openedByClick.value = event.type === 'click'
triggerRef.value = trigger
if (isVisible.value) {
hide()
await nextTick()
}
jobItemPopoverRef.value?.show(event, trigger)
}
function hide() {
jobItemPopoverRef.value?.hide()
}
function onHide() {
isVisible.value = false
openedByClick.value = false
}
function onEntry(entry: MenuEntry) {
if (entry.kind === 'divider' || entry.disabled) return
emit('action', entry)
}
defineExpose({ open, hide })
</script>

View File

@@ -9,7 +9,7 @@
<Teleport to="body">
<div
v-if="!isPreviewVisible && showDetails && popoverPosition"
class="fixed z-50"
class="fixed z-1700"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
@@ -23,7 +23,7 @@
<Teleport to="body">
<div
v-if="isPreviewVisible && canShowPreview && popoverPosition"
class="fixed z-50"
class="fixed z-1700"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`

View File

@@ -0,0 +1,364 @@
import { mount, flushPromises } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
const mockGetLogs = vi.fn(() => Promise.resolve('mock server logs'))
const mockSerialize = vi.fn(() => ({ nodes: [] }))
const mockGenerateErrorReport = vi.fn(
(_data?: unknown) => '# ComfyUI Error Report\n...'
)
vi.mock('@/scripts/api', () => ({
api: {
getLogs: () => mockGetLogs()
}
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: () => mockSerialize()
}
}
}))
vi.mock('@/utils/errorReportUtil', () => ({
generateErrorReport: (data: unknown) => mockGenerateErrorReport(data)
}))
const mockTrackHelpResourceClicked = vi.fn()
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackUiButtonClicked: vi.fn(),
trackHelpResourceClicked: mockTrackHelpResourceClicked
}))
}))
const mockExecuteCommand = vi.fn()
vi.mock('@/stores/commandStore', () => ({
useCommandStore: vi.fn(() => ({
execute: mockExecuteCommand
}))
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
staticUrls: {
githubIssues: 'https://github.com/comfyanonymous/ComfyUI/issues'
}
}))
}))
describe('ErrorNodeCard.vue', () => {
let i18n: ReturnType<typeof createI18n>
beforeEach(() => {
vi.clearAllMocks()
cardIdCounter = 0
mockGetLogs.mockResolvedValue('mock server logs')
mockGenerateErrorReport.mockReturnValue('# ComfyUI Error Report\n...')
i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
copy: 'Copy',
findIssues: 'Find Issues',
findOnGithub: 'Find on GitHub',
getHelpAction: 'Get Help'
},
rightSidePanel: {
locateNode: 'Locate Node',
enterSubgraph: 'Enter Subgraph',
findOnGithubTooltip: 'Search GitHub issues for related problems',
getHelpTooltip:
'Report this error and we\u0027ll help you resolve it'
},
issueReport: {
helpFix: 'Help Fix This'
}
}
}
})
})
function mountCard(card: ErrorCardData) {
return mount(ErrorNodeCard, {
props: { card },
global: {
plugins: [
PrimeVue,
i18n,
createTestingPinia({
createSpy: vi.fn,
initialState: {
systemStats: {
systemStats: {
system: {
os: 'Linux',
python_version: '3.11.0',
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.1.0',
argv: ['--listen']
},
devices: [
{
name: 'NVIDIA RTX 4090',
type: 'cuda',
vram_total: 24000,
vram_free: 12000,
torch_vram_total: 24000,
torch_vram_free: 12000
}
]
}
}
}
})
],
stubs: {
Button: {
template:
'<button :aria-label="$attrs[\'aria-label\']"><slot /></button>'
}
}
}
})
}
let cardIdCounter = 0
function makeRuntimeErrorCard(): ErrorCardData {
return {
id: `exec-${++cardIdCounter}`,
title: 'KSampler',
nodeId: '10',
nodeTitle: 'KSampler',
errors: [
{
message: 'RuntimeError: CUDA out of memory',
details: 'Traceback line 1\nTraceback line 2',
isRuntimeError: true,
exceptionType: 'RuntimeError'
}
]
}
}
function makeValidationErrorCard(): ErrorCardData {
return {
id: `node-${++cardIdCounter}`,
title: 'CLIPTextEncode',
nodeId: '6',
nodeTitle: 'CLIP Text Encode',
errors: [
{
message: 'Required input is missing',
details: 'Input: text'
}
]
}
}
it('displays enriched report for runtime errors on mount', async () => {
const reportText =
'# ComfyUI Error Report\n## System Information\n- OS: Linux'
mockGenerateErrorReport.mockReturnValue(reportText)
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
expect(wrapper.text()).toContain('ComfyUI Error Report')
expect(wrapper.text()).toContain('System Information')
expect(wrapper.text()).toContain('OS: Linux')
})
it('does not generate report for non-runtime errors', async () => {
mountCard(makeValidationErrorCard())
await flushPromises()
expect(mockGetLogs).not.toHaveBeenCalled()
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
})
it('displays original details for non-runtime errors', async () => {
const wrapper = mountCard(makeValidationErrorCard())
await flushPromises()
expect(wrapper.text()).toContain('Input: text')
expect(wrapper.text()).not.toContain('ComfyUI Error Report')
})
it('copies enriched report when copy button is clicked for runtime error', async () => {
const reportText = '# Full Report Content'
mockGenerateErrorReport.mockReturnValue(reportText)
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
const copyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Copy'))!
expect(copyButton.exists()).toBe(true)
await copyButton.trigger('click')
const emitted = wrapper.emitted('copyToClipboard')
expect(emitted).toHaveLength(1)
expect(emitted![0][0]).toContain('# Full Report Content')
})
it('copies original details when copy button is clicked for validation error', async () => {
const wrapper = mountCard(makeValidationErrorCard())
await flushPromises()
const copyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Copy'))!
await copyButton.trigger('click')
const emitted = wrapper.emitted('copyToClipboard')
expect(emitted).toHaveLength(1)
expect(emitted![0][0]).toBe('Required input is missing\n\nInput: text')
})
it('generates report with fallback logs when getLogs fails', async () => {
mockGetLogs.mockRejectedValue(new Error('Network error'))
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
// Report is still generated with fallback log message
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
expect(wrapper.text()).toContain('ComfyUI Error Report')
})
it('falls back to original details when generateErrorReport throws', async () => {
mockGenerateErrorReport.mockImplementation(() => {
throw new Error('Serialization error')
})
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
expect(wrapper.text()).toContain('Traceback line 1')
})
it('opens GitHub issues search when Find Issue button is clicked', async () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
const findIssuesButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Find on GitHub'))!
expect(findIssuesButton.exists()).toBe(true)
await findIssuesButton.trigger('click')
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining('github.com/comfyanonymous/ComfyUI/issues?q='),
'_blank',
'noopener,noreferrer'
)
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining('CUDA%20out%20of%20memory'),
expect.any(String),
expect.any(String)
)
openSpy.mockRestore()
})
it('executes ContactSupport command when Get Help button is clicked', async () => {
const wrapper = mountCard(makeRuntimeErrorCard())
await flushPromises()
const getHelpButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Get Help'))!
expect(getHelpButton.exists()).toBe(true)
await getHelpButton.trigger('click')
expect(mockExecuteCommand).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith(
expect.objectContaining({
resource_type: 'help_feedback',
source: 'error_dialog'
})
)
})
it('passes exceptionType from error item to report generator', async () => {
mountCard(makeRuntimeErrorCard())
await flushPromises()
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
exceptionType: 'RuntimeError'
})
)
})
it('uses fallback exception type when error item has no exceptionType', async () => {
const card: ErrorCardData = {
id: `exec-${++cardIdCounter}`,
title: 'KSampler',
nodeId: '10',
nodeTitle: 'KSampler',
errors: [
{
message: 'Unknown error occurred',
details: 'Some traceback',
isRuntimeError: true
}
]
}
mountCard(card)
await flushPromises()
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
expect.objectContaining({
exceptionType: 'Runtime Error'
})
)
})
it('falls back to original details when systemStats is unavailable', async () => {
const wrapper = mount(ErrorNodeCard, {
props: { card: makeRuntimeErrorCard() },
global: {
plugins: [
PrimeVue,
i18n,
createTestingPinia({
createSpy: vi.fn,
initialState: {
systemStats: { systemStats: null }
}
})
],
stubs: {
Button: {
template:
'<button :aria-label="$attrs[\'aria-label\']"><slot /></button>'
}
}
}
})
await flushPromises()
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('Traceback line 1')
})
})

View File

@@ -1,5 +1,5 @@
<template>
<div class="overflow-hidden">
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<!-- Card Header -->
<div
v-if="card.nodeId && !compact"
@@ -12,10 +12,10 @@
#{{ card.nodeId }}
</span>
<span
v-if="card.nodeTitle"
v-if="card.nodeTitle || card.title"
class="flex-1 truncate text-sm font-medium text-muted-foreground"
>
{{ card.nodeTitle }}
{{ card.nodeTitle || card.title }}
</span>
<div class="flex shrink-0 items-center">
<Button
@@ -40,12 +40,19 @@
</div>
<!-- Multiple Errors within one Card -->
<div class="space-y-4 divide-y divide-interface-stroke/20">
<div
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
>
<!-- Card Content -->
<div
v-for="(error, idx) in card.errors"
:key="idx"
class="flex flex-col gap-3"
:class="
cn(
'flex min-h-0 flex-col gap-3',
fullHeight && error.isRuntimeError && 'flex-1'
)
"
>
<!-- Error Message -->
<p
@@ -55,32 +62,60 @@
{{ error.message }}
</p>
<!-- Traceback / Details -->
<!-- Traceback / Details (enriched with full report for runtime errors) -->
<div
v-if="error.details"
v-if="displayedDetailsMap[idx]"
:class="
cn(
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background-hover p-2.5',
error.isRuntimeError ? 'max-h-[10lh]' : 'max-h-[6lh]'
error.isRuntimeError
? fullHeight
? 'min-h-0 flex-1'
: 'max-h-[15lh]'
: 'max-h-[6lh]'
)
"
>
<p
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ error.details }}
{{ displayedDetailsMap[idx] }}
</p>
</div>
<Button
variant="secondary"
size="sm"
class="h-8 w-full justify-center gap-2 text-xs"
@click="handleCopyError(error)"
>
<i class="icon-[lucide--copy] size-3.5" />
{{ t('g.copy') }}
</Button>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<Button
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
variant="secondary"
size="sm"
class="h-8 w-2/3 justify-center gap-1 rounded-lg text-xs"
@click="handleCheckGithub(error)"
>
{{ t('g.findOnGithub') }}
<i class="icon-[lucide--github] size-3.5" />
</Button>
<Button
variant="secondary"
size="sm"
class="h-8 w-1/3 justify-center gap-1 rounded-lg text-xs"
@click="handleCopyError(idx)"
>
{{ t('g.copy') }}
<i class="icon-[lucide--copy] size-3.5" />
</Button>
</div>
<Button
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
variant="secondary"
size="sm"
class="h-8 w-full justify-center gap-1 rounded-lg text-xs"
@click="handleGetHelp"
>
{{ t('g.getHelpAction') }}
<i class="icon-[lucide--external-link] size-3.5" />
</Button>
</div>
</div>
</div>
</div>
@@ -90,19 +125,26 @@
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { cn } from '@/utils/tailwindUtil'
import type { ErrorCardData, ErrorItem } from './types'
import { useErrorReport } from './useErrorReport'
const {
card,
showNodeIdBadge = false,
compact = false
compact = false,
fullHeight = false
} = defineProps<{
card: ErrorCardData
showNodeIdBadge?: boolean
/** Hide card header and error message (used in single-node selection mode) */
compact?: boolean
/** Allow runtime error details to fill available height (used in dedicated panel) */
fullHeight?: boolean
}>()
const emit = defineEmits<{
@@ -112,6 +154,10 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const telemetry = useTelemetry()
const { staticUrls } = useExternalLink()
const commandStore = useCommandStore()
const { displayedDetailsMap } = useErrorReport(() => card)
function handleLocateNode() {
if (card.nodeId) {
@@ -125,10 +171,30 @@ function handleEnterSubgraph() {
}
}
function handleCopyError(error: ErrorItem) {
emit(
'copyToClipboard',
[error.message, error.details].filter(Boolean).join('\n\n')
function handleCopyError(idx: number) {
const details = displayedDetailsMap.value[idx]
const message = card.errors[idx]?.message
emit('copyToClipboard', [message, details].filter(Boolean).join('\n\n'))
}
function handleCheckGithub(error: ErrorItem) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
})
const query = encodeURIComponent(error.message + ' is:issue')
window.open(
`${staticUrls.githubIssues}?q=${query}`,
'_blank',
'noopener,noreferrer'
)
}
function handleGetHelp() {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
commandStore.execute('Comfy.ContactSupport')
}
</script>

View File

@@ -106,7 +106,7 @@
<Button
variant="secondary"
size="md"
class="flex w-full flex-1"
class="flex w-full flex-1 rounded-lg"
:disabled="
comfyManagerStore.isPackInstalled(group.packId) || isInstalling
"
@@ -161,7 +161,7 @@
<Button
variant="secondary"
size="md"
class="flex w-full flex-1"
class="flex w-full flex-1 rounded-lg"
@click="
openManager({
initialTab: ManagerTab.All,

View File

@@ -209,12 +209,41 @@ describe('TabErrors.vue', () => {
}
})
// Find the copy button (rendered inside ErrorNodeCard)
const copyButtons = wrapper.findAll('button')
const copyButton = copyButtons.find((btn) => btn.text().includes('Copy'))
// Find the copy button by text (rendered inside ErrorNodeCard)
const copyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Copy'))
expect(copyButton).toBeTruthy()
await copyButton!.trigger('click')
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
})
it('renders single runtime error outside accordion in full-height panel', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue({
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
const wrapper = mountComponent({
executionError: {
lastExecutionError: {
prompt_id: 'abc',
node_id: '10',
node_type: 'KSampler',
exception_message: 'Out of memory',
exception_type: 'RuntimeError',
traceback: ['Line 1', 'Line 2'],
timestamp: Date.now()
}
}
})
// Runtime error panel title should show class type
expect(wrapper.text()).toContain('KSampler')
expect(wrapper.text()).toContain('RuntimeError: Out of memory')
// Should render in the dedicated runtime error panel, not inside accordion
const runtimePanel = wrapper.find('[data-testid="runtime-error-panel"]')
expect(runtimePanel.exists()).toBe(true)
})
})

View File

@@ -11,8 +11,31 @@
/>
</div>
<!-- Scrollable content -->
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
<!-- Runtime error: full-height panel outside accordion -->
<div
v-if="singleRuntimeErrorCard"
data-testid="runtime-error-panel"
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3"
>
<div
class="shrink-0 pb-2 text-sm font-semibold text-destructive-background-hover"
>
{{ singleRuntimeErrorGroup?.title }}
</div>
<ErrorNodeCard
:key="singleRuntimeErrorCard.id"
:card="singleRuntimeErrorCard"
:show-node-id-badge="showNodeIdBadge"
full-height
class="min-h-0 flex-1"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Scrollable content (non-runtime or mixed errors) -->
<div v-else class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
<TransitionGroup tag="div" name="list-scale" class="relative">
<div
v-if="filteredGroups.length === 0"
@@ -264,6 +287,20 @@ const {
swapNodeGroups
} = useErrorGroups(searchQuery, t)
const singleRuntimeErrorGroup = computed(() => {
if (filteredGroups.value.length !== 1) return null
const group = filteredGroups.value[0]
const isSoleRuntimeError =
group.type === 'execution' &&
group.cards.length === 1 &&
group.cards[0].errors.every((e) => e.isRuntimeError)
return isSoleRuntimeError ? group : null
})
const singleRuntimeErrorCard = computed(
() => singleRuntimeErrorGroup.value?.cards[0] ?? null
)
const missingModelStore = useMissingModelStore()
const downloadableModels = computed(() => {

View File

@@ -2,6 +2,7 @@ export interface ErrorItem {
message: string
details?: string
isRuntimeError?: boolean
exceptionType?: string
}
export interface ErrorCardData {

View File

@@ -395,7 +395,8 @@ export function useErrorGroups(
{
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true
isRuntimeError: true,
exceptionType: e.exception_type
}
],
filterBySelection

View File

@@ -0,0 +1,82 @@
import { computed, onMounted, onUnmounted, reactive, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { generateErrorReport } from '@/utils/errorReportUtil'
import type { ErrorCardData } from './types'
/** Fallback exception type for error reports when the backend does not provide one. Not i18n'd: used in diagnostic reports only. */
const FALLBACK_EXCEPTION_TYPE = 'Runtime Error'
export function useErrorReport(cardSource: MaybeRefOrGetter<ErrorCardData>) {
const systemStatsStore = useSystemStatsStore()
const enrichedDetails = reactive<Record<number, string>>({})
const displayedDetailsMap = computed(() => {
const card = toValue(cardSource)
return Object.fromEntries(
card.errors.map((error, idx) => [
idx,
enrichedDetails[idx] ?? error.details
])
)
})
let cancelled = false
onUnmounted(() => {
cancelled = true
})
onMounted(async () => {
const card = toValue(cardSource)
const runtimeErrors = card.errors
.map((error, idx) => ({ error, idx }))
.filter(({ error }) => error.isRuntimeError)
if (runtimeErrors.length === 0) return
if (!systemStatsStore.systemStats) {
try {
await systemStatsStore.refetchSystemStats()
} catch {
return
}
}
if (cancelled || !systemStatsStore.systemStats) return
let logs: string
try {
logs = await api.getLogs()
} catch {
logs = 'Failed to retrieve server logs'
}
if (cancelled) return
const workflow = app.rootGraph.serialize()
for (const { error, idx } of runtimeErrors) {
try {
const report = generateErrorReport({
exceptionType: error.exceptionType ?? FALLBACK_EXCEPTION_TYPE,
exceptionMessage: error.message,
traceback: error.details,
nodeId: card.nodeId,
nodeType: card.title,
systemStats: systemStatsStore.systemStats,
serverLogs: logs,
workflow
})
enrichedDetails[idx] = report
} catch {
// Fallback: keep original error.details
}
}
})
return { displayedDetailsMap }
}

View File

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { getSourceNodeId } from '@/core/graph/subgraph/promotionUtils'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
@@ -78,19 +79,22 @@ function isWidgetShownOnParents(
): boolean {
return parents.some((parent) => {
if (isPromotedWidgetView(widget)) {
return promotionStore.isPromoted(
parent.rootGraph.id,
parent.id,
widget.sourceNodeId,
widget.sourceWidgetName
)
const sourceNodeId = getSourceNodeId(widget)
const interiorNodeId =
String(widgetNode.id) === String(parent.id)
? widget.sourceNodeId
: String(widgetNode.id)
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
sourceNodeId: interiorNodeId,
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: sourceNodeId
})
}
return promotionStore.isPromoted(
parent.rootGraph.id,
parent.id,
String(widgetNode.id),
widget.name
)
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
sourceNodeId: String(widgetNode.id),
sourceWidgetName: widget.name
})
})
}

View File

@@ -14,6 +14,10 @@ import {
import { useI18n } from 'vue-i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
getSourceNodeId,
getWidgetName
} from '@/core/graph/subgraph/promotionUtils'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
@@ -84,15 +88,27 @@ const widgetsList = computed((): NodeWidgetsList => {
const { widgets = [] } = node
const result: NodeWidgetsList = []
for (const { interiorNodeId, widgetName } of entries) {
for (const {
sourceNodeId: entryNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
} of entries) {
const widget = widgets.find((w) => {
if (isPromotedWidgetView(w)) {
if (
String(w.sourceNodeId) !== entryNodeId ||
w.sourceWidgetName !== sourceWidgetName
)
return false
if (!disambiguatingSourceNodeId) return true
return (
String(w.sourceNodeId) === interiorNodeId &&
w.sourceWidgetName === widgetName
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
disambiguatingSourceNodeId
)
}
return w.name === widgetName
return w.name === sourceWidgetName
})
if (widget) {
result.push({ node, widget })
@@ -113,12 +129,11 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
return allInteriorWidgets.filter(
({ node: interiorNode, widget }) =>
!promotionStore.isPromoted(
node.rootGraph.id,
node.id,
String(interiorNode.id),
widget.name
)
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: getSourceNodeId(widget)
})
)
})

View File

@@ -7,7 +7,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import WidgetActions from './WidgetActions.vue'
@@ -93,8 +95,11 @@ describe('WidgetActions', () => {
function createMockNode(): LGraphNode {
return {
id: 1,
type: 'TestNode'
} as LGraphNode
type: 'TestNode',
rootGraph: { id: 'graph-test' },
computeSize: vi.fn(),
size: [200, 100]
} as unknown as LGraphNode
}
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
@@ -206,4 +211,66 @@ describe('WidgetActions', () => {
expect(wrapper.emitted('resetToDefault')![0]).toEqual(['option1'])
})
it('demotes promoted widgets by immediate interior node identity when shown from parent context', async () => {
mockGetInputSpecForWidget.mockReturnValue({
type: 'CUSTOM'
})
const parentSubgraphNode = {
id: 4,
rootGraph: { id: 'graph-test' },
computeSize: vi.fn(),
size: [300, 150]
} as unknown as SubgraphNode
const node = {
id: 4,
type: 'SubgraphNode',
rootGraph: { id: 'graph-test' }
} as unknown as LGraphNode
const widget = {
name: 'text',
type: 'text',
value: 'value',
label: 'Text',
options: {},
y: 0,
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
} as IBaseWidget
const promotionStore = usePromotionStore()
promotionStore.promote('graph-test', 4, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
const wrapper = mount(WidgetActions, {
props: {
widget,
node,
label: 'Text',
parents: [parentSubgraphNode],
isShownOnParents: true
},
global: {
plugins: [i18n]
}
})
const hideButton = wrapper
.findAll('button')
.find((button) => button.text().includes('Hide input'))
expect(hideButton).toBeDefined()
await hideButton?.trigger('click')
expect(
promotionStore.isPromoted('graph-test', 4, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(false)
})
})

View File

@@ -5,10 +5,11 @@ import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import {
demoteWidget,
getSourceNodeId,
promoteWidget
} from '@/core/graph/subgraph/promotionUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -16,6 +17,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -41,6 +43,7 @@ const label = defineModel<string>('label', { required: true })
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const nodeDefStore = useNodeDefStore()
const promotionStore = usePromotionStore()
const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
@@ -73,26 +76,29 @@ function handleHideInput() {
if (!parents?.length) return
if (isPromotedWidgetView(widget)) {
const sourceWidget = resolvePromotedWidgetSource(node, widget)
if (!sourceWidget) {
console.error('Could not resolve source widget for promoted widget')
return
}
const disambiguatingSourceNodeId = getSourceNodeId(widget)
demoteWidget(sourceWidget.node, sourceWidget.widget, parents)
for (const parent of parents) {
const source: PromotedWidgetSource = {
sourceNodeId:
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id),
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId
}
promotionStore.demote(parent.rootGraph.id, parent.id, source)
parent.computeSize(parent.size)
}
canvasStore.canvas?.setDirty(true, true)
} else {
// For regular widgets (not yet promoted), use them directly
demoteWidget(node, widget, parents)
}
canvasStore.canvas?.setDirty(true, true)
}
function handleShowInput() {
if (!parents?.length) return
promoteWidget(node, widget, parents)
canvasStore.canvas?.setDirty(true, true)
}
function handleToggleFavorite() {

View File

@@ -5,9 +5,11 @@ import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demoteWidget,
getPromotableWidgets,
getSourceNodeId,
getWidgetName,
isRecommendedWidget,
promoteWidget,
@@ -49,19 +51,29 @@ const activeWidgets = computed<WidgetItem[]>({
if (!node) return []
return promotionEntries.value.flatMap(
({ interiorNodeId, widgetName }): WidgetItem[] => {
if (interiorNodeId === '-1') {
const widget = node.widgets.find((w) => w.name === widgetName)
({
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}): WidgetItem[] => {
if (sourceNodeId === '-1') {
const widget = node.widgets.find((w) => w.name === sourceWidgetName)
if (!widget) return []
return [
[{ id: -1, title: t('subgraphStore.linked'), type: '' }, widget]
]
}
const wNode = node.subgraph._nodes_by_id[interiorNodeId]
const wNode = node.subgraph._nodes_by_id[sourceNodeId]
if (!wNode) return []
const widget = getPromotableWidgets(wNode).find(
(w) => w.name === widgetName
)
const widget = getPromotableWidgets(wNode).find((w) => {
if (w.name !== sourceWidgetName) return false
if (disambiguatingSourceNodeId && isPromotedWidgetView(w))
return (
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
disambiguatingSourceNodeId
)
return true
})
if (!widget) return []
return [[wNode, widget]]
}
@@ -76,11 +88,16 @@ const activeWidgets = computed<WidgetItem[]>({
promotionStore.setPromotions(
node.rootGraph.id,
node.id,
value.map(([n, w]) => ({
interiorNodeId: String(n.id),
widgetName: getWidgetName(w)
}))
value.map(([n, w]) => {
const sid = getSourceNodeId(w)
return {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
...(sid && { disambiguatingSourceNodeId: sid })
}
})
)
refreshPromotedWidgetRendering()
}
})
@@ -103,12 +120,11 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
if (!node) return []
return interiorWidgets.value.filter(
([n, w]: WidgetItem) =>
!promotionStore.isPromoted(
node.rootGraph.id,
node.id,
String(n.id),
w.name
)
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: getSourceNodeId(w)
})
)
})
const filteredCandidates = computed<WidgetItem[]>(() => {
@@ -137,8 +153,20 @@ const filteredActive = computed<WidgetItem[]>(() => {
)
})
function refreshPromotedWidgetRendering() {
const node = activeNode.value
if (!node) return
node.computeSize(node.size)
node.setDirtyCanvas(true, true)
canvasStore.canvas?.setDirty(true, true)
}
function toKey(item: WidgetItem) {
return `${item[0].id}: ${item[1].name}`
const sid = getSourceNodeId(item[1])
return sid
? `${item[0].id}: ${item[1].name}:${sid}`
: `${item[0].id}: ${item[1].name}`
}
function nodeWidgets(n: LGraphNode): WidgetItem[] {
return getPromotableWidgets(n).map((w) => [n, w])
@@ -147,49 +175,26 @@ function demote([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
demoteWidget(node, widget, [subgraphNode])
promotionStore.demote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
getWidgetName(widget)
)
}
function promote([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(node, widget, [subgraphNode])
promotionStore.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
widget.name
)
}
function showAll() {
const node = activeNode.value
if (!node) return
for (const [n, w] of filteredCandidates.value) {
promotionStore.promote(node.rootGraph.id, node.id, String(n.id), w.name)
for (const item of filteredCandidates.value) {
promote(item)
}
}
function hideAll() {
const node = activeNode.value
if (!node) return
for (const [n, w] of filteredActive.value) {
if (String(n.id) === '-1') continue
promotionStore.demote(
node.rootGraph.id,
node.id,
String(n.id),
getWidgetName(w)
)
for (const item of filteredActive.value) {
if (String(item[0].id) === '-1') continue
demote(item)
}
}
function showRecommended() {
const node = activeNode.value
if (!node) return
for (const [n, w] of recommendedWidgets.value) {
promotionStore.promote(node.rootGraph.id, node.id, String(n.id), w.name)
for (const item of recommendedWidgets.value) {
promote(item)
}
}

View File

@@ -8,16 +8,38 @@
@approach-end="emit('approach-end')"
>
<template #item="{ item }">
<MediaAssetCard
:asset="item.asset"
:selected="isSelected(item.asset.id)"
:show-output-count="showOutputCount(item.asset)"
:output-count="getOutputCount(item.asset)"
@click="emit('select-asset', item.asset)"
@context-menu="emit('context-menu', $event, item.asset)"
@zoom="emit('zoom', item.asset)"
@output-count-click="emit('output-count-click', item.asset)"
/>
<ContextMenu
close-on-scroll
content-class="z-1700 bg-transparent p-0 shadow-lg"
>
<MediaAssetCard
:asset="item.asset"
:selected="isSelected(item.asset.id)"
:show-output-count="showOutputCount(item.asset)"
:output-count="getOutputCount(item.asset)"
:show-delete-button
:selected-assets
:is-bulk-mode
@click="emit('select-asset', item.asset)"
@zoom="emit('zoom', item.asset)"
@asset-deleted="emit('asset-deleted')"
@bulk-download="emit('bulk-download', $event)"
@bulk-delete="emit('bulk-delete', $event)"
@bulk-add-to-workflow="emit('bulk-add-to-workflow', $event)"
@bulk-open-workflow="emit('bulk-open-workflow', $event)"
@bulk-export-workflow="emit('bulk-export-workflow', $event)"
@output-count-click="emit('output-count-click', item.asset)"
/>
<template #content="{ close, itemComponent, separatorComponent }">
<MenuPanel
:entries="getAssetMenuEntries(item.asset)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="mediaAssetMenuPanelProps"
@action="void onAssetMenuAction($event, close)"
/>
</template>
</ContextMenu>
</template>
</VirtualGrid>
</div>
@@ -26,24 +48,56 @@
<script setup lang="ts">
import { computed } from 'vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
import MenuPanel from '@/components/common/MenuPanel.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import { useMediaAssetMenu } from '@/platform/assets/composables/useMediaAssetMenu'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { mediaAssetMenuPanelProps } from '@/platform/assets/components/mediaAssetMenuPanelConfig'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
const { assets, isSelected, showOutputCount, getOutputCount } = defineProps<{
const {
assets,
isSelected,
showOutputCount,
getOutputCount,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
(e: 'zoom', asset: AssetItem): void
(e: 'asset-deleted'): void
(e: 'bulk-download', assets: AssetItem[]): void
(e: 'bulk-delete', assets: AssetItem[]): void
(e: 'bulk-add-to-workflow', assets: AssetItem[]): void
(e: 'bulk-open-workflow', assets: AssetItem[]): void
(e: 'bulk-export-workflow', assets: AssetItem[]): void
(e: 'output-count-click', asset: AssetItem): void
}>()
const { getMenuEntries } = useMediaAssetMenu({
inspectAsset: (asset) => emit('zoom', asset),
assetDeleted: () => emit('asset-deleted'),
bulkDownload: (assets) => emit('bulk-download', assets),
bulkDelete: (assets) => emit('bulk-delete', assets),
bulkAddToWorkflow: (assets) => emit('bulk-add-to-workflow', assets),
bulkOpenWorkflow: (assets) => emit('bulk-open-workflow', assets),
bulkExportWorkflow: (assets) => emit('bulk-export-workflow', assets)
})
type AssetGridItem = { key: string; asset: AssetItem }
@@ -54,6 +108,22 @@ const assetItems = computed<AssetGridItem[]>(() =>
}))
)
function getAssetMenuEntries(asset: AssetItem): MenuEntry[] {
return getMenuEntries({
asset,
assetType: getAssetType(asset.tags),
fileKind: getMediaTypeFromFilename(asset.name),
showDeleteButton,
selectedAssets,
isBulkMode
})
}
async function onAssetMenuAction(entry: MenuActionEntry, close: () => void) {
close()
await entry.onClick?.()
}
const gridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(min(200px, 30vw), 1fr))',

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
@@ -7,9 +8,9 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import AssetsSidebarListView from './AssetsSidebarListView.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
vi.mock('@/platform/assets/composables/useMediaAssetMenu', () => ({
useMediaAssetMenu: () => ({
getMenuEntries: () => []
})
}))
@@ -19,7 +20,18 @@ vi.mock('@/stores/assetsStore', () => ({
})
}))
const VirtualGridStub = defineComponent({
const i18n = createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: {
en: {}
}
})
const VirtualGridStub = {
name: 'VirtualGrid',
props: {
items: {
@@ -29,7 +41,41 @@ const VirtualGridStub = defineComponent({
},
template:
'<div><slot v-for="item in items" :key="item.key" name="item" :item="item" /></div>'
})
}
const AssetsListItemStub = {
name: 'AssetsListItem',
template:
'<div class="assets-list-item-stub"><slot /><slot name="actions" /></div>'
}
const ContextMenuStub = {
name: 'ContextMenu',
template:
'<div class="context-menu-stub"><slot /><slot name="content" v-bind="{ close: () => {}, itemComponent: \'div\', separatorComponent: \'div\' }" /></div>'
}
const DropdownMenuStub = {
name: 'DropdownMenu',
props: {
open: {
type: Boolean,
default: false
}
},
template:
'<div class="dropdown-menu-stub"><slot name="button" /><slot name="content" v-bind="{ close: () => {}, itemComponent: \'div\', separatorComponent: \'div\' }" /></div>'
}
const ButtonComponentStub = {
name: 'AppButton',
template: '<button class="button-stub" type="button"><slot /></button>'
}
const MenuPanelStub = {
name: 'MenuPanel',
template: '<div class="menu-panel-stub" />'
}
const buildAsset = (id: string, name: string): AssetItem =>
({
@@ -53,12 +99,35 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
toggleStack: async () => {}
},
global: {
plugins: [i18n],
stubs: {
VirtualGrid: VirtualGridStub
}
}
})
const mountInteractiveListView = (assetItems: OutputStackListItem[] = []) =>
mount(AssetsSidebarListView, {
props: {
assetItems,
selectableAssets: [],
isSelected: () => false,
isStackExpanded: () => false,
toggleStack: async () => {}
},
global: {
plugins: [i18n],
stubs: {
AssetsListItem: AssetsListItemStub,
Button: ButtonComponentStub,
ContextMenu: ContextMenuStub,
DropdownMenu: DropdownMenuStub,
MenuPanel: MenuPanelStub,
VirtualGrid: VirtualGridStub
}
}
})
describe('AssetsSidebarListView', () => {
it('marks mp4 assets as video previews', () => {
const videoAsset = {
@@ -131,4 +200,46 @@ describe('AssetsSidebarListView', () => {
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
})
it('keeps row actions mounted while the dropdown is open', async () => {
const imageAsset = {
...buildAsset('image-asset-open', 'image.png'),
user_metadata: {}
} satisfies AssetItem
const wrapper = mountInteractiveListView([buildOutputItem(imageAsset)])
const assetListItem = wrapper.find('.assets-list-item-stub')
await assetListItem.trigger('mouseenter')
const actionsMenu = wrapper.findComponent(DropdownMenuStub)
expect(actionsMenu.exists()).toBe(true)
actionsMenu.vm.$emit('update:open', true)
await nextTick()
await assetListItem.trigger('mouseleave')
await nextTick()
expect(wrapper.findComponent(DropdownMenuStub).exists()).toBe(true)
wrapper.findComponent(DropdownMenuStub).vm.$emit('update:open', false)
await nextTick()
expect(wrapper.findComponent(DropdownMenuStub).exists()).toBe(false)
})
it('does not select the row when clicking the actions trigger', async () => {
const imageAsset = {
...buildAsset('image-asset-actions', 'image.png'),
user_metadata: {}
} satisfies AssetItem
const wrapper = mountInteractiveListView([buildOutputItem(imageAsset)])
const assetListItem = wrapper.find('.assets-list-item-stub')
await assetListItem.trigger('mouseenter')
await wrapper.find('.button-stub').trigger('click')
expect(wrapper.emitted('select-asset')).toBeUndefined()
})
})

View File

@@ -16,49 +16,85 @@
>
<i class="pi pi-trash text-xs" />
</LoadingOverlay>
<AssetsListItem
role="button"
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: getAssetDisplayName(item.asset),
type: getAssetMediaType(item.asset)
})
"
:class="
cn(
getAssetCardClass(isSelected(item.asset.id)),
item.isChild && 'pl-6'
)
"
:preview-url="getAssetPreviewUrl(item.asset)"
:preview-alt="getAssetDisplayName(item.asset)"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
:stack-count="getStackCount(item.asset)"
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
:stack-expanded="isStackExpanded(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
@click.stop="emit('select-asset', item.asset, selectableAssets)"
@dblclick.stop="emit('preview-asset', item.asset)"
@preview-click="emit('preview-asset', item.asset)"
@stack-toggle="void toggleStack(item.asset)"
<ContextMenu
close-on-scroll
content-class="z-1700 bg-transparent p-0 shadow-lg"
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
<Button
variant="secondary"
size="icon"
:aria-label="t('mediaAsset.actions.moreOptions')"
@click.stop="emit('context-menu', $event, item.asset)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
<AssetsListItem
role="button"
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: getAssetDisplayName(item.asset),
type: getAssetMediaType(item.asset)
})
"
:class="
cn(
getAssetCardClass(isSelected(item.asset.id)),
item.isChild && 'pl-6'
)
"
:preview-url="getAssetPreviewUrl(item.asset)"
:preview-alt="getAssetDisplayName(item.asset)"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
:stack-count="getStackCount(item.asset)"
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
:stack-expanded="isStackExpanded(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@mouseleave="onAssetLeave(item.asset.id)"
@click.stop="emit('select-asset', item.asset, selectableAssets)"
@dblclick.stop="emit('preview-asset', item.asset)"
@preview-click="emit('preview-asset', item.asset)"
@stack-toggle="void toggleStack(item.asset)"
>
<template v-if="shouldShowActionsMenu(item.asset.id)" #actions>
<DropdownMenu
:open="openActionsAssetId === item.asset.id"
:show-arrow="false"
content-class="z-1700 bg-transparent p-0 shadow-lg"
:side-offset="4"
:collision-padding="8"
close-on-scroll
@update:open="onActionsMenuOpenChange(item.asset.id, $event)"
>
<template #button>
<Button
variant="secondary"
size="icon"
:aria-label="t('mediaAsset.actions.moreOptions')"
@click.stop
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
<template
#content="{ close, itemComponent, separatorComponent }"
>
<MenuPanel
:entries="getAssetMenuEntries(item.asset)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="mediaAssetMenuPanelProps"
@action="void onAssetMenuAction($event, close)"
/>
</template>
</DropdownMenu>
</template>
</AssetsListItem>
<template #content="{ close, itemComponent, separatorComponent }">
<MenuPanel
:entries="getAssetMenuEntries(item.asset)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="mediaAssetMenuPanelProps"
@action="void onAssetMenuAction($event, close)"
/>
</template>
</AssetsListItem>
</ContextMenu>
</div>
</template>
</VirtualGrid>
@@ -69,16 +105,23 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContextMenu from '@/components/common/ContextMenu.vue'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import MenuPanel from '@/components/common/MenuPanel.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import { useMediaAssetMenu } from '@/platform/assets/composables/useMediaAssetMenu'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { mediaAssetMenuPanelProps } from '@/platform/assets/components/mediaAssetMenuPanelConfig'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import { useAssetsStore } from '@/stores/assetsStore'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import {
formatDuration,
formatSize,
@@ -92,13 +135,19 @@ const {
selectableAssets,
isSelected,
isStackExpanded,
toggleStack
toggleStack,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
assetItems: OutputStackListItem[]
selectableAssets: AssetItem[]
isSelected: (assetId: string) => boolean
isStackExpanded: (asset: AssetItem) => boolean
toggleStack: (asset: AssetItem) => Promise<void>
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const assetsStore = useAssetsStore()
@@ -106,12 +155,27 @@ const assetsStore = useAssetsStore()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
(e: 'preview-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
(e: 'asset-deleted'): void
(e: 'bulk-download', assets: AssetItem[]): void
(e: 'bulk-delete', assets: AssetItem[]): void
(e: 'bulk-add-to-workflow', assets: AssetItem[]): void
(e: 'bulk-open-workflow', assets: AssetItem[]): void
(e: 'bulk-export-workflow', assets: AssetItem[]): void
}>()
const { t } = useI18n()
const hoveredAssetId = ref<string | null>(null)
const openActionsAssetId = ref<string | null>(null)
const { getMenuEntries } = useMediaAssetMenu({
inspectAsset: (asset) => emit('preview-asset', asset),
assetDeleted: () => emit('asset-deleted'),
bulkDownload: (assets) => emit('bulk-download', assets),
bulkDelete: (assets) => emit('bulk-delete', assets),
bulkAddToWorkflow: (assets) => emit('bulk-add-to-workflow', assets),
bulkOpenWorkflow: (assets) => emit('bulk-open-workflow', assets),
bulkExportWorkflow: (assets) => emit('bulk-export-workflow', assets)
})
const listGridStyle = {
display: 'grid',
@@ -128,6 +192,17 @@ function getAssetMediaType(asset: AssetItem) {
return getMediaTypeFromFilename(asset.name)
}
function getAssetMenuEntries(asset: AssetItem): MenuEntry[] {
return getMenuEntries({
asset,
assetType: getAssetType(asset.tags),
fileKind: getAssetMediaType(asset),
showDeleteButton,
selectedAssets,
isBulkMode
})
}
function isVideoAsset(asset: AssetItem): boolean {
return getAssetMediaType(asset) === 'video'
}
@@ -180,6 +255,28 @@ function getAssetCardClass(selected: boolean): string {
)
}
function shouldShowActionsMenu(assetId: string): boolean {
return (
hoveredAssetId.value === assetId || openActionsAssetId.value === assetId
)
}
function onActionsMenuOpenChange(assetId: string, isOpen: boolean): void {
if (isOpen) {
openActionsAssetId.value = assetId
return
}
if (openActionsAssetId.value === assetId) {
openActionsAssetId.value = null
}
}
async function onAssetMenuAction(entry: MenuActionEntry, close: () => void) {
close()
await entry.onClick?.()
}
function onAssetEnter(assetId: string) {
hoveredAssetId.value = assetId
}

View File

@@ -94,10 +94,18 @@
:is-selected="isSelected"
:selectable-assets="listViewSelectableAssets"
:is-stack-expanded="isListViewStackExpanded"
:show-delete-button="shouldShowDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
:toggle-stack="toggleListViewStack"
@asset-deleted="refreshAssets"
@bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete"
@bulk-add-to-workflow="handleBulkAddToWorkflow"
@bulk-open-workflow="handleBulkOpenWorkflow"
@bulk-export-workflow="handleBulkExportWorkflow"
@select-asset="handleAssetSelect"
@preview-asset="handleZoomClick"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
<AssetsSidebarGridView
@@ -106,8 +114,16 @@
:is-selected="isSelected"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
:show-delete-button="shouldShowDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
@asset-deleted="refreshAssets"
@bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete"
@bulk-add-to-workflow="handleBulkAddToWorkflow"
@bulk-open-workflow="handleBulkOpenWorkflow"
@bulk-export-workflow="handleBulkExportWorkflow"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
@zoom="handleZoomClick"
@output-count-click="enterFolderView"
@@ -174,24 +190,6 @@
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
<MediaAssetContextMenu
v-if="contextMenuAsset"
ref="contextMenuRef"
:asset="contextMenuAsset"
:asset-type="contextMenuAssetType"
:file-kind="contextMenuFileKind"
:show-delete-button="shouldShowDeleteButton"
:selected-assets="selectedAssets"
:is-bulk-mode="isBulkMode"
@zoom="handleZoomClick(contextMenuAsset)"
@hide="handleContextMenuHide"
@asset-deleted="refreshAssets"
@bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete"
@bulk-add-to-workflow="handleBulkAddToWorkflow"
@bulk-open-workflow="handleBulkOpenWorkflow"
@bulk-export-workflow="handleBulkExportWorkflow"
/>
</template>
<script setup lang="ts">
@@ -200,14 +198,12 @@ import {
useDebounceFn,
useElementHover,
useResizeObserver,
useStorage,
useTimeoutFn
useStorage
} from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import {
computed,
defineAsyncComponent,
nextTick,
onMounted,
onUnmounted,
ref,
@@ -224,9 +220,7 @@ import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
@@ -236,7 +230,6 @@ import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadat
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
@@ -267,9 +260,6 @@ const viewMode = useStorage<'list' | 'grid'>(
)
const isListView = computed(() => viewMode.value === 'list')
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
const contextMenuAsset = ref<AssetItem | null>(null)
// Determine if delete button should be shown
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
const shouldShowDeleteButton = computed(() => {
@@ -277,14 +267,6 @@ const shouldShowDeleteButton = computed(() => {
return true
})
const contextMenuAssetType = computed(() =>
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
)
const contextMenuFileKind = computed<MediaKind>(() =>
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
)
const shouldShowOutputCount = (item: AssetItem): boolean => {
if (activeTab.value !== 'output' || isInFolderView.value) {
return false
@@ -502,26 +484,6 @@ function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
handleAssetClick(asset, index, assetList)
}
const { start: scheduleCleanup, stop: cancelCleanup } = useTimeoutFn(
() => {
contextMenuAsset.value = null
},
0,
{ immediate: false }
)
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
cancelCleanup()
contextMenuAsset.value = asset
void nextTick(() => {
contextMenuRef.value?.show(event)
})
}
function handleContextMenuHide() {
scheduleCleanup()
}
const handleBulkDownload = (assets: AssetItem[]) => {
downloadMultipleAssets(assets)
clearSelection()

View File

@@ -5,6 +5,49 @@ import { defineComponent, nextTick } from 'vue'
import JobHistorySidebarTab from './JobHistorySidebarTab.vue'
vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
default: {
name: 'JobDetailsPopover',
template: '<div class="job-details-popover-stub" />'
}
}))
vi.mock('@/components/queue/JobHistoryActionsMenu.vue', () => ({
default: {
name: 'JobHistoryActionsMenu',
template: '<div />'
}
}))
vi.mock('@/components/queue/job/JobFilterActions.vue', () => ({
default: {
name: 'JobFilterActions',
template: '<div />'
}
}))
vi.mock('@/components/queue/job/JobFilterTabs.vue', () => ({
default: {
name: 'JobFilterTabs',
template: '<div />'
}
}))
vi.mock('@/components/sidebar/tabs/SidebarTabTemplate.vue', () => ({
default: {
name: 'SidebarTabTemplate',
template:
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
}
}))
vi.mock('@/components/sidebar/tabs/queue/MediaLightbox.vue', () => ({
default: {
name: 'MediaLightbox',
template: '<div />'
}
}))
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {
@@ -52,7 +95,7 @@ vi.mock('@/composables/queue/useJobList', async () => {
vi.mock('@/composables/queue/useJobMenu', () => ({
useJobMenu: () => ({
jobMenuEntries: [],
getJobMenuEntries: () => [],
cancelJob: vi.fn()
})
}))
@@ -130,7 +173,6 @@ function mountComponent() {
JobFilterTabs: true,
JobFilterActions: true,
JobHistoryActionsMenu: true,
JobContextMenu: true,
ResultGallery: true,
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub

View File

@@ -48,15 +48,11 @@
<template #body>
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
:get-menu-entries="getJobMenuEntries"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@menu-action="onJobMenuAction"
@view-item="onViewItem"
@menu="onMenuItem"
/>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<MediaLightbox
v-model:active-index="galleryActiveIndex"
@@ -67,15 +63,14 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, ref } from 'vue'
import { computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
import JobFilterTabs from '@/components/queue/job/JobFilterTabs.vue'
import JobAssetsList from '@/components/queue/job/JobAssetsList.vue'
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import type { MenuActionEntry } from '@/types/menuTypes'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
@@ -182,13 +177,7 @@ const onInspectAsset = (item: JobListItem) => {
void onViewItem(item)
}
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { jobMenuEntries, cancelJob } = useJobMenu(
() => currentMenuItem.value,
onInspectAsset
)
const { getJobMenuEntries, cancelJob } = useJobMenu(undefined, onInspectAsset)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
await cancelJob(item)
@@ -199,14 +188,9 @@ const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
await queueStore.delete(item.taskRef)
})
const onMenuItem = (item: JobListItem, event: Event) => {
currentMenuItem.value = item
jobContextMenuRef.value?.open(event)
}
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
if (entry.kind === 'divider') return
if (entry.onClick) await entry.onClick()
jobContextMenuRef.value?.hide()
})
const onJobMenuAction = wrapWithErrorHandlingAsync(
async (entry: MenuActionEntry) => {
if (entry.onClick) await entry.onClick()
}
)
</script>

View File

@@ -1,5 +1,5 @@
<template>
<ContextMenuRoot>
<ContextMenuRoot :modal="false">
<ContextMenuTrigger as-child>
<div
ref="workflowTabRef"

View File

@@ -413,12 +413,10 @@ describe('Subgraph Promoted Pseudo Widgets', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
'10',
'$$canvas-image-preview'
)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview'
})
const { vueNodeData } = useGraphNodeManager(graph)
const vueNode = vueNodeData.get(String(subgraphNode.id))
@@ -500,12 +498,10 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(independentNode.id),
'string_a'
)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(independentNode.id),
sourceWidgetName: 'string_a'
})
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
@@ -523,6 +519,70 @@ describe('Nested promoted widget mapping', () => {
])
)
})
it('maps duplicate-name promoted views from same intermediate node to distinct store identities', () => {
const innerSubgraph = createTestSubgraph()
const firstTextNode = new LGraphNode('FirstTextNode')
firstTextNode.addWidget('text', 'text', '11111111111', () => undefined)
innerSubgraph.add(firstTextNode)
const secondTextNode = new LGraphNode('SecondTextNode')
secondTextNode.addWidget('text', 'text', '22222222222', () => undefined)
innerSubgraph.add(secondTextNode)
const outerSubgraph = createTestSubgraph()
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 3,
parentGraph: outerSubgraph
})
outerSubgraph.add(innerSubgraphNode)
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 4 })
const graph = outerSubgraphNode.graph as LGraph
graph.add(outerSubgraphNode)
usePromotionStore().setPromotions(
innerSubgraphNode.rootGraph.id,
innerSubgraphNode.id,
[
{ sourceNodeId: String(firstTextNode.id), sourceWidgetName: 'text' },
{ sourceNodeId: String(secondTextNode.id), sourceWidgetName: 'text' }
]
)
usePromotionStore().setPromotions(
outerSubgraphNode.rootGraph.id,
outerSubgraphNode.id,
[
{
sourceNodeId: String(innerSubgraphNode.id),
sourceWidgetName: 'text',
disambiguatingSourceNodeId: String(firstTextNode.id)
},
{
sourceNodeId: String(innerSubgraphNode.id),
sourceWidgetName: 'text',
disambiguatingSourceNodeId: String(secondTextNode.id)
}
]
)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(outerSubgraphNode.id))
const promotedWidgets = nodeData?.widgets?.filter(
(widget) => widget.name === 'text'
)
expect(promotedWidgets).toHaveLength(2)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
).toEqual(
new Set([
`${outerSubgraphNode.subgraph.id}:${firstTextNode.id}`,
`${outerSubgraphNode.subgraph.id}:${secondTextNode.id}`
])
)
})
})
describe('Promoted widget sourceExecutionId', () => {

View File

@@ -6,6 +6,7 @@ import { reactiveComputed } from '@vueuse/core'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
@@ -224,19 +225,21 @@ function safeWidgetMapper(
function resolvePromotedSourceByInputName(inputName: string): {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
} | null {
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
if (!resolvedTarget) return null
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName
sourceWidgetName: resolvedTarget.widgetName,
disambiguatingSourceNodeId: resolvedTarget.sourceNodeId
}
}
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
displayName: string
promotedSource: { sourceNodeId: string; sourceWidgetName: string } | null
promotedSource: PromotedWidgetSource | null
} {
if (!isPromotedWidgetView(widget)) {
return {
@@ -250,7 +253,8 @@ function safeWidgetMapper(
const displayName = promotedInputName ?? widget.name
const directSource = {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
}
const promotedSource =
matchedInput?._widget === widget
@@ -297,7 +301,8 @@ function safeWidgetMapper(
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName
promotedSource.sourceWidgetName,
promotedSource.disambiguatingSourceNodeId
)
: null
const resolvedSource =
@@ -310,7 +315,11 @@ function safeWidgetMapper(
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
? String(
sourceNode?.id ??
promotedSource?.disambiguatingSourceNodeId ??
promotedSource?.sourceNodeId
)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
const mocks = vi.hoisted(() => ({
publishSubgraph: vi.fn(),
@@ -46,6 +46,10 @@ function createSubgraphNode(): SubgraphNode {
return node
}
function createRegularNode(): LGraphNode {
return new LGraphNode('testnode')
}
describe('useSubgraphOperations', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -87,4 +91,16 @@ describe('useSubgraphOperations', () => {
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
})
it('addSubgraphToLibrary does not call publishSubgraph when selected item is not a SubgraphNode', async () => {
mocks.selectedItems = [createRegularNode()]
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { addSubgraphToLibrary } = useSubgraphOperations()
await addSubgraphToLibrary()
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
})
})

View File

@@ -11,6 +11,7 @@ interface DragAndDropOptions<T> {
/**
* Adds drag and drop file handling to a node
* Will also resolve 'text/uri-list' to a file before passing
*/
export const useNodeDragAndDrop = <T>(
node: LGraphNode,
@@ -21,27 +22,55 @@ export const useNodeDragAndDrop = <T>(
const hasFiles = (items: DataTransferItemList) =>
!!Array.from(items).find((f) => f.kind === 'file')
const filterFiles = (files: FileList) => Array.from(files).filter(fileFilter)
const filterFiles = (files: FileList | File[]) =>
Array.from(files).filter(fileFilter)
const hasValidFiles = (files: FileList) => filterFiles(files).length > 0
const isDraggingFiles = (e: DragEvent | undefined) => {
if (!e?.dataTransfer?.items) return false
return onDragOver?.(e) ?? hasFiles(e.dataTransfer.items)
return (
onDragOver?.(e) ??
(hasFiles(e.dataTransfer.items) ||
e?.dataTransfer?.types?.includes('text/uri-list'))
)
}
const isDraggingValidFiles = (e: DragEvent | undefined) => {
if (!e?.dataTransfer?.files) return false
return hasValidFiles(e.dataTransfer.files)
if (e?.dataTransfer?.files?.length)
return hasValidFiles(e.dataTransfer.files)
return !!e?.dataTransfer?.getData('text/uri-list')
}
node.onDragOver = isDraggingFiles
node.onDragDrop = function (e: DragEvent) {
node.onDragDrop = async function (e: DragEvent) {
if (!isDraggingValidFiles(e)) return false
const files = filterFiles(e.dataTransfer!.files)
void onDrop(files)
if (files.length) {
await onDrop(files)
return true
}
const uri = URL.parse(e?.dataTransfer?.getData('text/uri-list') ?? '')
if (!uri || uri.origin !== location.origin) return false
try {
const resp = await fetch(uri)
const fileName = uri?.searchParams?.get('filename')
if (!fileName || !resp.ok) return false
const blob = await resp.blob()
const file = new File([blob], fileName, { type: blob.type })
const uriFiles = filterFiles([file])
if (!uriFiles.length) return false
await onDrop(uriFiles)
} catch {
return false
}
return true
}
}

View File

@@ -112,8 +112,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'seed'
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
@@ -126,8 +125,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const mockUrls = ['/view?filename=output.png']
@@ -137,8 +135,8 @@ describe(usePromotedPreviews, () => {
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: mockUrls
}
@@ -151,8 +149,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
seedOutputs(setup.subgraph.id, [10])
@@ -168,8 +165,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
seedOutputs(setup.subgraph.id, [10])
@@ -192,14 +188,12 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'20',
'$$canvas-image-preview'
{ sourceNodeId: '20', sourceWidgetName: '$$canvas-image-preview' }
)
seedOutputs(setup.subgraph.id, [10, 20])
@@ -221,8 +215,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const blobUrl = 'blob:http://localhost/glsl-preview'
@@ -232,8 +225,8 @@ describe(usePromotedPreviews, () => {
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
@@ -246,8 +239,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
@@ -259,8 +251,8 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
@@ -273,8 +265,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
@@ -286,8 +277,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'99',
'$$canvas-image-preview'
{ sourceNodeId: '99', sourceWidgetName: '$$canvas-image-preview' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
@@ -300,14 +290,12 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'seed'
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const mockUrls = ['/view?filename=img.png']

View File

@@ -8,8 +8,8 @@ import { usePromotionStore } from '@/stores/promotionStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
interface PromotedPreview {
interiorNodeId: string
widgetName: string
sourceNodeId: string
sourceWidgetName: string
type: 'image' | 'video' | 'audio'
urls: string[]
}
@@ -30,13 +30,15 @@ export function usePromotedPreviews(
if (!(node instanceof SubgraphNode)) return []
const entries = promotionStore.getPromotions(node.rootGraph.id, node.id)
const pseudoEntries = entries.filter((e) => e.widgetName.startsWith('$$'))
const pseudoEntries = entries.filter((e) =>
e.sourceWidgetName.startsWith('$$')
)
if (!pseudoEntries.length) return []
const previews: PromotedPreview[] = []
for (const entry of pseudoEntries) {
const interiorNode = node.subgraph.getNodeById(entry.interiorNodeId)
const interiorNode = node.subgraph.getNodeById(entry.sourceNodeId)
if (!interiorNode) continue
// Read from both reactive refs to establish Vue dependency
@@ -45,7 +47,7 @@ export function usePromotedPreviews(
// access the computed would never re-evaluate.
const locatorId = createNodeLocatorId(
node.subgraph.id,
entry.interiorNodeId
entry.sourceNodeId
)
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
@@ -63,8 +65,8 @@ export function usePromotedPreviews(
: 'image'
previews.push({
interiorNodeId: entry.interiorNodeId,
widgetName: entry.widgetName,
sourceNodeId: entry.sourceNodeId,
sourceWidgetName: entry.sourceWidgetName,
type,
urls
})

View File

@@ -3,7 +3,7 @@ import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import type { MenuEntry } from '@/types/menuTypes'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false

View File

@@ -20,20 +20,10 @@ import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import type { MenuEntry } from '@/types/menuTypes'
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { appendJsonExt } from '@/utils/formatUtil'
export type MenuEntry =
| {
kind?: 'item'
key: string
label: string
icon?: string
disabled?: boolean
onClick?: () => void | Promise<void>
}
| { kind: 'divider'; key: string }
/**
* Provides job context menu entries and actions.
*
@@ -117,10 +107,10 @@ export function useJobMenu(
// This is very magical only because it matches the respective backend implementation
// There is or will be a better way to do this
const addOutputLoaderNode = async () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
const addOutputLoaderNode = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
if (!result) return
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
@@ -168,10 +158,10 @@ export function useJobMenu(
/**
* Trigger a download of the job's previewable output asset.
*/
const downloadPreviewAsset = () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
const downloadPreviewAsset = (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
if (!result) return
downloadFile(result.url)
}
@@ -179,14 +169,14 @@ export function useJobMenu(
/**
* Export the workflow JSON attached to the job.
*/
const exportJobWorkflow = async () => {
const item = currentMenuItem()
if (!item) return
const data = await getJobWorkflow(item.id)
const exportJobWorkflow = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const data = await getJobWorkflow(target.id)
if (!data) return
const settingStore = useSettingStore()
let filename = `Job ${item.id}.json`
let filename = `Job ${target.id}.json`
if (settingStore.get('Comfy.PromptFilename')) {
const input = await useDialogService().prompt({
@@ -203,10 +193,10 @@ export function useJobMenu(
downloadBlob(filename, blob)
}
const deleteJobAsset = async () => {
const item = currentMenuItem()
if (!item) return
const task = item.taskRef as TaskItemImpl | undefined
const deleteJobAsset = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const task = target.taskRef as TaskItemImpl | undefined
const preview = task?.previewOutput
if (!task || !preview) return
@@ -237,11 +227,11 @@ export function useJobMenu(
st('queue.jobMenu.cancelJob', 'Cancel job')
)
const jobMenuEntries = computed<MenuEntry[]>(() => {
const item = currentMenuItem()
const state = item?.state
const buildJobMenuEntries = (item?: JobListItem | null): MenuEntry[] => {
const target = resolveItem(item)
const state = target?.state
if (!state) return []
const hasPreviewAsset = !!item?.taskRef?.previewOutput
const hasPreviewAsset = !!target?.taskRef?.previewOutput
if (state === 'completed') {
return [
{
@@ -251,8 +241,7 @@ export function useJobMenu(
disabled: !hasPreviewAsset || !onInspectAsset,
onClick: onInspectAsset
? () => {
const item = currentMenuItem()
if (item) onInspectAsset(item)
if (target) onInspectAsset(target)
}
: undefined
},
@@ -264,34 +253,34 @@ export function useJobMenu(
),
icon: 'icon-[comfy--node]',
disabled: !hasPreviewAsset,
onClick: addOutputLoaderNode
onClick: () => addOutputLoaderNode(target)
},
{
key: 'download',
label: st('queue.jobMenu.download', 'Download'),
icon: 'icon-[lucide--download]',
disabled: !hasPreviewAsset,
onClick: downloadPreviewAsset
onClick: () => downloadPreviewAsset(target)
},
{ kind: 'divider', key: 'd1' },
{
key: 'open-workflow',
label: jobMenuOpenWorkflowLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
onClick: () => openJobWorkflow(target)
},
{
key: 'export-workflow',
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
icon: 'icon-[comfy--file-output]',
onClick: exportJobWorkflow
onClick: () => exportJobWorkflow(target)
},
{ kind: 'divider', key: 'd2' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
onClick: () => copyJobId(target)
},
{ kind: 'divider', key: 'd3' },
...(hasPreviewAsset
@@ -300,7 +289,7 @@ export function useJobMenu(
key: 'delete',
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
icon: 'icon-[lucide--trash-2]',
onClick: deleteJobAsset
onClick: () => deleteJobAsset(target)
}
]
: [])
@@ -312,33 +301,33 @@ export function useJobMenu(
key: 'open-workflow',
label: jobMenuOpenWorkflowFailedLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
onClick: () => openJobWorkflow(target)
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
onClick: () => copyJobId(target)
},
{
key: 'copy-error',
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
icon: 'icon-[lucide--copy]',
onClick: copyErrorMessage
onClick: () => copyErrorMessage(target)
},
{
key: 'report-error',
label: st('queue.jobMenu.reportError', 'Report error'),
icon: 'icon-[lucide--message-circle-warning]',
onClick: reportError
onClick: () => reportError(target)
},
{ kind: 'divider', key: 'd2' },
{
key: 'delete',
label: st('queue.jobMenu.removeJob', 'Remove job'),
icon: 'icon-[lucide--circle-minus]',
onClick: removeFailedJob
onClick: () => removeFailedJob(target?.taskRef)
}
]
}
@@ -347,27 +336,30 @@ export function useJobMenu(
key: 'open-workflow',
label: jobMenuOpenWorkflowLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
onClick: () => openJobWorkflow(target)
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
onClick: () => copyJobId(target)
},
{ kind: 'divider', key: 'd2' },
{
key: 'cancel-job',
label: jobMenuCancelLabel.value,
icon: 'icon-[lucide--x]',
onClick: cancelJob
onClick: () => cancelJob(target)
}
]
})
}
const jobMenuEntries = computed<MenuEntry[]>(() => buildJobMenuEntries())
return {
jobMenuEntries,
getJobMenuEntries: buildJobMenuEntries,
openJobWorkflow,
copyJobId,
cancelJob,

View File

@@ -1,108 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { effectScope, ref } from 'vue'
import type { EffectScope, Ref } from 'vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
describe('useDismissableOverlay', () => {
let scope: EffectScope | undefined
let isOpen: Ref<boolean>
let overlayEl: HTMLElement
let triggerEl: HTMLElement
let outsideEl: HTMLElement
let dismissCount: number
const mountComposable = ({
dismissOnScroll = false,
getTriggerEl
}: {
dismissOnScroll?: boolean
getTriggerEl?: () => HTMLElement | null
} = {}) => {
scope = effectScope()
scope.run(() =>
useDismissableOverlay({
isOpen,
getOverlayEl: () => overlayEl,
getTriggerEl,
onDismiss: () => {
dismissCount += 1
},
dismissOnScroll
})
)
}
beforeEach(() => {
isOpen = ref(true)
overlayEl = document.createElement('div')
triggerEl = document.createElement('button')
outsideEl = document.createElement('div')
dismissCount = 0
document.body.append(overlayEl, triggerEl, outsideEl)
})
afterEach(() => {
scope?.stop()
scope = undefined
document.body.innerHTML = ''
})
it('dismisses on outside pointerdown', () => {
mountComposable()
outsideEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(dismissCount).toBe(1)
})
it('ignores pointerdown inside the overlay', () => {
mountComposable()
overlayEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(dismissCount).toBe(0)
})
it('ignores pointerdown inside the trigger', () => {
mountComposable({
getTriggerEl: () => triggerEl
})
triggerEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
expect(dismissCount).toBe(0)
})
it('dismisses on scroll when enabled', () => {
mountComposable({
dismissOnScroll: true
})
window.dispatchEvent(new Event('scroll'))
expect(dismissCount).toBe(1)
})
it('ignores scroll inside the overlay', () => {
mountComposable({
dismissOnScroll: true
})
overlayEl.dispatchEvent(new Event('scroll'))
expect(dismissCount).toBe(0)
})
it('does not dismiss when closed', () => {
isOpen.value = false
mountComposable({
dismissOnScroll: true
})
outsideEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
window.dispatchEvent(new Event('scroll'))
expect(dismissCount).toBe(0)
})
})

View File

@@ -1,60 +0,0 @@
import { useEventListener } from '@vueuse/core'
import { toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
interface UseDismissableOverlayOptions {
isOpen: MaybeRefOrGetter<boolean>
getOverlayEl: () => HTMLElement | null
onDismiss: () => void
getTriggerEl?: () => HTMLElement | null
dismissOnScroll?: boolean
}
const isNode = (value: EventTarget | null | undefined): value is Node =>
value instanceof Node
const isInside = (target: Node, element: HTMLElement | null | undefined) =>
!!element?.contains(target)
export function useDismissableOverlay({
isOpen,
getOverlayEl,
onDismiss,
getTriggerEl,
dismissOnScroll = false
}: UseDismissableOverlayOptions) {
const dismissIfOutside = (event: Event) => {
if (!toValue(isOpen)) {
return
}
const overlay = getOverlayEl()
if (!overlay) {
return
}
if (!isNode(event.target)) {
onDismiss()
return
}
if (
isInside(event.target, overlay) ||
isInside(event.target, getTriggerEl?.())
) {
return
}
onDismiss()
}
useEventListener(window, 'pointerdown', dismissIfOutside, { capture: true })
if (dismissOnScroll) {
useEventListener(window, 'scroll', dismissIfOutside, {
capture: true,
passive: true
})
}
}

View File

@@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref, shallowRef } from 'vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import Load3d from '@/extensions/core/extensions/load3d/Load3d'
import Load3dUtils from '@/extensions/core/extensions/load3d/Load3dUtils'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type { Size } from '@/lib/litegraph/src/interfaces'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -15,11 +15,11 @@ import {
createMockLGraphNode
} from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/extensions/core/extensions/load3d/Load3d', () => ({
vi.mock('@/extensions/core/load3d/Load3d', () => ({
default: vi.fn()
}))
vi.mock('@/extensions/core/extensions/load3d/Load3dUtils', () => ({
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
splitFilePath: vi.fn(),
getResourceURL: vi.fn(),

View File

@@ -3,8 +3,8 @@ import type { MaybeRef } from 'vue'
import { toRef } from '@vueuse/core'
import { nextTick, ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/extensions/load3d/Load3d'
import Load3dUtils from '@/extensions/core/extensions/load3d/Load3dUtils'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import {
isAssetPreviewSupported,
persistThumbnail
@@ -20,7 +20,7 @@ import type {
ModelConfig,
SceneConfig,
UpDirection
} from '@/extensions/core/extensions/load3d/interfaces'
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'

View File

@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
import { SUPPORTED_EXTENSIONS } from '@/extensions/core/extensions/load3d/constants'
import { SUPPORTED_EXTENSIONS } from '@/extensions/core/load3d/constants'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { createMockFileList } from '@/utils/__tests__/litegraphTestUtils'

View File

@@ -1,7 +1,7 @@
import { computed, ref, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { SUPPORTED_EXTENSIONS } from '@/extensions/core/extensions/load3d/constants'
import { SUPPORTED_EXTENSIONS } from '@/extensions/core/load3d/constants'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'

View File

@@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
import Load3d from '@/extensions/core/extensions/load3d/Load3d'
import Load3dUtils from '@/extensions/core/extensions/load3d/Load3dUtils'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -18,7 +18,7 @@ vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn()
}))
vi.mock('@/extensions/core/extensions/load3d/Load3dUtils', () => ({
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
uploadFile: vi.fn()
}
@@ -28,7 +28,7 @@ vi.mock('@/i18n', () => ({
t: vi.fn((key) => key)
}))
vi.mock('@/extensions/core/extensions/load3d/Load3d', () => ({
vi.mock('@/extensions/core/load3d/Load3d', () => ({
default: vi.fn()
}))

View File

@@ -1,8 +1,8 @@
import { ref, toRaw, watch } from 'vue'
import QuickLRU from '@alloc/quick-lru'
import Load3d from '@/extensions/core/extensions/load3d/Load3d'
import Load3dUtils from '@/extensions/core/extensions/load3d/Load3dUtils'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
AnimationItem,
BackgroundRenderModeType,
@@ -14,7 +14,7 @@ import type {
ModelConfig,
SceneConfig,
UpDirection
} from '@/extensions/core/extensions/load3d/interfaces'
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { useToastStore } from '@/platform/updates/common/toastStore'

View File

@@ -2,15 +2,28 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export type ResolvedPromotedWidget = {
export interface ResolvedPromotedWidget {
node: LGraphNode
widget: IBaseWidget
}
export interface PromotedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
}
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode
readonly sourceNodeId: string
readonly sourceWidgetName: string
/**
* The original leaf-level source node ID, used to distinguish promoted
* widgets with the same name on the same intermediate node. Unlike
* `sourceNodeId` (the direct interior node), this traces to the deepest
* origin.
*/
readonly disambiguatingSourceNodeId?: string
}
export function isPromotedWidgetView(

View File

@@ -27,6 +27,7 @@ import {
} from '@/stores/widgetValueStore'
import {
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState,
@@ -78,9 +79,9 @@ function setPromotions(
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
entries.map(([interiorNodeId, widgetName]) => ({
interiorNodeId,
widgetName
entries.map(([sourceNodeId, sourceWidgetName]) => ({
sourceNodeId,
sourceWidgetName
}))
)
}
@@ -114,6 +115,21 @@ describe(createPromotedWidgetView, () => {
const view = createPromotedWidgetView(subgraphNode, '42', 'myWidget')
expect(view.sourceNodeId).toBe('42')
expect(view.sourceWidgetName).toBe('myWidget')
expect(view.disambiguatingSourceNodeId).toBeUndefined()
})
test('exposes disambiguatingSourceNodeId when provided', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(
subgraphNode,
'42',
'myWidget',
undefined,
'99'
)
expect(view.sourceNodeId).toBe('42')
expect(view.sourceWidgetName).toBe('myWidget')
expect(view.disambiguatingSourceNodeId).toBe('99')
})
test('name defaults to widgetName when no displayName given', () => {
@@ -454,8 +470,8 @@ describe('SubgraphNode.widgets getter', () => {
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([
{
interiorNodeId: String(innerNode.id),
widgetName: 'picker'
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'picker'
}
])
})
@@ -497,8 +513,8 @@ describe('SubgraphNode.widgets getter', () => {
expect(promotions).toHaveLength(1)
expect(promotions[0]).toStrictEqual({
interiorNodeId: String(secondNode.id),
widgetName: 'picker'
sourceNodeId: String(secondNode.id),
sourceWidgetName: 'picker'
})
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].value).toBe('b')
@@ -591,18 +607,14 @@ describe('SubgraphNode.widgets getter', () => {
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(promotedNode.id),
'string_a'
)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(linkedNodeA.id),
'string_a'
)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(promotedNode.id),
sourceWidgetName: 'string_a'
})
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(linkedNodeA.id),
sourceWidgetName: 'string_a'
})
const widgets = promotedWidgets(subgraphNode)
expect(widgets).toHaveLength(2)
@@ -651,12 +663,10 @@ describe('SubgraphNode.widgets getter', () => {
subgraph.add(independentNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(independentNode.id),
'string_a'
)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(independentNode.id),
sourceWidgetName: 'string_a'
})
const widgets = promotedWidgets(subgraphNode)
const linkedView = widgets.find(
@@ -733,8 +743,8 @@ describe('SubgraphNode.widgets getter', () => {
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
{ interiorNodeId: '9999', widgetName: 'widgetB' }
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' },
{ sourceNodeId: '9999', sourceWidgetName: 'widgetB' }
])
})
@@ -764,8 +774,8 @@ describe('SubgraphNode.widgets getter', () => {
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
{ interiorNodeId: '9999', widgetName: 'widgetA' }
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' },
{ sourceNodeId: '9999', sourceWidgetName: 'widgetA' }
])
})
@@ -823,8 +833,8 @@ describe('SubgraphNode.widgets getter', () => {
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ interiorNodeId: String(linkedNodeA.id), widgetName: 'string_a' },
{ interiorNodeId: String(independentNode.id), widgetName: 'string_a' }
{ sourceNodeId: String(linkedNodeA.id), sourceWidgetName: 'string_a' },
{ sourceNodeId: String(independentNode.id), sourceWidgetName: 'string_a' }
])
})
@@ -868,8 +878,8 @@ describe('SubgraphNode.widgets getter', () => {
)
expect(promotions).toStrictEqual([
{
interiorNodeId: linkedEntry[0],
widgetName: linkedEntry[1]
sourceNodeId: linkedEntry[0],
sourceWidgetName: linkedEntry[1]
}
])
})
@@ -925,8 +935,8 @@ describe('SubgraphNode.widgets getter', () => {
)
expect(restoredPromotions).toStrictEqual([
{
interiorNodeId: String(activeAliasNode.id),
widgetName: 'string_a'
sourceNodeId: String(activeAliasNode.id),
sourceWidgetName: 'string_a'
}
])
@@ -1113,8 +1123,8 @@ describe('SubgraphNode.widgets getter', () => {
)
expect(entries).toStrictEqual([
{
interiorNodeId: String(innerNodes[0].id),
widgetName: 'stringWidget'
sourceNodeId: String(innerNodes[0].id),
sourceWidgetName: 'stringWidget'
}
])
})
@@ -1142,8 +1152,8 @@ describe('SubgraphNode.widgets getter', () => {
)
expect(restoredEntries).toStrictEqual([
{
interiorNodeId: String(innerNode.id),
widgetName: 'widgetA'
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'widgetA'
}
])
})
@@ -1280,8 +1290,8 @@ describe('SubgraphNode.widgets getter', () => {
const promotions = usePromotionStore().getPromotions(graph.id, hostNode.id)
expect(promotions).toStrictEqual([
{ interiorNodeId: '20', widgetName: 'string_a' },
{ interiorNodeId: '19', widgetName: 'string_a' }
{ sourceNodeId: '20', sourceWidgetName: 'string_a' },
{ sourceNodeId: '19', sourceWidgetName: 'string_a' }
])
const linkedView = hostWidgets[0]
@@ -1416,8 +1426,8 @@ describe('SubgraphNode.widgets getter', () => {
)
expect(hydratedEntries).toStrictEqual([
{
interiorNodeId: String(innerNode.id),
widgetName: 'widgetA'
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'widgetA'
}
])
})
@@ -1435,12 +1445,12 @@ describe('widgets getter caching', () => {
const reconcileSpy = vi.spyOn(
subgraphNode as unknown as {
_buildPromotionReconcileState: (
entries: Array<{ interiorNodeId: string; widgetName: string }>,
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
linkedEntries: Array<{
inputName: string
inputKey: string
interiorNodeId: string
widgetName: string
sourceNodeId: string
sourceWidgetName: string
}>
) => unknown
},
@@ -1465,12 +1475,12 @@ describe('widgets getter caching', () => {
const reconcileSpy = vi.spyOn(
subgraphNode as unknown as {
_buildPromotionReconcileState: (
entries: Array<{ interiorNodeId: string; widgetName: string }>,
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
linkedEntries: Array<{
inputName: string
inputKey: string
interiorNodeId: string
widgetName: string
sourceNodeId: string
sourceWidgetName: string
}>
) => unknown
},
@@ -1638,7 +1648,7 @@ describe('promote/demote cycle', () => {
subgraphNode.id
)
expect(entries).toStrictEqual([
{ interiorNodeId: innerIds[0], widgetName: 'widgetB' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
})
@@ -1709,6 +1719,197 @@ describe('disconnected state', () => {
})
})
function createThreeLevelNestedSubgraph() {
// Level C (innermost): concrete widget
const subgraphC = createTestSubgraph({
inputs: [{ name: 'c_input', type: '*' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
const concreteInput = concreteNode.addInput('c_input', '*')
const concreteWidget = concreteNode.addWidget(
'number',
'c_input',
100,
() => {}
)
concreteInput.widget = { name: 'c_input' }
subgraphC.add(concreteNode)
subgraphC.inputNode.slots[0].connect(concreteInput, concreteNode)
const subgraphNodeC = createTestSubgraphNode(subgraphC, { id: 501 })
// Level B (middle): wraps C
const subgraphB = createTestSubgraph({
inputs: [{ name: 'b_input', type: '*' }]
})
subgraphB.add(subgraphNodeC)
subgraphNodeC._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeC.inputs[0], subgraphNodeC)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 502 })
// Level A (outermost): wraps B
const subgraphA = createTestSubgraph({
inputs: [{ name: 'a_input', type: '*' }]
})
subgraphA.add(subgraphNodeB)
subgraphNodeB._internalConfigureAfterSlots()
subgraphA.inputNode.slots[0].connect(subgraphNodeB.inputs[0], subgraphNodeB)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 503 })
return { concreteNode, concreteWidget, subgraphNodeA }
}
describe('three-level nested value propagation', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('value set at outermost level propagates to concrete widget', () => {
const { concreteNode, subgraphNodeA } = createThreeLevelNestedSubgraph()
expect(subgraphNodeA.widgets).toHaveLength(1)
expect(subgraphNodeA.widgets[0].value).toBe(100)
subgraphNodeA.widgets[0].value = 200
expect(concreteNode.widgets![0].value).toBe(200)
})
test('type resolves correctly through all three layers', () => {
const { subgraphNodeA } = createThreeLevelNestedSubgraph()
expect(subgraphNodeA.widgets[0].type).toBe('number')
})
test('concrete value change is visible at the outermost level', () => {
const { concreteWidget, subgraphNodeA } = createThreeLevelNestedSubgraph()
concreteWidget.value = 999
expect(subgraphNodeA.widgets[0].value).toBe(999)
})
test('nested duplicate-name promotions resolve and update independently by disambiguating source node id', () => {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({ rootGraph })
const firstTextNode = new LGraphNode('FirstTextNode')
firstTextNode.addWidget('text', 'text', '11111111111', () => {})
innerSubgraph.add(firstTextNode)
const secondTextNode = new LGraphNode('SecondTextNode')
secondTextNode.addWidget('text', 'text', '22222222222', () => {})
innerSubgraph.add(secondTextNode)
const outerSubgraph = createTestSubgraph({ rootGraph })
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 3,
parentGraph: outerSubgraph
})
outerSubgraph.add(innerSubgraphNode)
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, {
id: 4,
parentGraph: rootGraph
})
rootGraph.add(outerSubgraphNode)
usePromotionStore().setPromotions(
innerSubgraphNode.rootGraph.id,
innerSubgraphNode.id,
[
{ sourceNodeId: String(firstTextNode.id), sourceWidgetName: 'text' },
{ sourceNodeId: String(secondTextNode.id), sourceWidgetName: 'text' }
]
)
usePromotionStore().setPromotions(
outerSubgraphNode.rootGraph.id,
outerSubgraphNode.id,
[
{
sourceNodeId: String(innerSubgraphNode.id),
sourceWidgetName: 'text',
disambiguatingSourceNodeId: String(firstTextNode.id)
},
{
sourceNodeId: String(innerSubgraphNode.id),
sourceWidgetName: 'text',
disambiguatingSourceNodeId: String(secondTextNode.id)
}
]
)
const widgets = promotedWidgets(outerSubgraphNode)
expect(widgets).toHaveLength(2)
expect(
widgets.map((widget) => widget.disambiguatingSourceNodeId)
).toStrictEqual([String(firstTextNode.id), String(secondTextNode.id)])
expect(widgets.map((widget) => widget.value)).toStrictEqual([
'11111111111',
'22222222222'
])
widgets[1].value = 'updated-second'
expect(firstTextNode.widgets?.[0]?.value).toBe('11111111111')
expect(secondTextNode.widgets?.[0]?.value).toBe('updated-second')
expect(widgets[0].value).toBe('11111111111')
expect(widgets[1].value).toBe('updated-second')
})
})
describe('multi-link representative determinism for input-based promotion', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('first link is consistently chosen as representative for reads and writes', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'shared', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 601 })
subgraphNode.graph?.add(subgraphNode)
const firstNode = new LGraphNode('FirstNode')
const firstInput = firstNode.addInput('shared', '*')
firstNode.addWidget('text', 'shared', 'first-val', () => {})
firstInput.widget = { name: 'shared' }
subgraph.add(firstNode)
const secondNode = new LGraphNode('SecondNode')
const secondInput = secondNode.addInput('shared', '*')
secondNode.addWidget('text', 'shared', 'second-val', () => {})
secondInput.widget = { name: 'shared' }
subgraph.add(secondNode)
const thirdNode = new LGraphNode('ThirdNode')
const thirdInput = thirdNode.addInput('shared', '*')
thirdNode.addWidget('text', 'shared', 'third-val', () => {})
thirdInput.widget = { name: 'shared' }
subgraph.add(thirdNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
subgraph.inputNode.slots[0].connect(secondInput, secondNode)
subgraph.inputNode.slots[0].connect(thirdInput, thirdNode)
const widgets = promotedWidgets(subgraphNode)
expect(widgets).toHaveLength(1)
expect(widgets[0].sourceNodeId).toBe(String(firstNode.id))
// Read returns the first link's value
expect(widgets[0].value).toBe('first-val')
// Write propagates to all linked nodes
widgets[0].value = 'updated'
expect(firstNode.widgets![0].value).toBe('updated')
expect(secondNode.widgets![0].value).toBe('updated')
expect(thirdNode.widgets![0].value).toBe('updated')
// Repeated reads are still deterministic
expect(widgets[0].value).toBe('updated')
})
})
function createFakeCanvasContext() {
return new Proxy({} as CanvasRenderingContext2D, {
get: () => vi.fn(() => ({ width: 10 }))
@@ -2057,12 +2258,12 @@ describe('promoted combo rendering', () => {
)
expect(promotions).toContainEqual({
interiorNodeId: String(subgraphNodeA.id),
widgetName: 'lora_name'
sourceNodeId: String(subgraphNodeA.id),
sourceWidgetName: 'lora_name'
})
expect(promotions).not.toContainEqual({
interiorNodeId: String(innerNode.id),
widgetName: 'lora_name'
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'lora_name'
})
})

View File

@@ -49,9 +49,16 @@ export function createPromotedWidgetView(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
displayName?: string
displayName?: string,
disambiguatingSourceNodeId?: string
): IPromotedWidgetView {
return new PromotedWidgetView(subgraphNode, nodeId, widgetName, displayName)
return new PromotedWidgetView(
subgraphNode,
nodeId,
widgetName,
displayName,
disambiguatingSourceNodeId
)
}
class PromotedWidgetView implements IPromotedWidgetView {
@@ -80,7 +87,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string
private readonly displayName?: string,
readonly disambiguatingSourceNodeId?: string
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
@@ -287,7 +295,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
return resolvePromotedWidgetAtHost(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
this.sourceWidgetName,
this.disambiguatingSourceNodeId
)
}
@@ -301,7 +310,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
const result = resolveConcretePromotedWidget(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
this.sourceWidgetName,
this.disambiguatingSourceNodeId
)
const resolved = result.status === 'resolved' ? result.resolved : undefined
@@ -341,7 +351,9 @@ class PromotedWidgetView implements IPromotedWidgetView {
if (boundWidget && isPromotedWidgetView(boundWidget)) {
return (
boundWidget.sourceNodeId === this.sourceNodeId &&
boundWidget.sourceWidgetName === this.sourceWidgetName
boundWidget.sourceWidgetName === this.sourceWidgetName &&
boundWidget.disambiguatingSourceNodeId ===
this.disambiguatingSourceNodeId
)
}

View File

@@ -18,6 +18,7 @@ vi.mock('@/services/litegraphService', () => ({
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
getPromotableWidgets,
hasUnpromotedWidgets,
isPreviewPseudoWidget,
promoteRecommendedWidgets,
pruneDisconnected
@@ -118,9 +119,12 @@ describe('pruneDisconnected', () => {
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' },
{ interiorNodeId: String(interiorNode.id), widgetName: 'missing-widget' },
{ interiorNodeId: '9999', widgetName: 'missing-node' }
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' },
{
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'missing-widget'
},
{ sourceNodeId: '9999', sourceWidgetName: 'missing-node' }
])
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
@@ -129,7 +133,9 @@ describe('pruneDisconnected', () => {
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' }])
).toEqual([
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' }
])
expect(warnSpy).toHaveBeenCalledOnce()
})
@@ -143,8 +149,8 @@ describe('pruneDisconnected', () => {
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{
interiorNodeId: String(interiorNode.id),
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
sourceNodeId: String(interiorNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
@@ -154,8 +160,8 @@ describe('pruneDisconnected', () => {
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([
{
interiorNodeId: String(interiorNode.id),
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
sourceNodeId: String(interiorNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
})
@@ -255,12 +261,10 @@ describe('promoteRecommendedWidgets', () => {
const store = usePromotionStore()
expect(
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(glslNode.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
@@ -280,12 +284,52 @@ describe('promoteRecommendedWidgets', () => {
const store = usePromotionStore()
expect(
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(glslNode.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
})
})
describe('hasUnpromotedWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns true when subgraph has at least one enabled unpromoted widget', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
expect(hasUnpromotedWidgets(subgraphNode)).toBe(true)
})
it('returns false when all enabled widgets are already promoted', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'seed'
})
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
it('ignores computed-disabled widgets', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
widget.computedDisabled = true
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
})

View File

@@ -1,4 +1,5 @@
import * as Sentry from '@sentry/vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { t } from '@/i18n'
import type {
@@ -26,6 +27,30 @@ export function getWidgetName(w: IBaseWidget): string {
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
}
export function getSourceNodeId(w: IBaseWidget): string | undefined {
if (!isPromotedWidgetView(w)) return undefined
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
}
function toPromotionSource(
node: PartialNode,
widget: IBaseWidget
): PromotedWidgetSource {
return {
sourceNodeId: String(node.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: getSourceNodeId(widget)
}
}
function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
for (const parent of parents) {
parent.computeSize(parent.size)
parent.setDirtyCanvas(true, true)
}
useCanvasStore().canvas?.setDirty(true, true)
}
/** Known non-$$ preview widget types added by core or popular extensions. */
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
@@ -51,16 +76,14 @@ export function promoteWidget(
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const nodeId = String(
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
)
const widgetName = getWidgetName(widget)
const source = toPromotionSource(node, widget)
for (const parent of parents) {
store.promote(parent.rootGraph.id, parent.id, nodeId, widgetName)
store.promote(parent.rootGraph.id, parent.id, source)
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Promoted widget "${widgetName}" on node ${node.id}`,
message: `Promoted widget "${source.sourceWidgetName}" on node ${node.id}`,
level: 'info'
})
}
@@ -71,16 +94,14 @@ export function demoteWidget(
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const nodeId = String(
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
)
const widgetName = getWidgetName(widget)
const source = toPromotionSource(node, widget)
for (const parent of parents) {
store.demote(parent.rootGraph.id, parent.id, nodeId, widgetName)
store.demote(parent.rootGraph.id, parent.id, source)
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Demoted widget "${widgetName}" on node ${node.id}`,
message: `Demoted widget "${source.sourceWidgetName}" on node ${node.id}`,
level: 'info'
})
}
@@ -110,10 +131,9 @@ export function addWidgetPromotionOptions(
) {
const store = usePromotionStore()
const parents = getParentNodes()
const nodeId = String(node.id)
const widgetName = getWidgetName(widget)
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
)
if (promotableParents.length > 0)
options.unshift({
@@ -147,10 +167,9 @@ export function tryToggleWidgetPromotion() {
const parents = getParentNodes()
if (!parents.length || !widget) return
const store = usePromotionStore()
const nodeId = String(node.id)
const widgetName = getWidgetName(widget)
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
)
if (promotableParents.length > 0)
promoteWidget(node, widget, promotableParents)
@@ -219,12 +238,10 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
const widget = node.widgets?.find(isPreviewPseudoWidget)
if (!widget) return
if (
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
widget.name
)
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(node.id),
sourceWidgetName: widget.name
})
)
return
promoteWidget(node, widget, [subgraphNode])
@@ -242,20 +259,18 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
// includes this node and onDrawBackground can call updatePreviews on it
// once execution outputs arrive.
if (supportsVirtualCanvasImagePreview(node)) {
const canvasSource: PromotedWidgetSource = {
sourceNodeId: String(node.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
if (
!store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
canvasSource
)
) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, canvasSource)
}
continue
}
@@ -271,8 +286,7 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(n.id),
getWidgetName(w)
toPromotionSource(n, w)
)
}
subgraphNode.computeSize(subgraphNode.size)
@@ -285,17 +299,16 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
subgraphNode.rootGraph.id,
subgraphNode.id
)
const removedEntries: Array<{ interiorNodeId: string; widgetName: string }> =
[]
const removedEntries: PromotedWidgetSource[] = []
const validEntries = entries.filter((entry) => {
const node = subgraph.getNodeById(entry.interiorNodeId)
const node = subgraph.getNodeById(entry.sourceNodeId)
if (!node) {
removedEntries.push(entry)
return false
}
const hasWidget = getPromotableWidgets(node).some(
(iw) => iw.name === entry.widgetName
(iw) => iw.name === entry.sourceWidgetName
)
if (!hasWidget) {
removedEntries.push(entry)
@@ -315,9 +328,26 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
}
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
refreshPromotedWidgetRendering([subgraphNode])
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Pruned ${removedEntries.length} disconnected promotion(s) from subgraph node ${subgraphNode.id}`,
level: 'info'
})
}
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
const promotionStore = usePromotionStore()
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>
(interiorNode.widgets ?? []).some(
(widget) =>
!widget.computedDisabled &&
!promotionStore.isPromoted(rootGraph.id, subgraphNodeId, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: widget.name
})
)
)
}

View File

@@ -30,6 +30,7 @@ type PromotedWidgetStub = Pick<
> & {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
node?: SubgraphNode
}
@@ -51,7 +52,8 @@ function createPromotedWidget(
name: string,
sourceNodeId: string,
sourceWidgetName: string,
node?: SubgraphNode
node?: SubgraphNode,
disambiguatingSourceNodeId?: string
): IBaseWidget {
const promotedWidget: PromotedWidgetStub = {
name,
@@ -61,6 +63,7 @@ function createPromotedWidget(
value: undefined,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId,
node
}
return promotedWidget as IBaseWidget
@@ -94,6 +97,27 @@ describe('resolvePromotedWidgetAtHost', () => {
expect(resolved).toBeUndefined()
})
test('resolves duplicate-name promoted host widgets by disambiguating source node id', () => {
const host = createHostNode(100)
const sourceNode = addNodeToHost(host, 'source')
sourceNode.widgets = [
createPromotedWidget('text', String(sourceNode.id), 'text', host, '1'),
createPromotedWidget('text', String(sourceNode.id), 'text', host, '2')
]
const resolved = resolvePromotedWidgetAtHost(
host,
String(sourceNode.id),
'text',
'2'
)
expect(resolved).toBeDefined()
expect(
(resolved?.widget as PromotedWidgetStub).disambiguatingSourceNodeId
).toBe('2')
})
})
describe('resolveConcretePromotedWidget', () => {

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