Compare commits
45 Commits
site
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a7ef5d9ca | ||
|
|
4c42cf4d52 | ||
|
|
405b711e16 | ||
|
|
83705e26f5 | ||
|
|
434927160c | ||
|
|
ec2e04a464 | ||
|
|
a17ec689a3 | ||
|
|
eda4d380e0 | ||
|
|
f9b155ba6e | ||
|
|
4a62008b1f | ||
|
|
a08cee7965 | ||
|
|
a7c480af62 | ||
|
|
f4790d5763 | ||
|
|
ed52a9e800 | ||
|
|
2fcd808f09 | ||
|
|
9d15286f7d | ||
|
|
537bd0f3ec | ||
|
|
648241604f | ||
|
|
c6f3d9d945 | ||
|
|
65e54f108a | ||
|
|
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
|
||||
|
||||
10
README.md
10
README.md
@@ -27,13 +27,13 @@ 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.
|
||||
|
||||
## Getting started
|
||||
|
||||
Download the [latest release here](https://github.com/refactoringhq/tolaria/releases/latest/download/Tolaria.app.tar.gz).
|
||||
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/).
|
||||
|
||||
When you open Tolaria for the first time you get the chance of cloning the [getting started vault](https://github.com/refactoringhq/tolaria-getting-started) — which gives you a walkthrough of the whole app.
|
||||
|
||||
@@ -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. App exit uses the same child cleanup path and waits for the bridge process after killing it. 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. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
|
||||
|
||||
### Vault Caching
|
||||
|
||||
@@ -602,6 +609,7 @@ The app uses internal light and dark themes owned by Tolaria (see [ADR-0081](adr
|
||||
1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states via `:root` / `[data-theme]`, bridged to Tailwind v4
|
||||
2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme`
|
||||
3. **Runtime theme bridge**: Applies `data-theme` and `.dark` for shadcn/ui, while CodeMirror and editor-specific consumers derive any non-CSS-variable values from the same semantic contract
|
||||
4. **Theme mode commands**: Command-palette actions for light and dark mode call the same `saveSettings` path as the Settings panel and persist only `settings.theme_mode`
|
||||
|
||||
## Localization
|
||||
|
||||
@@ -669,7 +677,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 +686,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 +707,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
|
||||
@@ -731,7 +741,7 @@ interface Settings {
|
||||
}
|
||||
```
|
||||
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette light/dark actions both update that same value. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
|
||||
## Telemetry
|
||||
|
||||
@@ -743,9 +753,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 +781,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,13 @@ 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.
|
||||
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`
|
||||
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 orchestration** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters
|
||||
3. **Shared runtime scaffold** (`cli_agent_runtime.rs`) — owns the common request shape, prompt wrapping, JSON-line subprocess lifecycle, normalized error/done handling, version probing, and Tolaria MCP server path resolution used by app-managed CLI agents
|
||||
4. **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 runtime specifics live in `codex_cli.rs` and run 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.
|
||||
5. **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 +244,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 +295,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 +327,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`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
|
||||
|
||||
### Architecture
|
||||
|
||||
@@ -376,11 +378,12 @@ 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.
|
||||
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 plus waited 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.
|
||||
|
||||
## Search
|
||||
|
||||
@@ -425,7 +428,7 @@ The app uses internal app-owned light and dark themes (see [ADR-0081](adr/0081-i
|
||||
|
||||
1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states. Bridged to Tailwind v4 via `@theme inline`.
|
||||
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`; editor colors resolve through the same semantic app variables.
|
||||
3. **Theme runtime**: Applies `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode is selected.
|
||||
3. **Theme runtime**: Applies `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode is selected. Settings and command-palette theme actions both write the same installation-local `settings.theme_mode` value.
|
||||
|
||||
## Localization
|
||||
|
||||
@@ -476,7 +479,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 +628,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
|
||||
@@ -636,8 +639,9 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
|
||||
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) |
|
||||
| `search.rs` | Keyword search — walkdir-based vault file scan with Gitignored-content visibility filtering |
|
||||
| `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch |
|
||||
| `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing |
|
||||
| `ai_agents.rs` | CLI-agent request normalization and adapter dispatch |
|
||||
| `cli_agent_runtime.rs` | Shared CLI-agent request, prompt, subprocess, version, and MCP path helpers |
|
||||
| `claude_cli.rs`, `codex_cli.rs`, `opencode_cli.rs` | Claude Code, Codex, and OpenCode command/config/event adapters |
|
||||
| `pi_cli.rs`, `pi_config.rs`, `pi_discovery.rs`, `pi_events.rs` | Pi subprocess launch, transient MCP adapter config, discovery, and JSON stream parsing |
|
||||
| `mcp.rs` | MCP server spawning + explicit config registration/removal |
|
||||
| `commands/` | Tauri command handlers (split into submodules) |
|
||||
@@ -670,7 +674,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 +728,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 +790,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 +947,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
|
||||
@@ -188,9 +189,11 @@ tolaria/
|
||||
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
|
||||
│ │ ├── telemetry.rs # Sentry init + path scrubber
|
||||
│ │ ├── search.rs # Keyword search (walkdir-based)
|
||||
│ │ ├── ai_agents.rs # Shared CLI-agent detection + stream adapters
|
||||
│ │ ├── claude_cli.rs # Claude CLI subprocess management
|
||||
│ │ ├── pi_cli.rs # Pi CLI subprocess management
|
||||
│ │ ├── ai_agents.rs # CLI-agent request normalization + adapter dispatch
|
||||
│ │ ├── cli_agent_runtime.rs # Shared CLI-agent runtime process/prompt/MCP helpers
|
||||
│ │ ├── claude_cli.rs # Claude CLI adapter
|
||||
│ │ ├── codex_cli.rs # Codex CLI adapter
|
||||
│ │ ├── pi_cli.rs # Pi CLI adapter
|
||||
│ │ ├── mcp.rs # MCP server lifecycle + explicit config registration/removal
|
||||
│ │ ├── app_updater.rs # Alpha/stable updater endpoint selection
|
||||
│ │ ├── settings.rs # App settings persistence
|
||||
@@ -261,9 +264,9 @@ 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/pi_cli.rs` | Pi subprocess spawning through JSON mode and transient MCP adapter config. |
|
||||
| `src-tauri/src/ai_agents.rs` | CLI-agent request normalization, availability aggregation, adapter dispatch, and Claude event mapping. |
|
||||
| `src-tauri/src/cli_agent_runtime.rs` | Shared CLI-agent request shape, prompt wrapping, JSON subprocess lifecycle, version probing, and MCP path helpers. |
|
||||
| `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs` | Per-agent command, config, discovery, and JSON event adapters. |
|
||||
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. |
|
||||
|
||||
### Editor
|
||||
@@ -282,9 +285,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 +308,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. |
|
||||
|
||||
@@ -341,7 +345,7 @@ type SidebarSelection =
|
||||
|
||||
### Command Registry
|
||||
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the light/dark theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
|
||||
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
|
||||
|
||||
@@ -420,11 +424,14 @@ 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. **Shared CLI runtime behavior**: Edit `src-tauri/src/cli_agent_runtime.rs` for process lifecycle, prompt wrapping, version probing, and common Tolaria MCP path handling.
|
||||
6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`). 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
|
||||
5. **Process lifecycle**: Stdio MCP servers in `mcp-server/index.js` must exit when their external client closes stdin, and the desktop-owned `ws-bridge.js` child must be stopped on vault deselection, vault switch, and app exit
|
||||
|
||||
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.
|
||||
35
docs/adr/0093-shared-cli-agent-runtime-adapters.md
Normal file
35
docs/adr/0093-shared-cli-agent-runtime-adapters.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0093"
|
||||
title: "Shared CLI agent runtime adapters"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria supports Claude Code, Codex, OpenCode, and Pi as local CLI agents in the AI panel. Each agent has different command-line arguments, configuration shape, and JSON event schema, but the Rust backend had grown repeated runtime plumbing around those differences: request shapes, prompt wrapping, subprocess launch, stdout JSON reading, stderr capture, exit handling, done events, version probing, and Tolaria MCP server path resolution.
|
||||
|
||||
That duplication made small runtime fixes expensive because they had to be repeated across several adapter files. It also kept Codex-specific command and event mapping inside `ai_agents.rs`, making the top-level module both an orchestrator and a bespoke adapter.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria uses `cli_agent_runtime.rs` as the shared runtime scaffold for app-managed CLI agents, while `ai_agents.rs` only normalizes and dispatches requests to per-agent adapter modules.**
|
||||
|
||||
The shared scaffold owns the common agent request shape, system/user prompt wrapping, JSON-line process lifecycle, normalized error/done handling for `AiAgentStreamEvent` adapters, version probing, and Tolaria MCP server path resolution. Per-agent modules keep the provider-specific pieces: binary discovery candidates, command arguments, transient config shape, authentication error wording, and JSON event mapping.
|
||||
|
||||
Codex now lives in `codex_cli.rs`, matching the Claude, OpenCode, and Pi adapter boundary. `ai_agents.rs` remains the Tauri-facing orchestrator that chooses an adapter and maps Claude's legacy event enum into the normalized event stream.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Shared runtime scaffold with thin adapters** (chosen): reduces repeated process lifecycle code without hiding provider-specific command/config/event behavior.
|
||||
- **One trait object per agent**: more uniform on paper, but adds indirection without removing much current complexity.
|
||||
- **Leave each adapter self-contained**: keeps local readability for a single file, but new process, prompt, and MCP fixes continue to land in parallel.
|
||||
- **Fully generic event mapping**: over-abstracts the JSON schemas and makes provider-specific edge cases harder to test.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Runtime lifecycle fixes should usually start in `cli_agent_runtime.rs`.
|
||||
- New agent adapters should use the shared request/prompt/process helpers and keep only command, config, discovery, and event mapping local.
|
||||
- `ai_agents.rs` should not grow provider-specific runtime code again; it should normalize the frontend request, dispatch to an adapter, and map any legacy event shape.
|
||||
- The shared scaffold deliberately does not erase provider differences. Authentication messages, permission semantics, and transient config formats remain adapter-owned and must stay covered by adapter tests.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0094"
|
||||
title: "Gitignored content visibility as a command-boundary filter"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's vault scanner now indexes more of the real filesystem so Folder views, search, and reload flows can reflect what is actually in the vault. In Git-backed vaults, that includes generated, local-only, or machine-specific content that users intentionally hide through `.gitignore`.
|
||||
|
||||
Always showing Gitignored files makes Folder lists and search noisy, especially in vaults that contain exports, build artifacts, or personal local scratch files. But removing those files during scanning would make visibility dependent on cache shape, complicate toggling, and blur the distinction between "what exists in the vault" and "what this installation chooses to surface."
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria keeps the vault scan and cache complete, then applies Gitignored-content visibility at the command boundary before entries, folders, or search results reach React.**
|
||||
|
||||
- `hide_gitignored_files` is an installation-local app setting and defaults to `true`.
|
||||
- Visibility checks use batched `git check-ignore --no-index --stdin` so Tolaria follows normal Git ignore and negation semantics as closely as practical.
|
||||
- `list_vault`, `reload_vault`, `list_vault_folders`, and keyword search all apply the same filter when the setting is enabled.
|
||||
- Toggling the setting reloads the current vault surfaces instead of rebuilding a different cache format.
|
||||
- If a vault has no `.gitignore`, or Gitignored visibility is turned off, Tolaria shows the full scanned result.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Complete scan/cache + boundary filter** (chosen): keeps the filesystem model authoritative, makes toggling cheap and consistent, and avoids cache divergence.
|
||||
- **Skip Gitignored content during scan/cache**: reduces later filtering work, but makes visibility part of the persisted cache shape and complicates instant toggling.
|
||||
- **Always show Gitignored content**: simplest implementation, but too noisy for real Git-backed vaults and undermines users' existing ignore rules.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Gitignored visibility is a per-installation comfort preference, not vault-authored shared metadata.
|
||||
- Search, folder lists, and note reloads stay aligned because they all consult the same boundary filter.
|
||||
- The cache can still support future visibility changes without a data migration.
|
||||
- Users can reveal ignored content again immediately by disabling the setting.
|
||||
- Future features that expose vault file lists should apply the same boundary filter unless they intentionally need raw filesystem output.
|
||||
35
docs/adr/0095-saved-view-order-field.md
Normal file
35
docs/adr/0095-saved-view-order-field.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0095"
|
||||
title: "Saved views use an explicit YAML order field"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Saved Views already persist as user-editable YAML files in the vault and sync through Git. Filename ordering was stable, but it forced users to rename files just to change sidebar order and gave Tolaria no durable way to support drag reordering, move actions, or keyboard-first ordering controls.
|
||||
|
||||
The ordering choice also needs to travel with the view definition itself. Saved Views are part of the vault's shared information architecture, not a machine-local preference.
|
||||
|
||||
## Decision
|
||||
|
||||
**Each Saved View may store an optional top-level `order` number in its YAML definition, and Tolaria sorts views by that value before falling back to filename.**
|
||||
|
||||
- Lower `order` values render earlier in the sidebar and other Saved View lists.
|
||||
- Views without `order` sort after ordered views and then fall back to filename ordering for stability.
|
||||
- Reordering actions rewrite affected view files with a dense sequence of order values instead of encoding position in filenames.
|
||||
- The same persisted order supports drag handles, explicit move buttons, and command-palette ordering actions.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Explicit `order` field in the view YAML** (chosen): portable, Git-syncable, easy to inspect by hand, and consistent with the existing file-first view model.
|
||||
- **Filename-based ordering only**: no schema change, but makes reordering clumsy and couples user-visible structure to file naming.
|
||||
- **App-local ordering state**: easy to prototype, but breaks cross-device consistency and separates ordering from the view artifact users already version.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Saved View ordering becomes part of the vault and syncs naturally through Git.
|
||||
- Existing views remain valid; unordered files keep a stable fallback sort until reordered.
|
||||
- Reordering can touch multiple view files in one action because Tolaria normalizes the sequence.
|
||||
- Future Saved View features should treat `order` as part of the shared YAML schema rather than introducing a parallel ordering store.
|
||||
@@ -142,3 +142,9 @@ 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 |
|
||||
| [0093](0093-shared-cli-agent-runtime-adapters.md) | Shared CLI agent runtime adapters | active |
|
||||
| [0094](0094-gitignored-content-visibility-boundary-filter.md) | Gitignored content visibility as a command-boundary filter | active |
|
||||
| [0095](0095-saved-view-order-field.md) | Saved views use an explicit YAML order field | active |
|
||||
|
||||
@@ -30,27 +30,69 @@ const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
|
||||
// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js).
|
||||
// The bridge relays messages to all other clients (the React frontend).
|
||||
let uiSocket = null
|
||||
let reconnectTimer = null
|
||||
let shutdownStarted = false
|
||||
const RECONNECT_INTERVAL_MS = 3000
|
||||
|
||||
function connectUiBridge() {
|
||||
if (shutdownStarted) return
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(WS_UI_URL)
|
||||
uiSocket = ws
|
||||
ws.on('open', () => {
|
||||
uiSocket = ws
|
||||
if (shutdownStarted) {
|
||||
closeUiSocket()
|
||||
return
|
||||
}
|
||||
console.error(`[mcp] Connected to UI bridge at ${WS_UI_URL}`)
|
||||
})
|
||||
ws.on('close', () => {
|
||||
uiSocket = null
|
||||
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
if (uiSocket === ws) uiSocket = null
|
||||
scheduleUiReconnect()
|
||||
})
|
||||
ws.on('error', () => {
|
||||
// Silent — bridge may not be running yet, will retry
|
||||
})
|
||||
} catch {
|
||||
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
scheduleUiReconnect()
|
||||
}
|
||||
}
|
||||
connectUiBridge()
|
||||
|
||||
function scheduleUiReconnect() {
|
||||
if (shutdownStarted) return
|
||||
|
||||
clearUiReconnectTimer()
|
||||
reconnectTimer = setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
reconnectTimer.unref?.()
|
||||
}
|
||||
|
||||
function clearUiReconnectTimer() {
|
||||
if (!reconnectTimer) return
|
||||
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
function closeUiSocket() {
|
||||
const socket = uiSocket
|
||||
uiSocket = null
|
||||
if (!socket) return
|
||||
|
||||
socket.removeAllListeners()
|
||||
socket.on('error', () => {})
|
||||
if (socket.readyState === WebSocket.CONNECTING) {
|
||||
socket.terminate?.()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
socket.close()
|
||||
} catch {
|
||||
// Ignore close races during process teardown.
|
||||
}
|
||||
socket.terminate?.()
|
||||
}
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
if (!uiSocket || uiSocket.readyState !== WebSocket.OPEN) return
|
||||
@@ -193,10 +235,47 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
}
|
||||
})
|
||||
|
||||
async function shutdown(exitCode = 0) {
|
||||
if (shutdownStarted) return
|
||||
|
||||
shutdownStarted = true
|
||||
clearUiReconnectTimer()
|
||||
closeUiSocket()
|
||||
|
||||
try {
|
||||
await server.close()
|
||||
} catch (error) {
|
||||
console.error(`[mcp] Error while closing server: ${error.message}`)
|
||||
}
|
||||
|
||||
process.exitCode = exitCode
|
||||
setImmediate(() => process.exit(exitCode))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport()
|
||||
server.onclose = () => {
|
||||
void shutdown(0)
|
||||
}
|
||||
process.stdin.once('end', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
process.stdin.once('close', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
process.once('SIGINT', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
process.once('SIGTERM', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
|
||||
connectUiBridge()
|
||||
await server.connect(transport)
|
||||
console.error(`Tolaria MCP server running (vault: ${VAULT_PATH})`)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
void shutdown(1)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, it, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
findMarkdownFiles, getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
@@ -11,6 +13,7 @@ import { evaluateBridgeRequest } from './ws-bridge.js'
|
||||
|
||||
let tmpDir
|
||||
const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
|
||||
const MCP_SERVER_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
before(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
|
||||
@@ -208,6 +211,34 @@ describe('requireVaultPath', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('stdio process lifecycle', () => {
|
||||
it('exits when the MCP client closes stdin', async () => {
|
||||
const child = spawn(process.execPath, ['index.js'], {
|
||||
cwd: MCP_SERVER_DIR,
|
||||
env: { ...process.env, VAULT_PATH: tmpDir, WS_UI_PORT: '65534' },
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
})
|
||||
let stderr = ''
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += chunk
|
||||
})
|
||||
|
||||
await sleep(200)
|
||||
child.stdin.end()
|
||||
|
||||
const exit = await waitForExit(child, 1_500)
|
||||
if (!exit) {
|
||||
child.kill()
|
||||
await waitForExit(child, 1_000)
|
||||
assert.fail(`MCP server stayed alive after stdin closed.\n${stderr}`)
|
||||
}
|
||||
|
||||
assert.equal(exit.signal, null)
|
||||
assert.equal(exit.code, 0, stderr)
|
||||
})
|
||||
})
|
||||
|
||||
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
|
||||
const outsideNote = path.join(outsideDir, 'outside.md')
|
||||
@@ -222,3 +253,28 @@ async function assertRejectsOutsideVault(prefix, resolveNotePath) {
|
||||
await fs.rm(outsideDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function waitForExit(child, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve(null)
|
||||
}, timeoutMs)
|
||||
|
||||
child.once('exit', onExit)
|
||||
|
||||
function onExit(code, signal) {
|
||||
cleanup()
|
||||
resolve({ code, signal })
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(timer)
|
||||
child.off('exit', onExit)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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/frontmatter-date-picker.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/missing-string-metadata-open-note.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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::BufRead;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -12,6 +9,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,14 +66,21 @@ 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 {
|
||||
AiAgentsStatus {
|
||||
claude_code: availability_from_claude(),
|
||||
codex: availability_from_codex(),
|
||||
opencode: availability_from_opencode(),
|
||||
pi: availability_from_pi(),
|
||||
codex: crate::codex_cli::check_cli(),
|
||||
opencode: crate::opencode_cli::check_cli(),
|
||||
pi: crate::pi_cli::check_cli(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,12 +88,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) {
|
||||
@@ -89,12 +103,21 @@ where
|
||||
}
|
||||
})
|
||||
}
|
||||
AiAgentId::Codex => run_codex_agent_stream(request, emit),
|
||||
AiAgentId::Codex => {
|
||||
let mapped = crate::codex_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::codex_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
AiAgentId::Opencode => {
|
||||
let mapped = crate::opencode_cli::AgentStreamRequest {
|
||||
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 +126,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)
|
||||
}
|
||||
@@ -117,351 +141,6 @@ fn availability_from_claude() -> AiAgentAvailability {
|
||||
}
|
||||
}
|
||||
|
||||
fn availability_from_codex() -> AiAgentAvailability {
|
||||
let binary = match find_codex_binary() {
|
||||
Ok(binary) => binary,
|
||||
Err(_) => {
|
||||
return AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
AiAgentAvailability {
|
||||
installed: true,
|
||||
version: version_for_binary(&binary),
|
||||
}
|
||||
}
|
||||
|
||||
fn availability_from_opencode() -> AiAgentAvailability {
|
||||
crate::opencode_cli::check_cli()
|
||||
}
|
||||
|
||||
fn availability_from_pi() -> AiAgentAvailability {
|
||||
crate::pi_cli::check_cli()
|
||||
}
|
||||
|
||||
fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
crate::hidden_command(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn find_codex_binary() -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_codex_binary_on_path() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_codex_binary_in_user_shell() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_existing_binary(codex_binary_candidates()) {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
|
||||
}
|
||||
|
||||
fn find_codex_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command("which")
|
||||
.arg("codex")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.find_map(|shell| command_path_from_shell(&shell, "codex"))
|
||||
}
|
||||
|
||||
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 codex_binary_candidates() -> Vec<PathBuf> {
|
||||
dirs::home_dir()
|
||||
.map(|home| codex_binary_candidates_for_home(&home))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".npm/bin/codex"),
|
||||
home.join(".bun/bin/codex"),
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
PathBuf::from("/opt/homebrew/bin/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
]
|
||||
}
|
||||
|
||||
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>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = find_codex_binary()?;
|
||||
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 child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Failed to spawn codex: {error}"))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
|
||||
let mut thread_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
Err(error) => {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format!("Read error: {error}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let json = match serde_json::from_str::<serde_json::Value>(&line) {
|
||||
Ok(json) => json,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(id) = json["thread_id"].as_str() {
|
||||
thread_id = id.to_string();
|
||||
}
|
||||
|
||||
dispatch_codex_event(&json, &mut emit);
|
||||
}
|
||||
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|stderr| std::io::read_to_string(stderr).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|error| format!("Wait failed: {error}"))?;
|
||||
if !status.success() {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format_codex_error(stderr_output, status.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
|
||||
Ok(thread_id)
|
||||
}
|
||||
|
||||
fn build_codex_command(
|
||||
binary: &Path,
|
||||
args: Vec<String>,
|
||||
prompt: String,
|
||||
vault_path: &str,
|
||||
) -> std::process::Command {
|
||||
let mut command = crate::hidden_command(binary);
|
||||
command
|
||||
.args(args)
|
||||
.arg(prompt)
|
||||
.current_dir(vault_path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
command
|
||||
}
|
||||
|
||||
fn build_codex_args(request: &AiAgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
|
||||
let mcp_server_path = mcp_server
|
||||
.to_str()
|
||||
.ok_or("Invalid MCP server path")?
|
||||
.to_string();
|
||||
|
||||
Ok(vec![
|
||||
"--sandbox".into(),
|
||||
"workspace-write".into(),
|
||||
"--ask-for-approval".into(),
|
||||
"never".into(),
|
||||
"exec".into(),
|
||||
"--json".into(),
|
||||
"-C".into(),
|
||||
request.vault_path.clone(),
|
||||
"-c".into(),
|
||||
r#"mcp_servers.tolaria.command="node""#.into(),
|
||||
"-c".into(),
|
||||
format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path),
|
||||
"-c".into(),
|
||||
format!(
|
||||
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#,
|
||||
request.vault_path
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn build_codex_prompt(request: &AiAgentStreamRequest) -> String {
|
||||
match request
|
||||
.system_prompt
|
||||
.as_ref()
|
||||
.map(|prompt| prompt.trim())
|
||||
.filter(|prompt| !prompt.is_empty())
|
||||
{
|
||||
Some(system_prompt) => format!(
|
||||
"System instructions:\n{system_prompt}\n\nUser request:\n{}",
|
||||
request.message
|
||||
),
|
||||
None => request.message.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_codex_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
match json["type"].as_str().unwrap_or_default() {
|
||||
"thread.started" => {
|
||||
if let Some(thread_id) = json["thread_id"].as_str() {
|
||||
emit(AiAgentStreamEvent::Init {
|
||||
session_id: thread_id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
"item.started" => emit_codex_item_event(json, false, emit),
|
||||
"item.completed" => emit_codex_item_event(json, true, emit),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_codex_item_event<F>(json: &serde_json::Value, completed: bool, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let item = &json["item"];
|
||||
let item_type = item["type"].as_str().unwrap_or_default();
|
||||
let item_id = item["id"].as_str().unwrap_or_default();
|
||||
|
||||
match item_type {
|
||||
"command_execution" => {
|
||||
if completed {
|
||||
emit(AiAgentStreamEvent::ToolDone {
|
||||
tool_id: item_id.to_string(),
|
||||
output: item["aggregated_output"]
|
||||
.as_str()
|
||||
.map(|output| output.to_string()),
|
||||
});
|
||||
} else {
|
||||
emit(AiAgentStreamEvent::ToolStart {
|
||||
tool_name: "Bash".into(),
|
||||
tool_id: item_id.to_string(),
|
||||
input: item["command"]
|
||||
.as_str()
|
||||
.map(|command| serde_json::json!({ "command": command }).to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
"agent_message" if completed => {
|
||||
if let Some(text) = item["text"].as_str() {
|
||||
emit(AiAgentStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_codex_error(stderr_output: String, status: String) -> String {
|
||||
let lower = stderr_output.to_ascii_lowercase();
|
||||
if is_codex_auth_error(&lower) {
|
||||
return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into();
|
||||
}
|
||||
|
||||
if is_codex_write_permission_error(&lower) {
|
||||
return "Codex could not write to the active vault. Tolaria starts Codex with a workspace-write sandbox, so verify the selected vault folder is writable and retry; writes outside the active vault remain blocked.".into();
|
||||
}
|
||||
|
||||
if stderr_output.trim().is_empty() {
|
||||
format!("codex exited with status {status}")
|
||||
} else {
|
||||
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_codex_auth_error(lower: &str) -> bool {
|
||||
["auth", "login", "sign in"]
|
||||
.iter()
|
||||
.any(|pattern| lower.contains(pattern))
|
||||
}
|
||||
|
||||
fn is_codex_write_permission_error(lower: &str) -> bool {
|
||||
[
|
||||
"read-only sandbox",
|
||||
"writing is blocked",
|
||||
"rejected by user approval",
|
||||
"rejected by the environment",
|
||||
]
|
||||
.iter()
|
||||
.any(|pattern| lower.contains(pattern))
|
||||
}
|
||||
|
||||
fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAgentStreamEvent> {
|
||||
match event {
|
||||
crate::claude_cli::ClaudeStreamEvent::Init { session_id } => {
|
||||
@@ -499,193 +178,20 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAge
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
#[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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_codex_prompt(&AiAgentStreamRequest {
|
||||
agent: AiAgentId::Codex,
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: Some("Be concise".into()),
|
||||
vault_path: "/tmp/vault".into(),
|
||||
});
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_args_uses_safe_default_permissions() {
|
||||
if let Ok(args) = build_codex_args(&AiAgentStreamRequest {
|
||||
agent: AiAgentId::Codex,
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
}) {
|
||||
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!(args.contains(&"--json".to_string()));
|
||||
assert!(args.contains(&"-C".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_command_keeps_agent_process_contract() {
|
||||
let binary = PathBuf::from("codex");
|
||||
let args = vec!["exec".to_string(), "--json".to_string()];
|
||||
let command = build_codex_command(&binary, args, "Summarize".into(), "/tmp/vault");
|
||||
let actual_args: Vec<&OsStr> = command.get_args().collect();
|
||||
|
||||
assert_eq!(command.get_program(), OsStr::new("codex"));
|
||||
assert_eq!(
|
||||
actual_args,
|
||||
vec![
|
||||
OsStr::new("exec"),
|
||||
OsStr::new("--json"),
|
||||
OsStr::new("Summarize")
|
||||
]
|
||||
);
|
||||
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_supported_macos_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = codex_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".bun/bin/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
let install_flags = [
|
||||
status.claude_code.installed,
|
||||
status.codex.installed,
|
||||
status.opencode.installed,
|
||||
status.pi.installed,
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_path_skips_empty_and_missing_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("missing-codex");
|
||||
let codex = dir.path().join("codex");
|
||||
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let stdout = format!("\n{}\n{}\n", missing.display(), codex.display());
|
||||
|
||||
assert_eq!(first_existing_path(&stdout), Some(codex));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn command_path_from_shell_finds_codex_from_login_shell() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let codex = dir.path().join("codex");
|
||||
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(&codex, 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",
|
||||
codex.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_codex_command_events_maps_to_bash_events() {
|
||||
let mut events = Vec::new();
|
||||
let started = serde_json::json!({
|
||||
"type": "item.started",
|
||||
"item": {
|
||||
"id": "item_1",
|
||||
"type": "command_execution",
|
||||
"command": "/bin/zsh -lc pwd"
|
||||
}
|
||||
});
|
||||
let completed = serde_json::json!({
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
"id": "item_1",
|
||||
"type": "command_execution",
|
||||
"aggregated_output": "/private/tmp\n"
|
||||
}
|
||||
});
|
||||
|
||||
dispatch_codex_event(&started, &mut |event| events.push(event));
|
||||
dispatch_codex_event(&completed, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ToolStart { tool_name, tool_id, .. }
|
||||
if tool_name == "Bash" && tool_id == "item_1"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ToolDone { tool_id, output }
|
||||
if tool_id == "item_1" && output.as_deref() == Some("/private/tmp\n")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_codex_agent_message_maps_to_text_delta() {
|
||||
let mut events = Vec::new();
|
||||
let completed = serde_json::json!({
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
"id": "item_2",
|
||||
"type": "agent_message",
|
||||
"text": "All set"
|
||||
}
|
||||
});
|
||||
|
||||
dispatch_codex_event(&completed, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "All set"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_codex_error_explains_vault_write_permission_failures() {
|
||||
let message = format_codex_error(
|
||||
"The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(),
|
||||
"exit status: 1".into(),
|
||||
);
|
||||
|
||||
assert!(message.contains("active vault"));
|
||||
assert!(message.contains("writable"));
|
||||
assert!(message.contains("outside"));
|
||||
assert!(install_flags
|
||||
.iter()
|
||||
.all(|installed| matches!(installed, true | false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
pub use crate::cli_agent_runtime::AgentStreamRequest;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::io::BufRead;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{ExitStatus, Stdio};
|
||||
|
||||
@@ -50,14 +51,6 @@ pub struct ChatStreamRequest {
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters accepted by Claude Code agent streams.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finding the `claude` binary
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -148,7 +141,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 +163,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> {
|
||||
@@ -193,16 +203,9 @@ pub fn check_cli() -> ClaudeCliStatus {
|
||||
}
|
||||
};
|
||||
|
||||
let version = crate::hidden_command(&bin)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
|
||||
|
||||
ClaudeCliStatus {
|
||||
installed: true,
|
||||
version,
|
||||
version: crate::cli_agent_runtime::version_for_binary(&bin),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +276,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,19 +290,25 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a temporary MCP config JSON string pointing to the vault MCP server.
|
||||
fn build_mcp_config(vault_path: &str) -> Result<String, String> {
|
||||
let server_dir = crate::mcp::mcp_server_dir()?;
|
||||
let index_js = server_dir.join("index.js");
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
let config = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"tolaria": {
|
||||
"command": "node",
|
||||
"args": [index_js.to_string_lossy()],
|
||||
"args": [mcp_server_path],
|
||||
"env": { "VAULT_PATH": vault_path }
|
||||
}
|
||||
}
|
||||
@@ -314,6 +323,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.
|
||||
@@ -327,55 +338,28 @@ fn run_claude_subprocess<F>(
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let mut cmd = build_claude_command(bin, args, cwd);
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
|
||||
let mut state = StreamState {
|
||||
session_id: String::new(),
|
||||
tool_inputs: HashMap::new(),
|
||||
current_tool_id: None,
|
||||
emitted_text: false,
|
||||
};
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
emit(ClaudeStreamEvent::Error {
|
||||
message: format!("Read error: {e}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
let cmd = build_claude_command(bin, args, cwd);
|
||||
let run = crate::cli_agent_runtime::run_json_line_process(
|
||||
cmd,
|
||||
"claude",
|
||||
emit,
|
||||
|message| ClaudeStreamEvent::Error { message },
|
||||
|json, emit, session_id| {
|
||||
dispatch_event(json, &mut state, emit);
|
||||
*session_id = state.session_id.clone();
|
||||
},
|
||||
)?;
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let json: serde_json::Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue, // skip non-JSON lines
|
||||
};
|
||||
|
||||
dispatch_event(&json, &mut state, emit);
|
||||
}
|
||||
|
||||
// Read stderr for potential error messages.
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|s| std::io::read_to_string(s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
|
||||
|
||||
if !status.success() && state.session_id.is_empty() {
|
||||
if !run.status.success() && state.session_id.is_empty() {
|
||||
emit(ClaudeStreamEvent::Error {
|
||||
message: format_failed_claude_exit(&stderr_output, status),
|
||||
message: format_failed_claude_exit(&run.stderr_output, run.status),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -473,7 +457,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 +475,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 +500,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 +545,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 +627,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 +713,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 +776,7 @@ mod tests {
|
||||
session_id: String::new(),
|
||||
tool_inputs: HashMap::new(),
|
||||
current_tool_id: None,
|
||||
emitted_text: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -765,10 +906,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 +1308,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 +1340,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 +1387,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 +1394,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 +1462,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));
|
||||
|
||||
186
src-tauri/src/cli_agent_runtime.rs
Normal file
186
src-tauri/src/cli_agent_runtime.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
use crate::ai_agents::{AiAgentPermissionMode, AiAgentStreamEvent};
|
||||
use serde::Deserialize;
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, ExitStatus};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
pub permission_mode: AiAgentPermissionMode,
|
||||
}
|
||||
|
||||
pub(crate) struct JsonLineRun {
|
||||
pub session_id: String,
|
||||
pub stderr_output: String,
|
||||
pub status: ExitStatus,
|
||||
}
|
||||
|
||||
pub(crate) fn build_prompt(message: &str, system_prompt: Option<&str>) -> String {
|
||||
match system_prompt
|
||||
.map(str::trim)
|
||||
.filter(|prompt| !prompt.is_empty())
|
||||
{
|
||||
Some(system_prompt) => {
|
||||
format!("System instructions:\n{system_prompt}\n\nUser request:\n{message}")
|
||||
}
|
||||
None => message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_server_path_string() -> Result<String, String> {
|
||||
Ok(crate::mcp::mcp_server_dir()?
|
||||
.join("index.js")
|
||||
.to_str()
|
||||
.ok_or("Invalid MCP server path")?
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
crate::hidden_command(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_json_line(
|
||||
line: Result<String, std::io::Error>,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
let line = line.map_err(|error| format!("Read error: {error}"))?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(serde_json::from_str::<serde_json::Value>(trimmed).ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_ai_agent_json_line<F>(
|
||||
line: Result<String, std::io::Error>,
|
||||
emit: &mut F,
|
||||
) -> Option<serde_json::Value>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
match parse_json_line(line) {
|
||||
Ok(json) => json,
|
||||
Err(message) => {
|
||||
emit(AiAgentStreamEvent::Error { message });
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_json_line_process<Event, F, H>(
|
||||
mut command: Command,
|
||||
process_name: &'static str,
|
||||
emit: &mut F,
|
||||
error_event: impl Fn(String) -> Event,
|
||||
mut handle_json: H,
|
||||
) -> Result<JsonLineRun, String>
|
||||
where
|
||||
F: FnMut(Event),
|
||||
H: FnMut(&serde_json::Value, &mut F, &mut String),
|
||||
{
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Failed to spawn {process_name}: {error}"))?;
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
let mut session_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
match parse_json_line(line) {
|
||||
Ok(Some(json)) => handle_json(&json, emit, &mut session_id),
|
||||
Ok(None) => {}
|
||||
Err(message) => {
|
||||
emit(error_event(message));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|stderr| std::io::read_to_string(stderr).ok())
|
||||
.unwrap_or_default();
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|error| format!("Wait failed: {error}"))?;
|
||||
|
||||
Ok(JsonLineRun {
|
||||
session_id,
|
||||
stderr_output,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn run_ai_agent_json_stream<F>(
|
||||
command: Command,
|
||||
process_name: &'static str,
|
||||
mut emit: F,
|
||||
session_id: impl Fn(&serde_json::Value) -> Option<&str>,
|
||||
dispatch_event: impl Fn(&serde_json::Value, &mut F),
|
||||
format_error: impl Fn(String, String) -> String,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let run = run_json_line_process(
|
||||
command,
|
||||
process_name,
|
||||
&mut emit,
|
||||
|message| AiAgentStreamEvent::Error { message },
|
||||
|json, emit, active_session_id| {
|
||||
if let Some(id) = session_id(json) {
|
||||
*active_session_id = id.to_string();
|
||||
}
|
||||
dispatch_event(json, emit);
|
||||
},
|
||||
)?;
|
||||
|
||||
if !run.status.success() {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format_error(run.stderr_output, run.status.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(run.session_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_prompt("Rename the note", Some("Be concise"));
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_prompt_skips_blank_system_prompt() {
|
||||
assert_eq!(
|
||||
build_prompt("Rename the note", Some(" ")),
|
||||
"Rename the note"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_json_line_reports_read_errors_and_skips_blank_or_invalid_lines() {
|
||||
assert!(parse_json_line(Ok(" ".into())).unwrap().is_none());
|
||||
assert!(parse_json_line(Ok("not json".into())).unwrap().is_none());
|
||||
|
||||
let error = parse_json_line(Err(std::io::Error::other("broken pipe"))).unwrap_err();
|
||||
assert!(error.contains("broken pipe"));
|
||||
}
|
||||
}
|
||||
607
src-tauri/src/codex_cli.rs
Normal file
607
src-tauri/src/codex_cli.rs
Normal file
@@ -0,0 +1,607 @@
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
||||
pub use crate::cli_agent_runtime::AgentStreamRequest;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
|
||||
pub fn check_cli() -> AiAgentAvailability {
|
||||
let binary = match find_codex_binary() {
|
||||
Ok(binary) => binary,
|
||||
Err(_) => {
|
||||
return AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
AiAgentAvailability {
|
||||
installed: true,
|
||||
version: crate::cli_agent_runtime::version_for_binary(&binary),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = find_codex_binary()?;
|
||||
run_agent_stream_with_binary(&binary, request, emit)
|
||||
}
|
||||
|
||||
fn find_codex_binary() -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_codex_binary_on_path() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_codex_binary_in_user_shell() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_existing_binary(codex_binary_candidates()) {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
|
||||
}
|
||||
|
||||
fn find_codex_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command("which")
|
||||
.arg("codex")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.find_map(|shell| command_path_from_shell(&shell, "codex"))
|
||||
}
|
||||
|
||||
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 codex_binary_candidates() -> Vec<PathBuf> {
|
||||
dirs::home_dir()
|
||||
.map(|home| codex_binary_candidates_for_home(&home))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".npm/bin/codex"),
|
||||
home.join(".bun/bin/codex"),
|
||||
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_agent_stream_with_binary<F>(
|
||||
binary: &Path,
|
||||
request: AgentStreamRequest,
|
||||
emit: F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let args = build_codex_args(&request)?;
|
||||
let prompt = build_codex_prompt(&request);
|
||||
let command = build_codex_command(binary, args, prompt, &request.vault_path);
|
||||
|
||||
crate::cli_agent_runtime::run_ai_agent_json_stream(
|
||||
command,
|
||||
"codex",
|
||||
emit,
|
||||
codex_session_id,
|
||||
dispatch_codex_event,
|
||||
format_codex_error,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_codex_command(
|
||||
binary: &Path,
|
||||
args: Vec<String>,
|
||||
prompt: String,
|
||||
vault_path: &str,
|
||||
) -> std::process::Command {
|
||||
let mut command = crate::hidden_command(binary);
|
||||
command
|
||||
.args(args)
|
||||
.arg(prompt)
|
||||
.current_dir(vault_path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
command
|
||||
}
|
||||
|
||||
fn build_codex_args(request: &AgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
|
||||
Ok(vec![
|
||||
"--sandbox".into(),
|
||||
"workspace-write".into(),
|
||||
"--ask-for-approval".into(),
|
||||
"never".into(),
|
||||
"exec".into(),
|
||||
"--json".into(),
|
||||
"-C".into(),
|
||||
request.vault_path.clone(),
|
||||
"-c".into(),
|
||||
r#"mcp_servers.tolaria.command="node""#.into(),
|
||||
"-c".into(),
|
||||
format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path),
|
||||
"-c".into(),
|
||||
format!(
|
||||
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#,
|
||||
request.vault_path
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn build_codex_prompt(request: &AgentStreamRequest) -> String {
|
||||
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
|
||||
}
|
||||
|
||||
fn codex_session_id(json: &serde_json::Value) -> Option<&str> {
|
||||
json["thread_id"].as_str()
|
||||
}
|
||||
|
||||
fn dispatch_codex_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
match json["type"].as_str().unwrap_or_default() {
|
||||
"thread.started" => {
|
||||
if let Some(thread_id) = json["thread_id"].as_str() {
|
||||
emit(AiAgentStreamEvent::Init {
|
||||
session_id: thread_id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
"item.started" => emit_codex_item_event(json, false, emit),
|
||||
"item.completed" => emit_codex_item_event(json, true, emit),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_codex_item_event<F>(json: &serde_json::Value, completed: bool, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let item = &json["item"];
|
||||
let item_type = item["type"].as_str().unwrap_or_default();
|
||||
let item_id = item["id"].as_str().unwrap_or_default();
|
||||
|
||||
match item_type {
|
||||
"command_execution" => {
|
||||
if completed {
|
||||
emit(AiAgentStreamEvent::ToolDone {
|
||||
tool_id: item_id.to_string(),
|
||||
output: item["aggregated_output"]
|
||||
.as_str()
|
||||
.map(|output| output.to_string()),
|
||||
});
|
||||
} else {
|
||||
emit(AiAgentStreamEvent::ToolStart {
|
||||
tool_name: "Bash".into(),
|
||||
tool_id: item_id.to_string(),
|
||||
input: item["command"]
|
||||
.as_str()
|
||||
.map(|command| serde_json::json!({ "command": command }).to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
"agent_message" if completed => {
|
||||
if let Some(text) = item["text"].as_str() {
|
||||
emit(AiAgentStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_codex_error(stderr_output: String, status: String) -> String {
|
||||
let lower = stderr_output.to_ascii_lowercase();
|
||||
if is_codex_auth_error(&lower) {
|
||||
return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into();
|
||||
}
|
||||
|
||||
if is_codex_write_permission_error(&lower) {
|
||||
return "Codex could not write to the active vault. Tolaria starts Codex with a workspace-write sandbox, so verify the selected vault folder is writable and retry; writes outside the active vault remain blocked.".into();
|
||||
}
|
||||
|
||||
if stderr_output.trim().is_empty() {
|
||||
format!("codex exited with status {status}")
|
||||
} else {
|
||||
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_codex_auth_error(lower: &str) -> bool {
|
||||
["auth", "login", "sign in"]
|
||||
.iter()
|
||||
.any(|pattern| lower.contains(pattern))
|
||||
}
|
||||
|
||||
fn is_codex_write_permission_error(lower: &str) -> bool {
|
||||
[
|
||||
"read-only sandbox",
|
||||
"writing is blocked",
|
||||
"rejected by user approval",
|
||||
"rejected by the environment",
|
||||
]
|
||||
.iter()
|
||||
.any(|pattern| lower.contains(pattern))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
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: AiAgentPermissionMode,
|
||||
) -> AgentStreamRequest {
|
||||
AgentStreamRequest {
|
||||
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_agent_stream_with_binary(
|
||||
&binary,
|
||||
codex_request(vault.path(), 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 build_codex_prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_codex_prompt(&AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: Some("Be concise".into()),
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
});
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_args_uses_safe_default_permissions() {
|
||||
if let Ok(args) = build_codex_args(&AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
}) {
|
||||
assert_eq!(args[4], "exec");
|
||||
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(&AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode,
|
||||
}) {
|
||||
assert_codex_workspace_write_contract(&args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_command_keeps_agent_process_contract() {
|
||||
let binary = PathBuf::from("codex");
|
||||
let args = vec!["exec".to_string(), "--json".to_string()];
|
||||
let command = build_codex_command(&binary, args, "Summarize".into(), "/tmp/vault");
|
||||
let actual_args: Vec<&OsStr> = command.get_args().collect();
|
||||
|
||||
assert_eq!(command.get_program(), OsStr::new("codex"));
|
||||
assert_eq!(
|
||||
actual_args,
|
||||
vec![
|
||||
OsStr::new("exec"),
|
||||
OsStr::new("--json"),
|
||||
OsStr::new("Summarize")
|
||||
]
|
||||
);
|
||||
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");
|
||||
let candidates = codex_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".bun/bin/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
let missing = dir.path().join("missing-codex");
|
||||
let codex = dir.path().join("codex");
|
||||
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let stdout = format!("\n{}\n{}\n", missing.display(), codex.display());
|
||||
|
||||
assert_eq!(first_existing_path(&stdout), Some(codex));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn command_path_from_shell_finds_codex_from_login_shell() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let codex = dir.path().join("codex");
|
||||
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(&codex, 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",
|
||||
codex.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_codex_command_events_maps_to_bash_events() {
|
||||
let mut events = Vec::new();
|
||||
let started = serde_json::json!({
|
||||
"type": "item.started",
|
||||
"item": {
|
||||
"id": "item_1",
|
||||
"type": "command_execution",
|
||||
"command": "/bin/zsh -lc pwd"
|
||||
}
|
||||
});
|
||||
let completed = serde_json::json!({
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
"id": "item_1",
|
||||
"type": "command_execution",
|
||||
"aggregated_output": "/private/tmp\n"
|
||||
}
|
||||
});
|
||||
|
||||
dispatch_codex_event(&started, &mut |event| events.push(event));
|
||||
dispatch_codex_event(&completed, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ToolStart { tool_name, tool_id, .. }
|
||||
if tool_name == "Bash" && tool_id == "item_1"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ToolDone { tool_id, output }
|
||||
if tool_id == "item_1" && output.as_deref() == Some("/private/tmp\n")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_codex_agent_message_maps_to_text_delta() {
|
||||
let mut events = Vec::new();
|
||||
let completed = serde_json::json!({
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
"id": "item_2",
|
||||
"type": "agent_message",
|
||||
"text": "All set"
|
||||
}
|
||||
});
|
||||
|
||||
dispatch_codex_event(&completed, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "All set"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_codex_error_explains_vault_write_permission_failures() {
|
||||
let message = format_codex_error(
|
||||
"The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(),
|
||||
"exit status: 1".into(),
|
||||
);
|
||||
|
||||
assert!(message.contains("active vault"));
|
||||
assert!(message.contains("writable"));
|
||||
assert!(message.contains("outside"));
|
||||
}
|
||||
}
|
||||
@@ -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![]),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod ai_agents;
|
||||
pub mod app_updater;
|
||||
pub mod claude_cli;
|
||||
mod cli_agent_runtime;
|
||||
pub mod codex_cli;
|
||||
mod commands;
|
||||
pub mod frontmatter;
|
||||
pub mod git;
|
||||
@@ -435,6 +437,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,
|
||||
@@ -460,10 +463,7 @@ fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(ref mut child) = *guard {
|
||||
let _ = child.kill();
|
||||
log::info!("ws-bridge child process killed on exit");
|
||||
}
|
||||
stop_ws_bridge_child(&mut guard);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,58 +1,120 @@
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
||||
use std::io::BufRead;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
}
|
||||
pub use crate::cli_agent_runtime::AgentStreamRequest;
|
||||
use std::path::Path;
|
||||
|
||||
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)?;
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Failed to spawn opencode: {error}"))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
let mut session_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let json = match crate::opencode_events::parse_line(line, &mut emit) {
|
||||
Some(json) => json,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if let Some(id) = crate::opencode_events::session_id(&json) {
|
||||
session_id = id.to_string();
|
||||
}
|
||||
|
||||
crate::opencode_events::dispatch_event(&json, &mut emit);
|
||||
}
|
||||
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|stderr| std::io::read_to_string(stderr).ok())
|
||||
.unwrap_or_default();
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|error| format!("Wait failed: {error}"))?;
|
||||
if !status.success() {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: crate::opencode_events::format_error(stderr_output, status.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(session_id)
|
||||
run_agent_stream_with_binary(&binary, request, emit)
|
||||
}
|
||||
|
||||
fn run_agent_stream_with_binary<F>(
|
||||
binary: &Path,
|
||||
request: AgentStreamRequest,
|
||||
emit: F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let command = crate::opencode_config::build_command(binary, &request)?;
|
||||
crate::cli_agent_runtime::run_ai_agent_json_stream(
|
||||
command,
|
||||
"opencode",
|
||||
emit,
|
||||
crate::opencode_events::session_id,
|
||||
crate::opencode_events::dispatch_event,
|
||||
crate::opencode_events::format_error,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
|
||||
#[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())
|
||||
@@ -26,30 +27,18 @@ fn build_args() -> Vec<String> {
|
||||
}
|
||||
|
||||
fn build_prompt(request: &AgentStreamRequest) -> String {
|
||||
match request
|
||||
.system_prompt
|
||||
.as_ref()
|
||||
.map(|prompt| prompt.trim())
|
||||
.filter(|prompt| !prompt.is_empty())
|
||||
{
|
||||
Some(system_prompt) => format!(
|
||||
"System instructions:\n{system_prompt}\n\nUser request:\n{}",
|
||||
request.message
|
||||
),
|
||||
None => request.message.clone(),
|
||||
}
|
||||
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
|
||||
}
|
||||
|
||||
fn build_config(vault_path: &str) -> Result<String, String> {
|
||||
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
|
||||
let mcp_server_path = mcp_server
|
||||
.to_str()
|
||||
.ok_or("Invalid MCP server path")?
|
||||
.to_string();
|
||||
fn build_config(
|
||||
vault_path: &str,
|
||||
permission_mode: AiAgentPermissionMode,
|
||||
) -> Result<String, String> {
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_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 +51,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 +64,7 @@ fn permission_config() -> serde_json::Value {
|
||||
"grep": "allow",
|
||||
"list": "allow",
|
||||
"external_directory": "deny",
|
||||
"bash": "deny"
|
||||
"bash": bash_permission
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,6 +79,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 +113,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 +133,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 {
|
||||
|
||||
@@ -5,7 +5,7 @@ pub(crate) fn check_cli() -> AiAgentAvailability {
|
||||
match find_binary() {
|
||||
Ok(binary) => AiAgentAvailability {
|
||||
installed: true,
|
||||
version: version_for_binary(&binary),
|
||||
version: crate::cli_agent_runtime::version_for_binary(&binary),
|
||||
},
|
||||
Err(_) => AiAgentAvailability {
|
||||
installed: false,
|
||||
@@ -30,23 +30,22 @@ pub(crate) fn find_binary() -> Result<PathBuf, String> {
|
||||
Err("OpenCode CLI not found. Install it: https://opencode.ai/docs/".into())
|
||||
}
|
||||
|
||||
fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
crate::hidden_command(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn find_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command("which")
|
||||
crate::hidden_command(path_lookup_command())
|
||||
.arg("opencode")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_lookup_command() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"where"
|
||||
} else {
|
||||
"which"
|
||||
}
|
||||
}
|
||||
|
||||
fn find_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
@@ -107,12 +106,26 @@ fn opencode_binary_candidates() -> Vec<PathBuf> {
|
||||
fn opencode_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".local/bin/opencode"),
|
||||
home.join(".local/bin/opencode.exe"),
|
||||
home.join(".opencode/bin/opencode"),
|
||||
home.join(".opencode/bin/opencode.exe"),
|
||||
home.join(".local/share/mise/shims/opencode"),
|
||||
home.join(".local/share/mise/shims/opencode.exe"),
|
||||
home.join(".asdf/shims/opencode"),
|
||||
home.join(".asdf/shims/opencode.exe"),
|
||||
home.join(".npm-global/bin/opencode"),
|
||||
home.join(".npm-global/bin/opencode.cmd"),
|
||||
home.join(".npm-global/bin/opencode.exe"),
|
||||
home.join(".npm/bin/opencode"),
|
||||
home.join(".npm/bin/opencode.cmd"),
|
||||
home.join(".npm/bin/opencode.exe"),
|
||||
home.join(".bun/bin/opencode"),
|
||||
home.join(".bun/bin/opencode.exe"),
|
||||
home.join("AppData/Roaming/npm/opencode.cmd"),
|
||||
home.join("AppData/Roaming/npm/opencode.exe"),
|
||||
home.join("AppData/Local/pnpm/opencode.cmd"),
|
||||
home.join("AppData/Local/pnpm/opencode.exe"),
|
||||
home.join("scoop/shims/opencode.exe"),
|
||||
PathBuf::from("/usr/local/bin/opencode"),
|
||||
PathBuf::from("/opt/homebrew/bin/opencode"),
|
||||
]
|
||||
@@ -145,6 +158,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_windows_npm_and_toolchain_shims() {
|
||||
let home = PathBuf::from(r"C:\Users\alex");
|
||||
let candidates = opencode_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".npm-global/bin/opencode.cmd"),
|
||||
home.join(".npm-global/bin/opencode.exe"),
|
||||
home.join(".npm/bin/opencode.cmd"),
|
||||
home.join(".npm/bin/opencode.exe"),
|
||||
home.join("AppData/Roaming/npm/opencode.cmd"),
|
||||
home.join("AppData/Roaming/npm/opencode.exe"),
|
||||
home.join("AppData/Local/pnpm/opencode.cmd"),
|
||||
home.join("AppData/Local/pnpm/opencode.exe"),
|
||||
home.join("scoop/shims/opencode.exe"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_lookup_command_matches_current_platform() {
|
||||
let expected = if cfg!(windows) { "where" } else { "which" };
|
||||
|
||||
assert_eq!(path_lookup_command(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_path_skips_empty_and_missing_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::ai_agents::AiAgentStreamEvent;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_line<F>(
|
||||
line: Result<String, std::io::Error>,
|
||||
emit: &mut F,
|
||||
@@ -7,22 +8,7 @@ pub(crate) fn parse_line<F>(
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
Err(error) => {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format!("Read error: {error}"),
|
||||
});
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
serde_json::from_str::<serde_json::Value>(trimmed).ok()
|
||||
crate::cli_agent_runtime::parse_ai_agent_json_line(line, emit)
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
@@ -101,12 +87,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 +102,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 +119,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,62 +1,124 @@
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
||||
use std::io::BufRead;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
}
|
||||
pub use crate::cli_agent_runtime::AgentStreamRequest;
|
||||
use std::path::Path;
|
||||
|
||||
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,
|
||||
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 child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Failed to spawn pi: {error}"))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
let mut session_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let json = match crate::pi_events::parse_line(line, &mut emit) {
|
||||
Some(json) => json,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if let Some(id) = crate::pi_events::session_id(&json) {
|
||||
session_id = id.to_string();
|
||||
}
|
||||
|
||||
crate::pi_events::dispatch_event(&json, &mut emit);
|
||||
}
|
||||
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|stderr| std::io::read_to_string(stderr).ok())
|
||||
.unwrap_or_default();
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|error| format!("Wait failed: {error}"))?;
|
||||
if !status.success() {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: crate::pi_events::format_error(stderr_output, status.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(session_id)
|
||||
let command = crate::pi_config::build_command(binary, &request, agent_dir.path())?;
|
||||
crate::cli_agent_runtime::run_ai_agent_json_stream(
|
||||
command,
|
||||
"pi",
|
||||
emit,
|
||||
crate::pi_events::session_id,
|
||||
crate::pi_events::dispatch_event,
|
||||
crate::pi_events::format_error,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
|
||||
#[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
|
||||
@@ -32,34 +33,26 @@ fn build_args() -> Vec<String> {
|
||||
}
|
||||
|
||||
fn build_prompt(request: &AgentStreamRequest) -> String {
|
||||
match request
|
||||
.system_prompt
|
||||
.as_ref()
|
||||
.map(|prompt| prompt.trim())
|
||||
.filter(|prompt| !prompt.is_empty())
|
||||
{
|
||||
Some(system_prompt) => format!(
|
||||
"System instructions:\n{system_prompt}\n\nUser request:\n{}",
|
||||
request.message
|
||||
),
|
||||
None => request.message.clone(),
|
||||
}
|
||||
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
|
||||
}
|
||||
|
||||
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> {
|
||||
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
|
||||
let mcp_server_path = mcp_server
|
||||
.to_str()
|
||||
.ok_or("Invalid MCP server path")?
|
||||
.to_string();
|
||||
fn build_mcp_config(
|
||||
vault_path: &str,
|
||||
_permission_mode: AiAgentPermissionMode,
|
||||
) -> Result<String, String> {
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"settings": {
|
||||
@@ -93,6 +86,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 +123,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 +143,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 {
|
||||
|
||||
@@ -5,7 +5,7 @@ pub(crate) fn check_cli() -> AiAgentAvailability {
|
||||
match find_binary() {
|
||||
Ok(binary) => AiAgentAvailability {
|
||||
installed: true,
|
||||
version: version_for_binary(&binary),
|
||||
version: crate::cli_agent_runtime::version_for_binary(&binary),
|
||||
},
|
||||
Err(_) => AiAgentAvailability {
|
||||
installed: false,
|
||||
@@ -30,23 +30,22 @@ pub(crate) fn find_binary() -> Result<PathBuf, String> {
|
||||
Err("Pi CLI not found. Install it: https://pi.dev".into())
|
||||
}
|
||||
|
||||
fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
crate::hidden_command(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn find_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command("which")
|
||||
crate::hidden_command(path_lookup_command())
|
||||
.arg("pi")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_lookup_command() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"where"
|
||||
} else {
|
||||
"which"
|
||||
}
|
||||
}
|
||||
|
||||
fn find_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
@@ -99,25 +98,97 @@ 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(".local/bin/pi.exe"),
|
||||
home.join(".pi/bin/pi"),
|
||||
home.join(".pi/bin/pi.exe"),
|
||||
home.join(".local/share/mise/shims/pi"),
|
||||
home.join(".local/share/mise/shims/pi.exe"),
|
||||
home.join(".asdf/shims/pi"),
|
||||
home.join(".asdf/shims/pi.exe"),
|
||||
home.join(".npm-global/bin/pi"),
|
||||
home.join(".npm-global/bin/pi.cmd"),
|
||||
home.join(".npm-global/bin/pi.exe"),
|
||||
home.join(".npm/bin/pi"),
|
||||
home.join(".npm/bin/pi.cmd"),
|
||||
home.join(".npm/bin/pi.exe"),
|
||||
home.join(".bun/bin/pi"),
|
||||
home.join(".bun/bin/pi.exe"),
|
||||
home.join("AppData/Roaming/npm/pi.cmd"),
|
||||
home.join("AppData/Roaming/npm/pi.exe"),
|
||||
home.join("AppData/Local/pnpm/pi.cmd"),
|
||||
home.join("AppData/Local/pnpm/pi.exe"),
|
||||
home.join("scoop/shims/pi.exe"),
|
||||
]);
|
||||
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 +204,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 +215,85 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_windows_npm_and_toolchain_shims() {
|
||||
let home = PathBuf::from(r"C:\Users\alex");
|
||||
let candidates = pi_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".npm-global/bin/pi.cmd"),
|
||||
home.join(".npm-global/bin/pi.exe"),
|
||||
home.join(".npm/bin/pi.cmd"),
|
||||
home.join(".npm/bin/pi.exe"),
|
||||
home.join("AppData/Roaming/npm/pi.cmd"),
|
||||
home.join("AppData/Roaming/npm/pi.exe"),
|
||||
home.join("AppData/Local/pnpm/pi.cmd"),
|
||||
home.join("AppData/Local/pnpm/pi.exe"),
|
||||
home.join("scoop/shims/pi.exe"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_lookup_command_matches_current_platform() {
|
||||
let expected = if cfg!(windows) { "where" } else { "which" };
|
||||
|
||||
assert_eq!(path_lookup_command(), expected);
|
||||
}
|
||||
|
||||
#[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,6 @@
|
||||
use crate::ai_agents::AiAgentStreamEvent;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_line<F>(
|
||||
line: Result<String, std::io::Error>,
|
||||
emit: &mut F,
|
||||
@@ -7,22 +8,7 @@ pub(crate) fn parse_line<F>(
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
Err(error) => {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format!("Read error: {error}"),
|
||||
});
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
serde_json::from_str::<serde_json::Value>(trimmed).ok()
|
||||
crate::cli_agent_runtime::parse_ai_agent_json_line(line, emit)
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
|
||||
@@ -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();
|
||||
|
||||
49
src/App.tsx
49
src/App.tsx
@@ -34,6 +34,7 @@ import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useDocumentThemeMode } from './hooks/useDocumentThemeMode'
|
||||
import { useThemeMode } from './hooks/useThemeMode'
|
||||
import type { ThemeMode } from './lib/themeMode'
|
||||
import { useNoteActions } from './hooks/useNoteActions'
|
||||
import { planNewTypeCreation } from './hooks/useNoteCreation'
|
||||
import { useCommitFlow } from './hooks/useCommitFlow'
|
||||
@@ -72,7 +73,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'
|
||||
@@ -434,6 +437,10 @@ function App() {
|
||||
const theme_mode = documentThemeMode === 'dark' ? 'light' : 'dark'
|
||||
void saveSettings({ ...settings, theme_mode })
|
||||
}, [documentThemeMode, saveSettings, settings])
|
||||
const handleSetThemeMode = useCallback((theme_mode: ThemeMode) => {
|
||||
if (!settingsLoaded) return
|
||||
void saveSettings({ ...settings, theme_mode })
|
||||
}, [saveSettings, settings, settingsLoaded])
|
||||
const handleSetUiLanguage = useCallback((uiLanguage: UiLanguagePreference) => {
|
||||
void saveSettings({ ...settings, ui_language: serializeUiLanguagePreference(uiLanguage) })
|
||||
}, [saveSettings, settings])
|
||||
@@ -453,7 +460,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 +509,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 +1083,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 +1328,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 +1437,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,
|
||||
@@ -1428,6 +1467,7 @@ function App() {
|
||||
systemLocale,
|
||||
selectedUiLanguage,
|
||||
onSetUiLanguage: handleSetUiLanguage,
|
||||
onSetThemeMode: handleSetThemeMode,
|
||||
mcpStatus,
|
||||
onInstallMcp: openMcpSetupDialog,
|
||||
onOpenAiAgents: dialogs.openSettings,
|
||||
@@ -1553,7 +1593,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 +1641,7 @@ function App() {
|
||||
onInitializeProperties={handleInitializeProperties}
|
||||
showAIChat={dialogs.showAIChat}
|
||||
onToggleAIChat={dialogs.toggleAIChat}
|
||||
onCopyMcpConfig={handleCopyMcpConfig}
|
||||
vaultPath={resolvedPath}
|
||||
noteList={aiNoteList}
|
||||
noteListFilter={aiNoteListFilter}
|
||||
@@ -1685,7 +1726,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())
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { AiPanel } from './AiPanel'
|
||||
import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
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,22 +53,121 @@ 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,
|
||||
})
|
||||
|
||||
function render(ui: Parameters<typeof rtlRender>[0]) {
|
||||
return rtlRender(ui, { wrapper: TooltipProvider })
|
||||
}
|
||||
|
||||
describe('AiPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockMessages = []
|
||||
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('radio', { 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('radio', { name: 'Vault Safe' })).toBeDisabled()
|
||||
expect(screen.getByRole('radio', { name: 'Power User' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('renders the permission mode toggle with high contrast selected state and explanatory tooltip', async () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
|
||||
expect(screen.getByTestId('ai-permission-mode-toggle')).toHaveClass('border', 'bg-muted')
|
||||
|
||||
const safeMode = screen.getByRole('radio', { name: 'Vault Safe' })
|
||||
const powerUserMode = screen.getByRole('radio', { name: 'Power User' })
|
||||
expect(safeMode).toHaveAttribute('aria-checked', 'true')
|
||||
expect(safeMode).toHaveClass('bg-background', 'text-foreground', 'shadow-xs')
|
||||
expect(powerUserMode).toHaveClass('text-muted-foreground')
|
||||
|
||||
fireEvent.focus(safeMode)
|
||||
|
||||
expect(await screen.findByTestId('ai-permission-mode-tooltip')).toHaveTextContent(
|
||||
'Vault Safe keeps agents to file, search, and edit tools. Power User also allows local shell commands for this vault.',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders data-testid ai-panel', () => {
|
||||
@@ -85,6 +192,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,17 +1,33 @@
|
||||
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 { ActionTooltip } from '@/components/ui/action-tooltip'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
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'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const AI_PERMISSION_MODE_TOOLTIP = {
|
||||
label: 'Vault Safe keeps agents to file, search, and edit tools. Power User also allows local shell commands for this vault.',
|
||||
}
|
||||
|
||||
interface AiPanelHeaderProps {
|
||||
agentLabel: string
|
||||
agentReadiness: AiAgentReadiness
|
||||
permissionMode: AiAgentPermissionMode
|
||||
permissionModeDisabled: boolean
|
||||
onPermissionModeChange: (mode: AiAgentPermissionMode) => void
|
||||
onClose: () => void
|
||||
onCopyMcpConfig?: () => void
|
||||
onNewChat: () => void
|
||||
}
|
||||
|
||||
@@ -121,44 +137,125 @@ 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 (
|
||||
<TooltipProvider>
|
||||
<div
|
||||
className="inline-flex w-full rounded-md border border-border bg-muted p-1"
|
||||
role="radiogroup"
|
||||
aria-label="AI agent permission mode"
|
||||
data-testid="ai-permission-mode-toggle"
|
||||
>
|
||||
{(['safe', 'power_user'] as const).map((mode) => {
|
||||
const selected = value === mode
|
||||
return (
|
||||
<ActionTooltip
|
||||
key={mode}
|
||||
copy={AI_PERMISSION_MODE_TOOLTIP}
|
||||
side="bottom"
|
||||
contentTestId="ai-permission-mode-tooltip"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
disabled={disabled}
|
||||
className={
|
||||
selected
|
||||
? 'h-7 flex-1 border border-border bg-background text-foreground shadow-xs hover:bg-background'
|
||||
: 'h-7 flex-1 text-muted-foreground hover:text-foreground'
|
||||
}
|
||||
onClick={() => onChange(mode)}
|
||||
>
|
||||
{AI_AGENT_PERMISSION_MODE_LABELS[mode].control}
|
||||
</Button>
|
||||
</ActionTooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanelContextBar({ activeEntry, linkedCount }: AiPanelContextBarProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -253,16 +350,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>
|
||||
)
|
||||
|
||||
@@ -11,6 +11,17 @@ describe('CreateViewDialog', () => {
|
||||
availableFields: ['type', 'status', 'title'],
|
||||
}
|
||||
|
||||
function makeEditingView(overrides: Partial<ViewDefinition> = {}): ViewDefinition {
|
||||
return {
|
||||
name: 'Active Projects',
|
||||
icon: '🚀',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
it('shows "Create View" title in create mode', () => {
|
||||
render(<CreateViewDialog {...defaultProps} />)
|
||||
expect(screen.getByText('Create View')).toBeInTheDocument()
|
||||
@@ -18,46 +29,28 @@ describe('CreateViewDialog', () => {
|
||||
})
|
||||
|
||||
it('shows "Edit View" title when editingView is provided', () => {
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Active Projects',
|
||||
icon: '🚀',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
render(<CreateViewDialog {...defaultProps} editingView={editingView} />)
|
||||
render(<CreateViewDialog {...defaultProps} editingView={makeEditingView()} />)
|
||||
expect(screen.getByText('Edit View')).toBeInTheDocument()
|
||||
expect(screen.getByText('Save')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('pre-populates name field in edit mode', () => {
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Active Projects',
|
||||
icon: '🚀',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
render(<CreateViewDialog {...defaultProps} editingView={editingView} />)
|
||||
render(<CreateViewDialog {...defaultProps} editingView={makeEditingView()} />)
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
expect(input).toHaveValue('Active Projects')
|
||||
})
|
||||
|
||||
it('preserves emoji icon when editing a view', async () => {
|
||||
it('preserves existing icon and markdown-defined color when editing a view', async () => {
|
||||
const onCreate = vi.fn()
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Monday',
|
||||
icon: '🗂️',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
const editingView = makeEditingView({ name: 'Monday', icon: '🗂️', color: 'blue' })
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} editingView={editingView} />)
|
||||
|
||||
// Submit the form without changing anything
|
||||
fireEvent.submit(screen.getByText('Save').closest('form')!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ icon: '🗂️' })
|
||||
expect.objectContaining({ icon: '🗂️', color: 'blue' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { FilterGroup, ViewDefinition } from '../types'
|
||||
|
||||
type SaveViewResult = boolean | void
|
||||
type SaveViewHandler = (definition: ViewDefinition) => SaveViewResult | Promise<SaveViewResult>
|
||||
type InitialViewFormValues = Pick<ViewDefinition, 'name' | 'icon' | 'color' | 'filters'>
|
||||
|
||||
interface CreateViewDialogProps {
|
||||
open: boolean
|
||||
@@ -22,6 +23,7 @@ interface CreateViewDialogFormProps {
|
||||
availableFields: string[]
|
||||
initialName: string
|
||||
initialIcon: string
|
||||
initialColor: string | null
|
||||
initialFilters: FilterGroup
|
||||
isEditing: boolean
|
||||
onClose: () => void
|
||||
@@ -32,6 +34,7 @@ function CreateViewDialogForm({
|
||||
availableFields,
|
||||
initialName,
|
||||
initialIcon,
|
||||
initialColor,
|
||||
initialFilters,
|
||||
isEditing,
|
||||
onClose,
|
||||
@@ -58,7 +61,7 @@ function CreateViewDialogForm({
|
||||
const definition: ViewDefinition = {
|
||||
name: trimmed,
|
||||
icon: icon || null,
|
||||
color: null,
|
||||
color: initialColor,
|
||||
sort: null,
|
||||
filters,
|
||||
}
|
||||
@@ -142,9 +145,27 @@ function CreateViewDialogForm({
|
||||
)
|
||||
}
|
||||
|
||||
function getInitialViewFormValues(
|
||||
editingView: ViewDefinition | null | undefined,
|
||||
availableFields: string[],
|
||||
): InitialViewFormValues {
|
||||
return {
|
||||
name: editingView?.name ?? '',
|
||||
icon: editingView?.icon ?? '',
|
||||
color: editingView?.color ?? null,
|
||||
filters: editingView?.filters ?? { all: [{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }] },
|
||||
}
|
||||
}
|
||||
|
||||
function getDialogDescription(isEditing: boolean) {
|
||||
return isEditing
|
||||
? 'Update the name, icon, and filters for this saved view.'
|
||||
: 'Create a saved view by choosing a name, icon, and filter rules.'
|
||||
}
|
||||
|
||||
export function CreateViewDialog({ open, onClose, onCreate, availableFields, editingView }: CreateViewDialogProps) {
|
||||
const isEditing = !!editingView
|
||||
const initialFilters = editingView?.filters ?? { all: [{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }] }
|
||||
const initialValues = getInitialViewFormValues(editingView, availableFields)
|
||||
const formKey = editingView ? `edit:${editingView.name}` : `create:${availableFields[0] ?? 'type'}`
|
||||
|
||||
return (
|
||||
@@ -153,16 +174,17 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, edi
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? 'Edit View' : 'Create View'}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{isEditing ? 'Update the name, icon, and filters for this saved view.' : 'Create a saved view by choosing a name, icon, and filter rules.'}
|
||||
{getDialogDescription(isEditing)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{open && (
|
||||
<CreateViewDialogForm
|
||||
key={formKey}
|
||||
availableFields={availableFields}
|
||||
initialName={editingView?.name ?? ''}
|
||||
initialIcon={editingView?.icon ?? ''}
|
||||
initialFilters={initialFilters}
|
||||
initialName={initialValues.name}
|
||||
initialIcon={initialValues.icon ?? ''}
|
||||
initialColor={initialValues.color}
|
||||
initialFilters={initialValues.filters}
|
||||
isEditing={isEditing}
|
||||
onClose={onClose}
|
||||
onCreate={onCreate}
|
||||
|
||||
@@ -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} />
|
||||
@@ -1465,6 +1492,69 @@ describe('Sidebar', () => {
|
||||
expect(screen.queryByText('rocket')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses saved view color metadata for sidebar accents', () => {
|
||||
const coloredViews = [{
|
||||
filename: 'active-projects.yml',
|
||||
definition: {
|
||||
name: 'Active Projects',
|
||||
icon: 'rocket',
|
||||
color: 'blue',
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Project' }] },
|
||||
},
|
||||
}]
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
entries={mockEntries}
|
||||
selection={{ kind: 'view', filename: 'active-projects.yml' }}
|
||||
onSelect={() => {}}
|
||||
views={coloredViews}
|
||||
/>
|
||||
)
|
||||
|
||||
const navItem = screen.getByText('Active Projects').closest('[class*="cursor-pointer"]') as HTMLElement
|
||||
const icon = navItem.querySelector('svg')
|
||||
const countChip = within(navItem).getByTestId('view-count-chip')
|
||||
|
||||
expect(navItem).toHaveStyle({
|
||||
background: 'var(--accent-blue-light)',
|
||||
color: 'var(--accent-blue)',
|
||||
})
|
||||
expect(icon).toHaveStyle({ color: 'var(--accent-blue)' })
|
||||
expect(countChip).toHaveStyle({
|
||||
background: 'var(--accent-blue)',
|
||||
color: 'var(--text-inverse)',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the default active styling when a saved view color is invalid', () => {
|
||||
const invalidColorViews = [{
|
||||
filename: 'active-projects.yml',
|
||||
definition: {
|
||||
name: 'Active Projects',
|
||||
icon: 'rocket',
|
||||
color: 'not-a-token',
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Project' }] },
|
||||
},
|
||||
}]
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
entries={mockEntries}
|
||||
selection={{ kind: 'view', filename: 'active-projects.yml' }}
|
||||
onSelect={() => {}}
|
||||
views={invalidColorViews}
|
||||
/>
|
||||
)
|
||||
|
||||
const navItem = screen.getByText('Active Projects').closest('[class*="cursor-pointer"]') as HTMLElement
|
||||
|
||||
expect(navItem.className).toContain('text-primary')
|
||||
expect(navItem.getAttribute('style')).not.toContain('--accent-')
|
||||
})
|
||||
|
||||
it('does not show count chip for views with 0 matching notes', () => {
|
||||
const emptyView = [{
|
||||
filename: 'empty.yml',
|
||||
|
||||
@@ -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 />)
|
||||
|
||||
|
||||
@@ -130,6 +130,40 @@ describe('editorModePosition', () => {
|
||||
expect(focus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clamps stale raw-editor restore selections to the current document', () => {
|
||||
const shortContent = '# Short'
|
||||
const dispatch = vi.fn((spec: { selection: { anchor: number; head: number } }) => {
|
||||
if (spec.selection.anchor > shortContent.length || spec.selection.head > shortContent.length) {
|
||||
throw new RangeError('Selection points outside of document')
|
||||
}
|
||||
})
|
||||
const view: CodeMirrorViewLike = {
|
||||
state: {
|
||||
doc: { toString: () => shortContent },
|
||||
selection: { main: { anchor: 0, head: 0 } },
|
||||
},
|
||||
scrollDOM: { scrollTop: 0 },
|
||||
dispatch,
|
||||
focus: vi.fn(),
|
||||
}
|
||||
installRawView(view)
|
||||
|
||||
const restored = restoreCodeMirrorView(document, {
|
||||
anchor: 500,
|
||||
head: 800,
|
||||
scrollTop: 12,
|
||||
})
|
||||
|
||||
expect(restored).toBe(true)
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
selection: {
|
||||
anchor: shortContent.length,
|
||||
head: shortContent.length,
|
||||
},
|
||||
})
|
||||
expect(view.scrollDOM.scrollTop).toBe(12)
|
||||
})
|
||||
|
||||
it('maps a raw-editor cursor back to the nearest BlockNote block', () => {
|
||||
const paragraphOffset = content.indexOf('Paragraph one') + 5
|
||||
const { editor, restored } = captureAndRestoreRawSelection({
|
||||
|
||||
@@ -81,6 +81,10 @@ function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function clampSelectionOffset(value: number, maxOffset: number): number {
|
||||
return Number.isFinite(value) ? clamp(value, 0, maxOffset) : 0
|
||||
}
|
||||
|
||||
function countLines({ text }: { text: string }): number {
|
||||
return text.length === 0 ? 1 : text.split('\n').length
|
||||
}
|
||||
@@ -336,7 +340,17 @@ export function restoreCodeMirrorView(
|
||||
const view = getRawEditorView(documentObject)
|
||||
if (!view) return false
|
||||
|
||||
view.dispatch({ selection: { anchor: state.anchor, head: state.head } })
|
||||
const maxOffset = view.state.doc.toString().length
|
||||
const selection = {
|
||||
anchor: clampSelectionOffset(state.anchor, maxOffset),
|
||||
head: clampSelectionOffset(state.head, maxOffset),
|
||||
}
|
||||
|
||||
try {
|
||||
view.dispatch({ selection })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
view.scrollDOM.scrollTop = state.scrollTop
|
||||
view.focus()
|
||||
return true
|
||||
|
||||
@@ -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,20 @@
|
||||
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'
|
||||
import { ACCENT_COLORS } from '../../utils/typeColors'
|
||||
|
||||
interface ViewAccent {
|
||||
color: string
|
||||
background: string
|
||||
}
|
||||
|
||||
interface SidebarViewItemProps {
|
||||
view: ViewFile
|
||||
@@ -13,63 +22,162 @@ 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
|
||||
}
|
||||
|
||||
function resolveViewAccent(color: string | null): ViewAccent | null {
|
||||
const colorKey = color?.trim().toLowerCase()
|
||||
if (!colorKey) return null
|
||||
const accent = ACCENT_COLORS.find((candidate) => candidate.key === colorKey)
|
||||
if (!accent) return null
|
||||
return {
|
||||
color: accent.css,
|
||||
background: accent.cssLight,
|
||||
}
|
||||
}
|
||||
|
||||
function getViewRowStyle(showCount: boolean, isActive: boolean, accent: ViewAccent | null) {
|
||||
return {
|
||||
padding: showCount ? SIDEBAR_ITEM_PADDING.withCount : SIDEBAR_ITEM_PADDING.regular,
|
||||
borderRadius: 4,
|
||||
...(isActive && accent ? { background: accent.background, color: accent.color } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function ViewIcon({
|
||||
icon,
|
||||
isActive,
|
||||
accent,
|
||||
}: {
|
||||
icon: string | null
|
||||
isActive: boolean
|
||||
accent: ViewAccent | null
|
||||
}) {
|
||||
if (icon) return <NoteTitleIcon icon={icon} size={16} color={accent?.color} />
|
||||
return <Funnel size={16} weight={isActive ? 'fill' : 'regular'} style={accent ? { color: accent.color } : undefined} />
|
||||
}
|
||||
|
||||
function ViewCountChip({
|
||||
count,
|
||||
isActive,
|
||||
accent,
|
||||
}: {
|
||||
count: number
|
||||
isActive: boolean
|
||||
accent: ViewAccent | null
|
||||
}) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<SidebarCountPill
|
||||
count={count}
|
||||
className="text-muted-foreground transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
|
||||
style={isActive && accent ? { background: accent.color, color: 'var(--text-inverse)' } : { background: 'var(--muted)' }}
|
||||
testId="view-count-chip"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarViewItem({
|
||||
view,
|
||||
isActive,
|
||||
onSelect,
|
||||
onEditView,
|
||||
onDeleteView,
|
||||
onMoveView,
|
||||
canMoveUp = false,
|
||||
canMoveDown = false,
|
||||
dragHandleProps,
|
||||
entries,
|
||||
locale = 'en',
|
||||
}: SidebarViewItemProps) {
|
||||
const count = useMemo(() => evaluateView(view.definition, entries).length, [view.definition, entries])
|
||||
const showCount = count > 0
|
||||
const icon = view.definition.icon
|
||||
? <NoteTitleIcon icon={view.definition.icon} size={16} />
|
||||
: <Funnel size={16} weight={isActive ? 'fill' : 'regular'} />
|
||||
const accent = resolveViewAccent(view.definition.color)
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<div
|
||||
className={`flex cursor-pointer select-none items-center gap-2 rounded transition-colors ${isActive ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-accent'}`}
|
||||
style={{ padding: showCount ? SIDEBAR_ITEM_PADDING.withCount : SIDEBAR_ITEM_PADDING.regular, borderRadius: 4 }}
|
||||
style={getViewRowStyle(showCount, isActive, accent)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{icon}
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{view.definition.name}</span>
|
||||
{showCount && (
|
||||
<SidebarCountPill
|
||||
count={count}
|
||||
className="text-muted-foreground transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
|
||||
style={{ background: 'var(--muted)' }}
|
||||
testId="view-count-chip"
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
<ViewIcon icon={view.definition.icon} isActive={isActive} accent={accent} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{view.definition.name}</span>
|
||||
<ViewCountChip count={count} isActive={isActive} accent={accent} />
|
||||
</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"
|
||||
|
||||
@@ -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 />
|
||||
|
||||
60
src/components/ui/overlayPresence.test.tsx
Normal file
60
src/components/ui/overlayPresence.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from './tooltip'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from './popover'
|
||||
|
||||
const PRESENCE_ANIMATION_CLASS_PARTS = [
|
||||
'animate-',
|
||||
'fade-',
|
||||
'zoom-',
|
||||
'slide-in-from',
|
||||
]
|
||||
|
||||
function expectNoPresenceAnimationClasses(element: HTMLElement) {
|
||||
const unstableClasses = element.className
|
||||
.split(/\s+/)
|
||||
.filter((className) =>
|
||||
PRESENCE_ANIMATION_CLASS_PARTS.some((part) => className.includes(part)),
|
||||
)
|
||||
|
||||
expect(unstableClasses).toEqual([])
|
||||
}
|
||||
|
||||
describe('overlay presence stability', () => {
|
||||
it('keeps tooltip content free of Radix presence animation classes', () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Tooltip open>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button">Tooltip trigger</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent data-testid="tooltip-content">Tooltip copy</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>,
|
||||
)
|
||||
|
||||
expectNoPresenceAnimationClasses(screen.getByTestId('tooltip-content'))
|
||||
})
|
||||
|
||||
it('keeps popover content free of Radix presence animation classes', () => {
|
||||
render(
|
||||
<Popover open>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button">Popover trigger</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent data-testid="popover-content">Popover copy</PopoverContent>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
expectNoPresenceAnimationClasses(screen.getByTestId('popover-content'))
|
||||
})
|
||||
})
|
||||
@@ -30,7 +30,7 @@ function PopoverContent({
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[12000] w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
"bg-popover text-popover-foreground z-[12000] w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -45,39 +45,48 @@ function PopoverAnchor({
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
interface PopoverTextSlotProps extends React.HTMLAttributes<HTMLElement> {
|
||||
baseClassName: string
|
||||
element: "div" | "p"
|
||||
slot: string
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
function PopoverTextSlot({
|
||||
baseClassName,
|
||||
className,
|
||||
element,
|
||||
slot,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
}: PopoverTextSlotProps) {
|
||||
return React.createElement(
|
||||
element,
|
||||
{
|
||||
"data-slot": slot,
|
||||
className: cn(baseClassName, className),
|
||||
...props,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverHeader(props: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
<PopoverTextSlot
|
||||
baseClassName="flex flex-col gap-1 text-sm"
|
||||
element="div"
|
||||
slot="popover-header"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle(props: React.ComponentProps<"div">) {
|
||||
return <PopoverTextSlot baseClassName="font-medium" element="div" slot="popover-title" {...props} />
|
||||
}
|
||||
|
||||
function PopoverDescription(props: React.ComponentProps<"p">) {
|
||||
return <PopoverTextSlot baseClassName="text-muted-foreground" element="p" slot="popover-description" {...props} />
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
|
||||
@@ -42,7 +42,7 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
"bg-foreground text-background z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -59,6 +59,8 @@ const STATIC_LABEL_KEYS: Partial<Record<string, TranslationKey>> = {
|
||||
'restore-getting-started': 'command.settings.restoreGettingStarted',
|
||||
'reload-vault': 'command.settings.reloadVault',
|
||||
'repair-vault': 'command.settings.repairVault',
|
||||
'use-light-mode': 'command.settings.useLightMode',
|
||||
'use-dark-mode': 'command.settings.useDarkMode',
|
||||
'toggle-gitignored-files-visibility': 'command.settings.toggleGitignoredFilesVisibility',
|
||||
'open-ai-agents': 'command.ai.openAgents',
|
||||
'restore-vault-ai-guidance': 'command.ai.restoreGuidance',
|
||||
|
||||
@@ -62,6 +62,50 @@ describe('buildSettingsCommands', () => {
|
||||
expect(onSetUiLanguage).toHaveBeenCalledWith('zh-CN')
|
||||
})
|
||||
|
||||
it('adds direct theme mode commands when a setter is available', () => {
|
||||
const onSetThemeMode = vi.fn()
|
||||
|
||||
const commands = buildSettingsCommands({
|
||||
onOpenSettings: vi.fn(),
|
||||
onSetThemeMode,
|
||||
})
|
||||
|
||||
const lightMode = commands.find((item) => item.id === 'use-light-mode')
|
||||
const darkMode = commands.find((item) => item.id === 'use-dark-mode')
|
||||
|
||||
expect(lightMode).toMatchObject({
|
||||
label: 'Use Light Mode',
|
||||
enabled: true,
|
||||
group: 'Settings',
|
||||
})
|
||||
expect(lightMode?.keywords).toEqual(expect.arrayContaining(['theme', 'light mode']))
|
||||
expect(darkMode).toMatchObject({
|
||||
label: 'Use Dark Mode',
|
||||
enabled: true,
|
||||
group: 'Settings',
|
||||
})
|
||||
expect(darkMode?.keywords).toEqual(expect.arrayContaining(['theme', 'dark mode']))
|
||||
|
||||
lightMode?.execute()
|
||||
darkMode?.execute()
|
||||
|
||||
expect(onSetThemeMode).toHaveBeenNthCalledWith(1, 'light')
|
||||
expect(onSetThemeMode).toHaveBeenNthCalledWith(2, 'dark')
|
||||
})
|
||||
|
||||
it('keeps theme mode commands visible but disabled until settings can be saved', () => {
|
||||
const commands = buildSettingsCommands({ onOpenSettings: vi.fn() })
|
||||
|
||||
expect(commands.find((item) => item.id === 'use-light-mode')).toMatchObject({
|
||||
label: 'Use Light Mode',
|
||||
enabled: false,
|
||||
})
|
||||
expect(commands.find((item) => item.id === 'use-dark-mode')).toMatchObject({
|
||||
label: 'Use Dark Mode',
|
||||
enabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('localizes language commands', () => {
|
||||
const commands = buildSettingsCommands({
|
||||
onOpenSettings: vi.fn(),
|
||||
@@ -78,6 +122,9 @@ describe('buildSettingsCommands', () => {
|
||||
label: '使用系统语言 (简体中文)',
|
||||
enabled: false,
|
||||
})
|
||||
expect(commands.find((item) => item.id === 'use-light-mode')).toMatchObject({
|
||||
label: '使用浅色模式',
|
||||
})
|
||||
})
|
||||
|
||||
it('adds a create-empty-vault command when the handler is available', () => {
|
||||
@@ -126,4 +173,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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type AppLocale,
|
||||
type UiLanguagePreference,
|
||||
} from '../../lib/i18n'
|
||||
import type { ThemeMode } from '../../lib/themeMode'
|
||||
|
||||
interface SettingsCommandsConfig {
|
||||
mcpStatus?: string
|
||||
@@ -31,6 +32,7 @@ interface SettingsCommandsConfig {
|
||||
systemLocale?: AppLocale
|
||||
selectedUiLanguage?: UiLanguagePreference
|
||||
onSetUiLanguage?: (language: UiLanguagePreference) => void
|
||||
onSetThemeMode?: (mode: ThemeMode) => void
|
||||
}
|
||||
|
||||
function commandKeywords(raw: string): string[] {
|
||||
@@ -121,6 +123,33 @@ function buildLanguageCommands({
|
||||
]
|
||||
}
|
||||
|
||||
function buildThemeCommands({
|
||||
locale = 'en',
|
||||
onSetThemeMode,
|
||||
}: Pick<SettingsCommandsConfig, 'locale' | 'onSetThemeMode'>): CommandAction[] {
|
||||
const t = createTranslator(locale)
|
||||
const canSetThemeMode = !!onSetThemeMode
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'use-light-mode',
|
||||
label: t('command.settings.useLightMode'),
|
||||
group: 'Settings',
|
||||
keywords: ['theme', 'appearance', 'light', 'light mode', 'day'],
|
||||
enabled: canSetThemeMode,
|
||||
execute: () => onSetThemeMode?.('light'),
|
||||
},
|
||||
{
|
||||
id: 'use-dark-mode',
|
||||
label: t('command.settings.useDarkMode'),
|
||||
group: 'Settings',
|
||||
keywords: ['theme', 'appearance', 'dark', 'dark mode', 'night'],
|
||||
enabled: canSetThemeMode,
|
||||
execute: () => onSetThemeMode?.('dark'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function buildVaultSettingsCommands({
|
||||
vaultCount,
|
||||
isGettingStartedHidden,
|
||||
@@ -149,7 +178,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?.(),
|
||||
},
|
||||
@@ -171,11 +200,12 @@ export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAc
|
||||
mcpStatus, vaultCount, isGettingStartedHidden,
|
||||
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
|
||||
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault, onToggleGitignoredFilesVisibility,
|
||||
locale = 'en', systemLocale = locale, selectedUiLanguage = SYSTEM_UI_LANGUAGE, onSetUiLanguage,
|
||||
locale = 'en', systemLocale = locale, selectedUiLanguage = SYSTEM_UI_LANGUAGE, onSetUiLanguage, onSetThemeMode,
|
||||
} = config
|
||||
|
||||
return [
|
||||
...buildPrimarySettingsCommands({ locale, onOpenSettings, onOpenFeedback, onCheckForUpdates }),
|
||||
...buildThemeCommands({ locale, onSetThemeMode }),
|
||||
...buildLanguageCommands({
|
||||
locale,
|
||||
systemLocale,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
47
src/hooks/editorBlockRepair.ts
Normal file
47
src/hooks/editorBlockRepair.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
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 fallbackParagraphBlock(): Record<string, unknown> {
|
||||
return {
|
||||
id: createEditorBlockId(),
|
||||
type: 'paragraph',
|
||||
content: [],
|
||||
children: [],
|
||||
}
|
||||
}
|
||||
|
||||
function repairEditorBlock(block: unknown): unknown {
|
||||
if (!isEditorBlockRecord(block)) return fallbackParagraphBlock()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
|
||||
import type { AppLocale, UiLanguagePreference } from '../lib/i18n'
|
||||
import type { ThemeMode } from '../lib/themeMode'
|
||||
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
|
||||
import { useAppKeyboard } from './useAppKeyboard'
|
||||
import { useCommandRegistry } from './useCommandRegistry'
|
||||
@@ -41,6 +42,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
|
||||
@@ -75,6 +81,7 @@ interface AppCommandsConfig {
|
||||
systemLocale?: AppLocale
|
||||
selectedUiLanguage?: UiLanguagePreference
|
||||
onSetUiLanguage?: (language: UiLanguagePreference) => void
|
||||
onSetThemeMode?: (mode: ThemeMode) => void
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
aiAgentsStatus?: AiAgentsStatus
|
||||
@@ -155,6 +162,11 @@ type CommandRegistryCoreActions = Pick<
|
||||
| 'onToggleInspector'
|
||||
| 'onToggleDiff'
|
||||
| 'onToggleRawEditor'
|
||||
| 'selectedViewName'
|
||||
| 'onMoveSelectedViewUp'
|
||||
| 'onMoveSelectedViewDown'
|
||||
| 'canMoveSelectedViewUp'
|
||||
| 'canMoveSelectedViewDown'
|
||||
| 'noteLayout'
|
||||
| 'onToggleNoteLayout'
|
||||
| 'onToggleAIChat'
|
||||
@@ -173,6 +185,7 @@ type CommandRegistryVaultActions = Pick<
|
||||
| 'systemLocale'
|
||||
| 'selectedUiLanguage'
|
||||
| 'onSetUiLanguage'
|
||||
| 'onSetThemeMode'
|
||||
| 'onRemoveActiveVault'
|
||||
| 'onRestoreGettingStarted'
|
||||
| 'isGettingStartedHidden'
|
||||
@@ -417,6 +430,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,
|
||||
@@ -441,6 +459,7 @@ function createCommandRegistryVaultConfig(
|
||||
systemLocale: config.systemLocale,
|
||||
selectedUiLanguage: config.selectedUiLanguage,
|
||||
onSetUiLanguage: config.onSetUiLanguage,
|
||||
onSetThemeMode: config.onSetThemeMode,
|
||||
onRemoveActiveVault: config.onRemoveActiveVault,
|
||||
onRestoreGettingStarted: config.onRestoreGettingStarted,
|
||||
isGettingStartedHidden: config.isGettingStartedHidden,
|
||||
|
||||
@@ -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))
|
||||
@@ -366,6 +413,31 @@ describe('useCommandRegistry', () => {
|
||||
expect(findCommand(result.current, 'toggle-note-layout')?.label).toBe('Use Centered Note Layout')
|
||||
})
|
||||
|
||||
it('exposes command palette actions for light and dark mode', () => {
|
||||
const onSetThemeMode = vi.fn()
|
||||
const config = makeConfig({ onSetThemeMode })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const lightMode = findCommand(result.current, 'use-light-mode')
|
||||
const darkMode = findCommand(result.current, 'use-dark-mode')
|
||||
|
||||
expect(lightMode).toMatchObject({
|
||||
label: 'Use Light Mode',
|
||||
enabled: true,
|
||||
group: 'Settings',
|
||||
})
|
||||
expect(darkMode).toMatchObject({
|
||||
label: 'Use Dark Mode',
|
||||
enabled: true,
|
||||
group: 'Settings',
|
||||
})
|
||||
|
||||
lightMode?.execute()
|
||||
darkMode?.execute()
|
||||
|
||||
expect(onSetThemeMode).toHaveBeenNthCalledWith(1, 'light')
|
||||
expect(onSetThemeMode).toHaveBeenNthCalledWith(2, 'dark')
|
||||
})
|
||||
|
||||
it('includes a New AI chat command that opens and resets the panel session', () => {
|
||||
const config = makeConfig()
|
||||
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { AiAgentId, AiAgentsStatus } from '../lib/aiAgents'
|
||||
import type { AppLocale, UiLanguagePreference } from '../lib/i18n'
|
||||
import type { ThemeMode } from '../lib/themeMode'
|
||||
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
|
||||
import type { NoteLayout, SidebarSelection, VaultEntry } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
@@ -46,6 +47,7 @@ interface CommandRegistryConfig {
|
||||
systemLocale?: AppLocale
|
||||
selectedUiLanguage?: UiLanguagePreference
|
||||
onSetUiLanguage?: (language: UiLanguagePreference) => void
|
||||
onSetThemeMode?: (mode: ThemeMode) => void
|
||||
onChangeNoteType?: () => void
|
||||
onMoveNoteToFolder?: () => void
|
||||
canMoveNoteToFolder?: boolean
|
||||
@@ -83,6 +85,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 +126,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,
|
||||
@@ -129,7 +137,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
mcpStatus, onInstallMcp, aiAgentsStatus, vaultAiGuidanceStatus,
|
||||
onOpenAiAgents, onRestoreVaultAiGuidance, onSetDefaultAiAgent, selectedAiAgent, onCycleDefaultAiAgent, selectedAiAgentLabel,
|
||||
onReloadVault, onRepairVault,
|
||||
locale, systemLocale, selectedUiLanguage, onSetUiLanguage,
|
||||
locale, systemLocale, selectedUiLanguage, onSetUiLanguage, onSetThemeMode,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
|
||||
onOpenInNewWindow, onRevealActiveFile, onCopyActiveFilePath, onOpenActiveFileExternal, onToggleFavorite, onToggleOrganized,
|
||||
onCustomizeNoteListColumns, canCustomizeNoteListColumns,
|
||||
@@ -214,23 +222,25 @@ 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({
|
||||
mcpStatus, vaultCount, isGettingStartedHidden,
|
||||
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
|
||||
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
|
||||
locale, systemLocale, selectedUiLanguage, onSetUiLanguage,
|
||||
locale, systemLocale, selectedUiLanguage, onSetUiLanguage, onSetThemeMode,
|
||||
}), [
|
||||
mcpStatus, vaultCount, isGettingStartedHidden, onOpenSettings, onOpenFeedback,
|
||||
onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
|
||||
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
|
||||
locale, systemLocale, selectedUiLanguage, onSetUiLanguage,
|
||||
locale, systemLocale, selectedUiLanguage, onSetUiLanguage, onSetThemeMode,
|
||||
])
|
||||
|
||||
const aiCommands = useMemo(() => buildAiAgentCommands({
|
||||
|
||||
@@ -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,97 @@ 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('replaces non-object malformed parsed blocks 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\nRecovered body.',
|
||||
}
|
||||
|
||||
const { mockEditor, rerenderWith } = await createSwapHarness({
|
||||
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
|
||||
setupEditor: (editor) => {
|
||||
editor.tryParseMarkdownToBlocks.mockReturnValue([
|
||||
null,
|
||||
'dangling content',
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Parent', styles: {} }],
|
||||
children: [
|
||||
undefined,
|
||||
{
|
||||
type: 'paragraph',
|
||||
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), type: 'paragraph', content: [], children: [] }),
|
||||
expect.objectContaining({ id: expect.any(String), type: 'paragraph', content: [], children: [] }),
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
children: [
|
||||
expect.objectContaining({ id: expect.any(String), type: 'paragraph', content: [], children: [] }),
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
content: [{ type: 'text', text: 'Child', styles: {} }],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
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,22 @@ 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 parseSafeBlocks = repairMalformedEditorBlocks(parsed) as EditorBlocks
|
||||
const nextState = {
|
||||
blocks: repairMalformedEditorBlocks(injectEditorMarkdownBlocks(parseSafeBlocks)) as EditorBlocks,
|
||||
scrollTop: 0,
|
||||
sourceContent: content,
|
||||
}
|
||||
cacheEditorState(cache, targetPath, nextState)
|
||||
return nextState
|
||||
}
|
||||
@@ -182,18 +190,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 +224,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 +246,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()
|
||||
})
|
||||
|
||||
|
||||
@@ -304,9 +304,9 @@ describe('resolveNewNote', () => {
|
||||
})
|
||||
|
||||
describe('resolveNewType', () => {
|
||||
it('creates a type entry at vault root', () => {
|
||||
it('creates a type entry in the canonical type directory', () => {
|
||||
const { entry, content } = resolveNewType({ typeName: 'Recipe', vaultPath: '/my/vault' })
|
||||
expect(entry.path).toBe('/my/vault/recipe.md')
|
||||
expect(entry.path).toBe('/my/vault/type/recipe.md')
|
||||
expect(entry.isA).toBe('Type')
|
||||
expect(entry.status).toBeNull()
|
||||
expect(content).toContain('type: Type')
|
||||
@@ -315,7 +315,7 @@ describe('resolveNewType', () => {
|
||||
|
||||
it('uses provided vault path instead of hardcoded path', () => {
|
||||
const { entry } = resolveNewType({ typeName: 'Responsibility', vaultPath: '/other/vault' })
|
||||
expect(entry.path).toBe('/other/vault/responsibility.md')
|
||||
expect(entry.path).toBe('/other/vault/type/responsibility.md')
|
||||
expect(entry.path).not.toContain('/Users/luca/Laputa')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -172,18 +172,23 @@ describe('resolveNewNote', () => {
|
||||
})
|
||||
|
||||
describe('resolveNewType', () => {
|
||||
it('creates a type entry', () => {
|
||||
it('creates a type entry in the canonical type directory', () => {
|
||||
const { entry, content } = resolveNewType({ typeName: 'Recipe', vaultPath: '/vault' })
|
||||
expect(entry.path).toBe('/vault/recipe.md')
|
||||
expect(entry.path).toBe('/vault/type/recipe.md')
|
||||
expect(entry.isA).toBe('Type')
|
||||
expect(content).toContain('type: Type')
|
||||
})
|
||||
|
||||
it('uses the unicode title when the type name has no ASCII characters', () => {
|
||||
const { entry } = resolveNewType({ typeName: '停智慧', vaultPath: '/vault' })
|
||||
expect(entry.path).toBe('/vault/停智慧.md')
|
||||
expect(entry.path).toBe('/vault/type/停智慧.md')
|
||||
expect(entry.filename).toBe('停智慧.md')
|
||||
})
|
||||
|
||||
it('can target an existing plural types directory', () => {
|
||||
const { entry } = resolveNewType({ typeName: 'Recipe', vaultPath: '/vault', typeDirectory: 'types' })
|
||||
expect(entry.path).toBe('/vault/types/recipe.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTemplate', () => {
|
||||
@@ -211,6 +216,10 @@ describe('useNoteCreation hook', () => {
|
||||
})
|
||||
|
||||
const tabDeps = { openTabWithContent }
|
||||
const flushImmediateCreate = async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -230,10 +239,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 +254,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 +278,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 +302,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
|
||||
@@ -358,9 +429,10 @@ describe('useNoteCreation hook', () => {
|
||||
expect(addEntry.mock.calls[0][0].title).toBe('Recipe')
|
||||
})
|
||||
|
||||
it('handleCreateType blocks when a non-type file already uses the target filename', async () => {
|
||||
const existing = makeEntry({ path: '/test/vault/briefing.md', filename: 'briefing.md', title: 'Briefing', isA: 'Note' })
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig([existing]), tabDeps))
|
||||
it('handleCreateType blocks when the target type file already exists', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(invoke).mockRejectedValueOnce(new Error('File already exists: /test/vault/type/briefing.md'))
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
|
||||
|
||||
let created = true
|
||||
await act(async () => {
|
||||
@@ -368,11 +440,66 @@ describe('useNoteCreation hook', () => {
|
||||
})
|
||||
|
||||
expect(created).toBe(false)
|
||||
expect(addEntry).not.toHaveBeenCalled()
|
||||
expect(openTabWithContent).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
|
||||
path: '/test/vault/type/briefing.md',
|
||||
content: expect.stringContaining('type: Type'),
|
||||
})
|
||||
expect(removeEntry).toHaveBeenCalledWith('/test/vault/type/briefing.md')
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Cannot create type "Briefing" because briefing.md already exists')
|
||||
})
|
||||
|
||||
it('handleCreateType lets disk creation decide when a stale entry collides with the target type path', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(invoke).mockResolvedValueOnce(undefined)
|
||||
const staleEntry = makeEntry({
|
||||
path: '/test/vault/type/pttep.md',
|
||||
filename: 'pttep.md',
|
||||
title: 'Stale cache entry',
|
||||
isA: 'Note',
|
||||
})
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig([staleEntry]), tabDeps))
|
||||
|
||||
let created = false
|
||||
await act(async () => {
|
||||
created = await result.current.handleCreateType('PTTEP')
|
||||
})
|
||||
|
||||
expect(created).toBe(true)
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
|
||||
path: '/test/vault/type/pttep.md',
|
||||
content: expect.stringContaining('type: Type'),
|
||||
})
|
||||
expect(addEntry).toHaveBeenCalledWith(expect.objectContaining({
|
||||
path: '/test/vault/type/pttep.md',
|
||||
filename: 'pttep.md',
|
||||
title: 'PTTEP',
|
||||
isA: 'Type',
|
||||
}))
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleCreateType uses an existing plural types folder convention', async () => {
|
||||
const existingType = makeEntry({
|
||||
path: '/test/vault/types/project.md',
|
||||
filename: 'project.md',
|
||||
title: 'Project',
|
||||
isA: 'Type',
|
||||
})
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig([existingType]), tabDeps))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateType('Hotel')
|
||||
})
|
||||
|
||||
expect(addEntry).toHaveBeenCalledWith(expect.objectContaining({
|
||||
path: '/test/vault/types/hotel.md',
|
||||
filename: 'hotel.md',
|
||||
title: 'Hotel',
|
||||
isA: 'Type',
|
||||
}))
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleCreateType ignores an existing untitled draft when a unicode filename is unique', async () => {
|
||||
const existing = makeEntry({ path: '/test/vault/untitled.md', filename: 'untitled.md', title: 'Untitled', isA: 'Note' })
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig([existing]), tabDeps))
|
||||
@@ -384,7 +511,7 @@ describe('useNoteCreation hook', () => {
|
||||
|
||||
expect(created).toBe(true)
|
||||
expect(addEntry).toHaveBeenCalledWith(expect.objectContaining({
|
||||
path: '/test/vault/停智慧.md',
|
||||
path: '/test/vault/type/停智慧.md',
|
||||
filename: '停智慧.md',
|
||||
title: '停智慧',
|
||||
isA: 'Type',
|
||||
|
||||
@@ -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
|
||||
@@ -122,11 +123,50 @@ export function resolveNewNote({ title, type, vaultPath, template }: NewNotePara
|
||||
export interface NewTypeParams {
|
||||
typeName: string
|
||||
vaultPath: string
|
||||
typeDirectory?: string
|
||||
}
|
||||
|
||||
export function resolveNewType({ typeName, vaultPath }: NewTypeParams): { entry: VaultEntry; content: string } {
|
||||
const DEFAULT_TYPE_DEFINITION_DIR = 'type'
|
||||
const TYPE_DEFINITION_DIRS = new Set([DEFAULT_TYPE_DEFINITION_DIR, 'types'])
|
||||
|
||||
interface RelativePathParams {
|
||||
entryPath: string
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
interface TypeDirectoryParams {
|
||||
entry: VaultEntry
|
||||
vaultPath: string
|
||||
}
|
||||
|
||||
function relativePathFromVault({ entryPath, vaultPath }: RelativePathParams): string | null {
|
||||
const normalizedPath = entryPath.replace(/\\/g, '/')
|
||||
const normalizedVaultPath = vaultPath.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
const prefix = `${normalizedVaultPath}/`
|
||||
if (normalizedPath.toLocaleLowerCase().startsWith(prefix.toLocaleLowerCase())) {
|
||||
return normalizedPath.slice(prefix.length)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function typeDirectoryFromEntry({ entry, vaultPath }: TypeDirectoryParams): string | null {
|
||||
const relativePath = relativePathFromVault({ entryPath: entry.path, vaultPath })
|
||||
const directory = relativePath?.replace(/\\/g, '/').split('/').filter(Boolean)[0] ?? null
|
||||
return directory && TYPE_DEFINITION_DIRS.has(directory.toLocaleLowerCase()) ? directory : null
|
||||
}
|
||||
|
||||
function resolveTypeDefinitionDirectory(entries: VaultEntry[], vaultPath: string): string {
|
||||
for (const entry of entries) {
|
||||
if (entry.isA !== 'Type') continue
|
||||
const directory = typeDirectoryFromEntry({ entry, vaultPath })
|
||||
if (directory?.toLocaleLowerCase() === 'types') return directory
|
||||
}
|
||||
return DEFAULT_TYPE_DEFINITION_DIR
|
||||
}
|
||||
|
||||
export function resolveNewType({ typeName, vaultPath, typeDirectory = DEFAULT_TYPE_DEFINITION_DIR }: NewTypeParams): { entry: VaultEntry; content: string } {
|
||||
const slug = slugify(typeName)
|
||||
const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
|
||||
const entry = buildNewEntry({ path: `${vaultPath}/${typeDirectory}/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
|
||||
return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n` }
|
||||
}
|
||||
|
||||
@@ -198,14 +238,8 @@ export function planNewTypeCreation({
|
||||
const existingType = findEquivalentTypeEntry(entries, typeName)
|
||||
if (existingType) return { status: 'existing', entry: existingType }
|
||||
|
||||
const resolved = resolveNewType({ typeName, vaultPath })
|
||||
const collision = findPathCollision(entries, resolved.entry.path)
|
||||
if (collision) {
|
||||
return {
|
||||
status: 'blocked',
|
||||
message: buildCreationCollisionMessage({ noun: 'type', title: typeName, path: resolved.entry.path }),
|
||||
}
|
||||
}
|
||||
const typeDirectory = resolveTypeDefinitionDirectory(entries, vaultPath)
|
||||
const resolved = resolveNewType({ typeName, vaultPath, typeDirectory })
|
||||
return { status: 'create', resolved }
|
||||
}
|
||||
|
||||
@@ -370,13 +404,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 +420,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 +448,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 +475,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 +544,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 +663,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,
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user