fix: implement auto-save for editor content

The editor was not persisting changes to disk. Edits would be lost
when closing and reopening a note.

Changes:
- Add save_note_content Tauri command (Rust) with read-only file check
- Add save_note_content mock handler for browser testing
- Add useAutoSave hook with 500ms debounce and flush-on-tab-switch
- Wire up BlockNote onChange → markdown serialization → auto-save
- Add restoreWikilinksInBlocks utility (reverse of injectWikilinks)
  to convert wikilink nodes back to [[target]] before markdown export
- Extract shared walkBlocks helper to eliminate code duplication
- Suppress onChange during programmatic content swaps (tab switching)
- 12 new frontend tests + 5 new Rust tests (all 469 + 174 passing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-23 14:48:21 +01:00
parent 1e19f226d4
commit 180ade7734
9 changed files with 518 additions and 12 deletions

View File

@@ -23,6 +23,11 @@ fn get_note_content(path: String) -> Result<String, String> {
vault::get_note_content(&path)
}
#[tauri::command]
fn save_note_content(path: String, content: String) -> Result<(), String> {
vault::save_note_content(&path, &content)
}
#[tauri::command]
fn update_frontmatter(
path: String,
@@ -165,6 +170,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
list_vault,
get_note_content,
save_note_content,
update_frontmatter,
delete_frontmatter_property,
rename_note,

View File

@@ -561,6 +561,33 @@ pub fn get_note_content(path: &str) -> Result<String, String> {
fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))
}
/// Save the full content (frontmatter + body) of a note to disk.
pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
let file_path = Path::new(path);
validate_save_path(file_path, path)?;
fs::write(file_path, content)
.map_err(|e| format!("Failed to save {}: {}", path, e))
}
fn validate_save_path(file_path: &Path, display_path: &str) -> Result<(), String> {
let parent_missing = file_path.parent().is_some_and(|p| !p.exists());
if parent_missing {
return Err(format!(
"Parent directory does not exist: {}",
file_path.parent().unwrap().display()
));
}
let is_readonly = file_path.exists()
&& fs::metadata(file_path)
.map_err(|e| format!("Failed to read file metadata: {}", e))?
.permissions()
.readonly();
if is_readonly {
return Err(format!("File is read-only: {}", display_path));
}
Ok(())
}
/// Scan a directory recursively for .md files and return VaultEntry for each.
pub fn scan_vault(vault_path: &str) -> Result<Vec<VaultEntry>, String> {
let path = Path::new(vault_path);
@@ -1999,6 +2026,82 @@ References:
assert_eq!(entry.is_a, Some("Type".to_string()));
}
// --- save_note_content tests ---
#[test]
fn test_save_note_content_writes_file() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("test.md");
let content = "---\nIs A: Note\n---\n# Test\n\nHello, world!";
// Create file first
create_test_file(dir.path(), "test.md", "original content");
let result = save_note_content(file_path.to_str().unwrap(), content);
assert!(result.is_ok());
let saved = fs::read_to_string(&file_path).unwrap();
assert_eq!(saved, content);
}
#[test]
fn test_save_note_content_creates_new_file() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("new-note.md");
let content = "---\nIs A: Note\n---\n# New Note\n\nContent here.";
let result = save_note_content(file_path.to_str().unwrap(), content);
assert!(result.is_ok());
assert!(file_path.exists());
let saved = fs::read_to_string(&file_path).unwrap();
assert_eq!(saved, content);
}
#[test]
fn test_save_note_content_nonexistent_parent() {
let result = save_note_content("/nonexistent/parent/dir/file.md", "content");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Parent directory does not exist"));
}
#[test]
fn test_save_note_content_readonly_file() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("readonly.md");
create_test_file(dir.path(), "readonly.md", "original");
// Make file read-only
let mut perms = fs::metadata(&file_path).unwrap().permissions();
perms.set_readonly(true);
fs::set_permissions(&file_path, perms).unwrap();
let result = save_note_content(file_path.to_str().unwrap(), "new content");
assert!(result.is_err());
assert!(result.unwrap_err().contains("read-only"));
// Cleanup: restore write permissions so tempdir can clean up
let mut perms = fs::metadata(&file_path).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
fs::set_permissions(&file_path, perms).unwrap();
}
#[test]
fn test_save_note_content_preserves_frontmatter() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("note.md");
let original = "---\nIs A: Project\nStatus: Active\n---\n# My Project\n\nOriginal body.";
create_test_file(dir.path(), "note.md", original);
let updated = "---\nIs A: Project\nStatus: Active\n---\n# My Project\n\nUpdated body with changes.";
save_note_content(file_path.to_str().unwrap(), updated).unwrap();
let saved = fs::read_to_string(&file_path).unwrap();
assert!(saved.contains("Is A: Project"));
assert!(saved.contains("Status: Active"));
assert!(saved.contains("Updated body with changes"));
}
// Frontmatter update/delete tests are in frontmatter.rs
// --- save_image tests ---

View File

@@ -13,6 +13,7 @@ import { GitHubVaultModal } from './components/GitHubVaultModal'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
import { useNoteActions, generateUntitledName } from './hooks/useNoteActions'
import { useAutoSave } from './hooks/useAutoSave'
import { useAppKeyboard } from './hooks/useAppKeyboard'
import { useViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
@@ -80,6 +81,15 @@ function App() {
const notes = useNoteActions({ addEntry: vault.addEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry })
const updateTabAndContent = useCallback((path: string, content: string) => {
vault.updateContent(path, content)
notes.setTabs((prev: { entry: { path: string }; content: string }[]) =>
prev.map((t) => t.entry.path === path ? { ...t, content } : t)
)
}, [vault, notes])
const { debouncedSave, flush: flushSave } = useAutoSave(updateTabAndContent)
const entryActions = useEntryActions({
entries: vault.entries,
updateEntry: vault.updateEntry,
@@ -215,6 +225,8 @@ function App() {
onArchiveNote={entryActions.handleArchiveNote}
onUnarchiveNote={entryActions.handleUnarchiveNote}
onRenameTab={handleRenameTab}
onContentChange={debouncedSave}
onFlushSave={flushSave}
/>
</div>
</div>

View File

@@ -14,7 +14,7 @@ import { ResizeHandle } from './ResizeHandle'
import { TabBar } from './TabBar'
import { BreadcrumbBar } from './BreadcrumbBar'
import { useEditorTheme } from '../hooks/useTheme'
import { splitFrontmatter, preProcessWikilinks, injectWikilinks, countWords } from '../utils/wikilinks'
import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, countWords } from '../utils/wikilinks'
import './Editor.css'
import './EditorTheme.css'
@@ -55,6 +55,8 @@ interface EditorProps {
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
onRenameTab?: (path: string, newTitle: string) => void
onContentChange?: (path: string, content: string) => void
onFlushSave?: () => void
}
// --- Custom Inline Content: WikiLink ---
@@ -124,7 +126,7 @@ const schema = BlockNoteSchema.create({
})
/** Single BlockNote editor view — content is swapped via replaceBlocks */
function SingleEditorView({ editor, entries, onNavigateWikilink }: { editor: ReturnType<typeof useCreateBlockNote>; entries: VaultEntry[]; onNavigateWikilink: (target: string) => void }) {
function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { editor: ReturnType<typeof useCreateBlockNote>; entries: VaultEntry[]; onNavigateWikilink: (target: string) => void; onChange?: () => void }) {
const navigateRef = useRef(onNavigateWikilink)
useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink])
const { cssVars } = useEditorTheme()
@@ -181,6 +183,7 @@ function SingleEditorView({ editor, entries, onNavigateWikilink }: { editor: Ret
<BlockNoteView
editor={editor}
theme="light"
onChange={onChange}
>
<SuggestionMenuController
triggerCharacter="[["
@@ -201,6 +204,8 @@ export const Editor = memo(function Editor({
onTrashNote, onRestoreNote,
onArchiveNote, onUnarchiveNote,
onRenameTab,
onContentChange,
onFlushSave,
}: EditorProps) {
const [diffMode, setDiffMode] = useState(false)
const [diffContent, setDiffContent] = useState<string | null>(null)
@@ -264,6 +269,37 @@ export const Editor = memo(function Editor({
return cleanup
}, [editor])
// Suppress auto-save during programmatic content swaps (tab switching / initial load)
const suppressChangeRef = useRef(false)
// Keep refs to callbacks for the onChange handler
const onContentChangeRef = useRef(onContentChange)
onContentChangeRef.current = onContentChange
const tabsRef = useRef(tabs)
tabsRef.current = tabs
// onChange handler: serialize editor blocks → markdown, reconstruct full file, call save
const handleEditorChange = useCallback(() => {
if (suppressChangeRef.current) return
const path = prevActivePathRef.current
if (!path) return
const tab = tabsRef.current.find(t => t.entry.path === path)
if (!tab) return
// Convert blocks → markdown, restoring wikilinks first
const blocks = editor.document
const restored = restoreWikilinksInBlocks(blocks)
const bodyMarkdown = editor.blocksToMarkdownLossy(restored)
// Reconstruct the full file: preserve original frontmatter + title heading
const [frontmatter] = splitFrontmatter(tab.content)
const title = tab.entry.title
const fullContent = `${frontmatter}# ${title}\n\n${bodyMarkdown}`
onContentChangeRef.current?.(path, fullContent)
}, [editor])
// Swap document content when active tab changes.
// Uses queueMicrotask to defer BlockNote mutations outside React's commit phase,
// avoiding flushSync-inside-lifecycle errors that silently prevent content from rendering.
@@ -274,6 +310,8 @@ export const Editor = memo(function Editor({
// Save current editor state for the tab we're leaving
if (prevPath && prevPath !== activeTabPath && editorMountedRef.current) {
cache.set(prevPath, editor.document)
// Flush any pending debounced save for the tab we're leaving
onFlushSave?.()
}
prevActivePathRef.current = activeTabPath
@@ -284,6 +322,7 @@ export const Editor = memo(function Editor({
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote's PartialBlock generic is extremely complex
const applyBlocks = (blocks: any[]) => {
suppressChangeRef.current = true
try {
const current = editor.document
if (current.length > 0 && blocks.length > 0) {
@@ -299,6 +338,10 @@ export const Editor = memo(function Editor({
} catch (err2) {
console.error('Fallback also failed:', err2)
}
} finally {
// Re-enable change detection on next microtask, after BlockNote
// finishes its internal state updates from the content swap
queueMicrotask(() => { suppressChangeRef.current = false })
}
}
@@ -532,6 +575,7 @@ export const Editor = memo(function Editor({
editor={editor}
entries={entries}
onNavigateWikilink={onNavigateWikilink}
onChange={handleEditorChange}
/>
</div>
)}

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useAutoSave } from './useAutoSave'
const mockInvokeFn = vi.fn(() => Promise.resolve(null))
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
updateMockContent: vi.fn(),
}))
describe('useAutoSave', () => {
let updateContent: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.useFakeTimers()
updateContent = vi.fn()
mockInvokeFn.mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
it('debounces save calls by 500ms', async () => {
const { result } = renderHook(() => useAutoSave(updateContent))
act(() => {
result.current.debouncedSave('/test/note.md', 'content v1')
})
// Not saved yet
expect(mockInvokeFn).not.toHaveBeenCalled()
expect(updateContent).not.toHaveBeenCalled()
// Advance 300ms — still not saved
act(() => { vi.advanceTimersByTime(300) })
expect(mockInvokeFn).not.toHaveBeenCalled()
// Advance to 500ms — now saved
await act(async () => { vi.advanceTimersByTime(200) })
expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', {
path: '/test/note.md',
content: 'content v1',
})
expect(updateContent).toHaveBeenCalledWith('/test/note.md', 'content v1')
})
it('only saves the latest content when debounce resets', async () => {
const { result } = renderHook(() => useAutoSave(updateContent))
act(() => {
result.current.debouncedSave('/test/note.md', 'version 1')
})
act(() => { vi.advanceTimersByTime(200) })
// Second call resets the timer
act(() => {
result.current.debouncedSave('/test/note.md', 'version 2')
})
// Advance past original deadline
act(() => { vi.advanceTimersByTime(300) })
expect(mockInvokeFn).not.toHaveBeenCalled()
// Advance to new deadline (200 + 500 = 700ms from second call)
await act(async () => { vi.advanceTimersByTime(200) })
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', {
path: '/test/note.md',
content: 'version 2',
})
})
it('flush() immediately saves pending content', async () => {
const { result } = renderHook(() => useAutoSave(updateContent))
act(() => {
result.current.debouncedSave('/test/note.md', 'flush me')
})
expect(mockInvokeFn).not.toHaveBeenCalled()
// Flush before debounce fires
await act(async () => {
result.current.flush()
})
expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', {
path: '/test/note.md',
content: 'flush me',
})
})
it('saves multiple paths when switching notes rapidly', async () => {
const { result } = renderHook(() => useAutoSave(updateContent))
act(() => {
result.current.debouncedSave('/test/note-a.md', 'content A')
result.current.debouncedSave('/test/note-b.md', 'content B')
})
await act(async () => { vi.advanceTimersByTime(500) })
expect(mockInvokeFn).toHaveBeenCalledTimes(2)
expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', {
path: '/test/note-a.md',
content: 'content A',
})
expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', {
path: '/test/note-b.md',
content: 'content B',
})
})
it('handles save errors gracefully without crashing', async () => {
mockInvokeFn.mockRejectedValueOnce(new Error('File is read-only'))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderHook(() => useAutoSave(updateContent))
act(() => {
result.current.debouncedSave('/test/readonly.md', 'content')
})
await act(async () => { vi.advanceTimersByTime(500) })
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Auto-save failed'),
expect.any(Error),
)
consoleSpy.mockRestore()
})
it('does nothing when flush is called with no pending saves', async () => {
const { result } = renderHook(() => useAutoSave(updateContent))
await act(async () => {
result.current.flush()
})
expect(mockInvokeFn).not.toHaveBeenCalled()
expect(updateContent).not.toHaveBeenCalled()
})
})

67
src/hooks/useAutoSave.ts Normal file
View File

@@ -0,0 +1,67 @@
import { useCallback, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke, updateMockContent } from '../mock-tauri'
const DEBOUNCE_MS = 500
async function persistContent(path: string, content: string): Promise<void> {
if (isTauri()) {
await invoke('save_note_content', { path, content })
} else {
await mockInvoke('save_note_content', { path, content })
}
}
/**
* Hook that provides a debounced auto-save function for note content.
* Calls the Tauri backend (or mock) to persist the full markdown (frontmatter + body)
* after 500ms of inactivity.
*
* @param updateContent - callback to also update in-memory allContent state
*/
export function useAutoSave(updateContent: (path: string, content: string) => void) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Track the latest pending content per path so a flush always saves the most recent version
const pendingRef = useRef<Map<string, string>>(new Map())
const save = useCallback(async (path: string, content: string) => {
try {
await persistContent(path, content)
if (!isTauri()) {
updateMockContent(path, content)
}
updateContent(path, content)
} catch (err) {
console.error(`Auto-save failed for ${path}:`, err)
}
}, [updateContent])
const debouncedSave = useCallback((path: string, content: string) => {
pendingRef.current.set(path, content)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
timerRef.current = null
// Save all pending paths (handles rapid note switching)
const pending = new Map(pendingRef.current)
pendingRef.current.clear()
for (const [p, c] of pending) {
save(p, c)
}
}, DEBOUNCE_MS)
}, [save])
/** Immediately flush any pending saves (call before closing a tab or switching notes) */
const flush = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
const pending = new Map(pendingRef.current)
pendingRef.current.clear()
for (const [p, c] of pending) {
save(p, c)
}
}, [save])
return { debouncedSave, flush }
}

View File

@@ -1681,6 +1681,13 @@ const mockHandlers: Record<string, (args: any) => any> = {
stop_reason: 'end_turn',
}
},
save_note_content: (args: { path: string; content: string }) => {
MOCK_CONTENT[args.path] = args.content
if (typeof window !== 'undefined') {
window.__mockContent = MOCK_CONTENT
}
return null
},
save_image: (args: { vault_path?: string; filename: string; data: string }) => {
// Return a plausible file path matching the real Rust backend behavior
const vault = args.vault_path ?? '/Users/luca/Laputa'

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { preProcessWikilinks, injectWikilinks, splitFrontmatter, countWords } from './wikilinks'
import { preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, splitFrontmatter, countWords } from './wikilinks'
interface TestBlock {
type?: string
@@ -196,3 +196,89 @@ describe('countWords', () => {
expect(countWords(content)).toBe(8)
})
})
describe('restoreWikilinksInBlocks', () => {
it('converts wikilink nodes back to [[target]] text', () => {
const blocks = [{
content: [
{ type: 'text', text: 'See ' },
{ type: 'wikilink', props: { target: 'My Note' }, content: undefined },
{ type: 'text', text: ' for details' },
],
}]
const result = restoreWikilinksInBlocks(blocks) as TestBlock[]
expect(result[0].content).toHaveLength(3)
expect(result[0].content![0]).toEqual({ type: 'text', text: 'See ' })
expect(result[0].content![1]).toEqual({ type: 'text', text: '[[My Note]]' })
expect(result[0].content![2]).toEqual({ type: 'text', text: ' for details' })
})
it('handles multiple wikilinks in one block', () => {
const blocks = [{
content: [
{ type: 'wikilink', props: { target: 'A' }, content: undefined },
{ type: 'text', text: ' and ' },
{ type: 'wikilink', props: { target: 'B' }, content: undefined },
],
}]
const result = restoreWikilinksInBlocks(blocks) as TestBlock[]
expect(result[0].content![0]).toEqual({ type: 'text', text: '[[A]]' })
expect(result[0].content![1]).toEqual({ type: 'text', text: ' and ' })
expect(result[0].content![2]).toEqual({ type: 'text', text: '[[B]]' })
})
it('recursively processes children blocks', () => {
const blocks = [{
content: [],
children: [{
content: [
{ type: 'wikilink', props: { target: 'Nested' }, content: undefined },
],
}],
}]
const result = restoreWikilinksInBlocks(blocks) as TestBlock[]
expect(result[0].children![0].content![0]).toEqual({ type: 'text', text: '[[Nested]]' })
})
it('passes through non-wikilink content unchanged', () => {
const blocks = [{
content: [
{ type: 'text', text: 'plain text' },
{ type: 'link', text: 'a link', href: 'http://example.com' },
],
}]
const result = restoreWikilinksInBlocks(blocks) as TestBlock[]
expect(result[0].content![0]).toEqual({ type: 'text', text: 'plain text' })
expect(result[0].content![1]).toEqual({ type: 'link', text: 'a link', href: 'http://example.com' })
})
it('handles blocks without content', () => {
const blocks = [{ type: 'heading', props: { level: 1 } }]
const result = restoreWikilinksInBlocks(blocks as unknown[]) as TestBlock[]
expect(result[0].type).toBe('heading')
})
it('is the inverse of injectWikilinks for simple cases', () => {
const WL_START = '\u2039WIKILINK:'
const WL_END = '\u203A'
// Start with placeholder text
const blocks = [{
content: [
{ type: 'text', text: `before ${WL_START}Target${WL_END} after` },
],
}]
// inject → restore should produce [[Target]] text
const injected = injectWikilinks(blocks) as TestBlock[]
const restored = restoreWikilinksInBlocks(injected) as TestBlock[]
// Find the text that was the wikilink
const texts = restored[0].content!.map(n => n.text).join('')
expect(texts).toContain('[[Target]]')
})
})

View File

@@ -23,17 +23,34 @@ interface InlineItem {
[key: string]: unknown
}
type ContentTransform = (content: InlineItem[]) => InlineItem[]
/** Walk blocks recursively, applying a transform to each block's inline content */
function walkBlocks(blocks: unknown[], transform: ContentTransform, clone = false): unknown[] {
return (blocks as BlockLike[]).map(block => {
const b = clone ? { ...block } : block
if (b.content && Array.isArray(b.content)) {
b.content = transform(b.content)
}
if (b.children && Array.isArray(b.children)) {
b.children = walkBlocks(b.children, transform, clone) as BlockLike[]
}
return b
})
}
/** Walk blocks and replace placeholder text with wikilink inline content */
export function injectWikilinks(blocks: unknown[]): unknown[] {
return (blocks as BlockLike[]).map(block => {
if (block.content && Array.isArray(block.content)) {
block.content = expandWikilinksInContent(block.content)
}
if (block.children && Array.isArray(block.children)) {
block.children = injectWikilinks(block.children) as BlockLike[]
}
return block
})
return walkBlocks(blocks, expandWikilinksInContent)
}
/**
* Deep-clone blocks and convert wikilink inline content back to [[target]] text.
* This is the reverse of injectWikilinks — used before blocksToMarkdownLossy
* so that wikilinks survive the markdown round-trip.
*/
export function restoreWikilinksInBlocks(blocks: unknown[]): unknown[] {
return walkBlocks(blocks, collapseWikilinksInContent, true)
}
function expandWikilinksInContent(content: InlineItem[]): InlineItem[] {
@@ -65,6 +82,18 @@ function expandWikilinksInContent(content: InlineItem[]): InlineItem[] {
return result
}
function collapseWikilinksInContent(content: InlineItem[]): InlineItem[] {
const result: InlineItem[] = []
for (const item of content) {
if (item.type === 'wikilink' && item.props?.target) {
result.push({ type: 'text', text: `[[${item.props.target}]]` })
} else {
result.push(item)
}
}
return result
}
/** Strip YAML frontmatter from markdown, returning [frontmatter, body] */
export function splitFrontmatter(content: string): [string, string] {
if (!content.startsWith('---')) return ['', content]