Compare commits

...

43 Commits

Author SHA1 Message Date
pythongosssss
02c13c403f Safety mechanism to prevent loading everything 2024-11-24 11:44:26 +00:00
pythongosssss
8254e3c9cf Fix number of items shown when loading more
Fix number of items shown on status update
2024-11-24 11:37:02 +00:00
Chenlei Hu
1160231b62 1.4.9 (#1661) 2024-11-23 17:49:38 -05:00
Chenlei Hu
a51e27bedf chore: update litegraph to 0.8.35 (#1662) 2024-11-23 17:49:27 -05:00
filtered
abed0656af Add Fit Group to Contents keybind (#1658)
* Add Fit Group to Nodes keyboard command

Fits all selected groups.

* nit - Rename

* Move to commandStore & Playwright test

* nit

* nit

* Update test expectations [skip ci]

---------

Co-authored-by: huchenlei <huchenlei@proton.me>
Co-authored-by: github-actions <github-actions@github.com>
2024-11-23 17:15:52 -05:00
Terry Jia
5febda16c7 fix bug and allow restore previous node size (#1659) 2024-11-23 10:56:59 -05:00
Chenlei Hu
069dc67c30 Reland "Fix undo / redo filling with empty steps" (#1653)
* Revert "Revert "Fix undo / redo filling with empty steps (#1649)" (#1652)"

This reverts commit 7623810166.

* Update test expectations

* Add dirty flag if workflow is not persisted

* Add dirty flag to other UI areas for new workflows

* Remove redundant code

* Fix regression: undo / redo steps lost on refresh

The history is still be cleared, but any changes made by issuing undo / redo comands prior to refresh are not lost.

* Update test expectations

Partially reverts f8cc2c0d67 - adds dirty flags back to unsaved workflows.

---------

Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2024-11-23 09:49:12 -05:00
Chenlei Hu
7623810166 Revert "Fix undo / redo filling with empty steps (#1649)" (#1652)
This reverts commit ad2c1a0d3e.
2024-11-22 22:02:56 -05:00
Chenlei Hu
21fa88461f [Electron][skip ci] Update install disk space requirement to 15GB (#1651) 2024-11-22 21:59:46 -05:00
Chenlei Hu
27b0493306 Move files to constants/ (#1650) 2024-11-22 21:55:44 -05:00
filtered
ad2c1a0d3e Fix undo / redo filling with empty steps (#1649) 2024-11-22 21:49:13 -05:00
Robin Huang
f51866d988 [desktop] Update crash report description (#1646)
* Update crash report descripton

* Update settings description.
2024-11-22 21:42:55 -05:00
Chenlei Hu
46627bb44b Remove host and port from server config panel (#1648) 2024-11-22 21:40:15 -05:00
Chenlei Hu
68cadbda9f 1.4.8 (#1647) 2024-11-22 20:36:56 -05:00
pythongosssss
0f2260065a [Electron] Allow users to submit error reports (#1633)
* Allows users to submit error reports

* Text change

* Add tooltip, change severity on submit
Remove unused import
2024-11-22 17:04:51 -05:00
Chenlei Hu
4007cc13c2 [Electron] ComfyUI server config (Launch args config) (#1644)
* Remove electron adapter server args

* Add server args typing

* Add server config constant file

* Tooltip to name; name to id

* Capitalize category

* Server config store

* Prevent default value

* Add serverconfig test

* Guard server config panel with electron flag

* Filter nullish values from server args

* Use slider for preview size
2024-11-22 16:50:24 -05:00
Chenlei Hu
3920210c5c Remove Ctrl+D keybinding (#1643) 2024-11-22 11:17:36 -05:00
Chenlei Hu
4e22bffae2 chore: update litegraph to 0.8.34 (#1642) 2024-11-22 11:03:02 -05:00
Chenlei Hu
462a131557 1.4.7 (#1638) 2024-11-21 17:12:14 -08:00
Chenlei Hu
ec01a04786 Hint shift to queue front on queue button tooltip (#1634) 2024-11-21 15:18:20 -05:00
Chenlei Hu
4c48241e19 Update litegraph 0.8.33 (#1632)
* chore: update litegraph to 0.8.33

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-11-21 14:57:21 -05:00
Chenlei Hu
886c40a69a Fix keybinding conflict (#1630) 2024-11-21 11:49:57 -05:00
Chenlei Hu
479d1b28c7 Update litegraph (Global snap to grid setting) (#1629) 2024-11-21 10:30:54 -05:00
Tristan Sommer
c41b57128a maskEditor UI interface revamp + brush smoothing precision adjustment (#1626) 2024-11-21 09:39:53 -05:00
Chenlei Hu
5d178a407d [chore] Update comfyui-electron-types (#1625)
* Remove electron external dep

* [chore] Update comfyui-electron-types
2024-11-21 00:07:50 -05:00
Chenlei Hu
73b7606f6e 1.4.6 (#1622) 2024-11-20 20:36:18 -05:00
oto-ciulis-tt
94f5031f0d feat: Update Electron Download types (#1621)
* feat: Update Electron Download types

* Fix vite rollup

---------

Co-authored-by: Oto Ciulis <oto.ciulis@gmail.com>
Co-authored-by: huchenlei <huchenlei@proton.me>
2024-11-20 20:34:27 -05:00
oto-ciulis-tt
c857e7d98c feat: #270 Improve error view (#1617)
* feat: #270 Improve error view

Reverting change

Lint & Format

PR comments

Fixing typo

* nit

---------

Co-authored-by: Oto Ciulis <oto.ciulis@gmail.com>
Co-authored-by: huchenlei <huchenlei@proton.me>
2024-11-20 16:35:14 -05:00
pythongosssss
d5b8a555d9 [Electron] xterm startup logs (#1620)
* Add live terminal output

* Fix scrolling

* Refactor loading

* Fallback to polling if endpoint fails

* Comment

* Move clientId to executionStore
Refactor types

* Remove polling

* wip terminal command input

* Refactor to use node-pty

* Hide tabs if not electron

* Lint fix

* ts fix

* Refactor tab components

* Use xterm for startup logs

* Nicer logs display

* Fix not setting xterm + mark terminal as raw
2024-11-20 16:09:54 -05:00
Chenlei Hu
f34d50da3d [Refactor] Extract 'FormItem' and 'SettingItem' (#1619)
* Extract SettingItem component

* Extract GeneralSettingItem

* Rename to FormItem

* nit

* nit
2024-11-20 15:10:17 -05:00
Chenlei Hu
4f3693e322 Reland 'Bind Ctrl+s to Comfy.SaveWorkflow' (#1618) 2024-11-20 15:01:04 -05:00
Terry Jia
431ad7d27f allow render depth directly (#1610) 2024-11-20 09:36:44 -05:00
Chenlei Hu
0c97b09a5a 1.4.5 (#1616) 2024-11-20 09:35:58 -05:00
Chenlei Hu
bdb9f0d845 chore: update litegraph to 0.8.31 (#1615) 2024-11-20 09:33:25 -05:00
Chenlei Hu
77b85acdd5 Revert "Bind Ctrl+s to Comfy.SaveWorkflow (#1599)" (#1614)
This reverts commit 0058691579.
2024-11-20 09:27:01 -05:00
Chenlei Hu
8906f5c26e Add Comfy-Desktop.ComfyServer.ExtraLaunchArgs (#1609) 2024-11-19 20:44:26 -05:00
Chenlei Hu
81194cc7fe 1.4.4 (#1608) 2024-11-19 19:59:22 -05:00
Chenlei Hu
f4b972fab5 chore: update litegraph to 0.8.30 (#1607) 2024-11-19 19:52:01 -05:00
Terry Jia
3aa1c03566 better support for animation (#1606) 2024-11-19 18:25:58 -05:00
Chenlei Hu
600b7f93e5 [Electron] Add missing i18n items (#1605) 2024-11-19 15:31:57 -05:00
Chenlei Hu
2a7df57404 Fix always snap to grid (#1604) 2024-11-19 12:10:40 -05:00
Chenlei Hu
6352cd86ee Show confirm dialog on workflow path conflict (Save As) (#1590)
* Show confirm dialog on workflow path conflict (Save As)

* Fix closeworkflow

* nit

* Add playwright tests

* nit

* nit

* Move workflows dir cleanup
2024-11-18 23:07:24 -05:00
Chenlei Hu
0058691579 Bind Ctrl+s to Comfy.SaveWorkflow (#1599) 2024-11-18 23:07:11 -05:00
51 changed files with 2934 additions and 1204 deletions

View File

@@ -0,0 +1,90 @@
{
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 3,
"type": "KSampler",
"pos": [
37,
98
],
"size": [
315,
262
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
]
}
],
"links": [],
"groups": [
{
"id": 1,
"title": "Group",
"bounding": [
23,
23,
900,
825
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

View File

@@ -11,7 +11,7 @@ test.describe('Browser tab title', () => {
const workflowName = await comfyPage.page.evaluate(async () => {
return window['app'].extensionManager.workflow.activeWorkflow.filename
})
expect(await comfyPage.page.title()).toBe(`${workflowName} - ComfyUI`)
expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`)
})
// Failing on CI

View File

@@ -56,9 +56,7 @@ test.describe('Change Tracker', () => {
expect(await comfyPage.getToastErrorCount()).toBe(0)
expect(await isModified()).toBe(false)
// TODO(huchenlei): Investigate why saving the workflow is causing the
// undo queue to be triggered.
expect(await getUndoQueueSize()).toBe(1)
expect(await getUndoQueueSize()).toBe(0)
expect(await getRedoQueueSize()).toBe(0)
const node = (await comfyPage.getFirstNodeRef())!
@@ -66,25 +64,25 @@ test.describe('Change Tracker', () => {
await node.click('collapse')
await expect(node).toBeCollapsed()
expect(await isModified()).toBe(true)
expect(await getUndoQueueSize()).toBe(2)
expect(await getUndoQueueSize()).toBe(1)
expect(await getRedoQueueSize()).toBe(0)
await comfyPage.ctrlB()
await expect(node).toBeBypassed()
expect(await isModified()).toBe(true)
expect(await getUndoQueueSize()).toBe(3)
expect(await getUndoQueueSize()).toBe(2)
expect(await getRedoQueueSize()).toBe(0)
await comfyPage.ctrlZ()
await expect(node).not.toBeBypassed()
expect(await isModified()).toBe(true)
expect(await getUndoQueueSize()).toBe(2)
expect(await getUndoQueueSize()).toBe(1)
expect(await getRedoQueueSize()).toBe(1)
await comfyPage.ctrlZ()
await expect(node).not.toBeCollapsed()
expect(await isModified()).toBe(false)
expect(await getUndoQueueSize()).toBe(1)
expect(await getUndoQueueSize()).toBe(0)
expect(await getRedoQueueSize()).toBe(2)
})
})

View File

@@ -737,6 +737,19 @@ export class ComfyPage {
)
}
async confirmDialog(prompt: string, text: string = 'Yes') {
const modal = this.page.locator(
`.comfy-modal-content:has-text("${prompt}")`
)
await expect(modal).toBeVisible()
await modal
.locator('.comfyui-button', {
hasText: text
})
.click()
await expect(modal).toBeHidden()
}
async convertAllNodesToGroupNode(groupNodeName: string) {
this.page.on('dialog', async (dialog) => {
await dialog.accept(groupNodeName)

View File

@@ -103,6 +103,12 @@ export class WorkflowsSidebarTab extends SidebarTab {
.allInnerTexts()
}
async getActiveWorkflowName() {
return await this.page
.locator('.comfyui-workflows-open .p-tree-node-selected .node-label')
.innerText()
}
async getTopLevelSavedWorkflowNames() {
return await this.page
.locator('.comfyui-workflows-browse .node-label')

View File

@@ -320,6 +320,15 @@ test.describe('Node Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('group-selected-nodes.png')
})
test('Can fit group to contents', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('oversized_group')
await comfyPage.ctrlA()
await comfyPage.nextFrame()
await comfyPage.executeCommand('Comfy.Graph.FitGroupToContents')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('group-fit-to-contents.png')
})
// Somehow this test fails on GitHub Actions. It works locally.
// https://github.com/Comfy-Org/ComfyUI_frontend/pull/736
test.skip('Can pin/unpin nodes with keyboard shortcut', async ({

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -379,7 +379,9 @@ test.describe('Menu', () => {
// Open the sidebar
const tab = comfyPage.menu.workflowsTab
await tab.open()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setupWorkflowsDirectory({})
})
@@ -392,7 +394,7 @@ test.describe('Menu', () => {
await tab.newBlankWorkflowButton.click()
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'Unsaved Workflow (2).json'
'*Unsaved Workflow (2).json'
])
})
@@ -450,6 +452,44 @@ test.describe('Menu', () => {
).toEqual(['*Unsaved Workflow.json', 'workflow3.json', 'workflow4.json'])
})
test('Can save workflow as with same name', async ({ comfyPage }) => {
await comfyPage.menu.topbar.saveWorkflow('workflow5.json')
expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
).toEqual(['workflow5.json'])
await comfyPage.menu.topbar.saveWorkflowAs('workflow5.json')
await comfyPage.confirmDialog('Overwrite existing file?', 'Yes')
expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
).toEqual(['workflow5.json'])
})
test('Can overwrite other workflows with save as', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.saveWorkflow('workflow1.json')
await topbar.saveWorkflowAs('workflow2.json')
await comfyPage.nextFrame()
expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
).toEqual(['workflow1.json', 'workflow2.json'])
expect(await comfyPage.menu.workflowsTab.getActiveWorkflowName()).toEqual(
'workflow2.json'
)
await topbar.saveWorkflowAs('workflow1.json')
await comfyPage.confirmDialog('Overwrite existing file?', 'Yes')
// The old workflow1.json should be deleted and the new one should be saved.
expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
).toEqual(['workflow2.json', 'workflow1.json'])
expect(await comfyPage.menu.workflowsTab.getActiveWorkflowName()).toEqual(
'workflow1.json'
)
})
test('Does not report warning when switching between opened workflows', async ({
comfyPage
}) => {
@@ -475,12 +515,12 @@ test.describe('Menu', () => {
`tempWorkflow-${test.info().title}`
)
const closeButton = comfyPage.page.locator(
'.comfyui-workflows-open .p-button-icon.pi-times'
'.comfyui-workflows-open .close-workflow-button'
)
await closeButton.click()
expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
).toEqual(['Unsaved Workflow.json'])
).toEqual(['*Unsaved Workflow.json'])
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 96 KiB

20
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{
"name": "comfyui-frontend",
"version": "1.4.3",
"version": "1.4.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "comfyui-frontend",
"version": "1.4.3",
"version": "1.4.9",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.2.16",
"@comfyorg/litegraph": "^0.8.29",
"@comfyorg/comfyui-electron-types": "^0.3.6",
"@comfyorg/litegraph": "^0.8.35",
"@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0",
"@xterm/addon-fit": "^0.10.0",
@@ -1917,15 +1917,15 @@
"dev": true
},
"node_modules/@comfyorg/comfyui-electron-types": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.2.16.tgz",
"integrity": "sha512-Hm6NeyMK4sd2V5AyOnvfI+tvCsXr5NBG8wOZlWyyD17ADpbQnpm6qPMWzvm4vCp/YvTR7cUbDGiY0quhofuQGg==",
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.3.6.tgz",
"integrity": "sha512-wgMgESnCcRzvVkk8CwWiTAUJxC4LBvw5uTENxzaWkEL0qrnmiGrVLore00yX3cYz04hJaTA6PqasLqgVLDOenw==",
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.8.29",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.29.tgz",
"integrity": "sha512-h7c+sW/BEAPfWSDYATNk2YtB1kduQ0v85z2Rq8q11UoLHRkTa0A1zosDFSwIdCul/prdG51wbXr99EHq3Dsfnw==",
"version": "0.8.35",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.35.tgz",
"integrity": "sha512-taxjPoNJLajZa3z3JSxwgArRIi5lYy3nlkmemup8bo0AtC7QpKOOE+xQ5wtSXcSMZZMxbsgQHp7FoBTeIUHngA==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.4.3",
"version": "1.4.9",
"type": "module",
"scripts": {
"dev": "vite",
@@ -72,8 +72,8 @@
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.2.16",
"@comfyorg/litegraph": "^0.8.29",
"@comfyorg/comfyui-electron-types": "^0.3.6",
"@comfyorg/litegraph": "^0.8.35",
"@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0",
"@xterm/addon-fit": "^0.10.0",

View File

@@ -26,7 +26,10 @@ const betaMenuEnabled = computed(
const workflowStore = useWorkflowStore()
const isUnsavedText = computed(() =>
workflowStore.activeWorkflow?.isModified ? ' *' : ''
workflowStore.activeWorkflow?.isModified ||
!workflowStore.activeWorkflow?.isPersisted
? ' *'
: ''
)
const workflowNameText = computed(() => {
const workflowName = workflowStore.activeWorkflow?.filename

View File

@@ -0,0 +1,102 @@
<!-- A generalized form item for rendering in a form. -->
<template>
<div class="form-label flex flex-grow items-center">
<span class="text-[var(--p-text-muted-color)]">
<slot name="name-prefix"></slot>
{{ props.item.name }}
<i
v-if="props.item.tooltip"
class="pi pi-info-circle bg-transparent"
v-tooltip="props.item.tooltip"
/>
<slot name="name-suffix"></slot>
</span>
</div>
<div class="form-input flex justify-end">
<component
:is="markRaw(getFormComponent(props.item))"
:id="props.id"
v-model:modelValue="formValue"
v-bind="getFormAttrs(props.item)"
/>
</div>
</template>
<script setup lang="ts">
import { FormItem } from '@/types/settingTypes'
import { markRaw, type Component } from 'vue'
import InputText from 'primevue/inputtext'
import InputNumber from 'primevue/inputnumber'
import Select from 'primevue/select'
import ToggleSwitch from 'primevue/toggleswitch'
import CustomFormValue from '@/components/common/CustomFormValue.vue'
import InputSlider from '@/components/common/InputSlider.vue'
const formValue = defineModel<any>('formValue')
const props = withDefaults(
defineProps<{
item: FormItem
id: string | undefined
}>(),
{
id: undefined
}
)
function getFormAttrs(item: FormItem) {
const attrs = { ...(item.attrs || {}) }
const inputType = item.type
if (typeof inputType === 'function') {
attrs['renderFunction'] = () =>
inputType(
props.item.name,
(v: any) => (formValue.value = v),
formValue.value,
item.attrs
)
}
switch (item.type) {
case 'combo':
attrs['options'] =
typeof item.options === 'function'
? item.options(formValue.value)
: item.options
if (typeof item.options[0] !== 'string') {
attrs['optionLabel'] = 'text'
attrs['optionValue'] = 'value'
}
break
}
return attrs
}
function getFormComponent(item: FormItem): Component {
if (typeof item.type === 'function') {
return CustomFormValue
}
switch (item.type) {
case 'boolean':
return ToggleSwitch
case 'number':
return InputNumber
case 'slider':
return InputSlider
case 'combo':
return Select
default:
return InputText
}
}
</script>
<style scoped>
.form-input :deep(.input-slider) .p-inputnumber input,
.form-input :deep(.input-slider) .slider-part {
@apply w-20;
}
.form-input :deep(.p-inputtext),
.form-input :deep(.p-select) {
@apply w-44;
}
</style>

View File

@@ -20,6 +20,7 @@
</template>
<div class="action-container">
<ReportIssueButton v-if="showSendError" :error="props.error" />
<FindIssueButton
:errorMessage="props.error.exception_message"
:repoOwner="repoOwner"
@@ -44,9 +45,11 @@ import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
import ReportIssueButton from '@/components/dialog/content/error/ReportIssueButton.vue'
import type { ExecutionErrorWsMessage, SystemStats } from '@/types/apiTypes'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { isElectron } from '@/utils/envUtil'
const props = defineProps<{
error: ExecutionErrorWsMessage
@@ -59,6 +62,7 @@ const reportOpen = ref(false)
const showReport = () => {
reportOpen.value = true
}
const showSendError = isElectron()
const toast = useToast()
const { copy, isSupported } = useClipboard()

View File

@@ -71,6 +71,14 @@
</template>
</Suspense>
</TabPanel>
<TabPanel key="server-config" value="Server-Config">
<Suspense>
<ServerConfigPanel />
<template #fallback>
<div>Loading server config panel...</div>
</template>
</Suspense>
</TabPanel>
</TabPanels>
</Tabs>
</ScrollPanel>
@@ -93,6 +101,7 @@ import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { flattenTree } from '@/utils/treeUtil'
import AboutPanel from './setting/AboutPanel.vue'
import FirstTimeUIMessage from './setting/FirstTimeUIMessage.vue'
import { isElectron } from '@/utils/envUtil'
const KeybindingPanel = defineAsyncComponent(
() => import('./setting/KeybindingPanel.vue')
@@ -100,6 +109,9 @@ const KeybindingPanel = defineAsyncComponent(
const ExtensionPanel = defineAsyncComponent(
() => import('./setting/ExtensionPanel.vue')
)
const ServerConfigPanel = defineAsyncComponent(
() => import('./setting/ServerConfigPanel.vue')
)
interface ISettingGroup {
label: string
@@ -124,18 +136,33 @@ const extensionPanelNode: SettingTreeNode = {
children: []
}
const serverConfigPanelNode: SettingTreeNode = {
key: 'server-config',
label: 'Server-Config',
children: []
}
const extensionPanelNodeList = computed<SettingTreeNode[]>(() => {
const settingStore = useSettingStore()
const showExtensionPanel = settingStore.get('Comfy.Settings.ExtensionPanel')
return showExtensionPanel ? [extensionPanelNode] : []
})
/**
* 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 categories = computed<SettingTreeNode[]>(() => [
...(settingRoot.value.children || []),
keybindingPanelNode,
...extensionPanelNodeList.value,
...serverConfigPanelNodeList.value,
aboutPanelNode
])
const activeCategory = ref<SettingTreeNode | null>(null)

View File

@@ -0,0 +1,51 @@
<template>
<Button
@click="reportIssue"
:label="$t('reportIssue')"
:severity="submitted ? 'success' : 'secondary'"
:icon="icon"
:disabled="submitted"
v-tooltip="$t('reportIssueTooltip')"
>
</Button>
</template>
<script setup lang="ts">
import { computed, ref, defineProps } from 'vue'
import Button from 'primevue/button'
import { useToast } from 'primevue/usetoast'
import { ExecutionErrorWsMessage } from '@/types/apiTypes'
import { useI18n } from 'vue-i18n'
import { electronAPI } from '@/utils/envUtil'
const { error } = defineProps<{
error: ExecutionErrorWsMessage
}>()
const { t } = useI18n()
const toast = useToast()
const submitting = ref(false)
const submitted = ref(false)
const icon = computed(
() => `pi ${submitting.value ? 'pi-spin pi-spinner' : 'pi-send'}`
)
const reportIssue = async () => {
if (submitting.value) return
submitting.value = true
try {
await electronAPI().sendErrorToSentry(error.exception_message, {
stackTrace: error.traceback?.join('\n'),
nodeType: error.node_type
})
submitted.value = true
toast.add({
severity: 'success',
summary: t('reportSent'),
life: 3000
})
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,43 @@
<template>
<div
v-for="([label, items], i) in Object.entries(serverConfigsByCategory)"
:key="label"
>
<Divider v-if="i > 0" />
<h3>{{ formatCamelCase(label) }}</h3>
<div v-for="item in items" :key="item.name" class="flex items-center mb-4">
<FormItem :item="item" v-model:formValue="item.value" />
</div>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import FormItem from '@/components/common/FormItem.vue'
import { formatCamelCase } from '@/utils/formatUtil'
import { useSettingStore } from '@/stores/settingStore'
import { useServerConfigStore } from '@/stores/serverConfigStore'
import { storeToRefs } from 'pinia'
import { onMounted, watch } from 'vue'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
const settingStore = useSettingStore()
const serverConfigStore = useServerConfigStore()
const { serverConfigsByCategory, launchArgs, serverConfigValues } =
storeToRefs(serverConfigStore)
onMounted(() => {
serverConfigStore.loadServerConfig(
SERVER_CONFIG_ITEMS,
settingStore.get('Comfy.Server.ServerConfigValues')
)
})
watch(launchArgs, (newVal) => {
settingStore.set('Comfy.Server.LaunchArgs', newVal)
})
watch(serverConfigValues, (newVal) => {
settingStore.set('Comfy.Server.ServerConfigValues', newVal)
})
</script>

View File

@@ -7,45 +7,15 @@
:key="setting.id"
class="setting-item flex items-center mb-4"
>
<div class="setting-label flex flex-grow items-center">
<span class="text-[var(--p-text-muted-color)]">
<Tag v-if="setting.experimental" :value="$t('experimental')" />
<Tag
v-if="setting.deprecated"
:value="$t('deprecated')"
severity="danger" />
{{ setting.name }}
<i
v-if="setting.tooltip"
class="pi pi-info-circle bg-transparent"
v-tooltip="setting.tooltip"
/></span>
</div>
<div class="setting-input flex justify-end">
<component
:is="markRaw(getSettingComponent(setting))"
:id="setting.id"
:modelValue="settingStore.get(setting.id)"
@update:modelValue="updateSetting(setting, $event)"
v-bind="getSettingAttrs(setting)"
/>
</div>
<SettingItem :setting="setting" />
</div>
</div>
</template>
<script setup lang="ts">
import { useSettingStore } from '@/stores/settingStore'
import { SettingParams } from '@/types/settingTypes'
import { markRaw, type Component } from 'vue'
import InputText from 'primevue/inputtext'
import InputNumber from 'primevue/inputnumber'
import Select from 'primevue/select'
import ToggleSwitch from 'primevue/toggleswitch'
import Divider from 'primevue/divider'
import Tag from 'primevue/tag'
import CustomSettingValue from '@/components/dialog/content/setting/CustomSettingValue.vue'
import InputSlider from '@/components/common/InputSlider.vue'
import SettingItem from '@/components/dialog/content/setting/SettingItem.vue'
import { SettingParams } from '@/types/settingTypes'
import { formatCamelCase } from '@/utils/formatUtil'
defineProps<{
@@ -55,67 +25,4 @@ defineProps<{
}
divider?: boolean
}>()
const settingStore = useSettingStore()
function getSettingAttrs(setting: SettingParams) {
const attrs = { ...(setting.attrs || {}) }
const settingType = setting.type
if (typeof settingType === 'function') {
attrs['renderFunction'] = () =>
settingType(
setting.name,
(v) => updateSetting(setting, v),
settingStore.get(setting.id),
setting.attrs
)
}
switch (setting.type) {
case 'combo':
attrs['options'] =
typeof setting.options === 'function'
? setting.options(settingStore.get(setting.id))
: setting.options
if (typeof setting.options[0] !== 'string') {
attrs['optionLabel'] = 'text'
attrs['optionValue'] = 'value'
}
break
}
return attrs
}
const updateSetting = (setting: SettingParams, value: any) => {
settingStore.set(setting.id, value)
}
function getSettingComponent(setting: SettingParams): Component {
if (typeof setting.type === 'function') {
return CustomSettingValue
}
switch (setting.type) {
case 'boolean':
return ToggleSwitch
case 'number':
return InputNumber
case 'slider':
return InputSlider
case 'combo':
return Select
default:
return InputText
}
}
</script>
<style scoped>
.setting-input :deep(.input-slider) .p-inputnumber input,
.setting-input :deep(.input-slider) .slider-part {
@apply w-20;
}
.setting-input :deep(.p-inputtext),
.setting-input :deep(.p-select) {
@apply w-44;
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<FormItem
:item="setting"
:id="setting.id"
:formValue="settingValue"
@update:formValue="updateSettingValue"
>
<template #name-prefix>
<Tag v-if="setting.experimental" :value="$t('experimental')" />
<Tag
v-if="setting.deprecated"
:value="$t('deprecated')"
severity="danger"
/>
</template>
</FormItem>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
import FormItem from '@/components/common/FormItem.vue'
import { useSettingStore } from '@/stores/settingStore'
import { SettingParams } from '@/types/settingTypes'
import { computed } from 'vue'
const props = defineProps<{
setting: SettingParams
}>()
const settingStore = useSettingStore()
const settingValue = computed(() => settingStore.get(props.setting.id))
const updateSettingValue = (value: any) => {
settingStore.set(props.setting.id, value)
}
</script>

View File

@@ -182,10 +182,7 @@ watchEffect(() => {
})
watchEffect(() => {
if (comfyApp.graph?.config) {
comfyApp.graph.config.alwaysSnapToGrid =
settingStore.get('pysssss.SnapToGrid')
}
LiteGraph.alwaysSnapToGrid = settingStore.get('pysssss.SnapToGrid')
})
watchEffect(() => {

View File

@@ -163,6 +163,7 @@ const outputFilterPopup = ref(null)
const ITEMS_PER_PAGE = 8
const SCROLL_THRESHOLD = 100 // pixels from bottom to trigger load
const MAX_LOAD_ITERATIONS = 10
const allTasks = computed(() =>
isInFolderView.value
@@ -181,42 +182,47 @@ const allGalleryItems = computed(() =>
)
const filterTasks = (tasks: TaskItemImpl[]) =>
tasks
.filter((t) => {
if (
hideCanceled.value &&
t.status?.messages?.at(-1)?.[0] === 'execution_interrupted'
) {
return false
}
tasks.filter((t) => {
if (
hideCanceled.value &&
t.status?.messages?.at(-1)?.[0] === 'execution_interrupted'
) {
return false
}
if (
hideCached.value &&
t.flatOutputs?.length &&
t.flatOutputs.every((o) => o.cached)
) {
return false
}
if (
hideCached.value &&
t.flatOutputs?.length &&
t.flatOutputs.every((o) => o.cached)
) {
return false
}
return true
})
.slice(0, ITEMS_PER_PAGE)
return true
})
const loadMoreItems = () => {
const loadMoreItems = (iteration: number) => {
const currentLength = visibleTasks.value.length
const newTasks = filterTasks(allTasks.value).slice(
currentLength,
currentLength + ITEMS_PER_PAGE
)
visibleTasks.value.push(...newTasks)
// If we've added some items, check if we need to add more
// Prevent loading everything at once in case of render update issues
if (newTasks.length && iteration < MAX_LOAD_ITERATIONS) {
nextTick(() => {
checkAndLoadMore(iteration + 1)
})
}
}
const checkAndLoadMore = () => {
const checkAndLoadMore = (iteration: number) => {
if (!scrollContainer.value) return
const { scrollHeight, scrollTop, clientHeight } = scrollContainer.value
if (scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD) {
loadMoreItems()
loadMoreItems(iteration)
}
}
@@ -224,7 +230,7 @@ useInfiniteScroll(
scrollContainer,
() => {
if (visibleTasks.value.length < allTasks.value.length) {
loadMoreItems()
loadMoreItems(0)
}
},
{ distance: SCROLL_THRESHOLD }
@@ -234,12 +240,16 @@ useInfiniteScroll(
// This is necessary as the sidebar tab can change size when user drags the splitter.
useResizeObserver(scrollContainer, () => {
nextTick(() => {
checkAndLoadMore()
checkAndLoadMore(0)
})
})
const updateVisibleTasks = () => {
visibleTasks.value = filterTasks(allTasks.value)
visibleTasks.value = filterTasks(allTasks.value).slice(0, ITEMS_PER_PAGE)
nextTick(() => {
checkAndLoadMore(0)
})
}
const toggleExpanded = () => {

View File

@@ -50,10 +50,13 @@
<template #node="{ node }">
<TreeExplorerTreeNode :node="node">
<template #before-label="{ node }">
<span v-if="node.data.isModified">*</span>
<span v-if="node.data.isModified || !node.data.isPersisted"
>*</span
>
</template>
<template #actions="{ node }">
<Button
class="close-workflow-button"
icon="pi pi-times"
text
:severity="

View File

@@ -18,7 +18,10 @@
<div class="relative">
<span
class="status-indicator"
v-if="!workspaceStore.shiftDown && option.workflow.isModified"
v-if="
!workspaceStore.shiftDown &&
(option.workflow.isModified || !option.workflow.isPersisted)
"
>•</span
>
<Button

View File

@@ -59,7 +59,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
key: 's',
ctrl: true
},
commandId: 'Comfy.ExportWorkflow'
commandId: 'Comfy.SaveWorkflow'
},
{
combo: {
@@ -74,13 +74,6 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
},
commandId: 'Comfy.ClearWorkflow'
},
{
combo: {
key: 'd',
ctrl: true
},
commandId: 'Comfy.LoadDefaultWorkflow'
},
{
combo: {
key: 'g',

View File

@@ -624,5 +624,23 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
defaultValue: false,
versionAdded: '1.3.13'
},
{
id: 'Comfy.Server.ServerConfigValues',
name: 'Server config values for frontend display',
tooltip: 'Server config values used for frontend display only',
type: 'hidden',
// Mapping from server config id to value.
defaultValue: {} as Record<string, any>,
versionAdded: '1.4.8'
},
{
id: 'Comfy.Server.LaunchArgs',
name: 'Server launch arguments',
tooltip:
'These are the actual arguments that are passed to the server when it is launched.',
type: 'hidden',
defaultValue: {} as Record<string, string>,
versionAdded: '1.4.8'
}
]

View File

@@ -0,0 +1,432 @@
import { FormItem } from '@/types/settingTypes'
import {
LatentPreviewMethod,
LogLevel,
HashFunction,
AutoLaunch,
CudaMalloc,
FloatingPointPrecision,
CrossAttentionMethod,
VramManagement
} from '@/types/serverArgs'
export interface ServerConfig<T> extends FormItem {
id: string
defaultValue: T
category?: string[]
// Override the default value getter with a custom function.
getValue?: (value: T) => Record<string, any>
}
export const WEB_ONLY_CONFIG_ITEMS: ServerConfig<any>[] = [
// We only need these settings in the web version. Desktop app manages them already.
{
id: 'listen',
name: 'Host: The IP address to listen on',
category: ['Network'],
type: 'text',
defaultValue: '127.0.0.1'
},
{
id: 'port',
name: 'Port: The port to listen on',
category: ['Network'],
type: 'number',
defaultValue: 8188
}
]
export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
// Network settings
{
id: 'tls-keyfile',
name: 'TLS Key File: Path to TLS key file for HTTPS',
category: ['Network'],
type: 'text',
defaultValue: undefined
},
{
id: 'tls-certfile',
name: 'TLS Certificate File: Path to TLS certificate file for HTTPS',
category: ['Network'],
type: 'text',
defaultValue: undefined
},
{
id: 'enable-cors-header',
name: 'Enable CORS header: Use "*" for all origins or specify domain',
category: ['Network'],
type: 'text',
defaultValue: undefined
},
{
id: 'max-upload-size',
name: 'Maximum upload size (MB)',
category: ['Network'],
type: 'number',
defaultValue: 100
},
// Launch behavior
{
id: 'auto-launch',
name: 'Automatically opens in the browser on startup',
category: ['Launch'],
type: 'combo',
options: Object.values(AutoLaunch),
defaultValue: AutoLaunch.Auto,
getValue: (value: AutoLaunch) => {
switch (value) {
case AutoLaunch.Auto:
return {}
case AutoLaunch.Enable:
return {
['auto-launch']: true
}
case AutoLaunch.Disable:
return {
['disable-auto-launch']: true
}
}
}
},
// CUDA settings
{
id: 'cuda-device',
name: 'CUDA device index to use',
category: ['CUDA'],
type: 'number',
defaultValue: undefined
},
{
id: 'cuda-malloc',
name: 'Use CUDA malloc for memory allocation',
category: ['CUDA'],
type: 'combo',
options: Object.values(CudaMalloc),
defaultValue: CudaMalloc.Auto,
getValue: (value: CudaMalloc) => {
switch (value) {
case CudaMalloc.Auto:
return {}
case CudaMalloc.Enable:
return {
['cuda-malloc']: true
}
case CudaMalloc.Disable:
return {
['disable-cuda-malloc']: true
}
}
}
},
// Precision settings
{
id: 'global-precision',
name: 'Global floating point precision',
category: ['Inference'],
type: 'combo',
options: [
FloatingPointPrecision.AUTO,
FloatingPointPrecision.FP32,
FloatingPointPrecision.FP16
],
defaultValue: FloatingPointPrecision.AUTO,
tooltip: 'Global floating point precision',
getValue: (value: FloatingPointPrecision) => {
switch (value) {
case FloatingPointPrecision.AUTO:
return {}
case FloatingPointPrecision.FP32:
return {
['force-fp32']: true
}
case FloatingPointPrecision.FP16:
return {
['force-fp16']: true
}
default:
return {}
}
}
},
// UNET precision
{
id: 'unet-precision',
name: 'UNET precision',
category: ['Inference'],
type: 'combo',
options: [
FloatingPointPrecision.AUTO,
FloatingPointPrecision.FP16,
FloatingPointPrecision.BF16,
FloatingPointPrecision.FP8E4M3FN,
FloatingPointPrecision.FP8E5M2
],
defaultValue: FloatingPointPrecision.AUTO,
tooltip: 'UNET precision',
getValue: (value: FloatingPointPrecision) => {
switch (value) {
case FloatingPointPrecision.AUTO:
return {}
default:
return {
[`${value.toLowerCase()}-unet`]: true
}
}
}
},
// VAE settings
{
id: 'vae-precision',
name: 'VAE precision',
category: ['Inference'],
type: 'combo',
options: [
FloatingPointPrecision.AUTO,
FloatingPointPrecision.FP16,
FloatingPointPrecision.FP32,
FloatingPointPrecision.BF16
],
defaultValue: FloatingPointPrecision.AUTO,
tooltip: 'VAE precision',
getValue: (value: FloatingPointPrecision) => {
switch (value) {
case FloatingPointPrecision.AUTO:
return {}
default:
return {
[`${value.toLowerCase()}-vae`]: true
}
}
}
},
{
id: 'cpu-vae',
name: 'Run VAE on CPU',
category: ['Inference'],
type: 'boolean',
defaultValue: false
},
// Text Encoder settings
{
id: 'text-encoder-precision',
name: 'Text Encoder precision',
category: ['Inference'],
type: 'combo',
options: [
FloatingPointPrecision.AUTO,
FloatingPointPrecision.FP8E4M3FN,
FloatingPointPrecision.FP8E5M2,
FloatingPointPrecision.FP16,
FloatingPointPrecision.FP32
],
defaultValue: FloatingPointPrecision.AUTO,
tooltip: 'Text Encoder precision',
getValue: (value: FloatingPointPrecision) => {
switch (value) {
case FloatingPointPrecision.AUTO:
return {}
default:
return {
[`${value.toLowerCase()}-text-enc`]: true
}
}
}
},
// Memory and performance settings
{
id: 'force-channels-last',
name: 'Force channels-last memory format',
category: ['Memory'],
type: 'boolean',
defaultValue: false
},
{
id: 'directml',
name: 'DirectML device index',
category: ['Memory'],
type: 'number',
defaultValue: undefined
},
{
id: 'disable-ipex-optimize',
name: 'Disable IPEX optimization',
category: ['Memory'],
type: 'boolean',
defaultValue: false
},
// Preview settings
{
id: 'preview-method',
name: 'Method used for latent previews',
category: ['Preview'],
type: 'combo',
options: Object.values(LatentPreviewMethod),
defaultValue: LatentPreviewMethod.NoPreviews
},
{
id: 'preview-size',
name: 'Size of preview images',
category: ['Preview'],
type: 'slider',
defaultValue: 512,
attrs: {
min: 128,
max: 2048,
step: 128
}
},
// Cache settings
{
id: 'cache-classic',
name: 'Use classic cache system',
category: ['Cache'],
type: 'boolean',
defaultValue: false
},
{
id: 'cache-lru',
name: 'Use LRU caching with a maximum of N node results cached. (0 to disable).',
category: ['Cache'],
type: 'number',
defaultValue: 0,
tooltip: 'May use more RAM/VRAM.'
},
// Attention settings
{
id: 'cross-attention-method',
name: 'Cross attention method',
category: ['Attention'],
type: 'combo',
options: Object.values(CrossAttentionMethod),
defaultValue: CrossAttentionMethod.Auto,
getValue: (value: CrossAttentionMethod) => {
switch (value) {
case CrossAttentionMethod.Auto:
return {}
default:
return {
[`use-${value.toLowerCase()}-cross-attention`]: true
}
}
}
},
{
id: 'disable-xformers',
name: 'Disable xFormers optimization',
type: 'boolean',
defaultValue: false
},
{
id: 'force-upcast-attention',
name: 'Force attention upcast',
category: ['Attention'],
type: 'boolean',
defaultValue: false
},
{
id: 'dont-upcast-attention',
name: 'Prevent attention upcast',
category: ['Attention'],
type: 'boolean',
defaultValue: false
},
// VRAM management
{
id: 'vram-management',
name: 'VRAM management mode',
category: ['Memory'],
type: 'combo',
options: Object.values(VramManagement),
defaultValue: VramManagement.Auto,
getValue: (value: VramManagement) => {
switch (value) {
case VramManagement.Auto:
return {}
default:
return {
[value]: true
}
}
}
},
{
id: 'reserve-vram',
name: 'Reserved VRAM (GB)',
category: ['Memory'],
type: 'number',
defaultValue: undefined,
tooltip:
'Set the amount of vram in GB you want to reserve for use by your OS/other software. By default some amount is reverved depending on your OS.'
},
// Misc settings
{
id: 'default-hashing-function',
name: 'Default hashing function for model files',
type: 'combo',
options: Object.values(HashFunction),
defaultValue: HashFunction.SHA256
},
{
id: 'disable-smart-memory',
name: 'Force ComfyUI to agressively offload to regular ram instead of keeping models in vram when it can.',
category: ['Memory'],
type: 'boolean',
defaultValue: false
},
{
id: 'deterministic',
name: 'Make pytorch use slower deterministic algorithms when it can.',
type: 'boolean',
defaultValue: false,
tooltip: 'Note that this might not make images deterministic in all cases.'
},
{
id: 'fast',
name: 'Enable some untested and potentially quality deteriorating optimizations.',
type: 'boolean',
defaultValue: false
},
{
id: 'dont-print-server',
name: "Don't print server output to console.",
type: 'boolean',
defaultValue: false
},
{
id: 'disable-metadata',
name: 'Disable saving prompt metadata in files.',
type: 'boolean',
defaultValue: false
},
{
id: 'disable-all-custom-nodes',
name: 'Disable loading all custom nodes.',
type: 'boolean',
defaultValue: false
},
{
id: 'log-level',
name: 'Logging verbosity level',
type: 'combo',
options: Object.values(LogLevel),
defaultValue: LogLevel.INFO,
getValue: (value: LogLevel) => {
return {
verbose: value
}
}
}
]

View File

@@ -5,6 +5,14 @@ import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
const electronAPI = getElectronAPI()
const desktopAppVersion = await electronAPI.getElectronVersion()
const onChangeRestartApp = (newValue: string, oldValue: string) => {
// Add a delay to allow changes to take effect before restarting.
if (oldValue !== undefined && newValue !== oldValue) {
electronAPI.restartApp('Restart ComfyUI to apply changes.', 1500)
}
}
app.registerExtension({
name: 'Comfy.ElectronAdapter',
settings: [
@@ -14,29 +22,15 @@ import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
name: 'Automatically check for updates',
type: 'boolean',
defaultValue: true,
onChange(newValue, oldValue) {
if (oldValue !== undefined && newValue !== oldValue) {
electronAPI.restartApp(
'Restart ComfyUI to apply changes.',
1500 // add delay to allow changes to take effect before restarting.
)
}
}
onChange: onChangeRestartApp
},
{
id: 'Comfy-Desktop.SendStatistics',
category: ['Comfy-Desktop', 'General', 'Send Statistics'],
name: 'Send anonymous usage statistics',
name: 'Send anonymous crash reports',
type: 'boolean',
defaultValue: true,
onChange(newValue, oldValue) {
if (oldValue !== undefined && newValue !== oldValue) {
electronAPI.restartApp(
'Restart ComfyUI to apply changes.',
1500 // add delay to allow changes to take effect before restarting.
)
}
}
onChange: onChangeRestartApp
}
],

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,16 @@
import { FitAddon } from '@xterm/addon-fit'
import { Terminal } from '@xterm/xterm'
import { debounce } from 'lodash'
import { onMounted, onUnmounted, Ref } from 'vue'
import { markRaw, onMounted, onUnmounted, Ref } from 'vue'
import '@xterm/xterm/css/xterm.css'
export function useTerminal(element: Ref<HTMLElement>) {
const fitAddon = new FitAddon()
const terminal = new Terminal({
convertEol: true
})
const terminal = markRaw(
new Terminal({
convertEol: true
})
)
terminal.loadAddon(fitAddon)
onMounted(async () => {

View File

@@ -15,7 +15,7 @@ const messages = {
failedToSelectDirectory: 'Failed to select directory',
pathValidationFailed: 'Failed to validate path',
installLocationDescription:
"Select the directory for ComfyUI's user data. A python environment will be installed to the selected location. Please make sure the selected disk has enough space (~5GB) left.",
"Select the directory for ComfyUI's user data. A python environment will be installed to the selected location. Please make sure the selected disk has enough space (~15GB) left.",
installLocationTooltip:
"ComfyUI's user data directory. Stores:\n- Python Environment\n- Models\n- Custom nodes\n",
appDataLocationTooltip:
@@ -33,24 +33,38 @@ const messages = {
'Configure how ComfyUI behaves on your desktop. You can change these settings later.',
settings: {
autoUpdate: 'Automatic Updates',
allowMetrics: 'Usage Analytics',
allowMetrics: 'Crash Reports',
autoUpdateDescription:
"Automatically download and install updates when they become available. You'll always be notified before updates are installed.",
allowMetricsDescription:
'Help improve ComfyUI by sending anonymous usage data. No personal information or workflow content will be collected.',
'Help improve ComfyUI by sending anonymous crash reports. No personal information or workflow content will be collected. This can be disabled at any time in the settings menu.',
learnMoreAboutData: 'Learn more about data collection',
dataCollectionDialog: {
title: 'About Data Collection',
whatWeCollect: 'What we collect:',
whatWeDoNotCollect: "What we don't collect:",
errorReports: 'Error reports',
systemInfo: 'Operating system and app version',
errorReports: 'Error message and stack trace',
systemInfo: 'Hardware, OS type, and app version',
personalInformation: 'Personal information',
workflowContent: 'Workflow content',
fileSystemInformation: 'File system information'
fileSystemInformation: 'File system information',
workflowContents: 'Workflow contents',
customNodeConfigurations: 'Custom node configurations'
}
}
},
serverStart: {
reinstall: 'Reinstall',
reportIssue: 'Report Issue',
openLogs: 'Open Logs',
process: {
'initial-state': 'Loading...',
'python-setup': 'Setting up Python Environment...',
'starting-server': 'Starting ComfyUI server...',
ready: 'Finishing...',
error: 'Unable to start ComfyUI'
}
},
firstTimeUIMessage:
'This is the first time you use the new UI. Choose "Menu > Use New Menu > Disabled" to restore the old UI.',
download: 'Download',
@@ -83,6 +97,9 @@ const messages = {
error: 'Error',
loading: 'Loading',
findIssues: 'Find Issues',
reportIssue: 'Send Report',
reportIssueTooltip: 'Submit the error report to Comfy Org',
reportSent: 'Report Submitted',
copyToClipboard: 'Copy to Clipboard',
openNewIssue: 'Open New Issue',
showReport: 'Show Report',
@@ -148,8 +165,8 @@ const messages = {
'The workflow will be queued instantly after a generation finishes',
change: 'On Change',
changeTooltip: 'The workflow will be queued once a change is made',
queueWorkflow: 'Queue workflow',
queueWorkflowFront: 'Queue workflow (Insert at Front)',
queueWorkflow: 'Queue workflow (Shift to queue at front)',
queueWorkflowFront: 'Queue workflow at front',
queue: 'Queue',
interrupt: 'Cancel current run',
refresh: 'Refresh node definitions',
@@ -275,8 +292,8 @@ const messages = {
instantTooltip: '工作流将会在生成完成后立即执行',
change: '变动',
changeTooltip: '工作流将会在改变后执行',
queueWorkflow: '执行工作流',
queueWorkflowFront: '执行工作流 (队列首)',
queueWorkflow: '执行 (Shift 执行到队列首)',
queueWorkflowFront: '执行队列首',
queue: '队列',
interrupt: '取消当前任务',
refresh: '刷新节点',
@@ -396,7 +413,7 @@ const messages = {
change: 'При изменении',
changeTooltip:
'Рабочий процесс будет поставлен в очередь после внесения изменений',
queueWorkflow: 'Очередь рабочего процесса',
queueWorkflow: 'Очередь рабочего процесса (Shift для вставки спереди)',
queueWorkflowFront: 'Очередь рабочего процесса (Вставка спереди)',
queue: 'Очередь',
interrupt: 'Отменить текущее выполнение',

View File

@@ -10,15 +10,7 @@ import _ from 'lodash'
import * as jsondiffpatch from 'jsondiffpatch'
import log from 'loglevel'
function clone(obj: any) {
try {
if (typeof structuredClone !== 'undefined') {
return structuredClone(obj)
}
} catch (error) {
// structuredClone is stricter than using JSON.parse/stringify so fallback to that
}
function clone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj))
}
@@ -69,7 +61,7 @@ export class ChangeTracker {
if (this.restoringState) return
logger.debug('Reset State')
this.activeState = state ?? this.activeState
if (state) this.activeState = clone(state)
this.initialState = clone(this.activeState)
}
@@ -91,6 +83,10 @@ export class ChangeTracker {
}
updateModified() {
api.dispatchEvent(
new CustomEvent('graphChanged', { detail: this.activeState })
)
// Get the workflow from the store as ChangeTracker is raw object, i.e.
// `this.workflow` is not reactive.
const workflow = useWorkflowStore().getWorkflowByPath(this.workflow.path)
@@ -112,9 +108,9 @@ export class ChangeTracker {
checkState() {
if (!this.app.graph || this.changeCount) return
// @ts-expect-error zod types issue. Will be fixed after we enable ts-strict
const currentState = this.app.graph.serialize() as ComfyWorkflowJSON
const currentState = clone(this.app.graph.serialize()) as ComfyWorkflowJSON
if (!this.activeState) {
this.activeState = clone(currentState)
this.activeState = currentState
return
}
if (!ChangeTracker.graphEqual(this.activeState, currentState)) {
@@ -124,11 +120,8 @@ export class ChangeTracker {
}
logger.debug('Diff detected. Undo queue length:', this.undoQueue.length)
this.activeState = clone(currentState)
this.activeState = currentState
this.redoQueue.length = 0
api.dispatchEvent(
new CustomEvent('graphChanged', { detail: this.activeState })
)
this.updateModified()
}
}
@@ -136,7 +129,7 @@ export class ChangeTracker {
async updateState(source: ComfyWorkflowJSON[], target: ComfyWorkflowJSON[]) {
const prevState = source.pop()
if (prevState) {
target.push(this.activeState!)
target.push(this.activeState)
this.restoringState = true
try {
await this.app.loadGraphData(prevState, false, false, this.workflow, {

View File

@@ -63,17 +63,36 @@ export const workflowService = {
const newPath = workflow.directory + '/' + appendJsonExt(newFilename)
const newKey = newPath.substring(ComfyWorkflow.basePath.length)
const workflowStore = useWorkflowStore()
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
if (existingWorkflow) {
const res = (await ComfyAsyncDialog.prompt({
title: 'Overwrite existing file?',
message: `"${newPath}" already exists. Do you want to overwrite it?`,
actions: ['Yes', 'No']
})) as 'Yes' | 'No'
if (res === 'No') return
if (existingWorkflow.path === workflow.path) {
await this.saveWorkflow(workflow)
return
}
const deleted = await this.deleteWorkflow(existingWorkflow)
if (!deleted) return
}
if (workflow.isTemporary) {
await this.renameWorkflow(workflow, newPath)
await useWorkflowStore().saveWorkflow(workflow)
await workflowStore.saveWorkflow(workflow)
} else {
const tempWorkflow = useWorkflowStore().createTemporary(
const tempWorkflow = workflowStore.createTemporary(
newKey,
workflow.activeState as ComfyWorkflowJSON
)
await this.openWorkflow(tempWorkflow)
await useWorkflowStore().saveWorkflow(tempWorkflow)
await workflowStore.saveWorkflow(tempWorkflow)
}
},
@@ -131,9 +150,9 @@ export const workflowService = {
async closeWorkflow(
workflow: ComfyWorkflow,
options: { warnIfUnsaved: boolean } = { warnIfUnsaved: true }
): Promise<void> {
): Promise<boolean> {
if (!workflow.isLoaded) {
return
return true
}
if (workflow.isModified && options.warnIfUnsaved) {
@@ -146,7 +165,7 @@ export const workflowService = {
if (res === 'Yes') {
await this.saveWorkflow(workflow)
} else if (res === 'Cancel') {
return
return false
}
}
@@ -161,18 +180,26 @@ export const workflowService = {
}
await workflowStore.closeWorkflow(workflow)
return true
},
async renameWorkflow(workflow: ComfyWorkflow, newPath: string) {
await useWorkflowStore().renameWorkflow(workflow, newPath)
},
async deleteWorkflow(workflow: ComfyWorkflow) {
/**
* Delete a workflow
* @param workflow The workflow to delete
* @returns true if the workflow was deleted, false if the user cancelled
*/
async deleteWorkflow(workflow: ComfyWorkflow): Promise<boolean> {
const workflowStore = useWorkflowStore()
if (workflowStore.isOpen(workflow)) {
await this.closeWorkflow(workflow)
const closed = await this.closeWorkflow(workflow)
if (!closed) return false
}
await workflowStore.deleteWorkflow(workflow)
return true
},
/**

View File

@@ -493,6 +493,24 @@ export const useCommandStore = defineStore('command', () => {
function: () => {
useWorkspaceStore().toggleFocusMode()
}
},
{
id: 'Comfy.Graph.FitGroupToContents',
icon: 'pi pi-expand',
label: 'Fit Group To Contents',
versionAdded: '1.4.9',
function: () => {
for (const group of app.canvas.selectedItems) {
if (group instanceof LGraphGroup) {
group.recomputeInsideNodes()
const padding = useSettingStore().get(
'Comfy.GroupSelectedNodes.Padding'
)
group.resizeTo(group.children, padding)
app.graph.change()
}
}
}
}
]

View File

@@ -1,13 +1,16 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { isElectron, electronAPI } from '@/utils/envUtil'
import type {
DownloadState,
DownloadStatus
} from '@comfyorg/comfyui-electron-types'
export interface ElectronDownload {
url: string
status: 'paused' | 'in_progress' | 'cancelled'
progress: number
savePath: string
filename: string
export interface ElectronDownload
extends Pick<DownloadState, 'url' | 'filename'> {
progress?: number
savePath?: string
status?: DownloadStatus
}
/** Electron donwloads store handler */
@@ -20,15 +23,14 @@ export const useElectronDownloadStore = defineStore('downloads', () => {
const initialize = async () => {
if (isElectron()) {
const allDownloads: ElectronDownload[] =
(await DownloadManager.getAllDownloads()) as unknown as ElectronDownload[]
const allDownloads = await DownloadManager.getAllDownloads()
for (const download of allDownloads) {
downloads.value.push(download)
}
// ToDO: replace with ElectronDownload type
DownloadManager.onDownloadProgress((data: any) => {
DownloadManager.onDownloadProgress((data) => {
if (!findByUrl(data.url)) {
downloads.value.push(data)
}
@@ -51,8 +53,11 @@ export const useElectronDownloadStore = defineStore('downloads', () => {
url,
savePath,
filename
}: Pick<ElectronDownload, 'url' | 'savePath' | 'filename'>) =>
DownloadManager.startDownload(url, savePath, filename)
}: {
url: string
savePath: string
filename: string
}) => DownloadManager.startDownload(url, savePath, filename)
const pause = (url: string) => DownloadManager.pauseDownload(url)
const resume = (url: string) => DownloadManager.resumeDownload(url)
const cancel = (url: string) => DownloadManager.cancelDownload(url)

View File

@@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
import { computed, Ref, ref, toRaw } from 'vue'
import { Keybinding, KeyCombo } from '@/types/keyBindingTypes'
import { useSettingStore } from './settingStore'
import { CORE_KEYBINDINGS } from './coreKeybindings'
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
import type { ComfyExtension } from '@/types/comfy'
export class KeybindingImpl implements Keybinding {
@@ -121,8 +121,7 @@ export const useKeybindingStore = defineStore('keybinding', () => {
const keybindingByKeyCombo = computed<Record<string, KeybindingImpl>>(() => {
const result: Record<string, KeybindingImpl> = {
...defaultKeybindings.value,
...userKeybindings.value
...defaultKeybindings.value
}
for (const keybinding of Object.values(userUnsetKeybindings.value)) {
@@ -131,7 +130,11 @@ export const useKeybindingStore = defineStore('keybinding', () => {
delete result[serializedCombo]
}
}
return result
return {
...result,
...userKeybindings.value
}
})
const keybindings = computed<KeybindingImpl[]>(() =>
@@ -218,7 +221,10 @@ export const useKeybindingStore = defineStore('keybinding', () => {
function unsetKeybinding(keybinding: KeybindingImpl) {
const serializedCombo = keybinding.combo.serialize()
if (!(serializedCombo in keybindingByKeyCombo.value)) {
throw new Error(`Keybinding on ${keybinding.combo} does not exist`)
console.warn(
`Trying to unset non-exist keybinding: ${JSON.stringify(keybinding)}`
)
return
}
if (userKeybindings.value[serializedCombo]?.equals(keybinding)) {
@@ -231,7 +237,7 @@ export const useKeybindingStore = defineStore('keybinding', () => {
return
}
throw new Error(`NOT_REACHED`)
throw new Error(`Unknown keybinding: ${JSON.stringify(keybinding)}`)
}
/**

View File

@@ -0,0 +1,73 @@
import { ServerConfig } from '@/constants/serverConfig'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export type ServerConfigWithValue<T> = ServerConfig<T> & {
value: T
}
export const useServerConfigStore = defineStore('serverConfig', () => {
const serverConfigById = ref<Record<string, ServerConfigWithValue<any>>>({})
const serverConfigs = computed(() => {
return Object.values(serverConfigById.value)
})
const serverConfigsByCategory = computed<
Record<string, ServerConfigWithValue<any>[]>
>(() => {
return serverConfigs.value.reduce(
(acc, config) => {
const category = config.category?.[0] ?? 'General'
acc[category] = acc[category] || []
acc[category].push(config)
return acc
},
{} as Record<string, ServerConfigWithValue<any>[]>
)
})
const serverConfigValues = computed<Record<string, any>>(() => {
return Object.fromEntries(
serverConfigs.value.map((config) => {
return [
config.id,
config.value === config.defaultValue || !config.value
? undefined
: config.value
]
})
)
})
const launchArgs = computed<Record<string, string>>(() => {
return Object.assign(
{},
...serverConfigs.value.map((config) => {
if (config.value === config.defaultValue || !config.value) {
return {}
}
return config.getValue
? config.getValue(config.value)
: { [config.id]: config.value }
})
)
})
function loadServerConfig(
configs: ServerConfig<any>[],
values: Record<string, any>
) {
for (const config of configs) {
serverConfigById.value[config.id] = {
...config,
value: values[config.id] ?? config.defaultValue
}
}
}
return {
serverConfigById,
serverConfigs,
serverConfigsByCategory,
serverConfigValues,
launchArgs,
loadServerConfig
}
})

View File

@@ -16,7 +16,7 @@ import type { SettingParams } from '@/types/settingTypes'
import type { TreeNode } from 'primevue/treenode'
import type { ComfyExtension } from '@/types/comfy'
import { buildTree } from '@/utils/treeUtil'
import { CORE_SETTINGS } from '@/stores/coreSettings'
import { CORE_SETTINGS } from '@/constants/coreSettings'
export interface SettingTreeNode extends TreeNode {
data?: SettingParams

View File

@@ -526,7 +526,9 @@ const zSettings = z.record(z.any()).and(
'Comfy.Settings.ExtensionPanel': z.boolean(),
'Comfy.LinkRenderMode': z.number(),
'Comfy.Node.AutoSnapLinkToSlot': z.boolean(),
'Comfy.Node.SnapHighlightsNode': z.boolean()
'Comfy.Node.SnapHighlightsNode': z.boolean(),
'Comfy.Server.ServerConfigValues': z.record(z.string(), z.any()),
'Comfy.Server.LaunchArgs': z.record(z.string(), z.string())
})
.optional()
)

65
src/types/serverArgs.ts Normal file
View File

@@ -0,0 +1,65 @@
export enum LatentPreviewMethod {
NoPreviews = 'none',
Auto = 'auto',
Latent2RGB = 'latent2rgb',
TAESD = 'taesd'
}
export enum LogLevel {
DEBUG = 'DEBUG',
INFO = 'INFO',
WARNING = 'WARNING',
ERROR = 'ERROR',
CRITICAL = 'CRITICAL'
}
export enum HashFunction {
MD5 = 'md5',
SHA1 = 'sha1',
SHA256 = 'sha256',
SHA512 = 'sha512'
}
export enum AutoLaunch {
// Let server decide whether to auto launch based on the current environment
Auto = 'auto',
// Disable auto launch
Disable = 'disable',
// Enable auto launch
Enable = 'enable'
}
export enum CudaMalloc {
// Let server decide whether to use CUDA malloc based on the current environment
Auto = 'auto',
// Disable CUDA malloc
Disable = 'disable',
// Enable CUDA malloc
Enable = 'enable'
}
export enum FloatingPointPrecision {
AUTO = 'auto',
FP32 = 'fp32',
FP16 = 'fp16',
BF16 = 'bf16',
FP8E4M3FN = 'fp8_e4m3fn',
FP8E5M2 = 'fp8_e5m2'
}
export enum CrossAttentionMethod {
Auto = 'auto',
Split = 'split',
Quad = 'quad',
Pytorch = 'pytorch'
}
export enum VramManagement {
Auto = 'auto',
GPUOnly = 'gpu-only',
HighVram = 'highvram',
NormalVram = 'normalvram',
LowVram = 'lowvram',
NoVram = 'novram',
CPU = 'cpu'
}

View File

@@ -29,15 +29,10 @@ export interface Setting {
render: () => HTMLElement
}
export interface SettingParams {
export interface SettingParams extends FormItem {
id: keyof Settings
name: string
type: SettingInputType | SettingCustomRenderer
defaultValue: any
onChange?: (newValue: any, oldValue?: any) => void
attrs?: any
tooltip?: string
options?: Array<string | SettingOption> | ((value: any) => SettingOption[])
// By default category is id.split('.'). However, changing id to assign
// new category has poor backward compatibility. Use this field to overwrite
// default category from id.
@@ -52,3 +47,14 @@ export interface SettingParams {
// Version of the setting when it was last modified
versionModified?: string
}
/**
* The base form item for rendering in a form.
*/
export interface FormItem {
name: string
type: SettingInputType | SettingCustomRenderer
tooltip?: string
attrs?: Record<string, any>
options?: Array<string | SettingOption> | ((value: any) => SettingOption[])
}

View File

@@ -2,43 +2,85 @@
<div
class="font-sans flex flex-col justify-center items-center h-screen m-0 text-neutral-300 bg-neutral-900 dark-theme pointer-events-auto"
>
<h2 class="text-2xl font-bold">{{ ProgressMessages[status] }}</h2>
<LogTerminal :fetch-logs="fetchLogs" :fetch-interval="500" />
<h2 class="text-2xl font-bold">{{ t(`serverStart.process.${status}`) }}</h2>
<div
v-if="status == ProgressStatus.ERROR"
class="flex items-center my-4 gap-2"
>
<Button
icon="pi pi-flag"
severity="secondary"
:label="t('serverStart.reportIssue')"
@click="reportIssue"
/>
<Button
icon="pi pi-file"
severity="secondary"
:label="t('serverStart.openLogs')"
@click="openLogs"
/>
<Button
icon="pi pi-refresh"
:label="t('serverStart.reinstall')"
@click="reinstall"
/>
</div>
<BaseTerminal @created="terminalCreated" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import LogTerminal from '@/components/common/LogTerminal.vue'
import {
ProgressStatus,
ProgressMessages
} from '@comfyorg/comfyui-electron-types'
import Button from 'primevue/button'
import { ref, onMounted, Ref } from 'vue'
import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue'
import { ProgressStatus } from '@comfyorg/comfyui-electron-types'
import { electronAPI } from '@/utils/envUtil'
import type { useTerminal } from '@/hooks/bottomPanelTabs/useTerminal'
import { Terminal } from '@xterm/xterm'
import { useI18n } from 'vue-i18n'
const electron = electronAPI()
const { t } = useI18n()
const status = ref<ProgressStatus>(ProgressStatus.INITIAL_STATE)
const logs = ref<string[]>([])
let xterm: Terminal | undefined
const updateProgress = ({ status: newStatus }: { status: ProgressStatus }) => {
status.value = newStatus
logs.value = [] // Clear logs when status changes
xterm?.clear()
}
const addLogMessage = (message: string) => {
logs.value = [...logs.value, message]
const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement>
) => {
xterm = terminal
useAutoSize(root, true, true)
electron.onLogMessage((message: string) => {
terminal.write(message)
})
terminal.options.cursorBlink = false
terminal.options.disableStdin = true
terminal.options.cursorInactiveStyle = 'block'
}
const fetchLogs = async () => {
return logs.value.join('\n')
const reinstall = () => electron.reinstall()
const reportIssue = () => {
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
}
const openLogs = () => electron.openLogsFolder()
onMounted(() => {
electron.sendReady()
electron.onProgressUpdate(updateProgress)
electron.onLogMessage((message: string) => {
addLogMessage(message)
})
})
</script>
<style scoped>
:deep(.xterm-helper-textarea) {
/* Hide this as it moves all over when uv is running */
display: none;
}
</style>

View File

@@ -0,0 +1,189 @@
import { setActivePinia, createPinia } from 'pinia'
import { useServerConfigStore } from '@/stores/serverConfigStore'
import { ServerConfig } from '@/constants/serverConfig'
import type { FormItem } from '@/types/settingTypes'
const dummyFormItem: FormItem = {
name: '',
type: 'text'
}
describe('useServerConfigStore', () => {
let store: ReturnType<typeof useServerConfigStore>
beforeEach(() => {
setActivePinia(createPinia())
store = useServerConfigStore()
})
it('should initialize with empty configs', () => {
expect(store.serverConfigs).toHaveLength(0)
expect(Object.keys(store.serverConfigById)).toHaveLength(0)
expect(Object.keys(store.serverConfigsByCategory)).toHaveLength(0)
expect(Object.keys(store.serverConfigValues)).toHaveLength(0)
expect(Object.keys(store.launchArgs)).toHaveLength(0)
})
it('should load server configs with default values', () => {
const configs: ServerConfig<any>[] = [
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 'default1',
category: ['Test']
},
{
...dummyFormItem,
id: 'test.config2',
defaultValue: 'default2'
}
]
store.loadServerConfig(configs, {})
expect(store.serverConfigs).toHaveLength(2)
expect(store.serverConfigById['test.config1'].value).toBe('default1')
expect(store.serverConfigById['test.config2'].value).toBe('default2')
})
it('should load server configs with provided values', () => {
const configs: ServerConfig<any>[] = [
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 'default1',
category: ['Test']
}
]
store.loadServerConfig(configs, {
'test.config1': 'custom1'
})
expect(store.serverConfigs).toHaveLength(1)
expect(store.serverConfigById['test.config1'].value).toBe('custom1')
})
it('should organize configs by category', () => {
const configs: ServerConfig<any>[] = [
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 'default1',
category: ['Test']
},
{
...dummyFormItem,
id: 'test.config2',
defaultValue: 'default2',
category: ['Other']
},
{
...dummyFormItem,
id: 'test.config3',
defaultValue: 'default3'
}
]
store.loadServerConfig(configs, {})
expect(Object.keys(store.serverConfigsByCategory)).toHaveLength(3)
expect(store.serverConfigsByCategory['Test']).toHaveLength(1)
expect(store.serverConfigsByCategory['Other']).toHaveLength(1)
expect(store.serverConfigsByCategory['General']).toHaveLength(1)
})
it('should generate server config values excluding defaults', () => {
const configs: ServerConfig<any>[] = [
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 'default1'
},
{
...dummyFormItem,
id: 'test.config2',
defaultValue: 'default2'
}
]
store.loadServerConfig(configs, {
'test.config1': 'custom1',
'test.config2': 'default2'
})
expect(Object.keys(store.serverConfigValues)).toHaveLength(2)
expect(store.serverConfigValues['test.config1']).toBe('custom1')
expect(store.serverConfigValues['test.config2']).toBeUndefined()
})
it('should generate launch arguments with custom getValue function', () => {
const configs: ServerConfig<any>[] = [
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 'default1',
getValue: (value: string) => ({ customArg: value })
},
{
...dummyFormItem,
id: 'test.config2',
defaultValue: 'default2'
}
]
store.loadServerConfig(configs, {
'test.config1': 'custom1',
'test.config2': 'custom2'
})
expect(Object.keys(store.launchArgs)).toHaveLength(2)
expect(store.launchArgs['customArg']).toBe('custom1')
expect(store.launchArgs['test.config2']).toBe('custom2')
})
it('should not include default values in launch arguments', () => {
const configs: ServerConfig<any>[] = [
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 'default1'
},
{
...dummyFormItem,
id: 'test.config2',
defaultValue: 'default2'
}
]
store.loadServerConfig(configs, {
'test.config1': 'custom1',
'test.config2': 'default2'
})
expect(Object.keys(store.launchArgs)).toHaveLength(1)
expect(store.launchArgs['test.config1']).toBe('custom1')
expect(store.launchArgs['test.config2']).toBeUndefined()
})
it('should not include nullish values in launch arguments', () => {
const configs: ServerConfig<any>[] = [
{ ...dummyFormItem, id: 'test.config1', defaultValue: 'default1' },
{ ...dummyFormItem, id: 'test.config2', defaultValue: 'default2' },
{ ...dummyFormItem, id: 'test.config3', defaultValue: 'default3' }
]
store.loadServerConfig(configs, {
'test.config1': undefined,
'test.config2': null,
'test.config3': ''
})
expect(Object.keys(store.launchArgs)).toHaveLength(0)
expect(Object.keys(store.serverConfigValues)).toEqual([
'test.config1',
'test.config2',
'test.config3'
])
})
})

View File

@@ -126,14 +126,14 @@ describe('useKeybindingStore', () => {
expect(store.getKeybinding(keybinding2.combo)).toEqual(keybinding2)
})
it('should throw an error when unsetting non-existent keybindings', () => {
it('should not throw an error when unsetting non-existent keybindings', () => {
const store = useKeybindingStore()
const keybinding = new KeybindingImpl({
commandId: 'test.command',
combo: { key: 'H', alt: true, shift: true }
})
expect(() => store.unsetKeybinding(keybinding)).toThrow()
expect(() => store.unsetKeybinding(keybinding)).not.toThrow()
})
it('should remove unset keybinding when adding back a default keybinding', () => {
@@ -160,4 +160,76 @@ describe('useKeybindingStore', () => {
defaultKeybinding
)
})
it('Should accept same keybinding from default and user', () => {
const store = useKeybindingStore()
const keybinding = new KeybindingImpl({
commandId: 'test.command',
combo: { key: 'J', ctrl: true }
})
// Add default keybinding.
// This can happen when we change default keybindings.
store.addDefaultKeybinding(keybinding)
// Add user keybinding.
store.addUserKeybinding(keybinding)
expect(store.keybindings).toHaveLength(1)
expect(store.getKeybinding(keybinding.combo)).toEqual(keybinding)
})
it('Should keep previously customized keybindings after default keybindings change', () => {
// Initially command 'foo' was bound to 'K, Ctrl'. User unset it and bound the
// command to 'A, Ctrl'.
// Now we change the default keybindings of 'foo' to 'A, Ctrl'.
// The user customized keybinding should be kept.
const store = useKeybindingStore()
const userUnsetKeybindings = [
new KeybindingImpl({
commandId: 'foo',
combo: { key: 'K', ctrl: true }
})
]
const userNewKeybindings = [
new KeybindingImpl({
commandId: 'foo',
combo: { key: 'A', ctrl: true }
})
]
const newCoreKeybindings = [
new KeybindingImpl({
commandId: 'foo',
combo: { key: 'A', ctrl: true }
})
]
for (const keybinding of newCoreKeybindings) {
store.addDefaultKeybinding(keybinding)
}
expect(store.keybindings).toHaveLength(1)
expect(store.getKeybinding(userNewKeybindings[0].combo)).toEqual(
userNewKeybindings[0]
)
for (const keybinding of userUnsetKeybindings) {
store.unsetKeybinding(keybinding)
}
expect(store.keybindings).toHaveLength(1)
expect(store.getKeybinding(userNewKeybindings[0].combo)).toEqual(
userNewKeybindings[0]
)
for (const keybinding of userNewKeybindings) {
store.addUserKeybinding(keybinding)
}
expect(store.keybindings).toHaveLength(1)
expect(store.getKeybinding(userNewKeybindings[0].combo)).toEqual(
userNewKeybindings[0]
)
})
})