This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Package manager: pnpm (lockfile is pnpm-lock.yaml).
pnpm install # bootstrap
pnpm tauri dev # run the desktop app (Vite + Rust)
pnpm dev # frontend-only in a browser (no Tauri APIs)
pnpm tauri build # production bundle
pnpm test # vitest, runs once
pnpm test:watch # vitest in watch mode
pnpm test -- src/lib/__tests__/imagePaste.test.ts # single file
pnpm test -- -t "encodeMarkdownUrl" # single test by name
cargo test --manifest-path src-tauri/Cargo.toml --lib # Rust unit tests
cargo test --manifest-path src-tauri/Cargo.toml --lib fs::tests::lists_only_markdown_files # one test
pnpm test:e2e # Playwright smoke (boots Vite, not Tauri)Releases are cut via the manual Release GitHub Actions workflow — see docs/RELEASING.md. Do not change plugins.updater.pubkey in tauri.conf.json without an upgrade plan: every installed copy only trusts updates signed by that exact key.
- Frontend (
src/) — React 19 + TypeScript + Tailwind v4 + Vite + Zustand. BlockNote for the block editor, CodeMirror for raw markdown (lazy-loaded inEditorPane). - Backend (
src-tauri/) — Tauri 2 + Rust. Filesystem ops, frontmatter parsing, file watching, recent-folders persistence, AI agent subprocess management.
All cross-process calls funnel through src/lib/ipc.ts. It is the single facade — no other frontend code should call invoke() directly. Commands are registered in src-tauri/src/lib.rs under tauri::generate_handler! and live in src-tauri/src/commands/{fs,recent,watch,agents,frontmatter}.rs.
src/features/<feature>/ is the unit of organization. Each folder owns its UI components and the hooks that wire it to the store. Composition happens in src/App.tsx. Notable features:
editor/—BlockEditor(BlockNote) andRawEditor(CodeMirror, lazy) share a singleopenDocin the store;useEditorModehandles the YAML round-trip when toggling.useAutoSavedebounces 500ms.tree/— file tree + context menu + keyboard shortcuts.properties/— frontmatter editor;fields/has one component per inferred YAML type.palette/— Cmd+P fuzzy file palette, Cmd+K agent ask, Cmd+Shift+F vault content search (usescmdk).SearchMode.tsxcallsipc.searchVault(Rustcommands/search.rs) with a 180ms debounce. Search results setpendingScroll: { path, line, matchText, occurrence }in the store before selecting the file. Whichever editor mode is active walks matches in document order and stops atoccurrence:RawEditorusesscrollViewToMatch(CM doc walk);BlockEditorusesfindNthBlockMatchagainsteditor.document. Both callflashHighlightwhich briefly outlines + tints the target line/block (~2s) by setting inline styles. The user's editor mode is respected — search never auto-flips.watcher/— listens forvault-changedevents from Rust and refreshes the tree / reloads the open doc when clean.ai/— chat panel that drives a Claude Code subprocess via the agents adapter.updates/— Tauri updater UI; checks ~10s after launch, banner in bottom-right.
src/lib/store.ts is the single store. The partialize slice deliberately persists only settings, rightPane, and aiAgent. Vault path, tree, and the open document are session-scoped — they reload from disk via useStartupRestore (which reads the recent-folders list from Rust and re-opens the most recent one). When you add new persisted state, update both partialize and the merge function (the latter handles legacy-key migrations — see the propertiesVisible / aiPanelVisible → rightPane migration for the pattern).
The file watcher (src-tauri/src/commands/watch.rs) emits vault-changed events on a 150ms debounce. The frontend (useExternalChanges) refreshes the tree and reloads the open doc only if it is clean.
To prevent self-write echo: every IPC write that originates in the app calls noteSelfWrite(path) immediately before ipc.writeFile. Events arriving within RECENT_WRITE_WINDOW_MS (1000ms) for a tracked path are dropped. Any new write path through the frontend must call noteSelfWrite or it will fight the autosave loop.
src-tauri/src/commands/fs.rs has two write strategies:
write_bytes_atomic_clobber— used bywrite_file(doc saves). Writes to a temp file, thenpersist(rename) onto the target, overwriting.write_bytes_atomic_no_clobber— used bywrite_image.persist_noclobberatomically refuses to overwrite, closing the TOCTOU race two concurrent pastes would otherwise hit.
Image filename collisions are handled by retrying with -1, -2, ... suffixes in saveImage (src/lib/imagePaste.ts), up to MAX_ATTEMPTS.
WKWebView on macOS reports types: ["Files"] for clipboard images but leaves items/files empty, so BlockNote's normal paste plugin can't see the bytes. The workaround in src/lib/imagePaste.ts:
readClipboardImageAsPng()reads native RGBA via@tauri-apps/plugin-clipboard-manager, draws it into a canvas, and re-encodes as PNG.ipc.writeImagebase64-encodes viaFileReader.readAsDataURLand sends as a string — a rawUint8Arrayover Tauri's JSON IPC stalls on multi-megabyte pastes.- Rust decodes base64, writes atomically no-clobber.
Image URLs in markdown are produced by encodeMarkdownUrl — it escapes whitespace, parens, and brackets but leaves path separators intact.
src-tauri/src/commands/agents/mod.rs defines an Agent trait. Only ClaudeCodeAgent is wired today; Codex/OpenCode/Pi/Gemini are stubs returned by detect_agents so the UI can show them as unavailable.
Adapter contract: detect() finds the binary, build_command() returns args+env, parse_line() parses one NDJSON line into AiStreamEvents. To add an agent, drop a file in commands/agents/<name>.rs and register it in agent_for().
super::which() does aggressive PATH fallback (Homebrew, mise, asdf, npm-global, ~/.claude/local, etc.) because the desktop app's inherited PATH on macOS is unreliable. Use this — not std::env::var("PATH") — when locating user-installed binaries.
The subprocess child is held behind Arc<Mutex<Child>> so the reader thread and stop_ai_session can both reach it; the waiter polls try_wait so it never blocks the mutex.
Two implementations have to stay in sync:
- Rust:
commands/frontmatter.rsusesgray_matter+serde_yaml. The canonical save path. - TS:
src/lib/yaml.ts(combineRaw) anduseEditorMode.ts(parseRaw) — only used to round-trip between block and raw mode in the editor. The simple-YAML parser handles strings/numbers/booleans/null and one-level arrays; complex YAML is filtered upstream byinferType.ts.
If you change one side, change the other and update inferType.test.ts / the Rust round_trip_preserves_content test.
tauri.conf.json points the updater at https://ryanb58.github.io/mdwriter/updates/latest.json. The bundle version is YEAR.MONTH.<build-n> (no leading zeros — semver doesn't allow them), e.g. 2026.6.7, stamped into Cargo.toml/tauri.conf.json/package.json by the release workflow. <build-n> is a per-month build counter the tag job computes as (highest existing patch for this YEAR.MONTH) + 1, so every release is strictly greater than the last — which is what the updater's semver comparison requires. Multiple releases in the same day/month are fine; they just get consecutive build numbers. The git tag is the version with a leading v (e.g. v2026.6.16), and the release is titled mdwriter 2026.6.16 (<short-sha>) — version, tag, and title all carry the same number so they can't be mismatched. The commit stays reachable via the tag (it points at the commit) and the short SHA in the title.
Playwright (e2e/) drives pnpm dev (browser-only — no Tauri runtime). Anything that requires real invoke() calls is silently no-op'd by useStartupRestore's try/catch. The current smoke test only verifies the empty state renders.
vite.config.ts splits BlockNote + CodeMirror + ProseMirror + Lezer + yjs + Shiki (used by @blocknote/code-block for fenced-block syntax highlighting) into an editor-vendor chunk. The 6 MB warning threshold is intentional — Shiki's precompiled language grammars dominate the chunk and that's the floor. Don't lower it.