feat(storage): show workspace document previews

- Load embedded Figma thumbnails through bounded S3 byte-range requests

- Add a headless Vue workspace composable with lazy previews and refresh lifecycle

- Cache local previews and refresh the workspace after saves and synchronization
This commit is contained in:
Danila Poyarkov 2026-08-10 17:18:12 +03:00
parent d6ec858243
commit dde7376bd3
16 changed files with 911 additions and 96 deletions

View file

@ -32,10 +32,10 @@
- Test OpenAI-compatible provider connections from AI settings with clearer setup errors.
- Configure separate Design, Review, Fast, and Vision models, providers, endpoints, and credentials from AI settings.
- Manage AI, agent, media, and storage credentials from unified Settings, using the system credential store on desktop and encrypted browser storage by default, with a session-only browser option.
- Connect an S3-compatible storage workspace with local-first saves and background synchronization.
- Connect an S3-compatible storage workspace with local-first saves, background synchronization, embedded `.fig` previews loaded without downloading full documents, and automatic refresh while the workspace is active.
- Add Japanese localization and improve menu translations across the existing supported languages. (#367)
- Author richer Design JSX with components, instances, variables, gradients, structured fills, shadows, blur effects, masks, and inline SVG vectors.
- Build custom property panels with new Vue SDK number fields, bindable values, property sections, responsive grids, segmented controls, property lists, color models, fill controls, and gradient primitives.
- Build custom property panels and document workspaces with new Vue SDK number fields, bindable values, property sections, responsive grids, segmented controls, property lists, color models, fill controls, gradient primitives, and the headless `useDocumentWorkspace()` composable.
- Use `useColorModel()` in the Vue SDK for extensible color formats and shared RGB, HSL, HSB, and OkHCL channel behavior.
- Add dedicated SceneGraph, Pen, Kiwi, Fig, and DOM/CSS packages with documented public entry points for building on OpenPencil.

View file

@ -6,6 +6,11 @@ export {
type FigParseResult,
type WriteFigArchiveInput
} from './archive'
export {
extractFigThumbnailFromReader,
type FigRangeReader,
type FigThumbnailLimits
} from './thumbnail'
export {
effectiveFigmaRawNodeFields,
effectiveFigmaSourcePayload,

View file

@ -0,0 +1,122 @@
import { inflateSync } from 'fflate'
export interface FigRangeReader {
readonly size: number
read(start: number, endExclusive: number): Promise<Uint8Array>
}
export type FigThumbnailLimits = {
maxTailBytes?: number
maxCompressedBytes?: number
maxOutputBytes?: number
}
type ThumbnailEntry = {
method: number
compressedSize: number
localOffset: number
}
const EOCD_SIGNATURE = 0x06054b50
const CENTRAL_SIGNATURE = 0x02014b50
const LOCAL_SIGNATURE = 0x04034b50
const EOCD_MIN_SIZE = 22
const MAX_ZIP_COMMENT = 65_535
const DEFAULT_MAX_TAIL = 4 * 1024 * 1024
const DEFAULT_MAX_COMPRESSED = 8 * 1024 * 1024
const DEFAULT_MAX_OUTPUT = 16 * 1024 * 1024
const THUMBNAIL_NAME = 'thumbnail.png'
function view(bytes: Uint8Array): DataView {
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
}
function findEOCD(bytes: Uint8Array): number {
const data = view(bytes)
for (let offset = bytes.byteLength - EOCD_MIN_SIZE; offset >= 0; offset--) {
if (data.getUint32(offset, true) === EOCD_SIGNATURE) return offset
}
return -1
}
function boundedLimit(value: number | undefined, fallback: number): number {
return Number.isFinite(value) && value && value > 0 ? value : fallback
}
function findThumbnailEntry(
central: Uint8Array,
maxCompressed: number,
maxOutput: number
): ThumbnailEntry | null {
const data = view(central)
const decoder = new TextDecoder()
for (let offset = 0; offset + 46 <= central.byteLength; ) {
if (data.getUint32(offset, true) !== CENTRAL_SIGNATURE) return null
const method = data.getUint16(offset + 10, true)
const compressedSize = data.getUint32(offset + 20, true)
const outputSize = data.getUint32(offset + 24, true)
const nameLength = data.getUint16(offset + 28, true)
const next =
offset +
46 +
nameLength +
data.getUint16(offset + 30, true) +
data.getUint16(offset + 32, true)
if (next > central.byteLength) return null
const name = decoder.decode(central.subarray(offset + 46, offset + 46 + nameLength))
if (name === THUMBNAIL_NAME) {
if (compressedSize > maxCompressed || outputSize > maxOutput) return null
return { method, compressedSize, localOffset: data.getUint32(offset + 42, true) }
}
offset = next
}
return null
}
async function readEntryPayload(
reader: FigRangeReader,
entry: ThumbnailEntry,
maxOutput: number
): Promise<Uint8Array | null> {
const header = await reader.read(entry.localOffset, Math.min(reader.size, entry.localOffset + 30))
if (header.byteLength < 30 || view(header).getUint32(0, true) !== LOCAL_SIGNATURE) return null
const headerView = view(header)
const dataStart =
entry.localOffset + 30 + headerView.getUint16(26, true) + headerView.getUint16(28, true)
if (dataStart + entry.compressedSize > reader.size) return null
const compressed = await reader.read(dataStart, dataStart + entry.compressedSize)
if (entry.method === 0) return compressed.byteLength <= maxOutput ? compressed : null
if (entry.method !== 8) return null
const output = inflateSync(compressed, { out: new Uint8Array(maxOutput + 1) })
return output.byteLength <= maxOutput ? output : null
}
/**
* Extract Figma's canonical `thumbnail.png` from a remote `.fig` ZIP through
* bounded range reads. The complete document is never requested.
*/
export async function extractFigThumbnailFromReader(
reader: FigRangeReader,
limits: FigThumbnailLimits = {}
): Promise<Uint8Array | null> {
if (!Number.isSafeInteger(reader.size) || reader.size < EOCD_MIN_SIZE) return null
const maxCentral = boundedLimit(limits.maxTailBytes, DEFAULT_MAX_TAIL)
const maxCompressed = boundedLimit(limits.maxCompressedBytes, DEFAULT_MAX_COMPRESSED)
const maxOutput = boundedLimit(limits.maxOutputBytes, DEFAULT_MAX_OUTPUT)
const tailSize = Math.min(reader.size, EOCD_MIN_SIZE + MAX_ZIP_COMMENT)
const tailStart = reader.size - tailSize
const tail = await reader.read(tailStart, reader.size)
const eocd = findEOCD(tail)
if (eocd < 0) return null
const tailView = view(tail)
const centralSize = tailView.getUint32(eocd + 12, true)
const centralOffset = tailView.getUint32(eocd + 16, true)
if (centralSize > maxCentral || centralOffset + centralSize > reader.size) return null
const central =
centralOffset >= tailStart && centralOffset + centralSize <= reader.size
? tail.subarray(centralOffset - tailStart, centralOffset - tailStart + centralSize)
: await reader.read(centralOffset, centralOffset + centralSize)
const entry = findThumbnailEntry(central, maxCompressed, maxOutput)
return entry ? readEntryPayload(reader, entry, maxOutput) : null
}

View file

@ -0,0 +1,248 @@
import { useEventListener, useIntervalFn } from '@vueuse/core'
import {
computed,
onBeforeUnmount,
onMounted,
readonly,
ref,
shallowRef,
type Directive,
type Ref
} from 'vue'
import { IS_BROWSER } from '@open-pencil/core/constants'
export type DocumentWorkspaceItem = {
id: string
name: string
updatedAt: string
}
export interface DocumentWorkspaceSource<Item extends DocumentWorkspaceItem> {
refresh(): Promise<Item[]>
loadPreview(id: string): Promise<Uint8Array | null>
subscribe?(listener: () => void): () => void
}
export type UseDocumentWorkspaceOptions<Item extends DocumentWorkspaceItem> = {
source: DocumentWorkspaceSource<Item>
refreshInterval?: number
refreshOnFocus?: boolean
refreshOnReconnect?: boolean
previewConcurrency?: number
previewMimeType?: string
}
export function useDocumentWorkspace<Item extends DocumentWorkspaceItem>(
options: UseDocumentWorkspaceOptions<Item>
) {
const documents = shallowRef<Item[]>([])
const loading = ref(false)
const error = shallowRef<unknown>(null)
const lastRefreshedAt = shallowRef<Date | null>(null)
const previewUrls = ref<Record<string, string>>({})
const previewCleanups = new WeakMap<Element, () => void>()
const previewGenerations = new Map<string, number>()
const previewQueue: string[] = []
const queued = new Set<string>()
const activePreviews = new Set<string>()
const concurrency = Math.max(1, Math.floor(options.previewConcurrency ?? 6))
let refreshPromise: Promise<void> | null = null
let refreshQueued = false
let disposed = false
function removePreviewURL(id: string): void {
previewGenerations.set(id, (previewGenerations.get(id) ?? 0) + 1)
const url = previewUrls.value[id]
if (!url) return
URL.revokeObjectURL(url)
previewUrls.value = Object.fromEntries(
Object.entries(previewUrls.value).filter(([previewId]) => previewId !== id)
)
}
function reconcilePreviewUrls(items: readonly Item[]): void {
const previousItems = new Map(documents.value.map((item) => [item.id, item.updatedAt]))
const currentItems = new Map(items.map((item) => [item.id, item.updatedAt]))
for (const id of Object.keys(previewUrls.value)) {
if (previousItems.get(id) !== currentItems.get(id)) removePreviewURL(id)
}
}
function clearPreviews(): void {
const ids = new Set([
...Object.keys(previewUrls.value),
...activePreviews,
...queued,
...previewGenerations.keys()
])
for (const id of ids) removePreviewURL(id)
previewQueue.length = 0
queued.clear()
}
function replacePreviewURL(id: string, bytes: Uint8Array): void {
if (disposed) return
const previous = previewUrls.value[id]
if (previous) URL.revokeObjectURL(previous)
const blobBytes = Uint8Array.from(bytes)
previewUrls.value = {
...previewUrls.value,
[id]: URL.createObjectURL(
new Blob([blobBytes.buffer], { type: options.previewMimeType ?? 'image/png' })
)
}
}
function drainPreviewQueue(): void {
while (activePreviews.size < concurrency) {
const id = previewQueue.shift()
if (!id) break
queued.delete(id)
if (activePreviews.has(id) || previewUrls.value[id]) continue
activePreviews.add(id)
const generation = previewGenerations.get(id) ?? 0
void options.source
.loadPreview(id)
.then((bytes) => {
if (bytes?.byteLength && generation === (previewGenerations.get(id) ?? 0)) {
replacePreviewURL(id, bytes)
}
return undefined
})
.catch(() => null)
.finally(() => {
activePreviews.delete(id)
drainPreviewQueue()
})
}
}
function loadPreview(id: string): void {
if (previewUrls.value[id] || activePreviews.has(id) || queued.has(id)) return
queued.add(id)
previewQueue.push(id)
drainPreviewQueue()
}
function previewURL(id: string): string | null {
return previewUrls.value[id] ?? null
}
function observePreview(element: Element | null, id: string): () => void {
if (!element || typeof IntersectionObserver === 'undefined') {
loadPreview(id)
return () => undefined
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
loadPreview(id)
observer.disconnect()
}
},
{ rootMargin: '240px' }
)
observer.observe(element)
return () => observer.disconnect()
}
function stopObservingPreview(element: Element): void {
previewCleanups.get(element)?.()
previewCleanups.delete(element)
}
const previewDirective: Directive<Element, string> = {
mounted(element, binding) {
previewCleanups.set(element, observePreview(element, binding.value))
},
updated(element, binding) {
if (binding.value === binding.oldValue) return
stopObservingPreview(element)
previewCleanups.set(element, observePreview(element, binding.value))
},
unmounted(element) {
stopObservingPreview(element)
}
}
function refresh(): Promise<void> {
if (refreshPromise) return refreshPromise
loading.value = true
error.value = null
const nextRefresh = options.source
.refresh()
.then((items) => {
if (!disposed) {
reconcilePreviewUrls(items)
documents.value = items
lastRefreshedAt.value = new Date()
}
return undefined
})
.catch((reason: unknown) => {
if (!disposed) error.value = reason
})
.finally(() => {
loading.value = false
refreshPromise = null
if (refreshQueued && !disposed) {
refreshQueued = false
void refresh()
}
})
refreshPromise = nextRefresh
return nextRefresh
}
function invalidate(): Promise<void> {
if (!refreshPromise) return refresh()
refreshQueued = true
return refreshPromise
}
if (options.refreshOnFocus !== false && IS_BROWSER) {
useEventListener(window, 'focus', () => void invalidate())
}
if (options.refreshOnReconnect !== false && IS_BROWSER) {
useEventListener(window, 'online', () => void invalidate())
}
if (options.refreshInterval && options.refreshInterval > 0) {
useIntervalFn(
() => {
if (typeof document === 'undefined' || document.visibilityState === 'visible') {
void invalidate()
}
},
options.refreshInterval,
{ immediate: false }
)
}
let unsubscribeSource: (() => void) | null = null
onMounted(() => {
unsubscribeSource = options.source.subscribe?.(() => void invalidate()) ?? null
void refresh()
})
onBeforeUnmount(() => {
unsubscribeSource?.()
disposed = true
clearPreviews()
})
return {
documents: readonly(documents) as Readonly<Ref<readonly Item[]>>,
loading: readonly(loading),
error: readonly(error),
lastRefreshedAt: readonly(lastRefreshedAt),
previewUrls: readonly(previewUrls),
hasDocuments: computed(() => documents.value.length > 0),
refresh,
invalidate,
clearPreviews,
loadPreview,
observePreview,
previewDirective,
previewURL
}
}

View file

@ -82,6 +82,12 @@ export { useAppearance } from '#vue/controls/appearance/use'
export { useMask } from '#vue/controls/mask/use'
export { useTypography } from '#vue/controls/typography/use'
export type { UseTypographyOptions } from '#vue/controls/typography/use'
export { useDocumentWorkspace } from '#vue/document/workspace/use'
export type {
DocumentWorkspaceItem,
DocumentWorkspaceSource,
UseDocumentWorkspaceOptions
} from '#vue/document/workspace/use'
export { useExport } from '#vue/document/export/use'
export type { ExportFormatId, ExportSetting } from '#vue/document/export/use'
export { useFillControls } from '#vue/controls/fill/use'

View file

@ -1,3 +1,5 @@
import { extractFigThumbnailFromReader } from '@open-pencil/fig'
import { isTauri } from '@/app/tauri/env'
import {
@ -16,7 +18,16 @@ import type {
StorageDocumentMetadata,
StorageProviderRuntime
} from '../types'
import { S3HttpError, deleteObject, getObject, headObject, listObjects, putObject } from './client'
import {
S3HttpError,
deleteObject,
getObject,
getObjectRange,
headObject,
headObjectSize,
listObjects,
putObject
} from './client'
import { CloudCORSError, formatBrowserCORSHelpMessage, isLikelyCORSOrNetworkError } from './cors'
import type { S3CompatibleConfig, S3ConnectionResult } from './types'
@ -244,7 +255,15 @@ export function createS3StorageAdapter(runtime: StorageProviderRuntime): S3Stora
async getThumbnail(id) {
const config = await resolveConfig(runtime)
return getObject(config, documentThumbnailKey(id))
const figKey = documentFigKey(id)
const size = await headObjectSize(config, figKey)
if (size == null) return null
return extractFigThumbnailFromReader({
size,
async read(start: number, endExclusive: number) {
return (await getObjectRange(config, figKey, start, endExclusive)) ?? new Uint8Array()
}
})
}
}
}

View file

@ -164,6 +164,38 @@ export async function headObject(config: S3CompatibleConfig, key: string): Promi
return true
}
export async function headObjectSize(
config: S3CompatibleConfig,
key: string
): Promise<number | null> {
const res = await s3Request(config, objectURL(config, key), { method: 'HEAD' })
if (res.status === 404) return null
const size = Number(res.headers.get('content-length'))
return Number.isSafeInteger(size) && size >= 0 ? size : null
}
export async function getObjectRange(
config: S3CompatibleConfig,
key: string,
start: number,
endExclusive: number
): Promise<Uint8Array | null> {
if (
!Number.isSafeInteger(start) ||
!Number.isSafeInteger(endExclusive) ||
endExclusive <= start
) {
throw new Error('Invalid S3 byte range')
}
const res = await s3Request(config, objectURL(config, key), {
method: 'GET',
headers: { Range: `bytes=${start}-${endExclusive - 1}` }
})
if (res.status === 404) return null
if (res.status !== 206) throw new Error('Storage provider did not honor the thumbnail byte range')
return new Uint8Array(await res.arrayBuffer())
}
export async function putObject(
config: S3CompatibleConfig,
key: string,

View file

@ -13,6 +13,7 @@ import { getOutbox } from '@/app/storage/sync/outbox'
import { setUploadProgress } from '@/app/storage/sync/progress'
import { setPendingSyncCount, setSyncUI } from '@/app/storage/sync/status'
import type { OutboxJob } from '@/app/storage/sync/types'
import { emitStorageWorkspaceEvent } from '@/app/storage/workspace/events'
const MAX_ATTEMPTS = 8
const BASE_BACKOFF_MS = 1500
@ -124,6 +125,11 @@ async function runJob(job: OutboxJob): Promise<void> {
{ expectedRevision: job.revision }
)
await evictLocalFigCache(new Set([job.canvasId]))
emitStorageWorkspaceEvent({
providerId: providerID,
documentId: job.canvasId,
kind: 'synced'
})
}
return
}

View file

@ -1,8 +1,11 @@
import { extractFigThumbnailFromReader } from '@open-pencil/fig'
import type { StorageProviderID } from '@/app/integrations/storage/types'
import { evictLocalFigCache } from '@/app/storage/cache-eviction'
import { getLocalCanvasStore } from '@/app/storage/local-store'
import type { LocalCanvasStore } from '@/app/storage/local-store/store'
import { enqueuePutCanvas } from '@/app/storage/sync/engine'
import { emitStorageWorkspaceEvent } from '@/app/storage/workspace/events'
export type StoragePersistenceDependencies = {
store: LocalCanvasStore
@ -25,14 +28,26 @@ export async function persistStorageCanvasLocally(
store: getLocalCanvasStore(),
enqueueCanvas: enqueuePutCanvas
}
const thumbnailBytes = await extractFigThumbnailFromReader({
size: options.figBytes.byteLength,
async read(start, endExclusive) {
return options.figBytes.subarray(start, endExclusive)
}
})
const metadata = await runtime.store.writeCanvas({
id: options.canvasId,
providerId: options.providerId,
name: options.name,
figBytes: options.figBytes,
thumbBytes: thumbnailBytes,
syncStatus: 'pending'
})
await runtime.enqueueCanvas(options.canvasId, metadata.revision)
emitStorageWorkspaceEvent({
providerId: options.providerId,
documentId: options.canvasId,
kind: 'changed'
})
return { revision: metadata.revision }
}

View file

@ -0,0 +1,18 @@
export type StorageWorkspaceEvent = {
providerId: string
documentId?: string
kind: 'changed' | 'synced'
}
type StorageWorkspaceListener = (event: StorageWorkspaceEvent) => void
const listeners = new Set<StorageWorkspaceListener>()
export function emitStorageWorkspaceEvent(event: StorageWorkspaceEvent): void {
for (const listener of listeners) listener(event)
}
export function onStorageWorkspaceEvent(listener: StorageWorkspaceListener): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}

View file

@ -0,0 +1,84 @@
import type { StorageDocument } from '@/app/integrations/storage'
import {
activeStorageProviderID,
createActiveStorageAdapter,
storageCredentialStatuses,
storagePreferencesComplete,
storageProviderRegistry
} from '@/app/integrations/storage'
import { getLocalCanvasStore } from '@/app/storage/local-store'
import { reconcileStorageDocuments } from '@/app/storage/reconcile'
import { onStorageWorkspaceEvent } from '@/app/storage/workspace/events'
export type StorageWorkspaceSnapshot = {
documents: StorageDocument[]
configured: boolean
}
export function createStorageWorkspaceSource(
onSnapshot: (snapshot: StorageWorkspaceSnapshot) => void
) {
return {
subscribe(listener: () => void): () => void {
return onStorageWorkspaceEvent((event) => {
if (event.providerId === activeStorageProviderID.value) listener()
})
},
async refresh(): Promise<StorageDocument[]> {
const providerID = activeStorageProviderID.value
const provider = storageProviderRegistry.get(providerID)
const statuses = await storageCredentialStatuses(providerID)
const configured =
storagePreferencesComplete(providerID) &&
provider.credentialFields.every(
(field) => !field.required || statuses[field.id] === 'configured'
)
const localStore = getLocalCanvasStore()
const local = (await localStore.listMetas(true)).filter(
(metadata) => metadata.providerId === providerID
)
if (!configured) {
const documents = local
.filter((metadata) => !metadata.tombstoned)
.map((metadata) => ({
id: metadata.id,
name: metadata.name,
updatedAt: metadata.updatedAt,
metadataAuthoritative: true
}))
onSnapshot({ documents, configured })
return documents
}
const remote = await createActiveStorageAdapter(providerID).listDocuments()
const reconciliation = reconcileStorageDocuments(local, remote)
for (const id of reconciliation.localIdsToPurge) await localStore.remove(id)
for (const document of reconciliation.remoteDocumentsToSeed) {
await localStore.upsertIndexMeta({
id: document.id,
providerId: providerID,
name: document.name,
updatedAt: document.updatedAt,
syncStatus: 'synced',
lastSyncedAt: document.updatedAt,
lastSyncError: null
})
}
onSnapshot({ documents: reconciliation.documents, configured })
return reconciliation.documents
},
async loadPreview(id: string): Promise<Uint8Array | null> {
const localStore = getLocalCanvasStore()
const local = await localStore.readThumb(id)
if (local?.byteLength) return local
const adapter = createActiveStorageAdapter()
if (!adapter.getThumbnail) return null
const remote = await adapter.getThumbnail(id)
if (!remote?.byteLength) return null
await localStore.writeThumb(id, remote)
return remote
}
}
}

View file

@ -1,87 +1,38 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from '@open-pencil/vue'
import { useDocumentWorkspace, useI18n } from '@open-pencil/vue'
import {
activeStorageProviderID,
createActiveStorageAdapter,
storageCredentialStatuses,
storagePreferencesComplete,
storageProviderRegistry,
type StorageDocument
} from '@/app/integrations/storage'
import { activeStorageProviderID, type StorageDocument } from '@/app/integrations/storage'
import { openSettingsDialog, settingsDialogOpen } from '@/app/settings/dialog'
import type { CredentialStatus } from '@/app/settings/credentials/types'
import { createCanvasId } from '@/app/storage/id'
import { reconcileStorageDocuments } from '@/app/storage/reconcile'
import { createStorageWorkspaceSource } from '@/app/storage/workspace/source'
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
import { getLocalCanvasStore } from '@/app/storage/local-store'
import Tip from '@/components/ui/Tip.vue'
import { activeTab, createTab, openStorageDocumentInNewTab } from '@/app/tabs'
const { dialogs } = useI18n()
const router = useRouter()
const provider = computed(() => storageProviderRegistry.get(activeStorageProviderID.value))
const documents = ref<StorageDocument[]>([])
const credentialStatuses = ref<Record<string, CredentialStatus>>({})
const configured = computed(
() =>
storagePreferencesComplete(provider.value.id) &&
provider.value.credentialFields.every(
(field) => !field.required || credentialStatuses.value[field.id] === 'configured'
)
)
const loading = ref(false)
const error = ref<string | null>(null)
async function paintLocalDocuments(): Promise<void> {
const local = (await getLocalCanvasStore().listMetas()).filter(
(metadata) => metadata.providerId === activeStorageProviderID.value
)
documents.value = local.map((metadata) => ({
id: metadata.id,
name: metadata.name,
updatedAt: metadata.updatedAt,
metadataAuthoritative: true
}))
}
async function refresh(): Promise<void> {
loading.value = true
error.value = null
await paintLocalDocuments()
try {
credentialStatuses.value = await storageCredentialStatuses(provider.value.id)
if (!configured.value) {
error.value = dialogs.value.storageNotConfigured
return
}
const remote = await createActiveStorageAdapter().listDocuments()
const localStore = getLocalCanvasStore()
const local = (await localStore.listMetas(true)).filter(
(metadata) => metadata.providerId === activeStorageProviderID.value
)
const reconciliation = reconcileStorageDocuments(local, remote)
documents.value = reconciliation.documents
for (const id of reconciliation.localIdsToPurge) await localStore.remove(id)
for (const document of reconciliation.remoteDocumentsToSeed) {
await localStore.upsertIndexMeta({
id: document.id,
providerId: activeStorageProviderID.value,
name: document.name,
updatedAt: document.updatedAt,
syncStatus: 'synced',
lastSyncedAt: document.updatedAt,
lastSyncError: null
})
}
} catch (reason) {
error.value = reason instanceof Error ? reason.message : String(reason)
} finally {
loading.value = false
}
}
const configured = ref(false)
const workspace = useDocumentWorkspace({
source: createStorageWorkspaceSource((snapshot) => {
configured.value = snapshot.configured
}),
refreshInterval: 60_000,
previewConcurrency: 6
})
const documents = workspace.documents
const loading = workspace.loading
const errorMessage = computed(() => {
const reason = workspace.error.value
if (reason == null) return null
return reason instanceof Error ? reason.message : String(reason)
})
const refresh = workspace.refresh
const invalidate = workspace.invalidate
const clearPreviews = workspace.clearPreviews
const previewURL = workspace.previewURL
const vWorkspacePreview = workspace.previewDirective
async function openDocument(document: StorageDocument): Promise<void> {
await router.push('/')
@ -106,14 +57,13 @@ async function createDocument(): Promise<void> {
await store.saveFigFile()
}
watch(activeStorageProviderID, () => void refresh())
watch(settingsDialogOpen, (open, wasOpen) => {
if (wasOpen && !open) void refresh()
watch(activeStorageProviderID, () => {
clearPreviews()
void invalidate()
})
onMounted(() => {
void refresh()
watch(settingsDialogOpen, (open, wasOpen) => {
if (wasOpen && !open) void invalidate()
})
</script>
@ -146,18 +96,21 @@ onMounted(() => {
<section class="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col p-6">
<div class="mb-4 flex shrink-0 items-center justify-between">
<p v-if="error && configured" class="text-xs text-danger" role="alert">
{{ error }}
<p v-if="errorMessage && configured" class="text-xs text-danger" role="alert">
{{ errorMessage }}
</p>
<span v-else />
<button
v-if="configured"
type="button"
class="rounded px-2 py-1 text-xs text-muted hover:bg-hover hover:text-surface"
@click="refresh"
>
{{ dialogs.refresh }}
</button>
<Tip v-if="configured" :label="dialogs.refresh">
<button
type="button"
class="flex size-7 items-center justify-center rounded text-muted hover:bg-hover hover:text-surface disabled:opacity-50"
:aria-label="dialogs.refresh"
:disabled="loading"
@click="refresh"
>
<icon-lucide-refresh-cw class="size-3.5" :class="{ 'animate-spin': loading }" />
</button>
</Tip>
</div>
<div
@ -172,7 +125,18 @@ onMounted(() => {
:data-document-id="document.id"
@click="openDocument(document)"
>
<div class="aspect-[4/3] bg-panel-field" />
<div
v-workspace-preview="document.id"
class="flex aspect-[4/3] items-center justify-center bg-panel-field"
>
<img
v-if="previewURL(document.id)"
:src="previewURL(document.id) ?? undefined"
alt=""
class="size-full object-cover"
/>
<icon-lucide-file-image v-else class="size-6 text-muted/50" />
</div>
<div class="border-t border-border p-3">
<p class="truncate text-xs font-medium">{{ document.name }}</p>
<p class="mt-1 text-[10px] text-muted">

View file

@ -4,8 +4,12 @@ import { expect, test } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
test('configured storage lists and opens a remote document', async ({ page }) => {
test('configured storage lists previews through ranges before opening the document', async ({
page
}) => {
const fixture = readFileSync('tests/fixtures/gold-preview.fig')
let fullDocumentGets = 0
let rangeGets = 0
await page.route('https://s3.example.com/**', async (route) => {
const url = new URL(route.request().url())
if (url.searchParams.get('list-type') === '2') {
@ -29,7 +33,37 @@ test('configured storage lists and opens a remote document', async ({ page }) =>
})
return
}
if (url.pathname.endsWith('/remote-1.fig') && route.request().headers().range) {
rangeGets++
const match = route
.request()
.headers()
.range?.match(/^bytes=(\d+)-(\d+)$/)
if (!match) {
await route.fulfill({ status: 416 })
return
}
const start = Number(match[1])
const end = Number(match[2])
await route.fulfill({
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fixture.byteLength}`
},
contentType: 'application/octet-stream',
body: fixture.subarray(start, end + 1)
})
return
}
if (url.pathname.endsWith('/remote-1.fig') && route.request().method() === 'HEAD') {
await route.fulfill({
status: 200,
headers: { 'Content-Length': String(fixture.byteLength) }
})
return
}
if (url.pathname.endsWith('/remote-1.fig')) {
fullDocumentGets++
await route.fulfill({ contentType: 'application/octet-stream', body: fixture })
return
}
@ -54,11 +88,17 @@ test('configured storage lists and opens a remote document', async ({ page }) =>
await page.getByTestId('settings-storage-open-workspace').click()
await expect(page.getByTestId('storage-workspace')).toBeVisible()
await expect(page.getByText('Remote design')).toBeVisible()
const preview = page.locator('[data-document-id="remote-1"] img')
await expect(preview).toBeVisible()
await expect(preview).toHaveAttribute('src', /^blob:/)
expect(rangeGets).toBeGreaterThan(0)
expect(fullDocumentGets).toBe(0)
await page.locator('[data-document-id="remote-1"]').click()
await expect(page).toHaveURL(/\/$/)
await canvas.waitForInit()
await expect(page.getByText('Remote design').first()).toBeVisible()
expect(fullDocumentGets).toBe(1)
})
test('storage workspace directs unconfigured users to Settings', async ({ page }) => {

View file

@ -1,4 +1,5 @@
import { describe, expect, test, vi } from 'bun:test'
import { readFileSync } from 'node:fs'
import { createMemoryLocalCanvasStore } from '@/app/storage/local-store'
import { persistStorageCanvasLocally } from '@/app/storage/sync/persist'
@ -30,4 +31,22 @@ describe('local-first storage persistence', () => {
providerId: 's3-compatible'
})
})
test('stores the embedded preview with the document', async () => {
const store = createMemoryLocalCanvasStore()
const enqueueCanvas = vi.fn(() => Promise.resolve())
const figBytes = new Uint8Array(readFileSync('tests/fixtures/gold-preview.fig'))
await persistStorageCanvasLocally(
{
providerId: 's3-compatible',
canvasId: 'canvas-preview',
name: 'Preview design',
figBytes
},
{ store, enqueueCanvas }
)
expect((await store.readThumb('canvas-preview'))?.byteLength).toBeGreaterThan(0)
})
})

View file

@ -0,0 +1,41 @@
import { describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
import { extractFigThumbnailFromReader } from '@open-pencil/fig'
function memoryReader(bytes: Uint8Array, ranges: Array<[number, number]>) {
return {
size: bytes.byteLength,
async read(start: number, endExclusive: number) {
ranges.push([start, endExclusive])
return bytes.slice(start, endExclusive)
}
}
}
describe('fig ranged thumbnail extraction', () => {
test('extracts thumbnail.png without reading the complete fig', async () => {
const bytes = new Uint8Array(readFileSync('tests/fixtures/gold-preview.fig'))
const ranges: Array<[number, number]> = []
const thumbnail = await extractFigThumbnailFromReader(memoryReader(bytes, ranges), {
maxTailBytes: 4 * 1024 * 1024
})
expect(thumbnail?.subarray(0, 8)).toEqual(
new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
)
expect(ranges.length).toBeGreaterThanOrEqual(2)
expect(ranges.every(([start, end]) => start !== 0 || end !== bytes.byteLength)).toBe(true)
expect(ranges.reduce((total, [start, end]) => total + end - start, 0)).toBeLessThan(
bytes.byteLength
)
})
test('rejects thumbnails above configured output limits', async () => {
const bytes = new Uint8Array(readFileSync('tests/fixtures/gold-preview.fig'))
const thumbnail = await extractFigThumbnailFromReader(memoryReader(bytes, []), {
maxOutputBytes: 32
})
expect(thumbnail).toBeNull()
})
})

View file

@ -0,0 +1,196 @@
import { afterEach, describe, expect, test, vi } from 'bun:test'
import { createRenderer, defineComponent, h, type ComponentPublicInstance } from 'vue'
import {
useDocumentWorkspace,
type DocumentWorkspaceItem,
type DocumentWorkspaceSource
} from '@open-pencil/vue'
type HostNode = {
children: HostNode[]
parent: HostNode | null
text: string
}
type Deferred<Value> = {
promise: Promise<Value>
resolve(value: Value): void
}
function deferred<Value>(): Deferred<Value> {
let resolvePromise: ((value: Value) => void) | null = null
const promise = new Promise<Value>((resolve) => {
resolvePromise = resolve
})
return {
promise,
resolve(value) {
resolvePromise?.(value)
}
}
}
function hostNode(text = ''): HostNode {
return { children: [], parent: null, text }
}
const renderer = createRenderer<HostNode, HostNode>({
patchProp() {
return undefined
},
insert(child, parent, anchor) {
child.parent = parent
const index = anchor ? parent.children.indexOf(anchor) : -1
if (index < 0) parent.children.push(child)
else parent.children.splice(index, 0, child)
},
remove(child) {
const parent = child.parent
if (!parent) return
const index = parent.children.indexOf(child)
if (index !== -1) parent.children.splice(index, 1)
child.parent = null
},
createElement() {
return hostNode()
},
createText: hostNode,
createComment: hostNode,
setText(node, text) {
node.text = text
},
setElementText(node, text) {
node.text = text
},
parentNode(node) {
return node.parent
},
nextSibling(node) {
const parent = node.parent
if (!parent) return null
return parent.children[parent.children.indexOf(node) + 1] ?? null
},
querySelector() {
return null
},
setScopeId() {
return undefined
},
insertStaticContent(content, parent, anchor) {
const node = hostNode(content)
this.insert(node, parent, anchor)
return [node, node]
}
})
type Workspace = ReturnType<typeof useDocumentWorkspace<DocumentWorkspaceItem>>
type WorkspaceHolder = { current: Workspace | null }
async function flushTasks(): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, 0)
})
}
function mountWorkspace(
source: DocumentWorkspaceSource<DocumentWorkspaceItem>,
options: { previewConcurrency?: number } = {}
): { workspace: Workspace; unmount(): void } {
const holder: WorkspaceHolder = { current: null }
const component = defineComponent({
setup() {
holder.current = useDocumentWorkspace({
source,
previewConcurrency: options.previewConcurrency,
refreshOnFocus: false,
refreshOnReconnect: false
})
return () => h('div')
}
})
const app = renderer.createApp(component)
app.mount(hostNode()) as ComponentPublicInstance
const workspace = holder.current
if (workspace == null) throw new Error('Workspace composable did not initialize')
return { workspace, unmount: () => app.unmount() }
}
afterEach(() => {
vi.restoreAllMocks()
})
describe('useDocumentWorkspace', () => {
test('shares an in-flight refresh and responds to source events', async () => {
const firstRefresh = deferred<DocumentWorkspaceItem[]>()
let sourceListener: (() => void) | null = null
const refresh = vi
.fn<() => Promise<DocumentWorkspaceItem[]>>()
.mockImplementationOnce(() => firstRefresh.promise)
.mockResolvedValueOnce([{ id: 'second', name: 'Second', updatedAt: '2026-08-10' }])
const mounted = mountWorkspace({
refresh,
loadPreview: async () => null,
subscribe(listener) {
sourceListener = listener
return () => {
sourceListener = null
}
}
})
const inFlightA = mounted.workspace.refresh()
const inFlightB = mounted.workspace.refresh()
expect(inFlightA).toBe(inFlightB)
expect(refresh).toHaveBeenCalledTimes(1)
sourceListener?.()
sourceListener?.()
firstRefresh.resolve([{ id: 'first', name: 'First', updatedAt: '2026-08-09' }])
await inFlightA
await flushTasks()
expect(refresh).toHaveBeenCalledTimes(2)
expect(mounted.workspace.documents.value.map(({ id }) => id)).toEqual(['second'])
mounted.unmount()
expect(sourceListener).toBeNull()
})
test('deduplicates previews, limits concurrency, and revokes URLs on unmount', async () => {
const loads = new Map<string, Deferred<Uint8Array | null>>()
const loadPreview = vi.fn((id: string) => {
const load = deferred<Uint8Array | null>()
loads.set(id, load)
return load.promise
})
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview')
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined)
const mounted = mountWorkspace(
{ refresh: async () => [], loadPreview },
{ previewConcurrency: 2 }
)
mounted.workspace.loadPreview('one')
mounted.workspace.loadPreview('one')
mounted.workspace.loadPreview('two')
mounted.workspace.loadPreview('three')
expect(loadPreview.mock.calls.map((call) => call[0])).toEqual(['one', 'two'])
loads.get('one')?.resolve(new Uint8Array([1]))
await loads.get('one')?.promise
await flushTasks()
expect(loadPreview.mock.calls.map((call) => call[0])).toEqual(['one', 'two', 'three'])
expect(createObjectURL).toHaveBeenCalledTimes(1)
mounted.unmount()
expect(revokeObjectURL).toHaveBeenCalledWith('blob:preview')
loads.get('two')?.resolve(new Uint8Array([2]))
loads.get('three')?.resolve(new Uint8Array([3]))
await Promise.all([loads.get('two')?.promise, loads.get('three')?.promise])
await flushTasks()
expect(createObjectURL).toHaveBeenCalledTimes(1)
})
})