mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
1 Commits
ext-api/i-
...
glary/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b88966d92 |
@@ -152,16 +152,6 @@ function createWrapper({
|
||||
return { container, unmount, user }
|
||||
}
|
||||
|
||||
function getLegacyCommandsContainer(container: Element): HTMLElement {
|
||||
const legacyContainer = container.querySelector(
|
||||
'[data-testid="legacy-topbar-container"]'
|
||||
)
|
||||
if (!(legacyContainer instanceof HTMLElement)) {
|
||||
throw new Error('Expected legacy commands container to be present')
|
||||
}
|
||||
return legacyContainer
|
||||
}
|
||||
|
||||
function createJob(id: string, status: JobStatus): JobListItem {
|
||||
return {
|
||||
id,
|
||||
@@ -542,73 +532,4 @@ describe('TopMenuSection', () => {
|
||||
|
||||
expect(container.querySelector('span.bg-red-500')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('coalesces legacy topbar mutation scans to one check per frame', async () => {
|
||||
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
|
||||
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
rafCallbacks.push(cb)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) => {
|
||||
if (key === 'Comfy.UseNewMenu') return 'Top'
|
||||
if (key === 'Comfy.UI.TabBarLayout') return 'Integrated'
|
||||
if (key === 'Comfy.RightSidePanel.IsOpen') return true
|
||||
return undefined
|
||||
})
|
||||
|
||||
const { container, unmount } = createWrapper({
|
||||
pinia,
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
try {
|
||||
await nextTick()
|
||||
|
||||
const actionbarContainer = container.querySelector('.actionbar-container')
|
||||
expect(actionbarContainer).not.toBeNull()
|
||||
expect(actionbarContainer!.classList).toContain('w-0')
|
||||
|
||||
const legacyContainer = getLegacyCommandsContainer(container)
|
||||
const querySpy = vi.spyOn(legacyContainer, 'querySelector')
|
||||
|
||||
if (rafCallbacks.length > 0) {
|
||||
const initialCallbacks = [...rafCallbacks]
|
||||
rafCallbacks.length = 0
|
||||
initialCallbacks.forEach((callback) => callback(0))
|
||||
await nextTick()
|
||||
}
|
||||
querySpy.mockClear()
|
||||
querySpy.mockReturnValue(document.createElement('div'))
|
||||
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const outer = document.createElement('div')
|
||||
const inner = document.createElement('div')
|
||||
inner.textContent = `legacy-${index}`
|
||||
outer.appendChild(inner)
|
||||
legacyContainer.appendChild(outer)
|
||||
}
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(rafCallbacks.length).toBeGreaterThan(0)
|
||||
})
|
||||
expect(querySpy).not.toHaveBeenCalled()
|
||||
|
||||
const callbacks = [...rafCallbacks]
|
||||
rafCallbacks.length = 0
|
||||
callbacks.forEach((callback) => callback(0))
|
||||
await nextTick()
|
||||
|
||||
expect(querySpy).toHaveBeenCalledTimes(1)
|
||||
expect(actionbarContainer!.classList).toContain('px-2')
|
||||
} finally {
|
||||
unmount()
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,12 +36,6 @@
|
||||
|
||||
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
data-testid="legacy-topbar-container"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
|
||||
<ComfyActionbar
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
@@ -127,9 +121,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
@@ -148,7 +142,6 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
|
||||
import { useQueueUIStore } from '@/stores/queueStore'
|
||||
@@ -196,7 +189,6 @@ const isActionbarFloating = computed(
|
||||
*/
|
||||
const hasDockedButtons = computed(() => {
|
||||
if (actionBarButtonStore.buttons.length > 0) return true
|
||||
if (hasLegacyContent.value) return true
|
||||
if (isLoggedIn.value && !isIntegratedTabBar.value) return true
|
||||
if (isDesktop && !isIntegratedTabBar.value) return true
|
||||
if (isCloud && flags.workflowSharingEnabled) return true
|
||||
@@ -282,51 +274,6 @@ const rightSidePanelTooltipConfig = computed(() =>
|
||||
buildTooltipConfig(t('rightSidePanel.togglePanel'))
|
||||
)
|
||||
|
||||
// Maintain support for legacy topbar elements attached by custom scripts
|
||||
const legacyCommandsContainerRef = ref<HTMLElement>()
|
||||
const hasLegacyContent = ref(false)
|
||||
let legacyContentCheckRafId: number | null = null
|
||||
|
||||
function checkLegacyContent() {
|
||||
const el = legacyCommandsContainerRef.value
|
||||
if (!el) {
|
||||
hasLegacyContent.value = false
|
||||
return
|
||||
}
|
||||
// Mirror the CSS: [&:not(:has(*>*:not(:empty)))]:hidden
|
||||
hasLegacyContent.value =
|
||||
el.querySelector(':scope > * > *:not(:empty)') !== null
|
||||
}
|
||||
|
||||
function scheduleLegacyContentCheck() {
|
||||
if (legacyContentCheckRafId !== null) return
|
||||
|
||||
legacyContentCheckRafId = requestAnimationFrame(() => {
|
||||
legacyContentCheckRafId = null
|
||||
checkLegacyContent()
|
||||
})
|
||||
}
|
||||
|
||||
useMutationObserver(legacyCommandsContainerRef, scheduleLegacyContentCheck, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (legacyCommandsContainerRef.value) {
|
||||
app.menu.element.style.width = 'fit-content'
|
||||
legacyCommandsContainerRef.value.appendChild(app.menu.element)
|
||||
checkLegacyContent()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (legacyContentCheckRafId === null) return
|
||||
|
||||
cancelAnimationFrame(legacyContentCheckRafId)
|
||||
legacyContentCheckRafId = null
|
||||
})
|
||||
|
||||
const openCustomNodeManager = async () => {
|
||||
try {
|
||||
await managerState.openManager({
|
||||
|
||||
@@ -110,10 +110,6 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('../migration/migrateV1toV2', () => ({
|
||||
migrateV1toV2: vi.fn()
|
||||
}))
|
||||
|
||||
type GraphChangedHandler = (() => void) | null
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* - Uses V2 draft store with per-draft keys
|
||||
* - Uses tab state composable for session pointers
|
||||
* - Adds 512ms debounce on graph change persistence
|
||||
* - Runs V1→V2 migration on first load
|
||||
*/
|
||||
|
||||
import { debounce } from 'es-toolkit'
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { PERSIST_DEBOUNCE_MS } from '../base/draftTypes'
|
||||
import { clearAllV2Storage } from '../base/storageIO'
|
||||
import { migrateV1toV2 } from '../migration/migrateV1toV2'
|
||||
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
|
||||
import { useWorkflowTabState } from './useWorkflowTabState'
|
||||
import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader'
|
||||
@@ -53,9 +51,6 @@ export function useWorkflowPersistenceV2() {
|
||||
const toast = useToast()
|
||||
const { onUserLogout } = useCurrentUser()
|
||||
|
||||
// Run migration on module load, passing clientId for tab state migration
|
||||
migrateV1toV2(undefined, api.clientId ?? api.initialClientId ?? undefined)
|
||||
|
||||
// Clear workflow persistence storage when user signs out (cloud only)
|
||||
onUserLogout(() => {
|
||||
if (isCloud) {
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { hashPath } from '../base/hashUtil'
|
||||
import { readOpenPaths } from '../base/storageIO'
|
||||
import {
|
||||
cleanupV1Data,
|
||||
getMigrationStatus,
|
||||
isV2MigrationComplete,
|
||||
migrateV1toV2
|
||||
} from './migrateV1toV2'
|
||||
|
||||
describe('migrateV1toV2', () => {
|
||||
const workspaceId = 'test-workspace'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
function setV1Data(
|
||||
drafts: Record<
|
||||
string,
|
||||
{ data: string; updatedAt: number; name: string; isTemporary: boolean }
|
||||
>,
|
||||
order: string[]
|
||||
) {
|
||||
localStorage.setItem(
|
||||
`Comfy.Workflow.Drafts:${workspaceId}`,
|
||||
JSON.stringify(drafts)
|
||||
)
|
||||
localStorage.setItem(
|
||||
`Comfy.Workflow.DraftOrder:${workspaceId}`,
|
||||
JSON.stringify(order)
|
||||
)
|
||||
}
|
||||
|
||||
describe('isV2MigrationComplete', () => {
|
||||
it('returns false when no V2 index exists', () => {
|
||||
expect(isV2MigrationComplete(workspaceId)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when V2 index exists', () => {
|
||||
localStorage.setItem(
|
||||
`Comfy.Workflow.DraftIndex.v2:${workspaceId}`,
|
||||
JSON.stringify({ v: 2, order: [], entries: {}, updatedAt: Date.now() })
|
||||
)
|
||||
expect(isV2MigrationComplete(workspaceId)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrateV1toV2', () => {
|
||||
it('returns -1 if V2 already exists', () => {
|
||||
localStorage.setItem(
|
||||
`Comfy.Workflow.DraftIndex.v2:${workspaceId}`,
|
||||
JSON.stringify({ v: 2, order: [], entries: {}, updatedAt: Date.now() })
|
||||
)
|
||||
|
||||
expect(migrateV1toV2(workspaceId)).toBe(-1)
|
||||
})
|
||||
|
||||
it('creates empty V2 index if no V1 data', () => {
|
||||
expect(migrateV1toV2(workspaceId)).toBe(0)
|
||||
|
||||
const indexJson = localStorage.getItem(
|
||||
`Comfy.Workflow.DraftIndex.v2:${workspaceId}`
|
||||
)
|
||||
expect(indexJson).not.toBeNull()
|
||||
|
||||
const index = JSON.parse(indexJson!)
|
||||
expect(index.v).toBe(2)
|
||||
expect(index.order).toEqual([])
|
||||
})
|
||||
|
||||
it('migrates V1 drafts to V2 format', () => {
|
||||
const v1Drafts = {
|
||||
'workflows/a.json': {
|
||||
data: '{"nodes":[1]}',
|
||||
updatedAt: 1000,
|
||||
name: 'a',
|
||||
isTemporary: true
|
||||
},
|
||||
'workflows/b.json': {
|
||||
data: '{"nodes":[2]}',
|
||||
updatedAt: 2000,
|
||||
name: 'b',
|
||||
isTemporary: false
|
||||
}
|
||||
}
|
||||
setV1Data(v1Drafts, ['workflows/a.json', 'workflows/b.json'])
|
||||
|
||||
const migrated = migrateV1toV2(workspaceId)
|
||||
expect(migrated).toBe(2)
|
||||
|
||||
// Check V2 index
|
||||
const indexJson = localStorage.getItem(
|
||||
`Comfy.Workflow.DraftIndex.v2:${workspaceId}`
|
||||
)
|
||||
const index = JSON.parse(indexJson!)
|
||||
expect(index.order).toHaveLength(2)
|
||||
|
||||
// Check payloads
|
||||
const keyA = hashPath('workflows/a.json')
|
||||
const keyB = hashPath('workflows/b.json')
|
||||
|
||||
const payloadA = localStorage.getItem(
|
||||
`Comfy.Workflow.Draft.v2:${workspaceId}:${keyA}`
|
||||
)
|
||||
const payloadB = localStorage.getItem(
|
||||
`Comfy.Workflow.Draft.v2:${workspaceId}:${keyB}`
|
||||
)
|
||||
|
||||
expect(payloadA).not.toBeNull()
|
||||
expect(payloadB).not.toBeNull()
|
||||
|
||||
expect(JSON.parse(payloadA!).data).toBe('{"nodes":[1]}')
|
||||
expect(JSON.parse(payloadB!).data).toBe('{"nodes":[2]}')
|
||||
})
|
||||
|
||||
it('preserves LRU order during migration', () => {
|
||||
const v1Drafts = {
|
||||
'workflows/first.json': {
|
||||
data: '{}',
|
||||
updatedAt: 1000,
|
||||
name: 'first',
|
||||
isTemporary: true
|
||||
},
|
||||
'workflows/second.json': {
|
||||
data: '{}',
|
||||
updatedAt: 2000,
|
||||
name: 'second',
|
||||
isTemporary: true
|
||||
},
|
||||
'workflows/third.json': {
|
||||
data: '{}',
|
||||
updatedAt: 3000,
|
||||
name: 'third',
|
||||
isTemporary: true
|
||||
}
|
||||
}
|
||||
setV1Data(v1Drafts, [
|
||||
'workflows/first.json',
|
||||
'workflows/second.json',
|
||||
'workflows/third.json'
|
||||
])
|
||||
|
||||
migrateV1toV2(workspaceId)
|
||||
|
||||
const indexJson = localStorage.getItem(
|
||||
`Comfy.Workflow.DraftIndex.v2:${workspaceId}`
|
||||
)
|
||||
const index = JSON.parse(indexJson!)
|
||||
|
||||
// Order should be preserved (oldest to newest)
|
||||
const expectedOrder = [
|
||||
hashPath('workflows/first.json'),
|
||||
hashPath('workflows/second.json'),
|
||||
hashPath('workflows/third.json')
|
||||
]
|
||||
expect(index.order).toEqual(expectedOrder)
|
||||
})
|
||||
|
||||
it('keeps V1 data intact after migration', () => {
|
||||
const v1Drafts = {
|
||||
'workflows/test.json': {
|
||||
data: '{}',
|
||||
updatedAt: 1000,
|
||||
name: 'test',
|
||||
isTemporary: true
|
||||
}
|
||||
}
|
||||
setV1Data(v1Drafts, ['workflows/test.json'])
|
||||
|
||||
migrateV1toV2(workspaceId)
|
||||
|
||||
// V1 data should still exist
|
||||
expect(
|
||||
localStorage.getItem(`Comfy.Workflow.Drafts:${workspaceId}`)
|
||||
).not.toBeNull()
|
||||
expect(
|
||||
localStorage.getItem(`Comfy.Workflow.DraftOrder:${workspaceId}`)
|
||||
).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanupV1Data', () => {
|
||||
it('removes V1 keys', () => {
|
||||
setV1Data(
|
||||
{
|
||||
'workflows/test.json': {
|
||||
data: '{}',
|
||||
updatedAt: 1,
|
||||
name: 'test',
|
||||
isTemporary: true
|
||||
}
|
||||
},
|
||||
['workflows/test.json']
|
||||
)
|
||||
|
||||
cleanupV1Data(workspaceId)
|
||||
|
||||
expect(
|
||||
localStorage.getItem(`Comfy.Workflow.Drafts:${workspaceId}`)
|
||||
).toBeNull()
|
||||
expect(
|
||||
localStorage.getItem(`Comfy.Workflow.DraftOrder:${workspaceId}`)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('V1 tab state migration', () => {
|
||||
it('migrates V1 tab state pointers to V2 format', () => {
|
||||
// Simulate V1 state: user had 3 workflows open, 2nd was active
|
||||
const v1Drafts = {
|
||||
'workflows/a.json': {
|
||||
data: '{"nodes":[1]}',
|
||||
updatedAt: 1000,
|
||||
name: 'a',
|
||||
isTemporary: true
|
||||
},
|
||||
'workflows/b.json': {
|
||||
data: '{"nodes":[2]}',
|
||||
updatedAt: 2000,
|
||||
name: 'b',
|
||||
isTemporary: true
|
||||
},
|
||||
'workflows/c.json': {
|
||||
data: '{"nodes":[3]}',
|
||||
updatedAt: 3000,
|
||||
name: 'c',
|
||||
isTemporary: false
|
||||
}
|
||||
}
|
||||
setV1Data(v1Drafts, [
|
||||
'workflows/a.json',
|
||||
'workflows/b.json',
|
||||
'workflows/c.json'
|
||||
])
|
||||
|
||||
// V1 tab state stored by setStorageValue (localStorage fallback keys)
|
||||
localStorage.setItem(
|
||||
'Comfy.OpenWorkflowsPaths',
|
||||
JSON.stringify([
|
||||
'workflows/a.json',
|
||||
'workflows/b.json',
|
||||
'workflows/c.json'
|
||||
])
|
||||
)
|
||||
localStorage.setItem('Comfy.ActiveWorkflowIndex', JSON.stringify(1))
|
||||
|
||||
// Run migration (simulating upgrade from pre-V2 to V2)
|
||||
const clientId = 'client-123'
|
||||
const result = migrateV1toV2(workspaceId, clientId)
|
||||
expect(result).toBe(3)
|
||||
|
||||
// V2 tab state should be readable via the V2 API
|
||||
const openPaths = readOpenPaths(clientId, workspaceId)
|
||||
|
||||
// This is the bug: V1 tab state is NOT migrated, so openPaths is null
|
||||
expect(openPaths).not.toBeNull()
|
||||
expect(openPaths!.paths).toEqual([
|
||||
'workflows/a.json',
|
||||
'workflows/b.json',
|
||||
'workflows/c.json'
|
||||
])
|
||||
expect(openPaths!.activeIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('does not migrate tab state when V1 tab state keys are absent', () => {
|
||||
const v1Drafts = {
|
||||
'workflows/a.json': {
|
||||
data: '{}',
|
||||
updatedAt: 1000,
|
||||
name: 'a',
|
||||
isTemporary: true
|
||||
}
|
||||
}
|
||||
setV1Data(v1Drafts, ['workflows/a.json'])
|
||||
|
||||
// No V1 tab state keys in localStorage
|
||||
migrateV1toV2(workspaceId)
|
||||
|
||||
const openPaths = readOpenPaths('any-client-id', workspaceId)
|
||||
|
||||
// No tab state to migrate — should remain null
|
||||
expect(openPaths).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMigrationStatus', () => {
|
||||
it('reports correct status', () => {
|
||||
setV1Data(
|
||||
{
|
||||
'workflows/a.json': {
|
||||
data: '{}',
|
||||
updatedAt: 1,
|
||||
name: 'a',
|
||||
isTemporary: true
|
||||
},
|
||||
'workflows/b.json': {
|
||||
data: '{}',
|
||||
updatedAt: 2,
|
||||
name: 'b',
|
||||
isTemporary: true
|
||||
}
|
||||
},
|
||||
['workflows/a.json', 'workflows/b.json']
|
||||
)
|
||||
|
||||
const status = getMigrationStatus(workspaceId)
|
||||
expect(status.v1Exists).toBe(true)
|
||||
expect(status.v2Exists).toBe(false)
|
||||
expect(status.v1DraftCount).toBe(2)
|
||||
expect(status.v2DraftCount).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* V1 to V2 Migration
|
||||
*
|
||||
* Migrates draft data from V1 blob format to V2 per-draft keys.
|
||||
* Runs once on first load if V2 index doesn't exist.
|
||||
* Keeps V1 data intact for rollback until 2026-07-15.
|
||||
*/
|
||||
|
||||
import type { DraftIndexV2 } from '../base/draftTypes'
|
||||
import { upsertEntry, createEmptyIndex } from '../base/draftCacheV2'
|
||||
import { hashPath } from '../base/hashUtil'
|
||||
import { getWorkspaceId } from '../base/storageKeys'
|
||||
import {
|
||||
readIndex,
|
||||
writeIndex,
|
||||
writeOpenPaths,
|
||||
writePayload
|
||||
} from '../base/storageIO'
|
||||
|
||||
/**
|
||||
* V1 draft snapshot structure (from draftCache.ts)
|
||||
*/
|
||||
interface V1DraftSnapshot {
|
||||
data: string
|
||||
updatedAt: number
|
||||
name: string
|
||||
isTemporary: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* V1 storage keys - workspace-scoped blob format
|
||||
*/
|
||||
const V1_KEYS = {
|
||||
drafts: (workspaceId: string) => `Comfy.Workflow.Drafts:${workspaceId}`,
|
||||
order: (workspaceId: string) => `Comfy.Workflow.DraftOrder:${workspaceId}`,
|
||||
openPaths: 'Comfy.OpenWorkflowsPaths',
|
||||
activeIndex: 'Comfy.ActiveWorkflowIndex'
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if V2 migration has been completed for the current workspace.
|
||||
*/
|
||||
export function isV2MigrationComplete(workspaceId: string): boolean {
|
||||
const v2Index = readIndex(workspaceId)
|
||||
return v2Index !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads V1 drafts from localStorage.
|
||||
*/
|
||||
function readV1Drafts(
|
||||
workspaceId: string
|
||||
): { drafts: Record<string, V1DraftSnapshot>; order: string[] } | null {
|
||||
try {
|
||||
const draftsJson = localStorage.getItem(V1_KEYS.drafts(workspaceId))
|
||||
const orderJson = localStorage.getItem(V1_KEYS.order(workspaceId))
|
||||
|
||||
if (!draftsJson) return null
|
||||
|
||||
const drafts = JSON.parse(draftsJson) as Record<string, V1DraftSnapshot>
|
||||
const order = orderJson ? (JSON.parse(orderJson) as string[]) : []
|
||||
|
||||
return { drafts, order }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates V1 drafts to V2 format.
|
||||
*
|
||||
* @returns Number of drafts migrated, or -1 if migration not needed/failed
|
||||
*/
|
||||
export function migrateV1toV2(
|
||||
workspaceId: string = getWorkspaceId(),
|
||||
clientId?: string
|
||||
): number {
|
||||
// Check if V2 already exists
|
||||
if (isV2MigrationComplete(workspaceId)) {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Read V1 data
|
||||
const v1Data = readV1Drafts(workspaceId)
|
||||
if (!v1Data) {
|
||||
// No V1 data to migrate - create empty V2 index
|
||||
if (!writeIndex(workspaceId, createEmptyIndex())) return -1
|
||||
return 0
|
||||
}
|
||||
|
||||
// Build V2 index and write payloads
|
||||
let index: DraftIndexV2 = createEmptyIndex()
|
||||
let migrated = 0
|
||||
|
||||
// Process in order (oldest first) to maintain LRU order
|
||||
for (const path of v1Data.order) {
|
||||
const draft = v1Data.drafts[path]
|
||||
if (!draft) continue
|
||||
|
||||
const draftKey = hashPath(path)
|
||||
|
||||
// Write payload
|
||||
const payloadWritten = writePayload(workspaceId, draftKey, {
|
||||
data: draft.data,
|
||||
updatedAt: draft.updatedAt
|
||||
})
|
||||
|
||||
if (!payloadWritten) {
|
||||
console.warn(`[V2 Migration] Failed to write payload for ${path}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update index
|
||||
const { index: newIndex } = upsertEntry(index, path, {
|
||||
name: draft.name,
|
||||
isTemporary: draft.isTemporary,
|
||||
updatedAt: draft.updatedAt
|
||||
})
|
||||
index = newIndex
|
||||
migrated++
|
||||
}
|
||||
|
||||
// Write final index
|
||||
if (!writeIndex(workspaceId, index)) {
|
||||
console.error('[V2 Migration] Failed to write index')
|
||||
return -1
|
||||
}
|
||||
|
||||
// Migrate V1 tab state pointers to V2 sessionStorage format.
|
||||
// V1 used setStorageValue which stored tab state in localStorage as fallback.
|
||||
// V2 uses sessionStorage keyed by clientId. Without this migration,
|
||||
// users upgrading from V1 lose their open tab list.
|
||||
migrateV1TabState(workspaceId, clientId)
|
||||
|
||||
if (migrated > 0) {
|
||||
console.warn(`[V2 Migration] Migrated ${migrated} drafts from V1 to V2`)
|
||||
}
|
||||
return migrated
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates V1 tab state (open paths + active index) to V2 format.
|
||||
* V1 stored these in localStorage via setStorageValue fallback.
|
||||
* V2 uses sessionStorage keyed by clientId.
|
||||
*/
|
||||
function migrateV1TabState(workspaceId: string, clientId?: string): void {
|
||||
if (!clientId) return
|
||||
|
||||
try {
|
||||
const pathsJson = localStorage.getItem(V1_KEYS.openPaths)
|
||||
if (!pathsJson) return
|
||||
|
||||
const paths = JSON.parse(pathsJson)
|
||||
if (!Array.isArray(paths) || paths.length === 0) return
|
||||
|
||||
const indexJson = localStorage.getItem(V1_KEYS.activeIndex)
|
||||
let activeIndex = 0
|
||||
if (indexJson !== null) {
|
||||
const parsed = JSON.parse(indexJson)
|
||||
if (typeof parsed === 'number' && Number.isFinite(parsed)) {
|
||||
activeIndex = Math.min(Math.max(0, parsed), paths.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
writeOpenPaths(clientId, { workspaceId, paths, activeIndex })
|
||||
} catch {
|
||||
// Best effort - don't block draft migration on tab state errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up V1 data after successful migration.
|
||||
* Should NOT be called until 2026-07-15 to allow rollback.
|
||||
*/
|
||||
export function cleanupV1Data(workspaceId: string = getWorkspaceId()): void {
|
||||
try {
|
||||
localStorage.removeItem(V1_KEYS.drafts(workspaceId))
|
||||
localStorage.removeItem(V1_KEYS.order(workspaceId))
|
||||
console.warn('[V2 Migration] Cleaned up V1 data')
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets migration status for debugging.
|
||||
*/
|
||||
export function getMigrationStatus(workspaceId: string = getWorkspaceId()): {
|
||||
v1Exists: boolean
|
||||
v2Exists: boolean
|
||||
v1DraftCount: number
|
||||
v2DraftCount: number
|
||||
} {
|
||||
const v1Data = readV1Drafts(workspaceId)
|
||||
const v2Index = readIndex(workspaceId)
|
||||
|
||||
return {
|
||||
v1Exists: v1Data !== null,
|
||||
v2Exists: v2Index !== null,
|
||||
v1DraftCount: v1Data ? v1Data.order.length : 0,
|
||||
v2DraftCount: v2Index ? v2Index.order.length : 0
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
writeIndex,
|
||||
writePayload
|
||||
} from '../base/storageIO'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
|
||||
interface DraftMeta {
|
||||
@@ -319,11 +318,7 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
|
||||
async function loadPersistedWorkflow(
|
||||
options: LoadPersistedWorkflowOptions
|
||||
): Promise<boolean> {
|
||||
const {
|
||||
workflowName,
|
||||
preferredPath,
|
||||
fallbackToLatestDraft = false
|
||||
} = options
|
||||
const { preferredPath, fallbackToLatestDraft = false } = options
|
||||
|
||||
// 1. Try preferred path
|
||||
if (preferredPath && (await loadDraft(preferredPath))) {
|
||||
@@ -338,33 +333,7 @@ export const useWorkflowDraftStoreV2 = defineStore('workflowDraftV2', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallbacks are NOT workspace-scoped and must only be used for
|
||||
// personal workspace to prevent cross-workspace data leakage.
|
||||
// These exist only for migration from V1 and should be removed after 2026-07-15.
|
||||
if (currentWorkspaceId() !== 'personal') {
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. Legacy fallback: sessionStorage payload (remove after 2026-07-15)
|
||||
const clientId = api.initialClientId ?? api.clientId
|
||||
if (clientId) {
|
||||
try {
|
||||
const sessionPayload = sessionStorage.getItem(`workflow:${clientId}`)
|
||||
if (await tryLoadGraph(sessionPayload, workflowName)) {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage access errors and continue fallback chain
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Legacy fallback: localStorage payload (remove after 2026-07-15)
|
||||
try {
|
||||
const localPayload = localStorage.getItem('workflow')
|
||||
return await tryLoadGraph(localPayload, workflowName)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user