From 290f4fd547557538d1e03c658ac670db1f7d15cf Mon Sep 17 00:00:00 2001 From: SecPan Date: Fri, 10 Jul 2026 04:01:41 -0700 Subject: [PATCH] Extract autosave timer scheduling from App.vue Moves the idle-autosave timer mechanics for local drafts and Android documents into a createAutosaveScheduler factory in features/document-session: per-target scheduling with debounced rescheduling, cancellation, the autosave/localDrafts guard predicates, and the mid-session resync applied when document settings change (disable clears pending timers; re-enable or a delay change reschedules a dirty document for its current target). The save workflows are untouched and injected as plain triggers, per the TODO constraint. App destructures the same function names, so every existing call site (editor sync, save entry points, editor teardown, draft flows) is unchanged, and the settings watch body becomes a one-line delegation. The only mechanical difference is bare setTimeout/clearTimeout instead of the window-prefixed forms, which is behavior-identical in the WebView and makes the module testable under fake timers. --- src/App.vue | 90 +++-------- .../autosaveScheduler.test.ts | 149 ++++++++++++++++++ .../document-session/autosaveScheduler.ts | 125 +++++++++++++++ 3 files changed, 292 insertions(+), 72 deletions(-) create mode 100644 src/features/document-session/autosaveScheduler.test.ts create mode 100644 src/features/document-session/autosaveScheduler.ts 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, + } +}