Compare commits

..

34 Commits

Author SHA1 Message Date
Comfy Org PR Bot
841cf55fbd [backport core/1.38] fix: prevent XSS vulnerability in context menu labels (#8922)
Backport of #8887 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8922-backport-core-1-38-fix-prevent-XSS-vulnerability-in-context-menu-labels-3096d73d3650811a9448fc9b2344a88b)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-17 02:24:10 -08:00
Comfy Org PR Bot
43b66ec5e5 1.38.14 (#8874)
Patch version increment to 1.38.14

**Base branch:** `core/1.38`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8874-1-38-14-3076d73d365081dfa3c6e6d8bd64bf73)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-02-14 06:55:44 -08:00
Comfy Org PR Bot
73e51572a0 [backport core/1.38] fix: clear draft on workflow close to prevent stale state on reopen (#8868)
Backport of #8854 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8868-backport-core-1-38-fix-clear-draft-on-workflow-close-to-prevent-stale-state-on-reopen-3076d73d36508163a919fdfb66616844)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-14 02:58:00 -08:00
Comfy Org PR Bot
fdd1bd3406 [backport core/1.38] Fix hit detection on vue node slots (#8798)
Backport of #8609 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8798-backport-core-1-38-Fix-hit-detection-on-vue-node-slots-3046d73d365081008dbefb2c67c3abc3)
by [Unito](https://www.unito.io)

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
2026-02-10 19:38:57 -08:00
Comfy Org PR Bot
94101d81d7 [backport core/1.38] fix: handle RIFF padding for odd-sized WEBP chunks (#8794)
Backport of #8527 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8794-backport-core-1-38-fix-handle-RIFF-padding-for-odd-sized-WEBP-chunks-3046d73d36508174afc8cdd8cd791169)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-02-10 18:51:54 -08:00
Comfy Org PR Bot
f741fb51e7 [backport core/1.38] Austin/fix move subgraph input (#8792)
Backport of #8777 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8792-backport-core-1-38-Austin-fix-move-subgraph-input-3046d73d3650816c9124c444022491de)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-02-10 18:51:08 -08:00
Comfy Org PR Bot
7dfadb5f42 [backport core/1.38] Remove comfy logo splash screen. (#8788)
Backport of #8786 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8788-backport-core-1-38-Remove-comfy-logo-splash-screen-3046d73d36508120b0cfeb49fceef38e)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-02-10 16:51:16 -08:00
Comfy Org PR Bot
8d9243e841 [backport core/1.38] fix: right-click context menu disabled when selection toolbox is off (#8783)
Backport of #8781 to `core/1.38`

Automatically created by backport workflow.

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2026-02-11 00:48:47 +01:00
Comfy Org PR Bot
b226b6db22 [backport core/1.38] fix(vue-nodes): hide slot labels for reroute nodes with empty names (#8727)
Backport of #8574 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8727-backport-core-1-38-fix-vue-nodes-hide-slot-labels-for-reroute-nodes-with-empty-names-3006d73d365081cc85b0fbb503fc130b)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-07 20:03:27 -08:00
AustinMroz
35e5f37221 [backport core/1.38] fix: localize node definition filter names and descriptions (#8564)
Backport of #8540 to core/1.38

Does not include dev-node changes since that has not been backported.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8564-backport-core-1-38-fix-localize-node-definition-filter-names-and-descriptions-2fc6d73d365081f1855ef5d1e0700329)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-03 11:02:59 -08:00
Comfy Org PR Bot
c8fd9a5374 1.38.13 (#8578)
Patch version increment to 1.38.13

**Base branch:** `core/1.38`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8578-1-38-13-2fc6d73d36508122b4c3dc622c48232e)
by [Unito](https://www.unito.io)

Co-authored-by: comfy-pr-bot <172744619+comfy-pr-bot@users.noreply.github.com>
2026-02-02 22:13:14 -08:00
Comfy Org PR Bot
ded366008e [backport core/1.38] fix: dedupe queueStore.update() to prevent race conditions (#8557)
Backport of #8523 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8557-backport-core-1-38-fix-dedupe-queueStore-update-to-prevent-race-conditions-2fc6d73d36508159ba03dd7ff626ecce)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-02-02 17:40:13 -08:00
Comfy Org PR Bot
d48e99db7c [backport core/1.38] fix: node header on preview has a gap on the right (not flush) (#8555)
Backport of #8487 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8555-backport-core-1-38-fix-node-header-on-preview-has-a-gap-on-the-right-not-flush-2fc6d73d365081a1b1cae2afb0675e9a)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-02-02 17:39:08 -08:00
Comfy Org PR Bot
c138670bf6 [backport core/1.38] fix: add Frame Nodes to core menu items for multi-selection context menu (#8553)
Backport of #8524 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8553-backport-core-1-38-fix-add-Frame-Nodes-to-core-menu-items-for-multi-selection-context--2fc6d73d36508188be6be38de9df2ba0)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-02-02 17:38:27 -08:00
Comfy Org PR Bot
e4f1950af5 [backport core/1.38] fix: update reactive ref after merge in imagePreviewStore (#8502)
Backport of #8479 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8502-backport-core-1-38-fix-update-reactive-ref-after-merge-in-imagePreviewStore-2f96d73d3650815f944cc401a8c2d264)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Austin Mroz <austin@comfy.org>
2026-01-31 23:40:30 -08:00
Comfy Org PR Bot
44e630d00f [backport core/1.38] Update control_after_generate schema (#8506)
Backport of #8505 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8506-backport-core-1-38-Update-control_after_generate-schema-2f96d73d36508106b677fafb3fe302fe)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-30 21:43:04 -08:00
Comfy Org PR Bot
d27f9faa9e [backport core/1.38] fix: prevent image/video preview reset on dynamic widget addition (#8492)
Backport of #8366 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8492-backport-core-1-38-fix-prevent-image-video-preview-reset-on-dynamic-widget-addition-2f86d73d36508123acfdfac61554da7e)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-30 08:37:38 -08:00
Comfy Org PR Bot
c902869b2c [backport core/1.38] fix: properties panel obscures menus in legacy layout (#8490)
Backport of #8474 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8490-backport-core-1-38-fix-properties-panel-obscures-menus-in-legacy-layout-2f86d73d36508181977df8801754eca7)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-30 08:23:40 -08:00
Comfy Org PR Bot
ff9823e8f0 [backport core/1.38] Fix: Hide Jobs in Assets Panel when Queue V2 is disabled. (#8485)
Backport of #8450 to `core/1.38`

Automatically created by backport workflow.

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-30 08:03:27 +00:00
AustinMroz
bc4e060e92 Revert matchtype slot reactivity on core/1.38 (#8481)
Fixes a bug where canvas functionality is lost if a multitype input
(like the native switch) is added to the graph in litegraph mode.

See also #8477

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8481-Revert-matchtype-slot-reactivity-on-core-1-38-2f86d73d365081ac8e0aeb5ce96fe685)
by [Unito](https://www.unito.io)
2026-01-29 23:09:57 -08:00
Comfy Org PR Bot
bc31970939 [backport core/1.38] feat: add category support for blueprints and protect global blueprints (#8465)
Backport of #8378 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8465-backport-core-1-38-feat-add-category-support-for-blueprints-and-protect-global-bluepri-2f86d73d365081b79a8be0ac55d87f0f)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 19:15:42 -08:00
Comfy Org PR Bot
6bab72feb9 [backport core/1.38] Improve template search input performance issue (#8471)
Backport of #8343 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8471-backport-core-1-38-Improve-template-search-input-performance-issue-2f86d73d365081e3afbff8779ea0ebe0)
by [Unito](https://www.unito.io)

Co-authored-by: Kelly Yang <124ykl@gmail.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-29 19:13:41 -08:00
Comfy Org PR Bot
390deac188 [backport core/1.38] fix: default image input for the template is displayed as empty on dropdown selection (#8455)
Backport of #8276 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8455-backport-core-1-38-fix-default-image-input-for-the-template-is-displayed-as-empty-on-d-2f86d73d3650814a895ddbb921bc4776)
by [Unito](https://www.unito.io)

Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2026-01-29 16:59:19 -08:00
Comfy Org PR Bot
e4d1554b80 [backport core/1.38] Fix invalid keybind flash (#8451)
Backport of #8435 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8451-backport-core-1-38-Fix-invalid-keybind-flash-2f86d73d365081ba8443ffc6696a9d4d)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 16:34:48 -08:00
Comfy Org PR Bot
94956089f1 [backport core/1.38] Fix Help Center display in linear mode (#8448)
Backport of #8438 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8448-backport-core-1-38-Fix-Help-Center-display-in-linear-mode-2f86d73d365081ebb6a7d6de3c4555e3)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 16:21:52 -08:00
Comfy Org PR Bot
af3f96c0ca [backport core/1.38] make new queue panel disabled by default (#8445)
Backport of #8444 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8445-backport-core-1-38-make-new-queue-panel-disabled-by-default-2f76d73d36508182b643e4cf25962fef)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-01-29 16:16:16 -08:00
Comfy Org PR Bot
ada3145c2d [backport core/1.38] fix: dragging (e.g., when selecting text) in Markdown note causes node to drag (#8427)
Backport of #8413 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8427-backport-core-1-38-fix-dragging-e-g-when-selecting-text-in-Markdown-note-causes-no-2f76d73d3650813e8e28c101270bc42f)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 10:04:47 -08:00
Comfy Org PR Bot
89c76f6861 [backport core/1.38] fix: use getAuthHeader in createCustomer to support API key auth (#8425)
Backport of #8408 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8425-backport-core-1-38-fix-use-getAuthHeader-in-createCustomer-to-support-API-key-auth-2f76d73d36508136b4f1c043c384caa2)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
2026-01-29 10:04:01 -08:00
Comfy Org PR Bot
b660638f22 [backport core/1.38] fix: add ResizeObserver to fix Preview3D initial render stretch (#8423)
Backport of #8351 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8423-backport-core-1-38-fix-add-ResizeObserver-to-fix-Preview3D-initial-render-stretch-2f76d73d365081a28ccbd2b5d9eb1aa5)
by [Unito](https://www.unito.io)

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-01-29 10:03:12 -08:00
Comfy Org PR Bot
a96938a495 [backport core/1.38] Fix flake hidream test (#8420)
Backport of #8406 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8420-backport-core-1-38-Fix-flake-hidream-test-2f76d73d36508162ad34d8bc11f000b6)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2026-01-29 00:11:10 -08:00
Comfy Org PR Bot
f6b571013d [backport core/1.38] [bugfix] Fix manager missing node tab with shared composable (#8410)
Backport of #8409 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8410-backport-core-1-38-bugfix-Fix-manager-missing-node-tab-with-shared-composable-2f76d73d36508137b1e8daadb13cb631)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-01-29 00:05:17 -08:00
Comfy Org PR Bot
54e8775acb [backport core/1.38] fix: add null check in getCanvasCenter to prevent crash on asset insert (#8403)
Backport of #8399 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8403-backport-core-1-38-fix-add-null-check-in-getCanvasCenter-to-prevent-crash-on-asset-ins-2f76d73d365081b6af21e13162afafc5)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2026-01-28 20:48:47 -08:00
Comfy Org PR Bot
13e8aa7466 [backport core/1.38] fix: increase Vue node resize handle size for better usability (#8394)
Backport of #8391 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8394-backport-core-1-38-fix-increase-Vue-node-resize-handle-size-for-better-usability-2f76d73d365081baa796c6d8c62c4352)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-01-28 19:50:43 -08:00
Comfy Org PR Bot
7a224efaa0 [backport core/1.38] CI: Add formatting after generating locales. (#8361)
Backport of #8360 to `core/1.38`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8361-backport-core-1-38-CI-Add-formatting-after-generating-locales-2f66d73d365081c09af5df4c2e0a898b)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-01-27 22:41:54 -08:00
133 changed files with 1913 additions and 2106 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -11,7 +11,6 @@ This guide covers patterns and examples for unit testing utilities, composables,
5. [Mocking Utility Functions](#mocking-utility-functions)
6. [Testing with Debounce and Throttle](#testing-with-debounce-and-throttle)
7. [Mocking Node Definitions](#mocking-node-definitions)
8. [Mocking Composables with Reactive State](#mocking-composables-with-reactive-state)
## Testing Vue Composables with Reactivity
@@ -254,79 +253,3 @@ it('should validate node definition', () => {
expect(validateComfyNodeDef(EXAMPLE_NODE_DEF)).not.toBeNull()
})
```
## Mocking Composables with Reactive State
When mocking composables that return reactive refs, define the mock implementation inline in `vi.mock()`'s factory function. This ensures stable singleton instances across all test invocations.
### Rules
1. **Define mocks in the factory function** — Create `vi.fn()` and `ref()` instances directly inside `vi.mock()`, not in `beforeEach`
2. **Use singleton pattern** — The factory runs once; all calls to the composable return the same mock object
3. **Access mocks per-test** — Call the composable directly in each test to get the singleton instance rather than storing in a shared variable
4. **Wrap in `vi.mocked()` for type safety** — Use `vi.mocked(service.method).mockResolvedValue(...)` when configuring
5. **Rely on `vi.resetAllMocks()`** — Resets call counts without recreating instances; ref values may need manual reset if mutated
### Pattern
```typescript
// Example from: src/platform/updates/common/releaseStore.test.ts
import { ref } from 'vue'
vi.mock('@/path/to/composable', () => {
const doSomething = vi.fn()
const isLoading = ref(false)
const error = ref<string | null>(null)
return {
useMyComposable: () => ({
doSomething,
isLoading,
error
})
}
})
describe('MyStore', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should call the composable method', async () => {
const service = useMyComposable()
vi.mocked(service.doSomething).mockResolvedValue({ data: 'test' })
await store.initialize()
expect(service.doSomething).toHaveBeenCalledWith(expectedArgs)
})
it('should handle errors from the composable', async () => {
const service = useMyComposable()
vi.mocked(service.doSomething).mockResolvedValue(null)
service.error.value = 'Something went wrong'
await store.initialize()
expect(store.error).toBe('Something went wrong')
})
})
```
### Anti-patterns
```typescript
// ❌ Don't configure mock return values in beforeEach with shared variable
let mockService: { doSomething: Mock }
beforeEach(() => {
mockService = { doSomething: vi.fn() }
vi.mocked(useMyComposable).mockReturnValue(mockService)
})
// ❌ Don't auto-mock then override — reactive refs won't work correctly
vi.mock('@/path/to/composable')
vi.mocked(useMyComposable).mockReturnValue({ isLoading: ref(false) })
```
```
```

View File

@@ -35,18 +35,6 @@
background-size: cover;
background-repeat: no-repeat;
}
#vue-app:has(#loading-logo) {
display: contents;
color: var(--fg-color);
& #loading-logo {
place-self: center;
font-size: clamp(2px, 1vw, 6px);
line-height: 1;
overflow: hidden;
max-width: 100vw;
border-radius: 20ch;
}
}
.visually-hidden {
position: absolute;
width: 1px;
@@ -65,36 +53,6 @@
<body class="litegraph grid">
<div id="vue-app">
<span class="visually-hidden" role="status">Loading ComfyUI...</span>
<svg
width="520"
height="520"
viewBox="0 0 520 520"
fill="none"
xmlns="http://www.w3.org/2000/svg"
id="loading-logo"
>
<mask
id="mask0_227_285"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="520"
height="520"
>
<path
d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z"
fill="#EEFF30"
/>
</mask>
<g mask="url(#mask0_227_285)">
<rect y="0.751831" width="520" height="520" fill="#172DD7" />
<path
d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z"
fill="#F0FF41"
/>
</g>
</svg>
</div>
<script type="module" src="src/main.ts"></script>
</body>

View File

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

View File

@@ -1,11 +1,13 @@
<template>
<router-view />
<ProgressSpinner
v-if="isLoading"
class="absolute inset-0 flex h-[unset] items-center justify-center"
/>
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
<WorkspaceAuthGate>
<router-view />
<ProgressSpinner
v-if="isLoading"
class="absolute inset-0 flex h-[unset] items-center justify-center"
/>
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
</WorkspaceAuthGate>
</template>
<script setup lang="ts">
@@ -14,6 +16,7 @@ import BlockUI from 'primevue/blockui'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onMounted } from 'vue'
import WorkspaceAuthGate from '@/components/auth/WorkspaceAuthGate.vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore'

View File

@@ -2,8 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
import type { Component } from 'vue'
import { computed, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
@@ -15,7 +14,6 @@ import type {
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isElectron } from '@/utils/envUtil'
@@ -38,17 +36,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
attachTo?: HTMLElement
}
function createWrapper({
pinia = createTestingPinia({ createSpy: vi.fn }),
stubs = {},
attachTo
}: WrapperOptions = {}) {
function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -67,21 +55,18 @@ function createWrapper({
})
return mount(TopMenuSection, {
attachTo,
global: {
plugins: [pinia, i18n],
stubs: {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
QueueInlineProgressSummary: true,
CurrentUserButton: true,
LoginButton: true,
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template: '<div />'
},
...stubs
}
},
directives: {
tooltip: () => {}
@@ -106,7 +91,6 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
})
describe('authentication state', () => {
@@ -167,7 +151,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const wrapper = createWrapper(pinia)
await nextTick()
@@ -185,7 +169,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = createWrapper({ pinia })
const wrapper = createWrapper(pinia)
const commandStore = useCommandStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
@@ -201,7 +185,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const wrapper = createWrapper(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
@@ -215,7 +199,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper({ pinia })
const wrapper = createWrapper(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
@@ -226,84 +210,6 @@ describe('TopMenuSection', () => {
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
describe('inline progress summary', () => {
const configureSettings = (
pinia: ReturnType<typeof createTestingPinia>,
qpoV2Enabled: boolean
) => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
})
}
it('renders inline progress summary when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(true)
})
it('does not render inline progress summary when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, false)
const wrapper = createWrapper({ pinia })
await nextTick()
expect(
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
).toBe(false)
})
it('teleports inline progress summary when actionbar is floating', async () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
const actionbarTarget = document.createElement('div')
document.body.appendChild(actionbarTarget)
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const executionStore = useExecutionStore(pinia)
executionStore.activePromptId = 'prompt-1'
const ComfyActionbarStub = defineComponent({
name: 'ComfyActionbar',
setup(_, { emit }) {
onMounted(() => {
emit('update:progressTarget', actionbarTarget)
})
return () => h('div')
}
})
const wrapper = createWrapper({
pinia,
attachTo: document.body,
stubs: {
ComfyActionbar: ComfyActionbarStub,
QueueInlineProgressSummary: false
}
})
try {
await nextTick()
expect(actionbarTarget.querySelector('[role="status"]')).not.toBeNull()
} finally {
wrapper.unmount()
actionbarTarget.remove()
}
})
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const wrapper = createWrapper()
const menu = wrapper.findComponent({ name: 'ContextMenu' })

View File

@@ -1,130 +1,101 @@
<template>
<div
v-if="!workspaceStore.focusMode"
class="ml-1 flex flex-col gap-1 pt-1"
class="ml-1 flex gap-x-0.5 pt-1"
@mouseenter="isTopMenuHovered = true"
@mouseleave="isTopMenuHovered = false"
>
<div class="flex gap-x-0.5">
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
</div>
<div
ref="actionbarContainerRef"
class="actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu
ref="queueContextMenu"
:model="queueContextMenuItems"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
</div>
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div>
<Teleport
v-if="inlineProgressSummaryTarget"
:to="inlineProgressSummaryTarget"
>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div
class="pointer-events-none absolute left-0 right-0 top-full mt-1 flex justify-end pr-1"
v-if="managerState.shouldShowManagerButtons.value"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<QueueInlineProgressSummary :hidden="isQueueOverlayExpanded" />
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('menu.customNodesManager')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[lucide--puzzle] size-4" />
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
</div>
</Teleport>
<QueueInlineProgressSummary
v-else-if="shouldShowInlineProgressSummary && !isActionbarFloating"
class="pr-1"
:hidden="isQueueOverlayExpanded"
<div
class="actionbar-container pointer-events-auto flex gap-2 h-12 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
@@ -133,7 +104,6 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
@@ -177,15 +147,6 @@ const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
const actionbarContainerRef = ref<HTMLElement>()
const isActionbarDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const actionbarPosition = computed(() => settingStore.get('Comfy.UseNewMenu'))
const isActionbarEnabled = computed(
() => actionbarPosition.value !== 'Disabled'
)
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
@@ -203,19 +164,6 @@ const isQueuePanelV2Enabled = computed(() =>
const isQueueProgressOverlayEnabled = computed(
() => !isQueuePanelV2Enabled.value
)
const shouldShowInlineProgressSummary = computed(
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
)
const progressTarget = ref<HTMLElement | null>(null)
function updateProgressTarget(target: HTMLElement | null) {
progressTarget.value = target
}
const inlineProgressSummaryTarget = computed(() => {
if (!shouldShowInlineProgressSummary.value || !isActionbarFloating.value) {
return null
}
return progressTarget.value
})
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)

View File

@@ -10,7 +10,6 @@
</div>
<Panel
ref="panelRef"
class="pointer-events-auto"
:style="style"
:class="panelClass"
@@ -19,7 +18,7 @@
content: { class: isDocked ? 'p-0' : 'p-1' }
}"
>
<div class="relative flex items-center select-none gap-2">
<div ref="panelRef" class="flex items-center select-none gap-2">
<span
ref="dragHandleRef"
:class="
@@ -44,14 +43,6 @@
</Button>
</div>
</Panel>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
<QueueInlineProgress
:hidden="queueOverlayExpanded"
:radius-class="cn(isDocked ? 'rounded-[7px]' : 'rounded-[5px]')"
data-testid="queue-inline-progress"
/>
</Teleport>
</div>
</template>
@@ -60,17 +51,14 @@ import {
useDraggable,
useEventListener,
useLocalStorage,
unrefElement,
watchDebounced
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import Panel from 'primevue/panel'
import { computed, nextTick, ref, watch } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -81,15 +69,6 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
}>()
const emit = defineEmits<{
(event: 'update:progressTarget', target: HTMLElement | null): void
}>()
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const { t } = useI18n()
@@ -97,22 +76,15 @@ const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const isQueuePanelV2Enabled = computed(() =>
settingsStore.get('Comfy.Queue.QPOV2')
)
const panelRef = ref<ComponentPublicInstance | null>(null)
const panelElement = computed<HTMLElement | null>(() => {
const element = unrefElement(panelRef)
return element instanceof HTMLElement ? element : null
})
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0
})
const { x, y, style, isDragging } = useDraggable(panelElement, {
const { x, y, style, isDragging } = useDraggable(panelRef, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body
@@ -129,12 +101,11 @@ watchDebounced(
// Set initial position to bottom center
const setInitialPosition = () => {
const panel = panelElement.value
if (panel) {
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panel.offsetWidth
const menuHeight = panel.offsetHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
if (menuWidth === 0 || menuHeight === 0) {
return
@@ -210,12 +181,11 @@ watch(
)
const adjustMenuPosition = () => {
const panel = panelElement.value
if (panel) {
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panel.offsetWidth
const menuHeight = panel.offsetHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
// Calculate distances to all edges
const distanceLeft = lastDragState.value.x
@@ -282,19 +252,6 @@ const onMouseLeaveDropZone = () => {
}
}
const inlineProgressTarget = computed(() => {
if (!visible.value || !isQueuePanelV2Enabled.value) return null
if (isDocked.value) return topMenuContainer ?? null
return panelElement.value
})
watch(
panelElement,
(target) => {
emit('update:progressTarget', target)
},
{ immediate: true }
)
// Handle drag state changes
watch(isDragging, (dragging) => {
if (dragging) {

View File

@@ -24,7 +24,7 @@
import { promiseTimeout, until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import { onMounted, ref } from 'vue'
import { ref } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
@@ -120,10 +120,7 @@ async function initializeWorkspaceMode(): Promise<void> {
}
}
// Initialize on mount. This gate should be placed on the authenticated layout
// (LayoutDefault) so it mounts fresh after login and unmounts on logout.
// The router guard ensures only authenticated users reach this layout.
onMounted(() => {
void initialize()
})
// Start initialization immediately during component setup
// (not in onMounted, so initialization starts before DOM is ready)
void initialize()
</script>

View File

@@ -14,12 +14,7 @@
</template>
<template #header>
<SearchBox
v-model="searchQuery"
size="lg"
class="max-w-[384px]"
autofocus
/>
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
</template>
<template #header-right-area>
@@ -772,7 +767,7 @@ useIntersectionObserver(loadTrigger, () => {
// Reset pagination when filters change
watch(
[
searchQuery,
filteredTemplates,
selectedNavItem,
sortBy,
selectedModels,

View File

@@ -265,18 +265,15 @@ function cancelEdit() {
}
async function saveKeybinding() {
if (currentEditingCommand.value && newBindingKeyCombo.value) {
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({
commandId: currentEditingCommand.value.id,
combo: newBindingKeyCombo.value
})
)
if (updated) {
await keybindingService.persistUserKeybindings()
}
}
const commandId = currentEditingCommand.value?.id
const combo = newBindingKeyCombo.value
cancelEdit()
if (!combo || commandId == undefined) return
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })
)
if (updated) await keybindingService.persistUserKeybindings()
}
async function resetKeybinding(commandData: ICommandData) {

View File

@@ -87,6 +87,7 @@
<template v-if="comfyAppReady">
<TitleEditor />
<SelectionToolbox v-if="selectionToolboxEnabled" />
<NodeContextMenu />
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
<DomWidgets v-if="!shouldRenderVueNodes" />
</template>
@@ -112,6 +113,7 @@ import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
@@ -149,7 +151,7 @@ import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useNewUserService } from '@/services/useNewUserService'
import { newUserService } from '@/services/newUserService'
import { storeToRefs } from 'pinia'
import { useBootstrapStore } from '@/stores/bootstrapStore'
@@ -457,9 +459,11 @@ onMounted(async () => {
// Register core settings immediately after settings are ready
CORE_SETTINGS.forEach(settingStore.addSetting)
// Wait for both i18n and newUserService in parallel
// (newUserService only needs settings, not i18n)
await Promise.all([
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
useNewUserService().initializeIfNewUser()
newUserService().initializeIfNewUser(settingStore)
])
if (i18nError.value) {
console.warn(

View File

@@ -13,8 +13,6 @@ import {
createMockCanvas,
createMockPositionable
} from '@/utils/__tests__/litegraphTestUtils'
import * as litegraphUtil from '@/utils/litegraphUtil'
import * as nodeFilterUtil from '@/utils/nodeFilterUtil'
function createMockExtensionService(): ReturnType<typeof useExtensionService> {
return {
@@ -291,8 +289,9 @@ describe('SelectionToolbox', () => {
)
})
it('should show mask editor only for single image nodes', () => {
const isImageNodeSpy = vi.spyOn(litegraphUtil, 'isImageNode')
it('should show mask editor only for single image nodes', async () => {
const mockUtils = await import('@/utils/litegraphUtil')
const isImageNodeSpy = vi.spyOn(mockUtils, 'isImageNode')
// Single image node
isImageNodeSpy.mockReturnValue(true)
@@ -308,8 +307,9 @@ describe('SelectionToolbox', () => {
expect(wrapper2.find('.mask-editor-button').exists()).toBe(false)
})
it('should show Color picker button only for single Load3D nodes', () => {
const isLoad3dNodeSpy = vi.spyOn(litegraphUtil, 'isLoad3dNode')
it('should show Color picker button only for single Load3D nodes', async () => {
const mockUtils = await import('@/utils/litegraphUtil')
const isLoad3dNodeSpy = vi.spyOn(mockUtils, 'isLoad3dNode')
// Single Load3D node
isLoad3dNodeSpy.mockReturnValue(true)
@@ -325,9 +325,13 @@ describe('SelectionToolbox', () => {
expect(wrapper2.find('.load-3d-viewer-button').exists()).toBe(false)
})
it('should show ExecuteButton only when output nodes are selected', () => {
const isOutputNodeSpy = vi.spyOn(nodeFilterUtil, 'isOutputNode')
const filterOutputNodesSpy = vi.spyOn(nodeFilterUtil, 'filterOutputNodes')
it('should show ExecuteButton only when output nodes are selected', async () => {
const mockNodeFilterUtil = await import('@/utils/nodeFilterUtil')
const isOutputNodeSpy = vi.spyOn(mockNodeFilterUtil, 'isOutputNode')
const filterOutputNodesSpy = vi.spyOn(
mockNodeFilterUtil,
'filterOutputNodes'
)
// With output node selected
isOutputNodeSpy.mockReturnValue(true)

View File

@@ -42,7 +42,6 @@
</Panel>
</Transition>
</div>
<NodeContextMenu />
</template>
<script setup lang="ts">
@@ -69,7 +68,6 @@ import { useExtensionService } from '@/services/extensionService'
import { useCommandStore } from '@/stores/commandStore'
import type { ComfyCommandImpl } from '@/stores/commandStore'
import NodeContextMenu from './NodeContextMenu.vue'
import FrameNodes from './selectionToolbox/FrameNodes.vue'
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'

View File

@@ -7,7 +7,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -48,7 +47,7 @@ describe('ExecuteButton', () => {
}
})
beforeEach(() => {
beforeEach(async () => {
// Set up Pinia with testing utilities
setActivePinia(
createTestingPinia({
@@ -72,7 +71,10 @@ describe('ExecuteButton', () => {
vi.spyOn(commandStore, 'execute').mockResolvedValue()
// Update the useSelectionState mock
vi.mocked(useSelectionState).mockReturnValue({
const { useSelectionState } = vi.mocked(
await import('@/composables/graph/useSelectionState')
)
useSelectionState.mockReturnValue({
selectedNodes: {
value: mockSelectedNodes
}

View File

@@ -1,6 +1,6 @@
<template>
<!-- Help Center Popup positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<Teleport to="body">
<div
v-if="isHelpCenterVisible"
class="help-center-popup"

View File

@@ -76,14 +76,6 @@ describe('NodePreview', () => {
expect(wrapper.find('._sb_preview_badge').text()).toBe('Preview')
})
it('applies text-ellipsis class to node header for text truncation', () => {
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')
expect(nodeHeader.classes()).toContain('text-ellipsis')
expect(nodeHeader.classes()).toContain('mr-4')
})
it('sets title attribute on node header with full display name', () => {
const wrapper = mountComponent()
const nodeHeader = wrapper.find('.node_header')

View File

@@ -10,7 +10,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
<div v-else class="_sb_node_preview bg-component-node-background">
<div class="_sb_table">
<div
class="node_header mr-4 text-ellipsis"
class="node_header text-ellipsis"
:title="nodeDef.display_name"
:style="{
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR,

View File

@@ -1,75 +0,0 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
const mockProgress = vi.hoisted(() => ({
totalPercent: null as unknown as Ref<number>,
currentNodePercent: null as unknown as Ref<number>
}))
vi.mock('@/composables/queue/useQueueProgress', () => ({
useQueueProgress: () => ({
totalPercent: mockProgress.totalPercent,
currentNodePercent: mockProgress.currentNodePercent
})
}))
const createWrapper = (props: { hidden?: boolean } = {}) =>
mount(QueueInlineProgress, { props })
describe('QueueInlineProgress', () => {
beforeEach(() => {
mockProgress.totalPercent = ref(0)
mockProgress.currentNodePercent = ref(0)
})
it('renders when total progress is non-zero', () => {
mockProgress.totalPercent.value = 12
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('renders when current node progress is non-zero', () => {
mockProgress.currentNodePercent.value = 33
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('does not render when hidden', () => {
mockProgress.totalPercent.value = 45
const wrapper = createWrapper({ hidden: true })
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
})
it('shows when progress becomes non-zero', async () => {
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
mockProgress.totalPercent.value = 10
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
})
it('hides when progress returns to zero', async () => {
mockProgress.totalPercent.value = 10
const wrapper = createWrapper()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(true)
mockProgress.totalPercent.value = 0
mockProgress.currentNodePercent.value = 0
await nextTick()
expect(wrapper.find('[aria-hidden="true"]').exists()).toBe(false)
})
})

View File

@@ -1,36 +0,0 @@
<template>
<div
v-if="shouldShow"
aria-hidden="true"
:class="
cn('pointer-events-none absolute inset-0 overflow-hidden', radiusClass)
"
>
<div
class="pointer-events-none absolute bottom-0 left-0 h-[3px] bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${totalPercent}%` }"
/>
<div
class="pointer-events-none absolute bottom-0 left-0 h-[3px] bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${currentNodePercent}%` }"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { cn } from '@/utils/tailwindUtil'
const { hidden = false, radiusClass = 'rounded-[7px]' } = defineProps<{
hidden?: boolean
radiusClass?: string
}>()
const { totalPercent, currentNodePercent } = useQueueProgress()
const shouldShow = computed(
() => !hidden && (totalPercent.value > 0 || currentNodePercent.value > 0)
)
</script>

View File

@@ -1,70 +0,0 @@
<template>
<div v-if="shouldShow" class="flex justify-end">
<div
class="flex items-center whitespace-nowrap text-[0.75rem] leading-[normal] drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
role="status"
aria-live="polite"
aria-atomic="true"
>
<div class="flex items-center text-base-foreground">
<span class="font-normal">
{{ t('sideToolbar.queueProgressOverlay.inlineTotalLabel') }}:
</span>
<span class="w-[5ch] shrink-0 text-right font-bold tabular-nums">
{{ totalPercentFormatted }}
</span>
</div>
<div class="flex items-center text-muted-foreground">
<span
class="w-[16ch] shrink-0 truncate text-right"
:title="currentNodeName"
>
{{ currentNodeName }}:
</span>
<span class="w-[5ch] shrink-0 text-right tabular-nums">
{{ currentNodePercentFormatted }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { st } from '@/i18n'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useExecutionStore } from '@/stores/executionStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
const props = defineProps<{
hidden?: boolean
}>()
const { t } = useI18n()
const executionStore = useExecutionStore()
const {
totalPercent,
totalPercentFormatted,
currentNodePercent,
currentNodePercentFormatted
} = useQueueProgress()
const currentNodeName = computed(() => {
return resolveNodeDisplayName(executionStore.executingNode, {
emptyLabel: t('g.emDash'),
untitledLabel: t('g.untitled'),
st
})
})
const shouldShow = computed(
() =>
!props.hidden &&
(!executionStore.isIdle ||
totalPercent.value > 0 ||
currentNodePercent.value > 0)
)
</script>

View File

@@ -8,14 +8,12 @@ import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import { st } from '@/i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import type { RightSidePanelTab } from '@/stores/workspace/rightSidePanelStore'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import TabInfo from './info/TabInfo.vue'
@@ -148,12 +146,9 @@ function resolveTitle() {
return groups[0].title || t('rightSidePanel.fallbackGroupTitle')
}
if (nodes.length === 1) {
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(nodes[0], {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
})
return (
nodes[0].title || nodes[0].type || t('rightSidePanel.fallbackNodeTitle')
)
}
}
return t('rightSidePanel.title', { count: items.length })

View File

@@ -14,8 +14,6 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { GetNodeParentGroupKey } from '../shared'
import WidgetItem from './WidgetItem.vue'
@@ -54,7 +52,7 @@ const rootElement = ref<HTMLElement>()
const widgets = shallowRef(widgetsProp)
watchEffect(() => (widgets.value = widgetsProp))
provide(HideLayoutFieldKey, true)
provide('hideLayoutField', true)
const canvasStore = useCanvasStore()
const { t } = useI18n()

View File

@@ -1,11 +1,9 @@
<script setup lang="ts">
import { computed, customRef, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getSharedWidgetEnhancements } from '@/composables/graph/useGraphNodeManager'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { st } from '@/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'
@@ -17,7 +15,6 @@ import {
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import { renameWidget } from '@/utils/widgetUtil'
@@ -41,7 +38,6 @@ const {
isShownOnParents?: boolean
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const isEditing = ref(false)
@@ -63,13 +59,7 @@ const sourceNodeName = computed((): string | null => {
const { graph, nodeId } = widget._overlay
sourceNode = getNodeByExecutionId(graph, nodeId)
}
if (!sourceNode) return null
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(sourceNode, {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
})
return sourceNode ? sourceNode.title || sourceNode.type : null
})
const hasParents = computed(() => parents?.length > 0)

View File

@@ -2,7 +2,7 @@
<div class="flex h-full flex-col">
<!-- Active Jobs Grid -->
<div
v-if="activeJobItems.length"
v-if="isQueuePanelV2Enabled && activeJobItems.length"
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
:style="gridStyle"
>
@@ -65,6 +65,7 @@ import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isActiveJobState } from '@/utils/queueUtil'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
@@ -90,6 +91,11 @@ const emit = defineEmits<{
const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
type AssetGridItem = { key: string; asset: AssetItem }

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex h-full flex-col">
<div
v-if="activeJobItems.length"
v-if="isQueuePanelV2Enabled && activeJobItems.length"
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
@@ -133,6 +133,7 @@ import {
} from '@/utils/formatUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
@@ -154,6 +155,11 @@ const emit = defineEmits<{
const { t } = useI18n()
const { jobItems } = useJobList()
const settingStore = useSettingStore()
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null)

View File

@@ -1,11 +1,7 @@
<template>
<div
:class="
cn(
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col',
props.class
)
"
class="comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col"
:class="props.class"
>
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
<Toolbar
@@ -39,8 +35,6 @@
import ScrollPanel from 'primevue/scrollpanel'
import Toolbar from 'primevue/toolbar'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
title: string
class?: string

View File

@@ -13,10 +13,7 @@
severity="danger"
/>
</template>
<template
v-if="nodeDef.name.startsWith(useSubgraphStore().typePrefix)"
#actions
>
<template v-if="isUserBlueprint" #actions>
<Button
variant="destructive"
size="icon-sm"
@@ -128,8 +125,18 @@ const editBlueprint = async () => {
await useSubgraphStore().editBlueprint(props.node.data.name)
}
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const subgraphStore = useSubgraphStore()
const isUserBlueprint = computed(() => {
const name = nodeDef.value.name
if (!name.startsWith(subgraphStore.typePrefix)) return false
return !subgraphStore.isGlobalBlueprint(
name.slice(subgraphStore.typePrefix.length)
)
})
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
if (!isUserBlueprint.value) return []
return [
{
label: t('g.delete'),
icon: 'pi pi-trash',
@@ -137,15 +144,14 @@ const menuItems = computed<MenuItem[]>(() => {
command: deleteBlueprint
}
]
return items
})
function handleContextMenu(event: Event) {
if (!nodeDef.value.name.startsWith(useSubgraphStore().typePrefix)) return
if (!isUserBlueprint.value) return
menu.value?.show(event)
}
function deleteBlueprint() {
if (!props.node.data) return
void useSubgraphStore().deleteBlueprint(props.node.data.name)
void subgraphStore.deleteBlueprint(props.node.data.name)
}
const nodePreviewStyle = ref<CSSProperties>({

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
import { whenever } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import Popover from '@/components/ui/Popover.vue'
@@ -10,7 +10,6 @@ defineProps<{
}>()
const feedbackRef = useTemplateRef('feedbackRef')
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
whenever(feedbackRef, () => {
const scriptEl = document.createElement('script')
@@ -19,20 +18,9 @@ whenever(feedbackRef, () => {
})
</script>
<template>
<Button
v-if="isMobile"
as="a"
:href="`https://form.typeform.com/to/${dataTfWidget}`"
target="_blank"
variant="inverted"
class="rounded-full size-12"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-question-mark] size-6" />
</Button>
<Popover v-else>
<Popover>
<template #button>
<Button variant="inverted" class="rounded-full size-12" v-bind="$attrs">
<Button variant="inverted" class="rounded-full size-12">
<i class="icon-[lucide--circle-question-mark] size-6" />
</Button>
</template>

View File

@@ -134,6 +134,27 @@ describe('contextMenuConverter', () => {
// Node Info (section 4) should come before or with Color (section 4)
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
})
it('should recognize Frame Nodes as a core menu item', () => {
const options: MenuOption[] = [
{ label: 'Rename', source: 'vue' },
{ label: 'Frame Nodes', source: 'vue' },
{ label: 'Custom Extension', source: 'vue' }
]
const result = buildStructuredMenu(options)
// Frame Nodes should appear in the core items section (before Extensions)
const frameNodesIndex = result.findIndex(
(opt) => opt.label === 'Frame Nodes'
)
const extensionsCategoryIndex = result.findIndex(
(opt) => opt.label === 'Extensions' && opt.type === 'category'
)
// Frame Nodes should come before Extensions category
expect(frameNodesIndex).toBeLessThan(extensionsCategoryIndex)
})
})
describe('convertContextMenuToOptions', () => {

View File

@@ -44,6 +44,7 @@ const CORE_MENU_ITEMS = new Set([
// Structure operations
'Convert to Subgraph',
'Frame selection',
'Frame Nodes',
'Minimize Node',
'Expand',
'Collapse',
@@ -103,7 +104,8 @@ function isDuplicateItem(label: string, existingItems: MenuOption[]): boolean {
shape: ['shape', 'shapes'],
pin: ['pin', 'unpin'],
delete: ['remove', 'delete'],
duplicate: ['clone', 'duplicate']
duplicate: ['clone', 'duplicate'],
frame: ['frame selection', 'frame nodes']
}
return existingItems.some((item) => {
@@ -226,6 +228,7 @@ const MENU_ORDER: string[] = [
// Section 3: Structure operations
'Convert to Subgraph',
'Frame selection',
'Frame Nodes',
'Minimize Node',
'Expand',
'Collapse',

View File

@@ -2,10 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectionMenuOptions } from '@/composables/graph/useSelectionMenuOptions'
const subgraphMocks = vi.hoisted(() => ({
const mocks = vi.hoisted(() => ({
convertToSubgraph: vi.fn(),
unpackSubgraph: vi.fn(),
addSubgraphToLibrary: vi.fn(),
frameNodes: vi.fn(),
createI18nMock: vi.fn(() => ({
global: {
t: vi.fn(),
@@ -19,7 +20,7 @@ vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
}),
createI18n: subgraphMocks.createI18nMock
createI18n: mocks.createI18nMock
}))
vi.mock('@/composables/graph/useSelectionOperations', () => ({
@@ -42,18 +43,46 @@ vi.mock('@/composables/graph/useNodeArrangement', () => ({
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
useSubgraphOperations: () => ({
convertToSubgraph: subgraphMocks.convertToSubgraph,
unpackSubgraph: subgraphMocks.unpackSubgraph,
addSubgraphToLibrary: subgraphMocks.addSubgraphToLibrary
convertToSubgraph: mocks.convertToSubgraph,
unpackSubgraph: mocks.unpackSubgraph,
addSubgraphToLibrary: mocks.addSubgraphToLibrary
})
}))
vi.mock('@/composables/graph/useFrameNodes', () => ({
useFrameNodes: () => ({
frameNodes: vi.fn()
frameNodes: mocks.frameNodes
})
}))
describe('useSelectionMenuOptions - multiple nodes options', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns Frame Nodes option that invokes frameNodes when called', () => {
const { getMultipleNodesOptions } = useSelectionMenuOptions()
const options = getMultipleNodesOptions()
const frameOption = options.find((opt) => opt.label === 'g.frameNodes')
expect(frameOption).toBeDefined()
expect(frameOption?.action).toBeDefined()
frameOption?.action?.()
expect(mocks.frameNodes).toHaveBeenCalledOnce()
})
it('returns Convert to Group Node option from getMultipleNodesOptions', () => {
const { getMultipleNodesOptions } = useSelectionMenuOptions()
const options = getMultipleNodesOptions()
const groupNodeOption = options.find(
(opt) => opt.label === 'contextMenu.Convert to Group Node'
)
expect(groupNodeOption).toBeDefined()
})
})
describe('useSelectionMenuOptions - subgraph options', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -68,7 +97,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
expect(options).toHaveLength(1)
expect(options[0]?.label).toBe('contextMenu.Convert to Subgraph')
expect(options[0]?.action).toBe(subgraphMocks.convertToSubgraph)
expect(options[0]?.action).toBe(mocks.convertToSubgraph)
})
it('includes convert, add to library, and unpack when subgraphs are selected', () => {
@@ -86,7 +115,7 @@ describe('useSelectionMenuOptions - subgraph options', () => {
const convertOption = options.find(
(option) => option.label === 'contextMenu.Convert to Subgraph'
)
expect(convertOption?.action).toBe(subgraphMocks.convertToSubgraph)
expect(convertOption?.action).toBe(mocks.convertToSubgraph)
})
it('hides convert option when only a single subgraph is selected', () => {

View File

@@ -87,6 +87,25 @@ describe('useSelectionState', () => {
const { hasAnySelection } = useSelectionState()
expect(hasAnySelection.value).toBe(true)
})
test('hasMultipleSelection should be true when 2+ items selected', () => {
const canvasStore = useCanvasStore()
const node1 = createMockLGraphNode({ id: 1 })
const node2 = createMockLGraphNode({ id: 2 })
canvasStore.$state.selectedItems = [node1, node2]
const { hasMultipleSelection } = useSelectionState()
expect(hasMultipleSelection.value).toBe(true)
})
test('hasMultipleSelection should be false when only 1 item selected', () => {
const canvasStore = useCanvasStore()
const node1 = createMockLGraphNode({ id: 1 })
canvasStore.$state.selectedItems = [node1]
const { hasMultipleSelection } = useSelectionState()
expect(hasMultipleSelection.value).toBe(false)
})
})
describe('Node Type Filtering', () => {

View File

@@ -17,7 +17,7 @@ import {
isToday,
isYesterday
} from '@/utils/dateTimeUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildJobDisplay } from '@/utils/queueDisplay'
import { jobStateFromTask } from '@/utils/queueUtil'
@@ -185,11 +185,13 @@ export function useJobList() {
executionStore.isPromptInitializing(promptId)
const currentNodeName = computed(() => {
return resolveNodeDisplayName(executionStore.executingNode, {
emptyLabel: t('g.emDash'),
untitledLabel: t('g.untitled'),
st
})
const node = executionStore.executingNode
if (!node) return t('g.emDash')
const title = (node.title ?? '').toString().trim()
if (title) return title
const nodeType = (node.type ?? '').toString().trim() || t('g.untitled')
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return st(key, nodeType)
})
const selectedJobTab = ref<JobTab>('All')

View File

@@ -4,7 +4,6 @@ import { nextTick, ref } from 'vue'
import type { IFuseOptions } from 'fuse.js'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
const defaultSettingStore = {
get: vi.fn((key: string) => {
@@ -51,6 +50,9 @@ vi.mock('@/scripts/api', () => ({
}
}))
const { useTemplateFiltering } =
await import('@/composables/useTemplateFiltering')
describe('useTemplateFiltering', () => {
beforeEach(() => {
setActivePinia(createPinia())

View File

@@ -1,4 +1,4 @@
import { refThrottled, watchDebounced } from '@vueuse/core'
import { refDebounced, watchDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { computed, ref, watch } from 'vue'
@@ -119,7 +119,7 @@ export function useTemplateFiltering(
)
})
const debouncedSearchQuery = refThrottled(searchQuery, 50)
const debouncedSearchQuery = refDebounced(searchQuery, 150)
const filteredBySearch = computed(() => {
if (!debouncedSearchQuery.value.trim()) {

View File

@@ -1,5 +1,4 @@
import { remove } from 'es-toolkit'
import { shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type {
@@ -345,7 +344,6 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
requestAnimationFrame(() => {
const input = node.inputs[index]
if (!input) return
node.inputs[index] = shallowReactive(input)
node.onConnectionsChange?.(
LiteGraph.INPUT,
index,

View File

@@ -16,16 +16,15 @@ useExtensionService().registerExtension({
const { isLoggedIn } = useCurrentUser()
const { isActiveSubscription } = useSubscription()
// Refresh config when auth or subscription status changes
// Primary auth refresh is handled by WorkspaceAuthGate on mount
// This watcher handles subscription changes and acts as a backup for auth
// Refresh config when subscription status changes
// Initial auth-aware refresh happens in WorkspaceAuthGate before app renders
watchDebounced(
[isLoggedIn, isActiveSubscription],
() => {
if (!isLoggedIn.value) return
void refreshRemoteConfig()
},
{ debounce: 256, immediate: true }
{ debounce: 256 }
)
// Poll for config updates every 10 minutes (with auth)

View File

@@ -157,11 +157,9 @@ class Load3d {
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
this.forceRender()
})
this.resizeObserver.observe(container)
}
@@ -524,6 +522,7 @@ class Load3d {
this.viewHelperManager.recreateViewHelper()
this.handleResize()
this.forceRender()
}
getCurrentCameraType(): 'perspective' | 'orthographic' {
@@ -585,6 +584,7 @@ class Load3d {
}
this.handleResize()
this.forceRender()
this.loadingPromise = null
}
@@ -618,6 +618,7 @@ class Load3d {
this.targetHeight = height
this.targetAspectRatio = width / height
this.handleResize()
this.forceRender()
}
addEventListener<T>(event: string, callback: EventCallback<T>): void {
@@ -630,6 +631,7 @@ class Load3d {
refreshViewport(): void {
this.handleResize()
this.forceRender()
}
handleResize(): void {

View File

@@ -10,17 +10,8 @@ import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type {
IBaseWidget,
IWidgetAssetOptions,
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import {
assetFilenameSchema,
assetItemSchema
} from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { isCloud } from '@/platform/distribution/types'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import {
@@ -28,10 +19,10 @@ import {
addValueControlWidgets,
isValidWidgetType
} from '@/scripts/widgets'
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
import { mergeInputSpec } from '@/utils/nodeDefUtil'
import { applyTextReplacements } from '@/utils/searchAndReplace'
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
const replacePropertyName = 'Run widget replace on values'
export class PrimitiveNode extends LGraphNode {
@@ -237,20 +228,6 @@ export class PrimitiveNode extends LGraphNode {
// Store current size as addWidget resizes the node
const [oldWidth, oldHeight] = this.size
let widget: IBaseWidget
// Cloud: Use asset widget for model-eligible inputs
if (isCloud && type === 'COMBO') {
const isEligible = assetService.isAssetBrowserEligible(
node.comfyClass,
widgetName
)
if (isEligible) {
widget = this.#createAssetWidget(node, widgetName, inputData)
this.#finalizeWidget(widget, oldWidth, oldHeight, recreating)
return
}
}
if (isValidWidgetType(type)) {
widget = (ComfyWidgets[type](this, 'value', inputData, app) || {}).widget
} else {
@@ -300,84 +277,20 @@ export class PrimitiveNode extends LGraphNode {
}
}
this.#finalizeWidget(widget, oldWidth, oldHeight, recreating)
}
#createAssetWidget(
targetNode: LGraphNode,
widgetName: string,
inputData: InputSpec
): IBaseWidget {
const defaultValue = inputData[1]?.default as string | undefined
const assetBrowserDialog = useAssetBrowserDialog()
const openModal = async (widget: IBaseWidget) => {
await assetBrowserDialog.show({
nodeType: targetNode.comfyClass ?? '',
inputName: widgetName,
currentValue: widget.value as string,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error('Invalid asset item:', validatedAsset.error.errors)
return
}
const filename = getAssetFilename(validatedAsset.data)
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors
)
return
}
const oldValue = widget.value
widget.value = validatedFilename.data
widget.callback?.(
widget.value,
app.canvas,
this,
app.canvas.graph_mouse,
{} as CanvasPointerEvent
)
this.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}
const options: IWidgetAssetOptions = { openModal }
return this.addWidget(
'asset',
'value',
defaultValue ?? '',
() => {},
options
)
}
#finalizeWidget(
widget: IBaseWidget,
oldWidth: number,
oldHeight: number,
recreating: boolean
) {
// When our value changes, update other widgets to reflect our changes
// e.g. so LoadImage shows correct image
widget.callback = useChainCallback(widget.callback, () => {
this.applyToGraph()
})
// Use the biggest dimensions in case the widgets caused the node to grow
this.setSize([
Math.max(this.size[0], oldWidth),
Math.max(this.size[1], oldHeight)
])
if (!recreating) {
// Grow our node more if required
const sz = this.computeSize()
if (this.size[0] < sz[0]) {
this.size[0] = sz[0]

View File

@@ -1,3 +1,5 @@
import DOMPurify from 'dompurify'
import type {
ContextMenuDivElement,
IContextMenuOptions,
@@ -5,6 +7,38 @@ import type {
} from './interfaces'
import { LiteGraph } from './litegraph'
const ALLOWED_TAGS = ['span', 'b', 'i', 'em', 'strong']
const ALLOWED_STYLE_PROPS = new Set([
'display',
'color',
'background-color',
'padding-left',
'border-left'
])
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
if (data.attrName === 'style') {
const sanitizedStyle = data.attrValue
.split(';')
.map((s) => s.trim())
.filter((s) => {
const colonIdx = s.indexOf(':')
if (colonIdx === -1) return false
const prop = s.slice(0, colonIdx).trim().toLowerCase()
return ALLOWED_STYLE_PROPS.has(prop)
})
.join('; ')
data.attrValue = sanitizedStyle
}
})
function sanitizeMenuHTML(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR: ['style']
})
}
// TODO: Replace this pattern with something more modern.
export interface ContextMenu<TValue = unknown> {
constructor: new (
@@ -123,7 +157,7 @@ export class ContextMenu<TValue = unknown> {
if (options.title) {
const element = document.createElement('div')
element.className = 'litemenu-title'
element.innerHTML = options.title
element.textContent = options.title
root.append(element)
}
@@ -218,11 +252,18 @@ export class ContextMenu<TValue = unknown> {
if (value === null) {
element.classList.add('separator')
} else {
const innerHtml = name === null ? '' : String(name)
const label = name === null ? '' : String(name)
if (typeof value === 'string') {
element.innerHTML = innerHtml
element.textContent = label
} else {
element.innerHTML = value?.title ?? innerHtml
// Use innerHTML for content that contains HTML tags, textContent otherwise
const hasHtmlContent =
value?.content !== undefined && /<[a-z][\s\S]*>/i.test(value.content)
if (hasHtmlContent) {
element.innerHTML = sanitizeMenuHTML(value.content!)
} else {
element.textContent = value?.title ?? label
}
if (value.disabled) {
disabled = true

View File

@@ -46,9 +46,12 @@ describe('LGraph', () => {
expect(graph.extra).toBe('TestGraph')
})
test('is exactly the same type', ({ expect }) => {
// LGraph from barrel export and LiteGraph.LGraph should be the same
expect(LiteGraph.LGraph).toBe(LGraph)
test('is exactly the same type', async ({ expect }) => {
const directImport = await import('@/lib/litegraph/src/LGraph')
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
expect(LiteGraph.LGraph).toBe(directImport.LGraph)
expect(LiteGraph.LGraph).toBe(entryPointImport.LGraph)
})
test('populates optional values', ({ expect, minimalSerialisableGraph }) => {

View File

@@ -0,0 +1,210 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
querySlotAtPoint: vi.fn(),
queryRerouteAtPoint: vi.fn(),
getNodeLayoutRef: vi.fn(() => ({ value: null })),
getSlotLayout: vi.fn()
}
}))
describe('LGraphCanvas slot hit detection', () => {
let graph: LGraph
let canvas: LGraphCanvas
let node: LGraphNode
let canvasElement: HTMLCanvasElement
beforeEach(() => {
vi.clearAllMocks()
canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
scale: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 50 }),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
closePath: vi.fn(),
arc: vi.fn(),
rect: vi.fn(),
clip: vi.fn(),
clearRect: vi.fn(),
setTransform: vi.fn(),
roundRect: vi.fn(),
getTransform: vi
.fn()
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
textAlign: 'left' as CanvasTextAlign,
textBaseline: 'alphabetic' as CanvasTextBaseline
} as unknown as CanvasRenderingContext2D
canvasElement.getContext = vi.fn().mockReturnValue(ctx)
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
left: 0,
top: 0,
width: 800,
height: 600
})
graph = new LGraph()
canvas = new LGraphCanvas(canvasElement, graph, {
skip_render: true
})
// Create a test node with an output slot
node = new LGraphNode('Test Node')
node.pos = [100, 100]
node.size = [150, 80]
node.addOutput('output', 'number')
graph.add(node)
// Enable Vue nodes mode for the test
LiteGraph.vueNodesMode = true
})
afterEach(() => {
LiteGraph.vueNodesMode = false
})
describe('processMouseDown slot fallback in Vue nodes mode', () => {
it('should query layoutStore.querySlotAtPoint when clicking outside node bounds', () => {
// Click position outside node bounds (node is at 100,100 with size 150x80)
// So node covers x: 100-250, y: 100-180
// Click at x=255 is outside the right edge
const clickX = 255
const clickY = 120
// Verify the click is outside the node bounds
expect(node.isPointInside(clickX, clickY)).toBe(false)
expect(graph.getNodeOnPos(clickX, clickY)).toBeNull()
// Mock the slot query to return our node's slot
vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({
nodeId: String(node.id),
index: 0,
type: 'output',
position: { x: 252, y: 120 },
bounds: { x: 246, y: 110, width: 20, height: 20 }
})
// Call processMouseDown - this should trigger the slot fallback
canvas.processMouseDown(
new MouseEvent('pointerdown', {
button: 1, // Middle button
clientX: clickX,
clientY: clickY
})
)
// The fix should query the layout store when no node is found at click position
expect(layoutStore.querySlotAtPoint).toHaveBeenCalledWith({
x: clickX,
y: clickY
})
})
it('should NOT query layoutStore when node is found directly at click position', () => {
// Initialize node's bounding rect
node.updateArea()
// Populate visible_nodes (normally done during render)
canvas.visible_nodes = [node]
// Click inside the node bounds
const clickX = 150
const clickY = 140
// Verify the click is inside the node bounds
expect(node.isPointInside(clickX, clickY)).toBe(true)
expect(graph.getNodeOnPos(clickX, clickY)).toBe(node)
// Call processMouseDown
canvas.processMouseDown(
new MouseEvent('pointerdown', {
button: 1,
clientX: clickX,
clientY: clickY
})
)
// Should NOT query the layout store since node was found directly
expect(layoutStore.querySlotAtPoint).not.toHaveBeenCalled()
})
it('should NOT query layoutStore when not in Vue nodes mode', () => {
LiteGraph.vueNodesMode = false
const clickX = 255
const clickY = 120
// Call processMouseDown
canvas.processMouseDown(
new MouseEvent('pointerdown', {
button: 1,
clientX: clickX,
clientY: clickY
})
)
// Should NOT query the layout store in non-Vue mode
expect(layoutStore.querySlotAtPoint).not.toHaveBeenCalled()
})
it('should find node via slot query for input slots extending beyond left edge', () => {
node.addInput('input', 'number')
// Click position left of node (node starts at x=100)
const clickX = 95
const clickY = 140
// Verify outside bounds
expect(node.isPointInside(clickX, clickY)).toBe(false)
vi.mocked(layoutStore.querySlotAtPoint).mockReturnValue({
nodeId: String(node.id),
index: 0,
type: 'input',
position: { x: 98, y: 140 },
bounds: { x: 88, y: 130, width: 20, height: 20 }
})
canvas.processMouseDown(
new MouseEvent('pointerdown', {
button: 1,
clientX: clickX,
clientY: clickY
})
)
expect(layoutStore.querySlotAtPoint).toHaveBeenCalledWith({
x: clickX,
y: clickY
})
})
})
})

View File

@@ -2187,9 +2187,21 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!is_inside) return
const node =
let node =
graph.getNodeOnPos(e.canvasX, e.canvasY, this.visible_nodes) ?? undefined
// In Vue nodes mode, slots extend beyond node bounds due to CSS transforms.
// If no node was found, check if the click is on a slot and use its owning node.
if (!node && LiteGraph.vueNodesMode) {
const slotLayout = layoutStore.querySlotAtPoint({
x: e.canvasX,
y: e.canvasY
})
if (slotLayout) {
node = graph.getNodeById(slotLayout.nodeId) ?? undefined
}
}
this.mouse[0] = x
this.mouse[1] = y
this.graph_mouse[0] = e.canvasX

View File

@@ -73,6 +73,28 @@ describe('LinkConnector SubgraphInput connection validation', () => {
expect(toTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1)
expect(fromTargetNode.onConnectionsChange).toHaveBeenCalledTimes(1)
})
it('should allow reconnection to same target', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
})
const node = new LGraphNode('TargetNode')
node.addInput('number_in', 'number')
subgraph.add(node)
const link = subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const renderLink = new ToInputFromIoNodeLink(
subgraph,
subgraph.inputNode,
subgraph.inputNode.slots[0],
undefined,
LinkDirection.CENTER,
link
)
renderLink.connectToInput(node, node.inputs[0], connector.events)
expect(node.inputs[0].link).not.toBeNull()
})
})
describe('MovingOutputLink validation', () => {

View File

@@ -58,6 +58,12 @@ export class ToInputFromIoNodeLink implements RenderLink {
events: CustomEventTarget<LinkConnectorEventMap>
) {
const { fromSlot, fromReroute, existingLink } = this
if (
existingLink &&
node.id === existingLink.target_id &&
node.inputs[existingLink.target_slot] === input
)
return
const newLink = fromSlot.connect(input, node, fromReroute?.id)

View File

@@ -1,15 +1,12 @@
import { clamp } from 'es-toolkit/compat'
import { describe, expect } from 'vitest'
import { beforeEach, describe, expect, vi } from 'vitest'
import {
LiteGraphGlobal,
LGraphCanvas,
LiteGraph,
LGraph
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { LGraph as DirectLGraph } from '@/lib/litegraph/src/LGraph'
import { test } from './__fixtures__/testExtensions'
describe('Litegraph module', () => {
@@ -30,9 +27,22 @@ describe('Litegraph module', () => {
})
describe('Import order dependency', () => {
test('Imports reference the same types', ({ expect }) => {
// Both imports should reference the same LGraph class
expect(LiteGraph.LGraph).toBe(DirectLGraph)
expect(LiteGraph.LGraph).toBe(LGraph)
beforeEach(() => {
vi.resetModules()
})
test('Imports without error when entry point is imported first', async ({
expect
}) => {
async function importNormally() {
const entryPointImport = await import('@/lib/litegraph/src/litegraph')
const directImport = await import('@/lib/litegraph/src/LGraph')
// Sanity check that imports were cleared.
expect(Object.is(LiteGraph, entryPointImport.LiteGraph)).toBe(false)
expect(Object.is(LiteGraph.LGraph, directImport.LGraph)).toBe(false)
}
await expect(importNormally()).resolves.toBeUndefined()
})
})

View File

@@ -1,261 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { LGraphNode as LGraphNodeType } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IColorWidget } from '@/lib/litegraph/src/types/widgets'
import type { ColorWidget as ColorWidgetType } from '@/lib/litegraph/src/widgets/ColorWidget'
type LGraphCanvasType = InstanceType<typeof LGraphCanvas>
function createMockWidgetConfig(
overrides: Partial<IColorWidget> = {}
): IColorWidget {
return {
type: 'color',
name: 'test_color',
value: '#ff0000',
options: {},
y: 0,
...overrides
}
}
function createMockCanvas(): LGraphCanvasType {
return {
setDirty: vi.fn()
} as Partial<LGraphCanvasType> as LGraphCanvasType
}
function createMockEvent(clientX = 100, clientY = 200): CanvasPointerEvent {
return { clientX, clientY } as CanvasPointerEvent
}
describe('ColorWidget', () => {
let node: LGraphNodeType
let widget: ColorWidgetType
let mockCanvas: LGraphCanvasType
let mockEvent: CanvasPointerEvent
let ColorWidget: typeof ColorWidgetType
let LGraphNode: typeof LGraphNodeType
beforeEach(async () => {
vi.clearAllMocks()
vi.useFakeTimers()
// Reset modules to get fresh globalColorInput state
vi.resetModules()
const litegraph = await import('@/lib/litegraph/src/litegraph')
LGraphNode = litegraph.LGraphNode
const colorWidgetModule =
await import('@/lib/litegraph/src/widgets/ColorWidget')
ColorWidget = colorWidgetModule.ColorWidget
node = new LGraphNode('TestNode')
mockCanvas = createMockCanvas()
mockEvent = createMockEvent()
})
afterEach(() => {
vi.useRealTimers()
document
.querySelectorAll('input[type="color"]')
.forEach((el) => el.remove())
})
describe('onClick', () => {
it('should create a color input and append it to document body', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input).toBeTruthy()
expect(input.parentElement).toBe(document.body)
})
it('should set input value from widget value', () => {
widget = new ColorWidget(
createMockWidgetConfig({ value: '#00ff00' }),
node
)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#00ff00')
})
it('should default to #000000 when widget value is empty', () => {
widget = new ColorWidget(createMockWidgetConfig({ value: '' }), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#000000')
})
it('should position input at click coordinates', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const event = createMockEvent(150, 250)
widget.onClick({ e: event, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.style.left).toBe('150px')
expect(input.style.top).toBe('250px')
})
it('should click the input on next animation frame', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
const clickSpy = vi.spyOn(input, 'click')
expect(clickSpy).not.toHaveBeenCalled()
vi.runAllTimers()
expect(clickSpy).toHaveBeenCalled()
})
it('should reuse the same input element on subsequent clicks', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const firstInput = document.querySelector('input[type="color"]')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const secondInput = document.querySelector('input[type="color"]')
expect(firstInput).toBe(secondInput)
expect(document.querySelectorAll('input[type="color"]').length).toBe(1)
})
it('should update input value when clicking with different widget values', () => {
const widget1 = new ColorWidget(
createMockWidgetConfig({ value: '#ff0000' }),
node
)
const widget2 = new ColorWidget(
createMockWidgetConfig({ value: '#0000ff' }),
node
)
widget1.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
expect(input.value).toBe('#ff0000')
widget2.onClick({ e: mockEvent, node, canvas: mockCanvas })
expect(input.value).toBe('#0000ff')
})
})
describe('onChange', () => {
it('should call setValue when color input changes', () => {
widget = new ColorWidget(
createMockWidgetConfig({ value: '#ff0000' }),
node
)
const setValueSpy = vi.spyOn(widget, 'setValue')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
expect(setValueSpy).toHaveBeenCalledWith('#00ff00', {
e: mockEvent,
node,
canvas: mockCanvas
})
})
it('should call canvas.setDirty after value change', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true)
})
it('should remove change listener after firing once', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const setValueSpy = vi.spyOn(widget, 'setValue')
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
input.value = '#0000ff'
input.dispatchEvent(new Event('change'))
// Should only be called once despite two change events
expect(setValueSpy).toHaveBeenCalledTimes(1)
expect(setValueSpy).toHaveBeenCalledWith('#00ff00', expect.any(Object))
})
it('should register new change listener on subsequent onClick', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
const setValueSpy = vi.spyOn(widget, 'setValue')
// First click and change
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
const input = document.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#00ff00'
input.dispatchEvent(new Event('change'))
// Second click and change
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
input.value = '#0000ff'
input.dispatchEvent(new Event('change'))
expect(setValueSpy).toHaveBeenCalledTimes(2)
expect(setValueSpy).toHaveBeenNthCalledWith(
1,
'#00ff00',
expect.any(Object)
)
expect(setValueSpy).toHaveBeenNthCalledWith(
2,
'#0000ff',
expect.any(Object)
)
})
})
describe('type', () => {
it('should have type "color"', () => {
widget = new ColorWidget(createMockWidgetConfig(), node)
expect(widget.type).toBe('color')
})
})
})

View File

@@ -1,26 +1,12 @@
import { t } from '@/i18n'
import type { IColorWidget } from '../types/widgets'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
import { BaseWidget } from './BaseWidget'
// Have one color input to prevent leaking instances
// Browsers don't seem to fire any events when the color picker is cancelled
let colorInput: HTMLInputElement | null = null
function getColorInput(): HTMLInputElement {
if (!colorInput) {
colorInput = document.createElement('input')
colorInput.type = 'color'
colorInput.style.position = 'absolute'
colorInput.style.opacity = '0'
colorInput.style.pointerEvents = 'none'
colorInput.style.zIndex = '-999'
document.body.appendChild(colorInput)
}
return colorInput
}
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for displaying a color picker using native HTML color input
* Widget for displaying a color picker
* This is a widget that only has a Vue widgets implementation
*/
export class ColorWidget
extends BaseWidget<IColorWidget>
@@ -29,59 +15,35 @@ export class ColorWidget
override type = 'color' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { fillStyle, strokeStyle, textAlign } = ctx
this.drawWidgetShape(ctx, options)
const { width } = options
const { height, y } = this
const { margin } = BaseWidget
const { y, height } = this
const swatchWidth = 40
const swatchHeight = height - 6
const swatchRadius = swatchHeight / 2
const rightPadding = 10
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
// Swatch fixed on the right
const swatchX = width - margin - rightPadding - swatchWidth
const swatchY = y + 3
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
// Draw color swatch as rounded pill
ctx.beginPath()
ctx.roundRect(swatchX, swatchY, swatchWidth, swatchHeight, swatchRadius)
ctx.fillStyle = this.value || '#000000'
ctx.fill()
ctx.strokeStyle = this.outline_color
ctx.strokeRect(15, y, width - 30, height)
// Draw label on the left
ctx.fillStyle = this.secondary_text_color
ctx.textAlign = 'left'
ctx.fillText(this.displayName, margin * 2 + 5, y + height * 0.7)
// Draw hex value to the left of swatch
ctx.fillStyle = this.text_color
ctx.textAlign = 'right'
ctx.fillText(this.value || '#000000', swatchX - 8, y + height * 0.7)
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
Object.assign(ctx, { textAlign, strokeStyle, fillStyle })
const text = `Color: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
}
onClick({ e, node, canvas }: WidgetEventOptions): void {
const input = getColorInput()
input.value = this.value || '#000000'
input.style.left = `${e.clientX}px`
input.style.top = `${e.clientY}px`
input.addEventListener(
'change',
() => {
this.setValue(input.value, { e, node, canvas })
canvas.setDirty(true)
},
{ once: true }
)
// Wait for next frame else Chrome doesn't render the color picker at the mouse
// Firefox always opens it in top left of window on Windows
requestAnimationFrame(() => input.click())
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
}
}

View File

@@ -107,7 +107,6 @@
"modelUploaded": "تم استيراد النموذج بنجاح.",
"noAssetsFound": "لم يتم العثور على أصول",
"noModelsInFolder": "لا توجد {type} متاحة في هذا المجلد",
"noResultsCanImport": "حاول تعديل البحث أو عوامل التصفية.\nيمكنك أيضًا إضافة النماذج باستخدام زر \"استيراد\" أعلاه.",
"noValidSourceDetected": "لم يتم اكتشاف مصدر استيراد صالح",
"notSureLeaveAsIs": "لست متأكدًا؟ فقط اتركه كما هو",
"onlyCivitaiUrlsSupported": "يتم دعم روابط Civitai فقط",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "الإجراءات",
"allMissingNodesInstalled": "تم تثبيت جميع العقد المفقودة بنجاح",
"applyChanges": "تطبيق التغييرات",
"basicInfo": "معلومات أساسية",
"changingVersion": "تغيير الإصدار من {from} إلى {to}",
"clickToFinishSetup": "انقر",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "الرخصة",
"loadingVersions": "جاري تحميل الإصدارات...",
"mixedSelectionMessage": "لا يمكن تنفيذ إجراء جماعي على تحديد مختلط",
"nav": {
"allExtensions": "جميع الإضافات",
"allInWorkflow": "الكل في: {workflowName}",
"allInstalled": "جميع المثبتة",
"conflicting": "تعارض",
"inWorkflowSection": "في سير العمل",
"installedSection": "المثبتة",
"missingNodes": "عقد مفقودة",
"notInstalled": "غير مثبت",
"updatesAvailable": "تحديثات متوفرة"
},
"nightlyVersion": "ليلي",
"noDescription": "لا يوجد وصف متاح",
"noNodesFound": "لم يتم العثور على عقد",
"noNodesFoundDescription": "لم يمكن تحليل عقد الحزمة، أو أن الحزمة هي امتداد للواجهة فقط ولا تحتوي على أي عقد.",
"noResultsFound": "لم يتم العثور على نتائج مطابقة لبحثك.",
"nodePack": "حزمة العقد",
"nodePackInfo": "معلومات حزمة العقد",
"notAvailable": "غير متوفر",
"packsSelected": "الحزم المحددة",
"repository": "المستودع",
@@ -1352,7 +1337,6 @@
"restartingBackend": "جاري إعادة تشغيل الخلفية لتطبيق التغييرات...",
"searchPlaceholder": "بحث",
"selectVersion": "اختر الإصدار",
"selected": "المحدد",
"sort": {
"created": "الأحدث",
"downloads": "الأكثر شيوعاً",

View File

@@ -756,7 +756,6 @@
"title": "Queue Progress",
"total": "Total: {percent}",
"colonPercent": ": {percent}",
"inlineTotalLabel": "Total",
"currentNode": "Current node:",
"viewAllJobs": "View all jobs",
"viewList": "List view",
@@ -994,7 +993,8 @@
"showAll": "Show all",
"hidden": "Hidden / nested parameters",
"hideAll": "Hide all",
"showRecommended": "Show recommended widgets"
"showRecommended": "Show recommended widgets",
"cannotDeleteGlobal": "Cannot delete installed blueprints"
},
"electronFileDownload": {
"inProgress": "In Progress",
@@ -2545,7 +2545,7 @@
"tagsPlaceholder": "e.g., models, checkpoint",
"tryAdjustingFilters": "Try adjusting your search or filters",
"unknown": "Unknown",
"unsupportedUrlSource": "This URL is not supported. Use a direct model link from {sources}. See the how-to videos below for help.",
"unsupportedUrlSource": "Only URLs from {sources} are supported",
"upgradeFeatureDescription": "This feature is only available with Creator or Pro plans.",
"upgradeToUnlockFeature": "Upgrade to unlock this feature",
"upload": "Import",
@@ -2825,5 +2825,15 @@
"label": "Preview Version",
"tooltip": "You are using a nightly version of ComfyUI. Please use the feedback button to share your thoughts about these features."
}
},
"nodeFilters": {
"hideDeprecated": "Hide Deprecated Nodes",
"hideDeprecatedDescription": "Hides nodes marked as deprecated unless explicitly enabled",
"hideExperimental": "Hide Experimental Nodes",
"hideExperimentalDescription": "Hides nodes marked as experimental unless explicitly enabled",
"hideDevOnly": "Hide Dev-Only Nodes",
"hideDevOnlyDescription": "Hides nodes marked as dev-only unless dev mode is enabled",
"hideSubgraph": "Hide Subgraph Nodes",
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search"
}
}

View File

@@ -107,7 +107,6 @@
"modelUploaded": "Modelo importado correctamente.",
"noAssetsFound": "No se encontraron recursos",
"noModelsInFolder": "No hay {type} disponibles en esta carpeta",
"noResultsCanImport": "Intenta ajustar tu búsqueda o filtros.\nTambién puedes añadir modelos usando el botón \"Importar\" de arriba.",
"noValidSourceDetected": "No se detectó una fuente de importación válida",
"notSureLeaveAsIs": "¿No estás seguro? Déjalo como está",
"onlyCivitaiUrlsSupported": "Solo se admiten URLs de Civitai",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "Acciones",
"allMissingNodesInstalled": "Todos los nodos faltantes se han instalado exitosamente",
"applyChanges": "Aplicar Cambios",
"basicInfo": "Información básica",
"changingVersion": "Cambiando versión de {from} a {to}",
"clickToFinishSetup": "Haz clic",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "Licencia",
"loadingVersions": "Cargando versiones...",
"mixedSelectionMessage": "No se puede realizar acción masiva en selección mixta",
"nav": {
"allExtensions": "Todas las extensiones",
"allInWorkflow": "Todo en: {workflowName}",
"allInstalled": "Todo instalado",
"conflicting": "En conflicto",
"inWorkflowSection": "EN EL FLUJO DE TRABAJO",
"installedSection": "INSTALADO",
"missingNodes": "Nodos faltantes",
"notInstalled": "No instalado",
"updatesAvailable": "Actualizaciones disponibles"
},
"nightlyVersion": "Nocturna",
"noDescription": "No hay descripción disponible",
"noNodesFound": "No se encontraron nodos",
"noNodesFoundDescription": "Los nodos del paquete no se pudieron analizar, o el paquete es solo una extensión de frontend y no tiene ningún nodo.",
"noResultsFound": "No se encontraron resultados que coincidan con tu búsqueda.",
"nodePack": "Paquete de Nodos",
"nodePackInfo": "Información del paquete de nodos",
"notAvailable": "No Disponible",
"packsSelected": "Paquetes Seleccionados",
"repository": "Repositorio",
@@ -1352,7 +1337,6 @@
"restartingBackend": "Reiniciando backend para aplicar cambios...",
"searchPlaceholder": "Buscar",
"selectVersion": "Seleccionar Versión",
"selected": "Seleccionado",
"sort": {
"created": "Más reciente",
"downloads": "Más Popular",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "مدل با موفقیت وارد شد.",
"noAssetsFound": "هیچ دارایی‌ای یافت نشد",
"noModelsInFolder": "هیچ {type} در این پوشه موجود نیست",
"noResultsCanImport": "جستجو یا فیلترهای خود را تغییر دهید.\nهمچنین می‌توانید مدل‌ها را با استفاده از دکمه «وارد کردن» در بالا اضافه کنید.",
"noValidSourceDetected": "هیچ منبع واردات معتبری شناسایی نشد",
"notSureLeaveAsIs": "مطمئن نیستید؟ همین را باقی بگذارید",
"onlyCivitaiUrlsSupported": "فقط URLهای Civitai پشتیبانی می‌شوند",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "اقدامات",
"allMissingNodesInstalled": "همه نودهای مفقود با موفقیت نصب شدند",
"applyChanges": "اعمال تغییرات",
"basicInfo": "اطلاعات پایه",
"changingVersion": "تغییر نسخه از {from} به {to}",
"clickToFinishSetup": "کلیک کنید",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "مجوز",
"loadingVersions": "در حال بارگذاری نسخه‌ها...",
"mixedSelectionMessage": "امکان انجام عملیات گروهی روی انتخاب ترکیبی وجود ندارد",
"nav": {
"allExtensions": "همه افزونه‌ها",
"allInWorkflow": "همه در: {workflowName}",
"allInstalled": "همه نصب شده‌ها",
"conflicting": "دارای تداخل",
"inWorkflowSection": "در Workflow",
"installedSection": "نصب شده",
"missingNodes": "Nodeهای مفقود",
"notInstalled": "نصب نشده",
"updatesAvailable": "به‌روزرسانی‌های موجود"
},
"nightlyVersion": "نسخه nightly",
"noDescription": "توضیحی موجود نیست",
"noNodesFound": "نودی یافت نشد",
"noNodesFoundDescription": "نودهای این بسته قابل تجزیه نبودند یا این بسته فقط یک افزونه فرانت‌اند است و نودی ندارد.",
"noResultsFound": "نتیجه‌ای مطابق با جستجوی شما یافت نشد.",
"nodePack": "بسته نود",
"nodePackInfo": "اطلاعات Node Pack",
"notAvailable": "در دسترس نیست",
"packsSelected": "بسته انتخاب شد",
"repository": "مخزن",
@@ -1352,7 +1337,6 @@
"restartingBackend": "در حال راه‌اندازی مجدد backend برای اعمال تغییرات...",
"searchPlaceholder": "جستجو",
"selectVersion": "انتخاب نسخه",
"selected": "انتخاب شده",
"sort": {
"created": "جدیدترین",
"downloads": "محبوب‌ترین",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "Modèle importé avec succès.",
"noAssetsFound": "Aucune ressource trouvée",
"noModelsInFolder": "Aucun {type} disponible dans ce dossier",
"noResultsCanImport": "Essayez dajuster votre recherche ou vos filtres.\nVous pouvez également ajouter des modèles en utilisant le bouton « Importer » ci-dessus.",
"noValidSourceDetected": "Aucune source d'importation valide détectée",
"notSureLeaveAsIs": "Vous n'êtes pas sûr ? Laissez tel quel",
"onlyCivitaiUrlsSupported": "Seules les URL Civitai sont prises en charge",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "Actions",
"allMissingNodesInstalled": "Tous les nœuds manquants ont été installés avec succès",
"applyChanges": "Appliquer les modifications",
"basicInfo": "Informations de base",
"changingVersion": "Changement de version de {from} à {to}",
"clickToFinishSetup": "Cliquez",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "Licence",
"loadingVersions": "Chargement des versions...",
"mixedSelectionMessage": "Impossible d'effectuer une action groupée sur une sélection mixte",
"nav": {
"allExtensions": "Toutes les extensions",
"allInWorkflow": "Tout dans : {workflowName}",
"allInstalled": "Tout installé",
"conflicting": "En conflit",
"inWorkflowSection": "DANS LE FLUX DE TRAVAIL",
"installedSection": "INSTALLÉ",
"missingNodes": "Nœuds manquants",
"notInstalled": "Non installé",
"updatesAvailable": "Mises à jour disponibles"
},
"nightlyVersion": "Nocturne",
"noDescription": "Aucune description disponible",
"noNodesFound": "Aucun nœud trouvé",
"noNodesFoundDescription": "Les nœuds du pack n'ont pas pu être analysés, ou le pack est une extension frontend uniquement et n'a pas de nœuds.",
"noResultsFound": "Aucun résultat trouvé correspondant à votre recherche.",
"nodePack": "Pack de Nœuds",
"nodePackInfo": "Informations sur le pack de nœuds",
"notAvailable": "Non disponible",
"packsSelected": "Packs sélectionnés",
"repository": "Référentiel",
@@ -1352,7 +1337,6 @@
"restartingBackend": "Redémarrage du backend pour appliquer les modifications...",
"searchPlaceholder": "Recherche",
"selectVersion": "Sélectionner la version",
"selected": "Sélectionné",
"sort": {
"created": "Le plus récent",
"downloads": "Le plus populaire",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "モデルが正常にインポートされました。",
"noAssetsFound": "アセットが見つかりません",
"noModelsInFolder": "このフォルダには{type}がありません",
"noResultsCanImport": "検索やフィルターを調整してみてください。\nまた、上の「インポート」ボタンからモデルを追加することもできます。",
"noValidSourceDetected": "有効なインポート元が検出されませんでした",
"notSureLeaveAsIs": "分からない場合はそのままにしてください",
"onlyCivitaiUrlsSupported": "CivitaiのURLのみサポートされています",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "アクション",
"allMissingNodesInstalled": "すべての不足しているノードが正常にインストールされました",
"applyChanges": "変更を適用",
"basicInfo": "基本情報",
"changingVersion": "バージョンを {from} から {to} に変更",
"clickToFinishSetup": "クリック",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "ライセンス",
"loadingVersions": "バージョンを読み込んでいます...",
"mixedSelectionMessage": "混在した選択では一括操作を実行できません",
"nav": {
"allExtensions": "すべての拡張機能",
"allInWorkflow": "{workflowName} 内のすべて",
"allInstalled": "すべてインストール済み",
"conflicting": "競合",
"inWorkflowSection": "ワークフロー内",
"installedSection": "インストール済み",
"missingNodes": "不足しているノード",
"notInstalled": "未インストール",
"updatesAvailable": "アップデートあり"
},
"nightlyVersion": "ナイトリー",
"noDescription": "説明はありません",
"noNodesFound": "ノードが見つかりません",
"noNodesFoundDescription": "パックのノードは解析できなかったか、パックがフロントエンドの拡張機能のみでノードがない可能性があります。",
"noResultsFound": "検索に一致する結果が見つかりませんでした。",
"nodePack": "ノードパック",
"nodePackInfo": "ノードパック情報",
"notAvailable": "利用不可",
"packsSelected": "選択したパック",
"repository": "リポジトリ",
@@ -1352,7 +1337,6 @@
"restartingBackend": "変更を適用するためにバックエンドを再起動しています...",
"searchPlaceholder": "検索",
"selectVersion": "バージョンを選択",
"selected": "選択済み",
"sort": {
"created": "最新",
"downloads": "最も人気",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "모델이 성공적으로 가져와졌습니다.",
"noAssetsFound": "에셋을 찾을 수 없습니다",
"noModelsInFolder": "이 폴더에 사용 가능한 {type}이(가) 없습니다",
"noResultsCanImport": "검색어나 필터를 조정해보세요.\n또는 위의 \"가져오기\" 버튼을 사용해 모델을 추가할 수 있습니다.",
"noValidSourceDetected": "유효한 가져오기 소스를 감지하지 못했습니다",
"notSureLeaveAsIs": "잘 모르겠다면 그대로 두세요",
"onlyCivitaiUrlsSupported": "Civitai URL만 지원됩니다",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "작업",
"allMissingNodesInstalled": "누락된 모든 노드가 성공적으로 설치되었습니다",
"applyChanges": "변경사항 적용",
"basicInfo": "기본 정보",
"changingVersion": "{from}에서 {to}(으)로 버전 변경 중",
"clickToFinishSetup": "클릭",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "라이선스",
"loadingVersions": "버전 로딩 중...",
"mixedSelectionMessage": "혼합 선택에 대해 일괄 작업을 수행할 수 없습니다",
"nav": {
"allExtensions": "모든 확장 프로그램",
"allInWorkflow": "모두: {workflowName}",
"allInstalled": "모두 설치됨",
"conflicting": "충돌",
"inWorkflowSection": "워크플로우 내",
"installedSection": "설치됨",
"missingNodes": "누락된 노드",
"notInstalled": "미설치",
"updatesAvailable": "업데이트 가능"
},
"nightlyVersion": "최신 테스트 버전(nightly)",
"noDescription": "설명이 없습니다",
"noNodesFound": "노드를 찾을 수 없습니다",
"noNodesFoundDescription": "팩의 노드를 파싱할 수 없거나, 팩이 프론트엔드 확장만을 가지고 있어서 노드가 없습니다.",
"noResultsFound": "검색과 일치하는 결과가 없습니다.",
"nodePack": "노드 팩",
"nodePackInfo": "노드 팩 정보",
"notAvailable": "사용 불가",
"packsSelected": "선택한 노드 팩",
"repository": "저장소",
@@ -1352,7 +1337,6 @@
"restartingBackend": "변경사항을 적용하기 위해 백엔드를 다시 시작하는 중...",
"searchPlaceholder": "검색",
"selectVersion": "버전 선택",
"selected": "선택됨",
"sort": {
"created": "최신",
"downloads": "가장 인기 있는",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "Modelo importado com sucesso.",
"noAssetsFound": "Nenhum ativo encontrado",
"noModelsInFolder": "Nenhum {type} disponível nesta pasta",
"noResultsCanImport": "Tente ajustar sua busca ou filtros.\nVocê também pode adicionar modelos usando o botão \"Importar\" acima.",
"noValidSourceDetected": "Nenhuma fonte de importação válida detectada",
"notSureLeaveAsIs": "Não tem certeza? Deixe como está",
"onlyCivitaiUrlsSupported": "Apenas URLs do Civitai são suportadas",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "Ações",
"allMissingNodesInstalled": "Todos os nodes ausentes foram instalados com sucesso",
"applyChanges": "Aplicar Alterações",
"basicInfo": "Informações Básicas",
"changingVersion": "Alterando versão de {from} para pt-BR",
"clickToFinishSetup": "Clique",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "Licença",
"loadingVersions": "Carregando versões...",
"mixedSelectionMessage": "Não é possível realizar ação em massa em seleção mista",
"nav": {
"allExtensions": "Todas as Extensões",
"allInWorkflow": "Todos em: {workflowName}",
"allInstalled": "Todos Instalados",
"conflicting": "Conflitante",
"inWorkflowSection": "NO FLUXO DE TRABALHO",
"installedSection": "INSTALADO",
"missingNodes": "Nós Ausentes",
"notInstalled": "Não Instalado",
"updatesAvailable": "Atualizações Disponíveis"
},
"nightlyVersion": "Noturna",
"noDescription": "Nenhuma descrição disponível",
"noNodesFound": "Nenhum node encontrado",
"noNodesFoundDescription": "Os nodes do pacote não puderam ser analisados ou o pacote é apenas uma extensão de frontend e não possui nodes.",
"noResultsFound": "Nenhum resultado encontrado para sua busca.",
"nodePack": "Node Pack",
"nodePackInfo": "Informações do Pacote de Nós",
"notAvailable": "Não Disponível",
"packsSelected": "pacotes selecionados",
"repository": "Repositório",
@@ -1352,7 +1337,6 @@
"restartingBackend": "Reiniciando backend para aplicar as alterações...",
"searchPlaceholder": "Buscar",
"selectVersion": "Selecionar Versão",
"selected": "Selecionado",
"sort": {
"created": "Mais Novos",
"downloads": "Mais Populares",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "Модель успешно импортирована.",
"noAssetsFound": "Ресурсы не найдены",
"noModelsInFolder": "Нет {type} в этой папке",
"noResultsCanImport": "Попробуйте изменить параметры поиска или фильтры.\nВы также можете добавить модели с помощью кнопки «Импортировать» выше.",
"noValidSourceDetected": "Не обнаружен действительный источник импорта",
"notSureLeaveAsIs": "Не уверены? Просто оставьте как есть",
"onlyCivitaiUrlsSupported": "Поддерживаются только ссылки Civitai",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "Действия",
"allMissingNodesInstalled": "Все отсутствующие ноды успешно установлены",
"applyChanges": "Применить изменения",
"basicInfo": "Основная информация",
"changingVersion": "Изменение версии с {from} на {to}",
"clickToFinishSetup": "Нажмите",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "Лицензия",
"loadingVersions": "Загрузка версий...",
"mixedSelectionMessage": "Невозможно выполнить массовое действие для смешанного выбора",
"nav": {
"allExtensions": "Все расширения",
"allInWorkflow": "Все в: {workflowName}",
"allInstalled": "Все установленные",
"conflicting": "Конфликтующие",
"inWorkflowSection": "В РАБОЧЕМ ПРОЦЕССЕ",
"installedSection": "УСТАНОВЛЕНО",
"missingNodes": "Отсутствующие узлы",
"notInstalled": "Не установлено",
"updatesAvailable": "Доступны обновления"
},
"nightlyVersion": "Ночная",
"noDescription": "Описание отсутствует",
"noNodesFound": "Узлы не найдены",
"noNodesFoundDescription": "Узлы пакета не могут быть проанализированы, или пакет является только расширением интерфейса и не имеет узлов.",
"noResultsFound": "По вашему запросу ничего не найдено.",
"nodePack": "Пакет Узлов",
"nodePackInfo": "Информация о пакете узлов",
"notAvailable": "Недоступно",
"packsSelected": "Выбрано пакетов",
"repository": "Репозиторий",
@@ -1352,7 +1337,6 @@
"restartingBackend": "Перезапуск бэкенда для применения изменений...",
"searchPlaceholder": "Поиск",
"selectVersion": "Выберите версию",
"selected": "Выбрано",
"sort": {
"created": "Новейшие",
"downloads": "Самые популярные",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "Model başarıyla içe aktarıldı.",
"noAssetsFound": "Varlık bulunamadı",
"noModelsInFolder": "Bu klasörde {type} mevcut değil",
"noResultsCanImport": "Aramanızı veya filtrelerinizi ayarlamayı deneyin.\nAyrıca yukarıdaki \"İçe Aktar\" butonunu kullanarak modeller ekleyebilirsiniz.",
"noValidSourceDetected": "Geçerli bir içe aktarma kaynağı tespit edilmedi",
"notSureLeaveAsIs": "Emin değil misiniz? Olduğu gibi bırakın",
"onlyCivitaiUrlsSupported": "Yalnızca Civitai URL'leri destekleniyor",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "Eylemler",
"allMissingNodesInstalled": "Tüm eksik düğümler başarıyla yüklendi",
"applyChanges": "Değişiklikleri Uygula",
"basicInfo": "Temel Bilgiler",
"changingVersion": "Sürüm {from} sürümünden {to} sürümüne değiştiriliyor",
"clickToFinishSetup": "Kurulumu tamamlamak için tıklayın",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "Lisans",
"loadingVersions": "Sürümler yükleniyor...",
"mixedSelectionMessage": "Karışık seçim üzerinde toplu işlem yapılamaz",
"nav": {
"allExtensions": "Tüm Eklentiler",
"allInWorkflow": "Tümü: {workflowName}",
"allInstalled": "Tümü yüklü",
"conflicting": "Çakışan",
"inWorkflowSection": "İŞ AKIŞINDA",
"installedSection": "YÜKLÜ",
"missingNodes": "Eksik Düğümler",
"notInstalled": "Yüklü Değil",
"updatesAvailable": "Güncellemeler Mevcut"
},
"nightlyVersion": "Gecelik",
"noDescription": "Açıklama yok",
"noNodesFound": "Düğüm bulunamadı",
"noNodesFoundDescription": "Paketin düğümleri ya ayrıştırılamadı ya da paket yalnızca bir ön uç uzantısı ve herhangi bir düğüme sahip değil.",
"noResultsFound": "Aramanızla eşleşen sonuç bulunamadı.",
"nodePack": "Düğüm Paketi",
"nodePackInfo": "Düğüm Paketi Bilgisi",
"notAvailable": "Mevcut Değil",
"packsSelected": "paket seçildi",
"repository": "Depo",
@@ -1352,7 +1337,6 @@
"restartingBackend": "Değişiklikleri uygulamak için arka uç yeniden başlatılıyor...",
"searchPlaceholder": "Ara",
"selectVersion": "Sürüm Seç",
"selected": "Seçildi",
"sort": {
"created": "En Yeni",
"downloads": "En Popüler",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "模型匯入成功。",
"noAssetsFound": "找不到資產",
"noModelsInFolder": "此資料夾中沒有可用的 {type}",
"noResultsCanImport": "請嘗試調整搜尋或篩選條件。\n你也可以使用上方的「匯入」按鈕新增模型。",
"noValidSourceDetected": "未偵測到有效的匯入來源",
"notSureLeaveAsIs": "不確定?請保持原樣",
"onlyCivitaiUrlsSupported": "僅支援 Civitai 的網址",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "操作",
"allMissingNodesInstalled": "所有缺少的節點已成功安裝",
"applyChanges": "套用變更",
"basicInfo": "基本資訊",
"changingVersion": "正在將版本從 {from} 變更為 {to}",
"clickToFinishSetup": "點擊",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "授權條款",
"loadingVersions": "正在載入版本...",
"mixedSelectionMessage": "無法對混合選取執行批次操作",
"nav": {
"allExtensions": "所有擴充功能",
"allInWorkflow": "全部於:{workflowName}",
"allInstalled": "全部已安裝",
"conflicting": "有衝突",
"inWorkflowSection": "工作流程中",
"installedSection": "已安裝",
"missingNodes": "缺少節點",
"notInstalled": "未安裝",
"updatesAvailable": "有可用更新"
},
"nightlyVersion": "每夜建置版",
"noDescription": "沒有可用的說明",
"noNodesFound": "找不到任何節點",
"noNodesFoundDescription": "此套件的節點無法解析,或此套件僅為前端擴充功能,沒有任何節點。",
"noResultsFound": "找不到符合搜尋條件的結果。",
"nodePack": "節點包",
"nodePackInfo": "節點包資訊",
"notAvailable": "不可用",
"packsSelected": "已選擇套件",
"repository": "儲存庫",
@@ -1352,7 +1337,6 @@
"restartingBackend": "正在重新啟動後端以套用變更...",
"searchPlaceholder": "搜尋",
"selectVersion": "選擇版本",
"selected": "已選取",
"sort": {
"created": "最新上架",
"downloads": "最受歡迎",

View File

@@ -107,7 +107,6 @@
"modelUploaded": "模型导入成功!🎉",
"noAssetsFound": "未找到资产",
"noModelsInFolder": "此文件夹中没有可用的{type}",
"noResultsCanImport": "尝试调整您的搜索或筛选条件。\n您也可以使用上方的“导入”按钮添加模型。",
"noValidSourceDetected": "检测不到有效的导入源",
"notSureLeaveAsIs": "不确定?那就放着不管吧",
"onlyCivitaiUrlsSupported": "仅支持 Civitai 链接",
@@ -1261,10 +1260,8 @@
}
},
"manager": {
"actions": "操作",
"allMissingNodesInstalled": "所有缺失节点已成功安装",
"applyChanges": "应用更改",
"basicInfo": "基本信息",
"changingVersion": "将版本从 {from} 更改为 {to}",
"clickToFinishSetup": "点击",
"conflicts": {
@@ -1327,24 +1324,12 @@
"license": "许可证",
"loadingVersions": "正在加载版本...",
"mixedSelectionMessage": "无法对混合选择执行批量操作",
"nav": {
"allExtensions": "全部扩展",
"allInWorkflow": "全部在:{workflowName}",
"allInstalled": "全部已安装",
"conflicting": "存在冲突",
"inWorkflowSection": "工作流中",
"installedSection": "已安装",
"missingNodes": "缺失节点",
"notInstalled": "未安装",
"updatesAvailable": "有可用更新"
},
"nightlyVersion": "每夜",
"noDescription": "无可用描述",
"noNodesFound": "未找到节点",
"noNodesFoundDescription": "无法解析包的节点,或者该包仅为前端扩展,没有任何节点。",
"noResultsFound": "未找到符合您搜索的结果。",
"nodePack": "节点包",
"nodePackInfo": "节点包信息",
"notAvailable": "不可用",
"packsSelected": "选定的包",
"repository": "仓库",
@@ -1352,7 +1337,6 @@
"restartingBackend": "正在重启后端以应用更改...",
"searchPlaceholder": "搜索",
"selectVersion": "选择版本",
"selected": "已选择",
"sort": {
"created": "最新",
"downloads": "最受欢迎",

View File

@@ -10,7 +10,7 @@ const createAssetData = (
overrides: Partial<AssetDisplayItem> = {}
): AssetDisplayItem => ({
...baseAsset,
secondaryText:
description:
'High-quality realistic images with perfect detail and natural lighting effects for professional photography',
badges: [
{ label: 'checkpoints', type: 'type' },
@@ -131,21 +131,20 @@ export const EdgeCases: Story = {
// Default case for comparison
createAssetData({
name: 'Complete Data',
secondaryText: 'Asset with all data present for comparison'
description: 'Asset with all data present for comparison'
}),
// No badges
createAssetData({
id: 'no-badges',
name: 'No Badges',
secondaryText:
'Testing graceful handling when badges are not provided',
description: 'Testing graceful handling when badges are not provided',
badges: []
}),
// No stars
createAssetData({
id: 'no-stars',
name: 'No Stars',
secondaryText: 'Testing missing stars data gracefully',
description: 'Testing missing stars data gracefully',
stats: {
downloadCount: '1.8k',
formattedDate: '3/15/25'
@@ -155,7 +154,7 @@ export const EdgeCases: Story = {
createAssetData({
id: 'no-downloads',
name: 'No Downloads',
secondaryText: 'Testing missing downloads data gracefully',
description: 'Testing missing downloads data gracefully',
stats: {
stars: '4.2k',
formattedDate: '3/15/25'
@@ -165,7 +164,7 @@ export const EdgeCases: Story = {
createAssetData({
id: 'no-date',
name: 'No Date',
secondaryText: 'Testing missing date data gracefully',
description: 'Testing missing date data gracefully',
stats: {
stars: '4.2k',
downloadCount: '1.8k'
@@ -175,21 +174,21 @@ export const EdgeCases: Story = {
createAssetData({
id: 'no-stats',
name: 'No Stats',
secondaryText: 'Testing when all stats are missing',
description: 'Testing when all stats are missing',
stats: {}
}),
// Long secondaryText
// Long description
createAssetData({
id: 'long-desc',
name: 'Long Description',
secondaryText:
description:
'This is a very long description that should demonstrate how the component handles text overflow and truncation with ellipsis. The description continues with even more content to ensure we test the 2-line clamp behavior properly and see how it renders when there is significantly more text than can fit in the allocated space.'
}),
// Minimal data
createAssetData({
id: 'minimal',
name: 'Minimal',
secondaryText: 'Basic model',
description: 'Basic model',
tags: ['models'],
badges: [],
stats: {}

View File

@@ -82,14 +82,14 @@
</h3>
<p
:id="descId"
v-tooltip.top="{ value: asset.secondaryText, showDelay: tooltipDelay }"
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
:class="
cn(
'm-0 text-sm line-clamp-2 [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
)
"
>
{{ asset.secondaryText }}
{{ asset.description }}
</p>
<div class="flex items-center justify-between gap-2 mt-auto">
<div class="flex gap-3 text-xs text-muted-foreground">

View File

@@ -34,7 +34,7 @@ describe('ModelInfoPanel', () => {
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
secondaryText: 'A test model description',
description: 'A test model description',
badges: [],
stats: {},
...overrides

View File

@@ -84,14 +84,14 @@ describe('useAssetBrowser', () => {
expect(result.name).toBe(apiAsset.name)
// Adds display properties
expect(result.secondaryText).toBe('test-asset.safetensors')
expect(result.description).toBe('Test model')
expect(result.badges).toContainEqual({
label: 'checkpoints',
type: 'type'
})
})
it('creates secondaryText from filename when metadata missing', () => {
it('creates fallback description from tags when metadata missing', () => {
const apiAsset = createApiAsset({
tags: ['models', 'loras'],
user_metadata: undefined
@@ -100,7 +100,7 @@ describe('useAssetBrowser', () => {
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
const result = filteredAssets.value[0]
expect(result.secondaryText).toBe('test-asset.safetensors')
expect(result.description).toBe('loras model')
})
it('removes category prefix from badge labels', () => {

View File

@@ -9,8 +9,8 @@ import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vu
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModels,
getAssetDisplayName,
getAssetFilename
getAssetDescription,
getAssetDisplayName
} from '@/platform/assets/utils/assetMetadataUtils'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
@@ -70,7 +70,7 @@ type AssetBadge = {
// Display properties for transformed assets
export interface AssetDisplayItem extends AssetItem {
secondaryText: string
description: string
badges: AssetBadge[]
stats: {
formattedDate?: string
@@ -116,11 +116,15 @@ export function useAssetBrowser(
// Transform API asset to display asset
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
const secondaryText = getAssetFilename(asset)
// Extract description from metadata or create from tags
const typeTag = asset.tags.find((tag) => tag !== 'models')
const description =
getAssetDescription(asset) ||
`${typeTag || t('assetBrowser.unknown')} model`
// Create badges from tags and metadata
const badges: AssetBadge[] = []
const typeTag = asset.tags.find((tag) => tag !== 'models')
// Type badge from non-root tag
if (typeTag) {
// Remove category prefix from badge label (e.g. "checkpoint/model" → "model")
@@ -148,7 +152,7 @@ export function useAssetBrowser(
return {
...asset,
secondaryText,
description,
badges,
stats
}

View File

@@ -4,8 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useMediaAssetActions } from './useMediaAssetActions'
// Use vi.hoisted to create a mutable reference for isCloud
const mockIsCloud = vi.hoisted(() => ({ value: false }))
@@ -128,6 +126,7 @@ describe('useMediaAssetActions', () => {
})
it('should use asset.name as filename', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()
const asset = createMockAsset({
@@ -147,6 +146,7 @@ describe('useMediaAssetActions', () => {
})
it('should use asset_hash as filename when available', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()
const asset = createMockAsset({
@@ -160,6 +160,7 @@ describe('useMediaAssetActions', () => {
})
it('should fall back to asset.name when asset_hash is not available', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()
const asset = createMockAsset({
@@ -173,6 +174,7 @@ describe('useMediaAssetActions', () => {
})
it('should fall back to asset.name when asset_hash is null', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()
const asset = createMockAsset({
@@ -194,6 +196,7 @@ describe('useMediaAssetActions', () => {
})
it('should use asset_hash for each asset', async () => {
const { useMediaAssetActions } = await import('./useMediaAssetActions')
const actions = useMediaAssetActions()
const assets = [

View File

@@ -1178,7 +1178,7 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
tooltip:
'Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout.',
defaultValue: true,
defaultValue: false,
experimental: true
},
{

View File

@@ -1,27 +1,27 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useFeatureUsageTracker } from './useFeatureUsageTracker'
const STORAGE_KEY = 'Comfy.FeatureUsage'
describe('useFeatureUsageTracker', () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
vi.resetModules()
})
afterEach(() => {
localStorage.clear()
})
it('initializes with zero count for new feature', () => {
const { useCount } = useFeatureUsageTracker('test-feature-1')
it('initializes with zero count for new feature', async () => {
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const { useCount } = useFeatureUsageTracker('test-feature')
expect(useCount.value).toBe(0)
})
it('increments count on trackUsage', () => {
const { useCount, trackUsage } = useFeatureUsageTracker('test-feature-2')
it('increments count on trackUsage', async () => {
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const { useCount, trackUsage } = useFeatureUsageTracker('test-feature')
expect(useCount.value).toBe(0)
@@ -32,12 +32,14 @@ describe('useFeatureUsageTracker', () => {
expect(useCount.value).toBe(2)
})
it('sets firstUsed only on first use', () => {
it('sets firstUsed only on first use', async () => {
vi.useFakeTimers()
const firstTs = 1000000
vi.setSystemTime(firstTs)
try {
const { usage, trackUsage } = useFeatureUsageTracker('test-feature-3')
const { useFeatureUsageTracker } =
await import('./useFeatureUsageTracker')
const { usage, trackUsage } = useFeatureUsageTracker('test-feature')
trackUsage()
expect(usage.value?.firstUsed).toBe(firstTs)
@@ -50,10 +52,12 @@ describe('useFeatureUsageTracker', () => {
}
})
it('updates lastUsed on each use', () => {
it('updates lastUsed on each use', async () => {
vi.useFakeTimers()
try {
const { usage, trackUsage } = useFeatureUsageTracker('test-feature-4')
const { useFeatureUsageTracker } =
await import('./useFeatureUsageTracker')
const { usage, trackUsage } = useFeatureUsageTracker('test-feature')
trackUsage()
const firstLastUsed = usage.value?.lastUsed ?? 0
@@ -67,9 +71,10 @@ describe('useFeatureUsageTracker', () => {
}
})
it('reset clears feature data', () => {
it('reset clears feature data', async () => {
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const { useCount, trackUsage, reset } =
useFeatureUsageTracker('test-feature-5')
useFeatureUsageTracker('test-feature')
trackUsage()
trackUsage()
@@ -79,7 +84,8 @@ describe('useFeatureUsageTracker', () => {
expect(useCount.value).toBe(0)
})
it('tracks multiple features independently', () => {
it('tracks multiple features independently', async () => {
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const featureA = useFeatureUsageTracker('feature-a')
const featureB = useFeatureUsageTracker('feature-b')
@@ -94,6 +100,8 @@ describe('useFeatureUsageTracker', () => {
it('persists to localStorage', async () => {
vi.useFakeTimers()
try {
const { useFeatureUsageTracker } =
await import('./useFeatureUsageTracker')
const { trackUsage } = useFeatureUsageTracker('persisted-feature')
trackUsage()
@@ -106,7 +114,7 @@ describe('useFeatureUsageTracker', () => {
}
})
it('loads existing data from localStorage', () => {
it('loads existing data from localStorage', async () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
@@ -114,6 +122,8 @@ describe('useFeatureUsageTracker', () => {
})
)
vi.resetModules()
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const { useCount } = useFeatureUsageTracker('existing-feature')
expect(useCount.value).toBe(5)

View File

@@ -1,10 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
startTopupTracking,
checkForCompletedTopup,
clearTopupTracking
} from '@/platform/telemetry/topupTracker'
import type * as TopupTrackerModule from '@/platform/telemetry/topupTracker'
import type { AuditLog } from '@/services/customerEventsService'
// Mock localStorage
@@ -29,15 +25,19 @@ vi.mock('@/platform/telemetry', () => ({
}))
describe('topupTracker', () => {
beforeEach(() => {
let topupTracker: typeof TopupTrackerModule
beforeEach(async () => {
vi.clearAllMocks()
// Dynamically import to ensure fresh module state
topupTracker = await import('@/platform/telemetry/topupTracker')
})
describe('startTopupTracking', () => {
it('should save current timestamp to localStorage', () => {
const beforeTimestamp = Date.now()
startTopupTracking()
topupTracker.startTopupTracking()
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'pending_topup_timestamp',
@@ -57,7 +57,7 @@ describe('topupTracker', () => {
it('should return false if no pending topup exists', () => {
mockLocalStorage.getItem.mockReturnValue(null)
const result = checkForCompletedTopup([])
const result = topupTracker.checkForCompletedTopup([])
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -66,7 +66,7 @@ describe('topupTracker', () => {
it('should return false if events array is empty', () => {
mockLocalStorage.getItem.mockReturnValue(Date.now().toString())
const result = checkForCompletedTopup([])
const result = topupTracker.checkForCompletedTopup([])
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -75,7 +75,7 @@ describe('topupTracker', () => {
it('should return false if events array is null', () => {
mockLocalStorage.getItem.mockReturnValue(Date.now().toString())
const result = checkForCompletedTopup(null)
const result = topupTracker.checkForCompletedTopup(null)
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -94,7 +94,7 @@ describe('topupTracker', () => {
}
]
const result = checkForCompletedTopup(events)
const result = topupTracker.checkForCompletedTopup(events)
expect(result).toBe(false)
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
@@ -122,7 +122,7 @@ describe('topupTracker', () => {
}
]
const result = checkForCompletedTopup(events)
const result = topupTracker.checkForCompletedTopup(events)
expect(result).toBe(true)
expect(mockTelemetry.trackApiCreditTopupSucceeded).toHaveBeenCalledOnce()
@@ -144,7 +144,7 @@ describe('topupTracker', () => {
}
]
const result = checkForCompletedTopup(events)
const result = topupTracker.checkForCompletedTopup(events)
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -164,7 +164,7 @@ describe('topupTracker', () => {
}
]
const result = checkForCompletedTopup(events)
const result = topupTracker.checkForCompletedTopup(events)
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -189,7 +189,7 @@ describe('topupTracker', () => {
}
]
const result = checkForCompletedTopup(events)
const result = topupTracker.checkForCompletedTopup(events)
expect(result).toBe(false)
expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled()
@@ -198,7 +198,7 @@ describe('topupTracker', () => {
describe('clearTopupTracking', () => {
it('should remove pending topup from localStorage', () => {
clearTopupTracking()
topupTracker.clearTopupTracking()
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'pending_topup_timestamp'

View File

@@ -1,7 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTelemetry } from '@/platform/telemetry'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
@@ -11,14 +9,17 @@ describe('useTelemetry', () => {
vi.clearAllMocks()
})
it('should return null when not in cloud distribution', () => {
it('should return null when not in cloud distribution', async () => {
const { useTelemetry } = await import('@/platform/telemetry')
const provider = useTelemetry()
// Should return null for OSS builds
expect(provider).toBeNull()
})
}, 10000)
it('should return null consistently for OSS builds', async () => {
const { useTelemetry } = await import('@/platform/telemetry')
it('should return null consistently for OSS builds', () => {
const provider1 = useTelemetry()
const provider2 = useTelemetry()

View File

@@ -1,96 +1,19 @@
import { until } from '@vueuse/core'
import { setActivePinia } from 'pinia'
import { compare } from 'semver'
import { createPinia, setActivePinia } from 'pinia'
import { compare, valid } from 'semver'
import type { Mock } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ReleaseNote } from '@/platform/updates/common/releaseService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { useReleaseService } from '@/platform/updates/common/releaseService'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { isElectron } from '@/utils/envUtil'
import { createTestingPinia } from '@pinia/testing'
import type { SystemStats } from '@/types'
// Mock the dependencies
vi.mock('semver', () => ({
compare: vi.fn(),
valid: vi.fn(() => '1.0.0')
}))
vi.mock('@/utils/envUtil', () => ({
isElectron: vi.fn(() => true)
}))
vi.mock('semver')
vi.mock('@/utils/envUtil')
vi.mock('@/platform/distribution/types', () => ({ isCloud: false }))
vi.mock('@/platform/updates/common/releaseService', () => {
const getReleases = vi.fn()
const isLoading = ref(false)
const error = ref<string | null>(null)
return {
useReleaseService: () => ({
getReleases,
isLoading,
error
})
}
})
vi.mock('@/platform/settings/settingStore', () => {
const get = vi.fn((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
const set = vi.fn()
return {
useSettingStore: () => ({ get, set })
}
})
const mockSystemStatsState = vi.hoisted(() => ({
systemStats: {
system: {
comfyui_version: '1.0.0',
argv: []
}
} satisfies {
system: Partial<SystemStats['system']>
},
isInitialized: true,
reset() {
this.systemStats = {
system: {
comfyui_version: '1.0.0',
argv: []
} satisfies Partial<SystemStats['system']>
}
this.isInitialized = true
}
}))
vi.mock('@/stores/systemStatsStore', () => {
const refetchSystemStats = vi.fn()
const getFormFactor = vi.fn(() => 'git-windows')
return {
useSystemStatsStore: () => ({
get systemStats() {
return mockSystemStatsState.systemStats
},
set systemStats(val) {
mockSystemStatsState.systemStats = val
},
get isInitialized() {
return mockSystemStatsState.isInitialized
},
set isInitialized(val) {
mockSystemStatsState.isInitialized = val
},
refetchSystemStats,
getFormFactor
})
}
})
vi.mock('@/platform/updates/common/releaseService')
vi.mock('@/platform/settings/settingStore')
vi.mock('@/stores/systemStatsStore')
vi.mock('@vueuse/core', () => ({
until: vi.fn(() => Promise.resolve()),
useStorage: vi.fn(() => ({ value: {} })),
@@ -98,6 +21,27 @@ vi.mock('@vueuse/core', () => ({
}))
describe('useReleaseStore', () => {
let store: ReturnType<typeof useReleaseStore>
let mockReleaseService: {
getReleases: Mock
isLoading: ReturnType<typeof ref<boolean>>
error: ReturnType<typeof ref<string | null>>
}
let mockSettingStore: { get: Mock; set: Mock }
let mockSystemStatsStore: {
systemStats: {
system: {
comfyui_version: string
argv?: string[]
[key: string]: unknown
}
devices?: unknown[]
} | null
isInitialized: boolean
refetchSystemStats: Mock
getFormFactor: Mock
}
const mockRelease = {
id: 1,
project: 'comfyui' as const,
@@ -107,16 +51,71 @@ describe('useReleaseStore', () => {
attention: 'high' as const
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
beforeEach(async () => {
setActivePinia(createPinia())
vi.resetAllMocks()
mockSystemStatsState.reset()
// Reset all mocks
vi.clearAllMocks()
// Setup mock services with proper refs
mockReleaseService = {
getReleases: vi.fn(),
isLoading: ref(false),
error: ref(null)
}
mockSettingStore = {
get: vi.fn(),
set: vi.fn()
}
mockSystemStatsStore = {
systemStats: {
system: {
comfyui_version: '1.0.0'
}
},
isInitialized: true,
refetchSystemStats: vi.fn(),
getFormFactor: vi.fn(() => 'git-windows')
}
// Setup mock implementations
const { useReleaseService } =
await import('@/platform/updates/common/releaseService')
const { useSettingStore } = await import('@/platform/settings/settingStore')
const { useSystemStatsStore } = await import('@/stores/systemStatsStore')
const { isElectron } = await import('@/utils/envUtil')
vi.mocked(useReleaseService).mockReturnValue(
mockReleaseService as Partial<
ReturnType<typeof useReleaseService>
> as ReturnType<typeof useReleaseService>
)
vi.mocked(useSettingStore).mockReturnValue(
mockSettingStore as Partial<
ReturnType<typeof useSettingStore>
> as ReturnType<typeof useSettingStore>
)
vi.mocked(useSystemStatsStore).mockReturnValue(
mockSystemStatsStore as Partial<
ReturnType<typeof useSystemStatsStore>
> as ReturnType<typeof useSystemStatsStore>
)
vi.mocked(isElectron).mockReturnValue(true)
vi.mocked(valid).mockReturnValue('1.0.0')
// Default showVersionUpdates to true
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
store = useReleaseStore()
})
describe('initial state', () => {
it('should initialize with default state', () => {
const store = useReleaseStore()
expect(store.releases).toEqual([])
expect(store.isLoading).toBe(false)
expect(store.error).toBeNull()
@@ -125,7 +124,6 @@ describe('useReleaseStore', () => {
describe('computed properties', () => {
it('should return most recent release', () => {
const store = useReleaseStore()
const olderRelease = {
...mockRelease,
id: 2,
@@ -138,7 +136,6 @@ describe('useReleaseStore', () => {
})
it('should return 3 most recent releases', () => {
const store = useReleaseStore()
const releases = [
mockRelease,
{ ...mockRelease, id: 2, version: '1.1.0' },
@@ -151,7 +148,6 @@ describe('useReleaseStore', () => {
})
it('should show update button (shouldShowUpdateButton)', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1) // newer version available
store.releases = [mockRelease]
@@ -159,7 +155,6 @@ describe('useReleaseStore', () => {
})
it('should not show update button when no new version', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(-1) // current version is newer
store.releases = [mockRelease]
@@ -168,17 +163,19 @@ describe('useReleaseStore', () => {
})
describe('showVersionUpdates setting', () => {
beforeEach(async () => {
store.releases = [mockRelease]
})
describe('when notifications are enabled', () => {
beforeEach(() => {
const settingStore = useSettingStore()
vi.mocked(settingStore.get).mockImplementation((key: string) => {
beforeEach(async () => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
})
it('should show toast for medium/high attention releases', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
store.releases = [mockRelease]
@@ -186,7 +183,6 @@ describe('useReleaseStore', () => {
})
it('should not show toast for low attention releases', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
const lowAttentionRelease = {
@@ -200,18 +196,13 @@ describe('useReleaseStore', () => {
})
it('should show red dot for new versions', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
vi.mocked(compare).mockReturnValue(0)
@@ -219,13 +210,11 @@ describe('useReleaseStore', () => {
})
it('should fetch releases during initialization', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith({
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
@@ -235,35 +224,27 @@ describe('useReleaseStore', () => {
})
describe('when notifications are disabled', () => {
beforeEach(() => {
const settingStore = useSettingStore()
vi.mocked(settingStore.get).mockImplementation((key: string) => {
beforeEach(async () => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
return null
})
})
it('should not show toast even with new version available', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowToast).toBe(false)
})
it('should not show red dot even with new version available', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(false)
})
it('should not show popup even for latest version', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
vi.mocked(compare).mockReturnValue(0)
@@ -271,19 +252,15 @@ describe('useReleaseStore', () => {
})
it('should skip fetching releases during initialization', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
await store.initialize()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
})
it('should not fetch releases when calling fetchReleases directly', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
await store.fetchReleases()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(store.isLoading).toBe(false)
})
})
@@ -291,13 +268,11 @@ describe('useReleaseStore', () => {
describe('release initialization', () => {
it('should fetch releases successfully', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith({
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
@@ -307,15 +282,12 @@ describe('useReleaseStore', () => {
})
it('should include form_factor in API call', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
vi.mocked(systemStatsStore.getFormFactor).mockReturnValue('desktop-mac')
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
mockSystemStatsStore.getFormFactor.mockReturnValue('desktop-mac')
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith({
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac',
@@ -324,22 +296,16 @@ describe('useReleaseStore', () => {
})
it('should skip fetching when --disable-api-nodes is present', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
mockSystemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
await store.initialize()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(store.isLoading).toBe(false)
})
it('should skip fetching when --disable-api-nodes is one of multiple args', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = [
mockSystemStatsStore.systemStats!.system.argv = [
'--port',
'8080',
'--disable-api-nodes',
@@ -348,46 +314,37 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(store.isLoading).toBe(false)
})
it('should fetch normally when --disable-api-nodes is not present', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = [
mockSystemStatsStore.systemStats!.system.argv = [
'--port',
'8080',
'--verbose'
]
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
expect(store.releases).toEqual([mockRelease])
})
it('should fetch normally when argv is undefined', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
// TODO: Consider deleting this test since the types have to be violated for it to be relevant
delete (systemStatsStore.systemStats!.system as { argv?: string[] }).argv
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
mockSystemStatsStore.systemStats!.system.argv = undefined
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
expect(store.releases).toEqual([mockRelease])
})
it('should handle API errors gracefully', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
vi.mocked(releaseService.getReleases).mockResolvedValue(null)
releaseService.error.value = 'API Error'
mockReleaseService.getReleases.mockResolvedValue(null)
mockReleaseService.error.value = 'API Error'
await store.initialize()
@@ -396,9 +353,7 @@ describe('useReleaseStore', () => {
})
it('should handle non-Error objects', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
vi.mocked(releaseService.getReleases).mockRejectedValue('String error')
mockReleaseService.getReleases.mockRejectedValue('String error')
await store.initialize()
@@ -406,14 +361,12 @@ describe('useReleaseStore', () => {
})
it('should set loading state correctly', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
let resolvePromise: (value: ReleaseNote[] | null) => void
const promise = new Promise<ReleaseNote[] | null>((resolve) => {
resolvePromise = resolve
})
vi.mocked(releaseService.getReleases).mockReturnValue(promise)
mockReleaseService.getReleases.mockReturnValue(promise)
const initPromise = store.initialize()
expect(store.isLoading).toBe(true)
@@ -425,23 +378,19 @@ describe('useReleaseStore', () => {
})
it('should fetch system stats if not available', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats = null
systemStatsStore.isInitialized = false
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.isInitialized = false
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(vi.mocked(until)).toHaveBeenCalled()
expect(releaseService.getReleases).toHaveBeenCalled()
expect(until).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
})
it('should not set loading state when notifications disabled', async () => {
const store = useReleaseStore()
const settingStore = useSettingStore()
vi.mocked(settingStore.get).mockImplementation((key: string) => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
return null
})
@@ -454,22 +403,16 @@ describe('useReleaseStore', () => {
describe('--disable-api-nodes argument handling', () => {
it('should skip fetchReleases when --disable-api-nodes is present', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
mockSystemStatsStore.systemStats!.system.argv = ['--disable-api-nodes']
await store.fetchReleases()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(store.isLoading).toBe(false)
})
it('should skip fetchReleases when --disable-api-nodes is among other args', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = [
mockSystemStatsStore.systemStats!.system.argv = [
'--port',
'8080',
'--disable-api-nodes',
@@ -478,109 +421,96 @@ describe('useReleaseStore', () => {
await store.fetchReleases()
expect(releaseService.getReleases).not.toHaveBeenCalled()
expect(mockReleaseService.getReleases).not.toHaveBeenCalled()
expect(store.isLoading).toBe(false)
})
it('should proceed with fetchReleases when --disable-api-nodes is not present', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.argv = [
mockSystemStatsStore.systemStats!.system.argv = [
'--port',
'8080',
'--verbose'
]
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.fetchReleases()
expect(releaseService.getReleases).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
})
it('should proceed with fetchReleases when argv is undefined', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
delete (systemStatsStore.systemStats!.system as { argv?: string[] }).argv
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
mockSystemStatsStore.systemStats!.system.argv = undefined
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.fetchReleases()
expect(releaseService.getReleases).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
})
it('should proceed with fetchReleases when system stats are not available', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats = null
systemStatsStore.isInitialized = false
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.isInitialized = false
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.fetchReleases()
expect(until).toHaveBeenCalled()
expect(releaseService.getReleases).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
})
})
describe('action handlers', () => {
it('should handle skip release', async () => {
const store = useReleaseStore()
beforeEach(async () => {
store.releases = [mockRelease]
const settingStore = useSettingStore()
})
it('should handle skip release', async () => {
await store.handleSkipRelease('1.2.0')
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Version',
'1.2.0'
)
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Status',
'skipped'
)
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Timestamp',
expect.any(Number)
)
})
it('should handle show changelog', async () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const settingStore = useSettingStore()
await store.handleShowChangelog('1.2.0')
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Version',
'1.2.0'
)
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Status',
'changelog seen'
)
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Timestamp',
expect.any(Number)
)
})
it('should handle whats new seen', async () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const settingStore = useSettingStore()
await store.handleWhatsNewSeen('1.2.0')
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Version',
'1.2.0'
)
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Status',
"what's new seen"
)
expect(settingStore.set).toHaveBeenCalledWith(
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Release.Timestamp',
expect.any(Number)
)
@@ -589,9 +519,7 @@ describe('useReleaseStore', () => {
describe('popup visibility', () => {
it('should show toast for medium/high attention releases', () => {
const store = useReleaseStore()
const settingStore = useSettingStore()
vi.mocked(settingStore.get).mockImplementation((key: string) => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Release.Version') return null
if (key === 'Comfy.Release.Status') return null
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
@@ -606,10 +534,8 @@ describe('useReleaseStore', () => {
})
it('should show red dot for new versions', () => {
const store = useReleaseStore()
const settingStore = useSettingStore()
vi.mocked(compare).mockReturnValue(1)
vi.mocked(settingStore.get).mockImplementation((key: string) => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
@@ -620,11 +546,8 @@ describe('useReleaseStore', () => {
})
it('should show popup for latest version', () => {
const store = useReleaseStore()
const systemStatsStore = useSystemStatsStore()
const settingStore = useSettingStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0' // Same as release
vi.mocked(settingStore.get).mockImplementation((key: string) => {
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0' // Same as release
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
@@ -639,11 +562,8 @@ describe('useReleaseStore', () => {
describe('edge cases', () => {
it('should handle missing system stats gracefully', async () => {
const store = useReleaseStore()
const systemStatsStore = useSystemStatsStore()
const settingStore = useSettingStore()
systemStatsStore.systemStats = null
vi.mocked(settingStore.get).mockImplementation((key: string) => {
mockSystemStatsStore.systemStats = null
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
return null
})
@@ -651,13 +571,11 @@ describe('useReleaseStore', () => {
await store.initialize()
// Should not fetch system stats when notifications disabled
expect(systemStatsStore.refetchSystemStats).not.toHaveBeenCalled()
expect(mockSystemStatsStore.refetchSystemStats).not.toHaveBeenCalled()
})
it('should handle concurrent fetchReleases calls', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
vi.mocked(releaseService.getReleases).mockImplementation(
mockReleaseService.getReleases.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve([mockRelease]), 100)
@@ -671,37 +589,41 @@ describe('useReleaseStore', () => {
await Promise.all([promise1, promise2])
// Should only call API once due to loading check
expect(releaseService.getReleases).toHaveBeenCalledTimes(1)
expect(mockReleaseService.getReleases).toHaveBeenCalledTimes(1)
})
})
describe('isElectron environment checks', () => {
beforeEach(async () => {
// Set up a new version available
store.releases = [mockRelease]
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
})
describe('when running in Electron (desktop)', () => {
beforeEach(() => {
beforeEach(async () => {
const { isElectron } = await import('@/utils/envUtil')
vi.mocked(isElectron).mockReturnValue(true)
})
it('should show toast when conditions are met', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
store.releases = [mockRelease]
expect(store.shouldShowToast).toBe(true)
})
it('should show red dot when new version available', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
vi.mocked(compare).mockReturnValue(0)
@@ -710,12 +632,12 @@ describe('useReleaseStore', () => {
})
describe('when NOT running in Electron (web)', () => {
beforeEach(() => {
beforeEach(async () => {
const { isElectron } = await import('@/utils/envUtil')
vi.mocked(isElectron).mockReturnValue(false)
})
it('should NOT show toast even when all other conditions are met', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
// Set up all conditions that would normally show toast
@@ -725,15 +647,12 @@ describe('useReleaseStore', () => {
})
it('should NOT show red dot even when new version available', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
vi.mocked(compare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(false)
})
it('should NOT show toast regardless of attention level', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
// Test with high attention releases
@@ -753,7 +672,6 @@ describe('useReleaseStore', () => {
})
it('should NOT show red dot even with high attention release', () => {
const store = useReleaseStore()
vi.mocked(compare).mockReturnValue(1)
store.releases = [{ ...mockRelease, attention: 'high' as const }]
@@ -762,10 +680,7 @@ describe('useReleaseStore', () => {
})
it('should NOT show popup even for latest version', () => {
const store = useReleaseStore()
store.releases = [mockRelease]
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
mockSystemStatsStore.systemStats!.system.comfyui_version = '1.2.0'
vi.mocked(compare).mockReturnValue(0)

View File

@@ -1,4 +1,3 @@
import { until } from '@vueuse/core'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
@@ -358,15 +357,17 @@ describe('useVersionCompatibilityStore', () => {
describe('initialization', () => {
it('should fetch system stats if not available', async () => {
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.isInitialized = false
await store.initialize()
expect(vi.mocked(until)).toHaveBeenCalled()
expect(until).toHaveBeenCalled()
})
it('should not fetch system stats if already available', async () => {
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = {
system: {
comfyui_version: '1.24.0',
@@ -377,7 +378,7 @@ describe('useVersionCompatibilityStore', () => {
await store.initialize()
expect(vi.mocked(until)).not.toHaveBeenCalled()
expect(until).not.toHaveBeenCalled()
})
})
})

View File

@@ -215,6 +215,8 @@ export const useWorkflowService = () => {
}
}
workflowDraftStore.removeDraft(workflow.path)
// If this is the last workflow, create a new default temporary workflow
if (workflowStore.openWorkflows.length === 1) {
await loadDefaultWorkflow()

View File

@@ -11,6 +11,7 @@ import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
@@ -910,4 +911,41 @@ describe('useWorkflowStore', () => {
expect(mostRecent).toBeNull()
})
})
describe('closeWorkflow draft cleanup', () => {
it('should remove draft for persisted workflows on close', async () => {
const draftStore = useWorkflowDraftStore()
await syncRemoteWorkflows(['a.json'])
const workflow = store.getWorkflowByPath('workflows/a.json')!
draftStore.saveDraft('workflows/a.json', {
data: '{"dirty":true}',
updatedAt: Date.now(),
name: 'a.json',
isTemporary: false
})
expect(draftStore.getDraft('workflows/a.json')).toBeDefined()
await store.closeWorkflow(workflow)
expect(draftStore.getDraft('workflows/a.json')).toBeUndefined()
})
it('should remove draft for temporary workflows on close', async () => {
const draftStore = useWorkflowDraftStore()
const workflow = store.createTemporary('temp.json')
draftStore.saveDraft(workflow.path, {
data: '{"dirty":true}',
updatedAt: Date.now(),
name: 'temp.json',
isTemporary: true
})
expect(draftStore.getDraft(workflow.path)).toBeDefined()
await store.closeWorkflow(workflow)
expect(draftStore.getDraft(workflow.path)).toBeUndefined()
})
})
})

View File

@@ -463,11 +463,9 @@ export const useWorkflowStore = defineStore('workflow', () => {
openWorkflowPaths.value = openWorkflowPaths.value.filter(
(path) => path !== workflow.path
)
useWorkflowDraftStore().removeDraft(workflow.path)
if (workflow.isTemporary) {
// Clear thumbnail when temporary workflow is closed
clearThumbnail(workflow.key)
// Clear draft when unsaved workflow tab is closed
useWorkflowDraftStore().removeDraft(workflow.path)
delete workflowLookup.value[workflow.path]
} else {
workflow.unload()

View File

@@ -394,6 +394,7 @@ interface SubgraphDefinitionBase<
id: string
revision: number
name: string
category?: string
inputNode: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExportedSubgraphIONode>
@@ -425,6 +426,7 @@ const zSubgraphDefinition = zComfyWorkflow1
id: z.string().uuid(),
revision: z.number(),
name: z.string(),
category: z.string().optional(),
inputNode: zExportedSubgraphIONode,
outputNode: zExportedSubgraphIONode,

View File

@@ -3,9 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { createGraphThumbnail } from '@/renderer/core/thumbnail/graphThumbnailRenderer'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { api } from '@/scripts/api'
vi.mock('@/renderer/core/thumbnail/graphThumbnailRenderer', () => ({
createGraphThumbnail: vi.fn()
@@ -22,6 +19,12 @@ vi.mock('@/scripts/api', () => ({
}
}))
const { useWorkflowThumbnail } =
await import('@/renderer/core/thumbnail/useWorkflowThumbnail')
const { createGraphThumbnail } =
await import('@/renderer/core/thumbnail/graphThumbnailRenderer')
const { api } = await import('@/scripts/api')
describe('useWorkflowThumbnail', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>

View File

@@ -205,7 +205,7 @@ defineExpose({ runButtonClick })
<NodeWidgets
:node-data
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg max-w-100"
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg max-w-100"
/>
</template>
</div>
@@ -237,7 +237,7 @@ defineExpose({ runButtonClick })
:node-data
:class="
cn(
'py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg',
'py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 not-has-[textarea]:flex-0 rounded-lg',
nodeData.hasErrors &&
'ring-2 ring-inset ring-node-stroke-error'
)

View File

@@ -1,58 +0,0 @@
<script setup lang="ts">
import {
CollapsibleRoot,
CollapsibleTrigger,
CollapsibleContent
} from 'reka-ui'
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { t } from '@/i18n'
import { useCommandStore } from '@/stores/commandStore'
</script>
<template>
<CollapsibleRoot class="flex flex-col">
<CollapsibleTrigger as-child>
<Button variant="secondary" class="size-10 self-end m-4 mb-2">
<i class="icon-[lucide--menu] size-8" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="flex gap-2 flex-col">
<div class="w-full border-b-2 border-border-subtle" />
<Popover>
<template #button>
<Button variant="secondary" size="lg" class="w-full">
<i class="icon-[comfy--workflow]" />
{{ t('Workflows') }}
</Button>
</template>
<WorkflowsSidebarTab class="h-300 w-[80vw]" />
</Popover>
<Button
variant="secondary"
size="lg"
class="w-full"
@click="useWorkflowTemplateSelectorDialog().show('menu')"
>
<i class="icon-[comfy--template]" />
{{ t('sideToolbar.templates') }}
</Button>
<Button
variant="secondary"
size="lg"
class="w-full"
@click="
useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source: 'button' }
})
"
>
<i class="icon-[lucide--log-out]" />
{{ t('linearMode.graphMode') }}
</Button>
<div class="w-full border-b-2 border-border-subtle" />
</CollapsibleContent>
</CollapsibleRoot>
</template>

View File

@@ -156,11 +156,7 @@ watch([selectedIndex, selectedOutput], doEmit)
watch(
() => outputs.media.value,
(newAssets, oldAssets) => {
if (
newAssets.length === oldAssets.length ||
(oldAssets.length === 0 && newAssets.length !== 1)
)
return
if (newAssets.length === oldAssets.length || oldAssets.length === 0) return
if (selectedIndex.value[0] <= 0) {
selectedIndex.value = [0, 0]
return

View File

@@ -200,8 +200,9 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
}))
}))
import { useMinimap } from '@/renderer/extensions/minimap/composables/useMinimap'
import { api } from '@/scripts/api'
const { useMinimap } =
await import('@/renderer/extensions/minimap/composables/useMinimap')
const { api } = await import('@/scripts/api')
describe('useMinimap', () => {
let moduleMockCanvasElement: HTMLCanvasElement

View File

@@ -103,7 +103,9 @@ describe('useMinimapRenderer', () => {
expect(vi.mocked(renderMinimapToCanvas)).not.toHaveBeenCalled()
})
it('should only render when redraw is needed', () => {
it('should only render when redraw is needed', async () => {
const { renderMinimapToCanvas } =
await import('@/renderer/extensions/minimap/minimapCanvasRenderer')
const canvasRef = ref(mockCanvas)
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const boundsRef = ref({ minX: 0, minY: 0, width: 100, height: 100 })

View File

@@ -4,11 +4,6 @@ import { ref } from 'vue'
import type { Ref } from 'vue'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import {
calculateMinimapScale,
calculateNodeBounds,
enforceMinimumBounds
} from '@/renderer/core/spatial/boundsCalculator'
import { useMinimapViewport } from '@/renderer/extensions/minimap/composables/useMinimapViewport'
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
@@ -71,7 +66,10 @@ describe('useMinimapViewport', () => {
expect(viewport.scale.value).toBe(1)
})
it('should calculate graph bounds from nodes', () => {
it('should calculate graph bounds from nodes', async () => {
const { calculateNodeBounds, enforceMinimumBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 100,
minY: 100,
@@ -94,7 +92,10 @@ describe('useMinimapViewport', () => {
expect(enforceMinimumBounds).toHaveBeenCalled()
})
it('should handle empty graph', () => {
it('should handle empty graph', async () => {
const { calculateNodeBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
vi.mocked(calculateNodeBounds).mockReturnValue(null)
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
@@ -130,7 +131,11 @@ describe('useMinimapViewport', () => {
})
})
it('should calculate viewport transform', () => {
it('should calculate viewport transform', async () => {
const { calculateNodeBounds, enforceMinimumBounds, calculateMinimapScale } =
await import('@/renderer/core/spatial/boundsCalculator')
// Mock the bounds calculation
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 0,
minY: 0,
@@ -231,7 +236,10 @@ describe('useMinimapViewport', () => {
expect(() => viewport.centerViewOn(100, 100)).not.toThrow()
})
it('should calculate scale correctly', () => {
it('should calculate scale correctly', async () => {
const { calculateMinimapScale, calculateNodeBounds, enforceMinimumBounds } =
await import('@/renderer/core/spatial/boundsCalculator')
const testBounds = {
minX: 0,
minY: 0,

View File

@@ -158,7 +158,15 @@ const hasMultipleVideos = computed(() => props.imageUrls.length > 1)
// Watch for URL changes and reset state
watch(
() => props.imageUrls,
(newUrls) => {
(newUrls, oldUrls) => {
// Only reset state if URLs actually changed (not just array reference)
const urlsChanged =
!oldUrls ||
newUrls.length !== oldUrls.length ||
newUrls.some((url, i) => url !== oldUrls[i])
if (!urlsChanged) return
// Reset current index if it's out of bounds
if (currentIndex.value >= newUrls.length) {
currentIndex.value = 0
@@ -169,7 +177,7 @@ watch(
videoError.value = false
showLoader.value = newUrls.length > 0
},
{ deep: true, immediate: true }
{ immediate: true }
)
// Event handlers

View File

@@ -308,4 +308,80 @@ describe('ImagePreview', () => {
expect(imgElement.exists()).toBe(true)
expect(imgElement.attributes('alt')).toBe('Node output 2')
})
describe('URL change detection', () => {
it('should NOT reset loading state when imageUrls prop is reassigned with identical URLs', async () => {
vi.useFakeTimers()
try {
const urls = ['/api/view?filename=test.png&type=output']
const wrapper = mountImagePreview({ imageUrls: urls })
// Simulate image load completing
const img = wrapper.find('img')
await img.trigger('load')
await nextTick()
// Verify loader is hidden after load
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
// Reassign with new array reference but same content
await wrapper.setProps({ imageUrls: [...urls] })
await nextTick()
// Advance past the 250ms delayed loader timeout
await vi.advanceTimersByTimeAsync(300)
await nextTick()
// Loading state should NOT have been reset - aria-busy should still be false
// because the URLs are identical (just a new array reference)
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
} finally {
vi.useRealTimers()
}
})
it('should reset loading state when imageUrls prop changes to different URLs', async () => {
const urls = ['/api/view?filename=test.png&type=output']
const wrapper = mountImagePreview({ imageUrls: urls })
// Simulate image load completing
const img = wrapper.find('img')
await img.trigger('load')
await nextTick()
// Verify loader is hidden
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(false)
// Change to different URL
await wrapper.setProps({
imageUrls: ['/api/view?filename=different.png&type=output']
})
await nextTick()
// After 250ms timeout, loading state should be reset (aria-busy="true")
// We can check the internal state via the Skeleton appearing
// or wait for the timeout
await new Promise((resolve) => setTimeout(resolve, 300))
await nextTick()
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
})
it('should handle empty to non-empty URL transitions correctly', async () => {
const wrapper = mountImagePreview({ imageUrls: [] })
// No preview initially
expect(wrapper.find('.image-preview').exists()).toBe(false)
// Add URLs
await wrapper.setProps({
imageUrls: ['/api/view?filename=test.png&type=output']
})
await nextTick()
// Preview should appear
expect(wrapper.find('.image-preview').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(true)
})
})
})

View File

@@ -176,7 +176,15 @@ const imageAltText = computed(() => `Node output ${currentIndex.value + 1}`)
// Watch for URL changes and reset state
watch(
() => props.imageUrls,
(newUrls) => {
(newUrls, oldUrls) => {
// Only reset state if URLs actually changed (not just array reference)
const urlsChanged =
!oldUrls ||
newUrls.length !== oldUrls.length ||
newUrls.some((url, i) => url !== oldUrls[i])
if (!urlsChanged) return
// Reset current index if it's out of bounds
if (currentIndex.value >= newUrls.length) {
currentIndex.value = 0
@@ -188,7 +196,7 @@ watch(
imageError.value = false
if (newUrls.length > 0) startDelayedLoader()
},
{ deep: true, immediate: true }
{ immediate: true }
)
// Event handlers

View File

@@ -7,7 +7,7 @@
cn(
'lg-slot lg-slot--input flex items-center group rounded-r-lg m-0',
'cursor-crosshair',
props.dotOnly ? 'lg-slot--dot-only' : 'pr-6',
dotOnly ? 'lg-slot--dot-only' : 'pr-6',
{
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible,
@@ -36,7 +36,7 @@
<!-- Slot Name -->
<div class="h-full flex items-center min-w-0">
<span
v-if="!dotOnly"
v-if="!props.dotOnly && !hasNoLabel"
:class="
cn(
'truncate text-node-component-slot-text',
@@ -47,8 +47,7 @@
{{
slotData.label ||
slotData.localized_name ||
slotData.name ||
`Input ${index}`
(slotData.name ?? `Input ${index}`)
}}
</span>
</div>
@@ -84,6 +83,14 @@ interface InputSlotProps {
const props = defineProps<InputSlotProps>()
const hasNoLabel = computed(
() =>
!props.slotData.label &&
!props.slotData.localized_name &&
props.slotData.name === ''
)
const dotOnly = computed(() => props.dotOnly || hasNoLabel.value)
const executionStore = useExecutionStore()
const hasSlotError = computed(() => {

View File

@@ -150,7 +150,9 @@
v-if="!isCollapsed && nodeData.resizable !== false"
role="button"
:aria-label="t('g.resizeFromBottomRight')"
:class="cn(baseResizeHandleClasses, 'right-0 bottom-0 cursor-se-resize')"
:class="
cn(baseResizeHandleClasses, '-right-1 -bottom-1 cursor-se-resize')
"
@pointerdown.stop="handleResizePointerDown"
/>
</div>
@@ -344,7 +346,7 @@ function initSizeStyles() {
}
const baseResizeHandleClasses =
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
'absolute h-5 w-5 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
const MIN_NODE_WIDTH = 225
@@ -549,6 +551,12 @@ const showAdvancedState = customRef((track, trigger) => {
}
})
const hasVideoInput = computed(() => {
return (
lgraphNode.value?.inputs?.some((input) => input.type === 'VIDEO') ?? false
)
})
const nodeMedia = computed(() => {
const newOutputs = nodeOutputs.nodeOutputs[nodeOutputLocatorId.value]
const node = lgraphNode.value
@@ -558,13 +566,9 @@ const nodeMedia = computed(() => {
const urls = nodeOutputs.getNodeImageUrls(node)
if (!urls?.length) return undefined
// Determine media type from previewMediaType or fallback to input slot types
// Note: Despite the field name "images", videos are also included in outputs
// TODO: fix the backend to return videos using the videos key instead of the images key
const hasVideoInput = node.inputs?.some((input) => input.type === 'VIDEO')
const type =
node.previewMediaType === 'video' ||
(!node.previewMediaType && hasVideoInput)
(!node.previewMediaType && hasVideoInput.value)
? 'video'
: 'image'

View File

@@ -108,11 +108,11 @@ import NodeBadge from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { normalizeI18nKey } from '@/utils/formatUtil'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@/utils/tailwindUtil'
import type { NodeBadgeProps } from './NodeBadge.vue'
@@ -160,12 +160,12 @@ const enterSubgraphTooltipConfig = computed(() => {
})
const resolveTitle = (info: VueNodeData | undefined) => {
const untitledLabel = st('g.untitled', 'Untitled')
return resolveNodeDisplayName(info ?? null, {
emptyLabel: untitledLabel,
untitledLabel,
st
})
const title = (info?.title ?? '').trim()
if (title.length > 0) return title
const nodeType = (info?.type ?? '').trim() || 'Untitled'
const key = `nodeDefs.${normalizeI18nKey(nodeType)}.display_name`
return st(key, nodeType)
}
// Local state for title to provide immediate feedback

View File

@@ -3,8 +3,11 @@
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
<div class="relative h-full flex items-center min-w-0">
<!-- Slot Name -->
<span v-if="!dotOnly" class="truncate text-node-component-slot-text">
{{ slotData.localized_name || slotData.name || `Output ${index}` }}
<span
v-if="!props.dotOnly && !hasNoLabel"
class="truncate text-node-component-slot-text"
>
{{ slotData.localized_name || (slotData.name ?? `Output ${index}`) }}
</span>
</div>
<!-- Connection Dot -->
@@ -44,6 +47,11 @@ interface OutputSlotProps {
const props = defineProps<OutputSlotProps>()
const hasNoLabel = computed(
() => !props.slotData.localized_name && props.slotData.name === ''
)
const dotOnly = computed(() => props.dotOnly || hasNoLabel.value)
// Error boundary implementation
const renderError = ref<string | null>(null)
@@ -79,7 +87,7 @@ const slotWrapperClass = computed(() =>
cn(
'lg-slot lg-slot--output flex items-center justify-end group rounded-l-lg h-6',
'cursor-crosshair',
props.dotOnly ? 'lg-slot--dot-only justify-center' : 'pl-6',
dotOnly.value ? 'lg-slot--dot-only justify-center' : 'pl-6',
{
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible,

View File

@@ -206,6 +206,60 @@ describe('WidgetMarkdown Dual Mode Display', () => {
expect(clickSpy).not.toHaveBeenCalled()
expect(keydownSpy).not.toHaveBeenCalled()
})
describe('Pointer Event Propagation', () => {
it('stops pointerdown propagation to prevent node drag during text selection', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
expect(textarea.exists()).toBe(true)
const parentPointerdownHandler = vi.fn()
const wrapperEl = wrapper.element as HTMLElement
wrapperEl.addEventListener('pointerdown', parentPointerdownHandler)
await textarea.trigger('pointerdown')
expect(parentPointerdownHandler).not.toHaveBeenCalled()
})
it('stops pointermove propagation during text selection', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
const parentPointermoveHandler = vi.fn()
const wrapperEl = wrapper.element as HTMLElement
wrapperEl.addEventListener('pointermove', parentPointermoveHandler)
await textarea.trigger('pointermove')
expect(parentPointermoveHandler).not.toHaveBeenCalled()
})
it('stops pointerup propagation after text selection', async () => {
const widget = createMockWidget('# Test')
const wrapper = mountComponent(widget, '# Test')
await clickToEdit(wrapper)
const textarea = wrapper.find('textarea')
const parentPointerupHandler = vi.fn()
const wrapperEl = wrapper.element as HTMLElement
wrapperEl.addEventListener('pointerup', parentPointerupHandler)
await textarea.trigger('pointerup')
expect(parentPointerupHandler).not.toHaveBeenCalled()
})
})
})
describe('Value Updates', () => {

View File

@@ -21,6 +21,9 @@
}
}"
data-capture-wheel="true"
@pointerdown.capture.stop
@pointermove.capture.stop
@pointerup.capture.stop
@click.stop
@keydown.stop
/>

View File

@@ -152,6 +152,50 @@ describe('WidgetSelectDropdown custom label mapping', () => {
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
it('falls back to original value when label mapping returns empty string', () => {
const getOptionLabel = vi.fn((value: string | null) => {
if (value === 'photo_abc.jpg') {
return ''
}
return `Labeled: ${value}`
})
const widget = createMockWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Labeled: img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('Labeled: hash789.png')
})
it('falls back to original value when label mapping returns undefined', () => {
const getOptionLabel = vi.fn((value: string | null) => {
if (value === 'hash789.png') {
return undefined as unknown as string
}
return `Labeled: ${value}`
})
const widget = createMockWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Labeled: img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('Labeled: photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('hash789.png')
})
})
describe('output items with custom label mapping', () => {
@@ -171,4 +215,102 @@ describe('WidgetSelectDropdown custom label mapping', () => {
expect(Array.isArray(outputItems)).toBe(true)
})
})
describe('missing value handling for template-loaded nodes', () => {
it('creates a fallback item in "all" filter when modelValue is not in available items', () => {
const widget = createMockWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems).toHaveLength(2)
expect(
inputItems.some((item) => item.name === 'template_image.png')
).toBe(false)
// The missing value should be accessible via dropdownItems when filter is 'all' (default)
const dropdownItems = (
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
).dropdownItems
expect(
dropdownItems.some((item) => item.name === 'template_image.png')
).toBe(true)
expect(dropdownItems[0].name).toBe('template_image.png')
expect(dropdownItems[0].id).toBe('missing-template_image.png')
})
it('does not include fallback item when filter is "inputs"', async () => {
const widget = createMockWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
const vmWithFilter = wrapper.vm as unknown as {
filterSelected: string
dropdownItems: DropdownItem[]
}
vmWithFilter.filterSelected = 'inputs'
await wrapper.vm.$nextTick()
const dropdownItems = vmWithFilter.dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
it('does not include fallback item when filter is "outputs"', async () => {
const widget = createMockWidget('template_image.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'template_image.png')
const vmWithFilter = wrapper.vm as unknown as {
filterSelected: string
dropdownItems: DropdownItem[]
outputItems: DropdownItem[]
}
vmWithFilter.filterSelected = 'outputs'
await wrapper.vm.$nextTick()
const dropdownItems = vmWithFilter.dropdownItems
expect(dropdownItems).toHaveLength(wrapper.vm.outputItems.length)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
it('does not create a fallback item when modelValue exists in available items', () => {
const widget = createMockWidget('img_001.png', {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, 'img_001.png')
const dropdownItems = (
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
).dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
it('does not create a fallback item when modelValue is undefined', () => {
const widget = createMockWidget(undefined as unknown as string, {
values: ['img_001.png', 'photo_abc.jpg']
})
const wrapper = mountComponent(widget, undefined)
const dropdownItems = (
wrapper.vm as unknown as { dropdownItems: DropdownItem[] }
).dropdownItems
expect(dropdownItems).toHaveLength(2)
expect(
dropdownItems.every((item) => !String(item.id).startsWith('missing-'))
).toBe(true)
})
})
})

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