mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +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.ColorPalette', 'light')
|
||||
const saveWorkflowInterval = 1000
|
||||
const workflow = await comfyPage.page.evaluate(() => {
|
||||
return localStorage.getItem('workflow')
|
||||
})
|
||||
for (const node of JSON.parse(workflow ?? '{}').nodes) {
|
||||
await comfyPage.nextFrame()
|
||||
const parsed = await (
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => {
|
||||
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.color) expect(node.color).not.toMatch(/hsla/)
|
||||
}
|
||||
|
||||
@@ -1749,6 +1749,7 @@
|
||||
"nothingToQueue": "Nothing to queue",
|
||||
"pleaseSelectOutputNodes": "Please select output nodes",
|
||||
"failedToQueue": "Failed to queue",
|
||||
"failedToSaveDraft": "Failed to save workflow draft",
|
||||
"failedExecutionPathResolution": "Could not resolve path to selected nodes",
|
||||
"no3dScene": "No 3D scene 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 { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
@@ -28,6 +29,7 @@ export const useWorkflowService = () => {
|
||||
const dialogService = useDialogService()
|
||||
const workflowThumbnail = useWorkflowThumbnail()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const workflowDraftStore = useWorkflowDraftStore()
|
||||
|
||||
async function getFilename(defaultName: string): Promise<string | null> {
|
||||
if (settingStore.get('Comfy.PromptFilename')) {
|
||||
@@ -291,6 +293,27 @@ export const useWorkflowService = () => {
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
if (activeWorkflow) {
|
||||
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
|
||||
void workflowThumbnail.storeThumbnail(activeWorkflow)
|
||||
domWidgetStore.clear()
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
@@ -66,6 +67,9 @@ describe('useWorkflowStore', () => {
|
||||
store = useWorkflowStore()
|
||||
bookmarkStore = useWorkflowBookmarkStore()
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
useWorkflowDraftStore().reset()
|
||||
|
||||
// Add default mock implementations
|
||||
vi.mocked(api.getUserData).mockResolvedValue({
|
||||
@@ -235,6 +239,60 @@ describe('useWorkflowStore', () => {
|
||||
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 () => {
|
||||
await syncRemoteWorkflows(['a.json', 'b.json'])
|
||||
|
||||
@@ -413,6 +471,20 @@ describe('useWorkflowStore', () => {
|
||||
expect(store.isOpen(workflow)).toBe(false)
|
||||
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', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
|
||||
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
@@ -86,6 +87,28 @@ export class ComfyWorkflow extends UserFile {
|
||||
override async load({ force = false }: { force?: boolean } = {}): Promise<
|
||||
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 })
|
||||
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')
|
||||
}
|
||||
|
||||
// Note: originalContent is populated by super.load()
|
||||
this.changeTracker = markRaw(
|
||||
new ChangeTracker(
|
||||
this,
|
||||
/* initialState= */ JSON.parse(this.originalContent)
|
||||
)
|
||||
)
|
||||
const initialState = JSON.parse(this.originalContent)
|
||||
this.changeTracker = markRaw(new ChangeTracker(this, initialState))
|
||||
if (draftState && draftContent) {
|
||||
this.changeTracker.activeState = draftState
|
||||
this.content = draftContent
|
||||
this._isModified = true
|
||||
draftStore.markDraftUsed(this.path)
|
||||
}
|
||||
return this as this & LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
@@ -109,12 +133,14 @@ export class ComfyWorkflow extends UserFile {
|
||||
}
|
||||
|
||||
override async save() {
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
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
|
||||
draftStore.removeDraft(this.path)
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -124,8 +150,11 @@ export class ComfyWorkflow extends UserFile {
|
||||
* @returns this
|
||||
*/
|
||||
override async saveAs(path: string) {
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
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> {
|
||||
@@ -436,6 +465,8 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
if (workflow.isTemporary) {
|
||||
// Clear thumbnail when temporary workflow is closed
|
||||
clearThumbnail(workflow.key)
|
||||
// Clear draft when unsaved workflow tab is closed
|
||||
useWorkflowDraftStore().removeDraft(workflow.path)
|
||||
delete workflowLookup.value[workflow.path]
|
||||
} else {
|
||||
workflow.unload()
|
||||
@@ -565,6 +596,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
const oldPath = workflow.path
|
||||
const oldKey = workflow.key
|
||||
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
|
||||
const draftStore = useWorkflowDraftStore()
|
||||
|
||||
const openIndex = detachWorkflow(workflow)
|
||||
// Perform the actual rename operation first
|
||||
@@ -574,6 +606,8 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
attachWorkflow(workflow, openIndex)
|
||||
}
|
||||
|
||||
draftStore.moveDraft(oldPath, newPath, workflow.key)
|
||||
|
||||
// Move thumbnail from old key to new key (using workflow keys, not full paths)
|
||||
const newKey = workflow.key
|
||||
moveWorkflowThumbnail(oldKey, newKey)
|
||||
@@ -591,6 +625,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
isBusy.value = true
|
||||
try {
|
||||
await workflow.delete()
|
||||
useWorkflowDraftStore().removeDraft(workflow.path)
|
||||
if (bookmarkStore.isBookmarked(workflow.path)) {
|
||||
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 { computed, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
@@ -9,7 +11,11 @@ import {
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
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 { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
@@ -17,12 +23,15 @@ import { getStorageValue, setStorageValue } from '@/scripts/utils'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
export function useWorkflowPersistence() {
|
||||
const { t } = useI18n()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingStore = useSettingStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const templateUrlLoader = useTemplateUrlLoader()
|
||||
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
|
||||
const workflowDraftStore = useWorkflowDraftStore()
|
||||
const toast = useToast()
|
||||
|
||||
const ensureTemplateQueryFromIntent = async () => {
|
||||
hydratePreservedQuery(TEMPLATE_NAMESPACE)
|
||||
@@ -42,14 +51,40 @@ export function useWorkflowPersistence() {
|
||||
settingStore.get('Comfy.Workflow.Persist')
|
||||
)
|
||||
|
||||
const lastSavedJsonByPath = ref<Record<string, string>>({})
|
||||
|
||||
const persistCurrentWorkflow = () => {
|
||||
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 {
|
||||
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) {
|
||||
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
|
||||
sessionStorage.setItem(`workflow:${api.clientId}`, workflowJson)
|
||||
}
|
||||
} catch (error) {
|
||||
// Only log our own keys and aggregate stats
|
||||
@@ -57,7 +92,7 @@ export function useWorkflowPersistence() {
|
||||
(key) => key.startsWith('workflow:') || key === 'workflow'
|
||||
)
|
||||
console.error('QuotaExceededError details:', {
|
||||
workflowSizeKB: Math.round(workflow.length / 1024),
|
||||
workflowSizeKB: Math.round(workflowJson.length / 1024),
|
||||
totalStorageItems: Object.keys(sessionStorage).length,
|
||||
ourWorkflowKeys: ourKeys.length,
|
||||
ourWorkflowSizes: ourKeys.map((key) => ({
|
||||
@@ -68,33 +103,25 @@ export function useWorkflowPersistence() {
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const loadWorkflowFromStorage = async (
|
||||
json: string | null,
|
||||
workflowName: string | null
|
||||
) => {
|
||||
if (!json) return false
|
||||
const workflow = JSON.parse(json)
|
||||
await comfyApp.loadGraphData(workflow, true, true, workflowName)
|
||||
return true
|
||||
lastSavedJsonByPath.value[workflowPath] = workflowJson
|
||||
|
||||
if (!activeWorkflow.isTemporary && !activeWorkflow.isModified) {
|
||||
workflowDraftStore.removeDraft(activeWorkflow.path)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const loadPreviousWorkflowFromStorage = async () => {
|
||||
const workflowName = getStorageValue('Comfy.PreviousWorkflow')
|
||||
const clientId = api.initialClientId ?? api.clientId
|
||||
|
||||
// Try loading from session storage first
|
||||
if (clientId) {
|
||||
const sessionWorkflow = sessionStorage.getItem(`workflow:${clientId}`)
|
||||
if (await loadWorkflowFromStorage(sessionWorkflow, workflowName)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to local storage
|
||||
const localWorkflow = localStorage.getItem('workflow')
|
||||
return await loadWorkflowFromStorage(localWorkflow, workflowName)
|
||||
const preferredPath = workflowName
|
||||
? `${ComfyWorkflow.basePath}${workflowName}`
|
||||
: null
|
||||
return await workflowDraftStore.loadPersistedWorkflow({
|
||||
workflowName,
|
||||
preferredPath,
|
||||
fallbackToLatestDraft: !workflowName
|
||||
})
|
||||
}
|
||||
|
||||
const loadDefaultWorkflow = async () => {
|
||||
@@ -141,6 +168,7 @@ export function useWorkflowPersistence() {
|
||||
persistCurrentWorkflow()
|
||||
}
|
||||
)
|
||||
|
||||
api.addEventListener('graphChanged', persistCurrentWorkflow)
|
||||
|
||||
// Clean up event listener when component unmounts
|
||||
@@ -158,24 +186,33 @@ export function useWorkflowPersistence() {
|
||||
}
|
||||
|
||||
const paths = openWorkflows.value
|
||||
.filter((workflow) => workflow?.isPersisted)
|
||||
.map((workflow) => workflow.path)
|
||||
const activeIndex = openWorkflows.value.findIndex(
|
||||
(workflow) => workflow.path === activeWorkflow.value?.path
|
||||
)
|
||||
.map((workflow) => workflow?.path)
|
||||
.filter(
|
||||
(path): path is string =>
|
||||
typeof path === 'string' && path.startsWith(ComfyWorkflow.basePath)
|
||||
)
|
||||
const activeIndex = paths.indexOf(activeWorkflow.value.path)
|
||||
|
||||
return { paths, activeIndex }
|
||||
}
|
||||
)
|
||||
|
||||
// Get storage values before setting watchers
|
||||
const storedWorkflows = JSON.parse(
|
||||
const parsedWorkflows = JSON.parse(
|
||||
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'
|
||||
)
|
||||
|
||||
const storedActiveIndex =
|
||||
typeof parsedIndex === 'number' && Number.isFinite(parsedIndex)
|
||||
? parsedIndex
|
||||
: -1
|
||||
watch(restoreState, ({ paths, activeIndex }) => {
|
||||
if (workflowPersistenceEnabled.value) {
|
||||
setStorageValue('Comfy.OpenWorkflowsPaths', JSON.stringify(paths))
|
||||
@@ -186,12 +223,29 @@ export function useWorkflowPersistence() {
|
||||
const restoreWorkflowTabsState = () => {
|
||||
if (!workflowPersistenceEnabled.value) return
|
||||
const isRestorable = storedWorkflows?.length > 0 && storedActiveIndex >= 0
|
||||
if (isRestorable) {
|
||||
workflowStore.openWorkflowsInBackground({
|
||||
left: storedWorkflows.slice(0, storedActiveIndex),
|
||||
right: storedWorkflows.slice(storedActiveIndex)
|
||||
})
|
||||
}
|
||||
if (!isRestorable) return
|
||||
|
||||
storedWorkflows.forEach((path: string) => {
|
||||
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 {
|
||||
|
||||
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