feat: auto-save notes with 500ms debounce after last keystroke
Content is automatically persisted 500ms after the last edit in both BlockNote and raw editor modes. Cmd+S still works as immediate flush. Tab close flushes any pending auto-save to prevent data loss. Updated the "unsaved" tab indicator to show "Auto-saving…" with pulse. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
20
src/App.tsx
20
src/App.tsx
@@ -313,6 +313,22 @@ function App() {
|
||||
}
|
||||
}, [resolvedPath, vault.entries, notes, dialogs])
|
||||
|
||||
/** Flush pending auto-save before closing a tab to prevent data loss. */
|
||||
const handleCloseTabWithFlush = useCallback((path: string) => {
|
||||
savePendingForPath(path).catch(() => {})
|
||||
notes.handleCloseTab(path)
|
||||
}, [savePendingForPath, notes])
|
||||
|
||||
// Wrap the close-tab ref so Cmd+W and menu bar also flush auto-save
|
||||
const closeTabWithFlushRef = useRef<(path: string) => void>(handleCloseTabWithFlush)
|
||||
useEffect(() => {
|
||||
const original = notes.handleCloseTabRef.current
|
||||
closeTabWithFlushRef.current = (path: string) => {
|
||||
savePendingForPath(path).catch(() => {})
|
||||
original(path)
|
||||
}
|
||||
})
|
||||
|
||||
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
|
||||
await savePendingForPath(path)
|
||||
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
|
||||
@@ -426,7 +442,7 @@ function App() {
|
||||
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
|
||||
handleCloseTabRef: closeTabWithFlushRef, tabs: notes.tabs,
|
||||
entries: vault.entries,
|
||||
modifiedCount: vault.modifiedFiles.length,
|
||||
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
|
||||
@@ -548,7 +564,7 @@ function App() {
|
||||
activeTabPath={notes.activeTabPath}
|
||||
entries={vault.entries}
|
||||
onSwitchTab={notes.handleSwitchTab}
|
||||
onCloseTab={notes.handleCloseTab}
|
||||
onCloseTab={handleCloseTabWithFlush}
|
||||
onReorderTabs={notes.handleReorderTabs}
|
||||
onNavigateWikilink={notes.handleNavigateWikilink}
|
||||
onLoadDiff={vault.loadDiff}
|
||||
|
||||
@@ -182,7 +182,7 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) {
|
||||
}
|
||||
|
||||
const STATUS_DOT: Record<string, { color: string; testId: string; title: string; pulse?: boolean }> = {
|
||||
unsaved: { color: 'var(--accent-blue, #3b82f6)', testId: 'tab-unsaved-indicator', title: 'Unsaved — Cmd+S to save' },
|
||||
unsaved: { color: 'var(--accent-blue, #3b82f6)', testId: 'tab-unsaved-indicator', title: 'Auto-saving…', pulse: true },
|
||||
pendingSave: { color: 'var(--accent-green)', testId: 'tab-pending-save-indicator', title: 'Saving to disk…', pulse: true },
|
||||
new: { color: 'var(--accent-green)', testId: 'tab-new-indicator', title: 'New (uncommitted)' },
|
||||
modified: { color: 'var(--accent-orange)', testId: 'tab-modified-indicator', title: 'Modified (uncommitted)' },
|
||||
@@ -242,7 +242,7 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
|
||||
{isEditing ? (
|
||||
<InlineTabEdit initialValue={tab.entry.title} onSave={onRenameSave} onCancel={onRenameCancel} />
|
||||
) : (
|
||||
<span className="truncate" style={noteStatus === 'unsaved' ? { fontStyle: 'italic' } : undefined} onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick() }}>
|
||||
<span className="truncate" onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick() }}>
|
||||
{tab.entry.icon && isEmoji(tab.entry.icon) && <span className="mr-1">{tab.entry.icon}</span>}
|
||||
{tab.entry.title}
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useEditorSave } from './useEditorSave'
|
||||
|
||||
@@ -256,6 +256,114 @@ describe('useEditorSave', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe('auto-save debounce', () => {
|
||||
beforeEach(() => { vi.useFakeTimers() })
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
it('auto-saves 500ms after last content change', async () => {
|
||||
const onNotePersisted = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onNotePersisted })
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/test/note.md', 'auto-saved content')
|
||||
})
|
||||
|
||||
// Not saved yet
|
||||
expect(mockInvokeFn).not.toHaveBeenCalled()
|
||||
|
||||
// Advance 500ms
|
||||
await act(async () => { vi.advanceTimersByTime(500) })
|
||||
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', {
|
||||
path: '/test/note.md',
|
||||
content: 'auto-saved content',
|
||||
})
|
||||
expect(onNotePersisted).toHaveBeenCalledWith('/test/note.md', 'auto-saved content')
|
||||
})
|
||||
|
||||
it('resets debounce timer on each content change', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage })
|
||||
)
|
||||
|
||||
act(() => { result.current.handleContentChange('/test/note.md', 'v1') })
|
||||
|
||||
// Advance 400ms (not yet 500ms)
|
||||
await act(async () => { vi.advanceTimersByTime(400) })
|
||||
expect(mockInvokeFn).not.toHaveBeenCalled()
|
||||
|
||||
// New edit resets timer
|
||||
act(() => { result.current.handleContentChange('/test/note.md', 'v2') })
|
||||
|
||||
// Another 400ms (800ms total, but only 400ms from last edit)
|
||||
await act(async () => { vi.advanceTimersByTime(400) })
|
||||
expect(mockInvokeFn).not.toHaveBeenCalled()
|
||||
|
||||
// 100ms more = 500ms from last edit
|
||||
await act(async () => { vi.advanceTimersByTime(100) })
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', {
|
||||
path: '/test/note.md',
|
||||
content: 'v2',
|
||||
})
|
||||
})
|
||||
|
||||
it('auto-save does not show toast', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage })
|
||||
)
|
||||
|
||||
act(() => { result.current.handleContentChange('/test/note.md', 'content') })
|
||||
await act(async () => { vi.advanceTimersByTime(500) })
|
||||
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+S cancels pending auto-save and saves immediately', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage })
|
||||
)
|
||||
|
||||
act(() => { result.current.handleContentChange('/test/note.md', 'content') })
|
||||
|
||||
// Cmd+S before debounce fires
|
||||
await act(async () => { await result.current.handleSave() })
|
||||
|
||||
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Saved')
|
||||
|
||||
// Advancing timer should NOT cause a second save
|
||||
await act(async () => { vi.advanceTimersByTime(500) })
|
||||
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('auto-save calls onAfterSave', async () => {
|
||||
const onAfterSave = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave })
|
||||
)
|
||||
|
||||
act(() => { result.current.handleContentChange('/test/note.md', 'content') })
|
||||
await act(async () => { vi.advanceTimersByTime(500) })
|
||||
|
||||
expect(onAfterSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears auto-save timer on unmount', async () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useEditorSave({ updateVaultContent, setTabs, setToastMessage })
|
||||
)
|
||||
|
||||
act(() => { result.current.handleContentChange('/test/note.md', 'content') })
|
||||
unmount()
|
||||
|
||||
await act(async () => { vi.advanceTimersByTime(500) })
|
||||
// Should not save after unmount
|
||||
expect(mockInvokeFn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('successive edits and saves persist each version correctly', async () => {
|
||||
const { result } = renderSaveHook()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { SetStateAction } from 'react'
|
||||
import { useSaveNote } from './useSaveNote'
|
||||
|
||||
@@ -18,13 +18,16 @@ interface EditorSaveConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that manages explicit save (Cmd+S) for editor content.
|
||||
* Tracks pending (unsaved) content and provides save + pre-rename helpers.
|
||||
* Hook that manages editor content persistence with auto-save.
|
||||
* Content is auto-saved 500ms after the last edit. Cmd+S flushes immediately.
|
||||
*/
|
||||
const noop = () => {}
|
||||
|
||||
const AUTO_SAVE_DEBOUNCE_MS = 500
|
||||
|
||||
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave = noop, onNotePersisted }: EditorSaveConfig) {
|
||||
const pendingContentRef = useRef<{ path: string; content: string } | null>(null)
|
||||
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const updateTabAndContent = useCallback((path: string, content: string) => {
|
||||
updateVaultContent(path, content)
|
||||
@@ -47,9 +50,22 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
|
||||
return true
|
||||
}, [saveNote, onNotePersisted])
|
||||
|
||||
// Stable ref for onAfterSave so the auto-save timer closure always calls the latest version
|
||||
const onAfterSaveRef = useRef(onAfterSave)
|
||||
useEffect(() => { onAfterSaveRef.current = onAfterSave }, [onAfterSave])
|
||||
|
||||
/** Cancel any pending auto-save timer. */
|
||||
const cancelAutoSave = useCallback(() => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current)
|
||||
autoSaveTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** Called by Cmd+S — persists the current editor content to disk.
|
||||
* Accepts optional fallback for unsaved notes with no pending edits. */
|
||||
const handleSave = useCallback(async (unsavedFallback?: { path: string; content: string }) => {
|
||||
cancelAutoSave()
|
||||
try {
|
||||
const saved = await flushPending()
|
||||
if (!saved && unsavedFallback) {
|
||||
@@ -65,26 +81,39 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
|
||||
console.error('Save failed:', err)
|
||||
setToastMessage(`Save failed: ${err}`)
|
||||
}
|
||||
}, [flushPending, setToastMessage, onAfterSave, saveNote, onNotePersisted])
|
||||
}, [cancelAutoSave, flushPending, setToastMessage, onAfterSave, saveNote, onNotePersisted])
|
||||
|
||||
/** Called by Editor onChange — buffers the latest content and syncs tab state
|
||||
* so consumers (e.g. AI panel) always see current editor content. */
|
||||
/** Called by Editor onChange — buffers the latest content, syncs tab state,
|
||||
* and schedules an auto-save after 500ms of inactivity. */
|
||||
const handleContentChange = useCallback((path: string, content: string) => {
|
||||
pendingContentRef.current = { path, content }
|
||||
setTabs((prev: Tab[]) =>
|
||||
prev.map((t) => t.entry.path === path ? { ...t, content } : t)
|
||||
)
|
||||
}, [setTabs])
|
||||
cancelAutoSave()
|
||||
autoSaveTimerRef.current = setTimeout(async () => {
|
||||
autoSaveTimerRef.current = null
|
||||
try {
|
||||
const saved = await flushPending()
|
||||
if (saved) onAfterSaveRef.current()
|
||||
} catch (err) {
|
||||
console.error('Auto-save failed:', err)
|
||||
}
|
||||
}, AUTO_SAVE_DEBOUNCE_MS)
|
||||
}, [setTabs, cancelAutoSave, flushPending])
|
||||
|
||||
/** Save pending content for a specific path (used before rename) */
|
||||
// Clear auto-save timer on unmount
|
||||
useEffect(() => () => cancelAutoSave(), [cancelAutoSave])
|
||||
|
||||
/** Save pending content for a specific path (used before rename / tab close) */
|
||||
const savePendingForPath = useCallback(
|
||||
(path: string): Promise<boolean> => flushPending(path),
|
||||
[flushPending],
|
||||
(path: string): Promise<boolean> => { cancelAutoSave(); return flushPending(path) },
|
||||
[cancelAutoSave, flushPending],
|
||||
)
|
||||
|
||||
/** Flush any pending content to disk silently (used before git commit).
|
||||
* Does NOT call onAfterSave — callers manage their own refresh. */
|
||||
const savePending = useCallback((): Promise<boolean> => flushPending(), [flushPending])
|
||||
const savePending = useCallback((): Promise<boolean> => { cancelAutoSave(); return flushPending() }, [cancelAutoSave, flushPending])
|
||||
|
||||
return { handleSave, handleContentChange, savePendingForPath, savePending }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user