Compare commits

..

2 Commits

Author SHA1 Message Date
github-actions
65ba0723a1 Update locales 2026-05-07 00:22:59 +00:00
christian-byrne
7e8b7959f2 [release] Increment version to 1.44.19 2026-05-07 00:16:18 +00:00
36 changed files with 113 additions and 471 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -13,7 +13,7 @@ const { stars } = defineProps<{
target="_blank"
rel="noopener noreferrer"
:aria-label="`ComfyUI on GitHub ${stars} stars`"
class="hidden shrink-0 items-center gap-1 lg:flex"
class="hidden shrink-0 items-center gap-2 lg:flex"
>
<NodeBadge
:segments="[{ text: stars }]"
@@ -22,7 +22,7 @@ const { stars } = defineProps<{
size-class="h-5 sm:h-5"
/>
<span
class="bg-primary-comfy-yellow block size-6 shrink-0"
class="bg-primary-comfy-yellow block size-7"
aria-hidden="true"
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
/>

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.45.0",
"version": "1.44.19",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -40,10 +40,7 @@
<template #contentFilter>
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
<div
:ref="primeVueOverlay.overlayScopeRef"
class="flex flex-wrap gap-2"
>
<div class="flex flex-wrap gap-2">
<!-- Model Filter -->
<MultiSelect
v-model="selectedModelObjects"
@@ -51,7 +48,6 @@
class="w-[250px]"
:label="modelFilterLabel"
:options="modelOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -66,7 +62,6 @@
v-model="selectedUseCaseObjects"
:label="useCaseFilterLabel"
:options="useCaseOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -81,7 +76,6 @@
v-model="selectedRunsOnObjects"
:label="runsOnFilterLabel"
:options="runsOnOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -98,7 +92,6 @@
v-model="sortBy"
:label="$t('templateWorkflows.sorting', 'Sort by')"
:options="sortOptions"
:content-style="selectContentStyle"
class="w-62.5"
>
<template #icon>
@@ -423,7 +416,6 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -640,8 +632,6 @@ const selectedRunsOnObjects = computed({
const loadingTemplate = ref<string | null>(null)
const hoveredTemplate = ref<string | null>(null)
const cardRefs = ref<HTMLElement[]>([])
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
// Force re-render key for templates when sorting changes
const templateListKey = ref(0)

View File

@@ -1,8 +1,5 @@
<template>
<div
:ref="primeVueOverlay.overlayScopeRef"
class="keybinding-panel flex flex-col gap-2"
>
<div class="keybinding-panel flex flex-col gap-2">
<Teleport defer to="#keybinding-panel-header">
<SearchInput
v-model="filters['global'].value"
@@ -18,12 +15,10 @@
<div class="flex items-center gap-2">
<KeybindingPresetToolbar
:preset-names="presetNames"
:content-style="keybindingOverlayContentStyle"
@presets-changed="refreshPresetList"
/>
<DropdownMenu
:entries="menuEntries"
:style="keybindingOverlayContentStyle"
icon="icon-[lucide--ellipsis]"
item-class="text-sm gap-2"
button-size="unset"
@@ -243,7 +238,6 @@
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
:style="keybindingOverlayContentStyle"
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>
<ContextMenuItem
@@ -320,7 +314,6 @@ import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
@@ -344,8 +337,6 @@ const settingStore = useSettingStore()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const keybindingOverlayContentStyle = primeVueOverlay.contentStyle
const presetNames = ref<string[]>([])

View File

@@ -9,10 +9,7 @@
{{ displayLabel }}
</SelectValue>
</SelectTrigger>
<SelectContent
:style="contentStyle"
class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1"
>
<SelectContent class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1">
<div class="max-w-60">
<SelectItem
value="default"
@@ -49,7 +46,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -61,9 +57,8 @@ import SelectValue from '@/components/ui/select/SelectValue.vue'
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
const { presetNames, contentStyle } = defineProps<{
const { presetNames } = defineProps<{
presetNames: string[]
contentStyle?: StyleValue
}>()
const emit = defineEmits<{

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
@@ -21,19 +21,13 @@ vi.mock('@/components/common/LazyImage.vue', () => ({
}
}))
const mockRect = (el: HTMLElement, width: number) => {
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
right: width,
bottom: 100,
width,
height: 100,
x: 0,
y: 0,
toJSON: () => ({})
} as DOMRect)
}
vi.mock('@vueuse/core', () => ({
useMouseInElement: () => ({
elementX: ref(50),
elementWidth: ref(100),
isOutside: ref(false)
})
}))
describe('CompareSliderThumbnail', () => {
const renderThumbnail = (props = {}) => {
@@ -80,44 +74,4 @@ describe('CompareSliderThumbnail', () => {
const divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('50%')
})
it('updates slider position on mousemove', async () => {
renderThumbnail()
const container = screen.getByTestId('compare-slider-container')
mockRect(container, 200)
const user = userEvent.setup()
await user.pointer({ target: container, coords: { clientX: 50 } })
const divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('25%')
})
it('clamps slider position to [0, 100] when pointer overshoots', async () => {
renderThumbnail()
const container = screen.getByTestId('compare-slider-container')
mockRect(container, 200)
const user = userEvent.setup()
await user.pointer({ target: container, coords: { clientX: -10 } })
let divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('0%')
await user.pointer({ target: container, coords: { clientX: 250 } })
divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('100%')
})
it('ignores mousemove when container has zero width', async () => {
renderThumbnail()
const container = screen.getByTestId('compare-slider-container')
mockRect(container, 0)
const user = userEvent.setup()
await user.pointer({ target: container, coords: { clientX: 50 } })
const divider = screen.getByTestId('compare-slider-divider')
expect(divider.style.left).toBe('50%')
})
})

View File

@@ -9,11 +9,7 @@
: 'max-w-full max-h-64 object-contain'
"
/>
<div
data-testid="compare-slider-container"
class="absolute inset-0"
@mousemove="updateSliderPosition"
>
<div ref="containerRef" class="absolute inset-0">
<LazyImage
:src="overlayImageSrc"
:alt="alt"
@@ -38,7 +34,8 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useMouseInElement } from '@vueuse/core'
import { ref, watch } from 'vue'
import LazyImage from '@/components/common/LazyImage.vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
@@ -60,20 +57,18 @@ const isVideoType =
false
const sliderPosition = ref(SLIDER_START_POSITION)
const containerRef = ref<HTMLElement | null>(null)
/**
* Update slider position from a local mousemove. Scoped to currentTarget so
* only the hovered card reads its rect — unlike useMouseInElement which
* attaches a global mousemove listener and fires for every mounted instance.
*/
function updateSliderPosition(event: MouseEvent) {
const el = event.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
if (rect.width === 0) return
// Clamp to [0, 100] — subpixel rounding or stale rects on hover-in can
// push the raw percentage slightly out of range, which would offset the
// divider past the container or invert the overlay's clipPath.
const raw = ((event.clientX - rect.left) / rect.width) * 100
sliderPosition.value = Math.max(0, Math.min(100, raw))
}
const { elementX, elementWidth, isOutside } = useMouseInElement(containerRef)
// Update slider position based on mouse position when hovered
watch(
[() => isHovered, elementX, elementWidth, isOutside],
([isHovered, x, width, outside]) => {
if (!isHovered) return
if (!outside) {
sliderPosition.value = (x / width) * 100
}
}
)
</script>

View File

@@ -50,7 +50,7 @@
position="popper"
:side-offset="8"
align="start"
:style="[popoverStyle, contentStyle]"
:style="popoverStyle"
:class="selectContentClass"
@keydown="onContentKeydown"
@focus-outside="preventFocusDismiss"
@@ -152,7 +152,6 @@ import {
ComboboxViewport
} from 'reka-ui'
import { computed, ref } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -184,8 +183,7 @@ const {
searchPlaceholder,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth,
contentStyle
popoverMaxWidth
} = defineProps<{
/** Input label shown on the trigger button */
label?: string
@@ -209,7 +207,6 @@ const {
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
contentStyle?: StyleValue
}>()
const selectedItems = defineModel<SelectOption[]>({

View File

@@ -70,7 +70,6 @@
v-if="suggestions.length > 0"
position="popper"
:side-offset="4"
:style="contentStyle"
:class="
cn(
'z-3000 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
@@ -100,7 +99,7 @@
</template>
<script setup lang="ts" generic="T">
import type { HTMLAttributes, StyleValue } from 'vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import {
@@ -133,8 +132,7 @@ const {
suggestions = [],
optionLabel,
optionKey,
class: className,
contentStyle
class: className
} = defineProps<{
placeholder?: string
icon?: string
@@ -146,7 +144,6 @@ const {
optionLabel?: keyof T & string
optionKey?: keyof T & string
class?: HTMLAttributes['class']
contentStyle?: StyleValue
}>()
const emit = defineEmits<{

View File

@@ -37,7 +37,7 @@
position="popper"
:side-offset="8"
align="start"
:style="[optionStyle, contentStyle]"
:style="optionStyle"
:class="cn(selectContentClass, 'min-w-(--reka-select-trigger-width)')"
@keydown="onContentKeydown"
>
@@ -82,7 +82,6 @@ import {
SelectViewport
} from 'reka-ui'
import { ref } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import {
@@ -109,8 +108,7 @@ const {
disabled = false,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth,
contentStyle
popoverMaxWidth
} = defineProps<{
label?: string
options?: SelectOption[]
@@ -128,7 +126,6 @@ const {
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
contentStyle?: StyleValue
}>()
const selectedItem = defineModel<string | undefined>({ required: true })

View File

@@ -1,91 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { effectScope } from 'vue'
import type { EffectScope } from 'vue'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
describe('usePrimeVueOverlayChildStyle', () => {
let scope: EffectScope | undefined
function mountComposable() {
scope = effectScope()
let composable: ReturnType<typeof usePrimeVueOverlayChildStyle> | undefined
scope.run(() => {
composable = usePrimeVueOverlayChildStyle()
})
if (!composable) {
throw new Error('Failed to mount composable')
}
return composable
}
beforeEach(() => {
document.body.innerHTML = ''
})
afterEach(() => {
scope?.stop()
scope = undefined
document.body.innerHTML = ''
})
it('preserves existing stacking when there is no PrimeVue parent overlay', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = document.createElement('div')
expect(contentStyle.value).toEqual({})
})
it('renders above the closest PrimeVue dialog mask', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = appendPrimeVueOverlay('p-dialog-mask', 5000)
expect(contentStyle.value).toEqual({ zIndex: 5001 })
})
it('renders above the closest PrimeVue overlay mask', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = appendPrimeVueOverlay('p-overlay-mask', 4200)
expect(contentStyle.value).toEqual({ zIndex: 4201 })
})
it('does not drop below the Reka select overlay z-index floor', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = appendPrimeVueOverlay('p-dialog-mask', 1200)
expect(contentStyle.value).toEqual({ zIndex: 3000 })
})
it('preserves existing stacking when the PrimeVue overlay z-index is not numeric', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = appendPrimeVueOverlay('p-dialog-mask')
expect(contentStyle.value).toEqual({})
})
})
function appendPrimeVueOverlay(
className: string,
zIndex?: number
): HTMLElement {
const overlay = document.createElement('div')
overlay.className = className
if (zIndex !== undefined) {
overlay.style.zIndex = String(zIndex)
}
const anchor = document.createElement('div')
overlay.append(anchor)
document.body.append(overlay)
return anchor
}

View File

@@ -1,14 +1,11 @@
import { computed, ref } from 'vue'
import type { CSSProperties, ComputedRef, Ref } from 'vue'
import { computed } from 'vue'
import type { CSSProperties, ComputedRef } from 'vue'
interface PopoverSizeOptions {
minWidth?: string
maxWidth?: string
}
// Matches the highest existing Reka popover z-index (e.g. z-3000 on SearchAutocomplete).
const PRIMEVUE_DIALOG_CHILD_Z_INDEX_FLOOR = 3000
/**
* Composable for managing popover sizing styles
* @param options Popover size configuration
@@ -32,30 +29,3 @@ export function usePopoverSizing(
return style
})
}
/**
* Keeps portaled Reka popovers above their containing PrimeVue dialog.
*
* This is a temporary bridge while PrimeVue dialogs and controls are
* incrementally migrated to Reka UI. Once the affected PrimeVue parents are
* migrated, this helper should be removed with the compatibility patch.
*/
export function usePrimeVueOverlayChildStyle(): {
overlayScopeRef: Ref<HTMLElement | null>
contentStyle: ComputedRef<CSSProperties>
} {
const overlayScopeRef = ref<HTMLElement | null>(null)
const contentStyle = computed<CSSProperties>(() => {
const overlay = overlayScopeRef.value?.closest(
'.p-dialog-mask, .p-overlay-mask'
)
if (!overlay) return {}
const zIndex = Number.parseInt(getComputedStyle(overlay).zIndex, 10)
if (!Number.isFinite(zIndex)) return {}
return { zIndex: Math.max(PRIMEVUE_DIALOG_CHILD_Z_INDEX_FLOOR, zIndex + 1) }
})
return { overlayScopeRef, contentStyle }
}

View File

@@ -17937,7 +17937,7 @@
"inputs": {
"warped_noise": {
"name": "warped_noise",
"tooltip": "الضجيج المشوه (latent) من VOIDWarpedNoise"
"tooltip": "الضجيج المشوه (warped noise latent) من VOIDWarpedNoise"
}
},
"outputs": {

View File

@@ -17919,7 +17919,7 @@
},
"video": {
"name": "video",
"tooltip": "Salida de fotogramas de video de la pasada 1 [T, H, W, 3]"
"tooltip": "Fotogramas de video de salida de la pasada 1 [T, H, W, 3]"
},
"width": {
"name": "width"

View File

@@ -237,7 +237,7 @@
},
"login": {
"andText": "و",
"backToGithubLogin": "ثبت‌نام با گیت‌هاب",
"backToGithubLogin": "به‌جای آن با Github ثبت‌نام کنید",
"backToLogin": "بازگشت به ورود",
"backToSocialLogin": "ثبت‌نام با Google یا Github",
"confirmPasswordLabel": "تأیید رمز عبور",

View File

@@ -17879,7 +17879,7 @@
"inputs": {
"dilate_width": {
"name": "dilate_width",
"tooltip": "شعاع گسترش برای ناحیه اصلی mask (۰ = بدون گسترش)"
"tooltip": "شعاع گسترش برای ناحیه اصلی ماسک (۰ = بدون گسترش)"
},
"mask": {
"name": "mask"
@@ -17919,7 +17919,7 @@
},
"video": {
"name": "video",
"tooltip": "خروجی فریم‌های ویدیو از مرحله ۱ [T, H, W, ۳]"
"tooltip": "خروجی فریم‌های ویدیو از مرحله اول [T, H, W, ۳]"
},
"width": {
"name": "width"
@@ -17937,7 +17937,7 @@
"inputs": {
"warped_noise": {
"name": "warped_noise",
"tooltip": "latent نویز تغییرشکل‌یافته از VOIDWarpedNoise"
"tooltip": "لاتنت نویز تغییرشکل‌یافته از VOIDWarpedNoise"
}
},
"outputs": {

View File

@@ -237,7 +237,7 @@
},
"login": {
"andText": "et",
"backToGithubLogin": "S'inscrire avec Github à la place",
"backToGithubLogin": "Sinscrire avec Github à la place",
"backToLogin": "Retour à la connexion",
"backToSocialLogin": "Inscrivez-vous avec Google ou Github à la place",
"confirmPasswordLabel": "Confirmer le mot de passe",

View File

@@ -17836,7 +17836,7 @@
},
"length": {
"name": "length",
"tooltip": "処理するピクセルフレーム数。CogVideoX-Fun-V1.5patch_size_t=2の場合、latent_tは偶数でなければなりません奇数のlatent_tになる長さは切り捨てられます49 → 45。"
"tooltip": "処理するピクセルフレーム数。CogVideoX-Fun-V1.5patch_size_t=2の場合、latent_tは偶数でなければなりません奇数のlatent_tになる長さは切り捨てられます49 → 45。"
},
"negative": {
"name": "negative"
@@ -17911,7 +17911,7 @@
},
"length": {
"name": "length",
"tooltip": "ピクセルフレーム数。latent_t偶数にするため切り捨てられますpatch_size_t=2の要件、例49 → 45。"
"tooltip": "ピクセルフレーム数。latent_t偶数になるよう切り捨てられますpatch_size_t=2の要件、例49 → 45。"
},
"optical_flow": {
"name": "optical_flow",

View File

@@ -17911,11 +17911,11 @@
},
"length": {
"name": "length",
"tooltip": "픽셀 프레임 수입니다. latent_t 짝수가 되도록 내림 처리됩니다 (patch_size_t=2 필요), 예: 49 → 45."
"tooltip": "픽셀 프레임 수입니다. latent_t 짝수로 맞추기 위해 내림 처리됩니다 (patch_size_t=2 필요), 예: 49 → 45."
},
"optical_flow": {
"name": "optical_flow",
"tooltip": "OpticalFlowLoader에서 가져온 광학 흐름 모델 (RAFT-large)."
"tooltip": "OpticalFlowLoader에서 불러온 광학 흐름 모델 (RAFT-large)."
},
"video": {
"name": "video",

View File

@@ -17937,7 +17937,7 @@
"inputs": {
"warped_noise": {
"name": "warped_noise",
"tooltip": "VOIDWarpedNoise'dan bozulmuş gürültü latent"
"tooltip": "VOIDWarpedNoise'dan alınan bozulmuş gürültü latent"
}
},
"outputs": {

View File

@@ -17846,7 +17846,7 @@
},
"quadmask": {
"name": "quadmask",
"tooltip": "来自 VOIDQuadmaskPreprocess 的预处理 quadmask [T, H, W]"
"tooltip": "来自 VOIDQuadmaskPreprocess 的预处理四边形掩码 [T, H, W]"
},
"vae": {
"name": "vae"
@@ -17879,7 +17879,7 @@
"inputs": {
"dilate_width": {
"name": "dilate_width",
"tooltip": "主 mask 区域的膨胀半径0 = 不膨胀)"
"tooltip": "主掩码区域的膨胀半径0 = 不膨胀)"
},
"mask": {
"name": "mask"
@@ -17937,7 +17937,7 @@
"inputs": {
"warped_noise": {
"name": "warped_noise",
"tooltip": "来自 VOIDWarpedNoise 的 warped noise latent"
"tooltip": "来自 VOIDWarpedNoise 的扭曲噪声 latent"
}
},
"outputs": {

View File

@@ -23,7 +23,6 @@
<template #header>
<div
:ref="primeVueOverlay.overlayScopeRef"
class="flex w-full items-center justify-between gap-2"
@click.self="focusedAsset = null"
>
@@ -53,7 +52,6 @@
<AssetFilterBar
:assets="categoryFilteredAssets"
:show-ownership-filter
:content-style="selectContentStyle"
@filter-change="updateFilters"
@click.self="focusedAsset = null"
/>
@@ -74,12 +72,7 @@
</template>
<template #rightPanel>
<ModelInfoPanel
v-if="focusedAsset"
:asset="focusedAsset"
:cache-key
:select-content-style="selectContentStyle"
/>
<ModelInfoPanel v-if="focusedAsset" :asset="focusedAsset" :cache-key />
<div
v-else
class="flex h-full items-center justify-center p-6 text-center wrap-break-word text-muted"
@@ -99,7 +92,6 @@ import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
import ModelInfoPanel from '@/platform/assets/components/modelInfo/ModelInfoPanel.vue'
@@ -117,8 +109,6 @@ const { t } = useI18n()
const assetStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore()
const breakpoints = useBreakpoints(breakpointsTailwind)
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
const props = defineProps<{
nodeType?: string

View File

@@ -17,6 +17,7 @@ const createAssetData = (
{ label: '2.1 GB', type: 'size' }
],
stats: {
formattedDate: '3/15/25',
downloadCount: '1.8k',
stars: '4.2k'
},
@@ -146,7 +147,8 @@ export const EdgeCases: Story = {
name: 'No Stars',
secondaryText: 'Testing missing stars data gracefully',
stats: {
downloadCount: '1.8k'
downloadCount: '1.8k',
formattedDate: '3/15/25'
}
}),
// No downloads
@@ -155,7 +157,8 @@ export const EdgeCases: Story = {
name: 'No Downloads',
secondaryText: 'Testing missing downloads data gracefully',
stats: {
stars: '4.2k'
stars: '4.2k',
formattedDate: '3/15/25'
}
}),
// No date

View File

@@ -103,9 +103,12 @@
<i class="icon-[lucide--download] size-3" />
{{ asset.stats.downloadCount }}
</span>
<span v-if="formattedDate" class="flex items-center gap-1">
<span
v-if="asset.stats.formattedDate"
class="flex items-center gap-1"
>
<i class="icon-[lucide--clock] size-3" />
{{ formattedDate }}
{{ asset.stats.formattedDate }}
</span>
</div>
<Button
@@ -159,7 +162,7 @@ const emit = defineEmits<{
showInfo: [asset: AssetDisplayItem]
}>()
const { t, d } = useI18n()
const { t } = useI18n()
const settingStore = useSettingStore()
const { closeDialog } = useDialogStore()
const { isDownloadedThisSession, acknowledgeAsset } = useAssetDownloadStore()
@@ -173,15 +176,6 @@ const descId = useId()
const displayName = computed(() => getAssetCardTitle(asset))
// Format at render so locale switches re-flow; the upstream WeakMap caches
// AssetItem -> AssetDisplayItem by reference, which would otherwise pin the
// formatted string to whichever locale was active when first transformed.
const formattedDate = computed(() =>
asset.created_at
? d(new Date(asset.created_at), { dateStyle: 'short' })
: undefined
)
const isNewlyImported = computed(() => isDownloadedThisSession(asset.id))
const showAssetOptions = computed(() => !(asset.is_immutable ?? true))

View File

@@ -12,7 +12,6 @@
v-model="activeFileFormatObjects"
:label="$t('assetBrowser.fileFormats')"
:options="availableFileFormats"
:content-style="contentStyle"
class="min-w-32"
data-component-id="asset-filter-file-formats"
@update:model-value="handleFilterChange"
@@ -23,7 +22,6 @@
v-model="activeBaseModelObjects"
:label="$t('assetBrowser.baseModels')"
:options="availableBaseModels"
:content-style="contentStyle"
class="min-w-32"
data-component-id="asset-filter-base-models"
@update:model-value="handleFilterChange"
@@ -34,7 +32,6 @@
v-model="ownership"
:label="$t('assetBrowser.ownership')"
:options="ownershipOptions"
:content-style="contentStyle"
class="min-w-32"
data-component-id="asset-filter-ownership"
@update:model-value="handleFilterChange"
@@ -46,7 +43,6 @@
v-model="sortBy"
:label="$t('assetBrowser.sortBy')"
:options="sortOptions"
:content-style="contentStyle"
class="min-w-32"
data-component-id="asset-filter-sort"
@update:model-value="handleFilterChange"
@@ -61,7 +57,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
@@ -83,14 +78,9 @@ const sortOptions = computed(() => [
{ name: t('assetBrowser.sortZA'), value: 'name-desc' as const }
])
const {
assets = [],
showOwnershipFilter = false,
contentStyle
} = defineProps<{
const { assets = [], showOwnershipFilter = false } = defineProps<{
assets?: AssetItem[]
showOwnershipFilter?: boolean
contentStyle?: StyleValue
}>()
const selectedFileFormats = ref<SelectOption[]>([])

View File

@@ -1,8 +1,5 @@
<template>
<div
:ref="primeVueOverlay.overlayScopeRef"
class="flex flex-col gap-4 text-sm text-muted-foreground"
>
<div class="flex flex-col gap-4 text-sm text-muted-foreground">
<div class="flex flex-col gap-2">
<p class="m-0">
{{ $t('assetBrowser.modelAssociatedWithLink') }}
@@ -42,7 +39,6 @@
"
:options="modelTypes"
:disabled="isLoading"
:content-style="selectContentStyle"
data-attr="upload-model-step2-type-selector"
/>
</div>
@@ -51,7 +47,6 @@
<script setup lang="ts">
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
@@ -63,6 +58,4 @@ defineProps<{
const modelValue = defineModel<string | undefined>()
const { modelTypes, isLoading } = useModelTypes()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
</script>

View File

@@ -77,7 +77,7 @@
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
/>
</SelectTrigger>
<SelectContent :style="selectContentStyle">
<SelectContent>
<SelectItem
v-for="option in modelTypes"
:key="option.value"
@@ -210,7 +210,6 @@
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
import { computed, ref, useTemplateRef, watch } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
@@ -258,10 +257,9 @@ const accordionClass = cn(
'border-t border-border-default bg-modal-panel-background'
)
const { asset, cacheKey, selectContentStyle } = defineProps<{
const { asset, cacheKey } = defineProps<{
asset: AssetDisplayItem
cacheKey?: string
selectContentStyle?: StyleValue
}>()
const assetsStore = useAssetsStore()

View File

@@ -1,113 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import * as assetMetadataUtils from '@/platform/assets/utils/assetMetadataUtils'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key,
d: (date: Date) => date.toLocaleDateString()
}))
const ASSET_COUNT = 200
const CATEGORIES = ['inputs', 'outputs'] as const
const TAB_SWITCHES = 6
function makeAsset(index: number): AssetItem {
const category = CATEGORIES[index % CATEGORIES.length]
return {
id: `asset-${index}`,
name: `asset-${index}.safetensors`,
asset_hash: `blake3:${index}`,
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', category],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
is_immutable: false
}
}
describe('useAssetBrowser - filter tab switching perf (FE-229)', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
})
it('does not re-transform every asset on each filter tab switch', async () => {
const assets = Array.from({ length: ASSET_COUNT }, (_, i) => makeAsset(i))
const filenameSpy = vi.spyOn(assetMetadataUtils, 'getAssetFilename')
const { selectedNavItem, filteredAssets } = useAssetBrowser(ref(assets))
// Initial materialization of the 'all' tab.
void filteredAssets.value
await nextTick()
const baselineCalls = filenameSpy.mock.calls.length
// Simulate the user clicking back and forth between All / Inputs / Outputs.
const tabs: ('all' | 'inputs' | 'outputs')[] = [
'inputs',
'outputs',
'all',
'inputs',
'outputs',
'all'
]
expect(tabs).toHaveLength(TAB_SWITCHES)
for (const tab of tabs) {
selectedNavItem.value = tab
void filteredAssets.value
await nextTick()
}
const switchCalls = filenameSpy.mock.calls.length - baselineCalls
// Naive (no memoization) cost is approximately:
// inputs (100) + outputs (100) + all (200) + inputs (100) + outputs (100) + all (200) = 800.
// With per-asset memoization the same asset object should never be transformed twice,
// so total work across all tab switches must stay within a small multiple of ASSET_COUNT.
const budget = ASSET_COUNT * 2
expect(switchCalls).toBeLessThanOrEqual(budget)
})
it('returns identical display item references for unchanged assets across tab switches', async () => {
const assets = Array.from({ length: ASSET_COUNT }, (_, i) => makeAsset(i))
const { selectedNavItem, filteredAssets } = useAssetBrowser(ref(assets))
const firstAllSnapshot = new Map(
filteredAssets.value.map((item) => [item.id, item])
)
await nextTick()
selectedNavItem.value = 'inputs'
void filteredAssets.value
await nextTick()
selectedNavItem.value = 'all'
const secondAll = filteredAssets.value
await nextTick()
// If transformAssetForDisplay is memoized per asset, the display items for
// the unchanged underlying assets should be the very same object identity
// when we navigate back to 'all'. Without memoization every re-render
// produces brand-new objects, which forces downstream components
// (AssetGrid / AssetCard) to re-render every card.
const reusedReferences = secondAll.filter(
(item) => firstAllSnapshot.get(item.id) === item
).length
expect(reusedReferences).toBe(ASSET_COUNT)
})
})

View File

@@ -4,7 +4,7 @@ import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import { storeToRefs } from 'pinia'
import { t } from '@/i18n'
import { d, t } from '@/i18n'
import type {
AssetFilterState,
OwnershipOption
@@ -38,51 +38,12 @@ export interface AssetDisplayItem extends AssetItem {
secondaryText: string
badges: AssetBadge[]
stats: {
formattedDate?: string
downloadCount?: string
stars?: string
}
}
const displayItemCache = new WeakMap<AssetItem, AssetDisplayItem>()
function buildDisplayItem(asset: AssetItem): AssetDisplayItem {
const badges: AssetBadge[] = []
const typeTag = asset.tags.find((tag) => tag !== 'models')
if (typeTag) {
const badgeLabel = typeTag.includes('/')
? typeTag.substring(typeTag.indexOf('/') + 1)
: typeTag
badges.push({ label: badgeLabel, type: 'type' })
}
for (const model of getAssetBaseModels(asset)) {
badges.push({ label: model, type: 'base' })
}
// Intentionally no formatted date here — the WeakMap caches by AssetItem
// reference, so a pre-formatted string would pin the locale active at first
// transform. AssetCard formats `created_at` at render via `d()` instead.
return {
...asset,
secondaryText: getAssetFilename(asset),
badges,
stats: {
downloadCount: undefined,
stars: undefined
}
}
}
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
const cached = displayItemCache.get(asset)
if (cached) return cached
const built = buildDisplayItem(asset)
displayItemCache.set(asset, built)
return built
}
/**
* Asset Browser composable
* Manages search, filtering, asset transformation and selection logic
@@ -121,6 +82,46 @@ export function useAssetBrowser(
return selectedNavItem.value
})
// Transform API asset to display asset
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
const secondaryText = getAssetFilename(asset)
const badges: AssetBadge[] = []
const typeTag = asset.tags.find((tag) => tag !== 'models')
// Type badge from non-root tag
if (typeTag) {
// Remove category prefix from badge label (e.g. "checkpoint/model" → "model")
const badgeLabel = typeTag.includes('/')
? typeTag.substring(typeTag.indexOf('/') + 1)
: typeTag
badges.push({ label: badgeLabel, type: 'type' })
}
// Base model badges from metadata
const baseModels = getAssetBaseModels(asset)
for (const model of baseModels) {
badges.push({ label: model, type: 'base' })
}
// Create display stats from API data
const stats = {
formattedDate: asset.created_at
? d(new Date(asset.created_at), { dateStyle: 'short' })
: undefined,
downloadCount: undefined, // Not available in API
stars: undefined // Not available in API
}
return {
...asset,
secondaryText,
badges,
stats
}
}
const typeCategories = computed<NavItemData[]>(() => {
const categories = assets.value
.filter((asset) => asset.tags.includes(MODELS_TAG))

View File

@@ -14,21 +14,16 @@
</template>
<template #header>
<div
:ref="primeVueOverlay.overlayScopeRef"
class="flex w-full items-center justify-between gap-2"
>
<div class="flex w-full items-center justify-between gap-2">
<div class="flex w-full items-center gap-2">
<SingleSelect
v-model="searchMode"
class="min-w-34"
:options="filterOptions"
:content-style="selectContentStyle"
/>
<SearchAutocomplete
v-model="searchQuery"
:suggestions="suggestions"
:content-style="selectContentStyle"
:placeholder="$t('manager.searchPlaceholder')"
option-label="query"
autofocus
@@ -92,7 +87,6 @@
v-model="sortField"
:label="$t('g.sort')"
:options="availableSortOptions"
:content-style="selectContentStyle"
class="w-48"
>
<template #icon>
@@ -169,7 +163,6 @@ import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { components } from '@/types/comfyRegistryTypes'
@@ -204,8 +197,6 @@ const { initialTab, initialPackId, onClose } = defineProps<{
provide(OnCloseKey, onClose)
const { t } = useI18n()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
const { buildDocsUrl } = useExternalLink()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()