Compare commits

..

1 Commits

Author SHA1 Message Date
Glary-Bot
7e10cf97ee fix(website): memoize GitHub stars fetch to one call per build
BaseLayout.astro frontmatter runs per page in SSG, so the unauthenticated
GitHub REST call ran ~46 times per build, easily exceeding the 60 req/h
anonymous rate limit. On rate-limited builds fetchGitHubStars returned
null, githubStars became '', and the star badge silently disappeared.

Memoize the in-flight promise in module scope keyed by owner/repo so all
pages share a single request. Pass an injectable fetchImpl for tests, and
narrow the JSON response type instead of treating it as any.

Also change the truthy check in BaseLayout from 'rawStars ? ...' to
'rawStars !== null ? ...' so a hypothetical zero-star repo would still
render (the old behavior also hid 0).
2026-05-27 21:35:01 +00:00
49 changed files with 1118 additions and 2132 deletions

View File

@@ -133,28 +133,30 @@ function scrollToDepartment(deptKey: string) {
:href="role.jobUrl"
target="_blank"
rel="noopener noreferrer"
class="border-primary-warm-gray/20 group flex items-start justify-between gap-4 border-b py-5"
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
data-testid="careers-role-link"
>
<div class="min-w-0 flex-1">
<div
<div class="min-w-0">
<span
class="text-primary-comfy-canvas text-base font-medium md:text-lg"
>
{{ role.title }}
</div>
<div
class="text-primary-warm-gray mt-1 flex flex-wrap gap-x-4 gap-y-1 text-sm"
>
<span>{{ role.department }}</span>
<span>{{ role.location }}</span>
</div>
</span>
<span class="text-primary-warm-gray ml-3 text-sm">
{{ role.department }}
</span>
</div>
<div class="ml-4 flex shrink-0 items-center gap-3">
<span class="text-primary-warm-gray text-sm">
{{ role.location }}
</span>
<img
src="/icons/arrow-up-right.svg"
alt=""
class="size-5"
aria-hidden="true"
/>
</div>
<img
src="/icons/arrow-up-right.svg"
alt=""
class="mt-1 size-5 shrink-0"
aria-hidden="true"
/>
</a>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
type NavDropdownItem = {
export type NavDropdownItem = {
label: string
href: string
badge?: string

View File

@@ -32,7 +32,7 @@ const ogImageURL = new URL(ogImage, siteBase)
const rawLocale = Astro.currentLocale ?? 'en'
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
const githubStars = rawStars !== null ? formatStarCount(rawStars) : ''
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD

View File

@@ -14,7 +14,7 @@ const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
interface DroppedRole {
export interface DroppedRole {
title: string
reason: string
}

View File

@@ -21,7 +21,7 @@ const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
interface DroppedNode {
export interface DroppedNode {
name: string
reason: string
}

View File

@@ -1,11 +1,16 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fetchGitHubStars, formatStarCount } from './github'
import {
fetchGitHubStars,
formatStarCount,
resetGitHubStarsFetcherForTests
} from './github'
describe('fetchGitHubStars', () => {
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
afterEach(() => {
resetGitHubStarsFetcherForTests()
vi.restoreAllMocks()
if (savedOverride === undefined)
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
@@ -27,6 +32,67 @@ describe('fetchGitHubStars', () => {
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
)
})
it('memoizes concurrent fetches for the same repo to one network call', async () => {
const fetchImpl = vi.fn(
async () =>
new Response(JSON.stringify({ stargazers_count: 110000 }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
)
const [a, b, c] = await Promise.all([
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
])
expect(a).toBe(110000)
expect(b).toBe(110000)
expect(c).toBe(110000)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('keys the in-flight cache by owner/repo', async () => {
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
const href = typeof url === 'string' ? url : url.toString()
const count = href.includes('other-repo') ? 42 : 110000
return new Response(JSON.stringify({ stargazers_count: count }), {
status: 200,
headers: { 'content-type': 'application/json' }
})
})
const [comfy, other] = await Promise.all([
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch),
fetchGitHubStars('Comfy-Org', 'other-repo', fetchImpl as typeof fetch)
])
expect(comfy).toBe(110000)
expect(other).toBe(42)
expect(fetchImpl).toHaveBeenCalledTimes(2)
})
it('returns null when GitHub responds non-2xx', async () => {
const fetchImpl = vi.fn(
async () => new Response('rate limited', { status: 403 })
)
await expect(
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
).resolves.toBeNull()
})
it('returns null when fetch throws', async () => {
const fetchImpl = vi.fn(async () => {
throw new Error('network down')
})
await expect(
fetchGitHubStars('Comfy-Org', 'ComfyUI', fetchImpl as typeof fetch)
).resolves.toBeNull()
})
})
describe('formatStarCount', () => {

View File

@@ -1,22 +1,50 @@
const inflight = new Map<string, Promise<number | null>>()
export function resetGitHubStarsFetcherForTests(): void {
inflight.clear()
}
export async function fetchGitHubStars(
owner: string,
repo: string
repo: string,
fetchImpl: typeof fetch = fetch
): Promise<number | null> {
const override = readGitHubStarsOverride()
if (override !== undefined) return override
const key = `${owner}/${repo}`
const cached = inflight.get(key)
if (cached) return cached
const request = doFetch(owner, repo, fetchImpl)
inflight.set(key, request)
return request
}
async function doFetch(
owner: string,
repo: string,
fetchImpl: typeof fetch
): Promise<number | null> {
try {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: { Accept: 'application/vnd.github.v3+json' }
})
const res = await fetchImpl(
`https://api.github.com/repos/${owner}/${repo}`,
{ headers: { Accept: 'application/vnd.github.v3+json' } }
)
if (!res.ok) return null
const data = await res.json()
return data.stargazers_count ?? null
const data: unknown = await res.json()
return readStargazerCount(data)
} catch {
return null
}
}
function readStargazerCount(data: unknown): number | null {
if (data === null || typeof data !== 'object') return null
const count = (data as { stargazers_count?: unknown }).stargazers_count
return typeof count === 'number' ? count : null
}
export function formatStarCount(count: number): string {
if (count >= 1_000_000) {
const m = count / 1_000_000

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -1,87 +0,0 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
async function waitForRootCanvasReady(page: Page) {
await expect
.poll(async () => {
const state = await page.evaluate(() => ({
rootId: window.app?.rootGraph?.id ?? '',
canvasGraphId: window.app?.canvas?.graph?.id ?? ''
}))
return state.rootId !== '' && state.canvasGraphId === state.rootId
})
.toBe(true)
}
async function expectCanvasOnRootGraph(page: Page) {
await expect
.poll(async () =>
page.evaluate(() => ({
rootId: window.app!.rootGraph.id,
canvasGraphId: window.app!.canvas.graph?.id,
hash: window.location.hash
}))
)
.toEqual({
rootId: expect.any(String),
canvasGraphId: expect.stringMatching(/.+/),
hash: expect.stringMatching(/^#.+/)
})
const state = await page.evaluate(() => ({
rootId: window.app!.rootGraph.id,
canvasGraphId: window.app!.canvas.graph?.id,
hash: window.location.hash
}))
expect(state.canvasGraphId).toBe(state.rootId)
expect(state.hash).toBe(`#${state.rootId}`)
}
test.describe(
'Subgraph hash validation (FE-559)',
{ tag: ['@subgraph'] },
() => {
test('redirects URL and canvas to root for a non-existent subgraph hash', async ({
comfyPage
}) => {
await waitForRootCanvasReady(comfyPage.page)
const rootId = await comfyPage.page.evaluate(
() => window.app!.rootGraph.id
)
const phantomId = '11111111-1111-4111-8111-111111111111'
expect(phantomId).not.toBe(rootId)
await comfyPage.page.evaluate((hash) => {
window.location.hash = hash
}, `#${phantomId}`)
await expect
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
timeout: 5000
})
.toBe(`#${rootId}`)
await expectCanvasOnRootGraph(comfyPage.page)
})
test('redirects URL and canvas to root when hash is malformed', async ({
comfyPage
}) => {
await waitForRootCanvasReady(comfyPage.page)
const rootId = await comfyPage.page.evaluate(
() => window.app!.rootGraph.id
)
await comfyPage.page.evaluate(() => {
window.location.hash = '#not-a-valid-uuid'
})
await expect
.poll(() => comfyPage.page.evaluate(() => window.location.hash), {
timeout: 5000
})
.toBe(`#${rootId}`)
await expectCanvasOnRootGraph(comfyPage.page)
})
}
)

View File

@@ -43,6 +43,7 @@ const config: KnipConfig = {
'@iconify/json',
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons'
],
ignore: [

View File

@@ -97,7 +97,7 @@
"axios": "catalog:",
"chart.js": "^4.5.0",
"cva": "catalog:",
"dompurify": "catalog:",
"dompurify": "^3.2.5",
"dotenv": "catalog:",
"es-toolkit": "^1.39.9",
"extendable-media-recorder": "^9.2.27",
@@ -193,7 +193,7 @@
"unplugin-icons": "catalog:",
"unplugin-typegpu": "catalog:",
"unplugin-vue-components": "catalog:",
"uuid": "catalog:",
"uuid": "^11.1.0",
"vite": "catalog:",
"vite-plugin-dts": "catalog:",
"vite-plugin-html": "catalog:",

View File

@@ -1892,17 +1892,3 @@ audio.comfy-audio.empty-audio-widget {
300% 14px;
background-attachment: local, local, scroll, scroll;
}
/*
PrimeVue overlays teleport to body. When a Reka modal dialog is open it sets
body { pointer-events: none } via DismissableLayer, which propagates to the
body-portaled overlays and makes them unclickable. PrimeVue's own Dialog
sets pointer-events inline, but Select / ColorPicker / Popover / Autocomplete
overlays do not, so they need to opt in here.
*/
.p-select-overlay,
.p-colorpicker-panel,
.p-popover,
.p-autocomplete-overlay {
pointer-events: auto;
}

1683
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@ catalog:
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
'@iconify/tailwind4': ^1.2.3
'@iconify/tailwind4': ^1.2.0
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
'@lobehub/i18n-cli': ^1.26.1
@@ -66,10 +66,10 @@ catalog:
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
astro: ^5.10.0
axios: ^1.15.2
axios: ^1.13.5
cross-env: ^10.1.0
cva: 1.0.0-beta.4
dompurify: ^3.4.5
dompurify: ^3.3.1
dotenv: ^16.4.5
eslint: ^9.39.1
eslint-config-prettier: ^10.1.8
@@ -87,12 +87,12 @@ catalog:
glob: ^13.0.6
globals: ^16.5.0
gsap: ^3.14.2
happy-dom: ^20.8.9
happy-dom: ^20.0.11
husky: ^9.1.7
jiti: 2.6.1
jsdom: ^27.4.0
jsonata: ^2.1.0
knip: ^6.14.1
knip: ^6.3.1
lenis: ^1.3.21
lint-staged: ^16.2.7
markdown-table: ^3.0.4
@@ -108,13 +108,13 @@ catalog:
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5
reka-ui: 2.5.0
reka-ui: ^2.5.0
rollup-plugin-visualizer: ^6.0.4
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.3.0
tailwindcss-primeui: ^0.6.1
three: ^0.184.0
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8
typegpu: ^0.8.2
@@ -123,14 +123,13 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
uuid: ^11.1.1
vee-validate: ^4.15.1
vite: ^8.0.13
vite: ^8.0.0
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0
vitest: ^4.0.16
vue: ^3.5.34
vue: ^3.5.13
vue-component-type-helpers: ^3.2.1
vue-eslint-parser: ^10.4.0
vue-i18n: ^9.14.5
@@ -161,13 +160,3 @@ overrides:
vite: 'catalog:'
'@tiptap/pm': 2.27.2
'@types/eslint': '-'
protobufjs: ~7.6.0
flatted: ~3.4.2
defu: ~6.1.7
# Security overrides (see pnpm.overrides in package.json for the actual pins):
# protobufjs ~7.6.0 — CVE-2026-41242 (CVSS 9.8): arbitrary code execution.
# Transitive via firebase, posthog-js. Remove after firebase upgrades protobufjs.
# flatted ~3.4.2 — GHSA-x7hr-w5r2-h6qg: prototype pollution.
# Transitive via eslint flat-cache@4.0.1. Dev-only. Remove after eslint upgrades flat-cache.
# defu ~6.1.7 — GHSA-47f6-5gq3-vx9c: prototype pollution.
# Transitive via reka-ui, c12, unplugin-typegpu. Remove after reka-ui upgrades defu.

View File

@@ -42,8 +42,7 @@ import type { StyleValue } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useMediaCache } from '@/services/mediaCacheService'
type ClassValue = string | Record<string, boolean> | ClassValue[]
import type { ClassValue } from '@comfyorg/tailwind-utils'
const {
src,

View File

@@ -8,10 +8,6 @@ import { defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import {
onRekaFocusOutside,
onRekaPointerDownOutside
} from '@/components/dialog/rekaPrimeVueBridge'
import { useDialogStore } from '@/stores/dialogStore'
const i18n = createI18n({
@@ -194,88 +190,3 @@ describe('GlobalDialog Reka parity with PrimeVue', () => {
expect(store.isDialogOpen('reka-esc-blocked')).toBe(true)
})
})
describe('shouldPreventRekaDismiss', () => {
function makeEvent(target: Element | null) {
let prevented = false
return {
detail: { originalEvent: { target } },
preventDefault: () => {
prevented = true
},
get defaultPrevented() {
return prevented
}
} as unknown as CustomEvent<{ originalEvent: PointerEvent }> & {
defaultPrevented: boolean
}
}
it.for([
'p-select-overlay',
'p-colorpicker-panel',
'p-popover',
'p-autocomplete-overlay',
'p-overlay-mask',
'p-dialog'
])('prevents dismiss when target is inside %s', (className) => {
const overlay = document.createElement('div')
overlay.className = className
const inner = document.createElement('button')
overlay.appendChild(inner)
document.body.appendChild(overlay)
const event = makeEvent(inner)
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
expect(event.defaultPrevented).toBe(true)
overlay.remove()
})
it('allows dismiss when target is outside any PrimeVue overlay', () => {
const event = makeEvent(document.body)
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
expect(event.defaultPrevented).toBe(false)
})
it('prevents dismiss when dismissableMask is false even outside an overlay', () => {
const event = makeEvent(document.body)
onRekaPointerDownOutside({ dismissableMask: false }, event)
expect(event.defaultPrevented).toBe(true)
})
it.for(['p-dialog', 'p-select-overlay'])(
'focus-outside on a sibling %s portal does not dismiss the parent',
(className) => {
const overlay = document.createElement('div')
overlay.className = className
const inner = document.createElement('button')
overlay.appendChild(inner)
document.body.appendChild(overlay)
const event = makeEvent(inner)
onRekaFocusOutside(event)
expect(event.defaultPrevented).toBe(true)
overlay.remove()
}
)
it('focus-outside still dismisses when focus moves to a non-portal element', () => {
const event = makeEvent(document.body)
onRekaFocusOutside(event)
expect(event.defaultPrevented).toBe(false)
})
it('focus-outside on a sibling Reka portal does not dismiss the parent', () => {
const portal = document.createElement('div')
portal.setAttribute('role', 'dialog')
document.body.appendChild(portal)
const event = makeEvent(portal)
onRekaFocusOutside(event)
expect(event.defaultPrevented).toBe(true)
portal.remove()
})
})

View File

@@ -8,14 +8,9 @@
@update:open="(open) => onRekaOpenChange(item.key, open)"
>
<DialogPortal>
<DialogOverlay
v-reka-z-index
:class="item.dialogComponentProps.overlayClass"
/>
<DialogOverlay />
<DialogContent
v-reka-z-index
:size="item.dialogComponentProps.size ?? 'md'"
:maximized="!!item.dialogComponentProps.maximized"
:class="item.dialogComponentProps.contentClass"
:aria-labelledby="item.key"
@escape-key-down="
@@ -24,51 +19,34 @@
e.preventDefault()
"
@pointer-down-outside="
(e) => onRekaPointerDownOutside(item.dialogComponentProps, e)
(e) =>
item.dialogComponentProps.dismissableMask === false &&
e.preventDefault()
"
@focus-outside="onRekaFocusOutside"
@mousedown="() => dialogStore.riseDialog({ key: item.key })"
>
<template v-if="item.dialogComponentProps.headless">
<DialogHeader v-if="!item.dialogComponentProps.headless">
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<DialogClose v-if="item.dialogComponentProps.closable !== false" />
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</template>
<template v-else>
<DialogHeader>
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<DialogTitle v-else :id="item.key">
{{ item.title || ' ' }}
</DialogTitle>
<div class="flex items-center gap-1">
<DialogMaximize
v-if="item.dialogComponentProps.maximizable"
:maximized="!!item.dialogComponentProps.maximized"
@toggle="toggleMaximize(item)"
/>
<DialogClose
v-if="item.dialogComponentProps.closable !== false"
/>
</div>
</DialogHeader>
<div class="flex-1 overflow-auto px-4 py-2">
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</template>
</div>
<DialogFooter v-if="item.footerComponent">
<component :is="item.footerComponent" v-bind="item.footerProps" />
</DialogFooter>
</DialogContent>
</DialogPortal>
</Dialog>
@@ -77,6 +55,7 @@
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
<template #header>
@@ -107,25 +86,29 @@
</template>
<script setup lang="ts">
import { merge } from 'es-toolkit/compat'
import PrimeDialog from 'primevue/dialog'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { computed } from 'vue'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogFooter from '@/components/ui/dialog/DialogFooter.vue'
import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogMaximize from '@/components/ui/dialog/DialogMaximize.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import {
onRekaFocusOutside,
onRekaPointerDownOutside
} from '@/components/dialog/rekaPrimeVueBridge'
import { vRekaZIndex } from '@/components/dialog/vRekaZIndex'
import type { DialogInstance } from '@/stores/dialogStore'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { DialogComponentProps, DialogInstance } from '@/stores/dialogStore'
import { useDialogStore } from '@/stores/dialogStore'
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const dialogStore = useDialogStore()
function isRekaItem(item: DialogInstance) {
@@ -136,8 +119,20 @@ function onRekaOpenChange(key: string, open: boolean) {
if (!open) dialogStore.closeDialog({ key })
}
function toggleMaximize(item: DialogInstance) {
item.dialogComponentProps.maximized = !item.dialogComponentProps.maximized
function getDialogPt(item: {
key: string
dialogComponentProps: DialogComponentProps
}): DialogPassThroughOptions {
const isWorkspaceSettingsDialog =
item.key === 'global-settings' && teamWorkspacesEnabled.value
const basePt = item.dialogComponentProps.pt || {}
if (isWorkspaceSettingsDialog) {
return merge(basePt, {
mask: { class: 'p-8' }
})
}
return basePt
}
</script>
@@ -168,6 +163,19 @@ function toggleMaximize(item: DialogInstance) {
}
}
/* Workspace mode: wider settings dialog */
.settings-dialog-workspace {
width: 100%;
max-width: 1440px;
height: 100%;
}
.settings-dialog-workspace .p-dialog-content {
width: 100%;
height: 100%;
overflow-y: auto;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;

View File

@@ -244,7 +244,7 @@
<ContextMenuPortal>
<ContextMenuContent
:style="keybindingOverlayContentStyle"
class="z-1800 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"

View File

@@ -1,49 +0,0 @@
// PrimeVue overlays (Select, ColorPicker, Popover, Autocomplete, stacked
// PrimeVue Dialogs) teleport to body. Reka treats clicks on body-portaled
// elements as outside its dialog and would auto-dismiss on the first
// interaction, tearing the overlay down mid-interaction. Treat any
// PrimeVue overlay click as inside.
const PRIMEVUE_OVERLAY_SELECTORS =
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog'
// Reka portals its own dialogs / popovers / menus into the body too. When a
// nested Reka layer opens on top of a non-modal parent, the parent's
// DismissableLayer sees the focus shift / pointer-down as "outside" and would
// 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"]'
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`
type OutsideEvent = CustomEvent<{ originalEvent: Event }>
function isInsideOverlay(target: EventTarget | null): boolean {
return (
target instanceof Element &&
target.closest(OUTSIDE_LAYER_SELECTORS) !== null
)
}
export function onRekaPointerDownOutside(
options: { dismissableMask?: boolean },
event: OutsideEvent
) {
if (isInsideOverlay(event.detail.originalEvent.target)) {
event.preventDefault()
return
}
if (options.dismissableMask === false) {
event.preventDefault()
}
}
// Focus / interact-outside fires when focus moves to a sibling portal (a
// nested Reka or PrimeVue dialog teleported to body). Without this guard a
// non-modal Reka dialog would dismiss itself the moment a nested dialog
// receives focus.
export function onRekaFocusOutside(event: OutsideEvent) {
if (isInsideOverlay(event.detail.originalEvent.target)) {
event.preventDefault()
}
}

View File

@@ -1,17 +0,0 @@
import { ZIndex } from '@primeuix/utils/zindex'
import type { Directive } from 'vue'
// Both Reka and PrimeVue dialogs can appear at any depth in dialogStack, in
// any order. PrimeVue auto-increments a per-key z-index counter so later
// dialogs always cover earlier ones; Reka uses a static z-1700 class which
// can lose to an already-open PrimeVue dialog. Registering Reka's content
// element with the same ZIndex counter (key 'modal', base 1700) makes both
// renderers share one stacking sequence: whichever dialog opens last wins.
export const vRekaZIndex: Directive<HTMLElement> = {
mounted(el) {
ZIndex.set('modal', el, 1700)
},
beforeUnmount(el) {
ZIndex.clear(el)
}
}

View File

@@ -10,13 +10,11 @@ import { dialogContentVariants } from './dialog.variants'
const {
size,
maximized = false,
class: customClass = '',
...restProps
} = defineProps<
DialogContentProps & {
size?: DialogContentSize
maximized?: boolean
class?: HTMLAttributes['class']
}
>()
@@ -28,7 +26,7 @@ const forwarded = useForwardPropsEmits(restProps, emits)
<template>
<DialogContent
v-bind="forwarded"
:class="cn(dialogContentVariants({ size, maximized }), customClass)"
:class="cn(dialogContentVariants({ size }), customClass)"
>
<slot />
</DialogContent>

View File

@@ -1,25 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const { maximized = false } = defineProps<{ maximized?: boolean }>()
const emit = defineEmits<{ toggle: [] }>()
const { t } = useI18n()
</script>
<template>
<Button
:aria-label="maximized ? t('g.restoreDialog') : t('g.maximizeDialog')"
size="icon"
variant="muted-textonly"
@click="emit('toggle')"
>
<i
:class="
maximized ? 'icon-[lucide--minimize-2]' : 'icon-[lucide--maximize-2]'
"
/>
</Button>
</template>

View File

@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const dialogContentVariants = cva({
base: 'fixed z-1700 flex flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
base: 'fixed top-1/2 left-1/2 z-1700 flex max-h-[85vh] w-[calc(100vw-1rem)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border border-border-subtle bg-base-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
variants: {
size: {
sm: 'sm:max-w-sm',
@@ -10,19 +10,14 @@ export const dialogContentVariants = cva({
lg: 'sm:max-w-3xl',
xl: 'sm:max-w-5xl',
full: 'sm:max-w-[calc(100vw-1rem)]'
},
maximized: {
true: 'inset-2 top-2 left-2 size-auto max-h-none max-w-none sm:max-w-none',
false: 'top-1/2 left-1/2 max-h-[85vh] w-[calc(100vw-1rem)] -translate-1/2'
}
},
defaultVariants: {
size: 'md',
maximized: false
size: 'md'
}
})
type DialogContentVariants = VariantProps<typeof dialogContentVariants>
export type DialogContentVariants = VariantProps<typeof dialogContentVariants>
export type DialogContentSize = NonNullable<DialogContentVariants['size']>

View File

@@ -192,7 +192,6 @@ describe('useLoad3d', () => {
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}),
getModelInfo: vi.fn().mockReturnValue(null),
captureThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,test'),
setAnimationTime: vi.fn(),
renderer: {
@@ -1355,46 +1354,6 @@ describe('useLoad3d', () => {
expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate')
})
it('gizmoTransformChange mirrors the live scene into Scene Config models', async () => {
const modelTransform = {
uuid: 'abc',
name: 'mesh',
type: 'Mesh',
position: { x: 5, y: 6, z: 7 },
rotation: { x: 0.5, y: 0.6, z: 0.7, order: 'XYZ' },
quaternion: { x: 0, y: 0, z: 0, w: 1 },
scale: { x: 3, y: 3, z: 3 },
up: { x: 0, y: 1, z: 0 },
visible: true,
matrix: new Array(16).fill(0)
}
vi.mocked(mockLoad3d.getModelInfo!).mockReturnValue(modelTransform)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const handler = addEventCalls.find(
([event]) => event === 'gizmoTransformChange'
)![1] as (data: unknown) => void
handler({
position: { x: 5, y: 6, z: 7 },
rotation: { x: 0.5, y: 0.6, z: 0.7 },
scale: { x: 3, y: 3, z: 3 },
enabled: true,
mode: 'rotate'
})
await nextTick()
expect(composable.sceneConfig.value.models).toEqual([modelTransform])
const savedScene = mockNode.properties['Scene Config'] as {
models: unknown[]
}
expect(savedScene.models).toEqual([modelTransform])
})
it('should reset gizmo config on model switch (not first load)', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')

View File

@@ -789,11 +789,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
const syncSceneModels = () => {
const modelInfo = load3d?.getModelInfo()
sceneConfig.value.models = modelInfo ? [modelInfo] : []
}
const eventConfig = {
materialModeChange: (value: string) => {
modelConfig.value.materialMode = value as MaterialMode
@@ -865,7 +860,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
]
hasSkeleton.value = load3d?.hasSkeleton() ?? false
applyGizmoConfigToLoad3d()
syncSceneModels()
isFirstModelLoad = false
},
modelReady: () => {
@@ -942,7 +936,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
modelConfig.value.gizmo.enabled = data.enabled
modelConfig.value.gizmo.mode = data.mode
}
syncSceneModels()
}
} as const
@@ -968,7 +961,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const transform = load3d.getGizmoTransform()
modelConfig.value.gizmo.position = transform.position
modelConfig.value.gizmo.scale = transform.scale
syncSceneModels()
}
const handleResetGizmoTransform = () => {

View File

@@ -1,7 +1,7 @@
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
interface ResolvedPreviewChainStep {
export interface ResolvedPreviewChainStep {
rootGraphId: UUID
hostNodeLocator: string
exposure: PreviewExposure

View File

@@ -4,7 +4,7 @@ import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import { parseNodePropertyArray } from './parseNodePropertyArray'
const previewExposureSchema = z.object({
export const previewExposureSchema = z.object({
name: z.string(),
sourceNodeId: z.string(),
sourcePreviewName: z.string()

View File

@@ -6,7 +6,7 @@ import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { parseNodePropertyArray } from './parseNodePropertyArray'
import { serializedProxyWidgetTupleSchema } from './promotionSchema'
const proxyWidgetQuarantineReasonSchema = z.enum([
export const proxyWidgetQuarantineReasonSchema = z.enum([
'missingSourceNode',
'missingSourceWidget',
'missingSubgraphInput',
@@ -18,7 +18,7 @@ export type ProxyWidgetQuarantineReason = z.infer<
typeof proxyWidgetQuarantineReasonSchema
>
const proxyWidgetErrorQuarantineEntrySchema = z.object({
export const proxyWidgetErrorQuarantineEntrySchema = z.object({
originalEntry: serializedProxyWidgetTupleSchema,
reason: proxyWidgetQuarantineReasonSchema,
hostValue: z.unknown().optional(),

View File

@@ -6,8 +6,7 @@ import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type {
CameraConfig,
CameraState,
ModelInfo
CameraState
} from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import {
@@ -403,9 +402,6 @@ useExtensionService().registerExtension({
currentLoad3d.handleResize()
const modelInfo = currentLoad3d.getModelInfo()
const model_info: ModelInfo = modelInfo ? [modelInfo] : []
const returnVal = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
@@ -413,8 +409,7 @@ useExtensionService().registerExtension({
camera_info:
(node.properties['Camera Config'] as CameraConfig | undefined)
?.state || null,
recording: '',
model_info
recording: ''
}
const recordingData = currentLoad3d.getRecordingData()

View File

@@ -162,57 +162,6 @@ describe('CameraManager', () => {
const snapshot = manager.getCameraState()
expect(snapshot.target.toArray()).toEqual([0, 0, 0])
})
it('captures the active camera orientation as a serializable quaternion', () => {
manager.perspectiveCamera.position.set(5, 0, 0)
manager.perspectiveCamera.lookAt(0, 0, 0)
const { quaternion } = manager.getCameraState()
expect(quaternion).toEqual({
x: manager.perspectiveCamera.quaternion.x,
y: manager.perspectiveCamera.quaternion.y,
z: manager.perspectiveCamera.quaternion.z,
w: manager.perspectiveCamera.quaternion.w
})
expect(Object.keys(quaternion ?? {})).not.toContain('_x')
})
it('captures the active camera orientation as a serializable euler rotation', () => {
manager.perspectiveCamera.position.set(5, 0, 0)
manager.perspectiveCamera.lookAt(0, 0, 0)
const { rotation } = manager.getCameraState()
expect(rotation).toEqual({
x: manager.perspectiveCamera.rotation.x,
y: manager.perspectiveCamera.rotation.y,
z: manager.perspectiveCamera.rotation.z,
order: manager.perspectiveCamera.rotation.order
})
expect(Object.keys(rotation ?? {})).not.toContain('_x')
})
it('captures the configured perspective fov regardless of active camera', () => {
manager.perspectiveCamera.fov = 42
manager.toggleCamera('orthographic')
expect(manager.getCameraState().fov).toBe(42)
})
it('reflects the perspective aspect after a resize', () => {
manager.handleResize(800, 400)
expect(manager.getCameraState().aspect).toBe(2)
})
it('reflects the orthographic frustum bounds after a resize', () => {
manager.toggleCamera('orthographic')
manager.handleResize(800, 400)
const { frustum } = manager.getCameraState()
expect(frustum).toEqual({ left: -10, right: 10, top: 5, bottom: -5 })
})
})
describe('setControls', () => {

View File

@@ -144,11 +144,6 @@ export class CameraManager implements CameraManagerInterface {
}
getCameraState(): CameraState {
const { x, y, z, w } = this.activeCamera.quaternion
const rotation = this.activeCamera.rotation
const activeCamera = this.activeCamera as
| THREE.PerspectiveCamera
| THREE.OrthographicCamera
return {
position: this.activeCamera.position.clone(),
target: this.controls?.target.clone() || new THREE.Vector3(),
@@ -156,24 +151,7 @@ export class CameraManager implements CameraManagerInterface {
this.activeCamera instanceof THREE.OrthographicCamera
? this.activeCamera.zoom
: (this.activeCamera as THREE.PerspectiveCamera).zoom,
cameraType: this.getCurrentCameraType(),
quaternion: { x, y, z, w },
rotation: {
x: rotation.x,
y: rotation.y,
z: rotation.z,
order: rotation.order
},
fov: this.perspectiveCamera.fov,
aspect: this.perspectiveCamera.aspect,
near: activeCamera.near,
far: activeCamera.far,
frustum: {
left: this.orthographicCamera.left,
right: this.orthographicCamera.right,
top: this.orthographicCamera.top,
bottom: this.orthographicCamera.bottom
}
cameraType: this.getCurrentCameraType()
}
}

View File

@@ -314,38 +314,6 @@ describe('GizmoManager', () => {
})
})
describe('getModelInfo', () => {
it('returns the full transform payload for the target object', () => {
manager.init()
const model = new THREE.Object3D()
model.name = 'my-model'
model.position.set(1, 2, 3)
model.rotation.set(0.1, 0.2, 0.3)
model.scale.set(4, 5, 6)
manager.setupForModel(model)
const info = manager.getModelInfo()
expect(info).not.toBeNull()
expect(info!.uuid).toBe(model.uuid)
expect(info!.name).toBe('my-model')
expect(info!.type).toBe('Object3D')
expect(info!.position).toEqual({ x: 1, y: 2, z: 3 })
expect(info!.rotation.x).toBeCloseTo(0.1)
expect(info!.rotation.order).toBe(model.rotation.order)
expect(info!.quaternion.w).toBeCloseTo(model.quaternion.w)
expect(info!.scale).toEqual({ x: 4, y: 5, z: 6 })
expect(info!.up).toEqual({ x: 0, y: 1, z: 0 })
expect(info!.visible).toBe(true)
expect(info!.matrix).toHaveLength(16)
})
it('returns null when there is no target', () => {
manager.init()
expect(manager.getModelInfo()).toBeNull()
})
})
describe('removeFromScene / ensureHelperInScene', () => {
it('removes helper from scene', () => {
manager.init()

View File

@@ -3,7 +3,7 @@ import { TransformControls } from 'three/examples/jsm/controls/TransformControls
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import type { GizmoMode, ModelTransform } from './interfaces'
import type { GizmoMode } from './interfaces'
export class GizmoManager {
private transformControls: TransformControls | null = null
@@ -215,48 +215,6 @@ export class GizmoManager {
}
}
getModelInfo(): ModelTransform | null {
const object = this.targetObject
if (!object) return null
object.updateMatrix()
return {
uuid: object.uuid,
name: object.name,
type: object.type,
position: {
x: object.position.x,
y: object.position.y,
z: object.position.z
},
rotation: {
x: object.rotation.x,
y: object.rotation.y,
z: object.rotation.z,
order: object.rotation.order
},
quaternion: {
x: object.quaternion.x,
y: object.quaternion.y,
z: object.quaternion.z,
w: object.quaternion.w
},
scale: {
x: object.scale.x,
y: object.scale.y,
z: object.scale.z
},
up: {
x: object.up.x,
y: object.up.y,
z: object.up.z
},
visible: object.visible,
matrix: object.matrix.toArray()
}
}
dispose(): void {
if (this.transformControls) {
const helper = this.transformControls.getHelper()

View File

@@ -25,7 +25,6 @@ import type {
Load3DOptions,
LoadModelOptions,
MaterialMode,
ModelTransform,
UpDirection
} from './interfaces'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
@@ -915,10 +914,6 @@ class Load3d {
return this.gizmoManager.getTransform()
}
public getModelInfo(): ModelTransform | null {
return this.gizmoManager.getModelInfo()
}
public fitToViewer(): void {
this.modelManager.fitToViewer()
this.forceRender()

View File

@@ -15,7 +15,7 @@ export interface ModelLoadContext {
readonly materialMode: MaterialMode
}
type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
export type ModelAdapterKind = 'mesh' | 'pointCloud' | 'splat'
export interface ModelAdapterCapabilities {
/**

View File

@@ -15,62 +15,18 @@ export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
export type CameraType = 'perspective' | 'orthographic'
export type BackgroundRenderModeType = 'tiled' | 'panorama'
interface CameraQuaternion {
x: number
y: number
z: number
w: number
}
interface CameraRotation {
x: number
y: number
z: number
order: string
}
interface CameraFrustum {
left: number
right: number
top: number
bottom: number
}
export interface CameraState {
position: THREE.Vector3
target: THREE.Vector3
zoom: number
cameraType: CameraType
quaternion?: CameraQuaternion
rotation?: CameraRotation
fov?: number
aspect?: number
near?: number
far?: number
frustum?: CameraFrustum
}
export interface ModelTransform {
uuid: string
name: string
type: string
position: { x: number; y: number; z: number }
rotation: { x: number; y: number; z: number; order: string }
quaternion: { x: number; y: number; z: number; w: number }
scale: { x: number; y: number; z: number }
up: { x: number; y: number; z: number }
visible: boolean
matrix: number[]
}
export type ModelInfo = ModelTransform[]
export interface SceneConfig {
showGrid: boolean
backgroundColor: string
backgroundImage?: string
backgroundRenderMode?: BackgroundRenderModeType
models?: ModelInfo
}
export type GizmoMode = 'translate' | 'rotate' | 'scale'

View File

@@ -138,8 +138,6 @@
"hideLeftPanel": "Hide left panel",
"showRightPanel": "Show right panel",
"hideRightPanel": "Hide right panel",
"maximizeDialog": "Maximize dialog",
"restoreDialog": "Restore dialog",
"or": "or",
"defaultBanner": "default banner",
"enableOrDisablePack": "Enable or disable pack",

View File

@@ -107,12 +107,6 @@ app.directive('tooltip', Tooltip)
app
.use(router)
.use(PrimeVue, {
zIndex: {
modal: 1800,
overlay: 1800,
menu: 1800,
tooltip: 1800
},
theme: {
preset: ComfyUIPreset,
options: {

View File

@@ -236,8 +236,5 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
['optical_flow', 'OpticalFlowLoader', 'model_name'],
// ---- WanVideo (ComfyUI-WanVideoWrapper) ----
['loras', 'WanVideoLoraSelect', 'lora'],
// ---- LTX-Video IC-LoRA (ComfyUI-LTXVideo) ----
['loras', 'LTXICLoRALoaderModelOnly', 'lora_name']
['loras', 'WanVideoLoraSelect', 'lora']
] as const satisfies ReadonlyArray<readonly [string, string, string]>

View File

@@ -30,7 +30,7 @@ type FirebaseRuntimeConfig = {
* be tweaked without a frontend release. Field types map 1:1 to a component
* in our internal UI library — see `DynamicSurveyField.vue`.
*/
type OnboardingSurveyFieldType = 'single' | 'multi' | 'text'
export type OnboardingSurveyFieldType = 'single' | 'multi' | 'text'
/**
* A translatable string. Either:

View File

@@ -1,98 +0,0 @@
/**
* Settings dialog migration regression net: `useSettingsDialog().show()` must
* open the Reka-renderer path with sizing that matches the previous
* `BaseModalLayout size="sm"` (960px × 80vh). Catches accidental reverts of
* the Phase 3 renderer flip.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const showDialog = vi.hoisted(() => vi.fn())
const teamWorkspacesFlag = vi.hoisted(() => ({ value: false }))
const isCloudRef = vi.hoisted(() => ({ value: false }))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({ showDialog, closeDialog: vi.fn() })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get teamWorkspacesEnabled() {
return teamWorkspacesFlag.value
}
}
})
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return isCloudRef.value
}
}))
vi.mock('@/i18n', () => ({ t: (k: string) => k }))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackEvent: vi.fn() })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: { value: true },
isFreeTier: { value: false },
type: { value: 'legacy' }
})
}))
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
describe('useSettingsDialog', () => {
beforeEach(() => {
showDialog.mockReset()
teamWorkspacesFlag.value = false
isCloudRef.value = false
})
it("show() opens the Reka renderer with size 'full' and 960px content sizing", () => {
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('global-settings')
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('full')
expect(args.dialogComponentProps.contentClass).toContain('max-w-[960px]')
expect(args.dialogComponentProps.contentClass).toContain('h-[80vh]')
})
it('show() uses non-modal Reka so nested PrimeVue dialogs keep focus and pointer events', () => {
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.modal).toBe(false)
})
it('show() omits overlayClass when not in workspace mode', () => {
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.overlayClass).toBeUndefined()
})
it("show() sets overlayClass 'p-8' when isCloud && teamWorkspacesEnabled", () => {
isCloudRef.value = true
teamWorkspacesFlag.value = true
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.dialogComponentProps.overlayClass).toBe('p-8')
})
it('show(panel) forwards defaultPanel to the dialog props', () => {
useSettingsDialog().show('about')
const [args] = showDialog.mock.calls[0]
expect(args.props.defaultPanel).toBe('about')
})
it('showAbout() opens the about panel', () => {
useSettingsDialog().showAbout()
const [args] = showDialog.mock.calls[0]
expect(args.props.defaultPanel).toBe('about')
})
})

View File

@@ -1,5 +1,3 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -8,20 +6,15 @@ import type { SettingPanelType } from '@/platform/settings/types'
const DIALOG_KEY = 'global-settings'
const SETTINGS_CONTENT_CLASS =
'w-[90vw] max-w-[960px] sm:max-w-[960px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
export function useSettingsDialog() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const { flags } = useFeatureFlags()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
function show(panel?: SettingPanelType, settingId?: string) {
const isWorkspaceMode = isCloud && flags.teamWorkspacesEnabled
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: SettingDialog,
@@ -29,18 +22,6 @@ export function useSettingsDialog() {
onClose: hide,
...(panel ? { defaultPanel: panel } : {}),
...(settingId ? { scrollToSettingId: settingId } : {})
},
dialogComponentProps: {
renderer: 'reka',
// Settings hosts nested PrimeVue dialogs (Edit Keybinding, Overwrite
// confirm, etc.) that teleport to body. Reka's modal mode traps focus
// inside the Settings content and disables body pointer-events, which
// breaks those nested dialogs' autofocus and click handling. Non-modal
// keeps the visual overlay without those traps.
modal: false,
size: 'full',
contentClass: SETTINGS_CONTENT_CLASS,
overlayClass: isWorkspaceMode ? 'p-8' : undefined
}
})
}

View File

@@ -1,56 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
import { isUuidShapedSubgraphId, zSubgraphId } from './subgraphIdSchema'
const CANONICAL_UUID = '550e8400-e29b-41d4-a716-446655440000'
const INVALID_STRING_CASES: Array<[label: string, value: string]> = [
['empty string', ''],
['arbitrary path', '/some/path'],
['plain word', 'subgraph'],
['hash leftover', '#abc'],
['hex but not UUID-shaped', 'abcdef0123456789'],
['UUID with leading hash', `#${CANONICAL_UUID}`],
['UUID with whitespace', ` ${CANONICAL_UUID} `]
]
const NON_STRING_CASES: Array<[label: string, value: unknown]> = [
['number', 123],
['undefined', undefined],
['null', null],
['object', { id: 'abc' }]
]
describe('subgraphIdSchema', () => {
describe('zSubgraphId', () => {
it('accepts a freshly generated UUID v4', () => {
expect(zSubgraphId.safeParse(createUuidv4()).success).toBe(true)
})
it('accepts a canonical UUID string', () => {
expect(zSubgraphId.safeParse(CANONICAL_UUID).success).toBe(true)
})
it.for(INVALID_STRING_CASES)('rejects %s', ([_label, value]) => {
expect(zSubgraphId.safeParse(value).success).toBe(false)
})
it.for(NON_STRING_CASES)('rejects non-string %s', ([_label, value]) => {
expect(zSubgraphId.safeParse(value).success).toBe(false)
})
})
describe('isUuidShapedSubgraphId', () => {
it('returns true for a valid UUID', () => {
expect(isUuidShapedSubgraphId(createUuidv4())).toBe(true)
})
it('returns false for an invalid value', () => {
expect(isUuidShapedSubgraphId('not-a-uuid')).toBe(false)
expect(isUuidShapedSubgraphId(undefined)).toBe(false)
expect(isUuidShapedSubgraphId(42)).toBe(false)
})
})
})

View File

@@ -1,10 +0,0 @@
import { z } from 'zod'
/** Hash values from the URL bar are untrusted; validate before lookup. */
export const zSubgraphId = z.string().uuid()
type SubgraphId = z.infer<typeof zSubgraphId>
export function isUuidShapedSubgraphId(value: unknown): value is SubgraphId {
return zSubgraphId.safeParse(value).success
}

View File

@@ -812,8 +812,8 @@ export class ComfyApi extends EventTarget {
locale && locale !== 'en' ? `index.${locale}.json` : 'index.json'
try {
const res = await axios.get(this.fileURL(`/templates/${fileName}`))
const contentType = String(res.headers['content-type'] ?? '')
return contentType.includes('application/json') ? res.data : []
const contentType = res.headers['content-type']
return contentType?.includes('application/json') ? res.data : []
} catch (error) {
// Fallback to default English version if localized version doesn't exist
if (locale && locale !== 'en') {
@@ -1411,8 +1411,8 @@ export class ComfyApi extends EventTarget {
}
}
)
const contentType = String(res.headers['content-type'] ?? '')
return contentType.includes('application/json') ? res.data : null
const contentType = res.headers['content-type']
return contentType?.includes('application/json') ? res.data : null
} catch (error) {
console.error('Error loading fuse options:', error)
return null

View File

@@ -4,8 +4,9 @@ import { merge } from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import type { DialogPassThroughOptions } from 'primevue/dialog'
import { markRaw, ref } from 'vue'
import type { Component, HTMLAttributes, Ref } from 'vue'
import type { Component, HTMLAttributes } from 'vue'
import type GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import type { DialogContentSize } from '@/components/ui/dialog/dialog.variants'
import type { ComponentAttrs } from 'vue-component-type-helpers'
@@ -47,26 +48,25 @@ interface CustomDialogComponentProps {
* PrimeVue path — use `pt` for that renderer.
*/
contentClass?: HTMLAttributes['class']
/**
* Class applied to the Reka-UI `DialogOverlay` element. Ignored on the
* PrimeVue path — use `pt.mask` for that renderer.
*/
overlayClass?: HTMLAttributes['class']
}
export type DialogComponentProps = Record<string, unknown> &
export type DialogComponentProps = ComponentAttrs<typeof GlobalDialog> &
CustomDialogComponentProps
export interface DialogInstance {
export interface DialogInstance<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
> {
key: string
visible: boolean
title?: string
headerComponent?: Component
headerProps?: Record<string, unknown>
component: Component
contentProps: Record<string, unknown>
footerComponent?: Component
footerProps?: Record<string, unknown>
headerComponent?: H
headerProps?: ComponentAttrs<H>
component: B
contentProps: ComponentAttrs<B>
footerComponent?: F
footerProps?: ComponentAttrs<F>
dialogComponentProps: DialogComponentProps
priority: number
}
@@ -100,7 +100,7 @@ interface UpdateDialogOptions {
}
export const useDialogStore = defineStore('dialog', () => {
const dialogStack: Ref<DialogInstance[]> = ref([])
const dialogStack = ref<DialogInstance[]>([])
/**
* The key of the currently active (top-most) dialog.
@@ -118,6 +118,7 @@ export const useDialogStore = defineStore('dialog', () => {
const insertIndex = dialogStack.value.findIndex(
(d) => d.priority <= dialog.priority
)
dialogStack.value.splice(
insertIndex === -1 ? dialogStack.value.length : insertIndex,
0,
@@ -144,8 +145,8 @@ export const useDialogStore = defineStore('dialog', () => {
if (!targetDialog) return
targetDialog.dialogComponentProps?.onClose?.()
const index = dialogStack.value.findIndex((d) => d.key === targetDialog.key)
if (index !== -1) dialogStack.value.splice(index, 1)
const index = dialogStack.value.indexOf(targetDialog)
dialogStack.value.splice(index, 1)
activeKey.value =
dialogStack.value.length > 0

View File

@@ -87,8 +87,7 @@ const MOCK_NODE_NAMES = [
'IPAdapterModelLoader',
'LS_LoadSegformerModel',
'LoadNLFModel',
'FlashVSRNode',
'LTXICLoRALoaderModelOnly'
'FlashVSRNode'
] as const
const mockNodeDefsByName = Object.fromEntries(
@@ -308,22 +307,7 @@ describe('useModelToNodeStore', () => {
)
const loraProviders = modelToNodeStore.getAllNodeProviders('loras')
expect(loraProviders).toHaveLength(3)
expect(loraProviders).toEqual(
expect.arrayContaining([
expect.objectContaining({
nodeDef: expect.objectContaining({ name: 'LoraLoader' })
}),
expect.objectContaining({
nodeDef: expect.objectContaining({ name: 'LoraLoaderModelOnly' })
}),
expect.objectContaining({
nodeDef: expect.objectContaining({
name: 'LTXICLoRALoaderModelOnly'
})
})
])
)
expect(loraProviders).toHaveLength(2)
})
it('should return single provider for model type with one node', () => {
@@ -577,18 +561,6 @@ describe('useModelToNodeStore', () => {
expect(modelToNodeStore.getCategoryForNodeType('')).toBeUndefined()
})
it('maps the IC-LoRA Loader Model Only node to loras so its lora_name dropdown uses the cloud asset browser (FE-838)', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(
modelToNodeStore.getCategoryForNodeType('LTXICLoRALoaderModelOnly')
).toBe('loras')
expect(
modelToNodeStore.getRegisteredNodeTypes()['LTXICLoRALoaderModelOnly']
).toBe('lora_name')
})
it('should return first category when node type exists in multiple categories', () => {
const modelToNodeStore = useModelToNodeStore()

View File

@@ -1,307 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type * as VueRouter from 'vue-router'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
const ids = vi.hoisted(() => ({
root: '00000000-0000-4000-8000-000000000000',
validSubgraph: '11111111-1111-4111-8111-111111111111',
deletedSubgraph: '22222222-2222-4222-8222-222222222222'
}))
const workflowStoreState = vi.hoisted(() => ({
openWorkflows: [] as unknown[],
activeSubgraph: undefined as unknown
}))
const routerMocks = vi.hoisted(() => ({
push: vi.fn().mockResolvedValue(undefined),
replace: vi.fn().mockResolvedValue(undefined)
}))
const routeHashRef = ref('')
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof VueRouter>()
return {
...actual,
useRouter: () => routerMocks
}
})
vi.mock('@vueuse/router', () => ({
useRouteHash: () => routeHashRef
}))
vi.mock('@/scripts/app', () => {
const mockCanvas = {
subgraph: null,
graph: null,
setGraph: vi.fn(),
setDirty: vi.fn(),
ds: {
scale: 1,
offset: [0, 0],
state: { scale: 1, offset: [0, 0] }
}
}
const mockRoot = {
id: ids.root,
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn()
}
return {
app: {
graph: mockRoot,
rootGraph: mockRoot,
canvas: mockCanvas
}
}
})
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: () => app.canvas,
currentGraph: null
})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ fitView: vi.fn() })
}))
vi.mock(
'@/renderer/extensions/vueNodes/composables/useSlotElementTracking',
() => ({ requestSlotLayoutSyncForAllNodes: vi.fn() })
)
const workflowServiceMocks = vi.hoisted(() => ({
openWorkflow: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => workflowServiceMocks
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => workflowStoreState
}))
function makeSubgraph(id: string): Subgraph {
return fromPartial<Subgraph>({
id,
rootGraph: app.rootGraph,
_nodes: [],
nodes: []
})
}
async function flushHashWatcher() {
await nextTick()
await Promise.resolve()
await nextTick()
}
describe('useSubgraphNavigationStore - navigateToHash validation', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.rootGraph.id = ids.root
app.rootGraph.subgraphs.clear()
app.canvas.subgraph = undefined
app.canvas.graph = app.rootGraph
workflowStoreState.openWorkflows = []
workflowStoreState.activeSubgraph = undefined
routeHashRef.value = ''
})
it('navigates to a valid, existing subgraph hash', async () => {
const subgraph = makeSubgraph(ids.validSubgraph)
app.rootGraph.subgraphs.set(subgraph.id, subgraph)
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.validSubgraph}`
await flushHashWatcher()
expect(app.canvas.setGraph).toHaveBeenCalledWith(subgraph)
expect(routerMocks.replace).not.toHaveBeenCalled()
})
it('redirects to root when hash references a deleted subgraph', async () => {
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() =>
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
)
})
it('redirects to root when hash is malformed (not a UUID)', async () => {
useSubgraphNavigationStore()
routeHashRef.value = '#not-a-valid-uuid'
await vi.waitFor(() =>
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
)
expect(app.canvas.setGraph).not.toHaveBeenCalled()
})
it('does not redirect when hash equals a non-UUID root graph id (loaded workflow slug)', async () => {
const slugRootId = 'test-missing-models-in-subgraph'
app.rootGraph.id = slugRootId
app.canvas.graph = fromPartial<LGraph>({ id: slugRootId })
useSubgraphNavigationStore()
routeHashRef.value = `#${slugRootId}`
await flushHashWatcher()
expect(routerMocks.replace).not.toHaveBeenCalled()
expect(app.canvas.setGraph).not.toHaveBeenCalled()
})
it('redirects when hash is a non-UUID slug that does not match root', async () => {
useSubgraphNavigationStore()
routeHashRef.value = '#some-other-slug'
await vi.waitFor(() =>
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
)
})
it('does not redirect or re-set graph when hash equals current root graph', async () => {
app.canvas.graph = fromPartial<LGraph>({ id: ids.root })
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.root}`
await flushHashWatcher()
expect(app.canvas.setGraph).not.toHaveBeenCalled()
expect(routerMocks.replace).not.toHaveBeenCalled()
})
it('does not redirect when transitioning to an empty hash on the root graph', async () => {
routeHashRef.value = `#${ids.root}`
app.canvas.graph = fromPartial<LGraph>({ id: ids.root })
useSubgraphNavigationStore()
await flushHashWatcher()
routerMocks.replace.mockClear()
vi.mocked(app.canvas.setGraph).mockClear()
routeHashRef.value = ''
await flushHashWatcher()
expect(routerMocks.replace).not.toHaveBeenCalled()
expect(app.canvas.setGraph).not.toHaveBeenCalled()
})
it('redirects when canvas still references a deleted subgraph (stale-graph guard)', async () => {
app.canvas.graph = makeSubgraph(ids.deletedSubgraph)
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() => {
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
})
})
it('recovers canvas to root even if router.replace rejects', async () => {
routerMocks.replace.mockRejectedValueOnce(new Error('navigation aborted'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
app.canvas.graph = makeSubgraph(ids.deletedSubgraph)
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() =>
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
)
warnSpy.mockRestore()
})
it('redirects when a workflow load resolves but the subgraph is still missing', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
workflowStoreState.openWorkflows = [
fromPartial<ComfyWorkflow>({
path: 'phantom-workflow.json',
activeState: {
id: ids.deletedSubgraph,
definitions: { subgraphs: [] }
}
})
]
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() => {
expect(workflowServiceMocks.openWorkflow).toHaveBeenCalled()
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('subgraph not found after workflow load')
)
})
warnSpy.mockRestore()
})
it('redirects when openWorkflow rejects during recovery', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
workflowServiceMocks.openWorkflow.mockRejectedValueOnce(
new Error('load failed')
)
workflowStoreState.openWorkflows = [
fromPartial<ComfyWorkflow>({
path: 'broken-workflow.json',
activeState: {
id: ids.deletedSubgraph,
definitions: { subgraphs: [] }
}
})
]
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() => {
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('workflow load failed')
)
})
warnSpy.mockRestore()
})
it('routeHash watcher does not re-enter navigateToHash during recovery redirect', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// Simulate the real router replace: trigger the routeHash watcher
// exactly the way vue-router does when the URL is replaced.
routerMocks.replace.mockImplementation((target) => {
const hash = typeof target === 'string' ? target : ''
routeHashRef.value = hash
return Promise.resolve(undefined)
})
app.canvas.graph = makeSubgraph(ids.deletedSubgraph)
useSubgraphNavigationStore()
routeHashRef.value = `#${ids.deletedSubgraph}`
await vi.waitFor(() => {
expect(routerMocks.replace).toHaveBeenCalledWith(`#${app.rootGraph.id}`)
})
// navigateToHash for the deleted id ran once and produced exactly one
// redirect. The watcher must NOT have fired again for the rewritten
// (root) hash and produced a second redirect.
expect(routerMocks.replace).toHaveBeenCalledTimes(1)
expect(app.canvas.setGraph).toHaveBeenCalledWith(app.rootGraph)
warnSpy.mockRestore()
})
})

View File

@@ -2,11 +2,7 @@ import QuickLRU from '@alloc/quick-lru'
import { useRouteHash } from '@vueuse/router'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef, watch } from 'vue'
import {
NavigationFailureType,
isNavigationFailure,
useRouter
} from 'vue-router'
import { useRouter } from 'vue-router'
import type { DragAndScaleState } from '@/lib/litegraph/src/DragAndScale'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
@@ -14,7 +10,6 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { isUuidShapedSubgraphId } from '@/schemas/subgraphIdSchema'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
@@ -205,64 +200,20 @@ export const useSubgraphNavigationStore = defineStore(
{ flush: 'sync' }
)
// Counter so nested/overlapping async navigations don't release
// suppression early; gates both the canvasStore.currentGraph watcher
// (updateHash) and the routeHash watcher to prevent re-entrant
// navigateToHash calls during router.replace().
let blockNavDepth = 0
//Allow navigation with forward/back buttons
let blockHashUpdate = false
let initialLoad = true
async function withNavBlocked<T>(op: () => Promise<T>): Promise<T> {
blockNavDepth++
try {
return await op()
} finally {
blockNavDepth--
}
}
function ensureCanvasOnRoot() {
const root = app.rootGraph
const canvas = canvasStore.getCanvas()
if (!root || !canvas) return
if (canvas.graph?.id !== root.id) canvas.setGraph(root)
}
async function redirectToRoot(reason: string) {
const root = app.rootGraph
console.warn(`[subgraphNavigation] ${reason}; redirecting to root graph`)
try {
await withNavBlocked(() => router.replace('#' + root.id))
} catch (err) {
if (
!isNavigationFailure(err, NavigationFailureType.duplicated) &&
!isNavigationFailure(err, NavigationFailureType.cancelled)
) {
console.warn(
'[subgraphNavigation] router.replace rejected during recovery',
err
)
}
} finally {
ensureCanvasOnRoot()
}
}
async function navigateToHash(newHash: string) {
const root = app.rootGraph
const locatorId = newHash?.slice(1) || root.id
const canvas = canvasStore.getCanvas()
const isRoot = locatorId === root.id
const targetGraph = isRoot
? root
: isUuidShapedSubgraphId(locatorId)
if (canvas.graph?.id === locatorId) return
const targetGraph =
(locatorId || root.id) !== root.id
? root.subgraphs.get(locatorId)
: undefined
if (targetGraph) {
if (canvas.graph?.id === targetGraph.id) return
return canvas.setGraph(targetGraph)
}
: root
if (targetGraph) return canvas.setGraph(targetGraph)
//Search all open workflows
for (const workflow of workflowStore.openWorkflows) {
@@ -271,48 +222,29 @@ export const useSubgraphNavigationStore = defineStore(
const subgraphs = activeState.definitions?.subgraphs ?? []
for (const graph of [activeState, ...subgraphs]) {
if (graph.id !== locatorId) continue
// This will trigger a navigation, which can break forward history.
// After openWorkflow resolves, app.rootGraph has been swapped, so we
// intentionally re-read app.rootGraph below instead of using the
// `root` captured at function entry.
//This will trigger a navigation, which can break forward history
try {
await withNavBlocked(() =>
useWorkflowService().openWorkflow(workflow)
)
} catch (err) {
console.warn(
'[subgraphNavigation] openWorkflow rejected during recovery',
err
)
return redirectToRoot('workflow load failed')
blockHashUpdate = true
await useWorkflowService().openWorkflow(workflow)
} finally {
blockHashUpdate = false
}
const loadedGraph =
const targetGraph =
app.rootGraph.id === locatorId
? app.rootGraph
: app.rootGraph.subgraphs.get(locatorId)
if (!loadedGraph) {
return redirectToRoot('subgraph not found after workflow load')
if (!targetGraph) {
console.error('subgraph poofed after load?')
return
}
if (canvas.graph?.id === loadedGraph.id) return
return canvas.setGraph(loadedGraph)
}
}
await redirectToRoot(`subgraph not found: ${locatorId}`)
}
async function safeRouterCall(op: () => Promise<unknown>, label: string) {
try {
await op()
} catch (err) {
if (!isNavigationFailure(err, NavigationFailureType.duplicated)) {
console.warn(`[subgraphNavigation] ${label} rejected`, err)
return canvas.setGraph(targetGraph)
}
}
}
async function updateHash() {
if (blockNavDepth > 0) return
if (blockHashUpdate) return
if (initialLoad) {
initialLoad = false
if (!routeHash.value) return
@@ -323,22 +255,16 @@ export const useSubgraphNavigationStore = defineStore(
}
const newId = canvasStore.getCanvas().graph?.id ?? ''
if (!routeHash.value) {
await safeRouterCall(
() => router.replace('#' + app.rootGraph.id),
'router.replace'
)
}
if (!routeHash.value) await router.replace('#' + app.rootGraph.id)
const currentId = routeHash.value?.slice(1)
if (!newId || newId === currentId) return
await safeRouterCall(() => router.push('#' + newId), 'router.push')
await router.push('#' + newId)
}
//update navigation hash
//NOTE: Doesn't apply on workflow load
watch(() => canvasStore.currentGraph, updateHash)
watch(routeHash, () => {
if (blockNavDepth > 0) return
void navigateToHash(String(routeHash.value))
})
watch(routeHash, () => navigateToHash(String(routeHash.value)))
/** Save the current viewport for the active graph/workflow. Called by
* workflowService.beforeLoadNewGraph() before the canvas is overwritten. */