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:
parent
a1182a1fc6
commit
37912d92a7
1148
CHANGELOG.md
1148
CHANGELOG.md
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
"cancel": "Annuler",
|
||||
"apply": "Appliquer",
|
||||
"close": "Fermer",
|
||||
"recoverUnsavedWork": "Récupérer le travail non enregistré",
|
||||
"recoverUnsavedWorkDescription": "OpenPencil a trouvé des documents d’une session précédente.",
|
||||
"restore": "Restaurer",
|
||||
"discard": "Ignorer",
|
||||
"recoveryFailed": "Impossible de restaurer ce document.",
|
||||
"rename": "Renommer",
|
||||
"renameLayers": "Renommer {count} calques",
|
||||
"renamePreview": "Aperçu",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
"cancel": "キャンセル",
|
||||
"apply": "適用",
|
||||
"close": "閉じる",
|
||||
"recoverUnsavedWork": "未保存の作業を復元",
|
||||
"recoverUnsavedWorkDescription": "OpenPencil が前回のセッションのドキュメントを見つけました。",
|
||||
"restore": "復元",
|
||||
"discard": "破棄",
|
||||
"recoveryFailed": "このドキュメントを復元できませんでした。",
|
||||
"rename": "名前を変更",
|
||||
"renameLayers": "{count} 個のレイヤー名を変更",
|
||||
"renamePreview": "プレビュー",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
"cancel": "Отмена",
|
||||
"apply": "Применить",
|
||||
"close": "Закрыть",
|
||||
"recoverUnsavedWork": "Восстановить несохранённую работу",
|
||||
"recoverUnsavedWorkDescription": "OpenPencil обнаружил документы из предыдущего сеанса.",
|
||||
"restore": "Восстановить",
|
||||
"discard": "Удалить",
|
||||
"recoveryFailed": "Не удалось восстановить этот документ.",
|
||||
"rename": "Переименовать",
|
||||
"renameLayers": "Переименовать слои: {count}",
|
||||
"renamePreview": "Предпросмотр",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
"cancel": "取消",
|
||||
"apply": "应用",
|
||||
"close": "关闭",
|
||||
"recoverUnsavedWork": "恢复未保存的工作",
|
||||
"recoverUnsavedWorkDescription": "OpenPencil 找到了上次会话中的文档。",
|
||||
"restore": "恢复",
|
||||
"discard": "丢弃",
|
||||
"recoveryFailed": "无法恢复此文档。",
|
||||
"rename": "重命名",
|
||||
"renameLayers": "重命名 {count} 个图层",
|
||||
"renamePreview": "预览",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
111
src/app/document/recovery/controller.ts
Normal file
111
src/app/document/recovery/controller.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
90
src/app/document/recovery/idb.ts
Normal file
90
src/app/document/recovery/idb.ts
Normal 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
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/app/document/recovery/index.ts
Normal file
13
src/app/document/recovery/index.ts
Normal 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'
|
||||
43
src/app/document/recovery/memory.ts
Normal file
43
src/app/document/recovery/memory.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/app/document/recovery/store.ts
Normal file
28
src/app/document/recovery/store.ts
Normal 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
|
||||
}
|
||||
27
src/app/document/recovery/types.ts
Normal file
27
src/app/document/recovery/types.ts
Normal 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>
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
110
src/components/recovery/RecoveryDialog.vue
Normal file
110
src/components/recovery/RecoveryDialog.vue
Normal 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>
|
||||
83
tests/engine/app/document/recovery/controller.test.ts
Normal file
83
tests/engine/app/document/recovery/controller.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
54
tests/engine/app/document/recovery/store.test.ts
Normal file
54
tests/engine/app/document/recovery/store.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue