Compare commits
149 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0b99b95c6 | ||
|
|
ddff592561 | ||
|
|
8491ca91b7 | ||
|
|
630fa04882 | ||
|
|
bf80ae7295 | ||
|
|
44348180f5 | ||
|
|
4e12800336 | ||
|
|
d883448b86 | ||
|
|
1c59e3b51b | ||
|
|
b79cbf69af | ||
|
|
b05407ffdd | ||
|
|
2a62f7ec7f | ||
|
|
bf757c11ef | ||
|
|
0ed29a198d | ||
|
|
31d5671f24 | ||
|
|
ba3e2edb8a | ||
|
|
9c2300d780 | ||
|
|
3f85ff751c | ||
|
|
0caf1686c3 | ||
|
|
26f98d24fb | ||
|
|
fcbdee54ec | ||
|
|
a944372f39 | ||
|
|
df51e89311 | ||
|
|
b314435f81 | ||
|
|
ba367c0214 | ||
|
|
64ad6a9bb0 | ||
|
|
3819db5ec4 | ||
|
|
80517e8204 | ||
|
|
40034e77f9 | ||
|
|
2ef8b7cfd7 | ||
|
|
9cf3a0e568 | ||
|
|
f562cf27cd | ||
|
|
4244a0a258 | ||
|
|
ad3d2fe2e9 | ||
|
|
4c23cfbd4d | ||
|
|
9e10e55633 | ||
|
|
59cbe90fd3 | ||
|
|
16bd9abccd | ||
|
|
e84bdc96cf | ||
|
|
a57be36d4d | ||
|
|
3bd508c001 | ||
|
|
612500a4dc | ||
|
|
e9723407d8 | ||
|
|
a01aa39423 | ||
|
|
ab94a55858 | ||
|
|
1bcf5e28d4 | ||
|
|
9e247063aa | ||
|
|
cdddf359a8 | ||
|
|
8558f87547 | ||
|
|
262991db6b | ||
|
|
585d52e24e | ||
|
|
b7535755f0 | ||
|
|
6b7b0f6ec1 | ||
|
|
c7318bcf0a | ||
|
|
11f909436c | ||
|
|
d8f4dc95bb | ||
|
|
c1bc664edd | ||
|
|
e7fe2046ba | ||
|
|
bf4ad38e9b | ||
|
|
2b024bb186 | ||
|
|
6e5930c355 | ||
|
|
6151d487c6 | ||
|
|
e027a9bf44 | ||
|
|
53ee5904e8 | ||
|
|
f82bb71b1e | ||
|
|
40d08a890d | ||
|
|
ebf3c0c049 | ||
|
|
e77d5c1f57 | ||
|
|
b5c1da22db | ||
|
|
0006dd3855 | ||
|
|
7355209c12 | ||
|
|
2aef0a9af8 | ||
|
|
b74887d543 | ||
|
|
bf4ae227b3 | ||
|
|
184bb582da | ||
|
|
3bc3179763 | ||
|
|
eb100894ce | ||
|
|
9a992cb14d | ||
|
|
133aa9bc87 | ||
|
|
3204637e5a | ||
|
|
b2cb719026 | ||
|
|
add805460c | ||
|
|
9621b8f339 | ||
|
|
6be381b15d | ||
|
|
8afe99f48c | ||
|
|
9cd11261f9 | ||
|
|
fbc6665ff4 | ||
|
|
2daa51421c | ||
|
|
0f175c3dc1 | ||
|
|
8d4263c94e | ||
|
|
04580ac031 | ||
|
|
cd35f1d86d | ||
|
|
5d584577fe | ||
|
|
10a96d1af6 | ||
|
|
03392a3cc7 | ||
|
|
12576243ad | ||
|
|
e2a6dc2ec8 | ||
|
|
2f77d74891 | ||
|
|
dacb59f5d3 | ||
|
|
74f991ec1b | ||
|
|
6bc03a624e | ||
|
|
1fb015e046 | ||
|
|
87bf2310b6 | ||
|
|
f1a25989d7 | ||
|
|
236e3fb3e9 | ||
|
|
50382827bc | ||
|
|
41675805b6 | ||
|
|
6321fae6f3 | ||
|
|
06caa21a4d | ||
|
|
9ce3cccfd4 | ||
|
|
9935b322f0 | ||
|
|
60dd242b23 | ||
|
|
cec0dcbccd | ||
|
|
907632a250 | ||
|
|
45c450cdb9 | ||
|
|
ca85b2b144 | ||
|
|
1f28e6ef33 | ||
|
|
fee444c64b | ||
|
|
851739a768 | ||
|
|
1631665efb | ||
|
|
1a066c7062 | ||
|
|
e45f5bdebb | ||
|
|
c270e7734a | ||
|
|
8d7a21e008 | ||
|
|
29e63baca6 | ||
|
|
b22713daf0 | ||
|
|
c8b8953e0a | ||
|
|
731ce8599d | ||
|
|
ec8e55c1c1 | ||
|
|
04d38f2538 | ||
|
|
1c41db75f8 | ||
|
|
c7a7397000 | ||
|
|
e660e1d678 | ||
|
|
fb19752389 | ||
|
|
d098d6ae4e | ||
|
|
e4a5355f58 | ||
|
|
42c004d41d | ||
|
|
009c389607 | ||
|
|
b449dbd26b | ||
|
|
67835edfca | ||
|
|
60c0ce228a | ||
|
|
1990f25638 | ||
|
|
30c473db77 | ||
|
|
2371288fed | ||
|
|
2337fe6f8e | ||
|
|
25e6386b2a | ||
|
|
a03841cb1a | ||
|
|
dc5d7ea1be | ||
|
|
59e20964a0 |
11
.cursorrules
@@ -8,6 +8,15 @@ const vue3CompositionApiBestPractices = [
|
||||
"Use watch and watchEffect for side effects",
|
||||
"Implement lifecycle hooks with onMounted, onUpdated, etc.",
|
||||
"Utilize provide/inject for dependency injection",
|
||||
"Use vue 3.5 style of default prop declaration. Example:
|
||||
|
||||
const { nodes, showTotal = true } = defineProps<{
|
||||
nodes: ApiNodeCost[]
|
||||
showTotal?: boolean
|
||||
}>()
|
||||
|
||||
",
|
||||
"Organize vue component in <template> <script> <style> order",
|
||||
]
|
||||
|
||||
// Folder structure
|
||||
@@ -40,4 +49,6 @@ const additionalInstructions = `
|
||||
7. Implement proper error handling
|
||||
8. Follow Vue 3 style guide and naming conventions
|
||||
9. Use Vite for fast development and building
|
||||
10. Use vue-i18n in composition API for any string literals. Place new translation
|
||||
entries in src/locales/en/main.json.
|
||||
`;
|
||||
|
||||
2
.github/workflows/release.yaml
vendored
@@ -29,9 +29,9 @@ jobs:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
|
||||
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
|
||||
USE_PROD_CONFIG: 'true'
|
||||
run: |
|
||||
npm ci
|
||||
npm run fetch-templates
|
||||
npm run build
|
||||
npm run zipdist
|
||||
- name: Upload dist artifact
|
||||
|
||||
3
.github/workflows/test-ui.yaml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
with:
|
||||
repository: 'Comfy-Org/ComfyUI_devtools'
|
||||
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
|
||||
ref: '49c8220be49120dbaff85f32813d854d6dff2d05'
|
||||
ref: '9d2421fd3208a310e4d0f71fca2ea0c985759c33'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -39,7 +39,6 @@ jobs:
|
||||
- name: Build ComfyUI_frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run fetch-templates
|
||||
npm run build
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
|
||||
3
.gitignore
vendored
@@ -55,3 +55,6 @@ dist.zip
|
||||
|
||||
# Temporary repository directory
|
||||
templates_repo/
|
||||
|
||||
# Vite’s timestamped config modules
|
||||
vite.config.mts.timestamp-*.mjs
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
const { defineConfig } = require('@lobehub/i18n-cli');
|
||||
|
||||
module.exports = defineConfig({
|
||||
modelName: 'gpt-4',
|
||||
modelName: 'gpt-4.1',
|
||||
splitToken: 1024,
|
||||
entry: 'src/locales/en',
|
||||
entryLocale: 'en',
|
||||
output: 'src/locales',
|
||||
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr', 'es'],
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora.
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream.
|
||||
'latent' is the short form of 'latent space'.
|
||||
'mask' is in the context of image processing.
|
||||
`
|
||||
|
||||
25
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"austenc.tailwind-docs",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"davidanson.vscode-markdownlint",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"eamodio.gitlens",
|
||||
"esbenp.prettier-vscode",
|
||||
"figma.figma-vscode-extension",
|
||||
"github.vscode-github-actions",
|
||||
"github.vscode-pull-request-github",
|
||||
"hbenl.vscode-test-explorer",
|
||||
"lokalise.i18n-ally",
|
||||
"ms-playwright.playwright",
|
||||
"vitest.explorer",
|
||||
"vue.volar",
|
||||
"sonarsource.sonarlint-vscode",
|
||||
"deque-systems.vscode-axe-linter",
|
||||
"kisstkondoros.vscode-codemetrics",
|
||||
"donjayamanne.githistory",
|
||||
"wix.vscode-import-cost",
|
||||
"prograhammer.tslint-vue",
|
||||
"antfu.vite"
|
||||
]
|
||||
}
|
||||
449
README.md
@@ -67,449 +67,6 @@ The development of successive minor versions overlaps. For example, while versio
|
||||
| 3 | Mar 15-21 | Released | Feature Freeze | Development | 1.1.7 through 1.1.13 (daily)<br>1.2.0 through 1.2.6 (daily) |
|
||||
| 4 | Mar 22-28 | - | Released | Feature Freeze | 1.2.7 through 1.2.13 (daily)<br>1.3.0 through 1.3.6 (daily) |
|
||||
|
||||
## Release Summary
|
||||
|
||||
### Major features
|
||||
|
||||
<details id='feature-native-translation'>
|
||||
<summary>v1.5: Native translation (i18n)</summary>
|
||||
|
||||
ComfyUI now includes built-in translation support, replacing the need for third-party translation extensions. Select your language
|
||||
in `Comfy > Locale > Language` to translate the interface into English, Chinese (Simplified), Russian, Japanese, or Korean. This native
|
||||
implementation offers better performance, reliability, and maintainability compared to previous solutions.<br>
|
||||
|
||||
More details available here: https://blog.comfy.org/p/native-localization-support-i18n
|
||||
</details>
|
||||
|
||||
<details id='feature-mask-editor'>
|
||||
<summary>v1.4: New mask editor</summary>
|
||||
|
||||
https://github.com/Comfy-Org/ComfyUI_frontend/pull/1284 implements a new mask editor.
|
||||
|
||||

|
||||
</details>
|
||||
|
||||
<details id='feature-integrated-server-terminal'>
|
||||
<summary>v1.3.22: Integrated server terminal</summary>
|
||||
|
||||
Press Ctrl + ` to toggle integrated terminal.
|
||||
|
||||
https://github.com/user-attachments/assets/eddedc6a-07a3-4a83-9475-63b3977f6d94
|
||||
</details>
|
||||
|
||||
<details id='feature-keybinding-customization'>
|
||||
<summary>v1.3.7: Keybinding customization</summary>
|
||||
|
||||
## Basic UI
|
||||

|
||||
|
||||
## Reset button
|
||||

|
||||
|
||||
## Edit Keybinding
|
||||

|
||||

|
||||
|
||||
[rec.webm](https://github.com/user-attachments/assets/a3984ed9-eb28-4d47-86c0-7fc3efc2b5d0)
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-node-library-sidebar'>
|
||||
<summary>v1.2.4: Node library sidebar tab</summary>
|
||||
|
||||
#### Drag & Drop
|
||||
https://github.com/user-attachments/assets/853e20b7-bc0e-49c9-bbce-a2ba7566f92f
|
||||
|
||||
#### Filter
|
||||
https://github.com/user-attachments/assets/4bbca3ee-318f-4cf0-be32-a5a5541066cf
|
||||
</details>
|
||||
|
||||
<details id='feature-queue-sidebar'>
|
||||
<summary>v1.2.0: Queue/History sidebar tab</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/86e264fe-4d26-4f07-aa9a-83bdd2d02b8f
|
||||
</details>
|
||||
|
||||
<details id='feature-node-search'>
|
||||
<summary>v1.1.0: Node search box</summary>
|
||||
|
||||
#### Fuzzy search & Node preview
|
||||

|
||||
|
||||
#### Release link with shift
|
||||
https://github.com/user-attachments/assets/a1b2b5c3-10d1-4256-b620-345de6858f25
|
||||
</details>
|
||||
|
||||
### QoL changes
|
||||
|
||||
<details id='feature-nested-group'>
|
||||
<summary>v1.3.32: **Litegraph** Nested group</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/f51adeb1-028e-40af-81e4-0ac13075198a
|
||||
</details>
|
||||
|
||||
<details id='feature-group-selection'>
|
||||
<summary>v1.3.24: **Litegraph** Group selection</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/e6230a94-411e-4fba-90cb-6c694200adaa
|
||||
</details>
|
||||
|
||||
<details id='feature-toggle-link-visibility'>
|
||||
<summary>v1.3.6: **Litegraph** Toggle link visibility</summary>
|
||||
|
||||
[rec.webm](https://github.com/user-attachments/assets/34e460ac-fbbc-44ef-bfbb-99a84c2ae2be)
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-auto-widget-conversion'>
|
||||
<summary>v1.3.4: **Litegraph** Auto widget to input conversion</summary>
|
||||
|
||||
Dropping a link of correct type on node widget will automatically convert the widget to input.
|
||||
|
||||
[rec.webm](https://github.com/user-attachments/assets/15cea0b0-b225-4bec-af50-2cdb16dc46bf)
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-pan-mode'>
|
||||
<summary>v1.3.4: **Litegraph** Canvas pan mode</summary>
|
||||
|
||||
The canvas becomes readonly in pan mode. Pan mode is activated by clicking the pan mode button on the canvas menu
|
||||
or by holding the space key.
|
||||
|
||||
[rec.webm](https://github.com/user-attachments/assets/c7872532-a2ac-44c1-9e7d-9e03b5d1a80b)
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-shift-drag-link-creation'>
|
||||
<summary>v1.3.1: **Litegraph** Shift drag link to create a new link</summary>
|
||||
|
||||
[rec.webm](https://github.com/user-attachments/assets/7e73aaf9-79e2-4c3c-a26a-911cba3b85e4)
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-optional-input-donuts'>
|
||||
<summary>v1.2.62: **Litegraph** Show optional input slots as donuts</summary>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-group-title-edit'>
|
||||
<summary>v1.2.44: **Litegraph** Double click group title to edit</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/5bf0e2b6-8b3a-40a7-b44f-f0879e9ad26f
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-group-selection-shortcut'>
|
||||
<summary>v1.2.39: **Litegraph** Group selected nodes with Ctrl + G</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/7805dc54-0854-4a28-8bcd-4b007fa01151
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-node-title-edit'>
|
||||
<summary>v1.2.38: **Litegraph** Double click node title to edit</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/d61d5d0e-f200-4153-b293-3e3f6a212b30
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-drag-multi-link'>
|
||||
<summary>v1.2.7: **Litegraph** drags multiple links with shift pressed</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/68826715-bb55-4b2a-be6e-675cfc424afe
|
||||
|
||||
https://github.com/user-attachments/assets/c142c43f-2fe9-4030-8196-b3bfd4c6977d
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-auto-connect-link'>
|
||||
<summary>v1.2.2: **Litegraph** auto connects to correct slot</summary>
|
||||
|
||||
#### Before
|
||||
https://github.com/user-attachments/assets/c253f778-82d5-4e6f-aec0-ea2ccf421651
|
||||
|
||||
#### After
|
||||
https://github.com/user-attachments/assets/b6360ac0-f0d2-447c-9daa-8a2e20c0dc1d
|
||||
</details>
|
||||
|
||||
<details id='feature-hide-text-overflow'>
|
||||
<summary>v1.1.8: **Litegraph** hides text overflow on widget value</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/5696a89d-4a47-4fcc-9e8c-71e1264943f2
|
||||
</details>
|
||||
|
||||
### Developer APIs
|
||||
|
||||
<details>
|
||||
<summary>v1.6.13: prompt/confirm/alert replacements for ComfyUI desktop</summary>
|
||||
|
||||
Several browser-only APIs are not available in ComfyUI desktop's electron environment.
|
||||
|
||||
- `window.prompt`
|
||||
- `window.confirm`
|
||||
- `window.alert`
|
||||
|
||||
Please use the following APIs as replacements.
|
||||
|
||||
```js
|
||||
// window.prompt
|
||||
window['app'].extensionManager.dialog
|
||||
.prompt({
|
||||
title: 'Test Prompt',
|
||||
message: 'Test Prompt Message'
|
||||
})
|
||||
.then((value: string) => {
|
||||
// Do something with the value user entered
|
||||
})
|
||||
```
|
||||
|
||||

|
||||
|
||||
```js
|
||||
// window.confirm
|
||||
window['app'].extensionManager.dialog
|
||||
.confirm({
|
||||
title: 'Test Confirm',
|
||||
message: 'Test Confirm Message'
|
||||
})
|
||||
.then((value: boolean) => {
|
||||
// Do something with the value user entered
|
||||
})
|
||||
```
|
||||
|
||||

|
||||
|
||||
```js
|
||||
// window.alert
|
||||
window['app'].extensionManager.toast
|
||||
.addAlert("Test Alert")
|
||||
```
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.3.34: Register about panel badges</summary>
|
||||
|
||||
```js
|
||||
app.registerExtension({
|
||||
name: 'TestExtension1',
|
||||
aboutPageBadges: [
|
||||
{
|
||||
label: 'Test Badge',
|
||||
url: 'https://example.com',
|
||||
icon: 'pi pi-box'
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details id='extension-api-bottom-panel-tabs'>
|
||||
<summary>v1.3.22: Register bottom panel tabs</summary>
|
||||
|
||||
```js
|
||||
app.registerExtension({
|
||||
name: 'TestExtension',
|
||||
bottomPanelTabs: [
|
||||
{
|
||||
id: 'TestTab',
|
||||
title: 'Test Tab',
|
||||
type: 'custom',
|
||||
render: (el) => {
|
||||
el.innerHTML = '<div>Custom tab</div>'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details id='extension-api-settings'>
|
||||
<summary>v1.3.22: New settings API</summary>
|
||||
|
||||
Legacy settings API.
|
||||
|
||||
```js
|
||||
// Register a new setting
|
||||
app.ui.settings.addSetting({
|
||||
id: 'TestSetting',
|
||||
name: 'Test Setting',
|
||||
type: 'text',
|
||||
defaultValue: 'Hello, world!'
|
||||
})
|
||||
|
||||
// Get the value of a setting
|
||||
const value = app.ui.settings.getSettingValue('TestSetting')
|
||||
|
||||
// Set the value of a setting
|
||||
app.ui.settings.setSettingValue('TestSetting', 'Hello, universe!')
|
||||
```
|
||||
|
||||
New settings API.
|
||||
|
||||
```js
|
||||
// Register a new setting
|
||||
app.registerExtension({
|
||||
name: 'TestExtension1',
|
||||
settings: [
|
||||
{
|
||||
id: 'TestSetting',
|
||||
name: 'Test Setting',
|
||||
type: 'text',
|
||||
defaultValue: 'Hello, world!'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Get the value of a setting
|
||||
const value = app.extensionManager.setting.get('TestSetting')
|
||||
|
||||
// Set the value of a setting
|
||||
app.extensionManager.setting.set('TestSetting', 'Hello, universe!')
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details id='extension-api-commands-keybindings'>
|
||||
<summary>v1.3.7: Register commands and keybindings</summary>
|
||||
|
||||
Extensions can call the following API to register commands and keybindings. Do
|
||||
note that keybindings defined in core cannot be overwritten, and some keybindings
|
||||
are reserved by the browser.
|
||||
|
||||
```js
|
||||
app.registerExtension({
|
||||
name: 'TestExtension1',
|
||||
commands: [
|
||||
{
|
||||
id: 'TestCommand',
|
||||
function: () => {
|
||||
alert('TestCommand')
|
||||
}
|
||||
}
|
||||
],
|
||||
keybindings: [
|
||||
{
|
||||
combo: { key: 'k' },
|
||||
commandId: 'TestCommand'
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details id='extension-api-topbar-menu'>
|
||||
<summary>v1.3.1: Extension API to register custom topbar menu items</summary>
|
||||
|
||||
Extensions can call the following API to register custom topbar menu items.
|
||||
|
||||
```js
|
||||
app.registerExtension({
|
||||
name: 'TestExtension1',
|
||||
commands: [
|
||||
{
|
||||
id: 'foo-id',
|
||||
label: 'foo',
|
||||
function: () => {
|
||||
alert(1)
|
||||
}
|
||||
}
|
||||
],
|
||||
menuCommands: [
|
||||
{
|
||||
path: ['ext', 'ext2'],
|
||||
commands: ['foo-id']
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||

|
||||
</details>
|
||||
|
||||
<details id='extension-api-toast'>
|
||||
<summary>v1.2.27: Extension API to add toast message</summary>i
|
||||
|
||||
Extensions can call the following API to add toast messages.
|
||||
|
||||
```js
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Loaded!',
|
||||
detail: 'Extension loaded!',
|
||||
life: 3000
|
||||
})
|
||||
```
|
||||
Documentation of all supported options can be found here: <https://primevue.org/toast/#api.toast.interfaces.ToastMessageOptions>
|
||||
|
||||

|
||||
</details>
|
||||
|
||||
<details id='extension-api-sidebar-tab'>
|
||||
<summary>v1.2.4: Extension API to register custom sidebar tab</summary>
|
||||
|
||||
Extensions now can call the following API to register a sidebar tab.
|
||||
|
||||
```js
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "search",
|
||||
icon: "pi pi-search",
|
||||
title: "search",
|
||||
tooltip: "search",
|
||||
type: "custom",
|
||||
render: (el) => {
|
||||
el.innerHTML = "<div>Custom search tab</div>";
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The list of supported icons can be found here: <https://primevue.org/icons/#list>
|
||||
|
||||
We will support custom icons later.
|
||||
|
||||

|
||||
</details>
|
||||
|
||||
<details id='extension-api-selection-toolbox'>
|
||||
<summary>v1.10.9: Selection Toolbox API</summary>
|
||||
|
||||
Extensions can register commands that appear in the selection toolbox when specific items are selected on the canvas.
|
||||
|
||||
```js
|
||||
app.registerExtension({
|
||||
name: 'TestExtension1',
|
||||
commands: [
|
||||
{
|
||||
id: 'test.selection.command',
|
||||
label: 'Test Command',
|
||||
icon: 'pi pi-star',
|
||||
function: () => {
|
||||
// Command logic here
|
||||
}
|
||||
}
|
||||
],
|
||||
// Return an array of command IDs to show in the selection toolbox
|
||||
// when an item is selected
|
||||
getSelectionToolboxCommands: (selectedItem) => ['test.selection.command']
|
||||
})
|
||||
```
|
||||
|
||||
The selection toolbox will display the command button when items are selected:
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## Development
|
||||
|
||||
### Tech Stack
|
||||
@@ -557,6 +114,12 @@ After you start the dev server, you should see following logs:
|
||||
Make sure your desktop machine and touch device are on the same network. On your touch device,
|
||||
navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to access the ComfyUI frontend.
|
||||
|
||||
### Recommended Code Editor Configuration
|
||||
|
||||
This project includes `.vscode/launch.json.default` and `.vscode/settings.json.default` files with recommended launch and workspace settings for editors that use the `.vscode` directory (e.g., VS Code, Cursor, etc.).
|
||||
|
||||
We’ve also included a list of recommended extensions in `.vscode/extensions.json`. Your editor should detect this file and show a human friendly list in the Extensions panel, linking each entry to its marketplace page.
|
||||
|
||||
### Unit Test
|
||||
|
||||
- `npm i` to install all dependencies
|
||||
|
||||
BIN
browser_tests/assets/animated_webp.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
11
browser_tests/assets/widgets/load_animated_webp.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"8": {
|
||||
"inputs": {
|
||||
"image": "animated_web.webp"
|
||||
},
|
||||
"class_type": "DevToolsLoadAnimatedImageTest",
|
||||
"_meta": {
|
||||
"title": "Load Animated Image"
|
||||
}
|
||||
}
|
||||
}
|
||||
60
browser_tests/assets/widgets/save_animated_webp.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"id": "3f1fcbf9-f9de-4935-8fad-401813f61b13",
|
||||
"revision": 0,
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 4,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveAnimatedWEBP",
|
||||
"pos": [336, 104],
|
||||
"size": [210, 368],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI", 6, true, 80, "default"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "DevToolsLoadAnimatedImageTest",
|
||||
"pos": [64, 104],
|
||||
"size": [210, 316],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [4]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "DevToolsLoadAnimatedImageTest"
|
||||
},
|
||||
"widgets_values": ["animated_web.webp", "image"]
|
||||
}
|
||||
],
|
||||
"links": [[4, 10, 0, 9, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"frontendVersion": "1.17.0"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -463,87 +463,128 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async dragAndDropFile(
|
||||
fileName: string,
|
||||
async dragAndDropExternalResource(
|
||||
options: {
|
||||
fileName?: string
|
||||
url?: string
|
||||
dropPosition?: Position
|
||||
} = {}
|
||||
) {
|
||||
const { dropPosition = { x: 100, y: 100 } } = options
|
||||
const { dropPosition = { x: 100, y: 100 }, fileName, url } = options
|
||||
|
||||
const filePath = this.assetPath(fileName)
|
||||
if (!fileName && !url)
|
||||
throw new Error('Must provide either fileName or url')
|
||||
|
||||
// Read the file content
|
||||
const buffer = fs.readFileSync(filePath)
|
||||
const evaluateParams: {
|
||||
dropPosition: Position
|
||||
fileName?: string
|
||||
fileType?: string
|
||||
buffer?: Uint8Array | number[]
|
||||
url?: string
|
||||
} = { dropPosition }
|
||||
|
||||
// Get file type
|
||||
const getFileType = (fileName: string) => {
|
||||
if (fileName.endsWith('.png')) return 'image/png'
|
||||
if (fileName.endsWith('.webp')) return 'image/webp'
|
||||
if (fileName.endsWith('.webm')) return 'video/webm'
|
||||
if (fileName.endsWith('.json')) return 'application/json'
|
||||
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
|
||||
return 'application/octet-stream'
|
||||
// Dropping a file from the filesystem
|
||||
if (fileName) {
|
||||
const filePath = this.assetPath(fileName)
|
||||
const buffer = fs.readFileSync(filePath)
|
||||
|
||||
const getFileType = (fileName: string) => {
|
||||
if (fileName.endsWith('.png')) return 'image/png'
|
||||
if (fileName.endsWith('.webp')) return 'image/webp'
|
||||
if (fileName.endsWith('.webm')) return 'video/webm'
|
||||
if (fileName.endsWith('.json')) return 'application/json'
|
||||
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
evaluateParams.fileName = fileName
|
||||
evaluateParams.fileType = getFileType(fileName)
|
||||
evaluateParams.buffer = [...new Uint8Array(buffer)]
|
||||
}
|
||||
|
||||
const fileType = getFileType(fileName)
|
||||
// Dropping a URL (e.g., dropping image across browser tabs in Firefox)
|
||||
if (url) evaluateParams.url = url
|
||||
|
||||
await this.page.evaluate(
|
||||
async ({ buffer, fileName, fileType, dropPosition }) => {
|
||||
const file = new File([new Uint8Array(buffer)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
// Execute the drag and drop in the browser
|
||||
await this.page.evaluate(async (params) => {
|
||||
const dataTransfer = new DataTransfer()
|
||||
|
||||
const targetElement = document.elementFromPoint(
|
||||
dropPosition.x,
|
||||
dropPosition.y
|
||||
)
|
||||
|
||||
if (!targetElement) {
|
||||
console.error('No element found at drop position:', dropPosition)
|
||||
return { success: false, error: 'No element at position' }
|
||||
}
|
||||
|
||||
const eventOptions = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer,
|
||||
clientX: dropPosition.x,
|
||||
clientY: dropPosition.y
|
||||
}
|
||||
|
||||
const dragOverEvent = new DragEvent('dragover', eventOptions)
|
||||
const dropEvent = new DragEvent('drop', eventOptions)
|
||||
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
Object.defineProperty(dropEvent, 'stopPropagation', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
|
||||
targetElement.dispatchEvent(dragOverEvent)
|
||||
targetElement.dispatchEvent(dropEvent)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
targetInfo: {
|
||||
tagName: targetElement.tagName,
|
||||
id: targetElement.id,
|
||||
classList: Array.from(targetElement.classList)
|
||||
// Add file if provided
|
||||
if (params.buffer && params.fileName && params.fileType) {
|
||||
const file = new File(
|
||||
[new Uint8Array(params.buffer)],
|
||||
params.fileName,
|
||||
{
|
||||
type: params.fileType
|
||||
}
|
||||
)
|
||||
dataTransfer.items.add(file)
|
||||
}
|
||||
|
||||
// Add URL data if provided
|
||||
if (params.url) {
|
||||
dataTransfer.setData('text/uri-list', params.url)
|
||||
dataTransfer.setData('text/x-moz-url', params.url)
|
||||
}
|
||||
|
||||
const targetElement = document.elementFromPoint(
|
||||
params.dropPosition.x,
|
||||
params.dropPosition.y
|
||||
)
|
||||
|
||||
if (!targetElement) {
|
||||
console.error('No element found at drop position:', params.dropPosition)
|
||||
return { success: false, error: 'No element at position' }
|
||||
}
|
||||
|
||||
const eventOptions = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer,
|
||||
clientX: params.dropPosition.x,
|
||||
clientY: params.dropPosition.y
|
||||
}
|
||||
|
||||
const dragOverEvent = new DragEvent('dragover', eventOptions)
|
||||
const dropEvent = new DragEvent('drop', eventOptions)
|
||||
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
|
||||
Object.defineProperty(dropEvent, 'stopPropagation', {
|
||||
value: () => {},
|
||||
writable: false
|
||||
})
|
||||
|
||||
targetElement.dispatchEvent(dragOverEvent)
|
||||
targetElement.dispatchEvent(dropEvent)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
targetInfo: {
|
||||
tagName: targetElement.tagName,
|
||||
id: targetElement.id,
|
||||
classList: Array.from(targetElement.classList)
|
||||
}
|
||||
},
|
||||
{ buffer: [...new Uint8Array(buffer)], fileName, fileType, dropPosition }
|
||||
)
|
||||
}
|
||||
}, evaluateParams)
|
||||
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async dragAndDropFile(
|
||||
fileName: string,
|
||||
options: { dropPosition?: Position } = {}
|
||||
) {
|
||||
return this.dragAndDropExternalResource({ fileName, ...options })
|
||||
}
|
||||
|
||||
async dragAndDropURL(url: string, options: { dropPosition?: Position } = {}) {
|
||||
return this.dragAndDropExternalResource({ url, ...options })
|
||||
}
|
||||
|
||||
async dragNode2() {
|
||||
await this.dragAndDrop({ x: 622, y: 400 }, { x: 622, y: 300 })
|
||||
await this.nextFrame()
|
||||
@@ -885,7 +926,7 @@ export class ComfyPage {
|
||||
async getNodeRefById(id: NodeId) {
|
||||
return new NodeReference(id, this)
|
||||
}
|
||||
async getNodes() {
|
||||
async getNodes(): Promise<LGraphNode[]> {
|
||||
return await this.page.evaluate(() => {
|
||||
return window['app'].graph.nodes
|
||||
})
|
||||
|
||||
@@ -341,3 +341,30 @@ test.describe('Error dialog', () => {
|
||||
await expect(errorDialog).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Signin dialog', () => {
|
||||
test('Paste content to signin dialog should not paste node on canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeNum = (await comfyPage.getNodes()).length
|
||||
await comfyPage.clickEmptyLatentNode()
|
||||
await comfyPage.ctrlC()
|
||||
|
||||
const textBox = comfyPage.widgetTextBox
|
||||
await textBox.click()
|
||||
await textBox.fill('test_password')
|
||||
await textBox.press('Control+a')
|
||||
await textBox.press('Control+c')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].extensionManager.dialog.showSignInDialog()
|
||||
})
|
||||
|
||||
const input = comfyPage.page.locator('#comfy-org-sign-in-password')
|
||||
await input.waitFor({ state: 'visible' })
|
||||
await input.press('Control+v')
|
||||
await expect(input).toHaveValue('test_password')
|
||||
|
||||
expect(await comfyPage.getNodes()).toHaveLength(nodeNum)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,4 +24,11 @@ test.describe('DOM Widget', () => {
|
||||
await expect(firstMultiline).not.toBeVisible()
|
||||
await expect(lastMultiline).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Position update when entering focus mode', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.executeCommand('Workspace.ToggleFocusMode')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 83 KiB |
@@ -3,17 +3,35 @@ import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Load Workflow in Media', () => {
|
||||
;[
|
||||
const fileNames = [
|
||||
'workflow.webp',
|
||||
'edited_workflow.webp',
|
||||
'no_workflow.webp',
|
||||
'large_workflow.webp',
|
||||
'workflow.webm',
|
||||
'workflow.glb'
|
||||
].forEach(async (fileName) => {
|
||||
test(`Load workflow in ${fileName}`, async ({ comfyPage }) => {
|
||||
]
|
||||
fileNames.forEach(async (fileName) => {
|
||||
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.dragAndDropFile(fileName)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
|
||||
})
|
||||
})
|
||||
|
||||
const urls = [
|
||||
'https://comfyanonymous.github.io/ComfyUI_examples/hidream/hidream_dev_example.png'
|
||||
]
|
||||
urls.forEach(async (url) => {
|
||||
test(`Load workflow from URL${url} (drop from different browser tabs)`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.dragAndDropURL(url)
|
||||
const readableName = url.split('/').pop()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
`dropped_workflow_url_${readableName}.png`
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 141 KiB |
@@ -92,4 +92,20 @@ test.describe('Node badge color', () => {
|
||||
'node-badge-unknown-color-palette.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can show node badge with light color palette', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.NodeBadge.NodeIdBadgeMode',
|
||||
NodeBadgeMode.ShowAll
|
||||
)
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
// Click empty space to trigger canvas re-render.
|
||||
await comfyPage.clickEmptySpace()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-badge-light-color-palette.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -251,4 +251,17 @@ test.describe('Release context menu', () => {
|
||||
'link-release-context-menu.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can search and add node from context menu', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
await comfyMouse.move({ x: 10, y: 10 })
|
||||
await comfyPage.clickContextMenuItem('Search')
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('CLIP Prompt')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'link-context-menu-search.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 108 KiB |
@@ -1,8 +1,17 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import fs from 'fs'
|
||||
import { Page, expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
async function checkTemplateFileExists(
|
||||
page: Page,
|
||||
filename: string
|
||||
): Promise<boolean> {
|
||||
const response = await page.request.head(
|
||||
new URL(`/templates/${filename}`, page.url()).toString()
|
||||
)
|
||||
return response.ok()
|
||||
}
|
||||
|
||||
test.describe('Templates', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
@@ -12,32 +21,32 @@ test.describe('Templates', () => {
|
||||
test('should have a JSON workflow file for each template', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.slow()
|
||||
const templates = await comfyPage.templates.getAllTemplates()
|
||||
for (const template of templates) {
|
||||
const workflowPath = comfyPage.templates.getTemplatePath(
|
||||
const exists = await checkTemplateFileExists(
|
||||
comfyPage.page,
|
||||
`${template.name}.json`
|
||||
)
|
||||
expect(
|
||||
fs.existsSync(workflowPath),
|
||||
`Missing workflow: ${template.name}`
|
||||
).toBe(true)
|
||||
expect(exists, `Missing workflow: ${template.name}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('should have all required thumbnail media for each template', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.slow()
|
||||
const templates = await comfyPage.templates.getAllTemplates()
|
||||
for (const template of templates) {
|
||||
const { name, mediaSubtype, thumbnailVariant } = template
|
||||
const baseMedia = `${name}-1.${mediaSubtype}`
|
||||
const basePath = comfyPage.templates.getTemplatePath(baseMedia)
|
||||
|
||||
// Check base thumbnail
|
||||
expect(
|
||||
fs.existsSync(basePath),
|
||||
`Missing base thumbnail: ${baseMedia}`
|
||||
).toBe(true)
|
||||
const baseExists = await checkTemplateFileExists(
|
||||
comfyPage.page,
|
||||
baseMedia
|
||||
)
|
||||
expect(baseExists, `Missing base thumbnail: ${baseMedia}`).toBe(true)
|
||||
|
||||
// Check second thumbnail for variants that need it
|
||||
if (
|
||||
@@ -45,9 +54,12 @@ test.describe('Templates', () => {
|
||||
thumbnailVariant === 'hoverDissolve'
|
||||
) {
|
||||
const secondMedia = `${name}-2.${mediaSubtype}`
|
||||
const secondPath = comfyPage.templates.getTemplatePath(secondMedia)
|
||||
const secondExists = await checkTemplateFileExists(
|
||||
comfyPage.page,
|
||||
secondMedia
|
||||
)
|
||||
expect(
|
||||
fs.existsSync(secondPath),
|
||||
secondExists,
|
||||
`Missing second thumbnail: ${secondMedia} required for ${thumbnailVariant}`
|
||||
).toBe(true)
|
||||
}
|
||||
@@ -86,4 +98,48 @@ test.describe('Templates', () => {
|
||||
// Expect the templates dialog to be shown
|
||||
expect(await comfyPage.templates.content.isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
test('Uses title field as fallback when the key is not found in locales', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Capture request for the index.json
|
||||
await comfyPage.page.route('**/templates/index.json', async (route, _) => {
|
||||
// Add a new template that won't have a translation pre-generated
|
||||
const response = [
|
||||
{
|
||||
moduleName: 'default',
|
||||
title: 'FALLBACK CATEGORY',
|
||||
type: 'image',
|
||||
templates: [
|
||||
{
|
||||
name: 'unknown_key_has_no_translation_available',
|
||||
title: 'FALLBACK TEMPLATE NAME',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description: 'No translations found'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(response),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Load the templates dialog
|
||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||
|
||||
// Expect the title to be used as fallback for template cards
|
||||
await expect(
|
||||
comfyPage.templates.content.getByText('FALLBACK TEMPLATE NAME')
|
||||
).toBeVisible()
|
||||
|
||||
// Expect the title to be used as fallback for the template categories
|
||||
await expect(comfyPage.page.getByLabel('FALLBACK CATEGORY')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -186,6 +186,105 @@ test.describe('Image widget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Animated image widget', () => {
|
||||
test('Shows preview of uploaded animated image', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/load_animated_webp')
|
||||
|
||||
// Get position of the load animated webp node
|
||||
const nodes = await comfyPage.getNodeRefsByType(
|
||||
'DevToolsLoadAnimatedImageTest'
|
||||
)
|
||||
const loadAnimatedWebpNode = nodes[0]
|
||||
const { x, y } = await loadAnimatedWebpNode.getPosition()
|
||||
|
||||
// Drag and drop image file onto the load animated webp node
|
||||
await comfyPage.dragAndDropFile('animated_webp.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
// Expect the image preview to change automatically
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'animated_image_preview_drag_and_dropped.png'
|
||||
)
|
||||
|
||||
// Wait for animation to go to next frame
|
||||
await comfyPage.page.waitForTimeout(512)
|
||||
|
||||
// Move mouse and click on canvas to trigger render
|
||||
await comfyPage.page.mouse.click(64, 64)
|
||||
|
||||
// Expect the image preview to change to the next frame of the animation
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'animated_image_preview_drag_and_dropped_next_frame.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can drag-and-drop animated webp image', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/load_animated_webp')
|
||||
|
||||
// Get position of the load animated webp node
|
||||
const nodes = await comfyPage.getNodeRefsByType(
|
||||
'DevToolsLoadAnimatedImageTest'
|
||||
)
|
||||
const loadAnimatedWebpNode = nodes[0]
|
||||
const { x, y } = await loadAnimatedWebpNode.getPosition()
|
||||
|
||||
// Drag and drop image file onto the load animated webp node
|
||||
await comfyPage.dragAndDropFile('animated_webp.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
// Expect the filename combo value to be updated
|
||||
const fileComboWidget = await loadAnimatedWebpNode.getWidget(0)
|
||||
const filename = await fileComboWidget.getValue()
|
||||
expect(filename).toContain('animated_webp.webp')
|
||||
})
|
||||
|
||||
test('Can preview saved animated webp image', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/save_animated_webp')
|
||||
|
||||
// Get position of the load animated webp node
|
||||
const loadNodes = await comfyPage.getNodeRefsByType(
|
||||
'DevToolsLoadAnimatedImageTest'
|
||||
)
|
||||
const loadAnimatedWebpNode = loadNodes[0]
|
||||
const { x, y } = await loadAnimatedWebpNode.getPosition()
|
||||
|
||||
// Drag and drop image file onto the load animated webp node
|
||||
await comfyPage.dragAndDropFile('animated_webp.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Get the SaveAnimatedWEBP node
|
||||
const saveNodes = await comfyPage.getNodeRefsByType('SaveAnimatedWEBP')
|
||||
const saveAnimatedWebpNode = saveNodes[0]
|
||||
if (!saveAnimatedWebpNode)
|
||||
throw new Error('SaveAnimatedWEBP node not found')
|
||||
|
||||
// Simulate the graph executing
|
||||
await comfyPage.page.evaluate(
|
||||
([loadId, saveId]) => {
|
||||
// Set the output of the SaveAnimatedWEBP node to equal the loader node's image
|
||||
window['app'].nodeOutputs[saveId] = window['app'].nodeOutputs[loadId]
|
||||
},
|
||||
[loadAnimatedWebpNode.id, saveAnimatedWebpNode.id]
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for animation to go to next frame
|
||||
await comfyPage.page.waitForTimeout(512)
|
||||
|
||||
// Move mouse and click on canvas to trigger render
|
||||
await comfyPage.page.mouse.click(64, 64)
|
||||
|
||||
// Expect the SaveAnimatedWEBP node to have an output preview
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'animated_image_preview_saved_webp.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Load audio widget', () => {
|
||||
test('Can load audio', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/load_audio_widget')
|
||||
|
||||
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 113 KiB |
59
build/plugins/addElementVnodeExportPlugin.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Plugin } from 'vite'
|
||||
|
||||
/**
|
||||
* Vite plugin that adds an alias export for Vue's createBaseVNode as createElementVNode.
|
||||
*
|
||||
* This plugin addresses compatibility issues where some components or libraries
|
||||
* might be using the older createElementVNode function name instead of createBaseVNode.
|
||||
* It modifies the Vue vendor chunk during build to add the alias export.
|
||||
*
|
||||
* @returns {Plugin} A Vite plugin that modifies the Vue vendor chunk exports
|
||||
*/
|
||||
export function addElementVnodeExportPlugin(): Plugin {
|
||||
return {
|
||||
name: 'add-element-vnode-export-plugin',
|
||||
|
||||
renderChunk(code, chunk, _options) {
|
||||
if (chunk.name.startsWith('vendor-vue')) {
|
||||
const exportRegex = /(export\s*\{)([^}]*)(\}\s*;?\s*)$/
|
||||
const match = code.match(exportRegex)
|
||||
|
||||
if (match) {
|
||||
const existingExports = match[2].trim()
|
||||
const exportsArray = existingExports
|
||||
.split(',')
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const hasCreateBaseVNode = exportsArray.some((e) =>
|
||||
e.startsWith('createBaseVNode')
|
||||
)
|
||||
const hasCreateElementVNode = exportsArray.some((e) =>
|
||||
e.includes('createElementVNode')
|
||||
)
|
||||
|
||||
if (hasCreateBaseVNode && !hasCreateElementVNode) {
|
||||
const newExportStatement = `${match[1]} ${existingExports ? existingExports + ',' : ''} createBaseVNode as createElementVNode ${match[3]}`
|
||||
const newCode = code.replace(exportRegex, newExportStatement)
|
||||
|
||||
console.log(
|
||||
`[add-element-vnode-export-plugin] Added 'createBaseVNode as createElementVNode' export to vendor-vue chunk.`
|
||||
)
|
||||
|
||||
return { code: newCode, map: null }
|
||||
} else if (!hasCreateBaseVNode) {
|
||||
console.warn(
|
||||
`[add-element-vnode-export-plugin] Warning: 'createBaseVNode' not found in exports of vendor-vue chunk. Cannot add alias.`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`[add-element-vnode-export-plugin] Warning: Could not find expected export block format in vendor-vue chunk.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
82
build/plugins/comfyAPIPlugin.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import path from 'path'
|
||||
import { Plugin } from 'vite'
|
||||
|
||||
interface ShimResult {
|
||||
code: string
|
||||
exports: string[]
|
||||
}
|
||||
|
||||
function isLegacyFile(id: string): boolean {
|
||||
return (
|
||||
id.endsWith('.ts') &&
|
||||
(id.includes('src/extensions/core') || id.includes('src/scripts'))
|
||||
)
|
||||
}
|
||||
|
||||
function transformExports(code: string, id: string): ShimResult {
|
||||
const moduleName = getModuleName(id)
|
||||
const exports: string[] = []
|
||||
let newCode = code
|
||||
|
||||
// Regex to match different types of exports
|
||||
const regex =
|
||||
/export\s+(const|let|var|function|class|async function)\s+([a-zA-Z$_][a-zA-Z\d$_]*)(\s|\()/g
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(code)) !== null) {
|
||||
const name = match[2]
|
||||
// All exports should be bind to the window object as new API endpoint.
|
||||
if (exports.length == 0) {
|
||||
newCode += `\nwindow.comfyAPI = window.comfyAPI || {};`
|
||||
newCode += `\nwindow.comfyAPI.${moduleName} = window.comfyAPI.${moduleName} || {};`
|
||||
}
|
||||
newCode += `\nwindow.comfyAPI.${moduleName}.${name} = ${name};`
|
||||
exports.push(
|
||||
`export const ${name} = window.comfyAPI.${moduleName}.${name};\n`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
code: newCode,
|
||||
exports
|
||||
}
|
||||
}
|
||||
|
||||
function getModuleName(id: string): string {
|
||||
// Simple example to derive a module name from the file path
|
||||
const parts = id.split('/')
|
||||
const fileName = parts[parts.length - 1]
|
||||
return fileName.replace(/\.\w+$/, '') // Remove file extension
|
||||
}
|
||||
|
||||
export function comfyAPIPlugin(isDev: boolean): Plugin {
|
||||
return {
|
||||
name: 'comfy-api-plugin',
|
||||
transform(code: string, id: string) {
|
||||
if (isDev) return null
|
||||
|
||||
if (isLegacyFile(id)) {
|
||||
const result = transformExports(code, id)
|
||||
|
||||
if (result.exports.length > 0) {
|
||||
const projectRoot = process.cwd()
|
||||
const relativePath = path.relative(path.join(projectRoot, 'src'), id)
|
||||
const shimFileName = relativePath.replace(/\.ts$/, '.js')
|
||||
|
||||
const shimComment = `// Shim for ${relativePath}\n`
|
||||
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: shimFileName,
|
||||
source: shimComment + result.exports.join('')
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
code: result.code,
|
||||
map: null // If you're not modifying the source map, return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
103
build/plugins/generateImportMapPlugin.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { OutputOptions } from 'rollup'
|
||||
import { HtmlTagDescriptor, Plugin } from 'vite'
|
||||
|
||||
interface VendorLibrary {
|
||||
name: string
|
||||
pattern: RegExp
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin that generates an import map for vendor chunks.
|
||||
*
|
||||
* This plugin creates a browser-compatible import map that maps module specifiers
|
||||
* (like 'vue' or 'primevue') to their actual file locations in the build output.
|
||||
* This improves module loading in modern browsers and enables better caching.
|
||||
*
|
||||
* The plugin:
|
||||
* 1. Tracks vendor chunks during bundle generation
|
||||
* 2. Creates mappings between module names and their file paths
|
||||
* 3. Injects an import map script tag into the HTML head
|
||||
* 4. Configures manual chunk splitting for vendor libraries
|
||||
*
|
||||
* @param vendorLibraries - An array of vendor libraries to split into separate chunks
|
||||
* @returns {Plugin} A Vite plugin that generates and injects an import map
|
||||
*/
|
||||
export function generateImportMapPlugin(
|
||||
vendorLibraries: VendorLibrary[]
|
||||
): Plugin {
|
||||
const importMapEntries: Record<string, string> = {}
|
||||
|
||||
return {
|
||||
name: 'generate-import-map-plugin',
|
||||
|
||||
// Configure manual chunks during the build process
|
||||
configResolved(config) {
|
||||
if (config.build) {
|
||||
// Ensure rollupOptions exists
|
||||
if (!config.build.rollupOptions) {
|
||||
config.build.rollupOptions = {}
|
||||
}
|
||||
|
||||
const outputOptions: OutputOptions = {
|
||||
manualChunks: (id: string) => {
|
||||
for (const lib of vendorLibraries) {
|
||||
if (lib.pattern.test(id)) {
|
||||
return `vendor-${lib.name}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
// Disable minification of internal exports to preserve function names
|
||||
minifyInternalExports: false
|
||||
}
|
||||
config.build.rollupOptions.output = outputOptions
|
||||
}
|
||||
},
|
||||
|
||||
generateBundle(_options, bundle) {
|
||||
for (const fileName in bundle) {
|
||||
const chunk = bundle[fileName]
|
||||
if (chunk.type === 'chunk' && !chunk.isEntry) {
|
||||
// Find matching vendor library by chunk name
|
||||
const vendorLib = vendorLibraries.find(
|
||||
(lib) => chunk.name === `vendor-${lib.name}`
|
||||
)
|
||||
|
||||
if (vendorLib) {
|
||||
const relativePath = `./${chunk.fileName.replace(/\\/g, '/')}`
|
||||
importMapEntries[vendorLib.name] = relativePath
|
||||
|
||||
console.log(
|
||||
`[ImportMap Plugin] Found chunk: ${chunk.name} -> Mapped '${vendorLib.name}' to '${relativePath}'`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
transformIndexHtml(html) {
|
||||
if (Object.keys(importMapEntries).length === 0) {
|
||||
console.warn(
|
||||
'[ImportMap Plugin] No vendor chunks found to create import map.'
|
||||
)
|
||||
return html
|
||||
}
|
||||
|
||||
const importMap = {
|
||||
imports: importMapEntries
|
||||
}
|
||||
|
||||
const importMapTag: HtmlTagDescriptor = {
|
||||
tag: 'script',
|
||||
attrs: { type: 'importmap' },
|
||||
children: JSON.stringify(importMap, null, 2),
|
||||
injectTo: 'head'
|
||||
}
|
||||
|
||||
return {
|
||||
html,
|
||||
tags: [importMapTag]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
build/plugins/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { addElementVnodeExportPlugin } from './addElementVnodeExportPlugin'
|
||||
export { comfyAPIPlugin } from './comfyAPIPlugin'
|
||||
export { generateImportMapPlugin } from './generateImportMapPlugin'
|
||||
@@ -1,4 +1,5 @@
|
||||
import pluginJs from '@eslint/js'
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import globals from 'globals'
|
||||
@@ -20,21 +21,26 @@ export default [
|
||||
globals: {
|
||||
...globals.browser,
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly'
|
||||
},
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
extraFileExtensions: ['.vue']
|
||||
}
|
||||
}
|
||||
},
|
||||
pluginJs.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...pluginVue.configs['flat/essential'],
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
files: ['src/**/*.vue'],
|
||||
languageOptions: { parserOptions: { parser: tseslint.parser } }
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/prefer-as-const': 'off'
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tseslint.parser
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -42,10 +48,12 @@ export default [
|
||||
'unused-imports': unusedImports
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/prefer-as-const': 'off',
|
||||
'unused-imports/no-unused-imports': 'error'
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'vue/no-v-html': 'off'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
1
global.d.ts
vendored
@@ -3,6 +3,7 @@ declare const __SENTRY_ENABLED__: boolean
|
||||
declare const __SENTRY_DSN__: string
|
||||
declare const __ALGOLIA_APP_ID__: string
|
||||
declare const __ALGOLIA_API_KEY__: string
|
||||
declare const __USE_PROD_CONFIG__: boolean
|
||||
|
||||
interface Navigator {
|
||||
/**
|
||||
|
||||
1289
package-lock.json
generated
15
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.16.5",
|
||||
"version": "1.18.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -25,8 +25,7 @@
|
||||
"lint:fix": "eslint src --fix",
|
||||
"locale": "lobe-i18n locale",
|
||||
"collect-i18n": "playwright test --config=playwright.i18n.config.ts",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
"fetch-templates": "tsx scripts/fetch-templates.ts"
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
@@ -44,6 +43,8 @@
|
||||
"autoprefixer": "^10.4.19",
|
||||
"chalk": "^5.3.0",
|
||||
"eslint": "^9.12.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"eslint-plugin-vue": "^9.27.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
@@ -60,7 +61,7 @@
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"unplugin-icons": "^0.19.3",
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
"vite": "^5.4.17",
|
||||
"vite": "^5.4.18",
|
||||
"vite-plugin-dts": "^4.3.0",
|
||||
"vitest": "^2.0.0",
|
||||
"vue-tsc": "^2.1.10",
|
||||
@@ -70,8 +71,8 @@
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.31",
|
||||
"@comfyorg/litegraph": "^0.13.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.39",
|
||||
"@comfyorg/litegraph": "^0.14.1",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
@@ -89,6 +90,7 @@
|
||||
"algoliasearch": "^5.21.0",
|
||||
"axios": "^1.8.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"firebase": "^11.6.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -101,6 +103,7 @@
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^9.14.3",
|
||||
"vue-router": "^4.4.3",
|
||||
"vuefire": "^3.2.1",
|
||||
"zod": "^3.23.8",
|
||||
"zod-validation-error": "^3.3.0"
|
||||
}
|
||||
|
||||
BIN
public/assets/images/Comfy_Logo_x32.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
@@ -1,24 +0,0 @@
|
||||
import fs from 'fs-extra'
|
||||
import { execSync } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
|
||||
const workflowTemplatesRepo = 'https://github.com/Comfy-Org/workflow_templates'
|
||||
const tempRepoDir = './templates_repo'
|
||||
|
||||
// Clone the repository
|
||||
execSync(`git clone ${workflowTemplatesRepo} --depth 1 ${tempRepoDir}`)
|
||||
|
||||
// Create public/templates directory if it doesn't exist
|
||||
fs.ensureDirSync('public/templates')
|
||||
|
||||
// Copy templates from repo to public/templates
|
||||
const sourceDir = path.join(tempRepoDir, 'templates')
|
||||
const targetDir = 'public/templates'
|
||||
|
||||
// Copy entire directory at once
|
||||
fs.copySync(sourceDir, targetDir)
|
||||
|
||||
// Remove the temporary repository directory
|
||||
fs.removeSync(tempRepoDir)
|
||||
|
||||
console.log('Templates fetched successfully')
|
||||
@@ -2,7 +2,7 @@
|
||||
<router-view />
|
||||
<ProgressSpinner
|
||||
v-if="isLoading"
|
||||
class="absolute inset-0 flex justify-center items-center h-screen"
|
||||
class="absolute inset-0 flex justify-center items-center h-[unset]"
|
||||
/>
|
||||
<GlobalDialog />
|
||||
<BlockUI full-screen :blocked="isLoading" />
|
||||
|
||||
115
src/components/BrowserTabTitle.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import BrowserTabTitle from '@/components/BrowserTabTitle.vue'
|
||||
|
||||
// Mock the execution store
|
||||
const executionStore = reactive({
|
||||
isIdle: true,
|
||||
executionProgress: 0,
|
||||
executingNode: null as any,
|
||||
executingNodeProgress: 0
|
||||
})
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => executionStore
|
||||
}))
|
||||
|
||||
// Mock the setting store
|
||||
const settingStore = reactive({
|
||||
get: vi.fn(() => 'Enabled')
|
||||
})
|
||||
vi.mock('@/stores/settingStore', () => ({
|
||||
useSettingStore: () => settingStore
|
||||
}))
|
||||
|
||||
// Mock the workflow store
|
||||
const workflowStore = reactive({
|
||||
activeWorkflow: null as any
|
||||
})
|
||||
vi.mock('@/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => workflowStore
|
||||
}))
|
||||
|
||||
describe('BrowserTabTitle.vue', () => {
|
||||
let wrapper: ReturnType<typeof mount> | null
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = null
|
||||
// reset execution store
|
||||
executionStore.isIdle = true
|
||||
executionStore.executionProgress = 0
|
||||
executionStore.executingNode = null as any
|
||||
executionStore.executingNodeProgress = 0
|
||||
|
||||
// reset setting and workflow stores
|
||||
;(settingStore.get as any).mockReturnValue('Enabled')
|
||||
workflowStore.activeWorkflow = null
|
||||
|
||||
// reset document title
|
||||
document.title = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
})
|
||||
|
||||
it('sets default title when idle and no workflow', () => {
|
||||
wrapper = mount(BrowserTabTitle)
|
||||
expect(document.title).toBe('ComfyUI')
|
||||
})
|
||||
|
||||
it('sets workflow name as title when workflow exists and menu enabled', async () => {
|
||||
;(settingStore.get as any).mockReturnValue('Enabled')
|
||||
workflowStore.activeWorkflow = {
|
||||
filename: 'myFlow',
|
||||
isModified: false,
|
||||
isPersisted: true
|
||||
}
|
||||
wrapper = mount(BrowserTabTitle)
|
||||
await nextTick()
|
||||
expect(document.title).toBe('myFlow - ComfyUI')
|
||||
})
|
||||
|
||||
it('adds asterisk for unsaved workflow', async () => {
|
||||
;(settingStore.get as any).mockReturnValue('Enabled')
|
||||
workflowStore.activeWorkflow = {
|
||||
filename: 'myFlow',
|
||||
isModified: true,
|
||||
isPersisted: true
|
||||
}
|
||||
wrapper = mount(BrowserTabTitle)
|
||||
await nextTick()
|
||||
expect(document.title).toBe('*myFlow - ComfyUI')
|
||||
})
|
||||
|
||||
it('disables workflow title when menu disabled', async () => {
|
||||
;(settingStore.get as any).mockReturnValue('Disabled')
|
||||
workflowStore.activeWorkflow = {
|
||||
filename: 'myFlow',
|
||||
isModified: false,
|
||||
isPersisted: true
|
||||
}
|
||||
wrapper = mount(BrowserTabTitle)
|
||||
await nextTick()
|
||||
expect(document.title).toBe('ComfyUI')
|
||||
})
|
||||
|
||||
it('shows execution progress when not idle without workflow', async () => {
|
||||
executionStore.isIdle = false
|
||||
executionStore.executionProgress = 0.3
|
||||
wrapper = mount(BrowserTabTitle)
|
||||
await nextTick()
|
||||
expect(document.title).toBe('[30%]ComfyUI')
|
||||
})
|
||||
|
||||
it('shows node execution title when executing a node', async () => {
|
||||
executionStore.isIdle = false
|
||||
executionStore.executionProgress = 0.4
|
||||
executionStore.executingNodeProgress = 0.5
|
||||
executionStore.executingNode = { type: 'Foo' }
|
||||
wrapper = mount(BrowserTabTitle)
|
||||
await nextTick()
|
||||
expect(document.title).toBe('[40%][50%] Foo')
|
||||
})
|
||||
})
|
||||
@@ -23,7 +23,7 @@ const executionText = computed(() =>
|
||||
)
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const betaMenuEnabled = computed(
|
||||
const newMenuEnabled = computed(
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
|
||||
@@ -50,7 +50,7 @@ const nodeExecutionTitle = computed(() =>
|
||||
const workflowTitle = computed(
|
||||
() =>
|
||||
executionText.value +
|
||||
(betaMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
|
||||
(newMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
|
||||
)
|
||||
|
||||
const title = computed(() => nodeExecutionTitle.value || workflowTitle.value)
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<template>
|
||||
<Splitter
|
||||
:key="sidebarStateKey"
|
||||
class="splitter-overlay-root splitter-overlay"
|
||||
:pt:gutter="sidebarPanelVisible ? '' : 'hidden'"
|
||||
:key="activeSidebarTabId ?? undefined"
|
||||
:stateKey="activeSidebarTabId ?? undefined"
|
||||
stateStorage="local"
|
||||
:state-key="sidebarStateKey"
|
||||
state-storage="local"
|
||||
>
|
||||
<SplitterPanel
|
||||
class="side-bar-panel"
|
||||
:minSize="10"
|
||||
:size="20"
|
||||
v-show="sidebarPanelVisible"
|
||||
v-if="sidebarLocation === 'left'"
|
||||
class="side-bar-panel"
|
||||
:min-size="10"
|
||||
:size="20"
|
||||
>
|
||||
<slot name="side-bar-panel"></slot>
|
||||
<slot name="side-bar-panel" />
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel :size="100">
|
||||
@@ -21,26 +21,26 @@
|
||||
class="splitter-overlay max-w-full"
|
||||
layout="vertical"
|
||||
:pt:gutter="bottomPanelVisible ? '' : 'hidden'"
|
||||
stateKey="bottom-panel-splitter"
|
||||
stateStorage="local"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
>
|
||||
<SplitterPanel class="graph-canvas-panel relative">
|
||||
<slot name="graph-canvas-panel"></slot>
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel class="bottom-panel" v-show="bottomPanelVisible">
|
||||
<slot name="bottom-panel"></slot>
|
||||
<SplitterPanel v-show="bottomPanelVisible" class="bottom-panel">
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel
|
||||
class="side-bar-panel"
|
||||
:minSize="10"
|
||||
:size="20"
|
||||
v-show="sidebarPanelVisible"
|
||||
v-if="sidebarLocation === 'right'"
|
||||
class="side-bar-panel"
|
||||
:min-size="10"
|
||||
:size="20"
|
||||
>
|
||||
<slot name="side-bar-panel"></slot>
|
||||
<slot name="side-bar-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</template>
|
||||
@@ -59,6 +59,10 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
|
||||
const unifiedWidth = computed(() =>
|
||||
settingStore.get('Comfy.Sidebar.UnifiedWidth')
|
||||
)
|
||||
|
||||
const sidebarPanelVisible = computed(
|
||||
() => useSidebarTabStore().activeSidebarTab !== null
|
||||
)
|
||||
@@ -68,6 +72,10 @@ const bottomPanelVisible = computed(
|
||||
const activeSidebarTabId = computed(
|
||||
() => useSidebarTabStore().activeSidebarTabId
|
||||
)
|
||||
|
||||
const sidebarStateKey = computed(() => {
|
||||
return unifiedWidth.value ? 'unified-sidebar' : activeSidebarTabId.value ?? ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
:style="positionCSS"
|
||||
>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('menu.showMenu'), showDelay: 300 }"
|
||||
icon="pi pi-bars"
|
||||
severity="secondary"
|
||||
text
|
||||
size="large"
|
||||
v-tooltip="{ value: $t('menu.showMenu'), showDelay: 300 }"
|
||||
:aria-label="$t('menu.showMenu')"
|
||||
aria-live="assertive"
|
||||
@click="exitFocusMode"
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<template>
|
||||
<div
|
||||
class="batch-count"
|
||||
v-tooltip.bottom="{
|
||||
value: $t('menu.batchCount'),
|
||||
showDelay: 600
|
||||
}"
|
||||
class="batch-count"
|
||||
:aria-label="$t('menu.batchCount')"
|
||||
>
|
||||
<InputNumber
|
||||
class="w-14"
|
||||
v-model="batchCount"
|
||||
class="w-14"
|
||||
:min="minQueueCount"
|
||||
:max="maxQueueCount"
|
||||
fluid
|
||||
showButtons
|
||||
show-buttons
|
||||
:pt="{
|
||||
incrementButton: {
|
||||
class: 'w-6',
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
:style="style"
|
||||
:class="{ 'is-dragging': isDragging, 'is-docked': isDocked }"
|
||||
>
|
||||
<div class="actionbar-content flex items-center select-none" ref="panelRef">
|
||||
<span class="drag-handle cursor-move mr-2 p-0!" ref="dragHandleRef">
|
||||
</span>
|
||||
<div ref="panelRef" class="actionbar-content flex items-center select-none">
|
||||
<span ref="dragHandleRef" class="drag-handle cursor-move mr-2 p-0!" />
|
||||
<ComfyQueueButton />
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -89,9 +88,9 @@ const setInitialPosition = () => {
|
||||
}
|
||||
}
|
||||
onMounted(setInitialPosition)
|
||||
watch(visible, (newVisible) => {
|
||||
watch(visible, async (newVisible) => {
|
||||
if (newVisible) {
|
||||
nextTick(setInitialPosition)
|
||||
await nextTick(setInitialPosition)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<template>
|
||||
<div class="queue-button-group flex">
|
||||
<SplitButton
|
||||
class="comfyui-queue-button"
|
||||
:label="activeQueueModeMenuItem.label"
|
||||
severity="primary"
|
||||
size="small"
|
||||
@click="queuePrompt"
|
||||
:model="queueModeMenuItems"
|
||||
data-testid="queue-button"
|
||||
v-tooltip.bottom="{
|
||||
value: workspaceStore.shiftDown
|
||||
? $t('menu.runWorkflowFront')
|
||||
: $t('menu.runWorkflow'),
|
||||
showDelay: 600
|
||||
}"
|
||||
class="comfyui-queue-button"
|
||||
:label="activeQueueModeMenuItem.label"
|
||||
severity="primary"
|
||||
size="small"
|
||||
:model="queueModeMenuItems"
|
||||
data-testid="queue-button"
|
||||
@click="queuePrompt"
|
||||
>
|
||||
<template #icon>
|
||||
<i-lucide:list-start v-if="workspaceStore.shiftDown" />
|
||||
@@ -23,15 +23,15 @@
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: item.tooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
:label="String(item.label)"
|
||||
:icon="item.icon"
|
||||
:severity="item.key === queueMode ? 'primary' : 'secondary'"
|
||||
size="small"
|
||||
text
|
||||
v-tooltip="{
|
||||
value: item.tooltip,
|
||||
showDelay: 600
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
</SplitButton>
|
||||
@@ -48,8 +48,7 @@
|
||||
text
|
||||
:aria-label="$t('menu.interrupt')"
|
||||
@click="() => commandStore.execute('Comfy.Interrupt')"
|
||||
>
|
||||
</Button>
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="{
|
||||
value: $t('sideToolbar.queueTab.clearPendingTasks'),
|
||||
@@ -135,12 +134,12 @@ const hasPendingTasks = computed(
|
||||
)
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
const queuePrompt = (e: Event) => {
|
||||
const queuePrompt = async (e: Event) => {
|
||||
const commandId =
|
||||
'shiftKey' in e && e.shiftKey
|
||||
? 'Comfy.QueuePromptFront'
|
||||
: 'Comfy.QueuePrompt'
|
||||
commandStore.execute(commandId)
|
||||
await commandStore.execute(commandId)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs v-model:value="bottomPanelStore.activeBottomPanelTabId">
|
||||
<TabList pt:tabList="border-none">
|
||||
<TabList pt:tab-list="border-none">
|
||||
<div class="w-full flex justify-between">
|
||||
<div class="tabs-container">
|
||||
<Tab
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative overflow-hidden h-full w-full bg-black" ref="rootEl">
|
||||
<div ref="rootEl" class="relative overflow-hidden h-full w-full bg-black">
|
||||
<div class="p-terminal rounded-none h-full w-full p-2">
|
||||
<div class="h-full terminal-host" ref="terminalEl"></div>
|
||||
<div ref="terminalEl" class="h-full terminal-host" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -24,17 +24,17 @@ const terminalCreated = (
|
||||
root,
|
||||
autoRows: true,
|
||||
autoCols: true,
|
||||
onResize: () => {
|
||||
onResize: async () => {
|
||||
// If we aren't visible, don't resize
|
||||
if (!terminal.element?.offsetParent) return
|
||||
|
||||
terminalApi.resize(terminal.cols, terminal.rows)
|
||||
await terminalApi.resize(terminal.cols, terminal.rows)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
offData = terminal.onData(async (message: string) => {
|
||||
terminalApi.write(message)
|
||||
await terminalApi.write(message)
|
||||
})
|
||||
|
||||
offOutput = terminalApi.onOutput((message) => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div class="bg-black h-full w-full">
|
||||
<p v-if="errorMessage" class="p-4 text-center">{{ errorMessage }}</p>
|
||||
<p v-if="errorMessage" class="p-4 text-center">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<ProgressSpinner
|
||||
v-else-if="loading"
|
||||
class="relative inset-0 flex justify-center items-center h-full z-10"
|
||||
@@ -57,7 +59,7 @@ const terminalCreated = (
|
||||
if (!clientId.value) {
|
||||
await until(clientId).not.toBeNull()
|
||||
}
|
||||
api.subscribeLogs(true)
|
||||
await api.subscribeLogs(true)
|
||||
api.addEventListener('logs', logReceived)
|
||||
}
|
||||
|
||||
@@ -76,9 +78,9 @@ const terminalCreated = (
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
onUnmounted(async () => {
|
||||
if (api.clientId) {
|
||||
api.subscribeLogs(false)
|
||||
await api.subscribeLogs(false)
|
||||
}
|
||||
api.removeEventListener('logs', logReceived)
|
||||
})
|
||||
|
||||
75
src/components/common/ApiNodesCostBreakdown.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 h-full">
|
||||
<div class="flex justify-between text-xs">
|
||||
<div>{{ t('apiNodesCostBreakdown.title') }}</div>
|
||||
<div>{{ t('apiNodesCostBreakdown.costPerRun') }}</div>
|
||||
</div>
|
||||
<ScrollPanel class="flex-grow h-0">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="node in nodes"
|
||||
:key="node.name"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-md bg-[var(--p-content-border-color)]"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-base font-medium leading-tight">{{
|
||||
node.name
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Tag
|
||||
severity="secondary"
|
||||
icon="pi pi-dollar"
|
||||
rounded
|
||||
class="text-amber-400 p-1"
|
||||
/>
|
||||
<span class="text-base font-medium leading-tight">
|
||||
{{ node.cost.toFixed(costPrecision) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollPanel>
|
||||
<template v-if="showTotal && nodes.length > 1">
|
||||
<Divider class="my-2" />
|
||||
<div class="flex justify-between items-center border-t px-3">
|
||||
<span class="text-sm">{{ t('apiNodesCostBreakdown.totalCost') }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Tag
|
||||
severity="secondary"
|
||||
icon="pi pi-dollar"
|
||||
rounded
|
||||
class="text-yellow-500 p-1"
|
||||
/>
|
||||
<span>{{ totalCost.toFixed(costPrecision) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ApiNodeCost } from '@/types/apiNodeTypes'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
nodes,
|
||||
showTotal = true,
|
||||
costPrecision = 3
|
||||
} = defineProps<{
|
||||
nodes: ApiNodeCost[]
|
||||
showTotal?: boolean
|
||||
costPrecision?: number
|
||||
}>()
|
||||
|
||||
const totalCost = computed(() =>
|
||||
nodes.reduce((sum, node) => sum + node.cost, 0)
|
||||
)
|
||||
</script>
|
||||
31
src/components/common/ApiNodesList.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 h-full">
|
||||
<div class="flex text-xs">
|
||||
<div>{{ t('apiNodesCostBreakdown.title') }}</div>
|
||||
</div>
|
||||
<ScrollPanel class="flex-grow h-0">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="nodeName in nodeNames"
|
||||
:key="nodeName"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-md bg-[var(--p-content-border-color)]"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-base font-medium leading-tight">{{
|
||||
nodeName
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { nodeNames } = defineProps<{ nodeNames: string[] }>()
|
||||
</script>
|
||||
@@ -5,7 +5,7 @@ import SelectButton from 'primevue/selectbutton'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, nextTick } from 'vue'
|
||||
|
||||
import ColorCustomizationSelector from '../ColorCustomizationSelector.vue'
|
||||
import ColorCustomizationSelector from './ColorCustomizationSelector.vue'
|
||||
|
||||
describe('ColorCustomizationSelector', () => {
|
||||
const colorOptions = [
|
||||
@@ -5,8 +5,8 @@
|
||||
<SelectButton
|
||||
v-model="selectedColorOption"
|
||||
:options="colorOptionsWithCustom"
|
||||
optionLabel="name"
|
||||
dataKey="value"
|
||||
option-label="name"
|
||||
data-key="value"
|
||||
:allow-empty="false"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
@@ -18,8 +18,8 @@
|
||||
backgroundColor: slotProps.option.value,
|
||||
borderRadius: '50%'
|
||||
}"
|
||||
></div>
|
||||
<i v-else class="pi pi-palette text-lg"></i>
|
||||
/>
|
||||
<i v-else class="pi pi-palette text-lg" />
|
||||
</template>
|
||||
</SelectButton>
|
||||
<ColorPicker
|
||||
|
||||
@@ -8,22 +8,22 @@
|
||||
<img
|
||||
v-if="contain"
|
||||
:src="src"
|
||||
@error="handleImageError"
|
||||
:data-test="src"
|
||||
class="comfy-image-blur"
|
||||
:style="{ 'background-image': `url(${src})` }"
|
||||
:alt="alt"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<img
|
||||
:src="src"
|
||||
@error="handleImageError"
|
||||
class="comfy-image-main"
|
||||
:class="classProp"
|
||||
:alt="alt"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</span>
|
||||
<div v-if="imageBroken" class="broken-image-placeholder">
|
||||
<i class="pi pi-image"></i>
|
||||
<i class="pi pi-image" />
|
||||
<span>{{ $t('g.imageFailedToLoad') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="container"></div>
|
||||
<div ref="container" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
<SelectButton
|
||||
v-model="selectedIcon"
|
||||
:options="iconOptions"
|
||||
optionLabel="name"
|
||||
dataKey="value"
|
||||
option-label="name"
|
||||
data-key="value"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<i
|
||||
:class="['pi', slotProps.option.value, 'mr-2']"
|
||||
:style="{ color: finalColor }"
|
||||
></i>
|
||||
/>
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
@@ -30,14 +30,14 @@
|
||||
<Button
|
||||
:label="$t('g.reset')"
|
||||
icon="pi pi-refresh"
|
||||
@click="resetCustomization"
|
||||
class="p-button-text"
|
||||
@click="resetCustomization"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('g.confirm')"
|
||||
icon="pi pi-check"
|
||||
@click="confirmCustomization"
|
||||
autofocus
|
||||
@click="confirmCustomization"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<template v-for="col in deviceColumns" :key="col.field">
|
||||
<div class="font-medium">{{ col.header }}</div>
|
||||
<div class="font-medium">
|
||||
{{ col.header }}
|
||||
</div>
|
||||
<div>
|
||||
{{ formatValue(props.device[col.field], col.field) }}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import InputText from 'primevue/inputtext'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import EditableText from '../EditableText.vue'
|
||||
import EditableText from './EditableText.vue'
|
||||
|
||||
describe('EditableText', () => {
|
||||
beforeAll(() => {
|
||||
@@ -6,19 +6,19 @@
|
||||
<!-- Avoid double triggering finishEditing event when keyup.enter is triggered -->
|
||||
<InputText
|
||||
v-else
|
||||
ref="inputRef"
|
||||
v-model:modelValue="inputValue"
|
||||
v-focus
|
||||
type="text"
|
||||
size="small"
|
||||
fluid
|
||||
v-model:modelValue="inputValue"
|
||||
ref="inputRef"
|
||||
@keyup.enter="blurInputElement"
|
||||
@click.stop
|
||||
:pt="{
|
||||
root: {
|
||||
onBlur: finishEditing
|
||||
}
|
||||
}"
|
||||
v-focus
|
||||
@keyup.enter="blurInputElement"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -45,10 +45,10 @@ const finishEditing = () => {
|
||||
}
|
||||
watch(
|
||||
() => isEditing,
|
||||
(newVal) => {
|
||||
async (newVal) => {
|
||||
if (newVal) {
|
||||
inputValue.value = modelValue
|
||||
nextTick(() => {
|
||||
await nextTick(() => {
|
||||
if (!inputRef.value) return
|
||||
const fileName = inputValue.value.includes('.')
|
||||
? inputValue.value.split('.').slice(0, -1).join('.')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<i class="pi pi-check text-green-500" v-if="status === 'completed'" />
|
||||
<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>
|
||||
@@ -14,20 +14,20 @@
|
||||
|
||||
<div class="file-action">
|
||||
<Button
|
||||
v-if="status === null || status === 'error'"
|
||||
class="file-action-button"
|
||||
:label="$t('g.download') + ' (' + fileSize + ')'"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="!!props.error"
|
||||
@click="triggerDownload"
|
||||
v-if="status === null || status === 'error'"
|
||||
icon="pi pi-download"
|
||||
@click="triggerDownload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-row items-center gap-2"
|
||||
v-if="status === 'in_progress' || status === 'paused'"
|
||||
class="flex flex-row items-center gap-2"
|
||||
>
|
||||
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
|
||||
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
|
||||
@@ -39,36 +39,36 @@
|
||||
/>
|
||||
|
||||
<Button
|
||||
class="file-action-button"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="!!props.error"
|
||||
@click="triggerPauseDownload"
|
||||
v-if="status === 'in_progress'"
|
||||
icon="pi pi-pause-circle"
|
||||
v-tooltip.top="t('electronFileDownload.pause')"
|
||||
/>
|
||||
|
||||
<Button
|
||||
class="file-action-button"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="!!props.error"
|
||||
@click="triggerResumeDownload"
|
||||
icon="pi pi-pause-circle"
|
||||
@click="triggerPauseDownload"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="status === 'paused'"
|
||||
icon="pi pi-play-circle"
|
||||
v-tooltip.top="t('electronFileDownload.resume')"
|
||||
/>
|
||||
|
||||
<Button
|
||||
class="file-action-button"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="!!props.error"
|
||||
@click="triggerCancelDownload"
|
||||
icon="pi pi-play-circle"
|
||||
@click="triggerResumeDownload"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-tooltip.top="t('electronFileDownload.cancel')"
|
||||
class="file-action-button"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="!!props.error"
|
||||
icon="pi pi-times-circle"
|
||||
severity="danger"
|
||||
v-tooltip.top="t('electronFileDownload.cancel')"
|
||||
@click="triggerCancelDownload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<component v-if="extension.type === 'vue'" :is="extension.component" />
|
||||
<component :is="extension.component" v-if="extension.type === 'vue'" />
|
||||
<div
|
||||
v-else
|
||||
:ref="
|
||||
@@ -11,7 +11,7 @@
|
||||
)
|
||||
}
|
||||
"
|
||||
></div>
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
:src="modelValue"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
<i v-else class="pi pi-image text-gray-400 text-xl"></i>
|
||||
<i v-else class="pi pi-image text-gray-400 text-xl" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
@@ -3,26 +3,26 @@
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="form-label flex flex-grow items-center">
|
||||
<span
|
||||
:id="`${props.id}-label`"
|
||||
class="text-muted"
|
||||
:class="props.labelClass"
|
||||
:id="`${props.id}-label`"
|
||||
>
|
||||
<slot name="name-prefix"></slot>
|
||||
<slot name="name-prefix" />
|
||||
{{ props.item.name }}
|
||||
<i
|
||||
v-if="props.item.tooltip"
|
||||
class="pi pi-info-circle bg-transparent"
|
||||
v-tooltip="props.item.tooltip"
|
||||
class="pi pi-info-circle bg-transparent"
|
||||
/>
|
||||
<slot name="name-suffix"></slot>
|
||||
<slot name="name-suffix" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-input flex justify-end">
|
||||
<component
|
||||
:is="markRaw(getFormComponent(props.item))"
|
||||
:id="props.id"
|
||||
:aria-labelledby="`${props.id}-label`"
|
||||
v-model:modelValue="formValue"
|
||||
:aria-labelledby="`${props.id}-label`"
|
||||
v-bind="getFormAttrs(props.item)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
<template>
|
||||
<div class="input-knob flex flex-row items-center gap-2">
|
||||
<Knob
|
||||
:modelValue="modelValue"
|
||||
@update:modelValue="updateValue"
|
||||
:valueTemplate="displayValue"
|
||||
:model-value="modelValue"
|
||||
:value-template="displayValue"
|
||||
class="knob-part"
|
||||
:class="knobClass"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
v-bind="$attrs"
|
||||
@update:model-value="updateValue"
|
||||
/>
|
||||
<InputNumber
|
||||
:modelValue="modelValue"
|
||||
@update:modelValue="updateValue"
|
||||
:model-value="modelValue"
|
||||
class="input-part"
|
||||
:max-fraction-digits="3"
|
||||
:class="inputClass"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:allowEmpty="false"
|
||||
:allow-empty="false"
|
||||
@update:model-value="updateValue"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<template>
|
||||
<div class="input-slider flex flex-row items-center gap-2">
|
||||
<Slider
|
||||
:modelValue="modelValue"
|
||||
@update:modelValue="(value) => updateValue(value as number)"
|
||||
:model-value="modelValue"
|
||||
class="slider-part"
|
||||
:class="sliderClass"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
v-bind="$attrs"
|
||||
@update:model-value="(value) => updateValue(value as number)"
|
||||
/>
|
||||
<InputNumber
|
||||
:modelValue="modelValue"
|
||||
@update:modelValue="updateValue"
|
||||
:model-value="modelValue"
|
||||
class="input-part"
|
||||
:max-fraction-digits="3"
|
||||
:class="inputClass"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:allowEmpty="false"
|
||||
:allow-empty="false"
|
||||
@update:model-value="updateValue"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-col items-center">
|
||||
<i :class="icon" style="font-size: 3rem; margin-bottom: 1rem"></i>
|
||||
<i :class="icon" style="font-size: 3rem; margin-bottom: 1rem" />
|
||||
<h3>{{ title }}</h3>
|
||||
<p class="whitespace-pre-line text-center">{{ message }}</p>
|
||||
<p class="whitespace-pre-line text-center">
|
||||
{{ message }}
|
||||
</p>
|
||||
<Button
|
||||
v-if="buttonLabel"
|
||||
:label="buttonLabel"
|
||||
@click="$emit('action')"
|
||||
class="p-button-text"
|
||||
@click="$emit('action')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
class="p-button-icon pi pi-refresh transition-all"
|
||||
:class="{ 'opacity-0': active }"
|
||||
data-pc-section="icon"
|
||||
></span>
|
||||
/>
|
||||
<span class="p-button-label" data-pc-section="label"> </span>
|
||||
<ProgressSpinner v-show="active" class="absolute w-1/2 h-1/2" />
|
||||
</Button>
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
/>
|
||||
<InputText
|
||||
class="search-box-input w-full"
|
||||
@input="handleInput"
|
||||
:modelValue="modelValue"
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<InputIcon v-if="!modelValue" :class="icon" />
|
||||
<Button
|
||||
@@ -26,8 +26,8 @@
|
||||
/>
|
||||
</IconField>
|
||||
<div
|
||||
class="search-filters pt-2 flex flex-wrap gap-2"
|
||||
v-if="filters?.length"
|
||||
class="search-filters pt-2 flex flex-wrap gap-2"
|
||||
>
|
||||
<SearchFilterChip
|
||||
v-for="filter in filters"
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<template>
|
||||
<div class="system-stats">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ $t('g.systemInfo') }}</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">
|
||||
{{ $t('g.systemInfo') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<template v-for="col in systemColumns" :key="col.field">
|
||||
<div class="font-medium">{{ col.header }}</div>
|
||||
<div class="font-medium">
|
||||
{{ col.header }}
|
||||
</div>
|
||||
<div>{{ formatValue(systemInfo[col.field], col.field) }}</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -13,7 +17,9 @@
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold mb-4">{{ $t('g.devices') }}</h2>
|
||||
<h2 class="text-2xl font-semibold mb-4">
|
||||
{{ $t('g.devices') }}
|
||||
</h2>
|
||||
<TabView v-if="props.stats.devices.length > 1">
|
||||
<TabPanel
|
||||
v-for="device in props.stats.devices"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<Tree
|
||||
class="tree-explorer py-0 px-2 2xl:px-4"
|
||||
:class="props.class"
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
v-model:selectionKeys="selectionKeys"
|
||||
class="tree-explorer py-0 px-2 2xl:px-4"
|
||||
:class="props.class"
|
||||
:value="renderedRoot.children"
|
||||
selectionMode="single"
|
||||
selection-mode="single"
|
||||
:pt="{
|
||||
nodeLabel: 'tree-explorer-node-label',
|
||||
nodeContent: ({ context }) => ({
|
||||
@@ -186,9 +186,9 @@ const menuItems = computed<MenuItem[]>(() =>
|
||||
{
|
||||
label: t('g.delete'),
|
||||
icon: 'pi pi-trash',
|
||||
command: () => {
|
||||
command: async () => {
|
||||
if (menuTargetNode.value) {
|
||||
deleteCommand(menuTargetNode.value)
|
||||
await deleteCommand(menuTargetNode.value)
|
||||
}
|
||||
},
|
||||
visible: menuTargetNode.value?.handleDelete !== undefined,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
:class="[
|
||||
'tree-node',
|
||||
{
|
||||
@@ -8,17 +9,16 @@
|
||||
'tree-leaf': props.node.leaf
|
||||
}
|
||||
]"
|
||||
ref="container"
|
||||
>
|
||||
<div class="node-content">
|
||||
<span class="node-label">
|
||||
<slot name="before-label" :node="props.node"></slot>
|
||||
<slot name="before-label" :node="props.node" />
|
||||
<EditableText
|
||||
:modelValue="node.label"
|
||||
:isEditing="isEditing"
|
||||
:model-value="node.label"
|
||||
:is-editing="isEditing"
|
||||
@edit="handleRename"
|
||||
/>
|
||||
<slot name="after-label" :node="props.node"></slot>
|
||||
<slot name="after-label" :node="props.node" />
|
||||
</span>
|
||||
<Badge
|
||||
v-if="showNodeBadgeText"
|
||||
@@ -30,7 +30,7 @@
|
||||
<div
|
||||
class="node-actions motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
|
||||
>
|
||||
<slot name="actions" :node="props.node"></slot>
|
||||
<slot name="actions" :node="props.node" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,7 @@ import InputText from 'primevue/inputtext'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, nextTick } from 'vue'
|
||||
|
||||
import UrlInput from '../UrlInput.vue'
|
||||
import UrlInput from './UrlInput.vue'
|
||||
|
||||
describe('UrlInput', () => {
|
||||
beforeEach(() => {
|
||||
@@ -60,7 +60,7 @@ describe('UrlInput', () => {
|
||||
})
|
||||
})
|
||||
|
||||
wrapper.setProps({ modelValue: 'https://test.com' })
|
||||
await wrapper.setProps({ modelValue: 'https://test.com' })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('UrlInput', () => {
|
||||
validateUrlFn: () => Promise.resolve(true)
|
||||
})
|
||||
|
||||
wrapper.setProps({ modelValue: 'https://test.com' })
|
||||
await wrapper.setProps({ modelValue: 'https://test.com' })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('UrlInput', () => {
|
||||
validateUrlFn: () => Promise.resolve(false)
|
||||
})
|
||||
|
||||
wrapper.setProps({ modelValue: 'https://test.com' })
|
||||
await wrapper.setProps({ modelValue: 'https://test.com' })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
@@ -141,14 +141,14 @@ describe('UrlInput', () => {
|
||||
}
|
||||
})
|
||||
|
||||
wrapper.setProps({ modelValue: 'https://test.com' })
|
||||
await wrapper.setProps({ modelValue: 'https://test.com' })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
// Trigger multiple validations in quick succession
|
||||
wrapper.find('.pi-spinner').trigger('click')
|
||||
wrapper.find('.pi-spinner').trigger('click')
|
||||
wrapper.find('.pi-spinner').trigger('click')
|
||||
await wrapper.find('.pi-spinner').trigger('click')
|
||||
await wrapper.find('.pi-spinner').trigger('click')
|
||||
await wrapper.find('.pi-spinner').trigger('click')
|
||||
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
@@ -3,7 +3,7 @@
|
||||
<div :style="{ height: `${(state.start / cols) * itemHeight}px` }" />
|
||||
<div :style="gridStyle">
|
||||
<div v-for="item in renderedItems" :key="item.key" data-virtual-grid-item>
|
||||
<slot name="item" :item="item"> </slot>
|
||||
<slot name="item" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
>
|
||||
<template #header>
|
||||
<component
|
||||
v-if="item.headerComponent"
|
||||
:is="item.headerComponent"
|
||||
v-if="item.headerComponent"
|
||||
:id="item.key"
|
||||
/>
|
||||
<h3 v-else :id="item.key">{{ item.title || ' ' }}</h3>
|
||||
<h3 v-else :id="item.key">
|
||||
{{ item.title || ' ' }}
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<component
|
||||
@@ -26,7 +28,7 @@
|
||||
:maximized="item.dialogComponentProps.maximized"
|
||||
/>
|
||||
|
||||
<template #footer v-if="item.footerComponent">
|
||||
<template v-if="item.footerComponent" #footer>
|
||||
<component :is="item.footerComponent" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
44
src/components/dialog/content/ApiNodesSignInContent.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<!-- Prompt user that the workflow contains API nodes that needs login to run -->
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 max-w-96 h-110 p-2">
|
||||
<div class="text-2xl font-medium mb-2">
|
||||
{{ t('apiNodesSignInDialog.title') }}
|
||||
</div>
|
||||
|
||||
<div class="text-base mb-4">
|
||||
{{ t('apiNodesSignInDialog.message') }}
|
||||
</div>
|
||||
|
||||
<ApiNodesList :node-names="apiNodeNames" />
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<Button :label="t('g.learnMore')" link @click="handleLearnMoreClick" />
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
:label="t('g.cancel')"
|
||||
outlined
|
||||
severity="secondary"
|
||||
@click="onCancel?.()"
|
||||
/>
|
||||
<Button :label="t('g.login')" @click="onLogin?.()" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { apiNodeNames, onLogin, onCancel } = defineProps<{
|
||||
apiNodeNames: string[]
|
||||
onLogin?: () => void
|
||||
onCancel?: () => void
|
||||
}>()
|
||||
|
||||
const handleLearnMoreClick = () => {
|
||||
window.open('https://www.comfy.org/faq', '_blank')
|
||||
}
|
||||
</script>
|
||||
@@ -2,7 +2,9 @@
|
||||
<section class="prompt-dialog-content flex flex-col gap-6 m-2 mt-4">
|
||||
<span>{{ message }}</span>
|
||||
<ul v-if="itemList?.length" class="pl-4 m-0 flex flex-col gap-2">
|
||||
<li v-for="item of itemList" :key="item">{{ item }}</li>
|
||||
<li v-for="item of itemList" :key="item">
|
||||
{{ item }}
|
||||
</li>
|
||||
</ul>
|
||||
<Message
|
||||
v-if="hint"
|
||||
@@ -18,53 +20,53 @@
|
||||
:label="$t('g.cancel')"
|
||||
icon="pi pi-undo"
|
||||
severity="secondary"
|
||||
@click="onCancel"
|
||||
autofocus
|
||||
@click="onCancel"
|
||||
/>
|
||||
<Button
|
||||
v-if="type === 'default'"
|
||||
:label="$t('g.confirm')"
|
||||
severity="primary"
|
||||
@click="onConfirm"
|
||||
icon="pi pi-check"
|
||||
@click="onConfirm"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="type === 'delete'"
|
||||
:label="$t('g.delete')"
|
||||
severity="danger"
|
||||
@click="onConfirm"
|
||||
icon="pi pi-trash"
|
||||
@click="onConfirm"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="type === 'overwrite'"
|
||||
:label="$t('g.overwrite')"
|
||||
severity="warn"
|
||||
@click="onConfirm"
|
||||
icon="pi pi-save"
|
||||
@click="onConfirm"
|
||||
/>
|
||||
<template v-else-if="type === 'dirtyClose'">
|
||||
<Button
|
||||
:label="$t('g.no')"
|
||||
severity="secondary"
|
||||
@click="onDeny"
|
||||
icon="pi pi-times"
|
||||
@click="onDeny"
|
||||
/>
|
||||
<Button :label="$t('g.save')" @click="onConfirm" icon="pi pi-save" />
|
||||
<Button :label="$t('g.save')" icon="pi pi-save" @click="onConfirm" />
|
||||
</template>
|
||||
<Button
|
||||
v-else-if="type === 'reinstall'"
|
||||
:label="$t('desktopMenu.reinstall')"
|
||||
severity="warn"
|
||||
@click="onConfirm"
|
||||
icon="pi pi-eraser"
|
||||
@click="onConfirm"
|
||||
/>
|
||||
<!-- Invalid - just show a close button. -->
|
||||
<Button
|
||||
v-else
|
||||
:label="$t('g.close')"
|
||||
severity="primary"
|
||||
@click="onCancel"
|
||||
icon="pi pi-times"
|
||||
@click="onCancel"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -25,6 +25,13 @@
|
||||
:label="$t('issueReport.helpFix')"
|
||||
@click="showSendReport"
|
||||
/>
|
||||
<Button
|
||||
v-if="authStore.currentUser"
|
||||
v-show="!reportOpen"
|
||||
text
|
||||
:label="$t('issueReport.contactSupportTitle')"
|
||||
@click="showContactSupport"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="reportOpen">
|
||||
<Divider />
|
||||
@@ -45,9 +52,9 @@
|
||||
/>
|
||||
<div class="flex gap-4 justify-end">
|
||||
<FindIssueButton
|
||||
:errorMessage="error.exceptionMessage"
|
||||
:repoOwner="repoOwner"
|
||||
:repoName="repoName"
|
||||
:error-message="error.exceptionMessage"
|
||||
:repo-owner="repoOwner"
|
||||
:repo-name="repoName"
|
||||
/>
|
||||
<Button
|
||||
v-if="reportOpen"
|
||||
@@ -72,6 +79,8 @@ import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.v
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import type { ReportField } from '@/types/issueReportTypes'
|
||||
import {
|
||||
@@ -81,6 +90,8 @@ import {
|
||||
|
||||
import ReportIssuePanel from './error/ReportIssuePanel.vue'
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
|
||||
const { error } = defineProps<{
|
||||
error: Omit<ErrorReportData, 'workflow' | 'systemStats' | 'serverLogs'> & {
|
||||
/**
|
||||
@@ -123,6 +134,10 @@ const stackTraceField = computed<ReportField>(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const showContactSupport = async () => {
|
||||
await useCommandStore().execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!systemStatsStore.systemStats) {
|
||||
await systemStatsStore.fetchSystemStats()
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
>
|
||||
<template #header>
|
||||
<header class="flex flex-col items-center w-full">
|
||||
<h2 id="issue-report-title" class="text-4xl">{{ title }}</h2>
|
||||
<h2 id="issue-report-title" class="text-4xl">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<span v-if="subtitle" class="text-muted mt-0">{{ subtitle }}</span>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
/>
|
||||
<ListBox
|
||||
:options="uniqueNodes"
|
||||
optionLabel="label"
|
||||
scrollHeight="100%"
|
||||
option-label="label"
|
||||
scroll-height="100%"
|
||||
class="comfy-missing-nodes"
|
||||
:pt="{
|
||||
list: { class: 'border-none' }
|
||||
@@ -22,16 +22,16 @@
|
||||
}}</span>
|
||||
<Button
|
||||
v-if="slotProps.option.action"
|
||||
@click="slotProps.option.action.callback"
|
||||
:label="slotProps.option.action.text"
|
||||
size="small"
|
||||
outlined
|
||||
@click="slotProps.option.action.callback"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ListBox>
|
||||
<div class="flex justify-end py-3">
|
||||
<Button label="Open Manager" @click="openManager" size="small" outlined />
|
||||
<Button label="Open Manager" size="small" outlined @click="openManager" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import ManagerProgressDialogContent from '../ManagerProgressDialogContent.vue'
|
||||
import ManagerProgressDialogContent from './ManagerProgressDialogContent.vue'
|
||||
|
||||
type ComponentInstance = InstanceType<typeof ManagerProgressDialogContent> & {
|
||||
lastPanelRef: HTMLElement | null
|
||||
@@ -129,9 +129,12 @@ const missingModels = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
onBeforeUnmount(async () => {
|
||||
if (doNotAskAgain.value) {
|
||||
useSettingStore().set('Comfy.Workflow.ShowMissingModelsWarning', false)
|
||||
await useSettingStore().set(
|
||||
'Comfy.Workflow.ShowMissingModelsWarning',
|
||||
false
|
||||
)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
<InputText
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
autofocus
|
||||
@keyup.enter="onConfirm"
|
||||
@focus="selectAllText"
|
||||
autofocus
|
||||
/>
|
||||
<label>{{ message }}</label>
|
||||
</FloatLabel>
|
||||
<Button @click="onConfirm">{{ $t('g.confirm') }}</Button>
|
||||
<Button @click="onConfirm">
|
||||
{{ $t('g.confirm') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,30 +2,36 @@
|
||||
<div class="settings-container">
|
||||
<ScrollPanel class="settings-sidebar flex-shrink-0 p-2 w-48 2xl:w-64">
|
||||
<SearchBox
|
||||
class="settings-search-box w-full mb-2"
|
||||
v-model:modelValue="searchQuery"
|
||||
@search="handleSearch"
|
||||
class="settings-search-box w-full mb-2"
|
||||
:placeholder="$t('g.searchSettings') + '...'"
|
||||
:debounce-time="128"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
<Listbox
|
||||
v-model="activeCategory"
|
||||
:options="categories"
|
||||
optionLabel="translatedLabel"
|
||||
scrollHeight="100%"
|
||||
:optionDisabled="
|
||||
:options="groupedMenuTreeNodes"
|
||||
option-label="translatedLabel"
|
||||
option-group-label="label"
|
||||
option-group-children="children"
|
||||
scroll-height="100%"
|
||||
:option-disabled="
|
||||
(option: SettingTreeNode) =>
|
||||
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
|
||||
"
|
||||
class="border-none w-full"
|
||||
/>
|
||||
>
|
||||
<template #optiongroup>
|
||||
<Divider class="my-0" />
|
||||
</template>
|
||||
</Listbox>
|
||||
</ScrollPanel>
|
||||
<Divider layout="vertical" class="mx-1 2xl:mx-4 hidden md:flex" />
|
||||
<Divider layout="horizontal" class="flex md:hidden" />
|
||||
<Tabs :value="tabValue" :lazy="true" class="settings-content h-full w-full">
|
||||
<TabPanels class="settings-tab-panels h-full w-full pr-0">
|
||||
<PanelTemplate value="Search Results">
|
||||
<SettingsPanel :settingGroups="searchResults" />
|
||||
<SettingsPanel :setting-groups="searchResults" />
|
||||
</PanelTemplate>
|
||||
|
||||
<PanelTemplate
|
||||
@@ -38,28 +44,13 @@
|
||||
<FirstTimeUIMessage v-if="tabValue === 'Comfy'" />
|
||||
<ColorPaletteMessage v-if="tabValue === 'Appearance'" />
|
||||
</template>
|
||||
<SettingsPanel :settingGroups="sortedGroups(category)" />
|
||||
<SettingsPanel :setting-groups="sortedGroups(category)" />
|
||||
</PanelTemplate>
|
||||
|
||||
<AboutPanel />
|
||||
<Suspense>
|
||||
<KeybindingPanel />
|
||||
<Suspense v-for="panel in panels" :key="panel.node.key">
|
||||
<component :is="panel.component" />
|
||||
<template #fallback>
|
||||
<div>Loading keybinding panel...</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
|
||||
<Suspense>
|
||||
<ExtensionPanel />
|
||||
<template #fallback>
|
||||
<div>Loading extension panel...</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
|
||||
<Suspense>
|
||||
<ServerConfigPanel />
|
||||
<template #fallback>
|
||||
<div>Loading server config panel...</div>
|
||||
<div>Loading {{ panel.node.label }} panel...</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
</TabPanels>
|
||||
@@ -73,107 +64,52 @@ import Listbox from 'primevue/listbox'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import { st } from '@/i18n'
|
||||
import {
|
||||
SettingTreeNode,
|
||||
getSettingInfo,
|
||||
useSettingStore
|
||||
} from '@/stores/settingStore'
|
||||
import { useSettingSearch } from '@/composables/setting/useSettingSearch'
|
||||
import { useSettingUI } from '@/composables/setting/useSettingUI'
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { SettingTreeNode } from '@/stores/settingStore'
|
||||
import { ISettingGroup, SettingParams } from '@/types/settingTypes'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { flattenTree } from '@/utils/treeUtil'
|
||||
|
||||
import AboutPanel from './setting/AboutPanel.vue'
|
||||
import ColorPaletteMessage from './setting/ColorPaletteMessage.vue'
|
||||
import CurrentUserMessage from './setting/CurrentUserMessage.vue'
|
||||
import FirstTimeUIMessage from './setting/FirstTimeUIMessage.vue'
|
||||
import PanelTemplate from './setting/PanelTemplate.vue'
|
||||
import SettingsPanel from './setting/SettingsPanel.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
defaultPanel?: 'about' | 'keybinding' | 'extension' | 'server-config'
|
||||
const { defaultPanel } = defineProps<{
|
||||
defaultPanel?:
|
||||
| 'about'
|
||||
| 'keybinding'
|
||||
| 'extension'
|
||||
| 'server-config'
|
||||
| 'user'
|
||||
| 'credits'
|
||||
}>()
|
||||
|
||||
const KeybindingPanel = defineAsyncComponent(
|
||||
() => import('./setting/KeybindingPanel.vue')
|
||||
)
|
||||
const ExtensionPanel = defineAsyncComponent(
|
||||
() => import('./setting/ExtensionPanel.vue')
|
||||
)
|
||||
const ServerConfigPanel = defineAsyncComponent(
|
||||
() => import('./setting/ServerConfigPanel.vue')
|
||||
)
|
||||
const {
|
||||
activeCategory,
|
||||
defaultCategory,
|
||||
settingCategories,
|
||||
groupedMenuTreeNodes,
|
||||
panels
|
||||
} = useSettingUI(defaultPanel)
|
||||
|
||||
const aboutPanelNode: SettingTreeNode = {
|
||||
key: 'about',
|
||||
label: 'About',
|
||||
children: []
|
||||
}
|
||||
const {
|
||||
searchQuery,
|
||||
searchResultsCategories,
|
||||
queryIsEmpty,
|
||||
inSearch,
|
||||
handleSearch: handleSearchBase,
|
||||
getSearchResults
|
||||
} = useSettingSearch()
|
||||
|
||||
const keybindingPanelNode: SettingTreeNode = {
|
||||
key: 'keybinding',
|
||||
label: 'Keybinding',
|
||||
children: []
|
||||
}
|
||||
|
||||
const extensionPanelNode: SettingTreeNode = {
|
||||
key: 'extension',
|
||||
label: 'Extension',
|
||||
children: []
|
||||
}
|
||||
|
||||
const serverConfigPanelNode: SettingTreeNode = {
|
||||
key: 'server-config',
|
||||
label: 'Server-Config',
|
||||
children: []
|
||||
}
|
||||
|
||||
/**
|
||||
* Server config panel is only available in Electron. We might want to support
|
||||
* it in the web version in the future.
|
||||
*/
|
||||
const serverConfigPanelNodeList = computed<SettingTreeNode[]>(() => {
|
||||
return isElectron() ? [serverConfigPanelNode] : []
|
||||
})
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const settingRoot = computed<SettingTreeNode>(() => settingStore.settingTree)
|
||||
const settingCategories = computed<SettingTreeNode[]>(
|
||||
() => settingRoot.value.children ?? []
|
||||
)
|
||||
const { t } = useI18n()
|
||||
const categories = computed<SettingTreeNode[]>(() =>
|
||||
[
|
||||
...settingCategories.value,
|
||||
keybindingPanelNode,
|
||||
extensionPanelNode,
|
||||
...serverConfigPanelNodeList.value,
|
||||
aboutPanelNode
|
||||
].map((node) => ({
|
||||
...node,
|
||||
translatedLabel: t(
|
||||
`settingsCategories.${normalizeI18nKey(node.label)}`,
|
||||
node.label
|
||||
)
|
||||
}))
|
||||
)
|
||||
|
||||
const activeCategory = ref<SettingTreeNode | null>(null)
|
||||
const getDefaultCategory = () => {
|
||||
return props.defaultPanel
|
||||
? categories.value.find((x) => x.key === props.defaultPanel) ??
|
||||
categories.value[0]
|
||||
: categories.value[0]
|
||||
}
|
||||
onMounted(() => {
|
||||
activeCategory.value = getDefaultCategory()
|
||||
})
|
||||
const authService = useFirebaseAuthService()
|
||||
|
||||
// Sort groups for a category
|
||||
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
|
||||
return [...(category.children ?? [])]
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
@@ -183,98 +119,29 @@ const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
|
||||
}))
|
||||
}
|
||||
|
||||
const searchQuery = ref<string>('')
|
||||
const filteredSettingIds = ref<string[]>([])
|
||||
const searchInProgress = ref<boolean>(false)
|
||||
watch(searchQuery, () => (searchInProgress.value = true))
|
||||
|
||||
const searchResults = computed<ISettingGroup[]>(() => {
|
||||
const groupedSettings: { [key: string]: SettingParams[] } = {}
|
||||
|
||||
filteredSettingIds.value.forEach((id) => {
|
||||
const setting = settingStore.settingsById[id]
|
||||
const info = getSettingInfo(setting)
|
||||
const groupLabel = info.subCategory
|
||||
|
||||
if (
|
||||
activeCategory.value === null ||
|
||||
activeCategory.value.label === info.category
|
||||
) {
|
||||
if (!groupedSettings[groupLabel]) {
|
||||
groupedSettings[groupLabel] = []
|
||||
}
|
||||
groupedSettings[groupLabel].push(setting)
|
||||
}
|
||||
})
|
||||
|
||||
return Object.entries(groupedSettings).map(([label, settings]) => ({
|
||||
label,
|
||||
settings
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* Settings categories that contains at least one setting in search results.
|
||||
*/
|
||||
const searchResultsCategories = computed<Set<string>>(() => {
|
||||
return new Set(
|
||||
filteredSettingIds.value.map(
|
||||
(id) => getSettingInfo(settingStore.settingsById[id]).category
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
if (!query) {
|
||||
filteredSettingIds.value = []
|
||||
activeCategory.value ??= getDefaultCategory()
|
||||
return
|
||||
}
|
||||
|
||||
const queryLower = query.toLocaleLowerCase()
|
||||
const allSettings = flattenTree<SettingParams>(settingRoot.value)
|
||||
const filteredSettings = allSettings.filter((setting) => {
|
||||
const idLower = setting.id.toLowerCase()
|
||||
const nameLower = setting.name.toLowerCase()
|
||||
const translatedName = st(
|
||||
`settings.${normalizeI18nKey(setting.id)}.name`,
|
||||
setting.name
|
||||
).toLocaleLowerCase()
|
||||
const info = getSettingInfo(setting)
|
||||
const translatedCategory = st(
|
||||
`settingsCategories.${normalizeI18nKey(info.category)}`,
|
||||
info.category
|
||||
).toLocaleLowerCase()
|
||||
const translatedSubCategory = st(
|
||||
`settingsCategories.${normalizeI18nKey(info.subCategory)}`,
|
||||
info.subCategory
|
||||
).toLocaleLowerCase()
|
||||
|
||||
return (
|
||||
idLower.includes(queryLower) ||
|
||||
nameLower.includes(queryLower) ||
|
||||
translatedName.includes(queryLower) ||
|
||||
translatedCategory.includes(queryLower) ||
|
||||
translatedSubCategory.includes(queryLower)
|
||||
)
|
||||
})
|
||||
|
||||
filteredSettingIds.value = filteredSettings.map((x) => x.id)
|
||||
searchInProgress.value = false
|
||||
activeCategory.value = null
|
||||
handleSearchBase(query)
|
||||
activeCategory.value = query ? null : defaultCategory.value
|
||||
}
|
||||
|
||||
const queryIsEmpty = computed(() => searchQuery.value.length === 0)
|
||||
const inSearch = computed(() => !queryIsEmpty.value && !searchInProgress.value)
|
||||
// Get search results
|
||||
const searchResults = computed<ISettingGroup[]>(() =>
|
||||
getSearchResults(activeCategory.value)
|
||||
)
|
||||
|
||||
const tabValue = computed<string>(() =>
|
||||
inSearch.value ? 'Search Results' : activeCategory.value?.label ?? ''
|
||||
)
|
||||
|
||||
// Don't allow null category to be set outside of search.
|
||||
// In search mode, the active category can be null to show all search results.
|
||||
watch(activeCategory, (_, oldValue) => {
|
||||
if (!tabValue.value) {
|
||||
activeCategory.value = oldValue
|
||||
}
|
||||
if (activeCategory.value?.key === 'credits') {
|
||||
void authService.fetchBalance()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -293,6 +160,10 @@ watch(activeCategory, (_, oldValue) => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-container {
|
||||
flex-direction: column;
|
||||
@@ -309,14 +180,8 @@ watch(activeCategory, (_, oldValue) => {
|
||||
}
|
||||
}
|
||||
|
||||
/* Show a separator line above the Keybinding tab */
|
||||
/* This indicates the start of custom setting panels */
|
||||
.settings-sidebar :deep(.p-listbox-option[aria-label='Keybinding']) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-sidebar :deep(.p-listbox-option[aria-label='Keybinding'])::before {
|
||||
@apply content-[''] top-0 left-0 absolute w-full;
|
||||
border-top: 1px solid var(--p-divider-border-color);
|
||||
/* Hide the first group separator */
|
||||
.settings-sidebar :deep(.p-listbox-option-group:nth-child(1)) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
132
src/components/dialog/content/SignInContent.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="w-96 p-2">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 mb-8">
|
||||
<h1 class="text-2xl font-medium leading-normal my-0">
|
||||
{{ isSignIn ? t('auth.login.title') : t('auth.signup.title') }}
|
||||
</h1>
|
||||
<p class="text-base my-0">
|
||||
<span class="text-muted">{{
|
||||
isSignIn
|
||||
? t('auth.login.newUser')
|
||||
: t('auth.signup.alreadyHaveAccount')
|
||||
}}</span>
|
||||
<span class="ml-1 cursor-pointer text-blue-500" @click="toggleState">{{
|
||||
isSignIn ? t('auth.login.signUp') : t('auth.signup.signIn')
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<SignInForm v-if="isSignIn" @submit="signInWithEmail" />
|
||||
<SignUpForm v-else @submit="signInWithEmail" />
|
||||
|
||||
<!-- Divider -->
|
||||
<Divider align="center" layout="horizontal" class="my-8">
|
||||
<span class="text-muted">{{ t('auth.login.orContinueWith') }}</span>
|
||||
</Divider>
|
||||
|
||||
<!-- Social Login Buttons -->
|
||||
<div class="flex flex-col gap-6">
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="signInWithGoogle"
|
||||
>
|
||||
<i class="pi pi-google mr-2"></i>
|
||||
{{
|
||||
isSignIn
|
||||
? t('auth.login.loginWithGoogle')
|
||||
: t('auth.signup.signUpWithGoogle')
|
||||
}}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="h-10"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="signInWithGithub"
|
||||
>
|
||||
<i class="pi pi-github mr-2"></i>
|
||||
{{
|
||||
isSignIn
|
||||
? t('auth.login.loginWithGithub')
|
||||
: t('auth.signup.signUpWithGithub')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Terms -->
|
||||
<p class="text-xs text-muted mt-8">
|
||||
{{ t('auth.login.termsText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/terms-of-service"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
>
|
||||
{{ t('auth.login.termsLink') }}
|
||||
</a>
|
||||
{{ t('auth.login.andText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/privacy"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
>
|
||||
{{ t('auth.login.privacyLink') }}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { SignInData, SignUpData } from '@/schemas/signInSchema'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
|
||||
import SignInForm from './signin/SignInForm.vue'
|
||||
import SignUpForm from './signin/SignUpForm.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { onSuccess } = defineProps<{
|
||||
onSuccess: () => void
|
||||
}>()
|
||||
|
||||
const firebaseAuthStore = useFirebaseAuthStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const isSignIn = ref(true)
|
||||
const toggleState = () => {
|
||||
isSignIn.value = !isSignIn.value
|
||||
}
|
||||
|
||||
const signInWithGoogle = wrapWithErrorHandlingAsync(async () => {
|
||||
await firebaseAuthStore.loginWithGoogle()
|
||||
onSuccess()
|
||||
})
|
||||
|
||||
const signInWithGithub = wrapWithErrorHandlingAsync(async () => {
|
||||
await firebaseAuthStore.loginWithGithub()
|
||||
onSuccess()
|
||||
})
|
||||
|
||||
const signInWithEmail = wrapWithErrorHandlingAsync(
|
||||
async (values: SignInData | SignUpData) => {
|
||||
const { email, password } = values
|
||||
if (isSignIn.value) {
|
||||
await firebaseAuthStore.login(email, password)
|
||||
} else {
|
||||
await firebaseAuthStore.register(email, password)
|
||||
}
|
||||
onSuccess()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
152
src/components/dialog/content/TopUpCreditsDialogContent.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="flex flex-col w-96 p-2 gap-10">
|
||||
<div v-if="isInsufficientCredits" class="flex flex-col gap-4">
|
||||
<h1 class="text-2xl font-medium leading-normal my-0">
|
||||
{{ $t('credits.topUp.insufficientTitle') }}
|
||||
</h1>
|
||||
<p class="text-base my-0">
|
||||
{{ $t('credits.topUp.insufficientMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Balance Section -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<div class="text-muted text-base">
|
||||
{{ $t('credits.yourCreditBalance') }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag
|
||||
severity="secondary"
|
||||
icon="pi pi-dollar"
|
||||
rounded
|
||||
class="text-amber-400 p-1"
|
||||
/>
|
||||
<span class="text-2xl">{{ formattedBalance }}</span>
|
||||
</div>
|
||||
<Button
|
||||
outlined
|
||||
severity="secondary"
|
||||
:label="$t('credits.topUp.seeDetails')"
|
||||
icon="pi pi-arrow-up-right"
|
||||
@click="handleSeeDetails"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amount Input Section -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-muted text-sm"
|
||||
>{{ $t('credits.topUp.quickPurchase') }}:</span
|
||||
>
|
||||
<div class="grid grid-cols-[2fr_1fr] gap-2">
|
||||
<template v-for="amount in amountOptions" :key="amount">
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag
|
||||
severity="secondary"
|
||||
icon="pi pi-dollar"
|
||||
rounded
|
||||
class="text-amber-400 p-1"
|
||||
/>
|
||||
<span class="text-xl">{{ amount }}</span>
|
||||
</div>
|
||||
<Button
|
||||
:severity="
|
||||
preselectedAmountOption === amount ? 'primary' : 'secondary'
|
||||
"
|
||||
:outlined="preselectedAmountOption !== amount"
|
||||
:label="$t('credits.topUp.buyNow')"
|
||||
@click="handleBuyNow(amount)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag
|
||||
severity="secondary"
|
||||
icon="pi pi-dollar"
|
||||
rounded
|
||||
class="text-amber-400 p-1"
|
||||
/>
|
||||
<InputNumber
|
||||
v-model="customAmount"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
:step="1"
|
||||
show-buttons
|
||||
:allow-empty="false"
|
||||
:highlight-on-focus="true"
|
||||
pt:pc-input-text:root="w-24"
|
||||
@blur="
|
||||
(e: InputNumberBlurEvent) => (customAmount = Number(e.value))
|
||||
"
|
||||
@input="
|
||||
(e: InputNumberInputEvent) => (customAmount = Number(e.value))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<ProgressSpinner v-if="loading" class="w-8 h-8" />
|
||||
<Button
|
||||
v-else
|
||||
:label="$t('credits.topUp.buyNow')"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="handleBuyNow(customAmount)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import InputNumber, {
|
||||
type InputNumberBlurEvent,
|
||||
type InputNumberInputEvent
|
||||
} from 'primevue/inputnumber'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import Tag from 'primevue/tag'
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import { useFirebaseAuthService } from '@/services/firebaseAuthService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
const {
|
||||
isInsufficientCredits = false,
|
||||
amountOptions = [5, 10, 20, 50],
|
||||
preselectedAmountOption = 10
|
||||
} = defineProps<{
|
||||
isInsufficientCredits?: boolean
|
||||
amountOptions?: number[]
|
||||
preselectedAmountOption?: number
|
||||
}>()
|
||||
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const authService = useFirebaseAuthService()
|
||||
const customAmount = ref<number>(100)
|
||||
const didClickBuyNow = ref(false)
|
||||
const loading = computed(() => authStore.loading)
|
||||
|
||||
const formattedBalance = computed(() => {
|
||||
if (!authStore.balance) return '0.000'
|
||||
return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
|
||||
})
|
||||
|
||||
const handleSeeDetails = async () => {
|
||||
await authService.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleBuyNow = async (amount: number) => {
|
||||
await authService.purchaseCredits(amount)
|
||||
didClickBuyNow.value = true
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (didClickBuyNow.value) {
|
||||
// If clicked buy now, then returned back to the dialog and closed, fetch the balance
|
||||
void authService.fetchBalance()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<Button
|
||||
@click="openGitHubIssues"
|
||||
:label="$t('g.findIssues')"
|
||||
severity="secondary"
|
||||
icon="pi pi-github"
|
||||
>
|
||||
</Button>
|
||||
@click="openGitHubIssues"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Form } from '@primevue/forms'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
@@ -11,7 +12,7 @@ import { createI18n } from 'vue-i18n'
|
||||
import enMesages from '@/locales/en/main.json'
|
||||
import { IssueReportPanelProps } from '@/types/issueReportTypes'
|
||||
|
||||
import ReportIssuePanel from '../ReportIssuePanel.vue'
|
||||
import ReportIssuePanel from './ReportIssuePanel.vue'
|
||||
|
||||
const DEFAULT_FIELDS = ['Workflow', 'Logs', 'Settings', 'SystemStats']
|
||||
const CUSTOM_FIELDS = [
|
||||
@@ -65,7 +66,12 @@ vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getLogs: vi.fn().mockResolvedValue('mock logs'),
|
||||
getSystemStats: vi.fn().mockResolvedValue('mock stats'),
|
||||
getSettings: vi.fn().mockResolvedValue('mock settings')
|
||||
getSettings: vi.fn().mockResolvedValue('mock settings'),
|
||||
fetchApi: vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
text: vi.fn().mockResolvedValue('')
|
||||
}),
|
||||
apiURL: vi.fn().mockReturnValue('https://test.com')
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -136,15 +142,25 @@ vi.mock('@primevue/forms', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/firebaseAuthStore', () => ({
|
||||
useFirebaseAuthStore: () => ({
|
||||
currentUser: {
|
||||
email: 'test@example.com'
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('ReportIssuePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
const mountComponent = (props: IssueReportPanelProps, options = {}): any => {
|
||||
return mount(ReportIssuePanel, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
plugins: [PrimeVue, i18n, createPinia()],
|
||||
directives: { tooltip: Tooltip }
|
||||
},
|
||||
props,
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<Form
|
||||
v-slot="$form"
|
||||
@submit="submit"
|
||||
:resolver="zodResolver(issueReportSchema)"
|
||||
@submit="submit"
|
||||
>
|
||||
<Panel :pt="$attrs.pt as any">
|
||||
<template #header>
|
||||
@@ -23,75 +23,136 @@
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 mt-2 border border-round surface-border shadow-1">
|
||||
<div class="flex flex-row gap-3 mb-2">
|
||||
<div v-for="field in fields" :key="field.value">
|
||||
<FormField
|
||||
v-if="field.optIn"
|
||||
v-slot="$field"
|
||||
:name="field.value"
|
||||
class="flex space-x-1"
|
||||
<div class="flex flex-col gap-6">
|
||||
<FormField
|
||||
v-slot="$field"
|
||||
name="contactInfo"
|
||||
:initial-value="authStore.currentUser?.email"
|
||||
>
|
||||
<div class="self-stretch inline-flex justify-start items-center">
|
||||
<label for="contactInfo" class="pb-2 pt-0 opacity-80">{{
|
||||
$t('issueReport.email')
|
||||
}}</label>
|
||||
</div>
|
||||
<InputText
|
||||
id="contactInfo"
|
||||
v-bind="$field"
|
||||
class="w-full"
|
||||
:placeholder="$t('issueReport.provideEmail')"
|
||||
/>
|
||||
<Message
|
||||
v-if="$field?.error && $field.touched && $field.value !== ''"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
<Checkbox
|
||||
{{ t('issueReport.validation.invalidEmail') }}
|
||||
</Message>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="$field" name="helpType">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-2.5"
|
||||
>
|
||||
<label for="helpType" class="pb-2 pt-0 opacity-80">{{
|
||||
$t('issueReport.whatDoYouNeedHelpWith')
|
||||
}}</label>
|
||||
</div>
|
||||
<Dropdown
|
||||
v-bind="$field"
|
||||
:inputId="field.value"
|
||||
:value="field.value"
|
||||
v-model="selection"
|
||||
v-model="$field.value"
|
||||
:options="helpTypes"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:placeholder="$t('issueReport.selectIssue')"
|
||||
class="w-full"
|
||||
/>
|
||||
<label :for="field.value">{{ field.label }}</label>
|
||||
<Message
|
||||
v-if="$field?.error"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ t('issueReport.validation.selectIssueType') }}
|
||||
</Message>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-2.5"
|
||||
>
|
||||
<span class="pb-2 pt-0 opacity-80">{{
|
||||
$t('issueReport.whatCanWeInclude')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-row gap-3">
|
||||
<div v-for="field in fields" :key="field.value">
|
||||
<FormField
|
||||
v-if="field.optIn"
|
||||
v-slot="$field"
|
||||
:name="field.value"
|
||||
class="flex space-x-1"
|
||||
>
|
||||
<Checkbox
|
||||
v-bind="$field"
|
||||
v-model="selection"
|
||||
:input-id="field.value"
|
||||
:value="field.value"
|
||||
/>
|
||||
<label :for="field.value">{{ field.label }}</label>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<FormField v-slot="$field" name="details">
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-2.5"
|
||||
>
|
||||
<label for="details" class="pb-2 pt-0 opacity-80">{{
|
||||
$t('issueReport.describeTheProblem')
|
||||
}}</label>
|
||||
</div>
|
||||
<Textarea
|
||||
v-bind="$field"
|
||||
id="details"
|
||||
class="w-full"
|
||||
rows="5"
|
||||
:placeholder="$t('issueReport.provideAdditionalDetails')"
|
||||
:aria-label="$t('issueReport.provideAdditionalDetails')"
|
||||
/>
|
||||
<Message
|
||||
v-if="$field?.error && $field.touched && $field.value"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ t('issueReport.validation.maxLength') }}
|
||||
</Message>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
<FormField class="mb-4" v-slot="$field" name="details">
|
||||
<Textarea
|
||||
v-bind="$field"
|
||||
class="w-full"
|
||||
rows="5"
|
||||
:placeholder="$t('issueReport.provideAdditionalDetails')"
|
||||
:aria-label="$t('issueReport.provideAdditionalDetails')"
|
||||
/>
|
||||
<Message
|
||||
v-if="$field?.error && $field.touched && $field.value"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ t('issueReport.validation.maxLength') }}
|
||||
</Message>
|
||||
</FormField>
|
||||
<FormField v-slot="$field" name="contactInfo">
|
||||
<InputText
|
||||
v-bind="$field"
|
||||
class="w-full"
|
||||
:placeholder="$t('issueReport.provideEmail')"
|
||||
/>
|
||||
<Message
|
||||
v-if="$field?.error && $field.touched && $field.value !== ''"
|
||||
severity="error"
|
||||
size="small"
|
||||
variant="simple"
|
||||
>
|
||||
{{ t('issueReport.validation.invalidEmail') }}
|
||||
</Message>
|
||||
</FormField>
|
||||
|
||||
<div class="flex flex-row gap-3 mt-2">
|
||||
<div v-for="checkbox in contactCheckboxes" :key="checkbox.value">
|
||||
<FormField
|
||||
v-slot="$field"
|
||||
:name="checkbox.value"
|
||||
class="flex space-x-1"
|
||||
>
|
||||
<Checkbox
|
||||
v-bind="$field"
|
||||
:inputId="checkbox.value"
|
||||
:value="checkbox.value"
|
||||
v-model="contactPrefs"
|
||||
:disabled="
|
||||
$form.contactInfo?.error || !$form.contactInfo?.value
|
||||
"
|
||||
/>
|
||||
<label :for="checkbox.value">{{ checkbox.label }}</label>
|
||||
</FormField>
|
||||
<div class="flex flex-col gap-3 mt-2">
|
||||
<div v-for="checkbox in contactCheckboxes" :key="checkbox.value">
|
||||
<FormField
|
||||
v-slot="$field"
|
||||
:name="checkbox.value"
|
||||
class="flex space-x-1"
|
||||
>
|
||||
<Checkbox
|
||||
v-bind="$field"
|
||||
v-model="contactPrefs"
|
||||
:input-id="checkbox.value"
|
||||
:value="checkbox.value"
|
||||
:disabled="
|
||||
$form.contactInfo?.error || !$form.contactInfo?.value
|
||||
"
|
||||
/>
|
||||
<label :for="checkbox.value">{{ checkbox.label }}</label>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,7 +162,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Form, FormField, type FormSubmitEvent } from '@primevue/forms'
|
||||
// @ts-expect-error https://github.com/primefaces/primevue/issues/6722
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import type { CaptureContext, User } from '@sentry/core'
|
||||
import { captureMessage } from '@sentry/core'
|
||||
@@ -109,6 +169,7 @@ import _ from 'lodash'
|
||||
import cloneDeep from 'lodash/cloneDeep'
|
||||
import Button from 'primevue/button'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import Panel from 'primevue/panel'
|
||||
@@ -123,14 +184,16 @@ import {
|
||||
} from '@/schemas/issueReportSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type {
|
||||
DefaultField,
|
||||
IssueReportPanelProps,
|
||||
ReportField
|
||||
} from '@/types/issueReportTypes'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
import { generateUUID } from '@/utils/formatUtil'
|
||||
|
||||
const ISSUE_NAME = 'User reported issue'
|
||||
const DEFAULT_ISSUE_NAME = 'User reported issue'
|
||||
|
||||
const props = defineProps<IssueReportPanelProps>()
|
||||
const { defaultFields = ['Workflow', 'Logs', 'SystemStats', 'Settings'] } =
|
||||
@@ -138,6 +201,7 @@ const { defaultFields = ['Workflow', 'Logs', 'SystemStats', 'Settings'] } =
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const authStore = useFirebaseAuthStore()
|
||||
|
||||
const selection = ref<string[]>([])
|
||||
const contactPrefs = ref<string[]>([])
|
||||
@@ -148,6 +212,20 @@ const contactCheckboxes = [
|
||||
{ label: t('issueReport.notifyResolve'), value: 'notifyOnResolution' }
|
||||
]
|
||||
|
||||
const helpTypes = [
|
||||
{
|
||||
label: t('issueReport.helpTypes.billingPayments'),
|
||||
value: 'billingPayments'
|
||||
},
|
||||
{
|
||||
label: t('issueReport.helpTypes.loginAccessIssues'),
|
||||
value: 'loginAccessIssues'
|
||||
},
|
||||
{ label: t('issueReport.helpTypes.giveFeedback'), value: 'giveFeedback' },
|
||||
{ label: t('issueReport.helpTypes.bugReport'), value: 'bugReport' },
|
||||
{ label: t('issueReport.helpTypes.somethingElse'), value: 'somethingElse' }
|
||||
]
|
||||
|
||||
const defaultFieldsConfig: ReportField[] = [
|
||||
{
|
||||
label: t('issueReport.systemStats'),
|
||||
@@ -214,6 +292,7 @@ const createCaptureContext = async (
|
||||
level: 'error',
|
||||
tags: {
|
||||
errorType: props.errorType,
|
||||
helpType: formData.helpType,
|
||||
followUp: formData.contactInfo ? formData.followUp : false,
|
||||
notifyOnResolution: formData.contactInfo
|
||||
? formData.notifyOnResolution
|
||||
@@ -228,11 +307,24 @@ const createCaptureContext = async (
|
||||
}
|
||||
}
|
||||
|
||||
const generateUniqueTicketId = (type: string) => `${type}-${generateUUID()}`
|
||||
|
||||
const submit = async (event: FormSubmitEvent) => {
|
||||
if (event.valid) {
|
||||
try {
|
||||
const captureContext = await createCaptureContext(event.values)
|
||||
captureMessage(ISSUE_NAME, captureContext)
|
||||
|
||||
// If it's billing or access issue, generate unique id to be used by customer service ticketing
|
||||
const isValidContactInfo = event.values.contactInfo?.length
|
||||
const isCustomerServiceIssue =
|
||||
isValidContactInfo &&
|
||||
['billingPayments', 'loginAccessIssues'].includes(
|
||||
event.values.helpType || ''
|
||||
)
|
||||
const issueName = isCustomerServiceIssue
|
||||
? `ticket-${generateUniqueTicketId(event.values.helpType || '')}`
|
||||
: DEFAULT_ISSUE_NAME
|
||||
captureMessage(issueName, captureContext)
|
||||
submitted.value = true
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
<div class="flex flex-1 relative overflow-hidden">
|
||||
<ManagerNavSidebar
|
||||
v-if="isSideNavOpen"
|
||||
:tabs="tabs"
|
||||
v-model:selectedTab="selectedTab"
|
||||
:tabs="tabs"
|
||||
/>
|
||||
<div
|
||||
class="flex-1 overflow-auto pr-80"
|
||||
@@ -29,7 +29,8 @@
|
||||
<RegistrySearchBar
|
||||
v-model:searchQuery="searchQuery"
|
||||
v-model:searchMode="searchMode"
|
||||
:searchResults="searchResults"
|
||||
v-model:sortField="sortField"
|
||||
:search-results="searchResults"
|
||||
:suggestions="suggestions"
|
||||
/>
|
||||
<div class="flex-1 overflow-auto">
|
||||
@@ -56,16 +57,16 @@
|
||||
<VirtualGrid
|
||||
:items="resultsWithKeys"
|
||||
:buffer-rows="3"
|
||||
:gridStyle="GRID_STYLE"
|
||||
:grid-style="GRID_STYLE"
|
||||
@approach-end="onApproachEnd"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<PackCard
|
||||
@click.stop="(event) => selectNodePack(item, event)"
|
||||
:node-pack="item"
|
||||
:is-selected="
|
||||
selectedNodePacks.some((pack) => pack.id === item.id)
|
||||
"
|
||||
@click.stop="(event) => selectNodePack(item, event)"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
@@ -73,9 +74,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="w-80 border-l-0 border-surface-border absolute right-0 top-0 bottom-0 flex z-20"
|
||||
>
|
||||
<div class="w-80 border-l-0 absolute right-0 top-0 bottom-0 flex z-20">
|
||||
<ContentDivider orientation="vertical" :width="0.2" />
|
||||
<div class="flex-1 flex flex-col isolate">
|
||||
<InfoPanel
|
||||
@@ -166,6 +165,7 @@ const {
|
||||
isLoading: isSearchLoading,
|
||||
searchResults,
|
||||
searchMode,
|
||||
sortField,
|
||||
suggestions
|
||||
} = useRegistrySearch()
|
||||
pageNumber.value = 0
|
||||
@@ -222,13 +222,13 @@ const filterOutdatedPacks = (packs: components['schemas']['Node'][]) =>
|
||||
|
||||
watch(
|
||||
[isUpdateAvailableTab, installedPacks],
|
||||
() => {
|
||||
async () => {
|
||||
if (!isUpdateAvailableTab.value) return
|
||||
|
||||
if (!isEmptySearch.value) {
|
||||
displayPacks.value = filterOutdatedPacks(installedPacks.value)
|
||||
} else if (!installedPacks.value.length) {
|
||||
startFetchInstalled()
|
||||
await startFetchInstalled()
|
||||
} else {
|
||||
displayPacks.value = filterOutdatedPacks(installedPacks.value)
|
||||
}
|
||||
@@ -238,7 +238,7 @@ watch(
|
||||
|
||||
watch(
|
||||
[isInstalledTab, installedPacks],
|
||||
() => {
|
||||
async () => {
|
||||
if (!isInstalledTab.value) return
|
||||
|
||||
if (!isEmptySearch.value) {
|
||||
@@ -248,7 +248,7 @@ watch(
|
||||
!installedPacksReady.value &&
|
||||
!isLoadingInstalled.value
|
||||
) {
|
||||
startFetchInstalled()
|
||||
await startFetchInstalled()
|
||||
} else {
|
||||
displayPacks.value = installedPacks.value
|
||||
}
|
||||
@@ -258,7 +258,7 @@ watch(
|
||||
|
||||
watch(
|
||||
[isMissingTab, isWorkflowTab, workflowPacks, installedPacks],
|
||||
() => {
|
||||
async () => {
|
||||
if (!isWorkflowTab.value && !isMissingTab.value) return
|
||||
|
||||
if (!isEmptySearch.value) {
|
||||
@@ -270,9 +270,9 @@ watch(
|
||||
!isLoadingWorkflow.value &&
|
||||
!workflowPacksReady.value
|
||||
) {
|
||||
startFetchWorkflowPacks()
|
||||
await startFetchWorkflowPacks()
|
||||
if (isMissingTab.value) {
|
||||
startFetchInstalled()
|
||||
await startFetchInstalled()
|
||||
}
|
||||
} else {
|
||||
displayPacks.value = isMissingTab.value
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<Listbox
|
||||
v-model="selectedTab"
|
||||
:options="tabs"
|
||||
optionLabel="label"
|
||||
listStyle="max-height:unset"
|
||||
option-label="label"
|
||||
list-style="max-height:unset"
|
||||
class="w-full border-0 bg-transparent shadow-none"
|
||||
:pt="{
|
||||
list: { class: 'p-5' },
|
||||
@@ -17,7 +17,7 @@
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="text-left flex items-center">
|
||||
<i :class="['pi', slotProps.option.icon, 'mr-3']"></i>
|
||||
<i :class="['pi', slotProps.option.icon, 'mr-3']" />
|
||||
<span class="text-lg">{{ slotProps.option.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<i
|
||||
class="pi pi-circle-fill mr-1.5 text-[0.6rem] p-0"
|
||||
:style="{ opacity: 0.8 }"
|
||||
></i>
|
||||
/>
|
||||
{{ $t(`manager.status.${statusLabel}`) }}
|
||||
</Message>
|
||||
</template>
|
||||
|
||||
@@ -9,8 +9,8 @@ import { createI18n } from 'vue-i18n'
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import { SelectedVersion } from '@/types/comfyManagerTypes'
|
||||
|
||||
import PackVersionBadge from '../PackVersionBadge.vue'
|
||||
import PackVersionSelectorPopover from '../PackVersionSelectorPopover.vue'
|
||||
import PackVersionBadge from './PackVersionBadge.vue'
|
||||
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
|
||||
|
||||
const mockNodePack = {
|
||||
id: 'test-pack',
|
||||
@@ -10,7 +10,7 @@ import { createI18n } from 'vue-i18n'
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import { SelectedVersion } from '@/types/comfyManagerTypes'
|
||||
|
||||
import PackVersionSelectorPopover from '../PackVersionSelectorPopover.vue'
|
||||
import PackVersionSelectorPopover from './PackVersionSelectorPopover.vue'
|
||||
|
||||
// Default mock versions for reference
|
||||
const defaultMockVersions = [
|
||||