diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index 6d2034d4..bf9266ff 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -371,9 +371,16 @@ fn validate_save_path(file_path: &Path, display_path: &str) -> Result<(), String Ok(()) } -/// Write content to a note file. Validates parent directory and read-only status. +/// Write content to a note file. Creates parent directory if needed, validates path, +/// then writes content to disk. pub fn save_note_content(path: &str, content: &str) -> Result<(), String> { let file_path = Path::new(path); + if let Some(parent) = file_path.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?; + } + } validate_save_path(file_path, path)?; fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e)) } @@ -960,6 +967,32 @@ References: assert!(entry.outgoing_links.contains(&"project/alpha".to_string())); } + // --- save_note_content tests --- + + #[test] + fn test_save_note_content_creates_parent_directory() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("new-type/untitled-note.md"); + let content = "---\ntitle: Untitled note\n---\n# Untitled note\n\n"; + + assert!(!path.parent().unwrap().exists()); + save_note_content(path.to_str().unwrap(), content).unwrap(); + + assert!(path.exists()); + assert_eq!(fs::read_to_string(&path).unwrap(), content); + } + + #[test] + fn test_save_note_content_existing_directory() { + let dir = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join("note")).unwrap(); + let path = dir.path().join("note/test.md"); + let content = "# Test\n"; + + save_note_content(path.to_str().unwrap(), content).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), content); + } + // Frontmatter update/delete tests are in frontmatter.rs // save_image tests are in vault/image.rs // purge_trash tests are in vault/trash.rs diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 31a59ed7..c1973376 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -429,10 +429,21 @@ export const Editor = memo(function Editor({ tabPathsRef.current = currentPaths }, [tabs]) - // Focus editor when a new note is created (signaled via custom event) + // Focus editor when a new note is created (signaled via custom event). + // Uses adaptive timing: fast rAF path when editor is already mounted, + // short timeout when waiting for first mount. useEffect(() => { - const handler = () => { - setTimeout(() => editor.focus(), 150) + const handler = (e: Event) => { + const t0 = (e as CustomEvent).detail?.t0 as number | undefined + const doFocus = () => { + editor.focus() + if (t0) console.debug(`[perf] createNote → focus: ${(performance.now() - t0).toFixed(1)}ms`) + } + if (editorMountedRef.current) { + requestAnimationFrame(doFocus) + } else { + setTimeout(doFocus, 80) + } } window.addEventListener('laputa:focus-editor', handler) return () => window.removeEventListener('laputa:focus-editor', handler) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 568597a4..103b3069 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -183,6 +183,11 @@ function navigateWikilink(entries: VaultEntry[], target: string, selectNote: (e: else console.warn(`Navigation target not found: ${target}`) } +/** Dispatch focus-editor event with perf timing marker. */ +function signalFocusEditor(): void { + window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { t0: performance.now() } })) +} + /** Persist to disk; on failure, call the revert handler. */ function persistOptimistic(path: string, content: string, onFail: (p: string) => void): void { persistNewNote(path, content).catch(() => onFail(path)) @@ -275,7 +280,7 @@ export function useNoteActions(config: NoteActionsConfig) { const title = generateUntitledName(entries, noteType, pendingNamesRef.current) pendingNamesRef.current.add(title) handleCreateNote(title, noteType) - window.dispatchEvent(new CustomEvent('laputa:focus-editor')) + signalFocusEditor() setTimeout(() => pendingNamesRef.current.delete(title), 500) }, [entries, handleCreateNote])