Compare commits

...

10 Commits

Author SHA1 Message Date
Benjamin Lu
f5726e5948 [test] Add test to verify targetSelector property deletion
Add a specific test case to ensure the keybinding migration properly
deletes the deprecated 'targetSelector' property after migrating to
'targetElementId'. This test provides additional coverage to verify
the migration doesn't leave behind obsolete properties.

The test verifies:
- The old 'targetSelector' property is removed
- The new 'targetElementId' property is added with correct value
- Other properties remain unchanged
2025-07-01 19:21:02 -04:00
Benjamin Lu
bd0df83a7c [fix] Improve type safety in keybinding migration
Replace 'any' type with proper 'Keybinding[]' type annotation in the
keybinding migration. Also create new objects during migration to avoid
mutating the original keybinding data, which follows immutability best
practices and prevents potential side effects.

This ensures better type checking and makes the migration code more
robust and maintainable.
2025-07-01 19:20:26 -04:00
Benjamin Lu
c5edbe588c [refactor] Move setting migrations to settingStore
Relocate runSettingMigrations() from GraphCanvas.vue to settingStore's
loadSettingValues() method. This improves separation of concerns by keeping
data migration logic within the store rather than coupling it to UI components.

The migrations now run at the optimal time: after loading setting values from
the server but before any settings are registered, ensuring proper initialization
order and preventing potential race conditions.

This change makes the architecture cleaner and more maintainable by centralizing
all setting-related logic in the appropriate store.
2025-07-01 19:19:51 -04:00
Benjamin Lu
6def711414 Add tests 2025-07-01 18:54:40 -04:00
Benjamin Lu
472f90799d [refactor] Consolidate settings migration logic into dedicated utility
- Extract all migrateDeprecatedValue logic from individual settings into centralized settingsMigration.ts
- Remove migrateDeprecatedValue from SettingParams interface and coreSettings definitions
- Simplify settingStore by removing tryMigrateDeprecatedValue function
- Ensure migrations run after loadSettingValues() for clean initialization flow
- Update tests to reflect new migration approach

This refactor centralizes all setting value migrations in one place, making them easier to maintain and avoiding timing issues with settings that don't exist yet.
2025-07-01 18:47:04 -04:00
Comfy Org PR Bot
4c177121a6 [chore] Update Comfy Registry API types from comfy-api@065aded (#4274)
Co-authored-by: bmcomfy <214909599+bmcomfy@users.noreply.github.com>
2025-06-25 23:57:37 +00:00
Jin Yi
63181a1ddd [Manager] Standardize Card Aspect Ratios & Enhance UI (#4271)
Co-authored-by: Claude <noreply@anthropic.com>
2025-06-25 12:34:19 -07:00
Jin Yi
e17ca7ce71 fix: node migration TypeError (#4260) 2025-06-25 03:01:40 -07:00
Comfy Org PR Bot
77d2cae301 1.23.2 (#4266)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-25 00:48:39 +00:00
Comfy Org PR Bot
164a4c4c25 [chore] Update Comfy Registry API types from comfy-api@af72ba5 (#4264)
Co-authored-by: bmcomfy <214909599+bmcomfy@users.noreply.github.com>
2025-06-24 14:57:41 -07:00
17 changed files with 762 additions and 207 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.23.1",
"version": "1.23.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.23.1",
"version": "1.23.2",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.23.1",
"version": "1.23.2",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -1,6 +1,5 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -33,6 +32,12 @@ vi.mock('@/stores/comfyManagerStore', () => ({
}))
}))
vi.mock('@/composables/nodePack/usePackUpdateStatus', () => ({
usePackUpdateStatus: vi.fn(() => ({
isUpdateAvailable: false
}))
}))
const mockToggle = vi.fn()
const mockHide = vi.fn()
const PopoverStub = {
@@ -78,9 +83,9 @@ describe('PackVersionBadge', () => {
it('renders with installed version from store', () => {
const wrapper = mountComponent()
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe('1.5.0') // From mockInstalledPacks
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe('1.5.0') // From mockInstalledPacks
})
it('falls back to latest_version when not installed', () => {
@@ -97,9 +102,9 @@ describe('PackVersionBadge', () => {
props: { nodePack: uninstalledPack }
})
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe('3.0.0') // From latest_version
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe('3.0.0') // From latest_version
})
it('falls back to NIGHTLY when no latest_version and not installed', () => {
@@ -113,9 +118,9 @@ describe('PackVersionBadge', () => {
props: { nodePack: noVersionPack }
})
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
})
it('falls back to NIGHTLY when nodePack.id is missing', () => {
@@ -127,16 +132,16 @@ describe('PackVersionBadge', () => {
props: { nodePack: invalidPack }
})
const button = wrapper.findComponent(Button)
expect(button.exists()).toBe(true)
expect(button.props('label')).toBe(SelectedVersion.NIGHTLY)
const badge = wrapper.find('[role="button"]')
expect(badge.exists()).toBe(true)
expect(badge.find('span').text()).toBe(SelectedVersion.NIGHTLY)
})
it('toggles the popover when button is clicked', async () => {
const wrapper = mountComponent()
// Click the button
await wrapper.findComponent(Button).trigger('click')
// Click the badge
await wrapper.find('[role="button"]').trigger('click')
// Verify that the toggle method was called
expect(mockToggle).toHaveBeenCalled()

View File

@@ -1,18 +1,23 @@
<template>
<div class="relative">
<Button
:label="installedVersion"
severity="secondary"
icon="pi pi-chevron-right"
icon-pos="right"
class="rounded-xl text-xs tracking-tighter p-0"
:pt="{
label: { class: 'pl-2 pr-0 py-0.5' },
icon: { class: 'text-xs pl-0 pr-2 py-0.5' }
}"
<div>
<div
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer px-2 py-1"
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700': fill }"
aria-haspopup="true"
role="button"
tabindex="0"
@click="toggleVersionSelector"
/>
@keydown.enter="toggleVersionSelector"
@keydown.space="toggleVersionSelector"
>
<i
v-if="isUpdateAvailable"
class="pi pi-arrow-circle-up text-blue-600"
style="font-size: 8px"
/>
<span>{{ installedVersion }}</span>
<i class="pi pi-chevron-right" style="font-size: 8px" />
</div>
<Popover
ref="popoverRef"
@@ -31,11 +36,11 @@
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref, watch } from 'vue'
import PackVersionSelectorPopover from '@/components/dialog/content/manager/PackVersionSelectorPopover.vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { SelectedVersion } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
@@ -43,11 +48,17 @@ import { isSemVer } from '@/utils/formatUtil'
const TRUNCATED_HASH_LENGTH = 7
const { nodePack, isSelected } = defineProps<{
const {
nodePack,
isSelected,
fill = true
} = defineProps<{
nodePack: components['schemas']['Node']
isSelected: boolean
fill?: boolean
}>()
const { isUpdateAvailable } = usePackUpdateStatus(nodePack)
const popoverRef = ref()
const managerStore = useComfyManagerStore()

View File

@@ -1,7 +1,7 @@
<template>
<Button
outlined
class="!m-0 p-0 rounded-lg"
class="!m-0 p-0 rounded-lg text-gray-900 dark-theme:text-gray-50"
:class="[
variant === 'black'
? 'bg-neutral-900 text-white border-neutral-900'
@@ -12,7 +12,7 @@
v-bind="$attrs"
@click="onClick"
>
<span class="py-2.5 px-3 whitespace-nowrap">
<span class="py-2 px-3 whitespace-nowrap">
<template v-if="loading">
{{ loadingMessage ?? $t('g.loading') }}
</template>

View File

@@ -1,5 +1,5 @@
<template>
<div :style="{ width: cssWidth, height: cssHeight }" class="overflow-hidden">
<div class="w-full aspect-[7/3] overflow-hidden">
<!-- default banner show -->
<div v-if="showDefaultBanner" class="w-full h-full">
<img
@@ -41,24 +41,12 @@ import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const {
nodePack,
width = '100%',
height = '12rem'
} = defineProps<{
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
width?: string
height?: string
}>()
const isImageError = ref(false)
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
const convertToCssValue = (value: string | number) =>
typeof value === 'number' ? `${value}rem` : value
const cssWidth = computed(() => convertToCssValue(width))
const cssHeight = computed(() => convertToCssValue(height))
</script>

View File

@@ -9,7 +9,12 @@
body: { class: 'p-0 flex flex-col w-full h-full rounded-lg gap-0' },
content: { class: 'flex-1 flex flex-col rounded-lg min-h-0' },
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
footer: { class: 'p-0 m-0' }
footer: {
class: 'p-0 m-0 flex flex-col gap-0',
style: {
borderTop: isLightTheme ? '1px solid #f4f4f4' : '1px solid #2C2C2C'
}
}
}"
>
<template #title>
@@ -29,75 +34,50 @@
</div>
</template>
<template v-else>
<div
class="self-stretch inline-flex flex-col justify-start items-start"
>
<div
class="px-4 py-3 inline-flex justify-start items-start cursor-pointer w-full"
>
<div
class="inline-flex flex-col justify-start items-start overflow-hidden gap-y-3 w-full"
<div class="pt-4 px-4 pb-3 w-full h-full">
<div class="flex flex-col gap-y-1 w-full h-full">
<span
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
>
<span
class="text-base font-bold truncate overflow-hidden text-ellipsis"
>
{{ nodePack.name }}
</span>
<p
v-if="nodePack.description"
class="flex-1 justify-start text-muted text-sm font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-5"
>
{{ nodePack.description }}
</p>
<div class="flex flex-col gap-y-2">
{{ nodePack.name }}
</span>
<p
v-if="nodePack.description"
class="flex-1 text-muted text-xs font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-4 mb-1 overflow-hidden"
>
{{ nodePack.description }}
</p>
<div class="flex flex-col gap-y-2">
<div class="flex-1 flex items-center gap-2">
<div v-if="nodesCount" class="p-2 pl-0 text-xs">
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
<PackVersionBadge
:node-pack="nodePack"
:is-selected="isSelected"
:fill="false"
/>
<div
class="self-stretch inline-flex justify-start items-center gap-1"
v-if="formattedLatestVersionDate"
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
>
<div
v-if="nodesCount"
class="pr-2 py-1 flex justify-center text-sm items-center gap-1"
>
<div
class="text-center justify-center font-medium leading-3"
>
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
</div>
<div class="px-2 py-1 flex justify-center items-center gap-1">
<div
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
>
<i class="pi pi-arrow-circle-up text-blue-600" />
</div>
<PackVersionBadge
:node-pack="nodePack"
:is-selected="isSelected"
/>
</div>
<div
v-if="formattedLatestVersionDate"
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
>
{{ formattedLatestVersionDate }}
</div>
</div>
<div class="flex">
<span
v-if="publisherName"
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
>
{{ publisherName }}
</span>
{{ formattedLatestVersionDate }}
</div>
</div>
<div class="flex">
<span
v-if="publisherName"
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
>
{{ publisherName }}
</span>
</div>
</div>
</div>
</div>
</template>
</template>
<template #footer>
<ContentDivider :width="0.1" />
<PackCardFooter :node-pack="nodePack" />
</template>
</Card>
@@ -110,12 +90,11 @@ import ProgressSpinner from 'primevue/progressspinner'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import PackBanner from '@/components/dialog/content/manager/packBanner/PackBanner.vue'
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import {
IsInstallingKey,
type MergedNodePack,
@@ -130,11 +109,15 @@ const { nodePack, isSelected = false } = defineProps<{
const { d } = useI18n()
const colorPaletteStore = useColorPaletteStore()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
const { isPackInstalled, isPackEnabled } = useComfyManagerStore()
const { isUpdateAvailable } = usePackUpdateStatus(nodePack)
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const isDisabled = computed(
@@ -167,14 +150,14 @@ const formattedLatestVersionDate = computed(() => {
position: relative;
}
.selected-card::before {
.selected-card::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 3px solid var(--p-primary-color);
border: 4px solid var(--p-primary-color);
border-radius: 0.5rem;
pointer-events: none;
z-index: 100;

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
class="min-h-12 flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
>
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
<i class="pi pi-download text-muted"></i>

View File

@@ -297,6 +297,7 @@ onMounted(async () => {
throw error
}
}
CORE_SETTINGS.forEach((setting) => {
settingStore.addSetting(setting)
})

View File

@@ -430,14 +430,7 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: 'Top',
name: 'Use new menu',
type: 'combo',
options: ['Disabled', 'Top', 'Bottom'],
migrateDeprecatedValue: (value: string) => {
// Floating is now supported by dragging the docked actionbar off.
if (value === 'Floating') {
return 'Top'
}
return value
}
options: ['Disabled', 'Top', 'Bottom']
},
{
id: 'Comfy.Workflow.WorkflowTabsPosition',
@@ -470,15 +463,7 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'hidden',
defaultValue: [] as Keybinding[],
versionAdded: '1.3.7',
versionModified: '1.7.3',
migrateDeprecatedValue: (value: any[]) => {
return value.map((keybinding) => {
if (keybinding['targetSelector'] === '#graph-canvas') {
keybinding['targetElementId'] = 'graph-canvas'
}
return keybinding
})
}
versionModified: '1.7.3'
},
{
id: 'Comfy.Keybinding.NewBindings',
@@ -716,11 +701,7 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'The active color palette id',
type: 'hidden',
defaultValue: 'dark',
versionModified: '1.6.7',
migrateDeprecatedValue(value: string) {
// Legacy custom palettes were prefixed with 'custom_'
return value.startsWith('custom_') ? value.replace('custom_', '') : value
}
versionModified: '1.6.7'
},
{
id: 'Comfy.CustomColorPalettes',

View File

@@ -46,22 +46,26 @@ export function transformNodeDefV1ToV2(
const outputs: OutputSpecV2[] = []
if (nodeDefV1.output) {
nodeDefV1.output.forEach((outputType, index) => {
const outputSpec: OutputSpecV2 = {
index,
name: nodeDefV1.output_name?.[index] || `output_${index}`,
type: Array.isArray(outputType) ? 'COMBO' : outputType,
is_list: nodeDefV1.output_is_list?.[index] || false,
tooltip: nodeDefV1.output_tooltips?.[index]
}
if (Array.isArray(nodeDefV1.output)) {
nodeDefV1.output.forEach((outputType, index) => {
const outputSpec: OutputSpecV2 = {
index,
name: nodeDefV1.output_name?.[index] || `output_${index}`,
type: Array.isArray(outputType) ? 'COMBO' : outputType,
is_list: nodeDefV1.output_is_list?.[index] || false,
tooltip: nodeDefV1.output_tooltips?.[index]
}
// Add options for combo outputs
if (Array.isArray(outputType)) {
outputSpec.options = outputType
}
// Add options for combo outputs
if (Array.isArray(outputType)) {
outputSpec.options = outputType
}
outputs.push(outputSpec)
})
outputs.push(outputSpec)
})
} else {
console.warn('nodeDefV1.output is not an array:', nodeDefV1.output)
}
}
// Create the V2 node definition

View File

@@ -7,6 +7,7 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { SettingParams } from '@/types/settingTypes'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { runSettingMigrations } from '@/utils/migration/settingsMigration'
export const getSettingInfo = (setting: SettingParams) => {
const parts = setting.category || setting.id.split('.')
@@ -20,10 +21,6 @@ export interface SettingTreeNode extends TreeNode {
data?: SettingParams
}
function tryMigrateDeprecatedValue(setting: SettingParams, value: any) {
return setting?.migrateDeprecatedValue?.(value) ?? value
}
function onChange(setting: SettingParams, newValue: any, oldValue: any) {
if (setting?.onChange) {
setting.onChange(newValue, oldValue)
@@ -54,16 +51,12 @@ export const useSettingStore = defineStore('setting', () => {
async function set<K extends keyof Settings>(key: K, value: Settings[K]) {
// Clone the incoming value to prevent external mutations
const clonedValue = _.cloneDeep(value)
const newValue = tryMigrateDeprecatedValue(
settingsById.value[key],
clonedValue
)
const oldValue = get(key)
if (newValue === oldValue) return
if (clonedValue === oldValue) return
onChange(settingsById.value[key], newValue, oldValue)
settingValues.value[key] = newValue
await api.storeSetting(key, newValue)
onChange(settingsById.value[key], clonedValue, oldValue)
settingValues.value[key] = clonedValue
await api.storeSetting(key, clonedValue)
}
/**
@@ -102,12 +95,7 @@ export const useSettingStore = defineStore('setting', () => {
settingsById.value[setting.id] = setting
if (settingValues.value[setting.id] !== undefined) {
settingValues.value[setting.id] = tryMigrateDeprecatedValue(
setting,
settingValues.value[setting.id]
)
}
// Trigger onChange callback with current value
onChange(setting, get(setting.id), undefined)
}
@@ -122,6 +110,9 @@ export const useSettingStore = defineStore('setting', () => {
)
}
settingValues.value = await api.getSettings()
// Run migrations after loading values but before settings are registered
await runSettingMigrations()
}
return {

View File

@@ -8742,16 +8742,42 @@ export interface components {
MoonvalleyUploadResponse: {
access_url?: string
}
/** @description GitHub release webhook payload based on official webhook documentation */
GithubReleaseWebhook: {
/** @description The action performed on the release */
action: string
/**
* @description The action performed on the release
* @enum {string}
*/
action:
| 'published'
| 'unpublished'
| 'created'
| 'edited'
| 'deleted'
| 'prereleased'
| 'released'
/** @description The release object */
release: {
/** @description The ID of the release */
id: number
/** @description The node ID of the release */
node_id: string
/** @description The API URL of the release */
url: string
/** @description The HTML URL of the release */
html_url: string
/** @description The URL to the release assets */
assets_url?: string
/** @description The URL to upload release assets */
upload_url?: string
/** @description The tag name of the release */
tag_name: string
/** @description The branch or commit the release was created from */
target_commitish: string
/** @description The name of the release */
name: string
name?: string | null
/** @description The release notes/body */
body: string
body?: string | null
/** @description Whether the release is a draft */
draft: boolean
/** @description Whether the release is a prerelease */
@@ -8760,33 +8786,228 @@ export interface components {
* Format: date-time
* @description When the release was created
*/
created_at?: string
created_at: string
/**
* Format: date-time
* @description When the release was published
*/
published_at?: string
published_at?: string | null
author: components['schemas']['GithubUser']
/** @description URL to the tarball */
tarball_url?: string
tarball_url: string
/** @description URL to the zipball */
zipball_url?: string
/** @description The branch or commit the release was created from */
target_commitish: string
}
repository: {
/** @description The name of the repository */
name: string
/** @description The full name of the repository (owner/repo) */
full_name: string
/** @description The HTML URL of the repository */
html_url: string
/** @description The clone URL of the repository */
clone_url: string
zipball_url: string
/** @description Array of release assets */
assets: components['schemas']['GithubReleaseAsset'][]
}
repository: components['schemas']['GithubRepository']
sender: components['schemas']['GithubUser']
organization?: components['schemas']['GithubOrganization']
installation?: components['schemas']['GithubInstallation']
enterprise?: components['schemas']['GithubEnterprise']
}
/** @description A GitHub user */
GithubUser: {
/** @description The user's login name */
login: string
/** @description The user's ID */
id: number
/** @description The user's node ID */
node_id: string
/** @description URL to the user's avatar */
avatar_url: string
/** @description The user's gravatar ID */
gravatar_id?: string | null
/** @description The API URL of the user */
url: string
/** @description The HTML URL of the user */
html_url: string
/**
* @description The type of user
* @enum {string}
*/
type: 'Bot' | 'User' | 'Organization'
/** @description Whether the user is a site admin */
site_admin: boolean
}
/** @description A GitHub repository */
GithubRepository: {
/** @description The repository ID */
id: number
/** @description The repository node ID */
node_id: string
/** @description The name of the repository */
name: string
/** @description The full name of the repository (owner/repo) */
full_name: string
/** @description Whether the repository is private */
private: boolean
owner: components['schemas']['GithubUser']
/** @description The HTML URL of the repository */
html_url: string
/** @description The repository description */
description?: string | null
/** @description Whether the repository is a fork */
fork: boolean
/** @description The API URL of the repository */
url: string
/** @description The clone URL of the repository */
clone_url: string
/** @description The git URL of the repository */
git_url: string
/** @description The SSH URL of the repository */
ssh_url: string
/** @description The default branch of the repository */
default_branch: string
/**
* Format: date-time
* @description When the repository was created
*/
created_at: string
/**
* Format: date-time
* @description When the repository was last updated
*/
updated_at: string
/**
* Format: date-time
* @description When the repository was last pushed to
*/
pushed_at: string
}
/** @description A GitHub release asset */
GithubReleaseAsset: {
/** @description The asset ID */
id: number
/** @description The asset node ID */
node_id: string
/** @description The name of the asset */
name: string
/** @description The label of the asset */
label?: string | null
/** @description The content type of the asset */
content_type: string
/**
* @description The state of the asset
* @enum {string}
*/
state: 'uploaded' | 'open'
/** @description The size of the asset in bytes */
size: number
/** @description The number of downloads */
download_count: number
/**
* Format: date-time
* @description When the asset was created
*/
created_at: string
/**
* Format: date-time
* @description When the asset was last updated
*/
updated_at: string
/** @description The browser download URL */
browser_download_url: string
uploader: components['schemas']['GithubUser']
}
/** @description A GitHub organization */
GithubOrganization: {
/** @description The organization's login name */
login: string
/** @description The organization ID */
id: number
/** @description The organization node ID */
node_id: string
/** @description The API URL of the organization */
url: string
/** @description The API URL of the organization's repositories */
repos_url: string
/** @description The API URL of the organization's events */
events_url: string
/** @description The API URL of the organization's hooks */
hooks_url: string
/** @description The API URL of the organization's issues */
issues_url: string
/** @description The API URL of the organization's members */
members_url: string
/** @description The API URL of the organization's public members */
public_members_url: string
/** @description URL to the organization's avatar */
avatar_url: string
/** @description The organization description */
description?: string | null
}
/** @description A GitHub App installation */
GithubInstallation: {
/** @description The installation ID */
id: number
account: components['schemas']['GithubUser']
/**
* @description Repository selection for the installation
* @enum {string}
*/
repository_selection: 'selected' | 'all'
/** @description The API URL for access tokens */
access_tokens_url: string
/** @description The API URL for repositories */
repositories_url: string
/** @description The HTML URL of the installation */
html_url: string
/** @description The GitHub App ID */
app_id: number
/** @description The target ID */
target_id: number
/** @description The target type */
target_type: string
/** @description The installation permissions */
permissions: Record<string, never>
/** @description The events the installation subscribes to */
events: string[]
/**
* Format: date-time
* @description When the installation was created
*/
created_at: string
/**
* Format: date-time
* @description When the installation was last updated
*/
updated_at: string
/** @description The single file name if applicable */
single_file_name?: string | null
}
/** @description A GitHub enterprise */
GithubEnterprise: {
/** @description The enterprise ID */
id: number
/** @description The enterprise slug */
slug: string
/** @description The enterprise name */
name: string
/** @description The enterprise node ID */
node_id: string
/** @description URL to the enterprise avatar */
avatar_url: string
/** @description The enterprise description */
description?: string | null
/** @description The enterprise website URL */
website_url?: string | null
/** @description The HTML URL of the enterprise */
html_url: string
/**
* Format: date-time
* @description When the enterprise was created
*/
created_at: string
/**
* Format: date-time
* @description When the enterprise was last updated
*/
updated_at: string
}
ReleaseNote: {
/** @description Unique identifier for the release note */
id?: number
id: number
/**
* @description The project this release note belongs to
* @enum {string}
@@ -8805,7 +9026,7 @@ export interface components {
* Format: date-time
* @description When the release note was published
*/
published_at?: string
published_at: string
}
}
responses: never
@@ -11011,6 +11232,8 @@ export interface operations {
sort?: string[]
/** @description node_id to use as filter */
node_id?: string[]
/** @description Comfy UI version */
comfyui_version?: string
/** @description The platform requesting the nodes */
form_factor?: string
}
@@ -11584,6 +11807,10 @@ export interface operations {
project: 'comfyui' | 'comfyui_frontend' | 'desktop'
/** @description The current version to filter release notes */
current_version?: string
/** @description The locale for the release notes */
locale?: 'en' | 'es' | 'fr' | 'ja' | 'ko' | 'ru' | 'zh'
/** @description The platform requesting the release notes */
form_factor?: string
}
header?: never
path?: never
@@ -11623,7 +11850,20 @@ export interface operations {
processReleaseWebhook: {
parameters: {
query?: never
header?: never
header: {
/** @description The name of the event that triggered the delivery */
'X-GitHub-Event': 'release'
/** @description A globally unique identifier (GUID) to identify the event */
'X-GitHub-Delivery': string
/** @description The unique identifier of the webhook */
'X-GitHub-Hook-ID': string
/** @description HMAC hex digest of the request body using SHA-256 hash function */
'X-Hub-Signature-256'?: string
/** @description The type of resource where the webhook was created */
'X-GitHub-Hook-Installation-Target-Type'?: string
/** @description The unique identifier of the resource where the webhook was created */
'X-GitHub-Hook-Installation-Target-ID'?: string
}
path?: never
cookie?: never
}
@@ -11649,6 +11889,15 @@ export interface operations {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Validation failed or endpoint has been spammed */
422: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Internal server error */
500: {
headers: {

View File

@@ -43,8 +43,6 @@ export interface SettingParams extends FormItem {
category?: string[]
experimental?: boolean
deprecated?: boolean
// Deprecated values are mapped to new values.
migrateDeprecatedValue?: (value: any) => any
// Version of the setting when it was added
versionAdded?: string
// Version of the setting when it was last modified

View File

@@ -0,0 +1,88 @@
import type { Keybinding } from '@/schemas/keyBindingSchema'
import { useSettingStore } from '@/stores/settingStore'
export interface SettingMigration {
condition: () => boolean
migrate: () => Promise<void>
}
/**
* Setting value migrations that transform deprecated values to new formats.
* These run after settings are loaded from the server but before they are
* registered, ensuring settings have valid values when the app initializes.
*/
export const SETTING_MIGRATIONS: SettingMigration[] = [
// Migrate Comfy.UseNewMenu "Floating" value to "Top"
{
condition: () => {
const settingStore = useSettingStore()
return (
settingStore.exists('Comfy.UseNewMenu') &&
(settingStore.get('Comfy.UseNewMenu') as string) === 'Floating'
)
},
migrate: async () => {
const settingStore = useSettingStore()
await settingStore.set('Comfy.UseNewMenu', 'Top')
}
},
// Migrate Comfy.Keybinding.UnsetBindings targetSelector to targetElementId
{
condition: () => {
const settingStore = useSettingStore()
if (!settingStore.exists('Comfy.Keybinding.UnsetBindings')) return false
const keybindings = settingStore.get(
'Comfy.Keybinding.UnsetBindings'
) as Keybinding[]
return keybindings.some((kb: any) => 'targetSelector' in kb)
},
migrate: async () => {
const settingStore = useSettingStore()
const keybindings = settingStore.get(
'Comfy.Keybinding.UnsetBindings'
) as Keybinding[]
const migrated = keybindings.map((keybinding) => {
// Create a new object to avoid mutating the original
const newKeybinding = { ...keybinding }
if (
'targetSelector' in newKeybinding &&
newKeybinding['targetSelector'] === '#graph-canvas'
) {
newKeybinding['targetElementId'] = 'graph-canvas'
delete newKeybinding['targetSelector']
}
return newKeybinding
})
await settingStore.set('Comfy.Keybinding.UnsetBindings', migrated)
}
},
// Migrate Comfy.ColorPalette custom_ prefix
{
condition: () => {
const settingStore = useSettingStore()
return (
settingStore.exists('Comfy.ColorPalette') &&
(settingStore.get('Comfy.ColorPalette') as string).startsWith('custom_')
)
},
migrate: async () => {
const settingStore = useSettingStore()
const value = settingStore.get('Comfy.ColorPalette') as string
await settingStore.set('Comfy.ColorPalette', value.replace('custom_', ''))
}
}
]
/**
* Runs all setting migrations that meet their conditions.
* This is called after loadSettingValues() to ensure all deprecated
* setting values are migrated to their current format before the
* application starts using them.
*/
export async function runSettingMigrations(): Promise<void> {
for (const migration of SETTING_MIGRATIONS) {
if (migration.condition()) {
await migration.migrate()
}
}
}

View File

@@ -92,21 +92,6 @@ describe('useSettingStore', () => {
'Setting test.setting must have a unique ID.'
)
})
it('should migrate deprecated values', () => {
const setting: SettingParams = {
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default',
migrateDeprecatedValue: (value: string) => value.toUpperCase()
}
store.settingValues['test.setting'] = 'oldvalue'
store.addSetting(setting)
expect(store.settingValues['test.setting']).toBe('OLDVALUE')
})
})
describe('get and set', () => {

View File

@@ -0,0 +1,271 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import { useSettingStore } from '@/stores/settingStore'
import {
SETTING_MIGRATIONS,
runSettingMigrations
} from '@/utils/migration/settingsMigration'
// Mock the api
vi.mock('@/scripts/api', () => ({
api: {
getSettings: vi.fn(),
storeSetting: vi.fn()
}
}))
// Mock the app
vi.mock('@/scripts/app', () => ({
app: {
ui: {
settings: {
dispatchChange: vi.fn()
}
}
}
}))
describe('settingsMigration', () => {
let store: ReturnType<typeof useSettingStore>
beforeEach(() => {
setActivePinia(createPinia())
store = useSettingStore()
vi.clearAllMocks()
// Mock the store methods to avoid needing registered settings
vi.spyOn(store, 'set').mockImplementation(async (key, value) => {
store.settingValues[key] = value
await api.storeSetting(key, value)
})
vi.spyOn(store, 'get').mockImplementation((key) => {
return store.settingValues[key]
})
vi.spyOn(store, 'exists').mockImplementation((key) => {
return key in store.settingValues
})
})
describe('Comfy.UseNewMenu migration', () => {
it('should migrate "Floating" value to "Top"', async () => {
// Setup initial state with old value
store.settingValues = { 'Comfy.UseNewMenu': 'Floating' }
// Check condition
const migration = SETTING_MIGRATIONS[0]
expect(migration.condition()).toBe(true)
// Run migration
await migration.migrate()
// Verify the value was updated
expect(store.settingValues['Comfy.UseNewMenu']).toBe('Top')
expect(api.storeSetting).toHaveBeenCalledWith('Comfy.UseNewMenu', 'Top')
})
it('should not migrate when value is not "Floating"', () => {
// Setup with different value
store.settingValues = { 'Comfy.UseNewMenu': 'Bottom' }
// Check condition
const migration = SETTING_MIGRATIONS[0]
expect(migration.condition()).toBe(false)
})
it('should not migrate when setting does not exist', () => {
// No settings
store.settingValues = {}
// Check condition
const migration = SETTING_MIGRATIONS[0]
expect(migration.condition()).toBe(false)
})
})
describe('Comfy.Keybinding.UnsetBindings migration', () => {
it('should migrate targetSelector to targetElementId', async () => {
// Setup with old format
store.settingValues = {
'Comfy.Keybinding.UnsetBindings': [
{ targetSelector: '#graph-canvas', key: 'a' },
{ targetSelector: '#other', key: 'b' }
]
}
// Check condition
const migration = SETTING_MIGRATIONS[1]
expect(migration.condition()).toBe(true)
// Run migration
await migration.migrate()
// Verify the migration
const result = store.settingValues['Comfy.Keybinding.UnsetBindings']
expect(result).toEqual([
{ targetElementId: 'graph-canvas', key: 'a' },
{ targetSelector: '#other', key: 'b' } // Only #graph-canvas is migrated
])
expect(api.storeSetting).toHaveBeenCalledWith(
'Comfy.Keybinding.UnsetBindings',
result
)
})
it('should delete targetSelector property after migration', async () => {
// Setup with old format
store.settingValues = {
'Comfy.Keybinding.UnsetBindings': [
{ targetSelector: '#graph-canvas', key: 'a' }
]
}
// Run migration
await SETTING_MIGRATIONS[1].migrate()
// Verify targetSelector is deleted and targetElementId is added
const result = store.settingValues['Comfy.Keybinding.UnsetBindings'][0]
expect(result).not.toHaveProperty('targetSelector')
expect(result).toHaveProperty('targetElementId', 'graph-canvas')
expect(result).toHaveProperty('key', 'a')
})
it('should not migrate when all keybindings use new format', () => {
// Setup with new format
store.settingValues = {
'Comfy.Keybinding.UnsetBindings': [
{ targetElementId: 'graph-canvas', key: 'a' }
]
}
// Check condition
const migration = SETTING_MIGRATIONS[1]
expect(migration.condition()).toBe(false)
})
it('should not migrate when setting does not exist', () => {
// No settings
store.settingValues = {}
// Check condition
const migration = SETTING_MIGRATIONS[1]
expect(migration.condition()).toBe(false)
})
it('should handle empty keybindings array', () => {
// Empty array
store.settingValues = { 'Comfy.Keybinding.UnsetBindings': [] }
// Check condition
const migration = SETTING_MIGRATIONS[1]
expect(migration.condition()).toBe(false)
})
})
describe('Comfy.ColorPalette migration', () => {
it('should remove "custom_" prefix', async () => {
// Setup with old format
store.settingValues = { 'Comfy.ColorPalette': 'custom_mytheme' }
// Check condition
const migration = SETTING_MIGRATIONS[2]
expect(migration.condition()).toBe(true)
// Run migration
await migration.migrate()
// Verify the migration
expect(store.settingValues['Comfy.ColorPalette']).toBe('mytheme')
expect(api.storeSetting).toHaveBeenCalledWith(
'Comfy.ColorPalette',
'mytheme'
)
})
it('should not migrate when value does not start with "custom_"', () => {
// Setup with value that doesn't need migration
store.settingValues = { 'Comfy.ColorPalette': 'dark' }
// Check condition
const migration = SETTING_MIGRATIONS[2]
expect(migration.condition()).toBe(false)
})
it('should not migrate when setting does not exist', () => {
// No settings
store.settingValues = {}
// Check condition
const migration = SETTING_MIGRATIONS[2]
expect(migration.condition()).toBe(false)
})
})
describe('runSettingMigrations', () => {
it('should run all applicable migrations', async () => {
// Setup state that triggers all migrations
store.settingValues = {
'Comfy.UseNewMenu': 'Floating',
'Comfy.Keybinding.UnsetBindings': [
{ targetSelector: '#graph-canvas', key: 'a' }
],
'Comfy.ColorPalette': 'custom_mytheme'
}
// Run all migrations
await runSettingMigrations()
// Verify all migrations ran
expect(store.settingValues['Comfy.UseNewMenu']).toBe('Top')
expect(store.settingValues['Comfy.Keybinding.UnsetBindings']).toEqual([
{ targetElementId: 'graph-canvas', key: 'a' }
])
expect(store.settingValues['Comfy.ColorPalette']).toBe('mytheme')
// Verify all API calls were made
expect(api.storeSetting).toHaveBeenCalledTimes(3)
})
it('should only run migrations that meet their conditions', async () => {
// Setup state that only triggers one migration
store.settingValues = {
'Comfy.UseNewMenu': 'Bottom', // Won't migrate
'Comfy.ColorPalette': 'custom_mytheme' // Will migrate
}
// Run migrations
await runSettingMigrations()
// Verify only one migration ran
expect(store.settingValues['Comfy.UseNewMenu']).toBe('Bottom')
expect(store.settingValues['Comfy.ColorPalette']).toBe('mytheme')
// Only one API call
expect(api.storeSetting).toHaveBeenCalledTimes(1)
expect(api.storeSetting).toHaveBeenCalledWith(
'Comfy.ColorPalette',
'mytheme'
)
})
it('should handle no migrations needed', async () => {
// Setup state that doesn't trigger any migrations
store.settingValues = {
'Comfy.UseNewMenu': 'Top',
'Comfy.Keybinding.UnsetBindings': [
{ targetElementId: 'graph-canvas', key: 'a' }
],
'Comfy.ColorPalette': 'dark'
}
// Run migrations
await runSettingMigrations()
// No API calls should be made
expect(api.storeSetting).not.toHaveBeenCalled()
})
})
})