mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 02:32:18 +00:00
Feat: Persist all unsaved workflow tabs (#6050)
## Summary - Keep all drafts in localStorage, mirroring the logic from VSCode. - Fix a bug where newly created blank workflow tabs would incorrectly restore as defaultGraph instead of blankGraph after page refresh. Resolves https://github.com/Comfy-Org/desktop/issues/910, Resolves https://github.com/Comfy-Org/ComfyUI_frontend/issues/4057, Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/3665 ## Changes ### What - Fix `restoreWorkflowTabsState` to parse and pass workflow data from drafts when recreating temporary workflows - Add error handling for invalid draft data with fallback to default workflow - Fix E2E test `should not serialize color adjustments in workflow` to wait for workflow persistence before assertions - Add proper validation for workflow nodes array in test assertions ### Breaking - None ### Dependencies - No new dependencies added ## Review Focus 1. **Workflow restoration**: Verify that blank workflows correctly restore as blankGraph after page refresh 2. **Error handling**: Check that invalid draft data gracefully falls back to default workflow 3. **Test coverage**: Ensure E2E test correctly waits for workflow persistence before checking node properties 4. **Edge cases**: Test with multiple tabs, switching between tabs, and rapid refresh scenarios --------- Co-authored-by: Yourz <crazilou@vip.qq.com>
This commit is contained in:
@@ -232,11 +232,25 @@ test.describe('Node Color Adjustments', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
|
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
|
||||||
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
|
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
|
||||||
const saveWorkflowInterval = 1000
|
await comfyPage.nextFrame()
|
||||||
const workflow = await comfyPage.page.evaluate(() => {
|
const parsed = await (
|
||||||
return localStorage.getItem('workflow')
|
await comfyPage.page.waitForFunction(
|
||||||
})
|
() => {
|
||||||
for (const node of JSON.parse(workflow ?? '{}').nodes) {
|
const workflow = localStorage.getItem('workflow')
|
||||||
|
if (!workflow) return null
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(workflow)
|
||||||
|
return Array.isArray(data?.nodes) ? data : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 3000 }
|
||||||
|
)
|
||||||
|
).jsonValue()
|
||||||
|
expect(parsed.nodes).toBeDefined()
|
||||||
|
expect(Array.isArray(parsed.nodes)).toBe(true)
|
||||||
|
for (const node of parsed.nodes) {
|
||||||
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
|
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
|
||||||
if (node.color) expect(node.color).not.toMatch(/hsla/)
|
if (node.color) expect(node.color).not.toMatch(/hsla/)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1749,6 +1749,7 @@
|
|||||||
"nothingToQueue": "Nothing to queue",
|
"nothingToQueue": "Nothing to queue",
|
||||||
"pleaseSelectOutputNodes": "Please select output nodes",
|
"pleaseSelectOutputNodes": "Please select output nodes",
|
||||||
"failedToQueue": "Failed to queue",
|
"failedToQueue": "Failed to queue",
|
||||||
|
"failedToSaveDraft": "Failed to save workflow draft",
|
||||||
"failedExecutionPathResolution": "Could not resolve path to selected nodes",
|
"failedExecutionPathResolution": "Could not resolve path to selected nodes",
|
||||||
"no3dScene": "No 3D scene to apply texture",
|
"no3dScene": "No 3D scene to apply texture",
|
||||||
"failedToApplyTexture": "Failed to apply texture",
|
"failedToApplyTexture": "Failed to apply texture",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
|||||||
import type { Point, SerialisableGraph } from '@/lib/litegraph/src/litegraph'
|
import type { Point, SerialisableGraph } from '@/lib/litegraph/src/litegraph'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||||
|
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||||
import {
|
import {
|
||||||
ComfyWorkflow,
|
ComfyWorkflow,
|
||||||
useWorkflowStore
|
useWorkflowStore
|
||||||
@@ -28,6 +29,7 @@ export const useWorkflowService = () => {
|
|||||||
const dialogService = useDialogService()
|
const dialogService = useDialogService()
|
||||||
const workflowThumbnail = useWorkflowThumbnail()
|
const workflowThumbnail = useWorkflowThumbnail()
|
||||||
const domWidgetStore = useDomWidgetStore()
|
const domWidgetStore = useDomWidgetStore()
|
||||||
|
const workflowDraftStore = useWorkflowDraftStore()
|
||||||
|
|
||||||
async function getFilename(defaultName: string): Promise<string | null> {
|
async function getFilename(defaultName: string): Promise<string | null> {
|
||||||
if (settingStore.get('Comfy.PromptFilename')) {
|
if (settingStore.get('Comfy.PromptFilename')) {
|
||||||
@@ -291,6 +293,27 @@ export const useWorkflowService = () => {
|
|||||||
const activeWorkflow = workflowStore.activeWorkflow
|
const activeWorkflow = workflowStore.activeWorkflow
|
||||||
if (activeWorkflow) {
|
if (activeWorkflow) {
|
||||||
activeWorkflow.changeTracker.store()
|
activeWorkflow.changeTracker.store()
|
||||||
|
if (settingStore.get('Comfy.Workflow.Persist') && activeWorkflow.path) {
|
||||||
|
const activeState = activeWorkflow.activeState
|
||||||
|
if (activeState) {
|
||||||
|
try {
|
||||||
|
const workflowJson = JSON.stringify(activeState)
|
||||||
|
workflowDraftStore.saveDraft(activeWorkflow.path, {
|
||||||
|
data: workflowJson,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
name: activeWorkflow.key,
|
||||||
|
isTemporary: activeWorkflow.isTemporary
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
toastStore.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('g.error'),
|
||||||
|
detail: t('toastMessages.failedToSaveDraft'),
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Capture thumbnail before loading new graph
|
// Capture thumbnail before loading new graph
|
||||||
void workflowThumbnail.storeThumbnail(activeWorkflow)
|
void workflowThumbnail.storeThumbnail(activeWorkflow)
|
||||||
domWidgetStore.clear()
|
domWidgetStore.clear()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
useWorkflowBookmarkStore,
|
useWorkflowBookmarkStore,
|
||||||
useWorkflowStore
|
useWorkflowStore
|
||||||
} from '@/platform/workflow/management/stores/workflowStore'
|
} from '@/platform/workflow/management/stores/workflowStore'
|
||||||
|
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { app as comfyApp } from '@/scripts/app'
|
import { app as comfyApp } from '@/scripts/app'
|
||||||
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
|
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||||
@@ -66,6 +67,9 @@ describe('useWorkflowStore', () => {
|
|||||||
store = useWorkflowStore()
|
store = useWorkflowStore()
|
||||||
bookmarkStore = useWorkflowBookmarkStore()
|
bookmarkStore = useWorkflowBookmarkStore()
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
useWorkflowDraftStore().reset()
|
||||||
|
|
||||||
// Add default mock implementations
|
// Add default mock implementations
|
||||||
vi.mocked(api.getUserData).mockResolvedValue({
|
vi.mocked(api.getUserData).mockResolvedValue({
|
||||||
@@ -235,6 +239,60 @@ describe('useWorkflowStore', () => {
|
|||||||
expect(workflow.isModified).toBe(false)
|
expect(workflow.isModified).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('prefers local draft snapshots when available', async () => {
|
||||||
|
localStorage.clear()
|
||||||
|
await syncRemoteWorkflows(['a.json'])
|
||||||
|
const workflow = store.getWorkflowByPath('workflows/a.json')!
|
||||||
|
|
||||||
|
const draftGraph = {
|
||||||
|
...defaultGraph,
|
||||||
|
nodes: [...defaultGraph.nodes]
|
||||||
|
}
|
||||||
|
|
||||||
|
useWorkflowDraftStore().saveDraft(workflow.path, {
|
||||||
|
data: JSON.stringify(draftGraph),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
name: workflow.key,
|
||||||
|
isTemporary: workflow.isTemporary
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mocked(api.getUserData).mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
text: () => Promise.resolve(defaultGraphJSON)
|
||||||
|
} as Response)
|
||||||
|
|
||||||
|
await workflow.load()
|
||||||
|
|
||||||
|
expect(workflow.isModified).toBe(true)
|
||||||
|
expect(workflow.changeTracker?.activeState).toEqual(draftGraph)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores stale drafts when server version is newer', async () => {
|
||||||
|
await syncRemoteWorkflows(['a.json'])
|
||||||
|
const workflow = store.getWorkflowByPath('workflows/a.json')!
|
||||||
|
const draftStore = useWorkflowDraftStore()
|
||||||
|
|
||||||
|
const draftSnapshot = {
|
||||||
|
data: JSON.stringify(defaultGraph),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
name: workflow.key,
|
||||||
|
isTemporary: workflow.isTemporary
|
||||||
|
}
|
||||||
|
|
||||||
|
draftStore.saveDraft(workflow.path, draftSnapshot)
|
||||||
|
workflow.lastModified = draftSnapshot.updatedAt + 1000
|
||||||
|
|
||||||
|
vi.mocked(api.getUserData).mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
text: () => Promise.resolve(defaultGraphJSON)
|
||||||
|
} as Response)
|
||||||
|
|
||||||
|
await workflow.load()
|
||||||
|
|
||||||
|
expect(workflow.isModified).toBe(false)
|
||||||
|
expect(draftStore.getDraft(workflow.path)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('should load and open a remote workflow', async () => {
|
it('should load and open a remote workflow', async () => {
|
||||||
await syncRemoteWorkflows(['a.json', 'b.json'])
|
await syncRemoteWorkflows(['a.json', 'b.json'])
|
||||||
|
|
||||||
@@ -413,6 +471,20 @@ describe('useWorkflowStore', () => {
|
|||||||
expect(store.isOpen(workflow)).toBe(false)
|
expect(store.isOpen(workflow)).toBe(false)
|
||||||
expect(store.getWorkflowByPath(workflow.path)).toBeNull()
|
expect(store.getWorkflowByPath(workflow.path)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should remove draft when closing temporary workflow', async () => {
|
||||||
|
const workflow = store.createTemporary('test.json')
|
||||||
|
const draftStore = useWorkflowDraftStore()
|
||||||
|
draftStore.saveDraft(workflow.path, {
|
||||||
|
data: defaultGraphJSON,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
name: workflow.key,
|
||||||
|
isTemporary: true
|
||||||
|
})
|
||||||
|
expect(draftStore.getDraft(workflow.path)).toBeDefined()
|
||||||
|
await store.closeWorkflow(workflow)
|
||||||
|
expect(draftStore.getDraft(workflow.path)).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('deleteWorkflow', () => {
|
describe('deleteWorkflow', () => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
ComfyWorkflowJSON,
|
ComfyWorkflowJSON,
|
||||||
NodeId
|
NodeId
|
||||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||||
|
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { app as comfyApp } from '@/scripts/app'
|
import { app as comfyApp } from '@/scripts/app'
|
||||||
@@ -86,6 +87,28 @@ export class ComfyWorkflow extends UserFile {
|
|||||||
override async load({ force = false }: { force?: boolean } = {}): Promise<
|
override async load({ force = false }: { force?: boolean } = {}): Promise<
|
||||||
this & LoadedComfyWorkflow
|
this & LoadedComfyWorkflow
|
||||||
> {
|
> {
|
||||||
|
const draftStore = useWorkflowDraftStore()
|
||||||
|
let draft = !force ? draftStore.getDraft(this.path) : undefined
|
||||||
|
let draftState: ComfyWorkflowJSON | null = null
|
||||||
|
let draftContent: string | null = null
|
||||||
|
|
||||||
|
if (draft) {
|
||||||
|
if (draft.updatedAt < this.lastModified) {
|
||||||
|
draftStore.removeDraft(this.path)
|
||||||
|
draft = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft) {
|
||||||
|
try {
|
||||||
|
draftState = JSON.parse(draft.data)
|
||||||
|
draftContent = draft.data
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to parse workflow draft, clearing it', err)
|
||||||
|
draftStore.removeDraft(this.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await super.load({ force })
|
await super.load({ force })
|
||||||
if (!force && this.isLoaded) return this as this & LoadedComfyWorkflow
|
if (!force && this.isLoaded) return this as this & LoadedComfyWorkflow
|
||||||
|
|
||||||
@@ -93,13 +116,14 @@ export class ComfyWorkflow extends UserFile {
|
|||||||
throw new Error('[ASSERT] Workflow content should be loaded')
|
throw new Error('[ASSERT] Workflow content should be loaded')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: originalContent is populated by super.load()
|
const initialState = JSON.parse(this.originalContent)
|
||||||
this.changeTracker = markRaw(
|
this.changeTracker = markRaw(new ChangeTracker(this, initialState))
|
||||||
new ChangeTracker(
|
if (draftState && draftContent) {
|
||||||
this,
|
this.changeTracker.activeState = draftState
|
||||||
/* initialState= */ JSON.parse(this.originalContent)
|
this.content = draftContent
|
||||||
)
|
this._isModified = true
|
||||||
)
|
draftStore.markDraftUsed(this.path)
|
||||||
|
}
|
||||||
return this as this & LoadedComfyWorkflow
|
return this as this & LoadedComfyWorkflow
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,12 +133,14 @@ export class ComfyWorkflow extends UserFile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async save() {
|
override async save() {
|
||||||
|
const draftStore = useWorkflowDraftStore()
|
||||||
this.content = JSON.stringify(this.activeState)
|
this.content = JSON.stringify(this.activeState)
|
||||||
// Force save to ensure the content is updated in remote storage incase
|
// Force save to ensure the content is updated in remote storage incase
|
||||||
// the isModified state is screwed by changeTracker.
|
// the isModified state is screwed by changeTracker.
|
||||||
const ret = await super.save({ force: true })
|
const ret = await super.save({ force: true })
|
||||||
this.changeTracker?.reset()
|
this.changeTracker?.reset()
|
||||||
this.isModified = false
|
this.isModified = false
|
||||||
|
draftStore.removeDraft(this.path)
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,8 +150,11 @@ export class ComfyWorkflow extends UserFile {
|
|||||||
* @returns this
|
* @returns this
|
||||||
*/
|
*/
|
||||||
override async saveAs(path: string) {
|
override async saveAs(path: string) {
|
||||||
|
const draftStore = useWorkflowDraftStore()
|
||||||
this.content = JSON.stringify(this.activeState)
|
this.content = JSON.stringify(this.activeState)
|
||||||
return await super.saveAs(path)
|
const result = await super.saveAs(path)
|
||||||
|
draftStore.removeDraft(path)
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
async promptSave(): Promise<string | null> {
|
async promptSave(): Promise<string | null> {
|
||||||
@@ -436,6 +465,8 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
|||||||
if (workflow.isTemporary) {
|
if (workflow.isTemporary) {
|
||||||
// Clear thumbnail when temporary workflow is closed
|
// Clear thumbnail when temporary workflow is closed
|
||||||
clearThumbnail(workflow.key)
|
clearThumbnail(workflow.key)
|
||||||
|
// Clear draft when unsaved workflow tab is closed
|
||||||
|
useWorkflowDraftStore().removeDraft(workflow.path)
|
||||||
delete workflowLookup.value[workflow.path]
|
delete workflowLookup.value[workflow.path]
|
||||||
} else {
|
} else {
|
||||||
workflow.unload()
|
workflow.unload()
|
||||||
@@ -565,6 +596,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
|||||||
const oldPath = workflow.path
|
const oldPath = workflow.path
|
||||||
const oldKey = workflow.key
|
const oldKey = workflow.key
|
||||||
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
|
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
|
||||||
|
const draftStore = useWorkflowDraftStore()
|
||||||
|
|
||||||
const openIndex = detachWorkflow(workflow)
|
const openIndex = detachWorkflow(workflow)
|
||||||
// Perform the actual rename operation first
|
// Perform the actual rename operation first
|
||||||
@@ -574,6 +606,8 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
|||||||
attachWorkflow(workflow, openIndex)
|
attachWorkflow(workflow, openIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
draftStore.moveDraft(oldPath, newPath, workflow.key)
|
||||||
|
|
||||||
// Move thumbnail from old key to new key (using workflow keys, not full paths)
|
// Move thumbnail from old key to new key (using workflow keys, not full paths)
|
||||||
const newKey = workflow.key
|
const newKey = workflow.key
|
||||||
moveWorkflowThumbnail(oldKey, newKey)
|
moveWorkflowThumbnail(oldKey, newKey)
|
||||||
@@ -591,6 +625,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
|||||||
isBusy.value = true
|
isBusy.value = true
|
||||||
try {
|
try {
|
||||||
await workflow.delete()
|
await workflow.delete()
|
||||||
|
useWorkflowDraftStore().removeDraft(workflow.path)
|
||||||
if (bookmarkStore.isBookmarked(workflow.path)) {
|
if (bookmarkStore.isBookmarked(workflow.path)) {
|
||||||
await bookmarkStore.setBookmarked(workflow.path, false)
|
await bookmarkStore.setBookmarked(workflow.path, false)
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/platform/workflow/persistence/base/draftCache.test.ts
Normal file
79
src/platform/workflow/persistence/base/draftCache.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
MAX_DRAFTS,
|
||||||
|
createDraftCacheState,
|
||||||
|
mostRecentDraftPath,
|
||||||
|
moveDraft,
|
||||||
|
removeDraft,
|
||||||
|
touchEntry,
|
||||||
|
upsertDraft
|
||||||
|
} from './draftCache'
|
||||||
|
import type { WorkflowDraftSnapshot } from './draftCache'
|
||||||
|
|
||||||
|
function createSnapshot(name: string): WorkflowDraftSnapshot {
|
||||||
|
return {
|
||||||
|
data: JSON.stringify({ name }),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
name,
|
||||||
|
isTemporary: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('draftCache helpers', () => {
|
||||||
|
it('touchEntry moves path to end', () => {
|
||||||
|
expect(touchEntry(['a', 'b'], 'a')).toEqual(['b', 'a'])
|
||||||
|
expect(touchEntry(['a', 'b'], 'c')).toEqual(['a', 'b', 'c'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upsertDraft stores snapshot and applies LRU', () => {
|
||||||
|
let state = createDraftCacheState()
|
||||||
|
for (let i = 0; i < MAX_DRAFTS; i++) {
|
||||||
|
const path = `workflows/Draft${i}.json`
|
||||||
|
state = upsertDraft(state, path, createSnapshot(String(i)))
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(Object.keys(state.drafts).length).toBe(MAX_DRAFTS)
|
||||||
|
|
||||||
|
state = upsertDraft(state, 'workflows/New.json', createSnapshot('new'))
|
||||||
|
expect(Object.keys(state.drafts).length).toBe(MAX_DRAFTS)
|
||||||
|
expect(state.drafts).not.toHaveProperty('workflows/Draft0.json')
|
||||||
|
expect(state.order[state.order.length - 1]).toBe('workflows/New.json')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removeDraft clears entry and order', () => {
|
||||||
|
const state = upsertDraft(
|
||||||
|
createDraftCacheState(),
|
||||||
|
'workflows/test.json',
|
||||||
|
createSnapshot('test')
|
||||||
|
)
|
||||||
|
|
||||||
|
const nextState = removeDraft(state, 'workflows/test.json')
|
||||||
|
expect(nextState.drafts).toEqual({})
|
||||||
|
expect(nextState.order).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moveDraft renames entry and updates order', () => {
|
||||||
|
const state = upsertDraft(
|
||||||
|
createDraftCacheState(),
|
||||||
|
'workflows/old.json',
|
||||||
|
createSnapshot('old')
|
||||||
|
)
|
||||||
|
|
||||||
|
const nextState = moveDraft(
|
||||||
|
state,
|
||||||
|
'workflows/old.json',
|
||||||
|
'workflows/new.json',
|
||||||
|
'new'
|
||||||
|
)
|
||||||
|
expect(nextState.drafts).not.toHaveProperty('workflows/old.json')
|
||||||
|
expect(nextState.drafts['workflows/new.json']?.name).toBe('new')
|
||||||
|
expect(nextState.order).toEqual(['workflows/new.json'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mostRecentDraftPath returns last entry', () => {
|
||||||
|
const state = createDraftCacheState({}, ['a', 'b', 'c'])
|
||||||
|
expect(mostRecentDraftPath(state.order)).toBe('c')
|
||||||
|
expect(mostRecentDraftPath([])).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
78
src/platform/workflow/persistence/base/draftCache.ts
Normal file
78
src/platform/workflow/persistence/base/draftCache.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
export interface WorkflowDraftSnapshot {
|
||||||
|
data: string
|
||||||
|
updatedAt: number
|
||||||
|
name: string
|
||||||
|
isTemporary: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftCacheState {
|
||||||
|
drafts: Record<string, WorkflowDraftSnapshot>
|
||||||
|
order: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_DRAFTS = 32
|
||||||
|
|
||||||
|
export const createDraftCacheState = (
|
||||||
|
drafts: Record<string, WorkflowDraftSnapshot> = {},
|
||||||
|
order: string[] = []
|
||||||
|
): DraftCacheState => ({ drafts, order })
|
||||||
|
|
||||||
|
export const touchEntry = (order: string[], path: string): string[] => {
|
||||||
|
const next = order.filter((entry) => entry !== path)
|
||||||
|
next.push(path)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export const upsertDraft = (
|
||||||
|
state: DraftCacheState,
|
||||||
|
path: string,
|
||||||
|
snapshot: WorkflowDraftSnapshot,
|
||||||
|
limit: number = MAX_DRAFTS
|
||||||
|
): DraftCacheState => {
|
||||||
|
const effectiveLimit = Math.max(1, limit)
|
||||||
|
const drafts = { ...state.drafts, [path]: snapshot }
|
||||||
|
const order = touchEntry(state.order, path)
|
||||||
|
|
||||||
|
while (order.length > effectiveLimit) {
|
||||||
|
const oldest = order.shift()
|
||||||
|
if (!oldest) continue
|
||||||
|
if (oldest !== path) {
|
||||||
|
delete drafts[oldest]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createDraftCacheState(drafts, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const removeDraft = (
|
||||||
|
state: DraftCacheState,
|
||||||
|
path: string
|
||||||
|
): DraftCacheState => {
|
||||||
|
if (!(path in state.drafts)) return state
|
||||||
|
const drafts = { ...state.drafts }
|
||||||
|
delete drafts[path]
|
||||||
|
const order = state.order.filter((entry) => entry !== path)
|
||||||
|
return createDraftCacheState(drafts, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moveDraft = (
|
||||||
|
state: DraftCacheState,
|
||||||
|
oldPath: string,
|
||||||
|
newPath: string,
|
||||||
|
name: string
|
||||||
|
): DraftCacheState => {
|
||||||
|
const draft = state.drafts[oldPath]
|
||||||
|
if (!draft) return state
|
||||||
|
const updatedDraft = { ...draft, name }
|
||||||
|
const drafts = { ...state.drafts }
|
||||||
|
delete drafts[oldPath]
|
||||||
|
drafts[newPath] = updatedDraft
|
||||||
|
const order = touchEntry(
|
||||||
|
state.order.filter((entry) => entry !== oldPath && entry !== newPath),
|
||||||
|
newPath
|
||||||
|
)
|
||||||
|
return createDraftCacheState(drafts, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mostRecentDraftPath = (order: string[]): string | null =>
|
||||||
|
order.length ? order[order.length - 1] : null
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type * as I18n from 'vue-i18n'
|
||||||
|
|
||||||
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
|
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
|
||||||
|
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||||
|
import { defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||||
|
import { setStorageValue } from '@/scripts/utils'
|
||||||
|
|
||||||
|
vi.mock('@/platform/settings/settingStore', () => ({
|
||||||
|
useSettingStore: vi.fn(() => ({
|
||||||
|
get: vi.fn((key: string) =>
|
||||||
|
key === 'Comfy.Workflow.Persist' ? true : undefined
|
||||||
|
),
|
||||||
|
set: vi.fn()
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockToastAdd = vi.fn()
|
||||||
|
vi.mock('primevue', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
add: mockToastAdd
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof I18n>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => key
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadBlankWorkflow = vi.fn()
|
||||||
|
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
|
||||||
|
useWorkflowService: () => ({
|
||||||
|
loadBlankWorkflow
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock(
|
||||||
|
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
|
||||||
|
() => ({
|
||||||
|
useTemplateUrlLoader: () => ({
|
||||||
|
loadTemplateFromUrlParams: vi.fn()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const executeCommand = vi.fn()
|
||||||
|
vi.mock('@/stores/commandStore', () => ({
|
||||||
|
useCommandStore: () => ({
|
||||||
|
execute: executeCommand
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
type GraphChangedHandler = (() => void) | null
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
const state = {
|
||||||
|
graphChangedHandler: null as GraphChangedHandler,
|
||||||
|
currentGraph: {} as Record<string, unknown>
|
||||||
|
}
|
||||||
|
const serializeMock = vi.fn(() => state.currentGraph)
|
||||||
|
const loadGraphDataMock = vi.fn()
|
||||||
|
const apiMock = {
|
||||||
|
clientId: 'test-client',
|
||||||
|
initialClientId: 'test-client',
|
||||||
|
addEventListener: vi.fn((event: string, handler: () => void) => {
|
||||||
|
if (event === 'graphChanged') {
|
||||||
|
state.graphChangedHandler = handler
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
getUserData: vi.fn(),
|
||||||
|
storeUserData: vi.fn(),
|
||||||
|
listUserDataFullInfo: vi.fn(),
|
||||||
|
storeSetting: vi.fn(),
|
||||||
|
getSettings: vi.fn(),
|
||||||
|
deleteUserData: vi.fn(),
|
||||||
|
moveUserData: vi.fn(),
|
||||||
|
apiURL: vi.fn((path: string) => path)
|
||||||
|
}
|
||||||
|
return { state, serializeMock, loadGraphDataMock, apiMock }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/scripts/app', () => ({
|
||||||
|
app: {
|
||||||
|
graph: {
|
||||||
|
serialize: () => mocks.serializeMock()
|
||||||
|
},
|
||||||
|
rootGraph: {
|
||||||
|
serialize: () => mocks.serializeMock()
|
||||||
|
},
|
||||||
|
loadGraphData: (...args: unknown[]) => mocks.loadGraphDataMock(...args),
|
||||||
|
canvas: {}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/scripts/api', () => ({
|
||||||
|
api: mocks.apiMock
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useWorkflowPersistence', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'))
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockToastAdd.mockClear()
|
||||||
|
useWorkflowDraftStore().reset()
|
||||||
|
mocks.state.graphChangedHandler = null
|
||||||
|
mocks.state.currentGraph = { initial: true }
|
||||||
|
mocks.serializeMock.mockImplementation(() => mocks.state.currentGraph)
|
||||||
|
mocks.loadGraphDataMock.mockReset()
|
||||||
|
mocks.apiMock.clientId = 'test-client'
|
||||||
|
mocks.apiMock.initialClientId = 'test-client'
|
||||||
|
mocks.apiMock.addEventListener.mockImplementation(
|
||||||
|
(event: string, handler: () => void) => {
|
||||||
|
if (event === 'graphChanged') {
|
||||||
|
mocks.state.graphChangedHandler = handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mocks.apiMock.removeEventListener.mockImplementation(() => {})
|
||||||
|
mocks.apiMock.listUserDataFullInfo.mockResolvedValue([])
|
||||||
|
mocks.apiMock.getUserData.mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
text: () => Promise.resolve(defaultGraphJSON)
|
||||||
|
} as Response)
|
||||||
|
mocks.apiMock.apiURL.mockImplementation((path: string) => path)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists snapshots for multiple workflows', async () => {
|
||||||
|
const workflowStore = useWorkflowStore()
|
||||||
|
const workflowA = workflowStore.createTemporary('DraftA.json')
|
||||||
|
await workflowStore.openWorkflow(workflowA)
|
||||||
|
|
||||||
|
const persistence = useWorkflowPersistence()
|
||||||
|
expect(persistence).toBeDefined()
|
||||||
|
expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
|
||||||
|
|
||||||
|
const graphA = { title: 'A' }
|
||||||
|
mocks.state.currentGraph = graphA
|
||||||
|
mocks.state.graphChangedHandler!()
|
||||||
|
await vi.advanceTimersByTimeAsync(800)
|
||||||
|
|
||||||
|
const workflowB = workflowStore.createTemporary('DraftB.json')
|
||||||
|
await workflowStore.openWorkflow(workflowB)
|
||||||
|
const graphB = { title: 'B' }
|
||||||
|
mocks.state.currentGraph = graphB
|
||||||
|
mocks.state.graphChangedHandler!()
|
||||||
|
await vi.advanceTimersByTimeAsync(800)
|
||||||
|
|
||||||
|
const drafts = JSON.parse(
|
||||||
|
localStorage.getItem('Comfy.Workflow.Drafts') ?? '{}'
|
||||||
|
) as Record<string, { data: string; isTemporary: boolean }>
|
||||||
|
|
||||||
|
expect(Object.keys(drafts)).toEqual(
|
||||||
|
expect.arrayContaining(['workflows/DraftA.json', 'workflows/DraftB.json'])
|
||||||
|
)
|
||||||
|
expect(JSON.parse(drafts['workflows/DraftA.json'].data)).toEqual(graphA)
|
||||||
|
expect(JSON.parse(drafts['workflows/DraftB.json'].data)).toEqual(graphB)
|
||||||
|
expect(drafts['workflows/DraftA.json'].isTemporary).toBe(true)
|
||||||
|
expect(drafts['workflows/DraftB.json'].isTemporary).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('evicts least recently used drafts beyond the limit', async () => {
|
||||||
|
const workflowStore = useWorkflowStore()
|
||||||
|
useWorkflowPersistence()
|
||||||
|
expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
|
||||||
|
|
||||||
|
for (let i = 0; i < 33; i++) {
|
||||||
|
const workflow = workflowStore.createTemporary(`Draft${i}.json`)
|
||||||
|
await workflowStore.openWorkflow(workflow)
|
||||||
|
mocks.state.currentGraph = { index: i }
|
||||||
|
mocks.state.graphChangedHandler!()
|
||||||
|
await vi.advanceTimersByTimeAsync(800)
|
||||||
|
vi.setSystemTime(new Date(Date.now() + 60000))
|
||||||
|
}
|
||||||
|
|
||||||
|
const drafts = JSON.parse(
|
||||||
|
localStorage.getItem('Comfy.Workflow.Drafts') ?? '{}'
|
||||||
|
) as Record<string, any>
|
||||||
|
|
||||||
|
expect(Object.keys(drafts).length).toBe(32)
|
||||||
|
expect(drafts['workflows/Draft0.json']).toBeUndefined()
|
||||||
|
expect(drafts['workflows/Draft32.json']).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores temporary tabs from cached drafts', async () => {
|
||||||
|
const workflowStore = useWorkflowStore()
|
||||||
|
const draftStore = useWorkflowDraftStore()
|
||||||
|
const draftData = JSON.parse(defaultGraphJSON)
|
||||||
|
draftStore.saveDraft('workflows/Unsaved Workflow.json', {
|
||||||
|
data: JSON.stringify(draftData),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
name: 'Unsaved Workflow.json',
|
||||||
|
isTemporary: true
|
||||||
|
})
|
||||||
|
setStorageValue(
|
||||||
|
'Comfy.OpenWorkflowsPaths',
|
||||||
|
JSON.stringify(['workflows/Unsaved Workflow.json'])
|
||||||
|
)
|
||||||
|
setStorageValue('Comfy.ActiveWorkflowIndex', JSON.stringify(0))
|
||||||
|
|
||||||
|
const { restoreWorkflowTabsState } = useWorkflowPersistence()
|
||||||
|
restoreWorkflowTabsState()
|
||||||
|
|
||||||
|
const restored = workflowStore.getWorkflowByPath(
|
||||||
|
'workflows/Unsaved Workflow.json'
|
||||||
|
)
|
||||||
|
expect(restored).toBeTruthy()
|
||||||
|
expect(restored?.isTemporary).toBe(true)
|
||||||
|
expect(
|
||||||
|
workflowStore.openWorkflows.map((workflow) => workflow?.path)
|
||||||
|
).toContain('workflows/Unsaved Workflow.json')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when draft save fails', async () => {
|
||||||
|
const workflowStore = useWorkflowStore()
|
||||||
|
const draftStore = useWorkflowDraftStore()
|
||||||
|
|
||||||
|
const workflow = workflowStore.createTemporary('FailingDraft.json')
|
||||||
|
await workflowStore.openWorkflow(workflow)
|
||||||
|
|
||||||
|
useWorkflowPersistence()
|
||||||
|
expect(mocks.state.graphChangedHandler).toBeTypeOf('function')
|
||||||
|
|
||||||
|
vi.spyOn(draftStore, 'saveDraft').mockImplementation(() => {
|
||||||
|
throw new Error('Storage quota exceeded')
|
||||||
|
})
|
||||||
|
|
||||||
|
mocks.state.currentGraph = { title: 'Test' }
|
||||||
|
mocks.state.graphChangedHandler!()
|
||||||
|
await vi.advanceTimersByTimeAsync(800)
|
||||||
|
|
||||||
|
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
severity: 'error',
|
||||||
|
detail: expect.any(String)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useToast } from 'primevue'
|
||||||
import { tryOnScopeDispose } from '@vueuse/core'
|
import { tryOnScopeDispose } from '@vueuse/core'
|
||||||
import { computed, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -9,7 +11,11 @@ import {
|
|||||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
import {
|
||||||
|
ComfyWorkflow,
|
||||||
|
useWorkflowStore
|
||||||
|
} from '@/platform/workflow/management/stores/workflowStore'
|
||||||
|
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||||
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
|
import { useTemplateUrlLoader } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { app as comfyApp } from '@/scripts/app'
|
import { app as comfyApp } from '@/scripts/app'
|
||||||
@@ -17,12 +23,15 @@ import { getStorageValue, setStorageValue } from '@/scripts/utils'
|
|||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
|
|
||||||
export function useWorkflowPersistence() {
|
export function useWorkflowPersistence() {
|
||||||
|
const { t } = useI18n()
|
||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const templateUrlLoader = useTemplateUrlLoader()
|
const templateUrlLoader = useTemplateUrlLoader()
|
||||||
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
|
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
|
||||||
|
const workflowDraftStore = useWorkflowDraftStore()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const ensureTemplateQueryFromIntent = async () => {
|
const ensureTemplateQueryFromIntent = async () => {
|
||||||
hydratePreservedQuery(TEMPLATE_NAMESPACE)
|
hydratePreservedQuery(TEMPLATE_NAMESPACE)
|
||||||
@@ -42,14 +51,40 @@ export function useWorkflowPersistence() {
|
|||||||
settingStore.get('Comfy.Workflow.Persist')
|
settingStore.get('Comfy.Workflow.Persist')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const lastSavedJsonByPath = ref<Record<string, string>>({})
|
||||||
|
|
||||||
const persistCurrentWorkflow = () => {
|
const persistCurrentWorkflow = () => {
|
||||||
if (!workflowPersistenceEnabled.value) return
|
if (!workflowPersistenceEnabled.value) return
|
||||||
const workflow = JSON.stringify(comfyApp.rootGraph.serialize())
|
const activeWorkflow = workflowStore.activeWorkflow
|
||||||
|
if (!activeWorkflow) return
|
||||||
|
const graphData = comfyApp.rootGraph.serialize()
|
||||||
|
const workflowJson = JSON.stringify(graphData)
|
||||||
|
const workflowPath = activeWorkflow.path
|
||||||
|
|
||||||
|
if (workflowJson === lastSavedJsonByPath.value[workflowPath]) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('workflow', workflow)
|
workflowDraftStore.saveDraft(activeWorkflow.path, {
|
||||||
|
data: workflowJson,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
name: activeWorkflow.key,
|
||||||
|
isTemporary: activeWorkflow.isTemporary
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save draft', error)
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('g.error'),
|
||||||
|
detail: t('toastMessages.failedToSaveDraft'),
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem('workflow', workflowJson)
|
||||||
if (api.clientId) {
|
if (api.clientId) {
|
||||||
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
|
sessionStorage.setItem(`workflow:${api.clientId}`, workflowJson)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Only log our own keys and aggregate stats
|
// Only log our own keys and aggregate stats
|
||||||
@@ -57,7 +92,7 @@ export function useWorkflowPersistence() {
|
|||||||
(key) => key.startsWith('workflow:') || key === 'workflow'
|
(key) => key.startsWith('workflow:') || key === 'workflow'
|
||||||
)
|
)
|
||||||
console.error('QuotaExceededError details:', {
|
console.error('QuotaExceededError details:', {
|
||||||
workflowSizeKB: Math.round(workflow.length / 1024),
|
workflowSizeKB: Math.round(workflowJson.length / 1024),
|
||||||
totalStorageItems: Object.keys(sessionStorage).length,
|
totalStorageItems: Object.keys(sessionStorage).length,
|
||||||
ourWorkflowKeys: ourKeys.length,
|
ourWorkflowKeys: ourKeys.length,
|
||||||
ourWorkflowSizes: ourKeys.map((key) => ({
|
ourWorkflowSizes: ourKeys.map((key) => ({
|
||||||
@@ -68,33 +103,25 @@ export function useWorkflowPersistence() {
|
|||||||
})
|
})
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const loadWorkflowFromStorage = async (
|
lastSavedJsonByPath.value[workflowPath] = workflowJson
|
||||||
json: string | null,
|
|
||||||
workflowName: string | null
|
if (!activeWorkflow.isTemporary && !activeWorkflow.isModified) {
|
||||||
) => {
|
workflowDraftStore.removeDraft(activeWorkflow.path)
|
||||||
if (!json) return false
|
return
|
||||||
const workflow = JSON.parse(json)
|
}
|
||||||
await comfyApp.loadGraphData(workflow, true, true, workflowName)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadPreviousWorkflowFromStorage = async () => {
|
const loadPreviousWorkflowFromStorage = async () => {
|
||||||
const workflowName = getStorageValue('Comfy.PreviousWorkflow')
|
const workflowName = getStorageValue('Comfy.PreviousWorkflow')
|
||||||
const clientId = api.initialClientId ?? api.clientId
|
const preferredPath = workflowName
|
||||||
|
? `${ComfyWorkflow.basePath}${workflowName}`
|
||||||
// Try loading from session storage first
|
: null
|
||||||
if (clientId) {
|
return await workflowDraftStore.loadPersistedWorkflow({
|
||||||
const sessionWorkflow = sessionStorage.getItem(`workflow:${clientId}`)
|
workflowName,
|
||||||
if (await loadWorkflowFromStorage(sessionWorkflow, workflowName)) {
|
preferredPath,
|
||||||
return true
|
fallbackToLatestDraft: !workflowName
|
||||||
}
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to local storage
|
|
||||||
const localWorkflow = localStorage.getItem('workflow')
|
|
||||||
return await loadWorkflowFromStorage(localWorkflow, workflowName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDefaultWorkflow = async () => {
|
const loadDefaultWorkflow = async () => {
|
||||||
@@ -141,6 +168,7 @@ export function useWorkflowPersistence() {
|
|||||||
persistCurrentWorkflow()
|
persistCurrentWorkflow()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
api.addEventListener('graphChanged', persistCurrentWorkflow)
|
api.addEventListener('graphChanged', persistCurrentWorkflow)
|
||||||
|
|
||||||
// Clean up event listener when component unmounts
|
// Clean up event listener when component unmounts
|
||||||
@@ -158,24 +186,33 @@ export function useWorkflowPersistence() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const paths = openWorkflows.value
|
const paths = openWorkflows.value
|
||||||
.filter((workflow) => workflow?.isPersisted)
|
.map((workflow) => workflow?.path)
|
||||||
.map((workflow) => workflow.path)
|
.filter(
|
||||||
const activeIndex = openWorkflows.value.findIndex(
|
(path): path is string =>
|
||||||
(workflow) => workflow.path === activeWorkflow.value?.path
|
typeof path === 'string' && path.startsWith(ComfyWorkflow.basePath)
|
||||||
)
|
)
|
||||||
|
const activeIndex = paths.indexOf(activeWorkflow.value.path)
|
||||||
|
|
||||||
return { paths, activeIndex }
|
return { paths, activeIndex }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get storage values before setting watchers
|
// Get storage values before setting watchers
|
||||||
const storedWorkflows = JSON.parse(
|
const parsedWorkflows = JSON.parse(
|
||||||
getStorageValue('Comfy.OpenWorkflowsPaths') || '[]'
|
getStorageValue('Comfy.OpenWorkflowsPaths') || '[]'
|
||||||
)
|
)
|
||||||
const storedActiveIndex = JSON.parse(
|
const storedWorkflows = Array.isArray(parsedWorkflows)
|
||||||
|
? parsedWorkflows.filter(
|
||||||
|
(entry): entry is string => typeof entry === 'string'
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
const parsedIndex = JSON.parse(
|
||||||
getStorageValue('Comfy.ActiveWorkflowIndex') || '-1'
|
getStorageValue('Comfy.ActiveWorkflowIndex') || '-1'
|
||||||
)
|
)
|
||||||
|
const storedActiveIndex =
|
||||||
|
typeof parsedIndex === 'number' && Number.isFinite(parsedIndex)
|
||||||
|
? parsedIndex
|
||||||
|
: -1
|
||||||
watch(restoreState, ({ paths, activeIndex }) => {
|
watch(restoreState, ({ paths, activeIndex }) => {
|
||||||
if (workflowPersistenceEnabled.value) {
|
if (workflowPersistenceEnabled.value) {
|
||||||
setStorageValue('Comfy.OpenWorkflowsPaths', JSON.stringify(paths))
|
setStorageValue('Comfy.OpenWorkflowsPaths', JSON.stringify(paths))
|
||||||
@@ -186,12 +223,29 @@ export function useWorkflowPersistence() {
|
|||||||
const restoreWorkflowTabsState = () => {
|
const restoreWorkflowTabsState = () => {
|
||||||
if (!workflowPersistenceEnabled.value) return
|
if (!workflowPersistenceEnabled.value) return
|
||||||
const isRestorable = storedWorkflows?.length > 0 && storedActiveIndex >= 0
|
const isRestorable = storedWorkflows?.length > 0 && storedActiveIndex >= 0
|
||||||
if (isRestorable) {
|
if (!isRestorable) return
|
||||||
workflowStore.openWorkflowsInBackground({
|
|
||||||
left: storedWorkflows.slice(0, storedActiveIndex),
|
storedWorkflows.forEach((path: string) => {
|
||||||
right: storedWorkflows.slice(storedActiveIndex)
|
if (workflowStore.getWorkflowByPath(path)) return
|
||||||
})
|
const draft = workflowDraftStore.getDraft(path)
|
||||||
}
|
if (!draft?.isTemporary) return
|
||||||
|
try {
|
||||||
|
const workflowData = JSON.parse(draft.data)
|
||||||
|
workflowStore.createTemporary(draft.name, workflowData)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
'Failed to parse workflow draft, creating with default',
|
||||||
|
err
|
||||||
|
)
|
||||||
|
workflowDraftStore.removeDraft(path)
|
||||||
|
workflowStore.createTemporary(draft.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
workflowStore.openWorkflowsInBackground({
|
||||||
|
left: storedWorkflows.slice(0, storedActiveIndex),
|
||||||
|
right: storedWorkflows.slice(storedActiveIndex)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
161
src/platform/workflow/persistence/stores/workflowDraftStore.ts
Normal file
161
src/platform/workflow/persistence/stores/workflowDraftStore.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DraftCacheState,
|
||||||
|
WorkflowDraftSnapshot
|
||||||
|
} from '@/platform/workflow/persistence/base/draftCache'
|
||||||
|
import {
|
||||||
|
MAX_DRAFTS,
|
||||||
|
createDraftCacheState,
|
||||||
|
mostRecentDraftPath,
|
||||||
|
moveDraft as moveDraftEntry,
|
||||||
|
removeDraft as removeDraftEntry,
|
||||||
|
touchEntry,
|
||||||
|
upsertDraft
|
||||||
|
} from '@/platform/workflow/persistence/base/draftCache'
|
||||||
|
import { api } from '@/scripts/api'
|
||||||
|
import { app as comfyApp } from '@/scripts/app'
|
||||||
|
|
||||||
|
const DRAFTS_STORAGE_KEY = 'Comfy.Workflow.Drafts'
|
||||||
|
const ORDER_STORAGE_KEY = 'Comfy.Workflow.DraftOrder'
|
||||||
|
|
||||||
|
interface LoadPersistedWorkflowOptions {
|
||||||
|
workflowName: string | null
|
||||||
|
preferredPath?: string | null
|
||||||
|
fallbackToLatestDraft?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWorkflowDraftStore = defineStore('workflowDraft', () => {
|
||||||
|
const storedDrafts = useStorage<Record<string, WorkflowDraftSnapshot>>(
|
||||||
|
DRAFTS_STORAGE_KEY,
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
const storedOrder = useStorage<string[]>(ORDER_STORAGE_KEY, [])
|
||||||
|
|
||||||
|
const mostRecentDraft = computed(() => mostRecentDraftPath(storedOrder.value))
|
||||||
|
|
||||||
|
const currentState = (): DraftCacheState =>
|
||||||
|
createDraftCacheState(storedDrafts.value, storedOrder.value)
|
||||||
|
|
||||||
|
const updateState = (state: DraftCacheState) => {
|
||||||
|
storedDrafts.value = state.drafts
|
||||||
|
storedOrder.value = state.order
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveDraft = (path: string, snapshot: WorkflowDraftSnapshot) => {
|
||||||
|
try {
|
||||||
|
updateState(upsertDraft(currentState(), path, snapshot, MAX_DRAFTS))
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof DOMException &&
|
||||||
|
error.name === 'QuotaExceededError'
|
||||||
|
) {
|
||||||
|
const state = currentState()
|
||||||
|
if (state.order.length > 0) {
|
||||||
|
const oldestPath = state.order[0]
|
||||||
|
updateState(removeDraftEntry(state, oldestPath))
|
||||||
|
updateState(upsertDraft(currentState(), path, snapshot, MAX_DRAFTS))
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeDraft = (path: string) => {
|
||||||
|
updateState(removeDraftEntry(currentState(), path))
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveDraft = (oldPath: string, newPath: string, name: string) => {
|
||||||
|
updateState(moveDraftEntry(currentState(), oldPath, newPath, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
const markDraftUsed = (path: string) => {
|
||||||
|
if (!(path in storedDrafts.value)) return
|
||||||
|
storedOrder.value = touchEntry(storedOrder.value, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDraft = (path: string) => storedDrafts.value[path]
|
||||||
|
|
||||||
|
const tryLoadGraph = async (
|
||||||
|
payload: string | null,
|
||||||
|
workflowName: string | null,
|
||||||
|
onFailure?: () => void
|
||||||
|
) => {
|
||||||
|
if (!payload) return false
|
||||||
|
try {
|
||||||
|
const workflow = JSON.parse(payload)
|
||||||
|
await comfyApp.loadGraphData(
|
||||||
|
workflow,
|
||||||
|
/* clean= */ true,
|
||||||
|
/* restore_view= */ true,
|
||||||
|
workflowName
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load persisted workflow', err)
|
||||||
|
onFailure?.()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDraft = async (path: string) => {
|
||||||
|
const draft = getDraft(path)
|
||||||
|
if (!draft) return false
|
||||||
|
const loaded = await tryLoadGraph(draft.data, draft.name, () => {
|
||||||
|
removeDraft(path)
|
||||||
|
})
|
||||||
|
if (loaded) {
|
||||||
|
markDraftUsed(path)
|
||||||
|
}
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPersistedWorkflow = async (
|
||||||
|
options: LoadPersistedWorkflowOptions
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const {
|
||||||
|
workflowName,
|
||||||
|
preferredPath,
|
||||||
|
fallbackToLatestDraft = false
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (preferredPath && (await loadDraft(preferredPath))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preferredPath && fallbackToLatestDraft) {
|
||||||
|
const fallbackPath = mostRecentDraft.value
|
||||||
|
if (fallbackPath && (await loadDraft(fallbackPath))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = api.initialClientId ?? api.clientId
|
||||||
|
if (clientId) {
|
||||||
|
const sessionPayload = sessionStorage.getItem(`workflow:${clientId}`)
|
||||||
|
if (await tryLoadGraph(sessionPayload, workflowName)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPayload = localStorage.getItem('workflow')
|
||||||
|
return await tryLoadGraph(localPayload, workflowName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
saveDraft,
|
||||||
|
removeDraft,
|
||||||
|
moveDraft,
|
||||||
|
markDraftUsed,
|
||||||
|
getDraft,
|
||||||
|
loadPersistedWorkflow,
|
||||||
|
reset: () => {
|
||||||
|
updateState(createDraftCacheState())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user