Compare commits

...

22 Commits

Author SHA1 Message Date
lucaronin
91db7c0098 fix: pause saves without active vault 2026-04-29 00:19:47 +02:00
lucaronin
87908d982f fix: allow guidance open path 2026-04-28 23:56:08 +02:00
lucaronin
40544fc551 fix: detect nvm ai agent binaries 2026-04-28 23:39:48 +02:00
lucaronin
f34fdb0289 fix: guard stale suggestion menu actions 2026-04-28 23:29:09 +02:00
lucaronin
e601a42f41 fix: surface ai agent final replies 2026-04-28 23:00:06 +02:00
lucaronin
de627c2478 fix: normalize missing vault metadata 2026-04-28 22:52:07 +02:00
lucaronin
46d9aaed48 fix: dedupe editor toolbar state closes 2026-04-28 21:57:26 +02:00
lucaronin
6b96b5caf6 fix: reset editor selection before note swaps 2026-04-28 21:31:54 +02:00
lucaronin
595c48ca59 fix: apply settings theme changes immediately 2026-04-28 21:16:30 +02:00
lucaronin
fd92ff5e9b feat: allow manual saved view ordering 2026-04-28 20:41:07 +02:00
lucaronin
a39b346a75 fix: detect mise node for mcp setup 2026-04-28 20:28:56 +02:00
lucaronin
deac10caa2 fix: detect nvm pi installs 2026-04-28 19:54:39 +02:00
lucaronin
0e1e0ff47a fix: preserve ai composer line breaks 2026-04-28 19:32:08 +02:00
lucaronin
a645edbc19 fix: repair malformed editor block ids 2026-04-28 18:56:36 +02:00
lucaronin
48f1fb0b50 feat: add gemini cli external ai setup 2026-04-28 18:30:14 +02:00
lucaronin
1aede09834 fix: preserve wikilinks inside markdown tables 2026-04-28 18:00:15 +02:00
lucaronin
d51b76f992 fix: restrict sentry releases to stable builds 2026-04-28 17:17:23 +02:00
lucaronin
8e66fb4c19 fix: preserve table math markdown 2026-04-28 16:47:52 +02:00
lucaronin
a2843a0cf3 fix: persist immediate note creation before open 2026-04-28 15:01:32 +02:00
lucaronin
38fb8a7370 feat: copy mcp config from app 2026-04-28 14:03:10 +02:00
lucaronin
be5a8bb300 fix: make editor left handle draggable 2026-04-28 12:31:13 +02:00
lucaronin
ce040c75fd test: cover table hover stability 2026-04-28 10:46:06 +02:00
104 changed files with 4696 additions and 712 deletions

View File

@@ -27,7 +27,7 @@ You can find some Loom walkthroughs below — they are short and to the point:
- 🔬 **Open source** — Tolaria is free and open source. I built this for [myself](https://x.com/lucaronin) and for sharing it with others.
- 📋 **Standards-based** — Notes are markdown files with YAML frontmatter. No proprietary formats, no locked-in data. Everything works with standard tools if you decide to move away from Tolaria.
- 🔍 **Types as lenses, not schemas** — Types in Tolaria are navigation aids, not enforcement mechanisms. There's no required fields, no validation, just helpful categories for finding notes.
- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code and Codex CLI (for now), but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out.
- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code, Codex CLI, and Gemini CLI setup paths, but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out.
- ⌨️ **Keyboard-first** — Tolaria is designed for power-users who want to use keyboard as much as possible. A lot of how we designed the Editor and the Command Palette is based on this.
- 💪 **Built from real use** — Tolaria was created for manage my personal vault of 10,000+ notes, and I use it every day. Every feature exists because it solved a real problem.
@@ -71,6 +71,12 @@ Tauri 2 on Linux requires WebKit2GTK 4.1 and GTK 3:
The bundled MCP server still spawns the system `node` binary at runtime on Linux, so install Node from your distro package manager if you want the external AI tooling flow.
### Gemini CLI setup
Tolaria can register the active vault's MCP server in `~/.gemini/settings.json` from the status bar or command palette action "Set Up External AI Tools". The generated entry is scoped to the selected vault with `VAULT_PATH` and requires Node.js 18+ plus a separately installed and signed-in Gemini CLI.
Use "Restore Tolaria AI Guidance" if you also want Tolaria to create a non-destructive `GEMINI.md` compatibility shim in the vault root. The shim points Gemini back to the shared `AGENTS.md` instructions and is not written over custom `GEMINI.md` content.
### Quick start
```bash

View File

@@ -175,6 +175,7 @@ Type is determined **purely** from the `type:` frontmatter field — it is never
├── some-topic.md ← type: Topic
├── AGENTS.md ← canonical Tolaria AI guidance
├── CLAUDE.md ← compatibility shim pointing at AGENTS.md
├── GEMINI.md ← optional Gemini CLI shim pointing at AGENTS.md
├── ...
└── type/ ← type definition documents
```
@@ -303,6 +304,12 @@ type SidebarSelection =
- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected.
- Folder deletion clears pending rename state, confirms destructive intent, drops affected folder-scoped tabs, reloads vault data, and resets folder selection if the deleted subtree owned the current selection.
### Saved Views
Saved Views live as YAML files under `views/`. Their definition includes user-visible fields (`name`, `icon`, `color`), note-list preferences (`sort`, `listPropertiesDisplay`), filters, and an optional top-level `order` number. The `order` value is stored directly in the YAML document, not in Markdown frontmatter, and lower values render earlier in every saved-View list. Views without an explicit order sort after ordered views by filename for stable fallback behavior.
The renderer uses `viewOrdering` helpers to convert drag or move-button intent into dense order updates before saving each affected view file through `save_view_cmd`. The sidebar exposes both a drag handle and explicit move-up/move-down controls, and the command palette mirrors those move actions for the currently selected saved View.
### Neighborhood Mode
`SidebarSelection.kind === 'entity'` is Tolaria's Neighborhood mode for note-list browsing.
@@ -345,7 +352,7 @@ Command-layer path access is fenced to the active vault before file operations r
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder and external-open calls route through the Tauri opener plugin, while copy-path uses the browser clipboard API; none of those actions mutate vault contents or bypass the backend write boundary.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files.
### Vault Caching
@@ -677,8 +684,9 @@ Per-vault settings stored locally and scoped by vault path:
Tolaria tracks managed vault-level AI guidance separately from normal note content:
- `AGENTS.md` is the canonical managed guidance file for Tolaria-aware coding agents
- `CLAUDE.md` is a compatibility shim that points Claude Code back to `AGENTS.md`
- `GEMINI.md` is an optional Gemini CLI compatibility shim that points Gemini back to `AGENTS.md`
- `useVaultAiGuidanceStatus` reads `get_vault_ai_guidance_status` and normalizes the backend state into four UI cases: `managed`, `missing`, `broken`, and `custom`
- `restore_vault_ai_guidance` repairs only Tolaria-managed files; user-authored custom `AGENTS.md` / `CLAUDE.md` files are surfaced as custom and left untouched
- `restore_vault_ai_guidance` repairs only Tolaria-managed files and creates the optional Gemini shim on explicit request; user-authored custom `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` files are surfaced as custom and left untouched
- The status bar AI badge and command palette consume that abstraction to expose restore actions only when the managed guidance is missing or broken
### Getting Started / Onboarding
@@ -697,7 +705,7 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte
`useAiAgentsOnboarding(enabled)` adds a separate first-launch agent step:
- Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key)
- Only shows after vault onboarding has already resolved to a ready state
- Uses `get_ai_agents_status`, whose backend treats the app process path, login-shell path, and supported local/toolchain/app install locations, including Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources
- Uses `get_ai_agents_status`, whose backend treats the app process path, login-shell path, and supported local/toolchain/app install locations, including nvm-managed Node installs plus Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources
- Persists dismissal locally once the user continues
### Remote Git Operations
@@ -743,9 +751,9 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
- **`useTelemetry(settings, loaded)`** — Reactively initializes/tears down Sentry and PostHog based on settings. Called once in `App`.
### Libraries
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/release/key from `VITE_SENTRY_DSN`, `VITE_SENTRY_RELEASE`, and `VITE_POSTHOG_KEY` env vars.
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/key from `VITE_SENTRY_DSN` and `VITE_POSTHOG_KEY`; `VITE_SENTRY_RELEASE` is treated as the build version and only becomes Sentry's `release` for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds tag `tolaria.build_version` and `tolaria.release_kind` without creating normal Sentry Releases entries.
- **`src/main.tsx`** — React root error callbacks (`onCaughtError`, `onUncaughtError`, `onRecoverableError`) forward component-stack context to `Sentry.reactErrorHandler()` for debuggable production React errors.
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes. `reinit_sentry()` for runtime toggle.
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes; stable calendar `CARGO_PKG_VERSION` values become Sentry releases, while alpha/prerelease/internal versions are kept as diagnostic tags only. `reinit_sentry()` for runtime toggle.
### Tauri Commands
- **`reinit_telemetry`** — Re-reads settings and toggles Rust Sentry on/off. Called from frontend when user changes crash reporting setting.
@@ -771,6 +779,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
### CI/CD
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`.
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -186,7 +186,7 @@ flowchart TD
└──────────────────────────────────────────────────────────────┘
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the handle, while keyboard users can use explicit move buttons or command-palette move actions. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image binaries get an image indicator and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Binary image files render through `FilePreview` as ordinary vault files using Tauri asset URLs, with explicit unsupported/broken fallback states and keyboard focus returning to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
@@ -229,7 +229,7 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too
3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, a file/search-only built-in tool list, hidden Windows subprocess launches, and closed stdin for print-mode subprocesses so Windows launches receive EOF; Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json`; OpenCode runs through `opencode run --format json`; Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`. OpenCode and Pi both launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
4. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, and Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs.
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs.
#### Agent Event Flow
@@ -294,7 +294,7 @@ Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existi
## MCP Server
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Cursor, or any MCP-compatible client).
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Gemini CLI, Cursor, or any MCP-compatible client).
### Tool Surface (14 tools)
@@ -326,10 +326,11 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
Tolaria can register itself as an MCP server in:
- `~/.claude.json` and `~/.claude/mcp.json` (Claude Code compatibility across current CLI and legacy MCP-file setups)
- `~/.gemini/settings.json` (Gemini CLI)
- `~/.cursor/mcp.json` (Cursor)
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`). The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`.
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria only writes the MCP entry and optional vault guidance shim. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`.
### Architecture
@@ -376,8 +377,9 @@ flowchart LR
|----------|---------|
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `sync_mcp_bridge_vault(vault_path?)` | Starts, restarts, or stops the desktop WebSocket bridge as the selected vault changes |
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Cursor, and generic MCP configs on user request |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Cursor, and generic MCP configs |
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Gemini CLI, Cursor, and generic MCP configs on user request |
| `mcp_config_snippet(vault_path)` | Builds the exact `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Gemini CLI, Cursor, and generic MCP configs |
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed on app exit via the `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
@@ -476,7 +478,7 @@ Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnbo
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. Optional `GEMINI.md` guidance is created only by the explicit AI guidance restore action. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
After the clone completes, Tolaria removes every configured git remote from the new starter vault. Getting Started vaults therefore open as local-only by default, and users opt into a remote later with the explicit Add Remote flow.
@@ -625,7 +627,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames |
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md`, `CLAUDE.md`, and optional `GEMINI.md` shims), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
| `getting_started.rs` | Clones and normalizes the public Getting Started starter vault |
## Rust Backend Modules
@@ -670,7 +672,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `check_vault_exists` | Check if vault path exists |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `type.md`, and `note.md` defaults |
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md` and the `CLAUDE.md` shim are managed, missing, broken, or custom |
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md`, `CLAUDE.md`, and optional `GEMINI.md` guidance are managed, missing, broken, or custom |
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
### Frontmatter
@@ -724,9 +726,10 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `check_claude_cli` | Check if Claude CLI is available |
| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi availability |
| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, or Pi through the normalized agent event layer |
| `register_mcp_tools` | Register MCP in Claude/Cursor/generic config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor/generic config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor/generic config |
| `register_mcp_tools` | Register MCP in Claude/Gemini/Cursor/generic config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Gemini/Cursor/generic config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Gemini/Cursor/generic config |
| `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault |
| `sync_mcp_bridge_vault` | Sync the desktop WebSocket bridge process to the selected vault, or stop it when no vault is selected |
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.
@@ -942,7 +945,7 @@ sequenceDiagram
**Architecture:**
- **Rust:** `sentry` crate initialized in `lib.rs::setup()` via `telemetry::init_sentry_from_settings()`
- **JS:** `@sentry/react` + `posthog-js` initialized lazily by `useTelemetry` hook; the React root also wires `onCaughtError`, `onUncaughtError`, and `onRecoverableError` through `Sentry.reactErrorHandler()` so production React invariants include component stack context when crash reporting is enabled.
- **Release grouping:** packaged release workflows pass `VITE_SENTRY_RELEASE` from the computed build version so frontend Sentry events group by the shipped alpha or stable version.
- **Release grouping:** packaged release workflows pass `VITE_SENTRY_RELEASE` from the computed build version, but the app only assigns Sentry's `release` field for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds omit `release` so they do not create normal Sentry Releases entries, while both frontend and Rust Sentry scopes tag `tolaria.build_version` and `tolaria.release_kind` for diagnostics.
- **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct
- **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null`

View File

@@ -425,6 +425,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
### Work with external MCP setup
1. **Backend registration/status**: Edit `src-tauri/src/mcp.rs`; registration must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and write an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx`; users should see the Node.js prerequisite and the manual config shape before Tolaria writes third-party config files
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes
4. **Gemini CLI compatibility**: Keep `~/.gemini/settings.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; Gemini itself still needs its own install and sign-in outside Tolaria

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0091"
title: "Gemini CLI external AI setup"
status: active
date: 2026-04-28
---
## Context
Tolaria already supports explicit MCP setup for external desktop AI tools. Users asked for Gemini CLI support so the same active-vault MCP server can be registered where Gemini reads tool configuration, with optional Gemini-specific vault guidance.
Gemini CLI reads MCP server definitions from `~/.gemini/settings.json` and can load project guidance from `GEMINI.md`. Tolaria must support those conventions without silently rewriting global user settings or overwriting user-authored vault instructions.
## Decision
Tolaria adds `~/.gemini/settings.json` to the explicit external AI setup path list. The existing MCP entry shape is reused: `mcpServers.tolaria` runs the packaged stdio server through Node.js, pins `VAULT_PATH` to the selected vault, and sets `WS_UI_PORT=9711`.
Vault guidance keeps `AGENTS.md` as the canonical shared source. `restore_vault_ai_guidance` can create or repair a managed `GEMINI.md` shim that imports `AGENTS.md`, while bootstrap and repair flows continue to seed only required Tolaria guidance (`AGENTS.md` and `CLAUDE.md`) plus type scaffolding. Custom `GEMINI.md` files are classified as custom and are not overwritten.
The setup dialog documents that Gemini CLI still needs its own install and sign-in. Tolaria does not store Gemini credentials or model-provider API keys.
## Options Considered
- **Add Gemini to explicit MCP setup and optional guidance restore** (chosen): matches the existing consent boundary, preserves user settings, and gives Gemini the shared vault context.
- **Generate `GEMINI.md` automatically for every vault**: simpler discovery, but turns an optional third-party compatibility file into startup side effect and dirties vaults for users who do not use Gemini.
- **Document manual Gemini setup only**: avoids code changes, but leaves users to transpose paths and loses the active-vault safety already available for other MCP clients.
- **Create a Gemini-specific MCP entry shape**: unnecessary because Gemini accepts the same `mcpServers` entry structure used by the existing Tolaria MCP server.
## Consequences
- External AI setup now writes/removes Tolaria's MCP entry in Claude, Gemini, Cursor, and generic MCP config files.
- Gemini users can create a managed `GEMINI.md` shim without duplicating the canonical `AGENTS.md` guidance.
- Existing vault bootstrap and repair remain non-invasive for users who do not use Gemini.
- Native QA requires a local Gemini CLI install and authentication for an end-to-end Gemini prompt; otherwise the app can still verify the generated config and documentation paths.

View File

@@ -142,3 +142,5 @@ proposed → active → superseded
| [0085](0085-non-git-vault-support.md) | Non-git vaults open with explicit later Git initialization | active |
| [0088](0088-markdown-durable-mermaid-diagrams.md) | Markdown-durable Mermaid diagrams in notes | active |
| [0089](0089-active-vault-filesystem-watcher.md) | Active vault filesystem watcher | active |
| [0090](0090-pi-cli-agent-adapter.md) | Pi CLI agent adapter | active |
| [0091](0091-gemini-cli-external-ai-setup.md) | Gemini CLI external AI setup | active |

View File

@@ -17,7 +17,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "node scripts/run-vitest-coverage.mjs",

View File

@@ -17,6 +17,7 @@
"dialog:default",
"updater:default",
"process:default",
"opener:default"
"opener:default",
"opener:allow-open-path"
]
}

View File

@@ -229,7 +229,7 @@ fn codex_binary_candidates() -> Vec<PathBuf> {
}
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
let mut candidates = vec![
home.join(".local/bin/codex"),
home.join(".codex/bin/codex"),
home.join(".local/share/mise/shims/codex"),
@@ -240,7 +240,24 @@ fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
PathBuf::from("/usr/local/bin/codex"),
PathBuf::from("/opt/homebrew/bin/codex"),
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
]
];
candidates.extend(nvm_node_binary_candidates_for_home(home, "codex"));
candidates
}
fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
return Vec::new();
};
let mut candidates = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.map(|path| path.join("bin").join(binary_name))
.collect::<Vec<_>>();
candidates.sort();
candidates
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
@@ -585,6 +602,18 @@ mod tests {
}
}
#[test]
fn codex_binary_candidates_include_nvm_managed_node_installs() {
let home = tempfile::tempdir().unwrap();
let codex = home.path().join(".nvm/versions/node/v22.12.0/bin/codex");
std::fs::create_dir_all(codex.parent().unwrap()).unwrap();
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
let candidates = codex_binary_candidates_for_home(home.path());
assert!(candidates.contains(&codex), "missing {}", codex.display());
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();

View File

@@ -148,7 +148,7 @@ fn claude_binary_candidates() -> Vec<PathBuf> {
}
fn claude_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
let mut candidates = vec![
home.join(".local/bin/claude"),
home.join(".local/bin/claude.exe"),
home.join(".claude/local/claude"),
@@ -170,7 +170,24 @@ fn claude_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
home.join("scoop/shims/claude.exe"),
PathBuf::from("/opt/homebrew/bin/claude"),
PathBuf::from("/usr/local/bin/claude"),
]
];
candidates.extend(nvm_node_binary_candidates_for_home(home, "claude"));
candidates
}
fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
return Vec::new();
};
let mut candidates = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.map(|path| path.join("bin").join(binary_name))
.collect::<Vec<_>>();
candidates.sort();
candidates
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
@@ -314,6 +331,8 @@ struct StreamState {
tool_inputs: HashMap<String, String>,
/// The tool_use id of the block currently being streamed.
current_tool_id: Option<String>,
/// Tracks whether response text has already been emitted for this run.
emitted_text: bool,
}
/// Core subprocess runner shared by chat and agent modes.
@@ -339,6 +358,7 @@ where
session_id: String::new(),
tool_inputs: HashMap::new(),
current_tool_id: None,
emitted_text: false,
};
for line in reader.lines() {
@@ -473,7 +493,15 @@ where
if !sid.is_empty() {
state.session_id = sid.clone();
}
let text = json["result"].as_str().unwrap_or("").to_string();
let text = if state.emitted_text {
String::new()
} else {
let text = json["result"].as_str().unwrap_or("").to_string();
if !text.is_empty() {
state.emitted_text = true;
}
text
};
emit(ClaudeStreamEvent::Result {
text,
session_id: sid,
@@ -483,19 +511,9 @@ where
// --- Complete assistant message (fallback for text when no partials) ---
"assistant" => {
if let Some(content) = json["message"]["content"].as_array() {
let emit_text = !state.emitted_text;
for block in content {
if block["type"].as_str() == Some("tool_use") {
if let (Some(id), Some(name)) =
(block["id"].as_str(), block["name"].as_str())
{
let input = format_tool_input(&block["input"], state, id);
emit(ClaudeStreamEvent::ToolStart {
tool_name: name.to_string(),
tool_id: id.to_string(),
input,
});
}
}
dispatch_assistant_content_block(block, emit_text, state, emit);
}
}
}
@@ -518,9 +536,7 @@ where
match delta["type"].as_str() {
Some("text_delta") => {
if let Some(text) = delta["text"].as_str() {
emit(ClaudeStreamEvent::TextDelta {
text: text.to_string(),
});
emit_text_delta(text, state, emit);
}
}
Some("thinking_delta") => {
@@ -565,6 +581,46 @@ where
}
}
fn dispatch_assistant_content_block<F>(
block: &serde_json::Value,
emit_text: bool,
state: &mut StreamState,
emit: &mut F,
) where
F: FnMut(ClaudeStreamEvent),
{
match block["type"].as_str() {
Some("text") if emit_text => {
if let Some(text) = block["text"].as_str() {
emit_text_delta(text, state, emit);
}
}
Some("tool_use") => {
if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) {
let input = format_tool_input(&block["input"], state, id);
emit(ClaudeStreamEvent::ToolStart {
tool_name: name.to_string(),
tool_id: id.to_string(),
input,
});
}
}
_ => {}
}
}
fn emit_text_delta<F>(text: &str, state: &mut StreamState, emit: &mut F)
where
F: FnMut(ClaudeStreamEvent),
{
if !text.is_empty() {
state.emitted_text = true;
}
emit(ClaudeStreamEvent::TextDelta {
text: text.to_string(),
});
}
/// Build the tool input string, preferring accumulated delta chunks over the
/// block's `input` field (which may be empty at stream start).
fn format_tool_input(
@@ -617,6 +673,18 @@ mod tests {
}
}
#[test]
fn claude_binary_candidates_include_nvm_managed_node_installs() {
let home = tempfile::tempdir().unwrap();
let claude = home.path().join(".nvm/versions/node/v22.12.0/bin/claude");
std::fs::create_dir_all(claude.parent().unwrap()).unwrap();
std::fs::write(&claude, "#!/bin/sh\n").unwrap();
let candidates = claude_binary_candidates_for_home(home.path());
assert!(candidates.contains(&claude), "missing {}", claude.display());
}
#[test]
fn build_mcp_config_is_valid_json() {
if let Ok(config_str) = build_mcp_config("/tmp/test-vault") {
@@ -636,6 +704,7 @@ mod tests {
session_id: String::new(),
tool_inputs: HashMap::new(),
current_tool_id: None,
emitted_text: false,
}
}
@@ -765,10 +834,35 @@ mod tests {
{ "type": "tool_use", "id": "tu_1", "name": "search_notes", "input": {} }
] }
}));
assert_eq!(events.len(), 1);
assert_eq!(events.len(), 2);
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1")
matches!(&events[0], ClaudeStreamEvent::TextDelta { text } if text == "Let me search.")
);
assert!(
matches!(&events[1], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1")
);
}
#[test]
fn dispatch_event_result_after_text_delta_does_not_duplicate_response_text() {
let (state, events) = run_dispatch_sequence(vec![
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_delta", "delta": { "type": "text_delta", "text": "Visible reply" } }
}),
serde_json::json!({
"type": "result",
"session_id": "session-1",
"result": "Visible reply"
}),
]);
assert_eq!(state.session_id, "session-1");
assert!(matches!(&events[..],
[
ClaudeStreamEvent::TextDelta { text },
ClaudeStreamEvent::Result { text: result_text, session_id },
] if text == "Visible reply" && result_text.is_empty() && session_id == "session-1"));
}
#[test]

View File

@@ -145,6 +145,8 @@ pub async fn stream_ai_agent(
mod tests {
use super::*;
use crate::vault::AiGuidanceFileState;
use serde_json::Value;
use std::{fs, path::Path};
#[test]
fn guidance_commands_report_and_restore_vault_guidance_files() {
@@ -154,14 +156,37 @@ mod tests {
let initial = get_vault_ai_guidance_status(vault_path.clone()).unwrap();
assert_eq!(initial.agents_state, AiGuidanceFileState::Missing);
assert_eq!(initial.claude_state, AiGuidanceFileState::Missing);
assert_eq!(initial.gemini_state, AiGuidanceFileState::Missing);
assert!(initial.can_restore);
let restored = restore_vault_ai_guidance(vault_path.clone()).unwrap();
assert_eq!(restored.agents_state, AiGuidanceFileState::Managed);
assert_eq!(restored.claude_state, AiGuidanceFileState::Managed);
assert_eq!(restored.gemini_state, AiGuidanceFileState::Managed);
assert!(!restored.can_restore);
assert!(dir.path().join("AGENTS.md").exists());
assert!(dir.path().join("CLAUDE.md").exists());
assert!(dir.path().join("GEMINI.md").exists());
}
#[test]
fn desktop_capability_allows_opening_restored_guidance_files() {
let capability_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("capabilities")
.join("default.json");
let capability_json = fs::read_to_string(capability_path).unwrap();
let capability: Value = serde_json::from_str(&capability_json).unwrap();
let permissions = capability
.get("permissions")
.and_then(Value::as_array)
.unwrap();
assert!(
permissions
.iter()
.any(|permission| permission.as_str() == Some("opener:allow-open-path")),
"desktop capabilities must allow opener open_path so restored guidance files can be opened"
);
}
}

View File

@@ -131,6 +131,15 @@ pub async fn check_mcp_status(vault_path: String) -> Result<crate::mcp::McpStatu
.map_err(|e| format!("MCP status check failed: {e}"))
}
#[cfg(desktop)]
#[tauri::command]
pub async fn get_mcp_config_snippet(vault_path: String) -> Result<String, String> {
let vault_path = super::expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::mcp::mcp_config_snippet(&vault_path))
.await
.map_err(|e| format!("MCP config task failed: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(
@@ -167,6 +176,12 @@ pub async fn check_mcp_status(_vault_path: String) -> Result<crate::mcp::McpStat
Ok(crate::mcp::McpStatus::NotInstalled)
}
#[cfg(mobile)]
#[tauri::command]
pub async fn get_mcp_config_snippet(_vault_path: String) -> Result<String, String> {
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(_vault_path: Option<String>) -> Result<String, String> {

View File

@@ -53,6 +53,7 @@ mod tests {
name: "Inbox".to_string(),
icon: None,
color: None,
order: None,
sort: None,
list_properties_display: vec![],
filters: crate::vault::FilterGroup::All(vec![]),

View File

@@ -435,6 +435,7 @@ macro_rules! app_invoke_handler {
commands::register_mcp_tools,
commands::remove_mcp_tools,
commands::check_mcp_status,
commands::get_mcp_config_snippet,
commands::sync_mcp_bridge_vault,
commands::repair_vault,
commands::reinit_telemetry,

View File

@@ -17,30 +17,88 @@ pub enum McpStatus {
/// Find the `node` binary path at runtime.
pub(crate) fn find_node() -> Result<PathBuf, String> {
let output = node_lookup_command()
.output()
.map_err(|e| format!("Failed to locate node on PATH: {e}"))?;
if output.status.success() {
if let Some(path) = first_node_lookup_path(&output.stdout) {
verify_node_version(&path)?;
return Ok(path);
let mut last_error = None;
for path in node_binary_candidates() {
match verify_node_version(&path) {
Ok(()) => return Ok(path),
Err(error) => last_error = Some(error),
}
}
if let Some(path) = fallback_node_path() {
verify_node_version(&path)?;
return Ok(path);
}
Err("node not found in PATH or common install locations".into())
Err(last_error.unwrap_or_else(|| "node not found in PATH or common install locations".into()))
}
fn first_node_lookup_path(stdout: &[u8]) -> Option<PathBuf> {
fn node_binary_candidates() -> Vec<PathBuf> {
let mut candidates = find_node_on_path();
candidates.extend(find_node_in_user_shell());
candidates.extend(fallback_node_paths());
candidates
}
fn find_node_on_path() -> Vec<PathBuf> {
node_lookup_command()
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| node_lookup_paths(&output.stdout))
.unwrap_or_default()
}
fn find_node_in_user_shell() -> Vec<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.filter_map(|shell| command_path_from_shell(&shell, "node"))
.collect()
}
fn node_lookup_paths(stdout: &[u8]) -> Vec<PathBuf> {
String::from_utf8_lossy(stdout)
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect()
}
fn user_shell_candidates() -> Vec<PathBuf> {
let mut shells = Vec::new();
if let Some(shell) = std::env::var_os("SHELL") {
if !shell.is_empty() {
shells.push(PathBuf::from(shell));
}
}
shells.push(PathBuf::from("/bin/zsh"));
shells.push(PathBuf::from("/bin/bash"));
shells
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
crate::hidden_command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
if output.status.success() {
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
})
}
fn verify_node_version(node: &Path) -> Result<(), String> {
@@ -91,7 +149,7 @@ fn node_lookup_command() -> Command {
command
}
fn fallback_node_path() -> Option<PathBuf> {
fn fallback_node_paths() -> Vec<PathBuf> {
let mut candidates = vec![
PathBuf::from("/opt/homebrew/bin/node"),
PathBuf::from("/usr/local/bin/node"),
@@ -120,24 +178,39 @@ fn fallback_node_path() -> Option<PathBuf> {
}
if let Some(home) = dirs::home_dir() {
candidates.push(home.join(".volta").join("bin").join(node_binary_name()));
let nvm_dir = home.join(".nvm").join("versions").join("node");
if let Ok(entries) = std::fs::read_dir(nvm_dir) {
let mut versions = entries
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.collect::<Vec<_>>();
versions.sort();
versions.reverse();
candidates.extend(
versions
.into_iter()
.map(|version| version.join("bin").join("node")),
);
}
candidates.extend(node_binary_candidates_for_home(&home));
}
candidates.into_iter().find(|path| path.is_file())
candidates
.into_iter()
.filter(|path| path.is_file())
.collect()
}
fn node_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let mut candidates = vec![
home.join(".local/share/mise/shims")
.join(node_binary_name()),
home.join(".mise").join("shims").join(node_binary_name()),
home.join(".asdf").join("shims").join(node_binary_name()),
home.join(".volta").join("bin").join(node_binary_name()),
];
let nvm_dir = home.join(".nvm").join("versions").join("node");
if let Ok(entries) = std::fs::read_dir(nvm_dir) {
let mut versions = entries
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.collect::<Vec<_>>();
versions.sort();
versions.reverse();
candidates.extend(
versions
.into_iter()
.map(|version| version.join("bin").join("node")),
);
}
candidates
}
fn node_binary_name() -> &'static str {
@@ -250,6 +323,7 @@ fn mcp_config_paths_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".claude.json"),
home.join(".claude").join("mcp.json"),
home.join(".gemini").join("settings.json"),
home.join(".cursor").join("mcp.json"),
home.join(".config").join("mcp").join("mcp.json"),
]
@@ -313,6 +387,28 @@ fn build_mcp_entry(node_command: &str, index_js: &str, vault_path: &str) -> serd
})
}
fn build_mcp_config_snippet(entry: &serde_json::Value) -> Result<String, String> {
let mut servers = serde_json::Map::new();
servers.insert(MCP_SERVER_NAME.to_string(), entry.clone());
let config = serde_json::json!({ "mcpServers": servers });
serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize MCP config snippet: {e}"))
}
/// Build the exact MCP config JSON users can copy into compatible tools.
pub fn mcp_config_snippet(vault_path: &str) -> Result<String, String> {
let node = find_node().map_err(|e| {
format!("Node.js 18+ is required on PATH before Tolaria can build MCP config: {e}")
})?;
let server_dir = mcp_server_dir()?;
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
let node_command = node.to_string_lossy().into_owned();
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
build_mcp_config_snippet(&entry)
}
/// Write MCP registration to a list of config file paths.
/// Returns "registered" on first registration, "updated" if already present.
fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf]) -> String {
@@ -502,6 +598,20 @@ mod tests {
write_config_json(config_path, serde_json::json!({ "mcpServers": servers }));
}
struct ExpectedMcpServer<'a> {
index_js: &'a str,
vault_path: &'a str,
}
fn assert_registered_tolaria_server(
config: &serde_json::Value,
expected: ExpectedMcpServer<'_>,
) {
let server = &config["mcpServers"][MCP_SERVER_NAME];
assert_eq!(server["args"][0], expected.index_js);
assert_eq!(server["env"]["VAULT_PATH"], expected.vault_path);
}
fn write_index_js(dir: &Path) -> PathBuf {
let index_js = dir.join("index.js");
std::fs::write(&index_js, "console.log('ok');").unwrap();
@@ -519,14 +629,69 @@ mod tests {
}
#[test]
fn first_node_lookup_path_uses_first_non_empty_line() {
fn build_mcp_config_snippet_wraps_tolaria_server_entry() {
let entry = test_mcp_entry("/path/to/index.js", "/my/vault");
let snippet = build_mcp_config_snippet(&entry).unwrap();
let config: serde_json::Value = serde_json::from_str(&snippet).unwrap();
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/path/to/index.js"
);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
"/my/vault"
);
}
#[test]
fn node_lookup_paths_keep_non_empty_lines_in_order() {
let stdout = b"\nC:\\Program Files\\nodejs\\node.exe\r\nC:\\Other\\node.exe\r\n";
assert_eq!(
first_node_lookup_path(stdout).unwrap(),
PathBuf::from("C:\\Program Files\\nodejs\\node.exe")
node_lookup_paths(stdout),
vec![
PathBuf::from("C:\\Program Files\\nodejs\\node.exe"),
PathBuf::from("C:\\Other\\node.exe"),
]
);
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing-node");
let node = dir.path().join("node");
std::fs::write(&node, "#!/bin/sh\n").unwrap();
let stdout = format!("\n{}\n{}\n", missing.display(), node.display());
assert_eq!(first_existing_path(&stdout), Some(node));
}
#[cfg(unix)]
#[test]
fn command_path_from_shell_finds_node_from_login_shell() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let node = dir.path().join("node");
std::fs::write(&node, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o755)).unwrap();
let shell = dir.path().join("shell");
std::fs::write(
&shell,
format!(
"#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n",
node.display()
),
)
.unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(command_path_from_shell(&shell, "node"), Some(node));
}
#[test]
fn node_major_version_accepts_current_node_output() {
assert_eq!(node_major_version("v24.13.1\n"), Some(24));
@@ -534,6 +699,25 @@ mod tests {
assert_eq!(node_major_version("not-node"), None);
}
#[test]
fn node_binary_candidates_include_shell_managed_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = node_binary_candidates_for_home(&home);
let expected = [
home.join(".local/share/mise/shims/node"),
home.join(".asdf/shims/node"),
home.join(".volta/bin/node"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn mcp_server_dir_candidates_prefer_exe_dir_before_macos_resources() {
let dev_path = Path::new("/repo/mcp-server");
@@ -565,13 +749,12 @@ mod tests {
assert!(!was_update);
let config = read_config(&config_path);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/test/index.js"
);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
"/test/vault"
assert_registered_tolaria_server(
&config,
ExpectedMcpServer {
index_js: "/test/index.js",
vault_path: "/test/vault",
},
);
}
@@ -666,6 +849,35 @@ mod tests {
assert!(config["mcpServers"][MCP_SERVER_NAME].is_object());
}
#[test]
fn upsert_preserves_gemini_settings_json_fields() {
let (_tmp, config_path) = temp_config_path("settings.json");
write_config_json(
&config_path,
serde_json::json!({
"theme": "GitHub",
"mcpServers": {
"other": { "command": "example" }
}
}),
);
let entry = test_mcp_entry("/gemini/index.js", "/gemini-vault");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
let config = read_config(&config_path);
assert!(!was_update);
assert_eq!(config["theme"], "GitHub");
assert_eq!(config["mcpServers"]["other"]["command"], "example");
assert_registered_tolaria_server(
&config,
ExpectedMcpServer {
index_js: "/gemini/index.js",
vault_path: "/gemini-vault",
},
);
}
#[test]
fn upsert_creates_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
@@ -736,6 +948,7 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let claude_user_cfg = tmp.path().join(".claude.json");
let claude_cfg = tmp.path().join("claude").join("mcp.json");
let gemini_cfg = tmp.path().join(".gemini").join("settings.json");
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
let generic_cfg = tmp.path().join(".config").join("mcp").join("mcp.json");
let entry = test_mcp_entry("/test/index.js", "/vault");
@@ -745,6 +958,7 @@ mod tests {
&[
claude_user_cfg.clone(),
claude_cfg.clone(),
gemini_cfg.clone(),
cursor_cfg.clone(),
generic_cfg.clone(),
],
@@ -752,14 +966,18 @@ mod tests {
assert!(claude_user_cfg.exists());
assert!(claude_cfg.exists());
assert!(gemini_cfg.exists());
assert!(cursor_cfg.exists());
assert!(generic_cfg.exists());
let raw = std::fs::read_to_string(&claude_user_cfg).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/test/index.js"
assert_registered_tolaria_server(
&config,
ExpectedMcpServer {
index_js: "/test/index.js",
vault_path: "/vault",
},
);
}
@@ -773,6 +991,7 @@ mod tests {
vec![
home.join(".claude.json"),
home.join(".claude").join("mcp.json"),
home.join(".gemini").join("settings.json"),
home.join(".cursor").join("mcp.json"),
home.join(".config").join("mcp").join("mcp.json"),
]

View File

@@ -101,12 +101,8 @@ where
F: FnMut(AiAgentStreamEvent),
{
let tool_id = tool_id(json).unwrap_or("tool").to_string();
let tool_name = json["name"]
.as_str()
.or_else(|| json["tool"].as_str())
.unwrap_or("tool")
.to_string();
let input = json.get("input").map(|input| input.to_string());
let tool_name = tool_name(json).unwrap_or("tool").to_string();
let input = tool_input(json);
emit(AiAgentStreamEvent::ToolStart {
tool_name,
@@ -120,10 +116,7 @@ where
F: FnMut(AiAgentStreamEvent),
{
let tool_id = tool_id(json).unwrap_or("tool").to_string();
let output = json["output"]
.as_str()
.map(|output| output.to_string())
.or_else(|| json.get("result").map(|result| result.to_string()));
let output = tool_output(json);
emit(AiAgentStreamEvent::ToolDone { tool_id, output });
}
@@ -140,24 +133,50 @@ where
}
fn tool_id(json: &serde_json::Value) -> Option<&str> {
json["id"]
.as_str()
.or_else(|| json["toolID"].as_str())
.or_else(|| json["tool_id"].as_str())
first_string_field(
json,
&["id", "toolID", "tool_id", "toolCallID", "toolCallId"],
)
}
fn text_value(json: &serde_json::Value) -> Option<&str> {
json["text"]
.as_str()
.or_else(|| json["content"].as_str())
.or_else(|| json["message"].as_str())
first_string_field(json, &["text", "content", "message"])
}
fn message_value(json: &serde_json::Value) -> Option<&str> {
json["message"]
first_string_field(json, &["message", "error", "text"])
}
fn tool_name(json: &serde_json::Value) -> Option<&str> {
first_string_field(json, &["name", "tool", "toolName"])
}
fn tool_input(json: &serde_json::Value) -> Option<String> {
first_json_field(json, &["input", "args"]).map(|input| input.to_string())
}
fn tool_output(json: &serde_json::Value) -> Option<String> {
first_json_field(json, &["output", "result"]).map(display_json_value)
}
fn first_string_field<'a>(json: &'a serde_json::Value, keys: &[&str]) -> Option<&'a str> {
keys.iter()
.find_map(|key| json[*key].as_str().or_else(|| json["part"][*key].as_str()))
}
fn first_json_field<'a>(
json: &'a serde_json::Value,
keys: &[&str],
) -> Option<&'a serde_json::Value> {
keys.iter()
.find_map(|key| json.get(*key).or_else(|| json["part"].get(*key)))
}
fn display_json_value(value: &serde_json::Value) -> String {
value
.as_str()
.or_else(|| json["error"].as_str())
.or_else(|| json["text"].as_str())
.map(|output| output.to_string())
.unwrap_or_else(|| value.to_string())
}
fn is_auth_error(lower: &str) -> bool {

View File

@@ -25,6 +25,32 @@ fn dispatch_maps_session_reasoning_and_text() {
));
}
#[test]
fn dispatch_maps_part_backed_reasoning_and_text() {
let mut events = Vec::new();
let reasoning = serde_json::json!({
"type": "reasoning",
"part": { "type": "reasoning", "text": "Checking links" }
});
let text = serde_json::json!({
"type": "text",
"part": { "type": "text", "text": "Done from OpenCode" }
});
for event in [reasoning, text] {
dispatch_event(&event, &mut |event| events.push(event));
}
assert!(matches!(
&events[0],
AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links"
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::TextDelta { text } if text == "Done from OpenCode"
));
}
#[test]
fn dispatch_maps_tool_events() {
let mut events = Vec::new();
@@ -51,6 +77,40 @@ fn dispatch_maps_tool_events() {
));
}
#[test]
fn dispatch_maps_part_backed_tool_events() {
let mut events = Vec::new();
let tool_start = serde_json::json!({
"type": "tool_use",
"part": {
"id": "prt_tool_1",
"tool": "read",
"input": { "path": "Note.md" }
}
});
let tool_done = serde_json::json!({
"type": "tool_result",
"part": {
"id": "prt_tool_1",
"output": "ok"
}
});
dispatch_event(&tool_start, &mut |event| events.push(event));
dispatch_event(&tool_done, &mut |event| events.push(event));
assert!(matches!(
&events[0],
AiAgentStreamEvent::ToolStart { tool_name, tool_id, input }
if tool_name == "read" && tool_id == "prt_tool_1" && input.as_deref() == Some(r#"{"path":"Note.md"}"#)
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ToolDone { tool_id, output }
if tool_id == "prt_tool_1" && output.as_deref() == Some("ok")
));
}
#[test]
fn format_error_explains_missing_auth_or_provider_setup() {
let message = format_error(

View File

@@ -99,13 +99,19 @@ fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
}
fn pi_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| pi_binary_candidates_for_home(&home))
.unwrap_or_default()
let mut candidates = pi_binary_candidates_from_env();
if let Some(home) = dirs::home_dir() {
candidates.extend(pi_binary_candidates_for_home(&home));
}
candidates.extend(pi_global_binary_candidates());
candidates
}
fn pi_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
let mut candidates = pi_nvm_binary_candidates_for_home(home);
candidates.extend([
home.join(".local/bin/pi"),
home.join(".pi/bin/pi"),
home.join(".local/share/mise/shims/pi"),
@@ -113,11 +119,63 @@ fn pi_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
home.join(".npm-global/bin/pi"),
home.join(".npm/bin/pi"),
home.join(".bun/bin/pi"),
]);
candidates
}
fn pi_global_binary_candidates() -> Vec<PathBuf> {
vec![
PathBuf::from("/usr/local/bin/pi"),
PathBuf::from("/opt/homebrew/bin/pi"),
]
}
fn pi_binary_candidates_from_env() -> Vec<PathBuf> {
let nvm_bin = std::env::var_os("NVM_BIN").map(PathBuf::from);
let npm_config_prefix = std::env::var_os("npm_config_prefix").map(PathBuf::from);
let npm_config_prefix_upper = std::env::var_os("NPM_CONFIG_PREFIX").map(PathBuf::from);
pi_binary_candidates_from_prefixes(nvm_bin, npm_config_prefix, npm_config_prefix_upper)
}
fn pi_binary_candidates_from_prefixes(
nvm_bin: Option<PathBuf>,
npm_config_prefix: Option<PathBuf>,
npm_config_prefix_upper: Option<PathBuf>,
) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(path) = nvm_bin.filter(|path| !path.as_os_str().is_empty()) {
candidates.push(path.join("pi"));
}
if let Some(path) = npm_config_prefix.filter(|path| !path.as_os_str().is_empty()) {
candidates.push(path.join("bin/pi"));
}
if let Some(path) = npm_config_prefix_upper.filter(|path| !path.as_os_str().is_empty()) {
candidates.push(path.join("bin/pi"));
}
candidates
}
fn pi_nvm_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let versions_dir = home.join(".nvm/versions/node");
let mut version_dirs = match std::fs::read_dir(versions_dir) {
Ok(entries) => entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>(),
Err(_) => return Vec::new(),
};
version_dirs.sort_by(|left, right| right.file_name().cmp(&left.file_name()));
version_dirs
.into_iter()
.map(|version_dir| version_dir.join("bin/pi"))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -133,7 +191,6 @@ mod tests {
home.join(".asdf/shims/pi"),
home.join(".npm-global/bin/pi"),
home.join(".bun/bin/pi"),
PathBuf::from("/opt/homebrew/bin/pi"),
];
for candidate in expected {
@@ -145,6 +202,53 @@ mod tests {
}
}
#[test]
fn binary_candidates_include_nvm_node_version_installs() {
let home = tempfile::tempdir().unwrap();
let pi = home.path().join(".nvm/versions/node/v22.20.0/bin/pi");
std::fs::create_dir_all(pi.parent().unwrap()).unwrap();
std::fs::write(&pi, "#!/bin/sh\n").unwrap();
let candidates = pi_binary_candidates_for_home(home.path());
assert!(
candidates.contains(&pi),
"missing nvm candidate {}",
pi.display()
);
}
#[test]
fn binary_candidates_include_static_global_fallbacks() {
let candidates = pi_global_binary_candidates();
assert!(candidates.contains(&PathBuf::from("/usr/local/bin/pi")));
assert!(candidates.contains(&PathBuf::from("/opt/homebrew/bin/pi")));
}
#[test]
fn binary_candidates_include_env_provided_npm_and_nvm_prefixes() {
let nvm_bin = PathBuf::from("/Users/alex/.nvm/versions/node/v22.20.0/bin");
let npm_config_prefix = PathBuf::from("/Users/alex/.npm-global");
let npm_config_prefix_upper = PathBuf::from("/Users/alex/.npm");
let candidates = pi_binary_candidates_from_prefixes(
Some(nvm_bin.clone()),
Some(npm_config_prefix.clone()),
Some(npm_config_prefix_upper.clone()),
);
assert_eq!(
candidates,
vec![
nvm_bin.join("pi"),
npm_config_prefix.join("bin/pi"),
npm_config_prefix_upper.join("bin/pi"),
]
);
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();

View File

@@ -1,5 +1,7 @@
use crate::settings;
use chrono::NaiveDate;
use regex::Regex;
use std::borrow::Cow;
use std::sync::Mutex;
/// Global Sentry guard — must live for the duration of the app.
@@ -44,6 +46,41 @@ fn parse_embedded_sentry_dsn(raw: Option<&str>) -> Option<sentry::types::Dsn> {
normalize_http_like_value(&normalized).parse().ok()
}
fn is_stable_calendar_release(version: &str) -> bool {
let mut parts = version.split('.');
let (Some(year), Some(month), Some(day)) = (parts.next(), parts.next(), parts.next()) else {
return false;
};
if parts.next().is_some() {
return false;
}
let (Ok(year), Ok(month), Ok(day)) = (
year.parse::<i32>(),
month.parse::<u32>(),
day.parse::<u32>(),
) else {
return false;
};
NaiveDate::from_ymd_opt(year, month, day).is_some()
}
fn sentry_release_for_version(version: &'static str) -> Option<Cow<'static, str>> {
is_stable_calendar_release(version).then(|| version.into())
}
fn sentry_release_kind(version: &str) -> &'static str {
if is_stable_calendar_release(version) {
"stable"
} else if version.contains('-') {
"prerelease"
} else {
"internal"
}
}
/// Initialize Sentry if the user has opted in to crash reporting.
/// Returns `true` if Sentry was initialized, `false` if skipped.
pub fn init_sentry_from_settings() -> bool {
@@ -61,9 +98,10 @@ pub fn init_sentry_from_settings() -> bool {
};
let anonymous_id = settings.anonymous_id.unwrap_or_default();
let build_version = env!("CARGO_PKG_VERSION");
let guard = sentry::init(sentry::ClientOptions {
dsn: Some(dsn),
release: Some(env!("CARGO_PKG_VERSION").into()),
release: sentry_release_for_version(build_version),
send_default_pii: false,
before_send: Some(std::sync::Arc::new(|mut event| {
if let Some(ref mut msg) = event.message {
@@ -79,6 +117,8 @@ pub fn init_sentry_from_settings() -> bool {
id: Some(anonymous_id),
..Default::default()
}));
scope.set_tag("tolaria.build_version", build_version);
scope.set_tag("tolaria.release_kind", sentry_release_kind(build_version));
});
*SENTRY_GUARD.lock().unwrap() = Some(guard);
@@ -160,4 +200,26 @@ mod tests {
// Without SENTRY_DSN env var set at compile time, init should return false
assert!(!init_sentry_from_settings());
}
#[test]
fn test_sentry_release_for_stable_calendar_version() {
assert!(sentry_release_for_version("2026.4.28").is_some());
}
#[test]
fn test_sentry_release_for_alpha_version_is_none() {
assert!(sentry_release_for_version("2026.4.28-alpha.7").is_none());
}
#[test]
fn test_sentry_release_for_local_dev_version_is_none() {
assert!(sentry_release_for_version("0.1.0").is_none());
}
#[test]
fn test_sentry_release_kind_classifies_versions() {
assert_eq!(sentry_release_kind("2026.4.28"), "stable");
assert_eq!(sentry_release_kind("2026.4.28-alpha.7"), "prerelease");
assert_eq!(sentry_release_kind("0.1.0"), "internal");
}
}

View File

@@ -58,6 +58,16 @@ _organized: true
This file is only a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
";
const GEMINI_MD_SHIM: &str = "---
type: Note
_organized: true
---
@AGENTS.md
This file is only a Gemini CLI compatibility shim. Keep shared agent instructions in `AGENTS.md`.
";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AiGuidanceFileState {
@@ -71,9 +81,16 @@ pub enum AiGuidanceFileState {
pub struct VaultAiGuidanceStatus {
pub agents_state: AiGuidanceFileState,
pub claude_state: AiGuidanceFileState,
pub gemini_state: AiGuidanceFileState,
pub can_restore: bool,
}
struct GuidancePaths {
agents: PathBuf,
claude: PathBuf,
gemini: PathBuf,
}
#[derive(Debug, Default)]
struct LegacyAgentsMigrationOutcome {
copied_to_root: bool,
@@ -109,6 +126,10 @@ fn matches_claude_shim(content: &str) -> bool {
|| trimmed == CLAUDE_MD_SHIM.trim()
}
fn matches_gemini_shim(content: &str) -> bool {
content.trim() == GEMINI_MD_SHIM.trim()
}
fn claude_shim_can_be_replaced(path: &Path) -> bool {
!path.exists() || {
let content = read_file_or_empty(path);
@@ -116,6 +137,13 @@ fn claude_shim_can_be_replaced(path: &Path) -> bool {
}
}
fn gemini_shim_can_be_replaced(path: &Path) -> bool {
!path.exists() || {
let content = read_file_or_empty(path);
content.trim().is_empty() || matches_gemini_shim(&content)
}
}
fn sync_managed_file(
path: &Path,
content: &str,
@@ -150,9 +178,12 @@ fn classify_guidance_file(
AiGuidanceFileState::Custom
}
fn guidance_paths(vault_path: &Path) -> (PathBuf, PathBuf) {
let vault = vault_path;
(vault.join("AGENTS.md"), vault.join("CLAUDE.md"))
fn guidance_paths(vault_path: &Path) -> GuidancePaths {
GuidancePaths {
agents: vault_path.join("AGENTS.md"),
claude: vault_path.join("CLAUDE.md"),
gemini: vault_path.join("GEMINI.md"),
}
}
fn classify_agents_file(path: &Path) -> AiGuidanceFileState {
@@ -167,6 +198,10 @@ fn classify_claude_file(path: &Path) -> AiGuidanceFileState {
classify_guidance_file(path, matches_claude_shim, claude_shim_can_be_replaced)
}
fn classify_gemini_file(path: &Path) -> AiGuidanceFileState {
classify_guidance_file(path, matches_gemini_shim, gemini_shim_can_be_replaced)
}
fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
matches!(
state,
@@ -175,29 +210,43 @@ fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
}
fn build_ai_guidance_status(vault_path: &Path) -> VaultAiGuidanceStatus {
let (agents_path, claude_path) = guidance_paths(vault_path);
let agents_state = classify_agents_file(&agents_path);
let claude_state = classify_claude_file(&claude_path);
let paths = guidance_paths(vault_path);
let agents_state = classify_agents_file(&paths.agents);
let claude_state = classify_claude_file(&paths.claude);
let gemini_state = classify_gemini_file(&paths.gemini);
VaultAiGuidanceStatus {
agents_state,
claude_state,
gemini_state,
can_restore: guidance_file_needs_restore(agents_state)
|| guidance_file_needs_restore(claude_state),
|| guidance_file_needs_restore(claude_state)
|| guidance_file_needs_restore(gemini_state),
}
}
fn sync_claude_shim_file(vault_path: &Path) -> Result<bool, String> {
let (_, claude_path) = guidance_paths(vault_path);
sync_managed_file(&claude_path, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
let paths = guidance_paths(vault_path);
sync_managed_file(&paths.claude, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
}
fn sync_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
fn sync_gemini_shim_file(vault_path: &Path) -> Result<bool, String> {
let paths = guidance_paths(vault_path);
sync_managed_file(&paths.gemini, GEMINI_MD_SHIM, gemini_shim_can_be_replaced)
}
fn sync_required_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
let wrote_agents = sync_default_agents_file(vault_path)?;
let wrote_claude = sync_claude_shim_file(vault_path)?;
Ok(wrote_agents || wrote_claude)
}
fn sync_all_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
let wrote_required = sync_required_ai_guidance_files(vault_path)?;
let wrote_gemini = sync_gemini_shim_file(vault_path)?;
Ok(wrote_required || wrote_gemini)
}
fn migrate_legacy_agents_file(
root_agents: &Path,
config_agents: &Path,
@@ -241,8 +290,8 @@ fn cleanup_empty_config_dir(vault: &Path) -> Result<bool, String> {
}
pub(super) fn sync_default_agents_file(vault_path: &Path) -> Result<bool, String> {
let (agents_path, _) = guidance_paths(vault_path);
sync_managed_file(&agents_path, AGENTS_MD, root_agents_can_be_replaced)
let paths = guidance_paths(vault_path);
sync_managed_file(&paths.agents, AGENTS_MD, root_agents_can_be_replaced)
}
pub fn get_ai_guidance_status(
@@ -255,7 +304,7 @@ pub fn restore_ai_guidance_files(
vault_path: impl AsRef<str>,
) -> Result<VaultAiGuidanceStatus, String> {
let vault_path = Path::new(vault_path.as_ref());
sync_ai_guidance_files(vault_path)?;
sync_all_ai_guidance_files(vault_path)?;
Ok(build_ai_guidance_status(vault_path))
}
@@ -263,7 +312,7 @@ pub fn restore_ai_guidance_files(
/// Also seeds Tolaria-managed root type definitions used by repair/bootstrap flows.
pub fn seed_config_files(vault_path: impl AsRef<str>) {
let vault_path = Path::new(vault_path.as_ref());
if sync_ai_guidance_files(vault_path).unwrap_or(false) {
if sync_required_ai_guidance_files(vault_path).unwrap_or(false) {
log::info!("Seeded vault AI guidance files at vault root");
}
@@ -307,7 +356,7 @@ pub fn migrate_agents_md(vault_path: impl AsRef<str>) {
log::info!("Removed empty config/ directory");
}
let _ = sync_ai_guidance_files(vault);
let _ = sync_required_ai_guidance_files(vault);
}
/// Repair config files: ensure `AGENTS.md` at vault root and root type definitions.
@@ -320,7 +369,7 @@ pub fn repair_config_files(vault_path: impl AsRef<str>) -> Result<String, String
migrate_legacy_agents_file(&root_agents, &config_agents)?;
let _ = cleanup_empty_config_dir(vault)?;
sync_ai_guidance_files(vault)?;
sync_required_ai_guidance_files(vault)?;
write_if_missing(&vault.join("type.md"), TYPE_TYPE_DEFINITION)?;
write_if_missing(&vault.join("note.md"), NOTE_TYPE_DEFINITION)?;
@@ -355,6 +404,10 @@ mod tests {
fs::write(vault.join("CLAUDE.md"), content).unwrap();
}
fn write_root_gemini(vault: &Path, content: &str) {
fs::write(vault.join("GEMINI.md"), content).unwrap();
}
fn write_legacy_agents(vault: &Path, content: &str) {
fs::write(config_dir(vault).join("agents.md"), content).unwrap();
}
@@ -367,6 +420,10 @@ mod tests {
fs::read_to_string(vault.join("CLAUDE.md")).unwrap()
}
fn read_root_gemini(vault: &Path) -> String {
fs::read_to_string(vault.join("GEMINI.md")).unwrap()
}
type VaultOperation = fn(&Path);
fn run_seed(vault: &Path) {
@@ -454,15 +511,52 @@ mod tests {
assert!(content.contains(expected_root_text));
}
fn assert_required_agents_file_seeded(vault: &Path) {
assert!(vault.join("AGENTS.md").exists());
assert!(read_root_agents(vault).contains("Tolaria Vault"));
}
fn assert_required_guidance_shims_seeded(vault: &Path) {
assert_eq!(read_root_claude(vault), CLAUDE_MD_SHIM);
assert!(!vault.join("GEMINI.md").exists());
}
fn assert_required_guidance_files_seeded(vault: &Path) {
assert_required_agents_file_seeded(vault);
assert_required_guidance_shims_seeded(vault);
}
fn assert_root_type_definitions_seeded(vault: &Path) {
assert!(vault.join("type.md").exists());
assert!(vault.join("note.md").exists());
assert!(!vault.join("config").exists());
}
fn assert_type_definition_content(vault: &Path) {
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert_type_definition_body(&type_content);
assert_note_definition_body(&note_content);
}
fn assert_type_definition_body(type_content: &str) {
assert!(type_content.contains("type: Type"));
assert!(type_content.contains("# Type"));
assert!(type_content.contains("visible: false"));
}
fn assert_note_definition_body(note_content: &str) {
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("# Note"));
}
#[test]
fn test_seed_config_files_creates_guidance_files_at_root() {
let (_dir, vault) = create_vault();
seed_config_files(vault.to_str().unwrap());
assert!(vault.join("AGENTS.md").exists());
assert!(read_root_agents(&vault).contains("Tolaria Vault"));
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
assert_required_guidance_files_seeded(&vault);
}
#[test]
@@ -471,16 +565,8 @@ mod tests {
seed_config_files(vault.to_str().unwrap());
assert!(vault.join("type.md").exists());
assert!(vault.join("note.md").exists());
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert!(type_content.contains("type: Type"));
assert!(type_content.contains("# Type"));
assert!(type_content.contains("visible: false"));
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("# Note"));
assert!(!vault.join("config").exists());
assert_root_type_definitions_seeded(&vault);
assert_type_definition_content(&vault);
}
#[test]
@@ -604,20 +690,12 @@ mod tests {
let msg = repair_config_files(vault.to_str().unwrap()).unwrap();
assert_eq!(msg, "Config files repaired");
assert!(vault.join("AGENTS.md").exists());
assert!(vault.join("CLAUDE.md").exists());
assert!(vault.join("type.md").exists());
assert!(vault.join("note.md").exists());
assert!(!vault.join("config").exists());
let agents = read_root_agents(&vault);
assert!(agents.contains("Tolaria Vault"));
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
assert!(type_content.contains("# Type"));
assert!(type_content.contains("visible: false"));
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("general-purpose document"));
assert_required_guidance_files_seeded(&vault);
assert_root_type_definitions_seeded(&vault);
assert_type_definition_content(&vault);
assert!(fs::read_to_string(vault.join("note.md"))
.unwrap()
.contains("general-purpose document"));
}
#[test]
@@ -656,9 +734,15 @@ mod tests {
write_root_claude(&vault, "");
let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap();
assert_eq!(status.agents_state, AiGuidanceFileState::Custom);
assert_eq!(status.claude_state, AiGuidanceFileState::Broken);
assert!(status.can_restore);
assert_eq!(
status,
VaultAiGuidanceStatus {
agents_state: AiGuidanceFileState::Custom,
claude_state: AiGuidanceFileState::Broken,
gemini_state: AiGuidanceFileState::Missing,
can_restore: true,
}
);
}
#[test]
@@ -668,11 +752,18 @@ mod tests {
write_root_claude(&vault, "");
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
assert_eq!(status.agents_state, AiGuidanceFileState::Custom);
assert_eq!(status.claude_state, AiGuidanceFileState::Managed);
assert!(!status.can_restore);
assert_eq!(
status,
VaultAiGuidanceStatus {
agents_state: AiGuidanceFileState::Custom,
claude_state: AiGuidanceFileState::Managed,
gemini_state: AiGuidanceFileState::Managed,
can_restore: false,
}
);
assert!(read_root_agents(&vault).contains("Custom Agent Config"));
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
assert_eq!(read_root_gemini(&vault), GEMINI_MD_SHIM);
}
#[test]
@@ -681,8 +772,28 @@ mod tests {
write_root_agents(&vault, "");
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
assert_eq!(status.agents_state, AiGuidanceFileState::Managed);
assert_eq!(status.claude_state, AiGuidanceFileState::Managed);
assert_eq!(
status,
VaultAiGuidanceStatus {
agents_state: AiGuidanceFileState::Managed,
claude_state: AiGuidanceFileState::Managed,
gemini_state: AiGuidanceFileState::Managed,
can_restore: false,
}
);
}
#[test]
fn test_restore_ai_guidance_files_preserves_custom_gemini() {
let (_dir, vault) = create_vault();
write_root_agents(&vault, AGENTS_MD);
write_root_claude(&vault, CLAUDE_MD_SHIM);
write_root_gemini(&vault, "# Custom Gemini instructions\nDo not overwrite\n");
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
assert_eq!(status.gemini_state, AiGuidanceFileState::Custom);
assert!(!status.can_restore);
assert!(read_root_gemini(&vault).contains("Custom Gemini instructions"));
}
}

View File

@@ -1,7 +1,8 @@
use chrono::{DateTime, Duration, Months, NaiveDate, NaiveDateTime, Utc};
use regex::RegexBuilder;
use regex::{Regex, RegexBuilder};
use serde::de::{self, MapAccess, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::cmp::Ordering;
use std::fmt;
use std::fs;
use std::path::Path;
@@ -15,6 +16,8 @@ pub struct ViewDefinition {
pub icon: Option<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub order: Option<i64>,
#[serde(default)]
pub sort: Option<String>,
#[serde(
@@ -254,10 +257,19 @@ pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
}
}
views.sort_by(|a, b| a.filename.cmp(&b.filename));
views.sort_by(compare_views);
views
}
fn compare_views(left: &ViewFile, right: &ViewFile) -> Ordering {
let order = left
.definition
.order
.unwrap_or(i64::MAX)
.cmp(&right.definition.order.unwrap_or(i64::MAX));
order.then_with(|| left.filename.cmp(&right.filename))
}
/// Save a view definition as YAML to `vault_path/views/{filename}`.
pub fn save_view(
vault_path: &Path,
@@ -422,120 +434,157 @@ fn supports_regex(op: &FilterOp) -> bool {
)
}
enum ConditionField<'a> {
Scalar(Option<String>),
Relationship(&'a [String]),
}
fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
let field = cond.field.as_str();
let mut relationship_values: Option<&[String]> = None;
// Boolean fields
match field {
"archived" => return evaluate_bool_field(entry.archived, &cond.op, &cond.value),
"favorite" => return evaluate_bool_field(entry.favorite, &cond.op, &cond.value),
_ => {}
if let Some(result) = evaluate_condition_bool_field(field, entry, &cond.op, &cond.value) {
return result;
}
// String/option fields
let field_value: Option<String> = match field {
"type" | "isA" => entry.is_a.clone(),
"status" => entry.status.clone(),
"title" => Some(entry.title.clone()),
"body" => Some(entry.snippet.clone()),
_ => {
// Check properties first, then relationships
if let Some(prop) = entry.properties.get(field) {
match prop {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
_ => None,
}
} else if let Some(rels) = entry.relationships.get(field) {
relationship_values = Some(rels);
None
} else {
None
}
}
};
let field_value = resolve_condition_field(field, entry);
let cond_value = cond.value.as_ref().and_then(yaml_value_to_string);
let regex = if cond.regex && supports_regex(&cond.op) {
cond_value.as_deref().and_then(build_regex)
} else {
None
};
let regex = condition_regex(cond, cond_value.as_deref());
if cond.regex && supports_regex(&cond.op) && regex.is_none() {
return false;
}
if let Some(re) = regex.as_ref() {
let matched = if let Some(prop) = field_value.as_deref() {
re.is_match(prop)
} else if let Some(rels) = relationship_values {
rels.iter().any(|item| {
relationship_candidates(item)
.into_iter()
.any(|candidate| re.is_match(&candidate))
})
} else {
false
};
return match cond.op {
FilterOp::Contains | FilterOp::Equals => matched,
FilterOp::NotContains | FilterOp::NotEquals => !matched,
_ => false,
};
return evaluate_regex_condition(&cond.op, &field_value, re);
}
if let Some(rels) = relationship_values {
return evaluate_relationship_op(&cond.op, rels, &cond.value);
match field_value {
ConditionField::Relationship(rels) => evaluate_relationship_op(&cond.op, rels, &cond.value),
ConditionField::Scalar(value) => evaluate_scalar_op(
&cond.op,
value.as_deref(),
cond_value.as_deref(),
&cond.value,
),
}
}
match cond.op {
FilterOp::Equals => match (&field_value, &cond_value) {
fn evaluate_condition_bool_field(
field: &str,
entry: &VaultEntry,
op: &FilterOp,
value: &Option<serde_yaml::Value>,
) -> Option<bool> {
match field {
"archived" => Some(evaluate_bool_field(entry.archived, op, value)),
"favorite" => Some(evaluate_bool_field(entry.favorite, op, value)),
_ => None,
}
}
fn resolve_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionField<'a> {
match field {
"type" | "isA" => ConditionField::Scalar(entry.is_a.clone()),
"status" => ConditionField::Scalar(entry.status.clone()),
"title" => ConditionField::Scalar(Some(entry.title.clone())),
"body" => ConditionField::Scalar(Some(entry.snippet.clone())),
_ => resolve_dynamic_condition_field(field, entry),
}
}
fn resolve_dynamic_condition_field<'a>(field: &str, entry: &'a VaultEntry) -> ConditionField<'a> {
if let Some(prop) = entry.properties.get(field) {
return ConditionField::Scalar(json_scalar_to_string(prop));
}
if let Some(relationships) = entry.relationships.get(field) {
return ConditionField::Relationship(relationships);
}
ConditionField::Scalar(None)
}
fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(value) => Some(value.clone()),
serde_json::Value::Number(value) => Some(value.to_string()),
serde_json::Value::Bool(value) => Some(value.to_string()),
_ => None,
}
}
fn condition_regex(cond: &FilterCondition, cond_value: Option<&str>) -> Option<Regex> {
if cond.regex && supports_regex(&cond.op) {
cond_value.and_then(build_regex)
} else {
None
}
}
fn evaluate_regex_condition(op: &FilterOp, field: &ConditionField<'_>, regex: &Regex) -> bool {
let matched = match field {
ConditionField::Scalar(Some(value)) => regex.is_match(value),
ConditionField::Relationship(values) => values.iter().any(|item| {
relationship_candidates(item)
.into_iter()
.any(|candidate| regex.is_match(&candidate))
}),
ConditionField::Scalar(None) => false,
};
match op {
FilterOp::Contains | FilterOp::Equals => matched,
FilterOp::NotContains | FilterOp::NotEquals => !matched,
_ => false,
}
}
fn evaluate_scalar_op(
op: &FilterOp,
field_value: Option<&str>,
cond_value: Option<&str>,
raw_value: &Option<serde_yaml::Value>,
) -> bool {
match op {
FilterOp::Equals => match (field_value, cond_value) {
(Some(f), Some(v)) => f.eq_ignore_ascii_case(v),
(None, None) => true,
_ => false,
},
FilterOp::NotEquals => match (&field_value, &cond_value) {
FilterOp::NotEquals => match (field_value, cond_value) {
(Some(f), Some(v)) => !f.eq_ignore_ascii_case(v),
(None, None) => false,
_ => true,
},
FilterOp::Contains => match (&field_value, &cond_value) {
FilterOp::Contains => match (field_value, cond_value) {
(Some(f), Some(v)) => f.to_lowercase().contains(&v.to_lowercase()),
_ => false,
},
FilterOp::NotContains => match (&field_value, &cond_value) {
FilterOp::NotContains => match (field_value, cond_value) {
(Some(f), Some(v)) => !f.to_lowercase().contains(&v.to_lowercase()),
(None, _) => true,
_ => true,
},
FilterOp::AnyOf => {
let values = cond
.value
let values = raw_value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default();
match &field_value {
match field_value {
Some(f) => values.iter().any(|v| f.eq_ignore_ascii_case(v)),
None => false,
}
}
FilterOp::NoneOf => {
let values = cond
.value
let values = raw_value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default();
match &field_value {
match field_value {
Some(f) => !values.iter().any(|v| f.eq_ignore_ascii_case(v)),
None => true,
}
}
FilterOp::IsEmpty => field_value.as_deref().map_or(true, |s| s.is_empty()),
FilterOp::IsNotEmpty => field_value.as_deref().is_some_and(|s| !s.is_empty()),
FilterOp::Before => match (&field_value, &cond_value) {
FilterOp::IsEmpty => field_value.map_or(true, str::is_empty),
FilterOp::IsNotEmpty => field_value.is_some_and(|s| !s.is_empty()),
FilterOp::Before => match (field_value, cond_value) {
(Some(f), Some(v)) => match (
parse_date_filter_timestamp(f, Utc::now()),
parse_date_filter_timestamp(v, Utc::now()),
@@ -545,7 +594,7 @@ fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
},
_ => false,
},
FilterOp::After => match (&field_value, &cond_value) {
FilterOp::After => match (field_value, cond_value) {
(Some(f), Some(v)) => match (
parse_date_filter_timestamp(f, Utc::now()),
parse_date_filter_timestamp(v, Utc::now()),
@@ -581,80 +630,61 @@ fn evaluate_relationship_op(
) -> bool {
match op {
FilterOp::Contains => {
let target = value.as_ref().and_then(yaml_value_to_string);
match target {
Some(t) => {
let t_stem = wikilink_stem(&t).to_lowercase();
rels.iter()
.any(|r| wikilink_stem(r).to_lowercase() == t_stem)
}
None => false,
}
relationship_target(value).is_some_and(|target| relationship_contains(rels, &target))
}
FilterOp::NotContains => {
let target = value.as_ref().and_then(yaml_value_to_string);
match target {
Some(t) => {
let t_stem = wikilink_stem(&t).to_lowercase();
!rels
.iter()
.any(|r| wikilink_stem(r).to_lowercase() == t_stem)
}
None => true,
}
}
FilterOp::AnyOf => {
let values = value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default();
rels.iter().any(|r| {
let r_stem = wikilink_stem(r).to_lowercase();
values
.iter()
.any(|v| wikilink_stem(v).to_lowercase() == r_stem)
})
}
FilterOp::NoneOf => {
let values = value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default();
!rels.iter().any(|r| {
let r_stem = wikilink_stem(r).to_lowercase();
values
.iter()
.any(|v| wikilink_stem(v).to_lowercase() == r_stem)
})
relationship_target(value).map_or(true, |target| !relationship_contains(rels, &target))
}
FilterOp::AnyOf => relationship_any_of(rels, &relationship_values(value)),
FilterOp::NoneOf => !relationship_any_of(rels, &relationship_values(value)),
FilterOp::IsEmpty => rels.is_empty(),
FilterOp::IsNotEmpty => !rels.is_empty(),
FilterOp::Equals => {
let target = value.as_ref().and_then(yaml_value_to_string);
match target {
Some(t) => {
rels.len() == 1
&& wikilink_stem(&rels[0]).to_lowercase()
== wikilink_stem(&t).to_lowercase()
}
None => rels.is_empty(),
}
}
FilterOp::NotEquals => {
let target = value.as_ref().and_then(yaml_value_to_string);
match target {
Some(t) => {
rels.len() != 1
|| wikilink_stem(&rels[0]).to_lowercase()
!= wikilink_stem(&t).to_lowercase()
}
None => !rels.is_empty(),
}
}
FilterOp::Equals => relationship_target(value).map_or_else(
|| rels.is_empty(),
|target| relationship_equals(rels, &target),
),
FilterOp::NotEquals => relationship_target(value).map_or_else(
|| !rels.is_empty(),
|target| !relationship_equals(rels, &target),
),
_ => false,
}
}
fn relationship_target(value: &Option<serde_yaml::Value>) -> Option<String> {
value.as_ref().and_then(yaml_value_to_string)
}
fn relationship_values(value: &Option<serde_yaml::Value>) -> Vec<String> {
value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default()
}
fn normalized_wikilink_stem(value: &str) -> String {
wikilink_stem(value).to_lowercase()
}
fn relationship_contains(rels: &[String], target: &str) -> bool {
let target_stem = normalized_wikilink_stem(target);
rels.iter()
.any(|relationship| normalized_wikilink_stem(relationship) == target_stem)
}
fn relationship_any_of(rels: &[String], values: &[String]) -> bool {
rels.iter().any(|relationship| {
let relationship_stem = normalized_wikilink_stem(relationship);
values
.iter()
.any(|value| normalized_wikilink_stem(value) == relationship_stem)
})
}
fn relationship_equals(rels: &[String], target: &str) -> bool {
rels.len() == 1 && relationship_contains(rels, target)
}
fn yaml_value_to_string(v: &serde_yaml::Value) -> Option<String> {
match v {
serde_yaml::Value::String(s) => Some(s.clone()),
@@ -686,6 +716,7 @@ mod tests {
name: name.to_string(),
icon: None,
color: None,
order: None,
sort: None,
list_properties_display: Vec::new(),
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
@@ -963,6 +994,34 @@ filters:
assert_eq!(views[1].definition.name, "Beta");
}
#[test]
fn test_scan_views_sorts_by_persisted_order_then_filename() {
let dir = tempfile::TempDir::new().unwrap();
let views_dir = dir.path().join("views");
fs::create_dir_all(&views_dir).unwrap();
let alpha = "name: Alpha\norder: 20\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
let beta = "name: Beta\norder: 10\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
let gamma = "name: Gamma\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
fs::write(views_dir.join("alpha.yml"), alpha).unwrap();
fs::write(views_dir.join("beta.yml"), beta).unwrap();
fs::write(views_dir.join("gamma.yml"), gamma).unwrap();
let views = scan_views(dir.path());
assert_eq!(
views
.iter()
.map(|view| (view.filename.as_str(), view.definition.order))
.collect::<Vec<_>>(),
vec![
("beta.yml", Some(10)),
("alpha.yml", Some(20)),
("gamma.yml", None),
]
);
}
#[test]
fn test_migrate_views_from_old_location() {
let dir = tempfile::TempDir::new().unwrap();

View File

@@ -72,7 +72,9 @@ import { useConflictFlow } from './hooks/useConflictFlow'
import { useAppSave } from './hooks/useAppSave'
import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi'
import { useVaultBridge } from './hooks/useVaultBridge'
import { useSavedViewOrdering } from './hooks/useSavedViewOrdering'
import { createViewFilename } from './utils/viewFilename'
import { nextViewOrder } from './utils/viewOrdering'
import type { CommitDiffRequest } from './hooks/useDiffMode'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
@@ -453,7 +455,16 @@ function App() {
trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length })
}
}, [vault.entries.length, gitRepoState, resolvedPath])
const { mcpStatus, connectMcp, disconnectMcp } = useMcpStatus(resolvedPath, setToastMessage)
const {
mcpStatus,
connectMcp,
disconnectMcp,
mcpConfigSnippet,
mcpConfigLoading,
mcpConfigError,
loadMcpConfigSnippet,
copyMcpConfig,
} = useMcpStatus(resolvedPath, setToastMessage)
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
const loadVaultModifiedFiles = vault.loadModifiedFiles
const refreshGitRemoteStatus = gitRemoteStatus.refreshRemoteStatus
@@ -493,6 +504,14 @@ function App() {
}
}, [disconnectMcp])
const handleCopyMcpConfig = useCallback(() => {
void copyMcpConfig()
}, [copyMcpConfig])
const handleLoadMcpConfigSnippet = useCallback(() => {
void loadMcpConfigSnippet().catch(() => undefined)
}, [loadMcpConfigSnippet])
// Detect external file renames on window focus
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
useEffect(() => {
@@ -1059,7 +1078,9 @@ function App() {
const filename = editing
? editing.filename
: createViewFilename(definition.name, vault.views.map((view) => view.filename))
const nextDefinition = editing ? { ...editing.definition, ...definition } : definition
const nextDefinition = editing
? { ...editing.definition, ...definition }
: { ...definition, order: nextViewOrder(vault.views) }
const target = isTauri() ? invoke : mockInvoke
try {
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition })
@@ -1302,6 +1323,14 @@ function App() {
? 'Customize All Notes columns'
: 'Customize Inbox columns'
}, [effectiveSelection, vault.views])
const viewOrdering = useSavedViewOrdering({
views: vault.views,
selection: effectiveSelection,
vaultPath: resolvedPath,
reloadViews: vault.reloadViews,
loadModifiedFiles: vault.loadModifiedFiles,
onToast: setToastMessage,
})
const activeNoteModified = useMemo(
() => vault.modifiedFiles.some((file) => file.path === notes.activeTabPath),
[notes.activeTabPath, vault.modifiedFiles],
@@ -1403,6 +1432,11 @@ function App() {
onToggleRawEditor: toggleRawEditorCommand,
noteLayout,
onToggleNoteLayout: toggleNoteLayout,
selectedViewName: viewOrdering.selectedViewName,
onMoveSelectedViewUp: viewOrdering.onMoveSelectedViewUp,
onMoveSelectedViewDown: viewOrdering.onMoveSelectedViewDown,
canMoveSelectedViewUp: viewOrdering.canMoveSelectedViewUp,
canMoveSelectedViewDown: viewOrdering.canMoveSelectedViewDown,
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
onSelect: handleSetSelection,
@@ -1553,7 +1587,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} />
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onReorderViews={viewOrdering.onReorderViews} onMoveView={viewOrdering.onMoveView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -1601,6 +1635,7 @@ function App() {
onInitializeProperties={handleInitializeProperties}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
onCopyMcpConfig={handleCopyMcpConfig}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
@@ -1685,7 +1720,7 @@ function App() {
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onDisconnect={handleDisconnectMcp} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} manualConfigSnippet={mcpConfigSnippet} manualConfigLoading={mcpConfigLoading} manualConfigError={mcpConfigError} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onCopyManualConfig={handleCopyMcpConfig} onDisconnect={handleDisconnectMcp} onLoadManualConfig={handleLoadMcpConfigSnippet} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
{deleteActions.confirmDelete && (
<ConfirmDeleteDialog

View File

@@ -85,6 +85,15 @@ describe('AiPanel', () => {
expect(mockClearConversation).toHaveBeenCalledOnce()
})
it('copies the MCP config from the AI panel header action', () => {
const onCopyMcpConfig = vi.fn()
render(<AiPanel onClose={vi.fn()} onCopyMcpConfig={onCopyMcpConfig} vaultPath="/tmp/vault" />)
fireEvent.click(screen.getByRole('button', { name: 'Copy MCP config' }))
expect(onCopyMcpConfig).toHaveBeenCalledOnce()
})
it('renders empty state without context', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('Open a note, then ask Claude Code about it')).toBeTruthy()

View File

@@ -21,6 +21,7 @@ export type { AiAgentMessage } from '../hooks/useCliAiAgent'
interface AiPanelProps {
onClose: () => void
onCopyMcpConfig?: () => void
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
@@ -42,6 +43,7 @@ interface AiPanelProps {
interface AiPanelViewProps {
controller: AiPanelController
onClose: () => void
onCopyMcpConfig?: () => void
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
@@ -58,6 +60,7 @@ function readinessFromReadyFlag(ready: boolean | undefined): AiAgentReadiness {
export function AiPanelView({
controller,
onClose,
onCopyMcpConfig,
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
@@ -113,6 +116,7 @@ export function AiPanelView({
agentLabel={agentLabel}
agentReadiness={defaultAiAgentReadiness}
onClose={onClose}
onCopyMcpConfig={onCopyMcpConfig}
onNewChat={handleNewChat}
/>
{activeEntry && (
@@ -144,6 +148,7 @@ export function AiPanelView({
export function AiPanel({
onClose,
onCopyMcpConfig,
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
@@ -183,6 +188,7 @@ export function AiPanel({
<AiPanelView
controller={controller}
onClose={onClose}
onCopyMcpConfig={onCopyMcpConfig}
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={providedDefaultAiAgent}

View File

@@ -1,6 +1,8 @@
import { useEffect, useRef } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { Copy } from 'lucide-react'
import { AiMessage } from './AiMessage'
import { Button } from '@/components/ui/button'
import { WikilinkChatInput } from './WikilinkChatInput'
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
import type { AiAgentMessage } from '../hooks/useCliAiAgent'
@@ -12,6 +14,7 @@ interface AiPanelHeaderProps {
agentLabel: string
agentReadiness: AiAgentReadiness
onClose: () => void
onCopyMcpConfig?: () => void
onNewChat: () => void
}
@@ -122,6 +125,7 @@ export function AiPanelHeader({
agentLabel,
agentReadiness,
onClose,
onCopyMcpConfig,
onNewChat,
}: AiPanelHeaderProps) {
return (
@@ -140,21 +144,39 @@ export function AiPanelHeader({
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ''}`}
</span>
</div>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
{onCopyMcpConfig ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onCopyMcpConfig}
aria-label="Copy MCP config"
title="Copy MCP config"
data-testid="ai-copy-mcp-config"
>
<Copy size={15} />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onNewChat}
aria-label="New AI chat"
title="New AI chat"
>
<Plus size={16} />
</button>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onClose}
aria-label="Close AI panel"
title="Close AI panel"
>
<X size={16} />
</button>
</Button>
</div>
)
}

View File

@@ -67,6 +67,7 @@ interface EditorProps {
onInitializeProperties?: (path: string) => void
showAIChat?: boolean
onToggleAIChat?: () => void
onCopyMcpConfig?: () => void
vaultPath?: string
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
@@ -338,6 +339,7 @@ function EditorLayout({
showDiffToggle,
showAIChat,
onToggleAIChat,
onCopyMcpConfig,
inspectorCollapsed,
onToggleInspector,
onNavigateWikilink,
@@ -400,6 +402,7 @@ function EditorLayout({
showDiffToggle: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
onCopyMcpConfig?: () => void
inspectorCollapsed: boolean
onToggleInspector: () => void
onNavigateWikilink: (target: string) => void
@@ -520,6 +523,7 @@ function EditorLayout({
noteListFilter={noteListFilter}
onToggleInspector={onToggleInspector}
onToggleAIChat={onToggleAIChat}
onCopyMcpConfig={onCopyMcpConfig}
onNavigateWikilink={onNavigateWikilink}
onViewCommitDiff={handleViewCommitDiff}
onUpdateFrontmatter={onUpdateFrontmatter}
@@ -551,6 +555,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
onCopyMcpConfig,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile,
onDeleteNote, onArchiveNote, onUnarchiveNote,
@@ -611,6 +616,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
showDiffToggle={showDiffToggle}
showAIChat={showAIChat}
onToggleAIChat={onToggleAIChat}
onCopyMcpConfig={onCopyMcpConfig}
inspectorCollapsed={inspectorCollapsed}
onToggleInspector={onToggleInspector}
onNavigateWikilink={onNavigateWikilink}

View File

@@ -25,6 +25,7 @@ interface EditorRightPanelProps {
noteListFilter?: { type: string | null; query: string }
onToggleInspector: () => void
onToggleAIChat?: () => void
onCopyMcpConfig?: () => void
onNavigateWikilink: (target: string) => void
onViewCommitDiff: (commitHash: string) => Promise<void>
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
@@ -48,6 +49,7 @@ export function EditorRightPanel({
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onCopyMcpConfig,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
onFileCreated, onFileModified, onVaultChanged,
locale,
@@ -87,6 +89,7 @@ export function EditorRightPanel({
<AiPanelView
controller={aiPanelController}
onClose={() => onToggleAIChat?.()}
onCopyMcpConfig={onCopyMcpConfig}
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={defaultAiAgent}

View File

@@ -82,6 +82,17 @@ function isSelectAllShortcut(event: React.KeyboardEvent<HTMLDivElement>) {
return event.key.toLowerCase() === 'a' && (event.metaKey || event.ctrlKey)
}
function isLineBreakShortcut(
event: React.KeyboardEvent<HTMLDivElement>,
isComposing: boolean,
) {
return event.key === 'Enter'
&& event.shiftKey
&& !isComposing
&& !event.nativeEvent.isComposing
&& event.keyCode !== 229
}
export const UNSUPPORTED_INLINE_PASTE_MESSAGE = 'Only text paste is supported in the AI composer right now.'
function hasUnsupportedClipboardPayload(clipboardData: DataTransfer) {
@@ -288,6 +299,12 @@ export function InlineWikilinkInput({
if (!isInsertBeforeInput(nativeEvent)) return
if (nativeEvent.inputType === 'insertLineBreak') {
nativeEvent.preventDefault()
insertTransferText('\n')
return
}
if (isPlainTextBeforeInput(nativeEvent)) {
nativeEvent.preventDefault()
insertTransferText(nativeEvent.data)
@@ -422,6 +439,12 @@ export function InlineWikilinkInput({
const submitValue = () =>
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!disabled && isLineBreakShortcut(event, isComposingRef.current)) {
event.preventDefault()
insertTransferText('\n')
return
}
if (isSelectAllShortcut(event)) {
event.preventDefault()
selectAllContent()

View File

@@ -203,7 +203,7 @@ export function InlineWikilinkEditorField({
contentEditable={!disabled}
suppressContentEditableWarning={true}
role="textbox"
aria-multiline="false"
aria-multiline="true"
aria-disabled={disabled || undefined}
aria-placeholder={placeholder}
data-testid={dataTestId}

View File

@@ -2,6 +2,20 @@ import { describe, it, expect, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { McpSetupDialog } from './McpSetupDialog'
const MANUAL_CONFIG = JSON.stringify({
mcpServers: {
tolaria: {
type: 'stdio',
command: 'node',
args: ['/Applications/Tolaria.app/Contents/Resources/mcp-server/index.js'],
env: {
VAULT_PATH: '/Users/luca/Laputa',
WS_UI_PORT: '9711',
},
},
},
}, null, 2)
describe('McpSetupDialog', () => {
it('renders the explicit setup flow without mutating config by default', () => {
render(
@@ -9,6 +23,7 @@ describe('McpSetupDialog', () => {
open={true}
status="not_installed"
busyAction={null}
manualConfigSnippet={MANUAL_CONFIG}
onClose={vi.fn()}
onConnect={vi.fn()}
onDisconnect={vi.fn()}
@@ -18,13 +33,16 @@ describe('McpSetupDialog', () => {
expect(screen.getByText('Set Up External AI Tools')).toBeInTheDocument()
expect(screen.getByText(/will not touch third-party config files until you confirm here/i)).toBeInTheDocument()
expect(screen.getByText(/requires Node.js 18\+ on PATH/i)).toBeInTheDocument()
expect(screen.getByText(/type: stdio/i)).toBeInTheDocument()
expect(screen.getByText(/VAULT_PATH/i)).toBeInTheDocument()
expect(screen.getByText(/WS_UI_PORT/i)).toBeInTheDocument()
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"type": "stdio"')
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"VAULT_PATH": "/Users/luca/Laputa"')
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"WS_UI_PORT": "9711"')
expect(screen.getAllByText('~/.claude.json')).toHaveLength(2)
expect(screen.getByText('~/.claude/mcp.json')).toBeInTheDocument()
expect(screen.getAllByText('~/.gemini/settings.json')).toHaveLength(2)
expect(screen.getAllByText('~/.config/mcp/mcp.json')).toHaveLength(2)
expect(screen.getByText(/picked up by other MCP-compatible tools/i)).toBeInTheDocument()
expect(screen.getByText(/Gemini CLI needs its own install and sign-in/i)).toBeInTheDocument()
expect(screen.getByText('GEMINI.md')).toBeInTheDocument()
expect(screen.getByTestId('mcp-setup-connect')).toHaveTextContent('Connect External AI Tools')
expect(screen.queryByTestId('mcp-setup-disconnect')).not.toBeInTheDocument()
})
@@ -49,6 +67,7 @@ describe('McpSetupDialog', () => {
it('routes actions through the dialog buttons', () => {
const onClose = vi.fn()
const onConnect = vi.fn()
const onCopyManualConfig = vi.fn()
const onDisconnect = vi.fn()
render(
@@ -58,16 +77,37 @@ describe('McpSetupDialog', () => {
busyAction={null}
onClose={onClose}
onConnect={onConnect}
onCopyManualConfig={onCopyManualConfig}
onDisconnect={onDisconnect}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
fireEvent.click(screen.getByTestId('mcp-copy-config'))
fireEvent.click(screen.getByTestId('mcp-setup-connect'))
fireEvent.click(screen.getByTestId('mcp-setup-disconnect'))
expect(onClose).toHaveBeenCalledOnce()
expect(onCopyManualConfig).toHaveBeenCalledOnce()
expect(onConnect).toHaveBeenCalledOnce()
expect(onDisconnect).toHaveBeenCalledOnce()
})
it('loads exact manual config when opened', () => {
const onLoadManualConfig = vi.fn()
render(
<McpSetupDialog
open={true}
status="not_installed"
busyAction={null}
onClose={vi.fn()}
onConnect={vi.fn()}
onDisconnect={vi.fn()}
onLoadManualConfig={onLoadManualConfig}
/>,
)
expect(onLoadManualConfig).toHaveBeenCalledOnce()
})
})

View File

@@ -1,4 +1,5 @@
import { ShieldCheck } from 'lucide-react'
import { useEffect } from 'react'
import { Copy, ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -14,6 +15,29 @@ interface McpSetupDialogProps {
open: boolean
status: McpStatus
busyAction: 'connect' | 'disconnect' | null
manualConfigError?: string | null
manualConfigLoading?: boolean
manualConfigSnippet?: string | null
onClose: () => void
onConnect: () => void
onCopyManualConfig?: () => void
onDisconnect: () => void
onLoadManualConfig?: () => void
}
interface ManualMcpConfigSectionProps {
error?: string | null
loading: boolean
onCopy?: () => void
snippet?: string | null
}
interface McpSetupActionsProps {
buttonsDisabled: boolean
connectBusy: boolean
disconnectBusy: boolean
primaryLabel: string
secondaryLabel: string | null
onClose: () => void
onConnect: () => void
onDisconnect: () => void
@@ -41,19 +65,100 @@ function actionCopy(status: McpStatus) {
}
}
function manualConfigText({ error, loading, snippet }: ManualMcpConfigSectionProps): string {
if (loading) return 'Loading exact MCP config...'
return error ?? snippet ?? 'Exact config is available after a vault is open.'
}
function ManualMcpConfigSection(props: ManualMcpConfigSectionProps) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="m-0 text-sm font-medium text-foreground">Manual MCP config</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={props.onCopy}
disabled={!props.onCopy || props.loading}
data-testid="mcp-copy-config"
>
<Copy size={14} />
Copy MCP config
</Button>
</div>
<pre
tabIndex={0}
className="max-h-48 overflow-auto rounded-md border border-border bg-background px-3 py-3 font-mono text-xs leading-5 text-foreground"
data-testid="mcp-config-snippet"
>
{manualConfigText(props)}
</pre>
</div>
)
}
function McpSetupActions({
buttonsDisabled,
connectBusy,
disconnectBusy,
primaryLabel,
secondaryLabel,
onClose,
onConnect,
onDisconnect,
}: McpSetupActionsProps) {
return (
<DialogFooter className="flex-row items-center justify-end gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={onClose} disabled={buttonsDisabled}>
Cancel
</Button>
{secondaryLabel ? (
<Button
type="button"
variant="destructive"
onClick={onDisconnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-disconnect"
>
{disconnectBusy ? 'Disconnecting…' : secondaryLabel}
</Button>
) : null}
<Button
type="button"
autoFocus
onClick={onConnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-connect"
>
{connectBusy ? 'Connecting…' : primaryLabel}
</Button>
</DialogFooter>
)
}
export function McpSetupDialog({
open,
status,
busyAction,
manualConfigError,
manualConfigLoading = false,
manualConfigSnippet,
onClose,
onConnect,
onCopyManualConfig,
onDisconnect,
onLoadManualConfig,
}: McpSetupDialogProps) {
const copy = actionCopy(status)
const connectBusy = busyAction === 'connect'
const disconnectBusy = busyAction === 'disconnect'
const buttonsDisabled = busyAction !== null || status === 'checking'
useEffect(() => {
if (open) onLoadManualConfig?.()
}, [open, onLoadManualConfig])
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[520px]" data-testid="mcp-setup-dialog">
@@ -75,46 +180,34 @@ export function McpSetupDialog({
<div className="rounded-md border border-border bg-muted/30 px-3 py-3 font-mono text-xs text-foreground">
<div>~/.claude.json</div>
<div>~/.claude/mcp.json</div>
<div>~/.gemini/settings.json</div>
<div>~/.cursor/mcp.json</div>
<div>~/.config/mcp/mcp.json</div>
</div>
<div className="rounded-md border border-border bg-background px-3 py-3 font-mono text-xs leading-5 text-foreground">
<div>type: stdio</div>
<div>command: node</div>
<div>args: &lt;Tolaria resources&gt;/mcp-server/index.js</div>
<div>VAULT_PATH: active vault</div>
<div>WS_UI_PORT: 9711</div>
</div>
<ManualMcpConfigSection
error={manualConfigError}
loading={manualConfigLoading}
onCopy={onCopyManualConfig}
snippet={manualConfigSnippet}
/>
<p>
Claude Code CLI reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.claude.json</code>, Cursor reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.cursor/mcp.json</code>, and the generic <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.config/mcp/mcp.json</code> path is picked up by other MCP-compatible tools. Cancel leaves all files untouched, reconnect is idempotent, and disconnect removes Tolaria&apos;s entry again.
Claude Code CLI reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.claude.json</code>, Gemini CLI reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.gemini/settings.json</code>, Cursor reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.cursor/mcp.json</code>, and the generic <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.config/mcp/mcp.json</code> path is picked up by other MCP-compatible tools. Cancel leaves all files untouched, reconnect is idempotent, and disconnect removes Tolaria&apos;s entry again.
</p>
<p>
Gemini CLI needs its own install and sign-in. Use Restore Tolaria AI Guidance when you want a vault-root <code className="rounded bg-muted px-1 py-0.5 text-xs">GEMINI.md</code> compatibility shim that points Gemini back to the shared <code className="rounded bg-muted px-1 py-0.5 text-xs">AGENTS.md</code> instructions without overwriting custom guidance.
</p>
</div>
<DialogFooter className="flex-row items-center justify-end gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={onClose} disabled={buttonsDisabled}>
Cancel
</Button>
{copy.secondaryLabel ? (
<Button
type="button"
variant="destructive"
onClick={onDisconnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-disconnect"
>
{disconnectBusy ? 'Disconnecting…' : copy.secondaryLabel}
</Button>
) : null}
<Button
type="button"
autoFocus
onClick={onConnect}
disabled={buttonsDisabled}
data-testid="mcp-setup-connect"
>
{connectBusy ? 'Connecting…' : copy.primaryLabel}
</Button>
</DialogFooter>
<McpSetupActions
buttonsDisabled={buttonsDisabled}
connectBusy={connectBusy}
disconnectBusy={disconnectBusy}
primaryLabel={copy.primaryLabel}
secondaryLabel={copy.secondaryLabel}
onClose={onClose}
onConnect={onConnect}
onDisconnect={onDisconnect}
/>
</DialogContent>
</Dialog>
)

View File

@@ -56,7 +56,7 @@ describe('NoteList virtualized datasets', () => {
expect(screen.getByText('Note 499')).toBeInTheDocument()
})
it('filters large datasets by search query', async () => {
it('filters large datasets by search query', { timeout: 15000 }, async () => {
vi.useFakeTimers()
try {
const entries = [

View File

@@ -54,6 +54,8 @@ describe('SettingsPanel', () => {
vi.clearAllMocks()
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true })
window.localStorage.clear()
document.documentElement.removeAttribute('data-theme')
document.documentElement.classList.remove('dark')
installPointerCapturePolyfill()
})
@@ -201,6 +203,21 @@ describe('SettingsPanel', () => {
}))
})
it('applies the selected dark color mode immediately while settings stays open', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByRole('radio', { name: 'Dark' }))
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
expect(document.documentElement).toHaveClass('dark')
expect(window.localStorage.getItem(THEME_MODE_STORAGE_KEY)).toBe('dark')
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
theme_mode: 'dark',
}))
})
it('preserves a saved dark color mode until changed', () => {
render(
<SettingsPanel

View File

@@ -28,9 +28,11 @@ import {
type UiLanguagePreference,
} from '../lib/i18n'
import {
applyThemeModeToDocument,
DEFAULT_THEME_MODE,
readStoredThemeMode,
type ThemeMode,
writeStoredThemeMode,
} from '../lib/themeMode'
import { normalizeReleaseChannel, serializeReleaseChannel, type ReleaseChannel } from '../lib/releaseChannel'
import { shouldHideGitignoredFiles } from '../lib/gitignoredVisibility'
@@ -203,6 +205,11 @@ function sanitizePositiveInteger(value: number | null | undefined, fallback: num
return Math.round(value)
}
function applyThemeModeSelection(value: ThemeMode): void {
if (typeof document !== 'undefined') applyThemeModeToDocument(document, value)
if (typeof window !== 'undefined') writeStoredThemeMode(window.localStorage, value)
}
export function SettingsPanel({
open,
settings,
@@ -279,6 +286,12 @@ function SettingsPanelInner({
onSave({ ...settings, hide_gitignored_files: value })
}, [onSave, settings, updateDraft])
const handleThemeModeChange = useCallback((value: ThemeMode) => {
updateDraft('themeMode', value)
applyThemeModeSelection(value)
onSave({ ...settings, theme_mode: value })
}, [onSave, settings, updateDraft])
const handleSave = useCallback(() => {
trackTelemetryConsentChange(settings.analytics_enabled === true, draft.analytics)
onSave(buildSettingsFromDraft(settings, draft))
@@ -344,7 +357,7 @@ function SettingsPanelInner({
releaseChannel={draft.releaseChannel}
setReleaseChannel={(value) => updateDraft('releaseChannel', value)}
themeMode={draft.themeMode}
setThemeMode={(value) => updateDraft('themeMode', value)}
setThemeMode={handleThemeModeChange}
uiLanguage={draft.uiLanguage}
setUiLanguage={(value) => updateDraft('uiLanguage', value)}
initialH1AutoRename={draft.initialH1AutoRename}

View File

@@ -1384,6 +1384,33 @@ describe('Sidebar', () => {
},
]
it('renders keyboard-accessible move buttons for saved views', () => {
const onMoveView = vi.fn()
render(
<Sidebar
entries={mockEntries}
selection={defaultSelection}
onSelect={() => {}}
views={mockViews}
onMoveView={onMoveView}
/>
)
const moveUpButtons = screen.getAllByTitle('Move view up')
const moveDownButtons = screen.getAllByTitle('Move view down')
expect(moveUpButtons[0]).toBeDisabled()
expect(moveUpButtons[1]).not.toBeDisabled()
expect(moveDownButtons[0]).not.toBeDisabled()
expect(moveDownButtons[1]).toBeDisabled()
fireEvent.click(moveUpButtons[1])
expect(onMoveView).toHaveBeenCalledWith('all-topics.yml', 'up')
fireEvent.click(moveDownButtons[0])
expect(onMoveView).toHaveBeenCalledWith('active-projects.yml', 'down')
})
it('shows note count chip for each view matching the filter results', () => {
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} />

View File

@@ -24,6 +24,7 @@ import {
import { useSidebarTypeInteractions } from './sidebar/useSidebarTypeInteractions'
import type { AppLocale } from '../lib/i18n'
import type { FolderFileActions } from '../hooks/useFileActions'
import type { ViewMoveDirection } from '../utils/viewOrdering'
interface SidebarProps {
entries: VaultEntry[]
@@ -43,6 +44,8 @@ interface SidebarProps {
onCreateView?: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
onReorderViews?: (orderedFilenames: string[]) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
folders?: FolderNode[]
onCreateFolder?: (name: string) => Promise<boolean> | boolean
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
@@ -68,6 +71,8 @@ interface SidebarNavigationProps extends Pick<
| 'onCreateView'
| 'onEditView'
| 'onDeleteView'
| 'onReorderViews'
| 'onMoveView'
| 'folders'
| 'onCreateFolder'
| 'onRenameFolder'
@@ -106,6 +111,8 @@ function SidebarNavigation({
onCreateView,
onEditView,
onDeleteView,
onReorderViews,
onMoveView,
folders = [],
onCreateFolder,
onRenameFolder,
@@ -170,6 +177,9 @@ function SidebarNavigation({
onCreateView={onCreateView}
onEditView={onEditView}
onDeleteView={onDeleteView}
onReorderViews={onReorderViews}
onMoveView={onMoveView}
sensors={sensors}
entries={entries}
locale={locale}
/>
@@ -232,6 +242,8 @@ export const Sidebar = memo(function Sidebar({
onCreateView,
onEditView,
onDeleteView,
onReorderViews,
onMoveView,
folders = [],
onCreateFolder,
onRenameFolder,
@@ -268,6 +280,7 @@ export const Sidebar = memo(function Sidebar({
const reordered = computeReorder(sectionIds, active.id as string, over.id as string)
if (reordered) onReorderSections?.(reordered.map((typeName, order) => ({ typeName, order })))
}, [sectionIds, onReorderSections])
const viewActions = { onCreateView, onEditView, onDeleteView, onReorderViews, onMoveView }
const sectionProps: SidebarSectionProps = {
entries,
@@ -291,9 +304,7 @@ export const Sidebar = memo(function Sidebar({
onSelectFavorite={onSelectFavorite}
onReorderFavorites={onReorderFavorites}
views={views}
onCreateView={onCreateView}
onEditView={onEditView}
onDeleteView={onDeleteView}
{...viewActions}
folders={folders}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}

View File

@@ -14,7 +14,9 @@ const state = vi.hoisted(() => ({
hoverGuardMock: vi.fn(),
imageDropState: { isDragOver: false },
linkActivationMock: vi.fn(),
personMentionCandidates: [] as Record<string, unknown>[],
wikilinkEntriesRef: { current: [] as VaultEntry[] },
wikilinkCandidates: [] as Record<string, unknown>[],
}))
vi.mock('@blocknote/react', () => ({
@@ -145,12 +147,12 @@ vi.mock('../utils/typeColors', () => ({
vi.mock('../utils/wikilinkSuggestions', () => ({
MIN_QUERY_LENGTH: 2,
deduplicateByPath: <T,>(items: T[]) => items,
preFilterWikilinks: () => [],
preFilterWikilinks: () => state.wikilinkCandidates,
}))
vi.mock('../utils/personMentionSuggestions', () => ({
PERSON_MENTION_MIN_QUERY: 1,
filterPersonMentions: () => [],
filterPersonMentions: () => state.personMentionCandidates,
}))
vi.mock('../utils/suggestionEnrichment', () => ({
@@ -241,6 +243,7 @@ function createEditor() {
{ id: 'heading-block', type: 'heading', content: [], children: [] },
cursorBlock,
],
domElement: undefined as HTMLElement | undefined,
tryParseMarkdownToBlocks: vi.fn(async () => [
{ type: 'table', content: { type: 'tableContent' } },
]),
@@ -308,7 +311,9 @@ describe('SingleEditorView', () => {
state.capturedBlockNoteOnChange = null
state.capturedMantineGetStyleNonce = null
state.imageDropState.isDragOver = false
state.personMentionCandidates = []
state.wikilinkEntriesRef.current = []
state.wikilinkCandidates = []
mockOpenExternalUrl.mockClear()
document.documentElement.removeAttribute('data-theme')
document.documentElement.classList.remove('dark')
@@ -435,6 +440,60 @@ describe('SingleEditorView', () => {
expect(onMentionItemClick).toHaveBeenCalledOnce()
})
it('ignores stale suggestion item clicks after the editor DOM disconnects', () => {
const editor = createEditor()
editor.domElement = document.createElement('div')
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const staleItemClick = vi.fn(() => {
throw new TypeError('Cannot read properties of undefined (reading isConnected)')
})
expect(() => {
;(state.capturedSuggestionProps['[['].onItemClick as (item: { onItemClick: () => void }) => void)({
onItemClick: staleItemClick,
})
}).not.toThrow()
expect(staleItemClick).not.toHaveBeenCalled()
})
it('guards stale click handlers stored on wikilink suggestion items', async () => {
const editor = createEditor()
editor.domElement = document.createElement('div')
const staleItemClick = vi.fn(() => {
throw new TypeError('Cannot read properties of undefined (reading isConnected)')
})
state.wikilinkCandidates = [{
title: 'Alpha',
path: '/vault/project/alpha.md',
onItemClick: staleItemClick,
}]
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const getItems = state.capturedSuggestionProps['[['].getItems as (
query: string
) => Promise<Array<{ onItemClick: () => void }>>
const items = await getItems('al')
expect(items).toHaveLength(1)
expect(() => items[0].onItemClick()).not.toThrow()
expect(staleItemClick).not.toHaveBeenCalled()
})
it('passes the active document theme to BlockNote', () => {
document.documentElement.setAttribute('data-theme', 'dark')
document.documentElement.classList.add('dark')

View File

@@ -67,6 +67,53 @@ type TestTableBlock = {
type?: string
content?: { type?: string; columnWidths?: Array<number | null> }
}
type SuggestionAction = () => void
type SuggestionItemWithClick = { onItemClick?: SuggestionAction }
function isEditorReadyForSuggestionAction(
editor: ReturnType<typeof useCreateBlockNote>,
container: HTMLElement | null,
) {
if (!container?.isConnected) return false
const editorElement = editor.domElement
if (!(editorElement instanceof HTMLElement)) return true
return editorElement.isConnected && container.contains(editorElement)
}
function runSuggestionActionSafely({
action,
container,
editor,
}: {
action: SuggestionAction
container: HTMLElement | null
editor: ReturnType<typeof useCreateBlockNote>
}) {
if (!isEditorReadyForSuggestionAction(editor, container)) return
try {
action()
} catch (error) {
console.warn('[editor] Ignored stale suggestion menu action:', error)
}
}
function guardSuggestionMenuItems<T extends SuggestionItemWithClick>(
items: T[],
runEditorAction: (action: SuggestionAction) => void,
): T[] {
return items.map((item) => {
if (!item.onItemClick) return item
const onItemClick = item.onItemClick
return {
...item,
onItemClick: () => runEditorAction(onItemClick),
}
})
}
function SharedContextBlockNoteView(props: React.ComponentProps<typeof BlockNoteViewRaw>) {
const { children, className, theme, ...rest } = props
@@ -412,20 +459,26 @@ function buildBaseSuggestionItems(entries: VaultEntry[]) {
})))
}
function useInsertWikilink(editor: ReturnType<typeof useCreateBlockNote>) {
function useInsertWikilink(
editor: ReturnType<typeof useCreateBlockNote>,
runEditorAction: (action: SuggestionAction) => void,
) {
return useCallback((target: string) => {
editor.insertInlineContent([
{ type: 'wikilink' as const, props: { target } },
" ",
], { updateSelection: true })
trackEvent('wikilink_inserted')
}, [editor])
runEditorAction(() => {
editor.insertInlineContent([
{ type: 'wikilink' as const, props: { target } },
" ",
], { updateSelection: true })
trackEvent('wikilink_inserted')
})
}, [editor, runEditorAction])
}
function useSuggestionMenuItems(options: {
baseItems: ReturnType<typeof buildBaseSuggestionItems>
editor: ReturnType<typeof useCreateBlockNote>
insertWikilink: (target: string) => void
runEditorAction: (action: SuggestionAction) => void
typeEntryMap: Record<string, VaultEntry>
vaultPath?: string
}) {
@@ -433,6 +486,7 @@ function useSuggestionMenuItems(options: {
baseItems,
editor,
insertWikilink,
runEditorAction,
typeEntryMap,
vaultPath,
} = options
@@ -447,8 +501,11 @@ function useSuggestionMenuItems(options: {
: filterPersonMentions(baseItems, normalizedQuery)
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
return enrichSuggestionItems(items, normalizedQuery, typeEntryMap)
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
return guardSuggestionMenuItems(
enrichSuggestionItems(items, normalizedQuery, typeEntryMap),
runEditorAction,
)
}, [baseItems, insertWikilink, runEditorAction, typeEntryMap, vaultPath])
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => (
buildItems(query, '[[') ?? []
@@ -458,9 +515,17 @@ function useSuggestionMenuItems(options: {
buildItems(query, '@') ?? []
), [buildItems])
const getSlashMenuItems = useCallback(async (query: string) => (
getTolariaSlashMenuItems(editor, query)
), [editor])
const getSlashMenuItems = useCallback(async (query: string) => {
try {
return guardSuggestionMenuItems(
await Promise.resolve(getTolariaSlashMenuItems(editor, query)),
runEditorAction,
)
} catch (error) {
console.warn('[editor] Ignored stale slash menu query:', error)
return []
}
}, [editor, runEditorAction])
return {
getWikilinkItems,
@@ -513,7 +578,14 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(() => buildBaseSuggestionItems(entries), [entries])
const insertWikilink = useInsertWikilink(editor)
const runEditorAction = useCallback((action: SuggestionAction) => {
runSuggestionActionSafely({
action,
container: containerRef.current,
editor,
})
}, [editor])
const insertWikilink = useInsertWikilink(editor, runEditorAction)
const {
getWikilinkItems,
getPersonMentionItems,
@@ -522,6 +594,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
baseItems,
editor,
insertWikilink,
runEditorAction,
typeEntryMap,
vaultPath,
})
@@ -568,13 +641,13 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
triggerCharacter="[["
getItems={getWikilinkItems}
suggestionMenuComponent={WikilinkSuggestionMenu}
onItemClick={(item: WikilinkSuggestionItem) => item.onItemClick()}
onItemClick={(item: WikilinkSuggestionItem) => runEditorAction(item.onItemClick)}
/>
<SuggestionMenuController
triggerCharacter="@"
getItems={getPersonMentionItems}
suggestionMenuComponent={WikilinkSuggestionMenu}
onItemClick={(item: WikilinkSuggestionItem) => item.onItemClick()}
onItemClick={(item: WikilinkSuggestionItem) => runEditorAction(item.onItemClick)}
/>
</SharedContextBlockNoteView>
</div>

View File

@@ -459,6 +459,59 @@ describe('WikilinkChatInput', () => {
expect(onSend).not.toHaveBeenCalled()
})
it('inserts one controlled newline on Shift+Enter without duplicating the draft', () => {
const onDraftChange = vi.fn()
render(<Controlled onDraftChange={onDraftChange} />)
updateEditorText('first line')
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true })
expect(onDraftChange).toHaveBeenLastCalledWith('first line\n')
expect(screen.getByTestId('agent-input').textContent).toBe('first line\n')
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true })
expect(onDraftChange).toHaveBeenLastCalledWith('first line\n\n')
expect(screen.getByTestId('agent-input').textContent).toBe('first line\n\n')
})
it('inserts one controlled newline from native insertLineBreak beforeinput', () => {
const onDraftChange = vi.fn()
render(<Controlled onDraftChange={onDraftChange} />)
updateEditorText('first line')
const editor = screen.getByTestId('agent-input')
const beforeInputEvent = new Event('beforeinput', {
bubbles: true,
cancelable: true,
})
Object.defineProperty(beforeInputEvent, 'inputType', {
value: 'insertLineBreak',
})
fireEvent(editor, beforeInputEvent)
expect(beforeInputEvent.defaultPrevented).toBe(true)
expect(onDraftChange).toHaveBeenLastCalledWith('first line\n')
expect(screen.getByTestId('agent-input').textContent).toBe('first line\n')
})
it('submits a multi-line draft with normal Enter after Shift+Enter', () => {
const onSend = vi.fn()
render(<Controlled onSend={onSend} />)
updateEditorText('first line')
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true })
const editor = screen.getByTestId('agent-input')
editor.textContent = 'first line\nsecond line'
setSelection(editor, 'first line\nsecond line'.length)
fireEvent.input(editor)
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
expect(onSend).toHaveBeenCalledWith('first line\nsecond line', [])
})
it('marks the editor disabled when disabled is true', () => {
render(<Controlled disabled />)

View File

@@ -48,7 +48,7 @@ describe('inlineWikilinkDom', () => {
nested.textContent = 'Tail'
root.append(nested)
expect(serializeInlineNode(root)).toBe('A B [[Project]]Tail')
expect(serializeInlineNode(root)).toBe('A B\n[[Project]]Tail')
})
it('reads selections inside the editor and falls back to the editor end for outside selections', () => {

View File

@@ -12,5 +12,5 @@ export function normalizeInlineWikilinkValue(value: string): string {
return value
.replace(/\u00A0/g, ' ')
.replace(/\u200B/g, '')
.replace(/\r?\n/g, ' ')
.replace(/\r\n?/g, '\n')
}

View File

@@ -21,8 +21,10 @@ import { TypeCustomizePopover } from '../TypeCustomizePopover'
import { useDragRegion } from '../../hooks/useDragRegion'
import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SidebarViewItem } from './SidebarViewItem'
import { computeReorder } from './sidebarHooks'
import { countByFilter } from '../../utils/noteListHelpers'
import { translate, type AppLocale } from '../../lib/i18n'
import { canMoveView, type ViewMoveDirection } from '../../utils/viewOrdering'
export { SidebarTopNav } from './SidebarTopNav'
export { FavoritesSection } from './FavoritesSection'
@@ -48,6 +50,9 @@ export function ViewsSection({
onCreateView,
onEditView,
onDeleteView,
onReorderViews,
onMoveView,
sensors,
entries,
locale = 'en',
}: {
@@ -59,9 +64,35 @@ export function ViewsSection({
onCreateView?: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
onReorderViews?: (orderedFilenames: string[]) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
sensors: ReturnType<typeof useSensors>
entries: VaultEntry[]
locale?: AppLocale
}) {
const viewIds = views.map((view) => view.filename)
const handleViewDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const reordered = computeReorder(viewIds, active.id as string, over.id as string)
if (reordered) onReorderViews?.(reordered)
}
const renderViewItem = (view: ViewFile) => (
<SidebarViewItem
key={view.filename}
view={view}
isActive={isSelectionActive(selection, { kind: 'view', filename: view.filename })}
onSelect={() => onSelect({ kind: 'view', filename: view.filename })}
onEditView={onEditView}
onDeleteView={onDeleteView}
onMoveView={onMoveView}
canMoveUp={canMoveView(views, view.filename, 'up')}
canMoveDown={canMoveView(views, view.filename, 'down')}
entries={entries}
locale={locale}
/>
)
return (
<div className="border-b border-border" style={{ padding: '0 6px' }}>
<SidebarGroupHeader label={translate(locale, 'sidebar.group.views')} collapsed={collapsed} onToggle={onToggle}>
@@ -81,24 +112,81 @@ export function ViewsSection({
</SidebarGroupHeader>
{!collapsed && (
<div style={{ paddingBottom: 4 }}>
{views.map((view) => (
<SidebarViewItem
key={view.filename}
view={view}
isActive={isSelectionActive(selection, { kind: 'view', filename: view.filename })}
onSelect={() => onSelect({ kind: 'view', filename: view.filename })}
onEditView={onEditView}
onDeleteView={onDeleteView}
entries={entries}
locale={locale}
/>
))}
{onReorderViews ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleViewDragEnd}>
<SortableContext items={viewIds} strategy={verticalListSortingStrategy}>
{views.map((view) => (
<SortableViewItem
key={view.filename}
view={view}
views={views}
selection={selection}
onSelect={onSelect}
onEditView={onEditView}
onDeleteView={onDeleteView}
onMoveView={onMoveView}
entries={entries}
locale={locale}
/>
))}
</SortableContext>
</DndContext>
) : views.map(renderViewItem)}
</div>
)}
</div>
)
}
function SortableViewItem({
view,
views,
selection,
onSelect,
onEditView,
onDeleteView,
onMoveView,
entries,
locale,
}: {
view: ViewFile
views: ViewFile[]
selection: SidebarSelection
onSelect: (selection: SidebarSelection) => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
entries: VaultEntry[]
locale?: AppLocale
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: view.filename })
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}}
>
<SidebarViewItem
view={view}
isActive={isSelectionActive(selection, { kind: 'view', filename: view.filename })}
onSelect={() => onSelect({ kind: 'view', filename: view.filename })}
onEditView={onEditView}
onDeleteView={onDeleteView}
onMoveView={onMoveView}
canMoveUp={canMoveView(views, view.filename, 'up')}
canMoveDown={canMoveView(views, view.filename, 'down')}
dragHandleProps={{ ...attributes, ...listeners }}
entries={entries}
locale={locale}
/>
</div>
)
}
function SortableSection({
group,
sectionProps,

View File

@@ -1,11 +1,14 @@
import { useMemo } from 'react'
import { useMemo, type HTMLAttributes } from 'react'
import type { VaultEntry, ViewFile } from '../../types'
import { evaluateView } from '../../utils/viewFilters'
import { Funnel, PencilSimple, Trash } from '@phosphor-icons/react'
import { ArrowDown, ArrowUp, GripVertical } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { NoteTitleIcon } from '../NoteTitleIcon'
import { SidebarCountPill } from '../SidebarParts'
import { SIDEBAR_ITEM_PADDING } from './sidebarStyles'
import { translate, type AppLocale } from '../../lib/i18n'
import type { ViewMoveDirection } from '../../utils/viewOrdering'
interface SidebarViewItemProps {
view: ViewFile
@@ -13,6 +16,10 @@ interface SidebarViewItemProps {
onSelect: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
canMoveUp?: boolean
canMoveDown?: boolean
dragHandleProps?: HTMLAttributes<HTMLButtonElement>
entries: VaultEntry[]
locale?: AppLocale
}
@@ -23,6 +30,10 @@ export function SidebarViewItem({
onSelect,
onEditView,
onDeleteView,
onMoveView,
canMoveUp = false,
canMoveDown = false,
dragHandleProps,
entries,
locale = 'en',
}: SidebarViewItemProps) {
@@ -39,6 +50,20 @@ export function SidebarViewItem({
style={{ padding: showCount ? SIDEBAR_ITEM_PADDING.withCount : SIDEBAR_ITEM_PADDING.regular, borderRadius: 4 }}
onClick={onSelect}
>
{dragHandleProps && (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="-ml-1 h-5 w-4 min-w-0 cursor-grab rounded p-0 text-muted-foreground hover:bg-transparent hover:text-foreground active:cursor-grabbing"
title={translate(locale, 'sidebar.action.reorderView')}
aria-label={translate(locale, 'sidebar.action.reorderView')}
onClick={(event) => event.stopPropagation()}
{...dragHandleProps}
>
<GripVertical size={12} />
</Button>
)}
{icon}
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{view.definition.name}</span>
{showCount && (
@@ -51,25 +76,59 @@ export function SidebarViewItem({
)}
</div>
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
{onMoveView && (
<>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 min-w-0 rounded p-0 text-muted-foreground hover:bg-transparent hover:text-foreground disabled:opacity-30"
disabled={!canMoveUp}
onClick={(event) => { event.stopPropagation(); onMoveView(view.filename, 'up') }}
title={translate(locale, 'sidebar.action.moveViewUp')}
aria-label={translate(locale, 'sidebar.action.moveViewUp')}
>
<ArrowUp size={12} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 min-w-0 rounded p-0 text-muted-foreground hover:bg-transparent hover:text-foreground disabled:opacity-30"
disabled={!canMoveDown}
onClick={(event) => { event.stopPropagation(); onMoveView(view.filename, 'down') }}
title={translate(locale, 'sidebar.action.moveViewDown')}
aria-label={translate(locale, 'sidebar.action.moveViewDown')}
>
<ArrowDown size={12} />
</Button>
</>
)}
{onEditView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 min-w-0 rounded p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={(event) => { event.stopPropagation(); onEditView(view.filename) }}
title={translate(locale, 'sidebar.action.editView')}
aria-label={translate(locale, 'sidebar.action.editView')}
>
<PencilSimple size={12} />
</button>
</Button>
)}
{onDeleteView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 min-w-0 rounded p-0 text-muted-foreground hover:bg-transparent hover:text-destructive"
onClick={(event) => { event.stopPropagation(); onDeleteView(view.filename) }}
title={translate(locale, 'sidebar.action.deleteView')}
aria-label={translate(locale, 'sidebar.action.deleteView')}
>
<Trash size={12} />
</button>
</Button>
)}
</div>
</div>

View File

@@ -58,6 +58,7 @@ describe('AiAgentsBadge', () => {
guidanceStatus={{
agentsState: 'missing',
claudeState: 'managed',
geminiState: 'managed',
canRestore: true,
}}
defaultAgent="claude_code"
@@ -88,6 +89,7 @@ describe('AiAgentsBadge', () => {
guidanceStatus={{
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'managed',
canRestore: true,
}}
defaultAgent="claude_code"

View File

@@ -6,14 +6,20 @@ import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
let capturedDragHandleMenu: ComponentType | null = null
vi.mock('@blocknote/react', () => ({
AddBlockButton: () => <button type="button">Add block</button>,
DragHandleMenu: ({ children }: PropsWithChildren) => (
<div data-testid="drag-handle-menu">{children}</div>
),
RemoveBlockItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
SideMenu: ({ dragHandleMenu }: { dragHandleMenu?: ComponentType }) => {
DragHandleButton: ({ dragHandleMenu }: { dragHandleMenu?: ComponentType }) => {
capturedDragHandleMenu = dragHandleMenu ?? null
return <div data-testid="side-menu" />
return (
<button type="button" draggable>
Open block menu
</button>
)
},
RemoveBlockItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
SideMenu: ({ children }: PropsWithChildren) => <div data-testid="side-menu">{children}</div>,
TableColumnHeaderItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
TableRowHeaderItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
useDictionary: () => ({
@@ -32,6 +38,10 @@ describe('TolariaSideMenu', () => {
expect(screen.getByTestId('side-menu')).toBeInTheDocument()
expect(capturedDragHandleMenu).not.toBeNull()
expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([
'Open block menu',
'Add block',
])
const DragHandleMenuComponent = capturedDragHandleMenu!
render(<DragHandleMenuComponent />)

View File

@@ -1,5 +1,7 @@
import {
AddBlockButton,
DragHandleMenu,
DragHandleButton,
RemoveBlockItem,
SideMenu,
TableColumnHeaderItem,
@@ -21,5 +23,10 @@ function TolariaDragHandleMenu() {
}
export function TolariaSideMenu(props: SideMenuProps) {
return <SideMenu {...props} dragHandleMenu={TolariaDragHandleMenu} />
return (
<SideMenu {...props}>
<DragHandleButton dragHandleMenu={TolariaDragHandleMenu} />
<AddBlockButton />
</SideMenu>
)
}

View File

@@ -305,6 +305,30 @@ describe('tolariaEditorFormatting behavior', () => {
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
})
it('deduplicates floating toolbar store writes during close races', () => {
const editor = createMockEditor('paragraph')
useBlockNoteEditorMock.mockReturnValue(editor)
render(
<TolariaFormattingToolbarController
formattingToolbar={() => <button data-testid="toolbar-action" type="button">Toolbar</button>}
/>,
)
const toolbarWrapper = screen.getByTestId('toolbar-action').parentElement as HTMLElement
const floatingOptions = positionPopoverState.lastProps?.useFloatingOptions as {
onOpenChange: (open: boolean, event: unknown, reason?: string) => void
}
floatingOptions.onOpenChange(true, undefined)
floatingOptions.onOpenChange(false, undefined)
floatingOptions.onOpenChange(false, undefined)
fireEvent.blur(toolbarWrapper, { relatedTarget: document.body })
expect(formattingToolbarStore.setState).toHaveBeenCalledTimes(1)
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
})
it('does not open the floating toolbar when the editor anchor element is unavailable', () => {
const editor = createMockEditor()
editor.domElement = document.createElement('div')

View File

@@ -130,6 +130,27 @@ function useFormattingToolbarCloseGrace({
return { closeGraceActive, clearCloseGrace }
}
type FormattingToolbarStore = {
setState(open: boolean): void
}
function useDeduplicatedFormattingToolbarStore(
store: FormattingToolbarStore,
show: boolean,
) {
const openRef = useRef(show)
useEffect(() => {
openRef.current = show
}, [show])
return useCallback((open: boolean) => {
if (openRef.current === open) return
openRef.current = open
store.setState(open)
}, [store])
}
const TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS = {
bold: {
label: 'Bold',
@@ -503,6 +524,10 @@ export function TolariaFormattingToolbarController(props: {
toolbarHasFocus,
toolbarHovered,
})
const setFormattingToolbarOpen = useDeduplicatedFormattingToolbarStore(
formattingToolbar.store,
show,
)
const isOpen = show || toolbarHasFocus || toolbarHovered || closeGraceActive
const hasFloatingToolbarAnchor = getFormattingToolbarAnchorElement(editor) !== null
@@ -556,7 +581,7 @@ export function TolariaFormattingToolbarController(props: {
useFloatingOptions: {
open: shouldRenderFloatingToolbar,
onOpenChange: (open, _event, reason) => {
formattingToolbar.store.setState(open)
setFormattingToolbarOpen(open)
if (!open) {
setToolbarHasFocus(false)
setToolbarHovered(false)
@@ -579,9 +604,9 @@ export function TolariaFormattingToolbarController(props: {
[
clearCloseGrace,
editor,
formattingToolbar.store,
placement,
props.floatingUIOptions,
setFormattingToolbarOpen,
shouldRenderFloatingToolbar,
],
)
@@ -611,7 +636,7 @@ export function TolariaFormattingToolbarController(props: {
}
setToolbarHasFocus(false)
formattingToolbar.store.setState(false)
setFormattingToolbarOpen(false)
}}
>
<Component />

View File

@@ -9,6 +9,7 @@ describe('buildAiAgentCommands', () => {
vaultAiGuidanceStatus: {
agentsState: 'missing',
claudeState: 'managed',
geminiState: 'managed',
canRestore: true,
},
onRestoreVaultAiGuidance,
@@ -16,6 +17,7 @@ describe('buildAiAgentCommands', () => {
const command = commands.find((item) => item.id === 'restore-vault-ai-guidance')
expect(command).toBeDefined()
expect(command?.keywords).toContain('gemini')
command?.execute()
expect(onRestoreVaultAiGuidance).toHaveBeenCalledOnce()
})
@@ -25,6 +27,7 @@ describe('buildAiAgentCommands', () => {
vaultAiGuidanceStatus: {
agentsState: 'managed',
claudeState: 'managed',
geminiState: 'managed',
canRestore: false,
},
onRestoreVaultAiGuidance: vi.fn(),

View File

@@ -67,7 +67,7 @@ function restoreGuidanceCommands({
id: 'restore-vault-ai-guidance',
label: 'Restore Tolaria AI Guidance',
group: 'Settings',
keywords: aiAgentKeywords('ai', 'agent', 'guidance', 'restore', 'repair', 'agents'),
keywords: aiAgentKeywords('ai', 'agent', 'guidance', 'restore', 'repair', 'agents', 'gemini'),
enabled: true,
execute: () => onRestoreVaultAiGuidance(),
},

View File

@@ -126,4 +126,16 @@ describe('buildSettingsCommands', () => {
expect(listener).toHaveBeenCalledTimes(1)
window.removeEventListener(TOGGLE_GITIGNORED_VISIBILITY_EVENT, listener)
})
it('makes external AI setup discoverable for Gemini CLI', () => {
const onInstallMcp = vi.fn()
const command = findCommand('install-mcp', buildSettingsCommands({
onOpenSettings: vi.fn(),
onInstallMcp,
}))
expect(command?.keywords).toContain('gemini')
command?.execute()
expect(onInstallMcp).toHaveBeenCalledOnce()
})
})

View File

@@ -149,7 +149,7 @@ function buildMaintenanceCommands({
id: 'install-mcp',
label: mcpStatus === 'installed' ? 'Manage External AI Tools…' : 'Set Up External AI Tools…',
group: 'Settings',
keywords: ['mcp', 'ai', 'tools', 'external', 'setup', 'connect', 'disconnect', 'claude', 'codex', 'cursor', 'consent'],
keywords: ['mcp', 'ai', 'tools', 'external', 'setup', 'details', 'copy', 'export', 'manual', 'config', 'connect', 'disconnect', 'claude', 'gemini', 'codex', 'cursor', 'consent'],
enabled: true,
execute: () => onInstallMcp?.(),
},

View File

@@ -28,6 +28,11 @@ interface ViewCommandsConfig {
onCustomizeNoteListColumns?: () => void
canCustomizeNoteListColumns?: boolean
noteListColumnsLabel: string
selectedViewName?: string
onMoveSelectedViewUp?: () => void
onMoveSelectedViewDown?: () => void
canMoveSelectedViewUp?: boolean
canMoveSelectedViewDown?: boolean
}
function buildNoteLayoutCommand(noteLayout: NoteLayout, onToggleNoteLayout?: () => void): CommandAction {
@@ -41,12 +46,32 @@ function buildNoteLayoutCommand(noteLayout: NoteLayout, onToggleNoteLayout?: ()
}
}
function buildMoveSavedViewCommand(
direction: 'Up' | 'Down',
selectedViewName: string | undefined,
onMoveSelectedView: (() => void) | undefined,
canMoveSelectedView: boolean | undefined,
): CommandAction {
const directionKeyword = direction.toLowerCase()
return {
id: `move-view-${directionKeyword}`,
label: selectedViewName ? `Move ${selectedViewName} ${direction}` : `Move View ${direction}`,
group: 'View',
keywords: ['saved view', 'view', 'views', 'order', 'sidebar', 'move', directionKeyword],
enabled: Boolean(onMoveSelectedView && canMoveSelectedView),
execute: onMoveSelectedView ?? noop,
}
}
export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
const {
hasActiveNote, activeNoteModified,
onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteLayout = 'centered', onToggleNoteLayout, onToggleAIChat,
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown,
canMoveSelectedViewUp, canMoveSelectedViewDown,
} = config
return [
@@ -60,6 +85,8 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleAiChat), keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'new-ai-chat', label: 'New AI chat', group: 'View', keywords: ['ai', 'agent', 'chat', 'assistant', 'new', 'fresh', 'conversation', 'reset'], enabled: true, execute: requestNewAiChat },
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
buildMoveSavedViewCommand('Up', selectedViewName, onMoveSelectedViewUp, canMoveSelectedViewUp),
buildMoveSavedViewCommand('Down', selectedViewName, onMoveSelectedViewDown, canMoveSelectedViewDown),
{ id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewZoomIn), keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewZoomOut), keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },

View File

@@ -0,0 +1,38 @@
let fallbackBlockIdSequence = 0
function createEditorBlockId(): string {
const randomUUID = globalThis.crypto?.randomUUID
if (typeof randomUUID === 'function') return randomUUID.call(globalThis.crypto)
fallbackBlockIdSequence += 1
return `tolaria-block-${fallbackBlockIdSequence}`
}
function isEditorBlockRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasUsableBlockId(block: Record<string, unknown>): boolean {
return typeof block.id === 'string' && block.id.trim().length > 0
}
function repairEditorBlock(block: unknown): unknown {
if (!isEditorBlockRecord(block)) return block
const children = Array.isArray(block.children)
? repairMalformedEditorBlocks(block.children)
: block.children
const missingId = !hasUsableBlockId(block)
if (!missingId && children === block.children) return block
return {
...block,
...(missingId ? { id: createEditorBlockId() } : {}),
...(children === block.children ? {} : { children }),
}
}
export function repairMalformedEditorBlocks(blocks: unknown[]): unknown[] {
return blocks.map(repairEditorBlock)
}

View File

@@ -68,6 +68,10 @@ export function isUntitledPath(path: FilePath): boolean {
return pathStem(path).startsWith('untitled-')
}
export function blankParagraphBlocks(): unknown[] {
return [{ type: 'paragraph', content: [], children: [] }]
}
function isParsedBlock(value: unknown): value is ParsedBlock {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

View File

@@ -0,0 +1,31 @@
type TiptapEditorBridge = {
state?: {
doc?: { content?: { size?: unknown } }
}
commands?: {
setTextSelection?: (position: number) => unknown
}
}
function getTiptapEditorBridge(editor: unknown): TiptapEditorBridge | null {
const editorWithBridge = editor as { _tiptapEditor?: TiptapEditorBridge }
return editorWithBridge._tiptapEditor ?? null
}
function getSafeTextSelectionPosition(tiptapEditor: TiptapEditorBridge): number {
const size = tiptapEditor.state?.doc?.content?.size
if (typeof size !== 'number' || !Number.isFinite(size)) return 0
return size > 0 ? Math.min(1, size) : 0
}
export function resetTextSelectionBeforeContentSwap(editor: unknown): void {
const tiptapEditor = getTiptapEditorBridge(editor)
const setTextSelection = tiptapEditor?.commands?.setTextSelection
if (!tiptapEditor || typeof setTextSelection !== 'function') return
try {
setTextSelection(getSafeTextSelectionPosition(tiptapEditor))
} catch (err) {
console.warn('Failed to reset editor selection before content swap:', err)
}
}

View File

@@ -41,6 +41,11 @@ interface AppCommandsConfig {
onToggleInspector: () => void
onToggleDiff?: () => void
onToggleRawEditor?: () => void
selectedViewName?: string
onMoveSelectedViewUp?: () => void
onMoveSelectedViewDown?: () => void
canMoveSelectedViewUp?: boolean
canMoveSelectedViewDown?: boolean
noteLayout?: NoteLayout
onToggleNoteLayout?: () => void
activeNoteModified: boolean
@@ -155,6 +160,11 @@ type CommandRegistryCoreActions = Pick<
| 'onToggleInspector'
| 'onToggleDiff'
| 'onToggleRawEditor'
| 'selectedViewName'
| 'onMoveSelectedViewUp'
| 'onMoveSelectedViewDown'
| 'canMoveSelectedViewUp'
| 'canMoveSelectedViewDown'
| 'noteLayout'
| 'onToggleNoteLayout'
| 'onToggleAIChat'
@@ -417,6 +427,11 @@ function createCommandRegistryCoreConfig(
onToggleInspector: config.onToggleInspector,
onToggleDiff: config.onToggleDiff,
onToggleRawEditor: config.onToggleRawEditor,
selectedViewName: config.selectedViewName,
onMoveSelectedViewUp: config.onMoveSelectedViewUp,
onMoveSelectedViewDown: config.onMoveSelectedViewDown,
canMoveSelectedViewUp: config.canMoveSelectedViewUp,
canMoveSelectedViewDown: config.canMoveSelectedViewDown,
onFindInNote: config.onFindInNote,
onReplaceInNote: config.onReplaceInNote,
noteLayout: config.noteLayout,

View File

@@ -198,6 +198,81 @@ describe('useAppSave', () => {
consoleSpy.mockRestore()
})
it('pauses manual saves when a stale editor has no active vault', async () => {
vi.mocked(isTauri).mockReturnValue(true)
const path = 'C:\\Users\\Luca\\Notes\\draft.md'
const entry = makeEntry(path, 'Draft', 'draft.md')
const { result } = renderSave({
resolvedPath: '',
tabs: [{ entry, content: '# Draft\n\nBody' }],
activeTabPath: path,
unsavedPaths: new Set([path]),
})
let saved = true
await act(async () => {
saved = await result.current.handleSave()
})
expect(saved).toBe(false)
expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('save_note_content', expect.anything())
expect(deps.setToastMessage).toHaveBeenCalledWith('Select or restore a vault before saving.')
expect(deps.clearUnsaved).not.toHaveBeenCalled()
})
it('pauses stale auto-save timers when the active vault disappears before debounce fires', async () => {
vi.useFakeTimers()
vi.mocked(isTauri).mockReturnValue(true)
const entry = makeEntry('/vault/draft.md', 'Draft', 'draft.md')
const tabs = [{ entry, content: '# Draft' }]
const { result, rerender } = renderHook(
({ vaultPath }: { vaultPath: string }) => useAppSave({
...deps,
resolvedPath: vaultPath,
tabs,
activeTabPath: entry.path,
unsavedPaths: new Set([entry.path]),
}),
{ initialProps: { vaultPath: '/vault' } },
)
act(() => {
result.current.handleContentChange(entry.path, '# Draft\n\nUnsaved')
})
rerender({ vaultPath: '' })
await act(async () => {
await vi.advanceTimersByTimeAsync(500)
})
expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('save_note_content', expect.anything())
expect(deps.clearUnsaved).not.toHaveBeenCalled()
})
it('does not flush unsaved tab content to disk without an active vault', async () => {
vi.mocked(isTauri).mockReturnValue(true)
const path = 'C:\\Users\\Luca\\Notes\\draft.md'
const entry = makeEntry(path, 'Draft', 'draft.md')
const { result } = renderSave({
resolvedPath: '',
tabs: [{ entry, content: '# Draft\n\nBody' }],
activeTabPath: path,
unsavedPaths: new Set([path]),
})
await act(async () => {
await result.current.flushBeforeAction(path)
})
expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('save_note_content', expect.anything())
expect(deps.setToastMessage).toHaveBeenCalledWith('Select or restore a vault before saving.')
expect(deps.clearUnsaved).not.toHaveBeenCalled()
})
it('handleContentChange is a function', () => {
const { result } = renderSave()
expect(typeof result.current.handleContentChange).toBe('function')

View File

@@ -1,6 +1,7 @@
import { startTransition, useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
import { MISSING_ACTIVE_VAULT_SAVE_MESSAGE } from './useEditorSave'
import { flushEditorContent } from '../utils/autoSave'
import { extractH1TitleFromContent } from '../utils/noteTitle'
import { isTauri } from '../mock-tauri'
@@ -464,6 +465,7 @@ function useAppSaveEffects({
}
function useFlushBeforeAction({
canPersist,
resolveCurrentPath,
savePendingForPath,
tabsRef,
@@ -472,6 +474,7 @@ function useFlushBeforeAction({
setToastMessage,
flushPendingUntitledRename,
}: {
canPersist: boolean
resolveCurrentPath: (path: string) => string
savePendingForPath: (path: string) => Promise<boolean>
tabsRef: MutableRefObject<TabState[]>
@@ -482,6 +485,10 @@ function useFlushBeforeAction({
}) {
return useCallback(async (path: string) => {
const currentPath = resolveCurrentPath(path)
if (!canPersist) {
if (unsavedPathsRef.current.has(currentPath)) setToastMessage(MISSING_ACTIVE_VAULT_SAVE_MESSAGE)
return
}
try {
await flushEditorContent(currentPath, {
savePendingForPath,
@@ -494,7 +501,7 @@ function useFlushBeforeAction({
setToastMessage(`Auto-save failed: ${err}`)
throw err
}
}, [resolveCurrentPath, savePendingForPath, tabsRef, unsavedPathsRef, clearUnsaved, setToastMessage, flushPendingUntitledRename])
}, [canPersist, resolveCurrentPath, savePendingForPath, tabsRef, unsavedPathsRef, clearUnsaved, setToastMessage, flushPendingUntitledRename])
}
async function preparePathForManualRename({
@@ -598,6 +605,7 @@ function useEditorPersistence({
scheduleUntitledRename,
resolveCurrentPath,
resolvePathBeforeSave,
canPersist,
}: {
updateEntry: AppSaveDeps['updateEntry']
setTabs: AppSaveDeps['setTabs']
@@ -610,6 +618,7 @@ function useEditorPersistence({
scheduleUntitledRename: (path: string, content: string) => void
resolveCurrentPath: (path: string) => string
resolvePathBeforeSave: (path: string) => Promise<string>
canPersist: boolean
}) {
const onAfterSave = useCallback(() => {
loadModifiedFiles()
@@ -635,12 +644,14 @@ function useEditorPersistence({
onNotePersisted,
resolvePath: resolveCurrentPath,
resolvePathBeforeSave,
canPersist,
disabledSaveMessage: MISSING_ACTIVE_VAULT_SAVE_MESSAGE,
})
const handleContentChange = useCallback((path: string, content: string) => {
const resolvedPath = resolveCurrentPath(path)
trackUnsaved?.(resolvedPath)
handleContentChangeRaw(resolvedPath, content)
const currentPath = resolveCurrentPath(path)
trackUnsaved?.(currentPath)
handleContentChangeRaw(currentPath, content)
}, [handleContentChangeRaw, resolveCurrentPath, trackUnsaved])
const savePendingForPath = useCallback((path: string) => (
@@ -666,6 +677,7 @@ function useReplaceRenamedEntry({
function useAppSaveHandlers({
contentChangeRef,
handleContentChange,
canPersist,
cancelPendingUntitledRename,
pendingUntitledRenameRef,
activeTabPath,
@@ -687,6 +699,7 @@ function useAppSaveHandlers({
}: {
contentChangeRef: MutableRefObject<(path: string, content: string) => void>
handleContentChange: (path: string, content: string) => void
canPersist: boolean
cancelPendingUntitledRename: (path?: string) => boolean
pendingUntitledRenameRef: MutableRefObject<PendingUntitledRename | null>
activeTabPath: string | null
@@ -715,6 +728,7 @@ function useAppSaveHandlers({
})
const flushBeforeAction = useFlushBeforeAction({
canPersist,
resolveCurrentPath,
savePendingForPath,
tabsRef,
@@ -753,6 +767,7 @@ export function useAppSave({
onInternalVaultWrite,
}: AppSaveDeps) {
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
const canPersist = resolvedPath.trim().length > 0
const { tabsRef, activeTabPathRef, unsavedPathsRef } = useAppSaveStateRefs({ tabs, activeTabPath, unsavedPaths })
const {
pendingUntitledRenameRef, cancelPendingUntitledRename, registerRenamedPath,
@@ -779,11 +794,13 @@ export function useAppSave({
scheduleUntitledRename,
resolveCurrentPath,
resolvePathBeforeSave,
canPersist,
})
const replaceRenamedEntry = useReplaceRenamedEntry({ registerRenamedPath, replaceEntry })
const { handleFilenameRename, handleSave, handleTitleSync, flushBeforeAction } = useAppSaveHandlers({
contentChangeRef,
handleContentChange,
canPersist,
cancelPendingUntitledRename,
pendingUntitledRenameRef,
activeTabPath,

View File

@@ -359,6 +359,53 @@ describe('useCommandRegistry', () => {
expect(onToggleNoteLayout).toHaveBeenCalledOnce()
})
it('exposes command palette actions for moving the selected saved view', () => {
const onMoveSelectedViewUp = vi.fn()
const onMoveSelectedViewDown = vi.fn()
const config = makeConfig({
selectedViewName: 'Active Projects',
onMoveSelectedViewUp,
onMoveSelectedViewDown,
canMoveSelectedViewUp: true,
canMoveSelectedViewDown: true,
})
const { result } = renderHook(() => useCommandRegistry(config))
const moveUp = findCommand(result.current, 'move-view-up')
const moveDown = findCommand(result.current, 'move-view-down')
expect(moveUp).toMatchObject({
label: 'Move Active Projects Up',
group: 'View',
enabled: true,
})
expect(moveDown).toMatchObject({
label: 'Move Active Projects Down',
group: 'View',
enabled: true,
})
moveUp!.execute()
moveDown!.execute()
expect(onMoveSelectedViewUp).toHaveBeenCalledOnce()
expect(onMoveSelectedViewDown).toHaveBeenCalledOnce()
})
it('disables saved view move commands at list boundaries', () => {
const config = makeConfig({
selectedViewName: 'Top View',
onMoveSelectedViewUp: vi.fn(),
onMoveSelectedViewDown: vi.fn(),
canMoveSelectedViewUp: false,
canMoveSelectedViewDown: true,
})
const { result } = renderHook(() => useCommandRegistry(config))
expect(findCommand(result.current, 'move-view-up')?.enabled).toBe(false)
expect(findCommand(result.current, 'move-view-down')?.enabled).toBe(true)
})
it('updates note layout command copy when left alignment is active', () => {
const config = makeConfig({ noteLayout: 'left' })
const { result } = renderHook(() => useCommandRegistry(config))

View File

@@ -83,6 +83,11 @@ interface CommandRegistryConfig {
onToggleInspector: () => void
onToggleDiff?: () => void
onToggleRawEditor?: () => void
selectedViewName?: string
onMoveSelectedViewUp?: () => void
onMoveSelectedViewDown?: () => void
canMoveSelectedViewUp?: boolean
canMoveSelectedViewDown?: boolean
onFindInNote?: () => void
onReplaceInNote?: () => void
noteLayout?: NoteLayout
@@ -119,6 +124,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onFindInNote, onReplaceInNote, noteLayout, onToggleNoteLayout, onToggleAIChat, onOpenVault, onCreateEmptyVault,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onRenameFolder, onDeleteFolder, onRevealSelectedFolder, onCopySelectedFolderPath,
@@ -214,11 +220,13 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
}), [
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat,
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
onCustomizeNoteListColumns, canCustomizeNoteListColumns, noteListColumnsLabel,
selectedViewName, onMoveSelectedViewUp, onMoveSelectedViewDown, canMoveSelectedViewUp, canMoveSelectedViewDown,
])
const settingsCommands = useMemo(() => buildSettingsCommands({

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, type MutableRefObject } from 'react'
import type { SetStateAction } from 'react'
import { useSaveNote } from './useSaveNote'
@@ -19,6 +19,9 @@ interface EditorSaveConfig {
resolvePath?: (path: string) => string
/** Wait for an in-flight path change to settle before persisting buffered content. */
resolvePathBeforeSave?: (path: string) => Promise<string>
/** False when editor state is present but no vault is available to receive writes. */
canPersist?: boolean
disabledSaveMessage?: string
}
/**
@@ -29,6 +32,7 @@ const noop = () => {}
const AUTO_SAVE_DEBOUNCE_MS = 500
const INVALID_PATH_SAVE_MESSAGE = 'Save failed: The note path is invalid on this platform. Rename the note or move it to a valid folder, then try again.'
export const MISSING_ACTIVE_VAULT_SAVE_MESSAGE = 'Select or restore a vault before saving.'
interface PendingContent {
path: string
@@ -53,6 +57,14 @@ function formatSaveFailureMessage(error: unknown): string {
return `Save failed: ${message}`
}
function useLatestValueRef<T>(value: T): MutableRefObject<T> {
const ref = useRef(value)
useLayoutEffect(() => {
ref.current = value
}, [value])
return ref
}
function resolveBufferedPath(path: string, resolvePath?: EditorSaveConfig['resolvePath']): string {
return resolvePath?.(path) ?? path
}
@@ -141,16 +153,19 @@ function usePendingContentFlush({
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
canPersistRef,
}: {
pendingContentRef: MutableRefObject<PendingContent | null>
saveNote: (path: string, content: string) => Promise<void>
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
canPersistRef: MutableRefObject<boolean>
}) {
return useCallback(async (pathFilter?: string): Promise<boolean> => {
const pending = pendingContentRef.current
if (!matchesPendingPath(pending, pathFilter, resolvePath)) return false
if (!canPersistRef.current) return false
const { path, content } = pending
await persistResolvedContent({
path,
@@ -162,7 +177,7 @@ function usePendingContentFlush({
})
pendingContentRef.current = null
return true
}, [onNotePersisted, pendingContentRef, resolvePath, resolvePathBeforeSave, saveNote])
}, [canPersistRef, onNotePersisted, pendingContentRef, resolvePath, resolvePathBeforeSave, saveNote])
}
function useCancelAutoSave(autoSaveTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>) {
@@ -201,7 +216,65 @@ async function persistUnsavedFallback({
return true
}
function pausedSaveResult({
canPersistRef,
pendingContentRef,
unsavedFallback,
setToastMessage,
disabledSaveMessage,
}: {
canPersistRef: MutableRefObject<boolean>
pendingContentRef: MutableRefObject<PendingContent | null>
unsavedFallback?: { path: string; content: string }
setToastMessage: EditorSaveConfig['setToastMessage']
disabledSaveMessage: string
}): boolean | null {
if (canPersistRef.current) return null
const hasUnsavedContent = pendingContentRef.current !== null || unsavedFallback !== undefined
setToastMessage(hasUnsavedContent ? disabledSaveMessage : 'Nothing to save')
return !hasUnsavedContent
}
async function persistImmediateSave({
unsavedFallback,
flushPending,
saveNote,
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
setToastMessage,
onAfterSave,
}: {
unsavedFallback?: { path: string; content: string }
flushPending: (pathFilter?: string) => Promise<boolean>
saveNote: (path: string, content: string) => Promise<void>
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
setToastMessage: EditorSaveConfig['setToastMessage']
onAfterSave: () => void
}): Promise<boolean> {
try {
const saved = await flushPending()
const savedFallback = !saved && await persistUnsavedFallback({
unsavedFallback,
saveNote,
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
})
setToastMessage(saved || savedFallback ? 'Saved' : 'Nothing to save')
onAfterSave()
return true
} catch (err) {
console.error('Save failed:', err)
setToastMessage(formatSaveFailureMessage(err))
return false
}
}
function useImmediateSaveCommands({
pendingContentRef,
cancelAutoSave,
flushPending,
setToastMessage,
@@ -210,7 +283,10 @@ function useImmediateSaveCommands({
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
canPersistRef,
disabledSaveMessage,
}: {
pendingContentRef: MutableRefObject<PendingContent | null>
cancelAutoSave: () => void
flushPending: (pathFilter?: string) => Promise<boolean>
setToastMessage: EditorSaveConfig['setToastMessage']
@@ -219,34 +295,43 @@ function useImmediateSaveCommands({
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
canPersistRef: MutableRefObject<boolean>
disabledSaveMessage: string
}) {
const handleSave = useCallback(async (unsavedFallback?: { path: string; content: string }): Promise<boolean> => {
cancelAutoSave()
try {
const saved = await flushPending()
const savedFallback = !saved && await persistUnsavedFallback({
unsavedFallback,
saveNote,
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
})
setToastMessage(saved || savedFallback ? 'Saved' : 'Nothing to save')
onAfterSave()
return true
} catch (err) {
console.error('Save failed:', err)
setToastMessage(formatSaveFailureMessage(err))
return false
}
}, [cancelAutoSave, flushPending, onAfterSave, onNotePersisted, resolvePath, resolvePathBeforeSave, saveNote, setToastMessage])
const pausedResult = pausedSaveResult({
canPersistRef,
pendingContentRef,
unsavedFallback,
setToastMessage,
disabledSaveMessage,
})
if (pausedResult !== null) return pausedResult
return persistImmediateSave({
unsavedFallback,
flushPending,
saveNote,
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
setToastMessage,
onAfterSave,
})
}, [canPersistRef, cancelAutoSave, disabledSaveMessage, flushPending, onAfterSave, onNotePersisted, pendingContentRef, resolvePath, resolvePathBeforeSave, saveNote, setToastMessage])
const savePendingForPath = useCallback(
(path: string): Promise<boolean> => { cancelAutoSave(); return flushPending(path) },
[cancelAutoSave, flushPending],
(path: string): Promise<boolean> => {
cancelAutoSave()
return canPersistRef.current ? flushPending(path) : Promise.resolve(false)
},
[canPersistRef, cancelAutoSave, flushPending],
)
const savePending = useCallback((): Promise<boolean> => { cancelAutoSave(); return flushPending() }, [cancelAutoSave, flushPending])
const savePending = useCallback((): Promise<boolean> => {
cancelAutoSave()
return canPersistRef.current ? flushPending() : Promise.resolve(false)
}, [canPersistRef, cancelAutoSave, flushPending])
return { handleSave, savePendingForPath, savePending }
}
@@ -259,6 +344,7 @@ function useContentChangeCommand({
cancelAutoSave,
flushPending,
onAfterSaveRef,
canPersistRef,
}: {
pendingContentRef: MutableRefObject<PendingContent | null>
autoSaveTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
@@ -267,13 +353,15 @@ function useContentChangeCommand({
cancelAutoSave: () => void
flushPending: () => Promise<boolean>
onAfterSaveRef: MutableRefObject<() => void>
canPersistRef: MutableRefObject<boolean>
}) {
return useCallback((path: string, content: string) => {
pendingContentRef.current = { path, content }
applyTabContent(setTabs, path, content)
cancelAutoSave()
if (!canPersistRef.current) return
scheduleAutoSave({ autoSaveTimerRef, flushPending, onAfterSaveRef, setToastMessage })
}, [autoSaveTimerRef, cancelAutoSave, flushPending, onAfterSaveRef, pendingContentRef, setTabs, setToastMessage])
}, [autoSaveTimerRef, canPersistRef, cancelAutoSave, flushPending, onAfterSaveRef, pendingContentRef, setTabs, setToastMessage])
}
function useEditorSaveCommands({
@@ -287,6 +375,8 @@ function useEditorSaveCommands({
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
canPersistRef,
disabledSaveMessage,
}: {
pendingContentRef: MutableRefObject<PendingContent | null>
autoSaveTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
@@ -298,6 +388,8 @@ function useEditorSaveCommands({
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
canPersistRef: MutableRefObject<boolean>
disabledSaveMessage: string
}) {
const flushPending = usePendingContentFlush({
pendingContentRef,
@@ -305,9 +397,11 @@ function useEditorSaveCommands({
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
canPersistRef,
})
const cancelAutoSave = useCancelAutoSave(autoSaveTimerRef)
const { handleSave, savePendingForPath, savePending } = useImmediateSaveCommands({
pendingContentRef,
cancelAutoSave,
flushPending,
setToastMessage,
@@ -316,6 +410,8 @@ function useEditorSaveCommands({
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
canPersistRef,
disabledSaveMessage,
})
const handleContentChange = useContentChangeCommand({
pendingContentRef,
@@ -325,6 +421,7 @@ function useEditorSaveCommands({
cancelAutoSave,
flushPending: () => flushPending(),
onAfterSaveRef,
canPersistRef,
})
return { handleSave, handleContentChange, savePendingForPath, savePending }
@@ -338,9 +435,12 @@ export function useEditorSave({
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
canPersist = true,
disabledSaveMessage = MISSING_ACTIVE_VAULT_SAVE_MESSAGE,
}: EditorSaveConfig) {
const pendingContentRef = useRef<{ path: string; content: string } | null>(null)
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const canPersistRef = useLatestValueRef(canPersist)
const updateTabAndContent = useCallback((path: string, content: string) => {
updateVaultContent(path, content)
@@ -361,5 +461,7 @@ export function useEditorSave({
onNotePersisted,
resolvePath,
resolvePathBeforeSave,
canPersistRef,
disabledSaveMessage,
})
}

View File

@@ -22,6 +22,8 @@ export function useEditorSaveWithLinks(config: {
onNotePersisted?: (path: string, content: string) => void
resolvePath?: (path: string) => string
resolvePathBeforeSave?: (path: string) => Promise<string>
canPersist?: boolean
disabledSaveMessage?: string
}) {
const { updateEntry } = config
const saveContent = useCallback((path: string, content: string) => {
@@ -32,15 +34,7 @@ export function useEditorSaveWithLinks(config: {
modifiedAt: Math.floor(Date.now() / 1000),
})
}, [updateEntry])
const editor = useEditorSave({
updateVaultContent: saveContent,
setTabs: config.setTabs,
setToastMessage: config.setToastMessage,
onAfterSave: config.onAfterSave,
onNotePersisted: config.onNotePersisted,
resolvePath: config.resolvePath,
resolvePathBeforeSave: config.resolvePathBeforeSave,
})
const editor = useEditorSave({ ...config, updateVaultContent: saveContent })
const { handleContentChange: rawOnChange } = editor
const prevLinksKeyRef = useRef('')
const prevFmKeyRef = useRef(EMPTY_DERIVED_ENTRY_STATE_KEY)

View File

@@ -229,7 +229,13 @@ function makeMockEditor(docRef: { current: unknown[] }) {
blocksToMarkdownLossy: vi.fn(() => ''),
blocksToHTMLLossy: vi.fn(() => ''),
tryParseMarkdownToBlocks: vi.fn(() => blocksA),
_tiptapEditor: { commands: { setContent: vi.fn() } },
_tiptapEditor: {
state: { doc: { content: { size: 8 } } },
commands: {
setContent: vi.fn(),
setTextSelection: vi.fn(),
},
},
_docRef: docRef,
}
Object.defineProperty(editor, 'document', { get: () => docRef.current })
@@ -454,6 +460,24 @@ describe('useEditorTabSwap raw mode sync', () => {
}
})
it('resets stale TipTap selection before applying a switched note', async () => {
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'Note B')
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
setupEditor: (editor) => {
editor._tiptapEditor.state.doc.content.size = 3
},
})
mockEditor._tiptapEditor.commands.setTextSelection.mockClear()
await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })
expect(mockEditor._tiptapEditor.commands.setTextSelection).toHaveBeenCalledWith(1)
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
})
it('re-parses when the active tab content changes without a path change', async () => {
const tabA = makeTab('a.md', 'Note A')
const refreshedTabA = {
@@ -476,6 +500,48 @@ describe('useEditorTabSwap raw mode sync', () => {
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
})
it('repairs parsed blocks with missing ids before applying them to the editor', async () => {
const tabA = makeTab('a.md', 'Note A')
const malformedTab = {
...makeTab('malformed.md', 'Malformed'),
content: '---\ntitle: Malformed\n---\n\n# Malformed\n\n- Parent\n - Child',
}
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
setupEditor: (editor) => {
editor.tryParseMarkdownToBlocks.mockReturnValue([
{
type: 'bulletListItem',
content: [{ type: 'text', text: 'Parent', styles: {} }],
children: [
{
type: 'bulletListItem',
content: [{ type: 'text', text: 'Child', styles: {} }],
children: [],
},
],
},
])
},
})
mockEditor.replaceBlocks.mockClear()
await rerenderWith({ tabs: [malformedTab], activeTabPath: 'malformed.md' })
const appliedBlocks = mockEditor.replaceBlocks.mock.calls[0][1]
expect(appliedBlocks).toEqual([
expect.objectContaining({
id: expect.any(String),
children: [
expect.objectContaining({
id: expect.any(String),
}),
],
}),
])
})
it('ignores editor change events before the pending tab swap applies a new untitled note', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

View File

@@ -7,7 +7,9 @@ import { injectMathInBlocks, preProcessMathMarkdown } from '../utils/mathMarkdow
import { injectMermaidInBlocks, preProcessMermaidMarkdown, serializeMermaidAwareBlocks } from '../utils/mermaidMarkdown'
import { failNoteOpenTrace, finishNoteOpenTrace } from '../utils/noteOpenPerformance'
import { resolveImageUrls, portableImageUrls } from '../utils/vaultImages'
import { repairMalformedEditorBlocks } from './editorBlockRepair'
import {
blankParagraphBlocks,
extractEditorBody,
getH1TextFromBlocks,
isUntitledPath,
@@ -16,6 +18,7 @@ import {
slugifyPathStem,
} from './editorTabContent'
import { clearEditorDomSelection, EDITOR_CONTAINER_SELECTOR } from './editorDomSelection'
import { resetTextSelectionBeforeContentSwap } from './editorTiptapSelection'
export { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './editorTabContent'
interface Tab {
@@ -119,10 +122,6 @@ function extractBodyRemainderAfterEmptyH1(options: { content: string }): string
return [secondLine, ...rest].join('\n').trimStart()
}
function blankParagraphBlocks(): EditorBlocks {
return [{ type: 'paragraph', content: [], children: [] }]
}
async function parseMarkdownBlocks(
editor: ReturnType<typeof useCreateBlockNote>,
preprocessed: string,
@@ -165,13 +164,21 @@ async function resolveBlocksForTarget(
const preprocessed = preProcessEditorMarkdown(body, vaultPath)
const fastPathBlocks = buildFastPathBlocks({ preprocessed })
if (fastPathBlocks) {
const nextState = { blocks: fastPathBlocks, scrollTop: 0, sourceContent: content }
const nextState = {
blocks: repairMalformedEditorBlocks(fastPathBlocks) as EditorBlocks,
scrollTop: 0,
sourceContent: content,
}
cacheEditorState(cache, targetPath, nextState)
return nextState
}
const parsed = normalizeParsedImageBlocks(await parseMarkdownBlocks(editor, preprocessed)) as EditorBlocks
const nextState = { blocks: injectEditorMarkdownBlocks(parsed), scrollTop: 0, sourceContent: content }
const nextState = {
blocks: repairMalformedEditorBlocks(injectEditorMarkdownBlocks(parsed)) as EditorBlocks,
scrollTop: 0,
sourceContent: content,
}
cacheEditorState(cache, targetPath, nextState)
return nextState
}
@@ -182,18 +189,20 @@ function applyBlocksToEditor(
scrollTop: number,
suppressChangeRef: MutableRefObject<boolean>,
) {
const safeBlocks = repairMalformedEditorBlocks(blocks) as EditorBlocks
suppressChangeRef.current = true
try {
resetTextSelectionBeforeContentSwap(editor)
const current = editor.document
if (current.length > 0 && blocks.length > 0) {
editor.replaceBlocks(current, blocks)
} else if (blocks.length > 0) {
editor.insertBlocks(blocks, current[0], 'before')
if (current.length > 0 && safeBlocks.length > 0) {
editor.replaceBlocks(current, safeBlocks)
} else if (safeBlocks.length > 0) {
editor.insertBlocks(safeBlocks, current[0], 'before')
}
} catch (err) {
console.error('applyBlocks failed, trying fallback:', err)
try {
const html = editor.blocksToHTMLLossy(blocks)
const html = editor.blocksToHTMLLossy(safeBlocks)
editor._tiptapEditor.commands.setContent(html)
} catch (err2) {
console.error('Fallback also failed:', err2)
@@ -214,6 +223,7 @@ function applyBlankStateToEditor(
) {
suppressChangeRef.current = true
try {
resetTextSelectionBeforeContentSwap(editor)
editor._tiptapEditor.commands.setContent('<p></p>')
} catch (err) {
console.error('applyBlankStateToEditor failed, falling back to replaceBlocks:', err)
@@ -235,6 +245,7 @@ function applyHtmlStateToEditor(
) {
suppressChangeRef.current = true
try {
resetTextSelectionBeforeContentSwap(editor)
editor._tiptapEditor.commands.setContent(html)
} catch (err) {
console.error('applyHtmlStateToEditor failed:', err)

View File

@@ -24,6 +24,14 @@ function renderSubject(onToast = vi.fn()) {
return renderHook(() => useMcpStatus('/vault', onToast))
}
function mockClipboard(writeText = vi.fn(() => Promise.resolve())) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
return writeText
}
function mockStatusFlow(
initialStatus: 'installed' | 'not_installed',
overrides: Partial<Record<'register_mcp_tools' | 'remove_mcp_tools', unknown>> = {},
@@ -76,6 +84,7 @@ async function runMutationScenario({
describe('useMcpStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
mockClipboard()
})
it('checks the active vault status without auto-registering on mount', async () => {
@@ -160,4 +169,47 @@ describe('useMcpStatus', () => {
expect(mockInvoke).toHaveBeenCalledWith(overrideKey, commandArgs)
expect(onToast).toHaveBeenCalledWith(expect.stringContaining(toastFragment))
})
it('loads the exact manual MCP config snippet for the active vault', async () => {
const snippet = JSON.stringify({ mcpServers: { tolaria: { type: 'stdio' } } })
mockCommands({
check_mcp_status: 'installed',
get_mcp_config_snippet: snippet,
})
const { result } = renderSubject()
await waitFor(() => {
expect(result.current.mcpStatus).toBe('installed')
})
await act(async () => {
await result.current.loadMcpConfigSnippet()
})
expect(result.current.mcpConfigSnippet).toBe(snippet)
expect(result.current.mcpConfigError).toBeNull()
expect(mockInvoke).toHaveBeenCalledWith('get_mcp_config_snippet', { vaultPath: '/vault' })
})
it('copies the manual MCP config snippet to the clipboard', async () => {
const writeText = mockClipboard()
const onToast = vi.fn()
const snippet = JSON.stringify({ mcpServers: { tolaria: { type: 'stdio' } } })
mockCommands({
check_mcp_status: 'not_installed',
get_mcp_config_snippet: snippet,
})
const { result } = renderSubject(onToast)
await waitFor(() => {
expect(result.current.mcpStatus).toBe('not_installed')
})
await act(async () => {
await expect(result.current.copyMcpConfig()).resolves.toBe(true)
})
expect(writeText).toHaveBeenCalledWith(snippet)
expect(onToast).toHaveBeenCalledWith('Tolaria MCP config copied to clipboard')
})
})

View File

@@ -1,49 +1,136 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
export type McpStatus = 'checking' | 'installed' | 'not_installed'
type ManualConfigSnippet = string
type McpCommand =
| 'check_mcp_status'
| 'get_mcp_config_snippet'
| 'register_mcp_tools'
| 'remove_mcp_tools'
type McpCommandResult = string
type McpStatusResponse = string
type ToastHandler = (msg: ToastMessage) => void
type ToastMessage = string
type VaultPath = string
function tauriCall<T>(command: string, args?: Record<string, unknown>): Promise<T> {
interface ManualMcpConfigState {
error: ToastMessage | null
loading: boolean
snippet: ManualConfigSnippet | null
vaultPath: VaultPath
}
const EMPTY_MANUAL_CONFIG: ManualMcpConfigState = {
error: null,
loading: false,
snippet: null,
vaultPath: '',
}
function tauriCall<T>(command: McpCommand, args?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
function normalizeMcpStatus(value: string | null | undefined): McpStatus {
function normalizeMcpStatus(value: McpStatusResponse | null | undefined): McpStatus {
return value === 'installed' ? 'installed' : 'not_installed'
}
async function fetchMcpStatus(vaultPath: string): Promise<McpStatus> {
async function fetchMcpStatus(vaultPath: VaultPath): Promise<McpStatus> {
try {
const result = await tauriCall<string>('check_mcp_status', { vaultPath })
const result = await tauriCall<McpStatusResponse>('check_mcp_status', { vaultPath })
return normalizeMcpStatus(result)
} catch {
return 'not_installed'
}
}
function connectSuccessToast(result: string): string {
function connectSuccessToast(result: McpCommandResult): ToastMessage {
return result === 'registered'
? 'Tolaria external AI tools connected successfully'
: 'Tolaria external AI tools setup refreshed successfully'
}
function disconnectSuccessToast(result: string): string {
function disconnectSuccessToast(result: McpCommandResult): ToastMessage {
return result === 'removed'
? 'Tolaria external AI tools disconnected successfully'
: 'Tolaria external AI tools were already disconnected'
}
function errorMessage(error: unknown): ToastMessage {
return error instanceof Error ? error.message : String(error)
}
function visibleManualConfig(
state: ManualMcpConfigState,
vaultPath: VaultPath,
): ManualMcpConfigState {
return state.vaultPath === vaultPath ? state : EMPTY_MANUAL_CONFIG
}
async function writeClipboardText(value: ManualConfigSnippet): Promise<void> {
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API is unavailable')
}
await navigator.clipboard.writeText(value)
}
function useManualMcpConfig(
vaultPath: VaultPath,
onToastRef: MutableRefObject<ToastHandler>,
) {
const [manualConfig, setManualConfig] = useState<ManualMcpConfigState>(EMPTY_MANUAL_CONFIG)
const loadMcpConfigSnippet = useCallback(async () => {
setManualConfig({ error: null, loading: true, snippet: null, vaultPath })
try {
const snippet = await tauriCall<ManualConfigSnippet>('get_mcp_config_snippet', { vaultPath })
setManualConfig({ error: null, loading: false, snippet, vaultPath })
return snippet
} catch (error) {
const message = errorMessage(error)
setManualConfig({ error: message, loading: false, snippet: null, vaultPath })
throw error
}
}, [vaultPath])
const copyMcpConfig = useCallback(async () => {
try {
const snippet = await loadMcpConfigSnippet()
await writeClipboardText(snippet)
onToastRef.current('Tolaria MCP config copied to clipboard')
return true
} catch (error) {
onToastRef.current(`Copy MCP config failed: ${errorMessage(error)}`)
return false
}
}, [loadMcpConfigSnippet, onToastRef])
const currentManualConfig = visibleManualConfig(manualConfig, vaultPath)
return {
mcpConfigSnippet: currentManualConfig.snippet,
mcpConfigLoading: currentManualConfig.loading,
mcpConfigError: currentManualConfig.error,
loadMcpConfigSnippet,
copyMcpConfig,
}
}
/**
* Detects whether the active vault is explicitly connected to external MCP
* clients and exposes connect / disconnect actions.
*/
export function useMcpStatus(
vaultPath: string,
onToast: (msg: string) => void,
vaultPath: VaultPath,
onToast: ToastHandler,
) {
const [status, setStatus] = useState<McpStatus>('checking')
const onToastRef = useRef(onToast)
useEffect(() => { onToastRef.current = onToast })
const manualConfigActions = useManualMcpConfig(vaultPath, onToastRef)
const refreshMcpStatus = useCallback(async () => {
const nextStatus = await fetchMcpStatus(vaultPath)
@@ -91,5 +178,11 @@ export function useMcpStatus(
}
}, [refreshMcpStatus])
return { mcpStatus: status, refreshMcpStatus, connectMcp, disconnectMcp }
return {
mcpStatus: status,
refreshMcpStatus,
connectMcp,
disconnectMcp,
...manualConfigActions,
}
}

View File

@@ -74,11 +74,17 @@ describe('useNoteActions hook', () => {
return renderHook(() => useNoteActions(makeConfig(entries)))
}
function createImmediateEntry(type?: string) {
async function flushAsyncWork() {
await Promise.resolve()
await Promise.resolve()
}
async function createImmediateEntry(type?: string) {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderActions()
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate(type)
await flushAsyncWork()
})
const [createdEntry] = addEntry.mock.calls[0]
vi.restoreAllMocks()
@@ -194,25 +200,29 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Property deleted')
})
it('handleCreateNoteImmediate creates note with timestamp-based title', () => {
const createdEntry = createImmediateEntry()
it('handleCreateNoteImmediate creates note with timestamp-based title', async () => {
const createdEntry = await createImmediateEntry()
expect(createdEntry.title).toBe('Untitled Note 1700000000')
expect(createdEntry.filename).toBe('untitled-note-1700000000.md')
expect(createdEntry.isA).toBe('Note')
})
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', async () => {
vi.useFakeTimers()
let ts = 1700000000000
vi.spyOn(Date, 'now').mockImplementation(() => { ts += 1000; return ts })
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushAsyncWork()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2)
await flushAsyncWork()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2) })
expect(addEntry).toHaveBeenCalledTimes(3)
const filenames = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.filename)
@@ -224,8 +234,8 @@ describe('useNoteActions hook', () => {
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate accepts custom type', () => {
const createdEntry = createImmediateEntry('Project')
it('handleCreateNoteImmediate accepts custom type', async () => {
const createdEntry = await createImmediateEntry('Project')
expect(createdEntry.filename).toMatch(/^untitled-project-\d+\.md$/)
expect(createdEntry.isA).toBe('Project')
})
@@ -255,40 +265,29 @@ describe('useNoteActions hook', () => {
expect(tabContent).toContain('## Steps')
})
it('handleCreateNoteImmediate does not throw for custom types with special characters', () => {
it.each([
['Q&A', (entry: VaultEntry) => { expect(entry.isA).toBe('Q&A') }],
['+++', (entry: VaultEntry) => { expect(entry.filename).not.toBe('.md') }],
])('handleCreateNoteImmediate handles custom type "%s"', async (typeName, assertEntry) => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
expect(() => {
act(() => {
result.current.handleCreateNoteImmediate('Q&A')
})
}).not.toThrow()
const [entry] = addEntry.mock.calls[0]
expect(entry.isA).toBe('Q&A')
expect(entry.path).not.toContain('//')
})
it('handleCreateNoteImmediate does not throw for types that slugify to empty string', () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
expect(() => {
act(() => {
result.current.handleCreateNoteImmediate('+++')
})
}).not.toThrow()
await act(async () => {
expect(() => { result.current.handleCreateNoteImmediate(typeName) }).not.toThrow()
await flushAsyncWork()
})
const [entry] = addEntry.mock.calls[0]
expect(entry.path).not.toContain('//')
expect(entry.filename).not.toBe('.md')
assertEntry(entry)
})
it('handleCreateNoteImmediate uses template for typed notes', () => {
it('handleCreateNoteImmediate uses template for typed notes', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom Template\n\n' })
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate('Project')
await flushAsyncWork()
})
const tabContent = result.current.tabs[0].content
@@ -307,7 +306,15 @@ describe('useNoteActions hook', () => {
})
describe('pending save lifecycle', () => {
it('createAndPersist calls addPendingSave on start (non-Tauri)', async () => {
it.each([
['start', 'Pending Test', 'pending-test.md', 'addPendingSave'],
['completion', 'Persist OK', 'persist-ok.md', 'removePendingSave'],
])('createAndPersist calls pending-save callback on %s (non-Tauri)', async (
_phase,
title,
pathFragment,
callbackName,
) => {
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
const config = makeConfig()
@@ -317,28 +324,12 @@ describe('useNoteActions hook', () => {
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNote('Pending Test', 'Note')
await new Promise((r) => setTimeout(r, 0))
result.current.handleCreateNote(title, 'Note')
await flushAsyncWork()
})
expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('pending-test.md'))
})
it('createAndPersist calls removePendingSave when persist completes (non-Tauri)', async () => {
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
const config = makeConfig()
config.addPendingSave = addPendingSave
config.removePendingSave = removePendingSave
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNote('Persist OK', 'Note')
await new Promise((r) => setTimeout(r, 0))
})
expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('persist-ok.md'))
const callback = callbackName === 'addPendingSave' ? addPendingSave : removePendingSave
expect(callback).toHaveBeenCalledWith(expect.stringContaining(pathFragment))
})
it('createAndPersist calls removePendingSave AND reverts when persist fails (Tauri)', async () => {
@@ -363,22 +354,34 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
})
it('handleCreateNoteImmediate calls trackUnsaved and markContentPending (no disk write)', async () => {
const trackUnsaved = vi.fn()
const markContentPending = vi.fn()
it('handleCreateNoteImmediate creates the backing file before opening the note', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockResolvedValueOnce(undefined)
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
const onNewNotePersisted = vi.fn()
const config = makeConfig()
config.trackUnsaved = trackUnsaved
config.markContentPending = markContentPending
config.addPendingSave = addPendingSave
config.removePendingSave = removePendingSave
config.onNewNotePersisted = onNewNotePersisted
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNoteImmediate()
await new Promise((r) => setTimeout(r, 0))
await flushAsyncWork()
})
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/))
expect(markContentPending).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/), expect.stringContaining('type: Note'))
const createdPath = expect.stringMatching(/untitled-note-\d+\.md$/)
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
path: createdPath,
content: expect.stringContaining('type: Note'),
})
expect(addPendingSave).toHaveBeenCalledWith(createdPath)
expect(removePendingSave).toHaveBeenCalledWith(createdPath)
expect(onNewNotePersisted).toHaveBeenCalledOnce()
expect(addEntry).toHaveBeenCalledTimes(1)
expect(result.current.tabs[0].entry.path).toMatch(/untitled-note-\d+\.md$/)
})
it('calls onNewNotePersisted after successful disk write (non-Tauri)', async () => {
@@ -454,20 +457,24 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).not.toHaveBeenCalled()
})
it('handleCreateNoteImmediate does not call invoke (no disk write)', async () => {
it('handleCreateNoteImmediate writes each rapid note before opening it', async () => {
vi.useFakeTimers()
vi.mocked(invoke).mockResolvedValue(undefined)
const { result } = renderHook(() => useNoteActions(makeConfig()))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushAsyncWork()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2)
await flushAsyncWork()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2) })
expect(addEntry).toHaveBeenCalledTimes(3)
// No disk writes for immediate creation — notes are unsaved/in-memory
expect(invoke).not.toHaveBeenCalled()
expect(vi.mocked(invoke).mock.calls.filter(([command]) => command === 'create_note_content')).toHaveLength(3)
expect(removeEntry).not.toHaveBeenCalled()
})

View File

@@ -211,6 +211,10 @@ describe('useNoteCreation hook', () => {
})
const tabDeps = { openTabWithContent }
const flushImmediateCreate = async () => {
await Promise.resolve()
await Promise.resolve()
}
beforeEach(() => {
vi.clearAllMocks()
@@ -230,10 +234,13 @@ describe('useNoteCreation hook', () => {
expect(openTabWithContent.mock.calls[0][1]).toBe('---\ntitle: Test Note\ntype: Note\n---\n')
})
it('handleCreateNoteImmediate generates timestamp-based title', () => {
it('handleCreateNoteImmediate generates timestamp-based title', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate() })
await act(async () => {
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(addEntry).toHaveBeenCalledTimes(1)
expect(addEntry.mock.calls[0][0].title).toBe('Untitled Note 1700000000')
expect(addEntry.mock.calls[0][0].filename).toBe('untitled-note-1700000000.md')
@@ -242,17 +249,21 @@ describe('useNoteCreation hook', () => {
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', () => {
it('handleCreateNoteImmediate generates unique names on rapid calls via timestamp', async () => {
vi.useFakeTimers()
let ts = 1700000000000
vi.spyOn(Date, 'now').mockImplementation(() => { ts += 1000; return ts })
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2)
await flushImmediateCreate()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2) })
const filenames = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.filename)
// Each call consumes Date.now() multiple times (filename + buildNewEntry), so just verify uniqueness
expect(new Set(filenames).size).toBe(3)
@@ -262,16 +273,20 @@ describe('useNoteCreation hook', () => {
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate avoids filename collisions when called twice in the same second', () => {
it('handleCreateNoteImmediate avoids filename collisions when called twice in the same second', async () => {
vi.useFakeTimers()
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushImmediateCreate()
})
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
const filenames = addEntry.mock.calls.map(([entry]: [VaultEntry]) => entry.filename)
expect(filenames).toEqual([
@@ -282,66 +297,117 @@ describe('useNoteCreation hook', () => {
vi.restoreAllMocks()
})
it('serializes rapid immediate-create bursts after the first note', () => {
it('serializes rapid immediate-create bursts after the first note', async () => {
vi.useFakeTimers()
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(addEntry).toHaveBeenCalledTimes(1)
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushImmediateCreate()
})
expect(addEntry).toHaveBeenCalledTimes(2)
act(() => { vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS) })
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushImmediateCreate()
})
expect(addEntry).toHaveBeenCalledTimes(3)
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate accepts custom type', () => {
it('handleCreateNoteImmediate accepts custom type', async () => {
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate('Project') })
await act(async () => {
result.current.handleCreateNoteImmediate('Project')
await flushImmediateCreate()
})
expect(addEntry.mock.calls[0][0].isA).toBe('Project')
expect(addEntry.mock.calls[0][0].status).toBeNull()
expect(openTabWithContent.mock.calls[0][1]).toBe('---\ntype: Project\n---\n\n# \n\n## Objective\n\n\n\n## Key Results\n\n\n\n## Notes\n\n')
})
it('handleCreateNoteImmediate slugifies custom type names for filenames', () => {
it('handleCreateNoteImmediate slugifies custom type names for filenames', async () => {
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => {
await act(async () => {
result.current.handleCreateNoteImmediate('Q&A / Ops')
await flushImmediateCreate()
})
expect(addEntry.mock.calls[0][0].filename).toBe('untitled-q-a-ops-1700000000.md')
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate tracks unsaved state', async () => {
const trackUnsaved = vi.fn()
const markContentPending = vi.fn()
const config = makeConfig()
config.trackUnsaved = trackUnsaved
config.markContentPending = markContentPending
it('handleCreateNoteImmediate creates the backing file before opening the note', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockResolvedValueOnce(undefined)
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
const onNewNotePersisted = vi.fn()
const config = {
...makeConfig(),
addPendingSave,
removePendingSave,
onNewNotePersisted,
}
const { result } = renderHook(() => useNoteCreation(config, tabDeps))
await act(async () => { result.current.handleCreateNoteImmediate() })
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringMatching(/untitled-note-\d+\.md$/))
expect(markContentPending).toHaveBeenCalled()
await act(async () => {
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
const createdPath = expect.stringMatching(/untitled-note-\d+\.md$/)
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
path: createdPath,
content: expect.stringContaining('type: Note'),
})
expect(addPendingSave).toHaveBeenCalledWith(createdPath)
expect(removePendingSave).toHaveBeenCalledWith(createdPath)
expect(onNewNotePersisted).toHaveBeenCalledOnce()
expect(addEntry).toHaveBeenCalledTimes(1)
expect(openTabWithContent).toHaveBeenCalledTimes(1)
expect(vi.mocked(invoke).mock.invocationCallOrder[0]).toBeLessThan(
openTabWithContent.mock.invocationCallOrder[0],
)
})
it('handleCreateNoteImmediate requests editor focus for the new path', () => {
it('handleCreateNoteImmediate does not open an optimistic note when disk creation fails', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockRejectedValueOnce(new Error('disk full'))
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
await act(async () => {
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(addEntry).not.toHaveBeenCalled()
expect(openTabWithContent).not.toHaveBeenCalled()
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
})
it('handleCreateNoteImmediate requests editor focus for the new path', async () => {
const focusListener = vi.fn()
window.addEventListener('laputa:focus-editor', focusListener)
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
act(() => { result.current.handleCreateNoteImmediate() })
await act(async () => {
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(focusListener).toHaveBeenCalledTimes(1)
const event = focusListener.mock.calls[0][0] as CustomEvent

View File

@@ -1,10 +1,11 @@
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, addMockEntry, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import { slugifyNoteStem as slugify } from '../utils/noteSlug'
import { resolveEntry } from '../utils/wikilink'
import { trackEvent } from '../lib/telemetry'
import { cacheNoteContent } from './useTabManagement'
export interface NewEntryParams {
path: string
@@ -370,13 +371,15 @@ async function createTypeSilently({
}
interface ImmediateCreateDeps {
addPendingSave?: (path: string) => void
entries: VaultEntry[]
vaultPath: string
pendingSlugs: Set<string>
openTabWithContent: (entry: VaultEntry, content: string) => void
addEntry: (entry: VaultEntry) => void
trackUnsaved?: (path: string) => void
markContentPending?: (path: string, content: string) => void
onNewNotePersisted?: () => void
removePendingSave?: (path: string) => void
setToastMessage: (msg: string | null) => void
}
interface ImmediateCreateRequest {
@@ -384,12 +387,14 @@ interface ImmediateCreateRequest {
}
interface ImmediateCreateQueueConfig {
addPendingSave?: (path: string) => void
entries: VaultEntry[]
vaultPath: string
addEntry: (entry: VaultEntry) => void
openTabWithContent: (entry: VaultEntry, content: string) => void
trackUnsaved?: (path: string) => void
markContentPending?: (path: string, content: string) => void
onNewNotePersisted?: () => void
removePendingSave?: (path: string) => void
setToastMessage: (msg: string | null) => void
}
/** Generate a unique untitled filename using a timestamp. */
@@ -410,8 +415,26 @@ function generateUntitledFilename(entries: VaultEntry[], type: string, pendingSl
return candidate
}
/** Create an untitled note without persisting to disk (deferred save). */
function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
async function persistImmediateEntry(
deps: ImmediateCreateDeps,
entry: VaultEntry,
content: string,
): Promise<boolean> {
try {
await persistOptimistic(entry.path, content, {
onStart: deps.addPendingSave,
onEnd: deps.removePendingSave,
onPersisted: deps.onNewNotePersisted,
})
return true
} catch (error) {
deps.setToastMessage(createPersistFailureMessage(entry, error))
return false
}
}
/** Create an untitled note and write its backing file before opening it. */
async function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): Promise<boolean> {
const noteType = type || 'Note'
const slug = generateUntitledFilename(deps.entries, noteType, deps.pendingSlugs)
const title = slug_to_title(slug)
@@ -419,11 +442,68 @@ function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
const status = null
const entry = buildNewEntry({ path: `${deps.vaultPath}/${slug}.md`, slug, title, type: noteType, status })
const content = buildNoteContent({ title: null, type: noteType, status, template, initialEmptyHeading: true })
const didPersist = await persistImmediateEntry(deps, entry, content)
if (!didPersist) return false
cacheNoteContent(entry.path, content)
deps.openTabWithContent(entry, content)
addEntryWithMock(entry, content, deps.addEntry)
deps.trackUnsaved?.(entry.path)
deps.markContentPending?.(entry.path, content)
signalFocusEditor({ path: entry.path, selectTitle: true })
return true
}
function trackImmediateCreate(request: ImmediateCreateRequest, didCreate: boolean): void {
if (!didCreate) return
trackEvent('note_created', {
has_type: request.type ? 1 : 0,
creation_path: request.type ? 'type_section' : 'cmd_n',
})
}
function useLatestImmediateCreateDeps(
config: ImmediateCreateQueueConfig,
pendingSlugsRef: MutableRefObject<Set<string>>,
) {
const {
entries,
vaultPath,
openTabWithContent,
addEntry,
addPendingSave,
onNewNotePersisted,
removePendingSave,
setToastMessage,
} = config
const latestDepsRef = useRef<ImmediateCreateDeps | null>(null)
const syncDeps = useCallback(() => {
latestDepsRef.current = {
entries,
vaultPath,
pendingSlugs: pendingSlugsRef.current,
openTabWithContent,
addEntry,
addPendingSave,
onNewNotePersisted,
removePendingSave,
setToastMessage,
}
}, [
entries,
vaultPath,
openTabWithContent,
addEntry,
addPendingSave,
onNewNotePersisted,
removePendingSave,
setToastMessage,
pendingSlugsRef,
])
useEffect(() => {
syncDeps()
}, [syncDeps])
return { latestDepsRef, syncDeps }
}
function useImmediateCreateQueue(config: ImmediateCreateQueueConfig): (type?: string) => void {
@@ -431,40 +511,13 @@ function useImmediateCreateQueue(config: ImmediateCreateQueueConfig): (type?: st
const queuedImmediateCreatesRef = useRef<ImmediateCreateRequest[]>([])
const immediateCreateLockedRef = useRef(false)
const immediateCreateTimerRef = useRef<number | null>(null)
const latestDepsRef = useRef<ImmediateCreateDeps | null>(null)
const syncDeps = useCallback(() => {
latestDepsRef.current = {
entries: config.entries,
vaultPath: config.vaultPath,
pendingSlugs: pendingSlugsRef.current,
openTabWithContent: config.openTabWithContent,
addEntry: config.addEntry,
trackUnsaved: config.trackUnsaved,
markContentPending: config.markContentPending,
}
}, [
config.entries,
config.vaultPath,
config.openTabWithContent,
config.addEntry,
config.trackUnsaved,
config.markContentPending,
])
useEffect(() => {
syncDeps()
}, [syncDeps])
const { latestDepsRef, syncDeps } = useLatestImmediateCreateDeps(config, pendingSlugsRef)
const executeRequest = useCallback((request: ImmediateCreateRequest) => {
const deps = latestDepsRef.current
if (!deps) return
createNoteImmediate(deps, request.type)
trackEvent('note_created', {
has_type: request.type ? 1 : 0,
creation_path: request.type ? 'type_section' : 'cmd_n',
})
}, [])
void createNoteImmediate(deps, request.type).then((didCreate) => trackImmediateCreate(request, didCreate))
}, [latestDepsRef])
const scheduleQueuedBurst = useCallback(function scheduleQueuedBurst() {
if (immediateCreateTimerRef.current !== null) return
@@ -577,9 +630,11 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
entries,
vaultPath,
addEntry,
addPendingSave,
openTabWithContent,
trackUnsaved: config.trackUnsaved,
markContentPending: config.markContentPending,
onNewNotePersisted,
removePendingSave,
setToastMessage,
})
return {

View File

@@ -0,0 +1,99 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SidebarSelection, ViewFile } from '../types'
import { useSavedViewOrdering } from './useSavedViewOrdering'
const mockInvokeFn = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
function makeView(filename: string, name: string, order: number): ViewFile {
return {
filename,
definition: {
name,
icon: null,
color: null,
order,
sort: null,
filters: { all: [] },
},
}
}
function renderOrdering(selection: SidebarSelection = { kind: 'view', filename: 'beta.yml' }) {
const reloadViews = vi.fn().mockResolvedValue([])
const loadModifiedFiles = vi.fn().mockResolvedValue(undefined)
const onToast = vi.fn()
const views = [
makeView('alpha.yml', 'Alpha', 0),
makeView('beta.yml', 'Beta', 1),
makeView('gamma.yml', 'Gamma', 2),
]
const hook = renderHook(() => useSavedViewOrdering({
views,
selection,
vaultPath: '/vault',
reloadViews,
loadModifiedFiles,
onToast,
}))
return { ...hook, reloadViews, loadModifiedFiles, onToast }
}
describe('useSavedViewOrdering', () => {
beforeEach(() => {
mockInvokeFn.mockReset()
mockInvokeFn.mockResolvedValue(null)
})
it('persists a dense order when moving a saved view', async () => {
const { result, reloadViews, loadModifiedFiles, onToast } = renderOrdering()
await act(async () => {
await result.current.onMoveView('beta.yml', 'up')
})
expect(mockInvokeFn).toHaveBeenCalledWith('save_view_cmd', {
vaultPath: '/vault',
filename: 'beta.yml',
definition: expect.objectContaining({ name: 'Beta', order: 0 }),
})
expect(mockInvokeFn).toHaveBeenCalledWith('save_view_cmd', {
vaultPath: '/vault',
filename: 'alpha.yml',
definition: expect.objectContaining({ name: 'Alpha', order: 1 }),
})
expect(reloadViews).toHaveBeenCalledOnce()
expect(loadModifiedFiles).toHaveBeenCalledOnce()
expect(onToast).toHaveBeenCalledWith('Views reordered')
})
it('exposes command state for the selected saved view', () => {
const { result } = renderOrdering()
expect(result.current.selectedViewName).toBe('Beta')
expect(result.current.selectedViewFilename).toBe('beta.yml')
expect(result.current.canMoveSelectedViewUp).toBe(true)
expect(result.current.canMoveSelectedViewDown).toBe(true)
})
it('ignores invalid drag orders without writing view files', async () => {
const { result, reloadViews } = renderOrdering()
await act(async () => {
await result.current.onReorderViews(['alpha.yml', 'alpha.yml', 'gamma.yml'])
})
expect(mockInvokeFn).not.toHaveBeenCalled()
expect(reloadViews).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,96 @@
import { useCallback, useMemo } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { SidebarSelection, ViewFile } from '../types'
import {
buildViewOrderUpdates,
canMoveView,
moveView,
orderViewsByFilename,
type ViewMoveDirection,
type ViewMoveHandler,
type ViewReorderHandler,
} from '../utils/viewOrdering'
interface SavedViewOrderingConfig {
views: ViewFile[]
selection: SidebarSelection
vaultPath: string
reloadViews: () => Promise<unknown>
loadModifiedFiles: () => Promise<void>
onToast: (message: string) => void
}
interface SavedViewOrdering {
onReorderViews: ViewReorderHandler
onMoveView: ViewMoveHandler
selectedViewName?: string
selectedViewFilename: string | null
onMoveSelectedViewUp?: () => void
onMoveSelectedViewDown?: () => void
canMoveSelectedViewUp: boolean
canMoveSelectedViewDown: boolean
}
function selectedSavedView(views: ViewFile[], selection: SidebarSelection): ViewFile | null {
if (selection.kind !== 'view') return null
return views.find((view) => view.filename === selection.filename) ?? null
}
function viewMoveCommand(
view: ViewFile | null,
direction: ViewMoveDirection,
onMoveView: ViewMoveHandler,
): (() => void) | undefined {
if (!view) return undefined
const { filename } = view
return () => { void onMoveView(filename, direction) }
}
export function useSavedViewOrdering({
views,
selection,
vaultPath,
reloadViews,
loadModifiedFiles,
onToast,
}: SavedViewOrderingConfig): SavedViewOrdering {
const persistViewOrder = useCallback(async (orderedViews: ViewFile[]) => {
const target = isTauri() ? invoke : mockInvoke
await Promise.all(buildViewOrderUpdates(orderedViews).map(({ filename, definition }) => (
target('save_view_cmd', { vaultPath, filename, definition })
)))
await reloadViews()
await loadModifiedFiles()
onToast('Views reordered')
}, [loadModifiedFiles, onToast, reloadViews, vaultPath])
const onReorderViews = useCallback<ViewReorderHandler>(async (orderedFilenames) => {
const orderedViews = orderViewsByFilename(views, orderedFilenames)
if (!orderedViews) return
await persistViewOrder(orderedViews)
}, [persistViewOrder, views])
const onMoveView = useCallback<ViewMoveHandler>(async (filename, direction) => {
const orderedViews = moveView(views, filename, direction)
if (!orderedViews) return
await persistViewOrder(orderedViews)
}, [persistViewOrder, views])
const selectedView = useMemo(
() => selectedSavedView(views, selection),
[selection, views],
)
const selectedViewFilename = selectedView?.filename ?? null
return {
onReorderViews,
onMoveView,
selectedViewName: selectedView?.definition.name,
selectedViewFilename,
onMoveSelectedViewUp: viewMoveCommand(selectedView, 'up', onMoveView),
onMoveSelectedViewDown: viewMoveCommand(selectedView, 'down', onMoveView),
canMoveSelectedViewUp: selectedViewFilename ? canMoveView(views, selectedViewFilename, 'up') : false,
canMoveSelectedViewDown: selectedViewFilename ? canMoveView(views, selectedViewFilename, 'down') : false,
}
}

View File

@@ -22,6 +22,7 @@ describe('useVaultAiGuidanceStatus', () => {
mockInvoke.mockResolvedValue({
agents_state: 'managed',
claude_state: 'broken',
gemini_state: 'missing',
can_restore: true,
})
@@ -29,11 +30,13 @@ describe('useVaultAiGuidanceStatus', () => {
expect(result.current.status.agentsState).toBe('checking')
expect(result.current.status.claudeState).toBe('checking')
expect(result.current.status.geminiState).toBe('checking')
await waitFor(() => {
expect(result.current.status).toEqual({
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'missing',
canRestore: true,
})
})
@@ -44,11 +47,13 @@ describe('useVaultAiGuidanceStatus', () => {
.mockResolvedValueOnce({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
})
.mockResolvedValueOnce({
agents_state: 'managed',
claude_state: 'broken',
gemini_state: 'managed',
can_restore: true,
})
@@ -65,6 +70,7 @@ describe('useVaultAiGuidanceStatus', () => {
expect(result.current.status).toEqual({
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'managed',
canRestore: true,
})
})

View File

@@ -11,6 +11,7 @@ import {
type RawVaultAiGuidanceStatus = Partial<{
agents_state: VaultAiGuidanceFileState | null
claude_state: VaultAiGuidanceFileState | null
gemini_state: VaultAiGuidanceFileState | null
can_restore: boolean | null
}>

View File

@@ -11,6 +11,8 @@ const mockEntries: VaultEntry[] = [
archived: false,
modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
sidebarLabel: null, view: null, visible: null, organized: false, favorite: false, favoriteIndex: null,
listPropertiesDisplay: [], properties: {}, hasH1: false,
},
]
@@ -152,6 +154,55 @@ describe('useVaultLoader', () => {
expect(result.current.entries[0].title).toBe('Hello')
})
it('normalizes missing entry and view string metadata from vault load', async () => {
backendInvokeFn.mockImplementation(((cmd: string) => {
if (isVaultLoadCommand(cmd)) {
return Promise.resolve([
{
path: '/vault/note/missing-title.md',
filename: undefined,
title: undefined,
aliases: undefined,
outgoingLinks: undefined,
relationships: undefined,
properties: undefined,
},
])
}
if (cmd === 'list_views') return Promise.resolve([{ filename: undefined, definition: {} }])
if (cmd === 'get_modified_files') return Promise.resolve([])
if (cmd === 'list_vault_folders') return Promise.resolve([])
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitForEntries(result)
await waitFor(() => {
expect(result.current.views).toHaveLength(1)
})
expect(result.current.entries[0]).toMatchObject({
path: '/vault/note/missing-title.md',
filename: 'missing-title.md',
title: 'missing-title',
aliases: [],
outgoingLinks: [],
relationships: {},
properties: {},
})
expect(result.current.views[0]).toMatchObject({
filename: 'view-1.yml',
definition: {
name: 'View 1',
icon: null,
color: null,
sort: null,
filters: { all: [] },
},
})
})
it('reports initial vault loading until the note scan resolves', async () => {
const entriesLoad = createDeferred<VaultEntry[]>()
backendInvokeFn.mockImplementation(((cmd: string) => {

View File

@@ -1,6 +1,4 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry, FolderNode, GitCommit, ModifiedFile, NoteStatus, GitPushResult, ViewFile } from '../types'
import {
GITIGNORED_VISIBILITY_CHANGED_EVENT,
@@ -8,46 +6,16 @@ import {
type GitignoredVisibilityChangedEvent,
} from '../lib/gitignoredVisibilityEvents'
import { clearPrefetchCache } from './useTabManagement'
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
}
function hasVaultPath(vaultPath: string): boolean {
return vaultPath.trim().length > 0
}
function loadVaultEntries(vaultPath: string): Promise<VaultEntry[]> {
const command = isTauri() ? 'reload_vault' : 'list_vault'
return tauriCall<VaultEntry[]>(command, { path: vaultPath })
}
async function loadVaultData(vaultPath: string) {
if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing')
const entries = await loadVaultEntries(vaultPath)
console.log(`Vault scan complete: ${entries.length} entries found`)
return { entries }
}
function loadVaultFolders(vaultPath: string): Promise<FolderNode[]> {
return tauriCall<FolderNode[]>('list_vault_folders', { path: vaultPath })
}
function loadVaultViews(vaultPath: string): Promise<ViewFile[]> {
return tauriCall<ViewFile[]>('list_views', { vaultPath })
}
async function loadVaultChrome(vaultPath: string) {
const [folders, views] = await Promise.all([
loadVaultFolders(vaultPath).catch(() => [] as FolderNode[]),
loadVaultViews(vaultPath).catch(() => [] as ViewFile[]),
])
return {
folders: folders ?? [],
views: views ?? [],
}
}
import {
commitWithPush,
hasVaultPath,
loadVaultChrome,
loadVaultData,
loadVaultFolders,
loadVaultViews,
reloadVaultEntries,
tauriCall,
} from './vaultLoaderCommands'
function resetVaultState(options: {
clearNewPaths: () => void
@@ -78,11 +46,11 @@ async function loadInitialVaultState(options: {
setViews: (views: ViewFile[]) => void
}) {
const { path, isCurrentVaultPath, setEntries, setFolders, setIsLoading, setViews } = options
const chromeLoad = loadVaultChrome(path)
const chromeLoad = loadVaultChrome({ vaultPath: path })
setIsLoading(true)
try {
const { entries } = await loadVaultData(path)
const { entries } = await loadVaultData({ vaultPath: path })
if (isCurrentVaultPath(path)) setEntries(entries)
} catch (err) {
console.warn('Vault scan failed:', err)
@@ -106,15 +74,6 @@ function useCurrentVaultPathGuard(vaultPath: string) {
return useCallback((path: string) => currentPathRef.current === path, [])
}
async function commitWithPush(vaultPath: string, message: string): Promise<GitPushResult> {
if (!isTauri()) {
await mockInvoke<string>('git_commit', { message })
return mockInvoke<GitPushResult>('git_push', {})
}
await invoke<string>('git_commit', { vaultPath, message })
return invoke<GitPushResult>('git_push', { vaultPath })
}
function useNewNoteTracker() {
const [newPaths, setNewPaths] = useState<Set<string>>(new Set())
@@ -242,7 +201,7 @@ function useInitialVaultLoad({
})
resetReloading()
if (!hasVaultPath(path)) return
if (!hasVaultPath({ vaultPath: path })) return
let cancelled = false
void loadInitialVaultState({
@@ -277,13 +236,17 @@ function useModifiedFilesLoader(vaultPath: string, isCurrentVaultPath: (path: st
const path = vaultPath
setModifiedFilesError(null)
if (!hasVaultPath(path)) {
if (!hasVaultPath({ vaultPath: path })) {
setModifiedFiles([])
return
}
try {
const files = await tauriCall<ModifiedFile[]>('get_modified_files', { vaultPath: path }, {})
const files = await tauriCall<ModifiedFile[]>({
command: 'get_modified_files',
tauriArgs: { vaultPath: path },
mockArgs: {},
})
if (isCurrentVaultPath(path)) setModifiedFiles(files)
} catch (err) {
if (!isCurrentVaultPath(path)) return
@@ -347,18 +310,32 @@ function useEntryMutations(
function useGitLoaders(vaultPath: string) {
const loadGitHistory = useCallback(async (path: string): Promise<GitCommit[]> => {
try { return await tauriCall<GitCommit[]>('get_file_history', { vaultPath, path }, { path }) }
try {
return await tauriCall<GitCommit[]>({
command: 'get_file_history',
tauriArgs: { vaultPath, path },
mockArgs: { path },
})
}
catch (err) { console.warn('Failed to load git history:', err); return [] }
}, [vaultPath])
const loadDiffAtCommit = useCallback((path: string, commitHash: string): Promise<string> =>
tauriCall<string>('get_file_diff_at_commit', { vaultPath, path, commitHash }, { path, commitHash }), [vaultPath])
tauriCall<string>({
command: 'get_file_diff_at_commit',
tauriArgs: { vaultPath, path, commitHash },
mockArgs: { path, commitHash },
}), [vaultPath])
const loadDiff = useCallback((path: string): Promise<string> =>
tauriCall<string>('get_file_diff', { vaultPath, path }, { path }), [vaultPath])
tauriCall<string>({
command: 'get_file_diff',
tauriArgs: { vaultPath, path },
mockArgs: { path },
}), [vaultPath])
const commitAndPush = useCallback((message: string): Promise<GitPushResult> =>
commitWithPush(vaultPath, message), [vaultPath])
commitWithPush({ vaultPath, message }), [vaultPath])
return { loadGitHistory, loadDiffAtCommit, loadDiff, commitAndPush }
}
@@ -386,9 +363,9 @@ function useVaultReloads({
const reloadFolders = useCallback(async () => {
const path = vaultPath
if (!hasVaultPath(path)) return [] as FolderNode[]
if (!hasVaultPath({ vaultPath: path })) return [] as FolderNode[]
try {
const folders = await loadVaultFolders(path)
const folders = await loadVaultFolders({ vaultPath: path })
if (!isCurrentVaultPath(path)) return [] as FolderNode[]
const nextFolders = folders ?? []
setFolders(nextFolders)
@@ -400,11 +377,11 @@ function useVaultReloads({
const reloadVault = useCallback(async () => {
const path = vaultPath
if (!hasVaultPath(path)) return [] as VaultEntry[]
if (!hasVaultPath({ vaultPath: path })) return [] as VaultEntry[]
clearPrefetchCache()
beginReload()
try {
const entries = await tauriCall<VaultEntry[]>('reload_vault', { path })
const entries = await reloadVaultEntries({ vaultPath: path })
if (!isCurrentVaultPath(path)) return [] as VaultEntry[]
setEntries(entries)
void loadModifiedFiles()
@@ -419,9 +396,9 @@ function useVaultReloads({
const reloadViews = useCallback(async () => {
const path = vaultPath
if (!hasVaultPath(path)) return []
if (!hasVaultPath({ vaultPath: path })) return []
try {
const nextViews = await loadVaultViews(path)
const nextViews = await loadVaultViews({ vaultPath: path })
if (!isCurrentVaultPath(path)) return []
const resolvedViews = nextViews ?? []
setViews(resolvedViews)
@@ -462,7 +439,7 @@ function useGitignoredVisibilityReloads(
export function useVaultLoader(vaultPath: string) {
const [entries, setEntries] = useState<VaultEntry[]>([])
const [folders, setFolders] = useState<FolderNode[]>([])
const [isLoading, setIsLoading] = useState(() => hasVaultPath(vaultPath))
const [isLoading, setIsLoading] = useState(() => hasVaultPath({ vaultPath }))
const [views, setViews] = useState<ViewFile[]>([])
const tracker = useNewNoteTracker()
const pendingSave = usePendingSaveTracker()

View File

@@ -0,0 +1,86 @@
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { FolderNode, GitPushResult, VaultEntry, ViewFile } from '../types'
import { normalizeVaultEntries, normalizeViewFiles } from '../utils/vaultMetadataNormalization'
interface TauriCallOptions {
command: string
tauriArgs: Record<string, unknown>
mockArgs?: Record<string, unknown>
}
interface VaultPathOptions {
vaultPath: string
}
interface CommitWithPushOptions extends VaultPathOptions {
message: string
}
interface LoadedVaultData {
entries: VaultEntry[]
}
interface LoadedVaultChrome {
folders: FolderNode[]
views: ViewFile[]
}
export function hasVaultPath({ vaultPath }: VaultPathOptions): boolean {
return vaultPath.trim().length > 0
}
export function tauriCall<T>({ command, tauriArgs, mockArgs }: TauriCallOptions): Promise<T> {
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
}
function loadVaultEntriesWithCommand({ vaultPath, command }: VaultPathOptions & { command: string }): Promise<VaultEntry[]> {
return tauriCall<unknown>({ command, tauriArgs: { path: vaultPath } })
.then((entries) => normalizeVaultEntries(entries, vaultPath))
}
function loadVaultEntries({ vaultPath }: VaultPathOptions): Promise<VaultEntry[]> {
const command = isTauri() ? 'reload_vault' : 'list_vault'
return loadVaultEntriesWithCommand({ vaultPath, command })
}
export function reloadVaultEntries({ vaultPath }: VaultPathOptions): Promise<VaultEntry[]> {
return loadVaultEntriesWithCommand({ vaultPath, command: 'reload_vault' })
}
export function loadVaultFolders({ vaultPath }: VaultPathOptions): Promise<FolderNode[]> {
return tauriCall<FolderNode[]>({ command: 'list_vault_folders', tauriArgs: { path: vaultPath } })
}
export function loadVaultViews({ vaultPath }: VaultPathOptions): Promise<ViewFile[]> {
return tauriCall<unknown>({ command: 'list_views', tauriArgs: { vaultPath } })
.then(normalizeViewFiles)
}
export async function loadVaultData({ vaultPath }: VaultPathOptions): Promise<LoadedVaultData> {
if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing')
const entries = await loadVaultEntries({ vaultPath })
console.log(`Vault scan complete: ${entries.length} entries found`)
return { entries }
}
export async function loadVaultChrome({ vaultPath }: VaultPathOptions): Promise<LoadedVaultChrome> {
const [folders, views] = await Promise.all([
loadVaultFolders({ vaultPath }).catch(() => [] as FolderNode[]),
loadVaultViews({ vaultPath }).catch(() => [] as ViewFile[]),
])
return {
folders: folders ?? [],
views: views ?? [],
}
}
export async function commitWithPush({ vaultPath, message }: CommitWithPushOptions): Promise<GitPushResult> {
if (!isTauri()) {
await mockInvoke<string>('git_commit', { message })
return mockInvoke<GitPushResult>('git_push', {})
}
await invoke<string>('git_commit', { vaultPath, message })
return invoke<GitPushResult>('git_push', { vaultPath })
}

View File

@@ -153,6 +153,9 @@
"sidebar.action.createView": "Create view",
"sidebar.action.editView": "Edit view",
"sidebar.action.deleteView": "Delete view",
"sidebar.action.reorderView": "Reorder view",
"sidebar.action.moveViewUp": "Move view up",
"sidebar.action.moveViewDown": "Move view down",
"sidebar.action.customizeSections": "Customize sections",
"sidebar.action.renameSection": "Rename section…",
"sidebar.action.customizeIconColor": "Customize icon & color…",

View File

@@ -3,6 +3,7 @@ import { afterEach, describe, it, expect, vi } from 'vitest'
const sentryMocks = vi.hoisted(() => ({
close: vi.fn(),
init: vi.fn(),
setTag: vi.fn(),
setUser: vi.fn(),
}))
@@ -22,6 +23,7 @@ afterEach(() => {
vi.unstubAllEnvs()
sentryMocks.close.mockClear()
sentryMocks.init.mockClear()
sentryMocks.setTag.mockClear()
sentryMocks.setUser.mockClear()
})
@@ -69,17 +71,23 @@ describe('trackEvent', () => {
})
describe('initSentry', () => {
it('passes the configured build release to Sentry', () => {
it.each([
['stable builds', '2026.4.23', '2026.4.23', 'stable'],
['alpha builds', '2026.4.28-alpha.7', undefined, 'prerelease'],
['local builds', '0.1.0', undefined, 'internal'],
])('sets release metadata for %s', (_name, buildVersion, sentryRelease, releaseKind) => {
vi.stubEnv('VITE_SENTRY_DSN', 'https://public@example.ingest.sentry.io/123456')
vi.stubEnv('VITE_SENTRY_RELEASE', '2026.4.23')
vi.stubEnv('VITE_SENTRY_RELEASE', buildVersion)
initSentry('anonymous-user')
expect(sentryMocks.init).toHaveBeenCalledWith(expect.objectContaining({
dsn: 'https://public@example.ingest.sentry.io/123456',
release: '2026.4.23',
release: sentryRelease,
}))
expect(sentryMocks.setUser).toHaveBeenCalledWith({ id: 'anonymous-user' })
expect(sentryMocks.setTag).toHaveBeenCalledWith('tolaria.build_version', buildVersion)
expect(sentryMocks.setTag).toHaveBeenCalledWith('tolaria.release_kind', releaseKind)
})
})

View File

@@ -25,7 +25,7 @@ let posthogInstance: typeof import('posthog-js').default | null = null
export function initSentry(anonymousId: string): void {
if (sentryInitialized) return
const { sentryDsn, sentryRelease } = resolveFrontendTelemetryConfig()
const { sentryDsn, sentryBuildVersion, sentryRelease } = resolveFrontendTelemetryConfig()
if (!sentryDsn) return
Sentry.init({
@@ -35,6 +35,14 @@ export function initSentry(anonymousId: string): void {
beforeSend: scrubSentryEvent,
})
Sentry.setUser({ id: anonymousId })
if (sentryBuildVersion) {
const releaseKind = sentryRelease
? 'stable'
: sentryBuildVersion.includes('-') ? 'prerelease' : 'internal'
Sentry.setTag('tolaria.build_version', sentryBuildVersion)
Sentry.setTag('tolaria.release_kind', releaseKind)
}
sentryInitialized = true
}

View File

@@ -48,6 +48,7 @@ describe('resolveFrontendTelemetryConfig', () => {
},
expected: {
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: 'https://eu.i.posthog.com',
@@ -62,6 +63,7 @@ describe('resolveFrontendTelemetryConfig', () => {
},
expected: {
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: 'https://eu.i.posthog.com',
@@ -83,6 +85,24 @@ describe('resolveFrontendTelemetryConfig', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: 'false' }).sentryRelease).toBe('')
})
it('keeps stable calendar versions as Sentry releases', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: '2026.4.28' }).sentryRelease).toBe('2026.4.28')
})
it('drops prerelease versions from the Sentry release field', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: '2026.4.28-alpha.7' })).toMatchObject({
sentryBuildVersion: '2026.4.28-alpha.7',
sentryRelease: '',
})
})
it('drops local development versions from the Sentry release field', () => {
expect(resolveConfig({ VITE_SENTRY_RELEASE: '0.1.0' })).toMatchObject({
sentryBuildVersion: '0.1.0',
sentryRelease: '',
})
})
it('drops invalid PostHog hosts instead of loading scripts from them', () => {
expect(resolveConfig({ VITE_POSTHOG_HOST: 'not a url' }).posthogHost).toBeNull()
})
@@ -93,6 +113,7 @@ describe('resolveFrontendTelemetryConfig', () => {
VITE_POSTHOG_HOST: 'false',
})).toEqual({
sentryDsn: '',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: null,
@@ -105,6 +126,7 @@ describe('resolveFrontendTelemetryConfig', () => {
VITE_POSTHOG_HOST: 'https://le',
})).toEqual({
sentryDsn: '',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: null,
@@ -115,6 +137,7 @@ describe('resolveFrontendTelemetryConfig', () => {
VITE_POSTHOG_HOST: 'http://localhost:8010',
})).toEqual({
sentryDsn: 'http://public@localhost:9000/123456',
sentryBuildVersion: '2026.4.23',
sentryRelease: '2026.4.23',
posthogKey: 'phc_test_key',
posthogHost: 'http://localhost:8010',

View File

@@ -17,6 +17,7 @@ type TelemetryEnv = {
export type FrontendTelemetryConfig = {
sentryDsn: string
sentryBuildVersion: string
sentryRelease: string
posthogKey: string
posthogHost: string | null
@@ -87,8 +88,18 @@ function normalizeSentryDsn(value: string): string {
}
function normalizeSentryRelease(value: string): string {
if (!value) return ''
return DISALLOWED_TELEMETRY_VALUES.has(value.toLowerCase()) ? '' : value
const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})$/.exec(value)
if (!match) return ''
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const date = new Date(Date.UTC(year, month - 1, day))
const validDate = date.getUTCFullYear() === year
&& date.getUTCMonth() === month - 1
&& date.getUTCDate() === day
return validDate ? value : ''
}
function normalizePostHogHost(value: string): string | null {
@@ -103,15 +114,17 @@ export function resolveFrontendTelemetryConfig(
const sentryDsn = normalizeSentryDsn(
sanitizeTelemetryEnvValue(env.VITE_SENTRY_DSN),
)
const sentryRelease = normalizeSentryRelease(
sanitizeTelemetryEnvValue(env.VITE_SENTRY_RELEASE),
)
const sanitizedSentryVersion = sanitizeTelemetryEnvValue(env.VITE_SENTRY_RELEASE)
const sentryBuildVersion = DISALLOWED_TELEMETRY_VALUES.has(sanitizedSentryVersion.toLowerCase())
? ''
: sanitizedSentryVersion
const sentryRelease = normalizeSentryRelease(sentryBuildVersion)
const posthogKey = sanitizeTelemetryEnvValue(env.VITE_POSTHOG_KEY)
const posthogHost = normalizePostHogHost(
sanitizeTelemetryEnvValue(env.VITE_POSTHOG_HOST),
)
return { sentryDsn, sentryRelease, posthogKey, posthogHost }
return { sentryDsn, sentryBuildVersion, sentryRelease, posthogKey, posthogHost }
}
export { DEFAULT_POSTHOG_HOST as _defaultPostHogHostForTest }

View File

@@ -51,6 +51,7 @@ describe('vaultAiGuidance helpers', () => {
expect(createCheckingVaultAiGuidanceStatus()).toEqual({
agentsState: 'checking',
claudeState: 'checking',
geminiState: 'checking',
canRestore: false,
})
})
@@ -59,10 +60,12 @@ describe('vaultAiGuidance helpers', () => {
expect(normalizeVaultAiGuidanceStatus({
agents_state: 'managed',
claude_state: 'broken',
gemini_state: 'missing',
can_restore: true,
})).toEqual({
agentsState: 'managed',
claudeState: 'broken',
geminiState: 'missing',
canRestore: true,
})
})
@@ -71,11 +74,13 @@ describe('vaultAiGuidance helpers', () => {
const restoreable = normalizeVaultAiGuidanceStatus({
agents_state: 'missing',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: true,
})
const custom = normalizeVaultAiGuidanceStatus({
agents_state: 'custom',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
})
@@ -85,13 +90,34 @@ describe('vaultAiGuidance helpers', () => {
expect(getVaultAiGuidanceSummary(custom)).toBe('Using custom AGENTS.md')
})
it('summarizes optional Gemini guidance states', () => {
const missing = normalizeVaultAiGuidanceStatus({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'missing',
can_restore: true,
})
const custom = normalizeVaultAiGuidanceStatus({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'custom',
can_restore: false,
})
expect(vaultAiGuidanceNeedsRestore(missing)).toBe(true)
expect(getVaultAiGuidanceSummary(missing)).toBe('Gemini guidance can be created')
expect(vaultAiGuidanceUsesCustomFiles(custom)).toBe(true)
expect(getVaultAiGuidanceSummary(custom)).toBe('Using custom GEMINI.md')
})
it('builds a refresh key from AGENTS and CLAUDE entries only', () => {
const key = buildVaultAiGuidanceRefreshKey([
makeEntry('alpha.md'),
makeEntry('CLAUDE.md', { modifiedAt: 20, fileSize: 30 }),
makeEntry('GEMINI.md', { modifiedAt: 25, fileSize: 35 }),
makeEntry('AGENTS.md', { modifiedAt: 15, fileSize: 40 }),
])
expect(key).toBe('/vault/AGENTS.md:15:40|/vault/CLAUDE.md:20:30')
expect(key).toBe('/vault/AGENTS.md:15:40|/vault/CLAUDE.md:20:30|/vault/GEMINI.md:25:35')
})
})

View File

@@ -5,21 +5,24 @@ export type VaultAiGuidanceFileState = 'checking' | 'managed' | 'missing' | 'bro
export interface VaultAiGuidanceStatus {
agentsState: VaultAiGuidanceFileState
claudeState: VaultAiGuidanceFileState
geminiState: VaultAiGuidanceFileState
canRestore: boolean
}
type RawVaultAiGuidanceStatus = Partial<{
agents_state: VaultAiGuidanceFileState | null
claude_state: VaultAiGuidanceFileState | null
gemini_state: VaultAiGuidanceFileState | null
can_restore: boolean | null
}>
const GUIDANCE_FILENAMES = new Set(['AGENTS.md', 'CLAUDE.md'])
const GUIDANCE_FILENAMES = new Set(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'])
export function createCheckingVaultAiGuidanceStatus(): VaultAiGuidanceStatus {
return {
agentsState: 'checking',
claudeState: 'checking',
geminiState: 'checking',
canRestore: false,
}
}
@@ -42,12 +45,15 @@ export function normalizeVaultAiGuidanceStatus(
return {
agentsState: normalizeFileState(payload?.agents_state),
claudeState: normalizeFileState(payload?.claude_state),
geminiState: normalizeFileState(payload?.gemini_state),
canRestore: payload?.can_restore === true,
}
}
export function isVaultAiGuidanceStatusChecking(status: VaultAiGuidanceStatus): boolean {
return status.agentsState === 'checking' || status.claudeState === 'checking'
return status.agentsState === 'checking'
|| status.claudeState === 'checking'
|| status.geminiState === 'checking'
}
export function vaultAiGuidanceNeedsRestore(status: VaultAiGuidanceStatus): boolean {
@@ -56,16 +62,26 @@ export function vaultAiGuidanceNeedsRestore(status: VaultAiGuidanceStatus): bool
|| status.agentsState === 'broken'
|| status.claudeState === 'missing'
|| status.claudeState === 'broken'
|| status.geminiState === 'missing'
|| status.geminiState === 'broken'
}
export function vaultAiGuidanceUsesCustomFiles(status: VaultAiGuidanceStatus): boolean {
return status.agentsState === 'custom' || status.claudeState === 'custom'
return status.agentsState === 'custom'
|| status.claudeState === 'custom'
|| status.geminiState === 'custom'
}
function isMissingOrBroken(state: VaultAiGuidanceFileState): boolean {
return state === 'missing' || state === 'broken'
}
function formatGuidanceFileList(names: string[]): string {
if (names.length < 2) return names.join('')
if (names.length === 2) return `${names[0]} and ${names[1]}`
return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`
}
function getBrokenGuidanceSummary(status: VaultAiGuidanceStatus): string | null {
if (isMissingOrBroken(status.agentsState)) {
return 'Tolaria guidance missing or broken'
@@ -73,15 +89,27 @@ function getBrokenGuidanceSummary(status: VaultAiGuidanceStatus): string | null
if (isMissingOrBroken(status.claudeState)) {
return 'Claude compatibility shim missing or broken'
}
if (status.geminiState === 'missing') {
return 'Gemini guidance can be created'
}
if (status.geminiState === 'broken') {
return 'Gemini guidance missing or broken'
}
return null
}
function getCustomGuidanceSummary(status: VaultAiGuidanceStatus): string | null {
if (status.agentsState === 'custom' && status.claudeState === 'custom') {
return 'Custom AGENTS.md and CLAUDE.md active'
const customNames = [
status.agentsState === 'custom' ? 'AGENTS.md' : null,
status.claudeState === 'custom' ? 'CLAUDE.md' : null,
status.geminiState === 'custom' ? 'GEMINI.md' : null,
].filter((name): name is string => name !== null)
if (customNames.length > 1) {
return `Custom ${formatGuidanceFileList(customNames)} active`
}
if (status.agentsState === 'custom') return 'Using custom AGENTS.md'
if (status.claudeState === 'custom') return 'Using custom CLAUDE.md'
if (status.geminiState === 'custom') return 'Using custom GEMINI.md'
return null
}

View File

@@ -142,11 +142,13 @@ describe('mockHandlers additional coverage', () => {
expect(mockHandlers.get_vault_ai_guidance_status()).toEqual({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
})
expect(mockHandlers.restore_vault_ai_guidance()).toEqual({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
})
expect(mockHandlers.repair_vault()).toBe('Vault repaired')

View File

@@ -135,6 +135,7 @@ let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vaul
let mockVaultAiGuidanceStatus = {
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
} as const
@@ -386,6 +387,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
mockVaultAiGuidanceStatus = {
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
}
return { ...mockVaultAiGuidanceStatus }
@@ -486,11 +488,25 @@ export const mockHandlers: Record<string, (args: any) => any> = {
},
register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed',
get_mcp_config_snippet: (args: { vaultPath?: string }) => JSON.stringify({
mcpServers: {
tolaria: {
type: 'stdio',
command: 'node',
args: ['/mock/Tolaria/mcp-server/index.js'],
env: {
VAULT_PATH: args.vaultPath ?? '/Users/mock/Documents/Getting Started',
WS_UI_PORT: '9711',
},
},
},
}, null, 2),
sync_mcp_bridge_vault: (args: { vaultPath?: string | null }) => args.vaultPath ? 'started' : 'stopped',
repair_vault: (): string => {
mockVaultAiGuidanceStatus = {
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
}
return 'Vault repaired'

View File

@@ -215,6 +215,8 @@ export interface ViewDefinition {
name: string
icon: string | null
color: string | null
/** Display order for saved Views in sidebar/list surfaces (lower = higher). */
order?: number | null
sort: string | null
listPropertiesDisplay?: string[]
filters: FilterGroup

View File

@@ -95,6 +95,58 @@ describe('math markdown round-trip', () => {
)
})
it('round-trips inline math inside table cells', () => {
const tableCellMath = preProcessMathMarkdown({ markdown: '$a+b$' })
const blocks = [{
type: 'table',
content: {
type: 'tableContent',
rows: [{
cells: [{
type: 'tableCell',
content: [{ type: 'text', text: tableCellMath, styles: {} }],
}],
}],
},
children: [],
}]
const [tableBlock] = injectMathInBlocks(blocks) as Array<{
content: { rows: Array<{ cells: Array<{ content: unknown[] }> }> }
}>
expect(tableBlock.content.rows[0].cells[0].content).toEqual([{
type: MATH_INLINE_TYPE,
props: { latex: 'a+b' },
content: undefined,
}])
const editor = {
blocksToMarkdownLossy: vi.fn(() => '| Formula |\n| --- |\n| $a+b$ |'),
}
expect(serializeMathAwareBlocks(editor, [tableBlock])).toBe('| Formula |\n| --- |\n| $a+b$ |')
expect(editor.blocksToMarkdownLossy).toHaveBeenCalledWith([{
type: 'table',
content: {
type: 'tableContent',
rows: [{
cells: [{
type: 'tableCell',
content: [{ type: 'text', text: '$a+b$' }],
}],
}],
},
children: [],
}])
})
it('leaves display-style math inside table cells as Markdown source', () => {
expect(preProcessMathMarkdown({ markdown: '| Formula |\n| --- |\n| $$c$$ |' })).toBe(
'| Formula |\n| --- |\n| $$c$$ |',
)
})
it('leaves inline code and fenced code math-looking text untouched', () => {
const markdown = [
'Keep `$not_math$` literal.',

View File

@@ -19,12 +19,33 @@ interface InlineItem {
interface BlockLike {
type?: string
content?: InlineItem[]
content?: BlockContent
props?: Record<string, string>
children?: BlockLike[]
[key: string]: unknown
}
type BlockContent = InlineItem[] | TableContentLike | unknown
interface TableContentLike {
type?: string
rows?: TableRowLike[]
[key: string]: unknown
}
interface TableRowLike {
cells?: TableCellValue[]
[key: string]: unknown
}
interface TableCellLike {
content?: InlineItem[]
[key: string]: unknown
}
type TableCellValue = TableCellLike | string
type InlineContentTransform = (content: InlineItem[]) => InlineItem[]
interface MarkdownSerializer {
blocksToMarkdownLossy: (blocks: unknown[]) => string
}
@@ -111,7 +132,7 @@ function isCodeFence({ text: line }: { text: string }): boolean {
}
function isSingleDollar({ text, index }: TextPosition): boolean {
return text[index] === '$' && text[index + 1] !== '$'
return text[index] === '$' && text[index - 1] !== '$' && text[index + 1] !== '$'
}
function isInlineMathEnd(position: TextPosition): boolean {
@@ -274,10 +295,47 @@ function restoreInlineMath(content: InlineItem[]): InlineItem[] {
})
}
function isTableContent(content: BlockContent): content is TableContentLike {
return Boolean(
content
&& typeof content === 'object'
&& !Array.isArray(content)
&& (content as TableContentLike).type === 'tableContent'
&& Array.isArray((content as TableContentLike).rows),
)
}
function transformTableCell(cell: TableCellValue, transform: InlineContentTransform): TableCellValue {
if (typeof cell === 'string' || !Array.isArray(cell.content)) return cell
return { ...cell, content: transform(cell.content) }
}
function transformTableContent(
content: TableContentLike,
transform: InlineContentTransform,
): TableContentLike {
return {
...content,
rows: content.rows?.map((row) => ({
...row,
cells: row.cells?.map((cell) => transformTableCell(cell, transform)),
})),
}
}
function transformBlockContent(
content: BlockContent,
transform: InlineContentTransform,
): BlockContent {
if (Array.isArray(content)) return transform(content)
if (isTableContent(content)) return transformTableContent(content, transform)
return content
}
function injectMathInBlock(block: BlockLike): BlockLike {
const content = Array.isArray(block.content) ? expandInlineMath(block.content) : block.content
const content = transformBlockContent(block.content, expandInlineMath)
const children = Array.isArray(block.children) ? block.children.map(injectMathInBlock) : block.children
const latex = readDisplayMathToken(content)
const latex = Array.isArray(content) ? readDisplayMathToken(content) : null
if (latex !== null) {
return buildMathBlock({ block, latex })
@@ -303,7 +361,7 @@ function buildMathBlock({ block, latex }: { block: BlockLike } & LatexPayload):
}
function restoreInlineMathInBlock(block: BlockLike): BlockLike {
const content = Array.isArray(block.content) ? restoreInlineMath(block.content) : block.content
const content = transformBlockContent(block.content, restoreInlineMath)
const children = Array.isArray(block.children) ? block.children.map(restoreInlineMathInBlock) : block.children
return { ...block, content, children }
}

View File

@@ -0,0 +1,218 @@
import type { FilterGroup, FilterNode, VaultEntry, ViewDefinition, ViewFile } from '../types'
type UnknownRecord = Record<string, unknown>
interface EntryNormalizationArgs {
rawEntry: unknown
vaultPath: string
index: number
}
interface EntryPathArgs {
explicitPath: string
filename: string
vaultPath: string
}
interface ViewNormalizationArgs {
rawView: unknown
index: number
}
interface ViewDefinitionArgs {
rawDefinition: unknown
filename: string
index: number
}
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function recordFrom(value: unknown): UnknownRecord {
return isRecord(value) ? value : {}
}
function stringFrom(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function nullableStringFrom(value: unknown): string | null {
const text = stringFrom(value).trim()
return text.length > 0 ? text : null
}
function numberFrom(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
function nullableNumberFrom(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function booleanFrom(value: unknown, fallback = false): boolean {
return typeof value === 'boolean' ? value : fallback
}
function nullableBooleanFrom(value: unknown): boolean | null {
return typeof value === 'boolean' ? value : null
}
function stringArrayFrom(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
}
function filenameFromPath(path: string): string {
const normalizedPath = path.replace(/\\/g, '/')
return normalizedPath.split('/').filter(Boolean).pop() ?? ''
}
function stripExtension(filename: string): string {
return filename.replace(/\.[^.]+$/, '')
}
function fallbackEntryFilename(source: UnknownRecord, index: number): string {
return stringFrom(source.filename) || filenameFromPath(stringFrom(source.path)) || `untitled-${index + 1}.md`
}
function resolveEntryPath({ explicitPath, filename, vaultPath }: EntryPathArgs): string {
if (explicitPath) return explicitPath
const root = vaultPath.replace(/\/+$/, '')
return root ? `${root}/${filename}` : filename
}
function normalizeRelationships(value: unknown): Record<string, string[]> {
const source = recordFrom(value)
const result: Record<string, string[]> = {}
for (const [key, rawRefs] of Object.entries(source)) {
const refs = stringArrayFrom(rawRefs)
if (refs.length > 0) result[key] = refs
}
return result
}
function normalizeProperties(value: unknown): VaultEntry['properties'] {
const source = recordFrom(value)
const result: VaultEntry['properties'] = {}
for (const [key, rawValue] of Object.entries(source)) {
if (
rawValue === null
|| typeof rawValue === 'string'
|| typeof rawValue === 'boolean'
|| (typeof rawValue === 'number' && Number.isFinite(rawValue))
) {
result[key] = rawValue
}
}
return result
}
function normalizeFileKind(value: unknown): VaultEntry['fileKind'] {
if (value === 'markdown' || value === 'text' || value === 'binary') return value
return undefined
}
function normalizeFilterGroup(value: unknown): FilterGroup {
const source = recordFrom(value)
if (Array.isArray(source.all)) return { all: source.all as FilterNode[] }
if (Array.isArray(source.any)) return { any: source.any as FilterNode[] }
return { all: [] }
}
function fallbackViewName(filename: string, index: number): string {
const stem = stripExtension(filename).trim()
return stem && stem !== `view-${index + 1}` ? stem : `View ${index + 1}`
}
function normalizeVaultEntry({ rawEntry, vaultPath, index }: EntryNormalizationArgs): VaultEntry {
const source = recordFrom(rawEntry)
const filename = fallbackEntryFilename(source, index)
const path = resolveEntryPath({
explicitPath: stringFrom(source.path),
filename,
vaultPath,
})
const title = stringFrom(source.title).trim() || stripExtension(filename) || 'Untitled'
const fileKind = normalizeFileKind(source.fileKind)
const entry = {
...(source as Partial<VaultEntry>),
path,
filename,
title,
isA: nullableStringFrom(source.isA),
aliases: stringArrayFrom(source.aliases),
belongsTo: stringArrayFrom(source.belongsTo),
relatedTo: stringArrayFrom(source.relatedTo),
status: nullableStringFrom(source.status),
archived: booleanFrom(source.archived),
modifiedAt: nullableNumberFrom(source.modifiedAt),
createdAt: nullableNumberFrom(source.createdAt),
fileSize: numberFrom(source.fileSize),
snippet: stringFrom(source.snippet),
wordCount: numberFrom(source.wordCount),
relationships: normalizeRelationships(source.relationships),
icon: nullableStringFrom(source.icon),
color: nullableStringFrom(source.color),
order: nullableNumberFrom(source.order),
sidebarLabel: nullableStringFrom(source.sidebarLabel),
template: nullableStringFrom(source.template),
sort: nullableStringFrom(source.sort),
view: nullableStringFrom(source.view),
visible: nullableBooleanFrom(source.visible),
organized: booleanFrom(source.organized),
favorite: booleanFrom(source.favorite),
favoriteIndex: nullableNumberFrom(source.favoriteIndex),
listPropertiesDisplay: stringArrayFrom(source.listPropertiesDisplay),
outgoingLinks: stringArrayFrom(source.outgoingLinks),
properties: normalizeProperties(source.properties),
hasH1: booleanFrom(source.hasH1),
} as VaultEntry
if (fileKind) entry.fileKind = fileKind
return entry
}
function normalizeViewDefinition({ rawDefinition, filename, index }: ViewDefinitionArgs): ViewDefinition {
const definition = recordFrom(rawDefinition)
const name = stringFrom(definition.name).trim() || fallbackViewName(filename, index)
const normalized = {
...(definition as Partial<ViewDefinition>),
name,
icon: nullableStringFrom(definition.icon),
color: nullableStringFrom(definition.color),
sort: nullableStringFrom(definition.sort),
filters: normalizeFilterGroup(definition.filters),
} as ViewDefinition
if ('order' in definition) normalized.order = nullableNumberFrom(definition.order)
if ('listPropertiesDisplay' in definition) {
normalized.listPropertiesDisplay = stringArrayFrom(definition.listPropertiesDisplay)
}
return normalized
}
function normalizeViewFile({ rawView, index }: ViewNormalizationArgs): ViewFile {
const source = recordFrom(rawView)
const filename = stringFrom(source.filename) || `view-${index + 1}.yml`
return {
filename,
definition: normalizeViewDefinition({
rawDefinition: source.definition,
filename,
index,
}),
}
}
export function normalizeVaultEntries(rawEntries: unknown, vaultPath: string): VaultEntry[] {
if (!Array.isArray(rawEntries)) return []
return rawEntries.map((rawEntry, index) => normalizeVaultEntry({ rawEntry, vaultPath, index }))
}
export function normalizeViewFiles(rawViews: unknown): ViewFile[] {
if (!Array.isArray(rawViews)) return []
return rawViews.map((rawView, index) => normalizeViewFile({ rawView, index }))
}

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import type { ViewFile } from '../types'
import {
buildViewOrderUpdates,
canMoveView,
moveView,
nextViewOrder,
orderViewsByFilename,
} from './viewOrdering'
function makeView(filename: string, name: string, order: number | null = null): ViewFile {
return {
filename,
definition: {
name,
icon: null,
color: null,
order,
sort: null,
filters: { all: [] },
},
}
}
describe('viewOrdering', () => {
const views = [
makeView('alpha.yml', 'Alpha', 0),
makeView('beta.yml', 'Beta', 1),
makeView('gamma.yml', 'Gamma', 2),
]
it('moves a view up or down without mutating the input order', () => {
expect(moveView(views, 'beta.yml', 'up')?.map((view) => view.filename)).toEqual([
'beta.yml',
'alpha.yml',
'gamma.yml',
])
expect(moveView(views, 'beta.yml', 'down')?.map((view) => view.filename)).toEqual([
'alpha.yml',
'gamma.yml',
'beta.yml',
])
expect(views.map((view) => view.filename)).toEqual(['alpha.yml', 'beta.yml', 'gamma.yml'])
})
it('blocks moves outside the list bounds', () => {
expect(canMoveView(views, 'alpha.yml', 'up')).toBe(false)
expect(canMoveView(views, 'gamma.yml', 'down')).toBe(false)
expect(moveView(views, 'missing.yml', 'up')).toBeNull()
})
it('orders views by a complete filename list', () => {
expect(orderViewsByFilename(views, ['gamma.yml', 'alpha.yml', 'beta.yml'])?.map((view) => view.filename)).toEqual([
'gamma.yml',
'alpha.yml',
'beta.yml',
])
})
it('rejects incomplete, duplicate, or unknown filename orders', () => {
expect(orderViewsByFilename(views, ['alpha.yml', 'beta.yml'])).toBeNull()
expect(orderViewsByFilename(views, ['alpha.yml', 'alpha.yml', 'gamma.yml'])).toBeNull()
expect(orderViewsByFilename(views, ['alpha.yml', 'beta.yml', 'missing.yml'])).toBeNull()
})
it('builds dense order updates while preserving view definitions', () => {
expect(buildViewOrderUpdates([views[2], views[0]]).map(({ filename, definition }) => ({
filename,
name: definition.name,
order: definition.order,
}))).toEqual([
{ filename: 'gamma.yml', name: 'Gamma', order: 0 },
{ filename: 'alpha.yml', name: 'Alpha', order: 1 },
])
})
it('places new views after the highest explicit order', () => {
expect(nextViewOrder(views)).toBe(3)
expect(nextViewOrder([makeView('untitled.yml', 'Untitled')])).toBe(1)
})
})

62
src/utils/viewOrdering.ts Normal file
View File

@@ -0,0 +1,62 @@
import type { ViewDefinition, ViewFile } from '../types'
export type ViewMoveDirection = 'up' | 'down'
export type ViewMoveHandler = (filename: string, direction: ViewMoveDirection) => Promise<void> | void
export type ViewReorderHandler = (orderedFilenames: string[]) => Promise<void> | void
export interface ViewOrderUpdate {
filename: string
definition: ViewDefinition
}
function orderedViewIndex(views: ViewFile[], filename: string): number {
return views.findIndex((view) => view.filename === filename)
}
function destinationIndex(index: number, direction: ViewMoveDirection): number {
return direction === 'up' ? index - 1 : index + 1
}
export function canMoveView(views: ViewFile[], filename: string, direction: ViewMoveDirection): boolean {
const index = orderedViewIndex(views, filename)
if (index === -1) return false
const nextIndex = destinationIndex(index, direction)
return nextIndex >= 0 && nextIndex < views.length
}
export function moveView(views: ViewFile[], filename: string, direction: ViewMoveDirection): ViewFile[] | null {
if (!canMoveView(views, filename, direction)) return null
const index = orderedViewIndex(views, filename)
const nextIndex = destinationIndex(index, direction)
const reordered = [...views]
const [view] = reordered.splice(index, 1)
reordered.splice(nextIndex, 0, view)
return reordered
}
export function orderViewsByFilename(views: ViewFile[], orderedFilenames: string[]): ViewFile[] | null {
if (views.length !== orderedFilenames.length) return null
if (new Set(orderedFilenames).size !== orderedFilenames.length) return null
const viewByFilename = new Map(views.map((view) => [view.filename, view]))
const ordered = orderedFilenames.map((filename) => viewByFilename.get(filename))
if (ordered.some((view) => view === undefined)) return null
return ordered as ViewFile[]
}
export function buildViewOrderUpdates(views: ViewFile[]): ViewOrderUpdate[] {
return views.map((view, order) => ({
filename: view.filename,
definition: { ...view.definition, order },
}))
}
export function nextViewOrder(views: ViewFile[]): number {
const explicitOrders = views
.map((view) => view.definition.order)
.filter((order): order is number => typeof order === 'number' && Number.isFinite(order))
return explicitOrders.length > 0 ? Math.max(...explicitOrders) + 1 : views.length
}

View File

@@ -34,6 +34,23 @@ describe('preProcessWikilinks', () => {
expect(preProcessWikilinks(input)).toBe(input)
})
it('preserves wikilinks and inline formatting inside markdown tables', () => {
const input = [
'| Topic | Reference |',
'| --- | --- |',
'| [[Project Alpha]] | **bold** and [[Project Beta]] |',
'',
'Outside [[Project Gamma]]',
].join('\n')
const result = preProcessWikilinks(input)
expect(result).toContain('| [[Project Alpha]] | **bold** and [[Project Beta]] |')
expect(result).toContain('WIKILINK:Project Gamma')
expect(result).not.toContain('WIKILINK:Project Alpha')
expect(result).not.toContain('WIKILINK:Project Beta')
})
it('handles empty string', () => {
expect(preProcessWikilinks('')).toBe('')
})

View File

@@ -2,10 +2,16 @@
const WL_START = '\u2039WIKILINK:'
const WL_END = '\u203A'
const WL_RE = new RegExp(`${WL_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^${WL_END}]+)${WL_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g')
const WIKILINK_RE = /\[\[([^\]]+)\]\]/g
const FORMAT_MARKERS = new Set(['*', '_', '`', '~'])
/** Pre-process markdown: replace [[target]] with placeholder tokens */
export function preProcessWikilinks(md: string): string {
return md.replace(/\[\[([^\]]+)\]\]/g, (_m, target) => `${WL_START}${target}${WL_END}`)
const lines = md.split('\n')
const tableLines = findMarkdownTableLines(lines)
return lines.map((line, index) => (
tableLines[index] ? line : replaceWikilinksWithPlaceholders(line)
)).join('\n')
}
// Minimal shape of a BlockNote block for wikilink processing
@@ -25,6 +31,54 @@ interface InlineItem {
type ContentTransform = (content: InlineItem[]) => InlineItem[]
function replaceWikilinksWithPlaceholders(line: string): string {
return line.replace(WIKILINK_RE, (_match, target) => `${WL_START}${target}${WL_END}`)
}
function findMarkdownTableLines(lines: string[]): boolean[] {
const tableLines = lines.map(() => false)
for (let index = 0; index < lines.length - 1; index++) {
if (!isPotentialTableRow(lines[index]) || !isMarkdownTableSeparator(lines[index + 1])) {
continue
}
tableLines[index] = true
tableLines[index + 1] = true
index = markTableBodyLines(lines, tableLines, index + 2) - 1
}
return tableLines
}
function markTableBodyLines(lines: string[], tableLines: boolean[], start: number): number {
let index = start
while (index < lines.length && isPotentialTableRow(lines[index])) {
tableLines[index] = true
index++
}
return index
}
function isPotentialTableRow(line: string): boolean {
const trimmed = line.trim()
return trimmed.includes('|') && trimmed !== '|'
}
function isMarkdownTableSeparator(line: string): boolean {
const cells = splitTableCells(line)
return cells.length > 1 && cells.every(isMarkdownTableSeparatorCell)
}
function splitTableCells(line: string): string[] {
let trimmed = line.trim()
if (trimmed.startsWith('|')) trimmed = trimmed.slice(1)
if (trimmed.endsWith('|')) trimmed = trimmed.slice(0, -1)
return trimmed.split('|').map((cell) => cell.trim()).filter(Boolean)
}
function isMarkdownTableSeparatorCell(cell: string): boolean {
return /^:?-{3,}:?$/.test(cell)
}
/** 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 => {
@@ -188,21 +242,15 @@ function stripMarkdownChars(s: string): string {
let result = ''
let i = 0
while (i < s.length) {
if (s[i] === '[' && s[i + 1] === '[') {
i += 2
let inner = ''
while (i < s.length - 1 && !(s[i] === ']' && s[i + 1] === ']')) { inner += s[i]; i++ }
if (i < s.length - 1) i += 2
const pipe = inner.indexOf('|')
result += pipe !== -1 ? inner.slice(pipe + 1) : inner
if (s.startsWith('[[', i)) {
const parsed = readUntilSequence(s, i + 2, ']]')
result += wikilinkDisplayText(parsed.text)
i = parsed.nextIndex
} else if (s[i] === '[') {
i++
let text = ''
while (i < s.length && s[i] !== ']') { text += s[i]; i++ }
if (i < s.length) i++
if (i < s.length && s[i] === '(') { i++; while (i < s.length && s[i] !== ')') i++; if (i < s.length) i++ }
result += text
} else if (s[i] === '*' || s[i] === '_' || s[i] === '`' || s[i] === '~') {
const parsed = readUntilChar(s, i + 1, ']')
result += parsed.text
i = skipMarkdownLinkDestination(s, parsed.nextIndex)
} else if (FORMAT_MARKERS.has(s[i])) {
i++
} else {
result += s[i]
@@ -212,6 +260,30 @@ function stripMarkdownChars(s: string): string {
return result
}
function readUntilSequence(value: string, start: number, sequence: string): { text: string, nextIndex: number } {
const end = value.indexOf(sequence, start)
if (end === -1) return { text: value.slice(start), nextIndex: value.length }
return { text: value.slice(start, end), nextIndex: end + sequence.length }
}
function readUntilChar(value: string, start: number, char: string): { text: string, nextIndex: number } {
const end = value.indexOf(char, start)
if (end === -1) return { text: value.slice(start), nextIndex: value.length }
return { text: value.slice(start, end), nextIndex: end + 1 }
}
function skipMarkdownLinkDestination(value: string, start: number): number {
if (value[start] !== '(') return start
const end = value.indexOf(')', start + 1)
return end === -1 ? value.length : end + 1
}
function wikilinkDisplayText(inner: string): string {
const pipe = inner.indexOf('|')
return pipe === -1 ? inner : inner.slice(pipe + 1)
}
/** Extract sub-heading text (## , ### , etc.) stripped of the # prefix. */
function extractSubheadingText(line: string): string | null {
const t = line.trim()

View File

@@ -23,6 +23,7 @@ interface FixturePageArgs {
interface FixtureVaultOptions {
isGitRepo?: boolean
expectedReadyTitle?: string
}
interface CopyDirArgs {
@@ -252,6 +253,19 @@ async function installFixtureVaultInitScript({ page, vaultPath, isGitRepo }: Fix
save_vault_list: () => null,
save_settings: () => null,
register_mcp_tools: () => null,
get_mcp_config_snippet: () => JSON.stringify({
mcpServers: {
tolaria: {
type: 'stdio',
command: 'node',
args: ['/fixture/Tolaria/mcp-server/index.js'],
env: {
VAULT_PATH: resolvedVaultPath,
WS_UI_PORT: '9711',
},
},
},
}, null, 2),
reinit_telemetry: () => null,
update_menu_state: () => null,
get_settings: () => ({
@@ -393,14 +407,18 @@ async function installFixtureVaultInitScript({ page, vaultPath, isGitRepo }: Fix
return applyFixtureVaultOverrides(ref) ?? ref
},
})
}, { dismissedKey: CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, initialIsGitRepo: isGitRepo, resolvedVaultPath: vaultPath })
}, {
dismissedKey: CLAUDE_CODE_ONBOARDING_DISMISSED_KEY,
initialIsGitRepo: isGitRepo,
resolvedVaultPath: vaultPath,
})
}
async function waitForFixtureVaultReady({ page }: FixturePageArgs): Promise<void> {
async function waitForFixtureVaultReady({ page, expectedTitle }: FixturePageArgs & { expectedTitle: string }): Promise<void> {
await page.goto('/', { waitUntil: 'domcontentloaded' })
await page.waitForFunction(() => Boolean(window.__mockHandlers?.list_vault))
await page.locator('[data-testid="note-list-container"]').waitFor({ timeout: FIXTURE_VAULT_READY_TIMEOUT })
await expect(page.getByText('Alpha Project', { exact: true }).first()).toBeVisible({
await expect(page.getByText(expectedTitle, { exact: true }).first()).toBeVisible({
timeout: FIXTURE_VAULT_READY_TIMEOUT,
})
}
@@ -410,8 +428,15 @@ export async function openFixtureVault(
vaultPath: string,
options: FixtureVaultOptions = {},
): Promise<void> {
await installFixtureVaultInitScript({ page, vaultPath, isGitRepo: options.isGitRepo ?? true })
await waitForFixtureVaultReady({ page })
await installFixtureVaultInitScript({
page,
vaultPath,
isGitRepo: options.isGitRepo ?? true,
})
await waitForFixtureVaultReady({
page,
expectedTitle: options.expectedReadyTitle ?? 'Alpha Project',
})
}
async function installFixtureVaultDesktopBridge({ page }: FixturePageArgs): Promise<void> {

View File

@@ -100,7 +100,7 @@ test.describe('AI chat conversation history', () => {
await expect(restoredMessage).toContainText('Keep this thread alive')
await expect(restoredMessage).toContainText('[mock-claude code]')
await page.keyboard.press('Tab')
await page.getByTitle('New AI chat').focus()
await expect(page.getByTitle('New AI chat')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('ai-message')).toHaveCount(0)

View File

@@ -0,0 +1,114 @@
import fs from 'fs'
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { triggerMenuCommand } from './testBridge'
interface CreateNoteProbe {
createCalls: string[]
getBeforeCreate: string[]
}
interface ProbeWindow {
__mockHandlers?: Record<string, (args?: unknown) => unknown>
__createNoteBackingFileProbe?: CreateNoteProbe
}
let tempVaultDir: string
async function pinFixtureHandlers(page: Page): Promise<void> {
await page.evaluate(() => {
const probeWindow = window as typeof window & ProbeWindow
const handlers = probeWindow.__mockHandlers
if (!handlers?.create_note_content || !handlers.get_note_content) {
throw new Error('Fixture vault handlers are missing create/read commands')
}
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
get: () => handlers,
set: (nextHandlers) => Object.assign(handlers, nextHandlers),
})
})
}
async function recordCreateNoteCalls(page: Page): Promise<void> {
await page.evaluate(() => {
const probeWindow = window as typeof window & ProbeWindow
const handlers = probeWindow.__mockHandlers as Record<string, (args?: unknown) => unknown>
const originalCreate = handlers.create_note_content.bind(handlers)
const probe: CreateNoteProbe = { createCalls: [], getBeforeCreate: [] }
probeWindow.__createNoteBackingFileProbe = probe
handlers.create_note_content = async (args?: unknown) => {
const notePath = String((args as { path?: unknown } | undefined)?.path ?? '')
await new Promise((resolve) => setTimeout(resolve, 100))
const result = await originalCreate(args)
probe.createCalls.push(notePath)
return result
}
})
}
async function rejectReadsBeforeCreate(page: Page): Promise<void> {
await page.evaluate(() => {
const probeWindow = window as typeof window & ProbeWindow
const handlers = probeWindow.__mockHandlers as Record<string, (args?: unknown) => unknown>
const originalGet = handlers.get_note_content.bind(handlers)
const probe = probeWindow.__createNoteBackingFileProbe as CreateNoteProbe
handlers.get_note_content = (args?: unknown) => {
const notePath = String((args as { path?: unknown } | undefined)?.path ?? '')
if (notePath.includes('untitled-note-') && !probe.createCalls.includes(notePath)) {
probe.getBeforeCreate.push(notePath)
throw new Error(`File does not exist: ${notePath}`)
}
return originalGet(args)
}
})
}
async function installCreateNoteBackingFileProbe(page: Page): Promise<void> {
await pinFixtureHandlers(page)
await recordCreateNoteCalls(page)
await rejectReadsBeforeCreate(page)
}
async function readProbe(page: Page): Promise<CreateNoteProbe> {
return page.evaluate(() => {
const probeWindow = window as typeof window & ProbeWindow
return probeWindow.__createNoteBackingFileProbe ?? { createCalls: [], getBeforeCreate: [] }
})
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVaultTauri(page, tempVaultDir)
await installCreateNoteBackingFileProbe(page)
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('@smoke creating a note writes its backing file before reload can read it', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await triggerMenuCommand(page, 'file-new-note')
await triggerMenuCommand(page, 'vault-reload')
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+/i, {
timeout: 5_000,
})
await expect.poll(() => readProbe(page), { timeout: 5_000 }).toMatchObject({
createCalls: [expect.stringMatching(/untitled-note-\d+\.md$/)],
getBeforeCreate: [],
})
const { createCalls, getBeforeCreate } = await readProbe(page)
expect(getBeforeCreate).toEqual([])
expect(errors.filter((message) => message.includes('File does not exist'))).toEqual([])
expect(fs.readFileSync(createCalls[0], 'utf8')).toContain('type: Note')
})

View File

@@ -19,10 +19,11 @@ async function blockOuterForText(page: Page, text: string): Promise<Locator> {
return textNode.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " bn-block-outer ")][1]')
}
async function visibleDragHandle(page: Page, block: Locator): Promise<Locator> {
async function visibleLeftBlockHandle(page: Page, block: Locator): Promise<Locator> {
await block.hover()
const handle = page.locator('.bn-side-menu [draggable="true"]').first()
const handle = page.locator('.bn-side-menu button').first()
await expect(handle).toBeVisible({ timeout: 5_000 })
await expect(handle).toHaveAttribute('draggable', 'true')
return handle
}
@@ -59,7 +60,7 @@ test('dragging the left block handle reorders editor blocks', async ({ page }) =
await expect.poll(async () => editor.textContent()).toMatch(/Alpha Project[\s\S]*This is a test project[\s\S]*Notes/)
const handle = await visibleDragHandle(page, notesHeading)
const handle = await visibleLeftBlockHandle(page, notesHeading)
await dragHandleToBlock(page, handle, paragraph)
await expect.poll(async () => editor.textContent()).toMatch(/Alpha Project[\s\S]*Notes[\s\S]*This is a test project/)

View File

@@ -0,0 +1,79 @@
import { test, expect, type Page } from '@playwright/test'
import {
createFixtureVaultCopy,
openFixtureVault,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { sendShortcut } from './helpers'
let tempVaultDir: string
function isCrashError(message: string): boolean {
return (
message.includes('Maximum update depth') ||
message.includes('React error #185') ||
message.includes('#185')
)
}
function collectCrashErrors(page: Page): string[] {
const errors: string[] = []
page.on('pageerror', (error) => {
if (isCrashError(error.message)) errors.push(error.message)
})
page.on('console', (message) => {
if (message.type() === 'error' && isCrashError(message.text())) {
errors.push(message.text())
}
})
return errors
}
async function openNote(page: Page, title: string) {
await page.getByTestId('note-list-container').getByText(title, { exact: true }).click()
}
async function focusHeadingEnd(page: Page, title: string) {
const heading = page.getByRole('heading', { name: title, level: 1 })
await expect(heading).toBeVisible({ timeout: 5_000 })
await heading.click()
await page.keyboard.press('End')
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVault(page, tempVaultDir)
await page.setViewportSize({ width: 1180, height: 760 })
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('editing and saving stays stable when truncated labels and tooltips are clicked', async ({ page }) => {
const errors = collectCrashErrors(page)
const initialTitle = 'Alpha Project'
const titleSuffix = ` Loop Guard ${Date.now()}`
const updatedTitle = `${initialTitle}${titleSuffix}`
const noteList = page.getByTestId('note-list-container')
await openNote(page, initialTitle)
await focusHeadingEnd(page, initialTitle)
await page.keyboard.type(titleSuffix)
await sendShortcut(page, 's', ['Control'])
await expect(page.getByRole('heading', { name: updatedTitle, level: 1 })).toBeVisible({ timeout: 5_000 })
await expect(noteList.getByText(updatedTitle, { exact: true })).toBeVisible({ timeout: 5_000 })
await page.getByTestId('breadcrumb-filename-trigger').locator('span.truncate').click()
await noteList.getByText(updatedTitle, { exact: true }).click()
const syncButton = page.getByTestId('breadcrumb-sync-button')
await expect(syncButton).toBeVisible({ timeout: 5_000 })
await syncButton.hover()
await syncButton.click()
await page.getByTestId('breadcrumb-filename-trigger').locator('span.truncate').click()
expect(errors).toHaveLength(0)
})

View File

@@ -9,6 +9,8 @@ let tempVaultDir: string
const INLINE_LATEX = 'E=mc^2'
const DISPLAY_LATEX = '\\int_0^1 x^2 \\, dx = \\frac{1}{3}'
const MALFORMED_LATEX = '\\frac{'
const TABLE_INLINE_LATEX = '\\frac{a}{b}+c'
const TABLE_DISPLAY_STYLE_LATEX = '\\sum_{i=1}^{n} i'
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
@@ -107,6 +109,11 @@ ${DISPLAY_LATEX}
$$
Malformed math $${MALFORMED_LATEX}$ stays visible.
| Kind | Formula |
| --- | --- |
| inline | $${TABLE_INLINE_LATEX}$ |
| display-style | $$${TABLE_DISPLAY_STYLE_LATEX}$$ |
`
await setRawEditorContent(page, nextContent)
@@ -117,15 +124,20 @@ Malformed math $${MALFORMED_LATEX}$ stays visible.
await toggleRawMode(page, '.bn-editor')
await expectMathNode(page, '.math--inline', INLINE_LATEX)
await expectMathNode(page, '.math--inline', TABLE_INLINE_LATEX)
await expectMathNode(page, '.math--block', DISPLAY_LATEX)
await expectMathNode(page, '.math--inline', MALFORMED_LATEX)
await expect(page.locator('.math .katex, .math .katex-error')).toHaveCount(3)
await expect(page.locator('table')).toHaveCount(1)
await expect(page.locator('.math .katex, .math .katex-error')).toHaveCount(4)
await toggleRawMode(page, '.cm-content')
const rawAfterRichMode = await getRawEditorContent(page)
expect(rawAfterRichMode).toContain(`$${INLINE_LATEX}$`)
expect(rawAfterRichMode).toContain(`$$\n${DISPLAY_LATEX}\n$$`)
expect(rawAfterRichMode).toContain(`$${MALFORMED_LATEX}$`)
expect(rawAfterRichMode).toContain(`$${TABLE_INLINE_LATEX}$`)
expect(rawAfterRichMode).toContain(`$$${TABLE_DISPLAY_STYLE_LATEX}$$`)
expect(rawAfterRichMode).not.toContain('@@TOLARIA_MATH')
await toggleRawMode(page, '.bn-editor')
await openNote(page, 'Note C')
@@ -136,4 +148,7 @@ Malformed math $${MALFORMED_LATEX}$ stays visible.
expect(reopenedRaw).toContain(`$${INLINE_LATEX}$`)
expect(reopenedRaw).toContain(`$$\n${DISPLAY_LATEX}\n$$`)
expect(reopenedRaw).toContain(`$${MALFORMED_LATEX}$`)
expect(reopenedRaw).toContain(`$${TABLE_INLINE_LATEX}$`)
expect(reopenedRaw).toContain(`$$${TABLE_DISPLAY_STYLE_LATEX}$$`)
expect(reopenedRaw).not.toContain('@@TOLARIA_MATH')
})

Some files were not shown because too many files have changed in this diff Show More