Compare commits

...

120 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
Alexander Brown
69062c6da1 deps: Update vite (#8509)
## Summary

Update from beta.8 to beta.12

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8509-deps-Update-vite-2f96d73d3650814c96dbe14cdfb02151)
by [Unito](https://www.unito.io)
2026-02-10 22:42:26 -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
Alexander Brown
a7c2115166 feat: add WidgetValueStore for centralized widget value management (#8594)
## Summary

Implements Phase 1 of the **Vue-owns-truth** pattern for widget values.
Widget values are now canonical in a Pinia store; `widget.value`
delegates to the store while preserving full backward compatibility.

## Changes

- **New store**: `src/stores/widgetValueStore.ts` - centralized widget
value storage with `get/set/remove/removeNode` API
- **BaseWidget integration**: `widget.value` getter/setter now delegates
to store when widget is associated with a node
- **LGraphNode wiring**: `addCustomWidget()` automatically calls
`widget.setNodeId(this.id)` to wire widgets to their nodes
- **Test fixes**: Added Pinia setup to test files that use widgets

## Why

This foundation enables:
- Vue components to reactively bind to widget values via `computed(() =>
store.get(...))`
- Future Yjs/CRDT backing for real-time collaboration
- Cleaner separation between Vue state and LiteGraph rendering

## Backward Compatibility

| Extension Pattern | Status |
|-------------------|--------|
| `widget.value = x` |  Works unchanged |
| `node.widgets[i].value` |  Works unchanged |
| `widget.callback` |  Still fires |
| `node.onWidgetChanged` |  Still fires |

## Testing

-  4252 unit tests pass
-  Build succeeds

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8594-feat-add-WidgetValueStore-for-centralized-widget-value-management-2fc6d73d36508160886fcb9f3ebd941e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-10 19:37:17 -08:00
Csongor Czezar
d044bed9b2 feat: use object info display_name as fallback before node name (#7622)
## Description
Implements fallback to object info display_name before using internal
node name when display_name is unavailable.

## Related Issue
Related to backend PR: comfyanonymous/ComfyUI#11340

## Changes
- Modified `getNodeDefs()` to use object info `display_name` before
falling back to `name`
- Added unit tests for display name fallback behavior

## Testing
- All existing tests pass
- Added 4 new unit tests covering various display_name scenarios

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7622-W-I-P-feat-use-object-info-display_name-as-fallback-before-node-name-2cd6d73d365081deb22fe5ed00e6dc2e)
by [Unito](https://www.unito.io)
2026-02-10 22:18:48 -05:00
Comfy Org PR Bot
d873c8048f 1.40.0 (#8797)
Minor version increment to 1.40.0

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8797-1-40-0-3046d73d36508109b4b7ed3f6ca4530e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-10 19:06:48 -08:00
Comfy Org PR Bot
475d7035f7 1.39.12 (#8790)
Patch version increment to 1.39.12

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8790-1-39-12-3046d73d3650812faaf5dfaf71f6a02a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-10 18:52:06 -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
guill
eb6bf91e20 fix(download): Use content-disposition filename (#8785)
When we download an output, we now check if there's a filename defined
in the content-disposition and use that if there is.

## Summary
This has been primarily an issue on Comfy Cloud where assets are
content-addressed. Before now,
the downloaded files have retained the hash as the filename. With this
change, downloaded files
will use the user-supplied filename instead.

## Changes

- **What**: Use content-disposition filename when downloading assets

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8785-fix-download-Use-content-disposition-filename-3046d73d365081ec952ef3c1930e773d)
by [Unito](https://www.unito.io)
2026-02-10 18:50:42 -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
Csongor Czezar
422227d2fc fix: viewport overflow in manager (#7775)
### **Summary**
Fixes viewport overflow in version selector dropdown when Manager dialog
is positioned near edges

**Changes**
Removed popover arrow for visual consistency across all positions
Implemented dialog boundary detection to constrain popover within
Manager viewport

**Testing**
All existing unit tests pass (17/17)
Visually tested across different screen positions


![after-fix-viewport-all-positions](https://github.com/user-attachments/assets/287952f1-eda3-4388-9d6a-8f4316acea7f)

![before-fix-viewport-low](https://github.com/user-attachments/assets/b88dc61d-896b-48af-870f-2b5d52a11a98)

![before-fix-viewport-high](https://github.com/user-attachments/assets/7a39c845-0593-480e-843e-d5da30b48661)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7775-fix-viewport-overflow-in-manager-2d76d73d365081a88c1df2a103c5925e)
by [Unito](https://www.unito.io)
2026-02-10 21:29:44 -05: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
Terry Jia
10e9bc2f8d fix: extract WidgetCallbackOptions interface and add curly braces (#8791)
## Summary

improve for https://github.com/Comfy-Org/ComfyUI_frontend/pull/8774

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8791-fix-extract-WidgetCallbackOptions-interface-and-add-curly-braces-3046d73d365081c49e37c0a2596f1958)
by [Unito](https://www.unito.io)
2026-02-10 17:47:37 -08:00
Terry Jia
f7b835e6a5 fix: disable control after generate during partial execution (#8774)
## Summary
Passes an isPartialExecution flag through widget
beforeQueued/afterQueued callbacks so control-after-generate widgets
skip value modifications (randomize, increment, decrement) when the user
queues selected output nodes via partial execution.

requested by @christian-byrne in notion

## Screenshots (if applicable)
before


https://github.com/user-attachments/assets/3e723087-8849-457b-9f95-b8b5fceab0ed


after


https://github.com/user-attachments/assets/d9816667-51e0-4538-a012-9c84d0944019

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8774-fix-disable-control-after-generate-during-partial-execution-3036d73d365081688ca3d6b0506d69ca)
by [Unito](https://www.unito.io)
2026-02-10 20:13:03 -05:00
Johnpaul Chiwetelu
7f30d6b6a5 feat: add visual indicator for list output slots (#8766)
## Summary

Add rounded square dot shape and "(Iterative)" tooltip for list-type
output slots in Vue nodes, matching litegraph's visual indicator.

## Changes

- **What**: `SlotConnectionDot.vue` renders `rounded-[1px]` instead of
`rounded-full` when slot shape is `RenderShape.GRID`. `OutputSlot.vue`
appends "(Iterative)" to the tooltip for these slots.

<img width="807" height="542" alt="Screenshot 2026-02-10 at 03 38 42"
src="https://github.com/user-attachments/assets/137b60c5-ac3b-457f-a52d-58f5f28a59ea"
/>


## Review Focus
- i18n key added for the iterative suffix

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8766-feat-add-visual-indicator-for-list-output-slots-3036d73d3650813aad85ce094d29c42b)
by [Unito](https://www.unito.io)
2026-02-11 01:49:58 +01:00
Benjamin Lu
da56c9e554 feat: integrate Impact telemetry with checkout attribution for subscriptions (#8688)
Implement Impact telemetry and checkout attribution through cloud
subscription checkout flows.

This PR adds Impact.com tracking support and carries attribution context
from landing-page visits into subscription checkout requests so
conversion attribution can be validated end-to-end.

- Register a new `ImpactTelemetryProvider` during cloud telemetry
initialization.
- Initialize the Impact queue/runtime (`ire`) and load the Universal
Tracking Tag script once.
- Invoke `ire('identify', ...)` on page views with dynamic `customerId`
and SHA-1 `customerEmail` (or empty strings when unknown).
- Expand checkout attribution capture to include `im_ref`, UTM fields,
and Google click IDs, with local persistence across navigation.
- Attempt `ire('generateClickId')` with a timeout and fall back to
URL/local attribution when unavailable.
- Include attribution payloads in checkout creation requests for both:
  - `/customers/cloud-subscription-checkout`
  - `/customers/cloud-subscription-checkout/{tier}`
- Extend begin-checkout telemetry metadata typing to include attribution
fields.
- Add focused unit coverage for provider behavior, attribution
persistence/fallback logic, and checkout request payloads.

Tradeoffs / constraints:
- Attribution collection is treated as best-effort in tiered checkout
flow to avoid blocking purchases.
- Backend checkout handlers must accept and process the additional JSON
attribution fields.

## Screenshots

<img width="908" height="208" alt="image"
src="https://github.com/user-attachments/assets/03c16d60-ffda-40c9-9bd6-8914d841be50"/>
<img width="1144" height="460" alt="image"
src="https://github.com/user-attachments/assets/74b97fde-ce0a-43e6-838e-9a4aba484488"/>
<img width="1432" height="320" alt="image"
src="https://github.com/user-attachments/assets/30c22a9f-7bd8-409f-b0ef-e4d02343780a"/>
<img width="341" height="135" alt="image"
src="https://github.com/user-attachments/assets/f6d918ae-5f80-45e0-855a-601abea61dec"/>
2026-02-10 16:40:51 -08:00
Alexander Brown
79063edf54 Remove comfy logo splash screen. (#8786)
## Summary

```



```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8786-Remove-comfy-logo-splash-screen-3046d73d3650816d92c3ff04afeb8cf6)
by [Unito](https://www.unito.io)
2026-02-10 16:32:18 -08:00
Johnpaul Chiwetelu
d4c40f5255 fix: right-click context menu disabled when selection toolbox is off (#8781)
## Summary

- Move `NodeContextMenu` from `SelectionToolbox.vue` to
`GraphCanvas.vue` so the right-click context menu renders independently
of the `Comfy.Canvas.SelectionToolbox` setting

- Fixes #8417

## Test plan

- [x] Disable selection toolbox in settings, right-click a node —
context menu appears
- [x] Enable selection toolbox, right-click a node — context menu still
appears
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8781-fix-right-click-context-menu-disabled-when-selection-toolbox-is-off-3036d73d36508168a9add58e060b7e93)
by [Unito](https://www.unito.io)
2026-02-11 00:35:52 +01:00
Johnpaul Chiwetelu
1e1d5c8308 fix: stop suppressing link rendering during node resize (#8780)
## Summary

Stop link flickering when resizing nodes by removing the
`pendingSlotSync` flag assertion from `scheduleSlotLayoutSync`.

## Changes

- **What**: Remove `layoutStore.setPendingSlotSync(true)` from
`scheduleSlotLayoutSync()` in `useSlotElementTracking.ts`. This call was
introduced in #8367 for graph reconfiguration but was also triggered on
every node resize, causing all links to disappear for one frame per
resize tick. The reconfigure path in `app.ts`
(`addAfterConfigureHandler`) still sets the flag explicitly, so
undo/redo link suppression is unaffected.

## Review Focus

The `pendingSlotSync` flag is still managed correctly for graph
reconfiguration: `app.ts:748` sets it before configure, and the
`finally` block flushes it synchronously. The
`flushScheduledSlotLayoutSync` early-return (pendingNodes empty but
graph has nodes) continues to handle late-mounting Vue components during
reconfigure.

## Before

https://github.com/user-attachments/assets/28cfe4d8-f3f0-46f1-a717-5cb81a28dd75



## After




https://github.com/user-attachments/assets/9445fd00-91f8-4d1e-90ac-86d138d29842

Fixes #8696

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8780-fix-stop-suppressing-link-rendering-during-node-resize-3036d73d365081029820ccfd57425a07)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-11 00:03:40 +01: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
Johnpaul Chiwetelu
e411a104f4 feat: scroll to specific setting when opening settings dialog (#8761)
## Summary

- Adds `settingId` parameter to `showSettingsDialog` that auto-navigates
to the correct category tab, scrolls to the setting, and briefly
highlights it with a CSS pulse animation
- Adds `data-setting-id` attributes to setting items for stable DOM
targeting
- Adds "Don't show this again" checkbox with "Re-enable in Settings"
deep-link to the missing nodes dialog
- Adds "Re-enable in Settings" deep-link to missing models and blueprint
overwrite "Don't show this again" checkboxes

- Fixes #3437

## Test plan

- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] Unit tests pass (59/59 including 5 new tests for `useSettingUI`)



https://github.com/user-attachments/assets/a9e80aea-7b69-4686-b030-55a2e0570ff0



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8761-feat-scroll-to-specific-setting-when-opening-settings-dialog-3036d73d365081d18d9afe9f9ed41ebc)
by [Unito](https://www.unito.io)
2026-02-10 23:00:46 +01:00
Terry Jia
19a724710c fix: address review nits in load3d (#8779)
## Summary
- Refactor getModelUrl to use const instead of let
- add missing language key

improve for https://github.com/Comfy-Org/ComfyUI_frontend/pull/8765

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8779-fix-address-review-nits-in-load3d-3036d73d36508183af11c5e9bc545650)
by [Unito](https://www.unito.io)
2026-02-10 16:09:54 -05: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
Terry Jia
9ecbb3af27 Feat/3d dropdown (#8765)
## Summary
Add mesh_upload and upload_subfolder to combo input schema so
WidgetSelect detects mesh uploads generically instead of hardcoding node
type checks. Inject these flags in load3dLazy.ts so they are available
before THREE.js loads.

Also unify SUPPORTED_EXTENSIONS_ACCEPT across load3d and dropdown, pass
uploadSubfolder prop through to WidgetSelectDropdown for correct upload
path, and update error message to list all supported extensions.

replacement for https://github.com/Comfy-Org/ComfyUI_frontend/pull/7975

(We should include thumbnail but not yet, will do it later)

## Screenshots (if applicable)


https://github.com/user-attachments/assets/2cb4b1da-af4f-439b-9786-3ac780c2480d

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8765-Feat-3d-dropdown-3036d73d365081d8a10ee19d3ed7d295)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Kelly Yang <124ykl@gmail.com>
2026-02-10 15:36:57 -05: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
AustinMroz
581452d312 Austin/fix move subgraph input (#8777)
Previously, moving a subgraph input link and re-attaching to the same
input slot would result in an invalid link


![broken-link](https://github.com/user-attachments/assets/085a0a6f-281d-4e06-be58-e5bdc873f1d5)

This occurred because:
- A new link is created to which overwrites the target `input.link`
- The previous link is then disconnected, which clears `input.link`

This is solved by instead returning early if the target is the same as
the existing link.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8777-Austin-fix-move-subgraph-input-3036d73d365081318de3cccb926f7fe7)
by [Unito](https://www.unito.io)
2026-02-10 12:31:09 -08:00
Simula_r
9dde4e7bc7 feat: sort workspaces (#8770)
## Summary

Sort workspaces so that the personal workspace appears first, followed
by the rest in ascending order (oldest first) by created_at / joined_at.

## Changes

- **What**: teamWorkspaceStore.ts, teamWorkspaceStore.test.ts
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->
2026-02-10 10:11:35 -08:00
Johnpaul Chiwetelu
0288ea5b39 feat: add setMany to settingStore for batch setting updates (#8767)
## Summary
- Adds `setMany()` method to `settingStore` for updating multiple
settings in a single API call via the existing `storeSettings` endpoint
- Extracts shared setting-apply logic (`applySettingLocally`) to reduce
duplication between `set()` and `setMany()`
- Migrates all call sites where multiple settings were updated
sequentially to use `setMany()`

## Call sites updated
- `releaseStore.ts` — `handleSkipRelease`, `handleShowChangelog`,
`handleWhatsNewSeen` (3 settings each)
- `keybindingService.ts` — `persistUserKeybindings` (2 settings)
- `coreSettings.ts` — `NavigationMode.onChange` (2 settings)

## Test plan
- [x] Unit tests for `setMany` (batch update, skip unchanged, no-op when
unchanged)
- [x] Updated `releaseStore.test.ts` assertions to verify `setMany`
usage
- [x] Updated `useCoreCommands.test.ts` mock to include `setMany`
- [x] All existing tests pass
- [x] `pnpm typecheck`, `pnpm lint`, `pnpm format` pass

Fixes #1079

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8767-feat-add-setMany-to-settingStore-for-batch-setting-updates-3036d73d36508161b8b6d298e1be1b7a)
by [Unito](https://www.unito.io)
2026-02-10 13:47:53 +01:00
Comfy Org PR Bot
061e96e488 1.39.11 (#8763)
Patch version increment to 1.39.11

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8763-1-39-11-3036d73d365081458389fe558cd921ee)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-02-09 21:28:11 -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
737 changed files with 10386 additions and 5082 deletions

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,6 +219,12 @@
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off",
"typescript/no-floating-promises": "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"
},
@@ -114,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

@@ -98,12 +98,10 @@ const config: StorybookConfig = {
},
build: {
rolldownOptions: {
experimental: {
strictExecutionOrder: true
},
treeshake: false,
output: {
keepNames: true
keepNames: true,
strictExecutionOrder: true
},
onwarn: (warning, warn) => {
// Suppress specific warnings

View File

@@ -298,7 +298,10 @@ test.describe('Settings', () => {
await input.press('Alt+n')
const requestPromise = comfyPage.page.waitForRequest(
'**/api/settings/Comfy.Keybinding.NewBindings'
(req) =>
req.url().includes('/api/settings') &&
!req.url().includes('/api/settings/') &&
req.method() === 'POST'
)
// Save keybinding

View File

@@ -5,13 +5,6 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Graph.DeduplicateSubgraphNodeIds',
true
)
})
test('All node IDs are globally unique after loading', async ({
comfyPage
}) => {

View File

@@ -61,7 +61,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
)
await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
@@ -77,7 +80,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()
const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
)
await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
await comfyPage.nextFrame()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 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)?:/'],

7
global.d.ts vendored
View File

@@ -5,6 +5,11 @@ declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
declare const __USE_PROD_CONFIG__: boolean
interface ImpactQueueFunction {
(...args: unknown[]): void
a?: unknown[][]
}
interface Window {
__CONFIG__: {
gtm_container_id?: string
@@ -37,6 +42,8 @@ interface Window {
session_number?: string
}
dataLayer?: Array<Record<string, unknown>>
ire_o?: string
ire?: ImpactQueueFunction
}
interface Navigator {

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.39.10",
"version": "1.40.0",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -193,7 +193,7 @@
},
"pnpm": {
"overrides": {
"vite": "^8.0.0-beta.8"
"vite": "catalog:"
}
}
}

1111
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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
@@ -92,7 +92,7 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vite: ^8.0.0-beta.8
vite: 8.0.0-beta.13
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.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

@@ -1,6 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { downloadFile } from '@/base/common/downloadUtil'
import {
downloadFile,
extractFilenameFromContentDisposition
} from '@/base/common/downloadUtil'
let mockIsCloud = false
@@ -46,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'
@@ -155,10 +158,14 @@ describe('downloadUtil', () => {
const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi.fn().mockReturnValue(null)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
@@ -195,5 +202,147 @@ describe('downloadUtil', () => {
expect(createObjectURLSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('uses filename from Content-Disposition header in cloud mode', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi.fn().mockReturnValue('attachment; filename="user-friendly.png"')
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
expect(fetchMock).toHaveBeenCalledWith(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
expect(headersMock.get).toHaveBeenCalledWith('Content-Disposition')
expect(mockLink.download).toBe('user-friendly.png')
})
it('uses RFC 5987 filename from Content-Disposition header', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi
.fn()
.mockReturnValue(
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%E4%B8%AD%E6%96%87.png'
)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl)
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
expect(mockLink.download).toBe('中文.png')
})
it('falls back to provided filename when Content-Disposition is missing', async () => {
mockIsCloud = true
const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
const blob = new Blob(['test'])
const blobFn = vi.fn().mockResolvedValue(blob)
const headersMock = {
get: vi.fn().mockReturnValue(null)
}
fetchMock.mockResolvedValue({
ok: true,
status: 200,
blob: blobFn,
headers: headersMock
} as unknown as Response)
downloadFile(testUrl, 'my-fallback.png')
const fetchPromise = fetchMock.mock.results[0].value as Promise<Response>
await fetchPromise
const blobPromise = blobFn.mock.results[0].value as Promise<Blob>
await blobPromise
await Promise.resolve()
expect(mockLink.download).toBe('my-fallback.png')
})
})
describe(extractFilenameFromContentDisposition, () => {
it('returns null for null header', () => {
expect(extractFilenameFromContentDisposition(null)).toBeNull()
})
it('returns null for empty header', () => {
expect(extractFilenameFromContentDisposition('')).toBeNull()
})
it('extracts filename from simple quoted format', () => {
const header = 'attachment; filename="test-file.png"'
expect(extractFilenameFromContentDisposition(header)).toBe(
'test-file.png'
)
})
it('extracts filename from unquoted format', () => {
const header = 'attachment; filename=test-file.png'
expect(extractFilenameFromContentDisposition(header)).toBe(
'test-file.png'
)
})
it('extracts filename from RFC 5987 format', () => {
const header = "attachment; filename*=UTF-8''test%20file.png"
expect(extractFilenameFromContentDisposition(header)).toBe(
'test file.png'
)
})
it('prefers RFC 5987 format over simple format', () => {
const header =
'attachment; filename="fallback.png"; filename*=UTF-8\'\'preferred.png'
expect(extractFilenameFromContentDisposition(header)).toBe(
'preferred.png'
)
})
it('handles unicode characters in RFC 5987 format', () => {
const header =
"attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.png"
expect(extractFilenameFromContentDisposition(header)).toBe('中文文件.png')
})
it('falls back to simple format when RFC 5987 decoding fails', () => {
const header =
'attachment; filename="fallback.png"; filename*=UTF-8\'\'%invalid'
expect(extractFilenameFromContentDisposition(header)).toBe('fallback.png')
})
it('handles header with only attachment disposition', () => {
const header = 'attachment'
expect(extractFilenameFromContentDisposition(header)).toBeNull()
})
it('handles case-insensitive filename parameter', () => {
const header = 'attachment; FILENAME="test.png"'
expect(extractFilenameFromContentDisposition(header)).toBe('test.png')
})
})
})

View File

@@ -75,14 +75,57 @@ const extractFilenameFromUrl = (url: string): string | null => {
}
}
/**
* Extract filename from Content-Disposition header
* Handles both simple format: attachment; filename="name.png"
* And RFC 5987 format: attachment; filename="fallback.png"; filename*=UTF-8''encoded%20name.png
* @param header - The Content-Disposition header value
* @returns The extracted filename or null if not found
*/
export function extractFilenameFromContentDisposition(
header: string | null
): string | null {
if (!header) return null
// Try RFC 5987 extended format first (filename*=UTF-8''...)
const extendedMatch = header.match(/filename\*=UTF-8''([^;]+)/i)
if (extendedMatch?.[1]) {
try {
return decodeURIComponent(extendedMatch[1])
} catch {
// Fall through to simple format
}
}
// Try simple quoted format: filename="..."
const quotedMatch = header.match(/filename="([^"]+)"/i)
if (quotedMatch?.[1]) {
return quotedMatch[1]
}
// Try unquoted format: filename=...
const unquotedMatch = header.match(/filename=([^;\s]+)/i)
if (unquotedMatch?.[1]) {
return unquotedMatch[1]
}
return null
}
const downloadViaBlobFetch = async (
href: string,
filename: string
fallbackFilename: string
): Promise<void> => {
const response = await fetch(href)
if (!response.ok) {
throw new Error(`Failed to fetch ${href}: ${response.status}`)
}
// Try to get filename from Content-Disposition header (set by backend)
const contentDisposition = response.headers.get('Content-Disposition')
const headerFilename =
extractFilenameFromContentDisposition(contentDisposition)
const blob = await response.blob()
downloadBlob(filename, blob)
downloadBlob(headerFilename ?? fallbackFilename, blob)
}

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

@@ -108,7 +108,7 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl(createJob(id, status))
}
describe('TopMenuSection', () => {
describe(TopMenuSection.__name ?? 'TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
@@ -242,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
})
}

View File

@@ -287,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

@@ -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

@@ -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

@@ -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="{
@@ -37,10 +37,6 @@
<ContextMenu ref="menu" :model="menuItems" />
</template>
<script setup lang="ts">
defineOptions({
inheritAttrs: false
})
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import Tree from 'primevue/tree'
@@ -60,6 +56,10 @@ import type {
} from '@/types/treeExplorerTypes'
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
defineOptions({
inheritAttrs: false
})
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys', {
required: true
})
@@ -68,7 +68,7 @@ const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
// Tracks whether the caller has set the selectionKeys model.
const storeSelectionKeys = selectionKeys.value !== undefined
const props = defineProps<{
const { root, class: className } = defineProps<{
root: TreeExplorerNode
class?: string
}>()
@@ -90,7 +90,7 @@ const {
)
const renderedRoot = computed<RenderedTreeExplorerNode>(() => {
const renderedRoot = fillNodeInfo(props.root)
const renderedRoot = fillNodeInfo(root)
return newFolderNode.value
? combineTrees(renderedRoot, newFolderNode.value)
: renderedRoot

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,7 +31,7 @@
<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>
@@ -52,7 +52,7 @@ import type {
TreeExplorerDragAndDropData
} from '@/types/treeExplorerTypes'
const props = defineProps<{
const { node } = defineProps<{
node: RenderedTreeExplorerNode
}>()
@@ -67,20 +67,20 @@ const emit = defineEmits<{
}>()
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, 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,14 +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)
await node.handleDrop?.(dndData)
canDrop.value = false
emit('itemDropped', props.node, dndData.data)
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

@@ -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

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

@@ -18,17 +18,35 @@
<div class="flex justify-end gap-4">
<div
v-if="type === 'overwriteBlueprint'"
class="flex justify-start gap-4"
class="flex flex-col justify-start gap-1"
>
<Checkbox
v-model="doNotAskAgain"
class="flex justify-start gap-4"
input-id="doNotAskAgain"
binary
/>
<label for="doNotAskAgain" severity="secondary">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
<div class="flex gap-4">
<input
id="doNotAskAgain"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgain">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="text-sm text-muted-foreground ml-8"
>
<template #link>
<Button
variant="textonly"
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
@click="openBlueprintOverwriteSetting"
>
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
</div>
<Button
@@ -92,17 +110,23 @@
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import Message from 'primevue/message'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
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
@@ -114,17 +138,25 @@ const { t } = useI18n()
const onCancel = () => useDialogStore().closeDialog()
function openBlueprintOverwriteSetting() {
useDialogStore().closeDialog()
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

@@ -5,11 +5,34 @@
:title="t('missingModelsDialog.missingModels')"
:message="t('missingModelsDialog.missingModelsMessage')"
/>
<div class="mb-4 flex gap-1">
<Checkbox v-model="doNotAskAgain" binary input-id="doNotAskAgain" />
<label for="doNotAskAgain">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
<div class="mb-4 flex flex-col gap-1">
<div class="flex gap-1">
<input
id="doNotAskAgain"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgain">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="text-sm text-muted-foreground ml-6"
>
<template #link>
<Button
variant="textonly"
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
@click="openShowMissingModelsSetting"
>
{{ t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
</div>
<ListBox :options="missingModels" class="comfy-missing-models">
<template #option="{ option }">
@@ -31,16 +54,18 @@
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import ListBox from 'primevue/listbox'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import ElectronFileDownload from '@/components/common/ElectronFileDownload.vue'
import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
// TODO: Read this from server internal API rather than hardcoding here
// as some installations may wish to use custom sources
@@ -69,7 +94,7 @@ interface ModelInfo {
folder_path?: string
}
const props = defineProps<{
const { missingModels: missingModelsProp, paths } = defineProps<{
missingModels: ModelInfo[]
paths: Record<string, string[]>
}>()
@@ -78,11 +103,19 @@ const { t } = useI18n()
const doNotAskAgain = ref(false)
function openShowMissingModelsSetting() {
useDialogStore().closeDialog({ key: 'global-missing-models-warning' })
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,
@@ -97,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)) {
@@ -124,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

@@ -54,7 +54,7 @@ import { isCloud } from '@/platform/distribution/types'
import type { MissingNodeType } from '@/types/comfy'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
const props = defineProps<{
const { missingNodeTypes } = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
@@ -63,7 +63,7 @@ const { missingCoreNodes } = useMissingNodes()
const uniqueNodes = computed(() => {
const seenTypes = new Set()
return props.missingNodeTypes
return missingNodeTypes
.filter((node) => {
const type = typeof node === 'object' ? node.type : node
if (seenTypes.has(type)) return false

View File

@@ -1,55 +1,86 @@
<template>
<!-- Cloud mode: Learn More + Got It buttons -->
<div
v-if="isCloud"
class="flex w-full items-center justify-between gap-2 py-2 px-4"
>
<Button
variant="textonly"
size="sm"
as="a"
href="https://www.comfy.org/cloud"
target="_blank"
rel="noopener noreferrer"
>
<i class="icon-[lucide--info]"></i>
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
</Button>
<Button variant="secondary" size="md" @click="handleGotItClick">{{
$t('missingNodes.cloud.gotIt')
}}</Button>
</div>
<div class="flex w-full flex-col gap-2 py-2 px-4">
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
<div class="flex items-center gap-1">
<input
id="doNotAskAgainNodes"
v-model="doNotAskAgain"
type="checkbox"
class="h-4 w-4 cursor-pointer"
/>
<label for="doNotAskAgainNodes">{{
$t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<i18n-t
v-if="doNotAskAgain"
keypath="missingModelsDialog.reEnableInSettings"
tag="span"
class="text-sm text-muted-foreground ml-6"
>
<template #link>
<Button
variant="textonly"
class="underline cursor-pointer p-0 text-sm text-muted-foreground hover:bg-transparent"
@click="openShowMissingNodesSetting"
>
{{ $t('missingModelsDialog.reEnableInSettingsLink') }}
</Button>
</template>
</i18n-t>
</div>
<!-- OSS mode: Open Manager + Install All buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1 py-2 px-4">
<Button variant="textonly" @click="openManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton
v-if="showInstallAllButton"
type="secondary"
size="md"
:disabled="
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
"
:is-loading="isLoading"
:node-packs="missingNodePacks"
:label="
isLoading
? $t('manager.gettingInfo')
: $t('manager.installAllMissingNodes')
"
/>
<!-- Cloud mode: Learn More + Got It buttons -->
<div v-if="isCloud" class="flex w-full items-center justify-between gap-2">
<Button
variant="textonly"
size="sm"
as="a"
href="https://www.comfy.org/cloud"
target="_blank"
rel="noopener noreferrer"
>
<i class="icon-[lucide--info]"></i>
<span>{{ $t('missingNodes.cloud.learnMore') }}</span>
</Button>
<Button variant="secondary" size="md" @click="handleGotItClick">{{
$t('missingNodes.cloud.gotIt')
}}</Button>
</div>
<!-- OSS mode: Open Manager + Install All buttons -->
<div v-else-if="showManagerButtons" class="flex justify-end gap-1">
<Button variant="textonly" @click="openManager">{{
$t('g.openManager')
}}</Button>
<PackInstallButton
v-if="showInstallAllButton"
type="secondary"
size="md"
:disabled="
isLoading || !!error || missingNodePacks.length === 0 || isInstalling
"
:is-loading="isLoading"
:node-packs="missingNodePacks"
:label="
isLoading
? $t('manager.gettingInfo')
: $t('manager.installAllMissingNodes')
"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
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 { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import PackInstallButton from '@/workbench/extensions/manager/components/manager/button/PackInstallButton.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
@@ -60,10 +91,24 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
const dialogStore = useDialogStore()
const { t } = useI18n()
const doNotAskAgain = ref(false)
watch(doNotAskAgain, (value) => {
void useSettingStore().set('Comfy.Workflow.ShowMissingNodesWarning', !value)
})
const handleGotItClick = () => {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
}
function openShowMissingNodesSetting() {
dialogStore.closeDialog({ key: 'global-missing-nodes' })
void useDialogService().showSettingsDialog(
undefined,
'Comfy.Workflow.ShowMissingNodesWarning'
)
}
const { missingNodePacks, isLoading, error } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
const managerState = useManagerState()

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

@@ -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

@@ -268,7 +268,7 @@ async function saveKeybinding() {
const commandId = currentEditingCommand.value?.id
const combo = newBindingKeyCombo.value
cancelEdit()
if (!combo || commandId == undefined) return
if (!combo || commandId == null) return
const updated = keybindingStore.updateKeybindingOnCommand(
new KeybindingImpl({ commandId, combo })

View File

@@ -1,5 +1,5 @@
<template>
<TabPanel :value="props.value" class="h-full w-full" :class="props.class">
<TabPanel :value class="h-full w-full" :class="panelClass">
<div class="flex h-full w-full flex-col gap-2">
<slot name="header" />
<ScrollPanel class="h-0 grow pr-2">
@@ -14,7 +14,7 @@
import ScrollPanel from 'primevue/scrollpanel'
import TabPanel from 'primevue/tabpanel'
const props = defineProps<{
const { value, class: panelClass } = defineProps<{
value: string
class?: string
}>()

View File

@@ -17,7 +17,7 @@ vi.mock('@/utils/formatUtil', () => ({
normalizeI18nKey: vi.fn()
}))
describe('SettingItem', () => {
describe(SettingItem.__name ?? 'SettingItem', () => {
const mountComponent = (props: Record<string, unknown>, options = {}) => {
return mount(SettingItem, {
global: {

View File

@@ -76,7 +76,7 @@ const i18n = createI18n({
}
})
describe('UsageLogsTable', () => {
describe(UsageLogsTable.__name ?? 'UsageLogsTable', () => {
const mockEventsResponse = {
events: [
{

View File

@@ -169,9 +169,10 @@ const loadEvents = async () => {
} else {
error.value = customerEventService.error.value || 'Failed to load events'
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error'
console.error('Error loading events:', err)
} catch (errorCaught) {
error.value =
errorCaught instanceof Error ? errorCaught.message : 'Unknown error'
console.error('Error loading events:', errorCaught)
} finally {
loading.value = false
}

View File

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

View File

@@ -58,7 +58,7 @@ vi.mock('primevue/usetoast', () => ({
}))
}))
describe('SignInForm', () => {
describe(SignInForm.__name ?? 'SignInForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSendPasswordReset.mockReset()
@@ -110,12 +110,17 @@ describe('SignInForm', () => {
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Mock getElementById to track focus
// Mock querySelector to track focus on the email input
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
const originalQuerySelector = document.querySelector.bind(document)
const spy = vi
.spyOn(document, 'querySelector')
.mockImplementation((selector: string) => {
if (selector === '#comfy-org-sign-in-email')
return mockElement as HTMLElement
return originalQuerySelector(selector)
})
// Click forgot password link while email is empty
await forgotPasswordSpan.trigger('click')
@@ -129,10 +134,9 @@ describe('SignInForm', () => {
})
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(spy).toHaveBeenCalledWith('#comfy-org-sign-in-email')
expect(mockFocus).toHaveBeenCalled()
spy.mockRestore()
// Should NOT call sendPasswordReset
expect(mockSendPasswordReset).not.toHaveBeenCalled()
@@ -212,7 +216,7 @@ describe('SignInForm', () => {
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(false)
} catch (error) {
} catch {
// Fallback test - check HTML content if component rendering fails
mockLoading = true
const wrapper = mountComponent()
@@ -270,21 +274,25 @@ describe('SignInForm', () => {
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
// Mock getElementById to track focus
// Mock querySelector to track focus on the email input
const mockFocus = vi.fn()
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
const originalQuerySelector = document.querySelector.bind(document)
const spy = vi
.spyOn(document, 'querySelector')
.mockImplementation((selector: string) => {
if (selector === '#comfy-org-sign-in-email')
return mockElement as HTMLElement
return originalQuerySelector(selector)
})
// Call handleForgotPassword with no email
await component.handleForgotPassword('', false)
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(spy).toHaveBeenCalledWith('#comfy-org-sign-in-email')
expect(mockFocus).toHaveBeenCalled()
spy.mockRestore()
})
it('does not focus email input when valid email is provided', async () => {

View File

@@ -120,7 +120,7 @@ const handleForgotPassword = async (
life: 5_000
})
// Focus the email input
document.getElementById(emailInputId)?.focus?.()
document.querySelector<HTMLElement>(`#${emailInputId}`)?.focus?.()
return
}
await firebaseAuthActions.sendPasswordReset(email)

View File

@@ -52,7 +52,7 @@ import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useDialogStore } from '@/stores/dialogStore'
const props = defineProps<{
const { cancelAt } = defineProps<{
cancelAt?: string
}>()
@@ -64,7 +64,7 @@ const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
const isLoading = ref(false)
const formattedEndDate = computed(() => {
const dateStr = props.cancelAt ?? subscription.value?.endDate
const dateStr = cancelAt ?? subscription.value?.endDate
if (!dateStr) return t('subscription.cancelDialog.endOfBillingPeriod')
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', {

View File

@@ -66,7 +66,7 @@ interface Props {
buttonStyles?: Record<string, string>
}
defineProps<Props>()
const { buttonStyles } = defineProps<Props>()
const buttonRef = ref<ComponentPublicInstance | null>(null)
const popover = ref<InstanceType<typeof Popover>>()
const commandStore = useCommandStore()

View File

@@ -65,11 +65,12 @@ const updateWidgets = () => {
const canvasStore = useCanvasStore()
whenever(
() => canvasStore.canvas,
(canvas) =>
(canvas.onDrawForeground = useChainCallback(
(canvas) => {
canvas.onDrawForeground = useChainCallback(
canvas.onDrawForeground,
updateWidgets
)),
)
},
{ immediate: true }
)
</script>

View File

@@ -94,6 +94,7 @@
<template v-if="comfyAppReady">
<TitleEditor />
<SelectionToolbox v-if="selectionToolboxEnabled" />
<NodeContextMenu />
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
<DomWidgets v-if="!shouldRenderVueNodes" />
</template>
@@ -121,6 +122,7 @@ import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
@@ -160,6 +162,8 @@ import { useColorPaletteService } from '@/services/colorPaletteService'
import { useNewUserService } from '@/services/useNewUserService'
import { storeToRefs } from 'pinia'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -169,11 +173,9 @@ import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
import SelectionRectangle from './SelectionRectangle.vue'
import { isCloud } from '@/platform/distribution/types'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
const { t } = useI18n()
const emit = defineEmits<{
@@ -246,9 +248,9 @@ watch(
}
)
const allNodes = computed((): VueNodeData[] =>
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
)
const allNodes = computed((): VueNodeData[] => [
...(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
])
function onLinkOverlayReady(el: HTMLCanvasElement) {
if (!canvasStore.canvas) return

View File

@@ -114,8 +114,8 @@ const { isModalVisible, toggleModal, hideModal, hasActivePopup } =
useZoomControls()
const stringifiedMinimapStyles = computed(() => {
const buttonGroupKeys = ['borderRadius']
const buttonKeys = ['borderRadius']
const buttonGroupKeys = new Set(['borderRadius'])
const buttonKeys = new Set(['borderRadius'])
const additionalButtonStyles = {
border: 'none'
}
@@ -124,14 +124,12 @@ const stringifiedMinimapStyles = computed(() => {
const buttonStyles = {
...Object.fromEntries(
Object.entries(containerStyles).filter(([key]) =>
buttonKeys.includes(key)
)
Object.entries(containerStyles).filter(([key]) => buttonKeys.has(key))
),
...additionalButtonStyles
}
const buttonGroupStyles = Object.entries(containerStyles)
.filter(([key]) => buttonGroupKeys.includes(key))
.filter(([key]) => buttonGroupKeys.has(key))
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
return { buttonStyles, buttonGroupStyles }

View File

@@ -248,8 +248,8 @@ defineExpose({ toggle, hide, isOpen, show })
function showColorPopover(event: MouseEvent) {
event.stopPropagation()
event.preventDefault()
const target = Array.from((event.currentTarget as HTMLElement).children).find(
(el) => el.classList.contains('icon-[lucide--chevron-right]')
const target = [...(event.currentTarget as HTMLElement).children].find((el) =>
el.classList.contains('icon-[lucide--chevron-right]')
) as HTMLElement
colorPickerMenu.value?.toggle(event, target)
}

View File

@@ -34,14 +34,14 @@ const left = ref<string>()
const top = ref<string>()
function hideTooltip() {
return (tooltipText.value = '')
tooltipText.value = ''
}
async function showTooltip(tooltip: string | null | undefined) {
if (!tooltip) return
left.value = comfyApp.canvas.mouse[0] + 'px'
top.value = comfyApp.canvas.mouse[1] + 'px'
left.value = `${comfyApp.canvas.mouse[0]}px`
top.value = `${comfyApp.canvas.mouse[1]}px`
tooltipText.value = tooltip
await nextTick()
@@ -50,11 +50,11 @@ async function showTooltip(tooltip: string | null | undefined) {
if (!rect) return
if (rect.right > window.innerWidth) {
left.value = comfyApp.canvas.mouse[0] - rect.width + 'px'
left.value = `${comfyApp.canvas.mouse[0] - rect.width}px`
}
if (rect.top < 0) {
top.value = comfyApp.canvas.mouse[1] + rect.height + 'px'
top.value = `${comfyApp.canvas.mouse[1] + rect.height}px`
}
}

View File

@@ -102,7 +102,7 @@ vi.mock('@/stores/nodeDefStore', () => ({
})
}))
describe('SelectionToolbox', () => {
describe(SelectionToolbox.__name ?? 'SelectionToolbox', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
const i18n = createI18n({

View File

@@ -42,7 +42,6 @@
</Panel>
</Transition>
</div>
<NodeContextMenu />
</template>
<script setup lang="ts">
@@ -69,7 +68,6 @@ import { useExtensionService } from '@/services/extensionService'
import { useCommandStore } from '@/stores/commandStore'
import type { ComfyCommandImpl } from '@/stores/commandStore'
import NodeContextMenu from './NodeContextMenu.vue'
import FrameNodes from './selectionToolbox/FrameNodes.vue'
import NodeOptionsButton from './selectionToolbox/NodeOptionsButton.vue'
import VerticalDivider from './selectionToolbox/VerticalDivider.vue'
@@ -84,16 +82,14 @@ const { visible } = useSelectionToolboxPosition(toolboxRef)
const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
const commandIds = new Set<string>(
canvasStore.selectedItems
.map(
(item) =>
extensionService
.invokeExtensions('getSelectionToolboxCommands', item)
.flat() as string[]
)
.flat()
canvasStore.selectedItems.flatMap(
(item) =>
extensionService
.invokeExtensions('getSelectionToolboxCommands', item)
.flat() as string[]
)
)
return Array.from(commandIds)
return [...commandIds]
.map((commandId) => commandStore.getCommand(commandId))
.filter((command): command is ComfyCommandImpl => command !== undefined)
})

View File

@@ -68,7 +68,7 @@ const createWrapper = (props = {}) => {
})
}
describe('ZoomControlsModal', () => {
describe(ZoomControlsModal.__name ?? 'ZoomControlsModal', () => {
beforeEach(() => {
vi.resetAllMocks()
})

View File

@@ -84,7 +84,7 @@ interface Props {
visible: boolean
}
const props = defineProps<Props>()
const { visible } = defineProps<Props>()
const interval = ref<number | null>(null)
@@ -132,7 +132,7 @@ const zoomToFitCommandText = computed(() =>
const zoomInputContainer = ref<HTMLDivElement | null>(null)
watch(
() => props.visible,
() => visible,
async (newVal) => {
if (newVal) {
await nextTick()

View File

@@ -20,7 +20,7 @@ vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => true)
}))
describe('BypassButton', () => {
describe(BypassButton.__name ?? 'BypassButton', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let commandStore: ReturnType<typeof useCommandStore>

View File

@@ -45,7 +45,7 @@ vi.mock('@/lib/litegraph/src/litegraph', async () => {
// Mock the colorUtil module
vi.mock('@/utils/colorUtil', () => ({
adjustColor: vi.fn((color: string) => color + '_light')
adjustColor: vi.fn((color: string) => `${color}_light`)
}))
// Mock the litegraphUtil module
@@ -56,7 +56,7 @@ vi.mock('@/utils/litegraphUtil', () => ({
isReroute: vi.fn(() => false)
}))
describe('ColorPickerButton', () => {
describe(ColorPickerButton.__name ?? 'ColorPickerButton', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let workflowStore: ReturnType<typeof useWorkflowStore>

View File

@@ -80,7 +80,7 @@ interface Emits {
(e: 'submenu-click', subOption: SubMenuOption): void
}
const props = defineProps<Props>()
const { option } = defineProps<Props>()
const emit = defineEmits<Emits>()
const { getCurrentShape } = useNodeCustomization()
@@ -113,9 +113,9 @@ const isShapeSelected = (subOption: SubMenuOption): boolean => {
const isColorSubmenu = computed(() => {
return (
props.option.submenu &&
props.option.submenu.length > 0 &&
props.option.submenu.every((item) => item.color && !item.icon)
option.submenu &&
option.submenu.length > 0 &&
option.submenu.every((item) => item.color && !item.icon)
)
})
</script>

View File

@@ -30,7 +30,7 @@ vi.mock('@/composables/graph/useSelectionState', () => ({
}))
}))
describe('ExecuteButton', () => {
describe(ExecuteButton.__name ?? 'ExecuteButton', () => {
let mockCanvas: LGraphCanvas
let mockSelectedNodes: LGraphNode[]

View File

@@ -24,7 +24,7 @@ import type { ComfyCommand } from '@/stores/commandStore'
import { useCommandStore } from '@/stores/commandStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
defineProps<{
const { command } = defineProps<{
command: ComfyCommand
}>()

View File

@@ -18,7 +18,7 @@ vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
})
}))
describe('InfoButton', () => {
describe(InfoButton.__name ?? 'InfoButton', () => {
const i18n = createI18n({
legacy: false,
locale: 'en',

View File

@@ -20,7 +20,7 @@ import { useExecutionStore } from '@/stores/executionStore'
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const modelValue = defineModel<string>({ required: true })
const props = defineProps<{
const { nodeId } = defineProps<{
nodeId: NodeId
}>()
@@ -30,7 +30,7 @@ const formattedText = computed(() => {
const src = modelValue.value
// Turn [[label|url]] into placeholders to avoid interfering with linkifyHtml
const tokens: { label: string; url: string }[] = []
const holed = src.replace(
const holed = src.replaceAll(
/\[\[([^|\]]+)\|([^\]]+)\]\]/g,
(_m, label, url) => {
tokens.push({ label: String(label), url: String(url) })
@@ -42,10 +42,10 @@ const formattedText = computed(() => {
let html = nl2br(linkifyHtml(holed))
// Restore placeholders as <a>...</a> (minimal escaping + http default)
html = html.replace(/__LNK(\d+)__/g, (_m, i) => {
html = html.replaceAll(/__LNK(\d+)__/g, (_m, i) => {
const { label, url } = tokens[+i]
const safeHref = url.replace(/"/g, '&quot;')
const safeLabel = label.replace(/</g, '&lt;').replace(/>/g, '&gt;')
const safeHref = url.replaceAll('"', '&quot;')
const safeLabel = label.replaceAll('<', '&lt;').replaceAll('>', '&gt;')
return /^https?:\/\//i.test(url)
? `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${safeLabel}</a>`
: safeLabel
@@ -58,7 +58,7 @@ let parentNodeId: NodeId | null = null
onMounted(() => {
// Get the parent node ID from props if provided
// For backward compatibility, fall back to the first executing node
parentNodeId = props.nodeId
parentNodeId = nodeId
})
// Watch for either a new node has starting execution or overall execution ending

View File

@@ -593,11 +593,11 @@ const onUpdateComfyUI = async (): Promise<void> => {
})
await rebootComfyUI()
} catch (err) {
} catch (error) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: err instanceof Error ? err.message : t('g.unknownError'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
}

View File

@@ -5,7 +5,7 @@ import { defineComponent, h, nextTick, ref } from 'vue'
import HoneyToast from './HoneyToast.vue'
describe('HoneyToast', () => {
describe(HoneyToast.__name ?? 'HoneyToast', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''

View File

@@ -110,7 +110,7 @@ import { ASPECT_RATIOS, useImageCrop } from '@/composables/useImageCrop'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Bounds } from '@/renderer/core/layout/types'
const props = defineProps<{
const { nodeId } = defineProps<{
nodeId: NodeId
}>()
@@ -142,5 +142,5 @@ const {
handleResizeStart,
handleResizeMove,
handleResizeEnd
} = useImageCrop(props.nodeId, { imageEl, containerEl, modelValue })
} = useImageCrop(nodeId, { imageEl, containerEl, modelValue })
</script>

View File

@@ -170,7 +170,7 @@ const getLabel = (val: string | null | undefined) => {
// Extract complex style logic from template
const optionStyle = computed(() => {
if (!popoverMinWidth && !popoverMaxWidth) return undefined
if (!popoverMinWidth && !popoverMaxWidth) return
const styles: string[] = []
if (popoverMinWidth) styles.push(`min-width: ${popoverMinWidth}`)

View File

@@ -84,24 +84,24 @@ import { app } from '@/scripts/app'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const props = defineProps<{
const { widget, nodeId } = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
}>()
function isComponentWidget(
widget: ComponentWidget<string[]> | SimplifiedWidget
): widget is ComponentWidget<string[]> {
return 'node' in widget && widget.node !== undefined
w: ComponentWidget<string[]> | SimplifiedWidget
): w is ComponentWidget<string[]> {
return 'node' in w && w.node !== undefined
}
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
if (isComponentWidget(widget)) {
node.value = widget.node
} else if (nodeId) {
onMounted(() => {
node.value = app.rootGraph?.getNodeById(props.nodeId!) || null
node.value = app.rootGraph?.getNodeById(nodeId!) || null
})
}

View File

@@ -35,7 +35,14 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
const props = defineProps<{
const {
initializeLoad3d,
cleanup,
loading,
loadingMessage,
onModelDrop,
isPreview
} = defineProps<{
initializeLoad3d: (containerRef: HTMLElement) => Promise<void>
cleanup: () => void
loading: boolean
@@ -53,20 +60,20 @@ function focusContainer() {
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
useLoad3dDrag({
onModelDrop: async (file) => {
if (props.onModelDrop) {
await props.onModelDrop(file)
if (onModelDrop) {
await onModelDrop(file)
}
},
disabled: computed(() => props.isPreview)
disabled: computed(() => isPreview)
})
onMounted(() => {
if (container.value) {
void props.initializeLoad3d(container.value)
void initializeLoad3d(container.value)
}
})
onUnmounted(() => {
props.cleanup()
cleanup()
})
</script>

View File

@@ -110,7 +110,7 @@ import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const props = defineProps<{
const { node, modelUrl } = defineProps<{
node?: LGraphNode
modelUrl?: string
}>()
@@ -120,11 +120,10 @@ const containerRef = ref<HTMLDivElement>()
const maximized = ref(false)
const mutationObserver = ref<MutationObserver | null>(null)
const isStandaloneMode = !props.node && props.modelUrl
const isStandaloneMode = !node && modelUrl
// Use sync version since useLoad3dViewer is already imported (module is loaded)
const viewer = props.node
? useLoad3dService().getOrCreateViewerSync(toRaw(props.node), useLoad3dViewer)
const viewer = node
? useLoad3dService().getOrCreateViewerSync(toRaw(node), useLoad3dViewer)
: useLoad3dViewer()
const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
@@ -138,10 +137,10 @@ const { isDragging, dragMessage, handleDragOver, handleDragLeave, handleDrop } =
onMounted(async () => {
if (!containerRef.value) return
if (isStandaloneMode && props.modelUrl) {
await viewer.initializeStandaloneViewer(containerRef.value, props.modelUrl)
} else if (props.node) {
const source = useLoad3dService().getLoad3d(props.node)
if (isStandaloneMode && modelUrl) {
await viewer.initializeStandaloneViewer(containerRef.value, modelUrl)
} else if (node) {
const source = useLoad3dService().getLoad3d(node)
if (source) {
await viewer.initializeViewer(containerRef.value, source)
}

View File

@@ -69,7 +69,7 @@ const backgroundRenderMode = defineModel<'tiled' | 'panorama'>(
'backgroundRenderMode'
)
defineProps<{
const { hasBackgroundImage, disableBackgroundUpload } = defineProps<{
hasBackgroundImage?: boolean
disableBackgroundUpload?: boolean
}>()

View File

@@ -22,10 +22,10 @@ const currentPanelComponent = computed<Component>(() => {
if (tool === Tools.MaskBucket) {
return PaintBucketSettingsPanel
} else if (tool === Tools.MaskColorFill) {
return ColorSelectSettingsPanel
} else {
return BrushSettingsPanel
}
if (tool === Tools.MaskColorFill) {
return ColorSelectSettingsPanel
}
return BrushSettingsPanel
})
</script>

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