Compare commits

...

12 Commits

Author SHA1 Message Date
Alexander Brown
8f2736095c refactor: move node definition loading from bootstrapStore to nodeDefStore
- Extract loadNodeDefs action to nodeDefStore for better separation of concerns
- Add initializeFromBackend action to nodeDefStore that handles full initialization
- Simplify bootstrapStore by delegating node def loading to nodeDefStore
- Update app.ts to use new nodeDefStore.initializeFromBackend method
- Expand test coverage for both stores

Amp-Thread-ID: https://ampcode.com/threads/T-019bfccc-e67e-739e-965f-d33c913371ef
Co-authored-by: Amp <amp@ampcode.com>
2026-01-26 17:07:33 -08:00
Alexander Brown
16b285a79c Remove the early bootstrap node def loading for now. 2026-01-26 16:57:41 -08:00
Alexander Brown
35eb0286e5 refactor: move workflow loading state from bootstrapStore to workflowStore
- Add useAsyncState in workflowStore for syncWorkflows with loading state

- Add loadWorkflows action that guards against double-loading

- Simplify bootstrapStore by delegating workflow loading to workflowStore

- Update test mocks for workflowStore

Amp-Thread-ID: https://ampcode.com/threads/T-019bfcce-cce0-70b0-abc6-742669ac86e3
Co-authored-by: Amp <amp@ampcode.com>
2026-01-26 16:44:52 -08:00
Alexander Brown
c171227d28 refactor: move settings loading state to settingStore
- Move isReady, isLoading, error state from bootstrapStore to settingStore
- Use useAsyncState in settingStore for load() action
- Rename loadSettingValues to load for consistency
- Update GraphCanvas to get settings state from settingStore
- Update tests to use createTestingPinia with stubActions: false

Amp-Thread-ID: https://ampcode.com/threads/T-019bfc95-aaa8-737d-bcd2-a5bdbc8b158f
Co-authored-by: Amp <amp@ampcode.com>
2026-01-26 15:16:49 -08:00
Alexander Brown
51bce9239e fix: use existing store references in useEventListener
Amp-Thread-ID: https://ampcode.com/threads/T-019bfb9a-f027-75d8-9859-26428d3025eb
Co-authored-by: Amp <amp@ampcode.com>
2026-01-26 10:40:55 -08:00
Alexander Brown
52483b34ed fix: improve locale change watcher to wait for settings and i18n in parallel
- Remove redundant useVueFeatureFlags() call
- Wait for settings and i18n readiness in parallel instead of sequentially
- Add warning log when locale changes during error state

Amp-Thread-ID: https://ampcode.com/threads/T-019bfb8f-8b22-740f-a77d-9280bcc44be8
Co-authored-by: Amp <amp@ampcode.com>
2026-01-26 10:29:20 -08:00
Alexander Brown
80d58ddc8b refactor: move composables and watchers outside onMounted
- Move useCopy, usePaste, useWorkflowAutoSave to top level

- Move locale change watcher outside onMounted with proper guard

- Move litegraph:set-graph event listener to top level

Amp-Thread-ID: https://ampcode.com/threads/T-019bf94d-b905-77ee-965d-a9c2a327dc2a
Co-authored-by: Amp <amp@ampcode.com>
2026-01-25 23:58:22 -08:00
Alexander Brown
3c0b227f94 refactor: consolidate workspace spinner and shiftDown state
- Replace manual keydown/keyup listeners with useMagicKeys for shiftDown

- Move app.extensionManager assignment to App.vue

- Remove init() wrapper function in GraphView.vue

Amp-Thread-ID: https://ampcode.com/threads/T-019bf90e-1cd0-71de-b23f-ea6cb0f99a87
Co-authored-by: Amp <amp@ampcode.com>
2026-01-25 22:48:48 -08:00
Alexander Brown
0a73c5a131 refactor: simplify GraphView event listeners with VueUse
Amp-Thread-ID: https://ampcode.com/threads/T-019bf491-17c4-75d1-a218-85842c463232
Co-authored-by: Amp <amp@ampcode.com>
2026-01-25 01:57:23 -08:00
Alexander Brown
326154f2b2 refactor: extract early bootstrap logic to bootstrapStore
- Add useBootstrapStore to centralize early initialization (api.init, fetchNodeDefs)
- Move settings loading and custom nodes i18n loading to store bootstrap phase
- Use VueUse's `until` to coordinate async dependencies in GraphCanvas
- Load settings, i18n, and newUserService initialization in parallel where possible
- Add unit tests for bootstrapStore

Amp-Thread-ID: https://ampcode.com/threads/T-019bf48d-af90-738f-99ce-46309e4be688
Co-authored-by: Amp <amp@ampcode.com>
2026-01-25 01:48:34 -08:00
Alexander Brown
33c457aca0 Assorted fixes for issues that happen when routing instead of reloading 2026-01-25 01:05:20 -08:00
Alexander Brown
8afb45db9c AGENTS changes 2026-01-25 01:03:33 -08:00
21 changed files with 610 additions and 274 deletions

View File

@@ -300,6 +300,12 @@ When referencing Comfy-Org repos:
Rules for agent-based coding tasks.
### Chrome DevTools MCP
When using `take_snapshot` to inspect dropdowns, listboxes, or other components with dynamic options:
- Use `verbose: true` to see the full accessibility tree including list items
- Non-verbose snapshots often omit nested options in comboboxes/listboxes
### Temporary Files
- Put planning documents under `/temp/plans/`

View File

@@ -17,6 +17,7 @@
- Clear public interfaces
- Restrict extension access
- Clean up subscriptions
- Only expose state/actions that are used externally; keep internal state private
## General Guidelines

View File

@@ -10,7 +10,6 @@
<script setup lang="ts">
import { captureException } from '@sentry/vue'
import { useEventListener } from '@vueuse/core'
import BlockUI from 'primevue/blockui'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, onMounted } from 'vue'
@@ -21,15 +20,13 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { electronAPI, isElectron } from './utils/envUtil'
import { app } from '@/scripts/app'
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
}
useEventListener(window, 'keydown', handleKey)
useEventListener(window, 'keyup', handleKey)
const showContextMenu = (event: MouseEvent) => {
const { target } = event

View File

@@ -93,7 +93,7 @@
</template>
<script setup lang="ts">
import { useEventListener, whenever } from '@vueuse/core'
import { until, useEventListener } from '@vueuse/core'
import {
computed,
nextTick,
@@ -129,7 +129,7 @@ import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { mergeCustomNodesI18n, t } from '@/i18n'
import { t } from '@/i18n'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
@@ -144,12 +144,15 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { UnauthorizedError, api } from '@/scripts/api'
import { UnauthorizedError } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { newUserService } from '@/services/newUserService'
import { storeToRefs } from 'pinia'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -175,11 +178,16 @@ const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()
const toastStore = useToastStore()
const colorPaletteStore = useColorPaletteStore()
const colorPaletteService = useColorPaletteService()
const canvasInteractions = useCanvasInteractions()
const bootstrapStore = useBootstrapStore()
const { isI18nReady, i18nError } = storeToRefs(bootstrapStore)
const { isReady: isSettingsReady, error: settingsError } =
storeToRefs(settingStore)
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
@@ -386,15 +394,6 @@ useEventListener(
{ passive: true }
)
const loadCustomNodesI18n = async () => {
try {
const i18nData = await api.getCustomNodesI18n()
mergeCustomNodesI18n(i18nData)
} catch (error) {
console.error('Failed to load custom nodes i18n', error)
}
}
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
const { flags } = useFeatureFlags()
@@ -404,35 +403,72 @@ useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()
useGlobalLitegraph()
useContextMenuTranslation()
useCopy()
usePaste()
useWorkflowAutoSave()
// Start watching for locale change after the initial value is loaded.
watch(
() => settingStore.get('Comfy.Locale'),
async (_newLocale, oldLocale) => {
if (!oldLocale) return
await Promise.all([
until(() => isSettingsReady.value || !!settingsError.value).toBe(true),
until(() => isI18nReady.value || !!i18nError.value).toBe(true)
])
if (settingsError.value || i18nError.value) {
console.warn(
'Somehow the Locale setting was changed while the settings or i18n had a setup error'
)
}
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
await useWorkflowService().reloadCurrentWorkflow()
}
)
useEventListener(
() => canvasStore.canvas?.canvas,
'litegraph:set-graph',
() => {
workflowStore.updateActiveGraph()
}
)
onMounted(async () => {
useGlobalLitegraph()
useContextMenuTranslation()
useCopy()
usePaste()
useWorkflowAutoSave()
useVueFeatureFlags()
comfyApp.vueAppReady = true
workspaceStore.spinner = true
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
ChangeTracker.init()
await loadCustomNodesI18n()
try {
await settingStore.loadSettingValues()
} catch (error) {
if (error instanceof UnauthorizedError) {
await until(() => isSettingsReady.value || !!settingsError.value).toBe(true)
if (settingsError.value) {
if (settingsError.value instanceof UnauthorizedError) {
localStorage.removeItem('Comfy.userId')
localStorage.removeItem('Comfy.userName')
window.location.reload()
} else {
throw error
return
}
throw settingsError.value
}
// Register core settings immediately after settings are ready
CORE_SETTINGS.forEach(settingStore.addSetting)
await newUserService().initializeIfNewUser(settingStore)
// Wait for both i18n and newUserService in parallel
// (newUserService only needs settings, not i18n)
await Promise.all([
until(() => isI18nReady.value || !!i18nError.value).toBe(true),
newUserService().initializeIfNewUser(settingStore)
])
if (i18nError.value) {
console.warn(
'[GraphCanvas] Failed to load custom nodes i18n:',
i18nError.value
)
}
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
@@ -487,25 +523,6 @@ onMounted(async () => {
const releaseStore = useReleaseStore()
void releaseStore.initialize()
// Start watching for locale change after the initial value is loaded.
watch(
() => settingStore.get('Comfy.Locale'),
async () => {
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
await useWorkflowService().reloadCurrentWorkflow()
}
)
whenever(
() => useCanvasStore().canvas,
(canvas) => {
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
useWorkflowStore().updateActiveGraph()
})
},
{ immediate: true }
)
emit('ready')
})

View File

@@ -71,6 +71,9 @@ export const useNodeBadge = () => {
}
onMounted(() => {
if (extensionStore.isExtensionInstalled('Comfy.NodeBadge')) return
// TODO: Fix the composables and watchers being setup in onMounted
const nodePricing = useNodePricing()
watch(

View File

@@ -167,25 +167,30 @@ describe('useCoreCommands', () => {
return {
get: vi.fn().mockReturnValue(getReturnValue),
addSetting: vi.fn(),
loadSettingValues: vi.fn(),
load: vi.fn(),
set: vi.fn(),
exists: vi.fn(),
getDefaultValue: vi.fn(),
isReady: true,
isLoading: false,
error: undefined,
settingValues: {},
settingsById: {},
$id: 'setting',
$state: {
settingValues: {},
settingsById: {}
settingsById: {},
isReady: true,
isLoading: false,
error: undefined
},
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {}
} as ReturnType<typeof useSettingStore>
_customProperties: new Set()
} satisfies ReturnType<typeof useSettingStore>
}
beforeEach(() => {

View File

@@ -67,10 +67,9 @@ import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelector
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore'
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
const moveSelectedNodesVersionAdded = '1.22.2'
export function useCoreCommands(): ComfyCommand[] {
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()

View File

@@ -14,6 +14,7 @@ import { VueFire, VueFireAuth } from 'vuefire'
import { getFirebaseConfig } from '@/config/firebase'
import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import App from './App.vue'
// Intentionally relative import to ensure the CSS is loaded in the right order (after litegraph.css)
@@ -43,6 +44,10 @@ const firebaseApp = initializeApp(getFirebaseConfig())
const app = createApp(App)
const pinia = createPinia()
const bootstrapStore = useBootstrapStore(pinia)
bootstrapStore.startEarlyBootstrap()
Sentry.init({
app,
dsn: __SENTRY_DSN__,
@@ -88,4 +93,6 @@ app
modules: [VueFireAuth()]
})
void bootstrapStore.startStoreBootstrap()
app.mount('#vue-app')

View File

@@ -203,6 +203,10 @@ function useSubscriptionInternal() {
if (loggedIn) {
try {
await fetchSubscriptionStatus()
} catch (error) {
// Network errors are expected during navigation/component unmount
// and when offline - log for debugging but don't surface to user
console.error('Failed to fetch subscription status:', error)
} finally {
isInitialized.value = true
}

View File

@@ -1,4 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
@@ -32,7 +33,7 @@ describe('useSettingStore', () => {
let store: ReturnType<typeof useSettingStore>
beforeEach(() => {
setActivePinia(createPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
store = useSettingStore()
vi.clearAllMocks()
})
@@ -42,18 +43,18 @@ describe('useSettingStore', () => {
expect(store.settingsById).toEqual({})
})
describe('loadSettingValues', () => {
describe('load', () => {
it('should load settings from API', async () => {
const mockSettings = { 'test.setting': 'value' }
vi.mocked(api.getSettings).mockResolvedValue(mockSettings as any)
await store.loadSettingValues()
await store.load()
expect(store.settingValues).toEqual(mockSettings)
expect(api.getSettings).toHaveBeenCalled()
})
it('should throw error if settings are loaded after registration', async () => {
it('should set error if settings are loaded after registration', async () => {
const setting: SettingParams = {
id: 'test.setting',
name: 'test.setting',
@@ -62,9 +63,14 @@ describe('useSettingStore', () => {
}
store.addSetting(setting)
await expect(store.loadSettingValues()).rejects.toThrow(
'Setting values must be loaded before any setting is registered.'
)
await store.load()
expect(store.error).toBeInstanceOf(Error)
if (store.error instanceof Error) {
expect(store.error.message).toBe(
'Setting values must be loaded before any setting is registered.'
)
}
})
})
@@ -82,18 +88,24 @@ describe('useSettingStore', () => {
expect(store.settingsById['test.setting']).toEqual(setting)
})
it('should throw error for duplicate setting ID', () => {
it('should warn and skip for duplicate setting ID', () => {
const setting: SettingParams = {
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default'
}
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
store.addSetting(setting)
expect(() => store.addSetting(setting)).toThrow(
'Setting test.setting must have a unique ID.'
store.addSetting(setting)
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Setting already registered: test.setting'
)
consoleWarnSpy.mockRestore()
})
it('should migrate deprecated values', () => {

View File

@@ -1,4 +1,5 @@
import _ from 'es-toolkit/compat'
import { useAsyncState } from '@vueuse/core'
import { defineStore } from 'pinia'
import { compare, valid } from 'semver'
import { ref } from 'vue'
@@ -47,6 +48,31 @@ export const useSettingStore = defineStore('setting', () => {
const settingValues = ref<Record<string, any>>({})
const settingsById = ref<Record<string, SettingParams>>({})
const {
isReady,
isLoading,
error,
execute: loadSettingValues
} = useAsyncState(
async () => {
if (Object.keys(settingsById.value).length) {
throw new Error(
'Setting values must be loaded before any setting is registered.'
)
}
settingValues.value = await api.getSettings()
await migrateZoomThresholdToFontSize()
},
undefined,
{ immediate: false }
)
async function load() {
if (!isReady.value && !isLoading.value) {
return loadSettingValues()
}
}
/**
* Check if a setting's value exists, i.e. if the user has set it manually.
* @param key - The key of the setting to check.
@@ -170,7 +196,11 @@ export const useSettingStore = defineStore('setting', () => {
throw new Error('Settings must have an ID')
}
if (setting.id in settingsById.value) {
throw new Error(`Setting ${setting.id} must have a unique ID.`)
// Setting already registered - skip to allow component remounting
// TODO: Add store reset methods to bootstrapStore and settingStore, then
// replace window.location.reload() with router.push() in SidebarLogoutIcon.vue
console.warn(`Setting already registered: ${setting.id}`)
return
}
settingsById.value[setting.id] = setting
@@ -184,22 +214,6 @@ export const useSettingStore = defineStore('setting', () => {
onChange(setting, get(setting.id), undefined)
}
/*
* Load setting values from server.
* This needs to be called before any setting is registered.
*/
async function loadSettingValues() {
if (Object.keys(settingsById.value).length) {
throw new Error(
'Setting values must be loaded before any setting is registered.'
)
}
settingValues.value = await api.getSettings()
// Migrate old zoom threshold setting to new font size setting
await migrateZoomThresholdToFontSize()
}
/**
* Migrate the old zoom threshold setting to the new font size setting.
* Preserves the exact zoom threshold behavior by converting it to equivalent font size.
@@ -242,8 +256,11 @@ export const useSettingStore = defineStore('setting', () => {
return {
settingValues,
settingsById,
isReady,
isLoading,
error,
load,
addSetting,
loadSettingValues,
set,
get,
exists,

View File

@@ -1,4 +1,5 @@
import _ from 'es-toolkit/compat'
import { useAsyncState } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, markRaw, ref, shallowRef, watch } from 'vue'
import type { Raw } from 'vue'
@@ -500,48 +501,67 @@ export const useWorkflowStore = defineStore('workflow', () => {
workflow.isPersisted && !workflow.path.startsWith('subgraphs/')
)
)
const syncWorkflows = async (dir: string = '') => {
await syncEntities(
dir ? 'workflows/' + dir : 'workflows',
workflowLookup.value,
(file) =>
new ComfyWorkflow({
path: file.path,
modified: file.modified,
size: file.size
}),
(existingWorkflow, file) => {
const isActiveWorkflow =
activeWorkflow.value?.path === existingWorkflow.path
const nextLastModified = Math.max(
existingWorkflow.lastModified,
file.modified
)
const {
isReady: isSyncReady,
isLoading: isSyncLoading,
execute: executeSyncWorkflows
} = useAsyncState(
async (dir: string = '') => {
await syncEntities(
dir ? 'workflows/' + dir : 'workflows',
workflowLookup.value,
(file) =>
new ComfyWorkflow({
path: file.path,
modified: file.modified,
size: file.size
}),
(existingWorkflow, file) => {
const isActiveWorkflow =
activeWorkflow.value?.path === existingWorkflow.path
const isMetadataUnchanged =
nextLastModified === existingWorkflow.lastModified &&
file.size === existingWorkflow.size
const nextLastModified = Math.max(
existingWorkflow.lastModified,
file.modified
)
if (!isMetadataUnchanged) {
existingWorkflow.lastModified = nextLastModified
existingWorkflow.size = file.size
}
const isMetadataUnchanged =
nextLastModified === existingWorkflow.lastModified &&
file.size === existingWorkflow.size
// Never unload the active workflow - it may contain unsaved in-memory edits.
if (isActiveWorkflow) {
return
}
if (!isMetadataUnchanged) {
existingWorkflow.lastModified = nextLastModified
existingWorkflow.size = file.size
}
// If nothing changed, keep any loaded content cached.
if (isMetadataUnchanged) {
return
}
// Never unload the active workflow - it may contain unsaved in-memory edits.
if (isActiveWorkflow) {
return
}
existingWorkflow.unload()
},
/* exclude */ (workflow) => workflow.isTemporary
)
// If nothing changed, keep any loaded content cached.
if (isMetadataUnchanged) {
return
}
existingWorkflow.unload()
},
/* exclude */ (workflow) => workflow.isTemporary
)
},
undefined,
{ immediate: false }
)
async function syncWorkflows(dir: string = '') {
return executeSyncWorkflows(0, dir)
}
async function loadWorkflows() {
if (!isSyncReady.value && !isSyncLoading.value) {
return syncWorkflows()
}
}
const bookmarkStore = useWorkflowBookmarkStore()
@@ -849,6 +869,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
modifiedWorkflows,
getWorkflowByPath,
syncWorkflows,
loadWorkflows,
isSubgraphActive,
activeSubgraph,

View File

@@ -139,21 +139,11 @@ describe('useMinimapGraph', () => {
expect(mockGraph.onConnectionChange).toBe(originalOnConnectionChange)
})
it('should handle cleanup for never-setup graph', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
it('should handle cleanup for never-setup graph silently', () => {
const graphRef = ref(mockGraph as any)
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.cleanupEventListeners()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Attempted to cleanup event listeners for graph that was never set up'
)
consoleErrorSpy.mockRestore()
expect(() => graphManager.cleanupEventListeners()).not.toThrow()
})
it('should detect node position changes', () => {

View File

@@ -102,9 +102,7 @@ export function useMinimapGraph(
const originalCallbacks = originalCallbacksMap.get(g.id)
if (!originalCallbacks) {
console.error(
'Attempted to cleanup event listeners for graph that was never set up'
)
// Graph was never set up (e.g., minimap destroyed before init) - nothing to clean up
return
}

View File

@@ -6,7 +6,7 @@ import { shallowRef } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
import { st, t } from '@/i18n'
import { t } from '@/i18n'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import {
LGraph,
@@ -61,7 +61,7 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
import { useModelStore } from '@/stores/modelStore'
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -881,21 +881,18 @@ export class ComfyApp {
this.canvas?.draw(true, true)
}
private updateVueAppNodeDefs(defs: Record<string, ComfyNodeDefV1>) {
// Frontend only nodes registered by custom nodes.
// Example: https://github.com/rgthree/rgthree-comfy/blob/dd534e5384be8cf0c0fa35865afe2126ba75ac55/src_web/comfyui/fast_groups_bypasser.ts#L10
// Only create frontend_only definitions for nodes that don't have backend definitions
const frontendOnlyDefs: Record<string, ComfyNodeDefV1> = {}
private buildFrontendOnlyDefs(
backendDefs: Record<string, ComfyNodeDefV1>
): ComfyNodeDefV1[] {
const frontendOnlyDefs: ComfyNodeDefV1[] = []
for (const [name, node] of Object.entries(
LiteGraph.registered_node_types
)) {
// Skip if we already have a backend definition or system definition
if (name in defs || name in SYSTEM_NODE_DEFS || node.skip_list) {
if (name in backendDefs || node.skip_list) {
continue
}
frontendOnlyDefs[name] = {
frontendOnlyDefs.push({
name,
display_name: name,
category: node.category || '__frontend_only__',
@@ -906,54 +903,33 @@ export class ComfyApp {
output_node: false,
python_module: 'custom_nodes.frontend_only',
description: node.description ?? `Frontend only node for ${name}`
} as ComfyNodeDefV1
})
}
const allNodeDefs = {
...frontendOnlyDefs,
...defs,
...SYSTEM_NODE_DEFS
}
const nodeDefStore = useNodeDefStore()
const nodeDefArray: ComfyNodeDefV1[] = Object.values(allNodeDefs)
useExtensionService().invokeExtensions(
'beforeRegisterVueAppNodeDefs',
nodeDefArray,
this
)
nodeDefStore.updateNodeDefs(nodeDefArray)
}
async getNodeDefs(): Promise<Record<string, ComfyNodeDefV1>> {
const translateNodeDef = (def: ComfyNodeDefV1): ComfyNodeDefV1 => ({
...def,
display_name: st(
`nodeDefs.${def.name}.display_name`,
def.display_name ?? def.name
),
description: def.description
? st(`nodeDefs.${def.name}.description`, def.description)
: '',
category: def.category
.split('/')
.map((category: string) => st(`nodeCategories.${category}`, category))
.join('/')
})
return _.mapValues(await api.getNodeDefs(), (def) => translateNodeDef(def))
return frontendOnlyDefs
}
/**
* Registers nodes with the graph
*/
async registerNodes() {
// Load node definitions from the backend
const defs = await this.getNodeDefs()
const nodeDefStore = useNodeDefStore()
while (!nodeDefStore.isReady) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
if (nodeDefStore.error) {
throw nodeDefStore.error
}
const defs = nodeDefStore.rawNodeDefs
await this.registerNodesFromDefs(defs)
await useExtensionService().invokeExtensionsAsync('registerCustomNodes')
if (this.vueAppReady) {
this.updateVueAppNodeDefs(defs)
const frontendOnlyDefs = this.buildFrontendOnlyDefs(defs)
const allDefs = [...frontendOnlyDefs, ...Object.values(defs)]
useNodeDefStore().updateNodeDefs(allDefs, this)
}
}
@@ -1653,7 +1629,7 @@ export class ComfyApp {
useToastStore().add(requestToastMessage)
}
const defs = await this.getNodeDefs()
const defs = await api.getNodeDefs()
for (const nodeId in defs) {
this.registerNodeDef(nodeId, defs[nodeId])
}
@@ -1698,7 +1674,9 @@ export class ComfyApp {
)
if (this.vueAppReady) {
this.updateVueAppNodeDefs(defs)
const frontendOnlyDefs = this.buildFrontendOnlyDefs(defs)
const allDefs = [...frontendOnlyDefs, ...Object.values(defs)]
useNodeDefStore().updateNodeDefs(allDefs, this)
useToastStore().remove(requestToastMessage)
useToastStore().add({
severity: 'success',

View File

@@ -0,0 +1,98 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useBootstrapStore } from './bootstrapStore'
vi.mock('@/scripts/api', () => ({
api: {
init: vi.fn().mockResolvedValue(undefined),
getNodeDefs: vi.fn().mockResolvedValue({ TestNode: { name: 'TestNode' } }),
getCustomNodesI18n: vi.fn().mockResolvedValue({}),
getUserConfig: vi.fn().mockResolvedValue({})
}
}))
vi.mock('@/i18n', () => ({
mergeCustomNodesI18n: vi.fn()
}))
const mockIsSettingsReady = ref(false)
const mockIsNodeDefsReady = ref(false)
const mockNodeDefStoreLoad = vi.fn(() => {
mockIsNodeDefsReady.value = true
})
const mockSettingStoreLoad = vi.fn(() => {
mockIsSettingsReady.value = true
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
load: mockSettingStoreLoad,
isReady: mockIsSettingsReady,
isLoading: { value: false },
error: { value: undefined }
}))
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: vi.fn(() => ({
load: mockNodeDefStoreLoad,
isReady: mockIsNodeDefsReady,
isLoading: { value: false },
error: { value: undefined },
rawNodeDefs: { value: {} }
}))
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
loadWorkflows: vi.fn(),
syncWorkflows: vi.fn().mockResolvedValue(undefined)
}))
}))
vi.mock('@/stores/userStore', () => ({
useUserStore: vi.fn(() => ({
initialize: vi.fn().mockResolvedValue(undefined),
needsLogin: false
}))
}))
describe('bootstrapStore', () => {
let store: ReturnType<typeof useBootstrapStore>
beforeEach(() => {
mockIsSettingsReady.value = false
mockIsNodeDefsReady.value = false
setActivePinia(createTestingPinia({ stubActions: false }))
store = useBootstrapStore()
vi.clearAllMocks()
})
it('initializes with all flags false', () => {
expect(mockIsNodeDefsReady.value).toBe(false)
expect(mockIsSettingsReady.value).toBe(false)
expect(store.isI18nReady).toBe(false)
})
it('starts early bootstrap (node defs)', async () => {
store.startEarlyBootstrap()
await vi.waitFor(() => {
expect(mockIsNodeDefsReady.value).toBe(true)
})
expect(mockNodeDefStoreLoad).toHaveBeenCalled()
})
it('starts store bootstrap (settings, i18n)', async () => {
void store.startStoreBootstrap()
await vi.waitFor(() => {
expect(mockIsSettingsReady.value).toBe(true)
expect(store.isI18nReady).toBe(true)
})
})
})

View File

@@ -0,0 +1,51 @@
import { useAsyncState } from '@vueuse/core'
import { defineStore } from 'pinia'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { api } from '@/scripts/api'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useUserStore } from '@/stores/userStore'
export const useBootstrapStore = defineStore('bootstrap', () => {
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workflowStore = useWorkflowStore()
const {
isReady: isI18nReady,
error: i18nError,
execute: loadI18n
} = useAsyncState(
async () => {
const { mergeCustomNodesI18n } = await import('@/i18n')
const i18nData = await api.getCustomNodesI18n()
mergeCustomNodesI18n(i18nData)
},
undefined,
{ immediate: false }
)
function startEarlyBootstrap() {
void nodeDefStore.load()
}
async function startStoreBootstrap() {
const userStore = useUserStore()
await userStore.initialize()
void loadI18n()
if (!userStore.needsLogin) {
await settingStore.load()
await workflowStore.loadWorkflows()
}
}
return {
isI18nReady,
i18nError,
startEarlyBootstrap,
startStoreBootstrap
}
})

View File

@@ -1,16 +1,28 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { SYSTEM_NODE_DEFS, useNodeDefStore } from '@/stores/nodeDefStore'
import type { NodeDefFilter } from '@/stores/nodeDefStore'
describe('useNodeDefStore', () => {
let store: ReturnType<typeof useNodeDefStore>
vi.mock('@/scripts/api', () => ({
api: {
getNodeDefs: vi.fn().mockResolvedValue({ TestNode: { name: 'TestNode' } }),
apiURL: vi.fn((path: string) => `/api${path}`),
addEventListener: vi.fn(),
getUserData: vi.fn(),
storeUserData: vi.fn(),
listUserDataFullInfo: vi.fn()
}
}))
const SYSTEM_NODE_COUNT = Object.keys(SYSTEM_NODE_DEFS).length
describe('useNodeDefStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
store = useNodeDefStore()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
const createMockNodeDef = (
@@ -31,8 +43,42 @@ describe('useNodeDefStore', () => {
...overrides
})
describe('load', () => {
it('initializes with isReady false', () => {
const store = useNodeDefStore()
expect(store.isReady).toBe(false)
})
it('loads node definitions from API', async () => {
const store = useNodeDefStore()
const { api } = await import('@/scripts/api')
await store.load()
await vi.waitFor(() => {
expect(store.isReady).toBe(true)
})
expect(api.getNodeDefs).toHaveBeenCalled()
})
it('does not reload if already ready', async () => {
const store = useNodeDefStore()
const { api } = await import('@/scripts/api')
await store.load()
await vi.waitFor(() => expect(store.isReady).toBe(true))
vi.clearAllMocks()
await store.load()
expect(api.getNodeDefs).not.toHaveBeenCalled()
})
})
describe('filter registry', () => {
it('should register a new filter', () => {
const store = useNodeDefStore()
const filter: NodeDefFilter = {
id: 'test.filter',
name: 'Test Filter',
@@ -44,6 +90,7 @@ describe('useNodeDefStore', () => {
})
it('should unregister a filter by id', () => {
const store = useNodeDefStore()
const filter: NodeDefFilter = {
id: 'test.filter',
name: 'Test Filter',
@@ -56,6 +103,7 @@ describe('useNodeDefStore', () => {
})
it('should register core filters on initialization', () => {
const store = useNodeDefStore()
const deprecatedFilter = store.nodeDefFilters.find(
(f) => f.id === 'core.deprecated'
)
@@ -69,12 +117,23 @@ describe('useNodeDefStore', () => {
})
describe('filter application', () => {
beforeEach(() => {
const systemNodeFilter: NodeDefFilter = {
id: 'test.no-system',
name: 'Hide System Nodes',
predicate: (node) => !(node.name in SYSTEM_NODE_DEFS)
}
function createFilterTestStore() {
const store = useNodeDefStore()
// Clear existing filters for isolated tests
store.nodeDefFilters.splice(0)
})
// Exclude system nodes from filter tests for cleaner assertions
store.registerNodeDefFilter(systemNodeFilter)
return store
}
it('should apply single filter to visible nodes', () => {
const store = createFilterTestStore()
const normalNode = createMockNodeDef({
name: 'normal',
deprecated: false
@@ -98,6 +157,7 @@ describe('useNodeDefStore', () => {
})
it('should apply multiple filters with AND logic', () => {
const store = createFilterTestStore()
const node1 = createMockNodeDef({
name: 'node1',
deprecated: false,
@@ -140,6 +200,10 @@ describe('useNodeDefStore', () => {
})
it('should show all nodes when no filters are registered', () => {
const store = createFilterTestStore()
// Remove system node filter for this test
store.unregisterNodeDefFilter('test.no-system')
const nodes = [
createMockNodeDef({ name: 'node1' }),
createMockNodeDef({ name: 'node2' }),
@@ -147,10 +211,12 @@ describe('useNodeDefStore', () => {
]
store.updateNodeDefs(nodes)
expect(store.visibleNodeDefs).toHaveLength(3)
// Includes 3 test nodes + 4 system nodes
expect(store.visibleNodeDefs).toHaveLength(3 + SYSTEM_NODE_COUNT)
})
it('should update visibility when filter is removed', () => {
const store = createFilterTestStore()
const deprecatedNode = createMockNodeDef({
name: 'deprecated',
deprecated: true
@@ -163,7 +229,7 @@ describe('useNodeDefStore', () => {
predicate: (node) => !node.deprecated
}
// Add filter - node should be hidden
// Add filter - node should be hidden (only system nodes remain, but they're filtered too)
store.registerNodeDefFilter(filter)
expect(store.visibleNodeDefs).toHaveLength(0)
@@ -175,6 +241,7 @@ describe('useNodeDefStore', () => {
describe('core filters behavior', () => {
it('should hide deprecated nodes by default', () => {
const store = useNodeDefStore()
const normalNode = createMockNodeDef({
name: 'normal',
deprecated: false
@@ -186,11 +253,18 @@ describe('useNodeDefStore', () => {
store.updateNodeDefs([normalNode, deprecatedNode])
expect(store.visibleNodeDefs).toHaveLength(1)
expect(store.visibleNodeDefs[0].name).toBe('normal')
// 1 normal test node + 4 system nodes
expect(store.visibleNodeDefs).toHaveLength(1 + SYSTEM_NODE_COUNT)
expect(
store.visibleNodeDefs.find((n) => n.name === 'normal')
).toBeDefined()
expect(
store.visibleNodeDefs.find((n) => n.name === 'deprecated')
).toBeUndefined()
})
it('should show deprecated nodes when showDeprecated is true', () => {
const store = useNodeDefStore()
const normalNode = createMockNodeDef({
name: 'normal',
deprecated: false
@@ -203,10 +277,12 @@ describe('useNodeDefStore', () => {
store.updateNodeDefs([normalNode, deprecatedNode])
store.showDeprecated = true
expect(store.visibleNodeDefs).toHaveLength(2)
// 2 test nodes + 4 system nodes
expect(store.visibleNodeDefs).toHaveLength(2 + SYSTEM_NODE_COUNT)
})
it('should hide experimental nodes by default', () => {
const store = useNodeDefStore()
const normalNode = createMockNodeDef({
name: 'normal',
experimental: false
@@ -218,11 +294,18 @@ describe('useNodeDefStore', () => {
store.updateNodeDefs([normalNode, experimentalNode])
expect(store.visibleNodeDefs).toHaveLength(1)
expect(store.visibleNodeDefs[0].name).toBe('normal')
// 1 normal test node + 4 system nodes
expect(store.visibleNodeDefs).toHaveLength(1 + SYSTEM_NODE_COUNT)
expect(
store.visibleNodeDefs.find((n) => n.name === 'normal')
).toBeDefined()
expect(
store.visibleNodeDefs.find((n) => n.name === 'experimental')
).toBeUndefined()
})
it('should show experimental nodes when showExperimental is true', () => {
const store = useNodeDefStore()
const normalNode = createMockNodeDef({
name: 'normal',
experimental: false
@@ -235,10 +318,12 @@ describe('useNodeDefStore', () => {
store.updateNodeDefs([normalNode, experimentalNode])
store.showExperimental = true
expect(store.visibleNodeDefs).toHaveLength(2)
// 2 test nodes + 4 system nodes
expect(store.visibleNodeDefs).toHaveLength(2 + SYSTEM_NODE_COUNT)
})
it('should hide subgraph nodes by default', () => {
const store = useNodeDefStore()
const normalNode = createMockNodeDef({
name: 'normal',
category: 'conditioning',
@@ -252,11 +337,18 @@ describe('useNodeDefStore', () => {
store.updateNodeDefs([normalNode, subgraphNode])
expect(store.visibleNodeDefs).toHaveLength(1)
expect(store.visibleNodeDefs[0].name).toBe('normal')
// 1 normal test node + 4 system nodes
expect(store.visibleNodeDefs).toHaveLength(1 + SYSTEM_NODE_COUNT)
expect(
store.visibleNodeDefs.find((n) => n.name === 'normal')
).toBeDefined()
expect(
store.visibleNodeDefs.find((n) => n.name === 'MySubgraph')
).toBeUndefined()
})
it('should show non-subgraph nodes with subgraph category', () => {
const store = useNodeDefStore()
const normalNode = createMockNodeDef({
name: 'normal',
category: 'conditioning',
@@ -270,20 +362,23 @@ describe('useNodeDefStore', () => {
store.updateNodeDefs([normalNode, fakeSubgraphNode])
expect(store.visibleNodeDefs).toHaveLength(2)
expect(store.visibleNodeDefs.map((n) => n.name)).toEqual([
'normal',
'FakeSubgraph'
])
// 2 test nodes + 4 system nodes
expect(store.visibleNodeDefs).toHaveLength(2 + SYSTEM_NODE_COUNT)
const testNodes = store.visibleNodeDefs
.filter((n) => !(n.name in SYSTEM_NODE_DEFS))
.map((n) => n.name)
expect(testNodes).toEqual(['normal', 'FakeSubgraph'])
})
})
describe('performance', () => {
it('should perform single traversal for multiple filters', () => {
const store = useNodeDefStore()
let filterCallCount = 0
const testFilterCount = 5
// Register multiple filters that count their calls
for (let i = 0; i < 5; i++) {
for (let i = 0; i < testFilterCount; i++) {
store.registerNodeDefFilter({
id: `test.counter-${i}`,
name: `Counter ${i}`,
@@ -294,7 +389,8 @@ describe('useNodeDefStore', () => {
})
}
const nodes = Array.from({ length: 10 }, (_, i) =>
const testNodeCount = 10
const nodes = Array.from({ length: testNodeCount }, (_, i) =>
createMockNodeDef({ name: `node${i}` })
)
store.updateNodeDefs(nodes)
@@ -302,8 +398,9 @@ describe('useNodeDefStore', () => {
// Force recomputation by accessing visibleNodeDefs
expect(store.visibleNodeDefs).toBeDefined()
// Each node (10) should be checked by each filter (5 test + 2 core = 7 total)
expect(filterCallCount).toBe(10 * 5)
// Each node (test nodes + system nodes) checked by each test filter
const totalNodes = testNodeCount + SYSTEM_NODE_COUNT
expect(filterCallCount).toBe(totalNodes * testFilterCount)
})
})
})

View File

@@ -1,9 +1,12 @@
import { useAsyncState } from '@vueuse/core'
import axios from 'axios'
import { retry } from 'es-toolkit'
import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { transformNodeDefV1ToV2 } from '@/schemas/nodeDef/migration'
import type {
@@ -17,7 +20,10 @@ import type {
ComfyOutputTypesSpec as ComfyOutputSpecV1,
PriceBadge
} from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import type { ComfyApp } from '@/scripts/app'
import { NodeSearchService } from '@/services/nodeSearchService'
import { useExtensionService } from '@/services/extensionService'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
import type { NodeSource } from '@/types/nodeSource'
@@ -305,6 +311,28 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
const showExperimental = ref(false)
const nodeDefFilters = ref<NodeDefFilter[]>([])
const {
state: rawNodeDefs,
isReady,
isLoading,
error,
execute: fetchNodeDefs
} = useAsyncState<Record<string, ComfyNodeDefV1>>(
() =>
retry(() => api.getNodeDefs(), {
retries: 3,
delay: (attempt: number) => Math.min(1000 * Math.pow(2, attempt), 10000)
}),
{},
{ immediate: false }
)
async function load() {
if (!isReady.value && !isLoading.value) {
return fetchNodeDefs()
}
}
const nodeDefs = computed(() => {
const subgraphStore = useSubgraphStore()
// Blueprints first for discoverability in the node library sidebar
@@ -335,18 +363,46 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
)
const nodeTree = computed(() => buildNodeDefTree(visibleNodeDefs.value))
function updateNodeDefs(nodeDefs: ComfyNodeDefV1[]) {
function translateNodeDef(def: ComfyNodeDefV1): ComfyNodeDefV1 {
return {
...def,
display_name: st(
`nodeDefs.${def.name}.display_name`,
def.display_name ?? def.name
),
description: def.description
? st(`nodeDefs.${def.name}.description`, def.description)
: '',
category: def.category
.split('/')
.map((category: string) => st(`nodeCategories.${category}`, category))
.join('/')
}
}
function updateNodeDefs(defs: ComfyNodeDefV1[], app?: ComfyApp) {
const allDefs = [...Object.values(SYSTEM_NODE_DEFS), ...defs]
if (app) {
useExtensionService().invokeExtensions(
'beforeRegisterVueAppNodeDefs',
allDefs,
app
)
}
const newNodeDefsByName: Record<string, ComfyNodeDefImpl> = {}
const newNodeDefsByDisplayName: Record<string, ComfyNodeDefImpl> = {}
for (const nodeDef of nodeDefs) {
for (const def of allDefs) {
const translatedDef = translateNodeDef(def)
const nodeDefImpl =
nodeDef instanceof ComfyNodeDefImpl
? nodeDef
: new ComfyNodeDefImpl(nodeDef)
translatedDef instanceof ComfyNodeDefImpl
? translatedDef
: new ComfyNodeDefImpl(translatedDef)
newNodeDefsByName[nodeDef.name] = nodeDefImpl
newNodeDefsByDisplayName[nodeDef.display_name] = nodeDefImpl
newNodeDefsByName[translatedDef.name] = nodeDefImpl
newNodeDefsByDisplayName[translatedDef.display_name] = nodeDefImpl
}
nodeDefsByName.value = newNodeDefsByName
@@ -448,6 +504,12 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
showExperimental,
nodeDefFilters,
rawNodeDefs,
isReady,
isLoading,
error,
load,
nodeDefs,
nodeDataTypes,
visibleNodeDefs,

View File

@@ -1,3 +1,4 @@
import { useMagicKeys } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
@@ -18,7 +19,7 @@ import { useSidebarTabStore } from './workspace/sidebarTabStore'
export const useWorkspaceStore = defineStore('workspace', () => {
const spinner = ref(false)
const shiftDown = ref(false)
const { shift: shiftDown } = useMagicKeys()
/**
* Whether the workspace is in focus mode.
* When in focus mode, only the graph editor is visible.

View File

@@ -24,7 +24,7 @@
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { useEventListener, useIntervalFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
@@ -79,7 +79,6 @@ import { useServerConfigStore } from '@/stores/serverConfigStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI, isElectron } from '@/utils/envUtil'
import LinearView from '@/views/LinearView.vue'
import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue'
@@ -102,9 +101,6 @@ const { linearMode } = storeToRefs(useCanvasStore())
const telemetry = useTelemetry()
const firebaseAuthStore = useFirebaseAuthStore()
let hasTrackedLogin = false
let visibilityListener: (() => void) | null = null
let tabCountInterval: number | null = null
let tabCountChannel: BroadcastChannel | null = null
watch(
() => colorPaletteStore.completedActivePalette,
@@ -198,15 +194,12 @@ watchEffect(() => {
queueStore.maxHistoryItems = settingStore.get('Comfy.Queue.MaxHistoryItems')
})
const init = () => {
const coreCommands = useCoreCommands()
useCommandStore().registerCommands(coreCommands)
useMenuItemStore().registerCoreMenuCommands()
useKeybindingService().registerCoreKeybindings()
useSidebarTabStore().registerCoreSidebarTabs()
useBottomPanelStore().registerCoreBottomPanelTabs()
app.extensionManager = useWorkspaceStore()
}
const coreCommands = useCoreCommands()
useCommandStore().registerCommands(coreCommands)
useMenuItemStore().registerCoreMenuCommands()
useKeybindingService().registerCoreKeybindings()
useSidebarTabStore().registerCoreSidebarTabs()
useBottomPanelStore().registerCoreBottomPanelTabs()
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
const sidebarTabStore = useSidebarTabStore()
@@ -274,15 +267,15 @@ if (isCloud) {
)
}
useEventListener(api, 'status', onStatus)
useEventListener(api, 'execution_success', onExecutionSuccess)
useEventListener(api, 'reconnecting', onReconnecting)
useEventListener(api, 'reconnected', onReconnected)
onMounted(() => {
api.addEventListener('status', onStatus)
api.addEventListener('execution_success', onExecutionSuccess)
api.addEventListener('reconnecting', onReconnecting)
api.addEventListener('reconnected', onReconnected)
executionStore.bindExecutionEvents()
try {
init()
// Relocate the legacy menu container to the graph canvas container so it is below other elements
graphCanvasContainerRef.value?.prepend(app.ui.menuContainer)
} catch (e) {
@@ -291,27 +284,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
api.removeEventListener('status', onStatus)
api.removeEventListener('execution_success', onExecutionSuccess)
api.removeEventListener('reconnecting', onReconnecting)
api.removeEventListener('reconnected', onReconnected)
executionStore.unbindExecutionEvents()
// Clean up page visibility listener
if (visibilityListener) {
document.removeEventListener('visibilitychange', visibilityListener)
visibilityListener = null
}
// Clean up tab count tracking
if (tabCountInterval) {
window.clearInterval(tabCountInterval)
tabCountInterval = null
}
if (tabCountChannel) {
tabCountChannel.close()
tabCountChannel = null
}
})
useEventListener(window, 'keydown', useKeybindingService().keybindHandler)
@@ -337,18 +310,17 @@ const onGraphReady = () => {
}
// Set up page visibility tracking (cloud only)
if (isCloud && telemetry && !visibilityListener) {
visibilityListener = () => {
if (isCloud && telemetry) {
useEventListener(document, 'visibilitychange', () => {
telemetry.trackPageVisibilityChanged({
visibility_state: document.visibilityState as 'visible' | 'hidden'
})
}
document.addEventListener('visibilitychange', visibilityListener)
})
}
// Set up tab count tracking (cloud only)
if (isCloud && telemetry && !tabCountInterval) {
tabCountChannel = new BroadcastChannel('comfyui-tab-count')
if (isCloud && telemetry) {
const tabCountChannel = new BroadcastChannel('comfyui-tab-count')
const activeTabs = new Map<string, number>()
const currentTabId = crypto.randomUUID()
@@ -363,7 +335,7 @@ const onGraphReady = () => {
}
// 5-minute heartbeat interval
tabCountInterval = window.setInterval(() => {
useIntervalFn(() => {
const now = Date.now()
// Clean up stale tabs (no heartbeat for 45 seconds)
@@ -374,7 +346,7 @@ const onGraphReady = () => {
})
// Broadcast our heartbeat
tabCountChannel?.postMessage({
tabCountChannel.postMessage({
type: 'heartbeat',
tabId: currentTabId
})