mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
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:
@@ -52,7 +52,9 @@ test.describe('Actionbar', () => {
|
||||
(n) => n.type === 'EmptyLatentImage'
|
||||
)
|
||||
node.widgets[0].value = value
|
||||
window['app'].workflowManager.activeWorkflow.changeTracker.checkState()
|
||||
window[
|
||||
'app'
|
||||
].extensionManager.workflow.activeWorkflow.changeTracker.checkState()
|
||||
}, value)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,24 +9,22 @@ test.describe('Browser tab title', () => {
|
||||
|
||||
test('Can display workflow name', async ({ comfyPage }) => {
|
||||
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
|
||||
// Release blocker for v1.3.0
|
||||
// Failing on CI
|
||||
// Cannot reproduce locally
|
||||
test.skip('Can display workflow name with unsaved changes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
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')
|
||||
|
||||
const textBox = comfyPage.widgetTextBox
|
||||
@@ -36,7 +34,7 @@ test.describe('Browser tab title', () => {
|
||||
|
||||
// Delete the saved workflow for cleanup.
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
window['app'].workflowManager.activeWorkflow.delete()
|
||||
return window['app'].extensionManager.workflow.activeWorkflow.delete()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,14 @@ export class Topbar {
|
||||
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[]) {
|
||||
if (path.length < 2) {
|
||||
throw new Error('Path is too short')
|
||||
|
||||
@@ -392,7 +392,7 @@ test.describe('Menu', () => {
|
||||
await tab.newBlankWorkflowButton.click()
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'*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 ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -441,7 +454,7 @@ test.describe('Menu', () => {
|
||||
await closeButton.click()
|
||||
expect(
|
||||
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])
|
||||
await comfyPage.menu.topbar.closeWorkflowTab(workflowName)
|
||||
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([
|
||||
'Unsaved Workflow (2)'
|
||||
'Unsaved Workflow'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,10 +26,10 @@ const betaMenuEnabled = computed(
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const isUnsavedText = computed(() =>
|
||||
workflowStore.activeWorkflow?.unsaved ? ' *' : ''
|
||||
workflowStore.activeWorkflow?.isModified ? ' *' : ''
|
||||
)
|
||||
const workflowNameText = computed(() => {
|
||||
const workflowName = workflowStore.activeWorkflow?.name
|
||||
const workflowName = workflowStore.activeWorkflow?.filename
|
||||
return workflowName
|
||||
? isUnsavedText.value + workflowName + TITLE_SUFFIX
|
||||
: DEFAULT_TITLE
|
||||
|
||||
@@ -55,6 +55,7 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import { usePragmaticDroppable } from '@/hooks/dndHooks'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { setStorageValue } from '@/scripts/utils'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
|
||||
const emit = defineEmits(['ready'])
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
@@ -147,7 +148,7 @@ const workflowStore = useWorkflowStore()
|
||||
watchEffect(() => {
|
||||
if (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
|
||||
|
||||
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)
|
||||
canvasStore.canvas = comfyApp.canvas
|
||||
workspaceStore.spinner = false
|
||||
|
||||
@@ -43,20 +43,22 @@
|
||||
<TextDivider text="Open" type="dashed" class="ml-2" />
|
||||
<TreeExplorer
|
||||
:roots="renderTreeNode(workflowStore.openWorkflowsTree).children"
|
||||
v-model:selectionKeys="selectionKeys"
|
||||
:selectionKeys="selectionKeys"
|
||||
>
|
||||
<template #node="{ node }">
|
||||
<TreeExplorerTreeNode :node="node">
|
||||
<template #before-label="{ node }">
|
||||
<span v-if="node.data.unsaved">*</span>
|
||||
<span v-if="node.data.isModified">*</span>
|
||||
</template>
|
||||
<template #actions="{ node }">
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
severity="secondary"
|
||||
:severity="
|
||||
workspaceStore.shiftDown ? 'danger' : 'secondary'
|
||||
"
|
||||
size="small"
|
||||
@click.stop="app.workflowManager.closeWorkflow(node.data)"
|
||||
@click.stop="handleCloseWorkflow(node.data)"
|
||||
/>
|
||||
</template>
|
||||
</TreeExplorerTreeNode>
|
||||
@@ -112,16 +114,17 @@ import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
import Button from 'primevue/button'
|
||||
import TextDivider from '@/components/common/TextDivider.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import type { TreeNode } from 'primevue/treenode'
|
||||
import { TreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { ComfyWorkflow } from '@/scripts/workflows'
|
||||
import { ComfyWorkflow } from '@/stores/workflowStore'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTreeExpansion } from '@/hooks/treeHooks'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { workflowService } from '@/services/workflowService'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const workflowTabsPosition = computed(() =>
|
||||
@@ -144,7 +147,7 @@ const handleSearch = (query: string) => {
|
||||
}
|
||||
const lowerQuery = query.toLocaleLowerCase()
|
||||
filteredWorkflows.value = workflowStore.workflows.filter((workflow) => {
|
||||
return workflow.name.toLocaleLowerCase().includes(lowerQuery)
|
||||
return workflow.path.toLocaleLowerCase().includes(lowerQuery)
|
||||
})
|
||||
nextTick(() => {
|
||||
expandNode(filteredRoot.value)
|
||||
@@ -152,12 +155,20 @@ const handleSearch = (query: string) => {
|
||||
}
|
||||
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const { t } = useI18n()
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
|
||||
|
||||
const handleCloseWorkflow = (workflow?: ComfyWorkflow) => {
|
||||
if (workflow) {
|
||||
workflowService.closeWorkflow(workflow, {
|
||||
warnIfUnsaved: !workspaceStore.shiftDown
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const renderTreeNode = (node: TreeNode): TreeExplorerNode<ComfyWorkflow> => {
|
||||
const children = node.children?.map(renderTreeNode)
|
||||
|
||||
@@ -168,8 +179,7 @@ const renderTreeNode = (node: TreeNode): TreeExplorerNode<ComfyWorkflow> => {
|
||||
e: MouseEvent
|
||||
) => {
|
||||
if (node.leaf) {
|
||||
const workflow = node.data
|
||||
workflow.load()
|
||||
workflowService.openWorkflow(workflow)
|
||||
} else {
|
||||
toggleNodeOnEvent(e, node)
|
||||
}
|
||||
@@ -181,14 +191,12 @@ const renderTreeNode = (node: TreeNode): TreeExplorerNode<ComfyWorkflow> => {
|
||||
node: TreeExplorerNode<ComfyWorkflow>,
|
||||
newName: string
|
||||
) => {
|
||||
const workflow = node.data
|
||||
await workflow.rename(newName)
|
||||
await workflowService.renameWorkflow(workflow, newName)
|
||||
},
|
||||
handleDelete: workflow.isTemporary
|
||||
? undefined
|
||||
: (node: TreeExplorerNode<ComfyWorkflow>) => {
|
||||
const workflow = node.data
|
||||
workflow.delete()
|
||||
: () => {
|
||||
workflowService.deleteWorkflow(workflow)
|
||||
},
|
||||
contextMenuItems: (node: TreeExplorerNode<ComfyWorkflow>) => {
|
||||
return [
|
||||
@@ -197,7 +205,7 @@ const renderTreeNode = (node: TreeNode): TreeExplorerNode<ComfyWorkflow> => {
|
||||
icon: 'pi pi-file-export',
|
||||
command: () => {
|
||||
const workflow = node.data
|
||||
workflow.insert()
|
||||
workflowService.insertWorkflow(workflow)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -216,6 +224,6 @@ const renderTreeNode = (node: TreeNode): TreeExplorerNode<ComfyWorkflow> => {
|
||||
}
|
||||
|
||||
const selectionKeys = computed(() => ({
|
||||
[`root/${workflowStore.activeWorkflow?.name}.json`]: true
|
||||
[`root/${workflowStore.activeWorkflow?.key}`]: true
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
<script setup lang="ts">
|
||||
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
|
||||
import Button from 'primevue/button'
|
||||
import { useWorkflowBookmarkStore } from '@/stores/workflowStore'
|
||||
import { ComfyWorkflow } from '@/scripts/workflows'
|
||||
import { ComfyWorkflow, useWorkflowBookmarkStore } from '@/stores/workflowStore'
|
||||
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { computed } from 'vue'
|
||||
|
||||
|
||||
@@ -11,12 +11,16 @@
|
||||
<template #option="{ option }">
|
||||
<span
|
||||
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>
|
||||
<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
|
||||
class="close-button p-0 w-auto"
|
||||
icon="pi pi-times"
|
||||
@@ -31,35 +35,31 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { app } from '@/scripts/app'
|
||||
import { ComfyWorkflow } from '@/scripts/workflows'
|
||||
import { ComfyWorkflow } from '@/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { workflowService } from '@/services/workflowService'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
interface WorkflowOption {
|
||||
label: string
|
||||
tooltip: string
|
||||
value: string
|
||||
unsaved: boolean
|
||||
workflow: ComfyWorkflow
|
||||
}
|
||||
|
||||
const workflowToOption = (workflow: ComfyWorkflow): WorkflowOption => ({
|
||||
label: workflow.name,
|
||||
tooltip: workflow.path,
|
||||
value: workflow.key,
|
||||
unsaved: workflow.unsaved
|
||||
value: workflow.path,
|
||||
workflow
|
||||
})
|
||||
|
||||
const optionToWorkflow = (option: WorkflowOption): ComfyWorkflow =>
|
||||
workflowStore.workflowLookup[option.value]
|
||||
|
||||
const options = computed<WorkflowOption[]>(() =>
|
||||
workflowStore.openWorkflows.map(workflowToOption)
|
||||
)
|
||||
@@ -78,13 +78,13 @@ const onWorkflowChange = (option: WorkflowOption) => {
|
||||
return
|
||||
}
|
||||
|
||||
const workflow = optionToWorkflow(option)
|
||||
workflow.load()
|
||||
workflowService.openWorkflow(option.workflow)
|
||||
}
|
||||
|
||||
const onCloseWorkflow = (option: WorkflowOption) => {
|
||||
const workflow = optionToWorkflow(option)
|
||||
app.workflowManager.closeWorkflow(workflow)
|
||||
workflowService.closeWorkflow(option.workflow, {
|
||||
warnIfUnsaved: !workspaceStore.shiftDown
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import { ComfyNodeDef, StatusWsMessageStatus } from '@/types/apiTypes'
|
||||
import { adjustColor, ColorAdjustOptions } from '@/utils/colorUtil'
|
||||
import { ComfyAppMenu } from './ui/menu/index'
|
||||
import { getStorageValue } from './utils'
|
||||
import { ComfyWorkflowManager, ComfyWorkflow } from './workflows'
|
||||
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
import {
|
||||
LGraphCanvas,
|
||||
LGraph,
|
||||
@@ -58,6 +58,7 @@ import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { shallowReactive } from 'vue'
|
||||
import { type IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import { workflowService } from '@/services/workflowService'
|
||||
|
||||
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
|
||||
|
||||
@@ -141,7 +142,6 @@ export class ComfyApp {
|
||||
multiUserServer: boolean
|
||||
ctx: CanvasRenderingContext2D
|
||||
widgets: Record<string, ComfyWidgetConstructor>
|
||||
workflowManager: ComfyWorkflowManager
|
||||
bodyTop: HTMLElement
|
||||
bodyLeft: HTMLElement
|
||||
bodyRight: HTMLElement
|
||||
@@ -170,7 +170,6 @@ export class ComfyApp {
|
||||
this.vueAppReady = false
|
||||
this.ui = new ComfyUI(this)
|
||||
this.logging = new ComfyLogging(this)
|
||||
this.workflowManager = new ComfyWorkflowManager(this)
|
||||
this.bodyTop = $el('div.comfyui-body-top', { parent: document.body })
|
||||
this.bodyLeft = $el('div.comfyui-body-left', { parent: document.body })
|
||||
this.bodyRight = $el('div.comfyui-body-right', { parent: document.body })
|
||||
@@ -1789,7 +1788,7 @@ export class ComfyApp {
|
||||
this.resizeCanvas()
|
||||
|
||||
await Promise.all([
|
||||
this.workflowManager.loadWorkflows(),
|
||||
useWorkspaceStore().workflow.syncWorkflows(),
|
||||
this.ui.settings.load()
|
||||
])
|
||||
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(
|
||||
graphData?: ComfyWorkflowJSON,
|
||||
clean: boolean = true,
|
||||
@@ -2198,12 +2182,6 @@ export class ComfyApp {
|
||||
graphData = structuredClone(graphData)
|
||||
}
|
||||
|
||||
try {
|
||||
this.workflowManager.setWorkflow(workflow)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
if (useSettingStore().get('Comfy.Validation.Workflows')) {
|
||||
// TODO: Show validation error in a dialog.
|
||||
const validatedGraphData = await validateComfyWorkflow(
|
||||
@@ -2217,6 +2195,8 @@ export class ComfyApp {
|
||||
graphData = validatedGraphData ?? graphData
|
||||
}
|
||||
|
||||
workflowService.beforeLoadNewGraph()
|
||||
|
||||
const missingNodeTypes: MissingNodeType[] = []
|
||||
const missingModels = []
|
||||
await this.#invokeExtensionsAsync(
|
||||
@@ -2270,12 +2250,6 @@ export class ComfyApp {
|
||||
this.canvas.ds.offset = graphData.extra.ds.offset
|
||||
this.canvas.ds.scale = graphData.extra.ds.scale
|
||||
}
|
||||
|
||||
try {
|
||||
this.workflowManager.activeWorkflow?.track()
|
||||
} catch (error) {
|
||||
// TODO: Do we want silently fail here?
|
||||
}
|
||||
} catch (error) {
|
||||
let errorHint = []
|
||||
// Try extracting filename to see if it was caused by an extension script
|
||||
@@ -2384,6 +2358,8 @@ export class ComfyApp {
|
||||
this.#showMissingModelsError(missingModels, paths)
|
||||
}
|
||||
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(() => {
|
||||
this.graph.setDirtyCanvas(true, true)
|
||||
})
|
||||
@@ -2602,9 +2578,11 @@ export class ComfyApp {
|
||||
this.canvas.draw(true, true)
|
||||
} else {
|
||||
try {
|
||||
this.workflowManager.storePrompt({
|
||||
useExecutionStore().storePrompt({
|
||||
id: res.prompt_id,
|
||||
nodes: Object.keys(p.output)
|
||||
nodes: Object.keys(p.output),
|
||||
workflow: useWorkspaceStore().workflow
|
||||
.activeWorkflow as ComfyWorkflow
|
||||
})
|
||||
} catch (error) {}
|
||||
}
|
||||
@@ -2678,9 +2656,12 @@ export class ComfyApp {
|
||||
} else if (pngInfo?.prompt) {
|
||||
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName)
|
||||
} else if (pngInfo?.parameters) {
|
||||
this.changeWorkflow(() => {
|
||||
importA1111(this.graph, pngInfo.parameters)
|
||||
}, fileName)
|
||||
// Note: Not putting this in `importA1111` as it is mostly not used
|
||||
// by external callers, and `importA1111` has no access to `app`.
|
||||
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 {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
@@ -2764,6 +2745,8 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
loadApiJson(apiData, fileName: string) {
|
||||
workflowService.beforeLoadNewGraph()
|
||||
|
||||
const missingNodeTypes = Object.values(apiData).filter(
|
||||
// @ts-expect-error
|
||||
(n) => !LiteGraph.registered_node_types[n.class_type]
|
||||
@@ -2786,40 +2769,38 @@ export class ComfyApp {
|
||||
app.graph.add(node)
|
||||
}
|
||||
|
||||
this.changeWorkflow(() => {
|
||||
for (const id of ids) {
|
||||
const data = apiData[id]
|
||||
const node = app.graph.getNodeById(id)
|
||||
for (const input in data.inputs ?? {}) {
|
||||
const value = data.inputs[input]
|
||||
if (value instanceof Array) {
|
||||
const [fromId, fromSlot] = value
|
||||
const fromNode = app.graph.getNodeById(fromId)
|
||||
let toSlot = node.inputs?.findIndex((inp) => inp.name === input)
|
||||
if (toSlot == null || toSlot === -1) {
|
||||
try {
|
||||
// Target has no matching input, most likely a converted widget
|
||||
const widget = node.widgets?.find((w) => w.name === input)
|
||||
// @ts-expect-error
|
||||
if (widget && node.convertWidgetToInput?.(widget)) {
|
||||
toSlot = node.inputs?.length - 1
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
if (toSlot != null || toSlot !== -1) {
|
||||
fromNode.connect(fromSlot, node, toSlot)
|
||||
}
|
||||
} else {
|
||||
const widget = node.widgets?.find((w) => w.name === input)
|
||||
if (widget) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
}
|
||||
for (const id of ids) {
|
||||
const data = apiData[id]
|
||||
const node = app.graph.getNodeById(id)
|
||||
for (const input in data.inputs ?? {}) {
|
||||
const value = data.inputs[input]
|
||||
if (value instanceof Array) {
|
||||
const [fromId, fromSlot] = value
|
||||
const fromNode = app.graph.getNodeById(fromId)
|
||||
let toSlot = node.inputs?.findIndex((inp) => inp.name === input)
|
||||
if (toSlot == null || toSlot === -1) {
|
||||
try {
|
||||
// Target has no matching input, most likely a converted widget
|
||||
const widget = node.widgets?.find((w) => w.name === input)
|
||||
// @ts-expect-error
|
||||
if (widget && node.convertWidgetToInput?.(widget)) {
|
||||
toSlot = node.inputs?.length - 1
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
if (toSlot != null || toSlot !== -1) {
|
||||
fromNode.connect(fromSlot, node, toSlot)
|
||||
}
|
||||
} else {
|
||||
const widget = node.widgets?.find((w) => w.name === input)
|
||||
if (widget) {
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
app.graph.arrange()
|
||||
}, fileName)
|
||||
}
|
||||
app.graph.arrange()
|
||||
|
||||
for (const id of ids) {
|
||||
const data = apiData[id]
|
||||
@@ -2854,6 +2835,9 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
app.graph.arrange()
|
||||
|
||||
// @ts-expect-error zod type issue on ComfyWorkflowJSON. Should be resolved after enabling ts-strict globally.
|
||||
workflowService.afterLoadNewGraph(fileName, this.serializeGraph())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,34 +1,62 @@
|
||||
import type { ComfyApp } from './app'
|
||||
import { api } from './api'
|
||||
import { clone } from './utils'
|
||||
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
|
||||
import { ComfyWorkflow } from './workflows'
|
||||
import type { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
|
||||
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
|
||||
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 _ 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 {
|
||||
static MAX_HISTORY = 50
|
||||
#app?: ComfyApp
|
||||
/**
|
||||
* The active state of the workflow.
|
||||
*/
|
||||
activeState: ComfyWorkflowJSON
|
||||
undoQueue: ComfyWorkflowJSON[] = []
|
||||
redoQueue: ComfyWorkflowJSON[] = []
|
||||
activeState: ComfyWorkflowJSON | null = null
|
||||
isOurLoad: boolean = false
|
||||
changeCount: number = 0
|
||||
|
||||
ds?: { scale: number; offset: [number, number] }
|
||||
nodeOutputs?: Record<string, any>
|
||||
|
||||
static app?: ComfyApp
|
||||
get app(): ComfyApp {
|
||||
// Global tracker has #app set, while other trackers have workflow bounded
|
||||
return this.#app ?? this.workflow.manager.app
|
||||
return ChangeTracker.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() {
|
||||
@@ -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() {
|
||||
if (!this.app.graph || this.changeCount) return
|
||||
|
||||
const currentState = this.app.graph.serialize()
|
||||
// @ts-expect-error zod types issue. Will be fixed after we enable ts-strict
|
||||
const currentState = this.app.graph.serialize() as ComfyWorkflowJSON
|
||||
if (!this.activeState) {
|
||||
this.activeState = clone(currentState)
|
||||
return
|
||||
@@ -63,10 +103,10 @@ export class ChangeTracker {
|
||||
}
|
||||
this.activeState = clone(currentState)
|
||||
this.redoQueue.length = 0
|
||||
this.workflow.unsaved = true
|
||||
api.dispatchEvent(
|
||||
new CustomEvent('graphChanged', { detail: this.activeState })
|
||||
)
|
||||
this.updateModified()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,12 +114,12 @@ export class ChangeTracker {
|
||||
const prevState = source.pop()
|
||||
if (prevState) {
|
||||
target.push(this.activeState!)
|
||||
this.isOurLoad = true
|
||||
await this.app.loadGraphData(prevState, false, false, this.workflow, {
|
||||
showMissingModelsDialog: false,
|
||||
showMissingNodesDialog: false
|
||||
})
|
||||
this.activeState = prevState
|
||||
this.updateModified()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,21 +154,11 @@ export class ChangeTracker {
|
||||
}
|
||||
|
||||
static init(app: ComfyApp) {
|
||||
const changeTracker = () =>
|
||||
app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker
|
||||
globalTracker.#setApp(app)
|
||||
const getCurrentChangeTracker = () =>
|
||||
useWorkflowStore().activeWorkflow?.changeTracker
|
||||
const checkState = () => getCurrentChangeTracker()?.checkState()
|
||||
|
||||
const loadGraphData = app.loadGraphData
|
||||
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
|
||||
}
|
||||
ChangeTracker.app = app
|
||||
|
||||
let keyIgnored = false
|
||||
window.addEventListener(
|
||||
@@ -160,12 +190,15 @@ export class ChangeTracker {
|
||||
e.key === 'Meta'
|
||||
if (keyIgnored) return
|
||||
|
||||
const changeTracker = getCurrentChangeTracker()
|
||||
if (!changeTracker) return
|
||||
|
||||
// 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 (ChangeTracker.bindInput(app, bindInputEl)) return
|
||||
changeTracker().checkState()
|
||||
changeTracker.checkState()
|
||||
})
|
||||
},
|
||||
true
|
||||
@@ -174,35 +207,35 @@ export class ChangeTracker {
|
||||
window.addEventListener('keyup', (e) => {
|
||||
if (keyIgnored) {
|
||||
keyIgnored = false
|
||||
changeTracker().checkState()
|
||||
checkState()
|
||||
}
|
||||
})
|
||||
|
||||
// Handle clicking DOM elements (e.g. widgets)
|
||||
window.addEventListener('mouseup', () => {
|
||||
changeTracker().checkState()
|
||||
checkState()
|
||||
})
|
||||
|
||||
// Handle prompt queue event for dynamic widget changes
|
||||
api.addEventListener('promptQueued', () => {
|
||||
changeTracker().checkState()
|
||||
checkState()
|
||||
})
|
||||
|
||||
api.addEventListener('graphCleared', () => {
|
||||
changeTracker().checkState()
|
||||
checkState()
|
||||
})
|
||||
|
||||
// Handle litegraph clicks
|
||||
const processMouseUp = LGraphCanvas.prototype.processMouseUp
|
||||
LGraphCanvas.prototype.processMouseUp = function (e) {
|
||||
const v = processMouseUp.apply(this, [e])
|
||||
changeTracker().checkState()
|
||||
checkState()
|
||||
return v
|
||||
}
|
||||
const processMouseDown = LGraphCanvas.prototype.processMouseDown
|
||||
LGraphCanvas.prototype.processMouseDown = function (e) {
|
||||
const v = processMouseDown.apply(this, [e])
|
||||
changeTracker().checkState()
|
||||
checkState()
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -216,7 +249,7 @@ export class ChangeTracker {
|
||||
) {
|
||||
const extendedCallback = (v: any) => {
|
||||
callback(v)
|
||||
changeTracker().checkState()
|
||||
checkState()
|
||||
}
|
||||
return prompt.apply(this, [title, value, extendedCallback, event])
|
||||
}
|
||||
@@ -225,7 +258,7 @@ export class ChangeTracker {
|
||||
const close = LiteGraph.ContextMenu.prototype.close
|
||||
LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) {
|
||||
const v = close.apply(this, [e])
|
||||
changeTracker().checkState()
|
||||
checkState()
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -234,10 +267,7 @@ export class ChangeTracker {
|
||||
LiteGraph.LGraph.prototype.onNodeAdded = function (node: LGraphNode) {
|
||||
const v = onNodeAdded?.apply(this, [node])
|
||||
if (!app?.configuringGraph) {
|
||||
const ct = changeTracker()
|
||||
if (!ct.isOurLoad) {
|
||||
ct.checkState()
|
||||
}
|
||||
checkState()
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -246,9 +276,9 @@ export class ChangeTracker {
|
||||
document.addEventListener('litegraph:canvas', (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail
|
||||
if (detail.subType === 'before-change') {
|
||||
changeTracker().beforeChange()
|
||||
getCurrentChangeTracker()?.beforeChange()
|
||||
} else if (detail.subType === 'after-change') {
|
||||
changeTracker().afterChange()
|
||||
getCurrentChangeTracker()?.afterChange()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -290,7 +320,7 @@ export class ChangeTracker {
|
||||
const htmlElement = activeEl as HTMLElement
|
||||
if (`on${evt}` in htmlElement) {
|
||||
const listener = () => {
|
||||
app.workflowManager.activeWorkflow?.changeTracker?.checkState()
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState?.()
|
||||
htmlElement.removeEventListener(evt, listener)
|
||||
}
|
||||
htmlElement.addEventListener(evt, listener)
|
||||
@@ -300,28 +330,24 @@ export class ChangeTracker {
|
||||
return false
|
||||
}
|
||||
|
||||
static graphEqual(a: any, b: any, path = '') {
|
||||
static graphEqual(a: ComfyWorkflowJSON, b: ComfyWorkflowJSON) {
|
||||
if (a === b) return true
|
||||
|
||||
if (typeof a == 'object' && a && typeof b == 'object' && b) {
|
||||
const keys = Object.getOwnPropertyNames(a)
|
||||
|
||||
if (keys.length != Object.getOwnPropertyNames(b).length) {
|
||||
// Compare nodes ignoring order
|
||||
if (
|
||||
!_.isEqualWith(a.nodes, b.nodes, (arrA, arrB) => {
|
||||
if (Array.isArray(arrA) && Array.isArray(arrB)) {
|
||||
return _.isEqual(new Set(arrA), new Set(arrB))
|
||||
}
|
||||
})
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
let av = a[key]
|
||||
let bv = 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)) {
|
||||
// Compare other properties normally
|
||||
for (const key of ['links', 'groups']) {
|
||||
if (!_.isEqual(a[key], b[key])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -332,5 +358,3 @@ export class ChangeTracker {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const globalTracker = new ChangeTracker({} as ComfyWorkflow)
|
||||
|
||||
@@ -135,3 +135,16 @@ export const defaultGraph: ComfyWorkflowJSON = {
|
||||
extra: {},
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LiteGraph } from '@comfyorg/litegraph'
|
||||
import { api } from './api'
|
||||
import { getFromPngFile } from './metadata/png'
|
||||
import { getFromFlacFile } from './metadata/flac'
|
||||
import { workflowService } from '@/services/workflowService'
|
||||
|
||||
// Original functions left in for backwards compatibility
|
||||
export function getPngMetadata(file: File): Promise<Record<string, string>> {
|
||||
|
||||
@@ -52,7 +52,15 @@ export class ComfyAsyncDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
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 content = [$el('span', message)]
|
||||
if (title) {
|
||||
|
||||
@@ -28,7 +28,7 @@ function formatDate(text: string, date: Date) {
|
||||
})
|
||||
}
|
||||
|
||||
export function clone(obj) {
|
||||
export function clone(obj: any) {
|
||||
try {
|
||||
if (typeof structuredClone !== 'undefined') {
|
||||
return structuredClone(obj)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { downloadBlob } from '@/scripts/utils'
|
||||
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 { 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> {
|
||||
if (useSettingStore().get('Comfy.PromptFilename')) {
|
||||
@@ -20,14 +27,20 @@ async function getFilename(defaultName: string): Promise<string | null> {
|
||||
return defaultName
|
||||
}
|
||||
|
||||
// TODO(huchenlei): Auto Error Handling for all methods.
|
||||
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(
|
||||
filename: string,
|
||||
promptProperty: 'workflow' | 'output'
|
||||
): Promise<void> {
|
||||
const workflow = useWorkflowStore().activeWorkflow
|
||||
if (workflow?.path) {
|
||||
filename = workflow.name
|
||||
filename = workflow.filename
|
||||
}
|
||||
const p = await app.graphToPrompt()
|
||||
const json = JSON.stringify(p[promptProperty], null, 2)
|
||||
@@ -35,5 +48,229 @@ export const workflowService = {
|
||||
const file = await getFilename(filename)
|
||||
if (!file) return
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { app } from '@/scripts/app'
|
||||
import { api } from '@/scripts/api'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { globalTracker } from '@/scripts/changeTracker'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import {
|
||||
@@ -15,7 +14,7 @@ import { ComfyExtension } from '@/types/comfy'
|
||||
import { LGraphGroup } from '@comfyorg/litegraph'
|
||||
import { useTitleEditorStore } from './graphStore'
|
||||
import { useErrorHandling } from '@/hooks/errorHooks'
|
||||
import { useWorkflowStore } from './workflowStore'
|
||||
import { ComfyWorkflow, useWorkflowStore } from './workflowStore'
|
||||
import { type KeybindingImpl, useKeybindingStore } from './keybindingStore'
|
||||
import { useBottomPanelStore } from './workspace/bottomPanelStore'
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
@@ -76,8 +75,7 @@ export class ComfyCommandImpl implements ComfyCommand {
|
||||
}
|
||||
}
|
||||
|
||||
const getTracker = () =>
|
||||
app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker
|
||||
const getTracker = () => useWorkflowStore()?.activeWorkflow?.changeTracker
|
||||
|
||||
const getSelectedNodes = (): LGraphNode[] => {
|
||||
const selectedNodes = app.canvas.selected_nodes
|
||||
@@ -120,12 +118,7 @@ export const useCommandStore = defineStore('command', () => {
|
||||
icon: 'pi pi-plus',
|
||||
label: 'New Blank Workflow',
|
||||
menubarLabel: 'New',
|
||||
function: () => {
|
||||
app.workflowManager.setWorkflow(null)
|
||||
app.clean()
|
||||
app.graph.clear()
|
||||
app.workflowManager.activeWorkflow?.track()
|
||||
}
|
||||
function: () => workflowService.loadBlankWorkflow()
|
||||
},
|
||||
{
|
||||
id: 'Comfy.OpenWorkflow',
|
||||
@@ -140,17 +133,18 @@ export const useCommandStore = defineStore('command', () => {
|
||||
id: 'Comfy.LoadDefaultWorkflow',
|
||||
icon: 'pi pi-code',
|
||||
label: 'Load Default Workflow',
|
||||
function: async () => {
|
||||
await app.loadGraphData()
|
||||
}
|
||||
function: () => workflowService.loadDefaultWorkflow()
|
||||
},
|
||||
{
|
||||
id: 'Comfy.SaveWorkflow',
|
||||
icon: 'pi pi-save',
|
||||
label: 'Save Workflow',
|
||||
menubarLabel: 'Save',
|
||||
function: () => {
|
||||
app.workflowManager.activeWorkflow?.save()
|
||||
function: async () => {
|
||||
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',
|
||||
label: 'Save Workflow As',
|
||||
menubarLabel: 'Save As',
|
||||
function: () => {
|
||||
app.workflowManager.activeWorkflow?.save(true)
|
||||
function: async () => {
|
||||
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',
|
||||
label: 'Undo',
|
||||
function: async () => {
|
||||
await getTracker().undo()
|
||||
await getTracker()?.undo?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -193,7 +190,7 @@ export const useCommandStore = defineStore('command', () => {
|
||||
icon: 'pi pi-refresh',
|
||||
label: 'Redo',
|
||||
function: async () => {
|
||||
await getTracker().redo()
|
||||
await getTracker()?.redo?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -387,7 +384,7 @@ export const useCommandStore = defineStore('command', () => {
|
||||
label: 'Next Opened Workflow',
|
||||
versionAdded: '1.3.9',
|
||||
function: () => {
|
||||
useWorkflowStore().loadNextOpenedWorkflow()
|
||||
workflowService.loadNextOpenedWorkflow()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -396,7 +393,7 @@ export const useCommandStore = defineStore('command', () => {
|
||||
label: 'Previous Opened Workflow',
|
||||
versionAdded: '1.3.9',
|
||||
function: () => {
|
||||
useWorkflowStore().loadPreviousOpenedWorkflow()
|
||||
workflowService.loadPreviousOpenedWorkflow()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { api } from '@/scripts/api'
|
||||
import { ComfyWorkflow } from '@/scripts/workflows'
|
||||
import { ComfyWorkflow } from './workflowStore'
|
||||
import type { ComfyNode, ComfyWorkflowJSON } from '@/types/comfyWorkflow'
|
||||
import type {
|
||||
ExecutedWsMessage,
|
||||
|
||||
@@ -4,6 +4,8 @@ import { buildTree } from '@/utils/treeUtil'
|
||||
import { computed, ref } from 'vue'
|
||||
import { TreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
import { UserDataFullInfo } from '@/types/apiTypes'
|
||||
import { syncEntities } from '@/utils/syncUtil'
|
||||
import { getPathDetails } from '@/utils/formatUtil'
|
||||
|
||||
/**
|
||||
* Represents a file in the user's data directory.
|
||||
@@ -37,25 +39,60 @@ export class UserFile {
|
||||
*/
|
||||
public lastModified: number,
|
||||
/**
|
||||
* File size in bytes.
|
||||
* File size in bytes. -1 for temporary files.
|
||||
*/
|
||||
public size: number
|
||||
) {
|
||||
this.directory = path.split('/').slice(0, -1).join('/')
|
||||
this.fullFilename = path.split('/').pop() ?? path
|
||||
this.filename = this.fullFilename.split('.').slice(0, -1).join('.')
|
||||
this.suffix = this.fullFilename.split('.').pop() ?? null
|
||||
const details = getPathDetails(path)
|
||||
this.path = path
|
||||
this.directory = details.directory
|
||||
this.fullFilename = details.fullFilename
|
||||
this.filename = details.filename
|
||||
this.suffix = details.suffix
|
||||
}
|
||||
|
||||
get isOpen() {
|
||||
return !!this.content
|
||||
updatePath(newPath: string) {
|
||||
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() {
|
||||
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
|
||||
const resp = await api.getUserData(this.path)
|
||||
if (resp.status !== 200) {
|
||||
@@ -66,19 +103,34 @@ export class UserFile {
|
||||
this.content = await resp.text()
|
||||
this.originalContent = this.content
|
||||
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> {
|
||||
const tempFile = new TempUserFile(newPath, this.content ?? undefined)
|
||||
const tempFile = this.isTemporary ? this : UserFile.createTemporary(newPath)
|
||||
tempFile.content = this.content
|
||||
await tempFile.save()
|
||||
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, {
|
||||
overwrite: this.isPersisted,
|
||||
throwOnError: true,
|
||||
full_info: true
|
||||
})
|
||||
@@ -95,6 +147,8 @@ export class UserFile {
|
||||
}
|
||||
|
||||
async delete(): Promise<void> {
|
||||
if (this.isTemporary) return
|
||||
|
||||
const resp = await api.deleteUserData(this.path)
|
||||
if (resp.status !== 204) {
|
||||
throw new Error(
|
||||
@@ -104,13 +158,18 @@ export class UserFile {
|
||||
}
|
||||
|
||||
async rename(newPath: string): Promise<UserFile> {
|
||||
if (this.isTemporary) {
|
||||
this.updatePath(newPath)
|
||||
return this
|
||||
}
|
||||
|
||||
const resp = await api.moveUserData(this.path, newPath)
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(
|
||||
`Failed to rename file '${this.path}': ${resp.status} ${resp.statusText}`
|
||||
)
|
||||
}
|
||||
this.path = newPath
|
||||
this.updatePath(newPath)
|
||||
// Note: Backend supports full_info=true feature after
|
||||
// https://github.com/comfyanonymous/ComfyUI/pull/5446
|
||||
const updatedFile = (await resp.json()) as string | UserDataFullInfo
|
||||
@@ -122,56 +181,21 @@ export class UserFile {
|
||||
}
|
||||
}
|
||||
|
||||
export class TempUserFile extends UserFile {
|
||||
constructor(path: string, content: string = '') {
|
||||
// Initialize with current timestamp and 0 size since it's temporary
|
||||
super(path, Date.now(), 0)
|
||||
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 interface LoadedUserFile extends UserFile {
|
||||
isLoaded: true
|
||||
originalContent: string
|
||||
content: string
|
||||
}
|
||||
|
||||
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(() =>
|
||||
userFiles.value.filter((file: UserFile) => file.isModified)
|
||||
)
|
||||
const openedFiles = computed(() =>
|
||||
userFiles.value.filter((file: UserFile) => file.isOpen)
|
||||
const loadedFiles = computed(() =>
|
||||
userFiles.value.filter((file: UserFile) => file.isLoaded)
|
||||
)
|
||||
|
||||
const fileTree = computed<TreeExplorerNode<UserFile>>(
|
||||
@@ -186,39 +210,22 @@ export const useUserFileStore = defineStore('userFile', () => {
|
||||
* @param dir The directory to sync.
|
||||
*/
|
||||
const syncFiles = async (dir: string = '') => {
|
||||
const files = await api.listUserDataFullInfo(dir)
|
||||
|
||||
for (const file of files) {
|
||||
const existingFile = userFilesByPath.value.get(file.path)
|
||||
|
||||
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
|
||||
await syncEntities(
|
||||
dir,
|
||||
userFilesByPath.value,
|
||||
(file) => new UserFile(file.path, file.modified, file.size),
|
||||
(existingFile, file) => {
|
||||
existingFile.lastModified = file.modified
|
||||
existingFile.size = file.size
|
||||
existingFile.originalContent = null
|
||||
existingFile.content = null
|
||||
existingFile.isLoading = false
|
||||
existingFile.unload()
|
||||
}
|
||||
}
|
||||
|
||||
// Remove files that no longer exist
|
||||
for (const [path, _] of userFilesByPath.value) {
|
||||
if (!files.some((file) => file.path === path)) {
|
||||
userFilesByPath.value.delete(path)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
userFiles,
|
||||
modifiedFiles,
|
||||
openedFiles,
|
||||
loadedFiles,
|
||||
fileTree,
|
||||
syncFiles
|
||||
}
|
||||
|
||||
@@ -1,24 +1,292 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ComfyWorkflow } from '@/scripts/workflows'
|
||||
import { computed, markRaw, ref } from 'vue'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
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', () => {
|
||||
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 workflows = computed(() => Object.values(workflowLookup.value))
|
||||
const persistedWorkflows = computed(() =>
|
||||
workflows.value.filter((workflow) => workflow.isPersisted)
|
||||
const workflows = computed<ComfyWorkflow[]>(() =>
|
||||
Object.values(workflowLookup.value)
|
||||
)
|
||||
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(() =>
|
||||
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(() =>
|
||||
workflows.value.filter((workflow) => workflow.isBookmarked)
|
||||
workflows.value.filter((workflow) =>
|
||||
bookmarkStore.isBookmarked(workflow.path)
|
||||
)
|
||||
)
|
||||
const modifiedWorkflows = computed(() =>
|
||||
workflows.value.filter((workflow) => workflow.unsaved)
|
||||
workflows.value.filter((workflow) => workflow.isModified)
|
||||
)
|
||||
|
||||
const buildWorkflowTree = (workflows: ComfyWorkflow[]) => {
|
||||
@@ -31,50 +299,78 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
)
|
||||
// Bookmarked workflows tree is flat.
|
||||
const bookmarkedWorkflowsTree = computed(() =>
|
||||
buildTree(bookmarkedWorkflows.value, (workflow: ComfyWorkflow) => [
|
||||
workflow.path ?? 'temporary_workflow'
|
||||
])
|
||||
buildTree(bookmarkedWorkflows.value, (workflow) => [workflow.key])
|
||||
)
|
||||
// Open workflows tree is flat.
|
||||
const openWorkflowsTree = computed(() =>
|
||||
buildTree(openWorkflows.value, (workflow: ComfyWorkflow) => [workflow.key])
|
||||
buildTree(openWorkflows.value, (workflow) => [workflow.key])
|
||||
)
|
||||
|
||||
const loadOpenedWorkflowIndexShift = async (shift: number) => {
|
||||
const index = openWorkflows.value.indexOf(
|
||||
activeWorkflow.value as ComfyWorkflow
|
||||
)
|
||||
if (index !== -1) {
|
||||
const length = openWorkflows.value.length
|
||||
const nextIndex = (index + shift + length) % length
|
||||
const nextWorkflow = openWorkflows.value[nextIndex]
|
||||
if (nextWorkflow) {
|
||||
await nextWorkflow.load()
|
||||
}
|
||||
const renameWorkflow = async (workflow: ComfyWorkflow, newName: string) => {
|
||||
// Capture all needed values upfront
|
||||
const oldPath = workflow.path
|
||||
const newPath = workflow.directory + '/' + appendJsonExt(newName)
|
||||
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
|
||||
|
||||
const openIndex = detachWorkflow(workflow)
|
||||
// Perform the actual rename operation first
|
||||
try {
|
||||
await workflow.rename(newName)
|
||||
} finally {
|
||||
attachWorkflow(workflow, openIndex)
|
||||
}
|
||||
|
||||
// Update bookmarks
|
||||
if (wasBookmarked) {
|
||||
bookmarkStore.setBookmarked(oldPath, false)
|
||||
bookmarkStore.setBookmarked(newPath, true)
|
||||
}
|
||||
}
|
||||
|
||||
const loadNextOpenedWorkflow = async () => {
|
||||
await loadOpenedWorkflowIndexShift(1)
|
||||
const deleteWorkflow = async (workflow: ComfyWorkflow) => {
|
||||
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 {
|
||||
activeWorkflow,
|
||||
workflows,
|
||||
isActive,
|
||||
openWorkflows,
|
||||
openWorkflowsTree,
|
||||
openedWorkflowIndexShift,
|
||||
openWorkflow,
|
||||
isOpen,
|
||||
closeWorkflow,
|
||||
createTemporary,
|
||||
renameWorkflow,
|
||||
deleteWorkflow,
|
||||
saveWorkflow,
|
||||
|
||||
workflows,
|
||||
bookmarkedWorkflows,
|
||||
modifiedWorkflows,
|
||||
workflowLookup,
|
||||
getWorkflowByPath,
|
||||
workflowsTree,
|
||||
bookmarkedWorkflowsTree,
|
||||
openWorkflowsTree,
|
||||
buildWorkflowTree,
|
||||
loadNextOpenedWorkflow,
|
||||
loadPreviousOpenedWorkflow
|
||||
syncWorkflows
|
||||
}
|
||||
})
|
||||
|
||||
@@ -98,6 +394,7 @@ export const useWorkflowBookmarkStore = defineStore('workflowBookmark', () => {
|
||||
}
|
||||
|
||||
const setBookmarked = (path: string, value: boolean) => {
|
||||
if (bookmarks.value.has(path) === value) return
|
||||
if (value) {
|
||||
bookmarks.value.add(path)
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useQueueSettingsStore } from './queueStore'
|
||||
import { useCommandStore } from './commandStore'
|
||||
import { useSidebarTabStore } from './workspace/sidebarTabStore'
|
||||
import { useSettingStore } from './settingStore'
|
||||
import { useWorkflowStore } from './workflowStore'
|
||||
|
||||
export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
const spinner = ref(false)
|
||||
@@ -26,6 +27,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
get: useSettingStore().get,
|
||||
set: useSettingStore().set
|
||||
}))
|
||||
const workflow = computed(() => useWorkflowStore())
|
||||
|
||||
/**
|
||||
* Registers a sidebar tab.
|
||||
@@ -66,6 +68,7 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
command,
|
||||
sidebarTab,
|
||||
setting,
|
||||
workflow,
|
||||
|
||||
registerSidebarTab,
|
||||
unregisterSidebarTab,
|
||||
|
||||
@@ -72,3 +72,58 @@ export function formatSize(value?: number) {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
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
45
src/utils/syncUtil.ts
Normal 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,6 @@ import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { i18n } from '@/i18n'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import {
|
||||
useWorkflowStore,
|
||||
useWorkflowBookmarkStore
|
||||
} from '@/stores/workflowStore'
|
||||
import GlobalToast from '@/components/toast/GlobalToast.vue'
|
||||
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.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(() => {
|
||||
api.addEventListener('status', onStatus)
|
||||
api.addEventListener('reconnecting', onReconnecting)
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import {
|
||||
TempUserFile,
|
||||
UserFile,
|
||||
useUserFileStore
|
||||
} from '@/stores/userFileStore'
|
||||
import { UserFile, useUserFileStore } from '@/stores/userFileStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
// Mock the api
|
||||
@@ -28,14 +24,14 @@ describe('useUserFileStore', () => {
|
||||
it('should initialize with empty files', () => {
|
||||
expect(store.userFiles).toHaveLength(0)
|
||||
expect(store.modifiedFiles).toHaveLength(0)
|
||||
expect(store.openedFiles).toHaveLength(0)
|
||||
expect(store.loadedFiles).toHaveLength(0)
|
||||
})
|
||||
|
||||
describe('syncFiles', () => {
|
||||
it('should add new files', async () => {
|
||||
const mockFiles = [
|
||||
{ path: 'dir/file1.txt', modified: 123, size: 100 },
|
||||
{ path: 'dir/file2.txt', modified: 456, size: 200 }
|
||||
{ path: 'file1.txt', modified: 123, size: 100 },
|
||||
{ path: 'file2.txt', modified: 456, size: 200 }
|
||||
]
|
||||
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(mockFiles)
|
||||
|
||||
@@ -47,11 +43,11 @@ describe('useUserFileStore', () => {
|
||||
})
|
||||
|
||||
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])
|
||||
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])
|
||||
await store.syncFiles('dir')
|
||||
|
||||
@@ -62,13 +58,13 @@ describe('useUserFileStore', () => {
|
||||
|
||||
it('should remove non-existent files', async () => {
|
||||
const initialFiles = [
|
||||
{ path: 'dir/file1.txt', modified: 123, size: 100 },
|
||||
{ path: 'dir/file2.txt', modified: 456, size: 200 }
|
||||
{ path: 'file1.txt', modified: 123, size: 100 },
|
||||
{ path: 'file2.txt', modified: 456, size: 200 }
|
||||
]
|
||||
;(api.listUserDataFullInfo as jest.Mock).mockResolvedValue(initialFiles)
|
||||
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)
|
||||
await store.syncFiles('dir')
|
||||
|
||||
@@ -102,6 +98,7 @@ describe('useUserFileStore', () => {
|
||||
expect(file.content).toBe('file content')
|
||||
expect(file.originalContent).toBe('file content')
|
||||
expect(file.isLoading).toBe(false)
|
||||
expect(file.isLoaded).toBe(true)
|
||||
})
|
||||
|
||||
it('should throw error on failed load', async () => {
|
||||
@@ -132,7 +129,7 @@ describe('useUserFileStore', () => {
|
||||
expect(api.storeUserData).toHaveBeenCalledWith(
|
||||
'file1.txt',
|
||||
'modified content',
|
||||
{ throwOnError: true, full_info: true }
|
||||
{ throwOnError: true, full_info: true, overwrite: true }
|
||||
)
|
||||
expect(file.lastModified).toBe(456)
|
||||
expect(file.size).toBe(200)
|
||||
@@ -194,9 +191,9 @@ describe('useUserFileStore', () => {
|
||||
expect(api.storeUserData).toHaveBeenCalledWith(
|
||||
'newfile.txt',
|
||||
'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.lastModified).toBe(456)
|
||||
expect(newFile.size).toBe(200)
|
||||
|
||||
358
tests-ui/tests/fast/store/workflowStore.test.ts
Normal file
358
tests-ui/tests/fast/store/workflowStore.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -51,7 +51,14 @@ module.exports = async function () {
|
||||
shiftDown: false,
|
||||
spinner: false,
|
||||
focusMode: false,
|
||||
toggleFocusMode: jest.fn()
|
||||
toggleFocusMode: jest.fn(),
|
||||
workflow: {
|
||||
activeWorkflow: null,
|
||||
syncWorkflows: jest.fn(),
|
||||
getWorkflowByPath: jest.fn(),
|
||||
createTemporary: jest.fn(),
|
||||
openWorkflow: jest.fn()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user