diff --git a/src/App.vue b/src/App.vue index f793845..c8aa467 100644 --- a/src/App.vue +++ b/src/App.vue @@ -74,6 +74,7 @@ import { getDocumentSettings, getSortedRecentDocumentListItems, } from './features/document-session/documentSettings' +import { createAutosaveScheduler } from './features/document-session/autosaveScheduler' import { createAndroidDocumentOpenResult, openAndroidMarkdownDocumentWorkflow, @@ -293,8 +294,6 @@ function setEditorElement(element: HTMLElement | null) { let editor: MuyaEditor | null = null let editorInitToken = 0 let lastContentLogAt = 0 -let draftSaveTimer: number | null = null -let androidSaveTimer: number | null = null let androidSaveInFlight = false let androidSaveRequestedAfterCurrent = false let appLifecycleListeners: AppLifecycleListeners | null = null @@ -341,29 +340,7 @@ watch( ) watch(documentSettings, (settings, previousSettings) => { - if (!settings.localDrafts) { - clearDraftSaveTimer() - } - - if (!settings.autoSave) { - clearDraftSaveTimer() - clearAndroidDocumentSaveTimer() - return - } - - if (!editor || currentScreen.value !== 'editor' || !documentState.value.isDirty) { - return - } - - const autoSaveWasEnabled = previousSettings?.autoSave ?? settings.autoSave - const delayChanged = previousSettings?.autoSaveDelayMs !== settings.autoSaveDelayMs - if (!autoSaveWasEnabled || delayChanged) { - if (documentState.value.autosaveTarget === 'android-document') { - scheduleAndroidDocumentSave() - } else if (settings.localDrafts) { - scheduleDraftSave() - } - } + applyDocumentSettingsChange(settings, previousSettings) }) watch( @@ -498,13 +475,22 @@ function canPersistAndroidRecoveryDrafts() { return documentSettings.value.recoveryDrafts } -function canRunIdleLocalDraftAutosave() { - return documentSettings.value.autoSave && canPersistLocalDrafts() -} - -function canRunIdleAndroidDocumentAutosave() { - return documentSettings.value.autoSave -} +const { + canRunIdleLocalDraftAutosave, + canRunIdleAndroidDocumentAutosave, + scheduleDraftSave, + clearDraftSaveTimer, + scheduleAndroidDocumentSave, + clearAndroidDocumentSaveTimer, + applyDocumentSettingsChange, +} = createAutosaveScheduler({ + documentSettings, + documentState, + isEditingActive: () => Boolean(editor) && currentScreen.value === 'editor', + canPersistLocalDrafts, + saveDraft, + saveAndroidDocument, +}) function getTransientAndroidSaveMessage() { return canPersistLocalDrafts() @@ -1083,46 +1069,6 @@ function logContentSnapshot(reason: string) { }) } -function scheduleDraftSave() { - if (!canRunIdleLocalDraftAutosave()) { - return - } - - if (draftSaveTimer !== null) { - window.clearTimeout(draftSaveTimer) - } - - draftSaveTimer = window.setTimeout(saveDraft, documentSettings.value.autoSaveDelayMs) -} - -function clearDraftSaveTimer() { - if (draftSaveTimer !== null) { - window.clearTimeout(draftSaveTimer) - draftSaveTimer = null - } -} - -function scheduleAndroidDocumentSave() { - if (!canRunIdleAndroidDocumentAutosave()) { - return - } - - if (androidSaveTimer !== null) { - window.clearTimeout(androidSaveTimer) - } - - androidSaveTimer = window.setTimeout(() => { - void saveAndroidDocument() - }, documentSettings.value.autoSaveDelayMs) -} - -function clearAndroidDocumentSaveTimer() { - if (androidSaveTimer !== null) { - window.clearTimeout(androidSaveTimer) - androidSaveTimer = null - } -} - function persistLocalDrafts(nextDrafts: LocalDraftRecord[]) { localDrafts.value = nextDrafts writeStoredLocalDrafts(nextDrafts) diff --git a/src/features/document-session/autosaveScheduler.test.ts b/src/features/document-session/autosaveScheduler.test.ts new file mode 100644 index 0000000..ebb2a5a --- /dev/null +++ b/src/features/document-session/autosaveScheduler.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { createAutosaveScheduler } from './autosaveScheduler' +import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from './documentSettings' +import { createUntitledDocument } from '../../lib/documentState' + +function createScheduler(overrides: { + settings?: Partial + autosaveTarget?: 'local-draft' | 'android-document' + isDirty?: boolean + isEditingActive?: boolean + canPersistLocalDrafts?: boolean +} = {}) { + const documentSettings = ref({ + ...DEFAULT_DOCUMENT_SETTINGS, + ...overrides.settings, + }) + const documentState = ref({ + ...createUntitledDocument({ + autosaveTarget: overrides.autosaveTarget ?? 'local-draft', + }), + isDirty: overrides.isDirty ?? true, + }) + const saveDraft = vi.fn() + const saveAndroidDocument = vi.fn().mockResolvedValue(true) + + const scheduler = createAutosaveScheduler({ + documentSettings, + documentState, + isEditingActive: () => overrides.isEditingActive ?? true, + canPersistLocalDrafts: () => + overrides.canPersistLocalDrafts ?? documentSettings.value.localDrafts, + saveDraft, + saveAndroidDocument, + }) + + return { scheduler, documentSettings, documentState, saveDraft, saveAndroidDocument } +} + +describe('autosaveScheduler', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('fires the draft save once after the configured delay', () => { + const { scheduler, saveDraft } = createScheduler() + + scheduler.scheduleDraftSave() + vi.advanceTimersByTime(DEFAULT_DOCUMENT_SETTINGS.autoSaveDelayMs - 1) + expect(saveDraft).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(saveDraft).toHaveBeenCalledTimes(1) + }) + + it('debounces rescheduling instead of stacking timers', () => { + const { scheduler, saveDraft } = createScheduler() + + scheduler.scheduleDraftSave() + vi.advanceTimersByTime(500) + scheduler.scheduleDraftSave() + vi.advanceTimersByTime(DEFAULT_DOCUMENT_SETTINGS.autoSaveDelayMs) + + expect(saveDraft).toHaveBeenCalledTimes(1) + }) + + it('respects the autosave guards per target', () => { + const { scheduler, saveDraft, saveAndroidDocument } = createScheduler({ + settings: { localDrafts: false }, + }) + + expect(scheduler.canRunIdleLocalDraftAutosave()).toBe(false) + expect(scheduler.canRunIdleAndroidDocumentAutosave()).toBe(true) + + scheduler.scheduleDraftSave() + scheduler.scheduleAndroidDocumentSave() + vi.runAllTimers() + + expect(saveDraft).not.toHaveBeenCalled() + expect(saveAndroidDocument).toHaveBeenCalledTimes(1) + }) + + it('cancels pending saves', () => { + const { scheduler, saveDraft, saveAndroidDocument } = createScheduler() + + scheduler.scheduleDraftSave() + scheduler.scheduleAndroidDocumentSave() + scheduler.clearDraftSaveTimer() + scheduler.clearAndroidDocumentSaveTimer() + vi.runAllTimers() + + expect(saveDraft).not.toHaveBeenCalled() + expect(saveAndroidDocument).not.toHaveBeenCalled() + }) + + it('clears both timers when autosave is disabled mid-session', () => { + const { scheduler, documentSettings, saveDraft, saveAndroidDocument } = createScheduler() + scheduler.scheduleDraftSave() + scheduler.scheduleAndroidDocumentSave() + + const previous = documentSettings.value + scheduler.applyDocumentSettingsChange({ ...previous, autoSave: false }, previous) + vi.runAllTimers() + + expect(saveDraft).not.toHaveBeenCalled() + expect(saveAndroidDocument).not.toHaveBeenCalled() + }) + + it('reschedules a dirty Android document when the delay changes', () => { + const { scheduler, documentSettings, saveAndroidDocument } = createScheduler({ + autosaveTarget: 'android-document', + }) + + const previous = documentSettings.value + documentSettings.value = { ...previous, autoSaveDelaySeconds: 5, autoSaveDelayMs: 5000 } + scheduler.applyDocumentSettingsChange(documentSettings.value, previous) + + vi.advanceTimersByTime(4999) + expect(saveAndroidDocument).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(saveAndroidDocument).toHaveBeenCalledTimes(1) + }) + + it('schedules a dirty local draft when autosave is re-enabled', () => { + const { scheduler, documentSettings, saveDraft } = createScheduler() + + const previous = { ...documentSettings.value, autoSave: false } + scheduler.applyDocumentSettingsChange(documentSettings.value, previous) + vi.runAllTimers() + + expect(saveDraft).toHaveBeenCalledTimes(1) + }) + + it('leaves timers alone when not actively editing or not dirty', () => { + const { scheduler, documentSettings, saveDraft } = createScheduler({ + isEditingActive: false, + }) + + const previous = { ...documentSettings.value, autoSave: false } + scheduler.applyDocumentSettingsChange(documentSettings.value, previous) + vi.runAllTimers() + + expect(saveDraft).not.toHaveBeenCalled() + }) +}) diff --git a/src/features/document-session/autosaveScheduler.ts b/src/features/document-session/autosaveScheduler.ts new file mode 100644 index 0000000..572e8e8 --- /dev/null +++ b/src/features/document-session/autosaveScheduler.ts @@ -0,0 +1,125 @@ +import type { Ref } from 'vue' +import type { DocumentSettings } from './documentSettings' +import type { MarkdownDocumentState } from '../../lib/documentState' + +export interface AutosaveSchedulerOptions { + documentSettings: Ref + documentState: Ref + /** True while the editor screen is active with a live editor instance. */ + isEditingActive: () => boolean + canPersistLocalDrafts: () => boolean + // The actual save entry points stay owned by App; the scheduler only times them. + saveDraft: () => void + saveAndroidDocument: () => Promise +} + +export interface AutosaveScheduler { + canRunIdleLocalDraftAutosave(): boolean + canRunIdleAndroidDocumentAutosave(): boolean + scheduleDraftSave(): void + clearDraftSaveTimer(): void + scheduleAndroidDocumentSave(): void + clearAndroidDocumentSaveTimer(): void + applyDocumentSettingsChange( + settings: DocumentSettings, + previousSettings: DocumentSettings | undefined, + ): void +} + +/** + * Owns the idle-autosave timers for local drafts and Android documents: + * scheduling, debounced rescheduling, cancellation, and the resync when the + * autosave settings change mid-session. The save workflows themselves are + * injected and unchanged. + */ +export function createAutosaveScheduler(options: AutosaveSchedulerOptions): AutosaveScheduler { + let draftSaveTimer: ReturnType | null = null + let androidSaveTimer: ReturnType | null = null + + function canRunIdleLocalDraftAutosave() { + return options.documentSettings.value.autoSave && options.canPersistLocalDrafts() + } + + function canRunIdleAndroidDocumentAutosave() { + return options.documentSettings.value.autoSave + } + + function scheduleDraftSave() { + if (!canRunIdleLocalDraftAutosave()) { + return + } + + if (draftSaveTimer !== null) { + clearTimeout(draftSaveTimer) + } + + draftSaveTimer = setTimeout(options.saveDraft, options.documentSettings.value.autoSaveDelayMs) + } + + function clearDraftSaveTimer() { + if (draftSaveTimer !== null) { + clearTimeout(draftSaveTimer) + draftSaveTimer = null + } + } + + function scheduleAndroidDocumentSave() { + if (!canRunIdleAndroidDocumentAutosave()) { + return + } + + if (androidSaveTimer !== null) { + clearTimeout(androidSaveTimer) + } + + androidSaveTimer = setTimeout(() => { + void options.saveAndroidDocument() + }, options.documentSettings.value.autoSaveDelayMs) + } + + function clearAndroidDocumentSaveTimer() { + if (androidSaveTimer !== null) { + clearTimeout(androidSaveTimer) + androidSaveTimer = null + } + } + + function applyDocumentSettingsChange( + settings: DocumentSettings, + previousSettings: DocumentSettings | undefined, + ) { + if (!settings.localDrafts) { + clearDraftSaveTimer() + } + + if (!settings.autoSave) { + clearDraftSaveTimer() + clearAndroidDocumentSaveTimer() + return + } + + if (!options.isEditingActive() || !options.documentState.value.isDirty) { + return + } + + const autoSaveWasEnabled = previousSettings?.autoSave ?? settings.autoSave + const delayChanged = previousSettings?.autoSaveDelayMs !== settings.autoSaveDelayMs + if (!autoSaveWasEnabled || delayChanged) { + if (options.documentState.value.autosaveTarget === 'android-document') { + scheduleAndroidDocumentSave() + } else if (settings.localDrafts) { + scheduleDraftSave() + } + } + } + + return { + canRunIdleLocalDraftAutosave, + canRunIdleAndroidDocumentAutosave, + scheduleDraftSave, + clearDraftSaveTimer, + scheduleAndroidDocumentSave, + clearAndroidDocumentSaveTimer, + applyDocumentSettingsChange, + } +}