Merge remote-tracking branch 'origin/main' into fix/remove-any-types-part8

This commit is contained in:
Johnpaul
2026-01-21 02:25:22 +01:00
55 changed files with 564 additions and 456 deletions

View File

@@ -28,7 +28,7 @@
/>
</div>
<div
class="node-actions touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
class="node-actions flex gap-1 touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
>
<slot name="actions" :node="props.node" />
</div>

View File

@@ -25,13 +25,31 @@ const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return nodes.map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
.filter(
(w) =>
!(w.options?.canvasOnly || w.options?.hidden || w.options?.advanced)
)
.map((widget) => ({ node, widget }))
return { widgets: shownWidgets, node }
})
})
const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
return nodes
.map((node) => {
const { widgets = [] } = node
const advancedWidgets = widgets
.filter(
(w) =>
!(w.options?.canvasOnly || w.options?.hidden) && w.options?.advanced
)
.map((widget) => ({ node, widget }))
return { widgets: advancedWidgets, node }
})
.filter(({ widgets }) => widgets.length > 0)
})
const isMultipleNodesSelected = computed(
() => widgetsSectionDataList.value.length > 1
)
@@ -56,6 +74,12 @@ const label = computed(() => {
: t('rightSidePanel.inputsNone')
: undefined // SectionWidgets display node titles by default
})
const advancedLabel = computed(() => {
return !mustShowNodeTitle && !isMultipleNodesSelected.value
? t('rightSidePanel.advancedInputs')
: undefined // SectionWidgets display node titles by default
})
</script>
<template>
@@ -93,4 +117,16 @@ const label = computed(() => {
class="border-b border-interface-stroke"
/>
</TransitionGroup>
<template v-if="advancedWidgetsSectionDataList.length > 0 && !isSearching">
<SectionWidgets
v-for="{ widgets, node } in advancedWidgetsSectionDataList"
:key="`advanced-${node.id}`"
:collapse="true"
:node
:label="advancedLabel"
:widgets
:show-locate-button="isMultipleNodesSelected"
class="border-b border-interface-stroke"
/>
</template>
</template>

View File

@@ -123,8 +123,7 @@ export const useContextMenuTranslation = () => {
}
// for capture translation text of input and widget
const extraInfo = (options.extra ||
options.parentMenu?.options?.extra) as
const extraInfo = (options.extra || options.parentMenu?.options?.extra) as
| { inputs?: INodeInputSlot[]; widgets?: IWidget[] }
| undefined
// widgets and inputs

View File

@@ -1,5 +1,6 @@
import { computed, reactive, readonly } from 'vue'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
@@ -95,6 +96,8 @@ export function useFeatureFlags() {
)
},
get teamWorkspacesEnabled() {
if (!isCloud) return false
return (
remoteConfig.value.team_workspaces_enabled ??
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)

View File

@@ -1350,12 +1350,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
})
function inner_clicked(
this: ContextMenu<string>,
this: ContextMenuDivElement,
v?: string | IContextMenuValue<string>
) {
if (!node || typeof v === 'string' || !v?.value) return
const rect = this.root.getBoundingClientRect()
const rect = this.getBoundingClientRect()
canvas.showEditPropertyValue(node, v.value, {
position: [rect.left, rect.top]
})

View File

@@ -158,6 +158,7 @@
"choose_file_to_upload": "choose file to upload",
"capture": "capture",
"nodes": "Nodes",
"nodesCount": "{count} nodes | {count} node | {count} nodes",
"community": "Community",
"all": "All",
"versionMismatchWarning": "Version Compatibility Warning",

View File

@@ -1,40 +1,42 @@
<template>
<div class="flex gap-3">
<div class="flex gap-3 items-center">
<SearchBox
:model-value="searchQuery"
:placeholder="$t('sideToolbar.searchAssets') + '...'"
@update:model-value="handleSearchChange"
/>
<MediaAssetFilterButton
v-if="isCloud"
v-tooltip.top="{ value: $t('assetBrowser.filterBy') }"
size="md"
>
<template #default="{ close }">
<MediaAssetFilterMenu
:media-type-filters="mediaTypeFilters"
:close="close"
@update:media-type-filters="handleMediaTypeFiltersChange"
/>
</template>
</MediaAssetFilterButton>
<AssetSortButton
v-if="isCloud"
v-tooltip.top="{ value: $t('assetBrowser.sortBy') }"
size="md"
>
<template #default="{ close }">
<MediaAssetSortMenu
v-model:sort-by="sortBy"
:show-generation-time-sort
:close="close"
/>
</template>
</AssetSortButton>
<MediaAssetViewModeToggle
v-if="isQueuePanelV2Enabled"
v-model:view-mode="viewMode"
/>
<div class="flex gap-1.5 items-center">
<MediaAssetFilterButton
v-if="isCloud"
v-tooltip.top="{ value: $t('assetBrowser.filterBy') }"
size="md"
>
<template #default="{ close }">
<MediaAssetFilterMenu
:media-type-filters
:close
@update:media-type-filters="handleMediaTypeFiltersChange"
/>
</template>
</MediaAssetFilterButton>
<AssetSortButton
v-if="isCloud"
v-tooltip.top="{ value: $t('assetBrowser.sortBy') }"
size="md"
>
<template #default="{ close }">
<MediaAssetSortMenu
v-model:sort-by="sortBy"
:show-generation-time-sort
:close
/>
</template>
</AssetSortButton>
<MediaAssetViewModeToggle
v-if="isQueuePanelV2Enabled"
v-model:view-mode="viewMode"
/>
</div>
</div>
</template>

View File

@@ -9,6 +9,7 @@ type Distribution = 'desktop' | 'localhost' | 'cloud'
declare global {
const __DISTRIBUTION__: Distribution
const __IS_NIGHTLY__: boolean
}
/** Current distribution - replaced at compile time */
@@ -18,3 +19,10 @@ const DISTRIBUTION: Distribution = __DISTRIBUTION__
export const isDesktop = DISTRIBUTION === 'desktop' || isElectron() // TODO: replace with build var
export const isCloud = DISTRIBUTION === 'cloud'
// export const isLocalhost = DISTRIBUTION === 'localhost' || (!isDesktop && !isCloud)
/**
* Whether this is a nightly build (from main branch).
* Nightly builds may show experimental features and surveys.
* @public
*/
export const isNightly = __IS_NIGHTLY__

View File

@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const STORAGE_KEY = 'Comfy.FeatureUsage'
describe('useFeatureUsageTracker', () => {
beforeEach(() => {
localStorage.clear()
vi.resetModules()
})
afterEach(() => {
localStorage.clear()
})
it('initializes with zero count for new feature', async () => {
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const { useCount } = useFeatureUsageTracker('test-feature')
expect(useCount.value).toBe(0)
})
it('increments count on trackUsage', async () => {
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const { useCount, trackUsage } = useFeatureUsageTracker('test-feature')
expect(useCount.value).toBe(0)
trackUsage()
expect(useCount.value).toBe(1)
trackUsage()
expect(useCount.value).toBe(2)
})
it('sets firstUsed only on first use', async () => {
vi.useFakeTimers()
const firstTs = 1000000
vi.setSystemTime(firstTs)
try {
const { useFeatureUsageTracker } =
await import('./useFeatureUsageTracker')
const { usage, trackUsage } = useFeatureUsageTracker('test-feature')
trackUsage()
expect(usage.value?.firstUsed).toBe(firstTs)
vi.setSystemTime(firstTs + 5000)
trackUsage()
expect(usage.value?.firstUsed).toBe(firstTs)
} finally {
vi.useRealTimers()
}
})
it('updates lastUsed on each use', async () => {
vi.useFakeTimers()
try {
const { useFeatureUsageTracker } =
await import('./useFeatureUsageTracker')
const { usage, trackUsage } = useFeatureUsageTracker('test-feature')
trackUsage()
const firstLastUsed = usage.value?.lastUsed ?? 0
vi.advanceTimersByTime(10)
trackUsage()
expect(usage.value?.lastUsed).toBeGreaterThan(firstLastUsed)
} finally {
vi.useRealTimers()
}
})
it('reset clears feature data', async () => {
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const { useCount, trackUsage, reset } =
useFeatureUsageTracker('test-feature')
trackUsage()
trackUsage()
expect(useCount.value).toBe(2)
reset()
expect(useCount.value).toBe(0)
})
it('tracks multiple features independently', async () => {
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const featureA = useFeatureUsageTracker('feature-a')
const featureB = useFeatureUsageTracker('feature-b')
featureA.trackUsage()
featureA.trackUsage()
featureB.trackUsage()
expect(featureA.useCount.value).toBe(2)
expect(featureB.useCount.value).toBe(1)
})
it('persists to localStorage', async () => {
vi.useFakeTimers()
try {
const { useFeatureUsageTracker } =
await import('./useFeatureUsageTracker')
const { trackUsage } = useFeatureUsageTracker('persisted-feature')
trackUsage()
await vi.runAllTimersAsync()
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
expect(stored['persisted-feature']?.useCount).toBe(1)
} finally {
vi.useRealTimers()
}
})
it('loads existing data from localStorage', async () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
'existing-feature': { useCount: 5, firstUsed: 1000, lastUsed: 2000 }
})
)
vi.resetModules()
const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker')
const { useCount } = useFeatureUsageTracker('existing-feature')
expect(useCount.value).toBe(5)
})
})

View File

@@ -0,0 +1,46 @@
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
interface FeatureUsage {
useCount: number
firstUsed: number
lastUsed: number
}
type FeatureUsageRecord = Record<string, FeatureUsage>
const STORAGE_KEY = 'Comfy.FeatureUsage'
/**
* Tracks feature usage for survey eligibility.
* Persists to localStorage.
*/
export function useFeatureUsageTracker(featureId: string) {
const usageData = useStorage<FeatureUsageRecord>(STORAGE_KEY, {})
const usage = computed(() => usageData.value[featureId])
const useCount = computed(() => usage.value?.useCount ?? 0)
function trackUsage() {
const now = Date.now()
const existing = usageData.value[featureId]
usageData.value[featureId] = {
useCount: (existing?.useCount ?? 0) + 1,
firstUsed: existing?.firstUsed ?? now,
lastUsed: now
}
}
function reset() {
delete usageData.value[featureId]
}
return {
usage,
useCount,
trackUsage,
reset
}
}

View File

@@ -482,18 +482,30 @@ const lgraphNode = computed(() => {
const showAdvancedInputsButton = computed(() => {
const node = lgraphNode.value
if (!node || !(node instanceof SubgraphNode)) return false
if (!node) return false
// Check if there are hidden inputs (widgets not promoted)
const interiorNodes = node.subgraph.nodes
const allInteriorWidgets = interiorNodes.flatMap((n) => n.widgets ?? [])
// For subgraph nodes: check for unpromoted widgets
if (node instanceof SubgraphNode) {
const interiorNodes = node.subgraph.nodes
const allInteriorWidgets = interiorNodes.flatMap((n) => n.widgets ?? [])
return allInteriorWidgets.some((w) => !w.computedDisabled && !w.promoted)
}
return allInteriorWidgets.some((w) => !w.computedDisabled && !w.promoted)
// For regular nodes: show button if there are advanced widgets and they're currently hidden
const hasAdvancedWidgets = nodeData.widgets?.some((w) => w.options?.advanced)
return hasAdvancedWidgets && !node.showAdvanced
})
function handleShowAdvancedInputs() {
const rightSidePanelStore = useRightSidePanelStore()
rightSidePanelStore.focusSection('advanced-inputs')
const node = lgraphNode.value
if (!node) return
if (node instanceof SubgraphNode) {
const rightSidePanelStore = useRightSidePanelStore()
rightSidePanelStore.focusSection('advanced-inputs')
} else {
node.showAdvanced = true
}
}
const nodeMedia = computed(() => {

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import RadioButton from 'primevue/radiobutton'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ControlOptions } from '@/types/simplifiedWidget'
@@ -14,14 +13,8 @@ type ControlOption = {
title: string
}
const popover = ref()
const settingStore = useSettingStore()
const toggle = (event: Event) => {
popover.value.toggle(event)
}
defineExpose({ toggle })
const controlOptions: ControlOption[] = [
{
mode: 'fixed',
@@ -57,70 +50,63 @@ const controlMode = defineModel<ControlOptions>()
</script>
<template>
<Popover
ref="popover"
class="bg-interface-panel-surface border border-interface-stroke rounded-lg"
>
<div class="w-113 max-w-md p-4 space-y-4">
<div class="text-sm text-muted-foreground leading-tight">
{{ $t('widgets.valueControl.header.prefix') }}
<span class="text-base-foreground font-medium">
{{
widgetControlMode === 'before'
? $t('widgets.valueControl.header.before')
: $t('widgets.valueControl.header.after')
}}
</span>
{{ $t('widgets.valueControl.header.postfix') }}
</div>
<div class="w-113 max-w-md p-4 space-y-4">
<div class="text-sm text-muted-foreground leading-tight">
{{ $t('widgets.valueControl.header.prefix') }}
<span class="text-base-foreground font-medium">
{{
widgetControlMode === 'before'
? $t('widgets.valueControl.header.before')
: $t('widgets.valueControl.header.after')
}}
</span>
{{ $t('widgets.valueControl.header.postfix') }}
</div>
<div class="space-y-2">
<div
v-for="option in controlOptions"
:key="option.mode"
class="flex items-center justify-between py-2 gap-7"
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<div
class="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0 bg-secondary-background border border-border-subtle"
<div class="space-y-2">
<div
v-for="option in controlOptions"
:key="option.mode"
class="flex items-center justify-between py-2 gap-7"
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<div
class="flex items-center justify-center w-8 h-8 rounded-lg flex-shrink-0 bg-secondary-background border border-border-subtle"
>
<i
v-if="option.icon"
:class="option.icon"
class="text-base text-base-foreground"
/>
<span
v-if="option.text"
class="text-xs font-normal text-base-foreground"
>
<i
v-if="option.icon"
:class="option.icon"
class="text-base text-base-foreground"
/>
<span
v-if="option.text"
class="text-xs font-normal text-base-foreground"
>
{{ option.text }}
</span>
</div>
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
<div
class="text-sm font-normal text-base-foreground leading-tight"
>
<span>
{{ $t(`widgets.valueControl.${option.title}`) }}
</span>
</div>
<div
class="text-sm font-normal text-muted-foreground leading-tight"
>
{{ $t(`widgets.valueControl.${option.description}`) }}
</div>
</div>
{{ option.text }}
</span>
</div>
<RadioButton
v-model="controlMode"
class="flex-shrink-0"
:input-id="option.mode"
:value="option.mode"
/>
<div class="flex flex-col gap-0.5 min-w-0 flex-1">
<div class="text-sm font-normal text-base-foreground leading-tight">
<span>
{{ $t(`widgets.valueControl.${option.title}`) }}
</span>
</div>
<div
class="text-sm font-normal text-muted-foreground leading-tight"
>
{{ $t(`widgets.valueControl.${option.description}`) }}
</div>
</div>
</div>
<RadioButton
v-model="controlMode"
class="flex-shrink-0"
:input-id="option.mode"
:value="option.mode"
/>
</div>
</div>
</Popover>
</div>
</template>

View File

@@ -24,13 +24,5 @@ onMounted(() => {
})
</script>
<template>
<div
ref="domEl"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
@mousedown.stop
@mousemove.stop
@mouseup.stop
/>
<div ref="domEl" @pointerdown.stop @pointermove.stop @pointerup.stop />
</template>

View File

@@ -3,6 +3,7 @@ import { onClickOutside } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { evaluateInput } from '@/lib/litegraph/src/utils/widget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -65,7 +66,6 @@ function updateValue(e: UIEvent) {
textEdit.value = false
}
const sharedButtonClass = 'w-8 bg-transparent border-0 text-sm text-smoke-700'
const canDecrement = computed(
() =>
modelValue.value > filteredProps.value.min &&
@@ -205,16 +205,17 @@ const sliderWidth = computed(() => {
class="bg-primary-background/15 absolute left-0 bottom-0 h-full rounded-lg pointer-events-none"
:style="{ width: `${sliderWidth}%` }"
/>
<button
<Button
v-if="!buttonsDisabled"
data-testid="decrement"
:class="
cn(sharedButtonClass, 'pi pi-minus', !canDecrement && 'opacity-60')
"
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canDecrement"
tabindex="-1"
@click="modelValue -= stepValue"
/>
>
<i class="pi pi-minus" />
</Button>
<div class="relative min-w-[4ch] flex-1 py-1.5 my-0.25">
<input
ref="inputField"
@@ -262,16 +263,17 @@ const sliderWidth = computed(() => {
</div>
<slot />
<button
<Button
v-if="!buttonsDisabled"
data-testid="increment"
:class="
cn(sharedButtonClass, 'pi pi-plus', !canIncrement && 'opacity-60')
"
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canIncrement"
tabindex="-1"
@click="modelValue += stepValue"
/>
>
<i class="pi pi-plus" />
</Button>
</div>
</WidgetLayoutField>
</template>

View File

@@ -2,6 +2,7 @@
import { computed, defineAsyncComponent, ref, watch } from 'vue'
import type { Component } from 'vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import type {
SimplifiedControlWidget,
@@ -19,8 +20,6 @@ const props = defineProps<{
const modelValue = defineModel<T>()
const popover = ref()
const controlModel = ref(props.widget.controlWidget.value)
const controlButtonIcon = computed(() => {
@@ -37,24 +36,24 @@ const controlButtonIcon = computed(() => {
})
watch(controlModel, props.widget.controlWidget.update)
const togglePopover = (event: Event) => {
popover.value.toggle(event)
}
</script>
<template>
<div class="relative grid grid-cols-subgrid">
<component :is="component" v-bind="$attrs" v-model="modelValue" :widget>
<Button
variant="textonly"
size="sm"
class="h-4 w-7 self-center rounded-xl bg-blue-100/30 p-0"
@click.stop.prevent="togglePopover"
>
<i :class="`${controlButtonIcon} text-blue-100 text-xs size-3.5`" />
</Button>
<Popover>
<template #button>
<Button
variant="textonly"
size="sm"
class="h-4 w-7 p-0 self-center rounded-xl bg-primary-background/30 hover:bg-primary-background-hover/30"
>
<i
:class="`${controlButtonIcon} text-primary-background text-xs w-full`"
/>
</Button>
</template>
<ValueControlPopover v-model="controlModel" />
</Popover>
</component>
<ValueControlPopover ref="popover" v-model="controlModel" />
</div>
</template>

View File

@@ -10,15 +10,22 @@ import type {
RegistryPack
} from '@/workbench/extensions/manager/types/comfyManagerTypes'
const translateMock = vi.hoisted(() =>
vi.fn((key: string, choice?: number) =>
typeof choice === 'number' ? `${key}-${choice}` : key
)
)
const dateMock = vi.hoisted(() => vi.fn(() => '2024. 1. 1.'))
// Mock dependencies
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
d: vi.fn(() => '2024. 1. 1.'),
t: vi.fn((key: string) => key)
d: dateMock,
t: translateMock
})),
createI18n: vi.fn(() => ({
global: {
t: vi.fn((key: string) => key),
t: translateMock,
te: vi.fn(() => true)
}
}))
@@ -187,6 +194,18 @@ describe('PackCard', () => {
// Should still render without errors
expect(wrapper.exists()).toBe(true)
})
it('should use localized singular/plural nodes label', () => {
const packWithNodes = {
...mockNodePack,
comfy_nodes: ['node-a']
} as MergedNodePack
const wrapper = createWrapper({ nodePack: packWithNodes })
expect(wrapper.text()).toContain('g.nodesCount-1')
expect(translateMock).toHaveBeenCalledWith('g.nodesCount', 1)
})
})
describe('component structure', () => {

View File

@@ -36,8 +36,8 @@
</p>
<div class="flex flex-col gap-y-2">
<div class="flex flex-1 items-center gap-2">
<div v-if="nodesCount" class="p-2 pl-0 text-xs">
{{ nodesCount }} {{ $t('g.nodes') }}
<div v-if="nodesLabel" class="p-2 pl-0 text-xs">
{{ nodesLabel }}
</div>
<PackVersionBadge
:node-pack="nodePack"
@@ -94,7 +94,7 @@ const { nodePack, isSelected = false } = defineProps<{
isSelected?: boolean
}>()
const { d } = useI18n()
const { d, t } = useI18n()
const colorPaletteStore = useColorPaletteStore()
const isLightTheme = computed(
@@ -115,6 +115,9 @@ const isDisabled = computed(
const nodesCount = computed(() =>
isMergedNodePack(nodePack) ? nodePack.comfy_nodes?.length : undefined
)
const nodesLabel = computed(() =>
nodesCount.value ? t('g.nodesCount', nodesCount.value) : ''
)
const publisherName = computed(() => {
if (!nodePack) return null