Compare commits

..

99 Commits

Author SHA1 Message Date
DrJKL
1c48d41a88 fix: resolve lint errors and document describe naming convention
Amp-Thread-ID: https://ampcode.com/threads/T-019c4b83-5c21-714d-9c01-38e2e748c019
Co-authored-by: Amp <amp@ampcode.com>
2026-02-11 01:00:59 -08:00
DrJKL
2fc88abd59 Merge remote-tracking branch 'origin/main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-10 22:43:11 -08:00
DrJKL
b92fa9efe8 Merge remote-tracking branch 'origin/main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-10 22:37:20 -08:00
DrJKL
0a5af960d5 fix: replace props.* references with destructured prop variables
Amp-Thread-ID: https://ampcode.com/threads/T-019c4a8a-ada4-74ac-9496-ed1d43ee8ed2
Co-authored-by: Amp <amp@ampcode.com>
2026-02-10 18:51:56 -08:00
Alexander Brown
cdd8105b1a Merge branch 'main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-10 18:48:33 -08:00
DrJKL
53013d04ef fix: use component __name for describe() labels in Vue component tests
Vue component objects render as [object Object] when passed directly
to describe(). Use Component.__name ?? 'Component' to produce readable
suite labels in test reporters.

Fixes vitest/prefer-describe-function-title for 69 component test files.

Amp-Thread-ID: https://ampcode.com/threads/T-019c4a6c-7593-710e-bf99-02821f6b76ba
Co-authored-by: Amp <amp@ampcode.com>
2026-02-10 18:19:49 -08:00
Alexander Brown
18f3877cab fix: resolve new oxlint violations after upgrade
- Fix import ordering (absolute before relative imports)

- Fix no-immediate-mutation (Set.add/push/Object.assign after init)

- Migrate describe.each to describe.for

- Hoist vi.mock to top level in firebaseAuthStore test

- Fix unsafe optional chaining in subgraphStore

- Fix acceptTypes computed returning null instead of undefined

- Configure vue/return-in-computed-property treatUndefinedAsUnspecified

- Add oxlint-disable for incorrect prefer-describe-function-title auto-fixes

Amp-Thread-ID: https://ampcode.com/threads/T-019c495f-2269-701f-9a3f-c6fe378804ba
Co-authored-by: Amp <amp@ampcode.com>
2026-02-10 14:52:12 -08:00
Alexander Brown
21445f1faf deps: Update oxfmt and oxlint 2026-02-10 13:01:38 -08:00
Alexander Brown
6a1dcf8a1e Merge branch 'main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-10 12:46:06 -08:00
Alexander Brown
a13d28cc16 Merge branch 'main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-10 12:34:58 -08:00
Alexander Brown
2ed5618331 fix: move import above vi.mock() calls to satisfy import/first rule
Amp-Thread-ID: https://ampcode.com/threads/T-019c4174-f519-717b-9274-b17c24711353
Co-authored-by: Amp <amp@ampcode.com>
2026-02-09 00:16:35 -08:00
Alexander Brown
c4c65070e9 Merge remote-tracking branch 'origin/main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-09 00:13:16 -08:00
Alexander Brown
7ad917343e Merge branch 'main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-07 22:36:21 -08:00
Alexander Brown
25cc481e08 Merge branch 'main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-07 19:50:46 -08:00
Alexander Brown
8ff385fc4d feat: wrap async Preview3d component in Suspense boundary
Amp-Thread-ID: https://ampcode.com/threads/T-019c3b5a-1924-741a-9970-e14ef828eb46
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 19:45:46 -08:00
Alexander Brown
6ae2bc0e2a fix: handle floating promise in DropZone onDragDrop call
Amp-Thread-ID: https://ampcode.com/threads/T-019c3b58-626e-743f-8d69-5c4ff6bac504
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 19:44:11 -08:00
Alexander Brown
ae8940c0c0 fix: use defineAsyncComponent for Preview3d, clean up RAF state in minimap tests
Amp-Thread-ID: https://ampcode.com/threads/T-019c3b52-524e-770c-9752-1bcf3f5c6388
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 19:36:54 -08:00
Alexander Brown
6465c48423 fix: forward class prop via cn() in Select and SelectValue wrappers
Amp-Thread-ID: https://ampcode.com/threads/T-019c3b4d-3855-7669-877f-fc96de0f89b6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 19:32:21 -08:00
Alexander Brown
6db94117b8 fix: misc cleanups and small fixes
- Remove duplicate console.error in TopMenuSection
- Use cn() for class merging in NoResultsPlaceholder
- Refactor UrlInput to use defineModel instead of manual v-model
- Use getElementById instead of querySelector for ID lookup in LGraphCanvas
- Fix LiteGraphGlobal.registerNodeType category when type has no slash
- Replace null with undefined in LGraphNode test mock computeds

Amp-Thread-ID: https://ampcode.com/threads/T-019c3b43-b81f-7028-b3d4-be4e08d63238
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 19:23:20 -08:00
Alexander Brown
58d82b789b fix: use Vue useId() instead of Math.random() for NodeSearchBox inputId
Amp-Thread-ID: https://ampcode.com/threads/T-019c3b39-c0e9-7116-9e25-0381624c4b82
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 19:10:23 -08:00
Alexander Brown
da43303ecf Fix merge issue. 2026-02-07 18:44:57 -08:00
Alexander Brown
e00e2848a8 fix: exclude JSONC files from strict JSON validation
.oxlintrc.json uses JSONC (comments), which jq cannot parse.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3a27-819d-712a-8762-03ee5eb6e76c
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 18:37:41 -08:00
Alexander Brown
f2d5c415ae Merge branch 'main' into drjkl/I-have-a-ruler-and-I-know-how-to-use-it 2026-02-07 18:32:29 -08:00
Alexander Brown
0edd5b17e7 Merge issues 2026-02-07 14:11:53 -08:00
Alexander Brown
8376db4813 fix: use oxlint-disable-next-line instead of eslint-disable-next-line
Amp-Thread-ID: https://ampcode.com/threads/T-019c39e2-0b8a-725a-b765-28f091b790f4
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:34 -08:00
Alexander Brown
d9e9d68230 chore: disable high-violation rules pending incremental cleanup
7 rules set to off with TODO comments noting violation counts:

- no-param-reassign (104), prefer-destructuring (581)

- promise/prefer-await-to-callbacks (76), promise/prefer-await-to-then (91)

- unicorn/consistent-function-scoping (147), unicorn/no-array-for-each (165)

- typescript/prefer-nullish-coalescing (372)

Amp-Thread-ID: https://ampcode.com/threads/T-019c39e2-0b8a-725a-b765-28f091b790f4
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:31 -08:00
Alexander Brown
06b96b13b9 chore: promote 0-violation guardrail rules from warn to error
vitest/prefer-describe-function-title, unicorn/no-immediate-mutation,

promise/no-nesting, typescript/prefer-optional-chain

All had 0 violations. vitest/warn-todo kept as warn (intentional annotation).

Amp-Thread-ID: https://ampcode.com/threads/T-019c39e2-0b8a-725a-b765-28f091b790f4
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:27 -08:00
Alexander Brown
05c5d08acb chore: enable oxlint rule promise/prefer-await-to-callbacks
76 warnings across many files. Prefer async/await over callback patterns.

Too many violations for one pass; kept as warn for incremental cleanup.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39e2-0b8a-725a-b765-28f091b790f4
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:24 -08:00
Alexander Brown
0aec287e83 chore: enable oxlint rule promise/prefer-await-to-then
91 warnings across the codebase. Kept as warn for incremental cleanup — .then() usage is widespread. Configured with strict: false.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39dc-eeba-709e-9885-eb3d0a605157
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:21 -08:00
Alexander Brown
a2fdb2fcb2 chore: enable oxlint rule eslint/prefer-destructuring
Amp-Thread-ID: https://ampcode.com/threads/T-019c39d8-20d7-71d2-9feb-52961de2c1f0
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:17 -08:00
Alexander Brown
20b16009c7 chore: enable oxlint rule eslint/no-param-reassign
Enabled as warn. 104 warnings — too many to fix in one pass.

Disallows reassigning function parameters. Aligns with AGENTS.md immutability preference.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39d6-cc76-7120-b787-8be5974594c2
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:15 -08:00
Alexander Brown
f13a88a64e chore: enable oxlint rule eslint/func-style
16 violations fixed: converted const fn = function() to function declarations (or arrow functions where type narrowing required it). Enabled as error.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39cd-c141-776b-9285-eabd3d9ffacd
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:11 -08:00
Alexander Brown
31a1e14d2c chore: enable oxlint rule unicorn/prefer-set-has
5 violations fixed across 3 files — converted array+includes to Set+has for constant membership-check collections. All violations were small constant arrays used only for .includes() lookups, safe to convert.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39c6-819f-77ef-809f-4a55da6b327c
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:07 -08:00
Alexander Brown
924db2b815 chore: enable oxlint rule unicorn/no-array-for-each
Enabled as warn with 165 violations. Enforces for...of over .forEach(). Too many violations for one pass; kept as warn for incremental cleanup. No fixes applied.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39c4-ce2d-7532-b4d9-454b7220e93f
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 14:00:02 -08:00
Alexander Brown
e0090a5a4b chore: enable oxlint rule unicorn/consistent-function-scoping
147 warnings across many files. Configured with checkArrowFunctions: true.
Kept as warn due to high violation count (50+ threshold).
Enforces moving functions that don't capture outer scope to module level.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39bf-3aef-723e-8f3a-d4c5c8648cff
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:57 -08:00
Alexander Brown
620ad24796 chore: enable oxlint rule typescript/prefer-optional-chain
0 violations found — guardrail rule enforcing foo?.bar over foo && foo.bar. Enabled as warn due to dangerous auto-fix semantics (optional chaining may change return types).

Amp-Thread-ID: https://ampcode.com/threads/T-019c39ba-188b-711c-a738-dcefc8c37a37
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:56 -08:00
Alexander Brown
ea0e6b9d2a chore: enable oxlint rule typescript/prefer-nullish-coalescing
372 warnings (0 errors). Enabled as warn with ignoreConditionalTests.

Too many violations for one pass - kept as warn for incremental cleanup.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39b4-0744-73d6-925e-1ede662289f9
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:54 -08:00
Alexander Brown
fd78ec3077 chore: enable oxlint rule promise/param-names
38 violations fixed across 5 files. All were shorthand Promise param names (r, resolveFn/rejectFn, _) renamed to resolve/reject/_resolve/_reject.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39af-8fe0-73ad-a7fd-1373cc0380db
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:52 -08:00
Alexander Brown
d02dfca1de chore: enable oxlint rule promise/prefer-catch
0 violations found — guardrail rule enforcing .catch(fn) over .then(null, fn) or .then(a, b).

Amp-Thread-ID: https://ampcode.com/threads/T-019c39aa-ceeb-723e-9660-29c0178b9e45
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:50 -08:00
Alexander Brown
81606328b8 chore: enable oxlint rule promise/no-nesting
Add promise plugin to oxlint config. Enable promise/no-nesting as warn.

0 no-nesting violations found. 1 auto-enabled no-callback-in-promise false positive fixed by renaming callback parameter in useMinimap.test.ts.

Amp-Thread-ID: https://ampcode.com/threads/T-019c39a3-7c77-75a0-9e4d-9e1310d099aa
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:47 -08:00
Alexander Brown
ee1d61b71b chore: enable oxlint rule prefer-template
61 violations across 39 files converted from string concatenation to template literals. Auto-fix listed as planned but not implemented — all fixes applied manually.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3992-c9b1-753d-8f16-4712af1f1ee8
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:43 -08:00
Alexander Brown
f7b50067a6 chore: enable oxlint rule import/first
99 violations fixed across 35 files. Reordered imports so absolute imports (packages, @/ aliases) come before relative imports (./, ../). Configured with absolute-first option.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3986-6dde-744d-84bf-8a7e445b9bf7
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:34 -08:00
Alexander Brown
00aa420049 chore: enable oxlint rule eslint/no-else-return
29 violations fixed across 25 files.
Configured with allowElseIf: false to also flatten else-if chains after returns.
19 auto-fixed by oxlint, 10 manually fixed (cascaded else-if/else chains).
Fixed broken auto-fix output in useConflictDetection.ts (dangling code block).

Amp-Thread-ID: https://ampcode.com/threads/T-019c3978-5586-76db-9c5b-25ca30371483
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:08 -08:00
Alexander Brown
eb883c507d chore: enable oxlint rule unicorn/prefer-array-find
0 violations found - guardrail rule enforcing .find() over .filter()[0]

Amp-Thread-ID: https://ampcode.com/threads/T-019c3973-c4a9-755c-9d6d-2a1ba5b448c6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:05 -08:00
Alexander Brown
4aa40a560c chore: enable oxlint rule unicorn/prefer-add-event-listener
14 violations across 6 files. Converted .onX= assignments to addEventListener().

In useNodeFileInput.ts, extracted handler to named function for proper removeEventListener cleanup.

In litegraphService.ts, introduced const img to allow TypeScript narrowing inside Promise closure, removing 3 @ts-expect-error comments.

Amp-Thread-ID: https://ampcode.com/threads/T-019c38c9-c4b0-71f7-a19f-9b9c339bb99d
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:59:03 -08:00
Alexander Brown
f998fc0aab chore: enable oxlint rule unicorn/no-immediate-mutation
Severity: warn (guardrail). 0 violations found.
Amp-Thread-ID: https://ampcode.com/threads/T-019c38c5-639d-76a6-85e4-69e082fab2b8
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:58:59 -08:00
Alexander Brown
c03cd17c87 chore: enable oxlint rule unicorn/no-lonely-if
17 violations across 12 files. All manually fixed by merging nested if conditions into combined && expressions. No inline disables needed.

Notable: deduplicated redundant dialog_close_on_mouse_leave checks in LGraphCanvas.ts (outer and inner if both checked the same flag).
Amp-Thread-ID: https://ampcode.com/threads/T-019c38bd-b37e-702f-88c4-cac54c012fc8
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:58:38 -08:00
Alexander Brown
62108147c3 chore: enable oxlint rule unicorn/no-typeof-undefined
8 violations fixed across 5 files. All were safe property access on window/globalThis/local variables — converted typeof x === 'undefined' to x === undefined.

Amp-Thread-ID: https://ampcode.com/threads/T-019c38b7-1552-748f-b5f1-28558b9402e6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:55:10 -08:00
Alexander Brown
94523defc1 chore: enable oxlint rule vitest/warn-todo
Severity: warn (annotation, not blocking)

0 violations found. 2 existing it.todo() calls will appear as warnings.

Amp-Thread-ID: https://ampcode.com/threads/T-019c38b2-c3b8-71fd-9681-66837588b9d7
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:55:09 -08:00
Alexander Brown
a4df681fb5 chore: enable oxlint rule vitest/consistent-test-filename
0 violations — guardrail rule enforcing .test.ts naming for test files in src/.

Amp-Thread-ID: https://ampcode.com/threads/T-019c38ae-48df-7394-b615-5201058b4cc6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:55:07 -08:00
Alexander Brown
5f3fd7f7be chore: enable oxlint rule vitest/consistent-each-for
Guardrail rule enforcing .for() over .each() for parameterized tests.

0 violations found.

Amp-Thread-ID: https://ampcode.com/threads/T-019c38a9-e1b3-754a-8609-2665c0a27fc5
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:55:06 -08:00
Alexander Brown
7c18a5a185 chore: enable oxlint rule eslint/prefer-spread
0 violations found. Guardrail rule enforcing spread syntax over .apply() patterns (e.g. Math.max(...args) over Math.max.apply(Math, args)).

Amp-Thread-ID: https://ampcode.com/threads/T-019c38a5-8149-7258-b2de-011e60870c8b
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:55:04 -08:00
Alexander Brown
99bc407a1d chore: enable oxlint rule eslint/prefer-rest-params
0 violations found. Guardrail rule enforcing ...args over arguments object.

Amp-Thread-ID: https://ampcode.com/threads/T-019c389d-fb63-71b1-ab49-b5a1c8ee3447
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:55:01 -08:00
Alexander Brown
e60a475d8e chore: enable oxlint rule eslint/no-return-assign
29 violations fixed across 17 files. Converted arrow expression bodies with assignments to statement bodies, and removed return-value assignments from regular functions.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3893-7b2f-74f2-98db-41bbdee782d6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:55:00 -08:00
Alexander Brown
b56a028635 chore: enable oxlint rule eslint/no-new-func
Security guardrail — disallows new Function() (equivalent to eval).

0 violations found.

Amp-Thread-ID: https://ampcode.com/threads/T-019c388f-28d4-704a-a76b-c87393d45baa
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:57 -08:00
Alexander Brown
cc0bdf22e7 chore: enable oxlint rule eslint/preserve-caught-error
0 violations found — guardrail rule enforcing { cause: err } when re-throwing in catch blocks.

Amp-Thread-ID: https://ampcode.com/threads/T-019c388a-6748-75ba-9e5f-bacc83714f68
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:56 -08:00
Alexander Brown
82b4be3988 chore: enable oxlint rule eslint/no-useless-concat
0 violations found — guardrail rule preventing concatenation of adjacent string literals.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3885-c77f-76dc-8f05-a1de1c72cf28
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:54 -08:00
Alexander Brown
5540d22f64 chore: enable oxlint rule eslint/no-throw-literal
13 violations, all in litegraph. Wrapped string/template literal throws with new Error().

Files: ContextMenu.ts, LiteGraphGlobal.ts, LGraph.ts, LGraphNode.ts, LGraphCanvas.ts
Amp-Thread-ID: https://ampcode.com/threads/T-019c387e-a22a-722b-a017-fac342453e76
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:53 -08:00
Alexander Brown
a3cef10edf chore: enable oxlint rule eslint/no-useless-call
2 violations found, both false positives in useCachedRequest.test.ts where .call(null) invokes an object method named 'call', not Function.prototype.call. Added inline disables with justification.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3878-879e-75b2-8290-3006270b31d0
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:51 -08:00
Alexander Brown
deb9603b30 chore: enable oxlint rule unicorn/no-useless-collection-argument
9 violations fixed across 5 files.

Removed empty array args from Set/Map constructors (new Set([]) -> new Set()) and unnecessary ?? [] fallbacks (new Set(x ?? []) -> new Set(x)) since collection constructors handle undefined.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3871-8533-745a-9f66-9641cacfc473
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:49 -08:00
Alexander Brown
82b83460fc chore: enable oxlint rule unicorn/no-useless-switch-case
15 violations across 11 files. All were empty case clauses falling through directly to default — removed the redundant case labels.

Amp-Thread-ID: https://ampcode.com/threads/T-019c386a-7f3e-7141-ac49-cd6203103ef4
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:47 -08:00
Alexander Brown
cd3b1c5afe chore: enable oxlint rule unicorn/no-this-assignment
4 violations in litegraph code. Refactored prompt close() and showSearchBox close() to arrow functions. Inline-disabled 3 where hoisted function declarations genuinely need outer this (ContextMenu inner_onclick, showConnectionMenu inner_clicked, showSearchBox select/refreshHelper).

Amp-Thread-ID: https://ampcode.com/threads/T-019c385c-c19d-7298-9572-7651ebbf68a0
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:45 -08:00
Alexander Brown
aa7e59deaa chore: enable oxlint rule unicorn/no-abusive-eslint-disable
0 violations found. Guardrail rule disallowing blanket eslint-disable/oxlint-disable comments without specifying rule names.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3857-b5df-7192-886e-dcbf9cc8357a
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:43 -08:00
Alexander Brown
4fc40e0039 chore: enable oxlint rule unicorn/error-message
0 violations found — guardrail rule requiring message arg when constructing Error/TypeError/etc.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3852-b601-732e-b86b-a85266327c90
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:41 -08:00
Alexander Brown
ac5c84b514 chore: enable oxlint rule vitest/no-conditional-tests
0 violations found. Guardrail rule disallowing if/ternary wrapping it/test/describe blocks.

Amp-Thread-ID: https://ampcode.com/threads/T-019c384e-648e-7299-8539-91a298c83abc
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:39 -08:00
Alexander Brown
0b1c2217e5 chore: enable oxlint rule vitest/hoisted-apis-on-top
0 violations found — guardrail rule ensuring vi.mock, vi.unmock, vi.hoisted are at file top level.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3846-699e-7777-b31b-baa3e0a92c43
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:38 -08:00
Alexander Brown
2269275bd9 chore: enable oxlint rule eslint/no-unneeded-ternary
5 violations fixed across 3 files:

- measure.ts: condition ? false : true → !(condition)

- LGraphCanvas.ts: condition ? false : true → negated conditions

- coreSettings.ts (3): isCloud ? false : true → !isCloud, isCloud ? true : false → isCloud

Configured with defaultAssignment: false.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3840-1a10-763b-9a6f-2e14e29395a9
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:36 -08:00
Alexander Brown
37f8b73cfd chore: enable oxlint rule eslint/operator-assignment
1 violation fixed: currentStep.value = currentStep.value - 1 → currentStep.value -= 1 in useUploadModelWizard.ts

Amp-Thread-ID: https://ampcode.com/threads/T-019c383a-a0af-7187-be7c-a9dbcbb20ced
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:34 -08:00
Alexander Brown
7741a9bbb2 chore: enable oxlint rule eslint/yoda
Disallows Yoda conditions (e.g. 'red' === value). Configured with 'never' and exceptRange: true. 0 violations found — guardrail rule.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3836-2e2e-75bc-9d0d-36420bdd8fad
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:33 -08:00
Alexander Brown
19bcbce4a6 chore: enable oxlint rule eslint/prefer-object-has-own
5 violations auto-fixed across 3 files. Replaced Object.prototype.hasOwnProperty.call() with Object.hasOwn().

Amp-Thread-ID: https://ampcode.com/threads/T-019c382f-0e79-7471-b0cc-6c6432e7ce6b
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:31 -08:00
Alexander Brown
53fa5c22c1 chore: enable oxlint rule eslint/prefer-object-spread
5 violations across 3 files. 3 auto-fixed to object spread, 2 inline-disabled (LiteGraph class instance spread loses methods; array spread overwrites length/Symbol.iterator). Removed unused DefaultOptions and HasShowSearchCallback types. Removed redundant position default (always overwritten by required optPass.position).

Amp-Thread-ID: https://ampcode.com/threads/T-019c3826-0f23-70fe-ac56-513f4f83c86d
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:29 -08:00
Alexander Brown
8d53bbf263 chore: enable oxlint rule eslint/prefer-const
0 violations — guardrail rule enforcing const when never reassigned.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3821-72c3-75bc-8d82-058490bade7a
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:27 -08:00
Alexander Brown
0d274344a1 chore: enable oxlint rule eslint/no-var
0 violations found — var is not used in src/. Guardrail rule.

Amp-Thread-ID: https://ampcode.com/threads/T-019c381d-3e37-7629-a0a6-423f01fcac19
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:25 -08:00
Alexander Brown
e6ec331a71 chore: enable oxlint rule eslint/no-useless-constructor
1 violation fixed: removed empty constructor from mock class in useSelectedLiteGraphItems.test.ts.

Amp-Thread-ID: https://ampcode.com/threads/T-019c381b-011d-725f-8b44-1616bbb37dfe
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:25 -08:00
Alexander Brown
8c38d8a5de chore: enable oxlint rule eslint/eqeqeq
283 violations fixed across 31 files. Configured with [always, {null: ignore}] to allow idiomatic == null checks.

Added String() coercion for NodeId comparisons against string proxy widget IDs. Replaced @ts-expect-error directives with proper (e.target as Element) casts in LGraphCanvas.ts.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3805-11cc-7475-80bb-47de0d690fc4
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:23 -08:00
Alexander Brown
9f525bb540 chore: enable oxlint rule unicorn/no-negation-in-equality-check
0 violations found. Guardrail against bugs like !foo === bar.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3800-f5ff-740f-9d31-c32bbd842a7d
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:21 -08:00
Alexander Brown
25a25efbb2 chore: enable oxlint rule typescript/prefer-ts-expect-error
0 violations found — no @ts-ignore usages exist in src/.

This rule ensures @ts-expect-error is used instead of @ts-ignore, which is safer because it errors when the suppression is no longer needed.

Amp-Thread-ID: https://ampcode.com/threads/T-019c37ff-d1fc-72ae-8064-4f5546a78c38
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:19 -08:00
Alexander Brown
933fc35f02 chore: enable oxlint rule unicorn/prefer-classlist-toggle
1 violation in GraphView.vue: if/else classList.add/remove converted to classList.toggle('dark-theme', !light_theme)

Amp-Thread-ID: https://ampcode.com/threads/T-019c37fa-bed2-7283-98ca-a2c7d5c3aa91
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:17 -08:00
Alexander Brown
27a5701636 chore: enable oxlint rule unicorn/prefer-spread
86 violations fixed across 59 files. Converted Array.from(x) to [...x], .concat() to spread, and .slice() to spread.

3 inline disables: 2 in useBrushDrawing.ts (ArrayBuffer not iterable), 1 in NodeSettings.vue (spread widens union type).

Amp-Thread-ID: https://ampcode.com/threads/T-019c37f1-73b2-7147-90b3-282867667e38
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:15 -08:00
Alexander Brown
af48faee96 chore: enable oxlint rule unicorn/prefer-query-selector
11 violations auto-fixed across 8 files. Converted getElementById to querySelector and getElementsByTagName to querySelectorAll/querySelector. Added generic type params to querySelector calls where needed for type safety. Updated 2 test spies in SignInForm.test.ts to use scoped querySelector mocks.

Amp-Thread-ID: https://ampcode.com/threads/T-019c37de-071a-7047-9ac3-585e2082c082
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:13 -08:00
Alexander Brown
4c5e213cd5 chore: enable oxlint rule unicorn/no-useless-undefined
84 violations fixed across 39 files. Configured with
checkArguments: false to avoid conflicts with TypeScript
function signatures requiring explicit undefined args.

Resolved 11 vue/return-in-computed-property eslint conflicts
by restructuring computed properties to use ternary expressions
or lookup objects instead of bare returns. 1 inline disable
used where restructuring was impractical.

Amp-Thread-ID: https://ampcode.com/threads/T-019c37c4-8030-7088-b95c-2ef35bbec64e
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:10 -08:00
Alexander Brown
a98d01b2a2 chore: enable oxlint rule unicorn/prefer-math-min-max
1 violation in queueStore.ts: ternary comparison replaced with Math.max(idx, 0)

Amp-Thread-ID: https://ampcode.com/threads/T-019c37bf-46e1-772b-aafa-dcc5824d21c3
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:08 -08:00
Alexander Brown
8fe2ed3efe chore: enable oxlint rule unicorn/prefer-prototype-methods
0 violations found. Rule enforces Array.prototype.slice.apply() over [].slice.apply().

Amp-Thread-ID: https://ampcode.com/threads/T-019c37bb-4101-756f-a11a-43929dd76f58
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:05 -08:00
Alexander Brown
e981c38df7 chore: enable oxlint rule unicorn/prefer-type-error
1 violation fixed in useComboWidget.test.ts: Error -> TypeError after typeof check.

Amp-Thread-ID: https://ampcode.com/threads/T-019c37b9-2b26-71eb-9e39-834413472473
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:54:05 -08:00
Alexander Brown
a516c1cb45 chore: enable oxlint rule unicorn/prefer-string-slice
24 violations auto-fixed across 17 files.

All .substring()/.substr() calls converted to .slice() — safe because all arguments are non-negative.

Amp-Thread-ID: https://ampcode.com/threads/T-019c37b2-17fa-74f5-abae-f4e915c7a9a5
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:53:01 -08:00
Alexander Brown
7821b69ef7 chore: enable oxlint rule unicorn/prefer-string-replace-all
38 violations auto-fixed across 22 files. Replaces .replace(/regex/g, ...) with .replaceAll(string, ...) or .replaceAll(/regex/g, ...) as appropriate.

Amp-Thread-ID: https://ampcode.com/threads/T-019c37a9-c58c-77fe-bbac-f0344f9debaf
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:52:42 -08:00
Alexander Brown
6a420f2896 chore: enable oxlint rule unicorn/prefer-regexp-test
2 violations in surveyNormalization.ts: .match() in boolean context replaced with RegExp#test().

Amp-Thread-ID: https://ampcode.com/threads/T-019c37a3-cf5d-71ce-94b8-35b49cb38319
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:52:39 -08:00
Alexander Brown
2d20de7e1f chore: enable oxlint rule unicorn/no-length-as-slice-end
0 violations found — rule acts as a guardrail for future code.

Amp-Thread-ID: https://ampcode.com/threads/T-019c379f-7f8d-7369-bfce-97aba5462e40
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:52:37 -08:00
Alexander Brown
4e9dc97ad5 chore: enable oxlint rule unicorn/prefer-array-flat-map
2 violations auto-fixed: .map().flat() converted to .flatMap() in SelectionToolbox.vue and serverConfigStore.ts

Amp-Thread-ID: https://ampcode.com/threads/T-019c3797-72f6-77f4-b097-999543928f71
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:52:36 -08:00
Alexander Brown
bb6ad22003 chore: enable oxlint rule unicorn/no-instanceof-array
0 violations found. Rule enforces Array.isArray() over instanceof Array.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3792-8934-7763-ab09-70ff08764435
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:52:35 -08:00
Alexander Brown
febc1951d4 chore: enable oxlint rule unicorn/catch-error-name
93 violations found, 92 auto-fixed, 1 inline-disabled (nested catch
in useErrorHandling.ts where outer scope already uses `error`).

Configured with `ignore: ["^error\\w+$"]` to allow `errorCaught` as
a catch variable name where renaming to `error` would shadow a
reactive `error` ref in the same scope (common pattern in composables
and stores).

Amp-Thread-ID: https://ampcode.com/threads/T-019c3782-789b-75a8-9653-d5e030adda1d
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:52:29 -08:00
Alexander Brown
d012682dec chore: enable oxlint rule unicorn/prefer-optional-catch-binding
21 violations auto-fixed across 17 files. All were unused catch binding parameters (e.g. catch (error) -> catch).

Amp-Thread-ID: https://ampcode.com/threads/T-019c377b-4568-7288-94da-08ca003caa04
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:39:33 -08:00
Alexander Brown
826f4b1d80 chore: enable oxlint rule unicorn/prefer-string-trim-start-end
0 violations found. Guardrail rule to enforce trimStart()/trimEnd() over trimLeft()/trimRight().

Amp-Thread-ID: https://ampcode.com/threads/T-019c3776-cdd3-747b-9fa7-013a5f364282
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:39:32 -08:00
Alexander Brown
832b34c381 chore: enable oxlint rule unicorn/throw-new-error
4 violations in src/utils/linkFixer.ts, all auto-fixed (added missing 'new' keyword).

Amp-Thread-ID: https://ampcode.com/threads/T-019c3771-486f-727c-af65-8f53c9d3142a
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:39:31 -08:00
Alexander Brown
74dbad2404 chore: enable oxlint rule vitest/prefer-describe-function-title
Enabled as warn severity. No violations found (0 warnings, 0 errors).

This rule auto-fixes describe('fnName', ...) to describe(fnName, ...) when fnName is an imported symbol, using the function reference as the title.

Amp-Thread-ID: https://ampcode.com/threads/T-019c376c-efce-716a-84a7-4b9070b42ac4
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:39:29 -08:00
Alexander Brown
aaf33ce6ad chore: enable oxlint rule vitest/consistent-vitest-vi
0 violations found. Enforces vi.* over vitest.* in test files.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3768-5c10-72bf-a87e-13da5999876c
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:39:27 -08:00
Alexander Brown
0bff3fe7ea feat: migrate all defineProps to reactive destructured pattern
Convert all 120 `const props = defineProps<...>()` and bare
`defineProps<...>()` usages to Vue 3.5 reactive destructured props.
Update all `props.X` references to direct destructured names in both
script and template sections.

Upgrade `vue/define-props-destructuring` oxlint rule from "warn" to
"error" to enforce the pattern going forward.

Amp-Thread-ID: https://ampcode.com/threads/T-019c3716-74a2-7347-8b74-ad6ce2d8c9a6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:39:27 -08:00
Alexander Brown
cd70d7a576 feat: add vue/define-props-destructuring lint rule as warning
Amp-Thread-ID: https://ampcode.com/threads/T-019c3676-05f6-76e3-b673-165fa08c1b46
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:39:24 -08:00
Alexander Brown
e974f88e86 refactor: migrate withDefaults to reactive destructured props
Replace all 14 usages of withDefaults(defineProps<...>(), {...}) with
Vue 3.5 reactive destructured props pattern across the codebase.

- Drop redundant `undefined` defaults (already the default for optional props)
- Rename `duration` computed to `effectiveDuration` in TransitionCollapse
  to avoid shadowing the destructured prop
- Remove phantom `runningNodeName` default in QueueJobItem (not in type)

Amp-Thread-ID: https://ampcode.com/threads/T-019c3676-05f6-76e3-b673-165fa08c1b46
Co-authored-by: Amp <amp@ampcode.com>
2026-02-07 13:39:21 -08:00
849 changed files with 8151 additions and 19508 deletions

View File

@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ !github.event.pull_request.head.repo.fork && github.head_ref || github.ref }}
token: ${{ !github.event.pull_request.head.repo.fork && secrets.PR_GH_TOKEN || github.token }}
token: ${{ secrets.PR_GH_TOKEN }}
- name: Setup frontend
uses: ./.github/actions/setup-frontend

View File

@@ -21,6 +21,7 @@
"eslint",
"import",
"oxc",
"promise",
"typescript",
"unicorn",
"vitest",
@@ -28,6 +29,12 @@
],
"rules": {
"no-async-promise-executor": "off",
"no-else-return": [
"error",
{
"allowElseIf": false
}
],
"no-console": [
"error",
{
@@ -35,8 +42,29 @@
}
],
"no-control-regex": "off",
"eqeqeq": [
"error",
"always",
{
"null": "ignore"
}
],
"func-style": [
"error",
"declaration",
{
"allowArrowFunctions": true
}
],
"no-eval": "off",
"no-new-func": "error",
// TODO: Enable and fix 104 violations
"no-param-reassign": "off",
"no-redeclare": "error",
"no-return-assign": ["error", "always"],
"no-throw-literal": "error",
"no-useless-constructor": "error",
"no-var": "error",
"no-restricted-imports": [
"error",
{
@@ -64,15 +92,66 @@
]
}
],
"no-unneeded-ternary": [
"error",
{
"defaultAssignment": false
}
],
"no-useless-call": "error",
"no-useless-concat": "error",
"prefer-const": "error",
// TODO: Enable and fix 581 violations
"prefer-destructuring": "off",
"prefer-object-has-own": "error",
"prefer-object-spread": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "error",
"promise/no-nesting": "error",
"promise/param-names": "error",
// TODO: Enable and fix 76 violations
"promise/prefer-await-to-callbacks": "off",
// TODO: Enable and fix 91 violations
"promise/prefer-await-to-then": "off",
"promise/prefer-catch": "error",
"preserve-caught-error": "error",
"yoda": [
"error",
"never",
{
"exceptRange": true
}
],
"no-self-assign": "allow",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
"no-useless-rename": "off",
"operator-assignment": ["error", "always"],
"import/default": "error",
"import/export": "error",
"import/first": ["error", "absolute-first"],
"import/namespace": "error",
"import/no-duplicates": "error",
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"vitest/consistent-each-for": [
"error",
{
"test": "for",
"describe": "for"
}
],
"vitest/consistent-test-filename": [
"error",
{
"pattern": ".*\\.test\\.ts$"
}
],
"vitest/consistent-vitest-vi": "error",
"vitest/warn-todo": "warn",
"vitest/hoisted-apis-on-top": "error",
"vitest/no-conditional-tests": "error",
"vitest/prefer-describe-function-title": "error",
"jest/expect-expect": "off",
"jest/no-conditional-expect": "off",
"jest/no-disabled-tests": "off",
@@ -82,11 +161,55 @@
"typescript/no-unnecessary-parameter-property-assignment": "off",
"typescript/no-unsafe-declaration-merging": "off",
"typescript/no-unused-vars": "off",
"unicorn/catch-error-name": [
"error",
{
"ignore": ["^error\\w+$"]
}
],
// TODO: Enable and fix 147 violations
"unicorn/consistent-function-scoping": "off",
"unicorn/error-message": "error",
"unicorn/no-abusive-eslint-disable": "error",
// TODO: Enable and fix 165 violations
"unicorn/no-array-for-each": "off",
"unicorn/no-immediate-mutation": "error",
"unicorn/no-instanceof-array": "error",
"unicorn/no-length-as-slice-end": "error",
"unicorn/no-lonely-if": "error",
"unicorn/no-negation-in-equality-check": "error",
"unicorn/no-typeof-undefined": "error",
"unicorn/prefer-math-min-max": "error",
"unicorn/prefer-array-flat-map": "error",
"unicorn/no-empty-file": "off",
"unicorn/no-new-array": "off",
"unicorn/prefer-add-event-listener": "error",
"unicorn/prefer-array-find": "error",
"unicorn/no-useless-undefined": [
"error",
{
"checkArguments": false,
"checkArrowFunctionBody": false
}
],
"unicorn/prefer-classlist-toggle": "error",
"unicorn/no-single-promise-in-promise-methods": "off",
"unicorn/no-this-assignment": "error",
"unicorn/no-useless-collection-argument": "error",
"unicorn/no-useless-switch-case": "error",
"unicorn/no-useless-fallback-in-spread": "off",
"unicorn/no-useless-spread": "off",
"unicorn/prefer-optional-catch-binding": "error",
"unicorn/prefer-prototype-methods": "error",
"unicorn/prefer-query-selector": "error",
"unicorn/prefer-spread": "error",
"unicorn/prefer-regexp-test": "error",
"unicorn/prefer-set-has": "error",
"unicorn/prefer-string-replace-all": "error",
"unicorn/prefer-string-slice": "error",
"unicorn/prefer-string-trim-start-end": "error",
"unicorn/prefer-type-error": "error",
"unicorn/throw-new-error": "error",
"typescript/await-thenable": "off",
"typescript/no-base-to-string": "off",
"typescript/no-duplicate-type-constituents": "off",
@@ -96,7 +219,12 @@
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "error",
"typescript/no-explicit-any": "error",
// TODO: Enable and fix 372 violations (use { "ignoreConditionalTests": true })
"typescript/prefer-nullish-coalescing": "off",
// TODO: Enable and fix violations
"typescript/prefer-optional-chain": "off",
"typescript/prefer-ts-expect-error": "error",
"vue/define-props-destructuring": "error",
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
},
@@ -115,7 +243,8 @@
"no-control-regex": "error",
"no-useless-rename": "error",
"no-unused-private-class-members": "error",
"unicorn/no-empty-file": "error"
"unicorn/no-empty-file": "error",
"vitest/consistent-test-filename": "off"
}
}
]

View File

@@ -1,205 +0,0 @@
{
"last_node_id": 7,
"last_link_id": 5,
"nodes": [
{
"id": 1,
"type": "T2IAdapterLoader",
"pos": [100, 100],
"size": [300, 80],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CONTROL_NET",
"type": "CONTROL_NET",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "T2IAdapterLoader"
},
"widgets_values": ["t2iadapter_model.safetensors"]
},
{
"id": 2,
"type": "CheckpointLoaderSimple",
"pos": [100, 300],
"size": [315, 98],
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [],
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [],
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 3,
"type": "ResizeImagesByLongerEdge",
"pos": [500, 100],
"size": [300, 80],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ResizeImagesByLongerEdge"
},
"widgets_values": [1024]
},
{
"id": 4,
"type": "ImageScaleBy",
"pos": [500, 280],
"size": [300, 80],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [2, 3],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImageScaleBy"
},
"widgets_values": ["lanczos", 1.5]
},
{
"id": 5,
"type": "ImageBatch",
"pos": [900, 100],
"size": [300, 80],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "image1",
"type": "IMAGE",
"link": 2
},
{
"name": "image2",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [4],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ImageBatch"
},
"widgets_values": []
},
{
"id": 6,
"type": "SaveImage",
"pos": [900, 300],
"size": [300, 80],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 3
}
],
"properties": {
"Node name for S&R": "SaveImage"
},
"widgets_values": ["ComfyUI"]
},
{
"id": 7,
"type": "PreviewImage",
"pos": [1250, 100],
"size": [300, 250],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 4
}
],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
}
],
"links": [
[1, 3, 0, 4, 0, "IMAGE"],
[2, 4, 0, 5, 0, "IMAGE"],
[3, 4, 0, 6, 0, "IMAGE"],
[4, 5, 0, 7, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -1,186 +0,0 @@
{
"last_node_id": 5,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "Load3DAnimation",
"pos": [100, 100],
"size": [300, 100],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "MESH",
"type": "MESH",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "Load3DAnimation"
},
"widgets_values": ["model.glb"]
},
{
"id": 2,
"type": "Preview3DAnimation",
"pos": [450, 100],
"size": [300, 100],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "mesh",
"type": "MESH",
"link": null
}
],
"properties": {
"Node name for S&R": "Preview3DAnimation"
},
"widgets_values": []
},
{
"id": 3,
"type": "ConditioningAverage ",
"pos": [100, 300],
"size": [300, 100],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "conditioning_to",
"type": "CONDITIONING",
"link": null
},
{
"name": "conditioning_from",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [1],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "ConditioningAverage "
},
"widgets_values": [1]
},
{
"id": 4,
"type": "SDV_img2vid_Conditioning",
"pos": [450, 300],
"size": [300, 150],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip_vision",
"type": "CLIP_VISION",
"link": null
},
{
"name": "init_image",
"type": "IMAGE",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "positive",
"type": "CONDITIONING",
"links": [],
"slot_index": 0
},
{
"name": "negative",
"type": "CONDITIONING",
"links": [],
"slot_index": 1
},
{
"name": "latent",
"type": "LATENT",
"links": [2],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "SDV_img2vid_Conditioning"
},
"widgets_values": [1024, 576, 14, 127, 25, 0.02]
},
{
"id": 5,
"type": "KSampler",
"pos": [800, 300],
"size": [300, 262],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1]
}
],
"links": [
[1, 3, 0, 5, 1, "CONDITIONING"],
[2, 4, 2, 5, 3, "LATENT"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -1,86 +0,0 @@
{
"id": "save-image-and-webm-test",
"revision": 0,
"last_node_id": 12,
"last_link_id": 2,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 100],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1, 2]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
},
{
"id": 11,
"type": "SaveImage",
"pos": [450, 100],
"size": [210, 270],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 12,
"type": "SaveWEBM",
"pos": [450, 450],
"size": [210, 368],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI", "vp9", 6, 32]
}
],
"links": [
[1, 10, 0, 11, 0, "IMAGE"],
[2, 10, 0, 12, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"frontendVersion": "1.17.0",
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -23,7 +23,9 @@ export class SettingDialog extends BaseDialog {
* @param value - The value to set
*/
async setStringSetting(id: string, value: string) {
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
const settingInputDiv = this.page.locator(
`div.settings-container div[id="${id}"]`
)
await settingInputDiv.locator('input').fill(value)
}
@@ -32,31 +34,16 @@ export class SettingDialog extends BaseDialog {
* @param id - The id of the setting
*/
async toggleBooleanSetting(id: string) {
const settingInputDiv = this.root.locator(`div[id="${id}"]`)
const settingInputDiv = this.page.locator(
`div.settings-container div[id="${id}"]`
)
await settingInputDiv.locator('input').click()
}
get searchBox() {
return this.root.getByPlaceholder(/Search/)
}
get categories() {
return this.root.locator('nav').getByRole('button')
}
category(name: string) {
return this.root.locator('nav').getByRole('button', { name })
}
get contentArea() {
return this.root.getByRole('main')
}
async goToAboutPanel() {
const aboutButton = this.root.locator('nav').getByRole('button', {
name: 'About'
})
await aboutButton.click()
await this.page.waitForSelector('.about-container')
await this.page.getByTestId(TestIds.dialogs.settingsTabAbout).click()
await this.page
.getByTestId(TestIds.dialogs.about)
.waitFor({ state: 'visible' })
}
}

View File

@@ -226,7 +226,9 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
await expect(bottomPanel.shortcuts.manageButton).toBeVisible()
await bottomPanel.shortcuts.manageButton.click()
await expect(comfyPage.settingDialog.root).toBeVisible()
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
await expect(comfyPage.page.getByRole('dialog')).toBeVisible()
await expect(
comfyPage.page.getByRole('option', { name: 'Keybinding' })
).toBeVisible()
})
})

View File

@@ -244,13 +244,9 @@ test.describe('Missing models warning', () => {
test.describe('Settings', () => {
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await expect(settingsDialog).toBeVisible()
const contentArea = settingsDialog.locator('main')
await expect(contentArea).toBeVisible()
const isUsableHeight = await contentArea.evaluate(
const settingsContent = comfyPage.page.locator('.settings-content')
await expect(settingsContent).toBeVisible()
const isUsableHeight = await settingsContent.evaluate(
(el) => el.clientHeight > 30
)
expect(isUsableHeight).toBeTruthy()
@@ -260,9 +256,7 @@ test.describe('Settings', () => {
await comfyPage.page.keyboard.down('ControlOrMeta')
await comfyPage.page.keyboard.press(',')
await comfyPage.page.keyboard.up('ControlOrMeta')
const settingsLocator = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
const settingsLocator = comfyPage.page.locator('.settings-container')
await expect(settingsLocator).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(settingsLocator).not.toBeVisible()
@@ -281,15 +275,10 @@ test.describe('Settings', () => {
test('Should persist keybinding setting', async ({ comfyPage }) => {
// Open the settings dialog
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]')
await comfyPage.page.waitForSelector('.settings-container')
// Open the keybinding tab
const settingsDialog = comfyPage.page.locator(
'[data-testid="settings-dialog"]'
)
await settingsDialog
.locator('nav [role="button"]', { hasText: 'Keybinding' })
.click()
await comfyPage.page.getByLabel('Keybinding').click()
await comfyPage.page.waitForSelector(
'[placeholder="Search Keybindings..."]'
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -215,14 +215,6 @@ test.describe('Node search box', { tag: '@node' }, () => {
await expectFilterChips(comfyPage, ['MODEL', 'CLIP'])
})
test('Does not add duplicate filter with same type and value', async ({
comfyPage
}) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await expectFilterChips(comfyPage, ['MODEL'])
})
test('Can remove filter', async ({ comfyPage }) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.removeFilter(0)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -1,42 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe(
'Save Image and WEBM preview',
{ tag: ['@screenshot', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('Can preview both SaveImage and SaveWEBM outputs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'widgets/save_image_and_animated_webp'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.runButton.click()
const saveImageNode = comfyPage.vueNodes.getNodeByTitle('Save Image')
const saveWebmNode = comfyPage.vueNodes.getNodeByTitle('SaveWEBM')
// Wait for SaveImage to render an img inside .image-preview
await expect(saveImageNode.locator('.image-preview img')).toBeVisible({
timeout: 30000
})
// Wait for SaveWEBM to render a video inside .video-preview
await expect(saveWebmNode.locator('.video-preview video')).toBeVisible({
timeout: 30000
})
await expect(comfyPage.page).toHaveScreenshot(
'save-image-and-webm-preview.png'
)
})
}
)

View File

@@ -826,7 +826,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
// Open settings dialog using hotkey
await comfyPage.page.keyboard.press('Control+,')
await comfyPage.page.waitForSelector('[data-testid="settings-dialog"]', {
await comfyPage.page.waitForSelector('.settings-container', {
state: 'visible'
})
@@ -836,7 +836,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
// Dialog should be closed
await expect(
comfyPage.page.locator('[data-testid="settings-dialog"]')
comfyPage.page.locator('.settings-container')
).not.toBeVisible()
// Should still be in subgraph

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -22,6 +22,7 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
name: 'TestSettingsExtension',
settings: [
{
// Extensions can register arbitrary setting IDs
id: 'TestHiddenSetting' as TestSettingId,
name: 'Test Hidden Setting',
type: 'hidden',
@@ -29,6 +30,7 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
category: ['Test', 'Hidden']
},
{
// Extensions can register arbitrary setting IDs
id: 'TestDeprecatedSetting' as TestSettingId,
name: 'Test Deprecated Setting',
type: 'text',
@@ -37,6 +39,7 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
category: ['Test', 'Deprecated']
},
{
// Extensions can register arbitrary setting IDs
id: 'TestVisibleSetting' as TestSettingId,
name: 'Test Visible Setting',
type: 'text',
@@ -49,143 +52,238 @@ test.describe('Settings Search functionality', { tag: '@settings' }, () => {
})
test('can open settings dialog and use search box', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
await expect(dialog.searchBox).toHaveAttribute(
// Find the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await expect(searchBox).toBeVisible()
// Verify search box has the correct placeholder
await expect(searchBox).toHaveAttribute(
'placeholder',
expect.stringContaining('Search')
)
})
test('search box is functional and accepts input', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
await dialog.searchBox.fill('Comfy')
await expect(dialog.searchBox).toHaveValue('Comfy')
// Find and interact with the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Comfy')
// Verify the input was accepted
await expect(searchBox).toHaveValue('Comfy')
})
test('search box clears properly', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
await dialog.searchBox.fill('test')
await expect(dialog.searchBox).toHaveValue('test')
// Find and interact with the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('test')
await expect(searchBox).toHaveValue('test')
await dialog.searchBox.clear()
await expect(dialog.searchBox).toHaveValue('')
// Clear the search box
await searchBox.clear()
await expect(searchBox).toHaveValue('')
})
test('settings categories are visible in sidebar', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
expect(await dialog.categories.count()).toBeGreaterThan(0)
// Check that the sidebar has categories
const categories = comfyPage.page.locator(
'.settings-sidebar .p-listbox-option'
)
expect(await categories.count()).toBeGreaterThan(0)
// Check that at least one category is visible
await expect(categories.first()).toBeVisible()
})
test('can select different categories in sidebar', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
const categoryCount = await dialog.categories.count()
// Click on a specific category (Appearance) to verify category switching
const appearanceCategory = comfyPage.page.getByRole('option', {
name: 'Appearance'
})
await appearanceCategory.click()
if (categoryCount > 1) {
await dialog.categories.nth(1).click()
// Verify the category is selected
await expect(appearanceCategory).toHaveClass(/p-listbox-option-selected/)
})
await expect(dialog.categories.nth(1)).toHaveClass(
/bg-interface-menu-component-surface-selected/
)
}
test('settings content area is visible', async ({ comfyPage }) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Check that the content area is visible
const contentArea = comfyPage.page.locator('.settings-content')
await expect(contentArea).toBeVisible()
// Check that tab panels are visible
const tabPanels = comfyPage.page.locator('.settings-tab-panels')
await expect(tabPanels).toBeVisible()
})
test('search functionality affects UI state', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
await dialog.searchBox.fill('graph')
await expect(dialog.searchBox).toHaveValue('graph')
// Find the search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
// Type in search box
await searchBox.fill('graph')
// Verify that the search input is handled
await expect(searchBox).toHaveValue('graph')
})
test('settings dialog can be closed', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
// Close with escape key
await comfyPage.page.keyboard.press('Escape')
await expect(dialog.root).not.toBeVisible()
// Verify dialog is closed
await expect(settingsDialog).not.toBeVisible()
})
test('search box has proper debouncing behavior', async ({ comfyPage }) => {
const dialog = comfyPage.settingDialog
await dialog.open()
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
await dialog.searchBox.fill('a')
await dialog.searchBox.fill('ab')
await dialog.searchBox.fill('abc')
await dialog.searchBox.fill('abcd')
// Type rapidly in search box
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('a')
await searchBox.fill('ab')
await searchBox.fill('abc')
await searchBox.fill('abcd')
await expect(dialog.searchBox).toHaveValue('abcd')
// Verify final value
await expect(searchBox).toHaveValue('abcd')
})
test('search excludes hidden settings from results', async ({
comfyPage
}) => {
const dialog = comfyPage.settingDialog
await dialog.open()
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
await dialog.searchBox.fill('Test')
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await expect(dialog.contentArea).toContainText('Test Visible Setting')
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should show visible setting but not hidden setting
await expect(settingsContent).toContainText('Test Visible Setting')
await expect(settingsContent).not.toContainText('Test Hidden Setting')
})
test('search excludes deprecated settings from results', async ({
comfyPage
}) => {
const dialog = comfyPage.settingDialog
await dialog.open()
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
await dialog.searchBox.fill('Test')
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await expect(dialog.contentArea).toContainText('Test Visible Setting')
await expect(dialog.contentArea).not.toContainText(
'Test Deprecated Setting'
)
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should show visible setting but not deprecated setting
await expect(settingsContent).toContainText('Test Visible Setting')
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
})
test('search shows visible settings but excludes hidden and deprecated', async ({
comfyPage
}) => {
const dialog = comfyPage.settingDialog
await dialog.open()
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
await dialog.searchBox.fill('Test')
// Search for our test settings
const searchBox = comfyPage.page.locator('.settings-search-box input')
await searchBox.fill('Test')
await expect(dialog.contentArea).toContainText('Test Visible Setting')
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
await expect(dialog.contentArea).not.toContainText(
'Test Deprecated Setting'
)
// Get all settings content
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
// Should only show the visible setting
await expect(settingsContent).toContainText('Test Visible Setting')
// Should not show hidden or deprecated settings
await expect(settingsContent).not.toContainText('Test Hidden Setting')
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
})
test('search by setting name excludes hidden and deprecated', async ({
comfyPage
}) => {
const dialog = comfyPage.settingDialog
await dialog.open()
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
const settingsDialog = comfyPage.page.locator('.settings-container')
await expect(settingsDialog).toBeVisible()
await dialog.searchBox.clear()
await dialog.searchBox.fill('Hidden')
await expect(dialog.contentArea).not.toContainText('Test Hidden Setting')
const searchBox = comfyPage.page.locator('.settings-search-box input')
const settingsContent = comfyPage.page.locator('.settings-tab-panels')
await dialog.searchBox.clear()
await dialog.searchBox.fill('Deprecated')
await expect(dialog.contentArea).not.toContainText(
'Test Deprecated Setting'
)
// Search specifically for hidden setting by name
await searchBox.clear()
await searchBox.fill('Hidden')
await dialog.searchBox.clear()
await dialog.searchBox.fill('Visible')
await expect(dialog.contentArea).toContainText('Test Visible Setting')
// Should not show the hidden setting even when searching by name
await expect(settingsContent).not.toContainText('Test Hidden Setting')
// Search specifically for deprecated setting by name
await searchBox.clear()
await searchBox.fill('Deprecated')
// Should not show the deprecated setting even when searching by name
await expect(settingsContent).not.toContainText('Test Deprecated Setting')
// Search for visible setting by name - should work
await searchBox.clear()
await searchBox.fill('Visible')
// Should show the visible setting
await expect(settingsContent).toContainText('Test Visible Setting')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -1,55 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
test.describe('Vue Nodes Image Preview', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
})
async function loadImageOnNode(
comfyPage: Awaited<
ReturnType<(typeof test)['info']>
>['fixtures']['comfyPage']
) {
const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
const imagePreview = comfyPage.page.locator('.image-preview')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')
return imagePreview
}
test('opens mask editor from image preview button', async ({ comfyPage }) => {
const imagePreview = await loadImageOnNode(comfyPage)
await imagePreview.locator('[role="img"]').hover()
await comfyPage.page.getByLabel('Edit or mask image').click()
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
})
test('shows image context menu options', async ({ comfyPage }) => {
await loadImageOnNode(comfyPage)
const nodeHeader = comfyPage.vueNodes.getNodeByTitle('Load Image')
await nodeHeader.click()
await nodeHeader.click({ button: 'right' })
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible()
await expect(contextMenu.getByText('Open Image')).toBeVisible()
await expect(contextMenu.getByText('Copy Image')).toBeVisible()
await expect(contextMenu.getByText('Save Image')).toBeVisible()
await expect(contextMenu.getByText('Open in Mask Editor')).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -12,6 +12,18 @@ This guide covers patterns and examples for testing Vue components in the ComfyU
6. [Asynchronous Component Testing](#asynchronous-component-testing)
7. [Working with Vue Reactivity](#working-with-vue-reactivity)
## Describe Block Naming
Use `Component.__name ?? 'ComponentName'` for the top-level `describe` title. This passes the function reference (satisfying the `prefer-describe-function-title` lint rule) while providing a readable fallback:
```typescript
import MyComponent from './MyComponent.vue'
describe(MyComponent.__name ?? 'MyComponent', () => {
// ...
})
```
## Basic Component Testing
Basic approach to testing a component's rendering and structure:
@@ -21,7 +33,7 @@ Basic approach to testing a component's rendering and structure:
import { mount } from '@vue/test-utils'
import SidebarIcon from './SidebarIcon.vue'
describe('SidebarIcon', () => {
describe(SidebarIcon.__name ?? 'SidebarIcon', () => {
const exampleProps = {
icon: 'pi pi-cog',
selected: false

View File

@@ -138,6 +138,10 @@ export default defineConfig([
'import-x/no-useless-path-segments': 'error',
'import-x/no-relative-packages': 'error',
'unused-imports/no-unused-imports': 'error',
'vue/return-in-computed-property': [
'error',
{ treatUndefinedAsUnspecified: false }
],
'vue/no-v-html': 'off',
// Prohibit dark-theme: and dark: prefixes
'vue/no-restricted-class': ['error', '/^dark(-theme)?:/'],

25
global.d.ts vendored
View File

@@ -10,28 +10,9 @@ interface ImpactQueueFunction {
a?: unknown[][]
}
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
interface GtagGetFieldValueMap {
client_id: string | number | undefined
session_id: string | number | undefined
session_number: string | number | undefined
}
interface GtagFunction {
<TField extends GtagGetFieldName>(
command: 'get',
targetId: string,
fieldName: TField,
callback: (value: GtagGetFieldValueMap[TField]) => void
): void
(...args: unknown[]): void
}
interface Window {
__CONFIG__: {
gtm_container_id?: string
ga_measurement_id?: string
mixpanel_token?: string
require_whitelist?: boolean
subscription_required?: boolean
@@ -55,8 +36,12 @@ interface Window {
badge?: string
}
}
__ga_identity__?: {
client_id?: string
session_id?: string
session_number?: string
}
dataLayer?: Array<Record<string, unknown>>
gtag?: GtagFunction
ire_o?: string
ire?: ImpactQueueFunction
}

View File

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

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9">
<path d="M1.82148 8.68376C1.61587 8.68376 1.44996 8.60733 1.34177 8.46284C1.23057 8.31438 1.20157 8.10711 1.26219 7.89434L1.50561 7.03961C1.52502 6.97155 1.51151 6.89831 1.46918 6.8417C1.42684 6.7852 1.3606 6.75194 1.29025 6.75194H0.590376C0.384656 6.75194 0.21875 6.67562 0.110614 6.53113C-0.000591531 6.38256 -0.0295831 6.17529 0.0310774 5.96252L0.867308 3.03952L0.959638 2.71838C1.08375 2.28258 1.53638 1.9284 1.96878 1.9284H2.80622C2.90615 1.9284 2.99406 1.86177 3.02157 1.76508L3.29852 0.79284C3.4225 0.357484 3.87514 0.0033043 4.30753 0.0033043L6.09854 0.000112775L7.40967 0C7.61533 0 7.78124 0.0763259 7.88937 0.220813C8.00058 0.369269 8.02957 0.576538 7.96895 0.78931L7.59405 2.10572C7.4701 2.54096 7.01746 2.89503 6.58507 2.89503L4.79008 2.89844H3.95292C3.8531 2.89844 3.7653 2.96496 3.73762 3.06155L3.03961 5.49964C3.02008 5.56781 3.03359 5.64127 3.07604 5.69787C3.11837 5.75437 3.18461 5.78763 3.2549 5.78763C3.25507 5.78763 4.44105 5.78532 4.44105 5.78532H5.7483C5.95396 5.78532 6.11986 5.86164 6.228 6.00613C6.33921 6.1547 6.3682 6.36197 6.30754 6.57474L5.93263 7.89092C5.80869 8.32628 5.35605 8.68034 4.92366 8.68034L3.12872 8.68376H1.82148Z" fill="#8A8A8A"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

477
pnpm-lock.yaml generated
View File

@@ -205,14 +205,14 @@ catalogs:
specifier: 22.2.6
version: 22.2.6
oxfmt:
specifier: ^0.26.0
version: 0.26.0
specifier: ^0.31.0
version: 0.31.0
oxlint:
specifier: ^1.33.0
version: 1.33.0
specifier: ^1.46.0
version: 1.46.0
oxlint-tsgolint:
specifier: ^0.9.1
version: 0.9.1
specifier: ^0.11.5
version: 0.11.5
picocolors:
specifier: ^1.1.1
version: 1.1.1
@@ -650,13 +650,13 @@ importers:
version: 22.2.6
oxfmt:
specifier: 'catalog:'
version: 0.26.0
version: 0.31.0
oxlint:
specifier: 'catalog:'
version: 1.33.0(oxlint-tsgolint@0.9.1)
version: 1.46.0(oxlint-tsgolint@0.11.5)
oxlint-tsgolint:
specifier: 'catalog:'
version: 0.9.1
version: 0.11.5
picocolors:
specifier: 'catalog:'
version: 1.1.1
@@ -2626,113 +2626,269 @@ packages:
cpu: [x64]
os: [win32]
'@oxfmt/darwin-arm64@0.26.0':
resolution: {integrity: sha512-AAGc+8CffkiWeVgtWf4dPfQwHEE5c/j/8NWH7VGVxxJRCZFdmWcqCXprvL2H6qZFewvDLrFbuSPRCqYCpYGaTQ==}
'@oxfmt/binding-android-arm-eabi@0.31.0':
resolution: {integrity: sha512-2A7s+TmsY7xF3yM0VWXq2YJ82Z7Rd7AOKraotyp58Fbk7q9cFZKczW6Zrz/iaMaJYfR/UHDxF3kMR11vayflug==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxfmt/binding-android-arm64@0.31.0':
resolution: {integrity: sha512-3ppKOIf2lQv/BFhRyENWs/oarueppCEnPNo0Az2fKkz63JnenRuJPoHaGRrMHg1oFMUitdYy+YH29Cv5ISZWRQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxfmt/binding-darwin-arm64@0.31.0':
resolution: {integrity: sha512-eFhNnle077DPRW6QPsBtl/wEzPoqgsB1LlzDRYbbztizObHdCo6Yo8T0hew9+HoYtnVMAP19zcRE7VG9OfqkMw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxfmt/darwin-x64@0.26.0':
resolution: {integrity: sha512-xFx5ijCTjw577wJvFlZEMmKDnp3HSCcbYdCsLRmC5i3TZZiDe9DEYh3P46uqhzj8BkEw1Vm1ZCWdl48aEYAzvQ==}
'@oxfmt/binding-darwin-x64@0.31.0':
resolution: {integrity: sha512-9UQSunEqokhR1WnlQCgJjkjw13y8PSnBvR98L78beGudTtNSaPMgwE7t/T0IPDibtDTxeEt+IQVKoQJ+8Jo6Lg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxfmt/linux-arm64-gnu@0.26.0':
resolution: {integrity: sha512-GubkQeQT5d3B/Jx/IiR7NMkSmXrCZcVI0BPh1i7mpFi8HgD1hQ/LbhiBKAMsMqs5bbugdQOgBEl8bOhe8JhW1g==}
'@oxfmt/binding-freebsd-x64@0.31.0':
resolution: {integrity: sha512-FHo7ITkDku3kQ8/44nU6IGR1UNH22aqYM3LV2ytV40hWSMVllXFlM+xIVusT+I/SZBAtuFpwEWzyS+Jn4TkkAQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxfmt/binding-linux-arm-gnueabihf@0.31.0':
resolution: {integrity: sha512-o1NiDlJDO9SOoY5wH8AyPUX60yQcOwu5oVuepi2eetArBp0iFF9qIH1uLlZsUu4QQ6ywqxcJSMjXCqGKC5uQFg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm-musleabihf@0.31.0':
resolution: {integrity: sha512-VXiRxlBz7ivAIjhnnVBEYdjCQ66AsjM0YKxYAcliS0vPqhWKiScIT61gee0DPCVaw1XcuW8u19tfRwpfdYoreg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm64-gnu@0.31.0':
resolution: {integrity: sha512-ryGPOtPViNcjs8N8Ap+wn7SM6ViiLzR9f0Pu7yprae+wjl6qwnNytzsUe7wcb+jT43DJYmvemFqE8tLVUavYbQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@oxfmt/linux-arm64-musl@0.26.0':
resolution: {integrity: sha512-OEypUwK69bFPj+aa3/LYCnlIUPgoOLu//WNcriwpnWNmt47808Ht7RJSg+MNK8a7pSZHpXJ5/E6CRK/OTwFdaQ==}
'@oxfmt/binding-linux-arm64-musl@0.31.0':
resolution: {integrity: sha512-BA3Euxp4bfd+AU3cKPgmHL44BbuBtmQTyAQoVDhX/nqPgbS/auoGp71uQBE4SAPTsQM/FcXxfKmCAdBS7ygF9w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@oxfmt/linux-x64-gnu@0.26.0':
resolution: {integrity: sha512-xO6iEW2bC6ZHyOTPmPWrg/nM6xgzyRPaS84rATy6F8d79wz69LdRdJ3l/PXlkqhi7XoxhvX4ExysA0Nf10ZZEQ==}
'@oxfmt/binding-linux-ppc64-gnu@0.31.0':
resolution: {integrity: sha512-wIiKHulVWE9s6PSftPItucTviyCvjugwPqEyUl1VD47YsFqa5UtQTknBN49NODHJvBgO+eqqUodgRqmNMp3xyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.31.0':
resolution: {integrity: sha512-6cM8Jt54bg9V/JoeUWhwnzHAS9Kvgc0oFsxql8PVs/njAGs0H4r+GEU4d+LXZPwI3b3ZUuzpbxlRJzer8KW+Cg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.31.0':
resolution: {integrity: sha512-d+b05wXVRGaO6gobTaDqUdBvTXwYc0ro7k1UVC37k4VimLRQOzEZqTwVinqIX3LxTaFCmfO1yG00u9Pct3AKwQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.31.0':
resolution: {integrity: sha512-Q+i2kj8e+two9jTZ3vxmxdNlg++qShe1ODL6xV4+Qt6SnJYniMxfcqphuXli4ft270kuHqd8HSVZs84CsSh1EA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.31.0':
resolution: {integrity: sha512-F2Z5ffj2okhaQBi92MylwZddKvFPBjrsZnGvvRmVvWRf8WJ0WkKUTtombDgRYNDgoW7GBUUrNNNgWhdB7kVjBA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@oxfmt/linux-x64-musl@0.26.0':
resolution: {integrity: sha512-Z3KuZFC+MIuAyFCXBHY71kCsdRq1ulbsbzTe71v+hrEv7zVBn6yzql+/AZcgfIaKzWO9OXNuz5WWLWDmVALwow==}
'@oxfmt/binding-linux-x64-musl@0.31.0':
resolution: {integrity: sha512-Vz7dZQd1yhE5wTWujGanPmZgDtzLZS1PQoeMmUj89p4eMTmpIkvWaIr3uquJCbh8dQd5cPZrFvMmdDgcY5z+GA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@oxfmt/win32-arm64@0.26.0':
resolution: {integrity: sha512-3zRbqwVWK1mDhRhTknlQFpRFL9GhEB5GfU6U7wawnuEwpvi39q91kJ+SRJvJnhyPCARkjZBd1V8XnweN5IFd1g==}
'@oxfmt/binding-openharmony-arm64@0.31.0':
resolution: {integrity: sha512-nm0gus6R5V9tM1XaELiiIduUzmdBuCefkwToWKL4UtuFoMCGkigVQnbzHwPTGLVWOEF6wTQucFA8Fn1U8hxxVw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxfmt/binding-win32-arm64-msvc@0.31.0':
resolution: {integrity: sha512-mMpvvPpoLD97Q2TMhjWDJSn+ib3kN+H+F4gq9p88zpeef6sqWc9djorJ3JXM2sOZMJ6KZ+1kSJfe0rkji74Pog==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxfmt/win32-x64@0.26.0':
resolution: {integrity: sha512-m8TfIljU22i9UEIkD+slGPifTFeaCwIUfxszN3E6ABWP1KQbtwSw9Ak0TdoikibvukF/dtbeyG3WW63jv9DnEg==}
'@oxfmt/binding-win32-ia32-msvc@0.31.0':
resolution: {integrity: sha512-zTngbPyrTDBYJFVQa4OJldM6w1Rqzi8c0/eFxAEbZRoj6x149GkyMkAY3kM+09ZhmszFitCML2S3p10NE2XmHA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxfmt/binding-win32-x64-msvc@0.31.0':
resolution: {integrity: sha512-TB30D+iRLe6eUbc/utOA93+FNz5C6vXSb/TEhwvlODhKYZZSSKn/lFpYzZC7bdhx3a8m4Jq8fEUoCJ6lKnzdpA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.9.1':
resolution: {integrity: sha512-vk+8kChWqN+F+QUOvp4/6jDTlDCzXPgYGkxdi6EOUSOmCP1ix0uYOlIi/ytH2imXmC8YfPgLR/1BhqbsuDKuew==}
'@oxlint-tsgolint/darwin-arm64@0.11.5':
resolution: {integrity: sha512-mzsjJVIUgcGJovBXME63VW2Uau7MS/xCe7xdYj2BplSCuRb5Yoy7WuwCIlbD5ISHjnS6rx26oD2kmzHLRV5Wfw==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/darwin-x64@0.9.1':
resolution: {integrity: sha512-yXmqr7El17+Oo56fWkPdUluU8d0jWxwRwAe1QZ0Xprxul9FHJeR/O2oYuBUngvCi02dbt0VZlwgJXcljQEdHlQ==}
'@oxlint-tsgolint/darwin-x64@0.11.5':
resolution: {integrity: sha512-zItUS0qLzSzVy0ZQHc4MOphA9lVeP5jffsgZFLCdo+JqmkbVZ14aDtiVUHSHi2hia+qatbb109CHQ9YIl0x7+A==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.9.1':
resolution: {integrity: sha512-ukLb35BHSsxXaVEe8eIvYXMTxOdv8K4CySmtkWyc0pJT0q8zh85by1bsREWAP2hZc0wN0ClHjZHPdKY3958Jwg==}
'@oxlint-tsgolint/linux-arm64@0.11.5':
resolution: {integrity: sha512-R0r/3QTdMtIjfUOM1oxIaCV0s+j7xrnUe4CXo10ZbBzlXfMesWYNcf/oCrhsy87w0kCPFsg58nAdKaIR8xylFg==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@0.9.1':
resolution: {integrity: sha512-kkxSS/meANLun4dHep2wnfvo8OHJKgdxzuY3RoooSWorVqw3/K5Qttmo0OQFt7UNq/oisn0YTaNhV28S0nAWyQ==}
'@oxlint-tsgolint/linux-x64@0.11.5':
resolution: {integrity: sha512-g23J3T29EHWUQYC6aTwLnhwcFtjQh+VfxyGuFjYGGTLhESdlQH9E/pwsN8K9HaAiYWjI51m3r3BqQjXxEW8Jjg==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.9.1':
resolution: {integrity: sha512-F9tiZZRn3x+kjXJC8GAE5C5xkvD8b8unoFeh7mS5W4USAH8+AzYydzLev5rAW2uXdOqtkO30EJl0ygl68Zlb8w==}
'@oxlint-tsgolint/win32-arm64@0.11.5':
resolution: {integrity: sha512-MJNT/MPUIZKQCRtCX5s6pCnoe7If/i3RjJzFMe4kSLomRsHrNFYOJBwt4+w/Hqfyg9jNOgR8tbgdx6ofjHaPMQ==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.9.1':
resolution: {integrity: sha512-DKTBgKUbycKNYgpWpglEHzkgiNVSG1rZmfiqw7w31keAq8q7avNGhz2WNmsRvXh8IGNw1PMb7vgxwUK8eyXIeg==}
'@oxlint-tsgolint/win32-x64@0.11.5':
resolution: {integrity: sha512-IQmj4EkcZOBlLnj1CdxKFrWT7NAWXZ9ypZ874X/w7S5gRzB2sO4KmE6Z0MWxx05pL9AQF+CWVRjZrKVIYWTzPg==}
cpu: [x64]
os: [win32]
'@oxlint/darwin-arm64@1.33.0':
resolution: {integrity: sha512-PmEQDLHAxiAdyttQ1ZWXd+5VpHLbHf3FTMJL9bg5TZamDnhNiW/v0Pamv3MTAdymnoDI3H8IVLAN/SAseV/adw==}
'@oxlint/binding-android-arm-eabi@1.46.0':
resolution: {integrity: sha512-vLPcE+HcZ/W/0cVA1KLuAnoUSejGougDH/fDjBFf0Q+rbBIyBNLevOhgx3AnBNAt3hcIGY7U05ISbJCKZeVa3w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxlint/binding-android-arm64@1.46.0':
resolution: {integrity: sha512-b8IqCczUsirdtJ3R/be4cRm64I5pMPafMO/9xyTAZvc+R/FxZHMQuhw0iNT9hQwRn+Uo5rNAoA8QS7QurG2QeA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxlint/binding-darwin-arm64@1.46.0':
resolution: {integrity: sha512-CfC/KGnNMhI01dkfCMjquKnW4zby3kqD5o/9XA7+pgo9I4b+Nipm+JVFyZPWMNwKqLXNmi35GTLWjs9svPxlew==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxlint/darwin-x64@1.33.0':
resolution: {integrity: sha512-2R9aH3kR0X2M30z5agGikv3tfNTi8/uLhU5/tYktu33VGUXpbf0OLZSlD25UEuwOKAlf3RVtzV5oDyjoq93JuQ==}
'@oxlint/binding-darwin-x64@1.46.0':
resolution: {integrity: sha512-m38mKPsV3rBdWOJ4TAGZiUjWU8RGrBxsmdSeMQ0bPr/8O6CUOm/RJkPBf0GAfPms2WRVcbkfEXvIiPshAeFkeA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxlint/linux-arm64-gnu@1.33.0':
resolution: {integrity: sha512-yb/k8GaMDgnX2LyO6km33kKItZ/n573SlbiHBBFU2HmeU7tzEHL5jHkHQXXcysUkapmqHd7UsDhOZDqPmXaQRg==}
'@oxlint/binding-freebsd-x64@1.46.0':
resolution: {integrity: sha512-YaFRKslSAfuMwn7ejS1/wo9jENqQigpGBjjThX+mrpmEROLYGky+zIC5xSVGRng28U92VEDVbSNJ/sguz3dUAA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxlint/binding-linux-arm-gnueabihf@1.46.0':
resolution: {integrity: sha512-Nlw+5mSZQtkg1Oj0N8ulxzG8ATpmSDz5V2DNaGhaYAVlcdR8NYSm/xTOnweOXc/UOOv3LwkPPYzqcfPhu2lEkA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm-musleabihf@1.46.0':
resolution: {integrity: sha512-d3Y5y4ukMqAGnWLMKpwqj8ftNUaac7pA0NrId4AZ77JvHzezmxEcm2gswaBw2HW8y1pnq6KDB0vEPPvpTfDLrA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm64-gnu@1.46.0':
resolution: {integrity: sha512-jkjx+XSOPuFR+C458prQmehO+v0VK19/3Hj2mOYDF4hHUf3CzmtA4fTmQUtkITZiGHnky7Oao6JeJX24mrX7WQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@oxlint/linux-arm64-musl@1.33.0':
resolution: {integrity: sha512-03pt9IO1C4ZfVOW6SQiOK26mzklAhLM3Kc79OXpX1kgZRlxk+rvFoMhlgCOzn7tEdrEgbePkBoxNnwDnJDFqJQ==}
'@oxlint/binding-linux-arm64-musl@1.46.0':
resolution: {integrity: sha512-X/aPB1rpJUdykjWSeeGIbjk6qbD8VDulgLuTSMWgr/t6m1ljcAjqHb1g49pVG9bZl55zjECgzvlpPLWnfb4FMQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
'@oxlint/linux-x64-gnu@1.33.0':
resolution: {integrity: sha512-Z7ImLWM50FoVXzYvyxUQ+QwBkBfRyK4YdLEGonyAGMp7iT3DksonDaTK9ODnJ1qHyAyAZCvuqXD7AEDsDvzDbA==}
'@oxlint/binding-linux-ppc64-gnu@1.46.0':
resolution: {integrity: sha512-AymyOxGWwKY2KJa8b+h8iLrYJZbWKYCjqctSc2q6uIAkYPrCsxcWlge1JP6XZ14Sa80DVMwI/QvktbytSV+xVw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.46.0':
resolution: {integrity: sha512-PkeVdPKCDA59rlMuucsel2LjlNEpslQN5AhkMMorIJZItbbqi/0JSuACCzaiIcXYv0oNfbeQ8rbOBikv+aT6cg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.46.0':
resolution: {integrity: sha512-snQaRLO/X+Ry/CxX1px1g8GUbmXzymdRs+/RkP2bySHWZFhFDtbLm2hA1ujX/jKlTLMJDZn4hYzFGLDwG/Rh2w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.46.0':
resolution: {integrity: sha512-kZhDMwUe/sgDTluGao9c0Dqc1JzV6wPzfGo0l/FLQdh5Zmp39Yg1FbBsCgsJfVKmKl1fNqsHyFLTShWMOlOEhA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.46.0':
resolution: {integrity: sha512-n5a7VtQTxHZ13cNAKQc3ziARv5bE1Fx868v/tnhZNVUjaRNYe5uiUrRJ/LZghdAzOxVuQGarjjq/q4QM2+9OPA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@oxlint/linux-x64-musl@1.33.0':
resolution: {integrity: sha512-idb55Uzu5kkqqpMiVUfI9nP7zOqPZinQKsIRQAIU40wILcf/ijvhNZKIu3ucDMmye0n6IWOaSnxIRL5W2fNoUQ==}
'@oxlint/binding-linux-x64-musl@1.46.0':
resolution: {integrity: sha512-KpsDU/BhdVn3iKCLxMXAOZIpO8fS0jEA5iluRoK1rhHPwKtpzEm/OCwERsu/vboMSZm66qnoTUVXRPJ8M+iKVQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
'@oxlint/win32-arm64@1.33.0':
resolution: {integrity: sha512-wKKFt7cubfrLelNzdmDsNSmtBrlSUe1fWus587+uSxDZdpFbQ7liU0gsUlCbcHvym0H1Tc2O3K3cnLrgQORLPQ==}
'@oxlint/binding-openharmony-arm64@1.46.0':
resolution: {integrity: sha512-jtbqUyEXlsDlRmMtTZqNbw49+1V/WxqNAR5l0S3OEkdat9diI5I+eqq9IT+jb5cSDdszTGcXpn7S3+gUYSydxQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxlint/binding-win32-arm64-msvc@1.46.0':
resolution: {integrity: sha512-EE8NjpqEZPwHQVigNvdyJ11dZwWIfsfn4VeBAuiJeAdrnY4HFX27mIjJINJgP5ZdBYEFV1OWH/eb9fURCYel8w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxlint/win32-x64@1.33.0':
resolution: {integrity: sha512-ReyR8rNHjKNnO7dxGny9RCPELRAdhm3y780FNBcA07E1wvxSCkB+Mn5db0Pa5bRmxrsU/MTZ/aaBFa+ERXDdXw==}
'@oxlint/binding-win32-ia32-msvc@1.46.0':
resolution: {integrity: sha512-BHyk3H/HRdXs+uImGZ/2+qCET+B8lwGHOm7m54JiJEEUWf3zYCFX/Df1SPqtozWWmnBvioxoTG1J3mPRAr8KUA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxlint/binding-win32-x64-msvc@1.46.0':
resolution: {integrity: sha512-DJbQsSJUr4KSi9uU0QqOgI7PX2C+fKGZX+YDprt3vM2sC0dWZsgVTLoN2vtkNyEWJSY2mnvRFUshWXT3bmo0Ug==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -6842,21 +6998,21 @@ packages:
oxc-resolver@11.15.0:
resolution: {integrity: sha512-Hk2J8QMYwmIO9XTCUiOH00+Xk2/+aBxRUnhrSlANDyCnLYc32R1WSIq1sU2yEdlqd53FfMpPEpnBYIKQMzliJw==}
oxfmt@0.26.0:
resolution: {integrity: sha512-UDD1wFNwfeorMm2ZY0xy1KRAAvJ5NjKBfbDmiMwGP7baEHTq65cYpC0aPP+BGHc8weXUbSZaK8MdGyvuRUvS4Q==}
oxfmt@0.31.0:
resolution: {integrity: sha512-ukl7nojEuJUGbqR4ijC0Z/7a6BYpD4RxLS2UsyJKgbeZfx6TNrsa48veG0z2yQbhTx1nVnes4GIcqMn7n2jFtw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
oxlint-tsgolint@0.9.1:
resolution: {integrity: sha512-w1lIvUDkkiAPFyo268SFGrdh1LQ3Lcs1XShES7I4X75TliQA0os5XJ5hNZ4lYsSevqcofgEtq4xq7rBumv69iQ==}
oxlint-tsgolint@0.11.5:
resolution: {integrity: sha512-4uVv43EhkeMvlxDU1GUsR5P5c0q74rB/pQRhjGsTOnMIrDbg3TABTntRyeAkmXItqVEJTcDRv9+Yk+LFXkHKlg==}
hasBin: true
oxlint@1.33.0:
resolution: {integrity: sha512-4WCL0K8jiOshwJ8WrVk35VAuVaZHC0iX6asjKsrENOrynkAAGcTLLx0Urf0eXZ1Tq7r+qAe3Z9EyHMFPzVyUkg==}
oxlint@1.46.0:
resolution: {integrity: sha512-I9h42QDtAVsRwoueJ4PL/7qN5jFzIUXvbO4Z5ddtII92ZCiD7uiS/JW2V4viBSfGLsbZkQp3YEs6Ls4I8q+8tA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.9.0'
oxlint-tsgolint: '>=0.11.2'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
@@ -7447,6 +7603,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -7757,8 +7918,8 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tinypool@2.0.0:
resolution: {integrity: sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg==}
tinypool@2.1.0:
resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==}
engines: {node: ^20.0.0 || >=22.0.0}
tinyrainbow@2.0.0:
@@ -10726,70 +10887,136 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.15.0':
optional: true
'@oxfmt/darwin-arm64@0.26.0':
'@oxfmt/binding-android-arm-eabi@0.31.0':
optional: true
'@oxfmt/darwin-x64@0.26.0':
'@oxfmt/binding-android-arm64@0.31.0':
optional: true
'@oxfmt/linux-arm64-gnu@0.26.0':
'@oxfmt/binding-darwin-arm64@0.31.0':
optional: true
'@oxfmt/linux-arm64-musl@0.26.0':
'@oxfmt/binding-darwin-x64@0.31.0':
optional: true
'@oxfmt/linux-x64-gnu@0.26.0':
'@oxfmt/binding-freebsd-x64@0.31.0':
optional: true
'@oxfmt/linux-x64-musl@0.26.0':
'@oxfmt/binding-linux-arm-gnueabihf@0.31.0':
optional: true
'@oxfmt/win32-arm64@0.26.0':
'@oxfmt/binding-linux-arm-musleabihf@0.31.0':
optional: true
'@oxfmt/win32-x64@0.26.0':
'@oxfmt/binding-linux-arm64-gnu@0.31.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.9.1':
'@oxfmt/binding-linux-arm64-musl@0.31.0':
optional: true
'@oxlint-tsgolint/darwin-x64@0.9.1':
'@oxfmt/binding-linux-ppc64-gnu@0.31.0':
optional: true
'@oxlint-tsgolint/linux-arm64@0.9.1':
'@oxfmt/binding-linux-riscv64-gnu@0.31.0':
optional: true
'@oxlint-tsgolint/linux-x64@0.9.1':
'@oxfmt/binding-linux-riscv64-musl@0.31.0':
optional: true
'@oxlint-tsgolint/win32-arm64@0.9.1':
'@oxfmt/binding-linux-s390x-gnu@0.31.0':
optional: true
'@oxlint-tsgolint/win32-x64@0.9.1':
'@oxfmt/binding-linux-x64-gnu@0.31.0':
optional: true
'@oxlint/darwin-arm64@1.33.0':
'@oxfmt/binding-linux-x64-musl@0.31.0':
optional: true
'@oxlint/darwin-x64@1.33.0':
'@oxfmt/binding-openharmony-arm64@0.31.0':
optional: true
'@oxlint/linux-arm64-gnu@1.33.0':
'@oxfmt/binding-win32-arm64-msvc@0.31.0':
optional: true
'@oxlint/linux-arm64-musl@1.33.0':
'@oxfmt/binding-win32-ia32-msvc@0.31.0':
optional: true
'@oxlint/linux-x64-gnu@1.33.0':
'@oxfmt/binding-win32-x64-msvc@0.31.0':
optional: true
'@oxlint/linux-x64-musl@1.33.0':
'@oxlint-tsgolint/darwin-arm64@0.11.5':
optional: true
'@oxlint/win32-arm64@1.33.0':
'@oxlint-tsgolint/darwin-x64@0.11.5':
optional: true
'@oxlint/win32-x64@1.33.0':
'@oxlint-tsgolint/linux-arm64@0.11.5':
optional: true
'@oxlint-tsgolint/linux-x64@0.11.5':
optional: true
'@oxlint-tsgolint/win32-arm64@0.11.5':
optional: true
'@oxlint-tsgolint/win32-x64@0.11.5':
optional: true
'@oxlint/binding-android-arm-eabi@1.46.0':
optional: true
'@oxlint/binding-android-arm64@1.46.0':
optional: true
'@oxlint/binding-darwin-arm64@1.46.0':
optional: true
'@oxlint/binding-darwin-x64@1.46.0':
optional: true
'@oxlint/binding-freebsd-x64@1.46.0':
optional: true
'@oxlint/binding-linux-arm-gnueabihf@1.46.0':
optional: true
'@oxlint/binding-linux-arm-musleabihf@1.46.0':
optional: true
'@oxlint/binding-linux-arm64-gnu@1.46.0':
optional: true
'@oxlint/binding-linux-arm64-musl@1.46.0':
optional: true
'@oxlint/binding-linux-ppc64-gnu@1.46.0':
optional: true
'@oxlint/binding-linux-riscv64-gnu@1.46.0':
optional: true
'@oxlint/binding-linux-riscv64-musl@1.46.0':
optional: true
'@oxlint/binding-linux-s390x-gnu@1.46.0':
optional: true
'@oxlint/binding-linux-x64-gnu@1.46.0':
optional: true
'@oxlint/binding-linux-x64-musl@1.46.0':
optional: true
'@oxlint/binding-openharmony-arm64@1.46.0':
optional: true
'@oxlint/binding-win32-arm64-msvc@1.46.0':
optional: true
'@oxlint/binding-win32-ia32-msvc@1.46.0':
optional: true
'@oxlint/binding-win32-x64-msvc@1.46.0':
optional: true
'@phenomnomnominal/tsquery@5.0.1(typescript@5.9.3)':
@@ -14489,7 +14716,7 @@ snapshots:
acorn: 8.15.0
eslint-visitor-keys: 3.4.3
espree: 9.6.1
semver: 7.7.3
semver: 7.7.4
jsonc-parser@3.2.0: {}
@@ -15512,39 +15739,61 @@ snapshots:
'@oxc-resolver/binding-win32-ia32-msvc': 11.15.0
'@oxc-resolver/binding-win32-x64-msvc': 11.15.0
oxfmt@0.26.0:
oxfmt@0.31.0:
dependencies:
tinypool: 2.0.0
tinypool: 2.1.0
optionalDependencies:
'@oxfmt/darwin-arm64': 0.26.0
'@oxfmt/darwin-x64': 0.26.0
'@oxfmt/linux-arm64-gnu': 0.26.0
'@oxfmt/linux-arm64-musl': 0.26.0
'@oxfmt/linux-x64-gnu': 0.26.0
'@oxfmt/linux-x64-musl': 0.26.0
'@oxfmt/win32-arm64': 0.26.0
'@oxfmt/win32-x64': 0.26.0
'@oxfmt/binding-android-arm-eabi': 0.31.0
'@oxfmt/binding-android-arm64': 0.31.0
'@oxfmt/binding-darwin-arm64': 0.31.0
'@oxfmt/binding-darwin-x64': 0.31.0
'@oxfmt/binding-freebsd-x64': 0.31.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.31.0
'@oxfmt/binding-linux-arm-musleabihf': 0.31.0
'@oxfmt/binding-linux-arm64-gnu': 0.31.0
'@oxfmt/binding-linux-arm64-musl': 0.31.0
'@oxfmt/binding-linux-ppc64-gnu': 0.31.0
'@oxfmt/binding-linux-riscv64-gnu': 0.31.0
'@oxfmt/binding-linux-riscv64-musl': 0.31.0
'@oxfmt/binding-linux-s390x-gnu': 0.31.0
'@oxfmt/binding-linux-x64-gnu': 0.31.0
'@oxfmt/binding-linux-x64-musl': 0.31.0
'@oxfmt/binding-openharmony-arm64': 0.31.0
'@oxfmt/binding-win32-arm64-msvc': 0.31.0
'@oxfmt/binding-win32-ia32-msvc': 0.31.0
'@oxfmt/binding-win32-x64-msvc': 0.31.0
oxlint-tsgolint@0.9.1:
oxlint-tsgolint@0.11.5:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.9.1
'@oxlint-tsgolint/darwin-x64': 0.9.1
'@oxlint-tsgolint/linux-arm64': 0.9.1
'@oxlint-tsgolint/linux-x64': 0.9.1
'@oxlint-tsgolint/win32-arm64': 0.9.1
'@oxlint-tsgolint/win32-x64': 0.9.1
'@oxlint-tsgolint/darwin-arm64': 0.11.5
'@oxlint-tsgolint/darwin-x64': 0.11.5
'@oxlint-tsgolint/linux-arm64': 0.11.5
'@oxlint-tsgolint/linux-x64': 0.11.5
'@oxlint-tsgolint/win32-arm64': 0.11.5
'@oxlint-tsgolint/win32-x64': 0.11.5
oxlint@1.33.0(oxlint-tsgolint@0.9.1):
oxlint@1.46.0(oxlint-tsgolint@0.11.5):
optionalDependencies:
'@oxlint/darwin-arm64': 1.33.0
'@oxlint/darwin-x64': 1.33.0
'@oxlint/linux-arm64-gnu': 1.33.0
'@oxlint/linux-arm64-musl': 1.33.0
'@oxlint/linux-x64-gnu': 1.33.0
'@oxlint/linux-x64-musl': 1.33.0
'@oxlint/win32-arm64': 1.33.0
'@oxlint/win32-x64': 1.33.0
oxlint-tsgolint: 0.9.1
'@oxlint/binding-android-arm-eabi': 1.46.0
'@oxlint/binding-android-arm64': 1.46.0
'@oxlint/binding-darwin-arm64': 1.46.0
'@oxlint/binding-darwin-x64': 1.46.0
'@oxlint/binding-freebsd-x64': 1.46.0
'@oxlint/binding-linux-arm-gnueabihf': 1.46.0
'@oxlint/binding-linux-arm-musleabihf': 1.46.0
'@oxlint/binding-linux-arm64-gnu': 1.46.0
'@oxlint/binding-linux-arm64-musl': 1.46.0
'@oxlint/binding-linux-ppc64-gnu': 1.46.0
'@oxlint/binding-linux-riscv64-gnu': 1.46.0
'@oxlint/binding-linux-riscv64-musl': 1.46.0
'@oxlint/binding-linux-s390x-gnu': 1.46.0
'@oxlint/binding-linux-x64-gnu': 1.46.0
'@oxlint/binding-linux-x64-musl': 1.46.0
'@oxlint/binding-openharmony-arm64': 1.46.0
'@oxlint/binding-win32-arm64-msvc': 1.46.0
'@oxlint/binding-win32-ia32-msvc': 1.46.0
'@oxlint/binding-win32-x64-msvc': 1.46.0
oxlint-tsgolint: 0.11.5
p-limit@3.1.0:
dependencies:
@@ -16282,6 +16531,8 @@ snapshots:
semver@7.7.3: {}
semver@7.7.4: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -16682,7 +16933,7 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tinypool@2.0.0: {}
tinypool@2.1.0: {}
tinyrainbow@2.0.0: {}

View File

@@ -69,9 +69,9 @@ catalog:
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.2.6
oxfmt: ^0.26.0
oxlint: ^1.33.0
oxlint-tsgolint: ^0.9.1
oxfmt: ^0.31.0
oxlint: ^1.46.0
oxlint-tsgolint: ^0.11.5
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0

View File

@@ -33,6 +33,7 @@ fi
EXCLUDE_PATTERNS=(
'**/tsconfig*.json'
'.oxlintrc.json'
)
if [ -n "${JSON_LINT_EXCLUDES:-}" ]; then

View File

@@ -16,12 +16,12 @@ import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { electronAPI } from '@/utils/envUtil'
import { isDesktop } from '@/platform/distribution/types'
import { app } from '@/scripts/app'
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()

View File

@@ -49,7 +49,7 @@ describe('downloadUtil', () => {
vi.unstubAllGlobals()
})
describe('downloadFile', () => {
describe(downloadFile, () => {
it('should create and trigger download with basic URL', () => {
const testUrl = 'https://example.com/image.png'
@@ -285,7 +285,7 @@ describe('downloadUtil', () => {
})
})
describe('extractFilenameFromContentDisposition', () => {
describe(extractFilenameFromContentDisposition, () => {
it('returns null for null header', () => {
expect(extractFilenameFromContentDisposition(null)).toBeNull()
})

View File

@@ -172,19 +172,17 @@ const splitterRefreshKey = computed(() => {
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
})
const firstPanelStyle = computed(() => {
if (sidebarLocation.value === 'left') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
return undefined
})
const firstPanelStyle = computed(() =>
sidebarLocation.value === 'left'
? { display: sidebarPanelVisible.value ? 'flex' : 'none' }
: undefined
)
const lastPanelStyle = computed(() => {
if (sidebarLocation.value === 'right') {
return { display: sidebarPanelVisible.value ? 'flex' : 'none' }
}
return undefined
})
const lastPanelStyle = computed(() =>
sidebarLocation.value === 'right'
? { display: sidebarPanelVisible.value ? 'flex' : 'none' }
: undefined
)
</script>
<style scoped>

View File

@@ -2,12 +2,11 @@ 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, ref } from 'vue'
import { computed, defineComponent, h, nextTick, onMounted } from 'vue'
import type { Component } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import type {
@@ -20,11 +19,7 @@ import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const mockData = vi.hoisted(() => ({
isLoggedIn: false,
isDesktop: false,
setShowConflictRedDot: (_value: boolean) => {}
}))
const mockData = vi.hoisted(() => ({ isLoggedIn: false, isDesktop: false }))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => {
@@ -41,36 +36,6 @@ vi.mock('@/platform/distribution/types', () => ({
return mockData.isDesktop
}
}))
vi.mock('@/platform/updates/common/releaseStore', () => ({
useReleaseStore: () => ({
shouldShowRedDot: computed(() => true)
})
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
() => {
const shouldShowConflictRedDot = ref(false)
mockData.setShowConflictRedDot = (value: boolean) => {
shouldShowConflictRedDot.value = value
}
return {
useConflictAcknowledgment: () => ({
shouldShowRedDot: shouldShowConflictRedDot
})
}
}
)
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
shouldShowManagerButtons: computed(() => true),
openManager: vi.fn()
})
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
currentUser: null,
@@ -114,7 +79,6 @@ function createWrapper({
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
QueueInlineProgressSummary: true,
QueueNotificationBannerHost: true,
CurrentUserButton: true,
LoginButton: true,
ContextMenu: {
@@ -144,25 +108,12 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl(createJob(id, status))
}
function createComfyActionbarStub(actionbarTarget: HTMLElement) {
return defineComponent({
name: 'ComfyActionbar',
setup(_, { emit }) {
onMounted(() => {
emit('update:progressTarget', actionbarTarget)
})
return () => h('div')
}
})
}
describe('TopMenuSection', () => {
describe(TopMenuSection.__name ?? 'TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
mockData.isDesktop = false
mockData.isLoggedIn = false
mockData.setShowConflictRedDot(false)
})
describe('authentication state', () => {
@@ -215,17 +166,6 @@ describe('TopMenuSection', () => {
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
expect(queueButton.text()).toContain('3 active')
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
true
)
})
it('hides the active jobs indicator when no jobs are active', () => {
const wrapper = createWrapper()
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
false
)
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
@@ -302,7 +242,7 @@ describe('TopMenuSection', () => {
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.Queue.QPOV2') return qpoV2Enabled
if (key === 'Comfy.UseNewMenu') return 'Top'
return undefined
return
})
}
@@ -341,7 +281,15 @@ describe('TopMenuSection', () => {
const executionStore = useExecutionStore(pinia)
executionStore.activePromptId = 'prompt-1'
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
const ComfyActionbarStub = defineComponent({
name: 'ComfyActionbar',
setup(_, { emit }) {
onMounted(() => {
emit('update:progressTarget', actionbarTarget)
})
return () => h('div')
}
})
const wrapper = createWrapper({
pinia,
@@ -363,103 +311,6 @@ describe('TopMenuSection', () => {
})
})
describe(QueueNotificationBannerHost, () => {
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 queue notification banners 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: 'QueueNotificationBannerHost' }).exists()
).toBe(true)
})
it('renders queue notification banners 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: 'QueueNotificationBannerHost' }).exists()
).toBe(true)
})
it('renders inline summary above banners when both are visible', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
configureSettings(pinia, true)
const wrapper = createWrapper({ pinia })
await nextTick()
const html = wrapper.html()
const inlineSummaryIndex = html.indexOf(
'queue-inline-progress-summary-stub'
)
const queueBannerIndex = html.indexOf(
'queue-notification-banner-host-stub'
)
expect(inlineSummaryIndex).toBeGreaterThan(-1)
expect(queueBannerIndex).toBeGreaterThan(-1)
expect(inlineSummaryIndex).toBeLessThan(queueBannerIndex)
})
it('does not teleport queue notification banners 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 = createComfyActionbarStub(actionbarTarget)
const wrapper = createWrapper({
pinia,
attachTo: document.body,
stubs: {
ComfyActionbar: ComfyActionbarStub,
QueueNotificationBannerHost: true
}
})
try {
await nextTick()
expect(
actionbarTarget.querySelector('queue-notification-banner-host-stub')
).toBeNull()
expect(
wrapper
.findComponent({ name: 'QueueNotificationBannerHost' })
.exists()
).toBe(true)
} 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' })
@@ -479,16 +330,4 @@ describe('TopMenuSection', () => {
const model = menu.props('model') as MenuItem[]
expect(model[0]?.disabled).toBe(false)
})
it('shows manager red dot only for manager conflicts', async () => {
const wrapper = createWrapper()
// Release red dot is mocked as true globally for this test file.
expect(wrapper.find('span.bg-red-500').exists()).toBe(false)
mockData.setShowConflictRedDot(true)
await nextTick()
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
})
})

View File

@@ -36,14 +36,7 @@
<div
ref="actionbarContainerRef"
:class="
cn(
'actionbar-container relative pointer-events-auto flex gap-2 h-12 items-center rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
)
"
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 -->
@@ -67,7 +60,7 @@
? isQueueOverlayExpanded
: undefined
"
class="relative px-3"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
@@ -75,12 +68,6 @@
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
@@ -118,7 +105,7 @@
</div>
</div>
<div class="flex flex-col items-end gap-1">
<div>
<Teleport
v-if="inlineProgressSummaryTarget"
:to="inlineProgressSummaryTarget"
@@ -134,10 +121,6 @@
class="pr-1"
:hidden="isQueueOverlayExpanded"
/>
<QueueNotificationBannerHost
v-if="shouldShowQueueNotificationBanners"
class="pr-1"
/>
</div>
</div>
</template>
@@ -152,9 +135,7 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgressSummary from '@/components/queue/QueueInlineProgressSummary.vue'
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
@@ -164,6 +145,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -175,7 +157,6 @@ import { isDesktop } from '@/platform/distribution/types'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import { cn } from '@/utils/tailwindUtil'
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
@@ -192,6 +173,8 @@ const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const releaseStore = useReleaseStore()
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
@@ -224,9 +207,6 @@ const isQueueProgressOverlayEnabled = computed(
const shouldShowInlineProgressSummary = computed(
() => isQueuePanelV2Enabled.value && isActionbarEnabled.value
)
const shouldShowQueueNotificationBanners = computed(
() => isActionbarEnabled.value
)
const progressTarget = ref<HTMLElement | null>(null)
function updateProgressTarget(target: HTMLElement | null) {
progressTarget.value = target
@@ -256,12 +236,12 @@ const queueContextMenuItems = computed<MenuItem[]>(() => [
}
])
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
return shouldShowConflictRedDot.value
const releaseRedDot = showReleaseRedDot.value
return releaseRedDot || shouldShowConflictRedDot.value
})
const { hasAnyError } = storeToRefs(executionStore)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const rightSidePanelTooltipConfig = computed(() =>
@@ -307,9 +287,8 @@ const openCustomNodeManager = async () => {
} catch (error) {
try {
toastErrorHandler(error)
} catch (toastError) {
} catch (error) {
console.error(error)
console.error(toastError)
}
}
}

View File

@@ -54,7 +54,7 @@ vi.mock('primevue/progressspinner', () => ({
default: { template: '<div class="progress-spinner" />' }
}))
describe('WorkspaceAuthGate', () => {
describe(WorkspaceAuthGate.__name ?? 'WorkspaceAuthGate', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCloud.value = true

View File

@@ -89,12 +89,12 @@ import { useI18n } from 'vue-i18n'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogService } from '@/services/dialogService'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import type { BottomPanelExtension } from '@/types/extensionTypes'
const bottomPanelStore = useBottomPanelStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const { t } = useI18n()
const isShortcutsTabActive = computed(() => {
@@ -115,7 +115,7 @@ const getTabDisplayTitle = (tab: BottomPanelExtension): string => {
}
const openKeybindingSettings = async () => {
settingsDialog.show('keybinding')
dialogService.showSettingsDialog('keybinding')
}
const closeBottomPanel = () => {

View File

@@ -51,7 +51,7 @@ vi.mock('@/stores/commandStore', () => ({
})
}))
describe('EssentialsPanel', () => {
describe(EssentialsPanel.__name ?? 'EssentialsPanel', () => {
beforeEach(() => {
setActivePinia(createPinia())
})

View File

@@ -23,7 +23,7 @@ vi.mock('vue-i18n', () => ({
})
}))
describe('ShortcutsList', () => {
describe(ShortcutsList.__name ?? 'ShortcutsList', () => {
const mockCommands: ComfyCommandImpl[] = [
{
id: 'Workflow.New',

View File

@@ -106,7 +106,7 @@ const mountBaseTerminal = () => {
})
}
describe('BaseTerminal', () => {
describe(BaseTerminal.__name ?? 'BaseTerminal', () => {
let wrapper: VueWrapper<InstanceType<typeof BaseTerminal>> | undefined
beforeEach(() => {

View File

@@ -62,8 +62,8 @@ const terminalCreated = (
onMounted(async () => {
try {
await loadLogEntries()
} catch (err) {
console.error('Error loading logs', err)
} catch (error) {
console.error('Error loading logs', error)
// On older backends the endpoints won't exist
errorMessage.value =
'Unable to load logs, please ensure you have updated your ComfyUI backend.'

View File

@@ -3,26 +3,49 @@
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.x') }}
</label>
<ScrubableNumberInput v-model="x" :min="0" :step="1" />
<input
v-model.number="x"
type="number"
:min="0"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.y') }}
</label>
<ScrubableNumberInput v-model="y" :min="0" :step="1" />
<input
v-model.number="y"
type="number"
:min="0"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.width') }}
</label>
<ScrubableNumberInput v-model="width" :min="1" :step="1" />
<input
v-model.number="width"
type="number"
:min="1"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.height') }}
</label>
<ScrubableNumberInput v-model="height" :min="1" :step="1" />
<input
v-model.number="height"
type="number"
:min="1"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import type { Bounds } from '@/renderer/core/layout/types'
const modelValue = defineModel<Bounds>({

View File

@@ -78,9 +78,7 @@ interface Props {
isActive?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const { item, isActive = false } = defineProps<Props>()
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
@@ -103,7 +101,7 @@ const rename = async (
) => {
if (newName && newName !== initialName) {
// Synchronize the node titles with the new name
props.item.updateTitle?.(newName)
item.updateTitle?.(newName)
if (workflowStore.activeSubgraph) {
workflowStore.activeSubgraph.name = newName
@@ -127,13 +125,13 @@ const rename = async (
}
}
const isRoot = props.item.key === 'root'
const isRoot = item.key === 'root'
const tooltipText = computed(() => {
if (hasMissingNodes.value && isRoot) {
return t('breadcrumbsMenu.missingNodesWarning')
}
return props.item.label
return item.label
})
const startRename = async () => {
@@ -145,7 +143,7 @@ const startRename = async () => {
}
isEditing.value = true
itemLabel.value = props.item.label as string
itemLabel.value = item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
@@ -165,12 +163,12 @@ const handleClick = (event: MouseEvent) => {
}
if (event.detail === 1) {
if (props.isActive) {
if (isActive) {
menu.value?.toggle(event)
} else {
props.item.command?.({ item: props.item, originalEvent: event })
item.command?.({ item, originalEvent: event })
}
} else if (props.isActive && event.detail === 2) {
} else if (isActive && event.detail === 2) {
menu.value?.hide()
event.stopPropagation()
event.preventDefault()
@@ -180,7 +178,7 @@ const handleClick = (event: MouseEvent) => {
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, props.item.label as string)
await rename(itemLabel.value, item.label as string)
}
isEditing.value = false

View File

@@ -7,123 +7,128 @@ import { createApp, nextTick } from 'vue'
import ColorCustomizationSelector from './ColorCustomizationSelector.vue'
describe('ColorCustomizationSelector', () => {
const colorOptions = [
{ name: 'Blue', value: '#0d6efd' },
{ name: 'Green', value: '#28a745' }
]
describe(
ColorCustomizationSelector.__name ?? 'ColorCustomizationSelector',
() => {
const colorOptions = [
{ name: 'Blue', value: '#0d6efd' },
{ name: 'Green', value: '#28a745' }
]
beforeEach(() => {
// Setup PrimeVue
const app = createApp({})
app.use(PrimeVue)
})
beforeEach(() => {
// Setup PrimeVue
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (props = {}) => {
return mount(ColorCustomizationSelector, {
global: {
plugins: [PrimeVue],
components: { SelectButton, ColorPicker }
},
props: {
modelValue: null,
colorOptions,
...props
}
const mountComponent = (props = {}) => {
return mount(ColorCustomizationSelector, {
global: {
plugins: [PrimeVue],
components: { SelectButton, ColorPicker }
},
props: {
modelValue: null,
colorOptions,
...props
}
})
}
it('renders predefined color options and custom option', () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('options')).toHaveLength(
colorOptions.length + 1
)
expect(selectButton.props('options')?.at(-1)?.name).toBe('_custom')
})
it('initializes with predefined color when provided', async () => {
const wrapper = mountComponent({
modelValue: '#0d6efd'
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: 'Blue',
value: '#0d6efd'
})
})
it('initializes with custom color when non-predefined color provided', async () => {
const wrapper = mountComponent({
modelValue: '#123456'
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
const colorPicker = wrapper.findComponent(ColorPicker)
expect(selectButton.props('modelValue').name).toBe('_custom')
expect(colorPicker.props('modelValue')).toBe('123456')
})
it('shows color picker when custom option is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
expect(wrapper.findComponent(ColorPicker).exists()).toBe(true)
})
it('emits update when predefined color is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
await selectButton.setValue(colorOptions[0])
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#0d6efd'])
})
it('emits update when custom color is changed', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
// Change custom color
const colorPicker = wrapper.findComponent(ColorPicker)
await colorPicker.setValue('ff0000')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#ff0000'])
})
it('inherits color from previous selection when switching to custom', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// First select a predefined color
await selectButton.setValue(colorOptions[0])
// Then switch to custom
await selectButton.setValue({ name: '_custom', value: '' })
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.props('modelValue')).toBe('0d6efd')
})
it('handles null modelValue correctly', async () => {
const wrapper = mountComponent({
modelValue: null
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: '_custom',
value: ''
})
})
}
it('renders predefined color options and custom option', () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('options')).toHaveLength(colorOptions.length + 1)
expect(selectButton.props('options')?.at(-1)?.name).toBe('_custom')
})
it('initializes with predefined color when provided', async () => {
const wrapper = mountComponent({
modelValue: '#0d6efd'
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: 'Blue',
value: '#0d6efd'
})
})
it('initializes with custom color when non-predefined color provided', async () => {
const wrapper = mountComponent({
modelValue: '#123456'
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
const colorPicker = wrapper.findComponent(ColorPicker)
expect(selectButton.props('modelValue').name).toBe('_custom')
expect(colorPicker.props('modelValue')).toBe('123456')
})
it('shows color picker when custom option is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
expect(wrapper.findComponent(ColorPicker).exists()).toBe(true)
})
it('emits update when predefined color is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
await selectButton.setValue(colorOptions[0])
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#0d6efd'])
})
it('emits update when custom color is changed', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
// Change custom color
const colorPicker = wrapper.findComponent(ColorPicker)
await colorPicker.setValue('ff0000')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#ff0000'])
})
it('inherits color from previous selection when switching to custom', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// First select a predefined color
await selectButton.setValue(colorOptions[0])
// Then switch to custom
await selectButton.setValue({ name: '_custom', value: '' })
const colorPicker = wrapper.findComponent(ColorPicker)
expect(colorPicker.props('modelValue')).toBe('0d6efd')
})
it('handles null modelValue correctly', async () => {
const wrapper = mountComponent({
modelValue: null
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: '_custom',
value: ''
})
})
})
)

View File

@@ -5,7 +5,7 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
const props = defineProps<{
const { renderFunction } = defineProps<{
renderFunction: () => HTMLElement
}>()
@@ -14,12 +14,12 @@ const container = ref<HTMLElement | null>(null)
function renderContent() {
if (container.value) {
container.value.innerHTML = ''
const element = props.renderFunction()
const element = renderFunction()
container.value.appendChild(element)
}
}
onMounted(renderContent)
watch(() => props.renderFunction, renderContent)
watch(() => renderFunction, renderContent)
</script>

View File

@@ -52,7 +52,7 @@ import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
const { t } = useI18n()
const props = defineProps<{
const { modelValue, initialIcon, initialColor } = defineProps<{
modelValue: boolean
initialIcon?: string
initialColor?: string
@@ -64,7 +64,7 @@ const emit = defineEmits<{
}>()
const visible = computed({
get: () => props.modelValue,
get: () => modelValue,
set: (value) => emit('update:modelValue', value)
})
@@ -96,17 +96,13 @@ const defaultIcon = iconOptions.find(
// @ts-expect-error fixme ts strict error
const selectedIcon = ref<{ name: string; value: string }>(defaultIcon)
const finalColor = ref(
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
)
const finalColor = ref(initialColor || nodeBookmarkStore.defaultBookmarkColor)
const resetCustomization = () => {
// @ts-expect-error fixme ts strict error
selectedIcon.value =
iconOptions.find((option) => option.value === props.initialIcon) ||
defaultIcon
finalColor.value =
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
iconOptions.find((option) => option.value === initialIcon) || defaultIcon
finalColor.value = initialColor || nodeBookmarkStore.defaultBookmarkColor
}
const confirmCustomization = () => {
@@ -119,7 +115,7 @@ const closeDialog = () => {
}
watch(
() => props.modelValue,
() => modelValue,
(newValue: boolean) => {
if (newValue) {
resetCustomization()

View File

@@ -5,7 +5,7 @@
{{ col.header }}
</div>
<div>
{{ formatValue(props.device[col.field], col.field) }}
{{ formatValue(device[col.field], col.field) }}
</div>
</template>
</div>
@@ -15,7 +15,7 @@
import type { DeviceStats } from '@/schemas/apiSchema'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
const { device } = defineProps<{
device: DeviceStats
}>()

View File

@@ -6,7 +6,7 @@ import { createApp } from 'vue'
import EditableText from './EditableText.vue'
describe('EditableText', () => {
describe(EditableText.__name ?? 'EditableText', () => {
beforeAll(() => {
// Create a Vue app instance for PrimeVue
const app = createApp({})

View File

@@ -5,10 +5,10 @@
<i v-if="status === 'completed'" class="pi pi-check text-green-500" />
<div class="file-info">
<div class="file-details">
<span class="file-type" :title="hint">{{ label }}</span>
<span class="file-type" :title="displayHint">{{ displayLabel }}</span>
</div>
<div v-if="props.error" class="file-error">
{{ props.error }}
<div v-if="error" class="file-error">
{{ error }}
</div>
</div>
@@ -18,14 +18,14 @@
class="file-action-button"
variant="secondary"
size="sm"
:disabled="!!props.error"
:disabled="!!error"
@click="triggerDownload"
>
<i class="pi pi-download" />
{{ $t('g.downloadWithSize', { size: fileSize }) }}
</Button>
<Button
v-if="(status === null || status === 'error') && !!props.url"
v-if="(status === null || status === 'error') && !!url"
variant="secondary"
size="sm"
@click="copyURL"
@@ -53,7 +53,7 @@
class="file-action-button"
variant="secondary"
size="sm"
:disabled="!!props.error"
:disabled="!!error"
@click="triggerPauseDownload"
>
<i class="pi pi-pause-circle" />
@@ -66,7 +66,7 @@
variant="secondary"
size="sm"
:aria-label="t('electronFileDownload.resume')"
:disabled="!!props.error"
:disabled="!!error"
@click="triggerResumeDownload"
>
<i class="pi pi-play-circle" />
@@ -78,7 +78,7 @@
variant="destructive"
size="sm"
:aria-label="t('electronFileDownload.cancel')"
:disabled="!!props.error"
:disabled="!!error"
@click="triggerCancelDownload"
>
<i class="pi pi-times-circle" />
@@ -98,7 +98,7 @@ import { useDownload } from '@/composables/useDownload'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
const { url, hint, label, error } = defineProps<{
url: string
hint?: string
label?: string
@@ -106,9 +106,9 @@ const props = defineProps<{
}>()
const { t } = useI18n()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const displayLabel = computed(() => label || url.split('/').pop())
const displayHint = computed(() => hint || url)
const download = useDownload(url)
const downloadProgress = ref<number>(0)
const status = ref<string | null>(null)
const fileSize = computed(() =>
@@ -117,10 +117,10 @@ const fileSize = computed(() =>
const { copyToClipboard } = useCopyToClipboard()
const electronDownloadStore = useElectronDownloadStore()
// @ts-expect-error fixme ts strict error
const [savePath, filename] = props.label.split('/')
const [savePath, filename] = label.split('/')
electronDownloadStore.$subscribe((_, { downloads }) => {
const download = downloads.find((download) => props.url === download.url)
const download = downloads.find((download) => url === download.url)
if (download) {
// @ts-expect-error fixme ts strict error
@@ -132,17 +132,17 @@ electronDownloadStore.$subscribe((_, { downloads }) => {
const triggerDownload = async () => {
await electronDownloadStore.start({
url: props.url,
url,
savePath: savePath.trim(),
filename: filename.trim()
})
}
const triggerCancelDownload = () => electronDownloadStore.cancel(props.url)
const triggerPauseDownload = () => electronDownloadStore.pause(props.url)
const triggerResumeDownload = () => electronDownloadStore.resume(props.url)
const triggerCancelDownload = () => electronDownloadStore.cancel(url)
const triggerPauseDownload = () => electronDownloadStore.pause(url)
const triggerResumeDownload = () => electronDownloadStore.resume(url)
const copyURL = async () => {
await copyToClipboard(props.url)
await copyToClipboard(url)
}
</script>

View File

@@ -5,10 +5,7 @@
:ref="
(el) => {
if (el)
mountCustomExtension(
props.extension as CustomExtension,
el as HTMLElement
)
mountCustomExtension(extension as CustomExtension, el as HTMLElement)
}
"
/>
@@ -19,17 +16,17 @@ import { onBeforeUnmount } from 'vue'
import type { CustomExtension, VueExtension } from '@/types/extensionTypes'
const props = defineProps<{
const { extension } = defineProps<{
extension: VueExtension | CustomExtension
}>()
const mountCustomExtension = (extension: CustomExtension, el: HTMLElement) => {
extension.render(el)
const mountCustomExtension = (ext: CustomExtension, el: HTMLElement) => {
ext.render(el)
}
onBeforeUnmount(() => {
if (props.extension.type === 'custom' && props.extension.destroy) {
props.extension.destroy()
if (extension.type === 'custom' && extension.destroy) {
extension.destroy()
}
})
</script>

View File

@@ -3,35 +3,35 @@
<div class="flex flex-row items-center gap-2">
<div>
<div>
<span :title="hint">{{ label }}</span>
<span :title="displayHint">{{ displayLabel }}</span>
</div>
<Message
v-if="props.error"
v-if="error"
severity="error"
icon="pi pi-exclamation-triangle"
size="small"
variant="outlined"
class="my-2 h-min max-w-xs px-1"
:title="props.error"
:title="error"
:pt="{
text: { class: 'overflow-hidden text-ellipsis' }
}"
>
{{ props.error }}
{{ error }}
</Message>
</div>
<div>
<Button
variant="secondary"
:disabled="!!props.error"
:title="props.url"
:disabled="!!error"
:title="url"
@click="download.triggerBrowserDownload"
>
{{ $t('g.downloadWithSize', { size: fileSize }) }}
</Button>
</div>
<div>
<Button variant="secondary" :disabled="!!props.error" @click="copyURL">
<Button variant="secondary" :disabled="!!error" @click="copyURL">
{{ $t('g.copyURL') }}
</Button>
</div>
@@ -47,22 +47,22 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
const { url, hint, label, error } = defineProps<{
url: string
hint?: string
label?: string
error?: string
}>()
const label = computed(() => props.label || props.url.split('/').pop())
const displayLabel = computed(() => label || url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const displayHint = computed(() => hint || url)
const download = useDownload(url)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const copyURL = async () => {
await copyToClipboard(props.url)
await copyToClipboard(url)
}
const { copyToClipboard } = useCopyToClipboard()

View File

@@ -10,7 +10,7 @@ import ColorPicker from 'primevue/colorpicker'
import InputText from 'primevue/inputtext'
const modelValue = defineModel<string>('modelValue')
defineProps<{
const { label } = defineProps<{
label?: string
}>()

View File

@@ -45,7 +45,7 @@ import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
defineProps<{
const { modelValue } = defineProps<{
modelValue: string
}>()
@@ -64,9 +64,9 @@ const handleFileUpload = (event: Event) => {
if (target.files && target.files[0]) {
const file = target.files[0]
const reader = new FileReader()
reader.onload = (e) => {
reader.addEventListener('load', (e) => {
emit('update:modelValue', e.target?.result as string)
}
})
reader.readAsDataURL(file)
}
}

View File

@@ -2,16 +2,12 @@
<template>
<div class="flex flex-row items-center gap-2">
<div class="form-label flex grow items-center">
<span
:id="`${props.id}-label`"
class="text-muted"
:class="props.labelClass"
>
<span :id="`${id}-label`" class="text-muted" :class="labelClass">
<slot name="name-prefix" />
{{ props.item.name }}
{{ item.name }}
<i
v-if="props.item.tooltip"
v-tooltip="props.item.tooltip"
v-if="item.tooltip"
v-tooltip="item.tooltip"
class="pi pi-info-circle bg-transparent"
/>
<slot name="name-suffix" />
@@ -19,11 +15,11 @@
</div>
<div class="form-input flex justify-end">
<component
:is="markRaw(getFormComponent(props.item))"
:id="props.id"
:is="markRaw(getFormComponent(item))"
:id="id"
v-model:model-value="formValue"
:aria-labelledby="`${props.id}-label`"
v-bind="getFormAttrs(props.item)"
:aria-labelledby="`${id}-label`"
v-bind="getFormAttrs(item)"
/>
</div>
</div>
@@ -48,35 +44,37 @@ import UrlInput from '@/components/common/UrlInput.vue'
import type { FormItem } from '@/platform/settings/types'
const formValue = defineModel<unknown>('formValue')
const props = defineProps<{
const { item, id, labelClass } = defineProps<{
item: FormItem
id?: string
labelClass?: string | Record<string, boolean>
}>()
function getFormAttrs(item: FormItem) {
const attrs = { ...(item.attrs || {}) }
const inputType = item.type
function getFormAttrs(formItem: FormItem) {
const attrs = { ...(formItem.attrs || {}) }
const inputType = formItem.type
if (typeof inputType === 'function') {
attrs['renderFunction'] = () =>
inputType(
props.item.name,
(v: unknown) => (formValue.value = v),
formItem.name,
(v: unknown) => {
formValue.value = v
},
formValue.value,
item.attrs
formItem.attrs
)
}
switch (item.type) {
switch (formItem.type) {
case 'combo':
case 'radio':
attrs['options'] =
typeof item.options === 'function'
typeof formItem.options === 'function'
? // @ts-expect-error: Audit and deprecate usage of legacy options type:
// (value) => [string | {text: string, value: string}]
item.options(formValue.value)
: item.options
formItem.options(formValue.value)
: formItem.options
if (typeof item.options?.[0] !== 'string') {
if (typeof formItem.options?.[0] !== 'string') {
attrs['optionLabel'] = 'text'
attrs['optionValue'] = 'value'
}
@@ -85,11 +83,11 @@ function getFormAttrs(item: FormItem) {
return attrs
}
function getFormComponent(item: FormItem): Component {
if (typeof item.type === 'function') {
function getFormComponent(formItem: FormItem): Component {
if (typeof formItem.type === 'function') {
return CustomFormValue
}
switch (item.type) {
switch (formItem.type) {
case 'boolean':
return ToggleSwitch
case 'number':

View File

@@ -5,239 +5,242 @@ import { beforeAll, describe, expect, it } from 'vitest'
import { createApp } from 'vue'
import type { SettingOption } from '@/platform/settings/types'
import FormRadioGroup from './FormRadioGroup.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
describe('FormRadioGroup', () => {
beforeAll(() => {
const app = createApp({})
app.use(PrimeVue)
})
import FormRadioGroup from './FormRadioGroup.vue'
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
return mount(FormRadioGroup, {
global: {
plugins: [PrimeVue],
components: { RadioButton }
},
props,
...options
describe(
(FormRadioGroup as { __name?: string }).__name ?? 'FormRadioGroup',
() => {
beforeAll(() => {
const app = createApp({})
app.use(PrimeVue)
})
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
return mount(FormRadioGroup, {
global: {
plugins: [PrimeVue],
components: { RadioButton }
},
props,
...options
})
}
describe('normalizedOptions computed property', () => {
it('handles string array options', () => {
const wrapper = mountComponent({
modelValue: 'option1',
options: ['option1', 'option2', 'option3'],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('option1')
expect(radioButtons[1].props('value')).toBe('option2')
expect(radioButtons[2].props('value')).toBe('option3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('option1')
expect(labels[1].text()).toBe('option2')
expect(labels[2].text()).toBe('option3')
})
it('handles SettingOption array', () => {
const options: SettingOption[] = [
{ text: 'Small', value: 'sm' },
{ text: 'Medium', value: 'md' },
{ text: 'Large', value: 'lg' }
]
const wrapper = mountComponent({
modelValue: 'md',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('sm')
expect(radioButtons[1].props('value')).toBe('md')
expect(radioButtons[2].props('value')).toBe('lg')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Small')
expect(labels[1].text()).toBe('Medium')
expect(labels[2].text()).toBe('Large')
})
it('handles SettingOption with undefined value (uses text as value)', () => {
const options: SettingOption[] = [
{ text: 'Option A', value: undefined },
{ text: 'Option B' }
]
const wrapper = mountComponent({
modelValue: 'Option A',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].props('value')).toBe('Option A')
expect(radioButtons[1].props('value')).toBe('Option B')
})
it('handles custom object with optionLabel and optionValue', () => {
const options = [
{ name: 'First Option', id: '1' },
{ name: 'Second Option', id: '2' },
{ name: 'Third Option', id: '3' }
]
const wrapper = mountComponent({
modelValue: 2,
options,
optionLabel: 'name',
optionValue: 'id',
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('1')
expect(radioButtons[1].props('value')).toBe('2')
expect(radioButtons[2].props('value')).toBe('3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('First Option')
expect(labels[1].text()).toBe('Second Option')
expect(labels[2].text()).toBe('Third Option')
})
it('handles mixed array with strings and SettingOptions', () => {
const options: (string | SettingOption)[] = [
'Simple String',
{ text: 'Complex Option', value: 'complex' },
'Another String'
]
const wrapper = mountComponent({
modelValue: 'complex',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('Simple String')
expect(radioButtons[1].props('value')).toBe('complex')
expect(radioButtons[2].props('value')).toBe('Another String')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Simple String')
expect(labels[1].text()).toBe('Complex Option')
expect(labels[2].text()).toBe('Another String')
})
it('handles empty options array', () => {
const wrapper = mountComponent({
modelValue: null,
options: [],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
})
it('handles undefined options gracefully', () => {
const wrapper = mountComponent({
modelValue: null,
options: undefined,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
})
it('handles object with missing properties gracefully', () => {
const options = [{ label: 'Option 1', val: 'opt1' }]
const wrapper = mountComponent({
modelValue: 'opt1',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(1)
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Unknown')
})
})
describe('component functionality', () => {
it('sets correct input-id and name attributes', () => {
const options = ['A', 'B']
const wrapper = mountComponent({
modelValue: 'A',
options,
id: 'my-radio-group'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].props('inputId')).toBe('my-radio-group-A')
expect(radioButtons[0].props('name')).toBe('my-radio-group')
expect(radioButtons[1].props('inputId')).toBe('my-radio-group-B')
expect(radioButtons[1].props('name')).toBe('my-radio-group')
})
it('associates labels with radio buttons correctly', () => {
const options = ['Yes', 'No']
const wrapper = mountComponent({
modelValue: 'Yes',
options,
id: 'confirm-radio'
})
const labels = wrapper.findAll('label')
expect(labels[0].attributes('for')).toBe('confirm-radio-Yes')
expect(labels[1].attributes('for')).toBe('confirm-radio-No')
})
it('sets aria-describedby attribute correctly', () => {
const options: SettingOption[] = [
{ text: 'Option 1', value: 'opt1' },
{ text: 'Option 2', value: 'opt2' }
]
const wrapper = mountComponent({
modelValue: 'opt1',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].attributes('aria-describedby')).toBe(
'Option 1-label'
)
expect(radioButtons[1].attributes('aria-describedby')).toBe(
'Option 2-label'
)
})
})
}
describe('normalizedOptions computed property', () => {
it('handles string array options', () => {
const wrapper = mountComponent({
modelValue: 'option1',
options: ['option1', 'option2', 'option3'],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('option1')
expect(radioButtons[1].props('value')).toBe('option2')
expect(radioButtons[2].props('value')).toBe('option3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('option1')
expect(labels[1].text()).toBe('option2')
expect(labels[2].text()).toBe('option3')
})
it('handles SettingOption array', () => {
const options: SettingOption[] = [
{ text: 'Small', value: 'sm' },
{ text: 'Medium', value: 'md' },
{ text: 'Large', value: 'lg' }
]
const wrapper = mountComponent({
modelValue: 'md',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('sm')
expect(radioButtons[1].props('value')).toBe('md')
expect(radioButtons[2].props('value')).toBe('lg')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Small')
expect(labels[1].text()).toBe('Medium')
expect(labels[2].text()).toBe('Large')
})
it('handles SettingOption with undefined value (uses text as value)', () => {
const options: SettingOption[] = [
{ text: 'Option A', value: undefined },
{ text: 'Option B' }
]
const wrapper = mountComponent({
modelValue: 'Option A',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].props('value')).toBe('Option A')
expect(radioButtons[1].props('value')).toBe('Option B')
})
it('handles custom object with optionLabel and optionValue', () => {
const options = [
{ name: 'First Option', id: '1' },
{ name: 'Second Option', id: '2' },
{ name: 'Third Option', id: '3' }
]
const wrapper = mountComponent({
modelValue: 2,
options,
optionLabel: 'name',
optionValue: 'id',
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('1')
expect(radioButtons[1].props('value')).toBe('2')
expect(radioButtons[2].props('value')).toBe('3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('First Option')
expect(labels[1].text()).toBe('Second Option')
expect(labels[2].text()).toBe('Third Option')
})
it('handles mixed array with strings and SettingOptions', () => {
const options: (string | SettingOption)[] = [
'Simple String',
{ text: 'Complex Option', value: 'complex' },
'Another String'
]
const wrapper = mountComponent({
modelValue: 'complex',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe('Simple String')
expect(radioButtons[1].props('value')).toBe('complex')
expect(radioButtons[2].props('value')).toBe('Another String')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Simple String')
expect(labels[1].text()).toBe('Complex Option')
expect(labels[2].text()).toBe('Another String')
})
it('handles empty options array', () => {
const wrapper = mountComponent({
modelValue: null,
options: [],
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
})
it('handles undefined options gracefully', () => {
const wrapper = mountComponent({
modelValue: null,
options: undefined,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(0)
})
it('handles object with missing properties gracefully', () => {
const options = [{ label: 'Option 1', val: 'opt1' }]
const wrapper = mountComponent({
modelValue: 'opt1',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(1)
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Unknown')
})
})
describe('component functionality', () => {
it('sets correct input-id and name attributes', () => {
const options = ['A', 'B']
const wrapper = mountComponent({
modelValue: 'A',
options,
id: 'my-radio-group'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].props('inputId')).toBe('my-radio-group-A')
expect(radioButtons[0].props('name')).toBe('my-radio-group')
expect(radioButtons[1].props('inputId')).toBe('my-radio-group-B')
expect(radioButtons[1].props('name')).toBe('my-radio-group')
})
it('associates labels with radio buttons correctly', () => {
const options = ['Yes', 'No']
const wrapper = mountComponent({
modelValue: 'Yes',
options,
id: 'confirm-radio'
})
const labels = wrapper.findAll('label')
expect(labels[0].attributes('for')).toBe('confirm-radio-Yes')
expect(labels[1].attributes('for')).toBe('confirm-radio-No')
})
it('sets aria-describedby attribute correctly', () => {
const options: SettingOption[] = [
{ text: 'Option 1', value: 'opt1' },
{ text: 'Option 2', value: 'opt2' }
]
const wrapper = mountComponent({
modelValue: 'opt1',
options,
id: 'test-radio'
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons[0].attributes('aria-describedby')).toBe(
'Option 1-label'
)
expect(radioButtons[1].attributes('aria-describedby')).toBe(
'Option 2-label'
)
})
})
})
)

View File

@@ -26,7 +26,7 @@ import { computed } from 'vue'
import type { SettingOption } from '@/platform/settings/types'
const props = defineProps<{
const { modelValue, options, optionLabel, optionValue, id } = defineProps<{
modelValue: T
options?: (string | SettingOption | Record<string, string>)[]
optionLabel?: string
@@ -39,9 +39,9 @@ defineEmits<{
}>()
const normalizedOptions = computed<SettingOption[]>(() => {
if (!props.options) return []
if (!options) return []
return props.options.map((option) => {
return options.map((option) => {
if (typeof option === 'string') {
return { text: option, value: option }
}
@@ -54,8 +54,8 @@ const normalizedOptions = computed<SettingOption[]>(() => {
}
// Handle optionLabel/optionValue
return {
text: option[props.optionLabel || 'text'] || 'Unknown',
value: option[props.optionValue || 'value']
text: option[optionLabel || 'text'] || 'Unknown',
value: option[optionValue || 'value']
}
})
})

View File

@@ -30,24 +30,25 @@ import InputNumber from 'primevue/inputnumber'
import Knob from 'primevue/knob'
import { ref, watch } from 'vue'
const props = defineProps<{
modelValue: number
inputClass?: string
knobClass?: string
min?: number
max?: number
step?: number
resolution?: number
}>()
const { modelValue, inputClass, knobClass, min, max, step, resolution } =
defineProps<{
modelValue: number
inputClass?: string
knobClass?: string
min?: number
max?: number
step?: number
resolution?: number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void
}>()
const localValue = ref(props.modelValue)
const localValue = ref(modelValue)
watch(
() => props.modelValue,
() => modelValue,
(newValue) => {
localValue.value = newValue
}
@@ -56,18 +57,18 @@ watch(
const updateValue = (newValue: number | null) => {
if (newValue === null) {
// If the input is cleared, reset to the minimum value or 0
newValue = Number(props.min) || 0
newValue = Number(min) || 0
}
const min = Number(props.min ?? Number.NEGATIVE_INFINITY)
const max = Number(props.max ?? Number.POSITIVE_INFINITY)
const step = Number(props.step) || 1
const minVal = Number(min ?? Number.NEGATIVE_INFINITY)
const maxVal = Number(max ?? Number.POSITIVE_INFINITY)
const stepVal = Number(step) || 1
// Ensure the value is within the allowed range
newValue = Math.max(min, Math.min(max, newValue))
newValue = Math.max(minVal, Math.min(maxVal, newValue))
// Round to the nearest step
newValue = Math.round(newValue / step) * step
newValue = Math.round(newValue / stepVal) * stepVal
// Update local value and emit change
localValue.value = newValue
@@ -76,11 +77,11 @@ const updateValue = (newValue: number | null) => {
const displayValue = (value: number): string => {
updateValue(value)
const stepString = (props.step ?? 1).toString()
const resolution = stepString.includes('.')
const stepString = (step ?? 1).toString()
const decimalPlaces = stepString.includes('.')
? stepString.split('.')[1].length
: 0
return value.toFixed(props.resolution ?? resolution)
return value.toFixed(resolution ?? decimalPlaces)
}
defineOptions({

View File

@@ -29,7 +29,7 @@ import InputNumber from 'primevue/inputnumber'
import Slider from 'primevue/slider'
import { ref, watch } from 'vue'
const props = defineProps<{
const { modelValue, inputClass, sliderClass, min, max, step } = defineProps<{
modelValue: number
inputClass?: string
sliderClass?: string
@@ -42,10 +42,10 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: number): void
}>()
const localValue = ref(props.modelValue)
const localValue = ref(modelValue)
watch(
() => props.modelValue,
() => modelValue,
(newValue) => {
localValue.value = newValue
}
@@ -54,18 +54,18 @@ watch(
const updateValue = (newValue: number | null) => {
if (newValue === null) {
// If the input is cleared, reset to the minimum value or 0
newValue = Number(props.min) || 0
newValue = Number(min) || 0
}
const min = Number(props.min ?? Number.NEGATIVE_INFINITY)
const max = Number(props.max ?? Number.POSITIVE_INFINITY)
const step = Number(props.step) || 1
const minVal = Number(min ?? Number.NEGATIVE_INFINITY)
const maxVal = Number(max ?? Number.POSITIVE_INFINITY)
const stepVal = Number(step) || 1
// Ensure the value is within the allowed range
newValue = Math.max(min, Math.min(max, newValue))
newValue = Math.max(minVal, Math.min(maxVal, newValue))
// Round to the nearest step
newValue = Math.round(newValue / step) * step
newValue = Math.round(newValue / stepVal) * stepVal
// Update local value and emit change
localValue.value = newValue

View File

@@ -41,7 +41,6 @@ const spinnerSizeClass = computed(() => {
switch (size) {
case 'sm':
return 'h-6 w-6 border-2'
case 'md':
default:
return 'h-12 w-12 border-4'
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="no-results-placeholder h-full p-8" :class="props.class">
<div :class="cn('no-results-placeholder h-full p-8', className)">
<Card>
<template #content>
<div class="flex flex-col items-center">
@@ -25,8 +25,16 @@
import Card from 'primevue/card'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
const {
class: className,
icon,
title,
message,
textClass,
buttonLabel
} = defineProps<{
class?: string
icon?: string
title: string

View File

@@ -1,175 +0,0 @@
<template>
<div
ref="container"
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
>
<slot name="background" />
<Button
v-if="!hideButtons"
:aria-label="t('g.ariaLabel.decrement')"
data-testid="decrement"
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canDecrement"
tabindex="-1"
@click="modelValue = clamp(modelValue - step)"
>
<i class="pi pi-minus" />
</Button>
<div class="relative min-w-[4ch] flex-1 py-1.5 my-0.25">
<input
ref="inputField"
v-bind="inputAttrs"
:value="displayValue ?? modelValue"
:disabled
:class="
cn(
'bg-transparent border-0 focus:outline-0 p-1 truncate text-sm absolute inset-0'
)
"
inputmode="decimal"
autocomplete="off"
autocorrect="off"
spellcheck="false"
@blur="handleBlur"
@keyup.enter="handleBlur"
@dragstart.prevent
/>
<div
:class="
cn(
'absolute inset-0 z-10 cursor-ew-resize',
textEdit && 'pointer-events-none hidden'
)
"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="resetDrag"
/>
</div>
<slot />
<Button
v-if="!hideButtons"
:aria-label="t('g.ariaLabel.increment')"
data-testid="increment"
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canIncrement"
tabindex="-1"
@click="modelValue = clamp(modelValue + step)"
>
<i class="pi pi-plus" />
</Button>
</div>
</template>
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const {
min,
max,
step = 1,
disabled = false,
hideButtons = false,
displayValue,
parseValue
} = defineProps<{
min?: number
max?: number
step?: number
disabled?: boolean
hideButtons?: boolean
displayValue?: string
parseValue?: (raw: string) => number | undefined
inputAttrs?: Record<string, unknown>
}>()
const { t } = useI18n()
const modelValue = defineModel<number>({ default: 0 })
const container = useTemplateRef<HTMLDivElement>('container')
const inputField = useTemplateRef<HTMLInputElement>('inputField')
const textEdit = ref(false)
onClickOutside(container, () => {
if (textEdit.value) textEdit.value = false
})
function clamp(value: number): number {
const lo = min ?? -Infinity
const hi = max ?? Infinity
return Math.min(hi, Math.max(lo, value))
}
const canDecrement = computed(
() => modelValue.value > (min ?? -Infinity) && !disabled
)
const canIncrement = computed(
() => modelValue.value < (max ?? Infinity) && !disabled
)
const dragging = ref(false)
const dragDelta = ref(0)
const hasDragged = ref(false)
function handleBlur(e: Event) {
const target = e.target as HTMLInputElement
const raw = target.value.trim()
const parsed = parseValue
? parseValue(raw)
: raw === ''
? undefined
: Number(raw)
if (parsed != null && !isNaN(parsed)) {
modelValue.value = clamp(parsed)
} else {
target.value = displayValue ?? String(modelValue.value)
}
textEdit.value = false
}
function handlePointerDown(e: PointerEvent) {
if (e.button !== 0) return
if (disabled) return
const target = e.target as HTMLElement
target.setPointerCapture(e.pointerId)
dragging.value = true
dragDelta.value = 0
hasDragged.value = false
}
function handlePointerMove(e: PointerEvent) {
if (!dragging.value) return
dragDelta.value += e.movementX
const steps = (dragDelta.value / 10) | 0
if (steps === 0) return
hasDragged.value = true
const unclipped = modelValue.value + steps * step
dragDelta.value %= 10
modelValue.value = clamp(unclipped)
}
function handlePointerUp() {
if (!dragging.value) return
if (!hasDragged.value) {
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
}
resetDrag()
}
function resetDrag() {
dragging.value = false
dragDelta.value = 0
}
</script>

View File

@@ -19,7 +19,7 @@ const i18n = createI18n({
}
})
describe('SearchBox', () => {
describe((SearchBox as { __name?: string }).__name ?? 'SearchBox', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()

View File

@@ -18,7 +18,7 @@ export interface SearchFilter {
id: string | number
}
defineProps<Omit<SearchFilter, 'id'>>()
const { text, badge, badgeClass } = defineProps<Omit<SearchFilter, 'id'>>()
defineEmits(['remove'])
</script>

View File

@@ -21,9 +21,9 @@
<h2 class="mb-4 text-2xl font-semibold">
{{ $t('g.devices') }}
</h2>
<TabView v-if="props.stats.devices.length > 1">
<TabView v-if="stats.devices.length > 1">
<TabPanel
v-for="device in props.stats.devices"
v-for="device in stats.devices"
:key="device.index"
:header="device.name"
:value="device.index"
@@ -31,7 +31,7 @@
<DeviceInfo :device="device" />
</TabPanel>
</TabView>
<DeviceInfo v-else :device="props.stats.devices[0]" />
<DeviceInfo v-else :device="stats.devices[0]" />
</div>
</template>
</div>
@@ -48,16 +48,16 @@ import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
const props = defineProps<{
const { stats } = defineProps<{
stats: SystemStats
}>()
const systemInfo = computed(() => ({
...props.stats.system,
argv: props.stats.system.argv.join(' ')
...stats.system,
argv: stats.system.argv.join(' ')
}))
const hasDevices = computed(() => props.stats.devices.length > 0)
const hasDevices = computed(() => stats.devices.length > 0)
type SystemInfoKey = keyof SystemStats['system']

View File

@@ -4,7 +4,7 @@
v-model:expanded-keys="expandedKeys"
v-model:selection-keys="selectionKeys"
class="tree-explorer px-2 py-0 2xl:px-4 bg-transparent"
:class="props.class"
:class="className"
:value="renderedRoot.children"
selection-mode="single"
:pt="{
@@ -12,9 +12,9 @@
nodeContent: ({ context }) => ({
class: 'group/tree-node',
onClick: (e: MouseEvent) =>
onNodeContentClick(e, context.node as RenderedTreeExplorerNode<T>),
onNodeContentClick(e, context.node as RenderedTreeExplorerNode),
onContextmenu: (e: MouseEvent) =>
handleContextMenu(e, context.node as RenderedTreeExplorerNode<T>)
handleContextMenu(e, context.node as RenderedTreeExplorerNode)
}),
nodeToggleButton: () => ({
onClick: (e: MouseEvent) => {
@@ -36,11 +36,11 @@
</Tree>
<ContextMenu ref="menu" :model="menuItems" />
</template>
<script setup lang="ts" generic="T">
<script setup lang="ts">
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import Tree from 'primevue/tree'
import { computed, provide, ref, shallowRef } from 'vue'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
@@ -68,14 +68,14 @@ const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
// Tracks whether the caller has set the selectionKeys model.
const storeSelectionKeys = selectionKeys.value !== undefined
const props = defineProps<{
root: TreeExplorerNode<T>
const { root, class: className } = defineProps<{
root: TreeExplorerNode
class?: string
}>()
const emit = defineEmits<{
(e: 'nodeClick', node: RenderedTreeExplorerNode<T>, event: MouseEvent): void
(e: 'nodeDelete', node: RenderedTreeExplorerNode<T>): void
(e: 'contextMenu', node: RenderedTreeExplorerNode<T>, event: MouseEvent): void
(e: 'nodeClick', node: RenderedTreeExplorerNode, event: MouseEvent): void
(e: 'nodeDelete', node: RenderedTreeExplorerNode): void
(e: 'contextMenu', node: RenderedTreeExplorerNode, event: MouseEvent): void
}>()
const {
@@ -83,19 +83,19 @@ const {
getAddFolderMenuItem,
handleFolderCreation,
addFolderCommand
} = useTreeFolderOperations<T>(
/* expandNode */ (node: TreeExplorerNode<T>) => {
} = useTreeFolderOperations(
/* expandNode */ (node: TreeExplorerNode) => {
expandedKeys.value[node.key] = true
}
)
const renderedRoot = computed<RenderedTreeExplorerNode<T>>(() => {
const renderedRoot = fillNodeInfo(props.root)
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
const renderedRoot = fillNodeInfo(root)
return newFolderNode.value
? combineTrees(renderedRoot, newFolderNode.value)
: renderedRoot
})
const getTreeNodeIcon = (node: TreeExplorerNode<T>) => {
const getTreeNodeIcon = (node: TreeExplorerNode) => {
if (node.getIcon) {
const icon = node.getIcon()
if (icon) {
@@ -111,9 +111,7 @@ const getTreeNodeIcon = (node: TreeExplorerNode<T>) => {
const isExpanded = expandedKeys.value?.[node.key] ?? false
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
}
const fillNodeInfo = (
node: TreeExplorerNode<T>
): RenderedTreeExplorerNode<T> => {
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
const children = node.children?.map(fillNodeInfo) ?? []
const totalLeaves = node.leaf
? 1
@@ -130,7 +128,7 @@ const fillNodeInfo = (
}
const onNodeContentClick = async (
e: MouseEvent,
node: RenderedTreeExplorerNode<T>
node: RenderedTreeExplorerNode
) => {
if (!storeSelectionKeys) {
selectionKeys.value = {}
@@ -141,22 +139,20 @@ const onNodeContentClick = async (
emit('nodeClick', node, e)
}
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const menuTargetNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
const extraMenuItems = computed(() => {
const node = menuTargetNode.value
return node?.contextMenuItems
? typeof node.contextMenuItems === 'function'
? node.contextMenuItems(node)
: node.contextMenuItems
return menuTargetNode.value?.contextMenuItems
? typeof menuTargetNode.value.contextMenuItems === 'function'
? menuTargetNode.value.contextMenuItems(menuTargetNode.value)
: menuTargetNode.value.contextMenuItems
: []
})
const renameEditingNode = shallowRef<RenderedTreeExplorerNode<T> | null>(null)
const renameEditingNode = ref<RenderedTreeExplorerNode | null>(null)
const errorHandling = useErrorHandling()
const handleNodeLabelEdit = async (
n: RenderedTreeExplorerNode,
node: RenderedTreeExplorerNode,
newName: string
) => {
const node = n as RenderedTreeExplorerNode<T>
await errorHandling.wrapWithErrorHandlingAsync(
async () => {
if (node.key === newFolderNode.value?.key) {
@@ -174,36 +170,35 @@ const handleNodeLabelEdit = async (
provide(InjectKeyHandleEditLabelFunction, handleNodeLabelEdit)
const { t } = useI18n()
const renameCommand = (node: RenderedTreeExplorerNode<T>) => {
const renameCommand = (node: RenderedTreeExplorerNode) => {
renameEditingNode.value = node
}
const deleteCommand = async (node: RenderedTreeExplorerNode<T>) => {
const deleteCommand = async (node: RenderedTreeExplorerNode) => {
await node.handleDelete?.()
emit('nodeDelete', node)
}
const menuItems = computed<MenuItem[]>(() => {
const node = menuTargetNode.value
return [
getAddFolderMenuItem(node),
const menuItems = computed<MenuItem[]>(() =>
[
getAddFolderMenuItem(menuTargetNode.value),
{
label: t('g.rename'),
icon: 'pi pi-file-edit',
command: () => {
if (node) {
renameCommand(node)
if (menuTargetNode.value) {
renameCommand(menuTargetNode.value)
}
},
visible: node?.handleRename !== undefined
visible: menuTargetNode.value?.handleRename !== undefined
},
{
label: t('g.delete'),
icon: 'pi pi-trash',
command: async () => {
if (node) {
await deleteCommand(node)
if (menuTargetNode.value) {
await deleteCommand(menuTargetNode.value)
}
},
visible: node?.handleDelete !== undefined,
visible: menuTargetNode.value?.handleDelete !== undefined,
isAsync: true // The delete command can be async
},
...extraMenuItems.value
@@ -215,12 +210,9 @@ const menuItems = computed<MenuItem[]>(() => {
})
: undefined
}))
})
)
const handleContextMenu = (
e: MouseEvent,
node: RenderedTreeExplorerNode<T>
) => {
const handleContextMenu = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
menuTargetNode.value = node
emit('contextMenu', node, e)
if (menuItems.value.filter((item) => item.visible).length > 0) {
@@ -232,13 +224,15 @@ const wrapCommandWithErrorHandler = (
command: (event: MenuItemCommandEvent) => void,
{ isAsync = false }: { isAsync: boolean }
) => {
const node = menuTargetNode.value
return isAsync
? errorHandling.wrapWithErrorHandlingAsync(
command as (event: MenuItemCommandEvent) => Promise<void>,
node?.handleError
menuTargetNode.value?.handleError
)
: errorHandling.wrapWithErrorHandling(
command,
menuTargetNode.value?.handleError
)
: errorHandling.wrapWithErrorHandling(command, node?.handleError)
}
defineExpose({

View File

@@ -19,7 +19,7 @@ const i18n = createI18n({
messages: {}
})
describe('TreeExplorerTreeNode', () => {
describe(TreeExplorerTreeNode.__name ?? 'TreeExplorerTreeNode', () => {
const mockNode = {
key: '1',
label: 'Test Node',

View File

@@ -5,21 +5,21 @@
'tree-node',
{
'can-drop': canDrop,
'tree-folder': !props.node.leaf,
'tree-leaf': props.node.leaf
'tree-folder': !node.leaf,
'tree-leaf': node.leaf
}
]"
:data-testid="`tree-node-${node.key}`"
>
<div class="node-content">
<span class="node-label">
<slot name="before-label" :node="props.node" />
<slot name="before-label" :node="node" />
<EditableText
:model-value="node.label"
:is-editing="isEditing"
@edit="handleRename"
/>
<slot name="after-label" :node="props.node" />
<slot name="after-label" :node="node" />
</span>
<Badge
v-if="showNodeBadgeText"
@@ -31,12 +31,12 @@
<div
class="node-actions flex gap-1 touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
>
<slot name="actions" :node="props.node" />
<slot name="actions" :node="node" />
</div>
</div>
</template>
<script setup lang="ts" generic="T">
<script setup lang="ts">
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
import Badge from 'primevue/badge'
import { computed, inject, ref } from 'vue'
@@ -52,35 +52,35 @@ import type {
TreeExplorerDragAndDropData
} from '@/types/treeExplorerTypes'
const props = defineProps<{
node: RenderedTreeExplorerNode<T>
const { node } = defineProps<{
node: RenderedTreeExplorerNode
}>()
const emit = defineEmits<{
(
e: 'itemDropped',
node: RenderedTreeExplorerNode<T>,
data: RenderedTreeExplorerNode<T>
node: RenderedTreeExplorerNode,
data: RenderedTreeExplorerNode
): void
(e: 'dragStart', node: RenderedTreeExplorerNode<T>): void
(e: 'dragEnd', node: RenderedTreeExplorerNode<T>): void
(e: 'dragStart', node: RenderedTreeExplorerNode): void
(e: 'dragEnd', node: RenderedTreeExplorerNode): void
}>()
const nodeBadgeText = computed<string>(() => {
if (props.node.leaf) {
if (node.leaf) {
return ''
}
if (props.node.badgeText !== undefined && props.node.badgeText !== null) {
return props.node.badgeText
if (node.badgeText !== undefined && node.badgeText !== null) {
return node.badgeText
}
return props.node.totalLeaves.toString()
return node.totalLeaves.toString()
})
const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
const isEditing = computed<boolean>(() => props.node.isEditingLabel ?? false)
const isEditing = computed<boolean>(() => node.isEditingLabel ?? false)
const handleEditLabel = inject(InjectKeyHandleEditLabelFunction)
const handleRename = (newName: string) => {
handleEditLabel?.(props.node as RenderedTreeExplorerNode, newName)
handleEditLabel?.(node, newName)
}
const container = ref<HTMLElement | null>(null)
@@ -89,21 +89,21 @@ const canDrop = ref(false)
const treeNodeElementGetter = () =>
container.value?.closest('.p-tree-node-content') as HTMLElement
if (props.node.draggable) {
if (node.draggable) {
usePragmaticDraggable(treeNodeElementGetter, {
getInitialData: () => {
return {
type: 'tree-explorer-node',
data: props.node
data: node
}
},
onDragStart: () => emit('dragStart', props.node),
onDrop: () => emit('dragEnd', props.node),
onGenerateDragPreview: props.node.renderDragPreview
onDragStart: () => emit('dragStart', node),
onDrop: () => emit('dragEnd', node),
onGenerateDragPreview: node.renderDragPreview
? ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
render: ({ container }) => {
return props.node.renderDragPreview?.(container)
return node.renderDragPreview?.(container)
},
nativeSetDragImage
})
@@ -112,18 +112,14 @@ if (props.node.draggable) {
})
}
if (props.node.droppable) {
if (node.droppable) {
usePragmaticDroppable(treeNodeElementGetter, {
onDrop: async (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
await props.node.handleDrop?.(dndData as TreeExplorerDragAndDropData<T>)
await node.handleDrop?.(dndData)
canDrop.value = false
emit(
'itemDropped',
props.node,
dndData.data as RenderedTreeExplorerNode<T>
)
emit('itemDropped', node, dndData.data)
}
},
onDragEnter: (event) => {

View File

@@ -6,10 +6,11 @@ import InputText from 'primevue/inputtext'
import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import UrlInput from './UrlInput.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
describe('UrlInput', () => {
import UrlInput from './UrlInput.vue'
describe(UrlInput.__name ?? 'UrlInput', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)

View File

@@ -17,7 +17,7 @@
'pi pi-times cursor-pointer text-red-500':
validationState === ValidationState.INVALID
}"
@click="validateUrl(props.modelValue)"
@click="validateUrl(model)"
/>
</IconField>
</template>
@@ -32,40 +32,34 @@ import { isValidUrl } from '@/utils/formatUtil'
import { checkUrlReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'
const props = defineProps<{
modelValue: string
const model = defineModel<string>({ required: true })
const { validateUrlFn } = defineProps<{
validateUrlFn?: (url: string) => Promise<boolean>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'state-change': [state: ValidationState]
}>()
const validationState = ref<ValidationState>(ValidationState.IDLE)
const cleanInput = (value: string): string =>
value ? value.replace(/\s+/g, '') : ''
value ? value.replaceAll(/\s+/g, '') : ''
// Add internal value state
const internalValue = ref(cleanInput(props.modelValue))
const internalValue = ref(cleanInput(model.value))
// Watch for external modelValue changes
watch(
() => props.modelValue,
async (newValue: string) => {
internalValue.value = cleanInput(newValue)
await validateUrl(newValue)
}
)
watch(model, async (newValue: string) => {
internalValue.value = cleanInput(newValue)
await validateUrl(newValue)
})
watch(validationState, (newState) => {
emit('state-change', newState)
})
// Validate on mount
onMounted(async () => {
await validateUrl(props.modelValue)
await validateUrl(model.value)
})
const handleInput = (value: string | undefined) => {
@@ -87,7 +81,7 @@ const handleBlur = async () => {
}
// Emit the update only on blur
emit('update:modelValue', normalizedUrl)
model.value = normalizedUrl
}
// Default validation implementation
@@ -113,7 +107,7 @@ const validateUrl = async (value: string) => {
validationState.value = ValidationState.LOADING
try {
const isValid = await (props.validateUrlFn ?? defaultValidateUrl)(url)
const isValid = await (validateUrlFn ?? defaultValidateUrl)(url)
validationState.value = isValid
? ValidationState.VALID
: ValidationState.INVALID

View File

@@ -23,7 +23,7 @@ const i18n = createI18n({
}
})
describe('UserAvatar', () => {
describe(UserAvatar.__name ?? 'UserAvatar', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)

View File

@@ -39,7 +39,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
describe('UserCredit', () => {
describe(UserCredit.__name ?? 'UserCredit', () => {
beforeEach(() => {
vi.clearAllMocks()
mockBalance.value = {

View File

@@ -1,7 +1,7 @@
<template>
<BaseModalLayout
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
size="md"
class="workflow-template-selector-dialog"
>
<template #leftPanelHeaderTitle>
<i class="icon-[comfy--template]" />
@@ -444,7 +444,6 @@ const distributions = computed(() => {
return [TemplateIncludeOnDistributionEnum.Cloud]
case 'localhost':
return [TemplateIncludeOnDistributionEnum.Local]
case 'desktop':
default:
if (systemStatsStore.systemStats?.system.os === 'darwin') {
return [
@@ -595,12 +594,10 @@ const coordinateNavAndSort = (source: 'nav' | 'sort') => {
// When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
sortBy.value = 'default'
}
} else if (source === 'sort') {
} else if (source === 'sort' && isPopularNav && !isPopularSort) {
// When sort is changed away from 'Popular' while in the 'Popular' category,
// reset the category to 'All Templates' to avoid a confusing state.
if (isPopularNav && !isPopularSort) {
selectedNavItem.value = 'all'
}
selectedNavItem.value = 'all'
}
}
@@ -681,37 +678,37 @@ const runsOnOptions = computed(() =>
const modelFilterLabel = computed(() => {
if (selectedModelObjects.value.length === 0) {
return t('templateWorkflows.modelFilter', 'Model Filter')
} else if (selectedModelObjects.value.length === 1) {
return selectedModelObjects.value[0].name
} else {
return t('templateWorkflows.modelsSelected', {
count: selectedModelObjects.value.length
})
}
if (selectedModelObjects.value.length === 1) {
return selectedModelObjects.value[0].name
}
return t('templateWorkflows.modelsSelected', {
count: selectedModelObjects.value.length
})
})
const useCaseFilterLabel = computed(() => {
if (selectedUseCaseObjects.value.length === 0) {
return t('templateWorkflows.useCaseFilter', 'Use Case')
} else if (selectedUseCaseObjects.value.length === 1) {
return selectedUseCaseObjects.value[0].name
} else {
return t('templateWorkflows.useCasesSelected', {
count: selectedUseCaseObjects.value.length
})
}
if (selectedUseCaseObjects.value.length === 1) {
return selectedUseCaseObjects.value[0].name
}
return t('templateWorkflows.useCasesSelected', {
count: selectedUseCaseObjects.value.length
})
})
const runsOnFilterLabel = computed(() => {
if (selectedRunsOnObjects.value.length === 0) {
return t('templateWorkflows.runsOnFilter', 'Runs On')
} else if (selectedRunsOnObjects.value.length === 1) {
return selectedRunsOnObjects.value[0].name
} else {
return t('templateWorkflows.runsOnSelected', {
count: selectedRunsOnObjects.value.length
})
}
if (selectedRunsOnObjects.value.length === 1) {
return selectedRunsOnObjects.value[0].name
}
return t('templateWorkflows.runsOnSelected', {
count: selectedRunsOnObjects.value.length
})
})
// Sort options
@@ -854,3 +851,19 @@ onBeforeUnmount(() => {
cardRefs.value = [] // Release DOM refs
})
</script>
<style>
/* Ensure the workflow template selector dialog fits within provided dialog */
.workflow-template-selector-dialog.base-widget-layout {
width: 100% !important;
max-width: 1400px;
height: 100% !important;
aspect-ratio: auto !important;
}
@media (min-width: 1600px) {
.workflow-template-selector-dialog.base-widget-layout {
max-width: 1600px;
}
}
</style>

View File

@@ -4,7 +4,12 @@
v-for="item in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
class="global-dialog"
:class="[
'global-dialog',
item.key === 'global-settings' && teamWorkspacesEnabled
? 'settings-dialog-workspace'
: ''
]"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"

View File

@@ -26,7 +26,7 @@ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
return true
}
return undefined
return
}
onMounted(() => {

View File

@@ -6,7 +6,7 @@
</div>
</template>
<script setup lang="ts">
defineProps<{
const { title } = defineProps<{
title?: string
}>()
</script>

View File

@@ -116,11 +116,17 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogService } from '@/services/dialogService'
import type { ConfirmationDialogType } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
const props = defineProps<{
const {
message,
type,
onConfirm: onConfirmProp,
itemList,
hint
} = defineProps<{
message: string
type: ConfirmationDialogType
onConfirm: (value?: boolean) => void
@@ -134,20 +140,23 @@ const onCancel = () => useDialogStore().closeDialog()
function openBlueprintOverwriteSetting() {
useDialogStore().closeDialog()
useSettingsDialog().show(undefined, 'Comfy.Workflow.WarnBlueprintOverwrite')
void useDialogService().showSettingsDialog(
undefined,
'Comfy.Workflow.WarnBlueprintOverwrite'
)
}
const doNotAskAgain = ref(false)
const onDeny = () => {
props.onConfirm(false)
onConfirmProp(false)
useDialogStore().closeDialog()
}
const onConfirm = () => {
if (props.type === 'overwriteBlueprint' && doNotAskAgain.value)
if (type === 'overwriteBlueprint' && doNotAskAgain.value)
void useSettingStore().set('Comfy.Workflow.WarnBlueprintOverwrite', false)
props.onConfirm(true)
onConfirmProp(true)
useDialogStore().closeDialog()
}
</script>

View File

@@ -30,7 +30,7 @@ const createMockNode = (type: string, version?: string): LGraphNode =>
outputs: []
})
describe('MissingCoreNodesMessage', () => {
describe(MissingCoreNodesMessage.__name ?? 'MissingCoreNodesMessage', () => {
const mockSystemStatsStore = {
systemStats: null as { system?: { comfyui_version?: string } } | null,
refetchSystemStats: vi.fn()

View File

@@ -64,7 +64,7 @@ import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
// TODO: Read this from server internal API rather than hardcoding here
@@ -94,7 +94,7 @@ interface ModelInfo {
folder_path?: string
}
const props = defineProps<{
const { missingModels: missingModelsProp, paths } = defineProps<{
missingModels: ModelInfo[]
paths: Record<string, string[]>
}>()
@@ -105,14 +105,17 @@ const doNotAskAgain = ref(false)
function openShowMissingModelsSetting() {
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingModelsWarning')
void useDialogService().showSettingsDialog(
undefined,
'Comfy.Workflow.ShowMissingModelsWarning'
)
}
const modelDownloads = ref<Record<string, ModelInfo>>({})
const missingModels = computed(() => {
return props.missingModels.map((model) => {
const paths = props.paths[model.directory]
if (model.directory_invalid || !paths) {
return missingModelsProp.map((model) => {
const modelPaths = paths[model.directory]
if (model.directory_invalid || !modelPaths) {
return {
label: `${model.directory} / ${model.name}`,
url: model.url,
@@ -127,7 +130,7 @@ const missingModels = computed(() => {
name: model.name,
directory: model.directory,
url: model.url,
folder_path: paths[0]
folder_path: modelPaths[0]
}
modelDownloads.value[model.name] = downloadInfo
if (!whiteListedUrls.has(model.url)) {
@@ -154,7 +157,7 @@ const missingModels = computed(() => {
progress: downloadInfo.progress,
error: downloadInfo.error,
name: model.name,
paths: paths,
paths: modelPaths,
folderPath: downloadInfo.folder_path
}
})

View File

@@ -1,12 +1,12 @@
<template>
<div
class="comfy-missing-nodes flex w-[490px] flex-col border-t border-border-default"
:class="isCloud ? 'border-b' : ''"
class="flex w-[490px] flex-col border-t-1 border-border-default"
:class="isCloud ? 'border-b-1' : ''"
>
<div class="flex h-full w-full flex-col gap-4 p-4">
<!-- Description -->
<div>
<p class="m-0 text-sm leading-5 text-muted-foreground">
<p class="m-0 text-sm leading-4 text-muted-foreground">
{{
isCloud
? $t('missingNodes.cloud.description')
@@ -14,210 +14,32 @@
}}
</p>
</div>
<MissingCoreNodesMessage v-if="!isCloud" :missing-core-nodes />
<!-- QUICK FIX AVAILABLE Section -->
<div v-if="replaceableNodes.length > 0" class="flex flex-col gap-2">
<!-- Section header with Replace button -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase text-primary">
{{ $t('nodeReplacement.quickFixAvailable') }}
</span>
<div class="h-2 w-2 rounded-full bg-primary" />
</div>
<Button
v-tooltip.top="$t('nodeReplacement.replaceWarning')"
variant="primary"
size="md"
:disabled="selectedTypes.size === 0"
@click="handleReplaceSelected"
>
<i class="icon-[lucide--refresh-cw] mr-1.5 h-4 w-4" />
{{
$t('nodeReplacement.replaceSelected', {
count: selectedTypes.size
})
}}
</Button>
</div>
<!-- Replaceable nodes list -->
<div
class="flex max-h-[200px] flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<!-- Select All row (sticky header) -->
<div
:class="
cn(
'sticky top-0 z-10 flex items-center gap-3 border-b border-border-default bg-secondary-background px-3 py-2',
pendingNodes.length > 0
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'opacity-50 pointer-events-none'
)
"
tabindex="0"
role="checkbox"
:aria-checked="
isAllSelected ? 'true' : isSomeSelected ? 'mixed' : 'false'
"
@click="toggleSelectAll"
@keydown.enter.prevent="toggleSelectAll"
@keydown.space.prevent="toggleSelectAll"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
:class="
isAllSelected || isSomeSelected
? 'bg-primary-background'
: 'bg-secondary-background'
"
>
<i
v-if="isAllSelected"
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
/>
<i
v-else-if="isSomeSelected"
class="icon-[lucide--minus] text-bold text-xs text-base-foreground"
/>
</div>
<span class="text-xs font-medium uppercase text-muted-foreground">
{{ $t('nodeReplacement.compatibleAlternatives') }}
</span>
</div>
<!-- Replaceable node items -->
<div
v-for="node in replaceableNodes"
:key="node.label"
:class="
cn(
'flex items-center gap-3 px-3 py-2',
replacedTypes.has(node.label)
? 'opacity-50 pointer-events-none'
: 'cursor-pointer hover:bg-secondary-background-hover'
)
"
tabindex="0"
role="checkbox"
:aria-checked="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
? 'true'
: 'false'
"
@click="toggleNode(node.label)"
@keydown.enter.prevent="toggleNode(node.label)"
@keydown.space.prevent="toggleNode(node.label)"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
:class="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
? 'bg-primary-background'
: 'bg-secondary-background'
"
>
<i
v-if="
replacedTypes.has(node.label) || selectedTypes.has(node.label)
"
class="icon-[lucide--check] text-bold text-xs text-base-foreground"
/>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span
v-if="replacedTypes.has(node.label)"
class="inline-flex h-4 items-center rounded-full border border-success bg-success/10 px-1.5 text-xxxs font-semibold uppercase text-success"
>
{{ $t('nodeReplacement.replaced') }}
</span>
<span
v-else
class="inline-flex h-4 items-center rounded-full border border-primary bg-primary/10 px-1.5 text-xxxs font-semibold uppercase text-primary"
>
{{ $t('nodeReplacement.replaceable') }}
</span>
<span class="text-sm text-foreground">
{{ node.label }}
</span>
</div>
<span class="text-xs text-muted-foreground">
{{ node.replacement?.new_node_id ?? node.hint ?? '' }}
</span>
</div>
</div>
</div>
</div>
<!-- MANUAL INSTALLATION REQUIRED Section -->
<!-- Missing Nodes List Wrapper -->
<div
v-if="nonReplaceableNodes.length > 0"
class="flex max-h-[200px] flex-col gap-2"
class="comfy-missing-nodes flex flex-col max-h-[256px] rounded-lg py-2 scrollbar-custom bg-secondary-background"
>
<!-- Section header -->
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase text-error">
{{ $t('nodeReplacement.installationRequired') }}
<div
v-for="(node, i) in uniqueNodes"
:key="i"
class="flex min-h-8 items-center justify-between px-4 py-2 bg-secondary-background text-muted-foreground"
>
<span class="text-xs">
{{ node.label }}
</span>
<i class="icon-[lucide--info] text-xs text-error" />
</div>
<!-- Non-replaceable nodes list -->
<div
class="flex flex-col overflow-y-auto rounded-lg bg-secondary-background scrollbar-custom"
>
<div
v-for="node in nonReplaceableNodes"
:key="node.label"
class="flex items-center justify-between px-4 py-3"
>
<div class="flex items-center gap-3">
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span
class="inline-flex h-4 items-center rounded-full border border-error bg-error/10 px-1.5 text-xxxs font-semibold uppercase text-error"
>
{{ $t('nodeReplacement.notReplaceable') }}
</span>
<span class="text-sm text-foreground">
{{ node.label }}
</span>
</div>
<span v-if="node.hint" class="text-xs text-muted-foreground">
{{ node.hint }}
</span>
</div>
</div>
<Button
v-if="node.action"
variant="destructive-textonly"
size="sm"
@click="node.action.callback"
>
{{ node.action.text }}
</Button>
</div>
<span v-if="node.hint" class="text-xs">{{ node.hint }}</span>
</div>
</div>
<!-- Bottom instruction box -->
<div
class="flex gap-3 rounded-lg border border-warning-background bg-warning-background/10 p-3"
>
<i
class="icon-[lucide--triangle-alert] mt-0.5 h-4 w-4 shrink-0 text-warning-background"
/>
<p class="m-0 text-xs leading-5 text-neutral-foreground">
<i18n-t keypath="nodeReplacement.instructionMessage">
<template #red>
<span class="text-error">{{
$t('nodeReplacement.redHighlight')
}}</span>
</template>
</i18n-t>
<!-- Bottom instruction -->
<div>
<p class="m-0 text-sm leading-4 text-muted-foreground">
{{
isCloud
? $t('missingNodes.cloud.replacementInstruction')
: $t('missingNodes.oss.replacementInstruction')
}}
</p>
</div>
</div>
@@ -225,38 +47,22 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed } from 'vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import { cn } from '@/utils/tailwindUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
const { missingNodeTypes } = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
// Get missing core nodes for OSS mode
const { missingCoreNodes } = useMissingNodes()
const { replaceNodesInPlace } = useNodeReplacement()
const dialogStore = useDialogStore()
interface ProcessedNode {
label: string
hint?: string
action?: { text: string; callback: () => void }
isReplaceable: boolean
replacement?: NodeReplacement
}
const replacedTypes = ref<Set<string>>(new Set())
const uniqueNodes = computed<ProcessedNode[]>(() => {
const seenTypes = new Set<string>()
const uniqueNodes = computed(() => {
const seenTypes = new Set()
return missingNodeTypes
.filter((node) => {
const type = typeof node === 'object' ? node.type : node
@@ -269,81 +75,10 @@ const uniqueNodes = computed<ProcessedNode[]>(() => {
return {
label: node.type,
hint: node.hint,
action: node.action,
isReplaceable: node.isReplaceable ?? false,
replacement: node.replacement
action: node.action
}
}
return { label: node, isReplaceable: false }
return { label: node }
})
})
const replaceableNodes = computed(() =>
uniqueNodes.value.filter((n) => n.isReplaceable)
)
const pendingNodes = computed(() =>
replaceableNodes.value.filter((n) => !replacedTypes.value.has(n.label))
)
const nonReplaceableNodes = computed(() =>
uniqueNodes.value.filter((n) => !n.isReplaceable)
)
// Selection state - all pending nodes selected by default
const selectedTypes = ref(new Set(pendingNodes.value.map((n) => n.label)))
const isAllSelected = computed(
() =>
pendingNodes.value.length > 0 &&
pendingNodes.value.every((n) => selectedTypes.value.has(n.label))
)
const isSomeSelected = computed(
() => selectedTypes.value.size > 0 && !isAllSelected.value
)
function toggleNode(label: string) {
if (replacedTypes.value.has(label)) return
const next = new Set(selectedTypes.value)
if (next.has(label)) {
next.delete(label)
} else {
next.add(label)
}
selectedTypes.value = next
}
function toggleSelectAll() {
if (isAllSelected.value) {
selectedTypes.value = new Set()
} else {
selectedTypes.value = new Set(pendingNodes.value.map((n) => n.label))
}
}
function handleReplaceSelected() {
const selected = missingNodeTypes.filter((node) => {
const type = typeof node === 'object' ? node.type : node
return selectedTypes.value.has(type)
})
const result = replaceNodesInPlace(selected)
const nextReplaced = new Set(replacedTypes.value)
const nextSelected = new Set(selectedTypes.value)
for (const type of result) {
nextReplaced.add(type)
nextSelected.delete(type)
}
replacedTypes.value = nextReplaced
selectedTypes.value = nextSelected
// Auto-close when all replaceable nodes replaced and no non-replaceable remain
const allReplaced = replaceableNodes.value.every((n) =>
nextReplaced.has(n.label)
)
if (allReplaced && nonReplaceableNodes.value.length === 0) {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
}
}
</script>

View File

@@ -30,18 +30,8 @@
</i18n-t>
</div>
<!-- All nodes replaceable: Skip button (cloud + OSS) -->
<div v-if="!hasNonReplaceableNodes" class="flex justify-end gap-1">
<Button variant="secondary" size="md" @click="handleGotItClick">
{{ $t('nodeReplacement.skipForNow') }}
</Button>
</div>
<!-- Cloud mode: Learn More + Got It buttons -->
<div
v-else-if="isCloud"
class="flex w-full items-center justify-between gap-2"
>
<div v-if="isCloud" class="flex w-full items-center justify-between gap-2">
<Button
variant="textonly"
size="sm"
@@ -58,9 +48,9 @@
}}</Button>
</div>
<!-- OSS mode: Manager buttons -->
<!-- OSS mode: Open Manager + Install All buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
<Button variant="textonly" @click="handleOpenManager">{{
<Button variant="textonly" @click="openManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton
@@ -90,19 +80,14 @@ import Button from '@/components/ui/button/Button.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import type { MissingNodeType } from '@/types/comfy'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
const { missingNodeTypes } = defineProps<{
missingNodeTypes?: MissingNodeType[]
}>()
const dialogStore = useDialogStore()
const { t } = useI18n()
@@ -118,18 +103,15 @@ const handleGotItClick = () => {
function openShowMissingNodesSetting() {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
useSettingsDialog().show(undefined, 'Comfy.Workflow.ShowMissingNodesWarning')
void useDialogService().showSettingsDialog(
undefined,
'Comfy.Workflow.ShowMissingNodesWarning'
)
}
const { missingNodePacks, isLoading, error } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
const managerState = useManagerState()
function handleOpenManager() {
managerState.openManager({
initialTab: ManagerTab.Missing,
showToastOnLegacyError: true
})
}
// Check if any of the missing packs are currently being installed
const isInstalling = computed(() => {
@@ -149,29 +131,15 @@ const showInstallAllButton = computed(() => {
return managerState.shouldShowInstallButton.value
})
const hasNonReplaceableNodes = computed(
() =>
missingNodeTypes?.some(
(n) =>
typeof n === 'string' || (typeof n === 'object' && !n.isReplaceable)
) ?? false
)
const openManager = async () => {
await managerState.openManager({
initialTab: ManagerTab.Missing,
showToastOnLegacyError: true
})
}
// Track whether missingNodePacks was ever non-empty (i.e. there were packs to install)
const hadMissingPacks = ref(false)
watch(
missingNodePacks,
(packs) => {
if (packs && packs.length > 0) hadMissingPacks.value = true
},
{ immediate: true }
)
// Only consider "all installed" when packs transitioned from non-empty to empty
// (actual installation happened). Replaceable-only case is handled by Content auto-close.
// Computed to check if all missing nodes have been installed
const allMissingNodesInstalled = computed(() => {
if (!hadMissingPacks.value) return false
return (
!isLoading.value &&
!isInstalling.value &&

View File

@@ -25,17 +25,22 @@ import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useDialogStore } from '@/stores/dialogStore'
const props = defineProps<{
const {
message,
defaultValue,
onConfirm: onConfirmProp,
placeholder
} = defineProps<{
message: string
defaultValue: string
onConfirm: (value: string) => void
placeholder?: string
}>()
const inputValue = ref<string>(props.defaultValue)
const inputValue = ref<string>(defaultValue)
const onConfirm = () => {
props.onConfirm(inputValue.value)
onConfirmProp(inputValue.value)
useDialogStore().closeDialog()
}

View File

@@ -162,7 +162,7 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
@@ -173,7 +173,7 @@ const { isInsufficientCredits = false } = defineProps<{
const { t } = useI18n()
const authActions = useFirebaseAuthActions()
const dialogStore = useDialogStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
@@ -266,7 +266,7 @@ async function handleBuy() {
: isSubscriptionEnabled()
? 'subscription'
: 'credits'
settingsDialog.show(settingsPanel)
dialogService.showSettingsDialog(settingsPanel)
} catch (error) {
console.error('Purchase failed:', error)

View File

@@ -161,8 +161,8 @@ import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { useDialogService } from '@/services/dialogService'
import { useBillingOperationStore } from '@/stores/billingOperationStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
@@ -172,7 +172,7 @@ const { isInsufficientCredits = false } = defineProps<{
const { t } = useI18n()
const dialogStore = useDialogStore()
const settingsDialog = useSettingsDialog()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
@@ -266,7 +266,7 @@ async function handleBuy() {
})
await fetchBalance()
handleClose(false)
settingsDialog.show('workspace')
dialogService.showSettingsDialog('workspace')
} else if (response.status === 'pending') {
billingOperationStore.startOperation(response.billing_op_id, 'topup')
} else {

View File

@@ -25,7 +25,7 @@ const mountOption = (
}
})
describe('CreditTopUpOption', () => {
describe(CreditTopUpOption.__name ?? 'CreditTopUpOption', () => {
it('renders credit amount and description', () => {
const wrapper = mountOption({ credits: 5000, description: '~500 videos*' })
expect(wrapper.text()).toContain('5,000')

View File

@@ -11,23 +11,20 @@ import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useTelemetry } from '@/platform/telemetry'
const props = defineProps<{
const { errorMessage, repoOwner, repoName } = defineProps<{
errorMessage: string
repoOwner: string
repoName: string
}>()
const queryString = computed(() => props.errorMessage + ' is:issue')
const queryString = computed(() => `${errorMessage} is:issue`)
/**
* Open GitHub issues search and track telemetry.
*/
const openGitHubIssues = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked'
})
const query = encodeURIComponent(queryString.value)
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`
const url = `https://github.com/${repoOwner}/${repoName}/issues?q=${query}`
window.open(url, '_blank')
}
</script>

View File

@@ -1,5 +1,9 @@
<template>
<div class="about-container flex flex-col gap-2" data-testid="about-panel">
<PanelTemplate
value="About"
class="about-container"
data-testid="about-panel"
>
<h2 class="mb-2 text-2xl font-bold">
{{ $t('g.about') }}
</h2>
@@ -28,7 +32,7 @@
v-if="systemStatsStore.systemStats"
:stats="systemStatsStore.systemStats"
/>
</div>
</PanelTemplate>
</template>
<script setup lang="ts">
@@ -39,6 +43,8 @@ import SystemStatsPanel from '@/components/common/SystemStatsPanel.vue'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import PanelTemplate from './PanelTemplate.vue'
const systemStatsStore = useSystemStatsStore()
const aboutPanelStore = useAboutPanelStore()
</script>

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