mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 17:28:58 +00:00
Compare commits
6 Commits
pysssss/de
...
split/memb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0c5775f49 | ||
|
|
f5c5597d70 | ||
|
|
85f9a5347d | ||
|
|
f2d632385b | ||
|
|
fa2e174d81 | ||
|
|
a898e39d20 |
@@ -7,7 +7,6 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
class SidebarTab {
|
||||
public readonly tabButton: Locator
|
||||
public readonly selectedTabButton: Locator
|
||||
public readonly badge: Locator
|
||||
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
@@ -17,7 +16,6 @@ class SidebarTab {
|
||||
this.selectedTabButton = page.locator(
|
||||
`.${tabId}-tab-button.side-bar-button-selected`
|
||||
)
|
||||
this.badge = this.tabButton.locator('.sidebar-icon-badge')
|
||||
}
|
||||
|
||||
async open() {
|
||||
@@ -27,7 +25,7 @@ class SidebarTab {
|
||||
await this.tabButton.click()
|
||||
}
|
||||
async close() {
|
||||
if (!(await this.tabButton.isVisible())) {
|
||||
if (!this.tabButton.isVisible()) {
|
||||
return
|
||||
}
|
||||
await this.tabButton.click()
|
||||
@@ -56,7 +54,7 @@ export class NodeLibrarySidebarTab extends SidebarTab {
|
||||
}
|
||||
|
||||
override async close() {
|
||||
if (!(await this.tabButton.isVisible())) {
|
||||
if (!this.tabButton.isVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -466,31 +464,3 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DeprecationWarningsSidebarTab extends SidebarTab {
|
||||
public readonly panel: Locator
|
||||
public readonly list: Locator
|
||||
public readonly clearAllButton: Locator
|
||||
public readonly emptyState: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'deprecation-warnings')
|
||||
this.panel = page.getByTestId('deprecation-warnings-sidebar')
|
||||
this.list = page.getByTestId('deprecation-warnings-list')
|
||||
this.clearAllButton = this.panel.getByRole('button', { name: 'Clear all' })
|
||||
this.emptyState = this.panel.getByText(
|
||||
'No deprecation warnings reported in this session.'
|
||||
)
|
||||
}
|
||||
|
||||
rowFor(message: string): Locator {
|
||||
return this.list.locator('li').filter({ hasText: message })
|
||||
}
|
||||
|
||||
async emitDeprecation(message: string): Promise<void> {
|
||||
await this.page.waitForFunction(() => !!window.__reportDeprecation)
|
||||
await this.page.evaluate((m: string) => {
|
||||
window.__reportDeprecation!(m)
|
||||
}, message)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
@@ -1,108 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { DeprecationWarningsSidebarTab } from '@e2e/fixtures/components/SidebarTab'
|
||||
|
||||
test.describe('Deprecation warnings sidebar', { tag: '@ui' }, () => {
|
||||
test.describe('with DevMode disabled', () => {
|
||||
test.use({ initialSettings: { 'Comfy.DevMode': false } })
|
||||
|
||||
test('does not render the sidebar icon', async ({ comfyPage }) => {
|
||||
const tab = new DeprecationWarningsSidebarTab(comfyPage.page)
|
||||
await expect(tab.tabButton).toHaveCount(0)
|
||||
expect(
|
||||
await comfyPage.page.evaluate(() => !!window.__reportDeprecation)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('with DevMode enabled', () => {
|
||||
test.use({ initialSettings: { 'Comfy.DevMode': true } })
|
||||
|
||||
test('records a new deprecation, shows it in the panel, dedupes repeats', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = new DeprecationWarningsSidebarTab(comfyPage.page)
|
||||
await expect(tab.tabButton).toBeVisible()
|
||||
|
||||
const uniqueMessage = `e2e-${Date.now()} is deprecated.`
|
||||
|
||||
await tab.emitDeprecation(uniqueMessage)
|
||||
await tab.open()
|
||||
await expect(tab.panel).toBeVisible()
|
||||
|
||||
const row = tab.rowFor(uniqueMessage)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await tab.emitDeprecation(uniqueMessage)
|
||||
await expect(row.getByTestId('deprecation-warning-badge')).toHaveText('2')
|
||||
await expect(tab.rowFor(uniqueMessage)).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('opening the panel clears the unseen badge', async ({ comfyPage }) => {
|
||||
const tab = new DeprecationWarningsSidebarTab(comfyPage.page)
|
||||
await expect(tab.tabButton).toBeVisible()
|
||||
|
||||
// Mark any pre-existing warnings as seen.
|
||||
await tab.open()
|
||||
await expect(tab.panel).toBeVisible()
|
||||
await tab.close()
|
||||
await expect(tab.panel).toBeHidden()
|
||||
|
||||
await expect(tab.badge).toHaveCount(0)
|
||||
|
||||
await tab.emitDeprecation(`e2e-badge-${Date.now()} is deprecated.`)
|
||||
await expect(tab.badge).toHaveText('1')
|
||||
|
||||
await tab.open()
|
||||
await expect(tab.badge).toHaveCount(0)
|
||||
|
||||
// Warnings arriving while the panel is open must not resurface the badge.
|
||||
const liveMessage = `e2e-badge-live-${Date.now()} is deprecated.`
|
||||
await tab.emitDeprecation(liveMessage)
|
||||
await expect(tab.rowFor(liveMessage)).toBeVisible()
|
||||
|
||||
await tab.close()
|
||||
await expect(tab.panel).toBeHidden()
|
||||
await expect(tab.badge).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('clear all empties the panel and shows the empty state', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = new DeprecationWarningsSidebarTab(comfyPage.page)
|
||||
await tab.open()
|
||||
await expect(tab.panel).toBeVisible()
|
||||
|
||||
const uniqueMessage = `e2e-clearAll-${Date.now()} is deprecated.`
|
||||
await tab.emitDeprecation(uniqueMessage)
|
||||
await expect(tab.rowFor(uniqueMessage)).toBeVisible()
|
||||
|
||||
// Tool-buttons slot is hover/focus-gated; hover the panel to reveal it.
|
||||
await tab.panel.hover()
|
||||
await tab.clearAllButton.click()
|
||||
|
||||
await expect(tab.list).toHaveCount(0)
|
||||
await expect(tab.emptyState).toBeVisible()
|
||||
await expect(tab.clearAllButton).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('clamps the toolbar badge at "9+" when many unseen warnings pile up', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = new DeprecationWarningsSidebarTab(comfyPage.page)
|
||||
await tab.open()
|
||||
await expect(tab.panel).toBeVisible()
|
||||
await tab.close()
|
||||
await expect(tab.panel).toBeHidden()
|
||||
|
||||
const stamp = Date.now()
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await tab.emitDeprecation(`e2e-${stamp}-${i} is deprecated.`)
|
||||
}
|
||||
|
||||
await expect(tab.badge).toHaveText('9+')
|
||||
})
|
||||
})
|
||||
})
|
||||
3
browser_tests/types/globals.d.ts
vendored
3
browser_tests/types/globals.d.ts
vendored
@@ -42,9 +42,6 @@ declare global {
|
||||
__capturedMessages?: CapturedMessages
|
||||
__appReadiness?: AppReadiness
|
||||
|
||||
/** For use in tests to inject a synthetic deprecation warning. */
|
||||
__reportDeprecation?: (message: string) => void
|
||||
|
||||
/**
|
||||
* WebSocket store used by test fixtures for mocking WebSocket connections.
|
||||
* @see browser_tests/fixtures/ws.ts
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.48.1",
|
||||
"version": "1.48.0",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -31,7 +31,6 @@
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
"format:check": "oxfmt --check",
|
||||
"format": "oxfmt --write",
|
||||
"gen:deprecations": "tsx scripts/generate-deprecations-doc.ts",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
"knip:no-cache": "knip",
|
||||
"knip": "knip --cache",
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { DeprecationEntry } from '../src/platform/dev/deprecations'
|
||||
import { renderDeprecationsMdx } from './generate-deprecations-doc'
|
||||
|
||||
const SAMPLE: Record<string, DeprecationEntry> = {
|
||||
'b.thing': {
|
||||
source: 'beta',
|
||||
message: 'thing is deprecated.',
|
||||
suggestion: 'Use otherThing.',
|
||||
since: '1.0',
|
||||
removeBy: '2.0',
|
||||
docsUrl: 'https://docs.example/thing'
|
||||
},
|
||||
'a.first': {
|
||||
source: 'alpha',
|
||||
message: 'first is gone.'
|
||||
},
|
||||
'a.second': {
|
||||
source: 'alpha',
|
||||
message: 'second is gone | really.'
|
||||
}
|
||||
}
|
||||
|
||||
describe('renderDeprecationsMdx', () => {
|
||||
const mdx = renderDeprecationsMdx(SAMPLE)
|
||||
|
||||
it('emits Mintlify frontmatter', () => {
|
||||
expect(mdx.startsWith('---\ntitle: "Deprecations"')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes an intro explaining the deprecation policy before the tables', () => {
|
||||
expect(mdx).toContain('Whilst we try to keep the frontend API stable')
|
||||
expect(mdx.indexOf('Whilst we try')).toBeLessThan(mdx.indexOf('## alpha'))
|
||||
})
|
||||
|
||||
it('documents the dev-mode-gated Deprecation Warnings panel', () => {
|
||||
expect(mdx).toContain('## Deprecation Warnings panel')
|
||||
expect(mdx).toContain('dev mode')
|
||||
})
|
||||
|
||||
it('groups entries by source, sorted alphabetically', () => {
|
||||
expect(mdx.indexOf('## alpha')).toBeLessThan(mdx.indexOf('## beta'))
|
||||
})
|
||||
|
||||
it('renders a row per entry with replacement, version and docs columns', () => {
|
||||
expect(mdx).toContain(
|
||||
'| thing is deprecated. | Use otherThing. | 1.0 | 2.0 | [Guide](https://docs.example/thing) |'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a dash for missing optional fields', () => {
|
||||
expect(mdx).toContain('| first is gone. | - | - | - | - |')
|
||||
})
|
||||
|
||||
it('escapes pipe characters so the table is not broken', () => {
|
||||
expect(mdx).toContain('second is gone \\| really.')
|
||||
})
|
||||
|
||||
it('omits optional columns when no entry anywhere has a value', () => {
|
||||
const minimal = renderDeprecationsMdx({
|
||||
'a.first': { source: 'alpha', message: 'first is gone.' },
|
||||
'a.second': { source: 'alpha', message: 'second is gone.' }
|
||||
})
|
||||
|
||||
expect(minimal).toContain('| Deprecated | Replacement |')
|
||||
expect(minimal).not.toContain('Since')
|
||||
expect(minimal).not.toContain('Removed in')
|
||||
expect(minimal).not.toContain('Docs')
|
||||
})
|
||||
|
||||
it('keeps a column when at least one entry in any table has a value', () => {
|
||||
const mixed = renderDeprecationsMdx({
|
||||
'a.first': { source: 'alpha', message: 'first is gone.' },
|
||||
'b.second': { source: 'beta', message: 'second is gone.', since: '1.0' }
|
||||
})
|
||||
|
||||
expect(mixed).toContain('| Deprecated | Replacement | Since |')
|
||||
expect(mixed).not.toContain('Removed in')
|
||||
})
|
||||
})
|
||||
@@ -1,119 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import type { DeprecationEntry } from '../src/platform/dev/deprecations'
|
||||
import { DEPRECATIONS } from '../src/platform/dev/deprecations'
|
||||
|
||||
const OUTPUT_PATH = './docs/deprecations.mdx'
|
||||
|
||||
const FRONTMATTER = `---
|
||||
title: "Deprecations"
|
||||
description: "Deprecated frontend APIs and their replacements."
|
||||
---`
|
||||
|
||||
const INTRO = `Whilst we try to keep the frontend API stable and backwards compatible, there are times where functionality must change - to fix bugs, improve performance, or make room for new features.
|
||||
|
||||
When that happens we deprecate the old API rather than removing it outright: it keeps working until the removal date, but logs a warning pointing you at the replacement. This page lists the currently deprecated APIs so you can migrate your custom nodes and extensions ahead of their removal.
|
||||
|
||||
## Deprecation Warnings panel
|
||||
|
||||
To see which deprecated APIs are actually being used in your current session, enable the **"Enable dev mode options"** setting (Settings → search "dev mode"). A **Deprecation Warnings** panel then appears in the main sidebar, listing each warning reported this session - with the suggested replacement and how many times it occurred.
|
||||
|
||||
Alternatively, every deprecation is logged to the browser DevTools console as it occurs, prefixed with a \`[DEPRECATED]\` tag.`
|
||||
|
||||
interface Column {
|
||||
header: string
|
||||
cell: (entry: DeprecationEntry) => string | undefined
|
||||
}
|
||||
|
||||
const REQUIRED_COLUMNS: Column[] = [
|
||||
{ header: 'Deprecated', cell: (entry) => entry.message },
|
||||
{ header: 'Replacement', cell: (entry) => entry.suggestion }
|
||||
]
|
||||
|
||||
const OPTIONAL_COLUMNS: Column[] = [
|
||||
{ header: 'Since', cell: (entry) => entry.since },
|
||||
{ header: 'Removed in', cell: (entry) => entry.removeBy },
|
||||
{
|
||||
header: 'Docs',
|
||||
cell: (entry) => (entry.docsUrl ? `[Guide](${entry.docsUrl})` : undefined)
|
||||
}
|
||||
]
|
||||
|
||||
function escapeCell(value: string | undefined): string {
|
||||
if (!value) return '-'
|
||||
return value.replace(/\|/g, '\\|').replace(/\n/g, ' ')
|
||||
}
|
||||
|
||||
function activeColumns(entries: DeprecationEntry[]): Column[] {
|
||||
return [
|
||||
...REQUIRED_COLUMNS,
|
||||
...OPTIONAL_COLUMNS.filter((column) =>
|
||||
entries.some((entry) => column.cell(entry))
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
function groupBySource(
|
||||
deprecations: Record<string, DeprecationEntry>
|
||||
): Map<string, DeprecationEntry[]> {
|
||||
const groups = new Map<string, DeprecationEntry[]>()
|
||||
for (const entry of Object.values(deprecations)) {
|
||||
const group = groups.get(entry.source) ?? []
|
||||
group.push(entry)
|
||||
groups.set(entry.source, group)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function renderRow(cells: string[]): string {
|
||||
return `| ${cells.join(' | ')} |`
|
||||
}
|
||||
|
||||
function renderSection(
|
||||
source: string,
|
||||
entries: DeprecationEntry[],
|
||||
columns: Column[]
|
||||
): string {
|
||||
const rows = entries.map((entry) =>
|
||||
renderRow(columns.map((column) => escapeCell(column.cell(entry))))
|
||||
)
|
||||
return [
|
||||
`## ${source}`,
|
||||
'',
|
||||
renderRow(columns.map((column) => column.header)),
|
||||
renderRow(columns.map(() => '---')),
|
||||
...rows
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function renderDeprecationsMdx(
|
||||
deprecations: Record<string, DeprecationEntry>
|
||||
): string {
|
||||
const columns = activeColumns(Object.values(deprecations))
|
||||
const sections = [...groupBySource(deprecations).entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([source, entries]) => renderSection(source, entries, columns))
|
||||
|
||||
return [
|
||||
FRONTMATTER,
|
||||
'',
|
||||
'{/* Generated by scripts/generate-deprecations-doc.ts. Do not edit by hand. */}',
|
||||
'',
|
||||
INTRO,
|
||||
'',
|
||||
sections.join('\n\n'),
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const mdx = renderDeprecationsMdx(DEPRECATIONS)
|
||||
fs.writeFileSync(path.resolve(OUTPUT_PATH), mdx)
|
||||
}
|
||||
|
||||
const invokedDirectly =
|
||||
process.argv[1] &&
|
||||
fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
|
||||
if (invokedDirectly) main()
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuArrow,
|
||||
@@ -11,15 +12,21 @@ import { computed, ref, toValue } from 'vue'
|
||||
|
||||
import DropdownItem from '@/components/common/DropdownItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { ButtonVariants } from '../ui/button/button.variants'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
const {
|
||||
itemClass: itemProp,
|
||||
contentClass: contentProp,
|
||||
modal = true
|
||||
} = defineProps<{
|
||||
entries?: MenuItem[]
|
||||
icon?: string
|
||||
to?: string | HTMLElement
|
||||
@@ -27,6 +34,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
contentClass?: string
|
||||
buttonSize?: ButtonVariants['size']
|
||||
buttonClass?: string
|
||||
modal?: boolean
|
||||
}>()
|
||||
|
||||
const itemClass = computed(() =>
|
||||
@@ -43,12 +51,19 @@ const contentClass = computed(() =>
|
||||
)
|
||||
)
|
||||
|
||||
// Body-portaled content keeps its static z-1700 unless a dialog that joined
|
||||
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
|
||||
// past that dialog so the menu isn't hidden behind it.
|
||||
const open = ref(false)
|
||||
const contentStyle = useModalLiftedZIndex(open)
|
||||
const contentStyle = computed(() => {
|
||||
if (!open.value) return undefined
|
||||
const topZIndex = ZIndex.getCurrent('modal')
|
||||
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot v-model:open="open">
|
||||
<DropdownMenuRoot v-model:open="open" :modal>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
|
||||
40
src/components/common/SelectionBar.vue
Normal file
40
src/components/common/SelectionBar.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="relative mx-2">
|
||||
<div
|
||||
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="{ value: deselectLabel, showDelay: 300 }"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
:aria-label="deselectLabel"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('deselect')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
/** The "N selected" text; the caller formats it (pluralization, wording). */
|
||||
label: string
|
||||
/** Accessible label + tooltip for the deselect button. */
|
||||
deselectLabel: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
deselect: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -14,7 +14,7 @@
|
||||
class="p-1 text-amber-400"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--component]" />
|
||||
<i class="icon-[lucide--coins]" />
|
||||
</template>
|
||||
</Tag>
|
||||
<div :class="textClass">
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
<i class="icon-[lucide--coins] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
v-if="isBelowMin"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
@@ -109,7 +109,7 @@
|
||||
v-if="showCeilingWarning"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
|
||||
@@ -12,7 +12,7 @@ const PRIMEVUE_OVERLAY_SELECTORS =
|
||||
// dismiss itself. These selectors cover the portaled roots so we can treat
|
||||
// interactions on them as inside.
|
||||
const REKA_PORTAL_SELECTORS =
|
||||
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]'
|
||||
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"], [aria-haspopup="menu"], [aria-haspopup="dialog"], [aria-haspopup="listbox"]'
|
||||
|
||||
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`
|
||||
|
||||
|
||||
@@ -170,7 +170,6 @@ import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
import { formatRelativeTime } from '@/utils/relativeTime'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
|
||||
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
|
||||
@@ -189,6 +188,16 @@ interface MenuItem {
|
||||
showExternalIcon?: boolean
|
||||
}
|
||||
|
||||
// Constants
|
||||
const TIME_UNITS = {
|
||||
MINUTE: 60 * 1000,
|
||||
HOUR: 60 * 60 * 1000,
|
||||
DAY: 24 * 60 * 60 * 1000,
|
||||
WEEK: 7 * 24 * 60 * 60 * 1000,
|
||||
MONTH: 30 * 24 * 60 * 60 * 1000,
|
||||
YEAR: 365 * 24 * 60 * 60 * 1000
|
||||
} as const
|
||||
|
||||
const SUBMENU_CONFIG = {
|
||||
DELAY_MS: 100,
|
||||
OFFSET_PX: 8,
|
||||
@@ -482,9 +491,29 @@ const calculateSubmenuPosition = (button: HTMLElement): CSSProperties => {
|
||||
}
|
||||
|
||||
const formatReleaseDate = (dateString?: string): string => {
|
||||
if (!dateString) return t('helpCenter.missingDate')
|
||||
const diffMs = Math.abs(Date.now() - new Date(dateString).getTime())
|
||||
return formatRelativeTime(t, diffMs)
|
||||
if (!dateString) return 'date'
|
||||
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffTime = Math.abs(now.getTime() - date.getTime())
|
||||
|
||||
const timeUnits = [
|
||||
{ unit: TIME_UNITS.YEAR, key: 'yearsAgo' },
|
||||
{ unit: TIME_UNITS.MONTH, key: 'monthsAgo' },
|
||||
{ unit: TIME_UNITS.WEEK, key: 'weeksAgo' },
|
||||
{ unit: TIME_UNITS.DAY, key: 'daysAgo' },
|
||||
{ unit: TIME_UNITS.HOUR, key: 'hoursAgo' },
|
||||
{ unit: TIME_UNITS.MINUTE, key: 'minutesAgo' }
|
||||
]
|
||||
|
||||
for (const { unit, key } of timeUnits) {
|
||||
const value = Math.floor(diffTime / unit)
|
||||
if (value > 0) {
|
||||
return t(`g.relativeTime.${key}`, { count: value })
|
||||
}
|
||||
}
|
||||
|
||||
return t('g.relativeTime.now')
|
||||
}
|
||||
|
||||
// Event Handlers
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--component] h-full bg-amber-400" />
|
||||
<i class="icon-[lucide--coins] h-full bg-amber-400" />
|
||||
<span class="truncate" v-text="text" />
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--component] size-3 text-amber-400"
|
||||
class="icon-[lucide--coins] size-3 text-amber-400"
|
||||
/>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<div ref="topToolbarRef" :class="groupClasses">
|
||||
<ComfyMenuButton />
|
||||
<SidebarIcon
|
||||
v-for="tab in topTabs"
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:icon="tab.icon"
|
||||
:icon-badge="tab.iconBadge"
|
||||
@@ -37,19 +37,6 @@
|
||||
</div>
|
||||
|
||||
<div ref="bottomToolbarRef" class="mt-auto" :class="groupClasses">
|
||||
<SidebarIcon
|
||||
v-for="tab in bottomTabs"
|
||||
:key="tab.id"
|
||||
:icon="tab.icon"
|
||||
:icon-badge="tab.iconBadge"
|
||||
:tooltip="tab.tooltip"
|
||||
:tooltip-suffix="getTabTooltipSuffix(tab)"
|
||||
:label="tab.label || tab.title"
|
||||
:is-small="isSmall"
|
||||
:selected="tab.id === selectedTab?.id"
|
||||
:class="tab.id + '-tab-button'"
|
||||
@click="onTabClick(tab)"
|
||||
/>
|
||||
<SidebarLogoutIcon
|
||||
v-if="userStore.isMultiUserServer"
|
||||
:is-small="isSmall"
|
||||
@@ -134,12 +121,6 @@ const isConnected = computed(
|
||||
)
|
||||
|
||||
const tabs = computed(() => workspaceStore.getSidebarTabs())
|
||||
const topTabs = computed(() =>
|
||||
tabs.value.filter((tab) => tab.placement !== 'bottom')
|
||||
)
|
||||
const bottomTabs = computed(() =>
|
||||
tabs.value.filter((tab) => tab.placement === 'bottom')
|
||||
)
|
||||
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import DeprecationWarningsTab from '@/components/sidebar/tabs/DeprecationWarningsTab.vue'
|
||||
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
|
||||
|
||||
function renderTab() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} }
|
||||
})
|
||||
return {
|
||||
user: userEvent.setup(),
|
||||
...render(DeprecationWarningsTab, { global: { plugins: [i18n] } })
|
||||
}
|
||||
}
|
||||
|
||||
describe('DeprecationWarningsTab', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
// Drain warnings buffered during module-load imports.
|
||||
useDeprecationWarningsStore().clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders the empty state when there are no warnings', () => {
|
||||
renderTab()
|
||||
|
||||
screen.getByText('deprecationWarnings.empty')
|
||||
expect(screen.queryByTestId('deprecation-warnings-list')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders each warning with its message, suggestion, and source', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({
|
||||
message: 'foo() is deprecated.',
|
||||
suggestion: 'Use bar() instead.',
|
||||
source: 'lib'
|
||||
})
|
||||
|
||||
renderTab()
|
||||
|
||||
screen.getByText('foo() is deprecated.')
|
||||
screen.getByText('Use bar() instead.')
|
||||
screen.getByText('lib')
|
||||
})
|
||||
|
||||
it('surfaces the extension and detail as distinct fields from the message', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({
|
||||
message: 'Use of defaultInput on a required input.',
|
||||
source: 'nodeDef',
|
||||
extension: 'custom_nodes.devtools',
|
||||
detail: 'DevToolsNode.int_input'
|
||||
})
|
||||
|
||||
renderTab()
|
||||
|
||||
screen.getByText('Use of defaultInput on a required input.')
|
||||
screen.getByText('nodeDef')
|
||||
screen.getByText('custom_nodes.devtools')
|
||||
screen.getByText('DevToolsNode.int_input')
|
||||
})
|
||||
|
||||
it('shows the extension filter only when more than one extension is present', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'only ext.', source: 's', extension: 'ext.alpha' })
|
||||
|
||||
const single = renderTab()
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'deprecationWarnings.filterByExtension'
|
||||
})
|
||||
).toBeNull()
|
||||
single.unmount()
|
||||
|
||||
store.report({
|
||||
message: 'second ext.',
|
||||
source: 's',
|
||||
extension: 'ext.bravo'
|
||||
})
|
||||
renderTab()
|
||||
screen.getByRole('button', {
|
||||
name: 'deprecationWarnings.filterByExtension'
|
||||
})
|
||||
})
|
||||
|
||||
it('filters the list to the selected extension', async () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({
|
||||
message: 'from alpha',
|
||||
source: 'nodeDef',
|
||||
extension: 'ext.alpha'
|
||||
})
|
||||
store.report({
|
||||
message: 'from bravo',
|
||||
source: 'nodeDef',
|
||||
extension: 'ext.bravo'
|
||||
})
|
||||
|
||||
const { user } = renderTab()
|
||||
screen.getByText('from alpha')
|
||||
screen.getByText('from bravo')
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'deprecationWarnings.filterByExtension'
|
||||
})
|
||||
)
|
||||
await user.click(await screen.findByRole('option', { name: 'ext.alpha' }))
|
||||
|
||||
screen.getByText('from alpha')
|
||||
expect(screen.queryByText('from bravo')).toBeNull()
|
||||
})
|
||||
|
||||
it('filters to warnings without an extension via the Unknown option', async () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({
|
||||
message: 'has ext',
|
||||
source: 'nodeDef',
|
||||
extension: 'ext.alpha'
|
||||
})
|
||||
store.report({ message: 'no ext', source: 'litegraph' })
|
||||
|
||||
const { user } = renderTab()
|
||||
screen.getByText('has ext')
|
||||
screen.getByText('no ext')
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'deprecationWarnings.filterByExtension'
|
||||
})
|
||||
)
|
||||
await user.click(
|
||||
await screen.findByRole('option', {
|
||||
name: 'deprecationWarnings.unknownExtension'
|
||||
})
|
||||
)
|
||||
|
||||
screen.getByText('no ext')
|
||||
expect(screen.queryByText('has ext')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the extension filter when warnings are cleared', async () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({
|
||||
message: 'from alpha',
|
||||
source: 'nodeDef',
|
||||
extension: 'ext.alpha'
|
||||
})
|
||||
store.report({
|
||||
message: 'from bravo',
|
||||
source: 'nodeDef',
|
||||
extension: 'ext.bravo'
|
||||
})
|
||||
|
||||
const { user } = renderTab()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'deprecationWarnings.filterByExtension'
|
||||
})
|
||||
)
|
||||
await user.click(await screen.findByRole('option', { name: 'ext.bravo' }))
|
||||
expect(screen.queryByText('from alpha')).toBeNull()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'deprecationWarnings.clearAll' })
|
||||
)
|
||||
store.report({
|
||||
message: 'fresh alpha',
|
||||
source: 'nodeDef',
|
||||
extension: 'ext.alpha'
|
||||
})
|
||||
|
||||
await screen.findByText('fresh alpha')
|
||||
expect(screen.queryByText('deprecationWarnings.noMatches')).toBeNull()
|
||||
})
|
||||
|
||||
it('drops a stale extension filter when its warnings are removed', async () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({
|
||||
message: 'from alpha',
|
||||
source: 'nodeDef',
|
||||
extension: 'ext.alpha'
|
||||
})
|
||||
store.report({
|
||||
message: 'from bravo',
|
||||
source: 'nodeDef',
|
||||
extension: 'ext.bravo'
|
||||
})
|
||||
|
||||
const { user } = renderTab()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'deprecationWarnings.filterByExtension'
|
||||
})
|
||||
)
|
||||
await user.click(await screen.findByRole('option', { name: 'ext.bravo' }))
|
||||
expect(screen.queryByText('from alpha')).toBeNull()
|
||||
|
||||
for (const warning of store.warnings.filter(
|
||||
(w) => w.extension === 'ext.bravo'
|
||||
)) {
|
||||
store.remove(warning.key)
|
||||
}
|
||||
|
||||
await screen.findByText('from alpha')
|
||||
expect(screen.queryByText('deprecationWarnings.noMatches')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a migration-guide link only when the warning has a docsUrl', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'no link here.' })
|
||||
store.report({
|
||||
message: 'has a guide.',
|
||||
docsUrl: 'https://docs.example/guide'
|
||||
})
|
||||
|
||||
renderTab()
|
||||
|
||||
const links = screen.getAllByRole('link', {
|
||||
name: 'deprecationWarnings.learnMore'
|
||||
})
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toHaveAttribute('href', 'https://docs.example/guide')
|
||||
expect(links[0]).toHaveAttribute('target', '_blank')
|
||||
expect(links[0]).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it('shows the per-warning count badge only when the warning has fired more than once', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'once' })
|
||||
store.report({ message: 'twice' })
|
||||
store.report({ message: 'twice' })
|
||||
|
||||
renderTab()
|
||||
|
||||
const badge = screen.getByTestId('deprecation-warning-badge')
|
||||
expect(badge.textContent).toBe('2')
|
||||
expect(badge.getAttribute('aria-label')).toBe(
|
||||
'deprecationWarnings.occurrenceCount'
|
||||
)
|
||||
expect(screen.queryAllByTestId('deprecation-warning-badge')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('marks all warnings as seen on mount', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'one' })
|
||||
store.report({ message: 'two' })
|
||||
expect(store.unseenCount).toBe(2)
|
||||
|
||||
renderTab()
|
||||
|
||||
expect(store.unseenCount).toBe(0)
|
||||
})
|
||||
|
||||
it('marks warnings seen as they arrive while the panel is open', async () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'before mount' })
|
||||
renderTab()
|
||||
|
||||
store.report({ message: 'arrived while open' })
|
||||
await screen.findByText('arrived while open')
|
||||
|
||||
expect(store.unseenCount).toBe(0)
|
||||
})
|
||||
|
||||
it('re-renders relative timestamps as time advances', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-01-01T12:00:00Z'))
|
||||
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'aged-out' })
|
||||
|
||||
renderTab()
|
||||
screen.getByText('g.relativeTime.now')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2 * 60_000)
|
||||
|
||||
screen.getByText('g.relativeTime.minutesAgo')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('removes a single warning via its per-warning clear action', async () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'keep me' })
|
||||
store.report({ message: 'remove me' })
|
||||
|
||||
renderTab()
|
||||
|
||||
const menus = screen.getAllByRole('button', {
|
||||
name: 'deprecationWarnings.moreOptions'
|
||||
})
|
||||
// reka's Popover trigger doesn't toggle through userEvent's synthesized
|
||||
// events under happy-dom, so drive the menu with native clicks.
|
||||
// Warnings render newest-first, so the first menu targets 'remove me'.
|
||||
menus[0].click()
|
||||
;(await screen.findByText('deprecationWarnings.clear')).click()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('remove me')).toBeNull()
|
||||
})
|
||||
screen.getByText('keep me')
|
||||
})
|
||||
|
||||
it('clear-all button empties the store and shows the empty state', async () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'one' })
|
||||
|
||||
const { user } = renderTab()
|
||||
|
||||
const clearAll = screen.getByRole('button', {
|
||||
name: 'deprecationWarnings.clearAll'
|
||||
})
|
||||
await user.click(clearAll)
|
||||
|
||||
expect(store.warnings).toEqual([])
|
||||
screen.getByText('deprecationWarnings.empty')
|
||||
})
|
||||
})
|
||||
@@ -1,244 +0,0 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
data-testid="deprecation-warnings-sidebar"
|
||||
:title="t('deprecationWarnings.title')"
|
||||
>
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
v-if="store.warnings.length > 0"
|
||||
variant="muted-textonly"
|
||||
size="md"
|
||||
class="text-sm"
|
||||
@click="clearWarnings"
|
||||
>
|
||||
<span>{{ t('deprecationWarnings.clearAll') }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
<template #body>
|
||||
<NoResultsPlaceholder
|
||||
v-if="store.warnings.length === 0"
|
||||
icon="pi pi-check-circle"
|
||||
:title="t('deprecationWarnings.emptyTitle')"
|
||||
:message="t('deprecationWarnings.empty')"
|
||||
/>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="extensionOptions.length > 1"
|
||||
class="flex justify-end px-3 pt-2"
|
||||
>
|
||||
<MultiSelect
|
||||
v-model="selectedExtensions"
|
||||
:options="extensionOptions"
|
||||
:label="t('deprecationWarnings.filterByExtension')"
|
||||
size="lg"
|
||||
show-search-box
|
||||
show-clear-button
|
||||
class="w-fit"
|
||||
/>
|
||||
</div>
|
||||
<NoResultsPlaceholder
|
||||
v-if="filteredWarnings.length === 0"
|
||||
icon="pi pi-filter-slash"
|
||||
:title="t('deprecationWarnings.noMatchesTitle')"
|
||||
:message="t('deprecationWarnings.noMatches')"
|
||||
/>
|
||||
<ul
|
||||
v-else
|
||||
class="mt-2 flex flex-col gap-2 px-3 pb-2"
|
||||
data-testid="deprecation-warnings-list"
|
||||
>
|
||||
<li
|
||||
v-for="warning in filteredWarnings"
|
||||
:key="warning.key"
|
||||
class="relative flex flex-col gap-2 rounded-md border border-interface-stroke p-4"
|
||||
>
|
||||
<div class="absolute top-2 right-2">
|
||||
<Popover :show-arrow="false" :entries="warningMenuItems(warning)">
|
||||
<template #button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="md"
|
||||
:aria-label="t('deprecationWarnings.moreOptions')"
|
||||
>
|
||||
<i class="icon-[lucide--more-horizontal] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-2 pr-8">
|
||||
<div
|
||||
class="min-w-0 flex-1 text-sm wrap-break-word text-text-primary"
|
||||
>
|
||||
{{ warning.message }}
|
||||
</div>
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 text-sm text-text-secondary"
|
||||
>
|
||||
<span
|
||||
:title="new Date(warning.lastSeenAt).toLocaleString(locale)"
|
||||
>
|
||||
{{
|
||||
formatRelativeTime(t, Math.max(0, now - warning.lastSeenAt))
|
||||
}}
|
||||
</span>
|
||||
<Badge
|
||||
v-if="warning.count > 1"
|
||||
v-bind="occurrenceAttrs(warning.count)"
|
||||
data-testid="deprecation-warning-badge"
|
||||
severity="contrast"
|
||||
variant="circle"
|
||||
:label="formatBadgeCount(warning.count, 9)"
|
||||
class="size-auto min-w-5 px-1.5 py-0.5 text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="metaParts(warning).length"
|
||||
class="flex flex-wrap items-center gap-1.5 font-mono text-xs wrap-break-word text-text-secondary"
|
||||
>
|
||||
<template v-for="(part, i) in metaParts(warning)" :key="i">
|
||||
<span v-if="i > 0" aria-hidden="true" class="opacity-50"
|
||||
>|</span
|
||||
>
|
||||
<span>{{ part }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="warning.suggestion || warning.docsUrl"
|
||||
class="my-1 flex flex-col gap-2 rounded-md bg-node-component-surface p-3 text-sm"
|
||||
>
|
||||
<div v-if="warning.suggestion" class="wrap-break-word">
|
||||
<span class="font-medium text-text-primary">{{
|
||||
t('deprecationWarnings.suggestionLabel')
|
||||
}}</span>
|
||||
<span class="ml-1 text-text-secondary">{{
|
||||
warning.suggestion
|
||||
}}</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="warning.docsUrl"
|
||||
as="a"
|
||||
variant="link"
|
||||
size="sm"
|
||||
:href="warning.docsUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="w-fit gap-2 px-0 text-sm underline"
|
||||
>
|
||||
<i class="icon-[lucide--book-open] size-4" />
|
||||
<span>{{ t('deprecationWarnings.learnMore') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Badge from '@/components/common/Badge.vue'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
||||
import type { SelectOption } from '@/components/ui/select/types'
|
||||
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
|
||||
import { formatBadgeCount } from '@/utils/numberUtil'
|
||||
import { formatRelativeTime } from '@/utils/relativeTime'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const store = useDeprecationWarningsStore()
|
||||
|
||||
const UNKNOWN_EXTENSION = '__unknown__'
|
||||
|
||||
const selectedExtensions = ref<SelectOption[]>([])
|
||||
|
||||
const extensionOptions = computed<SelectOption[]>(() => {
|
||||
const names = new Set<string>()
|
||||
let hasUnknown = false
|
||||
for (const warning of store.warnings) {
|
||||
if (warning.extension) names.add(warning.extension)
|
||||
else hasUnknown = true
|
||||
}
|
||||
const options = [...names].sort().map((name) => ({ name, value: name }))
|
||||
if (hasUnknown) {
|
||||
options.push({
|
||||
name: t('deprecationWarnings.unknownExtension'),
|
||||
value: UNKNOWN_EXTENSION
|
||||
})
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
// Prune selections whose option disappeared (e.g. its warnings were removed),
|
||||
// otherwise a stale filter keeps hiding warnings once the control is hidden.
|
||||
watch(extensionOptions, (options) => {
|
||||
const valid = new Set(options.map((option) => option.value))
|
||||
selectedExtensions.value = selectedExtensions.value.filter((option) =>
|
||||
valid.has(option.value)
|
||||
)
|
||||
})
|
||||
|
||||
const filteredWarnings = computed(() => {
|
||||
if (selectedExtensions.value.length === 0) return store.warnings
|
||||
const selected = new Set(selectedExtensions.value.map((o) => o.value))
|
||||
return store.warnings.filter((warning) =>
|
||||
warning.extension != null
|
||||
? selected.has(warning.extension)
|
||||
: selected.has(UNKNOWN_EXTENSION)
|
||||
)
|
||||
})
|
||||
|
||||
const now = ref(Date.now())
|
||||
useIntervalFn(() => {
|
||||
now.value = Date.now()
|
||||
}, 30_000)
|
||||
|
||||
function occurrenceAttrs(count: number) {
|
||||
const text = t('deprecationWarnings.occurrenceCount', count)
|
||||
return { 'aria-label': text, title: text }
|
||||
}
|
||||
|
||||
function metaParts(warning: {
|
||||
extension?: string
|
||||
source?: string
|
||||
detail?: string
|
||||
}): string[] {
|
||||
return [warning.extension, warning.source, warning.detail].filter(
|
||||
(part): part is string => !!part
|
||||
)
|
||||
}
|
||||
|
||||
function clearWarnings() {
|
||||
store.clear()
|
||||
selectedExtensions.value = []
|
||||
}
|
||||
|
||||
function warningMenuItems(warning: { key: string }): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
label: t('deprecationWarnings.clear'),
|
||||
icon: 'icon-[lucide--trash-2] size-4',
|
||||
command: () => store.remove(warning.key)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// watch, not watchEffect: markAllSeen mutates the watched source, and a
|
||||
// watchEffect's recursion guard drops the subscription after a mount-time
|
||||
// clear, leaving warnings that arrive while the panel is open unseen forever.
|
||||
watch(
|
||||
() => store.unseenCount,
|
||||
(count) => {
|
||||
if (count > 0) store.markAllSeen()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--coins] text-sm text-amber-400" />
|
||||
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
|
||||
22
src/components/ui/hover-card/HoverCard.vue
Normal file
22
src/components/ui/hover-card/HoverCard.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { HoverCardRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HoverCardRootEmits, HoverCardRootProps } from 'reka-ui'
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
import { hoverCardOpenKey } from './hoverCardContext'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
|
||||
const props = defineProps<HoverCardRootProps>()
|
||||
const emits = defineEmits<HoverCardRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
|
||||
const isOpen = ref(false)
|
||||
provide(hoverCardOpenKey, isOpen)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardRoot v-bind="forwarded" v-model:open="isOpen">
|
||||
<slot />
|
||||
</HoverCardRoot>
|
||||
</template>
|
||||
51
src/components/ui/hover-card/HoverCardContent.vue
Normal file
51
src/components/ui/hover-card/HoverCardContent.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import { HoverCardContent, HoverCardPortal, useForwardProps } from 'reka-ui'
|
||||
import type { HoverCardContentProps } from 'reka-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { hoverCardOpenKey } from './hoverCardContext'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
const {
|
||||
class: className,
|
||||
side = 'bottom',
|
||||
sideOffset = 8,
|
||||
...rest
|
||||
} = defineProps<HoverCardContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const forwarded = useForwardProps(computed(() => rest))
|
||||
|
||||
// Body-portaled content sits at a static z-1700 unless a dialog that joined
|
||||
// @primeuix's 'modal' counter is open above it; then lift past that dialog.
|
||||
const open = inject(hoverCardOpenKey, undefined)
|
||||
const contentStyle = computed(() => {
|
||||
if (!open?.value) return undefined
|
||||
const topZIndex = ZIndex.getCurrent('modal')
|
||||
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent
|
||||
v-bind="forwarded"
|
||||
:side
|
||||
:side-offset
|
||||
:style="contentStyle"
|
||||
:class="
|
||||
cn(
|
||||
'z-1700 rounded-lg border border-border-subtle bg-secondary-background p-2.5 shadow-md outline-none',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</template>
|
||||
12
src/components/ui/hover-card/HoverCardTrigger.vue
Normal file
12
src/components/ui/hover-card/HoverCardTrigger.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { HoverCardTrigger } from 'reka-ui'
|
||||
import type { HoverCardTriggerProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<HoverCardTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardTrigger v-bind="props">
|
||||
<slot />
|
||||
</HoverCardTrigger>
|
||||
</template>
|
||||
7
src/components/ui/hover-card/hoverCardContext.ts
Normal file
7
src/components/ui/hover-card/hoverCardContext.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
// Shares the root open-state with the content so it can lift its z-index above
|
||||
// a dialog that joined @primeuix's incrementing 'modal' counter (otherwise the
|
||||
// body-portaled content renders behind the settings dialog).
|
||||
export const hoverCardOpenKey: InjectionKey<Ref<boolean>> =
|
||||
Symbol('hoverCardOpen')
|
||||
@@ -93,30 +93,6 @@ describe('MultiSelect', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('closes when an outside pointerdown is stopped from propagating (e.g. canvas)', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Mimics the litegraph canvas, which stops pointerdown from bubbling to the
|
||||
// document — reka's bubble-phase outside-dismiss would never fire.
|
||||
const canvasLike = document.body.appendChild(document.createElement('div'))
|
||||
canvasLike.addEventListener('pointerdown', (e) => e.stopPropagation())
|
||||
|
||||
const { unmount } = renderInParent()
|
||||
|
||||
const trigger = screen.getByRole('button')
|
||||
await user.click(trigger)
|
||||
await nextTick()
|
||||
expect(trigger).toHaveAttribute('data-state', 'open')
|
||||
|
||||
canvasLike.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(trigger).toHaveAttribute('data-state', 'closed')
|
||||
|
||||
canvasLike.remove()
|
||||
unmount()
|
||||
})
|
||||
|
||||
describe('Escape key propagation', () => {
|
||||
it('stops Escape from propagating to parent when popover is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
ref="contentRef"
|
||||
position="popper"
|
||||
:side-offset="8"
|
||||
align="start"
|
||||
@@ -137,7 +136,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCurrentElement } from '@vueuse/core'
|
||||
import { useFuse } from '@vueuse/integrations/useFuse'
|
||||
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
|
||||
import type { FocusOutsideEvent } from 'reka-ui'
|
||||
@@ -168,7 +166,6 @@ import {
|
||||
stopEscapeToDocument
|
||||
} from '@/components/ui/select/select.variants'
|
||||
import type { SelectOption } from '@/components/ui/select/types'
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
@@ -224,19 +221,6 @@ const { t } = useI18n()
|
||||
const isOpen = ref(false)
|
||||
const selectedCount = computed(() => selectedItems.value.length)
|
||||
|
||||
const rootEl = useCurrentElement<HTMLElement>()
|
||||
const contentRef = ref<{ $el?: HTMLElement } | null>(null)
|
||||
|
||||
useDismissableOverlay({
|
||||
isOpen,
|
||||
getOverlayEl: () => contentRef.value?.$el ?? null,
|
||||
getTriggerEl: () =>
|
||||
rootEl.value instanceof HTMLElement ? rootEl.value : null,
|
||||
onDismiss: () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function onContentKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
stopEscapeToDocument(event)
|
||||
|
||||
72
src/components/ui/pagination/Pagination.vue
Normal file
72
src/components/ui/pagination/Pagination.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<PaginationRoot
|
||||
:page="page"
|
||||
:total="total"
|
||||
:items-per-page="itemsPerPage"
|
||||
:sibling-count="1"
|
||||
show-edges
|
||||
@update:page="(p: number) => emit('update:page', p)"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<PaginationPrev as-child>
|
||||
<Button variant="muted-textonly" size="md" class="text-sm">
|
||||
<i class="icon-[lucide--chevron-left] size-4" />
|
||||
{{ $t('g.previous') }}
|
||||
</Button>
|
||||
</PaginationPrev>
|
||||
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<PaginationListItem
|
||||
v-if="item.type === 'page'"
|
||||
:value="item.value"
|
||||
as-child
|
||||
>
|
||||
<Button
|
||||
:variant="item.value === page ? 'secondary' : 'muted-textonly'"
|
||||
size="icon"
|
||||
>
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis v-else :index="index" :class="ellipsisClass">
|
||||
…
|
||||
</PaginationEllipsis>
|
||||
</template>
|
||||
</PaginationList>
|
||||
<PaginationNext as-child>
|
||||
<Button variant="muted-textonly" size="md" class="text-sm">
|
||||
{{ $t('g.next') }}
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</Button>
|
||||
</PaginationNext>
|
||||
</div>
|
||||
</PaginationRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PaginationEllipsis,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationNext,
|
||||
PaginationPrev,
|
||||
PaginationRoot
|
||||
} from 'reka-ui'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
total,
|
||||
itemsPerPage = 10
|
||||
} = defineProps<{
|
||||
page?: number
|
||||
total: number
|
||||
itemsPerPage?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:page': [page: number] }>()
|
||||
|
||||
const ellipsisClass =
|
||||
'inline-flex size-8 items-center justify-center text-sm text-muted-foreground'
|
||||
</script>
|
||||
@@ -35,7 +35,7 @@ export const searchInputSizeConfig = {
|
||||
icon: 'size-4',
|
||||
iconPos: 'left-2.5',
|
||||
inputPl: 'pl-8',
|
||||
inputText: 'text-xs',
|
||||
inputText: 'text-sm',
|
||||
clearPos: 'left-2.5'
|
||||
},
|
||||
xl: {
|
||||
|
||||
30
src/components/ui/switch/Switch.vue
Normal file
30
src/components/ui/switch/Switch.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-model="checked"
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent px-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
checked ? 'bg-primary' : 'bg-interface-stroke'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SwitchThumb
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none block size-4 rounded-full bg-white shadow-sm transition-transform',
|
||||
checked ? 'translate-x-3.5' : 'translate-x-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { disabled = false } = defineProps<{ disabled?: boolean }>()
|
||||
const checked = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
17
src/components/ui/table/Table.vue
Normal file
17
src/components/ui/table/Table.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div :class="cn('relative w-full overflow-auto', className)">
|
||||
<table
|
||||
class="w-full caption-bottom border-separate border-spacing-0 text-sm"
|
||||
>
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
13
src/components/ui/table/TableBody.vue
Normal file
13
src/components/ui/table/TableBody.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<tbody :class="cn('[&_tr:last-child]:border-0', className)">
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
13
src/components/ui/table/TableCell.vue
Normal file
13
src/components/ui/table/TableCell.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<td :class="cn('px-2 py-2.5 align-middle whitespace-nowrap', className)">
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
21
src/components/ui/table/TableHead.vue
Normal file
21
src/components/ui/table/TableHead.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<th
|
||||
scope="col"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 px-2 text-left align-middle text-sm font-normal whitespace-nowrap text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
15
src/components/ui/table/TableHeader.vue
Normal file
15
src/components/ui/table/TableHeader.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<thead
|
||||
:class="cn('[&_tr]:border-b [&_tr]:border-interface-stroke/60', className)"
|
||||
>
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
20
src/components/ui/table/TableRow.vue
Normal file
20
src/components/ui/table/TableRow.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<tr
|
||||
:class="
|
||||
cn(
|
||||
'border-b border-interface-stroke/60 transition-colors hover:bg-secondary-background/50 data-[state=selected]:bg-secondary-background/50',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
16
src/components/ui/tabs/Tabs.vue
Normal file
16
src/components/ui/tabs/Tabs.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
|
||||
const props = defineProps<TabsRootProps>()
|
||||
const emits = defineEmits<TabsRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</TabsRoot>
|
||||
</template>
|
||||
20
src/components/ui/tabs/TabsList.vue
Normal file
20
src/components/ui/tabs/TabsList.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsList } from 'reka-ui'
|
||||
import type { TabsListProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...rest } = defineProps<
|
||||
TabsListProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsList
|
||||
v-bind="rest"
|
||||
:class="cn('inline-flex items-center gap-4', className)"
|
||||
>
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
||||
28
src/components/ui/tabs/TabsTrigger.vue
Normal file
28
src/components/ui/tabs/TabsTrigger.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsTrigger, useForwardProps } from 'reka-ui'
|
||||
import type { TabsTriggerProps } from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...rest } = defineProps<
|
||||
TabsTriggerProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwarded = useForwardProps(computed(() => rest))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsTrigger
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer appearance-none border-0 border-b-2 border-transparent bg-transparent px-0 pb-2 text-sm text-muted-foreground transition-colors outline-none data-[state=active]:border-base-foreground data-[state=active]:text-base-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TabsTrigger>
|
||||
</template>
|
||||
@@ -14,7 +14,12 @@
|
||||
>
|
||||
<header
|
||||
data-component-id="LeftPanelHeader"
|
||||
class="flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6',
|
||||
headerHeightClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="leftPanelHeaderTitle" />
|
||||
<Button
|
||||
@@ -33,7 +38,12 @@
|
||||
<div class="flex flex-col overflow-hidden bg-base-background">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="flex h-18 w-full items-center justify-between gap-2 px-6"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-18 w-full items-center justify-between gap-2 px-6',
|
||||
headerHeightClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 gap-2">
|
||||
<Button
|
||||
@@ -151,20 +161,22 @@ const SIZE_CLASSES = {
|
||||
} as const
|
||||
|
||||
type ModalSize = keyof typeof SIZE_CLASSES
|
||||
type ContentPadding = 'default' | 'compact' | 'none'
|
||||
type ContentPadding = 'default' | 'compact' | 'none' | 'flush'
|
||||
|
||||
const {
|
||||
contentTitle,
|
||||
rightPanelTitle,
|
||||
size = 'lg',
|
||||
leftPanelWidth = '14rem',
|
||||
contentPadding = 'default'
|
||||
contentPadding = 'default',
|
||||
headerHeightClass = 'h-18'
|
||||
} = defineProps<{
|
||||
contentTitle: string
|
||||
rightPanelTitle?: string
|
||||
size?: ModalSize
|
||||
leftPanelWidth?: string
|
||||
contentPadding?: ContentPadding
|
||||
headerHeightClass?: string
|
||||
}>()
|
||||
|
||||
const sizeClasses = computed(() => SIZE_CLASSES[size])
|
||||
@@ -204,7 +216,10 @@ const contentContainerClass = computed(() =>
|
||||
cn(
|
||||
'flex scrollbar-custom min-h-0 flex-1 flex-col overflow-y-auto',
|
||||
contentPadding === 'default' && 'px-6 pt-0 pb-10',
|
||||
contentPadding === 'compact' && 'px-6 pt-0 pb-2'
|
||||
contentPadding === 'compact' && 'px-6 pt-0 pb-2',
|
||||
// Keep the horizontal inset but let content run to the bottom edge (it
|
||||
// clips there instead of ending above a padding gap).
|
||||
contentPadding === 'flush' && 'px-6 pt-0'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -107,6 +107,8 @@ export interface BillingState {
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
type: ComputedRef<BillingType>
|
||||
/** Subscription paused on a failed payment (`subscriptionStatus === 'paused'`). */
|
||||
isPaused: ComputedRef<boolean>
|
||||
/**
|
||||
* True when the active team workspace is still on a pre-credit-slider
|
||||
* (legacy) per-member tier plan, which keeps the old team pricing table.
|
||||
|
||||
@@ -147,6 +147,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
const subscriptionStatus = computed(() =>
|
||||
toValue(activeContext.value.subscriptionStatus)
|
||||
)
|
||||
const isPaused = computed(() => subscriptionStatus.value === 'paused')
|
||||
const tier = computed(() => toValue(activeContext.value.tier))
|
||||
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
|
||||
|
||||
@@ -301,6 +302,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
isLegacyTeamPlan,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
isPaused,
|
||||
tier,
|
||||
renewalDate,
|
||||
getMaxSeats,
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
DEPRECATION_WARNINGS_TAB_ID,
|
||||
useDeprecationWarningsSidebarTab
|
||||
} from '@/composables/sidebarTabs/useDeprecationWarningsSidebarTab'
|
||||
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
function iconBadgeOf(tab = useDeprecationWarningsSidebarTab()): string | null {
|
||||
if (typeof tab.iconBadge !== 'function') {
|
||||
throw new Error('expected iconBadge to be a function')
|
||||
}
|
||||
return tab.iconBadge()
|
||||
}
|
||||
|
||||
describe('useDeprecationWarningsSidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
// Drain warnings buffered during module-load imports.
|
||||
useDeprecationWarningsStore().clear()
|
||||
})
|
||||
|
||||
it('iconBadge returns null when there are no unseen warnings', () => {
|
||||
expect(iconBadgeOf()).toBeNull()
|
||||
})
|
||||
|
||||
it('iconBadge returns the unseen count when warnings are present', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'one' })
|
||||
store.report({ message: 'two' })
|
||||
expect(iconBadgeOf()).toBe('2')
|
||||
})
|
||||
|
||||
it('iconBadge clamps the count at "9+"', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
for (let i = 0; i < 12; i++) store.report({ message: `m${i}` })
|
||||
expect(iconBadgeOf()).toBe('9+')
|
||||
})
|
||||
|
||||
it('iconBadge returns null while the tab itself is active', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'one' })
|
||||
|
||||
useSidebarTabStore().activeSidebarTabId = DEPRECATION_WARNINGS_TAB_ID
|
||||
|
||||
expect(iconBadgeOf()).toBeNull()
|
||||
})
|
||||
|
||||
it('iconBadge returns null after markAllSeen', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'one' })
|
||||
store.markAllSeen()
|
||||
expect(iconBadgeOf()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import DeprecationWarningsTab from '@/components/sidebar/tabs/DeprecationWarningsTab.vue'
|
||||
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
import { formatBadgeCount } from '@/utils/numberUtil'
|
||||
|
||||
export const DEPRECATION_WARNINGS_TAB_ID = 'deprecation-warnings'
|
||||
|
||||
export function useDeprecationWarningsSidebarTab(): SidebarTabExtension {
|
||||
return {
|
||||
id: DEPRECATION_WARNINGS_TAB_ID,
|
||||
icon: 'icon-[lucide--triangle-alert]',
|
||||
title: 'deprecationWarnings.title',
|
||||
tooltip: 'deprecationWarnings.title',
|
||||
label: 'deprecationWarnings.label',
|
||||
component: markRaw(DeprecationWarningsTab),
|
||||
type: 'vue',
|
||||
placement: 'bottom',
|
||||
iconBadge: () => {
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
if (sidebarTabStore.activeSidebarTabId === DEPRECATION_WARNINGS_TAB_ID) {
|
||||
return null
|
||||
}
|
||||
const count = useDeprecationWarningsStore().unseenCount
|
||||
return count > 0 ? formatBadgeCount(count, 9) : null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,9 @@ export function useExternalLink() {
|
||||
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
|
||||
githubElectron: 'https://github.com/Comfy-Org/electron',
|
||||
forum: 'https://forum.comfy.org/',
|
||||
comfyOrg: 'https://www.comfy.org/'
|
||||
comfyOrg: 'https://www.comfy.org/',
|
||||
teamPlanRequests:
|
||||
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests'
|
||||
}
|
||||
|
||||
/** Common doc paths for use with buildDocsUrl */
|
||||
|
||||
@@ -36,9 +36,9 @@ describe('Context Menu Extension Name in Warnings', () => {
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
const warningMessage = warnSpy.mock.calls[0][0]
|
||||
|
||||
expect(warningMessage).toContain('[DEPRECATED:litegraph]')
|
||||
expect(warningMessage).toContain('detail: getCanvasMenuOptions')
|
||||
expect(warningMessage).toContain('extension: MyCustomExtension')
|
||||
expect(warningMessage).toContain('[DEPRECATED]')
|
||||
expect(warningMessage).toContain('getCanvasMenuOptions')
|
||||
expect(warningMessage).toContain('"MyCustomExtension"')
|
||||
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
@@ -67,9 +67,9 @@ describe('Context Menu Extension Name in Warnings', () => {
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
const warningMessage = warnSpy.mock.calls[0][0]
|
||||
|
||||
expect(warningMessage).toContain('[DEPRECATED:litegraph]')
|
||||
expect(warningMessage).toContain('detail: getNodeMenuOptions')
|
||||
expect(warningMessage).toContain('extension: AnotherExtension')
|
||||
expect(warningMessage).toContain('[DEPRECATED]')
|
||||
expect(warningMessage).toContain('getNodeMenuOptions')
|
||||
expect(warningMessage).toContain('"AnotherExtension"')
|
||||
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
|
||||
import { app, ComfyApp } from '@/scripts/app'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform'
|
||||
|
||||
function openMaskEditor(node: LGraphNode): void {
|
||||
if (!node) {
|
||||
@@ -151,9 +150,11 @@ app.registerExtension({
|
||||
],
|
||||
init() {
|
||||
// Set up ComfyApp static methods for plugin compatibility (deprecated)
|
||||
ComfyApp.open_maskeditor = function open_maskeditor() {
|
||||
warnDeprecated('maskEditor.openMaskEditor')
|
||||
openMaskEditorFromClipspace()
|
||||
}
|
||||
ComfyApp.open_maskeditor = openMaskEditorFromClipspace
|
||||
|
||||
console.warn(
|
||||
'[MaskEditor] ComfyApp.open_maskeditor is deprecated. ' +
|
||||
'Plugins should migrate to using the command system or direct node context menu integration.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { registerExtension } = vi.hoisted(() => ({
|
||||
registerExtension: vi.fn()
|
||||
}))
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { registerExtension }
|
||||
}))
|
||||
|
||||
import { convertToInput } from '@/extensions/core/widgetInputs'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
describe('widgetInputs deprecation', () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
useDeprecationWarningsStore().clear()
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
describe('convertToInput', () => {
|
||||
it('returns the input slot whose widget name matches', () => {
|
||||
const slot = { widget: { name: 'seed' } }
|
||||
const node = createMockLGraphNode({
|
||||
inputs: [slot, { widget: { name: 'other' } }]
|
||||
})
|
||||
|
||||
expect(convertToInput(node, { name: 'seed' } as IBaseWidget)).toBe(slot)
|
||||
})
|
||||
|
||||
it('reports a deprecation warning to the store', () => {
|
||||
const node = createMockLGraphNode({ inputs: [] })
|
||||
|
||||
convertToInput(node, { name: 'x' } as IBaseWidget)
|
||||
|
||||
const store = useDeprecationWarningsStore()
|
||||
expect(store.warnings).toHaveLength(1)
|
||||
expect(store.warnings[0]).toMatchObject({
|
||||
message: 'convertToInput is no longer necessary.',
|
||||
source: 'widgetInputs'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertWidgetToInput prototype patch', () => {
|
||||
it('reports a deprecation when the patched method is called', async () => {
|
||||
const config = registerExtension.mock.calls.find(
|
||||
([c]) => c?.name === 'Comfy.WidgetInputs'
|
||||
)?.[0]
|
||||
expect(config?.beforeRegisterNodeDef).toBeDefined()
|
||||
|
||||
class MockNode {}
|
||||
await config.beforeRegisterNodeDef(MockNode, {})
|
||||
const instance = new MockNode() as { convertWidgetToInput: () => boolean }
|
||||
|
||||
expect(instance.convertWidgetToInput()).toBe(false)
|
||||
|
||||
const store = useDeprecationWarningsStore()
|
||||
expect(store.warnings[0]).toMatchObject({
|
||||
message: 'convertWidgetToInput is no longer necessary.',
|
||||
source: 'widgetInputs'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
|
||||
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
|
||||
import type { ComfyNodeDef, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import {
|
||||
@@ -427,12 +426,15 @@ function getConfig(this: LGraphNode, widgetName: string) {
|
||||
* @param node The node to convert the widget to an input slot for.
|
||||
* @param widget The widget to convert to an input slot.
|
||||
* @returns The input slot that was converted from the widget or undefined if the widget is not found.
|
||||
* @knipIgnoreUnusedButUsedByCustomNodes
|
||||
*/
|
||||
export function convertToInput(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): INodeInputSlot | undefined {
|
||||
warnDeprecated('widgetInputs.convertToInput')
|
||||
console.warn(
|
||||
'Please remove call to convertToInput. Widget to socket conversion is no longer necessary, as they co-exist now.'
|
||||
)
|
||||
return node.inputs.find((slot) => slot.widget?.name === widget.name)
|
||||
}
|
||||
|
||||
@@ -515,7 +517,9 @@ app.registerExtension({
|
||||
) {
|
||||
// @ts-expect-error adding extra property
|
||||
nodeType.prototype.convertWidgetToInput = function (this: LGraphNode) {
|
||||
warnDeprecated('widgetInputs.convertWidgetToInput')
|
||||
console.warn(
|
||||
'Please remove call to convertWidgetToInput. Widget to socket conversion is no longer necessary, as they co-exist now.'
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import { DEPRECATIONS } from '@/platform/dev/deprecations'
|
||||
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import { zeroUuid } from '@/utils/uuid'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
@@ -554,16 +552,13 @@ describe('Subgraph Definition Garbage Collection', () => {
|
||||
})
|
||||
|
||||
describe('beforeChange deprecated onBeforeChange shim', () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
useDeprecationWarningsStore().clear()
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
LiteGraph.onDeprecationWarning = []
|
||||
LiteGraph.alwaysRepeatWarnings = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore()
|
||||
LiteGraph.alwaysRepeatWarnings = false
|
||||
})
|
||||
|
||||
it('still invokes a listener assigned to onBeforeChange', () => {
|
||||
@@ -577,25 +572,28 @@ describe('beforeChange deprecated onBeforeChange shim', () => {
|
||||
expect(onBeforeChange).toHaveBeenCalledWith(graph, node)
|
||||
})
|
||||
|
||||
it('reports the onBeforeChange deprecation when used', () => {
|
||||
it('warns that onBeforeChange is deprecated when used', () => {
|
||||
const graph = new LGraph()
|
||||
const deprecationCallback = vi.fn()
|
||||
LiteGraph.onDeprecationWarning = [deprecationCallback]
|
||||
graph.onBeforeChange = vi.fn()
|
||||
|
||||
graph.beforeChange()
|
||||
|
||||
expect(useDeprecationWarningsStore().warnings).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message: DEPRECATIONS['litegraph.onBeforeChange'].message
|
||||
})
|
||||
expect(deprecationCallback).toHaveBeenCalledWith(
|
||||
expect.stringContaining('LGraph.onBeforeChange is deprecated'),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('does not report when no listener is assigned', () => {
|
||||
it('does not warn when no listener is assigned', () => {
|
||||
const graph = new LGraph()
|
||||
const deprecationCallback = vi.fn()
|
||||
LiteGraph.onDeprecationWarning = [deprecationCallback]
|
||||
|
||||
graph.beforeChange()
|
||||
|
||||
expect(useDeprecationWarningsStore().warnings).toHaveLength(0)
|
||||
expect(deprecationCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
createBounds,
|
||||
snapPoint
|
||||
} from './measure'
|
||||
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
|
||||
import { warnDeprecated } from './utils/feedback'
|
||||
import { SubgraphInput } from './subgraph/SubgraphInput'
|
||||
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
|
||||
import { SubgraphOutput } from './subgraph/SubgraphOutput'
|
||||
@@ -1382,7 +1382,9 @@ export class LGraph
|
||||
// used for undo, called before any change is made to the graph
|
||||
beforeChange(info?: LGraphNode): void {
|
||||
if (this.onBeforeChange) {
|
||||
warnDeprecated('litegraph.onBeforeChange')
|
||||
warnDeprecated(
|
||||
'LGraph.onBeforeChange is deprecated and will be removed in a future version. Assign a listener to LGraphCanvas.onBeforeChange instead.'
|
||||
)
|
||||
this.onBeforeChange(this, info)
|
||||
}
|
||||
this.canvasAction((c) => c.onBeforeChange?.(this))
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { UNASSIGNED_NODE_ID, toNodeId, serializeNodeId } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
@@ -96,6 +95,7 @@ import type {
|
||||
TWidgetValue
|
||||
} from './types/widgets'
|
||||
import { findFreeSlotOfType } from './utils/collections'
|
||||
import { warnDeprecated } from './utils/feedback'
|
||||
import { distributeSpace } from './utils/spaceDistribution'
|
||||
import { truncateText } from './utils/textUtils'
|
||||
import { BaseWidget } from './widgets/BaseWidget'
|
||||
@@ -3521,7 +3521,9 @@ export class LGraphNode
|
||||
* @deprecated Use {@link LGraphCanvas.pointer} instead.
|
||||
*/
|
||||
captureInput(v: boolean): void {
|
||||
warnDeprecated('litegraph.captureInput')
|
||||
warnDeprecated(
|
||||
'[DEPRECATED] captureInput will be removed in a future version. Please use LGraphCanvas.pointer (CanvasPointer) instead.'
|
||||
)
|
||||
if (!this.graph || !this.graph.list_of_graphcanvas) return
|
||||
|
||||
const list = this.graph.list_of_graphcanvas
|
||||
|
||||
@@ -28,15 +28,8 @@ import {
|
||||
RenderShape,
|
||||
TitleMode
|
||||
} from './types/globalEnums'
|
||||
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
|
||||
import { createUuidv4 } from '@/utils/uuid'
|
||||
|
||||
/** Never read; unfrozen so legacy `.push()` callers do not throw. */
|
||||
const inertDeprecationListeners: ((
|
||||
message: string,
|
||||
source?: object
|
||||
) => void)[] = []
|
||||
|
||||
/**
|
||||
* The Global Scope. It contains all the registered node classes.
|
||||
*/
|
||||
@@ -280,31 +273,18 @@ export class LiteGraphGlobal {
|
||||
context_menu_scaling = false
|
||||
|
||||
/**
|
||||
* @deprecated Removed; has no effect. Deprecation warnings are now surfaced in
|
||||
* the Dev Mode "Deprecation Warnings" sidebar panel rather than dispatched to
|
||||
* listeners. Retained so existing `.push()` callers do not crash; the array is
|
||||
* never read.
|
||||
* Debugging flag. Repeats deprecation warnings every time they are reported.
|
||||
* May impact performance.
|
||||
*/
|
||||
get onDeprecationWarning(): ((message: string, source?: object) => void)[] {
|
||||
warnDeprecated('litegraph.onDeprecationWarning')
|
||||
return inertDeprecationListeners
|
||||
}
|
||||
alwaysRepeatWarnings: boolean = false
|
||||
|
||||
set onDeprecationWarning(
|
||||
_value: ((message: string, source?: object) => void)[]
|
||||
) {
|
||||
warnDeprecated('litegraph.onDeprecationWarning')
|
||||
}
|
||||
|
||||
/** @deprecated Removed; has no effect. */
|
||||
get alwaysRepeatWarnings(): boolean {
|
||||
warnDeprecated('litegraph.alwaysRepeatWarnings')
|
||||
return false
|
||||
}
|
||||
|
||||
set alwaysRepeatWarnings(_value: boolean) {
|
||||
warnDeprecated('litegraph.alwaysRepeatWarnings')
|
||||
}
|
||||
/**
|
||||
* Array of callbacks to execute when Litegraph first reports a deprecated API being used.
|
||||
* @see alwaysRepeatWarnings By default, will not repeat identical messages.
|
||||
*/
|
||||
onDeprecationWarning: ((message: string, source?: object) => void)[] = [
|
||||
console.warn
|
||||
]
|
||||
|
||||
/**
|
||||
* @deprecated Removed; has no effect.
|
||||
|
||||
@@ -140,6 +140,7 @@ LiteGraphGlobal {
|
||||
"allow_multi_output_for_events": true,
|
||||
"allow_scripts": false,
|
||||
"alt_drag_do_clone_nodes": false,
|
||||
"alwaysRepeatWarnings": false,
|
||||
"alwaysSnapToGrid": undefined,
|
||||
"auto_load_slot_types": false,
|
||||
"canvasNavigationMode": "legacy",
|
||||
@@ -166,6 +167,9 @@ LiteGraphGlobal {
|
||||
"node_box_coloured_when_on": false,
|
||||
"node_images_path": "",
|
||||
"node_types_by_file_extension": {},
|
||||
"onDeprecationWarning": [
|
||||
[Function],
|
||||
],
|
||||
"overlapBounding": [Function],
|
||||
"pointerevents_method": "pointer",
|
||||
"proxy": null,
|
||||
|
||||
@@ -64,12 +64,12 @@ describe('contextMenuCompat', () => {
|
||||
|
||||
// Should have logged a warning with extension name
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[DEPRECATED'),
|
||||
expect.stringContaining('[DEPRECATED]'),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
)
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('extension: Test Extension'),
|
||||
expect.stringContaining('"Test Extension"'),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { LGraphCanvas } from './LGraphCanvas'
|
||||
import type { IContextMenuValue } from './interfaces'
|
||||
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
|
||||
|
||||
/**
|
||||
* Simple compatibility layer for legacy getCanvasMenuOptions and getNodeMenuOptions monkey patches.
|
||||
@@ -87,10 +86,13 @@ class LegacyMenuCompat {
|
||||
if (!this.hasWarned.has(fnKey) && this.currentExtension) {
|
||||
this.hasWarned.add(fnKey)
|
||||
|
||||
warnDeprecated('litegraph.contextMenuMonkeyPatch', {
|
||||
extension: this.currentExtension ?? undefined,
|
||||
detail: methodName as string
|
||||
})
|
||||
console.warn(
|
||||
`%c[DEPRECATED]%c Monkey-patching ${methodName as string} is deprecated. (Extension: "${this.currentExtension}")\n` +
|
||||
`Please use the new context menu API instead.\n\n` +
|
||||
`See: https://docs.comfy.org/custom-nodes/js/context-menu-migration`,
|
||||
'color: orange; font-weight: bold',
|
||||
'color: inherit'
|
||||
)
|
||||
}
|
||||
currentImpl = newImpl
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { clamp } from 'es-toolkit/compat'
|
||||
import { afterEach, beforeEach, describe, expect, vi } from 'vitest'
|
||||
import { describe, expect } from 'vitest'
|
||||
|
||||
import {
|
||||
LiteGraphGlobal,
|
||||
@@ -27,35 +27,6 @@ describe('Litegraph module', () => {
|
||||
expect(clamp(-1.124, 13, 24)).toStrictEqual(13)
|
||||
expect(clamp(Infinity, 18, 29)).toStrictEqual(29)
|
||||
})
|
||||
|
||||
describe('removed-API compatibility shims', () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('onDeprecationWarning remains a usable no-op for old extensions', () => {
|
||||
expect(() => LiteGraph.onDeprecationWarning.push(() => {})).not.toThrow()
|
||||
expect(LiteGraph.onDeprecationWarning).toBe(
|
||||
LiteGraph.onDeprecationWarning
|
||||
)
|
||||
expect(() => {
|
||||
LiteGraph.onDeprecationWarning = [() => {}]
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
test('alwaysRepeatWarnings remains a usable no-op for old extensions', () => {
|
||||
expect(() => {
|
||||
LiteGraph.alwaysRepeatWarnings = true
|
||||
}).not.toThrow()
|
||||
expect(LiteGraph.alwaysRepeatWarnings).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Import order dependency', () => {
|
||||
|
||||
58
src/lib/litegraph/src/utils/feedback.ts
Normal file
58
src/lib/litegraph/src/utils/feedback.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
/** Guard against unbound allocation. */
|
||||
const UNIQUE_MESSAGE_LIMIT = 10_000
|
||||
const sentWarnings: Set<string> = new Set()
|
||||
|
||||
/**
|
||||
* Warns that a deprecated function has been used via the public
|
||||
* {@link onDeprecationWarning} / {@link onEveryDeprecationWarning} callback arrays.
|
||||
* @param message Plain-language detail about what has been deprecated. This **should not** include unique data; use {@link source}.
|
||||
* @param source A reference object to include alongside the message, e.g. `this`.
|
||||
*/
|
||||
export function warnDeprecated(message: string, source?: object): void {
|
||||
if (!LiteGraph.alwaysRepeatWarnings) {
|
||||
// Do not repeat
|
||||
if (sentWarnings.has(message)) return
|
||||
|
||||
// Hard limit of unique messages per session
|
||||
if (sentWarnings.size > UNIQUE_MESSAGE_LIMIT) return
|
||||
|
||||
sentWarnings.add(message)
|
||||
}
|
||||
|
||||
for (const callback of LiteGraph.onDeprecationWarning) {
|
||||
callback(message, source)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a deprecated property alias that proxies to a current property,
|
||||
* logging a deprecation warning on first access.
|
||||
*
|
||||
* Warning is deduplicated by {@link warnDeprecated} (once per unique message per session).
|
||||
*
|
||||
* @param target The object to define the deprecated property on.
|
||||
* @param deprecatedKey The old property name to deprecate.
|
||||
* @param currentKey The new property name to proxy to.
|
||||
* @param message The deprecation warning message.
|
||||
*/
|
||||
export function defineDeprecatedProperty<T>(
|
||||
target: T,
|
||||
deprecatedKey: string,
|
||||
currentKey: keyof T & string,
|
||||
message: string
|
||||
): void {
|
||||
Object.defineProperty(target, deprecatedKey, {
|
||||
get() {
|
||||
warnDeprecated(message)
|
||||
return this[currentKey]
|
||||
},
|
||||
set(value: unknown) {
|
||||
warnDeprecated(message)
|
||||
this[currentKey] = value
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: false
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { ComboWidget } from '@/lib/litegraph/src/widgets/ComboWidget'
|
||||
import { DEPRECATIONS } from '@/platform/dev/deprecations'
|
||||
|
||||
const { LGraphCanvas } = await vi.importActual<typeof LGraphCanvasModule>(
|
||||
'@/lib/litegraph/src/LGraphCanvas'
|
||||
@@ -558,7 +557,9 @@ describe('ComboWidget', () => {
|
||||
})
|
||||
|
||||
it('should warn when using deprecated function values', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const deprecationCallback = vi.fn()
|
||||
const originalCallbacks = LiteGraph.onDeprecationWarning
|
||||
LiteGraph.onDeprecationWarning = [deprecationCallback]
|
||||
|
||||
const valuesFn = vi.fn().mockReturnValue(['a', 'b', 'c'])
|
||||
widget = new ComboWidget(
|
||||
@@ -582,15 +583,12 @@ describe('ComboWidget', () => {
|
||||
|
||||
widget.onClick({ e: mockEvent, node, canvas: mockCanvas })
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
DEPRECATIONS['litegraph.comboValuesFunction'].message
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(String)
|
||||
expect(deprecationCallback).toHaveBeenCalledWith(
|
||||
'Using a function for values is deprecated. Use an array of unique values instead.',
|
||||
undefined
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
LiteGraph.onDeprecationWarning = originalCallbacks
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
IComboWidget,
|
||||
IStringComboWidget
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
|
||||
import { warnDeprecated } from '@/lib/litegraph/src/utils/feedback'
|
||||
|
||||
import { BaseSteppedWidget } from './BaseSteppedWidget'
|
||||
import type { WidgetEventOptions } from './BaseWidget'
|
||||
@@ -129,7 +129,9 @@ export class ComboWidget
|
||||
|
||||
// Deprecated functionality (warning as of v0.14.5)
|
||||
if (typeof this.options.values === 'function') {
|
||||
warnDeprecated('litegraph.comboValuesFunction')
|
||||
warnDeprecated(
|
||||
'Using a function for values is deprecated. Use an array of unique values instead.'
|
||||
)
|
||||
}
|
||||
|
||||
// Determine if clicked on left/right arrows
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "يرجى إبقاء إجابتك أقل من {max} حرفًا.",
|
||||
"chooseAnOption": "يرجى اختيار خيار.",
|
||||
"describeAnswer": "يرجى وصف إجابتك.",
|
||||
"selectAtLeastOne": "يرجى اختيار خيار واحد على الأقل."
|
||||
},
|
||||
"intro": "ساعدنا في تخصيص تجربتك مع ComfyUI.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "جديد على ComfyUI",
|
||||
"pro": "أنا مستخدم محترف",
|
||||
"some": "لدي معرفة جيدة"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "عُقد مخصصة",
|
||||
"pipelines": "مسارات مؤتمتة",
|
||||
"products": "منتجات للآخرين"
|
||||
"familiarity": {
|
||||
"advanced": "مستخدم متقدم (سير عمل مخصصة)",
|
||||
"basics": "مرتاح مع الأساسيات",
|
||||
"expert": "خبير (أساعد الآخرين)",
|
||||
"new": "جديد في ComfyUI (لم أستخدمه من قبل)",
|
||||
"starting": "في البداية فقط (أتابع الدروس التعليمية)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "تطبيقات وواجهات برمجة التطبيقات",
|
||||
"exploring": "أستكشف فقط",
|
||||
"3d_game": "أصول ثلاثية الأبعاد / أصول ألعاب",
|
||||
"api": "نقاط نهاية API لتشغيل مسارات العمل",
|
||||
"apps": "تطبيقات مبسطة من مسارات العمل",
|
||||
"audio": "صوت / موسيقى",
|
||||
"custom_nodes": "عُقد مخصصة",
|
||||
"images": "صور",
|
||||
"other": "شيء آخر",
|
||||
"otherPlaceholder": "ماذا تريد أن تصنع؟",
|
||||
"video": "فيديو",
|
||||
"not_sure": "لست متأكداً",
|
||||
"videos": "فيديوهات",
|
||||
"workflows": "مسارات عمل أو خطوط معالجة مخصصة"
|
||||
},
|
||||
"source": {
|
||||
"community": "مجتمع أو منتدى",
|
||||
"conference": "مؤتمر أو فعالية",
|
||||
"discord": "ديسكورد / مجتمع",
|
||||
"friend": "صديق أو زميل",
|
||||
"other": "أخرى",
|
||||
"otherPlaceholder": "من أين وجدتنا؟",
|
||||
"search": "جوجل / بحث",
|
||||
"social": "وسائل التواصل الاجتماعي"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "ديسكورد",
|
||||
"github": "GitHub",
|
||||
"instagram": "إنستغرام",
|
||||
"linkedin": "لينكدإن",
|
||||
"newsletter": "النشرة البريدية أو مدونة",
|
||||
"other": "أخرى",
|
||||
"reddit": "ريديت",
|
||||
"tiktok": "تيك توك",
|
||||
"twitter": "X (تويتر)",
|
||||
"search": "جوجل / بحث",
|
||||
"twitter": "تويتر / X",
|
||||
"youtube": "يوتيوب"
|
||||
},
|
||||
"usage": {
|
||||
"education": "تعليمي (طالب أو معلم)",
|
||||
"personal": "استخدام شخصي",
|
||||
"work": "عمل"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "أخبرنا المزيد",
|
||||
"placeholder": "نص بديل لأسئلة الاستبيان",
|
||||
"steps": {
|
||||
"familiarity": "ما مدى معرفتك بـ ComfyUI؟",
|
||||
"intent": "ما الذي ترغب في إنشائه باستخدام ComfyUI؟",
|
||||
"source": "من أين سمعت عن ComfyUI؟",
|
||||
"usage": "كيف تخطط لاستخدام ComfyUI؟"
|
||||
},
|
||||
"title": "استبيان السحابة"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "تعرف على السحابة",
|
||||
"cloudStart_title": "ابدأ الإبداع في ثوانٍ",
|
||||
"cloudStart_wantToRun": "هل تريد تشغيل ComfyUI محليًا بدلاً من ذلك؟",
|
||||
"cloudSurvey_steps_experience": "ما مدى معرفتك بـ ComfyUI؟",
|
||||
"cloudSurvey_steps_focus": "ماذا تبني؟",
|
||||
"cloudSurvey_steps_familiarity": "ما مدى معرفتك بـ ComfyUI؟",
|
||||
"cloudSurvey_steps_intent": "ما الذي ترغب في إنشائه باستخدام ComfyUI؟",
|
||||
"cloudSurvey_steps_source": "من أين سمعت عن ComfyUI؟",
|
||||
"cloudSurvey_steps_source_social": "أي منصة؟",
|
||||
"cloudSurvey_steps_usage": "كيف تخطط لاستخدام ComfyUI؟",
|
||||
"cloudWaitlist_contactLink": "هنا",
|
||||
"cloudWaitlist_questionsText": "أسئلة؟ اتصل بنا",
|
||||
"color": {
|
||||
|
||||
@@ -1046,22 +1046,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecationWarnings": {
|
||||
"title": "Deprecation Warnings",
|
||||
"label": "Warnings",
|
||||
"emptyTitle": "All clear",
|
||||
"empty": "No deprecation warnings reported in this session.",
|
||||
"clear": "Clear",
|
||||
"clearAll": "Clear all",
|
||||
"moreOptions": "More options",
|
||||
"suggestionLabel": "Fix:",
|
||||
"learnMore": "Migration guide",
|
||||
"filterByExtension": "Extension",
|
||||
"unknownExtension": "Unknown",
|
||||
"noMatchesTitle": "No matches",
|
||||
"noMatches": "No warnings match the selected filters.",
|
||||
"occurrenceCount": "Reported {count} time | Reported {count} times"
|
||||
},
|
||||
"helpCenter": {
|
||||
"feedback": "Give Feedback",
|
||||
"docs": "Docs",
|
||||
@@ -1083,8 +1067,7 @@
|
||||
"updateComfyUIStartedDetail": "ComfyUI update has been queued. Please wait...",
|
||||
"updateComfyUISuccess": "Update Complete",
|
||||
"updateComfyUISuccessDetail": "ComfyUI has been updated. Rebooting...",
|
||||
"updateComfyUIFailed": "Failed to update ComfyUI. Please try again.",
|
||||
"missingDate": "Date unavailable"
|
||||
"updateComfyUIFailed": "Failed to update ComfyUI. Please try again."
|
||||
},
|
||||
"releaseToast": {
|
||||
"newVersionAvailable": "New update is out!",
|
||||
@@ -2431,16 +2414,6 @@
|
||||
"tooltipLearnMore": "Learn more..."
|
||||
}
|
||||
},
|
||||
"desktopLogin": {
|
||||
"confirmSummary": "Approve desktop sign-in?",
|
||||
"confirmMessage": "The ComfyUI desktop app is waiting to sign in with your account. Only continue if you just started signing in from the ComfyUI desktop app.",
|
||||
"successSummary": "Signed in",
|
||||
"successDetail": "You can return to the ComfyUI desktop app.",
|
||||
"expiredSummary": "Desktop sign-in failed",
|
||||
"expiredDetail": "The sign-in request expired or was already used. Start signing in again from the ComfyUI desktop app.",
|
||||
"failedSummary": "Desktop sign-in failed",
|
||||
"failedDetail": "Something went wrong completing the desktop sign-in. Start signing in again from the ComfyUI desktop app."
|
||||
},
|
||||
"validation": {
|
||||
"invalidEmail": "Invalid email address",
|
||||
"required": "Required",
|
||||
@@ -2620,7 +2593,7 @@
|
||||
"additionalCreditsInfo": "About additional credits",
|
||||
"additionalCredits": "Additional credits",
|
||||
"additionalCreditsInUse": "In use",
|
||||
"usedAfterMonthly": "Used after monthly runs out",
|
||||
"usedAfterMonthly": "Used after plan credits run out",
|
||||
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
|
||||
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
|
||||
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
|
||||
@@ -2858,7 +2831,10 @@
|
||||
"planUpdated": "Your plan has been successfully updated.",
|
||||
"receiptEmailed": "A receipt has been emailed to you.",
|
||||
"sendInvites": "Send invites"
|
||||
}
|
||||
},
|
||||
"enterprisePlanName": "Enterprise",
|
||||
"percentUsed": "{percent}% used",
|
||||
"usageProgress": "{used} of {total} credits used"
|
||||
},
|
||||
"userSettings": {
|
||||
"title": "My Account Settings",
|
||||
@@ -2873,7 +2849,7 @@
|
||||
"workspacePanel": {
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
"inviteLimitReached": "Your workspace is at the member limit",
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"planCredits": "Plan & Credits",
|
||||
@@ -2888,12 +2864,17 @@
|
||||
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
|
||||
"tabs": {
|
||||
"active": "Active",
|
||||
"pendingCount": "Pending ({count})"
|
||||
"pendingCount": "Pending ({count})",
|
||||
"membersCount": "Members ({count})",
|
||||
"pending": "Pending"
|
||||
},
|
||||
"columns": {
|
||||
"inviteDate": "Invite date",
|
||||
"expiryDate": "Expiry date",
|
||||
"role": "Role"
|
||||
"role": "Role",
|
||||
"creditsUsed": "Credits used this month",
|
||||
"email": "Email",
|
||||
"lastActivity": "Last activity"
|
||||
},
|
||||
"actions": {
|
||||
"resendInvite": "Resend invite",
|
||||
@@ -2909,14 +2890,22 @@
|
||||
"contactUs": "Contact us",
|
||||
"noInvites": "No pending invites",
|
||||
"noMembers": "No members",
|
||||
"searchPlaceholder": "Search..."
|
||||
"searchPlaceholder": "Search...",
|
||||
"activity": {
|
||||
"daysAgo": "{count} day ago | {count} days ago",
|
||||
"hoursAgo": "{n} hr ago",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{n} min ago"
|
||||
},
|
||||
"membersUsage": "{count} of {max} total members."
|
||||
},
|
||||
"menu": {
|
||||
"editWorkspace": "Edit workspace details",
|
||||
"leaveWorkspace": "Leave Workspace",
|
||||
"deleteWorkspace": "Delete Workspace",
|
||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
|
||||
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
|
||||
"creatorCannotLeave": "The workspace creator can't leave the workspace they created",
|
||||
"renameWorkspace": "Rename Workspace"
|
||||
},
|
||||
"editWorkspaceDialog": {
|
||||
"title": "Edit workspace details",
|
||||
@@ -3005,6 +2994,136 @@
|
||||
"failedToDeleteWorkspace": "Failed to delete workspace",
|
||||
"failedToLeaveWorkspace": "Failed to leave workspace",
|
||||
"failedToFetchWorkspaces": "Failed to load workspaces"
|
||||
},
|
||||
"charactersLeft": "{count} character left | {count} characters left",
|
||||
"doubleClickToRename": "Double-click to rename",
|
||||
"editWorkspaceImage": "Edit workspace image",
|
||||
"memberLimitDialog": {
|
||||
"message": "All seats are filled. To invite someone new, remove a member, rescind an invite, or request more seats.",
|
||||
"title": "Workspace is at the member limit"
|
||||
},
|
||||
"requestMore": "Request more",
|
||||
"workflowQueuedDialog": {
|
||||
"message": "Max workflow capacity reached. It'll start automatically as capacity opens up. If this happens often, you can also request for more capacity.",
|
||||
"title": "Your workflow is queued"
|
||||
},
|
||||
"billingStatus": {
|
||||
"ending": {
|
||||
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
|
||||
"reactivate": "Reactivate plan",
|
||||
"title": "Your team plan ends on {date}"
|
||||
},
|
||||
"outOfCredits": {
|
||||
"addCredits": "Add credits",
|
||||
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
|
||||
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
|
||||
"dismiss": "Dismiss",
|
||||
"title": "Out of credits"
|
||||
},
|
||||
"paused": {
|
||||
"body": "This workspace's subscription is paused. Update payment to resume.",
|
||||
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method.",
|
||||
"title": "Subscription paused"
|
||||
},
|
||||
"updatePayment": "Update payment",
|
||||
"warning": {
|
||||
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
|
||||
"title": "Payment declined"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"changePlan": "Change plan",
|
||||
"inactive": {
|
||||
"reactivate": "Reactivate plan",
|
||||
"subtitle": "Reactivate your team plan to add more members and run workflows",
|
||||
"subtitleEnterprise": "Reactivate your enterprise plan to add more members and run workflows",
|
||||
"title": "Inactive team subscription",
|
||||
"titleEnterprise": "Inactive enterprise subscription"
|
||||
},
|
||||
"learnMore": "Learn more",
|
||||
"managePayment": "Manage payment",
|
||||
"messageSupport": "Message support",
|
||||
"paused": "Paused",
|
||||
"perMonth": "mo",
|
||||
"pricingTable": "Partner Nodes pricing table",
|
||||
"renewsOn": "Renews on {date}",
|
||||
"seeMore": "See more",
|
||||
"snapshot": {
|
||||
"creditsUsed": "Credits used",
|
||||
"empty": {
|
||||
"recentActivity": "No activity yet",
|
||||
"topSpenders": "No credits used yet this month"
|
||||
},
|
||||
"lastActivity": "Last activity",
|
||||
"recentActivity": "Recent activity",
|
||||
"topSpenders": "Top spenders",
|
||||
"user": "User"
|
||||
},
|
||||
"nextInvoice": "Next month invoice",
|
||||
"usd": "USD"
|
||||
},
|
||||
"invoices": {
|
||||
"fullHistory": "Full invoice history"
|
||||
},
|
||||
"planCredits": {
|
||||
"tabs": {
|
||||
"invoices": "Invoices",
|
||||
"overview": "Credits",
|
||||
"activity": "Activity"
|
||||
}
|
||||
},
|
||||
"autoReload": {
|
||||
"badge": {
|
||||
"off": "Off",
|
||||
"paused": "Paused"
|
||||
},
|
||||
"dialog": {
|
||||
"allowsReloads": "Allows {count} reload /mo | Allows {count} reloads /mo",
|
||||
"amountLabel": "Add this amount of credits:",
|
||||
"budgetPlaceholderCredits": "Enter an amount of credits",
|
||||
"budgetPlaceholderUsd": "Enter an amount of dollars",
|
||||
"budgetToggleHint": "Limit how much is auto-reloaded per month",
|
||||
"budgetToggleLabel": "Monthly budget",
|
||||
"cancel": "Cancel",
|
||||
"minReload": "Minimum amount is {amount}",
|
||||
"thresholdLabel": "When credits drop below:",
|
||||
"title": "Auto-reload credits",
|
||||
"update": "Update"
|
||||
},
|
||||
"disabled": "Disabled",
|
||||
"edit": "Edit",
|
||||
"empty": {
|
||||
"body": "Keep your workflows running with auto-reloaded credits. Set a monthly budget so charges don't surprise you.",
|
||||
"cta": "Set up auto-reload"
|
||||
},
|
||||
"enabled": "Enabled",
|
||||
"subtitle": "Automatically add credits when your balance runs low, within an optional monthly budget.",
|
||||
"tile": {
|
||||
"label": "Auto-reload",
|
||||
"monthlyBudget": "Monthly budget",
|
||||
"percentSpent": "{percent}% spent",
|
||||
"spentOfBudget": "{spent} of {budget}",
|
||||
"whenBelow": "when credits drop below"
|
||||
},
|
||||
"title": "Credit auto-reload"
|
||||
},
|
||||
"activity": {
|
||||
"columns": {
|
||||
"creditsUsed": "Credits",
|
||||
"date": "Date",
|
||||
"eventDetails": "Event details",
|
||||
"eventType": "Event type",
|
||||
"user": "User"
|
||||
},
|
||||
"fullActivity": "Full activity",
|
||||
"hoverCard": {
|
||||
"lastActivity": "Last activity",
|
||||
"partnerNodeUsed": "Partner node used",
|
||||
"totalCreditsUsed": "Total credits used"
|
||||
},
|
||||
"perUserHint": "Looking for total usage per user?",
|
||||
"seeMembers": "See Members",
|
||||
"empty": "No activity yet."
|
||||
}
|
||||
},
|
||||
"teamWorkspacesDialog": {
|
||||
@@ -3017,7 +3136,7 @@
|
||||
"newWorkspace": "New workspace",
|
||||
"namePlaceholder": "e.g. Marketing Team",
|
||||
"createWorkspace": "Create workspace",
|
||||
"nameValidationError": "Name must be 1–50 characters using letters, numbers, spaces, or common punctuation."
|
||||
"nameValidationError": "Name must be 1–30 characters using letters, numbers, spaces, or common punctuation."
|
||||
},
|
||||
"workspaceSwitcher": {
|
||||
"switchWorkspace": "Switch workspace",
|
||||
@@ -3027,7 +3146,8 @@
|
||||
"roleMember": "Member",
|
||||
"createWorkspace": "Create a workspace",
|
||||
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",
|
||||
"failedToSwitch": "Failed to switch workspace"
|
||||
"failedToSwitch": "Failed to switch workspace",
|
||||
"roleAdmin": "Admin"
|
||||
},
|
||||
"selectionToolbox": {
|
||||
"executeButton": {
|
||||
|
||||
@@ -63,8 +63,7 @@
|
||||
"name": "Require confirmation when clearing workflow"
|
||||
},
|
||||
"Comfy_DevMode": {
|
||||
"name": "Enable dev mode options (API save, etc.)",
|
||||
"tooltip": "Enables the legacy \"Save (API Format)\" button, dev-only nodes in the node library, and a \"Deprecation Warnings\" sidebar tab that surfaces uses of removed/renamed APIs by custom nodes."
|
||||
"name": "Enable dev mode options (API save, etc.)"
|
||||
},
|
||||
"Comfy_DisableFloatRounding": {
|
||||
"name": "Disable default float widget rounding.",
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "Por favor, mantén tu respuesta por debajo de {max} caracteres.",
|
||||
"chooseAnOption": "Por favor, elige una opción.",
|
||||
"describeAnswer": "Por favor, describe tu respuesta.",
|
||||
"selectAtLeastOne": "Por favor, selecciona al menos una opción."
|
||||
},
|
||||
"intro": "Ayúdanos a personalizar tu experiencia con ComfyUI.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "Nuevo en ComfyUI",
|
||||
"pro": "Soy usuario avanzado",
|
||||
"some": "Ya tengo experiencia"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Nodos personalizados",
|
||||
"pipelines": "Pipelines automatizados",
|
||||
"products": "Productos para otros"
|
||||
"familiarity": {
|
||||
"advanced": "Usuario avanzado (flujos de trabajo personalizados)",
|
||||
"basics": "Cómodo con lo básico",
|
||||
"expert": "Experto (ayudo a otros)",
|
||||
"new": "Nuevo en ComfyUI (nunca lo he usado antes)",
|
||||
"starting": "Recién comenzando (siguiendo tutoriales)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "Aplicaciones y APIs",
|
||||
"exploring": "Solo explorando",
|
||||
"3d_game": "Recursos 3D / recursos para juegos",
|
||||
"api": "Endpoints de API para ejecutar flujos de trabajo",
|
||||
"apps": "Apps simplificadas a partir de flujos de trabajo",
|
||||
"audio": "Audio / música",
|
||||
"custom_nodes": "Nodos personalizados",
|
||||
"images": "Imágenes",
|
||||
"other": "Otra cosa",
|
||||
"otherPlaceholder": "¿Qué quieres crear?",
|
||||
"video": "Video",
|
||||
"not_sure": "No estoy seguro",
|
||||
"videos": "Videos",
|
||||
"workflows": "Flujos de trabajo o pipelines personalizados"
|
||||
},
|
||||
"source": {
|
||||
"community": "Una comunidad o foro",
|
||||
"conference": "Conferencia o evento",
|
||||
"discord": "Discord / comunidad",
|
||||
"friend": "Amigo o colega",
|
||||
"other": "Otro",
|
||||
"otherPlaceholder": "¿Dónde nos encontraste?",
|
||||
"search": "Google / búsqueda",
|
||||
"social": "Redes sociales"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "Newsletter o blog",
|
||||
"other": "Otro",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"search": "Google / búsqueda",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "Educación (estudiante o docente)",
|
||||
"personal": "Uso personal",
|
||||
"work": "Trabajo"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "Cuéntanos más",
|
||||
"placeholder": "Marcador de posición para preguntas de la encuesta",
|
||||
"steps": {
|
||||
"familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
|
||||
"intent": "¿Qué quieres crear con ComfyUI?",
|
||||
"source": "¿Dónde escuchaste sobre ComfyUI?",
|
||||
"usage": "¿Cómo planeas usar ComfyUI?"
|
||||
},
|
||||
"title": "Encuesta en la Nube"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "Conoce más sobre Cloud",
|
||||
"cloudStart_title": "comienza a crear en segundos",
|
||||
"cloudStart_wantToRun": "¿Prefieres ejecutar ComfyUI localmente?",
|
||||
"cloudSurvey_steps_experience": "¿Qué tanto conoces ComfyUI?",
|
||||
"cloudSurvey_steps_focus": "¿Qué estás construyendo?",
|
||||
"cloudSurvey_steps_familiarity": "¿Qué tan familiarizado estás con ComfyUI?",
|
||||
"cloudSurvey_steps_intent": "¿Qué quieres crear con ComfyUI?",
|
||||
"cloudSurvey_steps_source": "¿Dónde escuchaste sobre ComfyUI?",
|
||||
"cloudSurvey_steps_source_social": "¿En qué plataforma?",
|
||||
"cloudSurvey_steps_usage": "¿Cómo planeas usar ComfyUI?",
|
||||
"cloudWaitlist_contactLink": "aquí",
|
||||
"cloudWaitlist_questionsText": "¿Preguntas? Contáctanos",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "لطفاً پاسخ خود را کمتر از {max} نویسه نگه دارید.",
|
||||
"chooseAnOption": "لطفاً یک گزینه را انتخاب کنید.",
|
||||
"describeAnswer": "لطفاً پاسخ خود را توضیح دهید.",
|
||||
"selectAtLeastOne": "لطفاً حداقل یک گزینه را انتخاب کنید."
|
||||
},
|
||||
"intro": "به ما کمک کنید تا تجربه شما از ComfyUI را متناسبسازی کنیم.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "جدید در ComfyUI",
|
||||
"pro": "کاربر حرفهای هستم",
|
||||
"some": "آشنایی نسبی دارم"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Nodeهای سفارشی",
|
||||
"pipelines": "پایپلاینهای خودکار",
|
||||
"products": "محصولات برای دیگران"
|
||||
"familiarity": {
|
||||
"advanced": "کاربر پیشرفته (جریانکارهای سفارشی)",
|
||||
"basics": "آشنایی با مبانی",
|
||||
"expert": "کاربر خبره (به دیگران کمک میکنم)",
|
||||
"new": "جدید در ComfyUI (تا کنون استفاده نکردهام)",
|
||||
"starting": "تازه شروع کردهام (در حال دنبال کردن آموزشها)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "اپلیکیشنها و APIها",
|
||||
"exploring": "فقط در حال بررسی",
|
||||
"3d_game": "دارایی سهبعدی / دارایی بازی",
|
||||
"api": "API endpoint برای اجرای workflow",
|
||||
"apps": "اپلیکیشن سادهشده از workflow",
|
||||
"audio": "صدا / موسیقی",
|
||||
"custom_nodes": "node سفارشی",
|
||||
"images": "تصویر",
|
||||
"other": "چیز دیگری",
|
||||
"otherPlaceholder": "چه چیزی میخواهید بسازید؟",
|
||||
"video": "ویدیو",
|
||||
"not_sure": "مطمئن نیستم",
|
||||
"videos": "ویدیو",
|
||||
"workflows": "workflow یا pipeline سفارشی"
|
||||
},
|
||||
"source": {
|
||||
"community": "انجمن یا فروم",
|
||||
"conference": "کنفرانس یا رویداد",
|
||||
"discord": "Discord / انجمن",
|
||||
"friend": "دوست یا همکار",
|
||||
"other": "سایر",
|
||||
"otherPlaceholder": "از کجا با ما آشنا شدید؟",
|
||||
"search": "Google / جستجو",
|
||||
"social": "رسانههای اجتماعی"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "خبرنامه یا وبلاگ",
|
||||
"other": "سایر",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"search": "Google / جستجو",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "آموزشی (دانشجو یا مدرس)",
|
||||
"personal": "استفاده شخصی",
|
||||
"work": "کاری"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "بیشتر توضیح دهید",
|
||||
"placeholder": "جاینگهدار سوالات نظرسنجی",
|
||||
"steps": {
|
||||
"familiarity": "تا چه حد با ComfyUI آشنایی دارید؟",
|
||||
"intent": "مایل هستید با ComfyUI چه چیزی ایجاد کنید؟",
|
||||
"source": "از کجا با ComfyUI آشنا شدید؟",
|
||||
"usage": "برنامه شما برای استفاده از ComfyUI چیست؟"
|
||||
},
|
||||
"title": "نظرسنجی ابری"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "درباره Cloud بیشتر بدانید",
|
||||
"cloudStart_title": "در چند ثانیه شروع به خلق کنید",
|
||||
"cloudStart_wantToRun": "مایلید ComfyUI را به صورت محلی اجرا کنید؟",
|
||||
"cloudSurvey_steps_experience": "تا چه حد با ComfyUI آشنایی دارید؟",
|
||||
"cloudSurvey_steps_focus": "در حال ساخت چه چیزی هستید؟",
|
||||
"cloudSurvey_steps_familiarity": "تا چه اندازه با ComfyUI آشنایی دارید؟",
|
||||
"cloudSurvey_steps_intent": "مایل هستید با ComfyUI چه چیزی ایجاد کنید؟",
|
||||
"cloudSurvey_steps_source": "از کجا با ComfyUI آشنا شدید؟",
|
||||
"cloudSurvey_steps_source_social": "کدام پلتفرم؟",
|
||||
"cloudSurvey_steps_usage": "برنامه شما برای استفاده از ComfyUI چیست؟",
|
||||
"cloudWaitlist_contactLink": "اینجا",
|
||||
"cloudWaitlist_questionsText": "سؤالی دارید؟ با ما تماس بگیرید",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "Veuillez limiter votre réponse à {max} caractères.",
|
||||
"chooseAnOption": "Veuillez choisir une option.",
|
||||
"describeAnswer": "Veuillez décrire votre réponse.",
|
||||
"selectAtLeastOne": "Veuillez sélectionner au moins une option."
|
||||
},
|
||||
"intro": "Aidez-nous à personnaliser votre expérience ComfyUI.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "Nouveau sur ComfyUI",
|
||||
"pro": "Utilisateur avancé",
|
||||
"some": "Je me débrouille"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Nœuds personnalisés",
|
||||
"pipelines": "Pipelines automatisés",
|
||||
"products": "Produits pour les autres"
|
||||
"familiarity": {
|
||||
"advanced": "Utilisateur avancé (workflows personnalisés)",
|
||||
"basics": "À l'aise avec les bases",
|
||||
"expert": "Expert (j'aide les autres)",
|
||||
"new": "Nouveau sur ComfyUI (jamais utilisé auparavant)",
|
||||
"starting": "Je débute (je suis des tutoriels)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "Applications et API",
|
||||
"exploring": "Je découvre simplement",
|
||||
"3d_game": "Assets 3D / assets de jeu",
|
||||
"api": "Points de terminaison API pour exécuter des workflows",
|
||||
"apps": "Applications simplifiées à partir de workflows",
|
||||
"audio": "Audio / musique",
|
||||
"custom_nodes": "Nœuds personnalisés",
|
||||
"images": "Images",
|
||||
"other": "Autre chose",
|
||||
"otherPlaceholder": "Qu'aimeriez-vous créer ?",
|
||||
"video": "Vidéo",
|
||||
"not_sure": "Pas sûr",
|
||||
"videos": "Vidéos",
|
||||
"workflows": "Workflows ou pipelines personnalisés"
|
||||
},
|
||||
"source": {
|
||||
"community": "Une communauté ou un forum",
|
||||
"conference": "Conférence ou événement",
|
||||
"discord": "Discord / communauté",
|
||||
"friend": "Ami ou collègue",
|
||||
"other": "Autre",
|
||||
"otherPlaceholder": "Où nous avez-vous trouvés ?",
|
||||
"search": "Google / recherche",
|
||||
"social": "Réseaux sociaux"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "Newsletter ou blog",
|
||||
"other": "Autre",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"search": "Google / recherche",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "Éducation (étudiant ou enseignant)",
|
||||
"personal": "Usage personnel",
|
||||
"work": "Travail"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "Dites-nous en plus",
|
||||
"placeholder": "Texte indicatif des questions de l'enquête",
|
||||
"steps": {
|
||||
"familiarity": "Quelle est votre familiarité avec ComfyUI ?",
|
||||
"intent": "Que souhaitez-vous créer avec ComfyUI ?",
|
||||
"source": "Où avez-vous entendu parler de ComfyUI ?",
|
||||
"usage": "Comment prévoyez-vous d'utiliser ComfyUI ?"
|
||||
},
|
||||
"title": "Enquête Cloud"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "En savoir plus sur Cloud",
|
||||
"cloudStart_title": "créez en quelques secondes",
|
||||
"cloudStart_wantToRun": "Vous préférez exécuter ComfyUI localement ?",
|
||||
"cloudSurvey_steps_experience": "Quel est votre niveau de connaissance de ComfyUI ?",
|
||||
"cloudSurvey_steps_focus": "Qu'êtes-vous en train de créer ?",
|
||||
"cloudSurvey_steps_familiarity": "Quelle est votre familiarité avec ComfyUI ?",
|
||||
"cloudSurvey_steps_intent": "Que souhaitez-vous créer avec ComfyUI ?",
|
||||
"cloudSurvey_steps_source": "Où avez-vous entendu parler de ComfyUI ?",
|
||||
"cloudSurvey_steps_source_social": "Quelle plateforme ?",
|
||||
"cloudSurvey_steps_usage": "Comment prévoyez-vous d'utiliser ComfyUI ?",
|
||||
"cloudWaitlist_contactLink": "ici",
|
||||
"cloudWaitlist_questionsText": "Des questions ? Contactez-nous",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "אנא שמרו את התשובה שלכם עד {max} תווים.",
|
||||
"chooseAnOption": "אנא בחר אפשרות.",
|
||||
"describeAnswer": "אנא תאר את תשובתך.",
|
||||
"selectAtLeastOne": "אנא בחר לפחות אפשרות אחת."
|
||||
},
|
||||
"intro": "עזרו לנו להתאים את חוויית ה-ComfyUI שלך.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "חדש/ה ב-ComfyUI",
|
||||
"pro": "משתמש/ת מתקדם/ת",
|
||||
"some": "מכיר/ה את המערכת"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "צמתים מותאמים אישית",
|
||||
"pipelines": "צינורות עבודה אוטומטיים",
|
||||
"products": "מוצרים לאחרים"
|
||||
"familiarity": {
|
||||
"advanced": "מתקדם — בונה ועורך תהליכי עבודה",
|
||||
"basics": "בינוני — מרגיש בנוח עם היסודות",
|
||||
"expert": "מומחה — אני עוזר לאחרים",
|
||||
"new": "חדש — מעולם לא השתמשתי",
|
||||
"starting": "מתחיל — עוקב אחר מדריכים"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "אפליקציות ו-API",
|
||||
"exploring": "רק בודק/ת",
|
||||
"3d_game": "נכסי תלת-ממד / נכסי משחקים",
|
||||
"api": "נקודות קצה של API להרצת תהליכי עבודה",
|
||||
"apps": "יישומים מפושטים מתהליכי עבודה",
|
||||
"audio": "שמע / מוזיקה",
|
||||
"custom_nodes": "צמתים מותאמים",
|
||||
"images": "תמונות",
|
||||
"other": "משהו אחר",
|
||||
"otherPlaceholder": "מה תרצו ליצור?",
|
||||
"video": "וידאו",
|
||||
"not_sure": "לא בטוח",
|
||||
"videos": "סרטונים",
|
||||
"workflows": "תהליכי עבודה או צינורות (pipelines) מותאמים"
|
||||
},
|
||||
"source": {
|
||||
"community": "קהילה או פורום",
|
||||
"conference": "כנס או אירוע",
|
||||
"discord": "Discord / קהילה",
|
||||
"friend": "חבר או עמית",
|
||||
"other": "אחר",
|
||||
"otherPlaceholder": "היכן שמעתם עלינו?",
|
||||
"search": "Google / חיפוש",
|
||||
"social": "רשתות חברתיות"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "ניוזלטר או בלוג",
|
||||
"other": "אחר",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"search": "Google / חיפוש",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "חינוך (סטודנט או מרצה)",
|
||||
"personal": "שימוש אישי",
|
||||
"work": "עבודה"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "ספרו לנו עוד",
|
||||
"placeholder": "מציין מיקום לשאלות הסקר",
|
||||
"steps": {
|
||||
"familiarity": "עד כמה אתה מכיר את ComfyUI?",
|
||||
"intent": "מה ברצונך ליצור עם ComfyUI?",
|
||||
"source": "היכן שמעת על ComfyUI?",
|
||||
"usage": "כיצד אתה מתכנן להשתמש ב-ComfyUI?"
|
||||
},
|
||||
"title": "סקר ענן"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "למד על הענן",
|
||||
"cloudStart_title": "התחל ליצור תוך שניות",
|
||||
"cloudStart_wantToRun": "מעדיף להריץ את ComfyUI מקומית?",
|
||||
"cloudSurvey_steps_experience": "עד כמה אתם מכירים את ComfyUI?",
|
||||
"cloudSurvey_steps_focus": "מה אתם בונים?",
|
||||
"cloudSurvey_steps_familiarity": "עד כמה אתה מכיר את ComfyUI?",
|
||||
"cloudSurvey_steps_intent": "מה ברצונך ליצור עם ComfyUI?",
|
||||
"cloudSurvey_steps_source": "היכן שמעת על ComfyUI?",
|
||||
"cloudSurvey_steps_source_social": "באיזו פלטפורמה?",
|
||||
"cloudSurvey_steps_usage": "כיצד אתה מתכנן להשתמש ב-ComfyUI?",
|
||||
"cloudWaitlist_contactLink": "כאן",
|
||||
"cloudWaitlist_questionsText": "שאלות? צור איתנו קשר",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "回答は{max}文字以内で入力してください。",
|
||||
"chooseAnOption": "オプションを選択してください。",
|
||||
"describeAnswer": "回答を記述してください。",
|
||||
"selectAtLeastOne": "少なくとも1つ選択してください。"
|
||||
},
|
||||
"intro": "ComfyUIの体験をより最適化するためにご協力ください。",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "ComfyUIは初めて",
|
||||
"pro": "上級ユーザー",
|
||||
"some": "ある程度使い方が分かる"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "カスタムノード",
|
||||
"pipelines": "自動パイプライン",
|
||||
"products": "他者向けプロダクト"
|
||||
"familiarity": {
|
||||
"advanced": "上級ユーザー(カスタムワークフロー)",
|
||||
"basics": "基本操作に慣れている",
|
||||
"expert": "エキスパート(他者を支援)",
|
||||
"new": "ComfyUI初心者(使用経験なし)",
|
||||
"starting": "使い始め(チュートリアルをフォロー中)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "アプリ・API",
|
||||
"exploring": "探索中",
|
||||
"3d_game": "3Dアセット/ゲームアセット",
|
||||
"api": "ワークフロー実行用APIエンドポイント",
|
||||
"apps": "ワークフローから簡易アプリ作成",
|
||||
"audio": "音声/音楽",
|
||||
"custom_nodes": "カスタムノード",
|
||||
"images": "画像",
|
||||
"other": "その他",
|
||||
"otherPlaceholder": "何を作りたいですか?",
|
||||
"video": "動画",
|
||||
"not_sure": "まだ分からない",
|
||||
"videos": "動画",
|
||||
"workflows": "カスタムワークフローやパイプライン"
|
||||
},
|
||||
"source": {
|
||||
"community": "コミュニティ・フォーラム",
|
||||
"conference": "カンファレンスやイベント",
|
||||
"discord": "Discord/コミュニティ",
|
||||
"friend": "友人または同僚",
|
||||
"other": "その他",
|
||||
"otherPlaceholder": "どこで私たちを知りましたか?",
|
||||
"search": "Google/検索",
|
||||
"social": "ソーシャルメディア"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "ニュースレターやブログ",
|
||||
"other": "その他",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X(Twitter)",
|
||||
"search": "Google/検索",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "教育(学生または教育者)",
|
||||
"personal": "個人利用",
|
||||
"work": "仕事"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "詳細をお聞かせください",
|
||||
"placeholder": "アンケート質問のプレースホルダー",
|
||||
"steps": {
|
||||
"familiarity": "ComfyUIの使用経験はどの程度ですか?",
|
||||
"intent": "ComfyUIで何を作成したいですか?",
|
||||
"source": "ComfyUIをどこで知りましたか?",
|
||||
"usage": "ComfyUIをどのように利用する予定ですか?"
|
||||
},
|
||||
"title": "クラウドアンケート"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "クラウドについて学ぶ",
|
||||
"cloudStart_title": "数秒で作成を開始",
|
||||
"cloudStart_wantToRun": "代わりにローカルでComfyUIを実行したいですか?",
|
||||
"cloudSurvey_steps_experience": "ComfyUIの知識レベルは?",
|
||||
"cloudSurvey_steps_focus": "何を作成していますか?",
|
||||
"cloudSurvey_steps_familiarity": "ComfyUIにどの程度精通していますか?",
|
||||
"cloudSurvey_steps_intent": "ComfyUIで何を作成したいですか?",
|
||||
"cloudSurvey_steps_source": "ComfyUIをどこで知りましたか?",
|
||||
"cloudSurvey_steps_source_social": "どのプラットフォームですか?",
|
||||
"cloudSurvey_steps_usage": "ComfyUIをどのように利用する予定ですか?",
|
||||
"cloudWaitlist_contactLink": "こちら",
|
||||
"cloudWaitlist_questionsText": "質問がありますか?お問い合わせください",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "답변은 {max}자 이내로 작성해 주세요.",
|
||||
"chooseAnOption": "옵션을 선택해 주세요.",
|
||||
"describeAnswer": "답변을 설명해 주세요.",
|
||||
"selectAtLeastOne": "최소 한 가지 옵션을 선택해 주세요."
|
||||
},
|
||||
"intro": "ComfyUI 경험을 맞춤화할 수 있도록 도와주세요.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "ComfyUI가 처음이에요",
|
||||
"pro": "전문 사용자입니다",
|
||||
"some": "기본적인 사용법을 알아요"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "커스텀 노드",
|
||||
"pipelines": "자동화 파이프라인",
|
||||
"products": "타인을 위한 제품"
|
||||
"familiarity": {
|
||||
"advanced": "고급 사용자 (커스텀 워크플로우 사용)",
|
||||
"basics": "기본 기능에 익숙함",
|
||||
"expert": "전문가 (다른 사용자 도움)",
|
||||
"new": "ComfyUI 처음 사용 (이전에 사용한 적 없음)",
|
||||
"starting": "막 시작한 단계 (튜토리얼 따라하는 중)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "앱 및 API",
|
||||
"exploring": "그냥 둘러보는 중",
|
||||
"3d_game": "3D 에셋 / 게임 에셋",
|
||||
"api": "워크플로우 실행용 API 엔드포인트",
|
||||
"apps": "워크플로우 기반 간소화 앱",
|
||||
"audio": "오디오 / 음악",
|
||||
"custom_nodes": "커스텀 노드",
|
||||
"images": "이미지",
|
||||
"other": "기타",
|
||||
"otherPlaceholder": "무엇을 만들고 싶으신가요?",
|
||||
"video": "비디오",
|
||||
"not_sure": "잘 모르겠음",
|
||||
"videos": "비디오",
|
||||
"workflows": "맞춤형 워크플로우 또는 파이프라인"
|
||||
},
|
||||
"source": {
|
||||
"community": "커뮤니티 또는 포럼",
|
||||
"conference": "컨퍼런스 또는 이벤트",
|
||||
"discord": "Discord / 커뮤니티",
|
||||
"friend": "친구 또는 동료",
|
||||
"other": "기타",
|
||||
"otherPlaceholder": "어디서 저희를 알게 되셨나요?",
|
||||
"search": "Google / 검색",
|
||||
"social": "소셜 미디어"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "뉴스레터 또는 블로그",
|
||||
"other": "기타",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"search": "Google / 검색",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "교육용(학생 또는 교육자)",
|
||||
"personal": "개인용",
|
||||
"work": "업무용"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "자세히 알려주세요",
|
||||
"placeholder": "설문 질문 자리표시자",
|
||||
"steps": {
|
||||
"familiarity": "ComfyUI에 얼마나 익숙하신가요?",
|
||||
"intent": "ComfyUI로 무엇을 만들고 싶으신가요?",
|
||||
"source": "ComfyUI를 어디에서 알게 되셨나요?",
|
||||
"usage": "ComfyUI를 어떻게 사용하실 계획인가요?"
|
||||
},
|
||||
"title": "클라우드 설문"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "클라우드 알아보기",
|
||||
"cloudStart_title": "몇 초 만에 제작 시작",
|
||||
"cloudStart_wantToRun": "로컬에서 ComfyUI를 실행하고 싶으신가요?",
|
||||
"cloudSurvey_steps_experience": "ComfyUI를 얼마나 잘 알고 계신가요?",
|
||||
"cloudSurvey_steps_focus": "무엇을 만들고 계신가요?",
|
||||
"cloudSurvey_steps_familiarity": "ComfyUI에 얼마나 익숙하신가요?",
|
||||
"cloudSurvey_steps_intent": "ComfyUI로 무엇을 만들고 싶으신가요?",
|
||||
"cloudSurvey_steps_source": "ComfyUI를 어디에서 알게 되셨나요?",
|
||||
"cloudSurvey_steps_source_social": "어떤 플랫폼에서 알게 되셨나요?",
|
||||
"cloudSurvey_steps_usage": "ComfyUI를 어떻게 사용하실 계획인가요?",
|
||||
"cloudWaitlist_contactLink": "여기",
|
||||
"cloudWaitlist_questionsText": "질문이 있으신가요? 문의하기",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "Por favor, mantenha sua resposta com menos de {max} caracteres.",
|
||||
"chooseAnOption": "Por favor, escolha uma opção.",
|
||||
"describeAnswer": "Por favor, descreva sua resposta.",
|
||||
"selectAtLeastOne": "Por favor, selecione pelo menos uma opção."
|
||||
},
|
||||
"intro": "Ajude-nos a personalizar sua experiência no ComfyUI.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "Novo no ComfyUI",
|
||||
"pro": "Sou um usuário avançado",
|
||||
"some": "Já conheço um pouco"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Nós personalizados",
|
||||
"pipelines": "Pipelines automatizados",
|
||||
"products": "Produtos para outros"
|
||||
"familiarity": {
|
||||
"advanced": "Usuário avançado (fluxos de trabalho personalizados)",
|
||||
"basics": "Confortável com o básico",
|
||||
"expert": "Especialista (ajuda outras pessoas)",
|
||||
"new": "Novo no ComfyUI (nunca usei antes)",
|
||||
"starting": "Começando agora (seguindo tutoriais)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "Apps e APIs",
|
||||
"exploring": "Só explorando",
|
||||
"3d_game": "Assets 3D / assets para jogos",
|
||||
"api": "Endpoints de API para executar workflows",
|
||||
"apps": "Apps simplificados a partir de workflows",
|
||||
"audio": "Áudio / música",
|
||||
"custom_nodes": "Nodes personalizados",
|
||||
"images": "Imagens",
|
||||
"other": "Outra coisa",
|
||||
"otherPlaceholder": "O que você quer criar?",
|
||||
"video": "Vídeo",
|
||||
"not_sure": "Não tenho certeza",
|
||||
"videos": "Vídeos",
|
||||
"workflows": "Workflows ou pipelines personalizados"
|
||||
},
|
||||
"source": {
|
||||
"community": "Uma comunidade ou fórum",
|
||||
"conference": "Conferência ou evento",
|
||||
"discord": "Discord / comunidade",
|
||||
"friend": "Amigo ou colega",
|
||||
"other": "Outro",
|
||||
"otherPlaceholder": "Onde você nos encontrou?",
|
||||
"search": "Google / busca",
|
||||
"social": "Mídias sociais"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "Newsletter ou blog",
|
||||
"other": "Outro",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"search": "Google / busca",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "Educação (estudante ou educador)",
|
||||
"personal": "Uso pessoal",
|
||||
"work": "Trabalho"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "Conte-nos mais",
|
||||
"placeholder": "Espaço reservado para perguntas da pesquisa",
|
||||
"steps": {
|
||||
"familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
|
||||
"intent": "O que você deseja criar com o ComfyUI?",
|
||||
"source": "Onde você ouviu falar do ComfyUI?",
|
||||
"usage": "Como você pretende usar o ComfyUI?"
|
||||
},
|
||||
"title": "Pesquisa da Nuvem"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "Saiba mais sobre a Nuvem",
|
||||
"cloudStart_title": "comece a criar em segundos",
|
||||
"cloudStart_wantToRun": "Prefere rodar o ComfyUI localmente?",
|
||||
"cloudSurvey_steps_experience": "Qual o seu nível de conhecimento do ComfyUI?",
|
||||
"cloudSurvey_steps_focus": "O que você está construindo?",
|
||||
"cloudSurvey_steps_familiarity": "Qual o seu nível de familiaridade com o ComfyUI?",
|
||||
"cloudSurvey_steps_intent": "O que você deseja criar com o ComfyUI?",
|
||||
"cloudSurvey_steps_source": "Onde você ouviu falar do ComfyUI?",
|
||||
"cloudSurvey_steps_source_social": "Em qual plataforma?",
|
||||
"cloudSurvey_steps_usage": "Como você pretende usar o ComfyUI?",
|
||||
"cloudWaitlist_contactLink": "aqui",
|
||||
"cloudWaitlist_questionsText": "Dúvidas? Entre em contato conosco",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "Пожалуйста, сократите ваш ответ до {max} символов.",
|
||||
"chooseAnOption": "Пожалуйста, выберите вариант.",
|
||||
"describeAnswer": "Пожалуйста, опишите ваш ответ.",
|
||||
"selectAtLeastOne": "Пожалуйста, выберите хотя бы один вариант."
|
||||
},
|
||||
"intro": "Помогите нам адаптировать ваш опыт работы с ComfyUI.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "Впервые в ComfyUI",
|
||||
"pro": "Я опытный пользователь",
|
||||
"some": "Я немного знаком(а)"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Пользовательские узлы",
|
||||
"pipelines": "Автоматизированные пайплайны",
|
||||
"products": "Продукты для других"
|
||||
"familiarity": {
|
||||
"advanced": "Продвинутый пользователь (пользовательские рабочие процессы)",
|
||||
"basics": "Уверенно владею основами",
|
||||
"expert": "Эксперт (помогаю другим)",
|
||||
"new": "Новичок в ComfyUI (никогда не использовал)",
|
||||
"starting": "Только начинаю (следую руководствам)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "Приложения и API",
|
||||
"exploring": "Просто изучаю",
|
||||
"3d_game": "3D-ассеты / игровые ассеты",
|
||||
"api": "API endpoints для запуска workflow",
|
||||
"apps": "Упрощённые приложения из workflow",
|
||||
"audio": "Аудио / музыка",
|
||||
"custom_nodes": "Пользовательские node",
|
||||
"images": "Изображения",
|
||||
"other": "Другое",
|
||||
"otherPlaceholder": "Что вы хотите создать?",
|
||||
"video": "Видео",
|
||||
"not_sure": "Не уверен",
|
||||
"videos": "Видео",
|
||||
"workflows": "Пользовательские workflow или pipeline"
|
||||
},
|
||||
"source": {
|
||||
"community": "Сообщество или форум",
|
||||
"conference": "Конференция или мероприятие",
|
||||
"discord": "Discord / сообщество",
|
||||
"friend": "Друг или коллега",
|
||||
"other": "Другое",
|
||||
"otherPlaceholder": "Где вы о нас узнали?",
|
||||
"search": "Google / поиск",
|
||||
"social": "Социальные сети"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "Новостная рассылка или блог",
|
||||
"other": "Другое",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"search": "Google / поиск",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "Образование (студент или преподаватель)",
|
||||
"personal": "Личное использование",
|
||||
"work": "Работа"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "Расскажите подробнее",
|
||||
"placeholder": "Вопросы для опроса",
|
||||
"steps": {
|
||||
"familiarity": "Насколько вы знакомы с ComfyUI?",
|
||||
"intent": "Что вы хотите создавать с помощью ComfyUI?",
|
||||
"source": "Где вы узнали о ComfyUI?",
|
||||
"usage": "Как вы планируете использовать ComfyUI?"
|
||||
},
|
||||
"title": "Облачный опрос"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "Узнать о Cloud",
|
||||
"cloudStart_title": "начать создавать за секунды",
|
||||
"cloudStart_wantToRun": "Хотите запустить ComfyUI локально?",
|
||||
"cloudSurvey_steps_experience": "Насколько хорошо вы знаете ComfyUI?",
|
||||
"cloudSurvey_steps_focus": "Что вы создаёте?",
|
||||
"cloudSurvey_steps_familiarity": "Насколько вы знакомы с ComfyUI?",
|
||||
"cloudSurvey_steps_intent": "Что вы хотите создавать с помощью ComfyUI?",
|
||||
"cloudSurvey_steps_source": "Где вы узнали о ComfyUI?",
|
||||
"cloudSurvey_steps_source_social": "На какой платформе?",
|
||||
"cloudSurvey_steps_usage": "Как вы планируете использовать ComfyUI?",
|
||||
"cloudWaitlist_contactLink": "здесь",
|
||||
"cloudWaitlist_questionsText": "Есть вопросы? Свяжитесь с нами",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "Lütfen cevabınızı {max} karakterin altında tutun.",
|
||||
"chooseAnOption": "Lütfen bir seçenek seçin.",
|
||||
"describeAnswer": "Lütfen cevabınızı açıklayın.",
|
||||
"selectAtLeastOne": "Lütfen en az bir seçenek seçin."
|
||||
},
|
||||
"intro": "ComfyUI deneyiminizi size özel hale getirmemize yardımcı olun.",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "ComfyUI'ye yeni",
|
||||
"pro": "Güçlü bir kullanıcıyım",
|
||||
"some": "Biraz biliyorum"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "Özel node'lar",
|
||||
"pipelines": "Otomatikleştirilmiş pipeline'lar",
|
||||
"products": "Başkaları için ürünler"
|
||||
"familiarity": {
|
||||
"advanced": "İleri seviye kullanıcı (özel iş akışları)",
|
||||
"basics": "Temel bilgilerde rahatım",
|
||||
"expert": "Uzman (başkalarına yardım ediyorum)",
|
||||
"new": "ComfyUI'a yeni (daha önce hiç kullanmadım)",
|
||||
"starting": "Yeni başlıyorum (eğitimleri takip ediyorum)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "Uygulamalar ve API'ler",
|
||||
"exploring": "Sadece keşfediyorum",
|
||||
"3d_game": "3D varlıklar / oyun varlıkları",
|
||||
"api": "İş akışlarını çalıştırmak için API uç noktaları",
|
||||
"apps": "İş akışlarından basitleştirilmiş uygulamalar",
|
||||
"audio": "Ses / müzik",
|
||||
"custom_nodes": "Özel node'lar",
|
||||
"images": "Görseller",
|
||||
"other": "Başka bir şey",
|
||||
"otherPlaceholder": "Ne yapmak istiyorsunuz?",
|
||||
"video": "Video",
|
||||
"not_sure": "Emin değilim",
|
||||
"videos": "Videolar",
|
||||
"workflows": "Özel iş akışları veya boru hatları"
|
||||
},
|
||||
"source": {
|
||||
"community": "Bir topluluk veya forum",
|
||||
"conference": "Konferans veya etkinlik",
|
||||
"discord": "Discord / topluluk",
|
||||
"friend": "Arkadaş veya iş arkadaşı",
|
||||
"other": "Diğer",
|
||||
"otherPlaceholder": "Bizi nereden buldunuz?",
|
||||
"search": "Google / arama",
|
||||
"social": "Sosyal medya"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "Bülten veya blog",
|
||||
"other": "Diğer",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X (Twitter)",
|
||||
"search": "Google / arama",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "Eğitim (öğrenci veya eğitmen)",
|
||||
"personal": "Kişisel kullanım",
|
||||
"work": "İş"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "Daha fazla bilgi verin",
|
||||
"placeholder": "Anket soruları yer tutucusu",
|
||||
"steps": {
|
||||
"familiarity": "ComfyUI'a ne kadar aşinasınız?",
|
||||
"intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
|
||||
"source": "ComfyUI'yi nereden duydunuz?",
|
||||
"usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?"
|
||||
},
|
||||
"title": "Bulut Anketi"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "Cloud hakkında bilgi edinin",
|
||||
"cloudStart_title": "saniyeler içinde oluşturmaya başlayın",
|
||||
"cloudStart_wantToRun": "ComfyUI'ı yerel olarak çalıştırmak mı istiyorsunuz?",
|
||||
"cloudSurvey_steps_experience": "ComfyUI'yi ne kadar iyi biliyorsunuz?",
|
||||
"cloudSurvey_steps_focus": "Ne inşa ediyorsunuz?",
|
||||
"cloudSurvey_steps_familiarity": "ComfyUI'ya ne kadar aşinasınız?",
|
||||
"cloudSurvey_steps_intent": "ComfyUI ile ne oluşturmak istiyorsunuz?",
|
||||
"cloudSurvey_steps_source": "ComfyUI'yi nereden duydunuz?",
|
||||
"cloudSurvey_steps_source_social": "Hangi platform?",
|
||||
"cloudSurvey_steps_usage": "ComfyUI'yi nasıl kullanmayı planlıyorsunuz?",
|
||||
"cloudWaitlist_contactLink": "burada",
|
||||
"cloudWaitlist_questionsText": "Sorularınız mı var? Bize ulaşın",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "請將您的回答控制在 {max} 個字以內。",
|
||||
"chooseAnOption": "請選擇一個選項。",
|
||||
"describeAnswer": "請描述您的答案。",
|
||||
"selectAtLeastOne": "請至少選擇一個選項。"
|
||||
},
|
||||
"intro": "協助我們為您量身打造 ComfyUI 體驗。",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "ComfyUI 新手",
|
||||
"pro": "我是進階使用者",
|
||||
"some": "我已經熟悉操作"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "自訂節點",
|
||||
"pipelines": "自動化流程",
|
||||
"products": "為他人打造產品"
|
||||
"familiarity": {
|
||||
"advanced": "進階使用者(自訂工作流程)",
|
||||
"basics": "熟悉基礎操作",
|
||||
"expert": "專家(協助他人)",
|
||||
"new": "ComfyUI 新手(從未使用過)",
|
||||
"starting": "剛開始(正在跟隨教學)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "應用程式與 API",
|
||||
"exploring": "只是探索",
|
||||
"3d_game": "3D 素材/遊戲素材",
|
||||
"api": "執行工作流程的 API 端點",
|
||||
"apps": "由工作流程簡化的應用程式",
|
||||
"audio": "音訊/音樂",
|
||||
"custom_nodes": "自訂節點",
|
||||
"images": "圖像",
|
||||
"other": "其他",
|
||||
"otherPlaceholder": "您想製作什麼?",
|
||||
"video": "影片",
|
||||
"not_sure": "尚未確定",
|
||||
"videos": "影片",
|
||||
"workflows": "自訂工作流程或管線"
|
||||
},
|
||||
"source": {
|
||||
"community": "社群或論壇",
|
||||
"conference": "研討會或活動",
|
||||
"discord": "Discord/社群",
|
||||
"friend": "朋友或同事",
|
||||
"other": "其他",
|
||||
"otherPlaceholder": "您是在哪裡發現我們的?",
|
||||
"search": "Google/搜尋引擎",
|
||||
"social": "社群媒體"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "電子報或部落格",
|
||||
"other": "其他",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X(Twitter)",
|
||||
"search": "Google/搜尋引擎",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "教育用途(學生或教育者)",
|
||||
"personal": "個人用途",
|
||||
"work": "工作用途"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "請告訴我們更多",
|
||||
"placeholder": "問卷問題佔位符",
|
||||
"steps": {
|
||||
"familiarity": "您對 ComfyUI 的熟悉程度如何?",
|
||||
"intent": "您想用 ComfyUI 創作什麼?",
|
||||
"source": "您是從哪裡得知 ComfyUI 的?",
|
||||
"usage": "您打算如何使用 ComfyUI?"
|
||||
},
|
||||
"title": "雲端問卷"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "了解雲端服務",
|
||||
"cloudStart_title": "數秒內開始創作",
|
||||
"cloudStart_wantToRun": "想要在本機運行 ComfyUI?",
|
||||
"cloudSurvey_steps_experience": "您對 ComfyUI 的熟悉程度?",
|
||||
"cloudSurvey_steps_focus": "您正在製作什麼?",
|
||||
"cloudSurvey_steps_familiarity": "您對 ComfyUI 的熟悉程度如何?",
|
||||
"cloudSurvey_steps_intent": "您想用 ComfyUI 創作什麼?",
|
||||
"cloudSurvey_steps_source": "您是從哪裡得知 ComfyUI 的?",
|
||||
"cloudSurvey_steps_source_social": "哪個平台?",
|
||||
"cloudSurvey_steps_usage": "您打算如何使用 ComfyUI?",
|
||||
"cloudWaitlist_contactLink": "此處",
|
||||
"cloudWaitlist_questionsText": "有問題?聯絡我們",
|
||||
"color": {
|
||||
|
||||
@@ -515,52 +515,57 @@
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"answerTooLong": "请将您的回答控制在 {max} 个字符以内。",
|
||||
"chooseAnOption": "请选择一个选项。",
|
||||
"describeAnswer": "请描述您的答案。",
|
||||
"selectAtLeastOne": "请至少选择一个选项。"
|
||||
},
|
||||
"intro": "帮助我们为您定制 ComfyUI 体验。",
|
||||
"options": {
|
||||
"experience": {
|
||||
"new": "ComfyUI 新手",
|
||||
"pro": "我是高级用户",
|
||||
"some": "我已经熟悉操作"
|
||||
},
|
||||
"focus": {
|
||||
"custom_nodes": "自定义节点",
|
||||
"pipelines": "自动化流程",
|
||||
"products": "为他人制作产品"
|
||||
"familiarity": {
|
||||
"advanced": "高级用户(自定义工作流)",
|
||||
"basics": "熟练掌握基础知识",
|
||||
"expert": "专家(帮助他人)",
|
||||
"new": "ComfyUI 新手(从未使用过)",
|
||||
"starting": "刚刚开始(正在学习教程)"
|
||||
},
|
||||
"intent": {
|
||||
"apps_api": "应用和 API",
|
||||
"exploring": "只是探索一下",
|
||||
"3d_game": "3D 资产 / 游戏资产",
|
||||
"api": "运行工作流的 API 端点",
|
||||
"apps": "基于工作流的简化应用",
|
||||
"audio": "音频 / 音乐",
|
||||
"custom_nodes": "自定义节点",
|
||||
"images": "图像",
|
||||
"other": "其他",
|
||||
"otherPlaceholder": "你想做什么?",
|
||||
"video": "视频",
|
||||
"not_sure": "不确定",
|
||||
"videos": "视频",
|
||||
"workflows": "自定义工作流或流程"
|
||||
},
|
||||
"source": {
|
||||
"community": "社区或论坛",
|
||||
"conference": "会议或活动",
|
||||
"discord": "Discord / 社区",
|
||||
"friend": "朋友或同事",
|
||||
"other": "其他",
|
||||
"otherPlaceholder": "你是从哪里了解到我们的?",
|
||||
"search": "Google / 搜索",
|
||||
"social": "社交媒体"
|
||||
},
|
||||
"source_social": {
|
||||
"discord": "Discord",
|
||||
"github": "GitHub",
|
||||
"instagram": "Instagram",
|
||||
"linkedin": "LinkedIn",
|
||||
"newsletter": "新闻通讯或博客",
|
||||
"other": "其他",
|
||||
"reddit": "Reddit",
|
||||
"tiktok": "TikTok",
|
||||
"twitter": "X(推特)",
|
||||
"search": "Google / 搜索",
|
||||
"twitter": "Twitter / X",
|
||||
"youtube": "YouTube"
|
||||
},
|
||||
"usage": {
|
||||
"education": "教育(学生或教师)",
|
||||
"personal": "个人使用",
|
||||
"work": "工作"
|
||||
}
|
||||
},
|
||||
"otherPlaceholder": "请告诉我们更多",
|
||||
"placeholder": "调查问题占位符",
|
||||
"steps": {
|
||||
"familiarity": "你对 ComfyUI 有多熟悉?",
|
||||
"intent": "您希望用 ComfyUI 创作什么?",
|
||||
"source": "您是从哪里了解到 ComfyUI 的?",
|
||||
"usage": "您打算如何使用 ComfyUI?"
|
||||
},
|
||||
"title": "云调研"
|
||||
}
|
||||
},
|
||||
@@ -573,11 +578,10 @@
|
||||
"cloudStart_learnAboutButton": "了解云服务",
|
||||
"cloudStart_title": "几秒钟内开始创作",
|
||||
"cloudStart_wantToRun": "想在本地运行 ComfyUI 吗?",
|
||||
"cloudSurvey_steps_experience": "你对 ComfyUI 有多了解?",
|
||||
"cloudSurvey_steps_focus": "你正在构建什么?",
|
||||
"cloudSurvey_steps_familiarity": "你对 ComfyUI 有多熟悉?",
|
||||
"cloudSurvey_steps_intent": "您希望用 ComfyUI 创作什么?",
|
||||
"cloudSurvey_steps_source": "您是从哪里了解到 ComfyUI 的?",
|
||||
"cloudSurvey_steps_source_social": "你是在哪个平台上看到的?",
|
||||
"cloudSurvey_steps_usage": "您打算如何使用 ComfyUI?",
|
||||
"cloudWaitlist_contactLink": "这里",
|
||||
"cloudWaitlist_questionsText": "有问题?联系我们",
|
||||
"color": {
|
||||
|
||||
@@ -1,67 +1,48 @@
|
||||
<template>
|
||||
<div class="relative mx-2">
|
||||
<div
|
||||
data-testid="assets-selection-bar"
|
||||
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
|
||||
<SelectionBar
|
||||
data-testid="assets-selection-bar"
|
||||
:label="$t('mediaAsset.selection.selectedCount', { count })"
|
||||
:deselect-label="$t('mediaAsset.selection.deselectAll')"
|
||||
@deselect="emit('deselect')"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.downloadSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-download-selected"
|
||||
:aria-label="$t('mediaAsset.selection.downloadSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<template v-if="showDelete">
|
||||
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.deselectAll'),
|
||||
value: $t('mediaAsset.selection.deleteSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-deselect-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deselectAll')"
|
||||
data-testid="assets-delete-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deleteSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('deselect')"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
|
||||
{{ $t('mediaAsset.selection.selectedCount', { count }) }}
|
||||
</span>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.downloadSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-download-selected"
|
||||
:aria-label="$t('mediaAsset.selection.downloadSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<template v-if="showDelete">
|
||||
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.deleteSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-delete-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deleteSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SelectionBar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SelectionBar from '@/components/common/SelectionBar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { count, showDelete = true } = defineProps<{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div class="dark-theme flex max-h-full w-full max-w-md flex-col px-4 sm:px-6">
|
||||
<div
|
||||
class="dark-theme flex max-h-[85vh] w-full max-w-md flex-col overflow-y-auto px-4 sm:px-6"
|
||||
>
|
||||
<h1
|
||||
class="-mb-1 font-inter text-xl/8 font-semibold tracking-wide text-primary-comfy-canvas sm:text-2xl/8"
|
||||
>
|
||||
|
||||
@@ -1,618 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
/**
|
||||
* Every test drives a real in-memory router and the real preserved-query
|
||||
* manager: the tracker strips the code from the URL at capture time, so the
|
||||
* stash is the only carrier, and redemption fires from router.afterEach, an
|
||||
* auth watcher, and a delayed retry after a transient failure.
|
||||
*
|
||||
* The fake clock (installed for every test) keeps those retry timers from
|
||||
* leaking into later tests: afterEach discards them with vi.useRealTimers().
|
||||
*/
|
||||
|
||||
const mockConfirm = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => ({
|
||||
confirm: mockConfirm
|
||||
})
|
||||
}))
|
||||
|
||||
const mockToastAdd = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({
|
||||
add: mockToastAdd
|
||||
})
|
||||
}))
|
||||
|
||||
interface MockAuthStore {
|
||||
currentUser: {
|
||||
uid: string
|
||||
getIdToken: (forceRefresh?: boolean) => Promise<string>
|
||||
} | null
|
||||
getIdToken: () => Promise<string>
|
||||
}
|
||||
|
||||
const mockUserGetIdToken = vi.hoisted(() => vi.fn())
|
||||
const mockStoreGetIdToken = vi.hoisted(() => vi.fn())
|
||||
|
||||
// Reactive so the module's watcher on currentUser fires without a navigation.
|
||||
// The mock factory is cached across vi.resetModules(), so it reads a holder
|
||||
// refilled per test; watchers leaked by earlier module generations stay
|
||||
// subscribed to earlier stores and remain dormant.
|
||||
const authStoreHolder = vi.hoisted(() => ({
|
||||
store: null as MockAuthStore | null
|
||||
}))
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: () => authStoreHolder.store
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (path: string) => `/api${path}`
|
||||
}
|
||||
}))
|
||||
|
||||
const VALID_CODE = `dlc_${'A'.repeat(43)}`
|
||||
const SECOND_CODE = `dlc_${'B'.repeat(43)}`
|
||||
const REDEEM_URL = '/api/auth/desktop-login-codes/redeem'
|
||||
const NAMESPACE = 'desktop_login'
|
||||
const STORAGE_KEY = 'Comfy.PreservedQuery.desktop_login'
|
||||
const RETRY_DELAY_MS = 5_000
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
|
||||
let mockAuthStore: MockAuthStore
|
||||
|
||||
function okResponse() {
|
||||
return new Response(JSON.stringify({ status: 'redeemed' }), { status: 200 })
|
||||
}
|
||||
|
||||
function expectedFetchOptions(code: string) {
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer firebase-id-token',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
signal: expect.any(AbortSignal)
|
||||
}
|
||||
}
|
||||
|
||||
// The triggers fire-and-forget the redemption; a zero-length advance of the
|
||||
// fake clock yields the event loop so the whole mocked promise chain settles.
|
||||
async function flushRedemption() {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
|
||||
// vi.resetModules() also resets the preserved-query manager's in-memory map,
|
||||
// so the manager must be imported alongside the module under test.
|
||||
async function setup() {
|
||||
const { installDesktopLoginRedemption } =
|
||||
await import('./desktopLoginRedemption')
|
||||
const { capturePreservedQuery, getPreservedQueryParam } =
|
||||
await import('@/platform/navigation/preservedQueryManager')
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
|
||||
})
|
||||
installDesktopLoginRedemption(router)
|
||||
|
||||
let navigationCount = 0
|
||||
const trigger = async () => {
|
||||
await router.push(`/trigger-${navigationCount++}`)
|
||||
await flushRedemption()
|
||||
}
|
||||
|
||||
return {
|
||||
router,
|
||||
trigger,
|
||||
seedStash: (code: string) =>
|
||||
capturePreservedQuery(NAMESPACE, { desktop_login_code: code }, [
|
||||
'desktop_login_code'
|
||||
]),
|
||||
stashedCode: () => getPreservedQueryParam(NAMESPACE, 'desktop_login_code')
|
||||
}
|
||||
}
|
||||
|
||||
describe('installDesktopLoginRedemption', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
sessionStorage.clear()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockFetch.mockReset()
|
||||
mockConfirm.mockResolvedValue(true)
|
||||
mockUserGetIdToken.mockResolvedValue('firebase-id-token')
|
||||
mockAuthStore = reactive({
|
||||
currentUser: {
|
||||
uid: 'user-1',
|
||||
getIdToken: mockUserGetIdToken
|
||||
},
|
||||
getIdToken: mockStoreGetIdToken
|
||||
})
|
||||
authStoreHolder.store = mockAuthStore
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does nothing on navigation when no code is stashed', async () => {
|
||||
const { trigger } = await setup()
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).not.toHaveBeenCalled()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redeems a stashed code once on navigation with the Firebase bearer token after approval', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockConfirm).toHaveBeenCalledWith({
|
||||
title: 'desktopLogin.confirmSummary',
|
||||
message: 'desktopLogin.confirmMessage'
|
||||
})
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
REDEEM_URL,
|
||||
expectedFetchOptions(VALID_CODE)
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'success',
|
||||
summary: 'desktopLogin.successSummary',
|
||||
detail: 'desktopLogin.successDetail',
|
||||
life: 4000
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fetch before the user approves the confirmation dialog', async () => {
|
||||
const { trigger, seedStash } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
let approve!: (value: boolean) => void
|
||||
mockConfirm.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
approve = resolve
|
||||
})
|
||||
)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await trigger()
|
||||
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
|
||||
approve(true)
|
||||
await flushRedemption()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.for([
|
||||
['declines', false],
|
||||
['dismisses', null]
|
||||
] as const)(
|
||||
'clears the stash without a request or toast when the user %s the dialog',
|
||||
async ([_label, confirmResult]) => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockConfirm.mockResolvedValue(confirmResult)
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
|
||||
// Declining is final for that code: re-capturing it never re-prompts.
|
||||
seedStash(VALID_CODE)
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('asks for approval at most once per code across transient retries', async () => {
|
||||
const { trigger, seedStash } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
|
||||
|
||||
await trigger()
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('redeems a code hydrated lazily from sessionStorage', async () => {
|
||||
const { trigger } = await setup()
|
||||
sessionStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ desktop_login_code: VALID_CODE })
|
||||
)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
REDEEM_URL,
|
||||
expectedFetchOptions(VALID_CODE)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not redeem or prompt again after a successful redemption', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await trigger()
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A later navigation re-captures the already-redeemed code.
|
||||
seedStash(VALID_CODE)
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it.for([400, 403, 404, 409, 410])(
|
||||
'clears the stash, shows an error toast, and never retries on %s',
|
||||
async (status) => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(new Response(null, { status }))
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'desktopLogin.expiredSummary',
|
||||
detail: 'desktopLogin.expiredDetail',
|
||||
life: 6000
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.for([401, 500])(
|
||||
'keeps the stash on %s for the scheduled retry without a toast',
|
||||
async (status) => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(new Response(null, { status }))
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('retries once by itself, then clears the stash and shows an error toast when the budget is spent', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
|
||||
|
||||
await trigger()
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'desktopLogin.failedSummary',
|
||||
detail: 'desktopLogin.failedDetail',
|
||||
life: 6000
|
||||
})
|
||||
})
|
||||
|
||||
it('forces a token refresh on the retry after a 401', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response(null, { status: 401 }))
|
||||
.mockResolvedValueOnce(okResponse())
|
||||
|
||||
await trigger()
|
||||
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(mockUserGetIdToken).toHaveBeenLastCalledWith(true)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('passes a timeout signal and treats an aborted request as transient', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockRejectedValue(
|
||||
new DOMException('The operation timed out.', 'TimeoutError')
|
||||
)
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
REDEEM_URL,
|
||||
expectedFetchOptions(VALID_CODE)
|
||||
)
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats an id token failure as transient without a toast', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockUserGetIdToken.mockRejectedValue(new Error('firebase unavailable'))
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
// authStore.getIdToken surfaces failures through a modal error dialog,
|
||||
// which this background flow must never trigger.
|
||||
expect(mockStoreGetIdToken).not.toHaveBeenCalled()
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the stash without a dialog or request for a malformed code', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash('not-a-desktop-login-code')
|
||||
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).not.toHaveBeenCalled()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('contains an unexpected internal error instead of rejecting', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { trigger, seedStash } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockConfirm.mockRejectedValue(new Error('dialog exploded'))
|
||||
|
||||
await expect(trigger()).resolves.toBeUndefined()
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'[DesktopLoginRedemption] Redemption failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the stash while unauthenticated and redeems via the auth watcher once a session appears', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockAuthStore.currentUser = null
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
// The first completed navigation installs the watcher; without a session
|
||||
// nothing redeems and the stash is kept.
|
||||
await trigger()
|
||||
expect(mockConfirm).not.toHaveBeenCalled()
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
|
||||
// A session appearing without any further navigation redeems via the
|
||||
// watcher.
|
||||
mockAuthStore.currentUser = {
|
||||
uid: 'user-1',
|
||||
getIdToken: mockUserGetIdToken
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
REDEEM_URL,
|
||||
expectedFetchOptions(VALID_CODE)
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it.for([
|
||||
['succeeded', () => mockFetch.mockResolvedValueOnce(okResponse())],
|
||||
['was declined', () => mockConfirm.mockResolvedValueOnce(false)]
|
||||
] as const)(
|
||||
'gives a second code its own dialog and request after the first code %s',
|
||||
async ([_label, arrangeFirstOutcome]) => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
arrangeFirstOutcome()
|
||||
|
||||
await trigger()
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
|
||||
seedStash(SECOND_CODE)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
await trigger()
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||||
REDEEM_URL,
|
||||
expect.objectContaining({ body: JSON.stringify({ code: SECOND_CODE }) })
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('gives a second code a fresh attempt budget after the first code exhausted its own', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 500 }))
|
||||
|
||||
await trigger()
|
||||
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
|
||||
seedStash(SECOND_CODE)
|
||||
await trigger()
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3)
|
||||
expect(stashedCode()).toBe(SECOND_CODE)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(4)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('re-asks for approval when the account changes after approval and redeems with the new account token', async () => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response(null, { status: 500 }))
|
||||
.mockResolvedValueOnce(okResponse())
|
||||
|
||||
// user-1 approves; the redeem fails transiently, keeping the code stashed.
|
||||
await trigger()
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
expect(stashedCode()).toBe(VALID_CODE)
|
||||
|
||||
// The session changes to user-2 before the retry: user-1's approval must
|
||||
// not authorize redeeming with user-2's token.
|
||||
mockAuthStore.currentUser = {
|
||||
uid: 'user-2',
|
||||
getIdToken: vi.fn().mockResolvedValue('second-user-token')
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
|
||||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||||
REDEEM_URL,
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer second-user-token'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('re-prompts and redeems under the new account when the session changes while the approval dialog is open', async () => {
|
||||
const { seedStash, stashedCode, trigger } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
let approve!: (value: boolean) => void
|
||||
mockConfirm.mockReturnValueOnce(
|
||||
new Promise<boolean>((resolve) => {
|
||||
approve = resolve
|
||||
})
|
||||
)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await trigger()
|
||||
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
|
||||
|
||||
// The session swaps to user-2 while user-1's dialog is open: the stale
|
||||
// approval must not redeem, and the raced auth trigger is replayed to
|
||||
// re-prompt under user-2 without another navigation.
|
||||
const secondUser = {
|
||||
uid: 'user-2',
|
||||
getIdToken: vi.fn().mockResolvedValue('second-user-token')
|
||||
}
|
||||
mockAuthStore.currentUser = secondUser
|
||||
await flushRedemption()
|
||||
approve(true)
|
||||
await flushRedemption()
|
||||
|
||||
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
REDEEM_URL,
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer second-user-token'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
})
|
||||
|
||||
it.for([
|
||||
['succeeds', () => okResponse()],
|
||||
['fails terminally', () => new Response(null, { status: 404 })]
|
||||
] as const)(
|
||||
'processes a newer code stashed mid-flight after the older redemption %s',
|
||||
async ([_label, firstResponse]) => {
|
||||
const { trigger, seedStash, stashedCode } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
let resolveFirstFetch!: (response: Response) => void
|
||||
mockFetch.mockReturnValueOnce(
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFirstFetch = resolve
|
||||
})
|
||||
)
|
||||
|
||||
await trigger()
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1))
|
||||
|
||||
// A second code arrives while the first redemption is in flight; it
|
||||
// must survive the first's settlement and be processed right after.
|
||||
seedStash(SECOND_CODE)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
resolveFirstFetch(firstResponse())
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2))
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetch).toHaveBeenLastCalledWith(
|
||||
REDEEM_URL,
|
||||
expect.objectContaining({ body: JSON.stringify({ code: SECOND_CODE }) })
|
||||
)
|
||||
expect(stashedCode()).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('coalesces concurrent triggers into one dialog and one request', async () => {
|
||||
const { router, seedStash } = await setup()
|
||||
seedStash(VALID_CODE)
|
||||
let approve!: (value: boolean) => void
|
||||
mockConfirm.mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
approve = resolve
|
||||
})
|
||||
)
|
||||
mockFetch.mockResolvedValue(okResponse())
|
||||
|
||||
await router.push('/burst-1')
|
||||
await router.push('/burst-2')
|
||||
await vi.waitFor(() => expect(mockConfirm).toHaveBeenCalledTimes(1))
|
||||
|
||||
approve(true)
|
||||
await flushRedemption()
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,263 +0,0 @@
|
||||
import { watch } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import {
|
||||
clearPreservedQuery,
|
||||
getPreservedQueryParam
|
||||
} from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const NAMESPACE = PRESERVED_QUERY_NAMESPACES.DESKTOP_LOGIN
|
||||
const DESKTOP_LOGIN_CODE_KEY = 'desktop_login_code'
|
||||
|
||||
// The backend issues "dlc_" + 43 base64url chars; bounds are loose so the
|
||||
// backend stays the authority on exact code length.
|
||||
const DESKTOP_LOGIN_CODE_PATTERN = /^dlc_[A-Za-z0-9_-]{20,256}$/
|
||||
|
||||
// Statuses that mean the desktop app must start a fresh sign-in, so the code
|
||||
// is dropped. 401 stays transient: the session may still be settling.
|
||||
const TERMINAL_REDEEM_STATUSES = new Set([400, 403, 404, 409, 410])
|
||||
|
||||
// One delayed in-page retry, so an approved sign-in always reaches a success
|
||||
// or failure toast without ever looping within a page load.
|
||||
const MAX_REDEEM_ATTEMPTS = 2
|
||||
const RETRY_DELAY_MS = 5_000
|
||||
|
||||
// Abort the redeem request if the backend hangs; treated as transient.
|
||||
const REDEEM_TIMEOUT_MS = 10_000
|
||||
|
||||
interface CodeRedemptionState {
|
||||
attempts: number
|
||||
approvedUserUid: string | null
|
||||
settled: boolean
|
||||
forceTokenRefresh: boolean
|
||||
}
|
||||
|
||||
// Keyed by code so a different code arriving later gets its own approval and
|
||||
// attempt budget, while retries of the same code reuse both.
|
||||
const codeStates = new Map<string, CodeRedemptionState>()
|
||||
|
||||
// Coalesces concurrent triggers into one drain; a trigger arriving mid-drain
|
||||
// (e.g. the auth watcher firing while the dialog is open) is replayed as one
|
||||
// more pass instead of being dropped.
|
||||
let draining = false
|
||||
let retriggerRequested = false
|
||||
|
||||
let authWatcherInstalled = false
|
||||
|
||||
function getCodeState(code: string): CodeRedemptionState {
|
||||
const existing = codeStates.get(code)
|
||||
if (existing) return existing
|
||||
const fresh = {
|
||||
attempts: 0,
|
||||
approvedUserUid: null,
|
||||
settled: false,
|
||||
forceTokenRefresh: false
|
||||
}
|
||||
codeStates.set(code, fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
// A newer code can be stashed while an older one is mid-redemption; settling
|
||||
// the older one must not wipe it.
|
||||
function clearStashIfHolds(code: string): void {
|
||||
if (getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY) === code) {
|
||||
clearPreservedQuery(NAMESPACE)
|
||||
}
|
||||
}
|
||||
|
||||
function settle(code: string, state: CodeRedemptionState): void {
|
||||
state.settled = true
|
||||
clearStashIfHolds(code)
|
||||
}
|
||||
|
||||
function handleTransientFailure(
|
||||
code: string,
|
||||
state: CodeRedemptionState,
|
||||
reason: string
|
||||
): void {
|
||||
console.warn(`[DesktopLoginRedemption] Redeem request failed: ${reason}`)
|
||||
if (state.attempts < MAX_REDEEM_ATTEMPTS) {
|
||||
// attempts only increments, so this branch runs at most once per code
|
||||
// and cannot stack retry timers.
|
||||
setTimeout(() => {
|
||||
void redeemPendingDesktopLoginCode()
|
||||
}, RETRY_DELAY_MS)
|
||||
return
|
||||
}
|
||||
// Budget spent: drop the code and tell the user instead of failing silently.
|
||||
settle(code, state)
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('desktopLogin.failedSummary'),
|
||||
detail: t('desktopLogin.failedDetail'),
|
||||
life: 6000
|
||||
})
|
||||
}
|
||||
|
||||
// Explicit approval defeats device-code phishing: a lured click on a leaked
|
||||
// link must not bind the victim's session to an attacker's desktop app.
|
||||
// Approval is per code *and* account.
|
||||
async function confirmRedemption(
|
||||
state: CodeRedemptionState,
|
||||
uid: string
|
||||
): Promise<boolean> {
|
||||
if (state.approvedUserUid === uid) return true
|
||||
const confirmed = await useDialogService().confirm({
|
||||
title: t('desktopLogin.confirmSummary'),
|
||||
message: t('desktopLogin.confirmMessage')
|
||||
})
|
||||
if (confirmed !== true) return false
|
||||
state.approvedUserUid = uid
|
||||
return true
|
||||
}
|
||||
|
||||
async function redeemCode(code: string): Promise<void> {
|
||||
const state = getCodeState(code)
|
||||
if (state.settled) {
|
||||
// A later navigation can re-capture an already-settled code; drop it.
|
||||
clearStashIfHolds(code)
|
||||
return
|
||||
}
|
||||
|
||||
// No session yet (e.g. code captured on the login page): keep the stash and
|
||||
// let a post-login trigger redeem it.
|
||||
const user = useAuthStore().currentUser
|
||||
if (!user) return
|
||||
|
||||
if (!(await confirmRedemption(state, user.uid))) {
|
||||
// Declined/dismissed: drop the code without an error.
|
||||
settle(code, state)
|
||||
return
|
||||
}
|
||||
|
||||
// Approval binds the code to one account: if the session changed while the
|
||||
// dialog was open, keep the code stashed and let the (replayed) auth-change
|
||||
// trigger re-prompt under the now-current account.
|
||||
const approvedUser = useAuthStore().currentUser
|
||||
if (!approvedUser || approvedUser.uid !== state.approvedUserUid) return
|
||||
|
||||
state.attempts++
|
||||
|
||||
// Token comes straight from the Firebase user: authStore.getIdToken()
|
||||
// surfaces failures through a modal dialog this background flow must avoid.
|
||||
let idToken: string
|
||||
try {
|
||||
idToken = await approvedUser.getIdToken(state.forceTokenRefresh)
|
||||
} catch {
|
||||
handleTransientFailure(code, state, 'could not get id token')
|
||||
return
|
||||
}
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(api.apiURL('/auth/desktop-login-codes/redeem'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${idToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
// TODO(@comfyorg/ingest-types): type the payload with the generated
|
||||
// request type once the desktop-login-codes openapi addition propagates.
|
||||
body: JSON.stringify({ code }),
|
||||
signal: AbortSignal.timeout(REDEEM_TIMEOUT_MS)
|
||||
})
|
||||
} catch (error) {
|
||||
handleTransientFailure(
|
||||
code,
|
||||
state,
|
||||
error instanceof Error && error.name === 'TimeoutError'
|
||||
? 'request timed out'
|
||||
: 'network error'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
settle(code, state)
|
||||
useToastStore().add({
|
||||
severity: 'success',
|
||||
summary: t('desktopLogin.successSummary'),
|
||||
detail: t('desktopLogin.successDetail'),
|
||||
life: 4000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (TERMINAL_REDEEM_STATUSES.has(response.status)) {
|
||||
settle(code, state)
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('desktopLogin.expiredSummary'),
|
||||
detail: t('desktopLogin.expiredDetail'),
|
||||
life: 6000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// A 401 usually means a stale cached id token; mint a fresh one on retry.
|
||||
if (response.status === 401) state.forceTokenRefresh = true
|
||||
handleTransientFailure(code, state, `status ${response.status}`)
|
||||
}
|
||||
|
||||
async function redeemPendingDesktopLoginCode(): Promise<void> {
|
||||
// Never rejects: the triggers fire-and-forget this.
|
||||
if (draining) {
|
||||
retriggerRequested = true
|
||||
return
|
||||
}
|
||||
draining = true
|
||||
try {
|
||||
do {
|
||||
retriggerRequested = false
|
||||
const code = getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY)
|
||||
if (!code) continue
|
||||
if (!DESKTOP_LOGIN_CODE_PATTERN.test(code)) {
|
||||
clearPreservedQuery(NAMESPACE)
|
||||
continue
|
||||
}
|
||||
await redeemCode(code)
|
||||
if (code !== getPreservedQueryParam(NAMESPACE, DESKTOP_LOGIN_CODE_KEY))
|
||||
retriggerRequested = true
|
||||
} while (retriggerRequested)
|
||||
} catch (error) {
|
||||
console.error('[DesktopLoginRedemption] Redemption failed:', error)
|
||||
} finally {
|
||||
draining = false
|
||||
}
|
||||
}
|
||||
|
||||
function installAuthWatcherOnce(): void {
|
||||
if (authWatcherInstalled) return
|
||||
authWatcherInstalled = true
|
||||
// A session can appear without a navigation (e.g. dialog-based sign-in).
|
||||
// Installed lazily because pinia is not active when router.ts evaluates.
|
||||
watch(
|
||||
() => useAuthStore().currentUser,
|
||||
() => {
|
||||
void redeemPendingDesktopLoginCode()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeems desktop login codes (`?desktop_login_code=dlc_...`).
|
||||
*
|
||||
* The desktop app opens the browser with an opaque one-time code and polls
|
||||
* the cloud backend; redeeming the code from a signed-in browser session,
|
||||
* with the user's approval, releases a one-time custom token to that poll
|
||||
* and signs the desktop app in. The preserved-query tracker (configured in
|
||||
* router.ts) strips the code from the URL at capture time, so the stash is
|
||||
* the only place it lives.
|
||||
*/
|
||||
export function installDesktopLoginRedemption(router: Router): void {
|
||||
router.afterEach(() => {
|
||||
installAuthWatcherOnce()
|
||||
void redeemPendingDesktopLoginCode()
|
||||
})
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="max-h-[45vh] overflow-y-auto transition-[height] duration-300 ease-out sm:max-h-[55vh]"
|
||||
class="overflow-hidden transition-[height] duration-300 ease-out"
|
||||
:style="animatedHeightStyle"
|
||||
>
|
||||
<div ref="questionContent" class="relative">
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
balance: computed(() => state.balance),
|
||||
subscription: computed(() => state.subscription),
|
||||
isPaused: computed(() => false),
|
||||
isActiveSubscription: computed(() => state.isActiveSubscription),
|
||||
isFreeTier: computed(() => state.isFreeTier),
|
||||
currentTeamCreditStop: computed(() => state.currentTeamCreditStop),
|
||||
@@ -97,24 +98,14 @@ const i18n = createI18n({
|
||||
remaining: 'remaining',
|
||||
refreshCredits: 'Refresh credits',
|
||||
monthly: 'Monthly',
|
||||
refillsDate: 'Refills {date}',
|
||||
refillsNextCycle: 'Refills next cycle',
|
||||
creditsUsed: '{used} used',
|
||||
creditsLeftOfTotal: '{remaining} left of {total}',
|
||||
monthlyUsageProgress: '{used} of {total} monthly credits used',
|
||||
yearly: 'Yearly',
|
||||
percentUsed: '{percent}% used',
|
||||
usageProgress: '{used} of {total} credits used',
|
||||
additionalCreditsInfo: 'About additional credits',
|
||||
additionalCreditsTooltip: 'Credits you add on top of your plan.',
|
||||
additionalCredits: 'Additional credits',
|
||||
additionalCreditsInUse: 'In use',
|
||||
usedAfterMonthly: 'Used after monthly runs out',
|
||||
monthlyCreditsUsedUpTitle:
|
||||
'Monthly credits are used up. Refills {date}',
|
||||
monthlyCreditsUsedUpTitleNoDate: 'Monthly credits are used up',
|
||||
monthlyCreditsUsedUpDescription:
|
||||
"You're now spending additional credits.",
|
||||
outOfCreditsTitle: "You're out of credits. Credits refill {date}",
|
||||
outOfCreditsTitleNoDate: "You're out of credits",
|
||||
outOfCreditsDescription: 'Add more credits to continue generating.',
|
||||
usedAfterMonthly: 'Used after plan credits run out',
|
||||
addCredits: 'Add credits',
|
||||
upgradeToAddCredits: 'Upgrade to add credits'
|
||||
}
|
||||
@@ -178,27 +169,19 @@ describe('CreditsTile', () => {
|
||||
it('renders the monthly usage bar and additional breakdown', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678.
|
||||
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678 -> 98%.
|
||||
expect(container.textContent).toContain('Monthly')
|
||||
expect(container.textContent).toMatch(/Refills Feb/)
|
||||
expect(container.textContent).toContain('20,678 used')
|
||||
expect(container.textContent).toContain('422 left of 21,100')
|
||||
expect(container.textContent).toContain('98% used')
|
||||
expect(container.textContent).toContain('Additional credits')
|
||||
expect(container.textContent).toContain('633')
|
||||
expect(container.textContent).toContain('Used after monthly runs out')
|
||||
expect(container.textContent).toContain('Used after plan credits run out')
|
||||
})
|
||||
|
||||
it('renders a compact monthly summary for narrow containers', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('422 left of 21K')
|
||||
})
|
||||
|
||||
it('uses the team credit stop monthly grant for the monthly total', () => {
|
||||
it('uses the team credit stop grant for a monthly allowance', () => {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'TEAM',
|
||||
duration: 'ANNUAL',
|
||||
duration: 'MONTHLY',
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
state.currentTeamCreditStop = {
|
||||
@@ -207,13 +190,15 @@ describe('CreditsTile', () => {
|
||||
stop_usd: 2500
|
||||
}
|
||||
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
|
||||
const { container } = renderTile()
|
||||
// Monthly total is the stop's raw monthly grant, not the tier fallback,
|
||||
// and is not multiplied by 12 for annual billing.
|
||||
expect(container.textContent).toContain('422 left of 527,500')
|
||||
renderTile()
|
||||
// Allowance is the stop's grant, not the tier fallback.
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute(
|
||||
'aria-valuemax',
|
||||
'527500'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the per-month nominal grant for an annual personal tier', () => {
|
||||
it('grants the full year upfront for an annual plan', () => {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'PRO',
|
||||
@@ -221,35 +206,25 @@ describe('CreditsTile', () => {
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
|
||||
const { container } = renderTile()
|
||||
// Annual billing still grants the monthly nominal (21,100), not 12x.
|
||||
expect(container.textContent).toContain('422 left of 21,100')
|
||||
expect(container.textContent).not.toContain('253,200')
|
||||
renderTile()
|
||||
// Annual plans grant the whole year at once: 21,100 x 12.
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute(
|
||||
'aria-valuemax',
|
||||
'253200'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to a dateless refills label when renewal date is missing', () => {
|
||||
activeProSubscription()
|
||||
state.subscription = { tier: 'PRO', duration: 'MONTHLY', renewalDate: null }
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain('Refills next cycle')
|
||||
expect(container.textContent).not.toContain('Refills Feb')
|
||||
})
|
||||
|
||||
it('uses a dateless out-of-credits notice when renewal date is invalid', () => {
|
||||
activeProSubscription()
|
||||
it('labels the allowance by billing duration (yearly for annual)', () => {
|
||||
state.isActiveSubscription = true
|
||||
state.subscription = {
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
renewalDate: 'not-a-date'
|
||||
duration: 'ANNUAL',
|
||||
renewalDate: '2026-02-20T12:00:00Z'
|
||||
}
|
||||
state.balance = {
|
||||
amountMicros: 0,
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 0
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain("You're out of credits")
|
||||
expect(container.textContent).not.toContain('Credits refill')
|
||||
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
|
||||
renderTile()
|
||||
expect(screen.getByText('Yearly')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Monthly')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the breakdown and forces zeros in the zero state', () => {
|
||||
@@ -271,11 +246,9 @@ describe('CreditsTile', () => {
|
||||
expect(screen.queryByText('Add credits')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no depletion notice or in-use badge while monthly credits remain', () => {
|
||||
it('shows no in-use badge while monthly credits remain', () => {
|
||||
activeProSubscription()
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).not.toContain('Monthly credits are used up')
|
||||
expect(container.textContent).not.toContain("You're out of credits")
|
||||
renderTile()
|
||||
expect(screen.queryByText('In use')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -286,42 +259,29 @@ describe('CreditsTile', () => {
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 300
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain(
|
||||
'Monthly credits are used up. Refills Feb 20'
|
||||
)
|
||||
expect(container.textContent).toContain(
|
||||
"You're now spending additional credits."
|
||||
)
|
||||
renderTile()
|
||||
expect(screen.getByText('In use')).toBeTruthy()
|
||||
expect(screen.getByText('Add credits').dataset.variant).toBe('secondary')
|
||||
expect(screen.getByText('Add credits').dataset.variant).toBe('tertiary')
|
||||
})
|
||||
|
||||
it('emphasizes add-credits when fully out of credits', () => {
|
||||
it('emphasizes add-credits when fully out of credits, without a punch-out notice', () => {
|
||||
activeProSubscription()
|
||||
state.balance = {
|
||||
amountMicros: 0,
|
||||
cloudCreditBalanceMicros: 0,
|
||||
prepaidBalanceMicros: 0
|
||||
}
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).toContain(
|
||||
"You're out of credits. Credits refill Feb 20"
|
||||
)
|
||||
expect(container.textContent).toContain(
|
||||
'Add more credits to continue generating.'
|
||||
)
|
||||
renderTile()
|
||||
expect(screen.queryByText('In use')).toBeNull()
|
||||
expect(screen.getByText('Add credits').dataset.variant).toBe('inverted')
|
||||
})
|
||||
|
||||
it('suppresses the depletion notice until the balance has loaded', () => {
|
||||
it('shows no in-use badge until the balance has loaded', () => {
|
||||
activeProSubscription()
|
||||
state.balance = null
|
||||
state.isLoading = true
|
||||
const { container } = renderTile()
|
||||
expect(container.textContent).not.toContain('Monthly credits are used up')
|
||||
expect(container.textContent).not.toContain("You're out of credits")
|
||||
renderTile()
|
||||
expect(screen.queryByText('In use')).toBeNull()
|
||||
})
|
||||
|
||||
it('routes add-credits through telemetry + the top-up dialog', async () => {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<template>
|
||||
<div
|
||||
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
|
||||
:class="
|
||||
cn(
|
||||
'@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5 transition-opacity',
|
||||
// Paused subscriptions can't spend credits, so dim the whole tile to
|
||||
// read as frozen and defer to the Update-payment banner. A lapsed plan
|
||||
// (frozen) reads the same way.
|
||||
(isPaused || frozen) && 'opacity-50',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
@@ -19,8 +28,10 @@
|
||||
</div>
|
||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||
<div v-else class="flex items-baseline gap-2">
|
||||
<i class="icon-[lucide--component] size-4 self-center text-credit" />
|
||||
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
|
||||
<i class="icon-[lucide--coins] size-4 self-center text-credit" />
|
||||
<span class="text-2xl leading-none font-bold tabular-nums">{{
|
||||
displayTotal
|
||||
}}</span>
|
||||
<span class="text-sm text-muted @max-[300px]:hidden">{{
|
||||
$t('subscription.remaining')
|
||||
}}</span>
|
||||
@@ -28,37 +39,22 @@
|
||||
</div>
|
||||
|
||||
<template v-if="showBreakdown">
|
||||
<div
|
||||
v-if="emptyStateNotice"
|
||||
class="flex items-start gap-2 rounded-lg bg-base-background p-3 text-sm"
|
||||
>
|
||||
<i
|
||||
class="mt-0.5 icon-[lucide--info] size-4 shrink-0 text-base-foreground"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base-foreground">{{ emptyStateNotice.title }}</span>
|
||||
<span class="text-muted">{{ emptyStateNotice.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showBar"
|
||||
:class="cn('flex flex-col gap-2', isMonthlyDepleted && 'opacity-30')"
|
||||
:class="cn('flex flex-col gap-2', isAllowanceDepleted && 'opacity-30')"
|
||||
>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-text-primary">{{
|
||||
$t('subscription.monthly')
|
||||
}}</span>
|
||||
<span class="text-muted">{{ cycleLabel }}</span>
|
||||
<span class="text-muted">
|
||||
{{ refillsLabel }}
|
||||
{{ cycleStatusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
:aria-valuenow="usage.used"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="monthlyTotalCredits ?? 0"
|
||||
:aria-valuetext="monthlyUsageLabel"
|
||||
:aria-valuemax="allowanceTotalCredits ?? 0"
|
||||
:aria-valuetext="cycleUsageLabel"
|
||||
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
|
||||
>
|
||||
<div
|
||||
@@ -66,40 +62,6 @@
|
||||
:style="{ width: usedBarWidth }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 text-sm">
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
class="@max-[300px]:hidden"
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else class="text-muted @max-[300px]:hidden">
|
||||
{{ $t('subscription.creditsUsed', { used: usedDisplay }) }}
|
||||
</span>
|
||||
<Skeleton v-if="isLoadingBalance" width="9rem" height="1rem" />
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4 text-credit" />
|
||||
<span class="@max-[180px]:hidden">
|
||||
{{
|
||||
$t('subscription.creditsLeftOfTotal', {
|
||||
remaining: monthlyBonusCredits,
|
||||
total: monthlyTotalDisplay
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="hidden @max-[180px]:inline">
|
||||
{{
|
||||
$t('subscription.creditsLeftOfTotal', {
|
||||
remaining: monthlyRemainingCompact,
|
||||
total: monthlyTotalCompact
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px w-full bg-interface-stroke" />
|
||||
@@ -118,7 +80,7 @@
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('subscription.additionalCreditsInfo')"
|
||||
class="text-muted"
|
||||
class="flex cursor-help appearance-none items-center border-none bg-transparent p-0 text-muted transition-colors hover:text-text-primary"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</Button>
|
||||
@@ -132,9 +94,9 @@
|
||||
<Skeleton v-if="isLoadingBalance" width="3rem" height="1rem" />
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
class="flex items-center gap-1 font-bold text-text-primary tabular-nums"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4 text-credit" />
|
||||
<i class="icon-[lucide--coins] size-4 text-credit" />
|
||||
{{ displayPrepaid }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -156,15 +118,10 @@
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
:variant="isOutOfCredits ? 'inverted' : 'secondary'"
|
||||
:variant="isOutOfCredits ? 'inverted' : 'tertiary'"
|
||||
size="lg"
|
||||
:class="
|
||||
cn(
|
||||
'w-full font-normal',
|
||||
!isOutOfCredits &&
|
||||
'bg-interface-menu-component-surface-selected text-text-primary'
|
||||
)
|
||||
"
|
||||
class="w-full font-normal"
|
||||
:disabled="isPaused || frozen"
|
||||
@click="handleAddCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
@@ -178,6 +135,7 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCredits } from '@/base/credits/comfyCredits'
|
||||
@@ -186,40 +144,45 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { zeroState = false } = defineProps<{
|
||||
const {
|
||||
zeroState = false,
|
||||
frozen = false,
|
||||
class: customClass
|
||||
} = defineProps<{
|
||||
/** Forces the zero-credit display (e.g. unsubscribed / member view). */
|
||||
zeroState?: boolean
|
||||
/**
|
||||
* Renders the full breakdown but dimmed and non-interactive, for a lapsed
|
||||
* subscription that still has a shape to show. Mirrors the paused treatment.
|
||||
*/
|
||||
frozen?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
const {
|
||||
subscription,
|
||||
isPaused,
|
||||
balance,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
currentTeamCreditStop,
|
||||
fetchBalance,
|
||||
fetchStatus
|
||||
} = useBillingContext()
|
||||
const {
|
||||
monthlyBonusCredits,
|
||||
prepaidCredits,
|
||||
totalCredits,
|
||||
monthlyBonusCreditsValue,
|
||||
prepaidCreditsValue,
|
||||
isLoadingBalance
|
||||
isLoadingBalance,
|
||||
allowanceTotalCredits,
|
||||
usage
|
||||
} = useSubscriptionCredits()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
@@ -227,40 +190,18 @@ const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return DEFAULT_TIER_KEY
|
||||
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
|
||||
})
|
||||
|
||||
const monthlyTotalCredits = computed<number | null>(() => {
|
||||
const teamStop = currentTeamCreditStop.value
|
||||
if (teamStop) return teamStop.credits_monthly
|
||||
return getTierCredits(tierKey.value)
|
||||
})
|
||||
|
||||
const usage = computed(() =>
|
||||
computeMonthlyUsage(
|
||||
monthlyBonusCreditsValue.value,
|
||||
monthlyTotalCredits.value ?? 0
|
||||
)
|
||||
const cycleLabel = computed(() =>
|
||||
subscription.value?.duration === 'ANNUAL'
|
||||
? t('subscription.yearly')
|
||||
: t('subscription.monthly')
|
||||
)
|
||||
|
||||
const refillsDateShort = computed(() => {
|
||||
const raw = subscription.value?.renewalDate
|
||||
if (!raw) return ''
|
||||
const date = new Date(raw)
|
||||
return Number.isNaN(date.getTime())
|
||||
? ''
|
||||
: date.toLocaleDateString(locale.value, { month: 'short', day: 'numeric' })
|
||||
})
|
||||
const cycleUsedPercent = computed(() =>
|
||||
Math.round(usage.value.usedFraction * 100)
|
||||
)
|
||||
|
||||
const hasRefillsDate = computed(() => refillsDateShort.value !== '')
|
||||
|
||||
const refillsLabel = computed(() =>
|
||||
hasRefillsDate.value
|
||||
? t('subscription.refillsDate', { date: refillsDateShort.value })
|
||||
: t('subscription.refillsNextCycle')
|
||||
const cycleStatusLabel = computed(() =>
|
||||
t('subscription.percentUsed', { percent: cycleUsedPercent.value })
|
||||
)
|
||||
|
||||
const formatCreditCount = (value: number) =>
|
||||
@@ -270,82 +211,58 @@ const formatCreditCount = (value: number) =>
|
||||
numberOptions: { maximumFractionDigits: 0 }
|
||||
})
|
||||
|
||||
const monthlyTotalDisplay = computed(() => {
|
||||
const total = monthlyTotalCredits.value
|
||||
const allowanceTotalDisplay = computed(() => {
|
||||
const total = allowanceTotalCredits.value
|
||||
return total === null ? '—' : formatCreditCount(total)
|
||||
})
|
||||
|
||||
const usedDisplay = computed(() => formatCreditCount(usage.value.used))
|
||||
|
||||
const compactNumber = computed(
|
||||
() => new Intl.NumberFormat(locale.value, { notation: 'compact' })
|
||||
)
|
||||
const monthlyRemainingCompact = computed(() =>
|
||||
compactNumber.value.format(monthlyBonusCreditsValue.value)
|
||||
)
|
||||
const monthlyTotalCompact = computed(() => {
|
||||
const total = monthlyTotalCredits.value
|
||||
return total === null ? '—' : compactNumber.value.format(total)
|
||||
})
|
||||
|
||||
const displayTotal = computed(() => (zeroState ? '0' : totalCredits.value))
|
||||
const displayPrepaid = computed(() => (zeroState ? '0' : prepaidCredits.value))
|
||||
const usedBarWidth = computed(
|
||||
() => `${(usage.value.usedFraction * 100).toFixed(2)}%`
|
||||
)
|
||||
const monthlyUsageLabel = computed(() =>
|
||||
t('subscription.monthlyUsageProgress', {
|
||||
const cycleUsageLabel = computed(() =>
|
||||
t('subscription.usageProgress', {
|
||||
used: usedDisplay.value,
|
||||
total: monthlyTotalDisplay.value
|
||||
total: allowanceTotalDisplay.value
|
||||
})
|
||||
)
|
||||
|
||||
const showBreakdown = computed(() => isActiveSubscription.value && !zeroState)
|
||||
const showBreakdown = computed(
|
||||
() => (isActiveSubscription.value || frozen) && !zeroState
|
||||
)
|
||||
const showBar = computed(
|
||||
() =>
|
||||
showBreakdown.value &&
|
||||
monthlyTotalCredits.value !== null &&
|
||||
monthlyTotalCredits.value > 0
|
||||
allowanceTotalCredits.value !== null &&
|
||||
allowanceTotalCredits.value > 0
|
||||
)
|
||||
const showActionButton = computed(
|
||||
() => isActiveSubscription.value && !zeroState && permissions.value.canTopUp
|
||||
() =>
|
||||
(isActiveSubscription.value || frozen) &&
|
||||
!zeroState &&
|
||||
permissions.value.canTopUp
|
||||
)
|
||||
|
||||
const isMonthlyDepleted = computed(
|
||||
const isAllowanceDepleted = computed(
|
||||
() =>
|
||||
!isPaused.value &&
|
||||
!frozen &&
|
||||
showBar.value &&
|
||||
!isLoadingBalance.value &&
|
||||
balance.value != null &&
|
||||
monthlyBonusCreditsValue.value <= 0
|
||||
)
|
||||
const isOutOfCredits = computed(
|
||||
() => isMonthlyDepleted.value && prepaidCreditsValue.value <= 0
|
||||
)
|
||||
const isSpendingAdditional = computed(
|
||||
() => isMonthlyDepleted.value && prepaidCreditsValue.value > 0
|
||||
() => isAllowanceDepleted.value && prepaidCreditsValue.value > 0
|
||||
)
|
||||
// Fully out (monthly depleted and no additional credits left): emphasize the
|
||||
// add-credits button. Spending-additional keeps the quieter tertiary.
|
||||
const isOutOfCredits = computed(
|
||||
() => isAllowanceDepleted.value && prepaidCreditsValue.value <= 0
|
||||
)
|
||||
|
||||
const emptyStateNotice = computed(() => {
|
||||
if (isOutOfCredits.value) {
|
||||
return {
|
||||
title: hasRefillsDate.value
|
||||
? t('subscription.outOfCreditsTitle', { date: refillsDateShort.value })
|
||||
: t('subscription.outOfCreditsTitleNoDate'),
|
||||
description: t('subscription.outOfCreditsDescription')
|
||||
}
|
||||
}
|
||||
if (isMonthlyDepleted.value) {
|
||||
return {
|
||||
title: hasRefillsDate.value
|
||||
? t('subscription.monthlyCreditsUsedUpTitle', {
|
||||
date: refillsDateShort.value
|
||||
})
|
||||
: t('subscription.monthlyCreditsUsedUpTitleNoDate'),
|
||||
description: t('subscription.monthlyCreditsUsedUpDescription')
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const handleRefresh = wrapWithErrorHandlingAsync(async () => {
|
||||
await Promise.all([fetchBalance(), fetchStatus()])
|
||||
|
||||
@@ -6,6 +6,12 @@ import {
|
||||
formatCreditsFromCents
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import {
|
||||
DEFAULT_TIER_KEY,
|
||||
TIER_TO_KEY,
|
||||
getTierCredits
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
|
||||
|
||||
/**
|
||||
* Composable for handling subscription credit calculations and formatting.
|
||||
@@ -64,12 +70,44 @@ export function useSubscriptionCredits() {
|
||||
creditsFromMicros(toValue(billingContext.balance)?.prepaidBalanceMicros)
|
||||
)
|
||||
|
||||
// Total credits granted for the current billing cycle. Team plans read the
|
||||
// credit stop; personal tiers read the tier grant. Annual plans front-load the
|
||||
// whole year, so multiply the monthly nominal by the cycle length.
|
||||
const cycleMonths = computed(() =>
|
||||
toValue(billingContext.subscription)?.duration === 'ANNUAL' ? 12 : 1
|
||||
)
|
||||
const allowanceTotalCredits = computed<number | null>(() => {
|
||||
const teamStop = toValue(billingContext.currentTeamCreditStop)
|
||||
const tier = toValue(billingContext.subscription)?.tier
|
||||
const tierKey = tier
|
||||
? (TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY)
|
||||
: DEFAULT_TIER_KEY
|
||||
const monthly = teamStop
|
||||
? teamStop.credits_monthly
|
||||
: getTierCredits(tierKey)
|
||||
return monthly === null ? null : monthly * cycleMonths.value
|
||||
})
|
||||
|
||||
// Usage of that allowance drives the credits bar. Paused plans read as unused
|
||||
// (credits are frozen), so force it to zero.
|
||||
const usage = computed(() => {
|
||||
const base = computeMonthlyUsage(
|
||||
monthlyBonusCreditsValue.value,
|
||||
allowanceTotalCredits.value ?? 0
|
||||
)
|
||||
return toValue(billingContext.isPaused)
|
||||
? { ...base, used: 0, usedFraction: 0 }
|
||||
: base
|
||||
})
|
||||
|
||||
return {
|
||||
totalCredits,
|
||||
monthlyBonusCredits,
|
||||
prepaidCredits,
|
||||
monthlyBonusCreditsValue,
|
||||
prepaidCreditsValue,
|
||||
isLoadingBalance
|
||||
isLoadingBalance,
|
||||
allowanceTotalCredits,
|
||||
usage
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type MockApi = { getRawLogs: ReturnType<typeof vi.fn> }
|
||||
|
||||
const mockState = vi.hoisted<{ api: MockApi }>(() => ({
|
||||
api: { getRawLogs: undefined as never }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
get api() {
|
||||
return mockState.api
|
||||
}
|
||||
}))
|
||||
|
||||
function sample(path: string): string {
|
||||
return `[DEPRECATION WARNING] Detected import of deprecated legacy API: ${path}. This is likely caused by a custom node extension using outdated APIs. Please update your extensions or contact the extension author for an updated version.`
|
||||
}
|
||||
|
||||
describe('backfillServerDeprecations', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.resetModules()
|
||||
mockState.api = { getRawLogs: vi.fn() }
|
||||
})
|
||||
|
||||
it('seeds pre-load deprecation messages from the raw log buffer', async () => {
|
||||
mockState.api.getRawLogs.mockResolvedValue({
|
||||
entries: [
|
||||
{ t: 't', m: 'unrelated startup message' },
|
||||
{ t: 't', m: sample('/scripts/ui/legacy.js') }
|
||||
]
|
||||
})
|
||||
|
||||
const { backfillServerDeprecations } =
|
||||
await import('@/platform/dev/backfillServerDeprecations')
|
||||
const { useDeprecationWarningsStore } =
|
||||
await import('@/platform/dev/deprecationWarningsStore')
|
||||
|
||||
await backfillServerDeprecations()
|
||||
|
||||
const store = useDeprecationWarningsStore()
|
||||
expect(store.warnings).toHaveLength(1)
|
||||
expect(store.warnings[0].message).toBe(
|
||||
'Legacy API import: /scripts/ui/legacy.js'
|
||||
)
|
||||
})
|
||||
|
||||
it('extracts paths containing internal dots without truncation', async () => {
|
||||
mockState.api.getRawLogs.mockResolvedValue({
|
||||
entries: [{ t: 't', m: sample('/scripts/ui.foo.bar.js') }]
|
||||
})
|
||||
|
||||
const { backfillServerDeprecations } =
|
||||
await import('@/platform/dev/backfillServerDeprecations')
|
||||
const { useDeprecationWarningsStore } =
|
||||
await import('@/platform/dev/deprecationWarningsStore')
|
||||
|
||||
await backfillServerDeprecations()
|
||||
|
||||
expect(useDeprecationWarningsStore().warnings[0]?.message).toBe(
|
||||
'Legacy API import: /scripts/ui.foo.bar.js'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the raw line (without the legacy-API suggestion) for entries that do not match the template', async () => {
|
||||
mockState.api.getRawLogs.mockResolvedValue({
|
||||
entries: [{ t: 't', m: '[DEPRECATION WARNING] something unexpected' }]
|
||||
})
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const { backfillServerDeprecations } =
|
||||
await import('@/platform/dev/backfillServerDeprecations')
|
||||
const { useDeprecationWarningsStore } =
|
||||
await import('@/platform/dev/deprecationWarningsStore')
|
||||
|
||||
await backfillServerDeprecations()
|
||||
warnSpy.mockRestore()
|
||||
|
||||
const [warning] = useDeprecationWarningsStore().warnings
|
||||
expect(warning?.message).toBe('[DEPRECATION WARNING] something unexpected')
|
||||
expect(warning?.suggestion).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores non-deprecation log entries', async () => {
|
||||
mockState.api.getRawLogs.mockResolvedValue({
|
||||
entries: [
|
||||
{ t: 't', m: 'got prompt' },
|
||||
{ t: 't', m: '[INFO] something else' }
|
||||
]
|
||||
})
|
||||
|
||||
const { backfillServerDeprecations } =
|
||||
await import('@/platform/dev/backfillServerDeprecations')
|
||||
const { useDeprecationWarningsStore } =
|
||||
await import('@/platform/dev/deprecationWarningsStore')
|
||||
|
||||
await backfillServerDeprecations()
|
||||
|
||||
expect(useDeprecationWarningsStore().warnings).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('swallows getRawLogs failures gracefully and logs the error', async () => {
|
||||
const failure = new Error('401 unauthenticated')
|
||||
mockState.api.getRawLogs.mockRejectedValue(failure)
|
||||
|
||||
const { backfillServerDeprecations } =
|
||||
await import('@/platform/dev/backfillServerDeprecations')
|
||||
const { useDeprecationWarningsStore } =
|
||||
await import('@/platform/dev/deprecationWarningsStore')
|
||||
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
await backfillServerDeprecations()
|
||||
|
||||
expect(useDeprecationWarningsStore().warnings).toHaveLength(0)
|
||||
expect(errSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
'Failed to fetch initial server logs for deprecations:',
|
||||
failure
|
||||
)
|
||||
errSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('only fetches once after a successful backfill', async () => {
|
||||
const api = mockState.api
|
||||
api.getRawLogs.mockResolvedValue({ entries: [] })
|
||||
|
||||
const { backfillServerDeprecations } =
|
||||
await import('@/platform/dev/backfillServerDeprecations')
|
||||
|
||||
await backfillServerDeprecations()
|
||||
await backfillServerDeprecations()
|
||||
await backfillServerDeprecations()
|
||||
|
||||
expect(api.getRawLogs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dedupes concurrent callers to a single fetch', async () => {
|
||||
const api = mockState.api
|
||||
api.getRawLogs.mockResolvedValue({ entries: [] })
|
||||
|
||||
const { backfillServerDeprecations } =
|
||||
await import('@/platform/dev/backfillServerDeprecations')
|
||||
|
||||
await Promise.all([
|
||||
backfillServerDeprecations(),
|
||||
backfillServerDeprecations(),
|
||||
backfillServerDeprecations()
|
||||
])
|
||||
|
||||
expect(api.getRawLogs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries on a subsequent call after a failure', async () => {
|
||||
const api = mockState.api
|
||||
api.getRawLogs
|
||||
.mockRejectedValueOnce(new Error('transient'))
|
||||
.mockResolvedValueOnce({ entries: [] })
|
||||
|
||||
const { backfillServerDeprecations } =
|
||||
await import('@/platform/dev/backfillServerDeprecations')
|
||||
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
await backfillServerDeprecations()
|
||||
await backfillServerDeprecations()
|
||||
errSpy.mockRestore()
|
||||
|
||||
expect(api.getRawLogs).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const PREFIX = '[DEPRECATION WARNING]'
|
||||
const LEGACY_API_PATH_REGEX = /:\s*(\S+?)\.\s+This is likely caused/
|
||||
|
||||
let backfilled = false
|
||||
let backfillInProgress: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* Seed the store from the backend's raw log buffer.
|
||||
*/
|
||||
export async function backfillServerDeprecations(): Promise<void> {
|
||||
if (backfilled) return
|
||||
if (backfillInProgress) return backfillInProgress
|
||||
|
||||
backfillInProgress = (async () => {
|
||||
try {
|
||||
const logs = await api.getRawLogs()
|
||||
const store = useDeprecationWarningsStore()
|
||||
for (const entry of logs.entries) {
|
||||
if (!entry.m.includes(PREFIX)) continue
|
||||
const path = LEGACY_API_PATH_REGEX.exec(entry.m)?.[1]
|
||||
if (path) {
|
||||
store.report({
|
||||
message: `Legacy API import: ${path}`,
|
||||
suggestion:
|
||||
'Update the extension that imports this file, or ask its author to migrate.',
|
||||
source: 'server'
|
||||
})
|
||||
} else {
|
||||
console.warn(
|
||||
'Server deprecation log did not match legacy-API template; falling back to raw line. Update LEGACY_API_PATH_REGEX:',
|
||||
entry.m
|
||||
)
|
||||
store.report({
|
||||
message: entry.m.trim(),
|
||||
source: 'server'
|
||||
})
|
||||
}
|
||||
}
|
||||
backfilled = true
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'Failed to fetch initial server logs for deprecations:',
|
||||
err
|
||||
)
|
||||
} finally {
|
||||
backfillInProgress = null
|
||||
}
|
||||
})()
|
||||
|
||||
return backfillInProgress
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
|
||||
|
||||
describe('useDeprecationWarningsStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('appends a new warning when key is novel', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'thing is deprecated', source: 'frontend' })
|
||||
|
||||
expect(store.warnings).toHaveLength(1)
|
||||
expect(store.warnings[0]).toMatchObject({
|
||||
message: 'thing is deprecated',
|
||||
source: 'frontend',
|
||||
count: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('increments count and updates lastSeenAt on duplicate report', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'thing is deprecated', source: 'frontend' })
|
||||
const initialLastSeenAt = store.warnings[0].lastSeenAt
|
||||
|
||||
vi.advanceTimersByTime(5_000)
|
||||
store.report({ message: 'thing is deprecated', source: 'frontend' })
|
||||
|
||||
expect(store.warnings).toHaveLength(1)
|
||||
expect(store.warnings[0].count).toBe(2)
|
||||
expect(store.warnings[0].lastSeenAt).toBe(initialLastSeenAt + 5_000)
|
||||
})
|
||||
|
||||
it('treats different sources as distinct entries with the same message', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'foo', source: 'a' })
|
||||
store.report({ message: 'foo', source: 'b' })
|
||||
|
||||
expect(store.warnings).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('orders warnings with the most recently seen first', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'first' })
|
||||
store.report({ message: 'second' })
|
||||
store.report({ message: 'third' })
|
||||
|
||||
vi.advanceTimersByTime(1_000)
|
||||
store.report({ message: 'first' })
|
||||
|
||||
expect(store.warnings.map((w) => w.message)).toEqual([
|
||||
'first',
|
||||
'third',
|
||||
'second'
|
||||
])
|
||||
})
|
||||
|
||||
it('stops tracking new keys once the cap is reached but keeps counting known ones', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
for (let i = 0; i < 10_001; i++) {
|
||||
store.report({ message: `dep-${i}` })
|
||||
}
|
||||
|
||||
expect(store.warnings).toHaveLength(10_000)
|
||||
|
||||
const beforeRepeat = store.warnings.length
|
||||
store.report({ message: 'dep-0' })
|
||||
expect(store.warnings).toHaveLength(beforeRepeat)
|
||||
expect(store.warnings.find((w) => w.message === 'dep-0')?.count).toBe(2)
|
||||
})
|
||||
|
||||
it('exposes unseenCount that resets after markAllSeen', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
expect(store.unseenCount).toBe(0)
|
||||
|
||||
store.report({ message: 'a' })
|
||||
store.report({ message: 'b' })
|
||||
expect(store.unseenCount).toBe(2)
|
||||
|
||||
store.markAllSeen()
|
||||
expect(store.unseenCount).toBe(0)
|
||||
|
||||
store.report({ message: 'c' })
|
||||
expect(store.unseenCount).toBe(1)
|
||||
})
|
||||
|
||||
it('does not increase unseenCount when a duplicate is reported', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'a' })
|
||||
store.markAllSeen()
|
||||
store.report({ message: 'a' })
|
||||
|
||||
expect(store.unseenCount).toBe(0)
|
||||
expect(store.warnings[0].count).toBe(2)
|
||||
})
|
||||
|
||||
it('clear empties warnings and resets seen state', () => {
|
||||
const store = useDeprecationWarningsStore()
|
||||
store.report({ message: 'a' })
|
||||
store.report({ message: 'b' })
|
||||
store.markAllSeen()
|
||||
|
||||
store.clear()
|
||||
|
||||
expect(store.warnings).toEqual([])
|
||||
expect(store.unseenCount).toBe(0)
|
||||
|
||||
store.report({ message: 'a' })
|
||||
expect(store.warnings).toHaveLength(1)
|
||||
expect(store.unseenCount).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,132 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
interface DeprecationWarning {
|
||||
key: string
|
||||
message: string
|
||||
suggestion?: string
|
||||
/** Subsystem that reported the deprecation, e.g. 'nodeDef'. */
|
||||
source?: string
|
||||
/** Extension or node pack that triggered it, e.g. 'custom_nodes.devtools'. */
|
||||
extension?: string
|
||||
/** Specific locator within the source, e.g. an affected node/input or method. */
|
||||
detail?: string
|
||||
docsUrl?: string
|
||||
count: number
|
||||
lastSeenAt: number
|
||||
}
|
||||
|
||||
export interface ReportDeprecationInput {
|
||||
message: string
|
||||
suggestion?: string
|
||||
source?: string
|
||||
extension?: string
|
||||
detail?: string
|
||||
docsUrl?: string
|
||||
}
|
||||
|
||||
/** Guard against unbounded growth from high-cardinality reports. */
|
||||
const MAX_TRACKED_DEPRECATIONS = 10_000
|
||||
|
||||
function deprecationKey(input: ReportDeprecationInput): string {
|
||||
return JSON.stringify([
|
||||
input.source ?? '',
|
||||
input.message,
|
||||
input.extension ?? '',
|
||||
input.detail ?? ''
|
||||
])
|
||||
}
|
||||
|
||||
const pendingBuffer = new Map<
|
||||
string,
|
||||
{ input: ReportDeprecationInput; count: number }
|
||||
>()
|
||||
|
||||
/**
|
||||
* Buffers a deprecation reported before Pinia is active, deduped by key and
|
||||
* capped. Returns true the first time a key is buffered, so callers log to
|
||||
* the console once per unique deprecation.
|
||||
*/
|
||||
export function bufferDeprecation(input: ReportDeprecationInput): boolean {
|
||||
const key = deprecationKey(input)
|
||||
const buffered = pendingBuffer.get(key)
|
||||
if (buffered) {
|
||||
buffered.count += 1
|
||||
return false
|
||||
}
|
||||
if (pendingBuffer.size >= MAX_TRACKED_DEPRECATIONS) return false
|
||||
pendingBuffer.set(key, { input, count: 1 })
|
||||
return true
|
||||
}
|
||||
|
||||
export const useDeprecationWarningsStore = defineStore(
|
||||
'deprecationWarnings',
|
||||
() => {
|
||||
const warningsByKey = reactive(new Map<string, DeprecationWarning>())
|
||||
const unseenKeys = reactive(new Set<string>())
|
||||
|
||||
const warnings = computed<DeprecationWarning[]>(() =>
|
||||
Array.from(warningsByKey.values()).reverse()
|
||||
)
|
||||
const unseenCount = computed(() => unseenKeys.size)
|
||||
|
||||
function report(
|
||||
input: ReportDeprecationInput,
|
||||
occurrences: number = 1
|
||||
): boolean {
|
||||
const key = deprecationKey(input)
|
||||
const now = Date.now()
|
||||
const existing = warningsByKey.get(key)
|
||||
|
||||
if (existing) {
|
||||
existing.count += occurrences
|
||||
existing.lastSeenAt = now
|
||||
warningsByKey.delete(key)
|
||||
warningsByKey.set(key, existing)
|
||||
return false
|
||||
}
|
||||
|
||||
if (warningsByKey.size >= MAX_TRACKED_DEPRECATIONS) return false
|
||||
|
||||
warningsByKey.set(key, {
|
||||
key,
|
||||
message: input.message,
|
||||
suggestion: input.suggestion,
|
||||
source: input.source,
|
||||
extension: input.extension,
|
||||
detail: input.detail,
|
||||
docsUrl: input.docsUrl,
|
||||
count: occurrences,
|
||||
lastSeenAt: now
|
||||
})
|
||||
unseenKeys.add(key)
|
||||
return true
|
||||
}
|
||||
|
||||
function markAllSeen(): void {
|
||||
unseenKeys.clear()
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
warningsByKey.clear()
|
||||
unseenKeys.clear()
|
||||
}
|
||||
|
||||
function remove(key: string): void {
|
||||
warningsByKey.delete(key)
|
||||
unseenKeys.delete(key)
|
||||
}
|
||||
|
||||
for (const { input, count } of pendingBuffer.values()) report(input, count)
|
||||
pendingBuffer.clear()
|
||||
|
||||
return {
|
||||
warnings,
|
||||
unseenCount,
|
||||
report,
|
||||
markAllSeen,
|
||||
clear,
|
||||
remove
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatDeprecationConsole } from '@/platform/dev/deprecations'
|
||||
|
||||
describe('formatDeprecationConsole', () => {
|
||||
it('emits a bold, color-styled [DEPRECATED] tag', () => {
|
||||
const [text, tagStyle, restStyle] = formatDeprecationConsole({
|
||||
source: 'widget',
|
||||
message: 'foo is deprecated.'
|
||||
})
|
||||
|
||||
expect(text).toBe('%c[DEPRECATED:widget]%c foo is deprecated.')
|
||||
expect(tagStyle).toBe('color: orange; font-weight: bold')
|
||||
expect(restStyle).toBe('color: inherit')
|
||||
})
|
||||
|
||||
it('omits the source from the tag when none is given', () => {
|
||||
const [text] = formatDeprecationConsole({ message: 'foo is deprecated.' })
|
||||
|
||||
expect(text).toBe('%c[DEPRECATED]%c foo is deprecated.')
|
||||
})
|
||||
|
||||
it('adds labelled lines for each present field in a uniform order', () => {
|
||||
const [text] = formatDeprecationConsole({
|
||||
source: 'litegraph',
|
||||
message: 'foo is deprecated.',
|
||||
extension: 'Comfy.Clipspace',
|
||||
detail: 'getCanvasMenuOptions',
|
||||
suggestion: 'Use bar instead.',
|
||||
docsUrl: 'https://docs.example/guide'
|
||||
})
|
||||
|
||||
expect(text).toBe(
|
||||
[
|
||||
'%c[DEPRECATED:litegraph]%c foo is deprecated.',
|
||||
' extension: Comfy.Clipspace',
|
||||
' detail: getCanvasMenuOptions',
|
||||
' fix: Use bar instead.',
|
||||
' docs: https://docs.example/guide'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('omits absent fields', () => {
|
||||
const [text] = formatDeprecationConsole({
|
||||
source: 'ChangeTracker',
|
||||
message: 'checkState() is deprecated.',
|
||||
suggestion: 'Use captureCanvasState().'
|
||||
})
|
||||
|
||||
expect(text).toBe(
|
||||
'%c[DEPRECATED:ChangeTracker]%c checkState() is deprecated.\n fix: Use captureCanvasState().'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,143 +0,0 @@
|
||||
export interface DeprecationEntry {
|
||||
/** Category/origin shown in the console tag and docs, e.g. 'widgetInputs'. */
|
||||
source: string
|
||||
/** Static description of what is deprecated. */
|
||||
message: string
|
||||
/** What to do instead. */
|
||||
suggestion?: string
|
||||
/** Version the deprecation was introduced, e.g. '1.40'. */
|
||||
since?: string
|
||||
/** Version the deprecated API is expected to be removed, e.g. '2.0'. */
|
||||
removeBy?: string
|
||||
/** Link to a migration guide or docs page for this deprecation. */
|
||||
docsUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for every known frontend deprecation, used both at
|
||||
* runtime (call sites reference entries by id via `warnDeprecated`) and
|
||||
* statically (the docs generator in `scripts/generate-deprecations-doc.ts`).
|
||||
*/
|
||||
export const DEPRECATIONS = {
|
||||
'nodeDef.defaultInputRequired': {
|
||||
source: 'nodeDef',
|
||||
message: 'Use of defaultInput on a required input.',
|
||||
suggestion:
|
||||
'Drop the defaultInput option — required sockets are always present.'
|
||||
},
|
||||
'nodeDef.defaultInputOptional': {
|
||||
source: 'nodeDef',
|
||||
message: 'Use of defaultInput on an optional input.',
|
||||
suggestion: 'Replace with forceInput.'
|
||||
},
|
||||
'litegraphService.setSizeForImage': {
|
||||
source: 'litegraphService',
|
||||
message: 'node.setSizeForImage is deprecated and has no effect.',
|
||||
suggestion: 'Remove the call.'
|
||||
},
|
||||
'maskEditor.openMaskEditor': {
|
||||
source: 'MaskEditor',
|
||||
message: 'ComfyApp.open_maskeditor is deprecated.',
|
||||
suggestion:
|
||||
'Migrate to the command system or direct node context menu integration.'
|
||||
},
|
||||
'widgetInputs.convertToInput': {
|
||||
source: 'widgetInputs',
|
||||
message: 'convertToInput is no longer necessary.',
|
||||
suggestion:
|
||||
'Remove the call — widgets and sockets now co-exist on each input.'
|
||||
},
|
||||
'widgetInputs.convertWidgetToInput': {
|
||||
source: 'widgetInputs',
|
||||
message: 'convertWidgetToInput is no longer necessary.',
|
||||
suggestion:
|
||||
'Remove the call — widgets and sockets now co-exist on each input.'
|
||||
},
|
||||
'changeTracker.checkState': {
|
||||
source: 'ChangeTracker',
|
||||
message: 'ChangeTracker.checkState() is deprecated.',
|
||||
suggestion: 'Call captureCanvasState() instead.'
|
||||
},
|
||||
'comfyUI.legacyQueueMenu': {
|
||||
source: 'ComfyUI',
|
||||
message: 'The legacy queue/history menu is deprecated and unsupported.',
|
||||
suggestion:
|
||||
'Switch to the new menu: Settings → search "Use new menu" → change from "Disabled" to "Top".'
|
||||
},
|
||||
'comfySettings.getSettingValueDefault': {
|
||||
source: 'ComfySettingsDialog',
|
||||
message: 'getSettingValue() defaultValue parameter is deprecated.',
|
||||
suggestion:
|
||||
'Drop the argument — the default value in the setting definition will be used.'
|
||||
},
|
||||
'widget.inputEl': {
|
||||
source: 'widget',
|
||||
message: 'widget.inputEl is deprecated.',
|
||||
suggestion: 'Use widget.element instead (renamed in PR #8594).'
|
||||
},
|
||||
'litegraph.comboValuesFunction': {
|
||||
source: 'litegraph',
|
||||
message: 'Using a function for combo values is deprecated.',
|
||||
suggestion: 'Use an array of unique values instead.'
|
||||
},
|
||||
'litegraph.captureInput': {
|
||||
source: 'litegraph',
|
||||
message: 'captureInput will be removed in a future version.',
|
||||
suggestion: 'Use LGraphCanvas.pointer (CanvasPointer) instead.'
|
||||
},
|
||||
'litegraph.contextMenuMonkeyPatch': {
|
||||
source: 'litegraph',
|
||||
message: 'Monkey-patching context menu methods is deprecated.',
|
||||
suggestion: 'Use the new context menu API instead.',
|
||||
docsUrl: 'https://docs.comfy.org/custom-nodes/js/context-menu-migration'
|
||||
},
|
||||
'litegraph.onDeprecationWarning': {
|
||||
source: 'litegraph',
|
||||
message:
|
||||
'LiteGraph.onDeprecationWarning is no longer used and has no effect.',
|
||||
suggestion:
|
||||
'Remove the listener — there is no programmatic replacement; deprecation warnings now appear in the Dev Mode "Deprecation Warnings" sidebar panel.'
|
||||
},
|
||||
'litegraph.onBeforeChange': {
|
||||
source: 'litegraph',
|
||||
message:
|
||||
'LGraph.onBeforeChange is deprecated and will be removed in a future version.',
|
||||
suggestion: 'Assign a listener to LGraphCanvas.onBeforeChange instead.'
|
||||
},
|
||||
'litegraph.alwaysRepeatWarnings': {
|
||||
source: 'litegraph',
|
||||
message:
|
||||
'LiteGraph.alwaysRepeatWarnings is no longer used and has no effect.',
|
||||
suggestion:
|
||||
'Remove the assignment — deprecation warnings are always deduplicated and tracked with occurrence counts in the Dev Mode "Deprecation Warnings" sidebar panel.'
|
||||
}
|
||||
} satisfies Record<string, DeprecationEntry>
|
||||
|
||||
export type DeprecationId = keyof typeof DEPRECATIONS
|
||||
|
||||
/**
|
||||
* Builds the styled `console.warn` arguments shared by every deprecation
|
||||
* warning: a bold `[DEPRECATED:<source>]` tag and message, followed by labelled
|
||||
* lines for whichever fields are present, in a uniform order. Returns the args
|
||||
* tuple to spread into `console.warn`.
|
||||
*/
|
||||
export function formatDeprecationConsole(input: {
|
||||
source?: string
|
||||
message: string
|
||||
suggestion?: string
|
||||
extension?: string
|
||||
detail?: string
|
||||
docsUrl?: string
|
||||
}): [string, string, string] {
|
||||
const tag = input.source ? `[DEPRECATED:${input.source}]` : '[DEPRECATED]'
|
||||
const lines = [`%c${tag}%c ${input.message}`]
|
||||
if (input.extension) lines.push(` extension: ${input.extension}`)
|
||||
if (input.detail) lines.push(` detail: ${input.detail}`)
|
||||
if (input.suggestion) lines.push(` fix: ${input.suggestion}`)
|
||||
if (input.docsUrl) lines.push(` docs: ${input.docsUrl}`)
|
||||
return [
|
||||
lines.join('\n'),
|
||||
'color: orange; font-weight: bold',
|
||||
'color: inherit'
|
||||
]
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { getActivePinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
DEPRECATIONS,
|
||||
formatDeprecationConsole
|
||||
} from '@/platform/dev/deprecations'
|
||||
|
||||
describe('warnDeprecated', () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(undefined)
|
||||
vi.resetModules()
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('formats the console message with the registry source tag, message and suggestion', async () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
|
||||
|
||||
warnDeprecated('widgetInputs.convertToInput')
|
||||
|
||||
const entry = DEPRECATIONS['widgetInputs.convertToInput']
|
||||
expect(warnSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
...formatDeprecationConsole({
|
||||
source: entry.source,
|
||||
message: entry.message,
|
||||
suggestion: entry.suggestion
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards extension and detail as structured fields, not baked into the message', async () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
|
||||
|
||||
warnDeprecated('nodeDef.defaultInputRequired', {
|
||||
extension: 'custom_nodes.devtools',
|
||||
detail: 'NodeA.seed'
|
||||
})
|
||||
|
||||
const entry = DEPRECATIONS['nodeDef.defaultInputRequired']
|
||||
expect(warnSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
...formatDeprecationConsole({
|
||||
source: entry.source,
|
||||
message: entry.message,
|
||||
suggestion: entry.suggestion,
|
||||
extension: 'custom_nodes.devtools',
|
||||
detail: 'NodeA.seed'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not re-log to console on subsequent duplicate calls post-pinia', async () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
|
||||
|
||||
warnDeprecated('changeTracker.checkState')
|
||||
warnDeprecated('changeTracker.checkState')
|
||||
warnDeprecated('changeTracker.checkState')
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps distinct detail as distinct entries so every affected target stays visible', async () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
|
||||
const { useDeprecationWarningsStore } =
|
||||
await import('@/platform/dev/deprecationWarningsStore')
|
||||
|
||||
warnDeprecated('nodeDef.defaultInputRequired', { detail: 'NodeA.seed' })
|
||||
warnDeprecated('nodeDef.defaultInputRequired', { detail: 'NodeB.seed' })
|
||||
warnDeprecated('nodeDef.defaultInputRequired', { detail: 'NodeA.seed' })
|
||||
|
||||
const store = useDeprecationWarningsStore()
|
||||
expect(store.warnings).toHaveLength(2)
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2)
|
||||
const nodeA = store.warnings.find((w) => w.detail === 'NodeA.seed')
|
||||
expect(nodeA?.count).toBe(2)
|
||||
})
|
||||
|
||||
it('buffers warnings fired before pinia is active; drains them into the store on first access', async () => {
|
||||
expect(getActivePinia()).toBeUndefined()
|
||||
|
||||
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
|
||||
|
||||
warnDeprecated('changeTracker.checkState')
|
||||
warnDeprecated('maskEditor.openMaskEditor')
|
||||
warnDeprecated('changeTracker.checkState')
|
||||
|
||||
// Console logs once per unique deprecation even before pinia is active —
|
||||
// the repeated checkState warning must not log twice.
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2)
|
||||
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const { useDeprecationWarningsStore } =
|
||||
await import('@/platform/dev/deprecationWarningsStore')
|
||||
const store = useDeprecationWarningsStore()
|
||||
|
||||
expect(store.warnings).toHaveLength(2)
|
||||
const checkState = store.warnings.find((w) =>
|
||||
w.message.includes('checkState')
|
||||
)
|
||||
expect(checkState?.count).toBe(2)
|
||||
})
|
||||
|
||||
it('caps the pre-pinia buffer so a hot loop cannot grow it unboundedly', async () => {
|
||||
expect(getActivePinia()).toBeUndefined()
|
||||
|
||||
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
|
||||
|
||||
for (let i = 0; i < 10_001; i++) {
|
||||
warnDeprecated('changeTracker.checkState', { detail: `call-${i}` })
|
||||
}
|
||||
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const { useDeprecationWarningsStore } =
|
||||
await import('@/platform/dev/deprecationWarningsStore')
|
||||
|
||||
expect(useDeprecationWarningsStore().warnings).toHaveLength(10_000)
|
||||
})
|
||||
|
||||
it('drains the pre-pinia buffer when the next warnDeprecated lands post-pinia', async () => {
|
||||
const { warnDeprecated } = await import('@/platform/dev/warnDeprecated')
|
||||
|
||||
warnDeprecated('changeTracker.checkState')
|
||||
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
warnDeprecated('maskEditor.openMaskEditor')
|
||||
|
||||
const { useDeprecationWarningsStore } =
|
||||
await import('@/platform/dev/deprecationWarningsStore')
|
||||
const store = useDeprecationWarningsStore()
|
||||
expect(new Set(store.warnings.map((w) => w.source))).toEqual(
|
||||
new Set([
|
||||
DEPRECATIONS['maskEditor.openMaskEditor'].source,
|
||||
DEPRECATIONS['changeTracker.checkState'].source
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineDeprecatedProperty', () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.resetModules()
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('proxies reads to the current key and warns once with the registry entry', async () => {
|
||||
const { defineDeprecatedProperty } =
|
||||
await import('@/platform/dev/warnDeprecated')
|
||||
const { useDeprecationWarningsStore } =
|
||||
await import('@/platform/dev/deprecationWarningsStore')
|
||||
|
||||
const target: { element: string; inputEl?: string } = { element: 'hello' }
|
||||
defineDeprecatedProperty(target, 'inputEl', 'element', 'widget.inputEl')
|
||||
|
||||
expect(target.inputEl).toBe('hello')
|
||||
expect(target.inputEl).toBe('hello')
|
||||
|
||||
const store = useDeprecationWarningsStore()
|
||||
expect(store.warnings).toHaveLength(1)
|
||||
expect(store.warnings[0]).toMatchObject(DEPRECATIONS['widget.inputEl'])
|
||||
})
|
||||
|
||||
it('proxies writes to the current key and warns', async () => {
|
||||
const { defineDeprecatedProperty } =
|
||||
await import('@/platform/dev/warnDeprecated')
|
||||
const { useDeprecationWarningsStore } =
|
||||
await import('@/platform/dev/deprecationWarningsStore')
|
||||
|
||||
const target: { element: string; inputEl?: string } = { element: 'a' }
|
||||
defineDeprecatedProperty(target, 'inputEl', 'element', 'widget.inputEl')
|
||||
|
||||
target.inputEl = 'b'
|
||||
expect(target.element).toBe('b')
|
||||
expect(useDeprecationWarningsStore().warnings).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
import { getActivePinia } from 'pinia'
|
||||
|
||||
import type {
|
||||
DeprecationEntry,
|
||||
DeprecationId
|
||||
} from '@/platform/dev/deprecations'
|
||||
import {
|
||||
DEPRECATIONS,
|
||||
formatDeprecationConsole
|
||||
} from '@/platform/dev/deprecations'
|
||||
import type { ReportDeprecationInput } from '@/platform/dev/deprecationWarningsStore'
|
||||
import {
|
||||
bufferDeprecation,
|
||||
useDeprecationWarningsStore
|
||||
} from '@/platform/dev/deprecationWarningsStore'
|
||||
|
||||
interface WarnDeprecatedOptions {
|
||||
/** Extension or node pack that triggered the deprecation, surfaced as a tag. */
|
||||
extension?: string
|
||||
/** Specific locator within the source, e.g. an affected node/input or method. */
|
||||
detail?: string
|
||||
}
|
||||
|
||||
function resolveDeprecation(
|
||||
id: DeprecationId,
|
||||
options: WarnDeprecatedOptions
|
||||
): ReportDeprecationInput {
|
||||
const entry: DeprecationEntry = DEPRECATIONS[id]
|
||||
return {
|
||||
message: entry.message,
|
||||
suggestion: entry.suggestion,
|
||||
source: entry.source,
|
||||
docsUrl: entry.docsUrl,
|
||||
extension: options.extension,
|
||||
detail: options.detail
|
||||
}
|
||||
}
|
||||
|
||||
export function warnDeprecated(
|
||||
id: DeprecationId,
|
||||
options: WarnDeprecatedOptions = {}
|
||||
): void {
|
||||
const input = resolveDeprecation(id, options)
|
||||
|
||||
if (!getActivePinia()) {
|
||||
if (bufferDeprecation(input)) {
|
||||
console.warn(...formatDeprecationConsole(input))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (useDeprecationWarningsStore().report(input)) {
|
||||
console.warn(...formatDeprecationConsole(input))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a deprecated property alias that proxies to a current property,
|
||||
* logging a deprecation warning via {@link warnDeprecated} on first access.
|
||||
*/
|
||||
export function defineDeprecatedProperty<T>(
|
||||
target: T,
|
||||
deprecatedKey: string,
|
||||
currentKey: keyof T & string,
|
||||
id: DeprecationId
|
||||
): void {
|
||||
Object.defineProperty(target, deprecatedKey, {
|
||||
get() {
|
||||
warnDeprecated(id)
|
||||
return this[currentKey]
|
||||
},
|
||||
set(value: unknown) {
|
||||
warnDeprecated(id)
|
||||
this[currentKey] = value
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: false
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,5 @@ export const PRESERVED_QUERY_NAMESPACES = {
|
||||
SHARE_AUTH: 'share_auth',
|
||||
CREATE_WORKSPACE: 'create_workspace',
|
||||
OAUTH: 'oauth',
|
||||
PRICING: 'pricing',
|
||||
DESKTOP_LOGIN: 'desktop_login'
|
||||
PRICING: 'pricing'
|
||||
} as const
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<BaseModalLayout content-title="" data-testid="settings-dialog" size="full">
|
||||
<BaseModalLayout
|
||||
content-title=""
|
||||
data-testid="settings-dialog"
|
||||
size="full"
|
||||
header-height-class="h-22"
|
||||
:content-padding="isWorkspacePanel ? 'flush' : 'default'"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--settings]" />
|
||||
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
|
||||
@@ -48,6 +54,7 @@
|
||||
id="keybinding-panel-header"
|
||||
class="flex-1"
|
||||
/>
|
||||
<WorkspaceSettingsHeader v-else-if="isWorkspacePanel" />
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
@@ -55,6 +62,7 @@
|
||||
v-if="activeCategoryKey === 'keybinding'"
|
||||
id="keybinding-panel-actions"
|
||||
/>
|
||||
<WorkspaceMenuButton v-else-if="isWorkspacePanel" />
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
@@ -93,8 +101,11 @@ import NavTitle from '@/components/widget/nav/NavTitle.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
|
||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||
import WorkspaceMenuButton from '@/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue'
|
||||
import WorkspaceSettingsHeader from '@/platform/workspace/components/dialogs/settings/WorkspaceSettingsHeader.vue'
|
||||
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
||||
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
|
||||
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type {
|
||||
@@ -135,6 +146,14 @@ const { fetchBalance } = useBillingContext()
|
||||
const navRef = ref<HTMLElement | null>(null)
|
||||
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
|
||||
|
||||
// Let panels deep-link into a sibling panel (e.g. Overview → Members).
|
||||
const { requestedPanelKey } = useSettingsNavigation()
|
||||
watch(requestedPanelKey, (key) => {
|
||||
if (!key) return
|
||||
activeCategoryKey.value = key
|
||||
requestedPanelKey.value = null
|
||||
})
|
||||
|
||||
const searchableNavItems = computed(() =>
|
||||
navGroups.value.flatMap((g) =>
|
||||
g.items.map((item) => ({
|
||||
@@ -172,6 +191,17 @@ const activePanel = computed(() => {
|
||||
return findPanelByKey(activeCategoryKey.value)
|
||||
})
|
||||
|
||||
const WORKSPACE_PANEL_KEYS = [
|
||||
'workspace',
|
||||
'workspace-members',
|
||||
'workspace-partner-nodes'
|
||||
]
|
||||
const isWorkspacePanel = computed(
|
||||
() =>
|
||||
!!activeCategoryKey.value &&
|
||||
WORKSPACE_PANEL_KEYS.includes(activeCategoryKey.value)
|
||||
)
|
||||
|
||||
const getGroupSortOrder = (group: SettingTreeNode): number =>
|
||||
Math.max(0, ...flattenTree<SettingParams>(group).map((s) => s.sortOrder ?? 0))
|
||||
|
||||
|
||||
@@ -233,5 +233,12 @@ describe('useSettingUI', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('splits the workspace panel into plan and members sidebar entries', () => {
|
||||
const { navGroups } = useSettingUI()
|
||||
const keys = navKeys(navGroups.value)
|
||||
expect(keys).toContain('workspace')
|
||||
expect(keys).toContain('workspace-members')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,13 +27,14 @@ const CATEGORY_ICONS: Record<string, string> = {
|
||||
keybinding: 'icon-[lucide--keyboard]',
|
||||
LiteGraph: 'icon-[lucide--workflow]',
|
||||
'Mask Editor': 'icon-[lucide--pen-tool]',
|
||||
Members: 'icon-[lucide--users]',
|
||||
Other: 'icon-[lucide--ellipsis]',
|
||||
PlanCredits: 'icon-[lucide--credit-card]',
|
||||
PlanCredits: 'icon-[lucide--receipt-text]',
|
||||
secrets: 'icon-[lucide--key-round]',
|
||||
'server-config': 'icon-[lucide--server]',
|
||||
subscription: 'icon-[lucide--credit-card]',
|
||||
user: 'icon-[lucide--user]',
|
||||
workspace: 'icon-[lucide--building-2]'
|
||||
workspace: 'icon-[lucide--receipt-text]'
|
||||
}
|
||||
|
||||
interface SettingPanelItem {
|
||||
@@ -175,16 +176,30 @@ export function useSettingUI(
|
||||
)
|
||||
}
|
||||
|
||||
// Workspace panel: only available on cloud with team workspaces enabled
|
||||
const workspacePanel: SettingPanelItem = {
|
||||
// Workspace panels: only available on cloud with team workspaces enabled.
|
||||
// The old single "Workspace" panel is split into three sidebar entries; the
|
||||
// Plan & Credits entry keeps the 'workspace' key so existing deep links land.
|
||||
const planCreditsPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
label: 'PlanCredits',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/settings/WorkspacePanelContent.vue')
|
||||
import('@/platform/workspace/components/dialogs/settings/PlanCreditsPanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const membersPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace-members',
|
||||
label: 'Members',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/settings/WorkspaceMembersPanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -245,7 +260,9 @@ export function useSettingUI(
|
||||
aboutPanel,
|
||||
creditsPanel,
|
||||
userPanel,
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
|
||||
...(shouldShowWorkspacePanel.value
|
||||
? [planCreditsPanel, membersPanel]
|
||||
: []),
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
...(isDesktop ? [serverConfigPanel] : []),
|
||||
@@ -295,8 +312,13 @@ export function useSettingUI(
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
children: [
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
|
||||
...(isLoggedIn.value &&
|
||||
...(shouldShowWorkspacePanel.value
|
||||
? [planCreditsPanel.node, membersPanel.node]
|
||||
: []),
|
||||
// The legacy per-account Credits panel is redundant once the workspace
|
||||
// Plan & Credits panel is present, which now owns the credit balance.
|
||||
...(!shouldShowWorkspacePanel.value &&
|
||||
isLoggedIn.value &&
|
||||
!(isCloud && window.__CONFIG__?.subscription_required)
|
||||
? [creditsPanel.node]
|
||||
: [])
|
||||
|
||||
13
src/platform/settings/composables/useSettingsNavigation.ts
Normal file
13
src/platform/settings/composables/useSettingsNavigation.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// A one-shot request to switch the open Settings dialog to another panel, so a
|
||||
// panel's content can deep-link into a sibling panel (e.g. Overview → Members).
|
||||
const requestedPanelKey = ref<string | null>(null)
|
||||
|
||||
export function useSettingsNavigation() {
|
||||
function navigateToPanel(key: string) {
|
||||
requestedPanelKey.value = key
|
||||
}
|
||||
|
||||
return { requestedPanelKey, navigateToPanel }
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
|
||||
import { DEPRECATIONS } from '@/platform/dev/deprecations'
|
||||
import { useDeprecationWarningsStore } from '@/platform/dev/deprecationWarningsStore'
|
||||
|
||||
describe('Comfy.UseNewMenu setting', () => {
|
||||
const setting = CORE_SETTINGS.find((s) => s.id === 'Comfy.UseNewMenu')
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('reports the legacy-menu deprecation when set to Disabled', () => {
|
||||
setting?.onChange?.('Disabled')
|
||||
|
||||
const warnings = useDeprecationWarningsStore().warnings
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]).toMatchObject(DEPRECATIONS['comfyUI.legacyQueueMenu'])
|
||||
})
|
||||
|
||||
it('does not report a deprecation when the new menu is active', () => {
|
||||
setting?.onChange?.('Top')
|
||||
|
||||
expect(useDeprecationWarningsStore().warnings).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
getDefaultLocale,
|
||||
SUPPORTED_LOCALE_OPTIONS
|
||||
} from '@/locales/localeConfig'
|
||||
import { warnDeprecated } from '@/platform/dev/warnDeprecated'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
@@ -572,8 +571,6 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
{
|
||||
id: 'Comfy.DevMode',
|
||||
name: 'Enable dev mode options (API save, etc.)',
|
||||
tooltip:
|
||||
'Enables the legacy "Save (API Format)" button, dev-only nodes in the node library, and a "Deprecation Warnings" sidebar tab that surfaces uses of removed/renamed APIs by custom nodes.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
onChange: (value) => {
|
||||
@@ -615,9 +612,6 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'combo',
|
||||
options: ['Disabled', 'Top'],
|
||||
tooltip: 'Enable the redesigned top menu bar.',
|
||||
onChange: (value) => {
|
||||
if (value === 'Disabled') warnDeprecated('comfyUI.legacyQueueMenu')
|
||||
},
|
||||
migrateDeprecatedValue: (val: unknown) => {
|
||||
const value = val as string
|
||||
// Floating is now supported by dragging the docked actionbar off.
|
||||
|
||||
@@ -87,3 +87,5 @@ export type SettingPanelType =
|
||||
| 'subscription'
|
||||
| 'user'
|
||||
| 'workspace'
|
||||
| 'workspace-members'
|
||||
| 'workspace-partner-nodes'
|
||||
|
||||
@@ -37,6 +37,11 @@ export interface Member {
|
||||
// billing lifecycle actions (cancel / reactivate / downgrade).
|
||||
// Optional: the cloud OpenAPI does not carry this field yet.
|
||||
is_original_owner?: boolean
|
||||
// Last time the member ran or interacted with the workspace, and the credits
|
||||
// they've consumed in the current billing cycle. Optional: the cloud OpenAPI
|
||||
// does not carry these fields yet.
|
||||
last_active_at?: string | null
|
||||
credits_used_this_month?: number
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
@@ -244,6 +249,7 @@ export type BillingSubscriptionStatus =
|
||||
| 'scheduled'
|
||||
| 'ended'
|
||||
| 'canceled'
|
||||
| 'paused'
|
||||
|
||||
export type BillingStatus =
|
||||
| 'awaiting_payment_method'
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<!-- Credits Section -->
|
||||
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--coins] text-sm text-amber-400" />
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="4rem"
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
{{ t('subscription.monthlyCreditsPerMemberLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--coins] text-sm text-amber-400" />
|
||||
<span
|
||||
class="font-inter text-sm/normal font-bold text-base-foreground"
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Loading state while subscription is being set up -->
|
||||
<div
|
||||
v-if="isSettingUp"
|
||||
class="rounded-2xl border border-interface-stroke p-6"
|
||||
class="rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 py-4 text-muted-foreground">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
@@ -14,7 +14,7 @@
|
||||
<!-- Billing data still loading: avoid rendering a false Free/$0 plan -->
|
||||
<div
|
||||
v-else-if="isLoading && !subscription"
|
||||
class="rounded-2xl border border-interface-stroke p-6"
|
||||
class="rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 py-4 text-muted-foreground">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
@@ -25,7 +25,7 @@
|
||||
<!-- Billing fetch failed: offer retry rather than a misleading Free plan -->
|
||||
<div
|
||||
v-else-if="error && !subscription"
|
||||
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke p-6"
|
||||
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-text-secondary">
|
||||
<i class="pi pi-exclamation-circle text-danger" />
|
||||
@@ -67,7 +67,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div class="rounded-2xl border border-interface-stroke/60 p-6">
|
||||
<div>
|
||||
<div
|
||||
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
|
||||
@@ -439,11 +439,13 @@ const subscriptionTierName = computed(() => {
|
||||
: baseName
|
||||
})
|
||||
|
||||
const planDisplayName = computed(() =>
|
||||
isInPersonalWorkspace.value
|
||||
? subscriptionTierName.value
|
||||
const planDisplayName = computed(() => {
|
||||
if (isInPersonalWorkspace.value) return subscriptionTierName.value
|
||||
// 'ENTERPRISE' is a wire tier not yet in the generated SubscriptionTier union.
|
||||
return (subscription.value?.tier as string | null) === 'ENTERPRISE'
|
||||
? t('subscription.enterprisePlanName')
|
||||
: t('subscription.teamPlanName')
|
||||
)
|
||||
})
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscription.value?.tier
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user