Skip to content

Commit 743f3cb

Browse files
[Subgraph] Add subgraph breadcrumbs component (#3241)
Co-authored-by: filtered <[email protected]>
1 parent 111fdcc commit 743f3cb

File tree

5 files changed

+248
-5
lines changed

5 files changed

+248
-5
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<template>
2+
<div
3+
v-if="workflowStore.isSubgraphActive"
4+
class="fixed top-[var(--comfy-topbar-height)] left-[var(--sidebar-width)] p-2 subgraph-breadcrumb"
5+
>
6+
<Breadcrumb
7+
class="bg-transparent"
8+
:home="home"
9+
:model="items"
10+
aria-label="Graph navigation"
11+
@item-click="handleItemClick"
12+
/>
13+
</div>
14+
</template>
15+
16+
<script setup lang="ts">
17+
import { useEventListener, whenever } from '@vueuse/core'
18+
import Breadcrumb from 'primevue/breadcrumb'
19+
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
20+
import { computed } from 'vue'
21+
22+
import { useWorkflowService } from '@/services/workflowService'
23+
import { useCanvasStore } from '@/stores/graphStore'
24+
import { useWorkflowStore } from '@/stores/workflowStore'
25+
26+
const workflowService = useWorkflowService()
27+
const workflowStore = useWorkflowStore()
28+
29+
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
30+
31+
const items = computed(() => {
32+
if (!workflowStore.subgraphNamePath.length) return []
33+
34+
return workflowStore.subgraphNamePath.map<MenuItem>((name) => ({
35+
label: name,
36+
command: async () => {
37+
const workflow = workflowStore.getWorkflowByPath(name)
38+
if (workflow) await workflowService.openWorkflow(workflow)
39+
}
40+
}))
41+
})
42+
43+
const home = computed(() => ({
44+
label: workflowName.value,
45+
icon: 'pi pi-home',
46+
command: async () => {
47+
const canvas = useCanvasStore().getCanvas()
48+
if (!canvas.graph) throw new TypeError('Canvas has no graph')
49+
50+
canvas.setGraph(canvas.graph.rootGraph)
51+
}
52+
}))
53+
54+
const handleItemClick = (event: MenuItemCommandEvent) => {
55+
event.item.command?.(event)
56+
}
57+
58+
whenever(
59+
() => useCanvasStore().canvas,
60+
(canvas) => {
61+
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
62+
useWorkflowStore().updateActiveGraph()
63+
})
64+
}
65+
)
66+
</script>
67+
68+
<style>
69+
.subgraph-breadcrumb {
70+
.p-breadcrumb-item-link,
71+
.p-breadcrumb-item-icon {
72+
color: #d26565;
73+
user-select: none;
74+
}
75+
}
76+
</style>

src/components/graph/GraphCanvas.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
</SelectionOverlay>
4141
<DomWidgets />
4242
</template>
43+
<SubgraphBreadcrumb />
4344
</template>
4445

4546
<script setup lang="ts">
@@ -48,6 +49,7 @@ import { computed, onMounted, ref, watch, watchEffect } from 'vue'
4849
4950
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
5051
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
52+
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
5153
import DomWidgets from '@/components/graph/DomWidgets.vue'
5254
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
5355
import NodeBadge from '@/components/graph/NodeBadge.vue'

src/stores/workflowStore.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import _ from 'lodash'
22
import { defineStore } from 'pinia'
3-
import { computed, markRaw, ref } from 'vue'
3+
import { computed, markRaw, ref, watch } from 'vue'
44

55
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
66
import { api } from '@/scripts/api'
7+
import { app as comfyApp } from '@/scripts/app'
78
import { ChangeTracker } from '@/scripts/changeTracker'
89
import { defaultGraphJSON } from '@/scripts/defaultGraph'
910
import { getPathDetails } from '@/utils/formatUtil'
1011
import { syncEntities } from '@/utils/syncUtil'
12+
import { isSubgraph } from '@/utils/typeGuardUtil'
1113

1214
import { UserFile } from './userFileStore'
1315

@@ -128,7 +130,7 @@ export interface LoadedComfyWorkflow extends ComfyWorkflow {
128130
export interface WorkflowStore {
129131
activeWorkflow: LoadedComfyWorkflow | null
130132
isActive: (workflow: ComfyWorkflow) => boolean
131-
openWorkflows: LoadedComfyWorkflow[]
133+
openWorkflows: ComfyWorkflow[]
132134
openedWorkflowIndexShift: (shift: number) => LoadedComfyWorkflow | null
133135
openWorkflow: (workflow: ComfyWorkflow) => Promise<LoadedComfyWorkflow>
134136
openWorkflowsInBackground: (paths: {
@@ -153,6 +155,13 @@ export interface WorkflowStore {
153155
getWorkflowByPath: (path: string) => ComfyWorkflow | null
154156
syncWorkflows: (dir?: string) => Promise<void>
155157
reorderWorkflows: (from: number, to: number) => void
158+
159+
/** An ordered list of all parent subgraphs, ending with the current subgraph. */
160+
subgraphNamePath: string[]
161+
/** `true` if any subgraph is currently being viewed. */
162+
isSubgraphActive: boolean
163+
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
164+
updateActiveGraph: () => void
156165
}
157166

158167
export const useWorkflowStore = defineStore('workflow', () => {
@@ -418,6 +427,29 @@ export const useWorkflowStore = defineStore('workflow', () => {
418427
}
419428
}
420429

430+
/** @see WorkflowStore.subgraphNamePath */
431+
const subgraphNamePath = ref<string[]>([])
432+
/** @see WorkflowStore.isSubgraphActive */
433+
const isSubgraphActive = ref(false)
434+
435+
/** @see WorkflowStore.updateActiveGraph */
436+
const updateActiveGraph = () => {
437+
if (!comfyApp.canvas) return
438+
439+
const { subgraph } = comfyApp.canvas
440+
isSubgraphActive.value = isSubgraph(subgraph)
441+
442+
if (subgraph) {
443+
const [, ...pathFromRoot] = subgraph.pathToRootGraph
444+
445+
subgraphNamePath.value = pathFromRoot.map((graph) => graph.name)
446+
} else {
447+
subgraphNamePath.value = []
448+
}
449+
}
450+
451+
watch(activeWorkflow, updateActiveGraph)
452+
421453
return {
422454
activeWorkflow,
423455
isActive,
@@ -439,7 +471,11 @@ export const useWorkflowStore = defineStore('workflow', () => {
439471
persistedWorkflows,
440472
modifiedWorkflows,
441473
getWorkflowByPath,
442-
syncWorkflows
474+
syncWorkflows,
475+
476+
subgraphNamePath,
477+
isSubgraphActive,
478+
updateActiveGraph
443479
}
444480
}) as () => WorkflowStore
445481

src/utils/typeGuardUtil.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { LGraphNode } from '@comfyorg/litegraph'
1+
import { LGraph, LGraphNode } from '@comfyorg/litegraph'
2+
import { Subgraph } from '@comfyorg/litegraph'
23

34
import type { PrimitiveNode } from '@/extensions/core/widgetInputs'
45

@@ -16,3 +17,7 @@ export const isAbortError = (
1617
err: unknown
1718
): err is DOMException & { name: 'AbortError' } =>
1819
err instanceof DOMException && err.name === 'AbortError'
20+
21+
export const isSubgraph = (
22+
item: LGraph | Subgraph | undefined | null
23+
): item is Subgraph => item?.isRootGraph === false

tests-ui/tests/store/workflowStore.test.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { createPinia, setActivePinia } from 'pinia'
22
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { nextTick } from 'vue'
34

45
import { api } from '@/scripts/api'
6+
import { app as comfyApp } from '@/scripts/app'
57
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
68
import {
79
ComfyWorkflow,
@@ -15,7 +17,16 @@ vi.mock('@/scripts/api', () => ({
1517
api: {
1618
getUserData: vi.fn(),
1719
storeUserData: vi.fn(),
18-
listUserDataFullInfo: vi.fn()
20+
listUserDataFullInfo: vi.fn(),
21+
apiURL: vi.fn(),
22+
addEventListener: vi.fn()
23+
}
24+
}))
25+
26+
// Mock comfyApp globally for the store setup
27+
vi.mock('@/scripts/app', () => ({
28+
app: {
29+
canvas: null // Start with canvas potentially undefined or null
1930
}
2031
}))
2132

@@ -448,4 +459,117 @@ describe('useWorkflowStore', () => {
448459
expect(newWorkflow.isModified).toBe(false)
449460
})
450461
})
462+
463+
describe('Subgraphs', () => {
464+
beforeEach(async () => {
465+
// Ensure canvas exists for these tests
466+
vi.mocked(comfyApp).canvas = { subgraph: null } as any
467+
468+
// Setup an active workflow as updateActiveGraph depends on it
469+
const workflow = store.createTemporary('test-subgraph-workflow.json')
470+
// Mock load to avoid actual file operations/parsing
471+
vi.spyOn(workflow, 'load').mockImplementation(async () => {
472+
workflow.changeTracker = { activeState: {} } as any // Minimal mock
473+
workflow.originalContent = '{}'
474+
workflow.content = '{}'
475+
return workflow as LoadedComfyWorkflow
476+
})
477+
await store.openWorkflow(workflow)
478+
479+
// Reset mocks before each subgraph test
480+
vi.mocked(comfyApp.canvas).subgraph = undefined // Use undefined for root graph
481+
})
482+
483+
it('should handle when comfyApp.canvas is not available', async () => {
484+
// Arrange
485+
vi.mocked(comfyApp).canvas = null as any // Simulate canvas not ready
486+
487+
// Act
488+
console.debug(store.isSubgraphActive)
489+
store.updateActiveGraph()
490+
await nextTick()
491+
492+
// Assert
493+
console.debug(store.isSubgraphActive)
494+
expect(store.isSubgraphActive).toBe(false) // Should default to false
495+
expect(store.subgraphNamePath).toEqual([]) // Should default to empty
496+
})
497+
498+
it('should correctly update state when the root graph is active', async () => {
499+
// Arrange: Ensure comfyApp indicates root graph is active
500+
vi.mocked(comfyApp.canvas).subgraph = undefined // Use undefined for root graph
501+
502+
// Act: Trigger the update
503+
store.updateActiveGraph()
504+
await nextTick() // Wait for Vue reactivity
505+
506+
// Assert: Check store state
507+
expect(store.isSubgraphActive).toBe(false)
508+
expect(store.subgraphNamePath).toEqual([]) // Path is empty for root graph
509+
})
510+
511+
it('should correctly update state when a subgraph is active', async () => {
512+
// Arrange: Setup mock subgraph structure
513+
const mockSubgraph = {
514+
name: 'Level 2 Subgraph',
515+
isRootGraph: false,
516+
pathToRootGraph: [
517+
{ name: 'Root' }, // Root Graph (index 0, ignored)
518+
{ name: 'Level 1 Subgraph' },
519+
{ name: 'Level 2 Subgraph' }
520+
]
521+
}
522+
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any
523+
524+
// Act: Trigger the update
525+
store.updateActiveGraph()
526+
await nextTick() // Wait for Vue reactivity
527+
528+
// Assert: Check store state
529+
expect(store.isSubgraphActive).toBe(true)
530+
expect(store.subgraphNamePath).toEqual([
531+
'Level 1 Subgraph',
532+
'Level 2 Subgraph'
533+
]) // Path excludes the root
534+
})
535+
536+
it('should update automatically when activeWorkflow changes', async () => {
537+
// Arrange: Set initial canvas state (e.g., a subgraph)
538+
const initialSubgraph = {
539+
name: 'Initial Subgraph',
540+
pathToRootGraph: [{ name: 'Root' }, { name: 'Initial Subgraph' }],
541+
isRootGraph: false
542+
}
543+
vi.mocked(comfyApp.canvas).subgraph = initialSubgraph as any
544+
545+
// Trigger initial update based on the *first* workflow opened in beforeEach
546+
store.updateActiveGraph()
547+
await nextTick()
548+
549+
// Verify initial state
550+
expect(store.isSubgraphActive).toBe(true)
551+
expect(store.subgraphNamePath).toEqual(['Initial Subgraph'])
552+
553+
// Act: Change the active workflow
554+
const workflow2 = store.createTemporary('workflow2.json')
555+
// Mock load for the second workflow
556+
vi.spyOn(workflow2, 'load').mockImplementation(async () => {
557+
workflow2.changeTracker = { activeState: {} } as any
558+
workflow2.originalContent = '{}'
559+
workflow2.content = '{}'
560+
return workflow2 as LoadedComfyWorkflow
561+
})
562+
563+
// Before changing workflow, set the canvas state to something different (e.g., root)
564+
// This ensures the watcher *does* cause a state change we can assert
565+
vi.mocked(comfyApp.canvas).subgraph = undefined
566+
567+
await store.openWorkflow(workflow2) // This changes activeWorkflow and triggers the watch
568+
await nextTick() // Allow watcher and potential async operations in updateActiveGraph to complete
569+
570+
// Assert: Check that the state was updated by the watcher based on the *new* canvas state
571+
expect(store.isSubgraphActive).toBe(false) // Should reflect the change to undefined subgraph
572+
expect(store.subgraphNamePath).toEqual([]) // Path should be empty for root
573+
})
574+
})
451575
})

0 commit comments

Comments
 (0)