Workflow Management Reworked (#1406)

* Merge temp userfile

Basic migration

Remove deprecated isFavourite

Rename

nit

nit

Rework open/load

Refactor save

Refactor delete

Remove workflow dep on manager

WIP

Change map to record

Fix directory

nit

isActive

Move

nit

Add unload

Add close workflow

Remove workflowManager.closeWorkflow

nit

Remove workflowManager.storePrompt

move from commandStore

move more from commandStore

nit

Use workflowservice

nit

nit

implement setWorkflow

nit

Remove workflows.ts

Fix strict errors

nit

nit

Resolves circular dep

nit

nit

Fix workflow switching

Add openworkflowPaths

Fix store

Fix key

Serialize by default

Fix proxy

nit

Update path

Proper sync

Fix tabs

WIP

nit

Resolve merge conflict

Fix userfile store tests

Update jest test

Update tabs

patch tests

Fix changeTracker init

Move insert to service

nit

Fix insert

nit

Handle bookmark rename

Refactor tests

Add delete workflow

Add test on deleting workflow

Add closeWorkflow tests

nit

* Fix path

* Move load next/previous

* Move logic from store to service

* nit

* nit

* nit

* nit

* nit

* Add ChangeTracker.initialState

* ChangeTracker load/unload

* Remove app.changeWorkflow

* Hook to app.ts

* Changetracker restore

* nit

* nit

* nit

* Add debug logs

* Remove unnecessary checkState on graphLoad

* nit

* Fix strict

* Fix temp workflow name

* Track ismodified

* Fix reactivity

* nit

* Fix graph equal

* nit

* update test

* nit

* nit

* Fix modified state

* nit

* Fix modified state

* Sidebar force close

* tabs force close

* Fix save

* Add load remote workflow test

* Force save

* Add save test

* nit

* Correctly handle delete last opened workflow

* nit

* Fix workflow rename

* Fix save

* Fix tests

* Fix strict

* Update playwright tests

* Fix filename conflict handling

* nit

* Merge temporary and persisted ref

* Update playwright expectations

* nit

* nit

* Fix saveAs

* Add playwright test

* nit
This commit is contained in:
Chenlei Hu
2024-11-05 11:03:27 -05:00
committed by GitHub
parent 1387d7e627
commit c56533bb23
28 changed files with 1409 additions and 784 deletions

View File

@@ -52,7 +52,9 @@ test.describe('Actionbar', () => {
(n) => n.type === 'EmptyLatentImage' (n) => n.type === 'EmptyLatentImage'
) )
node.widgets[0].value = value node.widgets[0].value = value
window['app'].workflowManager.activeWorkflow.changeTracker.checkState() window[
'app'
].extensionManager.workflow.activeWorkflow.changeTracker.checkState()
}, value) }, value)
} }

View File

@@ -9,24 +9,22 @@ test.describe('Browser tab title', () => {
test('Can display workflow name', async ({ comfyPage }) => { test('Can display workflow name', async ({ comfyPage }) => {
const workflowName = await comfyPage.page.evaluate(async () => { const workflowName = await comfyPage.page.evaluate(async () => {
return window['app'].workflowManager.activeWorkflow.name return window['app'].extensionManager.workflow.activeWorkflow.filename
}) })
// Note: unsaved workflow name is always prepended with "*". expect(await comfyPage.page.title()).toBe(`${workflowName} - ComfyUI`)
expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`)
}) })
// Broken by https://github.com/Comfy-Org/ComfyUI_frontend/pull/893 // Failing on CI
// Release blocker for v1.3.0 // Cannot reproduce locally
test.skip('Can display workflow name with unsaved changes', async ({ test.skip('Can display workflow name with unsaved changes', async ({
comfyPage comfyPage
}) => { }) => {
const workflowName = await comfyPage.page.evaluate(async () => { const workflowName = await comfyPage.page.evaluate(async () => {
return window['app'].workflowManager.activeWorkflow.name return window['app'].extensionManager.workflow.activeWorkflow.filename
}) })
// Note: unsaved workflow name is always prepended with "*". expect(await comfyPage.page.title()).toBe(`${workflowName} - ComfyUI`)
expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`)
await comfyPage.menu.saveWorkflow('test') await comfyPage.menu.topbar.saveWorkflow('test')
expect(await comfyPage.page.title()).toBe('test - ComfyUI') expect(await comfyPage.page.title()).toBe('test - ComfyUI')
const textBox = comfyPage.widgetTextBox const textBox = comfyPage.widgetTextBox
@@ -36,7 +34,7 @@ test.describe('Browser tab title', () => {
// Delete the saved workflow for cleanup. // Delete the saved workflow for cleanup.
await comfyPage.page.evaluate(async () => { await comfyPage.page.evaluate(async () => {
window['app'].workflowManager.activeWorkflow.delete() return window['app'].extensionManager.workflow.activeWorkflow.delete()
}) })
}) })
}) })

View File

@@ -36,6 +36,14 @@ export class Topbar {
await this.page.waitForTimeout(300) await this.page.waitForTimeout(300)
} }
async saveWorkflowAs(workflowName: string) {
await this.triggerTopbarCommand(['Workflow', 'Save As'])
await this.page.locator('.p-dialog-content input').fill(workflowName)
await this.page.keyboard.press('Enter')
// Wait for the dialog to close.
await this.page.waitForTimeout(300)
}
async triggerTopbarCommand(path: string[]) { async triggerTopbarCommand(path: string[]) {
if (path.length < 2) { if (path.length < 2) {
throw new Error('Path is too short') throw new Error('Path is too short')

View File

@@ -392,7 +392,7 @@ test.describe('Menu', () => {
await tab.newBlankWorkflowButton.click() await tab.newBlankWorkflowButton.click()
expect(await tab.getOpenedWorkflowNames()).toEqual([ expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json', '*Unsaved Workflow.json',
'*Unsaved Workflow (2).json' 'Unsaved Workflow (2).json'
]) ])
}) })
@@ -411,6 +411,19 @@ test.describe('Menu', () => {
) )
}) })
test('Can save workflow as', async ({ comfyPage }) => {
await comfyPage.menu.workflowsTab.newBlankWorkflowButton.click()
await comfyPage.menu.topbar.saveWorkflowAs('workflow3.json')
expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
).toEqual(['*Unsaved Workflow.json', 'workflow3.json'])
await comfyPage.menu.topbar.saveWorkflowAs('workflow4.json')
expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
).toEqual(['*Unsaved Workflow.json', 'workflow3.json', 'workflow4.json'])
})
test('Does not report warning when switching between opened workflows', async ({ test('Does not report warning when switching between opened workflows', async ({
comfyPage comfyPage
}) => { }) => {
@@ -441,7 +454,7 @@ test.describe('Menu', () => {
await closeButton.click() await closeButton.click()
expect( expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames() await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
).toEqual(['*Unsaved Workflow (2).json']) ).toEqual(['Unsaved Workflow.json'])
}) })
}) })
@@ -466,7 +479,7 @@ test.describe('Menu', () => {
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([workflowName]) expect(await comfyPage.menu.topbar.getTabNames()).toEqual([workflowName])
await comfyPage.menu.topbar.closeWorkflowTab(workflowName) await comfyPage.menu.topbar.closeWorkflowTab(workflowName)
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([ expect(await comfyPage.menu.topbar.getTabNames()).toEqual([
'Unsaved Workflow (2)' 'Unsaved Workflow'
]) ])
}) })
}) })

View File

@@ -26,10 +26,10 @@ const betaMenuEnabled = computed(
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
const isUnsavedText = computed(() => const isUnsavedText = computed(() =>
workflowStore.activeWorkflow?.unsaved ? ' *' : '' workflowStore.activeWorkflow?.isModified ? ' *' : ''
) )
const workflowNameText = computed(() => { const workflowNameText = computed(() => {
const workflowName = workflowStore.activeWorkflow?.name const workflowName = workflowStore.activeWorkflow?.filename
return workflowName return workflowName
? isUnsavedText.value + workflowName + TITLE_SUFFIX ? isUnsavedText.value + workflowName + TITLE_SUFFIX
: DEFAULT_TITLE : DEFAULT_TITLE

View File

@@ -55,6 +55,7 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import { usePragmaticDroppable } from '@/hooks/dndHooks' import { usePragmaticDroppable } from '@/hooks/dndHooks'
import { useWorkflowStore } from '@/stores/workflowStore' import { useWorkflowStore } from '@/stores/workflowStore'
import { setStorageValue } from '@/scripts/utils' import { setStorageValue } from '@/scripts/utils'
import { ChangeTracker } from '@/scripts/changeTracker'
const emit = defineEmits(['ready']) const emit = defineEmits(['ready'])
const canvasRef = ref<HTMLCanvasElement | null>(null) const canvasRef = ref<HTMLCanvasElement | null>(null)
@@ -147,7 +148,7 @@ const workflowStore = useWorkflowStore()
watchEffect(() => { watchEffect(() => {
if (workflowStore.activeWorkflow) { if (workflowStore.activeWorkflow) {
const workflow = workflowStore.activeWorkflow const workflow = workflowStore.activeWorkflow
setStorageValue('Comfy.PreviousWorkflow', workflow.path ?? workflow.name) setStorageValue('Comfy.PreviousWorkflow', workflow.key)
} }
}) })
@@ -222,6 +223,9 @@ onMounted(async () => {
comfyApp.vueAppReady = true comfyApp.vueAppReady = true
workspaceStore.spinner = true workspaceStore.spinner = true
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
ChangeTracker.init(comfyApp)
await comfyApp.setup(canvasRef.value) await comfyApp.setup(canvasRef.value)
canvasStore.canvas = comfyApp.canvas canvasStore.canvas = comfyApp.canvas
workspaceStore.spinner = false workspaceStore.spinner = false

View File

@@ -43,20 +43,22 @@
<TextDivider text="Open" type="dashed" class="ml-2" /> <TextDivider text="Open" type="dashed" class="ml-2" />
<TreeExplorer <TreeExplorer
:roots="renderTreeNode(workflowStore.openWorkflowsTree).children" :roots="renderTreeNode(workflowStore.openWorkflowsTree).children"
v-model:selectionKeys="selectionKeys" :selectionKeys="selectionKeys"
> >
<template #node="{ node }"> <template #node="{ node }">
<TreeExplorerTreeNode :node="node"> <TreeExplorerTreeNode :node="node">
<template #before-label="{ node }"> <template #before-label="{ node }">
<span v-if="node.data.unsaved">*</span> <span v-if="node.data.isModified">*</span>
</template> </template>
<template #actions="{ node }"> <template #actions="{ node }">
<Button <Button
icon="pi pi-times" icon="pi pi-times"
text text
severity="secondary" :severity="
workspaceStore.shiftDown ? 'danger' : 'secondary'
"
size="small" size="small"
@click.stop="app.workflowManager.closeWorkflow(node.data)" @click.stop="handleCloseWorkflow(node.data)"
/> />
</template> </template>
</TreeExplorerTreeNode> </TreeExplorerTreeNode>
@@ -112,16 +114,17 @@ import TreeExplorer from '@/components/common/TreeExplorer.vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue' import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import Button from 'primevue/button' import Button from 'primevue/button'
import TextDivider from '@/components/common/TextDivider.vue' import TextDivider from '@/components/common/TextDivider.vue'
import { app } from '@/scripts/app'
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useWorkflowStore } from '@/stores/workflowStore' import { useWorkflowStore } from '@/stores/workflowStore'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import type { TreeNode } from 'primevue/treenode' import type { TreeNode } from 'primevue/treenode'
import { TreeExplorerNode } from '@/types/treeExplorerTypes' import { TreeExplorerNode } from '@/types/treeExplorerTypes'
import { ComfyWorkflow } from '@/scripts/workflows' import { ComfyWorkflow } from '@/stores/workflowStore'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useTreeExpansion } from '@/hooks/treeHooks' import { useTreeExpansion } from '@/hooks/treeHooks'
import { useSettingStore } from '@/stores/settingStore' import { useSettingStore } from '@/stores/settingStore'
import { workflowService } from '@/services/workflowService'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const settingStore = useSettingStore() const settingStore = useSettingStore()
const workflowTabsPosition = computed(() => const workflowTabsPosition = computed(() =>
@@ -144,7 +147,7 @@ const handleSearch = (query: string) => {
} }
const lowerQuery = query.toLocaleLowerCase() const lowerQuery = query.toLocaleLowerCase()
filteredWorkflows.value = workflowStore.workflows.filter((workflow) => { filteredWorkflows.value = workflowStore.workflows.filter((workflow) => {
return workflow.name.toLocaleLowerCase().includes(lowerQuery) return workflow.path.toLocaleLowerCase().includes(lowerQuery)
}) })
nextTick(() => { nextTick(() => {
expandNode(filteredRoot.value) expandNode(filteredRoot.value)
@@ -152,12 +155,20 @@ const handleSearch = (query: string) => {
} }
const commandStore = useCommandStore() const commandStore = useCommandStore()
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
const workspaceStore = useWorkspaceStore()
const { t } = useI18n() const { t } = useI18n()
const expandedKeys = ref<Record<string, boolean>>({}) const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys) const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const handleCloseWorkflow = (workflow?: ComfyWorkflow) => {
if (workflow) {
workflowService.closeWorkflow(workflow, {
warnIfUnsaved: !workspaceStore.shiftDown
})
}
}
const renderTreeNode = (node: TreeNode): TreeExplorerNode<ComfyWorkflow> => { const renderTreeNode = (node: TreeNode): TreeExplorerNode<ComfyWorkflow> => {
const children = node.children?.map(renderTreeNode) const children = node.children?.map(renderTreeNode)
@@ -168,8 +179,7 @@ const renderTreeNode = (node: TreeNode): TreeExplorerNode<ComfyWorkflow> => {
e: MouseEvent e: MouseEvent
) => { ) => {
if (node.leaf) { if (node.leaf) {
const workflow = node.data workflowService.openWorkflow(workflow)
workflow.load()
} else { } else {
toggleNodeOnEvent(e, node) toggleNodeOnEvent(e, node)
} }
@@ -181,14 +191,12 @@ const renderTreeNode = (node: TreeNode): TreeExplorerNode<ComfyWorkflow> => {
node: TreeExplorerNode<ComfyWorkflow>, node: TreeExplorerNode<ComfyWorkflow>,
newName: string newName: string
) => { ) => {
const workflow = node.data await workflowService.renameWorkflow(workflow, newName)
await workflow.rename(newName)
}, },
handleDelete: workflow.isTemporary handleDelete: workflow.isTemporary
? undefined ? undefined
: (node: TreeExplorerNode<ComfyWorkflow>) => { : () => {
const workflow = node.data workflowService.deleteWorkflow(workflow)
workflow.delete()
}, },
contextMenuItems: (node: TreeExplorerNode<ComfyWorkflow>) => { contextMenuItems: (node: TreeExplorerNode<ComfyWorkflow>) => {
return [ return [
@@ -197,7 +205,7 @@ const renderTreeNode = (node: TreeNode): TreeExplorerNode<ComfyWorkflow> => {
icon: 'pi pi-file-export', icon: 'pi pi-file-export',
command: () => { command: () => {
const workflow = node.data const workflow = node.data
workflow.insert() workflowService.insertWorkflow(workflow)
} }
} }
] ]
@@ -216,6 +224,6 @@ const renderTreeNode = (node: TreeNode): TreeExplorerNode<ComfyWorkflow> => {
} }
const selectionKeys = computed(() => ({ const selectionKeys = computed(() => ({
[`root/${workflowStore.activeWorkflow?.name}.json`]: true [`root/${workflowStore.activeWorkflow?.key}`]: true
})) }))
</script> </script>

View File

@@ -15,8 +15,7 @@
<script setup lang="ts"> <script setup lang="ts">
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue' import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import Button from 'primevue/button' import Button from 'primevue/button'
import { useWorkflowBookmarkStore } from '@/stores/workflowStore' import { ComfyWorkflow, useWorkflowBookmarkStore } from '@/stores/workflowStore'
import { ComfyWorkflow } from '@/scripts/workflows'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes' import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { computed } from 'vue' import { computed } from 'vue'

View File

@@ -11,12 +11,16 @@
<template #option="{ option }"> <template #option="{ option }">
<span <span
class="workflow-label text-sm max-w-[150px] truncate inline-block" class="workflow-label text-sm max-w-[150px] truncate inline-block"
v-tooltip="option.tooltip" v-tooltip.bottom="option.workflow.key"
> >
{{ option.label }} {{ option.workflow.filename }}
</span> </span>
<div class="relative"> <div class="relative">
<span class="status-indicator" v-if="option.unsaved"></span> <span
class="status-indicator"
v-if="!workspaceStore.shiftDown && option.workflow.isModified"
></span
>
<Button <Button
class="close-button p-0 w-auto" class="close-button p-0 w-auto"
icon="pi pi-times" icon="pi pi-times"
@@ -31,35 +35,31 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { app } from '@/scripts/app' import { ComfyWorkflow } from '@/stores/workflowStore'
import { ComfyWorkflow } from '@/scripts/workflows'
import { useWorkflowStore } from '@/stores/workflowStore' import { useWorkflowStore } from '@/stores/workflowStore'
import SelectButton from 'primevue/selectbutton' import SelectButton from 'primevue/selectbutton'
import Button from 'primevue/button' import Button from 'primevue/button'
import { computed } from 'vue' import { computed } from 'vue'
import { workflowService } from '@/services/workflowService'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const props = defineProps<{ const props = defineProps<{
class?: string class?: string
}>() }>()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
interface WorkflowOption { interface WorkflowOption {
label: string
tooltip: string
value: string value: string
unsaved: boolean workflow: ComfyWorkflow
} }
const workflowToOption = (workflow: ComfyWorkflow): WorkflowOption => ({ const workflowToOption = (workflow: ComfyWorkflow): WorkflowOption => ({
label: workflow.name, value: workflow.path,
tooltip: workflow.path, workflow
value: workflow.key,
unsaved: workflow.unsaved
}) })
const optionToWorkflow = (option: WorkflowOption): ComfyWorkflow =>
workflowStore.workflowLookup[option.value]
const options = computed<WorkflowOption[]>(() => const options = computed<WorkflowOption[]>(() =>
workflowStore.openWorkflows.map(workflowToOption) workflowStore.openWorkflows.map(workflowToOption)
) )
@@ -78,13 +78,13 @@ const onWorkflowChange = (option: WorkflowOption) => {
return return
} }
const workflow = optionToWorkflow(option) workflowService.openWorkflow(option.workflow)
workflow.load()
} }
const onCloseWorkflow = (option: WorkflowOption) => { const onCloseWorkflow = (option: WorkflowOption) => {
const workflow = optionToWorkflow(option) workflowService.closeWorkflow(option.workflow, {
app.workflowManager.closeWorkflow(workflow) warnIfUnsaved: !workspaceStore.shiftDown
})
} }
</script> </script>

View File

@@ -25,7 +25,7 @@ import { ComfyNodeDef, StatusWsMessageStatus } from '@/types/apiTypes'
import { adjustColor, ColorAdjustOptions } from '@/utils/colorUtil' import { adjustColor, ColorAdjustOptions } from '@/utils/colorUtil'
import { ComfyAppMenu } from './ui/menu/index' import { ComfyAppMenu } from './ui/menu/index'
import { getStorageValue } from './utils' import { getStorageValue } from './utils'
import { ComfyWorkflowManager, ComfyWorkflow } from './workflows' import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { import {
LGraphCanvas, LGraphCanvas,
LGraph, LGraph,
@@ -58,6 +58,7 @@ import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import { shallowReactive } from 'vue' import { shallowReactive } from 'vue'
import { type IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets' import { type IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { workflowService } from '@/services/workflowService'
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview' export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
@@ -141,7 +142,6 @@ export class ComfyApp {
multiUserServer: boolean multiUserServer: boolean
ctx: CanvasRenderingContext2D ctx: CanvasRenderingContext2D
widgets: Record<string, ComfyWidgetConstructor> widgets: Record<string, ComfyWidgetConstructor>
workflowManager: ComfyWorkflowManager
bodyTop: HTMLElement bodyTop: HTMLElement
bodyLeft: HTMLElement bodyLeft: HTMLElement
bodyRight: HTMLElement bodyRight: HTMLElement
@@ -170,7 +170,6 @@ export class ComfyApp {
this.vueAppReady = false this.vueAppReady = false
this.ui = new ComfyUI(this) this.ui = new ComfyUI(this)
this.logging = new ComfyLogging(this) this.logging = new ComfyLogging(this)
this.workflowManager = new ComfyWorkflowManager(this)
this.bodyTop = $el('div.comfyui-body-top', { parent: document.body }) this.bodyTop = $el('div.comfyui-body-top', { parent: document.body })
this.bodyLeft = $el('div.comfyui-body-left', { parent: document.body }) this.bodyLeft = $el('div.comfyui-body-left', { parent: document.body })
this.bodyRight = $el('div.comfyui-body-right', { parent: document.body }) this.bodyRight = $el('div.comfyui-body-right', { parent: document.body })
@@ -1789,7 +1788,7 @@ export class ComfyApp {
this.resizeCanvas() this.resizeCanvas()
await Promise.all([ await Promise.all([
this.workflowManager.loadWorkflows(), useWorkspaceStore().workflow.syncWorkflows(),
this.ui.settings.load() this.ui.settings.load()
]) ])
await this.#loadExtensions() await this.#loadExtensions()
@@ -2160,21 +2159,6 @@ export class ComfyApp {
}) })
} }
async changeWorkflow(callback, workflow = null) {
try {
this.workflowManager.activeWorkflow?.changeTracker?.store()
} catch (error) {
console.error(error)
}
await callback()
try {
this.workflowManager.setWorkflow(workflow)
this.workflowManager.activeWorkflow?.track()
} catch (error) {
console.error(error)
}
}
async loadGraphData( async loadGraphData(
graphData?: ComfyWorkflowJSON, graphData?: ComfyWorkflowJSON,
clean: boolean = true, clean: boolean = true,
@@ -2198,12 +2182,6 @@ export class ComfyApp {
graphData = structuredClone(graphData) graphData = structuredClone(graphData)
} }
try {
this.workflowManager.setWorkflow(workflow)
} catch (error) {
console.error(error)
}
if (useSettingStore().get('Comfy.Validation.Workflows')) { if (useSettingStore().get('Comfy.Validation.Workflows')) {
// TODO: Show validation error in a dialog. // TODO: Show validation error in a dialog.
const validatedGraphData = await validateComfyWorkflow( const validatedGraphData = await validateComfyWorkflow(
@@ -2217,6 +2195,8 @@ export class ComfyApp {
graphData = validatedGraphData ?? graphData graphData = validatedGraphData ?? graphData
} }
workflowService.beforeLoadNewGraph()
const missingNodeTypes: MissingNodeType[] = [] const missingNodeTypes: MissingNodeType[] = []
const missingModels = [] const missingModels = []
await this.#invokeExtensionsAsync( await this.#invokeExtensionsAsync(
@@ -2270,12 +2250,6 @@ export class ComfyApp {
this.canvas.ds.offset = graphData.extra.ds.offset this.canvas.ds.offset = graphData.extra.ds.offset
this.canvas.ds.scale = graphData.extra.ds.scale this.canvas.ds.scale = graphData.extra.ds.scale
} }
try {
this.workflowManager.activeWorkflow?.track()
} catch (error) {
// TODO: Do we want silently fail here?
}
} catch (error) { } catch (error) {
let errorHint = [] let errorHint = []
// Try extracting filename to see if it was caused by an extension script // Try extracting filename to see if it was caused by an extension script
@@ -2384,6 +2358,8 @@ export class ComfyApp {
this.#showMissingModelsError(missingModels, paths) this.#showMissingModelsError(missingModels, paths)
} }
await this.#invokeExtensionsAsync('afterConfigureGraph', missingNodeTypes) await this.#invokeExtensionsAsync('afterConfigureGraph', missingNodeTypes)
// @ts-expect-error zod types issue. Will be fixed after we enable ts-strict
workflowService.afterLoadNewGraph(workflow, this.graph.serialize())
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.graph.setDirtyCanvas(true, true) this.graph.setDirtyCanvas(true, true)
}) })
@@ -2602,9 +2578,11 @@ export class ComfyApp {
this.canvas.draw(true, true) this.canvas.draw(true, true)
} else { } else {
try { try {
this.workflowManager.storePrompt({ useExecutionStore().storePrompt({
id: res.prompt_id, id: res.prompt_id,
nodes: Object.keys(p.output) nodes: Object.keys(p.output),
workflow: useWorkspaceStore().workflow
.activeWorkflow as ComfyWorkflow
}) })
} catch (error) {} } catch (error) {}
} }
@@ -2678,9 +2656,12 @@ export class ComfyApp {
} else if (pngInfo?.prompt) { } else if (pngInfo?.prompt) {
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName) this.loadApiJson(JSON.parse(pngInfo.prompt), fileName)
} else if (pngInfo?.parameters) { } else if (pngInfo?.parameters) {
this.changeWorkflow(() => { // Note: Not putting this in `importA1111` as it is mostly not used
importA1111(this.graph, pngInfo.parameters) // by external callers, and `importA1111` has no access to `app`.
}, fileName) workflowService.beforeLoadNewGraph()
importA1111(this.graph, pngInfo.parameters)
// @ts-expect-error zod type issue on ComfyWorkflowJSON. Should be resolved after enabling ts-strict globally.
workflowService.afterLoadNewGraph(fileName, this.serializeGraph())
} else { } else {
this.showErrorOnFileLoad(file) this.showErrorOnFileLoad(file)
} }
@@ -2764,6 +2745,8 @@ export class ComfyApp {
} }
loadApiJson(apiData, fileName: string) { loadApiJson(apiData, fileName: string) {
workflowService.beforeLoadNewGraph()
const missingNodeTypes = Object.values(apiData).filter( const missingNodeTypes = Object.values(apiData).filter(
// @ts-expect-error // @ts-expect-error
(n) => !LiteGraph.registered_node_types[n.class_type] (n) => !LiteGraph.registered_node_types[n.class_type]
@@ -2786,40 +2769,38 @@ export class ComfyApp {
app.graph.add(node) app.graph.add(node)
} }
this.changeWorkflow(() => { for (const id of ids) {
for (const id of ids) { const data = apiData[id]
const data = apiData[id] const node = app.graph.getNodeById(id)
const node = app.graph.getNodeById(id) for (const input in data.inputs ?? {}) {
for (const input in data.inputs ?? {}) { const value = data.inputs[input]
const value = data.inputs[input] if (value instanceof Array) {
if (value instanceof Array) { const [fromId, fromSlot] = value
const [fromId, fromSlot] = value const fromNode = app.graph.getNodeById(fromId)
const fromNode = app.graph.getNodeById(fromId) let toSlot = node.inputs?.findIndex((inp) => inp.name === input)
let toSlot = node.inputs?.findIndex((inp) => inp.name === input) if (toSlot == null || toSlot === -1) {
if (toSlot == null || toSlot === -1) { try {
try { // Target has no matching input, most likely a converted widget
// Target has no matching input, most likely a converted widget const widget = node.widgets?.find((w) => w.name === input)
const widget = node.widgets?.find((w) => w.name === input) // @ts-expect-error
// @ts-expect-error if (widget && node.convertWidgetToInput?.(widget)) {
if (widget && node.convertWidgetToInput?.(widget)) { toSlot = node.inputs?.length - 1
toSlot = node.inputs?.length - 1 }
} } catch (error) {}
} catch (error) {} }
} if (toSlot != null || toSlot !== -1) {
if (toSlot != null || toSlot !== -1) { fromNode.connect(fromSlot, node, toSlot)
fromNode.connect(fromSlot, node, toSlot) }
} } else {
} else { const widget = node.widgets?.find((w) => w.name === input)
const widget = node.widgets?.find((w) => w.name === input) if (widget) {
if (widget) { widget.value = value
widget.value = value widget.callback?.(value)
widget.callback?.(value)
}
} }
} }
} }
app.graph.arrange() }
}, fileName) app.graph.arrange()
for (const id of ids) { for (const id of ids) {
const data = apiData[id] const data = apiData[id]
@@ -2854,6 +2835,9 @@ export class ComfyApp {
} }
app.graph.arrange() app.graph.arrange()
// @ts-expect-error zod type issue on ComfyWorkflowJSON. Should be resolved after enabling ts-strict globally.
workflowService.afterLoadNewGraph(fileName, this.serializeGraph())
} }
/** /**

View File

@@ -1,34 +1,62 @@
import type { ComfyApp } from './app' import type { ComfyApp } from './app'
import { api } from './api' import { api } from './api'
import { clone } from './utils'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph' import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
import { ComfyWorkflow } from './workflows' import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import type { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import { LGraphNode } from '@comfyorg/litegraph' import { LGraphNode } from '@comfyorg/litegraph'
import { ExecutedWsMessage } from '@/types/apiTypes' import type { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import type { ExecutedWsMessage } from '@/types/apiTypes'
import { useExecutionStore } from '@/stores/executionStore' import { useExecutionStore } from '@/stores/executionStore'
import _ from 'lodash'
function clone(obj: any) {
try {
if (typeof structuredClone !== 'undefined') {
return structuredClone(obj)
}
} catch (error) {
// structuredClone is stricter than using JSON.parse/stringify so fallback to that
}
return JSON.parse(JSON.stringify(obj))
}
export class ChangeTracker { export class ChangeTracker {
static MAX_HISTORY = 50 static MAX_HISTORY = 50
#app?: ComfyApp /**
* The active state of the workflow.
*/
activeState: ComfyWorkflowJSON
undoQueue: ComfyWorkflowJSON[] = [] undoQueue: ComfyWorkflowJSON[] = []
redoQueue: ComfyWorkflowJSON[] = [] redoQueue: ComfyWorkflowJSON[] = []
activeState: ComfyWorkflowJSON | null = null
isOurLoad: boolean = false
changeCount: number = 0 changeCount: number = 0
ds?: { scale: number; offset: [number, number] } ds?: { scale: number; offset: [number, number] }
nodeOutputs?: Record<string, any> nodeOutputs?: Record<string, any>
static app?: ComfyApp
get app(): ComfyApp { get app(): ComfyApp {
// Global tracker has #app set, while other trackers have workflow bounded return ChangeTracker.app!
return this.#app ?? this.workflow.manager.app
} }
constructor(public workflow: ComfyWorkflow) {} constructor(
/**
* The workflow that this change tracker is tracking
*/
public workflow: ComfyWorkflow,
/**
* The initial state of the workflow
*/
public initialState: ComfyWorkflowJSON
) {
this.activeState = initialState
}
#setApp(app: ComfyApp) { /**
this.#app = app * Save the current state as the initial state.
*/
reset(state?: ComfyWorkflowJSON) {
this.activeState = state ?? this.activeState
this.initialState = this.activeState
} }
store() { store() {
@@ -48,10 +76,22 @@ export class ChangeTracker {
} }
} }
updateModified() {
// Get the workflow from the store as ChangeTracker is raw object, i.e.
// `this.workflow` is not reactive.
const workflow = useWorkflowStore().getWorkflowByPath(this.workflow.path)
if (workflow) {
workflow.isModified = !ChangeTracker.graphEqual(
this.initialState,
this.activeState
)
}
}
checkState() { checkState() {
if (!this.app.graph || this.changeCount) return if (!this.app.graph || this.changeCount) return
// @ts-expect-error zod types issue. Will be fixed after we enable ts-strict
const currentState = this.app.graph.serialize() const currentState = this.app.graph.serialize() as ComfyWorkflowJSON
if (!this.activeState) { if (!this.activeState) {
this.activeState = clone(currentState) this.activeState = clone(currentState)
return return
@@ -63,10 +103,10 @@ export class ChangeTracker {
} }
this.activeState = clone(currentState) this.activeState = clone(currentState)
this.redoQueue.length = 0 this.redoQueue.length = 0
this.workflow.unsaved = true
api.dispatchEvent( api.dispatchEvent(
new CustomEvent('graphChanged', { detail: this.activeState }) new CustomEvent('graphChanged', { detail: this.activeState })
) )
this.updateModified()
} }
} }
@@ -74,12 +114,12 @@ export class ChangeTracker {
const prevState = source.pop() const prevState = source.pop()
if (prevState) { if (prevState) {
target.push(this.activeState!) target.push(this.activeState!)
this.isOurLoad = true
await this.app.loadGraphData(prevState, false, false, this.workflow, { await this.app.loadGraphData(prevState, false, false, this.workflow, {
showMissingModelsDialog: false, showMissingModelsDialog: false,
showMissingNodesDialog: false showMissingNodesDialog: false
}) })
this.activeState = prevState this.activeState = prevState
this.updateModified()
} }
} }
@@ -114,21 +154,11 @@ export class ChangeTracker {
} }
static init(app: ComfyApp) { static init(app: ComfyApp) {
const changeTracker = () => const getCurrentChangeTracker = () =>
app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker useWorkflowStore().activeWorkflow?.changeTracker
globalTracker.#setApp(app) const checkState = () => getCurrentChangeTracker()?.checkState()
const loadGraphData = app.loadGraphData ChangeTracker.app = app
app.loadGraphData = async function (...args) {
const v = await loadGraphData.apply(this, args)
const ct = changeTracker()
if (ct.isOurLoad) {
ct.isOurLoad = false
} else {
ct.checkState()
}
return v
}
let keyIgnored = false let keyIgnored = false
window.addEventListener( window.addEventListener(
@@ -160,12 +190,15 @@ export class ChangeTracker {
e.key === 'Meta' e.key === 'Meta'
if (keyIgnored) return if (keyIgnored) return
const changeTracker = getCurrentChangeTracker()
if (!changeTracker) return
// Check if this is a ctrl+z ctrl+y // Check if this is a ctrl+z ctrl+y
if (await changeTracker().undoRedo(e)) return if (await changeTracker.undoRedo(e)) return
// If our active element is some type of input then handle changes after they're done // If our active element is some type of input then handle changes after they're done
if (ChangeTracker.bindInput(app, bindInputEl)) return if (ChangeTracker.bindInput(app, bindInputEl)) return
changeTracker().checkState() changeTracker.checkState()
}) })
}, },
true true
@@ -174,35 +207,35 @@ export class ChangeTracker {
window.addEventListener('keyup', (e) => { window.addEventListener('keyup', (e) => {
if (keyIgnored) { if (keyIgnored) {
keyIgnored = false keyIgnored = false
changeTracker().checkState() checkState()
} }
}) })
// Handle clicking DOM elements (e.g. widgets) // Handle clicking DOM elements (e.g. widgets)
window.addEventListener('mouseup', () => { window.addEventListener('mouseup', () => {
changeTracker().checkState() checkState()
}) })
// Handle prompt queue event for dynamic widget changes // Handle prompt queue event for dynamic widget changes
api.addEventListener('promptQueued', () => { api.addEventListener('promptQueued', () => {
changeTracker().checkState() checkState()
}) })
api.addEventListener('graphCleared', () => { api.addEventListener('graphCleared', () => {
changeTracker().checkState() checkState()
}) })
// Handle litegraph clicks // Handle litegraph clicks
const processMouseUp = LGraphCanvas.prototype.processMouseUp const processMouseUp = LGraphCanvas.prototype.processMouseUp
LGraphCanvas.prototype.processMouseUp = function (e) { LGraphCanvas.prototype.processMouseUp = function (e) {
const v = processMouseUp.apply(this, [e]) const v = processMouseUp.apply(this, [e])
changeTracker().checkState() checkState()
return v return v
} }
const processMouseDown = LGraphCanvas.prototype.processMouseDown const processMouseDown = LGraphCanvas.prototype.processMouseDown
LGraphCanvas.prototype.processMouseDown = function (e) { LGraphCanvas.prototype.processMouseDown = function (e) {
const v = processMouseDown.apply(this, [e]) const v = processMouseDown.apply(this, [e])
changeTracker().checkState() checkState()
return v return v
} }
@@ -216,7 +249,7 @@ export class ChangeTracker {
) { ) {
const extendedCallback = (v: any) => { const extendedCallback = (v: any) => {
callback(v) callback(v)
changeTracker().checkState() checkState()
} }
return prompt.apply(this, [title, value, extendedCallback, event]) return prompt.apply(this, [title, value, extendedCallback, event])
} }
@@ -225,7 +258,7 @@ export class ChangeTracker {
const close = LiteGraph.ContextMenu.prototype.close const close = LiteGraph.ContextMenu.prototype.close
LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) { LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) {
const v = close.apply(this, [e]) const v = close.apply(this, [e])
changeTracker().checkState() checkState()
return v return v
} }
@@ -234,10 +267,7 @@ export class ChangeTracker {
LiteGraph.LGraph.prototype.onNodeAdded = function (node: LGraphNode) { LiteGraph.LGraph.prototype.onNodeAdded = function (node: LGraphNode) {
const v = onNodeAdded?.apply(this, [node]) const v = onNodeAdded?.apply(this, [node])
if (!app?.configuringGraph) { if (!app?.configuringGraph) {
const ct = changeTracker() checkState()
if (!ct.isOurLoad) {
ct.checkState()
}
} }
return v return v
} }
@@ -246,9 +276,9 @@ export class ChangeTracker {
document.addEventListener('litegraph:canvas', (e: Event) => { document.addEventListener('litegraph:canvas', (e: Event) => {
const detail = (e as CustomEvent).detail const detail = (e as CustomEvent).detail
if (detail.subType === 'before-change') { if (detail.subType === 'before-change') {
changeTracker().beforeChange() getCurrentChangeTracker()?.beforeChange()
} else if (detail.subType === 'after-change') { } else if (detail.subType === 'after-change') {
changeTracker().afterChange() getCurrentChangeTracker()?.afterChange()
} }
}) })
@@ -290,7 +320,7 @@ export class ChangeTracker {
const htmlElement = activeEl as HTMLElement const htmlElement = activeEl as HTMLElement
if (`on${evt}` in htmlElement) { if (`on${evt}` in htmlElement) {
const listener = () => { const listener = () => {
app.workflowManager.activeWorkflow?.changeTracker?.checkState() useWorkflowStore().activeWorkflow?.changeTracker?.checkState?.()
htmlElement.removeEventListener(evt, listener) htmlElement.removeEventListener(evt, listener)
} }
htmlElement.addEventListener(evt, listener) htmlElement.addEventListener(evt, listener)
@@ -300,28 +330,24 @@ export class ChangeTracker {
return false return false
} }
static graphEqual(a: any, b: any, path = '') { static graphEqual(a: ComfyWorkflowJSON, b: ComfyWorkflowJSON) {
if (a === b) return true if (a === b) return true
if (typeof a == 'object' && a && typeof b == 'object' && b) { if (typeof a == 'object' && a && typeof b == 'object' && b) {
const keys = Object.getOwnPropertyNames(a) // Compare nodes ignoring order
if (
if (keys.length != Object.getOwnPropertyNames(b).length) { !_.isEqualWith(a.nodes, b.nodes, (arrA, arrB) => {
if (Array.isArray(arrA) && Array.isArray(arrB)) {
return _.isEqual(new Set(arrA), new Set(arrB))
}
})
) {
return false return false
} }
for (const key of keys) { // Compare other properties normally
let av = a[key] for (const key of ['links', 'groups']) {
let bv = b[key] if (!_.isEqual(a[key], b[key])) {
if (!path && key === 'nodes') {
// Nodes need to be sorted as the order changes when selecting nodes
av = [...av].sort((a, b) => a.id - b.id)
bv = [...bv].sort((a, b) => a.id - b.id)
} else if (path === 'extra.ds') {
// Ignore view changes
continue
}
if (!ChangeTracker.graphEqual(av, bv, path + (path ? '.' : '') + key)) {
return false return false
} }
} }
@@ -332,5 +358,3 @@ export class ChangeTracker {
return false return false
} }
} }
export const globalTracker = new ChangeTracker({} as ComfyWorkflow)

View File

@@ -135,3 +135,16 @@ export const defaultGraph: ComfyWorkflowJSON = {
extra: {}, extra: {},
version: 0.4 version: 0.4
} }
export const defaultGraphJSON = JSON.stringify(defaultGraph)
export const blankGraph: ComfyWorkflowJSON = {
last_node_id: 0,
last_link_id: 0,
nodes: [],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4
}

View File

@@ -3,6 +3,7 @@ import { LiteGraph } from '@comfyorg/litegraph'
import { api } from './api' import { api } from './api'
import { getFromPngFile } from './metadata/png' import { getFromPngFile } from './metadata/png'
import { getFromFlacFile } from './metadata/flac' import { getFromFlacFile } from './metadata/flac'
import { workflowService } from '@/services/workflowService'
// Original functions left in for backwards compatibility // Original functions left in for backwards compatibility
export function getPngMetadata(file: File): Promise<Record<string, string>> { export function getPngMetadata(file: File): Promise<Record<string, string>> {

View File

@@ -52,7 +52,15 @@ export class ComfyAsyncDialog extends ComfyDialog<HTMLDialogElement> {
super.close() super.close()
} }
static async prompt({ title = null, message, actions }) { static async prompt({
title = null,
message,
actions
}: {
title: string | null
message: string
actions: Array<string | { value?: any; text: string }>
}) {
const dialog = new ComfyAsyncDialog(actions) const dialog = new ComfyAsyncDialog(actions)
const content = [$el('span', message)] const content = [$el('span', message)]
if (title) { if (title) {

View File

@@ -28,7 +28,7 @@ function formatDate(text: string, date: Date) {
}) })
} }
export function clone(obj) { export function clone(obj: any) {
try { try {
if (typeof structuredClone !== 'undefined') { if (typeof structuredClone !== 'undefined') {
return structuredClone(obj) return structuredClone(obj)

View File

@@ -1,430 +0,0 @@
// @ts-strict-ignore
import type { ComfyApp } from './app'
import { api } from './api'
import { ChangeTracker } from './changeTracker'
import { ComfyAsyncDialog } from './ui/components/asyncDialog'
import { LGraphCanvas, LGraph } from '@comfyorg/litegraph'
import { appendJsonExt, trimJsonExt } from '@/utils/formatUtil'
import {
useWorkflowStore,
useWorkflowBookmarkStore
} from '@/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
import { markRaw, toRaw } from 'vue'
import { UserDataFullInfo } from '@/types/apiTypes'
import { useToastStore } from '@/stores/toastStore'
import { showPromptDialog } from '@/services/dialogService'
export class ComfyWorkflowManager extends EventTarget {
executionStore: ReturnType<typeof useExecutionStore> | null
workflowStore: ReturnType<typeof useWorkflowStore> | null
workflowBookmarkStore: ReturnType<typeof useWorkflowBookmarkStore> | null
app: ComfyApp
#unsavedCount = 0
get workflowLookup(): Record<string, ComfyWorkflow> {
return this.workflowStore?.workflowLookup ?? {}
}
get workflows(): ComfyWorkflow[] {
return this.workflowStore?.workflows ?? []
}
get openWorkflows(): ComfyWorkflow[] {
return (this.workflowStore?.openWorkflows ?? []) as ComfyWorkflow[]
}
get _activeWorkflow(): ComfyWorkflow | null {
if (!this.app.vueAppReady) return null
return this.workflowStore!.activeWorkflow as ComfyWorkflow | null
}
set _activeWorkflow(workflow: ComfyWorkflow | null) {
if (!this.app.vueAppReady) return
this.workflowStore!.activeWorkflow = workflow ? workflow : null
}
get activeWorkflow(): ComfyWorkflow | null {
return this._activeWorkflow ?? this.openWorkflows[0]
}
get activePromptId() {
return this.executionStore?.activePromptId
}
get activePrompt() {
return this.executionStore?.activePrompt
}
constructor(app: ComfyApp) {
super()
this.app = app
ChangeTracker.init(app)
}
async loadWorkflows() {
try {
const [files, _] = await Promise.all([
api.listUserDataFullInfo('workflows'),
this.workflowBookmarkStore?.loadBookmarks()
])
files.forEach((file: UserDataFullInfo) => {
let workflow = this.workflowLookup[file.path]
if (!workflow) {
workflow = new ComfyWorkflow(this, file.path, file.path.split('/'))
this.workflowLookup[workflow.path] = workflow
}
})
} catch (error) {
useToastStore().addAlert(
'Error loading workflows: ' + (error.message ?? error)
)
}
}
createTemporary(path?: string): ComfyWorkflow {
const workflow = new ComfyWorkflow(
this,
path ??
`Unsaved Workflow${
this.#unsavedCount++ ? ` (${this.#unsavedCount})` : ''
}`
)
this.workflowLookup[workflow.key] = workflow
return workflow
}
/**
* @param {string | ComfyWorkflow | null} workflow
*/
setWorkflow(workflow) {
if (workflow && typeof workflow === 'string') {
const found = this.workflows.find((w) => w.path === workflow)
if (found) {
workflow = found
workflow.unsaved = !workflow
}
}
if (!workflow || typeof workflow === 'string') {
workflow = this.createTemporary(workflow)
}
if (!workflow.isOpen) {
// Opening a new workflow
workflow.track()
}
this._activeWorkflow = workflow
this.dispatchEvent(new CustomEvent('changeWorkflow'))
}
storePrompt({ nodes, id }) {
this.executionStore?.storePrompt({
nodes,
id,
workflow: this.activeWorkflow
})
}
async closeWorkflow(workflow: ComfyWorkflow, warnIfUnsaved: boolean = true) {
if (!workflow.isOpen) {
return true
}
if (workflow.unsaved && warnIfUnsaved) {
const res = await ComfyAsyncDialog.prompt({
title: 'Save Changes?',
message: `Do you want to save changes to "${workflow.path ?? workflow.name}" before closing?`,
actions: ['Yes', 'No', 'Cancel']
})
if (res === 'Yes') {
const active = this.activeWorkflow
if (active !== workflow) {
// We need to switch to the workflow to save it
await workflow.load()
}
if (!(await workflow.save())) {
// Save was canceled, restore the previous workflow
if (active !== workflow) {
await active.load()
}
return
}
} else if (res === 'Cancel') {
return
}
}
workflow.changeTracker = null
workflow.isOpen = false
if (this.openWorkflows.length > 0) {
this._activeWorkflow = this.openWorkflows[0]
await this._activeWorkflow.load()
} else {
// Load default
await this.app.loadGraphData()
}
}
}
export class ComfyWorkflow {
name: string
path: string | null
pathParts: string[] | null
unsaved = false
// Raw
manager: ComfyWorkflowManager
changeTracker: ChangeTracker | null = null
isOpen: boolean = false
get isTemporary() {
return !this.path
}
get isPersisted() {
return !this.isTemporary
}
get key() {
return this.pathParts?.join('/') ?? this.name + '.json'
}
get isBookmarked() {
return this.manager.workflowBookmarkStore?.isBookmarked(this.path) ?? false
}
constructor(
manager: ComfyWorkflowManager,
path: string,
pathParts?: string[]
) {
this.manager = markRaw(manager)
if (pathParts) {
this.updatePath(path, pathParts)
} else {
this.name = path
this.unsaved = true
}
}
private updatePath(path: string, pathParts: string[]) {
this.path = path
if (!pathParts) {
if (!path.includes('\\')) {
pathParts = path.split('/')
} else {
pathParts = path.split('\\')
}
}
this.pathParts = pathParts
this.name = trimJsonExt(pathParts[pathParts.length - 1])
}
async getWorkflowData() {
const resp = await api.getUserData('workflows/' + this.path)
if (resp.status !== 200) {
useToastStore().addAlert(
`Error loading workflow file '${this.path}': ${resp.status} ${resp.statusText}`
)
return
}
return await resp.json()
}
async load() {
if (this.isOpen) {
await this.manager.app.loadGraphData(
this.changeTracker.activeState,
true,
true,
this,
{
showMissingModelsDialog: false,
showMissingNodesDialog: false
}
)
} else {
const data = await this.getWorkflowData()
if (!data) return
await this.manager.app.loadGraphData(data, true, true, this)
}
}
async save(saveAs = false) {
const createNewFile = !this.path || saveAs
return !!(await this._save(
createNewFile ? null : this.path,
/* overwrite */ !createNewFile
))
}
async favorite(value: boolean) {
try {
if (this.isBookmarked === value) return
this.manager.workflowBookmarkStore?.setBookmarked(this.path, value)
this.manager.dispatchEvent(new CustomEvent('favorite', { detail: this }))
} catch (error) {
useToastStore().addAlert(
'Error favoriting workflow ' +
this.path +
'\n' +
(error.message ?? error)
)
}
}
async rename(path: string) {
path = appendJsonExt(path)
let resp = await api.moveUserData(
'workflows/' + this.path,
'workflows/' + path
)
if (resp.status === 409) {
if (
!confirm(
`Workflow '${path}' already exists, do you want to overwrite it?`
)
)
return resp
resp = await api.moveUserData(
'workflows/' + this.path,
'workflows/' + path,
{ overwrite: true }
)
}
if (resp.status !== 200) {
useToastStore().addAlert(
`Error renaming workflow file '${this.path}': ${resp.status} ${resp.statusText}`
)
return
}
if (this.isBookmarked) {
await this.favorite(false)
}
path = (await resp.json()).substring('workflows/'.length)
this.updatePath(path, null)
if (this.isBookmarked) {
await this.favorite(true)
}
this.manager.dispatchEvent(new CustomEvent('rename', { detail: this }))
}
async insert() {
const data = await this.getWorkflowData()
if (!data) return
const old = localStorage.getItem('litegrapheditor_clipboard')
const graph = new LGraph(data)
const canvas = new LGraphCanvas(null, graph, {
skip_events: true,
skip_render: true
})
canvas.selectNodes()
canvas.copyToClipboard()
this.manager.app.canvas.pasteFromClipboard()
localStorage.setItem('litegrapheditor_clipboard', old)
}
async delete() {
// TODO: fix delete of current workflow - should mark workflow as unsaved and when saving use old name by default
if (this.isBookmarked) {
await this.favorite(false)
}
const resp = await api.deleteUserData('workflows/' + this.path)
if (resp.status !== 204) {
useToastStore().addAlert(
`Error removing user data file '${this.path}': ${resp.status} ${resp.statusText}`
)
}
this.unsaved = true
this.path = null
this.pathParts = null
this.manager.workflows.splice(this.manager.workflows.indexOf(this), 1)
this.manager.dispatchEvent(new CustomEvent('delete', { detail: this }))
}
track() {
if (this.changeTracker) {
this.changeTracker.restore()
} else {
this.changeTracker = markRaw(new ChangeTracker(this))
}
this.isOpen = true
}
private async _save(path: string | null, overwrite: boolean) {
if (!path) {
path = await showPromptDialog({
title: 'Save workflow',
message: 'Enter the filename:',
defaultValue: trimJsonExt(this.path) ?? this.name ?? 'workflow'
})
if (!path) return
}
path = appendJsonExt(path)
const workflow = this.manager.app.serializeGraph()
const json = JSON.stringify(workflow, null, 2)
let resp = await api.storeUserData('workflows/' + path, json, {
stringify: false,
throwOnError: false,
overwrite
})
if (resp.status === 409) {
if (
!confirm(
`Workflow '${path}' already exists, do you want to overwrite it?`
)
)
return
resp = await api.storeUserData('workflows/' + path, json, {
stringify: false
})
}
if (resp.status !== 200) {
useToastStore().addAlert(
`Error saving workflow '${this.path}': ${resp.status} ${resp.statusText}`
)
return
}
path = (await resp.json()).substring('workflows/'.length)
if (!this.path) {
// Saved new workflow, patch this instance
const oldKey = this.key
this.updatePath(path, null)
// Update workflowLookup: change the key from the old unsaved path to the new saved path
delete this.manager.workflowStore.workflowLookup[oldKey]
this.manager.workflowStore.workflowLookup[this.key] = this
await this.manager.loadWorkflows()
this.unsaved = false
this.manager.dispatchEvent(new CustomEvent('rename', { detail: this }))
} else if (path !== this.path) {
// Saved as, open the new copy
await this.manager.loadWorkflows()
const workflow = this.manager.workflowLookup[path]
await workflow.load()
} else {
// Normal save
this.unsaved = false
this.manager.dispatchEvent(new CustomEvent('save', { detail: this }))
}
return true
}
}

View File

@@ -1,8 +1,15 @@
import { downloadBlob } from '@/scripts/utils' import { downloadBlob } from '@/scripts/utils'
import { useSettingStore } from '@/stores/settingStore' import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore' import { ComfyAsyncDialog } from '@/scripts/ui/components/asyncDialog'
import { useWorkflowStore, ComfyWorkflow } from '@/stores/workflowStore'
import { showPromptDialog } from './dialogService' import { showPromptDialog } from './dialogService'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { LGraphCanvas } from '@comfyorg/litegraph'
import { toRaw } from 'vue'
import { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
import { appendJsonExt } from '@/utils/formatUtil'
async function getFilename(defaultName: string): Promise<string | null> { async function getFilename(defaultName: string): Promise<string | null> {
if (useSettingStore().get('Comfy.PromptFilename')) { if (useSettingStore().get('Comfy.PromptFilename')) {
@@ -20,14 +27,20 @@ async function getFilename(defaultName: string): Promise<string | null> {
return defaultName return defaultName
} }
// TODO(huchenlei): Auto Error Handling for all methods.
export const workflowService = { export const workflowService = {
/**
* Export the current workflow as a JSON file
* @param filename The filename to save the workflow as
* @param promptProperty The property of the prompt to export
*/
async exportWorkflow( async exportWorkflow(
filename: string, filename: string,
promptProperty: 'workflow' | 'output' promptProperty: 'workflow' | 'output'
): Promise<void> { ): Promise<void> {
const workflow = useWorkflowStore().activeWorkflow const workflow = useWorkflowStore().activeWorkflow
if (workflow?.path) { if (workflow?.path) {
filename = workflow.name filename = workflow.filename
} }
const p = await app.graphToPrompt() const p = await app.graphToPrompt()
const json = JSON.stringify(p[promptProperty], null, 2) const json = JSON.stringify(p[promptProperty], null, 2)
@@ -35,5 +48,229 @@ export const workflowService = {
const file = await getFilename(filename) const file = await getFilename(filename)
if (!file) return if (!file) return
downloadBlob(file, blob) downloadBlob(file, blob)
},
/**
* Save a workflow as a new file
* @param workflow The workflow to save
*/
async saveWorkflowAs(workflow: ComfyWorkflow) {
const newFilename = await showPromptDialog({
title: 'Save workflow',
message: 'Enter the filename:',
defaultValue: workflow.filename
})
if (!newFilename) return
if (workflow.isTemporary) {
await this.renameWorkflow(workflow, newFilename)
await useWorkflowStore().saveWorkflow(workflow)
} else {
const tempWorkflow = useWorkflowStore().createTemporary(
(workflow.directory + '/' + appendJsonExt(newFilename)).substring(
'workflows/'.length
),
workflow.activeState as ComfyWorkflowJSON
)
await this.openWorkflow(tempWorkflow)
await useWorkflowStore().saveWorkflow(tempWorkflow)
}
},
/**
* Save a workflow
* @param workflow The workflow to save
*/
async saveWorkflow(workflow: ComfyWorkflow) {
if (workflow.isTemporary) {
await this.saveWorkflowAs(workflow)
} else {
await useWorkflowStore().saveWorkflow(workflow)
}
},
/**
* Load the default workflow
*/
async loadDefaultWorkflow() {
await app.loadGraphData(defaultGraph)
},
/**
* Load a blank workflow
*/
async loadBlankWorkflow() {
await app.loadGraphData(blankGraph)
},
async openWorkflow(workflow: ComfyWorkflow) {
if (useWorkflowStore().isActive(workflow)) return
const loadFromRemote = !workflow.isLoaded
if (loadFromRemote) {
await workflow.load()
}
await app.loadGraphData(
toRaw(workflow.activeState) as ComfyWorkflowJSON,
/* clean=*/ true,
/* restore_view=*/ true,
workflow,
{
showMissingModelsDialog: loadFromRemote,
showMissingNodesDialog: loadFromRemote
}
)
},
/**
* Close a workflow with confirmation if there are unsaved changes
* @param workflow The workflow to close
* @returns true if the workflow was closed, false if the user cancelled
*/
async closeWorkflow(
workflow: ComfyWorkflow,
options: { warnIfUnsaved: boolean } = { warnIfUnsaved: true }
): Promise<void> {
if (!workflow.isLoaded) {
return
}
if (workflow.isModified && options.warnIfUnsaved) {
const res = (await ComfyAsyncDialog.prompt({
title: 'Save Changes?',
message: `Do you want to save changes to "${workflow.path}" before closing?`,
actions: ['Yes', 'No', 'Cancel']
})) as 'Yes' | 'No' | 'Cancel'
if (res === 'Yes') {
await this.saveWorkflow(workflow)
} else if (res === 'Cancel') {
return
}
}
const workflowStore = useWorkflowStore()
// If this is the last workflow, create a new default temporary workflow
if (workflowStore.openWorkflows.length === 1) {
await this.loadDefaultWorkflow()
}
// If this is the active workflow, load the next workflow
if (workflowStore.isActive(workflow)) {
await this.loadNextOpenedWorkflow()
}
await workflowStore.closeWorkflow(workflow)
},
async renameWorkflow(workflow: ComfyWorkflow, newName: string) {
await useWorkflowStore().renameWorkflow(workflow, newName)
},
async deleteWorkflow(workflow: ComfyWorkflow) {
const workflowStore = useWorkflowStore()
if (workflowStore.isOpen(workflow)) {
await this.closeWorkflow(workflow)
}
await workflowStore.deleteWorkflow(workflow)
},
/**
* This method is called before loading a new graph.
* There are 3 major functions that loads a new graph to the graph editor:
* 1. loadGraphData
* 2. loadApiJson
* 3. importA1111
*
* This function is used to save the current workflow states before loading
* a new graph.
*/
beforeLoadNewGraph() {
// Use workspaceStore here as it is patched in jest tests.
const workflowStore = useWorkspaceStore().workflow
const activeWorkflow = workflowStore.activeWorkflow
if (activeWorkflow) {
activeWorkflow.changeTracker.store()
}
},
/**
* Set the active workflow after the new graph is loaded.
*
* The call relationship is
* workflowService.openWorkflow -> app.loadGraphData -> workflowService.afterLoadNewGraph
* app.loadApiJson -> workflowService.afterLoadNewGraph
* app.importA1111 -> workflowService.afterLoadNewGraph
*
* @param value The value to set as the active workflow.
* @param workflowData The initial workflow data loaded to the graph editor.
*/
async afterLoadNewGraph(
value: string | ComfyWorkflow | null,
workflowData: ComfyWorkflowJSON
) {
// Use workspaceStore here as it is patched in jest tests.
const workflowStore = useWorkspaceStore().workflow
if (typeof value === 'string') {
const workflow = workflowStore.getWorkflowByPath(
'workflows/' + appendJsonExt(value)
)
if (workflow?.isPersisted) {
const loadedWorkflow = await workflowStore.openWorkflow(workflow)
loadedWorkflow.changeTracker.restore()
loadedWorkflow.changeTracker.reset(workflowData)
return
}
}
if (value === null || typeof value === 'string') {
const path = value as string | null
const tempWorkflow = workflowStore.createTemporary(
path ? appendJsonExt(path) : undefined,
workflowData
)
await workflowStore.openWorkflow(tempWorkflow)
return
}
// value is a ComfyWorkflow.
const loadedWorkflow = await workflowStore.openWorkflow(value)
loadedWorkflow.changeTracker.reset(workflowData)
loadedWorkflow.changeTracker.restore()
},
/**
* Insert the given workflow into the current graph editor.
*/
async insertWorkflow(workflow: ComfyWorkflow) {
const loadedWorkflow = await workflow.load()
const data = loadedWorkflow.initialState
const old = localStorage.getItem('litegrapheditor_clipboard')
// @ts-expect-error: zod issue. Should be fixed after enable ts-strict globally
const graph = new LGraph(data)
const canvasElement = document.createElement('canvas')
const canvas = new LGraphCanvas(canvasElement, graph, {
skip_events: true,
skip_render: true
})
canvas.selectNodes()
canvas.copyToClipboard()
app.canvas.pasteFromClipboard()
if (old !== null) {
localStorage.setItem('litegrapheditor_clipboard', old)
}
},
async loadNextOpenedWorkflow() {
const nextWorkflow = useWorkflowStore().openedWorkflowIndexShift(1)
if (nextWorkflow) {
await this.openWorkflow(nextWorkflow)
}
},
async loadPreviousOpenedWorkflow() {
const previousWorkflow = useWorkflowStore().openedWorkflowIndexShift(-1)
if (previousWorkflow) {
await this.openWorkflow(previousWorkflow)
}
} }
} }

View File

@@ -2,7 +2,6 @@ import { app } from '@/scripts/app'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { globalTracker } from '@/scripts/changeTracker'
import { useSettingStore } from '@/stores/settingStore' import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore' import { useToastStore } from '@/stores/toastStore'
import { import {
@@ -15,7 +14,7 @@ import { ComfyExtension } from '@/types/comfy'
import { LGraphGroup } from '@comfyorg/litegraph' import { LGraphGroup } from '@comfyorg/litegraph'
import { useTitleEditorStore } from './graphStore' import { useTitleEditorStore } from './graphStore'
import { useErrorHandling } from '@/hooks/errorHooks' import { useErrorHandling } from '@/hooks/errorHooks'
import { useWorkflowStore } from './workflowStore' import { ComfyWorkflow, useWorkflowStore } from './workflowStore'
import { type KeybindingImpl, useKeybindingStore } from './keybindingStore' import { type KeybindingImpl, useKeybindingStore } from './keybindingStore'
import { useBottomPanelStore } from './workspace/bottomPanelStore' import { useBottomPanelStore } from './workspace/bottomPanelStore'
import { LGraphNode } from '@comfyorg/litegraph' import { LGraphNode } from '@comfyorg/litegraph'
@@ -76,8 +75,7 @@ export class ComfyCommandImpl implements ComfyCommand {
} }
} }
const getTracker = () => const getTracker = () => useWorkflowStore()?.activeWorkflow?.changeTracker
app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker
const getSelectedNodes = (): LGraphNode[] => { const getSelectedNodes = (): LGraphNode[] => {
const selectedNodes = app.canvas.selected_nodes const selectedNodes = app.canvas.selected_nodes
@@ -120,12 +118,7 @@ export const useCommandStore = defineStore('command', () => {
icon: 'pi pi-plus', icon: 'pi pi-plus',
label: 'New Blank Workflow', label: 'New Blank Workflow',
menubarLabel: 'New', menubarLabel: 'New',
function: () => { function: () => workflowService.loadBlankWorkflow()
app.workflowManager.setWorkflow(null)
app.clean()
app.graph.clear()
app.workflowManager.activeWorkflow?.track()
}
}, },
{ {
id: 'Comfy.OpenWorkflow', id: 'Comfy.OpenWorkflow',
@@ -140,17 +133,18 @@ export const useCommandStore = defineStore('command', () => {
id: 'Comfy.LoadDefaultWorkflow', id: 'Comfy.LoadDefaultWorkflow',
icon: 'pi pi-code', icon: 'pi pi-code',
label: 'Load Default Workflow', label: 'Load Default Workflow',
function: async () => { function: () => workflowService.loadDefaultWorkflow()
await app.loadGraphData()
}
}, },
{ {
id: 'Comfy.SaveWorkflow', id: 'Comfy.SaveWorkflow',
icon: 'pi pi-save', icon: 'pi pi-save',
label: 'Save Workflow', label: 'Save Workflow',
menubarLabel: 'Save', menubarLabel: 'Save',
function: () => { function: async () => {
app.workflowManager.activeWorkflow?.save() const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
if (!workflow) return
await workflowService.saveWorkflow(workflow)
} }
}, },
{ {
@@ -158,8 +152,11 @@ export const useCommandStore = defineStore('command', () => {
icon: 'pi pi-save', icon: 'pi pi-save',
label: 'Save Workflow As', label: 'Save Workflow As',
menubarLabel: 'Save As', menubarLabel: 'Save As',
function: () => { function: async () => {
app.workflowManager.activeWorkflow?.save(true) const workflow = useWorkflowStore().activeWorkflow as ComfyWorkflow
if (!workflow) return
await workflowService.saveWorkflowAs(workflow)
} }
}, },
{ {
@@ -185,7 +182,7 @@ export const useCommandStore = defineStore('command', () => {
icon: 'pi pi-undo', icon: 'pi pi-undo',
label: 'Undo', label: 'Undo',
function: async () => { function: async () => {
await getTracker().undo() await getTracker()?.undo?.()
} }
}, },
{ {
@@ -193,7 +190,7 @@ export const useCommandStore = defineStore('command', () => {
icon: 'pi pi-refresh', icon: 'pi pi-refresh',
label: 'Redo', label: 'Redo',
function: async () => { function: async () => {
await getTracker().redo() await getTracker()?.redo?.()
} }
}, },
{ {
@@ -387,7 +384,7 @@ export const useCommandStore = defineStore('command', () => {
label: 'Next Opened Workflow', label: 'Next Opened Workflow',
versionAdded: '1.3.9', versionAdded: '1.3.9',
function: () => { function: () => {
useWorkflowStore().loadNextOpenedWorkflow() workflowService.loadNextOpenedWorkflow()
} }
}, },
{ {
@@ -396,7 +393,7 @@ export const useCommandStore = defineStore('command', () => {
label: 'Previous Opened Workflow', label: 'Previous Opened Workflow',
versionAdded: '1.3.9', versionAdded: '1.3.9',
function: () => { function: () => {
useWorkflowStore().loadPreviousOpenedWorkflow() workflowService.loadPreviousOpenedWorkflow()
} }
}, },
{ {

View File

@@ -1,7 +1,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { ComfyWorkflow } from '@/scripts/workflows' import { ComfyWorkflow } from './workflowStore'
import type { ComfyNode, ComfyWorkflowJSON } from '@/types/comfyWorkflow' import type { ComfyNode, ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import type { import type {
ExecutedWsMessage, ExecutedWsMessage,

View File

@@ -4,6 +4,8 @@ import { buildTree } from '@/utils/treeUtil'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { TreeExplorerNode } from '@/types/treeExplorerTypes' import { TreeExplorerNode } from '@/types/treeExplorerTypes'
import { UserDataFullInfo } from '@/types/apiTypes' import { UserDataFullInfo } from '@/types/apiTypes'
import { syncEntities } from '@/utils/syncUtil'
import { getPathDetails } from '@/utils/formatUtil'
/** /**
* Represents a file in the user's data directory. * Represents a file in the user's data directory.
@@ -37,25 +39,60 @@ export class UserFile {
*/ */
public lastModified: number, public lastModified: number,
/** /**
* File size in bytes. * File size in bytes. -1 for temporary files.
*/ */
public size: number public size: number
) { ) {
this.directory = path.split('/').slice(0, -1).join('/') const details = getPathDetails(path)
this.fullFilename = path.split('/').pop() ?? path this.path = path
this.filename = this.fullFilename.split('.').slice(0, -1).join('.') this.directory = details.directory
this.suffix = this.fullFilename.split('.').pop() ?? null this.fullFilename = details.fullFilename
this.filename = details.filename
this.suffix = details.suffix
} }
get isOpen() { updatePath(newPath: string) {
return !!this.content const details = getPathDetails(newPath)
this.path = newPath
this.directory = details.directory
this.fullFilename = details.fullFilename
this.filename = details.filename
this.suffix = details.suffix
}
static createTemporary(path: string): UserFile {
return new UserFile(path, Date.now(), -1)
}
get isTemporary() {
return this.size === 0
}
get isPersisted() {
return !this.isTemporary
}
get key(): string {
return this.path
}
get isLoaded() {
return this.content !== null
} }
get isModified() { get isModified() {
return this.content !== this.originalContent return this.content !== this.originalContent
} }
async load(): Promise<UserFile> { /**
* Loads the file content from the remote storage.
*/
async load({
force = false
}: { force?: boolean } = {}): Promise<LoadedUserFile> {
if (this.isTemporary || (!force && this.isLoaded))
return this as LoadedUserFile
this.isLoading = true this.isLoading = true
const resp = await api.getUserData(this.path) const resp = await api.getUserData(this.path)
if (resp.status !== 200) { if (resp.status !== 200) {
@@ -66,19 +103,34 @@ export class UserFile {
this.content = await resp.text() this.content = await resp.text()
this.originalContent = this.content this.originalContent = this.content
this.isLoading = false this.isLoading = false
return this return this as LoadedUserFile
}
/**
* Unloads the file content from memory
*/
unload(): void {
this.content = null
this.originalContent = null
this.isLoading = false
} }
async saveAs(newPath: string): Promise<UserFile> { async saveAs(newPath: string): Promise<UserFile> {
const tempFile = new TempUserFile(newPath, this.content ?? undefined) const tempFile = this.isTemporary ? this : UserFile.createTemporary(newPath)
tempFile.content = this.content
await tempFile.save() await tempFile.save()
return tempFile return tempFile
} }
async save(): Promise<UserFile> { /**
if (!this.isModified) return this * Saves the file to the remote storage.
* @param force Whether to force the save even if the file is not modified.
*/
async save({ force = false }: { force?: boolean } = {}): Promise<UserFile> {
if (this.isPersisted && !this.isModified && !force) return this
const resp = await api.storeUserData(this.path, this.content, { const resp = await api.storeUserData(this.path, this.content, {
overwrite: this.isPersisted,
throwOnError: true, throwOnError: true,
full_info: true full_info: true
}) })
@@ -95,6 +147,8 @@ export class UserFile {
} }
async delete(): Promise<void> { async delete(): Promise<void> {
if (this.isTemporary) return
const resp = await api.deleteUserData(this.path) const resp = await api.deleteUserData(this.path)
if (resp.status !== 204) { if (resp.status !== 204) {
throw new Error( throw new Error(
@@ -104,13 +158,18 @@ export class UserFile {
} }
async rename(newPath: string): Promise<UserFile> { async rename(newPath: string): Promise<UserFile> {
if (this.isTemporary) {
this.updatePath(newPath)
return this
}
const resp = await api.moveUserData(this.path, newPath) const resp = await api.moveUserData(this.path, newPath)
if (resp.status !== 200) { if (resp.status !== 200) {
throw new Error( throw new Error(
`Failed to rename file '${this.path}': ${resp.status} ${resp.statusText}` `Failed to rename file '${this.path}': ${resp.status} ${resp.statusText}`
) )
} }
this.path = newPath this.updatePath(newPath)
// Note: Backend supports full_info=true feature after // Note: Backend supports full_info=true feature after
// https://github.com/comfyanonymous/ComfyUI/pull/5446 // https://github.com/comfyanonymous/ComfyUI/pull/5446
const updatedFile = (await resp.json()) as string | UserDataFullInfo const updatedFile = (await resp.json()) as string | UserDataFullInfo
@@ -122,56 +181,21 @@ export class UserFile {
} }
} }
export class TempUserFile extends UserFile { export interface LoadedUserFile extends UserFile {
constructor(path: string, content: string = '') { isLoaded: true
// Initialize with current timestamp and 0 size since it's temporary originalContent: string
super(path, Date.now(), 0) content: string
this.content = content
this.originalContent = content
}
// Override methods that interact with backend
async load(): Promise<TempUserFile> {
// No need to load as it's a temporary file
return this
}
async save(): Promise<TempUserFile> {
// First save should create the actual file on the backend
const resp = await api.storeUserData(this.path, this.content, {
throwOnError: true,
full_info: true
})
const updatedFile = (await resp.json()) as string | UserDataFullInfo
if (typeof updatedFile === 'object') {
this.lastModified = updatedFile.modified
this.size = updatedFile.size
}
this.originalContent = this.content
return this
}
async delete(): Promise<void> {
// No need to delete from backend as it doesn't exist there
}
async rename(newPath: string): Promise<TempUserFile> {
// Just update the path locally since it's not on backend yet
this.path = newPath
return this
}
} }
export const useUserFileStore = defineStore('userFile', () => { export const useUserFileStore = defineStore('userFile', () => {
const userFilesByPath = ref(new Map<string, UserFile>()) const userFilesByPath = ref<Record<string, UserFile>>({})
const userFiles = computed(() => Array.from(userFilesByPath.value.values())) const userFiles = computed(() => Object.values(userFilesByPath.value))
const modifiedFiles = computed(() => const modifiedFiles = computed(() =>
userFiles.value.filter((file: UserFile) => file.isModified) userFiles.value.filter((file: UserFile) => file.isModified)
) )
const openedFiles = computed(() => const loadedFiles = computed(() =>
userFiles.value.filter((file: UserFile) => file.isOpen) userFiles.value.filter((file: UserFile) => file.isLoaded)
) )
const fileTree = computed<TreeExplorerNode<UserFile>>( const fileTree = computed<TreeExplorerNode<UserFile>>(
@@ -186,39 +210,22 @@ export const useUserFileStore = defineStore('userFile', () => {
* @param dir The directory to sync. * @param dir The directory to sync.
*/ */
const syncFiles = async (dir: string = '') => { const syncFiles = async (dir: string = '') => {
const files = await api.listUserDataFullInfo(dir) await syncEntities(
dir,
for (const file of files) { userFilesByPath.value,
const existingFile = userFilesByPath.value.get(file.path) (file) => new UserFile(file.path, file.modified, file.size),
(existingFile, file) => {
if (!existingFile) {
// New file, add it to the map
userFilesByPath.value.set(
file.path,
new UserFile(file.path, file.modified, file.size)
)
} else if (existingFile.lastModified !== file.modified) {
// File has been modified, update its properties
existingFile.lastModified = file.modified existingFile.lastModified = file.modified
existingFile.size = file.size existingFile.size = file.size
existingFile.originalContent = null existingFile.unload()
existingFile.content = null
existingFile.isLoading = false
} }
} )
// Remove files that no longer exist
for (const [path, _] of userFilesByPath.value) {
if (!files.some((file) => file.path === path)) {
userFilesByPath.value.delete(path)
}
}
} }
return { return {
userFiles, userFiles,
modifiedFiles, modifiedFiles,
openedFiles, loadedFiles,
fileTree, fileTree,
syncFiles syncFiles
} }

View File

@@ -1,24 +1,292 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, markRaw, ref } from 'vue'
import { ComfyWorkflow } from '@/scripts/workflows'
import { buildTree } from '@/utils/treeUtil' import { buildTree } from '@/utils/treeUtil'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { UserFile } from './userFileStore'
import { ChangeTracker } from '@/scripts/changeTracker'
import { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import { appendJsonExt, getPathDetails } from '@/utils/formatUtil'
import { defaultGraphJSON } from '@/scripts/defaultGraph'
import { syncEntities } from '@/utils/syncUtil'
export class ComfyWorkflow extends UserFile {
/**
* The change tracker for the workflow. Non-reactive raw object.
*/
changeTracker: ChangeTracker | null = null
/**
* Whether the workflow has been modified comparing to the initial state.
*/
private _isModified: boolean = false
/**
* @param options The path, modified, and size of the workflow.
* Note: path is the full path, including the 'workflows/' prefix.
*/
constructor(options: { path: string; modified: number; size: number }) {
super(options.path, options.modified, options.size)
}
get key() {
return this.path.substring('workflows/'.length)
}
get activeState(): ComfyWorkflowJSON | null {
return this.changeTracker?.activeState ?? null
}
get initialState(): ComfyWorkflowJSON | null {
return this.changeTracker?.initialState ?? null
}
get isLoaded(): boolean {
return this.changeTracker !== null
}
get isModified(): boolean {
return this._isModified
}
set isModified(value: boolean) {
this._isModified = value
}
/**
* Load the workflow content from remote storage. Directly returns the loaded
* workflow if the content is already loaded.
*
* @param force Whether to force loading the content even if it is already loaded.
* @returns this
*/
async load({
force = false
}: { force?: boolean } = {}): Promise<LoadedComfyWorkflow> {
await super.load({ force })
if (!force && this.isLoaded) return this as LoadedComfyWorkflow
if (!this.originalContent) {
throw new Error('[ASSERT] Workflow content should be loaded')
}
// Note: originalContent is populated by super.load()
console.debug('load and start tracking of workflow', this.path)
this.changeTracker = markRaw(
new ChangeTracker(
this,
/* initialState= */ JSON.parse(this.originalContent)
)
)
return this as LoadedComfyWorkflow
}
unload(): void {
console.debug('unload workflow', this.path)
this.changeTracker = null
super.unload()
}
async save() {
this.content = JSON.stringify(this.activeState)
// Force save to ensure the content is updated in remote storage incase
// the isModified state is screwed by changeTracker.
const ret = await super.save({ force: true })
this.changeTracker?.reset()
this.isModified = false
return ret
}
/**
* Save the workflow as a new file.
* @param path The path to save the workflow to. Note: with 'workflows/' prefix.
* @returns this
*/
async saveAs(path: string) {
this.content = JSON.stringify(this.activeState)
return await super.saveAs(path)
}
async rename(newName: string) {
const newPath = this.directory + '/' + appendJsonExt(newName)
await super.rename(newPath)
return this
}
}
export interface LoadedComfyWorkflow extends ComfyWorkflow {
isLoaded: true
originalContent: string
content: string
changeTracker: ChangeTracker
initialState: ComfyWorkflowJSON
activeState: ComfyWorkflowJSON
}
export const useWorkflowStore = defineStore('workflow', () => { export const useWorkflowStore = defineStore('workflow', () => {
const activeWorkflow = ref<ComfyWorkflow | null>(null) /**
* Detach the workflow from the store. lightweight helper function.
* @param workflow The workflow to detach.
* @returns The index of the workflow in the openWorkflowPaths array, or -1 if the workflow was not open.
*/
const detachWorkflow = (workflow: ComfyWorkflow) => {
delete workflowLookup.value[workflow.path]
const index = openWorkflowPaths.value.indexOf(workflow.path)
if (index !== -1) {
openWorkflowPaths.value = openWorkflowPaths.value.filter(
(path) => path !== workflow.path
)
}
return index
}
/**
* Attach the workflow to the store. lightweight helper function.
* @param workflow The workflow to attach.
* @param openIndex The index to open the workflow at.
*/
const attachWorkflow = (workflow: ComfyWorkflow, openIndex: number = -1) => {
workflowLookup.value[workflow.path] = workflow
if (openIndex !== -1) {
openWorkflowPaths.value.splice(openIndex, 0, workflow.path)
}
}
/**
* The active workflow currently being edited.
*/
const activeWorkflow = ref<LoadedComfyWorkflow | null>(null)
const isActive = (workflow: ComfyWorkflow) =>
activeWorkflow.value?.path === workflow.path
/**
* All workflows.
*/
const workflowLookup = ref<Record<string, ComfyWorkflow>>({}) const workflowLookup = ref<Record<string, ComfyWorkflow>>({})
const workflows = computed(() => Object.values(workflowLookup.value)) const workflows = computed<ComfyWorkflow[]>(() =>
const persistedWorkflows = computed(() => Object.values(workflowLookup.value)
workflows.value.filter((workflow) => workflow.isPersisted)
) )
const getWorkflowByPath = (path: string): ComfyWorkflow | null =>
workflowLookup.value[path] ?? null
/**
* The paths of the open workflows. It is setup as a ref to allow user
* to reorder the workflows opened.
*/
const openWorkflowPaths = ref<string[]>([])
const openWorkflowPathSet = computed(() => new Set(openWorkflowPaths.value))
const openWorkflows = computed(() => const openWorkflows = computed(() =>
workflows.value.filter((workflow) => workflow.isOpen) openWorkflowPaths.value.map((path) => workflowLookup.value[path])
) )
const isOpen = (workflow: ComfyWorkflow) =>
openWorkflowPathSet.value.has(workflow.path)
/**
* Set the workflow as the active workflow.
* @param workflow The workflow to open.
*/
const openWorkflow = async (
workflow: ComfyWorkflow
): Promise<LoadedComfyWorkflow> => {
if (isActive(workflow)) return workflow as LoadedComfyWorkflow
if (!openWorkflowPaths.value.includes(workflow.path)) {
openWorkflowPaths.value.push(workflow.path)
}
const loadedWorkflow = await workflow.load()
activeWorkflow.value = loadedWorkflow
console.debug('[workflowStore] open workflow', workflow.path)
return loadedWorkflow
}
const getUnconflictedPath = (basePath: string): string => {
const { directory, filename, suffix } = getPathDetails(basePath)
let counter = 2
let newPath = basePath
while (workflowLookup.value[newPath]) {
newPath = `${directory}/${filename} (${counter}).${suffix}`
counter++
}
return newPath
}
const createTemporary = (path?: string, workflowData?: ComfyWorkflowJSON) => {
const fullPath = getUnconflictedPath(
'workflows/' + (path ?? 'Unsaved Workflow.json')
)
const workflow = new ComfyWorkflow({
path: fullPath,
modified: Date.now(),
size: 0
})
workflow.originalContent = workflow.content = workflowData
? JSON.stringify(workflowData)
: defaultGraphJSON
workflowLookup.value[workflow.path] = workflow
return workflow
}
const closeWorkflow = async (workflow: ComfyWorkflow) => {
openWorkflowPaths.value = openWorkflowPaths.value.filter(
(path) => path !== workflow.path
)
if (workflow.isTemporary) {
delete workflowLookup.value[workflow.path]
} else {
workflow.unload()
}
console.debug('[workflowStore] close workflow', workflow.path)
}
/**
* Get the workflow at the given index shift from the active workflow.
* @param shift The shift to the next workflow. Positive for next, negative for previous.
* @returns The next workflow or null if the shift is out of bounds.
*/
const openedWorkflowIndexShift = (shift: number): ComfyWorkflow | null => {
const index = openWorkflowPaths.value.indexOf(
activeWorkflow.value?.path ?? ''
)
if (index !== -1) {
const length = openWorkflows.value.length
const nextIndex = (index + shift + length) % length
const nextWorkflow = openWorkflows.value[nextIndex]
return nextWorkflow ?? null
}
return null
}
const persistedWorkflows = computed(() =>
Array.from(workflows.value).filter((workflow) => workflow.isPersisted)
)
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) => {
existingWorkflow.lastModified = file.modified
existingWorkflow.size = file.size
existingWorkflow.unload()
},
/* exclude */ (workflow) => workflow.isTemporary
)
}
const bookmarkStore = useWorkflowBookmarkStore()
const bookmarkedWorkflows = computed(() => const bookmarkedWorkflows = computed(() =>
workflows.value.filter((workflow) => workflow.isBookmarked) workflows.value.filter((workflow) =>
bookmarkStore.isBookmarked(workflow.path)
)
) )
const modifiedWorkflows = computed(() => const modifiedWorkflows = computed(() =>
workflows.value.filter((workflow) => workflow.unsaved) workflows.value.filter((workflow) => workflow.isModified)
) )
const buildWorkflowTree = (workflows: ComfyWorkflow[]) => { const buildWorkflowTree = (workflows: ComfyWorkflow[]) => {
@@ -31,50 +299,78 @@ export const useWorkflowStore = defineStore('workflow', () => {
) )
// Bookmarked workflows tree is flat. // Bookmarked workflows tree is flat.
const bookmarkedWorkflowsTree = computed(() => const bookmarkedWorkflowsTree = computed(() =>
buildTree(bookmarkedWorkflows.value, (workflow: ComfyWorkflow) => [ buildTree(bookmarkedWorkflows.value, (workflow) => [workflow.key])
workflow.path ?? 'temporary_workflow'
])
) )
// Open workflows tree is flat. // Open workflows tree is flat.
const openWorkflowsTree = computed(() => const openWorkflowsTree = computed(() =>
buildTree(openWorkflows.value, (workflow: ComfyWorkflow) => [workflow.key]) buildTree(openWorkflows.value, (workflow) => [workflow.key])
) )
const loadOpenedWorkflowIndexShift = async (shift: number) => { const renameWorkflow = async (workflow: ComfyWorkflow, newName: string) => {
const index = openWorkflows.value.indexOf( // Capture all needed values upfront
activeWorkflow.value as ComfyWorkflow const oldPath = workflow.path
) const newPath = workflow.directory + '/' + appendJsonExt(newName)
if (index !== -1) { const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
const length = openWorkflows.value.length
const nextIndex = (index + shift + length) % length const openIndex = detachWorkflow(workflow)
const nextWorkflow = openWorkflows.value[nextIndex] // Perform the actual rename operation first
if (nextWorkflow) { try {
await nextWorkflow.load() await workflow.rename(newName)
} } finally {
attachWorkflow(workflow, openIndex)
}
// Update bookmarks
if (wasBookmarked) {
bookmarkStore.setBookmarked(oldPath, false)
bookmarkStore.setBookmarked(newPath, true)
} }
} }
const loadNextOpenedWorkflow = async () => { const deleteWorkflow = async (workflow: ComfyWorkflow) => {
await loadOpenedWorkflowIndexShift(1) await workflow.delete()
if (bookmarkStore.isBookmarked(workflow.path)) {
bookmarkStore.setBookmarked(workflow.path, false)
}
delete workflowLookup.value[workflow.path]
} }
const loadPreviousOpenedWorkflow = async () => { /**
await loadOpenedWorkflowIndexShift(-1) * Save a workflow.
* @param workflow The workflow to save.
*/
const saveWorkflow = async (workflow: ComfyWorkflow) => {
// Detach the workflow and re-attach to force refresh the tree objects.
const openIndex = detachWorkflow(workflow)
try {
await workflow.save()
} finally {
attachWorkflow(workflow, openIndex)
}
} }
return { return {
activeWorkflow, activeWorkflow,
workflows, isActive,
openWorkflows, openWorkflows,
openWorkflowsTree,
openedWorkflowIndexShift,
openWorkflow,
isOpen,
closeWorkflow,
createTemporary,
renameWorkflow,
deleteWorkflow,
saveWorkflow,
workflows,
bookmarkedWorkflows, bookmarkedWorkflows,
modifiedWorkflows, modifiedWorkflows,
workflowLookup, getWorkflowByPath,
workflowsTree, workflowsTree,
bookmarkedWorkflowsTree, bookmarkedWorkflowsTree,
openWorkflowsTree,
buildWorkflowTree, buildWorkflowTree,
loadNextOpenedWorkflow, syncWorkflows
loadPreviousOpenedWorkflow
} }
}) })
@@ -98,6 +394,7 @@ export const useWorkflowBookmarkStore = defineStore('workflowBookmark', () => {
} }
const setBookmarked = (path: string, value: boolean) => { const setBookmarked = (path: string, value: boolean) => {
if (bookmarks.value.has(path) === value) return
if (value) { if (value) {
bookmarks.value.add(path) bookmarks.value.add(path)
} else { } else {

View File

@@ -6,6 +6,7 @@ import { useQueueSettingsStore } from './queueStore'
import { useCommandStore } from './commandStore' import { useCommandStore } from './commandStore'
import { useSidebarTabStore } from './workspace/sidebarTabStore' import { useSidebarTabStore } from './workspace/sidebarTabStore'
import { useSettingStore } from './settingStore' import { useSettingStore } from './settingStore'
import { useWorkflowStore } from './workflowStore'
export const useWorkspaceStore = defineStore('workspace', () => { export const useWorkspaceStore = defineStore('workspace', () => {
const spinner = ref(false) const spinner = ref(false)
@@ -26,6 +27,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
get: useSettingStore().get, get: useSettingStore().get,
set: useSettingStore().set set: useSettingStore().set
})) }))
const workflow = computed(() => useWorkflowStore())
/** /**
* Registers a sidebar tab. * Registers a sidebar tab.
@@ -66,6 +68,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
command, command,
sidebarTab, sidebarTab,
setting, setting,
workflow,
registerSidebarTab, registerSidebarTab,
unregisterSidebarTab, unregisterSidebarTab,

View File

@@ -72,3 +72,58 @@ export function formatSize(value?: number) {
const i = Math.floor(Math.log(bytes) / Math.log(k)) const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
} }
/**
* Finds the common directory prefix between two paths
* @example
* findCommonPrefix('a/b/c', 'a/b/d') // returns 'a/b'
* findCommonPrefix('x/y/z', 'a/b/c') // returns ''
* findCommonPrefix('a/b/c', 'a/b/c/d') // returns 'a/b/c'
*/
export function findCommonPrefix(path1: string, path2: string): string {
const parts1 = path1.split('/')
const parts2 = path2.split('/')
const commonParts: string[] = []
for (let i = 0; i < Math.min(parts1.length, parts2.length); i++) {
if (parts1[i] === parts2[i]) {
commonParts.push(parts1[i])
} else {
break
}
}
return commonParts.join('/')
}
/**
* Returns various filename components.
* Example:
* - fullFilename: 'file.txt'
* - filename: 'file'
* - suffix: 'txt'
*/
export function getFilenameDetails(fullFilename: string) {
if (fullFilename.includes('.')) {
return {
filename: fullFilename.split('.').slice(0, -1).join('.'),
suffix: fullFilename.split('.').pop() ?? null
}
} else {
return { filename: fullFilename, suffix: null }
}
}
/**
* Returns various path components.
* Example:
* - path: 'dir/file.txt'
* - directory: 'dir'
* - fullFilename: 'file.txt'
* - filename: 'file'
* - suffix: 'txt'
*/
export function getPathDetails(path: string) {
const directory = path.split('/').slice(0, -1).join('/')
const fullFilename = path.split('/').pop() ?? path
return { directory, fullFilename, ...getFilenameDetails(fullFilename) }
}

45
src/utils/syncUtil.ts Normal file
View File

@@ -0,0 +1,45 @@
import { api } from '@/scripts/api'
/**
* Sync entities from the API to the entityByPath map.
* @param dir The directory to sync from
* @param entityByPath The map to sync to
* @param createEntity A function to create an entity from a file
* @param updateEntity A function to update an entity from a file
* @param exclude A function to exclude an entity
*/
export async function syncEntities<T>(
dir: string,
entityByPath: Record<string, T>,
createEntity: (file: any) => T,
updateEntity: (entity: T, file: any) => void,
exclude: (file: T) => boolean = () => false
) {
const files = (await api.listUserDataFullInfo(dir)).map((file) => ({
...file,
path: dir ? `${dir}/${file.path}` : file.path
}))
for (const file of files) {
const existingEntity = entityByPath[file.path]
if (!existingEntity) {
// New entity, add it to the map
entityByPath[file.path] = createEntity(file)
} else if (exclude(existingEntity)) {
// Entity has been excluded, skip it
continue
} else {
// Entity has been modified, update its properties
updateEntity(existingEntity, file)
}
}
// Remove entities that no longer exist
for (const [path, entity] of Object.entries(entityByPath)) {
if (exclude(entity)) continue
if (!files.some((file) => file.path === path)) {
delete entityByPath[path]
}
}
}

View File

@@ -24,10 +24,6 @@ import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { i18n } from '@/i18n' import { i18n } from '@/i18n'
import { useExecutionStore } from '@/stores/executionStore' import { useExecutionStore } from '@/stores/executionStore'
import {
useWorkflowStore,
useWorkflowBookmarkStore
} from '@/stores/workflowStore'
import GlobalToast from '@/components/toast/GlobalToast.vue' import GlobalToast from '@/components/toast/GlobalToast.vue'
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue' import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
import BrowserTabTitle from '@/components/BrowserTabTitle.vue' import BrowserTabTitle from '@/components/BrowserTabTitle.vue'
@@ -128,12 +124,6 @@ const onReconnected = () => {
}) })
} }
const workflowStore = useWorkflowStore()
const workflowBookmarkStore = useWorkflowBookmarkStore()
app.workflowManager.executionStore = executionStore
app.workflowManager.workflowStore = workflowStore
app.workflowManager.workflowBookmarkStore = workflowBookmarkStore
onMounted(() => { onMounted(() => {
api.addEventListener('status', onStatus) api.addEventListener('status', onStatus)
api.addEventListener('reconnecting', onReconnecting) api.addEventListener('reconnecting', onReconnecting)

View File

@@ -1,9 +1,5 @@
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import { import { UserFile, useUserFileStore } from '@/stores/userFileStore'
TempUserFile,
UserFile,
useUserFileStore
} from '@/stores/userFileStore'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
// Mock the api // Mock the api
@@ -28,14 +24,14 @@ describe('useUserFileStore', () => {
it('should initialize with empty files', () => { it('should initialize with empty files', () => {
expect(store.userFiles).toHaveLength(0) expect(store.userFiles).toHaveLength(0)
expect(store.modifiedFiles).toHaveLength(0) expect(store.modifiedFiles).toHaveLength(0)
expect(store.openedFiles).toHaveLength(0) expect(store.loadedFiles).toHaveLength(0)
}) })
describe('syncFiles', () => { describe('syncFiles', () => {
it('should add new files', async () => { it('should add new files', async () => {
const mockFiles = [ const mockFiles = [
{ path: 'dir/file1.txt', modified: 123, size: 100 }, { path: 'file1.txt', modified: 123, size: 100 },
{ path: 'dir/file2.txt', modified: 456, size: 200 } { path: 'file2.txt', modified: 456, size: 200 }
] ]
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(mockFiles) ;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(mockFiles)
@@ -47,11 +43,11 @@ describe('useUserFileStore', () => {
}) })
it('should update existing files', async () => { it('should update existing files', async () => {
const initialFile = { path: 'dir/file1.txt', modified: 123, size: 100 } const initialFile = { path: 'file1.txt', modified: 123, size: 100 }
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([initialFile]) ;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([initialFile])
await store.syncFiles('dir') await store.syncFiles('dir')
const updatedFile = { path: 'dir/file1.txt', modified: 456, size: 200 } const updatedFile = { path: 'file1.txt', modified: 456, size: 200 }
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([updatedFile]) ;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue([updatedFile])
await store.syncFiles('dir') await store.syncFiles('dir')
@@ -62,13 +58,13 @@ describe('useUserFileStore', () => {
it('should remove non-existent files', async () => { it('should remove non-existent files', async () => {
const initialFiles = [ const initialFiles = [
{ path: 'dir/file1.txt', modified: 123, size: 100 }, { path: 'file1.txt', modified: 123, size: 100 },
{ path: 'dir/file2.txt', modified: 456, size: 200 } { path: 'file2.txt', modified: 456, size: 200 }
] ]
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(initialFiles) ;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(initialFiles)
await store.syncFiles('dir') await store.syncFiles('dir')
const updatedFiles = [{ path: 'dir/file1.txt', modified: 123, size: 100 }] const updatedFiles = [{ path: 'file1.txt', modified: 123, size: 100 }]
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(updatedFiles) ;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(updatedFiles)
await store.syncFiles('dir') await store.syncFiles('dir')
@@ -102,6 +98,7 @@ describe('useUserFileStore', () => {
expect(file.content).toBe('file content') expect(file.content).toBe('file content')
expect(file.originalContent).toBe('file content') expect(file.originalContent).toBe('file content')
expect(file.isLoading).toBe(false) expect(file.isLoading).toBe(false)
expect(file.isLoaded).toBe(true)
}) })
it('should throw error on failed load', async () => { it('should throw error on failed load', async () => {
@@ -132,7 +129,7 @@ describe('useUserFileStore', () => {
expect(api.storeUserData).toHaveBeenCalledWith( expect(api.storeUserData).toHaveBeenCalledWith(
'file1.txt', 'file1.txt',
'modified content', 'modified content',
{ throwOnError: true, full_info: true } { throwOnError: true, full_info: true, overwrite: true }
) )
expect(file.lastModified).toBe(456) expect(file.lastModified).toBe(456)
expect(file.size).toBe(200) expect(file.size).toBe(200)
@@ -194,9 +191,9 @@ describe('useUserFileStore', () => {
expect(api.storeUserData).toHaveBeenCalledWith( expect(api.storeUserData).toHaveBeenCalledWith(
'newfile.txt', 'newfile.txt',
'file content', 'file content',
{ throwOnError: true, full_info: true } { throwOnError: true, full_info: true, overwrite: true }
) )
expect(newFile).toBeInstanceOf(TempUserFile) expect(newFile).toBeInstanceOf(UserFile)
expect(newFile.path).toBe('newfile.txt') expect(newFile.path).toBe('newfile.txt')
expect(newFile.lastModified).toBe(456) expect(newFile.lastModified).toBe(456)
expect(newFile.size).toBe(200) expect(newFile.size).toBe(200)

View File

@@ -0,0 +1,358 @@
import { setActivePinia, createPinia } from 'pinia'
import {
LoadedComfyWorkflow,
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/stores/workflowStore'
import { api } from '@/scripts/api'
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
// Add mock for api at the top of the file
jest.mock('@/scripts/api', () => ({
api: {
getUserData: jest.fn(),
storeUserData: jest.fn(),
listUserDataFullInfo: jest.fn()
}
}))
describe('useWorkflowStore', () => {
let store: ReturnType<typeof useWorkflowStore>
let bookmarkStore: ReturnType<typeof useWorkflowBookmarkStore>
const syncRemoteWorkflows = async (filenames: string[]) => {
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(
filenames.map((filename) => ({
path: filename,
modified: new Date().toISOString(),
size: 1 // size !== 0 for remote workflows
}))
)
return await store.syncWorkflows()
}
beforeEach(() => {
setActivePinia(createPinia())
store = useWorkflowStore()
bookmarkStore = useWorkflowBookmarkStore()
jest.clearAllMocks()
// Add default mock implementations
;(api.getUserData as jest.Mock).mockResolvedValue({
status: 200,
json: () => Promise.resolve({ favorites: [] })
})
;(api.storeUserData as jest.Mock).mockResolvedValue({
status: 200
})
})
describe('syncWorkflows', () => {
it('should sync workflows', async () => {
await syncRemoteWorkflows(['a.json', 'b.json'])
expect(store.workflows.length).toBe(2)
})
it('should exclude temporary workflows', async () => {
const workflow = store.createTemporary('c.json')
await syncRemoteWorkflows(['a.json', 'b.json'])
expect(store.workflows.length).toBe(3)
expect(store.workflows.filter((w) => w.isTemporary)).toEqual([workflow])
})
})
describe('createTemporary', () => {
it('should create a temporary workflow with a unique path', () => {
const workflow = store.createTemporary()
expect(workflow.path).toBe('workflows/Unsaved Workflow.json')
const workflow2 = store.createTemporary()
expect(workflow2.path).toBe('workflows/Unsaved Workflow (2).json')
})
it('should create a temporary workflow not clashing with persisted workflows', async () => {
await syncRemoteWorkflows(['a.json'])
const workflow = store.createTemporary('a.json')
expect(workflow.path).toBe('workflows/a (2).json')
})
})
describe('openWorkflow', () => {
it('should load and open a temporary workflow', async () => {
// Create a test workflow
const workflow = store.createTemporary('test.json')
const mockWorkflowData = { nodes: [], links: [] }
// Mock the load response
jest.spyOn(workflow, 'load').mockImplementation(async () => {
workflow.changeTracker = { activeState: mockWorkflowData } as any
return workflow as LoadedComfyWorkflow
})
// Open the workflow
await store.openWorkflow(workflow)
// Verify the workflow is now active
expect(store.activeWorkflow?.path).toBe(workflow.path)
// Verify the workflow is in the open workflows list
expect(store.isOpen(workflow)).toBe(true)
})
it('should not reload an already active workflow', async () => {
const workflow = await store.createTemporary('test.json').load()
jest.spyOn(workflow, 'load')
// Set as active workflow
store.activeWorkflow = workflow
await store.openWorkflow(workflow)
// Verify load was not called
expect(workflow.load).not.toHaveBeenCalled()
})
it('should load a remote workflow', async () => {
await syncRemoteWorkflows(['a.json'])
const workflow = store.getWorkflowByPath('workflows/a.json')!
expect(workflow).not.toBeNull()
expect(workflow.path).toBe('workflows/a.json')
expect(workflow.isLoaded).toBe(false)
expect(workflow.isTemporary).toBe(false)
;(api.getUserData as jest.Mock).mockResolvedValue({
status: 200,
text: () => Promise.resolve(defaultGraphJSON)
})
await workflow.load()
expect(workflow.isLoaded).toBe(true)
expect(workflow.content).toEqual(defaultGraphJSON)
expect(workflow.originalContent).toEqual(defaultGraphJSON)
expect(workflow.activeState).toEqual(defaultGraph)
expect(workflow.initialState).toEqual(defaultGraph)
expect(workflow.isModified).toBe(false)
})
it('should load and open a remote workflow', async () => {
await syncRemoteWorkflows(['a.json', 'b.json'])
const workflow = store.getWorkflowByPath('workflows/a.json')!
expect(workflow).not.toBeNull()
expect(workflow.path).toBe('workflows/a.json')
expect(workflow.isLoaded).toBe(false)
;(api.getUserData as jest.Mock).mockResolvedValue({
status: 200,
text: () => Promise.resolve(defaultGraphJSON)
})
const loadedWorkflow = await store.openWorkflow(workflow)
expect(loadedWorkflow).toBe(workflow)
expect(loadedWorkflow.path).toBe('workflows/a.json')
expect(store.activeWorkflow?.path).toBe('workflows/a.json')
expect(store.isOpen(loadedWorkflow)).toBe(true)
expect(loadedWorkflow.content).toEqual(defaultGraphJSON)
expect(loadedWorkflow.originalContent).toEqual(defaultGraphJSON)
expect(loadedWorkflow.isLoaded).toBe(true)
expect(loadedWorkflow.activeState).toEqual(defaultGraph)
expect(loadedWorkflow.initialState).toEqual(defaultGraph)
expect(loadedWorkflow.isModified).toBe(false)
})
})
describe('renameWorkflow', () => {
it('should rename workflow and update bookmarks', async () => {
const workflow = store.createTemporary('dir/test.json')
// Set up initial bookmark
expect(workflow.path).toBe('workflows/dir/test.json')
bookmarkStore.setBookmarked(workflow.path, true)
expect(bookmarkStore.isBookmarked(workflow.path)).toBe(true)
// Mock super.rename
jest
.spyOn(Object.getPrototypeOf(workflow), 'rename')
.mockImplementation(async function (this: any, newPath: string) {
this.path = newPath
return this
} as any)
// Perform rename
const newName = 'renamed.json'
const newPath = 'workflows/dir/renamed.json'
await store.renameWorkflow(workflow, newName)
// Check that bookmark was transferred
expect(bookmarkStore.isBookmarked(newPath)).toBe(true)
expect(bookmarkStore.isBookmarked('workflows/dir/test.json')).toBe(false)
})
it('should rename workflow without affecting bookmarks if not bookmarked', async () => {
const workflow = store.createTemporary('test.json')
// Verify not bookmarked initially
expect(bookmarkStore.isBookmarked(workflow.path)).toBe(false)
// Mock super.rename
jest
.spyOn(Object.getPrototypeOf(workflow), 'rename')
.mockImplementation(async function (this: any, newPath: string) {
this.path = newPath
return this
} as any)
// Perform rename
const newName = 'renamed'
await workflow.rename(newName)
// Check that no bookmarks were affected
expect(bookmarkStore.isBookmarked(workflow.path)).toBe(false)
expect(bookmarkStore.isBookmarked('test.json')).toBe(false)
})
})
describe('closeWorkflow', () => {
it('should close a workflow', async () => {
const workflow = store.createTemporary('test.json')
await store.openWorkflow(workflow)
expect(store.isOpen(workflow)).toBe(true)
expect(store.getWorkflowByPath(workflow.path)).not.toBeNull()
await store.closeWorkflow(workflow)
expect(store.isOpen(workflow)).toBe(false)
expect(store.getWorkflowByPath(workflow.path)).toBeNull()
})
})
describe('deleteWorkflow', () => {
it('should close and delete an open workflow', async () => {
const workflow = store.createTemporary('test.json')
// Mock the necessary methods
jest.spyOn(workflow, 'delete').mockResolvedValue()
// Open the workflow first
await store.openWorkflow(workflow)
// Delete the workflow
await store.deleteWorkflow(workflow)
// Verify workflow was closed and deleted
expect(workflow.delete).toHaveBeenCalled()
})
it('should remove bookmark when deleting a bookmarked workflow', async () => {
const workflow = store.createTemporary('test.json')
// Mock delete method
jest.spyOn(workflow, 'delete').mockResolvedValue()
// Bookmark the workflow
bookmarkStore.setBookmarked(workflow.path, true)
expect(bookmarkStore.isBookmarked(workflow.path)).toBe(true)
// Delete the workflow
await store.deleteWorkflow(workflow)
// Verify bookmark was removed
expect(bookmarkStore.isBookmarked(workflow.path)).toBe(false)
})
})
describe('save', () => {
it('should save workflow content and reset modification state', async () => {
await syncRemoteWorkflows(['test.json'])
const workflow = store.getWorkflowByPath('workflows/test.json')!
// Mock the activeState
const mockState = { nodes: [] }
workflow.changeTracker = {
activeState: mockState,
reset: jest.fn()
} as any
;(api.storeUserData as jest.Mock).mockResolvedValue({
status: 200,
json: () =>
Promise.resolve({
path: 'workflows/test.json',
modified: Date.now(),
size: 2
})
})
// Save the workflow
await workflow.save()
// Verify the content was updated
expect(workflow.content).toBe(JSON.stringify(mockState))
expect(workflow.changeTracker!.reset).toHaveBeenCalled()
expect(workflow.isModified).toBe(false)
})
it('should save workflow even if isModified is screwed by changeTracker', async () => {
await syncRemoteWorkflows(['test.json'])
const workflow = store.getWorkflowByPath('workflows/test.json')!
workflow.isModified = false
// Mock the activeState
const mockState = { nodes: [] }
workflow.changeTracker = {
activeState: mockState,
reset: jest.fn()
} as any
;(api.storeUserData as jest.Mock).mockResolvedValue({
status: 200,
json: () =>
Promise.resolve({
path: 'workflows/test.json',
modified: Date.now(),
size: 2
})
})
// Save the workflow
await workflow.save()
// Verify storeUserData was called
expect(api.storeUserData).toHaveBeenCalled()
// Verify the content was updated
expect(workflow.changeTracker!.reset).toHaveBeenCalled()
expect(workflow.isModified).toBe(false)
})
})
describe('saveAs', () => {
it('should save workflow to new path and reset modification state', async () => {
await syncRemoteWorkflows(['test.json'])
const workflow = store.getWorkflowByPath('workflows/test.json')!
workflow.isModified = true
// Mock the activeState
const mockState = { nodes: [] }
workflow.changeTracker = {
activeState: mockState,
reset: jest.fn()
} as any
;(api.storeUserData as jest.Mock).mockResolvedValue({
status: 200,
json: () =>
Promise.resolve({
path: 'workflows/new-test.json',
modified: Date.now(),
size: 2
})
})
// Save the workflow with new path
const newWorkflow = await workflow.saveAs('workflows/new-test.json')
// Verify the content was updated
expect(workflow.path).toBe('workflows/test.json')
expect(workflow.isModified).toBe(true)
expect(newWorkflow.path).toBe('workflows/new-test.json')
expect(newWorkflow.content).toBe(JSON.stringify(mockState))
expect(newWorkflow.isModified).toBe(false)
})
})
})

View File

@@ -51,7 +51,14 @@ module.exports = async function () {
shiftDown: false, shiftDown: false,
spinner: false, spinner: false,
focusMode: false, focusMode: false,
toggleFocusMode: jest.fn() toggleFocusMode: jest.fn(),
workflow: {
activeWorkflow: null,
syncWorkflows: jest.fn(),
getWorkflowByPath: jest.fn(),
createTemporary: jest.fn(),
openWorkflow: jest.fn()
}
}) })
} }
}) })