diff --git a/src/App.tsx b/src/App.tsx index 6c8511f6..aed03f9f 100644 --- a/src/App.tsx +++ b/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} diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index cf61bf11..db15ea61 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -182,7 +182,7 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) { } const STATUS_DOT: Record = { - 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 ? ( ) : ( - { e.stopPropagation(); onDoubleClick() }}> + { e.stopPropagation(); onDoubleClick() }}> {tab.entry.icon && isEmoji(tab.entry.icon) && {tab.entry.icon}} {tab.entry.title} diff --git a/src/hooks/useEditorSave.test.ts b/src/hooks/useEditorSave.test.ts index 2cfc764b..9325a59a 100644 --- a/src/hooks/useEditorSave.test.ts +++ b/src/hooks/useEditorSave.test.ts @@ -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() diff --git a/src/hooks/useEditorSave.ts b/src/hooks/useEditorSave.ts index 51ea173c..6e6f0d59 100644 --- a/src/hooks/useEditorSave.ts +++ b/src/hooks/useEditorSave.ts @@ -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 | 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 => flushPending(path), - [flushPending], + (path: string): Promise => { 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 => flushPending(), [flushPending]) + const savePending = useCallback((): Promise => { cancelAutoSave(); return flushPending() }, [cancelAutoSave, flushPending]) return { handleSave, handleContentChange, savePendingForPath, savePending } }