feat(app): make crash recovery configurable

* feat(app): make crash recovery configurable

- Add an enabled-by-default persisted recovery preference and General settings control
- Stop recovery writes and remove the active document snapshot when disabled
- Suppress startup recovery discovery while the preference is disabled
- Cover disabled persistence and re-enable behavior

* fix(i18n): translate recovery preferences

* fix(app): serialize recovery disable cleanup

- Block re-enabled persistence until pending snapshot removal completes
- Preserve disable generations so stale cleanup cannot reset newer recovery state
- Display runtime-overridden recovery state in Settings
- Deep-clone nested preferences before updating recovery
This commit is contained in:
Danila Poyarkov 2026-08-21 18:58:27 +03:00 committed by GitHub
parent 0f981ff2af
commit f75d67ad4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 232 additions and 25 deletions

View file

@ -7,6 +7,10 @@
"restore": "Wiederherstellen",
"discard": "Verwerfen",
"recoveryFailed": "Dieses Dokument konnte nicht wiederhergestellt werden.",
"settingsRecovery": "Wiederherstellung",
"settingsRecoveryDescription": "Lokale Wiederherstellungskopien für ungespeicherte Dokumente verwalten.",
"preserveUnsavedWork": "Ungespeicherte Arbeit automatisch sichern",
"preserveUnsavedWorkDescription": "Lokale Wiederherstellungskopien speichern, um Dokumente nach einem unerwarteten Beenden wiederherzustellen.",
"rename": "Umbenennen",
"renameLayers": "{count} Ebenen umbenennen",
"renamePreview": "Vorschau",

View file

@ -350,5 +350,9 @@
"mcpStatus_starting": "Starting",
"mcpStatus_running": "Running",
"mcpStatus_stopped": "Stopped",
"mcpStatus_error": "Error"
"mcpStatus_error": "Error",
"settingsRecovery": "Recuperación",
"settingsRecoveryDescription": "Controla las copias locales de recuperación de documentos no guardados.",
"preserveUnsavedWork": "Conservar automáticamente el trabajo no guardado",
"preserveUnsavedWorkDescription": "Guarda copias locales para restaurar documentos después de un cierre inesperado."
}

View file

@ -350,5 +350,9 @@
"mcpStatus_starting": "Starting",
"mcpStatus_running": "Running",
"mcpStatus_stopped": "Stopped",
"mcpStatus_error": "Error"
"mcpStatus_error": "Error",
"settingsRecovery": "Récupération",
"settingsRecoveryDescription": "Gérez les copies locales de récupération des documents non enregistrés.",
"preserveUnsavedWork": "Conserver automatiquement le travail non enregistré",
"preserveUnsavedWorkDescription": "Stockez des copies locales afin de restaurer les documents après un arrêt inattendu."
}

View file

@ -350,5 +350,9 @@
"mcpStatus_starting": "Starting",
"mcpStatus_running": "Running",
"mcpStatus_stopped": "Stopped",
"mcpStatus_error": "Error"
"mcpStatus_error": "Error",
"settingsRecovery": "Ripristino",
"settingsRecoveryDescription": "Gestisci le copie locali di ripristino dei documenti non salvati.",
"preserveUnsavedWork": "Conserva automaticamente il lavoro non salvato",
"preserveUnsavedWorkDescription": "Salva copie locali per ripristinare i documenti dopo una chiusura imprevista."
}

View file

@ -350,5 +350,9 @@
"mcpStatus_starting": "Starting",
"mcpStatus_running": "Running",
"mcpStatus_stopped": "Stopped",
"mcpStatus_error": "Error"
"mcpStatus_error": "Error",
"settingsRecovery": "復元",
"settingsRecoveryDescription": "未保存ドキュメントのローカル復元コピーを管理します。",
"preserveUnsavedWork": "未保存の作業を自動的に保持",
"preserveUnsavedWorkDescription": "予期しない終了後にドキュメントを復元できるよう、ローカルコピーを保存します。"
}

View file

@ -350,5 +350,9 @@
"mcpStatus_starting": "Starting",
"mcpStatus_running": "Running",
"mcpStatus_stopped": "Stopped",
"mcpStatus_error": "Error"
"mcpStatus_error": "Error",
"settingsRecovery": "Odzyskiwanie",
"settingsRecoveryDescription": "Zarządzaj lokalnymi kopiami odzyskiwania niezapisanych dokumentów.",
"preserveUnsavedWork": "Automatycznie zachowuj niezapisaną pracę",
"preserveUnsavedWorkDescription": "Przechowuj lokalne kopie, aby odtworzyć dokumenty po nieoczekiwanym zamknięciu."
}

View file

@ -350,5 +350,9 @@
"mcpStatus_starting": "Starting",
"mcpStatus_running": "Running",
"mcpStatus_stopped": "Stopped",
"mcpStatus_error": "Error"
"mcpStatus_error": "Error",
"settingsRecovery": "Восстановление",
"settingsRecoveryDescription": "Управляйте локальными резервными копиями несохранённых документов.",
"preserveUnsavedWork": "Автоматически сохранять несохранённую работу",
"preserveUnsavedWorkDescription": "Храните локальные копии, чтобы восстановить документы после неожиданного завершения работы."
}

View file

@ -350,5 +350,9 @@
"mcpStatus_starting": "Starting",
"mcpStatus_running": "Running",
"mcpStatus_stopped": "Stopped",
"mcpStatus_error": "Error"
"mcpStatus_error": "Error",
"settingsRecovery": "恢复",
"settingsRecoveryDescription": "管理未保存文档的本地恢复副本。",
"preserveUnsavedWork": "自动保留未保存的工作",
"preserveUnsavedWorkDescription": "存储本地恢复副本,以便在意外关闭后恢复文档。"
}

View file

@ -61,6 +61,11 @@ export const dialogMessageDefaults = {
restore: 'Restore',
discard: 'Discard',
recoveryFailed: 'Could not restore this document.',
settingsRecovery: 'Recovery',
settingsRecoveryDescription: 'Control local crash-recovery copies for unsaved documents.',
preserveUnsavedWork: 'Automatically preserve unsaved work',
preserveUnsavedWorkDescription:
'Store local recovery copies so documents can be restored after an unexpected shutdown.',
showUI: params('Show UI ({shortcut})'),
fontSettings: 'Font settings',
fontIssueFound: '1 font face is unavailable or substituted',

View file

@ -11,6 +11,7 @@ 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 { recoveryEnabled } from '@/app/document/recovery/preferences'
import type { StorageDocumentBinding } from '@/app/integrations/storage/types'
type DocumentSourceState = EditorState & {
@ -58,6 +59,7 @@ export function createDocumentSourceActions({
const recovery = createDocumentRecovery({
state,
isEnabled: () => recoveryEnabled.value,
buildFigFile: buildRecoveryFigFile,
hasWritableSource: () => !!getFileHandle() || !!getFilePath() || !!getStorageBinding()
})

View file

@ -1,5 +1,5 @@
import { watchDebounced } from '@vueuse/core'
import type { WatchHandle } from 'vue'
import { watch, type WatchHandle } from 'vue'
import type { EditorState } from '@open-pencil/core/editor'
@ -13,6 +13,7 @@ interface DocumentRecoveryOptions {
state: RecoveryState
buildFigFile: () => Promise<Uint8Array> | Uint8Array
hasWritableSource: () => boolean
isEnabled?: () => boolean
store?: RecoveryStore
recoveryId?: string
}
@ -30,6 +31,7 @@ export function createDocumentRecovery({
state,
buildFigFile,
hasWritableSource,
isEnabled = () => true,
store = getRecoveryStore(),
recoveryId = createCanvasId()
}: DocumentRecoveryOptions): DocumentRecoveryController {
@ -39,14 +41,15 @@ export function createDocumentRecovery({
let requestedVersion = protectedVersion
let lifecycleGeneration = 0
let writing: Promise<void> | null = null
let cleanup: Promise<void> = Promise.resolve()
let disposed = false
async function runWrites(generation: number): Promise<void> {
if (disposed || generation !== lifecycleGeneration) return
if (disposed || generation !== lifecycleGeneration || !isEnabled()) return
if (hasWritableSource() || requestedVersion === protectedVersion) return
const version = requestedVersion
const bytes = await buildFigFile()
if (generation !== lifecycleGeneration || hasWritableSource()) return
if (generation !== lifecycleGeneration || hasWritableSource() || !isEnabled()) return
await store.write({
id,
documentName: state.documentName,
@ -60,7 +63,8 @@ export function createDocumentRecovery({
}
async function persistNow(): Promise<void> {
if (disposed || hasWritableSource()) return
await cleanup
if (disposed || hasWritableSource() || !isEnabled()) return
requestedVersion = state.sceneVersion
if (requestedVersion === protectedVersion) return
if (!writing) {
@ -72,7 +76,7 @@ export function createDocumentRecovery({
await writing
}
const stop: WatchHandle = watchDebounced(
const stopVersionWatch: WatchHandle = watchDebounced(
() => state.sceneVersion,
() => {
void persistNow().catch((error) => console.warn('[Recovery] Snapshot failed:', error))
@ -80,9 +84,35 @@ export function createDocumentRecovery({
{ debounce: 3000, maxWait: 10000 }
)
const stopEnabledWatch: WatchHandle = watch(
isEnabled,
(enabled) => {
if (enabled) {
protectedVersion = state.sceneVersion
requestedVersion = state.sceneVersion
return
}
lifecycleGeneration++
const cleanupGeneration = lifecycleGeneration
const snapshotId = id
requestedVersion = state.sceneVersion
protectedVersion = state.sceneVersion
const activeWrite = writing
cleanup = cleanup
.then(async () => {
await activeWrite
await store.remove(snapshotId)
if (cleanupGeneration === lifecycleGeneration) persistedVersion = null
return undefined
})
.catch((error) => console.warn('[Recovery] Failed to disable recovery:', error))
},
{ flush: 'sync' }
)
async function invalidateActiveWrite(): Promise<void> {
lifecycleGeneration++
await writing
await Promise.all([writing, cleanup])
}
return {
@ -117,7 +147,8 @@ export function createDocumentRecovery({
disposeRecovery() {
disposed = true
lifecycleGeneration++
stop()
stopVersionWatch()
stopEnabledWatch()
}
}
}

View file

@ -0,0 +1,17 @@
import { computed, shallowRef, type ComputedRef } from 'vue'
import { appPreferences, updateRecoveryEnabled } from '@/app/settings/preferences/store'
const runtimeOverride = shallowRef<boolean | null>(null)
export const recoveryEnabled: ComputedRef<boolean> = computed(
() => runtimeOverride.value ?? appPreferences.value.recovery.enabled
)
export function setRecoveryEnabled(enabled: boolean): void {
updateRecoveryEnabled(enabled)
}
export function setRecoveryRuntimeOverride(enabled: boolean | null): void {
runtimeOverride.value = enabled
}

View file

@ -4,6 +4,9 @@ import { DEFAULT_SNAPPING_PREFERENCES, type SnappingPreferences } from '@open-pe
export interface AppPreferences {
version: 1
recovery: {
enabled: boolean
}
editing: {
snapping: SnappingPreferences
}
@ -11,6 +14,7 @@ export interface AppPreferences {
export const DEFAULT_APP_PREFERENCES: Readonly<AppPreferences> = {
version: 1,
recovery: { enabled: true },
editing: {
snapping: { ...DEFAULT_SNAPPING_PREFERENCES }
}
@ -28,12 +32,9 @@ interface StoredSnappingPreferences {
pixelGrid?: unknown
}
interface StoredEditingPreferences {
snapping?: StoredSnappingPreferences
}
interface StoredAppPreferences {
editing?: StoredEditingPreferences
recovery?: { enabled?: unknown }
editing?: { snapping?: StoredSnappingPreferences }
}
function isStoredAppPreferences(value: unknown): value is StoredAppPreferences {
@ -41,10 +42,14 @@ function isStoredAppPreferences(value: unknown): value is StoredAppPreferences {
}
function normalizePreferences(value: unknown): AppPreferences {
const snapping = isStoredAppPreferences(value) ? value.editing?.snapping : undefined
const stored = isStoredAppPreferences(value) ? value : undefined
const snapping = stored?.editing?.snapping
return {
version: 1,
recovery: {
enabled: booleanOrDefault(stored?.recovery?.enabled, DEFAULT_APP_PREFERENCES.recovery.enabled)
},
editing: {
snapping: {
geometry: booleanOrDefault(
@ -70,6 +75,12 @@ export const appPreferences = useLocalStorage<AppPreferences>(
{ mergeDefaults: (storageValue) => normalizePreferences(storageValue) }
)
export function updateRecoveryEnabled(enabled: boolean): void {
const preferences = structuredClone(appPreferences.value)
preferences.recovery.enabled = enabled
appPreferences.value = preferences
}
export function updateSnappingPreferences(changes: Partial<SnappingPreferences>): void {
appPreferences.value = {
...appPreferences.value,

View file

@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { AlertDialogCancel, AlertDialogDescription, AlertDialogTitle } from 'reka-ui'
import { useI18n } from '@open-pencil/vue'
import { recoveryEnabled } from '@/app/document/recovery/preferences'
import { useNotificationMessages } from '@/app/i18n/notifications'
import { discardRecoverySnapshot, listRecoverySnapshots, restoreRecoverySnapshot } from '@/app/tabs'
import type { RecoverySnapshotMeta } from '@/app/document/recovery'
@ -54,7 +55,7 @@ async function discard(snapshot: RecoverySnapshotMeta): Promise<void> {
}
onMounted(async () => {
if (route.path !== '/') return
if (route.path !== '/' || !recoveryEnabled.value) return
try {
snapshots.value = await listRecoverySnapshots()
open.value = snapshots.value.length > 0

View file

@ -2,12 +2,18 @@
import { computed } from 'vue'
import { useI18n } from '@open-pencil/vue'
import { recoveryEnabled, setRecoveryEnabled } from '@/app/document/recovery/preferences'
import { setSnappingPreference } from '@/app/settings/preferences/apply'
import { appPreferences } from '@/app/settings/preferences/store'
import AppSwitch from '@/components/ui/AppSwitch.vue'
const { dialogs } = useI18n()
const preserveUnsavedWork = computed({
get: () => recoveryEnabled.value,
set: setRecoveryEnabled
})
const snapToGeometry = computed({
get: () => appPreferences.value.editing.snapping.geometry,
set: (enabled: boolean) => setSnappingPreference('geometry', enabled)
@ -26,6 +32,27 @@ const snapToPixelGrid = computed({
<template>
<section class="flex flex-col gap-4" data-test-id="settings-general-panel">
<div>
<h3 class="text-xs font-semibold text-surface">{{ dialogs.settingsRecovery }}</h3>
<p class="mt-1 text-[11px] text-muted">{{ dialogs.settingsRecoveryDescription }}</p>
</div>
<div class="flex flex-col rounded border border-border">
<label class="flex items-center justify-between gap-4 px-3 py-2.5">
<span>
<span class="block text-xs text-surface">{{ dialogs.preserveUnsavedWork }}</span>
<span class="block text-[10px] text-muted">{{
dialogs.preserveUnsavedWorkDescription
}}</span>
</span>
<AppSwitch
v-model="preserveUnsavedWork"
:label="dialogs.preserveUnsavedWork"
data-test-id="settings-recovery-enabled"
/>
</label>
</div>
<div>
<h3 class="text-xs font-semibold text-surface">{{ dialogs.settingsEditing }}</h3>
<p class="mt-1 text-[11px] text-muted">{{ dialogs.settingsSnappingDescription }}</p>

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { reactive } from 'vue'
import { reactive, ref } from 'vue'
import { createDefaultEditorState } from '@open-pencil/core/editor'
@ -23,18 +23,45 @@ function deferredWriteStore() {
return { store, release: () => release?.() }
}
function setup(buildFigFile = async () => new Uint8Array([1, 2, 3])) {
function deferredRemoveStore() {
const memory = createMemoryRecoveryStore()
let release: (() => void) | null = null
const store: RecoveryStore = {
...memory,
async remove(id: string) {
await new Promise<void>((resolve) => {
release = resolve
})
await memory.remove(id)
}
}
return { store, release: () => release?.() }
}
function setup(
buildFigFile = async () => new Uint8Array([1, 2, 3]),
initialEnabled = true,
injectedStore?: RecoveryStore
) {
const state = reactive({ ...createDefaultEditorState('page-1'), documentName: 'Agent draft' })
const store = createMemoryRecoveryStore()
const store = injectedStore ?? createMemoryRecoveryStore()
let writable = false
const enabled = ref(initialEnabled)
const recovery = createDocumentRecovery({
state,
store,
recoveryId: 'recovery-1',
hasWritableSource: () => writable,
isEnabled: () => enabled.value,
buildFigFile
})
return { state, store, recovery, setWritable: (value: boolean) => (writable = value) }
return {
state,
store,
recovery,
setWritable: (value: boolean) => (writable = value),
setEnabled: (value: boolean) => (enabled.value = value)
}
}
describe('document recovery controller', () => {
@ -49,6 +76,60 @@ describe('document recovery controller', () => {
recovery.disposeRecovery()
})
test('does not serialize or persist while disabled', async () => {
let builds = 0
const { state, store, recovery } = setup(async () => {
builds++
return new Uint8Array([1])
}, false)
state.sceneVersion = 1
await recovery.persistNow()
expect(builds).toBe(0)
expect(await store.list()).toEqual([])
recovery.disposeRecovery()
})
test('removes the owned snapshot when disabled and resumes from the current version', async () => {
const { state, store, recovery, setEnabled } = setup()
state.sceneVersion = 1
await recovery.persistNow()
expect(await store.list()).toHaveLength(1)
setEnabled(false)
await Promise.resolve()
await Promise.resolve()
expect(await store.list()).toEqual([])
state.sceneVersion = 2
setEnabled(true)
await recovery.persistNow()
expect(await store.list()).toEqual([])
state.sceneVersion = 3
await recovery.persistNow()
expect((await store.read('recovery-1'))?.sceneVersion).toBe(3)
recovery.disposeRecovery()
})
test('waits for disable cleanup before writing after re-enable', async () => {
const deferred = deferredRemoveStore()
const { state, store, recovery, setEnabled } = setup(undefined, true, deferred.store)
state.sceneVersion = 1
await recovery.persistNow()
setEnabled(false)
setEnabled(true)
state.sceneVersion = 2
const nextWrite = recovery.persistNow()
await Promise.resolve()
expect((await store.read('recovery-1'))?.sceneVersion).toBe(1)
deferred.release()
await nextWrite
expect((await store.read('recovery-1'))?.sceneVersion).toBe(2)
recovery.disposeRecovery()
})
test('does not persist documents with writable sources', async () => {
const { state, store, recovery, setWritable } = setup()
setWritable(true)