mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-10 15:40:24 +00:00
[Subgraph] Add subgraph breadcrumbs component (#3241)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
This commit is contained in:
76
src/components/breadcrumb/SubgraphBreadcrumb.vue
Normal file
76
src/components/breadcrumb/SubgraphBreadcrumb.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="workflowStore.isSubgraphActive"
|
||||
class="fixed top-[var(--comfy-topbar-height)] left-[var(--sidebar-width)] p-2 subgraph-breadcrumb"
|
||||
>
|
||||
<Breadcrumb
|
||||
class="bg-transparent"
|
||||
:home="home"
|
||||
:model="items"
|
||||
aria-label="Graph navigation"
|
||||
@item-click="handleItemClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import Breadcrumb from 'primevue/breadcrumb'
|
||||
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
|
||||
|
||||
const items = computed(() => {
|
||||
if (!workflowStore.subgraphNamePath.length) return []
|
||||
|
||||
return workflowStore.subgraphNamePath.map<MenuItem>((name) => ({
|
||||
label: name,
|
||||
command: async () => {
|
||||
const workflow = workflowStore.getWorkflowByPath(name)
|
||||
if (workflow) await workflowService.openWorkflow(workflow)
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
const home = computed(() => ({
|
||||
label: workflowName.value,
|
||||
icon: 'pi pi-home',
|
||||
command: async () => {
|
||||
const canvas = useCanvasStore().getCanvas()
|
||||
if (!canvas.graph) throw new TypeError('Canvas has no graph')
|
||||
|
||||
canvas.setGraph(canvas.graph.rootGraph)
|
||||
}
|
||||
}))
|
||||
|
||||
const handleItemClick = (event: MenuItemCommandEvent) => {
|
||||
event.item.command?.(event)
|
||||
}
|
||||
|
||||
whenever(
|
||||
() => useCanvasStore().canvas,
|
||||
(canvas) => {
|
||||
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
|
||||
useWorkflowStore().updateActiveGraph()
|
||||
})
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.subgraph-breadcrumb {
|
||||
.p-breadcrumb-item-link,
|
||||
.p-breadcrumb-item-icon {
|
||||
color: #d26565;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -40,6 +40,7 @@
|
||||
</SelectionOverlay>
|
||||
<DomWidgets />
|
||||
</template>
|
||||
<SubgraphBreadcrumb />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -48,6 +49,7 @@ import { computed, onMounted, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import NodeBadge from '@/components/graph/NodeBadge.vue'
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import _ from 'lodash'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, markRaw, ref } from 'vue'
|
||||
import { computed, markRaw, ref, watch } from 'vue'
|
||||
|
||||
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
import { getPathDetails } from '@/utils/formatUtil'
|
||||
import { syncEntities } from '@/utils/syncUtil'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
import { UserFile } from './userFileStore'
|
||||
|
||||
@@ -128,7 +130,7 @@ export interface LoadedComfyWorkflow extends ComfyWorkflow {
|
||||
export interface WorkflowStore {
|
||||
activeWorkflow: LoadedComfyWorkflow | null
|
||||
isActive: (workflow: ComfyWorkflow) => boolean
|
||||
openWorkflows: LoadedComfyWorkflow[]
|
||||
openWorkflows: ComfyWorkflow[]
|
||||
openedWorkflowIndexShift: (shift: number) => LoadedComfyWorkflow | null
|
||||
openWorkflow: (workflow: ComfyWorkflow) => Promise<LoadedComfyWorkflow>
|
||||
openWorkflowsInBackground: (paths: {
|
||||
@@ -153,6 +155,13 @@ export interface WorkflowStore {
|
||||
getWorkflowByPath: (path: string) => ComfyWorkflow | null
|
||||
syncWorkflows: (dir?: string) => Promise<void>
|
||||
reorderWorkflows: (from: number, to: number) => void
|
||||
|
||||
/** An ordered list of all parent subgraphs, ending with the current subgraph. */
|
||||
subgraphNamePath: string[]
|
||||
/** `true` if any subgraph is currently being viewed. */
|
||||
isSubgraphActive: boolean
|
||||
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
|
||||
updateActiveGraph: () => void
|
||||
}
|
||||
|
||||
export const useWorkflowStore = defineStore('workflow', () => {
|
||||
@@ -418,6 +427,29 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** @see WorkflowStore.subgraphNamePath */
|
||||
const subgraphNamePath = ref<string[]>([])
|
||||
/** @see WorkflowStore.isSubgraphActive */
|
||||
const isSubgraphActive = ref(false)
|
||||
|
||||
/** @see WorkflowStore.updateActiveGraph */
|
||||
const updateActiveGraph = () => {
|
||||
if (!comfyApp.canvas) return
|
||||
|
||||
const { subgraph } = comfyApp.canvas
|
||||
isSubgraphActive.value = isSubgraph(subgraph)
|
||||
|
||||
if (subgraph) {
|
||||
const [, ...pathFromRoot] = subgraph.pathToRootGraph
|
||||
|
||||
subgraphNamePath.value = pathFromRoot.map((graph) => graph.name)
|
||||
} else {
|
||||
subgraphNamePath.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeWorkflow, updateActiveGraph)
|
||||
|
||||
return {
|
||||
activeWorkflow,
|
||||
isActive,
|
||||
@@ -439,7 +471,11 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
persistedWorkflows,
|
||||
modifiedWorkflows,
|
||||
getWorkflowByPath,
|
||||
syncWorkflows
|
||||
syncWorkflows,
|
||||
|
||||
subgraphNamePath,
|
||||
isSubgraphActive,
|
||||
updateActiveGraph
|
||||
}
|
||||
}) as () => WorkflowStore
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { LGraph, LGraphNode } from '@comfyorg/litegraph'
|
||||
import { Subgraph } from '@comfyorg/litegraph'
|
||||
|
||||
import type { PrimitiveNode } from '@/extensions/core/widgetInputs'
|
||||
|
||||
@@ -16,3 +17,7 @@ export const isAbortError = (
|
||||
err: unknown
|
||||
): err is DOMException & { name: 'AbortError' } =>
|
||||
err instanceof DOMException && err.name === 'AbortError'
|
||||
|
||||
export const isSubgraph = (
|
||||
item: LGraph | Subgraph | undefined | null
|
||||
): item is Subgraph => item?.isRootGraph === false
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
@@ -15,7 +17,16 @@ vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getUserData: vi.fn(),
|
||||
storeUserData: vi.fn(),
|
||||
listUserDataFullInfo: vi.fn()
|
||||
listUserDataFullInfo: vi.fn(),
|
||||
apiURL: vi.fn(),
|
||||
addEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock comfyApp globally for the store setup
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: null // Start with canvas potentially undefined or null
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -448,4 +459,117 @@ describe('useWorkflowStore', () => {
|
||||
expect(newWorkflow.isModified).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraphs', () => {
|
||||
beforeEach(async () => {
|
||||
// Ensure canvas exists for these tests
|
||||
vi.mocked(comfyApp).canvas = { subgraph: null } as any
|
||||
|
||||
// Setup an active workflow as updateActiveGraph depends on it
|
||||
const workflow = store.createTemporary('test-subgraph-workflow.json')
|
||||
// Mock load to avoid actual file operations/parsing
|
||||
vi.spyOn(workflow, 'load').mockImplementation(async () => {
|
||||
workflow.changeTracker = { activeState: {} } as any // Minimal mock
|
||||
workflow.originalContent = '{}'
|
||||
workflow.content = '{}'
|
||||
return workflow as LoadedComfyWorkflow
|
||||
})
|
||||
await store.openWorkflow(workflow)
|
||||
|
||||
// Reset mocks before each subgraph test
|
||||
vi.mocked(comfyApp.canvas).subgraph = undefined // Use undefined for root graph
|
||||
})
|
||||
|
||||
it('should handle when comfyApp.canvas is not available', async () => {
|
||||
// Arrange
|
||||
vi.mocked(comfyApp).canvas = null as any // Simulate canvas not ready
|
||||
|
||||
// Act
|
||||
console.debug(store.isSubgraphActive)
|
||||
store.updateActiveGraph()
|
||||
await nextTick()
|
||||
|
||||
// Assert
|
||||
console.debug(store.isSubgraphActive)
|
||||
expect(store.isSubgraphActive).toBe(false) // Should default to false
|
||||
expect(store.subgraphNamePath).toEqual([]) // Should default to empty
|
||||
})
|
||||
|
||||
it('should correctly update state when the root graph is active', async () => {
|
||||
// Arrange: Ensure comfyApp indicates root graph is active
|
||||
vi.mocked(comfyApp.canvas).subgraph = undefined // Use undefined for root graph
|
||||
|
||||
// Act: Trigger the update
|
||||
store.updateActiveGraph()
|
||||
await nextTick() // Wait for Vue reactivity
|
||||
|
||||
// Assert: Check store state
|
||||
expect(store.isSubgraphActive).toBe(false)
|
||||
expect(store.subgraphNamePath).toEqual([]) // Path is empty for root graph
|
||||
})
|
||||
|
||||
it('should correctly update state when a subgraph is active', async () => {
|
||||
// Arrange: Setup mock subgraph structure
|
||||
const mockSubgraph = {
|
||||
name: 'Level 2 Subgraph',
|
||||
isRootGraph: false,
|
||||
pathToRootGraph: [
|
||||
{ name: 'Root' }, // Root Graph (index 0, ignored)
|
||||
{ name: 'Level 1 Subgraph' },
|
||||
{ name: 'Level 2 Subgraph' }
|
||||
]
|
||||
}
|
||||
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any
|
||||
|
||||
// Act: Trigger the update
|
||||
store.updateActiveGraph()
|
||||
await nextTick() // Wait for Vue reactivity
|
||||
|
||||
// Assert: Check store state
|
||||
expect(store.isSubgraphActive).toBe(true)
|
||||
expect(store.subgraphNamePath).toEqual([
|
||||
'Level 1 Subgraph',
|
||||
'Level 2 Subgraph'
|
||||
]) // Path excludes the root
|
||||
})
|
||||
|
||||
it('should update automatically when activeWorkflow changes', async () => {
|
||||
// Arrange: Set initial canvas state (e.g., a subgraph)
|
||||
const initialSubgraph = {
|
||||
name: 'Initial Subgraph',
|
||||
pathToRootGraph: [{ name: 'Root' }, { name: 'Initial Subgraph' }],
|
||||
isRootGraph: false
|
||||
}
|
||||
vi.mocked(comfyApp.canvas).subgraph = initialSubgraph as any
|
||||
|
||||
// Trigger initial update based on the *first* workflow opened in beforeEach
|
||||
store.updateActiveGraph()
|
||||
await nextTick()
|
||||
|
||||
// Verify initial state
|
||||
expect(store.isSubgraphActive).toBe(true)
|
||||
expect(store.subgraphNamePath).toEqual(['Initial Subgraph'])
|
||||
|
||||
// Act: Change the active workflow
|
||||
const workflow2 = store.createTemporary('workflow2.json')
|
||||
// Mock load for the second workflow
|
||||
vi.spyOn(workflow2, 'load').mockImplementation(async () => {
|
||||
workflow2.changeTracker = { activeState: {} } as any
|
||||
workflow2.originalContent = '{}'
|
||||
workflow2.content = '{}'
|
||||
return workflow2 as LoadedComfyWorkflow
|
||||
})
|
||||
|
||||
// Before changing workflow, set the canvas state to something different (e.g., root)
|
||||
// This ensures the watcher *does* cause a state change we can assert
|
||||
vi.mocked(comfyApp.canvas).subgraph = undefined
|
||||
|
||||
await store.openWorkflow(workflow2) // This changes activeWorkflow and triggers the watch
|
||||
await nextTick() // Allow watcher and potential async operations in updateActiveGraph to complete
|
||||
|
||||
// Assert: Check that the state was updated by the watcher based on the *new* canvas state
|
||||
expect(store.isSubgraphActive).toBe(false) // Should reflect the change to undefined subgraph
|
||||
expect(store.subgraphNamePath).toEqual([]) // Path should be empty for root
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user