Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions src/platform/workflow/persistence/base/storageIO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,36 @@ import { StorageKeys } from './storageKeys'
/** Flag indicating if storage is available */
let storageAvailable = true

/** @knipIgnoreUsedByStackedPR Used by workflowPersistenceV2.ts (PR #3) */
export function isStorageAvailable(): boolean {
return storageAvailable
}

/** @knipIgnoreUsedByStackedPR Used by workflowPersistenceV2.ts (PR #3) */
export function markStorageUnavailable(): void {
storageAvailable = false
}

function isQuotaExceeded(error: unknown): boolean {
return (
error instanceof DOMException &&
(error.name === 'QuotaExceededError' ||
error.name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
error.code === 22 ||
error.code === 1014)
)
}

function isValidIndex(value: unknown): value is DraftIndexV2 {
if (typeof value !== 'object' || value === null) return false
const obj = value as Record<string, unknown>
return (
obj.v === 2 &&
typeof obj.updatedAt === 'number' &&
Array.isArray(obj.order) &&
typeof obj.entries === 'object' &&
obj.entries !== null
)
}

/**
* Reads and parses the draft index from localStorage.
*/
Expand All @@ -37,9 +57,9 @@ export function readIndex(workspaceId: string): DraftIndexV2 | null {
if (!json) return null

const parsed = JSON.parse(json)
if (parsed.v !== 2) return null
if (!isValidIndex(parsed)) return null

return parsed as DraftIndexV2
return parsed
} catch {
return null
}
Expand All @@ -56,9 +76,7 @@ export function writeIndex(workspaceId: string, index: DraftIndexV2): boolean {
localStorage.setItem(key, JSON.stringify(index))
return true
} catch (error) {
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
return false
}
if (isQuotaExceeded(error)) return false
throw error
}
}
Expand Down Expand Up @@ -98,9 +116,7 @@ export function writePayload(
localStorage.setItem(key, JSON.stringify(payload))
return true
} catch (error) {
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
return false
}
if (isQuotaExceeded(error)) return false
throw error
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@/scripts/api', () => ({
api: {
clientId: 'test-client-id',
initialClientId: 'test-client-id'
}
}))

describe('useWorkflowTabState', () => {
beforeEach(() => {
vi.resetModules()
sessionStorage.clear()
})

describe('activePath', () => {
it('returns null when no pointer exists', async () => {
const { useWorkflowTabState } = await import('./useWorkflowTabState')
const { getActivePath } = useWorkflowTabState()

expect(getActivePath()).toBeNull()
})

it('saves and retrieves active path', async () => {
const { useWorkflowTabState } = await import('./useWorkflowTabState')
const { getActivePath, setActivePath } = useWorkflowTabState()

setActivePath('workflows/test.json')
expect(getActivePath()).toBe('workflows/test.json')
})

it('ignores pointer from different workspace', async () => {
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({ type: 'team', id: 'ws-1' })
)
const { useWorkflowTabState } = await import('./useWorkflowTabState')
const { setActivePath } = useWorkflowTabState()
setActivePath('workflows/test.json')

vi.resetModules()
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({ type: 'team', id: 'ws-2' })
)

const { useWorkflowTabState: useWorkflowTabState2 } =
await import('./useWorkflowTabState')
const { getActivePath } = useWorkflowTabState2()

expect(getActivePath()).toBeNull()
})
})

describe('openPaths', () => {
it('returns null when no pointer exists', async () => {
const { useWorkflowTabState } = await import('./useWorkflowTabState')
const { getOpenPaths } = useWorkflowTabState()

expect(getOpenPaths()).toBeNull()
})

it('saves and retrieves open paths', async () => {
const { useWorkflowTabState } = await import('./useWorkflowTabState')
const { getOpenPaths, setOpenPaths } = useWorkflowTabState()

const paths = ['workflows/a.json', 'workflows/b.json']
setOpenPaths(paths, 1)

const result = getOpenPaths()
expect(result).not.toBeNull()
expect(result!.paths).toEqual(paths)
expect(result!.activeIndex).toBe(1)
})

it('ignores pointer from different workspace', async () => {
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({ type: 'team', id: 'ws-1' })
)
const { useWorkflowTabState } = await import('./useWorkflowTabState')
const { setOpenPaths } = useWorkflowTabState()
setOpenPaths(['workflows/test.json'], 0)

vi.resetModules()
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({ type: 'team', id: 'ws-2' })
)

const { useWorkflowTabState: useWorkflowTabState2 } =
await import('./useWorkflowTabState')
const { getOpenPaths } = useWorkflowTabState2()

expect(getOpenPaths()).toBeNull()
})
})
})
101 changes: 101 additions & 0 deletions src/platform/workflow/persistence/composables/useWorkflowTabState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Tab State Management - Per-tab workflow pointers in sessionStorage.
*
* Uses api.clientId to scope pointers per browser tab.
* Includes workspaceId for validation to prevent cross-workspace contamination.
*/

import type { ActivePathPointer, OpenPathsPointer } from '../base/draftTypes'
import { getWorkspaceId } from '../base/storageKeys'
import {
readActivePath,
readOpenPaths,
writeActivePath,
writeOpenPaths
} from '../base/storageIO'
import { api } from '@/scripts/api'

/**
* Gets the current client ID for browser tab identification.
* Falls back to initialClientId if clientId is not yet set.
*/
function getClientId(): string | null {
return api.clientId ?? api.initialClientId ?? null
}

/**
* Composable for managing per-tab workflow state in sessionStorage.
*/
export function useWorkflowTabState() {
const currentWorkspaceId = getWorkspaceId()

/**
* Gets the active workflow path for the current tab.
* Returns null if no pointer exists or workspaceId doesn't match.
*/
function getActivePath(): string | null {
const clientId = getClientId()
if (!clientId) return null

const pointer = readActivePath(clientId)
if (!pointer) return null

// Validate workspace - ignore stale pointers from different workspace
if (pointer.workspaceId !== currentWorkspaceId) return null

return pointer.path
}

/**
* Sets the active workflow path for the current tab.
*/
function setActivePath(path: string): void {
const clientId = getClientId()
if (!clientId) return

const pointer: ActivePathPointer = {
workspaceId: currentWorkspaceId,
path
}
writeActivePath(clientId, pointer)
}

/**
* Gets the open workflow paths for the current tab.
* Returns null if no pointer exists or workspaceId doesn't match.
*/
function getOpenPaths(): { paths: string[]; activeIndex: number } | null {
const clientId = getClientId()
if (!clientId) return null

const pointer = readOpenPaths(clientId)
if (!pointer) return null

// Validate workspace
if (pointer.workspaceId !== currentWorkspaceId) return null

return { paths: pointer.paths, activeIndex: pointer.activeIndex }
}

/**
* Sets the open workflow paths for the current tab.
*/
function setOpenPaths(paths: string[], activeIndex: number): void {
const clientId = getClientId()
if (!clientId) return

const pointer: OpenPathsPointer = {
workspaceId: currentWorkspaceId,
paths,
activeIndex
}
writeOpenPaths(clientId, pointer)
}

return {
getActivePath,
setActivePath,
getOpenPaths,
setOpenPaths
}
}
Loading
Loading