feat(app): recover unsaved documents

- Persist debounced FIG snapshots for source-less documents in IndexedDB

- Restore or discard orphaned snapshots from the editor startup dialog

- Clear recovery data after successful saves and explicit tab closure
This commit is contained in:
Danila Poyarkov 2026-08-12 20:12:37 +03:00
parent a1182a1fc6
commit 37912d92a7
26 changed files with 1301 additions and 585 deletions

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,11 @@
"cancel": "Abbrechen",
"apply": "Anwenden",
"close": "Schließen",
"recoverUnsavedWork": "Nicht gespeicherte Arbeit wiederherstellen",
"recoverUnsavedWorkDescription": "OpenPencil hat Dokumente aus einer vorherigen Sitzung gefunden.",
"restore": "Wiederherstellen",
"discard": "Verwerfen",
"recoveryFailed": "Dieses Dokument konnte nicht wiederhergestellt werden.",
"rename": "Umbenennen",
"renameLayers": "{count} Ebenen umbenennen",
"renamePreview": "Vorschau",

View file

@ -2,6 +2,11 @@
"cancel": "Cancelar",
"apply": "Aplicar",
"close": "Cerrar",
"recoverUnsavedWork": "Recuperar trabajo no guardado",
"recoverUnsavedWorkDescription": "OpenPencil encontró documentos de una sesión anterior.",
"restore": "Restaurar",
"discard": "Descartar",
"recoveryFailed": "No se pudo restaurar este documento.",
"rename": "Renombrar",
"renameLayers": "Renombrar {count} capas",
"renamePreview": "Vista previa",

View file

@ -2,6 +2,11 @@
"cancel": "Annuler",
"apply": "Appliquer",
"close": "Fermer",
"recoverUnsavedWork": "Récupérer le travail non enregistré",
"recoverUnsavedWorkDescription": "OpenPencil a trouvé des documents dune session précédente.",
"restore": "Restaurer",
"discard": "Ignorer",
"recoveryFailed": "Impossible de restaurer ce document.",
"rename": "Renommer",
"renameLayers": "Renommer {count} calques",
"renamePreview": "Aperçu",

View file

@ -2,6 +2,11 @@
"cancel": "Annulla",
"apply": "Applica",
"close": "Chiudi",
"recoverUnsavedWork": "Recupera il lavoro non salvato",
"recoverUnsavedWorkDescription": "OpenPencil ha trovato documenti di una sessione precedente.",
"restore": "Ripristina",
"discard": "Scarta",
"recoveryFailed": "Impossibile ripristinare questo documento.",
"rename": "Rinomina",
"renameLayers": "Rinomina {count} livelli",
"renamePreview": "Anteprima",

View file

@ -2,6 +2,11 @@
"cancel": "キャンセル",
"apply": "適用",
"close": "閉じる",
"recoverUnsavedWork": "未保存の作業を復元",
"recoverUnsavedWorkDescription": "OpenPencil が前回のセッションのドキュメントを見つけました。",
"restore": "復元",
"discard": "破棄",
"recoveryFailed": "このドキュメントを復元できませんでした。",
"rename": "名前を変更",
"renameLayers": "{count} 個のレイヤー名を変更",
"renamePreview": "プレビュー",

View file

@ -2,6 +2,11 @@
"cancel": "Anuluj",
"apply": "Zastosuj",
"close": "Zamknij",
"recoverUnsavedWork": "Odzyskaj niezapisaną pracę",
"recoverUnsavedWorkDescription": "OpenPencil znalazł dokumenty z poprzedniej sesji.",
"restore": "Przywróć",
"discard": "Odrzuć",
"recoveryFailed": "Nie udało się przywrócić tego dokumentu.",
"rename": "Zmień nazwę",
"renameLayers": "Zmień nazwy {count} warstw",
"renamePreview": "Podgląd",

View file

@ -2,6 +2,11 @@
"cancel": "Отмена",
"apply": "Применить",
"close": "Закрыть",
"recoverUnsavedWork": "Восстановить несохранённую работу",
"recoverUnsavedWorkDescription": "OpenPencil обнаружил документы из предыдущего сеанса.",
"restore": "Восстановить",
"discard": "Удалить",
"recoveryFailed": "Не удалось восстановить этот документ.",
"rename": "Переименовать",
"renameLayers": "Переименовать слои: {count}",
"renamePreview": "Предпросмотр",

View file

@ -2,6 +2,11 @@
"cancel": "取消",
"apply": "应用",
"close": "关闭",
"recoverUnsavedWork": "恢复未保存的工作",
"recoverUnsavedWorkDescription": "OpenPencil 找到了上次会话中的文档。",
"restore": "恢复",
"discard": "丢弃",
"recoveryFailed": "无法恢复此文档。",
"rename": "重命名",
"renameLayers": "重命名 {count} 个图层",
"renamePreview": "预览",

View file

@ -45,6 +45,11 @@ export const dialogMessageDefaults = {
importToCanvas: 'Import to canvas',
newTab: 'New tab',
closeTab: params('Close {name}'),
recoverUnsavedWork: 'Recover unsaved work',
recoverUnsavedWorkDescription: 'OpenPencil found documents from a previous session.',
restore: 'Restore',
discard: 'Discard',
recoveryFailed: 'Could not restore this document.',
showUI: params('Show UI ({shortcut})'),
fontSettings: 'Font settings',
fontSettingsDesktopDescription:

View file

@ -1,17 +1,20 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useEventListener } from '@vueuse/core'
import { useHead } from '@unhead/vue'
import { TooltipProvider } from 'reka-ui'
import { provideEditor, useI18n } from '@open-pencil/vue'
import AppShell from '@/components/Shell/AppShell.vue'
import AppToast from '@/components/Shell/AppToast.vue'
import RecoveryDialog from '@/components/recovery/RecoveryDialog.vue'
import SettingsDialog from '@/components/settings/SettingsDialog.vue'
import { useEditorStore } from '@/app/editor/active-store'
import { toast } from '@/app/shell/ui'
import { useAppTheme } from '@/app/shell/theme'
import { scheduleStartupUpdateCheck } from '@/app/shell/updater'
import { kickSyncEngine } from '@/app/storage/sync'
import { prepareForReload } from '@/app/tabs'
const store = useEditorStore()
const { dialogs, locale } = useI18n()
@ -23,6 +26,9 @@ useHead({
provideEditor(store)
useAppTheme()
useEventListener(window, 'pagehide', () => {
void prepareForReload()
})
onMounted(() => {
toast.setupGlobalErrorHandler()
@ -37,6 +43,7 @@ onMounted(() => {
<RouterView />
</AppShell>
<SettingsDialog />
<RecoveryDialog />
<AppToast />
</TooltipProvider>
</template>

View file

@ -70,6 +70,10 @@ export function createDocumentIOActions(
getDocumentFilePath: sourceState.getFilePath,
getSourceIdentity: sourceState.getSourceIdentity,
getStorageBinding: sourceState.getStorageBinding,
getRecoveryId: sourceActions.getRecoveryId,
adoptRecoverySnapshot: sourceActions.adoptRecoverySnapshot,
persistRecoveryNow: sourceActions.persistRecoveryNow,
discardRecovery: sourceActions.discardRecovery,
setDocumentSource: sourceActions.setDocumentSource,
setStorageDocumentSource: sourceActions.setStorageDocumentSource,
setPlannedFilePath: sourceActions.setPlannedFilePath,

View file

@ -14,6 +14,8 @@ type SaveActionsOptions = Omit<DocumentSourceAccess, 'getSavedVersion'> & {
state: SaveDocumentState
buildFigFile: () => Uint8Array | Promise<Uint8Array>
startWatchingFile: () => void
onWriteSuccess?: (version: number) => void | Promise<void>
onDownloadSuccess?: (version: number) => void | Promise<void>
}
export function createSaveActions({
@ -30,7 +32,9 @@ export function createSaveActions({
setSourceIdentity,
setSavedVersion,
setLastWriteTime,
startWatchingFile
startWatchingFile,
onWriteSuccess,
onDownloadSuccess
}: SaveActionsOptions) {
const writeFile = createDocumentWriter({
state,
@ -38,26 +42,35 @@ export function createSaveActions({
getFileHandle,
getStorageBinding,
setSavedVersion,
setLastWriteTime
setLastWriteTime,
onWriteSuccess
})
async function buildVersionedFigFile() {
const version = state.sceneVersion
return { data: await buildFigFile(), version }
}
async function saveFigFile() {
const filePath = getFilePath()
const fileHandle = getFileHandle()
const storageBinding = getStorageBinding()
const downloadName = getDownloadName()
if (storageBinding || filePath || fileHandle) {
const wrote = await writeFile(await buildFigFile())
const { data, version } = await buildVersionedFigFile()
const wrote = await writeFile(data, version)
if (wrote && !storageBinding) setSourceIdentity({ handle: fileHandle, path: filePath })
} else if (downloadName) {
downloadBlob(new Uint8Array(await buildFigFile()), downloadName, 'application/octet-stream')
const { data, version } = await buildVersionedFigFile()
downloadBlob(new Uint8Array(data), downloadName, 'application/octet-stream')
await onDownloadSuccess?.(version)
} else {
await saveFigFileAs()
}
}
async function saveFigFileAs() {
const data = await buildFigFile()
const { data, version } = await buildVersionedFigFile()
if (IS_TAURI) {
const path = await chooseTauriFigSavePath()
@ -66,7 +79,7 @@ export function createSaveActions({
setFilePath(path)
setFileHandle(null)
state.documentName = documentNameFromFigPath(path)
if (await writeFile(data)) setSourceIdentity({ handle: null, path })
if (await writeFile(data, version)) setSourceIdentity({ handle: null, path })
startWatchingFile()
return
}
@ -78,7 +91,7 @@ export function createSaveActions({
setFileHandle(handle)
setFilePath(null)
state.documentName = documentNameFromFigPath(handle.name)
if (await writeFile(data)) setSourceIdentity({ handle, path: null })
if (await writeFile(data, version)) setSourceIdentity({ handle, path: null })
startWatchingFile()
return
}
@ -89,6 +102,7 @@ export function createSaveActions({
setDownloadName(filename)
state.documentName = documentNameFromFigPath(filename)
downloadBlob(new Uint8Array(data), filename, 'application/octet-stream')
await onDownloadSuccess?.(version)
}
return { saveFigFile, saveFigFileAs, writeFile }

View file

@ -10,6 +10,7 @@ import {
import { createSaveActions } from '@/app/document/io/save'
import { createDocumentSourceState } from '@/app/document/io/source-state'
import type { DocumentSourceAccess } from '@/app/document/io/types'
import { createDocumentRecovery } from '@/app/document/recovery'
import type { StorageDocumentBinding } from '@/app/integrations/storage/types'
type DocumentSourceState = EditorState & {
@ -50,6 +51,16 @@ export function createDocumentSourceActions({
return exportFigFile(editor.graph, undefined, getRenderer() ?? undefined, state.currentPageId)
}
function buildRecoveryFigFile() {
return exportFigFile(editor.graph, undefined, undefined, state.currentPageId)
}
const recovery = createDocumentRecovery({
state,
buildFigFile: buildRecoveryFigFile,
hasWritableSource: () => !!getFileHandle() || !!getFilePath() || !!getStorageBinding()
})
const { saveFigFile, saveFigFileAs, writeFile } = createSaveActions({
state,
buildFigFile,
@ -66,7 +77,9 @@ export function createDocumentSourceActions({
setLastWriteTime,
startWatchingFile: () => {
void startWatchingFile()
}
},
onWriteSuccess: (version) => recovery.markProtectedVersion(version),
onDownloadSuccess: (version) => recovery.markProtectedVersion(version)
})
const { disposeAutosave } = createAutosave({
@ -92,6 +105,7 @@ export function createDocumentSourceActions({
setDownloadName(figDownloadName(fileName, sourceFormat))
setSourceIdentity({ handle: handle ?? null, path: path ?? null })
setSavedVersion(state.sceneVersion)
void recovery.markProtectedVersion(state.sceneVersion)
if (isFig && (handle || path)) {
void startWatchingFile()
}
@ -107,6 +121,7 @@ export function createDocumentSourceActions({
state.documentName = documentName
state.autosaveEnabled = true
setSavedVersion(state.sceneVersion)
void recovery.markProtectedVersion(state.sceneVersion)
}
function setPlannedFilePath(path: string) {
@ -126,6 +141,7 @@ export function createDocumentSourceActions({
function disposeDocumentIO() {
stopWatchingFile()
disposeAutosave()
recovery.disposeRecovery()
}
return {
@ -136,6 +152,11 @@ export function createDocumentSourceActions({
disposeDocumentIO,
saveFigFile,
saveFigFileAs,
getStorageBinding
getStorageBinding,
getRecoveryId: () => recovery.getRecoveryId(),
adoptRecoverySnapshot: (id: string, version: number) =>
recovery.adoptRecoverySnapshot(id, version),
persistRecoveryNow: () => recovery.persistNow(),
discardRecovery: () => recovery.discardRecovery()
}
}

View file

@ -13,6 +13,7 @@ type DocumentWriterOptions = {
getStorageBinding: () => StorageDocumentBinding | null
setSavedVersion: (version: number) => void
setLastWriteTime: (time: number) => void
onWriteSuccess?: (version: number) => void | Promise<void>
}
export function createDocumentWriter({
@ -21,9 +22,19 @@ export function createDocumentWriter({
getFileHandle,
getStorageBinding,
setSavedVersion,
setLastWriteTime
setLastWriteTime,
onWriteSuccess
}: DocumentWriterOptions) {
return async function writeFile(data: Uint8Array): Promise<boolean> {
async function finishWrite(version: number): Promise<true> {
setSavedVersion(version)
await onWriteSuccess?.(version)
return true
}
return async function writeFile(
data: Uint8Array,
version = state.sceneVersion
): Promise<boolean> {
setLastWriteTime(Date.now())
const storage = getStorageBinding()
if (storage) {
@ -33,8 +44,7 @@ export function createDocumentWriter({
name: state.documentName || 'Untitled',
figBytes: data
})
setSavedVersion(state.sceneVersion)
return true
return finishWrite(version)
}
const filePath = getFilePath()
@ -42,15 +52,13 @@ export function createDocumentWriter({
if (filePath && isTauri()) {
const { writeFile: tauriWrite } = await import('@tauri-apps/plugin-fs')
await tauriWrite(filePath, data)
setSavedVersion(state.sceneVersion)
return true
return finishWrite(version)
}
if (fileHandle) {
const writable = await fileHandle.createWritable()
await writable.write(new Uint8Array(data))
await writable.close()
setSavedVersion(state.sceneVersion)
return true
return finishWrite(version)
}
return false
}

View file

@ -0,0 +1,111 @@
import { watchDebounced } from '@vueuse/core'
import type { WatchHandle } from 'vue'
import type { EditorState } from '@open-pencil/core/editor'
import { getRecoveryStore } from '@/app/document/recovery/store'
import type { RecoveryStore } from '@/app/document/recovery/types'
import { createCanvasId } from '@/app/storage/id'
type RecoveryState = EditorState & { documentName: string }
interface DocumentRecoveryOptions {
state: RecoveryState
buildFigFile: () => Promise<Uint8Array> | Uint8Array
hasWritableSource: () => boolean
store?: RecoveryStore
recoveryId?: string
}
export interface DocumentRecoveryController {
getRecoveryId(): string
adoptRecoverySnapshot(id: string, sceneVersion: number): void
persistNow(): Promise<void>
markProtectedVersion(version: number): Promise<void>
discardRecovery(): Promise<void>
disposeRecovery(): void
}
export function createDocumentRecovery({
state,
buildFigFile,
hasWritableSource,
store = getRecoveryStore(),
recoveryId = createCanvasId()
}: DocumentRecoveryOptions): DocumentRecoveryController {
let id = recoveryId
let protectedVersion = state.sceneVersion
let requestedVersion = protectedVersion
let lifecycleGeneration = 0
let writing: Promise<void> | null = null
let disposed = false
async function runWrites(generation: number): Promise<void> {
if (disposed || generation !== lifecycleGeneration) return
if (hasWritableSource() || requestedVersion === protectedVersion) return
const version = requestedVersion
const bytes = await buildFigFile()
if (generation !== lifecycleGeneration || hasWritableSource()) return
await store.write({
id,
documentName: state.documentName,
sceneVersion: version,
figBytes: bytes
})
if (generation !== lifecycleGeneration) return
protectedVersion = version
if (requestedVersion !== version) await runWrites(generation)
}
async function persistNow(): Promise<void> {
if (disposed || hasWritableSource()) return
requestedVersion = state.sceneVersion
if (requestedVersion === protectedVersion) return
if (!writing) {
const generation = lifecycleGeneration
writing = runWrites(generation)
.catch((error) => console.warn('[Recovery] Snapshot failed:', error))
.finally(() => {
writing = null
})
}
await writing
}
const stop: WatchHandle = watchDebounced(
() => state.sceneVersion,
() => {
void persistNow()
},
{ debounce: 3000, maxWait: 10000 }
)
return {
getRecoveryId: () => id,
adoptRecoverySnapshot(nextId, sceneVersion) {
lifecycleGeneration++
id = nextId
protectedVersion = sceneVersion
requestedVersion = sceneVersion
disposed = false
},
persistNow,
async markProtectedVersion(version) {
lifecycleGeneration++
protectedVersion = version
requestedVersion = version
await store.remove(id)
},
async discardRecovery() {
lifecycleGeneration++
protectedVersion = state.sceneVersion
requestedVersion = state.sceneVersion
await store.remove(id)
},
disposeRecovery() {
disposed = true
lifecycleGeneration++
stop()
}
}
}

View file

@ -0,0 +1,90 @@
import { openDB, type DBSchema } from 'idb'
import type {
RecoverySnapshot,
RecoverySnapshotInput,
RecoverySnapshotMeta,
RecoveryStore
} from '@/app/document/recovery/types'
const DB_NAME = 'open-pencil-recovery'
const DB_VERSION = 1
interface RecoveryDatabase extends DBSchema {
meta: {
key: string
value: RecoverySnapshotMeta
}
fig: {
key: string
value: Uint8Array
}
}
export function createIdbRecoveryStore(): RecoveryStore {
const database = openDB<RecoveryDatabase>(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta', { keyPath: 'id' })
if (!db.objectStoreNames.contains('fig')) db.createObjectStore('fig')
}
})
return {
async list() {
const rows = await (await database).getAll('meta')
return rows.toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt))
},
async read(id: string) {
const db = await database
const transaction = db.transaction(['meta', 'fig'])
const [metadata, figBytes] = await Promise.all([
transaction.objectStore('meta').get(id),
transaction.objectStore('fig').get(id)
])
await transaction.done
return metadata && figBytes
? ({ ...metadata, figBytes: Uint8Array.from(figBytes) } satisfies RecoverySnapshot)
: null
},
async write(input: RecoverySnapshotInput) {
const db = await database
const transaction = db.transaction(['meta', 'fig'], 'readwrite')
const metadata: RecoverySnapshotMeta = {
id: input.id,
documentName: input.documentName || 'Untitled',
updatedAt: new Date().toISOString(),
sceneVersion: input.sceneVersion,
byteLength: input.figBytes.byteLength,
formatVersion: 1
}
await Promise.all([
transaction.objectStore('meta').put(metadata),
transaction.objectStore('fig').put(Uint8Array.from(input.figBytes), input.id),
transaction.done
])
return metadata
},
async remove(id: string) {
const db = await database
const transaction = db.transaction(['meta', 'fig'], 'readwrite')
await Promise.all([
transaction.objectStore('meta').delete(id),
transaction.objectStore('fig').delete(id),
transaction.done
])
},
async clear() {
const db = await database
const transaction = db.transaction(['meta', 'fig'], 'readwrite')
await Promise.all([
transaction.objectStore('meta').clear(),
transaction.objectStore('fig').clear(),
transaction.done
])
}
}
}

View file

@ -0,0 +1,13 @@
export { createDocumentRecovery } from '@/app/document/recovery/controller'
export type { DocumentRecoveryController } from '@/app/document/recovery/controller'
export {
getRecoveryStore,
isRecoveryStoreMemoryFallback,
resetRecoveryStoreForTests
} from '@/app/document/recovery/store'
export type {
RecoverySnapshot,
RecoverySnapshotInput,
RecoverySnapshotMeta,
RecoveryStore
} from '@/app/document/recovery/types'

View file

@ -0,0 +1,43 @@
import type {
RecoverySnapshot,
RecoverySnapshotInput,
RecoverySnapshotMeta,
RecoveryStore
} from '@/app/document/recovery/types'
export function createMemoryRecoveryStore(): RecoveryStore {
const snapshots = new Map<string, RecoverySnapshot>()
return {
async list() {
return [...snapshots.values()]
.map(({ figBytes: _figBytes, ...metadata }) => structuredClone(metadata))
.toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt))
},
async read(id: string) {
const snapshot = snapshots.get(id)
return snapshot ? structuredClone(snapshot) : null
},
async write(input: RecoverySnapshotInput) {
const metadata: RecoverySnapshotMeta = {
id: input.id,
documentName: input.documentName || 'Untitled',
updatedAt: new Date().toISOString(),
sceneVersion: input.sceneVersion,
byteLength: input.figBytes.byteLength,
formatVersion: 1
}
snapshots.set(input.id, {
...metadata,
figBytes: new Uint8Array(input.figBytes)
})
return structuredClone(metadata)
},
async remove(id: string) {
snapshots.delete(id)
},
async clear() {
snapshots.clear()
}
}
}

View file

@ -0,0 +1,28 @@
import { createIdbRecoveryStore } from '@/app/document/recovery/idb'
import { createMemoryRecoveryStore } from '@/app/document/recovery/memory'
import type { RecoveryStore } from '@/app/document/recovery/types'
let singleton: RecoveryStore | null = null
let memoryFallback = false
export function getRecoveryStore(): RecoveryStore {
if (singleton) return singleton
if (typeof indexedDB !== 'undefined') {
singleton = createIdbRecoveryStore()
memoryFallback = false
} else {
console.warn('[Recovery] IndexedDB unavailable; crash recovery is limited to this session')
singleton = createMemoryRecoveryStore()
memoryFallback = true
}
return singleton
}
export function isRecoveryStoreMemoryFallback(): boolean {
return memoryFallback
}
export function resetRecoveryStoreForTests(store?: RecoveryStore): void {
singleton = store ?? null
memoryFallback = false
}

View file

@ -0,0 +1,27 @@
export interface RecoverySnapshotMeta {
id: string
documentName: string
updatedAt: string
sceneVersion: number
byteLength: number
formatVersion: 1
}
export interface RecoverySnapshot extends RecoverySnapshotMeta {
figBytes: Uint8Array
}
export interface RecoverySnapshotInput {
id: string
documentName: string
sceneVersion: number
figBytes: Uint8Array
}
export interface RecoveryStore {
list(): Promise<RecoverySnapshotMeta[]>
read(id: string): Promise<RecoverySnapshot | null>
write(input: RecoverySnapshotInput): Promise<RecoverySnapshotMeta>
remove(id: string): Promise<void>
clear(): Promise<void>
}

View file

@ -76,6 +76,10 @@ export function createEditorStoreModules(
getDocumentFilePath: documentIO.getDocumentFilePath,
getSourceIdentity: documentIO.getSourceIdentity,
getStorageBinding: documentIO.getStorageBinding,
getRecoveryId: documentIO.getRecoveryId,
adoptRecoverySnapshot: documentIO.adoptRecoverySnapshot,
persistRecoveryNow: documentIO.persistRecoveryNow,
discardRecovery: documentIO.discardRecovery,
setDocumentSource: documentIO.setDocumentSource,
setStorageDocumentSource: documentIO.setStorageDocumentSource,
setPlannedFilePath: documentIO.setPlannedFilePath,

View file

@ -7,6 +7,7 @@ import type { SceneGraph } from '@open-pencil/scene-graph'
import { setOpenPencilStore } from '@/app/browser-bridge'
import type { DocumentSourceIdentity } from '@/app/document/io/types'
import { getRecoveryStore, type RecoverySnapshotMeta } from '@/app/document/recovery'
import { setActiveEditorStore } from '@/app/editor/active-store'
import { createEditorStore } from '@/app/editor/session'
import type { EditorStore } from '@/app/editor/session'
@ -96,6 +97,8 @@ export function closeTab(tabId: string) {
const closingTab = tabsRef.value[idx]
const wasActive = activeTabId.value === tabId
closingTab.store.dispose()
void closingTab.store.discardRecovery()
tabsRef.value = tabsRef.value.filter((t) => t.id !== tabId)
if (tabsRef.value.length === 0) {
@ -265,6 +268,41 @@ export async function openFileInNewTab(
}
}
export async function listRecoverySnapshots(): Promise<RecoverySnapshotMeta[]> {
return getRecoveryStore().list()
}
export async function discardRecoverySnapshot(id: string): Promise<void> {
await getRecoveryStore().remove(id)
}
export async function restoreRecoverySnapshot(id: string): Promise<void> {
const snapshot = await getRecoveryStore().read(id)
if (!snapshot) throw new Error('Recovery snapshot is no longer available')
const fileBytes = new Uint8Array(snapshot.figBytes)
const file = new File([fileBytes.buffer], `${snapshot.documentName}.fig`, {
type: 'application/octet-stream'
})
const imported = await readFigFile(file, { populate: 'first-page' })
const firstPageId = imported.getPages()[0]?.id
if (firstPageId) computeAllLayouts(imported, firstPageId)
const store = reusableTabStore()
store.replaceGraph(imported)
store.undo.clear()
store.state.documentName = snapshot.documentName
store.adoptRecoverySnapshot(id, store.state.sceneVersion)
store.clearSelection()
const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId
await store.switchPage(pageId)
await store.fitCurrentPageToViewport()
}
export async function prepareForReload(): Promise<void> {
await Promise.all(tabsRef.value.map((tab) => tab.store.persistRecoveryNow()))
}
export function tabCount(): number {
return tabsRef.value.length
}
@ -282,6 +320,10 @@ export function useTabsStore() {
getTabsSnapshot,
openFileInNewTab,
openStorageDocumentInNewTab,
listRecoverySnapshots,
restoreRecoverySnapshot,
discardRecoverySnapshot,
prepareForReload,
getActiveStore,
tabCount
}

View file

@ -0,0 +1,110 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { AlertDialogCancel, AlertDialogDescription, AlertDialogTitle } from 'reka-ui'
import { useI18n } from '@open-pencil/vue'
import { discardRecoverySnapshot, listRecoverySnapshots, restoreRecoverySnapshot } from '@/app/tabs'
import type { RecoverySnapshotMeta } from '@/app/document/recovery'
import { formatStorageBytes } from '@/app/storage/format-bytes'
import { toast } from '@/app/shell/ui'
import { AppAlertDialogRoot, AppDialogBody, AppDialogFooter } from '@/components/ui/dialog'
const { dialogs } = useI18n()
const route = useRoute()
const snapshots = ref<RecoverySnapshotMeta[]>([])
const busyId = ref<string | null>(null)
const open = ref(false)
function updatedLabel(snapshot: RecoverySnapshotMeta): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(snapshot.updatedAt))
}
async function restore(snapshot: RecoverySnapshotMeta): Promise<void> {
busyId.value = snapshot.id
try {
await restoreRecoverySnapshot(snapshot.id)
snapshots.value = snapshots.value.filter((candidate) => candidate.id !== snapshot.id)
} catch (error) {
toast.error(error instanceof Error ? error.message : dialogs.value.recoveryFailed)
} finally {
busyId.value = null
}
}
async function discard(snapshot: RecoverySnapshotMeta): Promise<void> {
busyId.value = snapshot.id
try {
await discardRecoverySnapshot(snapshot.id)
snapshots.value = snapshots.value.filter((candidate) => candidate.id !== snapshot.id)
} finally {
busyId.value = null
}
}
onMounted(async () => {
if (route.path !== '/') return
try {
snapshots.value = await listRecoverySnapshots()
open.value = snapshots.value.length > 0
} catch (error) {
console.warn('[Recovery] Failed to list snapshots:', error)
}
})
</script>
<template>
<AppAlertDialogRoot v-model:open="open" size="md" data-test-id="recovery-dialog">
<div class="border-b border-border px-4 py-3">
<AlertDialogTitle class="text-sm font-semibold text-surface">
{{ dialogs.recoverUnsavedWork }}
</AlertDialogTitle>
</div>
<AppDialogBody class="space-y-3">
<AlertDialogDescription class="text-xs text-muted">
{{ dialogs.recoverUnsavedWorkDescription }}
</AlertDialogDescription>
<div class="max-h-72 space-y-2 overflow-y-auto">
<div
v-for="snapshot in snapshots"
:key="snapshot.id"
class="flex items-center gap-3 rounded border border-border p-3"
>
<icon-lucide-file-clock class="size-4 shrink-0 text-muted" />
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium text-surface">{{ snapshot.documentName }}</p>
<p class="text-[11px] text-muted">
{{ updatedLabel(snapshot) }} · {{ formatStorageBytes(snapshot.byteLength) }}
</p>
</div>
<button
type="button"
class="rounded px-2 py-1 text-xs text-danger hover:bg-danger/10 disabled:opacity-50"
:disabled="busyId !== null"
@click="discard(snapshot)"
>
{{ dialogs.discard }}
</button>
<button
type="button"
class="rounded bg-accent px-2.5 py-1 text-xs text-white disabled:opacity-50"
:disabled="busyId !== null"
@click="restore(snapshot)"
>
{{ dialogs.restore }}
</button>
</div>
</div>
</AppDialogBody>
<AppDialogFooter>
<AlertDialogCancel as-child>
<button class="rounded px-3 py-1.5 text-xs text-muted hover:bg-hover">
{{ dialogs.close }}
</button>
</AlertDialogCancel>
</AppDialogFooter>
</AppAlertDialogRoot>
</template>

View file

@ -0,0 +1,83 @@
import { describe, expect, test } from 'bun:test'
import { reactive } from 'vue'
import { createDefaultEditorState } from '@open-pencil/core/editor'
import { createDocumentRecovery } from '@/app/document/recovery/controller'
import { createMemoryRecoveryStore } from '@/app/document/recovery/memory'
function setup(buildFigFile = async () => new Uint8Array([1, 2, 3])) {
const state = reactive({ ...createDefaultEditorState('page-1'), documentName: 'Agent draft' })
const store = createMemoryRecoveryStore()
let writable = false
const recovery = createDocumentRecovery({
state,
store,
recoveryId: 'recovery-1',
hasWritableSource: () => writable,
buildFigFile
})
return { state, store, recovery, setWritable: (value: boolean) => (writable = value) }
}
describe('document recovery controller', () => {
test('persists source-less changes and skips untouched documents', async () => {
const { state, store, recovery } = setup()
await recovery.persistNow()
expect(await store.list()).toEqual([])
state.sceneVersion = 1
await recovery.persistNow()
expect((await store.read('recovery-1'))?.sceneVersion).toBe(1)
recovery.disposeRecovery()
})
test('does not persist documents with writable sources', async () => {
const { state, store, recovery, setWritable } = setup()
setWritable(true)
state.sceneVersion = 1
await recovery.persistNow()
expect(await store.list()).toEqual([])
recovery.disposeRecovery()
})
test('coalesces concurrent changes to the latest scene version', async () => {
let release: (() => void) | null = null
let calls = 0
const { state, store, recovery } = setup(async () => {
calls++
if (calls === 1) {
await new Promise<void>((resolve) => {
release = resolve
})
}
return new Uint8Array([calls])
})
state.sceneVersion = 1
const pending = recovery.persistNow()
await Promise.resolve()
state.sceneVersion = 2
void recovery.persistNow()
const releaseFirst = () => {
if (release) release()
}
releaseFirst()
await pending
expect(calls).toBe(2)
expect((await store.read('recovery-1'))?.sceneVersion).toBe(2)
recovery.disposeRecovery()
})
test('successful save removes recovery data', async () => {
const { state, store, recovery } = setup()
state.sceneVersion = 1
await recovery.persistNow()
expect(await store.list()).toHaveLength(1)
await recovery.markProtectedVersion(1)
expect(await store.list()).toEqual([])
recovery.disposeRecovery()
})
})

View file

@ -0,0 +1,54 @@
import 'fake-indexeddb/auto'
import { beforeEach, describe, expect, test } from 'bun:test'
import { createIdbRecoveryStore } from '@/app/document/recovery/idb'
import { createMemoryRecoveryStore } from '@/app/document/recovery/memory'
const bytes = new Uint8Array([1, 2, 3, 4])
describe('document recovery store', () => {
beforeEach(async () => {
await new Promise<void>((resolve) => {
const request = indexedDB.deleteDatabase('open-pencil-recovery')
request.onsuccess = () => resolve()
request.onerror = () => resolve()
request.onblocked = () => resolve()
})
})
test('stores metadata and FIG bytes atomically in IndexedDB', async () => {
const store = createIdbRecoveryStore()
const metadata = await store.write({
id: 'recovery-1',
documentName: 'Agent draft',
sceneVersion: 12,
figBytes: bytes
})
expect(metadata).toMatchObject({
id: 'recovery-1',
documentName: 'Agent draft',
sceneVersion: 12,
byteLength: 4,
formatVersion: 1
})
expect(await store.list()).toEqual([metadata])
expect(await store.read('recovery-1')).toEqual({ ...metadata, figBytes: bytes })
await store.remove('recovery-1')
expect(await store.list()).toEqual([])
expect(await store.read('recovery-1')).toBeNull()
})
test('memory store owns input and output bytes', async () => {
const store = createMemoryRecoveryStore()
const input = new Uint8Array(bytes)
await store.write({ id: 'one', documentName: 'Draft', sceneVersion: 1, figBytes: input })
input[0] = 99
const first = await store.read('one')
expect(first?.figBytes[0]).toBe(1)
if (first) first.figBytes[0] = 88
expect((await store.read('one'))?.figBytes[0]).toBe(1)
})
})