Compare commits
25 Commits
site
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58f8a774b5 | ||
|
|
0bf280d3ec | ||
|
|
d2d4746d5c | ||
|
|
91db7c0098 | ||
|
|
87908d982f | ||
|
|
40544fc551 | ||
|
|
f34fdb0289 | ||
|
|
e601a42f41 | ||
|
|
de627c2478 | ||
|
|
46d9aaed48 | ||
|
|
6b96b5caf6 | ||
|
|
595c48ca59 | ||
|
|
fd92ff5e9b | ||
|
|
a39b346a75 | ||
|
|
deac10caa2 | ||
|
|
0e1e0ff47a | ||
|
|
a645edbc19 | ||
|
|
48f1fb0b50 | ||
|
|
1aede09834 | ||
|
|
d51b76f992 | ||
|
|
8e66fb4c19 | ||
|
|
a2843a0cf3 | ||
|
|
38fb8a7370 | ||
|
|
be5a8bb300 | ||
|
|
ce040c75fd |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=10.0
|
||||
AVERAGE_THRESHOLD=9.89
|
||||
AVERAGE_THRESHOLD=9.91
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -669,7 +676,8 @@ No indexing step required — search runs directly against the filesystem.
|
||||
|
||||
Per-vault settings stored locally and scoped by vault path:
|
||||
- Managed by `useVaultConfig` hook and `vaultConfigStore`
|
||||
- Settings: zoom, view mode, editor mode, note layout, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle
|
||||
- Settings: zoom, view mode, editor mode, note layout, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, AI agent permission mode (`safe` / `power_user`)
|
||||
- Missing, null, and unknown AI agent permission modes normalize to `safe`; the AI panel can switch modes per vault, preserving the transcript and applying the new mode only to the next agent run
|
||||
- One-time migration from localStorage (`configMigration.ts`)
|
||||
|
||||
### AI Guidance Files
|
||||
@@ -677,8 +685,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 +706,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 +752,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 +780,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.
|
||||
|
||||
@@ -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).
|
||||
@@ -224,12 +224,12 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
|
||||
|
||||
Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration.
|
||||
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, and default-agent selection
|
||||
2. **Backend** (`ai_agents.rs`) — normalizes agent availability and streaming, dispatching to per-agent adapters
|
||||
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.
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-agent selection, and the per-vault Safe / Power User permission mode shown in the panel header
|
||||
2. **Backend** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters
|
||||
3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools, while Power User adds Bash without using dangerous bypass flags. Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json` in both modes. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config. 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
|
||||
|
||||
@@ -243,7 +243,7 @@ sequenceDiagram
|
||||
|
||||
U->>FE: sendMessage(text, references)
|
||||
FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs)
|
||||
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath})
|
||||
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath, permissionMode})
|
||||
R->>R: pick adapter for claude_code, codex, opencode, or pi
|
||||
R->>C: spawn agent with MCP-enabled config
|
||||
|
||||
@@ -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.
|
||||
@@ -785,14 +788,14 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |
|
||||
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
|
||||
| `useTheme` | Editor theme CSS vars and theme-mode bridge | Editor typography and app theme runtime |
|
||||
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline |
|
||||
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline and vault permission mode |
|
||||
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
|
||||
| `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints |
|
||||
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
|
||||
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
|
||||
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
|
||||
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
|
||||
| `useVaultConfig` | Per-vault UI preferences, AI permission mode | Vault-specific config |
|
||||
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
|
||||
|
||||
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ tolaria/
|
||||
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
|
||||
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
|
||||
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
|
||||
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent)
|
||||
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent + per-vault permission mode)
|
||||
│ │ ├── AiMessage.tsx # Agent message display
|
||||
│ │ ├── AiActionCard.tsx # Agent tool action cards
|
||||
│ │ ├── AiAgentsOnboardingPrompt.tsx # First-launch AI agent installer prompt
|
||||
@@ -114,6 +114,7 @@ tolaria/
|
||||
│ │ ├── useNoteCreation.ts # Note/type creation
|
||||
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
|
||||
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline
|
||||
│ │ ├── aiAgentPermissionMode.ts # Safe/Power User mode normalization + labels
|
||||
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi availability polling
|
||||
│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling
|
||||
│ │ ├── useAiActivity.ts # MCP UI bridge listener
|
||||
@@ -261,8 +262,8 @@ tolaria/
|
||||
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
|
||||
| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse, add-remote). |
|
||||
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
|
||||
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, adapter dispatch, and stream normalization. |
|
||||
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
|
||||
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, adapter dispatch, stream normalization, and permission-mode request normalization. |
|
||||
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning, permission-mode tool mapping, and NDJSON stream parsing. |
|
||||
| `src-tauri/src/pi_cli.rs` | Pi subprocess spawning through JSON mode and transient MCP adapter config. |
|
||||
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. |
|
||||
|
||||
@@ -282,9 +283,10 @@ tolaria/
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, and actions. |
|
||||
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, actions, and per-vault permission mode. |
|
||||
| `src/hooks/useCliAiAgent.ts` | Thin React owner for the selected CLI agent session state. |
|
||||
| `src/lib/aiAgentSession.ts` | Single message/session lifecycle for prompt normalization, history, streaming, and reset behavior. |
|
||||
| `src/lib/aiAgentPermissionMode.ts` | Safe/Power User mode normalization, display labels, and local transcript marker text. |
|
||||
| `src/lib/aiAgentFileOperations.ts` | Detects agent-created or modified vault files from normalized tool inputs. |
|
||||
| `src/lib/aiAgents.ts` | Supported agent definitions, status normalization, and default-agent helpers. |
|
||||
| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. |
|
||||
@@ -304,7 +306,7 @@ tolaria/
|
||||
| `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). |
|
||||
| `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. |
|
||||
| `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow). |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow, AI permission mode). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, default AI agent, and the vault-level explicit organization toggle. |
|
||||
| `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. |
|
||||
|
||||
@@ -420,11 +422,13 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
1. **Agent system prompt**: Edit `src/utils/ai-agent.ts` (inline system prompt string)
|
||||
2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent
|
||||
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
|
||||
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`; keep app-managed launches on strict Tolaria MCP config, `acceptEdits`, and the scoped file/search tool list)
|
||||
5. **Shared agent adapters / Codex/Pi args**: Edit `src-tauri/src/ai_agents.rs` plus the per-agent adapter modules (keep Codex sandboxed with active-vault `workspace-write`, keep Pi on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode)
|
||||
4. **Permission-mode UI and request plumbing**: Edit `src/lib/aiAgentPermissionMode.ts`, `src/components/AiPanel*.tsx`, `src/hooks/useCliAiAgent.ts`, and `src/utils/streamAiAgent.ts`
|
||||
5. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`; keep app-managed launches on strict Tolaria MCP config, `acceptEdits`, and the scoped file/search tool list unless the permission mode explicitly broadens it)
|
||||
6. **Shared agent adapters / Codex/Pi args**: Edit `src-tauri/src/ai_agents.rs` plus the per-agent adapter modules (keep Codex sandboxed with active-vault `workspace-write`, keep Pi on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode)
|
||||
|
||||
### 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
|
||||
|
||||
35
docs/adr/0091-gemini-cli-external-ai-setup.md
Normal file
35
docs/adr/0091-gemini-cli-external-ai-setup.md
Normal 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.
|
||||
39
docs/adr/0092-vault-ai-agent-permission-modes.md
Normal file
39
docs/adr/0092-vault-ai-agent-permission-modes.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0092"
|
||||
title: "Vault-scoped AI agent permission modes"
|
||||
status: active
|
||||
date: 2026-04-28
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0074 established explicit setup and least-privilege defaults for desktop AI tools. The in-app AI panel now supports multiple local CLI agents, and users need a clear per-vault way to choose whether an agent should stay in the narrow vault-safe profile or use broader local-work tools for that vault.
|
||||
|
||||
The mode must be visible at the point of use, must not mutate global CLI settings, and must not silently restore dangerous bypass flags. Existing transcripts should remain intact when the mode changes because a change applies to the next agent run, not to a process that is already streaming.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria stores an `ai_agent_permission_mode` per vault with values `safe` and `power_user`, defaulting missing or null values to `safe`, and passes that normalized mode through the AI panel stream request into each CLI adapter.**
|
||||
|
||||
The AI panel header displays the current mode and offers a compact Vault Safe / Power User control that is disabled while an agent run is active. Changing the mode preserves the transcript and inserts a local transcript marker.
|
||||
|
||||
Adapter mappings remain conservative:
|
||||
- Claude Code Safe keeps `acceptEdits`, strict Tolaria MCP config, and file/search/edit tools only; Power User adds Bash to the allowed tool list without using `--dangerously-skip-permissions`.
|
||||
- Codex keeps the active-vault `workspace-write` sandbox and `--ask-for-approval never` in both modes.
|
||||
- OpenCode uses transient `OPENCODE_CONFIG_CONTENT`; Safe denies bash and external directories, while Power User allows bash but still denies external directories.
|
||||
- Pi receives the mode on the adapter request path; both modes currently use the same transient MCP adapter config.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Per-vault Safe / Power User modes** (chosen): makes the permission surface explicit where the agent is used and preserves least-privilege defaults for each vault.
|
||||
- **Global app setting**: simpler storage, but a single toggle can over-apply a power-user profile to unrelated vaults.
|
||||
- **Dangerous bypass mode**: maximizes CLI freedom, but violates ADR-0074's least-privilege boundary and needs a separate explicit security decision.
|
||||
- **Adapter-specific UI switches**: exposes too much implementation detail and makes cross-agent behavior harder to reason about.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Vault config normalization owns the safe default for old vaults and malformed values.
|
||||
- Agent requests now carry a permission mode through frontend and Rust boundaries, so new adapters must choose an explicit mapping.
|
||||
- Power User is intentionally not equivalent across agents; where an adapter lacks a safe broader local-work switch, both modes may map to the same conservative behavior and must document that with tests.
|
||||
- Any future dangerous mode requires a new ADR and separate UI language.
|
||||
@@ -142,3 +142,6 @@ 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 |
|
||||
| [0092](0092-vault-ai-agent-permission-modes.md) | Vault-scoped AI agent permission modes | active |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
"process:default",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"opener:allow-open-path"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@ pub enum AiAgentId {
|
||||
Pi,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiAgentPermissionMode {
|
||||
#[default]
|
||||
Safe,
|
||||
PowerUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AiAgentAvailability {
|
||||
pub installed: bool,
|
||||
@@ -61,6 +69,13 @@ pub struct AiAgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
pub permission_mode: Option<AiAgentPermissionMode>,
|
||||
}
|
||||
|
||||
impl AiAgentStreamRequest {
|
||||
fn permission_mode(&self) -> AiAgentPermissionMode {
|
||||
self.permission_mode.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
@@ -76,12 +91,14 @@ pub fn run_ai_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Res
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let permission_mode = request.permission_mode();
|
||||
match request.agent {
|
||||
AiAgentId::ClaudeCode => {
|
||||
let mapped = crate::claude_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::claude_cli::run_agent_stream(mapped, |event| {
|
||||
if let Some(mapped_event) = map_claude_event(event) {
|
||||
@@ -95,6 +112,7 @@ where
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::opencode_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
@@ -103,6 +121,7 @@ where
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::pi_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
@@ -229,7 +248,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,22 +259,50 @@ 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> {
|
||||
candidates.into_iter().find(|candidate| candidate.exists())
|
||||
}
|
||||
|
||||
fn run_codex_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Result<String, String>
|
||||
fn run_codex_agent_stream<F>(request: AiAgentStreamRequest, emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = find_codex_binary()?;
|
||||
run_codex_agent_stream_with_binary(&binary, request, emit)
|
||||
}
|
||||
|
||||
fn run_codex_agent_stream_with_binary<F>(
|
||||
binary: &Path,
|
||||
request: AiAgentStreamRequest,
|
||||
mut emit: F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let args = build_codex_args(&request)?;
|
||||
let prompt = build_codex_prompt(&request);
|
||||
|
||||
let mut command = build_codex_command(&binary, args, prompt, &request.vault_path);
|
||||
let mut command = build_codex_command(binary, args, prompt, &request.vault_path);
|
||||
|
||||
let mut child = command
|
||||
.spawn()
|
||||
@@ -501,13 +548,85 @@ mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn executable_script(dir: &Path, name: &str, body: &str) -> PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let script = dir.join(name);
|
||||
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
script
|
||||
}
|
||||
|
||||
fn codex_request(
|
||||
vault_path: &Path,
|
||||
permission_mode: Option<AiAgentPermissionMode>,
|
||||
) -> AiAgentStreamRequest {
|
||||
AiAgentStreamRequest {
|
||||
agent: AiAgentId::Codex,
|
||||
message: "Summarize".into(),
|
||||
system_prompt: None,
|
||||
vault_path: vault_path.to_string_lossy().into_owned(),
|
||||
permission_mode,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_codex_workspace_write_contract(args: &[String]) {
|
||||
let prefix = [
|
||||
"--sandbox",
|
||||
"workspace-write",
|
||||
"--ask-for-approval",
|
||||
"never",
|
||||
];
|
||||
|
||||
assert_eq!(&args[..prefix.len()], prefix);
|
||||
assert!(!args.iter().any(|arg| arg == "danger-full-access"));
|
||||
assert!(!args
|
||||
.iter()
|
||||
.any(|arg| arg == "--dangerously-bypass-approvals-and-sandbox"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_codex_script(body: &str) -> (String, Vec<AiAgentStreamEvent>) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(dir.path(), "codex", body);
|
||||
let mut events = Vec::new();
|
||||
let thread_id = run_codex_agent_stream_with_binary(
|
||||
&binary,
|
||||
codex_request(vault.path(), Some(AiAgentPermissionMode::Safe)),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
(thread_id, events)
|
||||
}
|
||||
|
||||
fn assert_codex_text_flow(events: &[AiAgentStreamEvent], session: &str, text_delta: &str) {
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Init { session_id } if session_id == session
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == text_delta
|
||||
));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_status_contains_all_agents() {
|
||||
let status = get_ai_agents_status();
|
||||
assert!(matches!(status.claude_code.installed, true | false));
|
||||
assert!(matches!(status.codex.installed, true | false));
|
||||
assert!(matches!(status.opencode.installed, true | false));
|
||||
assert!(matches!(status.pi.installed, true | false));
|
||||
let install_flags = [
|
||||
status.claude_code.installed,
|
||||
status.codex.installed,
|
||||
status.opencode.installed,
|
||||
status.pi.installed,
|
||||
];
|
||||
|
||||
assert!(install_flags
|
||||
.iter()
|
||||
.all(|installed| matches!(installed, true | false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -517,6 +636,7 @@ mod tests {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: Some("Be concise".into()),
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: Some(AiAgentPermissionMode::Safe),
|
||||
});
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
@@ -530,19 +650,33 @@ mod tests {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: None,
|
||||
}) {
|
||||
assert_eq!(args[0], "--sandbox");
|
||||
assert_eq!(args[1], "workspace-write");
|
||||
assert_eq!(args[2], "--ask-for-approval");
|
||||
assert_eq!(args[3], "never");
|
||||
assert_eq!(args[4], "exec");
|
||||
assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
|
||||
assert!(!args.contains(&"danger-full-access".to_string()));
|
||||
assert_codex_workspace_write_contract(&args);
|
||||
assert!(args.contains(&"--json".to_string()));
|
||||
assert!(args.contains(&"-C".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_permission_modes_keep_workspace_write_without_dangerous_bypass() {
|
||||
for permission_mode in [
|
||||
AiAgentPermissionMode::Safe,
|
||||
AiAgentPermissionMode::PowerUser,
|
||||
] {
|
||||
if let Ok(args) = build_codex_args(&AiAgentStreamRequest {
|
||||
agent: AiAgentId::Codex,
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: Some(permission_mode),
|
||||
}) {
|
||||
assert_codex_workspace_write_contract(&args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_command_keeps_agent_process_contract() {
|
||||
let binary = PathBuf::from("codex");
|
||||
@@ -562,6 +696,37 @@ mod tests {
|
||||
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_codex_agent_stream_reads_ndjson_and_returns_thread_id() {
|
||||
let (thread_id, events) = run_codex_script(
|
||||
r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
|
||||
printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Done"}}'
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_eq!(thread_id, "thread_1");
|
||||
assert_codex_text_flow(&events, "thread_1", "Done");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_codex_agent_stream_reports_nonzero_exit_errors() {
|
||||
let (thread_id, events) = run_codex_script(
|
||||
r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
|
||||
printf '%s\n' 'login required' >&2
|
||||
exit 2
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_eq!(thread_id, "thread_1");
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AiAgentStreamEvent::Error { message } if message.contains("not authenticated")
|
||||
)));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_supported_macos_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
@@ -585,6 +750,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();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::io::BufRead;
|
||||
@@ -56,6 +57,7 @@ pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
pub permission_mode: AiAgentPermissionMode,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -148,7 +150,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 +172,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> {
|
||||
@@ -273,7 +292,7 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
"--permission-mode".into(),
|
||||
"acceptEdits".into(),
|
||||
"--tools".into(),
|
||||
"Read,Edit,MultiEdit,Write,Glob,Grep,LS".into(),
|
||||
agent_tools(req.permission_mode).into(),
|
||||
"--no-session-persistence".into(),
|
||||
];
|
||||
|
||||
@@ -287,6 +306,13 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn agent_tools(permission_mode: AiAgentPermissionMode) -> &'static str {
|
||||
match permission_mode {
|
||||
AiAgentPermissionMode::Safe => "Read,Edit,MultiEdit,Write,Glob,Grep,LS",
|
||||
AiAgentPermissionMode::PowerUser => "Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash",
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -314,6 +340,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 +367,7 @@ where
|
||||
session_id: String::new(),
|
||||
tool_inputs: HashMap::new(),
|
||||
current_tool_id: None,
|
||||
emitted_text: false,
|
||||
};
|
||||
|
||||
for line in reader.lines() {
|
||||
@@ -473,7 +502,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 +520,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 +545,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 +590,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(
|
||||
@@ -607,6 +672,82 @@ mod tests {
|
||||
use std::ffi::OsString;
|
||||
use std::process::Command;
|
||||
|
||||
macro_rules! chat_request {
|
||||
($message:expr, None, None $(,)?) => {
|
||||
ChatStreamRequest {
|
||||
message: $message.into(),
|
||||
system_prompt: None,
|
||||
session_id: None,
|
||||
}
|
||||
};
|
||||
($message:expr, Some($system_prompt:expr), None $(,)?) => {
|
||||
ChatStreamRequest {
|
||||
message: $message.into(),
|
||||
system_prompt: Some($system_prompt.to_string()),
|
||||
session_id: None,
|
||||
}
|
||||
};
|
||||
($message:expr, None, Some($session_id:expr) $(,)?) => {
|
||||
ChatStreamRequest {
|
||||
message: $message.into(),
|
||||
system_prompt: None,
|
||||
session_id: Some($session_id.to_string()),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! agent_request {
|
||||
($message:expr, None, $permission_mode:expr $(,)?) => {
|
||||
AgentStreamRequest {
|
||||
message: $message.into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: $permission_mode,
|
||||
}
|
||||
};
|
||||
($message:expr, Some($system_prompt:expr), $permission_mode:expr $(,)?) => {
|
||||
AgentStreamRequest {
|
||||
message: $message.into(),
|
||||
system_prompt: Some($system_prompt.to_string()),
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: $permission_mode,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! assert_args_contain {
|
||||
($args:expr, [$($value:expr),+ $(,)?] $(,)?) => {
|
||||
$(
|
||||
assert!($args.contains(&$value.to_string()), "missing {}", $value);
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! assert_args_lack {
|
||||
($args:expr, [$($value:expr),+ $(,)?] $(,)?) => {
|
||||
$(
|
||||
assert!(!$args.contains(&$value.to_string()), "unexpected {}", $value);
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! assert_no_arg_contains {
|
||||
($args:expr, $fragment:expr $(,)?) => {
|
||||
assert!(!$args.iter().any(|arg| arg.contains($fragment)));
|
||||
};
|
||||
}
|
||||
|
||||
fn assert_binary_candidates_include(home: &Path, expected: &[PathBuf]) {
|
||||
let candidates = claude_binary_candidates_for_home(home);
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_cli_returns_status() {
|
||||
let status = check_cli();
|
||||
@@ -617,6 +758,50 @@ 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 agent_args_use_safe_mode_without_bash_by_default() {
|
||||
let args = build_agent_args(&agent_request!(
|
||||
"Rename the note",
|
||||
None,
|
||||
AiAgentPermissionMode::Safe,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_args_contain!(
|
||||
args,
|
||||
["--strict-mcp-config", "--permission-mode", "acceptEdits"]
|
||||
);
|
||||
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]);
|
||||
assert_no_arg_contains!(args, "Bash");
|
||||
assert_args_lack!(args, ["--dangerously-skip-permissions"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_args_allow_bash_in_power_user_mode_without_dangerous_bypass() {
|
||||
let args = build_agent_args(&agent_request!(
|
||||
"Rename the note",
|
||||
None,
|
||||
AiAgentPermissionMode::PowerUser,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_args_contain!(args, ["--strict-mcp-config"]);
|
||||
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash"]);
|
||||
assert_args_lack!(args, ["--dangerously-skip-permissions"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_mcp_config_is_valid_json() {
|
||||
if let Ok(config_str) = build_mcp_config("/tmp/test-vault") {
|
||||
@@ -636,6 +821,7 @@ mod tests {
|
||||
session_id: String::new(),
|
||||
tool_inputs: HashMap::new(),
|
||||
current_tool_id: None,
|
||||
emitted_text: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -765,10 +951,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]
|
||||
@@ -1142,52 +1353,31 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_basic() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hello".into(),
|
||||
system_prompt: None,
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"-p".to_string()));
|
||||
assert!(args.contains(&"hello".to_string()));
|
||||
assert!(args.contains(&"stream-json".to_string()));
|
||||
assert!(!args.contains(&"--system-prompt".to_string()));
|
||||
assert!(!args.contains(&"--resume".to_string()));
|
||||
let args = build_chat_args(&chat_request!("hello", None, None));
|
||||
|
||||
assert_args_contain!(args, ["-p", "hello", "stream-json"]);
|
||||
assert_args_lack!(args, ["--system-prompt", "--resume"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_with_system_prompt() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hi".into(),
|
||||
system_prompt: Some("You are helpful.".into()),
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"--system-prompt".to_string()));
|
||||
assert!(args.contains(&"You are helpful.".to_string()));
|
||||
let args = build_chat_args(&chat_request!("hi", Some("You are helpful."), None));
|
||||
|
||||
assert_args_contain!(args, ["--system-prompt", "You are helpful."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_empty_system_prompt_is_skipped() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hi".into(),
|
||||
system_prompt: Some(String::new()),
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
let args = build_chat_args(&chat_request!("hi", Some(""), None));
|
||||
|
||||
assert!(!args.contains(&"--system-prompt".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_with_session_id() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "continue".into(),
|
||||
system_prompt: None,
|
||||
session_id: Some("sess-abc".into()),
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"--resume".to_string()));
|
||||
assert!(args.contains(&"sess-abc".to_string()));
|
||||
let args = build_chat_args(&chat_request!("continue", None, Some("sess-abc")));
|
||||
|
||||
assert_args_contain!(args, ["--resume", "sess-abc"]);
|
||||
}
|
||||
|
||||
// --- build_agent_args ---
|
||||
@@ -1195,46 +1385,44 @@ mod tests {
|
||||
#[test]
|
||||
fn build_agent_args_basic() {
|
||||
// build_agent_args calls build_mcp_config which needs mcp_server_dir
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "create note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
}) {
|
||||
assert!(args.contains(&"-p".to_string()));
|
||||
assert!(args.contains(&"create note".to_string()));
|
||||
assert!(args.contains(&"--mcp-config".to_string()));
|
||||
assert!(args.contains(&"--strict-mcp-config".to_string()));
|
||||
assert!(args.contains(&"--permission-mode".to_string()));
|
||||
assert!(args.contains(&"acceptEdits".to_string()));
|
||||
assert!(args.contains(&"--tools".to_string()));
|
||||
assert!(args.contains(&"Read,Edit,MultiEdit,Write,Glob,Grep,LS".to_string()));
|
||||
assert!(!args.contains(&"--dangerously-skip-permissions".to_string()));
|
||||
assert!(!args.contains(&"bypassPermissions".to_string()));
|
||||
assert!(!args.contains(&"Bash".to_string()));
|
||||
assert!(args.contains(&"--no-session-persistence".to_string()));
|
||||
assert!(!args.contains(&"--append-system-prompt".to_string()));
|
||||
if let Ok(args) = build_agent_args(&agent_request!(
|
||||
"create note",
|
||||
None,
|
||||
AiAgentPermissionMode::Safe,
|
||||
)) {
|
||||
assert_args_contain!(args, ["-p", "create note", "--mcp-config"]);
|
||||
assert_args_contain!(args, ["--strict-mcp-config", "--permission-mode"]);
|
||||
assert_args_contain!(args, ["acceptEdits", "--tools"]);
|
||||
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]);
|
||||
assert_args_contain!(args, ["--no-session-persistence"]);
|
||||
assert_args_lack!(
|
||||
args,
|
||||
[
|
||||
"--dangerously-skip-permissions",
|
||||
"bypassPermissions",
|
||||
"--append-system-prompt",
|
||||
],
|
||||
);
|
||||
assert_no_arg_contains!(args, "Bash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_agent_args_with_system_prompt() {
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "do it".into(),
|
||||
system_prompt: Some("Act as expert.".into()),
|
||||
vault_path: "/tmp/v".into(),
|
||||
}) {
|
||||
assert!(args.contains(&"--append-system-prompt".to_string()));
|
||||
assert!(args.contains(&"Act as expert.".to_string()));
|
||||
if let Ok(args) = build_agent_args(&agent_request!(
|
||||
"do it",
|
||||
Some("Act as expert."),
|
||||
AiAgentPermissionMode::Safe,
|
||||
)) {
|
||||
assert_args_contain!(args, ["--append-system-prompt", "Act as expert."]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_agent_args_empty_system_prompt_is_skipped() {
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "x".into(),
|
||||
system_prompt: Some(String::new()),
|
||||
vault_path: "/tmp/v".into(),
|
||||
}) {
|
||||
if let Ok(args) =
|
||||
build_agent_args(&agent_request!("x", Some(""), AiAgentPermissionMode::Safe))
|
||||
{
|
||||
assert!(!args.contains(&"--append-system-prompt".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -1244,7 +1432,6 @@ mod tests {
|
||||
#[test]
|
||||
fn claude_binary_candidates_include_supported_local_and_toolchain_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = claude_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/claude"),
|
||||
home.join(".claude/local/claude"),
|
||||
@@ -1252,32 +1439,19 @@ mod tests {
|
||||
home.join(".npm-global/bin/claude"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
assert_binary_candidates_include(&home, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_binary_candidates_include_windows_exe_installs() {
|
||||
let home = PathBuf::from(r"C:\Users\alex");
|
||||
let candidates = claude_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/claude.exe"),
|
||||
home.join(".claude/local/claude.exe"),
|
||||
home.join("AppData/Roaming/npm/claude.cmd"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
assert_binary_candidates_include(&home, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1333,6 +1507,7 @@ mod tests {
|
||||
message: "test".into(),
|
||||
system_prompt: Some("sys".into()),
|
||||
vault_path: "/tmp/nonexistent".into(),
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
};
|
||||
let mut events = vec![];
|
||||
let result = run_agent_stream(req, |e| events.push(e));
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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![]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentPermissionMode, AiAgentStreamEvent};
|
||||
use std::io::BufRead;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
pub permission_mode: AiAgentPermissionMode,
|
||||
}
|
||||
|
||||
pub fn check_cli() -> AiAgentAvailability {
|
||||
crate::opencode_discovery::check_cli()
|
||||
}
|
||||
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = crate::opencode_discovery::find_binary()?;
|
||||
let mut command = crate::opencode_config::build_command(&binary, &request)?;
|
||||
run_agent_stream_with_binary(&binary, request, emit)
|
||||
}
|
||||
|
||||
fn run_agent_stream_with_binary<F>(
|
||||
binary: &Path,
|
||||
request: AgentStreamRequest,
|
||||
mut emit: F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let mut command = crate::opencode_config::build_command(binary, &request)?;
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Failed to spawn opencode: {error}"))?;
|
||||
@@ -56,3 +69,88 @@ where
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let script = dir.join("opencode");
|
||||
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
script
|
||||
}
|
||||
|
||||
fn request(vault_path: String) -> AgentStreamRequest {
|
||||
AgentStreamRequest {
|
||||
message: "Summarize".into(),
|
||||
system_prompt: None,
|
||||
vault_path,
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_agent_stream_maps_opencode_json_events() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
r#"printf '%s\n' '{"type":"session","sessionID":"open_1"}'
|
||||
printf '%s\n' '{"type":"message","text":"Done"}'
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut events = Vec::new();
|
||||
let session_id = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
request(vault.path().to_string_lossy().into_owned()),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session_id, "open_1");
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Init { session_id } if session_id == "open_1"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "Done"
|
||||
));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_agent_stream_reports_opencode_nonzero_exit_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
r#"printf '%s\n' '{"type":"session","sessionID":"open_1"}'
|
||||
printf '%s\n' 'provider login required' >&2
|
||||
exit 3
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut events = Vec::new();
|
||||
let session_id = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
request(vault.path().to_string_lossy().into_owned()),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session_id, "open_1");
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AiAgentStreamEvent::Error { message } if message.contains("provider configured")
|
||||
)));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
use crate::opencode_cli::AgentStreamRequest;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
@@ -12,7 +13,7 @@ pub(crate) fn build_command(
|
||||
.arg(build_prompt(request))
|
||||
.env(
|
||||
"OPENCODE_CONFIG_CONTENT",
|
||||
build_config(&request.vault_path)?,
|
||||
build_config(&request.vault_path, request.permission_mode)?,
|
||||
)
|
||||
.current_dir(&request.vault_path)
|
||||
.stdin(Stdio::null())
|
||||
@@ -40,7 +41,10 @@ fn build_prompt(request: &AgentStreamRequest) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_config(vault_path: &str) -> Result<String, String> {
|
||||
fn build_config(
|
||||
vault_path: &str,
|
||||
permission_mode: AiAgentPermissionMode,
|
||||
) -> Result<String, String> {
|
||||
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
|
||||
let mcp_server_path = mcp_server
|
||||
.to_str()
|
||||
@@ -49,7 +53,7 @@ fn build_config(vault_path: &str) -> Result<String, String> {
|
||||
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": permission_config(),
|
||||
"permission": permission_config(permission_mode),
|
||||
"mcp": {
|
||||
"tolaria": {
|
||||
"type": "local",
|
||||
@@ -62,7 +66,12 @@ fn build_config(vault_path: &str) -> Result<String, String> {
|
||||
.map_err(|error| format!("Failed to serialize opencode config: {error}"))
|
||||
}
|
||||
|
||||
fn permission_config() -> serde_json::Value {
|
||||
fn permission_config(permission_mode: AiAgentPermissionMode) -> serde_json::Value {
|
||||
let bash_permission = match permission_mode {
|
||||
AiAgentPermissionMode::Safe => "deny",
|
||||
AiAgentPermissionMode::PowerUser => "allow",
|
||||
};
|
||||
|
||||
serde_json::json!({
|
||||
"read": "allow",
|
||||
"edit": "allow",
|
||||
@@ -70,7 +79,7 @@ fn permission_config() -> serde_json::Value {
|
||||
"grep": "allow",
|
||||
"list": "allow",
|
||||
"external_directory": "deny",
|
||||
"bash": "deny"
|
||||
"bash": bash_permission
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,6 +94,7 @@ mod tests {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +128,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn config_includes_permissions_and_tolaria_mcp_server() {
|
||||
if let Ok(config) = build_config("/tmp/vault") {
|
||||
if let Ok(config) =
|
||||
build_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe)
|
||||
{
|
||||
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
|
||||
assert_eq!(json["permission"]["edit"], "allow");
|
||||
assert_eq!(json["permission"]["external_directory"], "deny");
|
||||
@@ -136,6 +148,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_user_config_allows_bash_but_keeps_external_directories_denied() {
|
||||
if let Ok(config) = build_config(
|
||||
"/tmp/vault",
|
||||
crate::ai_agents::AiAgentPermissionMode::PowerUser,
|
||||
) {
|
||||
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
|
||||
assert_eq!(json["permission"]["bash"], "allow");
|
||||
assert_eq!(json["permission"]["external_directory"], "deny");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_prompt(&AgentStreamRequest {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,61 @@
|
||||
use super::*;
|
||||
|
||||
fn dispatch_events(events: impl IntoIterator<Item = serde_json::Value>) -> Vec<AiAgentStreamEvent> {
|
||||
let mut mapped = Vec::new();
|
||||
|
||||
for event in events {
|
||||
dispatch_event(&event, &mut |event| mapped.push(event));
|
||||
}
|
||||
|
||||
mapped
|
||||
}
|
||||
|
||||
struct ToolExpectation<'a> {
|
||||
tool_id: &'a str,
|
||||
tool_name: &'a str,
|
||||
input: Option<&'a str>,
|
||||
output: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn assert_tool_pair(events: &[AiAgentStreamEvent], expected: ToolExpectation<'_>) {
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ToolStart {
|
||||
tool_name: actual_name,
|
||||
tool_id: actual_id,
|
||||
input: actual_input,
|
||||
} if actual_name == expected.tool_name
|
||||
&& actual_id == expected.tool_id
|
||||
&& actual_input.as_deref() == expected.input
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ToolDone {
|
||||
tool_id: actual_id,
|
||||
output: actual_output,
|
||||
} if actual_id == expected.tool_id && actual_output.as_deref() == expected.output
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_line_reports_read_errors_and_skips_blank_or_invalid_lines() {
|
||||
let mut events = Vec::new();
|
||||
|
||||
let read_error = parse_line(Err(std::io::Error::other("broken pipe")), &mut |event| {
|
||||
events.push(event)
|
||||
});
|
||||
let blank = parse_line(Ok(" ".into()), &mut |event| events.push(event));
|
||||
let invalid = parse_line(Ok("not json".into()), &mut |event| events.push(event));
|
||||
|
||||
assert!(read_error.is_none());
|
||||
assert!(blank.is_none());
|
||||
assert!(invalid.is_none());
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Error { message } if message.contains("broken pipe")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_session_reasoning_and_text() {
|
||||
let mut events = Vec::new();
|
||||
@@ -26,28 +82,90 @@ fn dispatch_maps_session_reasoning_and_text() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_tool_events() {
|
||||
fn dispatch_maps_part_backed_reasoning_and_text() {
|
||||
let mut events = Vec::new();
|
||||
let tool_start = serde_json::json!({
|
||||
"type": "tool_use",
|
||||
"id": "tool_1",
|
||||
"name": "read",
|
||||
"input": { "path": "Note.md" }
|
||||
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" }
|
||||
});
|
||||
let tool_done = serde_json::json!({ "type": "tool_result", "id": "tool_1", "output": "ok" });
|
||||
|
||||
dispatch_event(&tool_start, &mut |event| events.push(event));
|
||||
dispatch_event(&tool_done, &mut |event| events.push(event));
|
||||
for event in [reasoning, text] {
|
||||
dispatch_event(&event, &mut |event| events.push(event));
|
||||
}
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ToolStart { tool_name, tool_id, input }
|
||||
if tool_name == "read" && tool_id == "tool_1" && input.as_deref() == Some(r#"{"path":"Note.md"}"#)
|
||||
AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ToolDone { tool_id, output }
|
||||
if tool_id == "tool_1" && output.as_deref() == Some("ok")
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "Done from OpenCode"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_tool_events() {
|
||||
let direct = dispatch_events([
|
||||
serde_json::json!({
|
||||
"type": "tool_use",
|
||||
"id": "tool_1",
|
||||
"name": "read",
|
||||
"input": { "path": "Note.md" }
|
||||
}),
|
||||
serde_json::json!({ "type": "tool_result", "id": "tool_1", "output": "ok" }),
|
||||
]);
|
||||
let part_backed = dispatch_events([
|
||||
serde_json::json!({
|
||||
"type": "tool_use",
|
||||
"part": {
|
||||
"id": "prt_tool_1",
|
||||
"tool": "read",
|
||||
"input": { "path": "Note.md" }
|
||||
}
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"part": {
|
||||
"id": "prt_tool_1",
|
||||
"output": "ok"
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
assert_tool_pair(
|
||||
&direct,
|
||||
ToolExpectation {
|
||||
tool_id: "tool_1",
|
||||
tool_name: "read",
|
||||
input: Some(r#"{"path":"Note.md"}"#),
|
||||
output: Some("ok"),
|
||||
},
|
||||
);
|
||||
assert_tool_pair(
|
||||
&part_backed,
|
||||
ToolExpectation {
|
||||
tool_id: "prt_tool_1",
|
||||
tool_name: "read",
|
||||
input: Some(r#"{"path":"Note.md"}"#),
|
||||
output: Some("ok"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_error_events() {
|
||||
let mut events = Vec::new();
|
||||
let error = serde_json::json!({ "type": "error", "message": "provider failed" });
|
||||
|
||||
dispatch_event(&error, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Error { message } if message == "provider failed"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -61,3 +179,12 @@ fn format_error_explains_missing_auth_or_provider_setup() {
|
||||
assert!(message.contains("OpenCode CLI is not authenticated"));
|
||||
assert!(message.contains("opencode auth login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_error_uses_status_or_first_stderr_lines() {
|
||||
let empty = format_error(String::new(), "exit status: 2".into());
|
||||
let truncated = format_error("line 1\nline 2\nline 3\nline 4".into(), "ignored".into());
|
||||
|
||||
assert_eq!(empty, "opencode exited with status exit status: 2");
|
||||
assert_eq!(truncated, "line 1\nline 2\nline 3");
|
||||
}
|
||||
|
||||
@@ -1,27 +1,40 @@
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentPermissionMode, AiAgentStreamEvent};
|
||||
use std::io::BufRead;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
pub permission_mode: AiAgentPermissionMode,
|
||||
}
|
||||
|
||||
pub fn check_cli() -> AiAgentAvailability {
|
||||
crate::pi_discovery::check_cli()
|
||||
}
|
||||
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = crate::pi_discovery::find_binary()?;
|
||||
run_agent_stream_with_binary(&binary, request, emit)
|
||||
}
|
||||
|
||||
fn run_agent_stream_with_binary<F>(
|
||||
binary: &Path,
|
||||
request: AgentStreamRequest,
|
||||
mut emit: F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let agent_dir = tempfile::Builder::new()
|
||||
.prefix("tolaria-pi-agent-")
|
||||
.tempdir()
|
||||
.map_err(|error| format!("Failed to create Pi config directory: {error}"))?;
|
||||
let mut command = crate::pi_config::build_command(&binary, &request, agent_dir.path())?;
|
||||
let mut command = crate::pi_config::build_command(binary, &request, agent_dir.path())?;
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Failed to spawn pi: {error}"))?;
|
||||
@@ -60,3 +73,88 @@ where
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let script = dir.join("pi");
|
||||
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
script
|
||||
}
|
||||
|
||||
fn request(vault_path: String) -> AgentStreamRequest {
|
||||
AgentStreamRequest {
|
||||
message: "Summarize".into(),
|
||||
system_prompt: None,
|
||||
vault_path,
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_agent_stream_maps_pi_json_events() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
r#"printf '%s\n' '{"type":"session","id":"pi_1"}'
|
||||
printf '%s\n' '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"Done"}}'
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut events = Vec::new();
|
||||
let session_id = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
request(vault.path().to_string_lossy().into_owned()),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session_id, "pi_1");
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Init { session_id } if session_id == "pi_1"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "Done"
|
||||
));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_agent_stream_reports_pi_nonzero_exit_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
r#"printf '%s\n' '{"type":"session","id":"pi_1"}'
|
||||
printf '%s\n' 'api key login required' >&2
|
||||
exit 4
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut events = Vec::new();
|
||||
let session_id = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
request(vault.path().to_string_lossy().into_owned()),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session_id, "pi_1");
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AiAgentStreamEvent::Error { message } if message.contains("not authenticated")
|
||||
)));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
use crate::pi_cli::AgentStreamRequest;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
@@ -7,7 +8,7 @@ pub(crate) fn build_command(
|
||||
request: &AgentStreamRequest,
|
||||
agent_dir: &Path,
|
||||
) -> Result<std::process::Command, String> {
|
||||
write_mcp_config(agent_dir, &request.vault_path)?;
|
||||
write_mcp_config(agent_dir, &request.vault_path, request.permission_mode)?;
|
||||
|
||||
let mut command = crate::hidden_command(binary);
|
||||
command
|
||||
@@ -46,15 +47,22 @@ fn build_prompt(request: &AgentStreamRequest) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_mcp_config(agent_dir: &Path, vault_path: &str) -> Result<(), String> {
|
||||
fn write_mcp_config(
|
||||
agent_dir: &Path,
|
||||
vault_path: &str,
|
||||
permission_mode: AiAgentPermissionMode,
|
||||
) -> Result<(), String> {
|
||||
std::fs::create_dir_all(agent_dir)
|
||||
.map_err(|error| format!("Failed to create Pi agent directory: {error}"))?;
|
||||
let config = build_mcp_config(vault_path)?;
|
||||
let config = build_mcp_config(vault_path, permission_mode)?;
|
||||
std::fs::write(agent_dir.join("mcp.json"), config)
|
||||
.map_err(|error| format!("Failed to write Pi MCP config: {error}"))
|
||||
}
|
||||
|
||||
fn build_mcp_config(vault_path: &str) -> Result<String, String> {
|
||||
fn build_mcp_config(
|
||||
vault_path: &str,
|
||||
_permission_mode: AiAgentPermissionMode,
|
||||
) -> Result<String, String> {
|
||||
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
|
||||
let mcp_server_path = mcp_server
|
||||
.to_str()
|
||||
@@ -93,6 +101,7 @@ mod tests {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +138,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mcp_config_includes_tolaria_server_for_active_vault() {
|
||||
if let Ok(config) = build_mcp_config("/tmp/vault") {
|
||||
if let Ok(config) =
|
||||
build_mcp_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe)
|
||||
{
|
||||
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
|
||||
assert_eq!(json["settings"]["toolPrefix"], "none");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["command"], "node");
|
||||
@@ -147,6 +158,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_user_mode_uses_the_same_pi_mcp_config_as_safe_mode() {
|
||||
let safe =
|
||||
build_mcp_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe).unwrap();
|
||||
let power = build_mcp_config(
|
||||
"/tmp/vault",
|
||||
crate::ai_agents::AiAgentPermissionMode::PowerUser,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(safe, power);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_prompt(&AgentStreamRequest {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_line_reports_read_errors_and_skips_blank_or_invalid_lines() {
|
||||
let mut events = Vec::new();
|
||||
|
||||
let read_error = parse_line(Err(std::io::Error::other("broken pipe")), &mut |event| {
|
||||
events.push(event)
|
||||
});
|
||||
let blank = parse_line(Ok(" ".into()), &mut |event| events.push(event));
|
||||
let invalid = parse_line(Ok("not json".into()), &mut |event| events.push(event));
|
||||
|
||||
assert!(read_error.is_none());
|
||||
assert!(blank.is_none());
|
||||
assert!(invalid.is_none());
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Error { message } if message.contains("broken pipe")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_session_thinking_and_text() {
|
||||
let mut events = Vec::new();
|
||||
@@ -62,6 +81,19 @@ fn dispatch_maps_tool_events() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_error_events() {
|
||||
let mut events = Vec::new();
|
||||
let error = serde_json::json!({ "type": "error", "message": "provider failed" });
|
||||
|
||||
dispatch_event(&error, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Error { message } if message == "provider failed"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_error_explains_missing_auth_or_provider_setup() {
|
||||
let message = format_error(
|
||||
@@ -72,3 +104,12 @@ fn format_error_explains_missing_auth_or_provider_setup() {
|
||||
assert!(message.contains("Pi CLI is not authenticated"));
|
||||
assert!(message.contains("pi /login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_error_uses_status_or_first_stderr_lines() {
|
||||
let empty = format_error(String::new(), "exit status: 2".into());
|
||||
let truncated = format_error("line 1\nline 2\nline 3\nline 4".into(), "ignored".into());
|
||||
|
||||
assert_eq!(empty, "pi exited with status exit status: 2");
|
||||
assert_eq!(truncated, "line 1\nline 2\nline 3");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¬e_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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
43
src/App.tsx
43
src/App.tsx
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface AiAction {
|
||||
export interface AiMessageProps {
|
||||
userMessage: string
|
||||
references?: NoteReference[]
|
||||
localMarker?: string
|
||||
reasoning?: string
|
||||
reasoningDone?: boolean
|
||||
actions: AiAction[]
|
||||
@@ -27,6 +28,18 @@ export interface AiMessageProps {
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
}
|
||||
|
||||
function LocalMarker({ text }: { text: string }) {
|
||||
return (
|
||||
<div
|
||||
className="mx-auto text-center text-muted-foreground"
|
||||
style={{ fontSize: 11, margin: '8px 0 16px', maxWidth: '85%' }}
|
||||
data-testid="ai-local-marker"
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReferencePill({ reference, onClick }: {
|
||||
reference: NoteReference
|
||||
onClick?: (path: string) => void
|
||||
@@ -176,7 +189,15 @@ function StreamingIndicator() {
|
||||
)
|
||||
}
|
||||
|
||||
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
|
||||
export function AiMessage(props: AiMessageProps) {
|
||||
if (props.localMarker) {
|
||||
return <LocalMarker text={props.localMarker} />
|
||||
}
|
||||
|
||||
return <ConversationMessage {...props} />
|
||||
}
|
||||
|
||||
function ConversationMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
|
||||
// Manual override: null = follow auto behavior, true/false = user forced
|
||||
const [userOverride, setUserOverride] = useState(false)
|
||||
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
|
||||
|
||||
@@ -4,20 +4,27 @@ import { AiPanel } from './AiPanel'
|
||||
import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { queueAiPrompt } from '../utils/aiPromptBridge'
|
||||
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
|
||||
|
||||
// Mock the hooks and utils to isolate component tests
|
||||
let mockMessages: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['messages'] = []
|
||||
let mockStatus: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['status'] = 'idle'
|
||||
const mockSendMessage = vi.fn()
|
||||
const mockClearConversation = vi.fn()
|
||||
const mockAddLocalMarker = vi.fn()
|
||||
const mockUseCliAiAgent = vi.fn()
|
||||
|
||||
vi.mock('../hooks/useCliAiAgent', () => ({
|
||||
useCliAiAgent: () => ({
|
||||
messages: mockMessages,
|
||||
status: mockStatus,
|
||||
sendMessage: mockSendMessage,
|
||||
clearConversation: mockClearConversation,
|
||||
}),
|
||||
useCliAiAgent: (...args: unknown[]) => {
|
||||
mockUseCliAiAgent(...args)
|
||||
return {
|
||||
messages: mockMessages,
|
||||
status: mockStatus,
|
||||
sendMessage: mockSendMessage,
|
||||
clearConversation: mockClearConversation,
|
||||
addLocalMarker: mockAddLocalMarker,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../utils/ai-chat', () => ({
|
||||
@@ -45,7 +52,18 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -55,12 +73,78 @@ describe('AiPanel', () => {
|
||||
mockStatus = 'idle'
|
||||
mockSendMessage.mockReset()
|
||||
mockClearConversation.mockReset()
|
||||
mockAddLocalMarker.mockReset()
|
||||
mockUseCliAiAgent.mockReset()
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore({
|
||||
zoom: null,
|
||||
view_mode: null,
|
||||
editor_mode: null,
|
||||
note_layout: null,
|
||||
tag_colors: null,
|
||||
status_colors: null,
|
||||
property_display_modes: null,
|
||||
inbox: null,
|
||||
allNotes: null,
|
||||
ai_agent_permission_mode: 'safe',
|
||||
}, vi.fn())
|
||||
})
|
||||
|
||||
it('renders panel with the default CLI agent header', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByText('AI Agent')).toBeTruthy()
|
||||
expect(screen.getByText('Claude Code')).toBeTruthy()
|
||||
expect(screen.getByText('Claude Code · Safe')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('passes the vault permission mode to the AI agent session', () => {
|
||||
bindVaultConfigStore({
|
||||
...getVaultConfig(),
|
||||
ai_agent_permission_mode: 'power_user',
|
||||
}, vi.fn())
|
||||
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
|
||||
expect(screen.getByText('Claude Code · Power User')).toBeTruthy()
|
||||
expect(mockUseCliAiAgent).toHaveBeenCalledWith(
|
||||
'/tmp/vault',
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ permissionMode: 'power_user' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('persists permission mode changes and records a local transcript marker', () => {
|
||||
const save = vi.fn()
|
||||
bindVaultConfigStore({
|
||||
...getVaultConfig(),
|
||||
ai_agent_permission_mode: 'safe',
|
||||
}, save)
|
||||
mockMessages = [{
|
||||
userMessage: 'Existing question',
|
||||
actions: [],
|
||||
response: 'Existing answer.',
|
||||
id: 'msg-existing',
|
||||
}]
|
||||
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Power User' }))
|
||||
|
||||
expect(getVaultConfig().ai_agent_permission_mode).toBe('power_user')
|
||||
expect(save).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
ai_agent_permission_mode: 'power_user',
|
||||
}))
|
||||
expect(mockAddLocalMarker).toHaveBeenCalledWith(
|
||||
'AI permission mode changed to Power User. It will apply to the next message.',
|
||||
)
|
||||
})
|
||||
|
||||
it('disables permission mode changes while the AI agent is running', () => {
|
||||
mockStatus = 'thinking'
|
||||
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Vault Safe' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Power User' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('renders data-testid ai-panel', () => {
|
||||
@@ -85,6 +169,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()
|
||||
|
||||
@@ -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,
|
||||
@@ -79,8 +82,10 @@ export function AiPanelView({
|
||||
linkedEntries,
|
||||
hasContext,
|
||||
isActive,
|
||||
permissionMode,
|
||||
handleSend,
|
||||
handleNavigateWikilink,
|
||||
handlePermissionModeChange,
|
||||
handleNewChat,
|
||||
} = controller
|
||||
|
||||
@@ -112,7 +117,11 @@ export function AiPanelView({
|
||||
<AiPanelHeader
|
||||
agentLabel={agentLabel}
|
||||
agentReadiness={defaultAiAgentReadiness}
|
||||
permissionMode={permissionMode}
|
||||
permissionModeDisabled={isActive}
|
||||
onPermissionModeChange={handlePermissionModeChange}
|
||||
onClose={onClose}
|
||||
onCopyMcpConfig={onCopyMcpConfig}
|
||||
onNewChat={handleNewChat}
|
||||
/>
|
||||
{activeEntry && (
|
||||
@@ -144,6 +153,7 @@ export function AiPanelView({
|
||||
|
||||
export function AiPanel({
|
||||
onClose,
|
||||
onCopyMcpConfig,
|
||||
onOpenNote,
|
||||
onUnsupportedAiPaste,
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
@@ -183,6 +193,7 @@ export function AiPanel({
|
||||
<AiPanelView
|
||||
controller={controller}
|
||||
onClose={onClose}
|
||||
onCopyMcpConfig={onCopyMcpConfig}
|
||||
onOpenNote={onOpenNote}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
defaultAiAgent={providedDefaultAiAgent}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
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 {
|
||||
AI_AGENT_PERMISSION_MODE_LABELS,
|
||||
type AiAgentPermissionMode,
|
||||
} from '../lib/aiAgentPermissionMode'
|
||||
import type { AiAgentMessage } from '../hooks/useCliAiAgent'
|
||||
import type { AiAgentReadiness } from '../lib/aiAgents'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
@@ -11,7 +17,11 @@ import type { VaultEntry } from '../types'
|
||||
interface AiPanelHeaderProps {
|
||||
agentLabel: string
|
||||
agentReadiness: AiAgentReadiness
|
||||
permissionMode: AiAgentPermissionMode
|
||||
permissionModeDisabled: boolean
|
||||
onPermissionModeChange: (mode: AiAgentPermissionMode) => void
|
||||
onClose: () => void
|
||||
onCopyMcpConfig?: () => void
|
||||
onNewChat: () => void
|
||||
}
|
||||
|
||||
@@ -121,40 +131,108 @@ function AiPanelEmptyState({
|
||||
export function AiPanelHeader({
|
||||
agentLabel,
|
||||
agentReadiness,
|
||||
permissionMode,
|
||||
permissionModeDisabled,
|
||||
onPermissionModeChange,
|
||||
onClose,
|
||||
onCopyMcpConfig,
|
||||
onNewChat,
|
||||
}: AiPanelHeaderProps) {
|
||||
const modeLabel = AI_AGENT_PERMISSION_MODE_LABELS[permissionMode].short
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center border-b border-border"
|
||||
style={{ height: 52, padding: '0 12px', gap: 8 }}
|
||||
className="flex shrink-0 flex-col border-b border-border"
|
||||
style={{ padding: '8px 12px', gap: 8 }}
|
||||
>
|
||||
<Robot size={16} className="shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
AI Agent
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{agentReadiness === 'checking'
|
||||
? 'Checking availability'
|
||||
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ''}`}
|
||||
</span>
|
||||
<div className="flex items-center" style={{ gap: 8 }}>
|
||||
<Robot size={16} className="shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
AI Agent
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{agentReadiness === 'checking'
|
||||
? 'Checking availability'
|
||||
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ` · ${modeLabel}`}`}
|
||||
</span>
|
||||
</div>
|
||||
{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
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onClose}
|
||||
aria-label="Close AI panel"
|
||||
title="Close AI panel"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
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"
|
||||
onClick={onClose}
|
||||
title="Close AI panel"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<AiPermissionModeToggle
|
||||
value={permissionMode}
|
||||
disabled={permissionModeDisabled}
|
||||
onChange={onPermissionModeChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AiPermissionModeToggle({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
value: AiAgentPermissionMode
|
||||
disabled: boolean
|
||||
onChange: (mode: AiAgentPermissionMode) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="grid rounded-md bg-muted"
|
||||
style={{ gridTemplateColumns: '1fr 1fr', gap: 2, padding: 2 }}
|
||||
role="group"
|
||||
aria-label="AI agent permission mode"
|
||||
data-testid="ai-permission-mode-toggle"
|
||||
>
|
||||
{(['safe', 'power_user'] as const).map((mode) => {
|
||||
const selected = value === mode
|
||||
return (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
size="xs"
|
||||
variant={selected ? 'secondary' : 'ghost'}
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onChange(mode)}
|
||||
>
|
||||
{AI_AGENT_PERMISSION_MODE_LABELS[mode].control}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -253,16 +331,20 @@ export function AiPanelComposer({
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
||||
style={sendButtonStyle}
|
||||
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
|
||||
disabled={!canSend}
|
||||
aria-label="Send message"
|
||||
title="Send message"
|
||||
data-testid="agent-send"
|
||||
>
|
||||
<PaperPlaneRight size={16} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: <Tolaria resources>/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'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'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>
|
||||
)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />)
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import type { AiAgentId, AiAgentReadiness } from '../lib/aiAgents'
|
||||
import {
|
||||
aiAgentPermissionModeMarker,
|
||||
normalizeAiAgentPermissionMode,
|
||||
type AiAgentPermissionMode,
|
||||
} from '../lib/aiAgentPermissionMode'
|
||||
import { useCliAiAgent, type AgentFileCallbacks } from '../hooks/useCliAiAgent'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
getVaultConfig,
|
||||
subscribeVaultConfig,
|
||||
updateVaultConfigField,
|
||||
} from '../utils/vaultConfigStore'
|
||||
import {
|
||||
type NoteListItem,
|
||||
type NoteReference,
|
||||
@@ -32,8 +42,10 @@ export interface AiPanelController {
|
||||
linkedEntries: ReturnType<typeof useAiPanelContextSnapshot>['linkedEntries']
|
||||
hasContext: boolean
|
||||
isActive: boolean
|
||||
permissionMode: AiAgentPermissionMode
|
||||
handleSend: (text: string, references: NoteReference[]) => void
|
||||
handleNavigateWikilink: (target: string) => void
|
||||
handlePermissionModeChange: (mode: AiAgentPermissionMode) => void
|
||||
handleNewChat: () => void
|
||||
}
|
||||
|
||||
@@ -44,6 +56,26 @@ function resolveAgentReady(
|
||||
return (readiness ?? (ready ? 'ready' : 'missing')) === 'ready'
|
||||
}
|
||||
|
||||
function useVaultAiAgentPermissionMode(): AiAgentPermissionMode {
|
||||
const vaultConfig = useSyncExternalStore(subscribeVaultConfig, getVaultConfig)
|
||||
return normalizeAiAgentPermissionMode(vaultConfig.ai_agent_permission_mode)
|
||||
}
|
||||
|
||||
function useAgentFileCallbacks({
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}: Pick<
|
||||
UseAiPanelControllerArgs,
|
||||
'onFileCreated' | 'onFileModified' | 'onVaultChanged'
|
||||
>): AgentFileCallbacks {
|
||||
return useMemo<AgentFileCallbacks>(() => ({
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}), [onFileCreated, onFileModified, onVaultChanged])
|
||||
}
|
||||
|
||||
export function useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent,
|
||||
@@ -71,15 +103,13 @@ export function useAiPanelController({
|
||||
noteListFilter,
|
||||
})
|
||||
|
||||
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}), [onFileCreated, onFileModified, onVaultChanged])
|
||||
const fileCallbacks = useAgentFileCallbacks({ onFileCreated, onFileModified, onVaultChanged })
|
||||
const permissionMode = useVaultAiAgentPermissionMode()
|
||||
|
||||
const agent = useCliAiAgent(vaultPath, contextPrompt, fileCallbacks, {
|
||||
agent: defaultAiAgent,
|
||||
agentReady: resolveAgentReady(defaultAiAgentReadiness, defaultAiAgentReady),
|
||||
permissionMode,
|
||||
})
|
||||
const hasContext = !!activeEntry
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
@@ -94,6 +124,14 @@ export function useAiPanelController({
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
const handlePermissionModeChange = useCallback((mode: AiAgentPermissionMode) => {
|
||||
const nextMode = normalizeAiAgentPermissionMode(mode)
|
||||
if (isActive || nextMode === permissionMode) return
|
||||
|
||||
updateVaultConfigField('ai_agent_permission_mode', nextMode)
|
||||
agent.addLocalMarker(aiAgentPermissionModeMarker(nextMode))
|
||||
}, [agent, isActive, permissionMode])
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
agent.clearConversation()
|
||||
setInput('')
|
||||
@@ -106,8 +144,10 @@ export function useAiPanelController({
|
||||
linkedEntries,
|
||||
hasContext,
|
||||
isActive,
|
||||
permissionMode,
|
||||
handleSend,
|
||||
handleNavigateWikilink,
|
||||
handlePermissionModeChange,
|
||||
handleNewChat,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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?.(),
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
|
||||
38
src/hooks/editorBlockRepair.ts
Normal file
38
src/hooks/editorBlockRepair.ts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
31
src/hooks/editorTiptapSelection.ts
Normal file
31
src/hooks/editorTiptapSelection.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,11 +14,15 @@ vi.mock('../utils/ai-agent', () => ({
|
||||
const mockStreamAiAgent = vi.mocked(streamAiAgent)
|
||||
const VAULT = '/Users/luca/Laputa'
|
||||
|
||||
function renderAgent(contextPrompt: string | undefined = undefined) {
|
||||
function renderAgent(
|
||||
contextPrompt: string | undefined = undefined,
|
||||
permissionMode: 'safe' | 'power_user' = 'safe',
|
||||
) {
|
||||
return renderHook(
|
||||
({ context }) => useCliAiAgent(VAULT, context, undefined, {
|
||||
agent: 'codex',
|
||||
agentReady: true,
|
||||
permissionMode,
|
||||
}),
|
||||
{ initialProps: { context: contextPrompt } },
|
||||
)
|
||||
@@ -49,6 +53,37 @@ describe('useCliAiAgent', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('forwards the current permission mode to the stream request', async () => {
|
||||
const { result } = renderAgent(undefined, 'power_user')
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('Use the local tools')
|
||||
})
|
||||
|
||||
expect(mockStreamAiAgent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
permissionMode: 'power_user',
|
||||
}))
|
||||
})
|
||||
|
||||
it('adds local transcript markers without sending them as chat history', async () => {
|
||||
const { result } = renderAgent()
|
||||
|
||||
act(() => {
|
||||
result.current.addLocalMarker('AI permission mode changed to Power User. It will apply to the next message.')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('Continue')
|
||||
})
|
||||
|
||||
expect(result.current.messages[0]).toEqual(expect.objectContaining({
|
||||
localMarker: 'AI permission mode changed to Power User. It will apply to the next message.',
|
||||
}))
|
||||
expect(mockStreamAiAgent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'Continue',
|
||||
}))
|
||||
})
|
||||
|
||||
it('embeds completed conversation history and clears it for a fresh chat', async () => {
|
||||
let responseNumber = 0
|
||||
mockStreamAiAgent.mockImplementation(async ({ callbacks }) => {
|
||||
@@ -80,6 +115,7 @@ describe('useCliAiAgent', () => {
|
||||
const { result } = renderHook(() => useCliAiAgent(VAULT, undefined, undefined, {
|
||||
agent: 'codex',
|
||||
agentReady: false,
|
||||
permissionMode: 'safe',
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
|
||||
import type { AiAgentId } from '../lib/aiAgents'
|
||||
import type { AiAgentPermissionMode } from '../lib/aiAgentPermissionMode'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import {
|
||||
type AgentStatus,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
} from '../lib/aiAgentConversation'
|
||||
import type { AgentFileCallbacks } from '../lib/aiAgentFileOperations'
|
||||
import {
|
||||
addAgentLocalMarker,
|
||||
clearAgentConversation,
|
||||
sendAgentMessage,
|
||||
type AiAgentSessionRuntime,
|
||||
@@ -20,6 +22,7 @@ export type { AiAgentMessage } from '../lib/aiAgentConversation'
|
||||
interface UseCliAiAgentOptions {
|
||||
agent: AiAgentId
|
||||
agentReady: boolean
|
||||
permissionMode: AiAgentPermissionMode
|
||||
}
|
||||
|
||||
interface UseCliAiAgentRuntime extends AiAgentSessionRuntime {
|
||||
@@ -66,6 +69,7 @@ export function useCliAiAgent(
|
||||
options: UseCliAiAgentOptions,
|
||||
) {
|
||||
const { agent, agentReady } = options
|
||||
const { permissionMode } = options
|
||||
const runtime = useCliAiAgentRuntime(fileCallbacks)
|
||||
const { messages, status } = runtime
|
||||
|
||||
@@ -76,6 +80,7 @@ export function useCliAiAgent(
|
||||
agent,
|
||||
ready: agentReady,
|
||||
vaultPath,
|
||||
permissionMode,
|
||||
systemPromptOverride: contextPrompt,
|
||||
},
|
||||
prompt: { text, references },
|
||||
@@ -86,5 +91,9 @@ export function useCliAiAgent(
|
||||
clearAgentConversation(runtime)
|
||||
}
|
||||
|
||||
return { messages, status, sendMessage, clearConversation }
|
||||
function addLocalMarker(text: string): void {
|
||||
addAgentLocalMarker(runtime, text)
|
||||
}
|
||||
|
||||
return { messages, status, sendMessage, clearConversation, addLocalMarker }
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
99
src/hooks/useSavedViewOrdering.test.ts
Normal file
99
src/hooks/useSavedViewOrdering.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
96
src/hooks/useSavedViewOrdering.ts
Normal file
96
src/hooks/useSavedViewOrdering.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type RawVaultAiGuidanceStatus = Partial<{
|
||||
agents_state: VaultAiGuidanceFileState | null
|
||||
claude_state: VaultAiGuidanceFileState | null
|
||||
gemini_state: VaultAiGuidanceFileState | null
|
||||
can_restore: boolean | null
|
||||
}>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
subscribeVaultConfig,
|
||||
} from '../utils/vaultConfigStore'
|
||||
import { migrateLocalStorageToVaultConfig } from '../utils/configMigration'
|
||||
import { DEFAULT_AI_AGENT_PERMISSION_MODE } from '../lib/aiAgentPermissionMode'
|
||||
|
||||
const STORAGE_PREFIX = 'laputa:vault-config:'
|
||||
|
||||
@@ -20,9 +21,10 @@ function storageKey(vaultPath: string): string {
|
||||
|
||||
function loadFromStorage(vaultPath: string): VaultConfig {
|
||||
const DEFAULT: VaultConfig = {
|
||||
zoom: null, view_mode: null, editor_mode: null,
|
||||
zoom: null, view_mode: null, editor_mode: null, note_layout: null,
|
||||
ai_agent_permission_mode: DEFAULT_AI_AGENT_PERMISSION_MODE,
|
||||
tag_colors: null, status_colors: null, property_display_modes: null,
|
||||
inbox: null,
|
||||
inbox: null, allNotes: null,
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(vaultPath))
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
86
src/hooks/vaultLoaderCommands.ts
Normal file
86
src/hooks/vaultLoaderCommands.ts
Normal 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 })
|
||||
}
|
||||
@@ -11,10 +11,12 @@ import {
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import type { AiAgentId } from './aiAgents'
|
||||
import { getAiAgentDefinition } from './aiAgents'
|
||||
import type { AiAgentPermissionMode } from './aiAgentPermissionMode'
|
||||
|
||||
export interface AiAgentMessage {
|
||||
userMessage: string
|
||||
references?: NoteReference[]
|
||||
localMarker?: string
|
||||
reasoning?: string
|
||||
reasoningDone?: boolean
|
||||
actions: AiAction[]
|
||||
@@ -29,6 +31,7 @@ export interface AgentExecutionContext {
|
||||
agent: AiAgentId
|
||||
ready: boolean
|
||||
vaultPath: string
|
||||
permissionMode: AiAgentPermissionMode
|
||||
systemPromptOverride?: string
|
||||
}
|
||||
|
||||
@@ -38,7 +41,7 @@ export interface PendingUserPrompt {
|
||||
}
|
||||
|
||||
function toChatHistory(messages: AiAgentMessage[]): ChatMessage[] {
|
||||
return messages.flatMap((message) => {
|
||||
return messages.filter((message) => !message.localMarker).flatMap((message) => {
|
||||
const history: ChatMessage[] = [{ role: 'user', content: message.userMessage, id: message.id ?? '' }]
|
||||
if (message.response) {
|
||||
history.push({ role: 'assistant', content: message.response, id: `${message.id}-resp` })
|
||||
@@ -47,6 +50,21 @@ function toChatHistory(messages: AiAgentMessage[]): ChatMessage[] {
|
||||
})
|
||||
}
|
||||
|
||||
export function appendLocalMarker(
|
||||
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>,
|
||||
text: string,
|
||||
): void {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{
|
||||
userMessage: '',
|
||||
localMarker: text,
|
||||
actions: [],
|
||||
id: nextMessageId(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
export function createMissingAgentResponse(agent: AiAgentId): string {
|
||||
const definition = getAiAgentDefinition(agent)
|
||||
return `${definition.label} is not available on this machine. Install it or switch the default AI agent in Settings.`
|
||||
|
||||
30
src/lib/aiAgentPermissionMode.test.ts
Normal file
30
src/lib/aiAgentPermissionMode.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AI_AGENT_PERMISSION_MODE_LABELS,
|
||||
DEFAULT_AI_AGENT_PERMISSION_MODE,
|
||||
aiAgentPermissionModeMarker,
|
||||
normalizeAiAgentPermissionMode,
|
||||
} from './aiAgentPermissionMode'
|
||||
|
||||
describe('aiAgentPermissionMode', () => {
|
||||
it('defaults missing, null, and unknown values to vault safe mode', () => {
|
||||
expect(DEFAULT_AI_AGENT_PERMISSION_MODE).toBe('safe')
|
||||
expect(normalizeAiAgentPermissionMode(undefined)).toBe('safe')
|
||||
expect(normalizeAiAgentPermissionMode(null)).toBe('safe')
|
||||
expect(normalizeAiAgentPermissionMode('danger')).toBe('safe')
|
||||
})
|
||||
|
||||
it('preserves known permission modes and exposes compact labels', () => {
|
||||
expect(normalizeAiAgentPermissionMode('safe')).toBe('safe')
|
||||
expect(normalizeAiAgentPermissionMode('power_user')).toBe('power_user')
|
||||
expect(AI_AGENT_PERMISSION_MODE_LABELS.safe.short).toBe('Safe')
|
||||
expect(AI_AGENT_PERMISSION_MODE_LABELS.safe.control).toBe('Vault Safe')
|
||||
expect(AI_AGENT_PERMISSION_MODE_LABELS.power_user.short).toBe('Power User')
|
||||
})
|
||||
|
||||
it('formats a local transcript marker for mode changes', () => {
|
||||
expect(aiAgentPermissionModeMarker('power_user')).toBe(
|
||||
'AI permission mode changed to Power User. It will apply to the next message.',
|
||||
)
|
||||
})
|
||||
})
|
||||
26
src/lib/aiAgentPermissionMode.ts
Normal file
26
src/lib/aiAgentPermissionMode.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type AiAgentPermissionMode = 'safe' | 'power_user'
|
||||
|
||||
export const DEFAULT_AI_AGENT_PERMISSION_MODE: AiAgentPermissionMode = 'safe'
|
||||
|
||||
export const AI_AGENT_PERMISSION_MODE_LABELS: Record<
|
||||
AiAgentPermissionMode,
|
||||
{ short: string; control: string }
|
||||
> = {
|
||||
safe: {
|
||||
short: 'Safe',
|
||||
control: 'Vault Safe',
|
||||
},
|
||||
power_user: {
|
||||
short: 'Power User',
|
||||
control: 'Power User',
|
||||
},
|
||||
}
|
||||
|
||||
export function normalizeAiAgentPermissionMode(value: unknown): AiAgentPermissionMode {
|
||||
return value === 'power_user' ? 'power_user' : DEFAULT_AI_AGENT_PERMISSION_MODE
|
||||
}
|
||||
|
||||
export function aiAgentPermissionModeMarker(mode: AiAgentPermissionMode): string {
|
||||
const label = AI_AGENT_PERMISSION_MODE_LABELS[mode].short
|
||||
return `AI permission mode changed to ${label}. It will apply to the next message.`
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
|
||||
import {
|
||||
appendLocalResponse,
|
||||
appendLocalMarker,
|
||||
appendStreamingMessage,
|
||||
buildFormattedMessage,
|
||||
createMissingAgentResponse,
|
||||
@@ -80,6 +81,7 @@ export async function sendAgentMessage({
|
||||
message: formattedMessage,
|
||||
systemPrompt,
|
||||
vaultPath: context.vaultPath,
|
||||
permissionMode: context.permissionMode,
|
||||
callbacks: createStreamCallbacks({
|
||||
agent: context.agent,
|
||||
messageId,
|
||||
@@ -94,6 +96,13 @@ export async function sendAgentMessage({
|
||||
})
|
||||
}
|
||||
|
||||
export function addAgentLocalMarker(
|
||||
runtime: Pick<AiAgentSessionRuntime, 'setMessages'>,
|
||||
text: string,
|
||||
): void {
|
||||
appendLocalMarker(runtime.setMessages, text)
|
||||
}
|
||||
|
||||
export function clearAgentConversation(runtime: Pick<AiAgentSessionRuntime, 'abortRef' | 'responseAccRef' | 'toolInputMapRef' | 'setMessages' | 'setStatus'>): void {
|
||||
runtime.abortRef.current.aborted = true
|
||||
runtime.responseAccRef.current = ''
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user