Compare commits

...

119 Commits

Author SHA1 Message Date
lucaronin
dcd0d73848 fix: open vault files externally 2026-04-29 14:25:15 +02:00
lucaronin
0e8c8fb61a docs: add product analytics workflow 2026-04-29 14:24:12 +02:00
lucaronin
42bdd559ef fix: move mcp config copy to settings 2026-04-29 13:51:28 +02:00
lucaronin
3a6c97272d fix: allow pdf asset previews in csp 2026-04-29 13:32:29 +02:00
lucaronin
f53a3746d3 fix: localize recent UI copy 2026-04-29 13:25:22 +02:00
lucaronin
23145a05f5 feat: preview pdf files in app 2026-04-29 13:14:44 +02:00
lucaronin
5b86b0b50d fix: validate active note cache refresh 2026-04-29 12:09:58 +02:00
lucaronin
dc1b1783b6 docs: add localization developer workflow 2026-04-29 12:09:58 +02:00
lucaronin
3dc520abea feat: add safe note open cache 2026-04-29 12:09:58 +02:00
lucaronin
792b3009dd fix: keep saved view reorder UI native 2026-04-29 11:51:22 +02:00
lucaronin
6ee7f219f5 feat: add gemini cli agent support 2026-04-29 11:16:52 +02:00
lucaronin
3db1ed9dc5 fix: split ai permission tooltips 2026-04-29 10:44:20 +02:00
lucaronin
7a7ef5d9ca fix: improve ai permission toggle contrast 2026-04-29 10:35:19 +02:00
Luca Rossi
4c42cf4d52 Updated release download link 2026-04-29 10:30:53 +02:00
lucaronin
405b711e16 revert: restore editor side menu ordering 2026-04-29 10:06:03 +02:00
lucaronin
83705e26f5 docs: add ADRs for gitignored visibility and saved view ordering (guard — from commits d2c15b8, fd92ff5) 2026-04-29 08:03:17 +02:00
lucaronin
434927160c fix: stabilize radix overlay presence 2026-04-29 07:45:02 +02:00
lucaronin
ec2e04a464 test: wait for date picker smoke note load 2026-04-29 06:55:07 +02:00
lucaronin
a17ec689a3 fix: tolerate stale vault watcher listeners 2026-04-29 06:36:39 +02:00
lucaronin
eda4d380e0 fix: let disk decide type file conflicts 2026-04-29 06:09:31 +02:00
lucaronin
f9b155ba6e fix: repair malformed editor block entries 2026-04-29 05:27:39 +02:00
lucaronin
4a62008b1f fix: clamp stale raw editor selections 2026-04-29 05:05:15 +02:00
lucaronin
a08cee7965 refactor: share cli agent runtime scaffold 2026-04-29 04:47:16 +02:00
lucaronin
a7c480af62 fix: normalize stale vault entry metadata 2026-04-29 04:33:18 +02:00
lucaronin
f4790d5763 fix: detect Windows npm ai agent shims 2026-04-29 04:01:53 +02:00
lucaronin
ed52a9e800 fix: preserve frontmatter date picker days 2026-04-29 03:56:44 +02:00
lucaronin
2fcd808f09 fix: detect Windows npm ai agent shims 2026-04-29 03:53:54 +02:00
lucaronin
9d15286f7d fix: stop orphaned mcp server processes 2026-04-29 03:29:16 +02:00
lucaronin
537bd0f3ec fix: normalize Windows vault image paths 2026-04-29 03:02:57 +02:00
lucaronin
648241604f feat: add theme mode command actions 2026-04-29 02:35:52 +02:00
lucaronin
c6f3d9d945 fix: create type files in type folder 2026-04-29 02:24:01 +02:00
lucaronin
65e54f108a fix: render saved view colors 2026-04-29 01:40:16 +02:00
lucaronin
58f8a774b5 chore: ratchet codescene thresholds 2026-04-29 01:26:33 +02:00
lucaronin
0bf280d3ec fix: satisfy ai permission mode clippy 2026-04-29 01:26:33 +02:00
lucaronin
d2d4746d5c feat: add vault ai agent permission modes 2026-04-29 01:26:33 +02:00
lucaronin
91db7c0098 fix: pause saves without active vault 2026-04-29 00:19:47 +02:00
lucaronin
87908d982f fix: allow guidance open path 2026-04-28 23:56:08 +02:00
lucaronin
40544fc551 fix: detect nvm ai agent binaries 2026-04-28 23:39:48 +02:00
lucaronin
f34fdb0289 fix: guard stale suggestion menu actions 2026-04-28 23:29:09 +02:00
lucaronin
e601a42f41 fix: surface ai agent final replies 2026-04-28 23:00:06 +02:00
lucaronin
de627c2478 fix: normalize missing vault metadata 2026-04-28 22:52:07 +02:00
lucaronin
46d9aaed48 fix: dedupe editor toolbar state closes 2026-04-28 21:57:26 +02:00
lucaronin
6b96b5caf6 fix: reset editor selection before note swaps 2026-04-28 21:31:54 +02:00
lucaronin
595c48ca59 fix: apply settings theme changes immediately 2026-04-28 21:16:30 +02:00
lucaronin
fd92ff5e9b feat: allow manual saved view ordering 2026-04-28 20:41:07 +02:00
lucaronin
a39b346a75 fix: detect mise node for mcp setup 2026-04-28 20:28:56 +02:00
lucaronin
deac10caa2 fix: detect nvm pi installs 2026-04-28 19:54:39 +02:00
lucaronin
0e1e0ff47a fix: preserve ai composer line breaks 2026-04-28 19:32:08 +02:00
lucaronin
a645edbc19 fix: repair malformed editor block ids 2026-04-28 18:56:36 +02:00
lucaronin
48f1fb0b50 feat: add gemini cli external ai setup 2026-04-28 18:30:14 +02:00
lucaronin
1aede09834 fix: preserve wikilinks inside markdown tables 2026-04-28 18:00:15 +02:00
lucaronin
d51b76f992 fix: restrict sentry releases to stable builds 2026-04-28 17:17:23 +02:00
lucaronin
8e66fb4c19 fix: preserve table math markdown 2026-04-28 16:47:52 +02:00
lucaronin
a2843a0cf3 fix: persist immediate note creation before open 2026-04-28 15:01:32 +02:00
lucaronin
38fb8a7370 feat: copy mcp config from app 2026-04-28 14:03:10 +02:00
lucaronin
be5a8bb300 fix: make editor left handle draggable 2026-04-28 12:31:13 +02:00
lucaronin
ce040c75fd test: cover table hover stability 2026-04-28 10:46:06 +02:00
lucaronin
ced89a42bd fix: render mermaid diagrams with lightbox 2026-04-28 10:32:37 +02:00
lucaronin
20a960551f fix: update fallback vault git email 2026-04-28 10:08:18 +02:00
lucaronin
2e331315e8 fix: refine Italian locale labels 2026-04-28 09:43:44 +02:00
lucaronin
d2c15b8469 feat: hide gitignored vault content 2026-04-28 09:07:21 +02:00
lucaronin
2fe6c5b580 fix: respect selected ai agent in right panel 2026-04-28 07:36:22 +02:00
lucaronin
2a224f78f8 refactor: simplify note mutation flushing 2026-04-28 06:25:39 +02:00
lucaronin
b85d54bb5d fix: preserve ai agent final result text 2026-04-28 06:17:33 +02:00
lucaronin
9c05e17d37 fix: sanitize custom view filenames 2026-04-28 05:39:41 +02:00
lucaronin
cf6ad71fff ci: de-duplicate coverage-backed test lane 2026-04-28 04:53:39 +02:00
lucaronin
db82aee172 refactor: consolidate ai agent session flow 2026-04-28 04:20:40 +02:00
lucaronin
c72d833624 feat: add pi ai agent support 2026-04-28 03:41:04 +02:00
lucaronin
a3ca78fced fix: guard git init parent folders 2026-04-28 02:38:32 +02:00
lucaronin
e912ab9a9f fix: use selected vault for mcp bridge 2026-04-28 02:26:14 +02:00
lucaronin
49717dc671 feat: add opencode ai agent option 2026-04-28 01:26:21 +02:00
lucaronin
d9a0a04ddb chore: ratchet codescene thresholds 2026-04-28 01:21:59 +02:00
lucaronin
0561fe68b1 fix: restore editor block handle reordering 2026-04-28 01:09:47 +02:00
lucaronin
2f078fab11 feat: watch active vault filesystem changes 2026-04-27 23:54:50 +02:00
lucaronin
98fde6571a feat: render mermaid note diagrams 2026-04-27 22:14:23 +02:00
lucaronin
a5c4b8fa20 fix: hide ai agent subprocess windows 2026-04-27 21:21:00 +02:00
lucaronin
1301653b81 fix: avoid tooltip dropdown popper nesting 2026-04-27 20:58:31 +02:00
lucaronin
ee90b05159 fix: guard inline input types 2026-04-27 20:36:59 +02:00
lucaronin
b6c11b1468 fix: close claude print-mode stdin 2026-04-27 19:25:53 +02:00
lucaronin
2fbdafa6f2 fix: set git identity before remote connect 2026-04-27 18:59:23 +02:00
lucaronin
39f44b86aa fix: copy fenced code blocks exactly 2026-04-27 18:31:51 +02:00
lucaronin
cb3274cdbc fix: harden windows mcp setup 2026-04-27 17:51:25 +02:00
lucaronin
76d8bba332 fix: remove hidden Claude permission bypass 2026-04-27 16:46:32 +02:00
lucaronin
86853cf337 fix: allow Codex to edit active vault 2026-04-27 16:07:05 +02:00
lucaronin
9a292d2476 fix: map releases to Sentry 2026-04-27 15:27:31 +02:00
lucaronin
4a22b02810 test: harden locale catalog validation 2026-04-27 14:48:49 +02:00
github-actions[bot]
e4270cda3b Merge branch 'main' into main 2026-04-27 12:45:03 +00:00
lucaronin
9cb0de37a5 fix: parse crlf frontmatter 2026-04-27 14:31:51 +02:00
github-actions[bot]
2a794cb373 Merge branch 'main' into main 2026-04-27 11:37:50 +00:00
lucaronin
324af6b271 fix: normalize folder filtering across path separators 2026-04-27 13:27:20 +02:00
Disheng Qiu
926db0eeb5 feat: add lara-powered app localization 2026-04-27 13:06:59 +02:00
lucaronin
b1a97834c9 fix: keep raw editor full width 2026-04-27 11:54:15 +02:00
lucaronin
da13cb38ac fix: satisfy editor find command contract 2026-04-27 10:57:58 +02:00
lucaronin
dbf8ba5f40 feat: add editor find and replace 2026-04-27 10:57:58 +02:00
lucaronin
3e8a6e5d7c fix: render inline math while typing 2026-04-27 10:44:35 +02:00
lucaronin
8141644729 fix: revert note width modes 2026-04-27 10:13:55 +02:00
lucaronin
0d88c75718 Merge branches 'main', 'main', 'main' and 'main' of https://github.com/refactoringhq/tolaria 2026-04-27 09:42:36 +02:00
lucaronin
f8721f2a1b feat: add note width modes 2026-04-27 05:34:11 +02:00
lucaronin
fb39c6679a fix: handle invalid Windows save paths 2026-04-27 04:04:04 +02:00
lucaronin
f1bed131bf fix: refresh type state after type changes 2026-04-27 03:25:24 +02:00
lucaronin
257cf6ed02 fix: keep theme tooltip inside window 2026-04-27 02:20:28 +02:00
lucaronin
908daafaa1 feat: add basic file actions 2026-04-27 01:45:16 +02:00
lucaronin
d15437face test: cover consecutive IME composer input 2026-04-27 01:05:34 +02:00
lucaronin
30281f879d chore: ratchet codescene thresholds 2026-04-27 00:49:39 +02:00
lucaronin
4ef6edfb10 feat: preview vault images in app 2026-04-27 00:36:24 +02:00
lucaronin
918419e371 fix: respect macos control text bindings 2026-04-26 23:22:53 +02:00
lucaronin
2704002973 fix: remove note drag and drop flows 2026-04-26 22:51:08 +02:00
lucaronin
9265b8f117 fix: stabilize AI composer text editing 2026-04-26 22:11:54 +02:00
lucaronin
943fa854e1 fix: satisfy localization build 2026-04-26 19:39:56 +02:00
lucaronin
a2f8ba72a2 fix: complete app localization pass 2026-04-26 19:37:21 +02:00
lucaronin
1d546dde3d fix: allow note drops to insert wikilinks 2026-04-26 18:15:22 +02:00
lucaronin
63877e61ec fix: store window state in logical points 2026-04-26 17:54:41 +02:00
lucaronin
8896f1d451 fix: restore saved window size after launch 2026-04-26 17:54:41 +02:00
lucaronin
f523099854 feat: add vault loading skeleton 2026-04-26 17:35:09 +02:00
lucaronin
15f38f096f feat: support non-git vaults 2026-04-26 17:08:14 +02:00
lucaronin
cdd2ddec6c feat: persist main window placement 2026-04-26 15:45:34 +02:00
lucaronin
292d3739df fix: prevent editor handling note wikilink drops 2026-04-26 14:37:11 +02:00
lucaronin
1136c1948e fix: clarify macos release artifact names 2026-04-26 14:29:47 +02:00
lucaronin
8406042db1 fix: support native folder drops in AI inputs 2026-04-26 13:59:39 +02:00
388 changed files with 33186 additions and 5452 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.98
AVERAGE_THRESHOLD=9.87
HOTSPOT_THRESHOLD=10.0
AVERAGE_THRESHOLD=9.91

View File

@@ -12,3 +12,7 @@ VITE_SENTRY_DSN=
# PostHog (https://posthog.com → Project → Settings → Project API Key)
VITE_POSTHOG_KEY=
VITE_POSTHOG_HOST=https://eu.i.posthog.com
# Lara CLI (https://github.com/translated/lara-cli)
LARA_ACCESS_KEY_ID=
LARA_ACCESS_KEY_SECRET=

View File

@@ -65,22 +65,18 @@ jobs:
- name: Vite build check
run: pnpm build
# ── 1. Tests ──────────────────────────────────────────────────────────
- name: Run frontend tests
run: pnpm test
# ── 1. Coverage-backed tests ──────────────────────────────────────────
# The coverage commands run the same frontend and Rust test suites, so keep
# them as the canonical test lane instead of running every suite twice.
- name: Bundle MCP server resources (required by Tauri build)
run: node scripts/bundle-mcp-server.mjs
- name: Run Rust tests
run: cargo test --manifest-path=src-tauri/Cargo.toml
# ── 2. Coverage (enforced — fails build if thresholds not met) ────────
- name: Frontend coverage (≥70% lines/functions/branches/statements)
# ── 2. Tests + coverage (enforced — fails build if thresholds not met) ─
- name: Frontend tests + coverage (≥70% lines/functions/branches/statements)
run: pnpm test:coverage
# Thresholds configured in vite.config.ts — exits non-zero if coverage drops
- name: Rust coverage (≥85% lines)
- name: Rust tests + coverage (≥85% lines)
run: |
cargo llvm-cov \
--manifest-path src-tauri/Cargo.toml \

View File

@@ -229,6 +229,7 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
@@ -320,6 +321,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
@@ -431,6 +433,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
@@ -503,6 +506,8 @@ jobs:
run: |
normalize_macos_artifacts() {
local arch="$1"
local normalized_updater="$2"
local normalized_dmg="$3"
local updater_dir="updater-${arch}"
local updater_file
updater_file=$(find "$updater_dir" -maxdepth 1 -name "*.app.tar.gz" -print -quit)
@@ -517,7 +522,6 @@ jobs:
return 1
fi
local normalized_updater="${updater_dir}/Tolaria_${arch}.app.tar.gz"
local normalized_sig="${normalized_updater}.sig"
if [ "$updater_file" != "$normalized_updater" ]; then
mv "$updater_file" "$normalized_updater"
@@ -534,14 +538,17 @@ jobs:
return 1
fi
local normalized_dmg="${dmg_dir}/Tolaria_${arch}.dmg"
if [ "$dmg_file" != "$normalized_dmg" ]; then
mv "$dmg_file" "$normalized_dmg"
fi
}
normalize_macos_artifacts aarch64
normalize_macos_artifacts x86_64
normalize_macos_artifacts aarch64 \
"updater-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.app.tar.gz" \
"dmg-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.dmg"
normalize_macos_artifacts x86_64 \
"updater-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.app.tar.gz" \
"dmg-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.dmg"
- name: Generate release notes
run: |

View File

@@ -288,6 +288,7 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
@@ -374,6 +375,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
@@ -485,6 +487,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
@@ -557,6 +560,7 @@ jobs:
run: |
normalize_updater() {
local arch="$1"
local normalized_updater="$2"
local artifact_dir="updater-${arch}"
local updater_file
updater_file=$(find "$artifact_dir" -maxdepth 1 -name "*.app.tar.gz" -print -quit)
@@ -571,7 +575,6 @@ jobs:
return 1
fi
local normalized_updater="${artifact_dir}/Tolaria_${arch}.app.tar.gz"
local normalized_sig="${normalized_updater}.sig"
if [ "$updater_file" != "$normalized_updater" ]; then
mv "$updater_file" "$normalized_updater"
@@ -581,8 +584,8 @@ jobs:
fi
}
normalize_updater aarch64
normalize_updater x86_64
normalize_updater aarch64 "updater-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.app.tar.gz"
normalize_updater x86_64 "updater-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.app.tar.gz"
- name: Generate release notes
run: |

1
.gitignore vendored
View File

@@ -70,5 +70,6 @@ CODE-HEALTH-REPORT.md
*.key.pub
# Local environment variables (never commit)
.env
.env.local
.env.*.local

View File

@@ -22,7 +22,7 @@ Run `/laputa-next-task` — fetches next task (To Rework first, then Open), move
- Work on `main` branch — **no branches, no PRs, ever**. Pre-commit and pre-push block work from any other branch.
- Commit every 2030 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
- **⛔ NEVER use --no-verify**
- For UI tasks: open `ui-design.pen` first, study visual language, design in light mode
- For UI tasks: open `ui-design.pen` first, study visual language, design in light mode. You don't need Pencil to use it you can open it as a JSON file.
### 1c. When done
@@ -47,16 +47,14 @@ bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
After both phases pass, add a **completion comment** to the Todoist task before running `/laputa-done`. The comment must include:
- What was implemented (12 lines)
After both phases pass, add a **completion comment** to the Todoist task. The comment must include:
- What was implemented (a few lines covering logic and UX/UI)
- QA: what was tested and how (Playwright / native screenshot / osascript)
- Refactoring: any files refactored to meet the CodeScene gate (or "none needed")
- ADRs: any new/updated ADRs (or "none")
- Docs: any updated docs (ARCHITECTURE.md, ABSTRACTIONS.md, etc.) (or "none")
- Code health: final Hotspot and Average scores after push
Then run `/laputa-done <task_id>` → moves to In Review, notifies Brian, self-dispatches next task.
---
## 2. Development Process
@@ -73,6 +71,22 @@ Red → Green → Refactor → Commit. One cycle per commit. For bugs: write fai
**Test quality (Kent Beck's Desiderata):** Isolated · Deterministic · Fast · Behavioral · Structure-insensitive · Specific · Predictive. Fix flaky tests first. Prefer E2E over unit tests for user flows.
### Localization (mandatory for UI copy)
All user-facing UI labels/copy must live in `src/lib/locales/en.json` and be translated into every target listed in `lara.yaml`. When adding or changing interface copy:
```bash
pnpm l10n:translate
```
Use `pnpm l10n:translate:force` only when intentionally regenerating existing translations. Commit `src/lib/locales/*.json`, `lara.yaml`/`lara.lock` changes if produced, and verify placeholders/product names stayed intact.
### Product analytics (mandatory for meaningful features)
New features should almost always emit a PostHog event so we can see whether users actually discover and use them. Skip instrumentation only for very small changes where a dedicated event would create noise. Use clear, stable event names, avoid PII or note content, and include only safe metadata that helps evaluate adoption and failures.
When adding or changing a meaningful user-facing feature, include the event name(s) in the Todoist completion comment alongside QA, docs, and code health. If intentionally not instrumenting a feature, explain why in the completion comment.
### Code health (mandatory)
Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code Health** ≥ thresholds in `.codescene-thresholds`. Both gates block commit/push. Thresholds are a **ratchet** — only go up. When pre-push sees improved remote scores, it updates `.codescene-thresholds`, stages it, and stops so you can commit the new floor with normal verified hooks before pushing again. Never add `// eslint-disable`, `#[allow(...)]`, or `as any`.
@@ -122,10 +136,6 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
- **Delete all test notes from disk** when done — do not leave untitled or temporary notes on the filesystem. Run `cd ~/Laputa && git checkout -- . && git clean -fd` to restore the vault to its last committed state.
- **Rationale:** test notes pollute the local vault over time, making it a collection of nonsensical untitled files. The vault must stay clean on disk, not just on the remote.
### UI design
Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task; on completion merge into `ui-design.pen` and delete it.
### UI components — mandatory rules
**Always use shadcn/ui components.** Never use raw HTML form elements (`<input>`, `<select>`, `<button>`, native `<input type="date">`, etc.) for user-facing UI. Every interactive element must use the shadcn/ui equivalent:
@@ -155,10 +165,11 @@ Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task
- Tauri menu accelerators: `MenuItemBuilder::new(label).accelerator("CmdOrCtrl+1")`
- `app.set_menu()` replaces the ENTIRE menu bar — include all submenus
- `mock-tauri.ts` silently swallows Tauri calls — not a substitute for native testing
### QA scripts
```bash
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh Tolaria
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/out.png
bash ~/.openclaw/skills/tolaria-qa/scripts/shortcut.sh "command" "s"
```

View File

@@ -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

View File

@@ -57,6 +57,19 @@ The frontmatter parser (Rust: `vault/mod.rs`, TS: `utils/frontmatter.ts`) must f
All data lives in markdown files with YAML frontmatter. There is no database — the filesystem is the source of truth.
### Vault Git Capability
Git is a per-vault capability, not a prerequisite for the document model. A vault can be:
| State | Meaning | UI behavior |
|---|---|---|
| Git-backed | The vault path contains a Git repository | History, changes, commits, sync, conflict resolution, remotes, AutoGit, and auto-sync are available according to remote/config state |
| Non-git | The vault path is a plain folder | Markdown scanning, editing, search, and navigation work; Git-dependent status-bar controls and command-palette entries are replaced by `Git disabled` + `Initialize Git for Current Vault` |
Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. Features that depend on Git must check this capability instead of assuming every vault has `.git`.
Git initialization is intentionally scoped to dedicated vault folders. When the current non-git folder looks like a broad personal root such as Documents, Desktop, or Downloads and does not already carry Tolaria-managed vault markers, `init_git_repo` refuses to run Git and asks the user to select or create a dedicated subfolder instead.
### VaultEntry
The core data type representing a single note, defined in Rust (`src-tauri/src/vault/mod.rs`) and TypeScript (`src/types.ts`).
@@ -132,9 +145,28 @@ interface VaultEntry {
trashed: boolean // Kept for backward compatibility (Trash system removed — delete is permanent)
trashedAt: number | null // Kept for backward compatibility (Trash system removed)
properties: Record<string, string> // Scalar frontmatter fields (custom properties)
fileKind?: 'markdown' | 'text' | 'binary' // Controls editor/raw/preview behavior
}
```
### File kinds and binary previews
`VaultEntry.fileKind` comes from the Rust vault scanner and intentionally stays coarse-grained:
| `fileKind` | Source files | UI behavior |
|---|---|---|
| `markdown` or absent | `.md`, `.markdown` | Full Tolaria note model: frontmatter, BlockNote, raw editor, relationships, title sync |
| `text` | UTF-8 editable formats such as `.yml`, `.json`, `.ts`, `.py`, `.sh` | Opens through the raw editor without Markdown note semantics |
| `binary` | Images, PDFs, archives, other non-text files | Stays a normal vault file; previewable images and PDFs open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `<img>` and supported PDFs render through the webview's PDF object renderer, both backed by Tauri asset URLs. The "open in default app" action re-enters the vault command boundary through `open_vault_file_external` before delegating to the native opener, so preview and external-open access stay scoped to the active vault. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
### Note Content Freshness
The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. Before showing cached markdown or editor-ready blocks, `useTabManagement` validates the cached string with the `validate_note_content` Tauri command. That command re-enters the same vault path boundary checks as `get_note_content` and compares the cached text against the current on-disk file bytes. A mismatch, missing file, or unreadable file falls back to the normal fresh-read path and existing missing/unreadable recovery.
Prepared BlockNote blocks in `useEditorTabSwap` are keyed by path plus source content. They can be built ahead of time from prefetched markdown, but they are reused only when the validated raw content for that path is identical to the source content that produced the blocks.
### Entity Types (isA / type)
Entity type is stored in the `type:` frontmatter field (e.g. `type: Quarter`). The legacy field name `Is A:` is still accepted as an alias for backwards compatibility but new notes use `type:`. The `VaultEntry.isA` property in TypeScript/Rust holds the resolved value.
@@ -149,6 +181,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
```
@@ -242,6 +275,7 @@ Tolaria separates **display title** from the file identifier:
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions stage crash-safe note renames through a hidden `.tolaria-rename-txn/` transaction directory, recover unfinished renames on the next vault scan, update wikilinks across the vault, and surface any failed backlink rewrites instead of silently reporting partial success. The editor body remains the title editing surface.
- **Unicode-aware note stems** (`src/utils/noteSlug.ts`, `vault/rename.rs`): frontend and backend slugging preserve Unicode letters/digits in note filenames, untitled-rename detection, and fallback wikilink targets while still collapsing symbol-only titles to `untitled`.
- **Portable filename validation** (`vault/filename_rules.rs`): note filenames, folder names, and custom view filenames all reject Windows-reserved device names, invalid characters, and trailing dot/space suffixes so a vault created on macOS/Linux still clones and syncs cleanly on Windows.
- **Recoverable save failures** (`useEditorSave`, `vault/file.rs`): invalid platform path syntax is reported as a clear retryable save error, while the editor keeps the unsaved buffer intact for another attempt.
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
### Title Surface (UI)
@@ -270,12 +304,18 @@ type SidebarSelection =
`SidebarSelection.kind === 'folder'` is a first-class navigation target, not just a visual highlight.
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated.
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry.
- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands.
- 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 command-palette move intent into dense order updates before saving each affected view file through `save_view_cmd`. The sidebar keeps the saved View row visually unchanged: pointer users drag the row itself, and keyboard users use the command palette actions for the currently selected saved View.
### Neighborhood Mode
`SidebarSelection.kind === 'entity'` is Tolaria's Neighborhood mode for note-list browsing.
@@ -296,25 +336,30 @@ type SidebarSelection =
`vault::scan_vault(path)` in `src-tauri/src/vault/mod.rs`:
1. Validates the path exists and is a directory
2. Scans root-level `.md` files (non-recursive)
3. Recursively scans protected folders: `type/`, legacy `config/`, `attachments/`
4. Files in non-protected subfolders are **not indexed** (flat vault enforcement)
5. For each `.md` file, calls `parse_md_file()`:
2. Recursively scans non-hidden files while skipping hidden directories such as `.git/`
3. For each `.md` file, calls `parse_md_file()`:
- Reads content with `fs::read_to_string()`
- Parses frontmatter with `gray_matter::Matter::<YAML>`
- Extracts title from first `#` heading
- Reads entity type from `type:` frontmatter field (`Is A:` accepted as legacy alias); type is never inferred from folder
- Parses dates as ISO 8601 to Unix timestamps
- Extracts relationships, outgoing links, custom properties, word count, snippet
4. For recognized non-markdown text and binary files, emits a minimal `VaultEntry` with `fileKind`
5. Sorts by `modified_at` descending
6. Skips unparseable files with a warning log
The folder tree hides only the dedicated `type/` directory, since note types already have their own sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders.
6. Sorts by `modified_at` descending
7. Skips unparseable files with a warning log
Command-facing vault content is filtered through `vault::filter_gitignored_entries`, `vault::filter_gitignored_folders`, and `vault::filter_gitignored_paths` when the app setting `hide_gitignored_files` is enabled. The cache still stores the complete scan; `list_vault`, `reload_vault`, `list_vault_folders`, and search apply the visibility filter at the boundary before React consumes entries. The filter batches paths through `git check-ignore --no-index --stdin`, so negated and specific `.gitignore` patterns follow Git semantics as closely as the app can reasonably support.
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands refresh the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and 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. 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
`vault::scan_vault_cached(path)` wraps scanning with git-based caching:
@@ -417,6 +462,10 @@ interface PulseCommit {
- `pullAndPush()`: pulls then auto-pushes for divergence recovery
- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs)
### External Vault Refresh
External vault mutations are any disk writes Tolaria did not just perform through its own save path: Git pulls, AI-agent writes, filesystem watcher events, and edits from another app. These changes must route through `refreshPulledVaultState()` rather than calling `reloadVault()` in isolation. The shared refresh abstraction reloads entries, folders, and saved views together, preserves unsaved active-editor content, reopens a clean active note from disk, and closes the active tab if the file disappeared. `useVaultWatcher` supplies changed filesystem paths to this abstraction after debouncing and after filtering recent app-owned saves.
`useGitRemoteStatus` is the commit-time companion to `useAutoSync`:
- Re-checks `git_remote_status` when the Commit dialog opens and right before submit
- Converts `hasRemote: false` into a local-only commit path
@@ -482,6 +531,15 @@ Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and s
- `serializeMathAwareBlocks()` converts math nodes back to Markdown delimiters before save, raw-mode entry, and editor-position snapshots.
- Raw CodeMirror mode always shows the plain Markdown source, so imported technical notes stay editable outside Tolaria.
### Mermaid Diagrams
Defined in `src/utils/mermaidMarkdown.ts`, `src/components/MermaidDiagram.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
- Fenced `mermaid` blocks become `mermaidBlock` schema nodes before BlockNote sees the Markdown body.
- Each `mermaidBlock` stores the original fenced Markdown plus the diagram body, so raw-mode entry and saves can restore the canonical source instead of serializing generated SVG.
- The rich editor renders diagrams with the `mermaid` package and uses the original source as an inline fallback when rendering fails.
- `serializeMermaidAwareBlocks()` wraps the math-aware serializer so math, wikilinks, and diagrams share the same Markdown-first save path.
### Formatting Surface Policy
Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tolariaEditorFormattingConfig.ts`:
@@ -492,30 +550,33 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface.
- `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags.
- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription and duplicate-unlisten cleanup used by native drop features.
- `useNativePathDrop()` is the shared Tauri file/folder-drop abstraction for text inputs that need filesystem paths instead of attachment import. It consumes native window drag/drop events, gates them to the target element bounds or focused text selection, and lets AI composer / command-palette inputs insert formatted paths at the current cursor.
### Markdown-to-BlockNote Pipeline
```mermaid
flowchart LR
A["📄 Raw markdown\n(from disk)"] --> B["splitFrontmatter()\n→ yaml + body"]
B --> C["preProcessWikilinks(body)\n[[target]]token"]
C --> D["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
D --> E["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
E --> F["injectWikilinks + injectMathInBlocks\n tokens → schema nodes"]
F --> G["editor.replaceBlocks()\n→ rendered editor"]
B --> C["preProcessMermaidMarkdown(body)\nmermaid fence → token"]
C --> D["preProcessWikilinks(body)\n[[target]]token"]
D --> E["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
E --> F["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
F --> G["injectWikilinks + injectMathInBlocks + injectMermaidInBlocks\n tokens → schema nodes"]
G --> H["editor.replaceBlocks()\n→ rendered editor"]
style A fill:#f8f9fa,stroke:#6c757d,color:#000
style G fill:#d4edda,stroke:#28a745,color:#000
style H fill:#d4edda,stroke:#28a745,color:#000
```
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math placeholder tokens use ASCII sentinels with URI-encoded LaTeX payloads.
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math and Mermaid placeholder tokens use ASCII sentinels with URI-encoded payloads.
### BlockNote-to-Markdown Pipeline (Save)
```mermaid
flowchart LR
A["✏️ BlockNote blocks\n(editor state)"] --> B["blocksToMarkdownLossy()"]
B --> C["restoreWikilinks + serializeMathAwareBlocks()\nschema nodes → Markdown source"]
B --> C["restoreWikilinks + serializeMermaidAwareBlocks()\nschema nodes → Markdown source"]
C --> D["prepend frontmatter yaml"]
D --> E["invoke('save_note_content')\n→ disk write"]
@@ -537,6 +598,8 @@ Wikilink resolution (`resolveEntry` in `src/utils/wikilink.ts`) uses multi-pass
Toggle via Cmd+K → "Raw Editor" or breadcrumb bar button. Uses CodeMirror 6 (`useCodeMirror` hook) to edit the raw markdown + frontmatter directly. Changes saved via the same `save_note_content` command.
While the user types, `useEditorSaveWithLinks` derives a transient `VaultEntry` patch from parseable frontmatter so the Inspector, relationship chips, and note-list-visible metadata stay in sync with the raw editor before the next vault reload. Temporarily invalid or half-typed frontmatter is ignored until it becomes parseable again, which avoids clobbering the last known good derived state.
Current-note find/replace is intentionally backed by raw CodeMirror mode. `Cmd+F`, "Find in Note", and "Replace in Note" switch the active Markdown/text note to raw mode, show the compact find bar above CodeMirror, and operate on the current note only. Plain text matching is case-insensitive by default, `Aa` toggles case sensitivity, `.*` toggles JavaScript-regex matching, and regex replacement supports capture groups through JavaScript replacement syntax.
### Arrow Ligature Normalization
Typed ASCII arrow sequences are normalized consistently in both editor modes:
@@ -552,15 +615,17 @@ 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
App UI strings are centralized in `src/lib/i18n.ts` (see [ADR-0084](adr/0084-app-localization-foundation.md)):
App UI strings are resolved through `src/lib/i18n.ts`, with flat JSON catalogs in `src/lib/locales/*.json` (see [ADR-0087](adr/0087-json-catalogs-and-lara-cli-localization.md)):
- `AppLocale`: currently `'en' | 'zh-Hans'`
- `AppLocale`: canonical locale tags such as `'en'`, `'zh-CN'`, `'fr-FR'`, `'es-419'`
- `UiLanguagePreference`: `'system' | AppLocale`; persisted settings serialize `system` as `null`
- `resolveEffectiveLocale()`: maps an explicit preference or system/browser language list to the effective supported locale
- `resolveEffectiveLocale()`: maps an explicit preference or system/browser language list to the effective supported locale, including legacy aliases
- `translate()` / `createTranslator()`: resolve keys with English fallback and simple `{name}` interpolation
- `scripts/validate-locales.mjs`: asserts every checked-in locale catalog matches the English keyset and stays flat-string-only
`App.tsx` owns the effective locale and passes it to localized app chrome through props. Settings and command-palette language commands call back into `saveSettings`, so UI language changes update the current session without touching vault content or reopening the vault.
@@ -584,7 +649,7 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
### Search
Keyword-based search scans all vault `.md` files using `walkdir`:
Keyword-based search scans all vault `.md` files using `walkdir` and applies the same Gitignored-content visibility filter as vault loading:
```typescript
interface SearchResult {
@@ -618,7 +683,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
@@ -626,8 +692,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
@@ -646,7 +713,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
@@ -674,12 +741,13 @@ interface Settings {
anonymous_id: string | null
release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed
theme_mode: 'light' | 'dark' | null
ui_language: 'en' | 'zh-Hans' | null
default_ai_agent: 'claude_code' | 'codex' | null
ui_language: AppLocale | null
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | null
hide_gitignored_files: boolean | null // null = default true
}
```
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. `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. 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
@@ -691,9 +759,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/key from `VITE_SENTRY_DSN` / `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.
@@ -719,6 +787,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.
- **`.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.
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -24,6 +24,7 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
| Pinned properties per type | API keys (OpenAI, Google) |
| Sidebar label overrides | Auto-sync interval |
| Property display order | Window size / position |
| Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files |
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.tolaria.app/settings.json` or localStorage.
@@ -32,7 +33,7 @@ Examples:
- ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties)
- ✅ Vault: `_icon: shapes` in a Type note (icon is part of the type's identity)
- ✅ App settings: `zoom: 1.3` (machine-specific preference)
- ✅ App settings: `ui_language: "zh-Hans"` (installation-specific UI language)
- ✅ App settings: `ui_language: "zh-CN"` (installation-specific UI language)
### No hardcoded exceptions
@@ -82,6 +83,17 @@ flowchart LR
3. **No orphan state updates**: Never call `updateEntry()` before the corresponding `handleUpdateFrontmatter()` or `handleDeleteProperty()` has resolved. The three functions in `useEntryActions` (`handleCustomizeType`, `handleRenameSection`, `handleToggleTypeVisibility`) follow this rule — disk write first, then state update.
4. **Recovery via reload**: If state ever diverges from disk (crash, external edit, race condition), `Reload Vault` (Cmd+K → "Reload Vault") invalidates the cache and does a full filesystem rescan via the `reload_vault` Tauri command, replacing all React state. The `reload_vault_entry` command can re-read a single file.
5. **Cache is disposable**: The `reload_vault` command deletes the cache file before rescanning, guaranteeing fresh data. The cache never contains data that doesn't exist on the filesystem.
6. **Visibility filters are command-boundary concerns**: Gitignored-content visibility is applied after scanning/caching, before entries, folders, or search results reach React. The cache remains complete so toggling the setting can show ignored content again without rebuilding a different cache shape.
#### External Change Detection
The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and the clean active editor all refresh under the ADR-0071 unsaved-edit rules. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads.
#### Note Opening Fast Path
Note opening uses a bounded in-memory fast path split across raw content and editor-ready blocks. `useTabManagement` owns the raw markdown prefetch cache and `useEditorTabSwap` owns the prepared BlockNote block cache. Cached or preloaded markdown is never rendered directly: before reusing it, the renderer calls the `validate_note_content` Tauri command, which compares the cached text with the current file bytes inside the validated vault boundary. If validation fails, Tolaria discards the cached entry and reads fresh disk content before swapping the editor.
The note list opportunistically preloads visible and adjacent markdown/text entries after a short idle delay. Once raw content resolves, the editor prepares BlockNote blocks in the background when it is mounted and not in raw mode. This targets the expensive markdown-to-editor conversion stage while keeping filesystem content authoritative and keeping preload memory bounded by the same cache limits as ordinary note switches.
## Tech Stack
@@ -91,6 +103,7 @@ flowchart LR
| Frontend | React + TypeScript | React 19, TS 5.9 |
| Editor | BlockNote | 0.46.2 |
| Code block highlighting | @blocknote/code-block | 0.46.2 |
| Diagram rendering | Mermaid | 11.14.0 |
| Raw editor | CodeMirror 6 | - |
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
| UI primitives | Radix UI + shadcn/ui | - |
@@ -98,9 +111,10 @@ flowchart LR
| Build | Vite | 7.3.1 |
| Backend language | Rust (edition 2021) | 1.77.2 |
| Frontmatter parsing | gray_matter | 0.2 |
| AI (agent panel) | CLI agent adapters (Claude Code + Codex) | - |
| Filesystem watcher | notify | 6.1 |
| AI (agent panel) | CLI agent adapters (Claude Code + Codex + OpenCode + Pi) | - |
| Search | Keyword (walkdir-based file scan) | - |
| Localization | App-owned dictionary (`src/lib/i18n.ts`) | English fallback + `zh-Hans` |
| Localization | App-owned runtime + JSON catalogs (`src/lib/i18n.ts`, `src/lib/locales/*.json`, `lara.yaml`) | English fallback + Lara CLI sync |
| MCP | @modelcontextprotocol/sdk | 1.0 |
| Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - |
| Package manager | pnpm | - |
@@ -137,7 +151,7 @@ flowchart TD
end
subgraph EXT["External Services"]
CCLI["Claude CLI / Codex CLI\n(agent subprocesses)"]
CCLI["Claude / Codex / OpenCode / Pi CLI\n(agent subprocesses)"]
MCP["MCP Server\n(ws://9710, 9711)"]
GCLI["git CLI\n(system executable)"]
REMOTE["Git remotes\n(GitHub/GitLab/Gitea/etc.)"]
@@ -178,15 +192,17 @@ 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, 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. 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, 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. Decomposed into `Editor` (orchestrator), `EditorContent`, `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.
- **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 existing view row, while keyboard users can use 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 and PDF binaries get file indicators 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 and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns 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).
Panels are separated by `ResizeHandle` components that support drag-to-resize.
The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds sidebar / note-list / expanded-inspector allowances on top, and calls the native `update_current_window_min_size` command whenever view mode or inspector visibility changes. That same native command also grows the current window back out when a wider pane combination is restored, while note windows skip this path and keep their dedicated 800×700 initial sizing.
The main Tauri window also persists its last normal size and screen position in the app config directory as `window-state.json`. The state stores logical window points, while `window_state.rs` migrates older physical-pixel state on read so Retina and non-Retina launches restore the same user-facing bounds. On startup, the restored frame applies only to the main window and clamps to the currently available monitor work areas, so stale coordinates from a disconnected display fall back to a visible placement. Maximized, fullscreen, minimized, and detached note-window frames are not written as the restore baseline.
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that macOS uses for native menu clicks.
When Tolaria is launched from a Linux AppImage, `run()` also injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` unless the user already set that variable. This keeps the workaround scoped to bundled WebKitGTK launches that are prone to Fedora/Wayland DMA-BUF crashes without changing native package installs.
@@ -214,12 +230,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` + `aiAgents.ts`) — streaming UI with 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`; Codex runs through `codex exec --json` with the CLI's normal approval / sandbox defaults
4. **MCP Integration** — Claude receives the generated MCP config file path, while Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides
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. Gemini runs through `gemini --output-format json --prompt` with Safe using `auto_edit` plus `tools.exclude=["run_shell_command"]` and Power User using `yolo` against a trusted transient Tolaria MCP entry. OpenCode, Pi, and Gemini all 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`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
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
@@ -233,12 +250,12 @@ sequenceDiagram
U->>FE: sendMessage(text, references)
FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs)
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath})
R->>R: pick adapter for claude_code or codex
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
loop Normalized stream
C-->>R: Claude NDJSON or Codex JSONL events
C-->>R: Claude NDJSON, Codex JSONL, OpenCode JSON, or Pi JSON events
R-->>FE: emit("ai-agent-stream", event)
alt TextDelta
FE->>FE: accumulate response (revealed on Done)
@@ -260,7 +277,7 @@ sequenceDiagram
#### File Operation Detection
When the agent writes or edits vault files, `useCliAiAgent` detects this from normalized tool inputs and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload.
When the agent writes or edits vault files, `aiAgentFileOperations.ts` detects this from normalized tool inputs and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload. Unrecognized write-like operations fall back to a full vault refresh.
### Context Building
@@ -280,11 +297,11 @@ Token budget: 60% of 180k context limit (~108k tokens max). Active note gets pri
### Authentication
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed. Tolaria does not store model-provider API keys in app settings.
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed; Pi surfaces a friendly prompt to run `pi /login` or configure a provider API key when needed. Tolaria does not store model-provider API keys in app settings.
## 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)
@@ -316,16 +333,17 @@ 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. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`).
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 writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. 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
```mermaid
flowchart TD
subgraph MCP["MCP Server (Node.js) — spawned by Tauri on startup"]
subgraph MCP["MCP Server (Node.js) — selected-vault scoped"]
IDX["index.js"]
VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"]
WSB["ws-bridge.js"]
@@ -337,7 +355,7 @@ flowchart TD
WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"]
end
TAURI["Tauri (mcp.rs)"] -->|"spawn on startup"| MCP
TAURI["Tauri bridge lifecycle"] -->|"start/stop/restart with active VAULT_PATH"| MCP
UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude.json\n~/.claude/mcp.json\n~/.cursor/mcp.json\n~/.config/mcp/mcp.json"]
```
@@ -365,11 +383,13 @@ flowchart LR
| Function | Purpose |
|----------|---------|
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code, Cursor, and generic MCP configs on explicit user request |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Cursor, and generic MCP configs |
| `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, 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 killed on app exit via `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
@@ -414,11 +434,11 @@ 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
Tolaria's app chrome uses an app-owned localization layer in `src/lib/i18n.ts` (see [ADR-0084](adr/0084-app-localization-foundation.md)). English is the canonical fallback, and Simplified Chinese (`zh-Hans`) is the first additional locale. The installation-local `ui_language` setting stores an explicit locale when the user chooses one; `null` means "follow the system language when Tolaria supports it, otherwise English." Missing translation keys fall back to English so partially translated locales do not render broken placeholders.
Tolaria's app chrome uses an app-owned localization runtime in `src/lib/i18n.ts`, backed by flat JSON catalogs in `src/lib/locales/` and Lara CLI synchronization through `lara.yaml` (see [ADR-0087](adr/0087-json-catalogs-and-lara-cli-localization.md)). `en.json` is the canonical source catalog, locale files are one file per locale, and English remains the fallback for any missing locale file or key. The installation-local `ui_language` setting stores an explicit locale when the user chooses one; `null` means "follow the system language when Tolaria supports it, otherwise English." Legacy stored values such as `zh-Hans` are normalized to canonical locale codes like `zh-CN`.
`App.tsx` derives the effective locale from settings and browser/system language hints, then passes it down to localized surfaces. Settings exposes a keyboard-accessible shadcn `Select`, and the command palette includes actions to open language settings or switch directly to a supported language.
@@ -454,16 +474,18 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
On first launch, `useOnboarding` checks if the default vault exists. If not, it shows `WelcomeScreen` with three options:
- **Create a new vault** → creates an empty git repo in a folder the user chooses
- **Open an existing folder** → system file picker
- **Open an existing folder** → system file picker; plain Markdown folders without `.git` open immediately in supported non-git mode
- **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately
When an opened folder is not yet a git repo, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action.
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup and remote-connection commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.md>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, OpenCode, Pi, and Gemini CLI are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
`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.
@@ -581,7 +603,9 @@ flowchart TD
`useGitRemoteStatus` re-checks `git_remote_status` when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps the flow local-only: the status bar shows a neutral `No remote` chip, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted.
The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds.
If the current vault is not a Git repository, Tolaria treats Git as disabled instead of degraded. The status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup. Command registration follows the same state: only `Initialize Git for Current Vault` is available in the Git group, while pull, commit, changes, conflict, and remote commands are hidden. `useAutoSync` is disabled for non-git vaults so the app does not run background Git commands against plain folders.
The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command ensures the local author identity, adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds.
`useCommitFlow` also exposes `runAutomaticCheckpoint()`, a dialog-free commit path shared by AutoGit and the bottom-bar Commit button. `useAutoGit` watches the last editor activity plus app focus/visibility state, and when the vault is git-backed, all saves are flushed, and no unsaved edits remain, it triggers the same deterministic `Updated N note(s)` / `Updated N file(s)` commit message path after the configured idle or inactive thresholds. The bottom-bar quick action reuses that checkpoint flow after forcing a save first, so manual quick commits and scheduled AutoGit commits stay aligned on message generation and push behavior.
@@ -605,11 +629,12 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` |
| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow |
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
| `ignored.rs` | Gitignored-content visibility filtering via batched `git check-ignore` |
| `filename_rules.rs` | Cross-platform validation for note filenames, folder names, and custom view filenames |
| `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
@@ -619,9 +644,11 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `vault/` | Vault scanning, caching, parsing, rename, image, migration |
| `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 |
| `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch |
| `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing |
| `search.rs` | Keyword search — walkdir-based vault file scan with Gitignored-content visibility filtering |
| `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) |
| `settings.rs` | App settings persistence |
@@ -635,7 +662,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| Command | Description |
|---------|-------------|
| `list_vault` | Scan vault (cached) → `Vec<VaultEntry>` |
| `list_vault` | Scan vault (cached), then apply Gitignored-content visibility`Vec<VaultEntry>` |
| `get_note_content` | Read note file content |
| `save_note_content` | Write note content to disk |
| `delete_note` | Permanently delete note from disk (with confirm dialog) |
@@ -647,12 +674,14 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `sync_note_title` | Legacy helper: rewrite `title` frontmatter from filename → `bool` (modified); not used by the normal note-open flow |
| `batch_archive_notes` | Archive multiple notes |
| `batch_delete_notes` | Permanently delete notes from disk |
| `reload_vault` | Sync the active vault asset scope, invalidate cache, and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault` | Sync the active vault asset scope, invalidate cache, full rescan from filesystem, then apply Gitignored-content visibility`Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `open_vault_file_external` | Validate an existing file against the active vault boundary, then open it with the system default app |
| `start_vault_watcher` / `stop_vault_watcher` | Start or stop native active-vault filesystem change events |
| `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
@@ -703,13 +732,14 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| Command | Description |
|---------|-------------|
| `stream_claude_chat` | Claude CLI chat mode (streaming) |
| `stream_claude_agent` | Claude CLI agent mode (streaming + tools) |
| `check_claude_cli` | Check if Claude CLI is available |
| `get_ai_agents_status` | Check Claude Code + Codex availability |
| `stream_ai_agent` | Stream the selected CLI agent through the normalized 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 |
| `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/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.
@@ -761,18 +791,20 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
| `useNoteRename` | — | Note renaming and folder moves with wikilink update |
| `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions |
| `useTauriDragDropEvent` | — | Shared native window drag/drop event subscription and cleanup |
| `useNativePathDrop` | — | Tauri-native file/folder path drops for AI and command text inputs |
| `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch |
| `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 |
| `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) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
| `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, 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`.
@@ -785,20 +817,23 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
| Cmd+P / Cmd+O | Open quick open palette |
| Cmd+N | Create new note |
| Cmd+S | Save current note |
| Cmd+F | Find in current note when the editor is focused; otherwise note-list search can claim it |
| Cmd+Shift+F | Find in vault |
| Cmd+[ / Cmd+] | Navigate back / forward (replaces tabs) |
| Cmd+Z / Cmd+Shift+Z | Undo / Redo |
| Cmd+19 | Switch to tab N |
| Cmd+[ / Cmd+] | Navigate back / forward |
| `[[` in editor | Open wikilink suggestion menu |
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Rename Folder" and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Shortcut routing is explicit:
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata
- `formatShortcutDisplay()` derives platform-accurate visible shortcut labels (`⌘` on macOS, `Ctrl` on Windows/Linux) from that same manifest so menus, tooltips, and command-palette copy stay aligned with real accelerators
- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs
- macOS browser-reserved chords such as `Cmd+O` and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same command IDs for native menu clicks, accelerators, and custom titlebar menu actions
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
- Deterministic QA uses two explicit proof paths from the shared manifest:
@@ -821,7 +856,7 @@ push to main
→ build job:
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin --bundles app
→ pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin --bundles app
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig updater artifacts
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig updater artifacts named Tolaria_<version>_macOS_Silicon and Tolaria_<version>_macOS_Intel
→ build-windows job:
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
@@ -844,7 +879,7 @@ push stable-vYYYY.M.D tag
→ build job:
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin
→ pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig and .dmg artifacts
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig and .dmg artifacts named Tolaria_<version>_macOS_Silicon and Tolaria_<version>_macOS_Intel
→ build-linux job:
→ pnpm install, stamp version, tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
→ upload .deb, .AppImage, and signed Linux updater bundles
@@ -897,7 +932,7 @@ sequenceDiagram
App->>User: TelemetryConsentDialog
alt Accept
User->>Settings: telemetry_consent=true, anonymous_id=UUID
Settings->>Sentry: init(DSN, anonymous_id)
Settings->>Sentry: init(DSN, release, anonymous_id)
Settings->>PostHog: init(key, anonymous_id)
else Decline
User->>Settings: telemetry_consent=false
@@ -919,6 +954,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, 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`
@@ -973,7 +1009,7 @@ Desktop-only modules gated at the crate level:
Desktop-only features gated at the function level in `commands/`:
- Git operations (commit, pull, push, status, history, diff, conflicts)
- Clone-by-URL via system git (`clone_repo`)
- CLI AI agent streaming (Claude, Codex)
- CLI AI agent streaming (Claude, Codex, OpenCode, Pi)
- MCP registration and status
- Menu state updates

View File

@@ -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
@@ -106,16 +106,16 @@ tolaria/
│ │ └── ui/ # shadcn/ui primitives
│ │ ├── button.tsx, dialog.tsx, input.tsx, ...
│ │
│ ├── hooks/ # Custom React hooks (~87 files)
│ ├── hooks/ # Custom React hooks (~86 files)
│ │ ├── useVaultLoader.ts # Loads vault entries + content
│ │ ├── useVaultSwitcher.ts # Multi-vault management
│ │ ├── useVaultConfig.ts # Per-vault UI settings
│ │ ├── useNoteActions.ts # Composes creation + rename + frontmatter
│ │ ├── useNoteCreation.ts # Note/type creation
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
│ │ ├── useAiAgent.ts # Legacy Claude-specific stream helpers reused by the shared agent hook
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized tool tracking
│ │ ├── useAiAgentsStatus.ts # Claude/Codex availability polling
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline
│ │ ├── aiAgentPermissionMode.ts # Safe/Power User mode normalization + labels
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi/Gemini availability polling
│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling
│ │ ├── useAiActivity.ts # MCP UI bridge listener
│ │ ├── useAutoSync.ts # Auto git pull/push
@@ -157,7 +157,8 @@ tolaria/
│ ├── lib/
│ │ ├── aiAgents.ts # Shared agent registry + status helpers
│ │ ├── appUpdater.ts # Frontend wrapper around channel-aware updater commands
│ │ ├── i18n.ts # App-owned localization dictionary and locale resolution
│ │ ├── i18n.ts # App-owned localization runtime and locale resolution
│ │ ├── locales/ # JSON locale catalogs (English source + translated locales)
│ │ ├── releaseChannel.ts # Alpha/stable normalization helpers
│ │ └── utils.ts # Tailwind merge + cn() helper
│ │
@@ -188,8 +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
│ │ ├── 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
@@ -212,6 +216,7 @@ tolaria/
├── scripts/ # Build/utility scripts
├── package.json # Frontend dependencies + scripts
├── lara.yaml # Lara CLI locale sync configuration
├── vite.config.ts # Vite bundler config
├── tsconfig.json # TypeScript config
├── playwright.config.ts # Full Playwright regression config
@@ -259,8 +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, safe-default Codex adapter, and stream normalization. |
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
| `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`, `src-tauri/src/gemini_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
@@ -279,8 +285,11 @@ tolaria/
| File | Why it matters |
|------|---------------|
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, and actions. |
| `src/hooks/useCliAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. |
| `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. |
@@ -299,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. |
@@ -336,10 +345,12 @@ 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.
Current-note find/replace is a surface-aware command: editor focus enables "Find in Note" / "Replace in Note" and routes Cmd+F into raw CodeMirror mode; note-list focus enables existing note-list search instead. When adding another focus-dependent command, mirror this pattern with an availability event consumed by `useMenuEvents.ts` and `update_menu_state`.
For automated shortcut QA, use the explicit proof path from `appCommandCatalog.ts`:
- `window.__laputaTest.triggerShortcutCommand()` for deterministic renderer shortcut-event coverage
@@ -413,5 +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()`)
5. **Shared agent adapters / Codex args**: Edit `src-tauri/src/ai_agents.rs` (keep Codex on the normal approval/sandbox path unless you are intentionally designing an advanced 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_*`, `gemini_*`). Keep Codex sandboxed with active-vault `workspace-write`, keep Pi and Gemini 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/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`; app-managed Gemini sessions still require the user to install and sign in to Gemini CLI, but Tolaria supplies transient MCP settings when Gemini is selected as the default AI agent
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

View File

@@ -0,0 +1,39 @@
---
type: ADR
id: "0085"
title: "Non-git vaults open with explicit later Git initialization"
status: active
date: 2026-04-26
supersedes: "0034"
---
## Context
ADR-0034 made Git a hard prerequisite for opening a vault because Git-backed cache, history, change, and sync flows failed invisibly when users opened plain Markdown folders. That protected Git features, but it blocked the common adoption path of opening an existing folder from Obsidian, iCloud, Dropbox, or a manually maintained notes directory.
Tolaria now needs the opposite default: browsing and editing Markdown should work immediately, while Git remains an explicit capability users can enable when they want history, sync, commits, or collaboration.
## Decision
**Open existing Markdown folders even when they are not Git repositories.** A non-git vault is a supported state, not an error state. On open, Tolaria asks whether to initialize Git; if the user dismisses the prompt, the app keeps working and the status bar permanently shows a `Git disabled` warning. Clicking that warning, or running `Initialize Git for Current Vault` from the command palette, reopens the setup action.
While a vault is not Git-backed:
- Git history, change, commit, sync, conflict, and remote actions are hidden or disabled.
- Background auto-sync and AutoGit checkpoints do not run.
- Markdown scanning, note browsing, note editing, search, and non-Git vault features continue normally.
`init_git_repo` remains the single backend command for enabling Git later. It creates the repository, writes Tolaria's default `.gitignore`, stages the vault, and creates the unsigned setup commit.
## Options considered
- **Option A (chosen): Supported non-git mode with explicit later initialization.** Best adoption path; keeps Git capabilities visible without blocking the basic notes workflow.
- **Option B: Keep the ADR-0034 blocking modal.** Prevents Git feature ambiguity, but rejects valid plain-folder workflows.
- **Option C: Auto-initialize Git when opening a plain folder.** Low friction, but surprising for users who do not want Tolaria to mutate folder metadata.
## Consequences
- Existing Git-backed vaults keep the same history, commit, sync, and remote behavior.
- UI surfaces must treat Git capability as stateful per vault, not as an app-wide invariant.
- Tests need to cover both Git-backed and non-git vaults in browser mocks and native QA.
- Future Git-dependent features must check the current vault's Git state before registering commands or running background work.

View File

@@ -0,0 +1,32 @@
---
type: ADR
id: "0086"
title: "In-app image previews for binary vault files"
status: active
date: 2026-04-26
supersedes: "0041"
---
## Context
ADR-0041 made the vault scanner index all visible files and introduced `fileKind` as `"markdown"`, `"text"`, or `"binary"`. Binary files were deliberately shown as inert entries until Tolaria had a dedicated preview model.
That made image references visible in folder views, but opening an image still felt outside the normal Tolaria workflow. Users need to inspect screenshots, diagrams, and other image assets while keeping their place in the vault.
## Decision
**Tolaria previews supported image files in the editor pane while keeping them as ordinary binary `VaultEntry` files.**
- The scanner keeps the existing `fileKind: "binary"` representation. Image previewability is inferred in the renderer from the file extension, not by introducing a proprietary image document type.
- Opening a binary entry creates the same single active-tab state used for notes, but with empty content and no `get_note_content` text read.
- `FilePreview` renders supported image extensions through Tauri's asset protocol (`convertFileSrc`) so the original file remains on disk.
- Broken images and unsupported binary files render an explicit fallback state with an intentional "Open in default app" action instead of launching another app automatically.
- Note-list rows use an image indicator for previewable image binaries. Unsupported binary rows remain muted and non-clickable in the normal list surface.
- The preview surface is keyboard focusable and `Escape` returns focus to the note list, matching the app's keyboard-first navigation model.
## Consequences
- The existing `VaultEntry` model and cache version do not need to change.
- Supported image files can participate in normal selection/navigation context without being converted into Markdown notes.
- Unsupported/broken binary files have a clear in-app state when reached through navigation paths that can select them.
- Any future PDF, audio, or video preview should extend the same file-preview renderer rather than adding new vault-owned document representations.

View File

@@ -0,0 +1,39 @@
---
type: ADR
id: "0087"
title: "JSON locale catalogs with Lara CLI synchronization"
status: active
date: 2026-04-27
supersedes:
- "0084"
---
## Context
ADR-0084 established an app-owned localization layer in `src/lib/i18n.ts` with English fallback and hand-maintained TypeScript dictionaries. That was enough for the first localized UI surface, but it does not scale well to a broader locale matrix or machine-assisted translation workflows.
We now want Tolaria to support a wider set of locales and to automate translation updates with Lara CLI while keeping the runtime dependency-light and preserving the existing English fallback behavior.
## Decision
Tolaria will keep its app-owned runtime localization layer, but the translation source-of-truth moves to flat JSON catalogs in `src/lib/locales/`.
- `src/lib/locales/en.json` is the canonical source catalog.
- Additional locale files use one JSON file per locale code (for example `zh-CN.json`, `fr-FR.json`).
- `src/lib/i18n.ts` keeps fallback, interpolation, locale resolution, and props-down locale wiring, but it now loads locale catalogs from JSON files instead of TypeScript objects.
- Lara CLI configuration lives in `lara.yaml`, and translation runs happen through repo scripts (`pnpm l10n:translate`, `pnpm l10n:translate:force`).
- `scripts/validate-locales.mjs` verifies that every locale catalog present in the repo matches the English keyset and only contains flat string values.
- Legacy stored preferences such as `zh-Hans` are normalized to the canonical `zh-CN` locale.
## Alternatives considered
- **Keep TypeScript dictionaries and point Lara at `.ts` files**: possible, but JSON is the more standard interchange format for translation tooling and keeps diffs simpler for translators and reviewers.
- **Adopt a full frontend i18n framework now**: rejected because Tolaria already has working locale propagation and fallback behavior, and the immediate need is better content management plus translation automation.
- **Store translated strings outside the app repo**: rejected because Tolaria's chrome localization should stay versioned with the app code that consumes it.
## Consequences
- Translators and automation tools now work against plain JSON catalogs instead of editing source code.
- The runtime keeps English fallback behavior, so a missing locale file or missing key does not break app chrome.
- Locale additions become a data/config change first: add the locale metadata, run Lara, review JSON output, then ship.
- Localization work now has a dedicated validation step that can run in CI or before commit.

View File

@@ -0,0 +1,40 @@
---
type: ADR
id: "0088"
title: "Markdown-durable Mermaid diagrams in notes"
status: active
date: 2026-04-27
---
## Context
Tolaria notes are plain Markdown files, while the rich editor uses BlockNote and raw mode uses CodeMirror. Users need fenced `mermaid` blocks to render as diagrams in the note surface without changing the canonical file format or hiding the source from raw editing.
BlockNote can parse fenced code blocks, but a generic highlighted code block does not provide diagram rendering. Rendering Mermaid directly from the Markdown fence also has to preserve the original fence source when notes are saved, copied through raw mode, closed, and reopened.
## Decision
**Tolaria will support Mermaid diagrams through a Markdown placeholder round-trip owned by the editor pipeline and rendered with the `mermaid` package.**
The implementation:
- Converts fenced `mermaid` blocks to temporary placeholders before BlockNote parses Markdown.
- Replaces placeholders with a `mermaidBlock` schema block that stores both the original fenced source and the diagram body.
- Renders the block through Mermaid in the rich editor.
- Serializes `mermaidBlock` nodes back to their stored fenced Markdown before save, raw-mode entry, and editor-position snapshots.
- Shows the original source as an inline fallback when Mermaid cannot render a diagram.
## Options considered
- **Tolaria-owned placeholder round-trip with Mermaid rendering** (chosen): matches the existing wikilink and math architecture, keeps Markdown as the source of truth, and gives Tolaria explicit control over serialization.
- **Render all `mermaid` code blocks by overriding the generic code-block renderer**: smaller surface, but it couples diagram behavior to the code-highlighting package and makes exact source preservation harder.
- **Raw-mode-only Mermaid support**: preserves source but fails the enhanced note reading experience users expect.
- **Store parsed diagram metadata outside the Markdown body**: enables richer future editing, but violates the files-first model.
## Consequences
- `src/utils/mermaidMarkdown.ts` is the canonical parser/serializer bridge for note diagrams.
- Rich mode renders diagrams as schema-backed blocks; raw mode remains the direct source editor.
- Invalid Mermaid source remains visible instead of breaking the editor surface.
- `mermaid` is now a runtime dependency and should be upgraded deliberately with rendering regression coverage.
- Future diagram controls, such as copy source or expand, can attach to the same `mermaidBlock` without changing storage.

View File

@@ -0,0 +1,36 @@
---
type: ADR
id: "0089"
title: "Active vault filesystem watcher"
status: active
date: 2026-04-27
---
## Context
Tolaria treats the filesystem as the source of truth, but before this decision the running app only noticed external file changes after a manual Reload Vault, a Git pull, or an AI-agent-specific refresh callback. Edits from another editor, terminal commands, another Tolaria window, or a non-pull Git operation could leave React state and the editor surface stale.
ADR-0071 already defines the safe reconciliation policy for external vault mutations: reload vault-derived state, protect unsaved local edits, and reopen the clean active note from disk when needed. Filesystem watching needed to reuse that policy instead of adding another ad hoc reload path.
## Decision
**Tolaria watches the active desktop vault with a native filesystem watcher and routes external change batches through the shared external-refresh reconciler.**
The desktop backend exposes `start_vault_watcher` and `stop_vault_watcher` commands backed by Rust `notify`. It watches the active vault recursively, ignores known non-content churn such as `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`, then emits `vault-changed` events with the active vault path and changed paths.
The renderer owns batching and reconciliation. `useVaultWatcher` starts the backend watcher for the active main-window vault, debounces native events into one refresh, filters out recent app-owned saves, and calls `refreshPulledVaultState()`. Manual Reload Vault still uses `reload_vault` directly, but now exposes visible reload feedback.
## Options considered
- **Native active-vault watcher plus shared reconciliation** (chosen): keeps external changes visible without polling and preserves ADR-0071 behavior for clean and unsaved tabs. Cons: adds one native dependency and a long-lived watcher state.
- **Frontend-only polling**: simpler backend surface, but wastes work on idle vaults and still needs careful active-note reconciliation.
- **Direct `reloadVault()` on every native event**: easy to implement, but bypasses clean-tab reopen handling and can clobber the user experience around unsaved edits.
- **Watch every configured vault**: could pre-warm state, but burns resources for inactive vaults and complicates event ownership across windows.
## Consequences
- External writes converge automatically into the visible vault state after a short debounce.
- Active clean notes are refreshed through the same path as pull and AI-agent updates; unsaved local edits remain protected.
- Tolaria app-owned saves are suppressed briefly so autosave does not trigger a full external refresh loop.
- The status bar can show reload progress for manual and automatic refreshes.
- The watcher is a desktop-only integration; mobile builds keep no-op command stubs until a mobile-specific filesystem strategy exists.

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0090"
title: "Pi CLI agent adapter"
status: active
date: 2026-04-28
---
## Context
Tolaria already supports Claude Code, Codex, and OpenCode as local CLI agents in the AI panel. The next provider request is Pi Coding Agent support with the same first-class availability, settings, status, streaming, and MCP vault access.
Pi exposes non-interactive JSON events through `pi --mode json`, but Pi core intentionally does not include built-in MCP. Its supported MCP path is the `pi-mcp-adapter` extension, which reads MCP server definitions from Pi-compatible config files.
## Decision
Tolaria adds Pi as a supported CLI agent id (`pi`) and launches app-managed Pi sessions through a dedicated adapter module.
The adapter runs `pi --mode json --no-session` from the active vault cwd, closes stdin, and points `PI_CODING_AGENT_DIR` at a temporary directory containing Tolaria's `mcp.json`. That config loads the Tolaria MCP server through `pi-mcp-adapter`, pins `VAULT_PATH` to the selected vault, sets `WS_UI_PORT=9711`, uses lazy server lifecycle, and exposes the small Tolaria tool set directly.
Pi availability follows the existing desktop pattern: check the inherited `PATH`, the user's login shell, and common local/toolchain install locations. Pi authentication remains owned by the Pi CLI; Tolaria only surfaces setup errors and does not store provider API keys.
## Options Considered
- **Transient Pi adapter config** (chosen): gives app-launched Pi sessions Tolaria MCP access without mutating user or vault config files. Cons: requires the Pi MCP adapter extension path to be available to Pi.
- **Write `.mcp.json` into the active vault**: simple for Pi discovery, but creates project files as a side effect of a chat session and can dirty user vaults.
- **Rely on global `~/.pi/agent/mcp.json`**: matches Pi documentation, but silently retargets or depends on user-global state and conflicts with Tolaria's explicit integration boundary.
- **Skip MCP for Pi**: easier to implement, but creates a weaker agent than the existing providers and violates the requirement for first-class MCP support.
## Consequences
- The AI panel, settings, command palette, status bar, onboarding, and stream normalization now treat Pi as a first-class supported agent.
- App-launched Pi sessions can use Tolaria vault MCP tools while remaining scoped to the selected vault.
- Pi support stays isolated in Pi-specific modules instead of expanding the shared `ai_agents.rs` hotspot.
- Users still need Pi itself, a configured Pi model/provider, and the Pi MCP adapter extension path available to the Pi CLI.

View File

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

View File

@@ -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.

View 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.

View File

@@ -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.

View 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.

View File

@@ -0,0 +1,42 @@
---
type: ADR
id: "0097"
title: "Gemini CLI agent adapter"
status: active
date: 2026-04-29
---
## Context
ADR 0091 added Gemini CLI to explicit external MCP setup, but Gemini was still absent from Tolaria's selectable app-managed AI agents. That left the AI panel able to generate Gemini-compatible MCP configuration while the actual agent picker, availability checks, install links, and streaming dispatch did not treat Gemini like Claude Code, Codex, OpenCode, or Pi.
Gemini CLI supports headless `--prompt` execution with JSON output, configurable approval modes, tool exclusion, and settings-file overrides through `GEMINI_CLI_SYSTEM_SETTINGS_PATH`. Those features are enough to launch Gemini from Tolaria without mutating the user's durable `~/.gemini/settings.json` during app-managed sessions.
## Decision
Tolaria adds Gemini CLI as a first-class `AiAgentId`. The frontend agent definitions, onboarding prompt, install links, default-agent normalization, status badge, command registry, settings persistence, and mock Tauri status payloads include `gemini`.
The desktop backend adds a Gemini adapter that:
- discovers `gemini` through the process path, login shell, and common local/toolchain install locations
- runs `gemini --output-format json --approval-mode <mode> --prompt <prompt>` from the active vault
- supplies Tolaria MCP through a temporary settings file referenced by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
- uses Safe mode with `auto_edit`, an untrusted MCP entry, and `tools.exclude=["run_shell_command"]`
- uses Power User mode with `yolo` and a trusted Tolaria MCP entry
- maps Gemini JSON responses into Tolaria's existing AI panel stream events
The existing external MCP setup remains explicit and durable. The app-managed Gemini adapter uses transient settings so selecting Gemini in Tolaria does not rewrite the user's global Gemini config.
## Options Considered
- **Add Gemini as a first-class app-managed agent** (chosen): matches the existing agent picker and onboarding UI, uses Gemini's headless JSON mode, and keeps MCP setup vault-scoped.
- **Keep Gemini as external MCP setup only**: avoids another adapter, but keeps the interface inconsistent and requires users to leave Tolaria for a flow that other agents support in-panel.
- **Write app-managed Gemini config into `~/.gemini/settings.json`**: reuses the external setup path, but would blur the consent boundary and risk overwriting user preferences during normal AI panel usage.
- **Use interactive Gemini sessions**: could preserve richer CLI state, but does not fit Tolaria's current one-request stream lifecycle and would make cleanup/auth/error handling harder.
## Consequences
- Gemini appears anywhere users can choose, install, or switch local AI agents.
- End-to-end native Gemini QA requires the Gemini CLI to be installed and authenticated, but missing/auth failures now produce agent-specific guidance.
- Safe and Power User behavior is limited by Gemini's own approval/tool semantics; if Gemini changes those names, the adapter tests and docs need updating.
- The durable MCP setup path and optional `GEMINI.md` shim continue to serve external Gemini usage outside Tolaria's AI panel.

View File

@@ -0,0 +1,29 @@
---
type: ADR
id: "0098"
title: "In-app image and PDF previews for binary vault files"
status: active
date: 2026-04-29
supersedes: "0086"
---
## Context
ADR-0086 introduced the `FilePreview` path for image binaries while keeping binary files as ordinary `VaultEntry` records. The same file-first model should now cover PDFs, because asset-heavy vaults often mix screenshots, diagrams, and document exports that users need to inspect without leaving Tolaria.
## Decision
**Tolaria previews supported image and PDF files in the editor pane while keeping them as ordinary binary vault files.**
- The scanner keeps the coarse `fileKind: "binary"` representation. Previewability stays a renderer concern inferred from the file extension in `src/utils/filePreview.ts`.
- Supported images render with `<img>` and supported PDFs render with the webview PDF object renderer, both using Tauri asset URLs from `convertFileSrc`.
- The Tauri CSP permits scoped asset URLs in `object-src` so PDF objects can load vault-backed files without broadening script, connect, or image policy.
- PDF preview fallback content lives inside the PDF object so unsupported or failed renderers still expose an explicit "Open in default app" escape hatch.
- Note-list rows for previewable images and PDFs remain clickable and carry file-specific indicators; unsupported binary rows stay muted and non-clickable.
- `Escape` on the preview surface returns keyboard focus to the note list, matching the existing image-preview keyboard behavior.
## Consequences
- PDFs do not become notes and do not get Markdown editor semantics.
- The asset preview surface can keep growing to additional safe binary formats without changing the vault scanner or persisted cache shape.
- Broken PDFs may rely on the webview's own renderer failure state, but the surrounding Tolaria preview chrome still provides reveal, copy path, and default-app actions.

View File

@@ -89,7 +89,7 @@ proposed → active → superseded
| [0031](0031-full-app-for-note-windows.md) | Full App instance for secondary note windows | active |
| [0032](0032-status-bar-for-git-actions.md) | Git actions (Changes, Pulse, Commit) in status bar, not sidebar | active |
| [0033](0033-subfolder-scanning-and-folder-tree.md) | Subfolder scanning and folder tree navigation | active |
| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | active |
| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | superseded → [0085](0085-non-git-vault-support.md) |
| [0035](0035-path-suffix-wikilink-resolution.md) | Path-suffix wikilink resolution for subfolder vaults | active |
| [0036](0036-external-rename-detection-via-git-diff.md) | External rename detection via git diff on focus regain | active |
| [0037](0037-codemirror-language-markdown-highlighting.md) | Language-based markdown syntax highlighting in raw editor | active |
@@ -139,3 +139,14 @@ proposed → active → superseded
| [0081](0081-internal-light-dark-theme-runtime.md) | Internal light and dark theme runtime | active |
| [0082](0082-markdown-durable-math-notes.md) | Markdown-durable math in notes | active |
| [0083](0083-dual-architecture-macos-release-artifacts.md) | Dual-architecture macOS release artifacts | active |
| [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 |
| [0097](0097-gemini-cli-agent-adapter.md) | Gemini CLI agent adapter | active |
| [0098](0098-in-app-image-and-pdf-file-previews.md) | In-app image and PDF previews for binary vault files | active |

485
lara.lock Normal file
View File

@@ -0,0 +1,485 @@
version: 1.1.0
files:
e6ab9bf36c30b9d72bc97897e0d89a16:
command.noMatches: a2a97d96e196148e04c51c47d985e327
command.palettePlaceholder: 6d6b61b4c08ed382ad7ba406a9ba395d
command.footerNavigate: e3808db59d29bcfc91532d6539c4f9ae
command.footerSelect: 6d73339906ed42e20f6be91bf7f5f4a9
command.footerClose: dcf77460e548fe215ef28583f092a496
command.footerSend: 6b19fc9694fd8b7b3f1aafaca83db864
command.aiMode: 7197e3c5f9afd24022377176611f35cb
command.openSettings: 638c05f4f159a403e04aebe94b46a2a8
command.openSettings.keywords: ab6b5e03395f4db955ed9cbfd4de33b7
command.openLanguageSettings: e5a425aa6222fab3df66c0e1e0ca4183
command.openLanguageSettings.keywords: 68efcb7e847b1a9d472baa609824be80
command.useSystemLanguage: b7f0315c47d4a59faa2a49571fcf1fca
command.openH1Setting: 05b4f6edc0e98b29670e8ee902cdb9bf
command.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03
command.contribute: 9887a4451812854f0f1b6f669a874307
command.checkUpdates: f77f38f684c8b974dd4a56bb321cfd5f
command.group.navigation: 846495f9ceed11accf8879f555936a7d
command.group.note: 3b0649c72650c313a357338dcdfb64ec
command.group.git: 0bcc70105ad279503e31fe7b3f47b665
command.group.view: 4351cfebe4b61d8aa5efa1d020710005
command.group.settings: f4f70727dc34561dfde1a3c529b6205c
command.navigation.searchNotes: d8002e888293774162e43b263658cbb5
command.navigation.goAllNotes: 7030dcbbb85b6f01d3c8b2fdcb6a5cff
command.navigation.goArchived: b620b8cb8fef8a287b986ff83a992a2a
command.navigation.goChanges: c6dd094ce55f8a765b68cef40a1c6bd9
command.navigation.goHistory: 39b86b661267053a240ffe89b9eca847
command.navigation.goBack: 4f2f5e1d6e2a13469ad431474a68ab82
command.navigation.goForward: d13c318438f67d749cfc0904e3fafcda
command.navigation.goInbox: 522af71c26bc7e3ce1dae6204c2fbe9f
command.navigation.renameFolder: a3d3b0f787c52a984af61085c577bff2
command.navigation.deleteFolder: 991e322cd8225c369a963bb60b0a9688
command.navigation.showOpenNotes: bd1905c340d0518187865f4a6694e14d
command.navigation.showArchivedNotes: 241399b908884adfe8608a3ab827ca74
command.navigation.listType: 4f70c27c6826a37543c8ada561397c22
command.note.newNote: 23c4edda8b815f750782f01870d27271
command.note.newType: adce5108f18945cc502a06c02445e32d
command.note.newTypedNote: 1493eda772c2179cb6247169d00723b2
command.note.saveNote: 2a309cda46e95b00d2a5a8afb3cc0047
command.note.findInNote: f72f8f93d8e6119d5d08b60eb3a7b3bc
command.note.replaceInNote: b008e2e20c5e4cd202f4386271d6bed1
command.note.deleteNote: 56f727a2ee11d15159f1aa6373314cb6
command.note.archiveNote: 6043509fda6dd7f6f167e4108033f8e8
command.note.unarchiveNote: 6937fb694d00b11d579c21cd4bf103a2
command.note.addFavorite: 782d6b28dce4e5d0b2ac92142c6624ba
command.note.removeFavorite: 4f9b5fa08f2ef86fdc5d0ceefc271981
command.note.markOrganized: 505ad63357f533fb39e4276a2bfb9496
command.note.markUnorganized: 1c62f1bffcb793c63d729096d2794ced
command.note.restoreDeleted: d704f8283ca322a9b3713d2ccf9a6cd4
command.note.setIcon: 99b81d2663266c53e45e4d9c62c87a29
command.note.removeIcon: 4bccbad66025e506b37b4218498b299c
command.note.changeType: 4a54ffd47ed1f65ee97f34bfe8c3de14
command.note.moveToFolder: 3a0f26c42b9168ad839960d134065905
command.note.openNewWindow: 6f67b2857093fa0fd45b1122dff4865d
command.git.initialize: 54a57cdc7105db6fb22dca661ce01ad6
command.git.commitPush: 8cb5faa4019716f43dd69c41496b1d46
command.git.addRemote: 7eef951b3f909340a68dc9811be83e9e
command.git.pull: 3988d61f4a4546381f61a4eaf61315b4
command.git.resolveConflicts: 871ac36968cfca3d2297a89b28b25dee
command.git.viewChanges: b2699edf69d8e1031afce2b2f94e5c8d
command.view.editorOnly: 8355e056b32086b9190d639be290dda8
command.view.editorNoteList: b9604b1609eb6f581cd9570dca72d3f0
command.view.fullLayout: 1cebdf839eee2b1713828731aaa3b507
command.view.toggleProperties: 54f12756a363401243d82cbd0466117e
command.view.toggleDiff: 148a10ef6f04f897e73289f529ecae86
command.view.toggleRaw: 9b622257cd6345ceaf58e42b1410899b
command.view.leftLayout: 5046a7166675e2d0175b9da3befdcaca
command.view.centerLayout: 3bc6759c005178aae519aa3dd227cb74
command.view.toggleAiPanel: 4279cbf31767465f25feff6e7bdd336e
command.view.newAiChat: 1b0a886d6e77f628ec96c2d71cdb0a9b
command.view.toggleBacklinks: b3c4be2d2c07bb8df181355a2c3658a7
command.view.moveViewUp: 76f92d3250e028609349d825c672f13e
command.view.moveViewDown: 9deea30162b8d3234f8d52795ad7393f
command.view.moveNamedViewUp: b4ac634d13a09eacb680ef12c69dd1f7
command.view.moveNamedViewDown: 15302c02df26fe52def77636e9141711
command.view.zoomIn: ae4a8e406b707636392b6673fca0e6a6
command.view.zoomOut: 97326f8cd9df886a6b19af180fb7dcc9
command.view.resetZoom: 193ee8c32391fc5cc303a51617cfd046
command.settings.createEmptyVault: d7c9673bb6c62a2b8aef009546ce8b23
command.settings.openVault: c40e74822da9417fdf52eb4a37bb23f2
command.settings.removeVault: 390e2b2031cde0dd18381efee0923bea
command.settings.restoreGettingStarted: b3358dc1c7b873b2a02ac6f05473fe4d
command.settings.manageExternalAi: c36b9bd171f11a18a1e624365d2b57ac
command.settings.setupExternalAi: 24eea679eb699763daa2e3f2a67fcf9a
command.settings.reloadVault: f6e506dad6b6cf2be24d9438d458ae54
command.settings.repairVault: cee560b1fd5ad9d1ff1c19a0a2afe77a
command.settings.useLightMode: a76d5b1c076983bc113d247f0520c166
command.settings.useDarkMode: ba7873c42ae00542ba71db9be3b6761f
command.settings.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03
command.ai.openAgents: 612abe804edd0f5ae228a534f77f3d23
command.ai.restoreGuidance: 23331fecc692d11d85cf24838ed273f2
command.ai.switchToAgent: 5bb02187e653b360ab7ed661403271f2
command.ai.switchDefault: 42724de254240d598fbcc40fdb5b18a3
command.ai.switchDefaultWithAgent: d405f7914bb1cd86172f108f3c33d953
settings.title: f4f70727dc34561dfde1a3c529b6205c
settings.close: 7812b3644f897c5d3efb0dd63440e751
settings.sync.title: 36f62a237420dcdc7096ad3a9018c4d9
settings.sync.description: 06a6957c40867be2c28783183dc1bf5f
settings.pullInterval: 4f95ca677398604bca241427261ed07b
settings.releaseChannel: 2da37055e15a217f18bd403922085c3f
settings.releaseStable: fa3aff3c185c6dc7754235f397c2099a
settings.releaseAlpha: 6132295fcf5570fb8b0a944ef322a598
settings.appearance.title: a1c58e94227389415de133efdf78ea6e
settings.appearance.description: ca7b75a4c10ab8a540707e3a96cd3592
settings.theme.label: d721757161f7f70c5b0949fdb6ec2c30
settings.theme.light: 9914a0ce04a7b7b6a8e39bec55064b82
settings.theme.dark: a18366b217ebf811ad1886e4f4f865b2
settings.language.title: 4994a8ffeba4ac3140beb89e8d41f174
settings.language.description: e2bb9b523067fdc32a494b1d6fd97906
settings.language.label: 244c7b77926f077b863c33ff1244e1ec
settings.language.system: 41e2252344aa441e925589ddcb762e6d
settings.language.summary: edc83f1f48ce29bc80bf2f2f90e24997
settings.autogit.title: e59f720605ec1cfa42b3b97d89d7e883
settings.autogit.description.enabled: e65bec70ca4d7921be56f84d10836018
settings.autogit.description.disabled: 1143ef6ee00614816785e3c6fc299d51
settings.autogit.enable: 9b205a4ae0e3ad4e6708155880ce9c75
settings.autogit.enableDescription: a7424a8f90c0b7f290a8144d12374554
settings.autogit.idleThreshold: 28016cc6fccd951a904ee3020cd733b6
settings.autogit.inactiveThreshold: d28cb60d1e70e020ae8d1294e32dd474
settings.titles.title: 761443f70a5067ecf015c6fb3fae9cef
settings.titles.description: b94aeeca78dd376cc19b9aa019e53f6c
settings.titles.autoRename: 5383db8e790ebf5f956d9c59cb4c4f67
settings.titles.autoRenameDescription: cc28c28d8817736e658082a2261d9a6e
settings.vaultContent.title: 78b883ce9acb9c25e611158a518d9185
settings.vaultContent.description: a53487bf1c7898a1459dc1510e216f7d
settings.vaultContent.hideGitignored: 2c07ee6c8bfe746d356f44275d42f90e
settings.vaultContent.hideGitignoredDescription: e8b1ddfa416610f9d6eb299fa123a1d6
settings.aiAgents.title: 27f08387b26e1ceeb1b60bd9c97e7a47
settings.aiAgents.description: a13222e67eb121676ff6dfc7b085f02d
settings.aiAgents.default: 6af573559fd05d8c35bc57b41cc78e24
settings.aiAgents.installed: 73329564760013a7824ff9d5d1af91ff
settings.aiAgents.missing: ea21841da70e6405af19fabc4ff8bdd9
settings.aiAgents.ready: 909a648c713be25fcd050725d0a41e37
settings.aiAgents.notInstalled: 0ac3f76ef78bfdbab6331731ee56ba22
settings.workflow.title: 24f47cdbe9ddba774a7cc53e51d9032e
settings.workflow.description: aa9a07539b87cf407c3edc8a22732d45
settings.workflow.explicit: 54a5b5c27937d25271c3127d093e8361
settings.workflow.explicitDescription: ce523427b5dd0f9895f63d1fbb90ad24
settings.workflow.autoAdvance: 9ed337f5bc61603d19a1ef35f3a3a243
settings.workflow.autoAdvanceDescription: ae58ad8abf03df7cbdae8c3a725258f7
settings.privacy.title: 0dd6c14e00cc9f45a68c1bca88d1317d
settings.privacy.description: 33d53059be620b58e38b8e5c5ab26b27
settings.privacy.crashReporting: b4efc5b61526566d431e99242f5c21b4
settings.privacy.crashReportingDescription: e9d7c7efce44adc08d0460be1aad5c42
settings.privacy.analytics: 29d55bb222ea76e1a17396375dfab228
settings.privacy.analyticsDescription: a0f8b61d555d6d3fd279de2f47aaca76
settings.footerShortcut: 7dc840d6b0ef9f422a663ba41975871e
settings.cancel: ea4788705e6873b424c65e91c2846b19
settings.save: c9cc8cce247e49bae79f15173ce97354
common.cancel: ea4788705e6873b424c65e91c2846b19
ai.permission.safe.short: c6eea0560cd6f377e78dff2c85cc9122
ai.permission.safe.control: 386cc66605d50e5bf1fb726f97e55b55
ai.permission.safe.tooltip: 3bd635e2b2223da302994d77bfd7edfd
ai.permission.powerUser.short: d4a47db271fc1ef3cd17f7396326b057
ai.permission.powerUser.control: d4a47db271fc1ef3cd17f7396326b057
ai.permission.powerUser.tooltip: 2585ecdb79ca02a3ee779bb61552de03
ai.permission.modeAria: 3ff6346566a4c44fd5c7395907125d3e
ai.permission.changed: b5d06ad47e6053fe5a73f4571a40e00c
ai.panel.title: 4412225fe5b326643b8c2e1d9b7b10ae
ai.panel.status.checking: 118740af1c4521b917e29791d661db82
ai.panel.status.missing: fe07f8eac233c229d81cbe9aa25668a0
ai.panel.status.ready: 296a0ac791eab2df130789f55fdc109b
ai.panel.copyMcpConfig: 6a8c9fe401557c870e3a90bbfab01b25
ai.panel.mcpConfig: 53bac93d0314941c8d2c1163026f3ad9
ai.panel.newChat: 1b0a886d6e77f628ec96c2d71cdb0a9b
ai.panel.close: d00ba22b83762f5a6e66db0be9e916d7
ai.panel.linkedCount: 5292fda189a357c9597b1cb0e1b0900b
ai.panel.empty.checkingTitle: 0dde2076cb53fe497d05d231296d50bb
ai.panel.empty.checkingDescription: 31e41510d851c366860377ba0c956f0b
ai.panel.empty.missingTitle: a2b764da52cb43b191bb4ecfd8ea4d26
ai.panel.empty.missingDescription: 91825c7d86b1d588d44a33e1d94af950
ai.panel.empty.withContextTitle: 66f02985937083e4ef62b98cc9f25aee
ai.panel.empty.noContextTitle: e919aeb0a49b58e494e43e5f6f6597c9
ai.panel.empty.withContextDescription: dd8faefcc8160a2db800a41d6628facb
ai.panel.empty.noContextDescription: 6a6e610835808016d1f2790c3f991a54
ai.panel.placeholder.checking: 08fec77719c37d3d6279b2641b37b4d3
ai.panel.placeholder.missing: 547c492026d115608067988b7cfbb9a9
ai.panel.placeholder.ready: 31f5265c2e0432a7ca10d08e5c6f3114
ai.panel.send: 747c6a3564774149c989884e0c581d53
mcp.setup.manageTitle: d786c7b9ab9b2406258648931bca0e01
mcp.setup.setupTitle: b4b373f690c8a0008885b31e78e93a5c
mcp.setup.connectedDescription: a2a3be70c0ac9fa7ef112df5252249fa
mcp.setup.setupDescription: 8bf199ffbcf303a99abac078c0c5e094
mcp.setup.reconnect: 813ba447b5e64c17ff9b68c693358ca3
mcp.setup.connect: fb95777dbc9f7b22014dc360a2957652
mcp.setup.disconnect: 42ae25231906c83927831e0ef7c317ac
mcp.setup.connecting: 182e7eda4e721ad00737460b88701dee
mcp.setup.disconnecting: 11de134aefe51cb4a5c545b5a057a871
mcp.setup.nodeRequirement: ac58a35cf21d2204fed548c227f77594
mcp.setup.writeEntryDescription: 370aa4403d2ffc37f574503bc3cd00e6
mcp.setup.clientPathsDescription: 94565852412051c799e78d2f4525ff53
mcp.setup.geminiGuidanceDescription: 182cf5b84b2f56cb1cf41dcdb5e093d8
mcp.setup.manual.title: 2188ca52346158cbd103ca7ce7c12dd9
mcp.setup.manual.copy: 6a8c9fe401557c870e3a90bbfab01b25
mcp.setup.manual.loading: 11ee201c121c8e2015c065952474a3ea
mcp.setup.manual.unavailable: 8b4330b839c41bad378c86c91ee0b5cd
mcp.toast.connected: 7716c0f9dad41a7fdbef592074459239
mcp.toast.refreshed: 227d8721ebec3077792b1223c1f30e54
mcp.toast.disconnected: 9b5254079561087d679e35aa10c80638
mcp.toast.alreadyDisconnected: 645bec2e33bd9de2b334b704d9fa84cf
mcp.toast.configCopied: 458b50444b9a1cb069b00475c0951b8a
mcp.toast.configCopyFailed: b451cfb95f87458d142d026a8aca74cf
mcp.toast.setupFailed: 51f290962c08d8ba76bfc6c19552eaed
mcp.toast.disconnectFailed: ddc3387ab6e996ce15bc4950a3548e1e
mcp.error.clipboardUnavailable: 3b9ed3fe7dba627edb74e1cdaa02b2ee
save.toast.saved: 248336101b461380a4b2391a7625493d
save.toast.nothingToSave: 86ae8cef7371e639f3c007449a2f7d1f
save.toast.missingActiveVault: 49cf5064a7dce757f895950d8beeed68
save.error.failed: b55b9fefe93fabae3c277e4e7956a534
save.error.autoFailed: 09b2b131f07baddb5ba8f5a3ee6234c5
save.error.invalidPath: c96fb669715e86508f4c5e73da9f6995
savedViews.reordered: 531081a566b826eed2e714ce70a0de08
locale.en: 78463a384a5aa4fad5fa73e2f506ecfc
sidebar.nav.inbox: 3882d32c66e7e768145ecd8f104b0c08
sidebar.nav.allNotes: cd76cf851b08a9d2feafaf255bc1f4d5
sidebar.nav.archive: e727b00944f81e1d0a95c12886ac4641
sidebar.group.favorites: 953bf4d5e8a1807c15dec4e255384b1d
sidebar.group.views: 8f56a72923a6ca8ff53462533d89e567
sidebar.group.types: 6b20155008db90cf7589c07baa7334e9
sidebar.group.folders: d86e2756f698bea5aac3e2276639f042
sidebar.action.createView: ba019414ea8c7fcdb4a83a70ec1bb639
sidebar.action.editView: acdf34bd08b0692b906d446e590b1eb9
sidebar.action.deleteView: c8f4f9cd8ff820f955474e5c9f70ff39
sidebar.action.reorderView: ca2f42548fd57160d22fd0809bf8ed6e
sidebar.action.moveViewUp: e601b7f0901f12efb71f7821c0fdb79e
sidebar.action.moveViewDown: 6f86951085367864e3e4eb13f5c8688e
sidebar.action.customizeSections: 050a134cad1e9c6f04abe5d635592703
sidebar.action.renameSection: 8936914051df04e7550d0eeb4a31e0e0
sidebar.action.customizeIconColor: 9a2f685c74d261ab483ef00dee5314fe
sidebar.action.createType: 6cfb8b8aa3fdce38e675819691b48f5c
sidebar.action.collapse: 00873bdddd457791609bff1243c40a6b
sidebar.action.expand: 8f98e9696f6eed958573012f52710faa
sidebar.action.createFolder: 5dc1e97c14fbe72d8b566ce29c39f010
sidebar.action.renameFolder: db76034f8218cda07dadb746a5643019
sidebar.action.deleteFolder: 2a2f15300f6f7c81d69860ac1796b517
sidebar.action.revealFolderMenu: 76f4ed52da9937ae432dcf5a108fabb4
sidebar.action.copyFolderPathMenu: 1779ec6018a385912c1a5499a1bbf2b2
sidebar.action.renameFolderMenu: deb1d8fa030292e7eda2c244b313aca2
sidebar.action.deleteFolderMenu: c78c889ac7396dc148fc859c9221e545
sidebar.folder.name: 710417c4e57a6a01500fe4c0c448ce3d
sidebar.folder.newName: e0ad5b2db20359a85de80c1231323bea
sidebar.folder.expand: b7545cb143816942ff096d890090fb6f
sidebar.folder.collapse: e9f8c6da708219313d72d99cc3998388
sidebar.section.name: c3642037e309db278bbbfe0590114c02
sidebar.section.showInSidebar: e38d073926d6fb3dfc4d4347708e3cf5
sidebar.section.toggle: 2992ffbc76e607a2dc0e99781f101637
sidebar.disabled.comingSoon: 81151a1669ebd695e837f304a0d8ec79
noteList.title.archive: e727b00944f81e1d0a95c12886ac4641
noteList.title.changes: c112bb3542e98308d12d5ecb10a67abc
noteList.title.inbox: 3882d32c66e7e768145ecd8f104b0c08
noteList.title.history: 16d2b386b2034b9488996466aaae0b57
noteList.title.view: 4351cfebe4b61d8aa5efa1d020710005
noteList.title.notes: f4c6f851b00d5518bf888815de279aba
noteList.searchPlaceholder: 4857cd2689a0a40eb4a77cdc745c518e
noteList.searchAction: 5012120fc480c67686dd22ee2f9493cd
noteList.createNote: f1484bc40bd3fe3430c13fb89e2ce1af
noteList.empty.changesError: 4fca500c4b70f61f7d9413c57c34bdc4
noteList.empty.noChanges: ad82ac153532f5625760c29c176c5d5f
noteList.empty.noArchived: 75fa9e2aaa902f65d433be856a2e8331
noteList.empty.noMatching: 22838e3b8d3d174d33d7986ab6bdd693
noteList.empty.allOrganized: ea8fbb54d21c03fae1469635e7043b9b
noteList.empty.noNotes: 910bd677fdd52f3e6edb4abc1d03ade8
noteList.empty.noMatchingItems: 25cd76c4bbd00b682a45687705b41a14
noteList.empty.noRelatedItems: 67eecd103b56ef0e56fd892d379b5a5e
noteList.sort.modified: 35e0c8c0b180c95d4e122e55ed62cc64
noteList.sort.created: 0eceeb45861f9585dd7a97a3e36f85c6
noteList.sort.title: b78a3223503896721cca1303f776159b
noteList.sort.status: ec53a8c4f07baed5d8825072c89799be
noteList.sort.by: 5cd12b67b005056a94f06368a7645b8e
noteList.sort.menu: 62bfadcf958eb6ad238e6c71e312a0ce
noteList.sort.ascending: cf3fb1ff52ea1eed3347ac5401ee7f0c
noteList.sort.descending: e3cf5ac19407b1a62c6fccaff675a53b
noteList.filter.open: c3bf447eabe632720a3aa1a7ce401274
noteList.filter.archived: 7d69b3cb4cada18ae61811304f8fbcb5
noteList.filter.week: d2ce009594dcc60befa6a4e6cbeb71fc
noteList.filter.month: 7cbb885aa1164b390a0bc050a64e1812
noteList.filter.all: b1c94ca2fbc3e78fc30069c8d0f01680
noteList.properties.customizeColumns: be39bb193d872e11bb21388add6eca23
noteList.properties.customizeAllColumns: 0d382ece9a9d08bb9ddf10ed2c88d726
noteList.properties.customizeInboxColumns: e7cf53b914ce1079801eeee603875660
noteList.properties.customizeViewColumns: a5612e5d2aa2e86941d536a85b27c8b4
noteList.properties.showInNoteList: f22a57c5fc14ff477000cc66d837c344
noteList.properties.searchPlaceholder: 9c7569eb531ff041b2b7cf4de1e5a619
noteList.properties.searchLabel: de1bc9695d5e79a75296abf74dc25056
noteList.properties.noMatches: 1627ecc53b9e97c94b2b6b1b93a58721
noteList.properties.reorder: c0955c7efaa1ce90d449a23cbc52b553
noteList.changes.restoreNote: d6e99303c0e0b7b2abce06fe9214788b
noteList.changes.discardChanges: 98313f623bb6f464b9a154eca0b99bf3
noteList.changes.restoreDescription: 45e13380b0538e6cbff330f99c260c0a
noteList.changes.discardDescription: cd953f51a81e10f8c90135e0106a45d6
noteList.changes.thisFile: 976b976e66879a470635bf0f660e81fc
noteList.changes.restore: 2bd339d85ee3b33e513359ce781b60cc
noteList.changes.discard: d94b42030b9785fd754d5c1754961269
noteList.changes.cancel: ea4788705e6873b424c65e91c2846b19
editor.empty.selectNote: a71ec7c80aefe49c59f6aba033bf453e
editor.empty.shortcuts: 1133fdf3ecef6a09dd9e2a185e1bd4ec
editor.raw.label: 7a9754c15264b0604aa31dfea32321ea
editor.find.findLabel: 4cfa6c981549e990fe2344e4c805405e
editor.find.findPlaceholder: 4cfa6c981549e990fe2344e4c805405e
editor.find.replaceLabel: 0ebe6df8a3ac338e0512acc741823fdb
editor.find.replacePlaceholder: 0ebe6df8a3ac338e0512acc741823fdb
editor.find.matchCount: 29d3cd243c6850f5fbac283e79d6d6a7
editor.find.noMatches: 3b470c1f6a10f58c8a8cb6b904577786
editor.find.invalidRegex: 9704b2ccc6158864810a313702019d68
editor.find.regexMustMatchText: 267728c2e7f2889b39847ee9b9b38891
editor.find.previousMatch: afb1b7e6bbdfaa97114e1b519a817f13
editor.find.nextMatch: 7f4d218b4f566ea7ff69ab8d912ba7b9
editor.find.showReplace: 82c8fdb73dfe8baa1759a36c6c0fb282
editor.find.hideReplace: a85baf9735f9acf26aa15b70f2ee55fb
editor.find.regex: 7486f19f279f95357c84706dd09ce9da
editor.find.matchCase: ba945280b8802ee1a7a50140e2fe94e2
editor.find.close: b040fbda901d2135cad53e54e0127071
editor.find.replace: 0ebe6df8a3ac338e0512acc741823fdb
editor.find.replaceAll: b1c94ca2fbc3e78fc30069c8d0f01680
editor.toolbar.rawReturn: 5ddcf5e0dc8b933b344bb6ca3d8e131d
editor.toolbar.rawOpen: 89ad60735a649b60dacd2301cda0c4ec
editor.toolbar.centerLayout: 3fabf7e9b285eeec90704d271f1049b7
editor.toolbar.leftLayout: acab6c8612816e012ff9f4220a42e485
editor.toolbar.removeFavorite: 7188286e36fc6c1892dd53aaa54237fb
editor.toolbar.addFavorite: 910885435dbc44a22b68cb45c79e4a7e
editor.toolbar.markUnorganized: 83fb1b23a3609fab493737fe7af0a198
editor.toolbar.markOrganized: 9d170c916afae3d7a82456838728ffa5
editor.toolbar.noDiff: b2c4189f90648aaeb5cd2e81f9bd8d94
editor.toolbar.loadingDiff: 430386d6aae3570fd399a133e41c7372
editor.toolbar.showDiff: 08d579de8d3abdcdfce0953a797362c6
editor.toolbar.openAi: d2e93006570a66709c8ce0dc27082ef1
editor.toolbar.closeAi: cd46973ef73076d612dff9903ce54498
editor.toolbar.restoreArchived: 1bff5cf4943af64c05b2e9aeadccbc84
editor.toolbar.archive: 63dc964c32e715217b49f8739d49636b
editor.toolbar.delete: f48134a07b016a1de05d4ba6d2fbfb41
editor.toolbar.revealFile: 76f4ed52da9937ae432dcf5a108fabb4
editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27
editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e
editor.filename.rename: c31c2b468229232ad6287e734fe67d96
editor.filename.renameToTitle: bff34a6a2dd1eb87c59403946679237f
editor.filename.trigger: 4e9739c2f937c828bbf5d1f935fe7fb9
editor.banner.archived: 7d69b3cb4cada18ae61811304f8fbcb5
editor.banner.unarchive: 9fd02d2eb5ce348a74372eea202a0aec
editor.banner.conflict: 384fc5a4b31c662b1e3426a7ef56441e
editor.banner.keepMine: 544a2f2e8e09f93919f10e1920d1b69e
editor.banner.keepMineTooltip: 9988cb86b051dd728e2d2f852f0283e4
editor.banner.keepTheirs: 46131020af44cc6ec4bfa0a8ff39e044
editor.banner.keepTheirsTooltip: 9e5b845c4459747d6ab0df6438ccfac4
inspector.title.properties: 9fc2d28c05ed9eb1d75ba4465abf15a9
inspector.title.propertiesShortcut: 427d1cbdc813cf54c5fac2508d38d407
inspector.title.closePropertiesShortcut: 97b953e45042d4143dd19624dc345d29
inspector.empty.noNoteSelected: 046d95682b747a30ed8a7b0b1d581629
inspector.empty.noProperties: 6864166e0f65d81b1eb474e41fde8822
inspector.empty.initializeProperties: edf249d214b68f5afe8634c577b709c4
inspector.empty.invalidProperties: 2ee2429c1ffbabcda9f57f91fb6c0b9d
inspector.empty.fixInEditor: b86d4934630d68bc5333c0feb25e5f3f
inspector.properties.addProperty: 9820ade036bf1bdf80c0b7da24a4ce0a
inspector.properties.deleteProperty: f1d0ab48c8596108e949dfc90e13050b
inspector.properties.none: 6adf97f83acf6453d4a6a4b1070f3754
inspector.properties.missingType: 9179080e7bcc72408f3de7cb944ff400
inspector.properties.missingTypeAria: 582c2c46117cff0534d13e7c8a45a3ba
inspector.properties.searchTypes: 84c8d94846183a79444bf36667e8d7ea
inspector.properties.noMatchingTypes: 9b64c31214171ba3b2d591743990b840
inspector.properties.yes: 93cba07454f06a4a960172bbd6e2a435
inspector.properties.no: bafd7322c6e97d25b6299b5d6fe8920b
inspector.properties.pickDate: e8e91fbba934dc8adfd7e433f5193911
inspector.properties.valuePlaceholder: 689202409e48743b914713f96d93947c
inspector.properties.propertyName: 7e9982311d69d66d38809e5d85377a08
inspector.relationship.add: ec211f7c20af43e742bf2570c3cb84f9
inspector.relationship.addRelationship: d2b0cb45dcdbb00ee70db397c36f73ad
inspector.relationship.name: 7ed3fccc071d6939eff211b1a6fd9696
inspector.relationship.noteTitle: f72ea00f174785710f0369b23c53963c
inspector.relationship.createAndOpen: 55bb53fea743c584c4024e7439c55bfe
inspector.info.title: 4059b0251f66a18cb56f544728796875
inspector.info.modified: 35e0c8c0b180c95d4e122e55ed62cc64
inspector.info.created: 0eceeb45861f9585dd7a97a3e36f85c6
inspector.info.words: 6f15b8d4b7287d60a8ea3d1c5cbadc84
inspector.info.size: 6f6cb72d544962fa333e2e34ce64f719
update.available: 992477975168b876be8e77ce2975e390
update.releaseNotes: 5dd03e8d039863e563e049be198c3fd3
update.updateNow: 99b1054c0f320be9f109c877a5efd0b2
update.dismiss: c8a59e7135a20b362f9c768b09454fdb
update.downloading: 2c7fdcafb08f831420f661810f826549
update.readyRestart: 4d7487b02d955a44d599189ec383e469
update.restartNow: 2d9c2140c53daf05855033aaceeaa8fd
status.update.check: eb1b4bb3667dd670210c7822badc2f1d
status.zoom.reset: dbf36ab275e5fbd256590e65aa1ca9a3
status.feedback.contribute: 96ab5025e38aef43fdb73c9830795264
status.feedback.label: 9887a4451812854f0f1b6f669a874307
status.theme.light: 4abf27a209985375949aaa426c7c7f3d
status.theme.dark: 541be2a55a52b518b8e510c476c7cc95
status.settings.open: bae08226d065231df6f01b08cd681c9b
status.build.unknown: d250349dd489bdfeb0cb0750b8b3ff89
status.vault.switch: 8a2ad262643c556ff0cfa99497aaa529
status.vault.default: 5b70a213f150f01f0776fa9481ef2ddf
status.vault.createEmpty: f37c03f236fbf1516222360a936249e8
status.vault.openLocal: a367344e0429a12a5cc9a6cecbbfcde5
status.vault.cloneGit: a7d0aef7c6237a36e54fd5a26e9c23fe
status.vault.cloneGettingStarted: 9953a11a477b441976a626a9acf5d7df
status.vault.notFound: 3119b7fa94c96209bca571b7c7ed0b7e
status.vault.reloading: 893da50ef3a9e01d8a99ff5d3ceb55e9
status.vault.reloadingTooltip: 4a884bd7d113797ad16f905f70742da8
status.vault.remove: 7f3b4f76df23626170940dbc55c70728
status.remote.noneConfigured: 18a4edd4e8d862ccb343937f45585fb1
status.remote.inSync: a56f571b4df55d88fb06e61e343b3c91
status.remote.aheadTitle: 1516f4b92671b83c17441b4cddf25a7a
status.remote.behindTitle: 55ee20469bc46b7c61e7515fbfdc0b5d
status.remote.ahead: 681557ace3a84e15060ffca1623b75e6
status.remote.behind: 1825bb12c857a71861280e490e29debd
status.remote.add: f292f8ef85219f0acde6b66eb9c9c0f4
status.remote.none: ed3f2ebe0d847584fd62fc9e0db2df33
status.remote.noneDescription: f47636010c21c88a6f81cc41a962b26c
status.sync.syncing: b21888f3f83c224b3451901da243d00a
status.sync.conflict: f1d4ac54357cc0932f385d56814ba7e4
status.sync.failed: 85b08b086888f8ee2680ddd3398f3574
status.sync.pullRequired: 1ea27a2d3048b078a18665b24e8dcd91
status.sync.notSynced: d18d6927f7ffe96f72e18b4da12e306b
status.sync.justNow: 7b9275ee2786ef410555041b92b36bb3
status.sync.minutesAgo: b3ac76d1e2595531940035a8e2e2d13c
status.sync.resolveConflicts: b9f44cc5ee01c964f004f16e3a2833ca
status.sync.inProgress: 278d2bf6768809fbf6e97e7bb319a7c0
status.sync.pullAndPush: e07c084a9e734a54bd079d5327a924e7
status.sync.retry: ca43e18d4f930e8a3e6d403f31d30a39
status.sync.now: c03fafa3a653240e6104348e1293bb88
status.sync.synced: 5befab0dde764b6dd8b24a34dc30afa7
status.sync.conflicts: d35cef9595a935771f5e8158f387227b
status.sync.error: 902b0d55fddef6f8d651fe1035b7d4bd
status.sync.status: 76590d5f9708692e3735891bdca40903
status.sync.pull: 718f59718640c6506b3721fbc8bf3a4d
status.conflict.count: 46293d20ed2071fb30762b71fdaa5894
status.offline.title: 3bf45a7003646cc4f963810b0b7ac0f5
status.offline.label: 8d9da4bc0e49a50e09ac9f7e56789d39
status.changes.view: 6d60d24d8f475ddaef94bbbc58514b5a
status.changes.label: c112bb3542e98308d12d5ecb10a67abc
status.commit.local: 6474fe7e2ed525df3da3eb447943bfcd
status.commit.push: a4aaf10ef20cf17a982bb5b6dad198e0
status.commit.label: 59d5b10c3a447f036d85cb5ce524c96c
status.commit.openOnGitHub: 445ad18680a573c2aab062c3273ae16a
status.git.disabledTooltip: 6981fe5b4993e4b3299c774b185ecd70
status.git.disabled: 7f822b0ee91b8922b0c41723da867bd1
status.history.onlyGit: 7da5397c33ada1b73a81c5a449cbab16
status.history.open: 802ec1fd97592612c19ebef7bebbb5c2
status.history.label: 16d2b386b2034b9488996466aaae0b57
status.mcp.notConnected: 7e8850f4a564088ced4f592996a39309
status.mcp.unknown: 051ff29ff900592f75974d0ef554c2eb
status.claude.missing: 89021856e6152467f54ed33faed55e2f
status.claude.label: 38c66b824d5769e42c3866005296525b
status.claude.install: 1f4688bc2a2acc268811c9edcec810a1
status.ai.noAgents: 0876a176a6297e79366d0a2935851f81
status.ai.noAgentsTooltip: 1bdc02ec2dd7f9701b4371ffd5770318
status.ai.selectedMissing: a37f3dbc73725219e90d709fe0d3ad97
status.ai.defaultAgent: 79650d57d1678e56c75dbbcba6ea87f7
status.ai.restoreDetails: 47a913b58ce40add6521bc93e807476f
status.ai.withGuidance: 1eafd34810db6bba54dac7aa549b5182
status.ai.active: 059e3a3167c2ab00f4ca872e82a48d98
status.ai.unavailable: 0d3e6e3afef545710aa24ed2ea4990af
status.ai.install: 349838fb1d851d3e2014b9fe39203275
status.ai.installAgent: 78ac3395d977f8b86ca9a02f781f4af8
status.ai.vaultGuidance: 7bb4644efe6ae7cec9993c8bd85f1519
status.ai.restoreGuidance: 23331fecc692d11d85cf24838ed273f2
status.ai.openOptions: d387b1ab67586c7a2d83db8900a4dd8e
pulse.title: 16d2b386b2034b9488996466aaae0b57
pulse.today: 1dd1c5fb7f25cd41b291d43a89e3aefd
pulse.yesterday: ebfe9ce86e6e9fb953aa7a25b59c1956
pulse.commitCount: 17dc5f11868c946f55ff8164d318856d
pulse.commitSingular: fffca4d67ea0a788813031b8bbc3b329
pulse.commitPlural: a90814b38bbdfe717205f6d24183f6a7
pulse.openOnGitHub: ddab0146d46f585f2ab36072c92a0048
pulse.expandFiles: 53c567ce9f999dd937fc954c39853e81
pulse.collapseFiles: 0ae74096d72faf840cd7d35635aa0cf9
pulse.noActivity: cfbd8245c849f5f00b495d61ea14dfdf
pulse.emptyDescription: b5ee4968b62908444166182fe8e593c1
pulse.retry: 6327b4e59f58137083214a1fec358855
pulse.loadingActivity: 26bf6a6bc2fd8148cd8753542a0ddf86
pulse.loading: 5f02f05c744a0f875aebb8625ae1486f
pulse.loadError: 63c17996fe219e6bd33b27d2b7ce0c96
command.switchLanguage: 60f1a6760c7d1da0b1f7f6d0529608ee
locale.itIT: 4be8e06d27bca7e1828f2fa9a49ca985
locale.frFR: ad225f707802ba118c22987186dd38e8
locale.deDE: 86bc3115eb4e9873ac96904a4a68e19e
locale.ruRU: deba6920e70615401385fe1fb5a379ec
locale.esES: cdbe021be1976335ab583a845edf7ed6
locale.ptBR: 26d5352ead4a731f3d11eb2e85d0e297
locale.ptPT: 71bfc0d79772120b52c1c0782450ff84
locale.es419: edbf64ac382b82a54795fb409beb54be
locale.zhCN: 1e81fc32fea0e9f3a978661e4b5e484d
locale.jaJP: f32ced6a9ba164c4b3c047fd1d7c882e
locale.koKR: d0bdb3cde477d82e766da05ebda50ccb

30
lara.yaml Normal file
View File

@@ -0,0 +1,30 @@
version: "1.0.0"
project:
instruction: >
Tolaria is a desktop knowledge-management app. Keep product names, CLI names,
markdown wikilinks, frontmatter keys, file paths, and placeholders like
{agent}, {zoom}, {language}, {name}, {label}, {file}, and {count} unchanged.
locales:
source: en
target:
- it-IT
- fr-FR
- de-DE
- ru-RU
- es-ES
- pt-BR
- pt-PT
- es-419
- zh-CN
- ja-JP
- ko-KR
files:
json:
include:
- "src/lib/locales/[locale].json"
exclude: []
lockedKeys: []
ignoredKeys: []

View File

@@ -21,35 +21,78 @@ import {
} from '@modelcontextprotocol/sdk/types.js'
import WebSocket from 'ws'
import { searchNotes, getNote, vaultContext } from './vault.js'
import { requireVaultPath } from './vault-path.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const VAULT_PATH = requireVaultPath()
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
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
@@ -192,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)
})

View File

@@ -1,15 +1,19 @@
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'
import { requireVaultPath } from './vault-path.js'
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-'))
@@ -191,6 +195,50 @@ describe('evaluateBridgeRequest', () => {
})
})
describe('requireVaultPath', () => {
it('returns the explicit configured vault path', () => {
assert.equal(
requireVaultPath({ VAULT_PATH: '/tmp/Selected Vault' }),
'/tmp/Selected Vault',
)
})
it('rejects missing vault paths instead of falling back to ~/Laputa', () => {
assert.throws(
() => requireVaultPath({}),
/VAULT_PATH is required/,
)
})
})
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')
@@ -205,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)
}
})
}

7
mcp-server/vault-path.js Normal file
View File

@@ -0,0 +1,7 @@
export function requireVaultPath(env = process.env) {
const vaultPath = env.VAULT_PATH?.trim()
if (!vaultPath) {
throw new Error('VAULT_PATH is required. Open a vault in Tolaria before starting MCP tools.')
}
return vaultPath
}

View File

@@ -24,8 +24,8 @@ import { WebSocketServer } from 'ws'
import {
getNote, searchNotes, vaultContext,
} from './vault.js'
import { requireVaultPath } from './vault-path.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
const LOOPBACK_HOST = 'localhost'
@@ -37,6 +37,12 @@ const TRUSTED_UI_ORIGINS = new Set([
/** @type {WebSocketServer | null} */
let uiBridge = null
let vaultPath = null
function activeVaultPath() {
vaultPath ??= requireVaultPath()
return vaultPath
}
function broadcastUiAction(action, payload) {
if (!uiBridge) return
@@ -48,10 +54,10 @@ function broadcastUiAction(action, payload) {
const TOOL_HANDLERS = {
open_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
read_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
vault_context: () => vaultContext(VAULT_PATH),
open_note: (args) => getNote(activeVaultPath(), args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
read_note: (args) => getNote(activeVaultPath(), args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
search_notes: (args) => searchNotes(activeVaultPath(), args.query, args.limit),
vault_context: () => vaultContext(activeVaultPath()),
ui_open_note: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
ui_open_tab: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
@@ -164,6 +170,7 @@ export function startUiBridge(port = WS_UI_PORT) {
}
export function startBridge(port = WS_PORT) {
const currentVaultPath = activeVaultPath()
const wss = new WebSocketServer({
port,
host: LOOPBACK_HOST,
@@ -171,7 +178,7 @@ export function startBridge(port = WS_PORT) {
})
wss.on('connection', (ws) => {
console.error(`[ws-bridge] Client connected (vault: ${VAULT_PATH})`)
console.error(`[ws-bridge] Client connected (vault: ${currentVaultPath})`)
ws.on('message', async (raw) => {
try {
@@ -192,5 +199,11 @@ export function startBridge(port = WS_PORT) {
// Run directly if invoked as main module
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
if (isMain) {
startUiBridge().then(() => startBridge())
try {
activeVaultPath()
startUiBridge().then(() => startBridge())
} catch (err) {
console.error(`[ws-bridge] ${err.message}`)
process.exit(1)
}
}

View File

@@ -9,12 +9,15 @@
"build": "tsc -b && vite build",
"bundle-mcp": "node scripts/bundle-mcp-server.mjs",
"lint": "eslint .",
"l10n:translate": "lara-cli translate",
"l10n:translate:force": "lara-cli translate --force",
"l10n:validate": "node scripts/validate-locales.mjs",
"preview": "vite preview",
"tauri": "tauri",
"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",
@@ -57,6 +60,7 @@
"date-fns": "^4.1.0",
"katex": "^0.16.28",
"lucide-react": "^0.564.0",
"mermaid": "^11.14.0",
"posthog-js": "^1.363.5",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
@@ -77,6 +81,7 @@
"@tauri-apps/cli": "^2.10.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@translated/lara-cli": "^1.3.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",

1767
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const localesDir = path.join(root, 'src/lib/locales')
const sourcePath = path.join(localesDir, 'en.json')
function readCatalog(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
function isFlatObject(value) {
if (!value) return false
if (typeof value !== 'object') return false
return !Array.isArray(value)
}
function assertFlatStringCatalog(locale, catalog) {
if (!isFlatObject(catalog)) {
throw new Error(`${locale}: expected a flat object of translation keys`)
}
for (const [key, value] of Object.entries(catalog)) {
if (typeof value !== 'string') {
throw new Error(`${locale}: key "${key}" must map to a string`)
}
}
}
function missingKeys(sourceKeys, localeKeys) {
const localeKeySet = new Set(localeKeys)
return sourceKeys.filter((key) => !localeKeySet.has(key))
}
function extraKeys(sourceKeys, localeKeys) {
const sourceKeySet = new Set(sourceKeys)
return localeKeys.filter((key) => !sourceKeySet.has(key))
}
function placeholders(value) {
return Array.from(value.matchAll(/\{(\w+)\}/g), (match) => match[1]).sort()
}
function sameValues(left, right) {
if (left.length !== right.length) return false
return left.every((value, index) => value === right[index])
}
function formatValues(values) {
return values.length === 0 ? 'none' : values.join(', ')
}
function placeholderIssues(locale, sourceCatalog, catalog) {
const issues = []
for (const [key, sourceValue] of Object.entries(sourceCatalog)) {
if (!(key in catalog)) continue
const sourcePlaceholders = placeholders(sourceValue)
const localePlaceholders = placeholders(catalog[key])
if (sameValues(sourcePlaceholders, localePlaceholders)) continue
issues.push(
`${locale}: key "${key}" placeholders differ ` +
`(expected ${formatValues(sourcePlaceholders)}, found ${formatValues(localePlaceholders)})`,
)
}
return issues
}
const sourceCatalog = readCatalog(sourcePath)
assertFlatStringCatalog('en', sourceCatalog)
const sourceKeys = Object.keys(sourceCatalog).sort()
const localeFiles = fs.readdirSync(localesDir).filter((file) => file.endsWith('.json'))
const issues = []
for (const file of localeFiles) {
const locale = file.replace(/\.json$/, '')
const filePath = path.join(localesDir, file)
const catalog = readCatalog(filePath)
assertFlatStringCatalog(locale, catalog)
if (locale === 'en') continue
const keys = Object.keys(catalog).sort()
const missing = missingKeys(sourceKeys, keys)
const extra = extraKeys(sourceKeys, keys)
if (missing.length > 0) {
issues.push(`${locale}: missing ${missing.length} key(s)`)
}
if (extra.length > 0) {
issues.push(`${locale}: extra ${extra.length} key(s)`)
}
issues.push(...placeholderIssues(locale, sourceCatalog, catalog))
}
if (issues.length > 0) {
console.error('Locale validation failed:')
for (const issue of issues) {
console.error(`- ${issue}`)
}
process.exit(1)
}
console.log(`Validated ${localeFiles.length} locale catalog(s) against ${sourceKeys.length} English keys.`)

83
src-tauri/Cargo.lock generated
View File

@@ -1239,6 +1239,15 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "funty"
version = "2.0.0"
@@ -2069,6 +2078,26 @@ dependencies = [
"cfb",
]
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -2207,6 +2236,26 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "kqueue"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@@ -2386,6 +2435,18 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.1.1"
@@ -2489,6 +2550,25 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
dependencies = [
"bitflags 2.11.0",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio 0.8.11",
"walkdir",
"windows-sys 0.48.0",
]
[[package]]
name = "num-conv"
version = "0.2.0"
@@ -5042,7 +5122,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
dependencies = [
"bytes",
"libc",
"mio",
"mio 1.1.1",
"pin-project-lite",
"socket2",
"tokio-macros",
@@ -5103,6 +5183,7 @@ dependencies = [
"futures-util",
"gray_matter",
"log",
"notify",
"regex",
"sentry",
"serde",

View File

@@ -20,6 +20,7 @@ serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
log = "0.4"
notify = "6.1"
tauri = { version = "2.10.0", features = ["protocol-asset", "devtools"] }
tauri-plugin-log = "2"
gray_matter = "0.2"

View File

@@ -1,13 +1,21 @@
use serde::{Deserialize, Serialize};
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AiAgentId {
ClaudeCode,
Codex,
Opencode,
Pi,
Gemini,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum AiAgentPermissionMode {
#[default]
Safe,
PowerUser,
}
#[derive(Debug, Clone, Serialize)]
@@ -20,6 +28,9 @@ pub struct AiAgentAvailability {
pub struct AiAgentsStatus {
pub claude_code: AiAgentAvailability,
pub codex: AiAgentAvailability,
pub opencode: AiAgentAvailability,
pub pi: AiAgentAvailability,
pub gemini: AiAgentAvailability,
}
#[derive(Debug, Clone, Serialize)]
@@ -57,12 +68,22 @@ 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(),
codex: crate::codex_cli::check_cli(),
opencode: crate::opencode_cli::check_cli(),
pi: crate::pi_cli::check_cli(),
gemini: crate::gemini_cli::check_cli(),
}
}
@@ -70,12 +91,14 @@ pub fn run_ai_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Res
where
F: FnMut(AiAgentStreamEvent),
{
let permission_mode = request.permission_mode();
match request.agent {
AiAgentId::ClaudeCode => {
let mapped = crate::claude_cli::AgentStreamRequest {
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
permission_mode,
};
crate::claude_cli::run_agent_stream(mapped, |event| {
if let Some(mapped_event) = map_claude_event(event) {
@@ -83,7 +106,42 @@ 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)
}
AiAgentId::Pi => {
let mapped = crate::pi_cli::AgentStreamRequest {
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
permission_mode,
};
crate::pi_cli::run_agent_stream(mapped, emit)
}
AiAgentId::Gemini => {
let mapped = crate::gemini_cli::AgentStreamRequest {
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
permission_mode,
};
crate::gemini_cli::run_agent_stream(mapped, emit)
}
}
}
@@ -95,314 +153,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 version_for_binary(binary: &PathBuf) -> Option<String> {
Command::new(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> {
Command::new("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> {
Command::new(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 = Command::new(binary);
command
.args(args)
.arg(prompt)
.current_dir(&request.vault_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
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_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![
"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 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 map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAgentStreamEvent> {
match event {
crate::claude_cli::ClaudeStreamEvent::Init { session_id } => {
@@ -430,6 +180,9 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAge
Some(AiAgentStreamEvent::Error { message })
}
crate::claude_cli::ClaudeStreamEvent::Done => Some(AiAgentStreamEvent::Done),
crate::claude_cli::ClaudeStreamEvent::Result { text, .. } if !text.is_empty() => {
Some(AiAgentStreamEvent::TextDelta { text })
}
crate::claude_cli::ClaudeStreamEvent::Result { .. } => None,
}
}
@@ -439,151 +192,19 @@ mod tests {
use super::*;
#[test]
fn normalize_status_contains_both_agents() {
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));
}
#[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!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
assert!(args.contains(&"--json".to_string()));
assert!(args.contains(&"-C".to_string()));
}
}
#[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,
status.gemini.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"
));
assert!(install_flags
.iter()
.all(|installed| matches!(installed, true | false)));
}
#[test]
@@ -592,4 +213,17 @@ mod tests {
assert!(matches!(mapped, Some(AiAgentStreamEvent::Done)));
}
#[test]
fn map_claude_result_event_preserves_final_text() {
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Result {
text: "Final answer from Claude".into(),
session_id: "session-1".into(),
});
assert!(matches!(
mapped,
Some(AiAgentStreamEvent::TextDelta { text }) if text == "Final answer from Claude"
));
}
}

View File

@@ -1,8 +1,9 @@
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::{Command, ExitStatus, Stdio};
use std::process::{ExitStatus, Stdio};
/// Status returned by `check_claude_cli`.
#[derive(Debug, Serialize, Clone)]
@@ -50,14 +51,6 @@ pub struct ChatStreamRequest {
pub session_id: Option<String>,
}
/// Parameters accepted by `stream_claude_agent`.
#[derive(Debug, Deserialize)]
pub struct AgentStreamRequest {
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: String,
}
// ---------------------------------------------------------------------------
// Finding the `claude` binary
// ---------------------------------------------------------------------------
@@ -79,7 +72,7 @@ pub(crate) fn find_claude_binary() -> Result<PathBuf, String> {
}
fn find_claude_binary_on_path() -> Option<PathBuf> {
Command::new(claude_path_lookup_command())
crate::hidden_command(claude_path_lookup_command())
.arg("claude")
.output()
.ok()
@@ -114,7 +107,7 @@ fn user_shell_candidates() -> Vec<PathBuf> {
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
Command::new(shell)
crate::hidden_command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
@@ -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 = Command::new(&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),
}
}
@@ -269,7 +272,11 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
"--include-partial-messages".into(),
"--mcp-config".into(),
mcp_config,
"--dangerously-skip-permissions".into(),
"--strict-mcp-config".into(),
"--permission-mode".into(),
"acceptEdits".into(),
"--tools".into(),
agent_tools(req.permission_mode).into(),
"--no-session-persistence".into(),
];
@@ -283,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 }
}
}
@@ -310,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.
@@ -323,62 +338,28 @@ fn run_claude_subprocess<F>(
where
F: FnMut(ClaudeStreamEvent),
{
let mut cmd = Command::new(bin);
cmd.args(args)
.env_remove("CLAUDECODE") // prevent "nested session" guard
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
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),
});
}
@@ -387,6 +368,23 @@ where
Ok(state.session_id)
}
fn build_claude_command(
bin: &PathBuf,
args: &[String],
cwd: Option<&str>,
) -> std::process::Command {
let mut cmd = crate::hidden_command(bin);
cmd.args(args)
.env_remove("CLAUDECODE") // prevent "nested session" guard
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
cmd
}
fn format_failed_claude_exit(stderr_output: &str, status: ExitStatus) -> String {
if is_claude_auth_error(stderr_output) {
return "Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into();
@@ -459,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,
@@ -469,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);
}
}
}
@@ -504,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") => {
@@ -551,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(
@@ -589,6 +623,85 @@ fn extract_tool_result_text(json: &serde_json::Value) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
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() {
@@ -600,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") {
@@ -619,6 +776,7 @@ mod tests {
session_id: String::new(),
tool_inputs: HashMap::new(),
current_tool_id: None,
emitted_text: false,
}
}
@@ -748,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]
@@ -902,6 +1085,33 @@ mod tests {
// --- run_claude_subprocess with mock scripts ---
#[test]
fn build_claude_command_keeps_streaming_process_contract() {
let bin = PathBuf::from("claude");
let args = vec!["-p".to_string(), "hello".to_string()];
let command = build_claude_command(&bin, &args, Some("/tmp/vault"));
let actual_args: Vec<OsString> = command.get_args().map(OsStr::to_os_string).collect();
let claude_code_env = command
.get_envs()
.find(|(key, _)| *key == OsStr::new("CLAUDECODE"))
.map(|(_, value)| value.map(OsStr::to_os_string));
assert_eq!(
(
command.get_program().to_os_string(),
actual_args,
command.get_current_dir().map(Path::to_path_buf),
claude_code_env,
),
(
OsString::from("claude"),
vec![OsString::from("-p"), OsString::from("hello")],
Some(PathBuf::from("/tmp/vault")),
Some(None),
),
);
}
#[cfg(unix)]
fn run_mock_script(script: &str) -> (Result<String, String>, Vec<ClaudeStreamEvent>) {
run_mock_script_with_args(script, &[])
@@ -938,6 +1148,99 @@ mod tests {
assert!(matches!(&events[3], ClaudeStreamEvent::Done));
}
#[test]
fn run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open() {
use std::io::Read;
use std::time::{Duration, Instant};
let mut child = Command::new(std::env::current_exe().unwrap())
.arg("stdin_probe_parent_child")
.arg("--ignored")
.arg("--nocapture")
.env("TOLARIA_STDIN_PROBE_CHILD", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let child_stdin = child.stdin.take().unwrap();
let mut stdout = child.stdout.take().unwrap();
let mut stderr = child.stderr.take().unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
if Instant::now() >= deadline {
child.kill().unwrap();
drop(child_stdin);
panic!("stdin probe child timed out");
}
std::thread::sleep(Duration::from_millis(10));
};
drop(child_stdin);
let mut stdout_text = String::new();
let mut stderr_text = String::new();
stdout.read_to_string(&mut stdout_text).unwrap();
stderr.read_to_string(&mut stderr_text).unwrap();
assert!(
status.success(),
"stdin probe child failed with {status}\nstdout:\n{stdout_text}\nstderr:\n{stderr_text}"
);
}
#[ignore = "spawned by run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open"]
#[test]
fn stdin_probe_parent_child() {
if std::env::var_os("TOLARIA_STDIN_PROBE_CHILD").is_none() {
return;
}
let fake_bin = std::env::current_exe().unwrap();
let args = vec![
"stdin_probe_mock_claude_child".to_string(),
"--ignored".to_string(),
"--nocapture".to_string(),
];
std::env::set_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD", "1");
let mut events = vec![];
let result = run_claude_subprocess(&fake_bin, &args, None, &mut |event| events.push(event));
std::env::remove_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD");
assert_eq!(result.unwrap(), "stdin-ok");
assert!(matches!(
events.first(),
Some(ClaudeStreamEvent::Result { text, session_id })
if text == "stdin closed" && session_id == "stdin-ok"
));
assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done)));
}
#[ignore = "spawned by stdin_probe_parent_child"]
#[test]
fn stdin_probe_mock_claude_child() {
if std::env::var_os("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD").is_none() {
return;
}
use std::io::Read;
let mut stdin = String::new();
std::io::stdin().read_to_string(&mut stdin).unwrap();
assert!(stdin.is_empty(), "stdin was not EOF");
println!(
"{}",
serde_json::json!({
"type": "result",
"result": "stdin closed",
"session_id": "stdin-ok"
})
);
}
#[cfg(unix)]
#[test]
fn run_subprocess_skips_blank_and_non_json_lines() {
@@ -1005,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 ---
@@ -1058,41 +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(&"--dangerously-skip-permissions".to_string()));
assert!(args.contains(&"--no-session-persistence".to_string()));
assert!(!args.contains(&"--append-system-prompt".to_string()));
// Native tools must NOT be disabled
assert!(!args.contains(&"--tools".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()));
}
}
@@ -1102,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"),
@@ -1110,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]
@@ -1191,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));

View 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
View 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"));
}
}

View File

@@ -1,6 +1,6 @@
#[cfg(desktop)]
use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus};
use crate::claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus};
use crate::claude_cli::{ChatStreamRequest, ClaudeCliStatus};
use crate::vault::VaultAiGuidanceStatus;
use super::expand_tilde;
@@ -81,14 +81,6 @@ define_desktop_stream_command!(
crate::claude_cli::run_chat_stream
);
#[cfg(desktop)]
define_desktop_stream_command!(
stream_claude_agent,
AgentStreamRequest,
"claude-agent-stream",
crate::claude_cli::run_agent_stream
);
#[cfg(desktop)]
define_desktop_stream_command!(
stream_ai_agent,
@@ -120,6 +112,18 @@ pub fn get_ai_agents_status() -> AiAgentsStatus {
installed: false,
version: None,
},
opencode: crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
pi: crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
gemini: crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
}
}
@@ -132,15 +136,6 @@ pub async fn stream_claude_chat(
Err("Claude CLI is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn stream_claude_agent(
_app_handle: tauri::AppHandle,
_request: AgentStreamRequest,
) -> Result<String, String> {
Err("Claude CLI is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn stream_ai_agent(
@@ -163,14 +158,17 @@ 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());
}
}

View File

@@ -158,10 +158,62 @@ pub fn is_git_repo(vault_path: VaultPathArg) -> bool {
.is_dir()
}
#[cfg(desktop)]
fn validate_git_init_target(vault_path: &str) -> Result<(), String> {
let path = std::path::Path::new(vault_path);
if !path.exists() {
return Err("Choose an existing vault folder before initializing Git".to_string());
}
if !path.is_dir() {
return Err("Choose a folder before initializing Git".to_string());
}
if is_broad_personal_folder(path) && !has_tolaria_vault_marker(path) {
return Err(format!(
"Choose a dedicated vault folder before initializing Git. '{}' looks like a broad personal folder; create or select a subfolder such as '{}' instead.",
path.display(),
path.join("Tolaria").display()
));
}
Ok(())
}
#[cfg(desktop)]
fn is_broad_personal_folder(path: &std::path::Path) -> bool {
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
matches!(
name.to_ascii_lowercase().as_str(),
"desktop"
| "documents"
| "downloads"
| "movies"
| "music"
| "pictures"
| "public"
| "templates"
| "videos"
)
}
#[cfg(desktop)]
fn has_tolaria_vault_marker(path: &std::path::Path) -> bool {
["AGENTS.md", "CLAUDE.md", "type.md", "note.md"]
.iter()
.any(|file| path.join(file).is_file())
|| ["attachments", "type", "views"]
.iter()
.any(|dir| path.join(dir).is_dir())
}
#[cfg(desktop)]
#[tauri::command]
pub fn init_git_repo(vault_path: VaultPathArg) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
validate_git_init_target(&vault_path)?;
crate::git::init_repo(&vault_path)
}
@@ -374,6 +426,33 @@ mod tests {
);
}
#[test]
fn init_git_repo_rejects_broad_personal_folders() {
let dir = TempDir::new().unwrap();
let documents = dir.path().join("Documents");
fs::create_dir_all(&documents).unwrap();
fs::write(documents.join("unrelated.txt"), "not a vault").unwrap();
let err = init_git_repo(documents.to_string_lossy().into_owned())
.expect_err("expected Documents itself to be rejected before git init");
assert!(err.contains("dedicated vault folder"));
assert!(!documents.join(".git").exists());
}
#[test]
fn init_git_repo_allows_named_vault_subfolder_under_documents() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("Documents").join("Tolaria");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
let vault = vault.to_string_lossy().into_owned();
init_git_repo(vault.clone()).unwrap();
assert!(is_git_repo(vault));
}
#[tokio::test]
async fn desktop_remote_commands_report_no_remote() {
let (_dir, vault) = create_initialized_vault();

View File

@@ -131,6 +131,31 @@ 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(
app: tauri::AppHandle,
vault_path: Option<String>,
) -> Result<String, String> {
let expanded_vault_path = vault_path
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
.map(|path| super::expand_tilde(path).into_owned());
let vault_path = expanded_vault_path.as_deref().map(std::path::Path::new);
crate::sync_ws_bridge_for_vault(&app, vault_path).map(str::to_string)
}
// ── MCP commands (mobile stubs) ─────────────────────────────────────────────
#[cfg(mobile)]
@@ -151,6 +176,18 @@ 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> {
Err("MCP is not available on mobile".into())
}
// ── Menu commands ───────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
@@ -162,6 +199,7 @@ pub struct MenuStateUpdate {
has_restorable_deleted_note: Option<bool>,
has_no_remote: Option<bool>,
note_list_search_enabled: Option<bool>,
editor_find_enabled: Option<bool>,
}
#[cfg(desktop)]
@@ -186,6 +224,9 @@ pub fn update_menu_state(
if let Some(v) = state.note_list_search_enabled {
menu::set_note_list_search_items_enabled(&app_handle, v);
}
if let Some(v) = state.editor_find_enabled {
menu::set_editor_find_items_enabled(&app_handle, v);
}
Ok(())
}

View File

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

View File

@@ -23,6 +23,14 @@ fn with_note_path<T>(
)
}
fn with_external_file_path<T>(
path: &Path,
vault_path: Option<&Path>,
action: impl FnOnce(&Path) -> Result<T, String>,
) -> Result<T, String> {
with_note_path(path, vault_path, ValidatedPathMode::Existing, action)
}
fn with_expanded_vault_root<T>(
path: &Path,
action: impl FnOnce(&Path) -> Result<T, String>,
@@ -75,6 +83,26 @@ pub fn sync_vault_asset_scope_for_window(
})
}
#[tauri::command]
pub fn open_vault_file_external(
app_handle: tauri::AppHandle,
path: PathBuf,
vault_path: Option<PathBuf>,
) -> Result<(), String> {
with_external_file_path(path.as_path(), vault_path.as_deref(), |validated_path| {
open_path_with_default_app(&app_handle, validated_path)
})
}
fn open_path_with_default_app(app_handle: &tauri::AppHandle, path: &Path) -> Result<(), String> {
use tauri_plugin_opener::OpenerExt;
app_handle
.opener()
.open_path(path.to_string_lossy().into_owned(), None::<String>)
.map_err(|error| error.to_string())
}
fn with_writable_note_path<T>(
path: PathBuf,
vault_path: Option<PathBuf>,
@@ -101,6 +129,20 @@ pub fn get_note_content(path: PathBuf, vault_path: Option<PathBuf>) -> Result<St
)
}
#[tauri::command]
pub fn validate_note_content(
path: PathBuf,
content: String,
vault_path: Option<PathBuf>,
) -> Result<bool, String> {
with_note_path(
path.as_path(),
vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| vault::note_content_matches(validated_path, &content),
)
}
#[tauri::command]
pub fn save_note_content(
path: PathBuf,
@@ -165,6 +207,24 @@ fn ensure_missing_folder(folder_path: &Path, folder_name: &str) -> Result<(), St
Ok(())
}
fn scan_visible_vault_entries(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
let entries = vault::scan_vault_cached(vault_path)?;
Ok(vault::filter_gitignored_entries(
vault_path,
entries,
crate::settings::hide_gitignored_files_enabled(),
))
}
fn scan_visible_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String> {
let folders = vault::scan_vault_folders(vault_path)?;
Ok(vault::filter_gitignored_folders(
vault_path,
folders,
crate::settings::hide_gitignored_files_enabled(),
))
}
/// Sync the `title` frontmatter field with the filename on note open.
/// Returns `true` if the file was modified (title was absent or desynced).
#[tauri::command]
@@ -207,12 +267,12 @@ pub fn copy_image_to_vault(
#[tauri::command]
pub fn list_vault(path: PathBuf) -> Result<Vec<VaultEntry>, String> {
with_expanded_vault_root(path.as_path(), vault::scan_vault_cached)
with_expanded_vault_root(path.as_path(), scan_visible_vault_entries)
}
#[tauri::command]
pub fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
with_expanded_vault_root(path.as_path(), vault::scan_vault_folders)
with_expanded_vault_root(path.as_path(), scan_visible_vault_folders)
}
#[cfg(test)]
@@ -266,6 +326,28 @@ mod tests {
.contains("title: Command Note"));
}
#[test]
fn note_content_commands_accept_windows_sensitive_valid_segments() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
let note = root
.join("@raflymln")
.join("notes with spaces")
.join("résumé note.md");
save_note_content(
note.clone(),
"# Windows-Sensitive Path\n\nBody\n".to_string(),
Some(root.clone()),
)
.unwrap();
assert_eq!(
get_note_content(note, Some(root)).unwrap(),
"# Windows-Sensitive Path\n\nBody\n"
);
}
#[test]
fn folder_and_listing_commands_use_expanded_vault_root() {
let dir = TempDir::new().unwrap();
@@ -301,4 +383,53 @@ mod tests {
.unwrap_err();
assert!(folder_error.contains("Path must stay inside the active vault"));
}
#[test]
fn external_file_paths_accept_files_inside_requested_vault() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
let attachment = note_path(&dir, "attachments/photo.png");
fs::create_dir_all(attachment.parent().unwrap()).unwrap();
fs::write(&attachment, "image-bytes").unwrap();
let validated = with_external_file_path(
attachment.as_path(),
Some(root.as_path()),
|validated_path| Ok(validated_path.to_path_buf()),
)
.unwrap();
assert_eq!(validated, attachment);
}
#[test]
fn external_file_paths_reject_files_outside_requested_vault() {
let vault = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("photo.png");
fs::write(&outside_file, "image-bytes").unwrap();
let error = with_external_file_path(
outside_file.as_path(),
Some(vault.path()),
|validated_path| Ok(validated_path.to_path_buf()),
)
.unwrap_err();
assert!(error.contains("Path must stay inside the active vault"));
}
#[test]
fn validate_note_content_compares_against_disk() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
let note = note_path(&dir, "note.md");
fs::write(&note, "# Fresh\n").unwrap();
assert!(
validate_note_content(note.clone(), "# Fresh\n".to_string(), Some(root.clone()),)
.unwrap()
);
assert!(!validate_note_content(note, "# Stale\n".to_string(), Some(root)).unwrap());
}
}

View File

@@ -83,8 +83,14 @@ pub async fn reload_vault(
let path = expand_tilde(&path).into_owned();
crate::sync_vault_asset_scope(&app_handle, Path::new(&path))?;
tokio::task::spawn_blocking(move || {
vault::invalidate_cache(Path::new(&path));
vault::scan_vault_cached(Path::new(&path))
let vault_path = Path::new(&path);
vault::invalidate_cache(vault_path);
let entries = vault::scan_vault_cached(vault_path)?;
Ok(vault::filter_gitignored_entries(
vault_path,
entries,
crate::settings::hide_gitignored_files_enabled(),
))
})
.await
.map_err(|e| format!("Task panicked: {e}"))?

220
src-tauri/src/gemini_cli.rs Normal file
View File

@@ -0,0 +1,220 @@
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
pub use crate::cli_agent_runtime::AgentStreamRequest;
use std::path::Path;
use std::process::Output;
pub fn check_cli() -> AiAgentAvailability {
crate::gemini_discovery::check_cli()
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = crate::gemini_discovery::find_binary()?;
run_agent_stream_with_binary(&binary, request, emit)
}
fn run_agent_stream_with_binary<F>(
binary: &Path,
request: AgentStreamRequest,
mut emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let settings_dir = tempfile::Builder::new()
.prefix("tolaria-gemini-agent-")
.tempdir()
.map_err(|error| format!("Failed to create Gemini settings directory: {error}"))?;
let mut command = crate::gemini_config::build_command(binary, &request, settings_dir.path())?;
let output = command
.output()
.map_err(|error| format!("Failed to spawn gemini: {error}"))?;
emit(AiAgentStreamEvent::Init {
session_id: "gemini-headless".into(),
});
if output.status.success() {
emit_gemini_success(&output, &mut emit);
} else {
emit(AiAgentStreamEvent::Error {
message: format_gemini_error(output_stderr(&output), output.status.to_string()),
});
}
emit(AiAgentStreamEvent::Done);
Ok("gemini-headless".into())
}
fn emit_gemini_success<F>(output: &Output, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
match gemini_response_text(&output_stdout(output)) {
GeminiOutput::Response(text) => emit(AiAgentStreamEvent::TextDelta { text }),
GeminiOutput::Error(message) => emit(AiAgentStreamEvent::Error { message }),
GeminiOutput::Empty => {}
}
}
enum GeminiOutput {
Response(String),
Error(String),
Empty,
}
fn gemini_response_text(stdout: &str) -> GeminiOutput {
let trimmed = stdout.trim();
if trimmed.is_empty() {
return GeminiOutput::Empty;
}
match serde_json::from_str::<serde_json::Value>(trimmed) {
Ok(json) => response_from_json(&json),
Err(_) => GeminiOutput::Response(trimmed.to_string()),
}
}
fn response_from_json(json: &serde_json::Value) -> GeminiOutput {
if let Some(message) = json["error"]["message"].as_str() {
return GeminiOutput::Error(message.to_string());
}
if let Some(response) = json["response"].as_str() {
return GeminiOutput::Response(response.to_string());
}
GeminiOutput::Empty
}
fn output_stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).to_string()
}
fn output_stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).to_string()
}
fn format_gemini_error(stderr_output: String, status: String) -> String {
let lower = stderr_output.to_ascii_lowercase();
if is_auth_error(&lower) {
return "Gemini CLI is not authenticated. Run `gemini` in your terminal to sign in, or set GEMINI_API_KEY and retry.".into();
}
if stderr_output.trim().is_empty() {
format!("gemini exited with status {status}")
} else {
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
}
}
fn is_auth_error(lower: &str) -> bool {
[
"auth",
"login",
"sign in",
"api key",
"gemini_api_key",
"google_api_key",
"oauth",
"401",
]
.iter()
.any(|pattern| lower.contains(pattern))
}
#[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("gemini");
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_gemini_json_response() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' '{"response":"Done","stats":{"tools":{"totalCalls":0}}}'"#,
);
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, "gemini-headless");
assert!(matches!(
&events[0],
AiAgentStreamEvent::Init { session_id } if session_id == "gemini-headless"
));
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_gemini_auth_errors() {
let dir = tempfile::tempdir().unwrap();
let vault = tempfile::tempdir().unwrap();
let binary = executable_script(
dir.path(),
r#"printf '%s\n' 'oauth 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, "gemini-headless");
assert!(events.iter().any(|event| matches!(
event,
AiAgentStreamEvent::Error { message } if message.contains("not authenticated")
)));
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
}
#[test]
fn gemini_response_text_reads_json_response_or_plain_text() {
match gemini_response_text(r#"{"response":"Structured"}"#) {
GeminiOutput::Response(text) => assert_eq!(text, "Structured"),
_ => panic!("expected response"),
}
match gemini_response_text("Plain answer") {
GeminiOutput::Response(text) => assert_eq!(text, "Plain answer"),
_ => panic!("expected response"),
}
}
}

View File

@@ -0,0 +1,164 @@
use crate::ai_agents::AiAgentPermissionMode;
use crate::gemini_cli::AgentStreamRequest;
use std::path::{Path, PathBuf};
use std::process::Stdio;
pub(crate) fn build_command(
binary: &Path,
request: &AgentStreamRequest,
settings_dir: &Path,
) -> Result<std::process::Command, String> {
let settings_path = write_settings(settings_dir, &request.vault_path, request.permission_mode)?;
let mut command = crate::hidden_command(binary);
command
.args(build_args(request.permission_mode))
.arg("--prompt")
.arg(build_prompt(request))
.env("GEMINI_CLI_SYSTEM_SETTINGS_PATH", settings_path)
.env("NO_COLOR", "1")
.current_dir(&request.vault_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
Ok(command)
}
fn build_args(permission_mode: AiAgentPermissionMode) -> Vec<String> {
vec![
"--output-format".into(),
"json".into(),
"--approval-mode".into(),
approval_mode(permission_mode).into(),
]
}
fn approval_mode(permission_mode: AiAgentPermissionMode) -> &'static str {
match permission_mode {
AiAgentPermissionMode::Safe => "auto_edit",
AiAgentPermissionMode::PowerUser => "yolo",
}
}
fn build_prompt(request: &AgentStreamRequest) -> String {
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
}
fn write_settings(
settings_dir: &Path,
vault_path: &str,
permission_mode: AiAgentPermissionMode,
) -> Result<PathBuf, String> {
std::fs::create_dir_all(settings_dir)
.map_err(|error| format!("Failed to create Gemini settings directory: {error}"))?;
let settings_path = settings_dir.join("settings.json");
let settings = build_settings(vault_path, permission_mode)?;
std::fs::write(&settings_path, settings)
.map_err(|error| format!("Failed to write Gemini settings: {error}"))?;
Ok(settings_path)
}
fn build_settings(
vault_path: &str,
permission_mode: AiAgentPermissionMode,
) -> Result<String, String> {
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
let mut settings = serde_json::json!({
"mcpServers": {
"tolaria": {
"command": "node",
"args": [mcp_server_path],
"env": {
"VAULT_PATH": vault_path,
"WS_UI_PORT": "9711"
},
"description": "Tolaria active vault MCP server",
"trust": permission_mode == AiAgentPermissionMode::PowerUser
}
}
});
if permission_mode == AiAgentPermissionMode::Safe {
settings["tools"] = serde_json::json!({
"exclude": ["run_shell_command"]
});
}
serde_json::to_string(&settings)
.map_err(|error| format!("Failed to serialize Gemini settings: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
fn request() -> AgentStreamRequest {
AgentStreamRequest {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: AiAgentPermissionMode::Safe,
}
}
#[test]
fn command_uses_headless_json_mode_and_temp_settings() {
let settings_dir = tempfile::tempdir().unwrap();
let command =
build_command(&PathBuf::from("gemini"), &request(), settings_dir.path()).unwrap();
let actual_args: Vec<&OsStr> = command.get_args().collect();
let settings_path = command
.get_envs()
.find(|(key, _)| *key == OsStr::new("GEMINI_CLI_SYSTEM_SETTINGS_PATH"))
.and_then(|(_, value)| value);
assert_eq!(command.get_program(), OsStr::new("gemini"));
assert_eq!(actual_args[0], OsStr::new("--output-format"));
assert_eq!(actual_args[1], OsStr::new("json"));
assert!(actual_args.contains(&OsStr::new("--prompt")));
assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note")));
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
assert!(settings_path.is_some());
assert!(settings_dir.path().join("settings.json").exists());
}
#[test]
fn safe_settings_include_tolaria_mcp_and_exclude_shell() {
let settings = build_settings("/tmp/vault", AiAgentPermissionMode::Safe).unwrap();
let json: serde_json::Value = serde_json::from_str(&settings).unwrap();
assert_eq!(json["mcpServers"]["tolaria"]["command"], "node");
assert_eq!(
json["mcpServers"]["tolaria"]["env"]["VAULT_PATH"],
"/tmp/vault"
);
assert_eq!(json["mcpServers"]["tolaria"]["env"]["WS_UI_PORT"], "9711");
assert_eq!(json["mcpServers"]["tolaria"]["trust"], false);
assert_eq!(json["tools"]["exclude"][0], "run_shell_command");
assert!(json["mcpServers"]["tolaria"]["args"][0]
.as_str()
.unwrap()
.ends_with("index.js"));
}
#[test]
fn power_user_settings_trust_tolaria_and_allow_shell_discovery() {
let settings = build_settings("/tmp/vault", AiAgentPermissionMode::PowerUser).unwrap();
let json: serde_json::Value = serde_json::from_str(&settings).unwrap();
assert_eq!(json["mcpServers"]["tolaria"]["trust"], true);
assert!(json.get("tools").is_none());
assert_eq!(approval_mode(AiAgentPermissionMode::PowerUser), "yolo");
}
#[test]
fn prompt_keeps_system_prompt_first() {
let prompt = build_prompt(&AgentStreamRequest {
system_prompt: Some("Be concise".into()),
..request()
});
assert!(prompt.starts_with("System instructions:\nBe concise"));
assert!(prompt.contains("User request:\nRename the note"));
}
}

View File

@@ -0,0 +1,187 @@
use crate::ai_agents::AiAgentAvailability;
use std::path::{Path, PathBuf};
pub(crate) fn check_cli() -> AiAgentAvailability {
match find_binary() {
Ok(binary) => AiAgentAvailability {
installed: true,
version: crate::cli_agent_runtime::version_for_binary(&binary),
},
Err(_) => AiAgentAvailability {
installed: false,
version: None,
},
}
}
pub(crate) fn find_binary() -> Result<PathBuf, String> {
if let Some(binary) = find_binary_on_path() {
return Ok(binary);
}
if let Some(binary) = find_binary_in_user_shell() {
return Ok(binary);
}
if let Some(binary) = find_existing_binary(gemini_binary_candidates()) {
return Ok(binary);
}
Err("Gemini CLI not found. Install it: https://google-gemini.github.io/gemini-cli/".into())
}
fn find_binary_on_path() -> Option<PathBuf> {
crate::hidden_command(path_lookup_command())
.arg("gemini")
.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()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, "gemini"))
}
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 find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
fn gemini_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| gemini_binary_candidates_for_home(&home))
.unwrap_or_default()
}
fn gemini_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let mut candidates = vec![
home.join(".local/bin/gemini"),
home.join(".local/bin/gemini.exe"),
home.join(".gemini/bin/gemini"),
home.join(".gemini/bin/gemini.exe"),
home.join(".local/share/mise/shims/gemini"),
home.join(".local/share/mise/shims/gemini.exe"),
home.join(".asdf/shims/gemini"),
home.join(".asdf/shims/gemini.exe"),
home.join(".npm-global/bin/gemini"),
home.join(".npm-global/bin/gemini.cmd"),
home.join(".npm-global/bin/gemini.exe"),
home.join(".npm/bin/gemini"),
home.join(".npm/bin/gemini.cmd"),
home.join(".npm/bin/gemini.exe"),
home.join(".bun/bin/gemini"),
home.join(".bun/bin/gemini.exe"),
home.join("AppData/Roaming/npm/gemini.cmd"),
home.join("AppData/Roaming/npm/gemini.exe"),
home.join("AppData/Local/pnpm/gemini.cmd"),
home.join("AppData/Local/pnpm/gemini.exe"),
home.join("scoop/shims/gemini.exe"),
PathBuf::from("/usr/local/bin/gemini"),
PathBuf::from("/opt/homebrew/bin/gemini"),
];
candidates.extend(nvm_binary_candidates_for_home(home));
candidates
}
fn nvm_binary_candidates_for_home(home: &Path) -> 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("gemini"))
.collect::<Vec<_>>();
candidates.sort();
candidates
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn binary_candidates_include_supported_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = gemini_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/gemini"),
home.join(".gemini/bin/gemini"),
home.join(".local/share/mise/shims/gemini"),
home.join(".asdf/shims/gemini"),
home.join(".npm-global/bin/gemini"),
home.join(".bun/bin/gemini"),
PathBuf::from("/opt/homebrew/bin/gemini"),
];
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-gemini");
let gemini = dir.path().join("gemini");
std::fs::write(&gemini, "#!/bin/sh\n").unwrap();
let stdout = format!("\n{}\n{}\n", missing.display(), gemini.display());
assert_eq!(first_existing_path(&stdout), Some(gemini));
}
}

View File

@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Output;
use super::git_command;
use super::{ensure_author_config, git_command};
const DEFAULT_REMOTE_NAME: &str = "origin";
@@ -92,6 +92,8 @@ pub fn git_add_remote(vault_path: &str, remote_url: &str) -> Result<GitAddRemote
));
}
ensure_author_config(vault)?;
let branch = current_branch(vault)?;
if branch.is_empty() {
return Ok(connect_result(
@@ -444,6 +446,28 @@ mod tests {
git_commit(path.to_str().unwrap(), message).unwrap();
}
fn clear_local_author(path: &Path) {
for key in ["user.name", "user.email"] {
StdCommand::new("git")
.args(["config", "--local", "--unset-all", key])
.current_dir(path)
.output()
.unwrap();
}
}
fn local_author_is_configured(path: &Path) -> bool {
["user.name", "user.email"].into_iter().all(|key| {
let output = StdCommand::new("git")
.args(["config", "--local", key])
.current_dir(path)
.output()
.unwrap();
output.status.success() && !String::from_utf8_lossy(&output.stdout).trim().is_empty()
})
}
#[test]
fn disconnect_all_remotes_removes_every_remote() {
let dir = setup_git_repo();
@@ -489,6 +513,26 @@ mod tests {
assert_eq!((status.ahead, status.behind), (0, 0));
}
#[test]
fn git_add_remote_sets_local_identity_when_existing_repo_has_none() {
let local = setup_git_repo();
create_local_commit(local.path(), "note.md", "Local", "Initial local commit");
clear_local_author(local.path());
assert!(!local_author_is_configured(local.path()));
let bare = TempDir::new().unwrap();
init_bare_remote(bare.path());
let result = git_add_remote(
local.path().to_str().unwrap(),
bare.path().to_str().unwrap(),
)
.unwrap();
assert_eq!(result.status, "connected");
assert!(local_author_is_configured(local.path()));
}
#[test]
fn git_add_remote_pushes_when_remote_is_the_local_branch_ancestor() {
let local = setup_git_repo();

View File

@@ -129,21 +129,20 @@ fn git_command_label<'a>(args: &'a [&'a str]) -> &'a str {
}
/// Set local user.name and user.email if not already configured.
fn ensure_author_config(dir: &Path) -> Result<(), String> {
for (key, fallback) in [
("user.name", "Tolaria"),
("user.email", "vault@tolaria.app"),
] {
let check = git_command()
.args(["config", key])
pub(crate) fn ensure_author_config(dir: &Path) -> Result<(), String> {
for (key, fallback) in [("user.name", "Tolaria"), ("user.email", "vault@tolaria.md")] {
let local = git_command()
.args(["config", "--local", key])
.current_dir(dir)
.output()
.map_err(|e| format!("Failed to check git config: {}", e))?;
.map_err(|e| format!("Failed to check git config {key}: {e}"))?;
let value = String::from_utf8_lossy(&check.stdout);
if !check.status.success() || value.trim().is_empty() {
run_git(dir, &["config", key, fallback])?;
let value = String::from_utf8_lossy(&local.stdout);
if local.status.success() && !value.trim().is_empty() {
continue;
}
run_git(dir, &["config", "--local", key, fallback])?;
}
Ok(())
}

View File

@@ -1,17 +1,33 @@
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 gemini_cli;
mod gemini_config;
mod gemini_discovery;
pub mod git;
pub mod mcp;
#[cfg(desktop)]
pub mod menu;
pub mod opencode_cli;
mod opencode_config;
mod opencode_discovery;
mod opencode_events;
pub mod pi_cli;
mod pi_config;
mod pi_discovery;
mod pi_events;
pub mod search;
pub mod settings;
pub mod telemetry;
pub mod vault;
pub mod vault_list;
pub mod vault_watcher;
#[cfg(desktop)]
mod window_state;
use std::ffi::OsStr;
use std::process::Command;
@@ -95,6 +111,78 @@ fn log_startup_result(label: &str, result: Result<usize, String>) {
}
}
#[cfg(desktop)]
fn selected_mcp_bridge_vault_path(vault_list: &vault_list::VaultList) -> Option<PathBuf> {
vault_list
.active_vault
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
.map(PathBuf::from)
}
#[cfg(desktop)]
fn validate_mcp_bridge_vault_path(vault_path: &Path) -> Result<PathBuf, String> {
let resolved = std::fs::canonicalize(vault_path).map_err(|e| {
format!(
"MCP bridge vault is not available: {} ({e})",
vault_path.display()
)
})?;
if !resolved.is_dir() {
return Err(format!(
"MCP bridge vault is not available: {} is not a directory",
vault_path.display()
));
}
Ok(resolved)
}
#[cfg(desktop)]
fn stop_ws_bridge_child(active_child: &mut Option<Child>) {
if let Some(mut child) = active_child.take() {
let _ = child.kill();
let _ = child.wait();
log::info!("ws-bridge child process stopped");
}
}
#[cfg(desktop)]
pub(crate) fn sync_ws_bridge_for_vault(
app_handle: &tauri::AppHandle,
vault_path: Option<&Path>,
) -> Result<&'static str, String> {
use tauri::Manager;
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
let mut active_child = state
.0
.lock()
.map_err(|_| "Failed to lock ws-bridge state".to_string())?;
let Some(vault_path) = vault_path else {
stop_ws_bridge_child(&mut active_child);
return Ok("stopped");
};
let resolved_vault_path = match validate_mcp_bridge_vault_path(vault_path) {
Ok(path) => path,
Err(e) => {
stop_ws_bridge_child(&mut active_child);
return Err(e);
}
};
stop_ws_bridge_child(&mut active_child);
let child = mcp::spawn_ws_bridge(&resolved_vault_path)?;
*active_child = Some(child);
Ok("started")
}
/// Run startup housekeeping on the default vault (migrate legacy frontmatter, seed configs).
#[cfg(desktop)]
fn run_startup_tasks() {
@@ -117,17 +205,21 @@ fn run_startup_tasks() {
#[cfg(desktop)]
fn spawn_ws_bridge(app: &mut tauri::App) {
use tauri::Manager;
let vault_path = dirs::home_dir()
.map(|h| h.join("Laputa"))
.unwrap_or_default();
let vp_str = vault_path.to_string_lossy().to_string();
match mcp::spawn_ws_bridge(&vp_str) {
Ok(child) => {
let state: tauri::State<'_, WsBridgeChild> = app.state();
*state.0.lock().unwrap() = Some(child);
let vault_path = match vault_list::load_vault_list() {
Ok(vault_list) => selected_mcp_bridge_vault_path(&vault_list),
Err(e) => {
log::warn!("Failed to load active vault for ws-bridge startup: {}", e);
None
}
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
};
let Some(vault_path) = vault_path else {
log::info!("ws-bridge not started: no active vault selected");
return;
};
if let Err(e) = sync_ws_bridge_for_vault(app.handle(), Some(&vault_path)) {
log::warn!("Failed to start ws-bridge: {}", e);
}
}
@@ -154,6 +246,7 @@ fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error:
#[cfg(not(target_os = "linux"))]
menu::setup_menu(app)?;
setup_linux_window_chrome(app)?;
window_state::restore_main_window_state(app);
Ok(())
}
@@ -173,7 +266,7 @@ fn setup_linux_window_chrome(_app: &mut tauri::App) -> Result<(), Box<dyn std::e
}
#[cfg(any(test, all(desktop, target_os = "macos")))]
const MACOS_WEBVIEW_RESERVED_COMMAND_KEYS: &[&str] = &["O"];
const MACOS_WEBVIEW_RESERVED_COMMAND_KEYS: &[&str] = &["O", "F"];
#[cfg(any(test, all(desktop, target_os = "macos")))]
const MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS: &[&str] = &["L"];
@@ -279,6 +372,7 @@ macro_rules! app_invoke_handler {
commands::list_vault,
commands::list_vault_folders,
commands::get_note_content,
commands::validate_note_content,
commands::create_note_content,
commands::save_note_content,
commands::update_frontmatter,
@@ -313,11 +407,11 @@ macro_rules! app_invoke_handler {
commands::get_vault_ai_guidance_status,
commands::restore_vault_ai_guidance,
commands::stream_claude_chat,
commands::stream_claude_agent,
commands::stream_ai_agent,
commands::reload_vault,
commands::reload_vault_entry,
commands::sync_vault_asset_scope_for_window,
commands::open_vault_file_external,
commands::sync_note_title,
commands::save_image,
commands::copy_image_to_vault,
@@ -348,11 +442,15 @@ 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,
commands::list_views,
commands::save_view_cmd,
commands::delete_view_cmd
commands::delete_view_cmd,
vault_watcher::start_vault_watcher,
vault_watcher::stop_vault_watcher
]
};
}
@@ -365,13 +463,12 @@ fn with_invoke_handler(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<ta
fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
use tauri::Manager;
window_state::handle_run_event(app_handle, event);
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);
}
}
@@ -383,7 +480,9 @@ pub fn run() {
#[cfg(desktop)]
let builder = builder
.manage(WsBridgeChild(Mutex::new(None)))
.manage(ActiveAssetScopeRoots(Mutex::new(Vec::new())));
.manage(ActiveAssetScopeRoots(Mutex::new(Vec::new())))
.manage(window_state::MainWindowFrameState::default())
.manage(vault_watcher::VaultWatcherState::new());
with_invoke_handler(builder)
.setup(setup_app)
@@ -399,13 +498,20 @@ pub fn run() {
mod tests {
use super::linux_appimage_startup_env_overrides_with;
use super::StartupEnvOverride;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
#[cfg(desktop)]
use super::{selected_mcp_bridge_vault_path, validate_mcp_bridge_vault_path};
#[cfg(desktop)]
use crate::vault_list::VaultList;
#[cfg(all(desktop, unix))]
use super::vault_asset_scope_roots;
#[test]
fn macos_webview_shortcut_prevention_includes_ai_panel_shortcut() {
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_KEYS, ["O", "F"]);
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]);
}
@@ -443,6 +549,48 @@ mod tests {
assert!(overrides.is_empty());
}
#[cfg(desktop)]
#[test]
fn selected_mcp_bridge_vault_path_uses_persisted_active_vault() {
let list = VaultList {
vaults: Vec::new(),
active_vault: Some("/tmp/Selected Vault".to_string()),
hidden_defaults: Vec::new(),
};
assert_eq!(
selected_mcp_bridge_vault_path(&list),
Some(std::path::PathBuf::from("/tmp/Selected Vault"))
);
}
#[cfg(desktop)]
#[test]
fn selected_mcp_bridge_vault_path_ignores_blank_active_vault() {
let list = VaultList {
vaults: Vec::new(),
active_vault: Some(" ".to_string()),
hidden_defaults: Vec::new(),
};
assert_eq!(selected_mcp_bridge_vault_path(&list), None);
}
#[cfg(desktop)]
#[test]
fn validate_mcp_bridge_vault_path_requires_existing_directory() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("Vault With Spaces");
std::fs::create_dir(&vault).unwrap();
let resolved = validate_mcp_bridge_vault_path(&vault).unwrap();
assert_eq!(resolved, vault.canonicalize().unwrap());
let missing = dir.path().join("Missing Vault");
let err = validate_mcp_bridge_vault_path(&missing).unwrap_err();
assert!(err.contains("MCP bridge vault is not available"));
}
#[cfg(all(desktop, unix))]
#[test]
fn vault_asset_scope_roots_include_requested_symlink_path() {

View File

@@ -17,21 +17,126 @@ 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() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Ok(PathBuf::from(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() {
return Ok(path);
Err(last_error.unwrap_or_else(|| "node not found in PATH or common install locations".into()))
}
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)
.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> {
let output = crate::hidden_command(node)
.arg("--version")
.output()
.map_err(|e| format!("Failed to run {} --version: {e}", node.display()))?;
if !output.status.success() {
return Err(format!(
"{} --version failed; install Node.js 18+ and make it available on PATH",
node.display()
));
}
Err("node not found in PATH or common install locations".into())
let raw_version = String::from_utf8_lossy(&output.stdout);
let Some(major) = node_major_version(&raw_version) else {
return Err(format!(
"Cannot parse Node.js version from '{}'",
raw_version.trim()
));
};
if major < 18 {
return Err(format!(
"Node.js 18+ is required for Tolaria MCP tools; found {}",
raw_version.trim()
));
}
Ok(())
}
fn node_major_version(version: &str) -> Option<u32> {
version
.trim()
.trim_start_matches('v')
.split('.')
.next()
.and_then(|major| major.parse().ok())
}
fn node_lookup_command() -> Command {
@@ -44,31 +149,76 @@ 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"),
];
if let Some(home) = dirs::home_dir() {
candidates.push(home.join(".volta").join("bin").join("node"));
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")),
#[cfg(windows)]
{
if let Some(program_files) = std::env::var_os("ProgramFiles") {
candidates.push(PathBuf::from(program_files).join("nodejs").join("node.exe"));
}
if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") {
candidates.push(
PathBuf::from(program_files_x86)
.join("nodejs")
.join("node.exe"),
);
}
if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
candidates.push(
PathBuf::from(local_app_data)
.join("Programs")
.join("nodejs")
.join("node.exe"),
);
}
}
candidates.into_iter().find(|path| path.is_file())
if let Some(home) = dirs::home_dir() {
candidates.extend(node_binary_candidates_for_home(&home));
}
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 {
if cfg!(windows) {
"node.exe"
} else {
"node"
}
}
/// Resolve the path to `mcp-server/`.
@@ -79,34 +229,70 @@ pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("mcp-server");
if dev_path.join("ws-bridge.js").exists() {
return Ok(std::fs::canonicalize(&dev_path).unwrap_or(dev_path));
}
let exe = std::env::current_exe().map_err(|e| format!("Cannot find executable: {e}"))?;
// On macOS the exe lives at Contents/MacOS/<binary>.
// Resources are placed at Contents/Resources/ by Tauri.
let release_path = exe
.parent()
.and_then(|p| p.parent())
.map(|p| p.join("Resources").join("mcp-server"))
.ok_or_else(|| "Cannot resolve mcp-server directory".to_string())?;
if release_path.join("ws-bridge.js").exists() {
return Ok(release_path);
let appdir = std::env::var_os("APPDIR").map(PathBuf::from);
let candidates = mcp_server_dir_candidates(&dev_path, &exe, appdir.as_deref());
if let Some(path) = candidates
.iter()
.find(|path| mcp_server_dir_has_files(path))
{
return Ok(std::fs::canonicalize(path).unwrap_or_else(|_| path.clone()));
}
let searched = candidates
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ");
Err(format!(
"mcp-server not found at {} or {}",
dev_path.display(),
release_path.display()
"mcp-server not found. Searched these paths: {searched}"
))
}
fn mcp_server_dir_candidates(
dev_path: &Path,
exe_path: &Path,
appdir: Option<&Path>,
) -> Vec<PathBuf> {
let mut candidates = vec![dev_path.to_path_buf()];
if let Some(exe_dir) = exe_path.parent() {
candidates.push(exe_dir.join("mcp-server"));
if let Some(bundle_root) = exe_dir.parent() {
candidates.push(bundle_root.join("Resources").join("mcp-server"));
candidates.push(bundle_root.join("lib").join("tolaria").join("mcp-server"));
}
}
if let Some(appdir) = appdir {
candidates.push(
appdir
.join("usr")
.join("lib")
.join("tolaria")
.join("mcp-server"),
);
}
candidates.push(
PathBuf::from("/usr")
.join("lib")
.join("tolaria")
.join("mcp-server"),
);
candidates
}
fn mcp_server_dir_has_files(path: &Path) -> bool {
path.join("index.js").is_file() && path.join("ws-bridge.js").is_file()
}
/// Spawn the WebSocket bridge as a child process.
pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
pub fn spawn_ws_bridge(vault_path: impl AsRef<Path>) -> Result<Child, String> {
let node = find_node()?;
let server_dir = mcp_server_dir()?;
let script = server_dir.join("ws-bridge.js");
let vault_path = vault_path.as_ref();
let child = crate::hidden_command(node)
.arg(&script)
@@ -119,7 +305,11 @@ pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
.spawn()
.map_err(|e| format!("Failed to spawn ws-bridge: {e}"))?;
log::info!("ws-bridge spawned (pid: {})", child.id());
log::info!(
"ws-bridge spawned (pid: {}, vault: {})",
child.id(),
vault_path.display()
);
Ok(child)
}
@@ -133,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"),
]
@@ -160,6 +351,14 @@ fn entry_index_js_exists(entry: &serde_json::Value) -> bool {
.is_some_and(|index_js| Path::new(index_js).exists())
}
fn entry_uses_stdio(entry: &serde_json::Value) -> bool {
entry["type"].as_str() == Some("stdio")
}
fn entry_has_ui_port(entry: &serde_json::Value) -> bool {
entry["env"]["WS_UI_PORT"].as_str() == Some("9711")
}
fn entry_targets_vault(entry: &serde_json::Value, vault_path: &Path) -> bool {
let Some(entry_vault_path) = entry["env"]["VAULT_PATH"].as_str() else {
return false;
@@ -176,14 +375,40 @@ fn entry_targets_vault(entry: &serde_json::Value, vault_path: &Path) -> bool {
}
/// Build the MCP server entry JSON for a given vault path and index.js path.
fn build_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
fn build_mcp_entry(node_command: &str, index_js: &str, vault_path: &str) -> serde_json::Value {
serde_json::json!({
"command": "node",
"type": "stdio",
"command": node_command,
"args": [index_js],
"env": { "VAULT_PATH": vault_path }
"env": {
"VAULT_PATH": vault_path,
"WS_UI_PORT": "9711"
}
})
}
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 {
@@ -200,10 +425,14 @@ fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf])
/// Register Tolaria as an MCP server in external AI tool config files.
pub fn register_mcp(vault_path: &str) -> Result<String, String> {
let node = find_node().map_err(|e| {
format!("Node.js 18+ is required on PATH before Tolaria can register MCP tools: {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(&index_js, vault_path);
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
Ok(register_mcp_to_configs(&entry, &mcp_config_paths()))
}
@@ -317,7 +546,10 @@ pub fn check_mcp_status(vault_path: &str) -> McpStatus {
let active_vault_path = Path::new(vault_path);
if mcp_config_paths().into_iter().any(|config_path| {
read_registered_mcp_entry(&config_path).is_some_and(|entry| {
entry_index_js_exists(&entry) && entry_targets_vault(&entry, active_vault_path)
entry_uses_stdio(&entry)
&& entry_index_js_exists(&entry)
&& entry_has_ui_port(&entry)
&& entry_targets_vault(&entry, active_vault_path)
})
}) {
McpStatus::Installed
@@ -347,12 +579,17 @@ mod tests {
fn managed_server(index_js: &str, vault_path: &str) -> serde_json::Value {
serde_json::json!({
"type": "stdio",
"command": "node",
"args": [index_js],
"env": { "VAULT_PATH": vault_path }
"env": { "VAULT_PATH": vault_path, "WS_UI_PORT": "9711" }
})
}
fn test_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
build_mcp_entry("node", index_js, vault_path)
}
fn write_mcp_servers_config(config_path: &Path, servers: Vec<(&str, serde_json::Value)>) {
let servers = servers
.into_iter()
@@ -361,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();
@@ -369,29 +620,141 @@ mod tests {
#[test]
fn build_mcp_entry_produces_correct_json() {
let entry = build_mcp_entry("/path/to/index.js", "/my/vault");
assert_eq!(entry["command"], "node");
let entry = build_mcp_entry("/usr/local/bin/node", "/path/to/index.js", "/my/vault");
assert_eq!(entry["type"], "stdio");
assert_eq!(entry["command"], "/usr/local/bin/node");
assert_eq!(entry["args"][0], "/path/to/index.js");
assert_eq!(entry["env"]["VAULT_PATH"], "/my/vault");
assert_eq!(entry["env"]["WS_UI_PORT"], "9711");
}
#[test]
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!(
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));
assert_eq!(node_major_version("18.19.0"), Some(18));
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");
let exe_path = Path::new("/Users/tester/AppData/Local/Tolaria/tolaria.exe");
let candidates = mcp_server_dir_candidates(dev_path, exe_path, None);
let windows_dir = PathBuf::from("/Users/tester/AppData/Local/Tolaria/mcp-server");
let macos_dir = PathBuf::from("/Users/tester/AppData/Local/Resources/mcp-server");
let windows_pos = candidates
.iter()
.position(|path| path == &windows_dir)
.unwrap();
let macos_pos = candidates
.iter()
.position(|path| path == &macos_dir)
.unwrap();
assert_eq!(candidates[0], dev_path);
assert!(windows_pos < macos_pos);
}
#[test]
fn upsert_creates_new_config() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/test/vault");
let entry = test_mcp_entry("/test/index.js", "/test/vault");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
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",
},
);
}
@@ -400,10 +763,10 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let entry1 = build_mcp_entry("/test/index.js", "/vault/v1");
let entry1 = test_mcp_entry("/test/index.js", "/vault/v1");
upsert_mcp_config(&config_path, &entry1).unwrap();
let entry2 = build_mcp_entry("/test/index.js", "/vault/v2");
let entry2 = test_mcp_entry("/test/index.js", "/vault/v2");
let was_update = upsert_mcp_config(&config_path, &entry2).unwrap();
assert!(was_update);
@@ -430,7 +793,7 @@ mod tests {
});
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
assert!(was_update);
@@ -453,7 +816,7 @@ mod tests {
)],
);
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
upsert_mcp_config(&config_path, &entry).unwrap();
let raw = std::fs::read_to_string(&config_path).unwrap();
@@ -476,7 +839,7 @@ mod tests {
}),
);
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
upsert_mcp_config(&config_path, &entry).unwrap();
let config = read_config(&config_path);
@@ -486,11 +849,40 @@ 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();
let config_path = tmp.path().join("nested").join("dir").join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
upsert_mcp_config(&config_path, &entry).unwrap();
assert!(config_path.exists());
@@ -500,7 +892,7 @@ mod tests {
fn register_mcp_to_configs_returns_registered_for_new() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("claude").join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
let status = register_mcp_to_configs(&entry, &[config]);
assert_eq!(status, "registered");
@@ -510,7 +902,7 @@ mod tests {
fn register_mcp_to_configs_returns_updated_for_existing() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
// First call
register_mcp_to_configs(&entry, std::slice::from_ref(&config));
@@ -556,15 +948,17 @@ 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 = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
register_mcp_to_configs(
&entry,
&[
claude_user_cfg.clone(),
claude_cfg.clone(),
gemini_cfg.clone(),
cursor_cfg.clone(),
generic_cfg.clone(),
],
@@ -572,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",
},
);
}
@@ -593,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"),
]
@@ -603,14 +1002,14 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
std::fs::write(&config_path, "not valid json{{{{").unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
let result = upsert_mcp_config(&config_path, &entry);
assert!(result.is_err());
}
#[test]
fn register_mcp_to_configs_handles_empty_list() {
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
// Empty config list — function should return "registered" (no existing)
let status = register_mcp_to_configs(&entry, &[]);
// With empty config list, there were no updates, so status should be "registered"
@@ -695,7 +1094,7 @@ mod tests {
let config_path = tmp.path().join("mcp.json");
std::fs::write(&config_path, "[]").unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
let result = upsert_mcp_config(&config_path, &entry);
assert!(matches!(result, Err(ref error) if error.contains("Config is not a JSON object")));
}
@@ -709,7 +1108,7 @@ mod tests {
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
let result = upsert_mcp_config(&config_path, &entry);
assert!(
matches!(result, Err(ref error) if error.contains("mcpServers is not a JSON object"))

View File

@@ -13,6 +13,8 @@ const FILE_QUICK_OPEN: &str = "file-quick-open";
const FILE_QUICK_OPEN_ALIAS: &str = "file-quick-open-alias";
const FILE_SAVE: &str = "file-save";
const EDIT_FIND_IN_NOTE: &str = "edit-find-in-note";
const EDIT_REPLACE_IN_NOTE: &str = "edit-replace-in-note";
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
const EDIT_TOGGLE_NOTE_LIST_SEARCH: &str = "edit-toggle-note-list-search";
const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor";
@@ -62,6 +64,8 @@ const CUSTOM_IDS: &[&str] = &[
FILE_QUICK_OPEN,
FILE_QUICK_OPEN_ALIAS,
FILE_SAVE,
EDIT_FIND_IN_NOTE,
EDIT_REPLACE_IN_NOTE,
EDIT_FIND_IN_VAULT,
EDIT_TOGGLE_NOTE_LIST_SEARCH,
EDIT_TOGGLE_RAW_EDITOR,
@@ -112,6 +116,9 @@ const NOTE_DEPENDENT_IDS: &[&str] = &[
NOTE_OPEN_IN_NEW_WINDOW,
];
/// IDs of menu items that depend on the editor being the active surface.
const EDITOR_FIND_DEPENDENT_IDS: &[&str] = &[EDIT_FIND_IN_NOTE, EDIT_REPLACE_IN_NOTE];
/// IDs of menu items that depend on the note list being the active surface.
const NOTE_LIST_SEARCH_DEPENDENT_IDS: &[&str] = &[EDIT_TOGGLE_NOTE_LIST_SEARCH];
@@ -191,6 +198,15 @@ fn build_file_menu(app: &App) -> MenuResult {
}
fn build_edit_menu(app: &App) -> MenuResult {
let find_in_note = MenuItemBuilder::new("Find in Note")
.id(EDIT_FIND_IN_NOTE)
.accelerator("CmdOrCtrl+F")
.enabled(false)
.build(app)?;
let replace_in_note = MenuItemBuilder::new("Replace in Note")
.id(EDIT_REPLACE_IN_NOTE)
.enabled(false)
.build(app)?;
let find_in_vault = MenuItemBuilder::new("Find in Vault")
.id(EDIT_FIND_IN_VAULT)
.accelerator("CmdOrCtrl+Shift+F")
@@ -214,6 +230,8 @@ fn build_edit_menu(app: &App) -> MenuResult {
.separator()
.select_all()
.separator()
.item(&find_in_note)
.item(&replace_in_note)
.item(&find_in_vault)
.item(&toggle_note_list_search)
.item(&toggle_diff)
@@ -468,6 +486,11 @@ pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, NOTE_DEPENDENT_IDS, enabled);
}
/// Enable or disable menu items that depend on the editor being the active surface.
pub fn set_editor_find_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, EDITOR_FIND_DEPENDENT_IDS, enabled);
}
/// Enable or disable menu items that depend on the note list being the active surface.
pub fn set_note_list_search_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, NOTE_LIST_SEARCH_DEPENDENT_IDS, enabled);
@@ -506,6 +529,8 @@ mod tests {
FILE_NEW_TYPE,
FILE_QUICK_OPEN,
FILE_SAVE,
EDIT_FIND_IN_NOTE,
EDIT_REPLACE_IN_NOTE,
EDIT_FIND_IN_VAULT,
EDIT_TOGGLE_NOTE_LIST_SEARCH,
EDIT_TOGGLE_RAW_EDITOR,
@@ -564,6 +589,16 @@ mod tests {
}
}
#[test]
fn editor_find_dependent_ids_are_subset_of_custom_ids() {
for id in EDITOR_FIND_DEPENDENT_IDS {
assert!(
CUSTOM_IDS.contains(id),
"editor-find-dependent ID {id} not in CUSTOM_IDS"
);
}
}
#[test]
fn git_dependent_ids_are_subset_of_custom_ids() {
for id in GIT_COMMIT_DEPENDENT_IDS {

View File

@@ -0,0 +1,120 @@
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
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, emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = crate::opencode_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 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)));
}
}

View File

@@ -0,0 +1,158 @@
use crate::ai_agents::AiAgentPermissionMode;
use crate::opencode_cli::AgentStreamRequest;
use std::path::Path;
use std::process::Stdio;
pub(crate) fn build_command(
binary: &Path,
request: &AgentStreamRequest,
) -> Result<std::process::Command, String> {
let mut command = crate::hidden_command(binary);
command
.args(build_args())
.arg(build_prompt(request))
.env(
"OPENCODE_CONFIG_CONTENT",
build_config(&request.vault_path, request.permission_mode)?,
)
.current_dir(&request.vault_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
Ok(command)
}
fn build_args() -> Vec<String> {
vec!["run".into(), "--format".into(), "json".into()]
}
fn build_prompt(request: &AgentStreamRequest) -> String {
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
}
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_mode),
"mcp": {
"tolaria": {
"type": "local",
"command": ["node", mcp_server_path],
"environment": { "VAULT_PATH": vault_path },
"enabled": true
}
}
}))
.map_err(|error| format!("Failed to serialize opencode config: {error}"))
}
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",
"glob": "allow",
"grep": "allow",
"list": "allow",
"external_directory": "deny",
"bash": bash_permission
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
use std::path::PathBuf;
fn request() -> AgentStreamRequest {
AgentStreamRequest {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe,
}
}
#[test]
fn args_use_documented_safe_run_mode() {
let args = build_args();
assert_eq!(args, vec!["run", "--format", "json"]);
assert!(!args.contains(&"--dangerously-skip-permissions".to_string()));
assert!(!args.contains(&"--dir".to_string()));
assert!(!args.contains(&"--thinking".to_string()));
}
#[test]
fn command_sets_vault_cwd_and_mcp_config() {
let command = build_command(&PathBuf::from("opencode"), &request()).unwrap();
let actual_args: Vec<&OsStr> = command.get_args().collect();
let config_value = command
.get_envs()
.find(|(key, _)| *key == OsStr::new("OPENCODE_CONFIG_CONTENT"))
.and_then(|(_, value)| value);
assert_eq!(command.get_program(), OsStr::new("opencode"));
assert_eq!(actual_args[0], OsStr::new("run"));
assert_eq!(actual_args[1], OsStr::new("--format"));
assert_eq!(actual_args[2], OsStr::new("json"));
assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note")));
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
assert!(config_value.is_some());
}
#[test]
fn config_includes_permissions_and_tolaria_mcp_server() {
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");
assert_eq!(json["permission"]["bash"], "deny");
assert_eq!(json["mcp"]["tolaria"]["type"], "local");
assert_eq!(json["mcp"]["tolaria"]["command"][0], "node");
assert_eq!(
json["mcp"]["tolaria"]["environment"]["VAULT_PATH"],
"/tmp/vault"
);
assert!(json["mcp"]["tolaria"]["command"][1]
.as_str()
.unwrap()
.ends_with("index.js"));
}
}
#[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 {
system_prompt: Some("Be concise".into()),
..request()
});
assert!(prompt.starts_with("System instructions:\nBe concise"));
assert!(prompt.contains("User request:\nRename the note"));
}
}

View File

@@ -0,0 +1,204 @@
use crate::ai_agents::AiAgentAvailability;
use std::path::{Path, PathBuf};
pub(crate) fn check_cli() -> AiAgentAvailability {
match find_binary() {
Ok(binary) => AiAgentAvailability {
installed: true,
version: crate::cli_agent_runtime::version_for_binary(&binary),
},
Err(_) => AiAgentAvailability {
installed: false,
version: None,
},
}
}
pub(crate) fn find_binary() -> Result<PathBuf, String> {
if let Some(binary) = find_binary_on_path() {
return Ok(binary);
}
if let Some(binary) = find_binary_in_user_shell() {
return Ok(binary);
}
if let Some(binary) = find_existing_binary(opencode_binary_candidates()) {
return Ok(binary);
}
Err("OpenCode CLI not found. Install it: https://opencode.ai/docs/".into())
}
fn find_binary_on_path() -> Option<PathBuf> {
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()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, "opencode"))
}
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 find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
fn opencode_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| opencode_binary_candidates_for_home(&home))
.unwrap_or_default()
}
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"),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn binary_candidates_include_supported_local_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = opencode_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/opencode"),
home.join(".opencode/bin/opencode"),
home.join(".local/share/mise/shims/opencode"),
home.join(".asdf/shims/opencode"),
home.join(".npm-global/bin/opencode"),
home.join(".bun/bin/opencode"),
PathBuf::from("/opt/homebrew/bin/opencode"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[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();
let missing = dir.path().join("missing-opencode");
let opencode = dir.path().join("opencode");
std::fs::write(&opencode, "#!/bin/sh\n").unwrap();
let stdout = format!("\n{}\n{}\n", missing.display(), opencode.display());
assert_eq!(first_existing_path(&stdout), Some(opencode));
}
}

View File

@@ -0,0 +1,176 @@
use crate::ai_agents::AiAgentStreamEvent;
#[cfg(test)]
pub(crate) fn parse_line<F>(
line: Result<String, std::io::Error>,
emit: &mut F,
) -> Option<serde_json::Value>
where
F: FnMut(AiAgentStreamEvent),
{
crate::cli_agent_runtime::parse_ai_agent_json_line(line, emit)
}
pub(crate) fn dispatch_event<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
if json["type"].as_str() == Some("session") {
emit_session_event(json, emit);
}
match json["type"].as_str().unwrap_or_default() {
"message" | "text" => emit_text(json, emit),
"reasoning" => emit_reasoning(json, emit),
"tool_use" | "tool" => emit_tool_start(json, emit),
"tool_result" | "tool_done" => emit_tool_done(json, emit),
"error" => emit_error_event(json, emit),
_ => {}
}
}
pub(crate) fn session_id(json: &serde_json::Value) -> Option<&str> {
json["sessionID"]
.as_str()
.or_else(|| json["session_id"].as_str())
.or_else(|| json["session"]["id"].as_str())
}
pub(crate) fn format_error(stderr_output: String, status: String) -> String {
let lower = stderr_output.to_ascii_lowercase();
if is_auth_error(&lower) {
return "OpenCode CLI is not authenticated or has no provider configured. Run `opencode auth login` or configure a provider in OpenCode, then retry.".into();
}
if stderr_output.trim().is_empty() {
format!("opencode exited with status {status}")
} else {
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
}
}
fn emit_session_event<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
if let Some(session_id) = session_id(json) {
emit(AiAgentStreamEvent::Init {
session_id: session_id.to_string(),
});
}
}
fn emit_text<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
if let Some(text) = text_value(json) {
emit(AiAgentStreamEvent::TextDelta {
text: text.to_string(),
});
}
}
fn emit_reasoning<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
if let Some(text) = text_value(json) {
emit(AiAgentStreamEvent::ThinkingDelta {
text: text.to_string(),
});
}
}
fn emit_tool_start<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
let tool_id = tool_id(json).unwrap_or("tool").to_string();
let tool_name = tool_name(json).unwrap_or("tool").to_string();
let input = tool_input(json);
emit(AiAgentStreamEvent::ToolStart {
tool_name,
tool_id,
input,
});
}
fn emit_tool_done<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
let tool_id = tool_id(json).unwrap_or("tool").to_string();
let output = tool_output(json);
emit(AiAgentStreamEvent::ToolDone { tool_id, output });
}
fn emit_error_event<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
if let Some(message) = message_value(json) {
emit(AiAgentStreamEvent::Error {
message: message.to_string(),
});
}
}
fn tool_id(json: &serde_json::Value) -> Option<&str> {
first_string_field(
json,
&["id", "toolID", "tool_id", "toolCallID", "toolCallId"],
)
}
fn text_value(json: &serde_json::Value) -> Option<&str> {
first_string_field(json, &["text", "content", "message"])
}
fn message_value(json: &serde_json::Value) -> Option<&str> {
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()
.map(|output| output.to_string())
.unwrap_or_else(|| value.to_string())
}
fn is_auth_error(lower: &str) -> bool {
["auth", "login", "sign in", "api key", "provider"]
.iter()
.any(|pattern| lower.contains(pattern))
}
#[cfg(test)]
#[path = "opencode_events_tests.rs"]
mod tests;

View File

@@ -0,0 +1,190 @@
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();
let started = serde_json::json!({ "type": "session", "sessionID": "ses_1" });
let reasoning = serde_json::json!({ "type": "reasoning", "text": "Checking links" });
let text = serde_json::json!({ "type": "message", "text": "Done" });
for event in [started, reasoning, text] {
dispatch_event(&event, &mut |event| events.push(event));
}
assert!(matches!(
&events[0],
AiAgentStreamEvent::Init { session_id } if session_id == "ses_1"
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links"
));
assert!(matches!(
&events[2],
AiAgentStreamEvent::TextDelta { text } if text == "Done"
));
}
#[test]
fn dispatch_maps_part_backed_reasoning_and_text() {
let mut events = Vec::new();
let reasoning = serde_json::json!({
"type": "reasoning",
"part": { "type": "reasoning", "text": "Checking links" }
});
let text = serde_json::json!({
"type": "text",
"part": { "type": "text", "text": "Done from OpenCode" }
});
for event in [reasoning, text] {
dispatch_event(&event, &mut |event| events.push(event));
}
assert!(matches!(
&events[0],
AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links"
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::TextDelta { text } if text == "Done from OpenCode"
));
}
#[test]
fn dispatch_maps_tool_events() {
let 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"
));
}
#[test]
fn format_error_explains_missing_auth_or_provider_setup() {
let message = format_error(
"provider auth failed: please login".into(),
"exit status: 1".into(),
);
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");
}

124
src-tauri/src/pi_cli.rs Normal file
View File

@@ -0,0 +1,124 @@
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
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, 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 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)));
}
}

169
src-tauri/src/pi_config.rs Normal file
View File

@@ -0,0 +1,169 @@
use crate::ai_agents::AiAgentPermissionMode;
use crate::pi_cli::AgentStreamRequest;
use std::path::Path;
use std::process::Stdio;
pub(crate) fn build_command(
binary: &Path,
request: &AgentStreamRequest,
agent_dir: &Path,
) -> Result<std::process::Command, String> {
write_mcp_config(agent_dir, &request.vault_path, request.permission_mode)?;
let mut command = crate::hidden_command(binary);
command
.args(build_args())
.arg(build_prompt(request))
.env("PI_CODING_AGENT_DIR", agent_dir)
.current_dir(&request.vault_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
Ok(command)
}
fn build_args() -> Vec<String> {
vec![
"--mode".into(),
"json".into(),
"--no-session".into(),
"--extension".into(),
"npm:pi-mcp-adapter".into(),
]
}
fn build_prompt(request: &AgentStreamRequest) -> String {
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
}
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, 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,
_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": {
"toolPrefix": "none",
"idleTimeout": 10
},
"mcpServers": {
"tolaria": {
"command": "node",
"args": [mcp_server_path],
"env": {
"VAULT_PATH": vault_path,
"WS_UI_PORT": "9711"
},
"lifecycle": "lazy",
"directTools": true
}
}
}))
.map_err(|error| format!("Failed to serialize Pi MCP config: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
use std::path::PathBuf;
fn request() -> AgentStreamRequest {
AgentStreamRequest {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe,
}
}
#[test]
fn args_use_documented_json_mode_with_mcp_adapter() {
let args = build_args();
assert_eq!(args[0], "--mode");
assert_eq!(args[1], "json");
assert!(args.contains(&"--no-session".to_string()));
assert!(args.contains(&"--extension".to_string()));
assert!(args.contains(&"npm:pi-mcp-adapter".to_string()));
assert!(!args.contains(&"--no-tools".to_string()));
}
#[test]
fn command_sets_vault_cwd_closed_stdin_and_config_dir() {
let agent_dir = tempfile::tempdir().unwrap();
let command = build_command(&PathBuf::from("pi"), &request(), agent_dir.path()).unwrap();
let actual_args: Vec<&OsStr> = command.get_args().collect();
let config_dir = command
.get_envs()
.find(|(key, _)| *key == OsStr::new("PI_CODING_AGENT_DIR"))
.and_then(|(_, value)| value);
assert_eq!(command.get_program(), OsStr::new("pi"));
assert_eq!(actual_args[0], OsStr::new("--mode"));
assert_eq!(actual_args[1], OsStr::new("json"));
assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note")));
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
assert_eq!(config_dir, Some(agent_dir.path().as_os_str()));
assert!(agent_dir.path().join("mcp.json").exists());
}
#[test]
fn mcp_config_includes_tolaria_server_for_active_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");
assert_eq!(json["mcpServers"]["tolaria"]["lifecycle"], "lazy");
assert_eq!(json["mcpServers"]["tolaria"]["directTools"], true);
assert_eq!(
json["mcpServers"]["tolaria"]["env"]["VAULT_PATH"],
"/tmp/vault"
);
assert_eq!(json["mcpServers"]["tolaria"]["env"]["WS_UI_PORT"], "9711");
assert!(json["mcpServers"]["tolaria"]["args"][0]
.as_str()
.unwrap()
.ends_with("index.js"));
}
}
#[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 {
system_prompt: Some("Be concise".into()),
..request()
});
assert!(prompt.starts_with("System instructions:\nBe concise"));
assert!(prompt.contains("User request:\nRename the note"));
}
}

View File

@@ -0,0 +1,308 @@
use crate::ai_agents::AiAgentAvailability;
use std::path::{Path, PathBuf};
pub(crate) fn check_cli() -> AiAgentAvailability {
match find_binary() {
Ok(binary) => AiAgentAvailability {
installed: true,
version: crate::cli_agent_runtime::version_for_binary(&binary),
},
Err(_) => AiAgentAvailability {
installed: false,
version: None,
},
}
}
pub(crate) fn find_binary() -> Result<PathBuf, String> {
if let Some(binary) = find_binary_on_path() {
return Ok(binary);
}
if let Some(binary) = find_binary_in_user_shell() {
return Ok(binary);
}
if let Some(binary) = find_existing_binary(pi_binary_candidates()) {
return Ok(binary);
}
Err("Pi CLI not found. Install it: https://pi.dev".into())
}
fn find_binary_on_path() -> Option<PathBuf> {
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()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, "pi"))
}
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 find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
fn pi_binary_candidates() -> Vec<PathBuf> {
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> {
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::*;
#[test]
fn binary_candidates_include_supported_local_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = pi_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/pi"),
home.join(".pi/bin/pi"),
home.join(".local/share/mise/shims/pi"),
home.join(".asdf/shims/pi"),
home.join(".npm-global/bin/pi"),
home.join(".bun/bin/pi"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[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();
let missing = dir.path().join("missing-pi");
let pi = dir.path().join("pi");
std::fs::write(&pi, "#!/bin/sh\n").unwrap();
let stdout = format!("\n{}\n{}\n", missing.display(), pi.display());
assert_eq!(first_existing_path(&stdout), Some(pi));
}
}

145
src-tauri/src/pi_events.rs Normal file
View File

@@ -0,0 +1,145 @@
use crate::ai_agents::AiAgentStreamEvent;
#[cfg(test)]
pub(crate) fn parse_line<F>(
line: Result<String, std::io::Error>,
emit: &mut F,
) -> Option<serde_json::Value>
where
F: FnMut(AiAgentStreamEvent),
{
crate::cli_agent_runtime::parse_ai_agent_json_line(line, emit)
}
pub(crate) fn dispatch_event<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
if json["type"].as_str() == Some("session") {
emit_session_event(json, emit);
}
match json["type"].as_str().unwrap_or_default() {
"message_update" => emit_message_update(json, emit),
"tool_execution_start" => emit_tool_start(json, emit),
"tool_execution_end" => emit_tool_done(json, emit),
"error" => emit_error_event(json, emit),
_ => {}
}
}
pub(crate) fn session_id(json: &serde_json::Value) -> Option<&str> {
json["id"]
.as_str()
.or_else(|| json["session_id"].as_str())
.or_else(|| json["session"]["id"].as_str())
}
pub(crate) fn format_error(stderr_output: String, status: String) -> String {
let lower = stderr_output.to_ascii_lowercase();
if is_auth_error(&lower) {
return "Pi CLI is not authenticated. Run `pi /login` in your terminal or configure a provider API key, then retry.".into();
}
if stderr_output.trim().is_empty() {
format!("pi exited with status {status}")
} else {
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
}
}
fn emit_session_event<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
if let Some(session_id) = session_id(json) {
emit(AiAgentStreamEvent::Init {
session_id: session_id.to_string(),
});
}
}
fn emit_message_update<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
let event = &json["assistantMessageEvent"];
match event["type"].as_str().unwrap_or_default() {
"text_delta" => emit_delta(event, emit, |text| AiAgentStreamEvent::TextDelta { text }),
"thinking_delta" => emit_delta(event, emit, |text| AiAgentStreamEvent::ThinkingDelta {
text,
}),
_ => {}
}
}
fn emit_delta<F>(
json: &serde_json::Value,
emit: &mut F,
build: impl FnOnce(String) -> AiAgentStreamEvent,
) where
F: FnMut(AiAgentStreamEvent),
{
if let Some(delta) = json["delta"].as_str() {
emit(build(delta.to_string()));
}
}
fn emit_tool_start<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
emit(AiAgentStreamEvent::ToolStart {
tool_name: tool_name(json),
tool_id: tool_id(json),
input: json.get("args").map(|args| args.to_string()),
});
}
fn emit_tool_done<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
emit(AiAgentStreamEvent::ToolDone {
tool_id: tool_id(json),
output: json.get("result").map(|result| result.to_string()),
});
}
fn emit_error_event<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
if let Some(message) = message_value(json) {
emit(AiAgentStreamEvent::Error {
message: message.to_string(),
});
}
}
fn tool_name(json: &serde_json::Value) -> String {
json["toolName"].as_str().unwrap_or("tool").to_string()
}
fn tool_id(json: &serde_json::Value) -> String {
json["toolCallId"].as_str().unwrap_or("tool").to_string()
}
fn message_value(json: &serde_json::Value) -> Option<&str> {
json["message"]
.as_str()
.or_else(|| json["error"].as_str())
.or_else(|| json["text"].as_str())
}
fn is_auth_error(lower: &str) -> bool {
[
"auth", "login", "sign in", "api key", "api.key", "provider", "401",
]
.iter()
.any(|pattern| lower.contains(pattern))
}
#[cfg(test)]
#[path = "pi_events_tests.rs"]
mod tests;

View File

@@ -0,0 +1,115 @@
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();
let started = serde_json::json!({ "type": "session", "id": "pi-session" });
let thinking = serde_json::json!({
"type": "message_update",
"assistantMessageEvent": { "type": "thinking_delta", "delta": "Checking links" }
});
let text = serde_json::json!({
"type": "message_update",
"assistantMessageEvent": { "type": "text_delta", "delta": "Done" }
});
for event in [started, thinking, text] {
dispatch_event(&event, &mut |event| events.push(event));
}
assert!(matches!(
&events[0],
AiAgentStreamEvent::Init { session_id } if session_id == "pi-session"
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links"
));
assert!(matches!(
&events[2],
AiAgentStreamEvent::TextDelta { text } if text == "Done"
));
}
#[test]
fn dispatch_maps_tool_events() {
let mut events = Vec::new();
let tool_start = serde_json::json!({
"type": "tool_execution_start",
"toolCallId": "tool_1",
"toolName": "search_notes",
"args": { "query": "today" }
});
let tool_done = serde_json::json!({
"type": "tool_execution_end",
"toolCallId": "tool_1",
"toolName": "search_notes",
"result": { "ok": true }
});
dispatch_event(&tool_start, &mut |event| events.push(event));
dispatch_event(&tool_done, &mut |event| events.push(event));
assert!(matches!(
&events[0],
AiAgentStreamEvent::ToolStart { tool_name, tool_id, input }
if tool_name == "search_notes" && tool_id == "tool_1" && input.as_deref() == Some(r#"{"query":"today"}"#)
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ToolDone { tool_id, output }
if tool_id == "tool_1" && output.as_deref() == Some(r#"{"ok":true}"#)
));
}
#[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(
"provider auth failed: api key required".into(),
"exit status: 1".into(),
);
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");
}

View File

@@ -1,5 +1,5 @@
use serde::Serialize;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::time::Instant;
use walkdir::WalkDir;
@@ -20,6 +20,14 @@ pub struct SearchResponse {
pub mode: String,
}
pub struct SearchOptions<'a> {
pub vault_path: &'a str,
pub query: &'a str,
pub mode: &'a str,
pub limit: usize,
pub hide_gitignored_files: bool,
}
fn extract_snippet(content: &str, query_lower: &str) -> String {
let content_lower = content.to_lowercase();
let pos = match content_lower.find(query_lower) {
@@ -63,26 +71,45 @@ pub fn search_vault(
_mode: &str,
limit: usize,
) -> Result<SearchResponse, String> {
search_vault_with_options(SearchOptions {
vault_path,
query,
mode: _mode,
limit,
hide_gitignored_files: crate::settings::hide_gitignored_files_enabled(),
})
}
fn is_markdown_search_candidate(path: &Path) -> bool {
if !path.extension().is_some_and(|ext| ext == "md") {
return false;
}
!path
.components()
.any(|component| component.as_os_str().to_string_lossy().starts_with('.'))
}
fn collect_markdown_paths(vault_dir: &Path, hide_gitignored_files: bool) -> Vec<PathBuf> {
let paths = WalkDir::new(vault_dir)
.into_iter()
.filter_map(|entry| entry.ok())
.map(|entry| entry.into_path())
.filter(|path| is_markdown_search_candidate(path))
.collect::<Vec<_>>();
crate::vault::filter_gitignored_paths(vault_dir, paths, hide_gitignored_files)
}
pub fn search_vault_with_options(options: SearchOptions<'_>) -> Result<SearchResponse, String> {
let start = Instant::now();
let query_lower = query.to_lowercase();
let vault_dir = Path::new(vault_path);
let query_lower = options.query.to_lowercase();
let vault_dir = Path::new(options.vault_path);
let mut results: Vec<SearchResult> = Vec::new();
for entry in WalkDir::new(vault_dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if !path.extension().is_some_and(|ext| ext == "md") {
continue;
}
// Skip hidden dirs and .laputa config
if path
.components()
.any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
{
continue;
}
let content = match std::fs::read_to_string(path) {
for path in collect_markdown_paths(vault_dir, options.hide_gitignored_files) {
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
@@ -117,15 +144,15 @@ pub fn search_vault(
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.truncate(limit);
results.truncate(options.limit);
let elapsed_ms = start.elapsed().as_millis() as u64;
Ok(SearchResponse {
results,
elapsed_ms,
query: query.to_string(),
mode: "keyword".to_string(),
query: options.query.to_string(),
mode: options.mode.to_string(),
})
}
@@ -135,6 +162,14 @@ mod tests {
use std::fs;
use tempfile::Builder;
fn init_git_repo(root: &Path) {
crate::hidden_command("git")
.args(["init"])
.current_dir(root)
.output()
.unwrap();
}
#[test]
fn test_extract_snippet_basic() {
let content = "line one\nline with keyword here\nline three";
@@ -188,4 +223,38 @@ mod tests {
assert_eq!(response.results.len(), 1);
assert_eq!(response.results[0].title, "Updated Display Title");
}
#[test]
fn test_search_vault_hides_gitignored_notes_when_enabled() {
let dir = Builder::new()
.prefix("search-gitignored-")
.tempdir_in(std::env::current_dir().unwrap())
.unwrap();
init_git_repo(dir.path());
fs::create_dir_all(dir.path().join("ignored")).unwrap();
fs::write(dir.path().join(".gitignore"), "ignored/\n").unwrap();
fs::write(dir.path().join("visible.md"), "# Visible\n\nneedle").unwrap();
fs::write(dir.path().join("ignored/hidden.md"), "# Hidden\n\nneedle").unwrap();
let hidden = search_vault_with_options(SearchOptions {
vault_path: dir.path().to_str().unwrap(),
query: "needle",
mode: "keyword",
limit: 10,
hide_gitignored_files: true,
})
.unwrap();
let shown = search_vault_with_options(SearchOptions {
vault_path: dir.path().to_str().unwrap(),
query: "needle",
mode: "keyword",
limit: 10,
hide_gitignored_files: false,
})
.unwrap();
assert_eq!(hidden.results.len(), 1);
assert_eq!(hidden.results[0].title, "Visible");
assert_eq!(shown.results.len(), 2);
}
}

View File

@@ -4,6 +4,8 @@ use std::path::PathBuf;
const APP_CONFIG_DIR: &str = "com.tolaria.app";
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] = &["claude_code", "codex", "opencode", "pi", "gemini"];
pub const DEFAULT_HIDE_GITIGNORED_FILES: bool = true;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Settings {
@@ -21,6 +23,7 @@ pub struct Settings {
pub ui_language: Option<String>,
pub initial_h1_auto_rename_enabled: Option<bool>,
pub default_ai_agent: Option<String>,
pub hide_gitignored_files: Option<bool>,
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
@@ -50,7 +53,7 @@ pub fn effective_release_channel(value: Option<&str>) -> &'static str {
pub fn normalize_default_ai_agent(value: Option<&str>) -> Option<String> {
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
Some(agent) if agent == "claude_code" || agent == "codex" => Some(agent),
Some(agent) if SUPPORTED_DEFAULT_AI_AGENTS.contains(&agent.as_str()) => Some(agent),
_ => None,
}
}
@@ -62,6 +65,18 @@ pub fn normalize_theme_mode(value: Option<&str>) -> Option<String> {
}
}
pub fn should_hide_gitignored_files(settings: &Settings) -> bool {
settings
.hide_gitignored_files
.unwrap_or(DEFAULT_HIDE_GITIGNORED_FILES)
}
pub fn hide_gitignored_files_enabled() -> bool {
get_settings()
.map(|settings| should_hide_gitignored_files(&settings))
.unwrap_or(DEFAULT_HIDE_GITIGNORED_FILES)
}
fn canonical_language_code(value: &str) -> Option<String> {
let code = value.trim().replace('_', "-").to_ascii_lowercase();
if code.is_empty() {
@@ -110,6 +125,7 @@ fn normalize_settings(settings: Settings) -> Settings {
ui_language: normalize_ui_language(settings.ui_language.as_deref()),
initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled,
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
hide_gitignored_files: settings.hide_gitignored_files,
}
}
@@ -117,7 +133,7 @@ fn app_config_dir() -> Result<PathBuf, String> {
dirs::config_dir().ok_or_else(|| "Could not determine config directory".to_string())
}
fn preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
pub(crate) fn preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
Ok(app_config_dir()?.join(APP_CONFIG_DIR).join(file_name))
}
@@ -252,6 +268,7 @@ mod tests {
ui_language: Some("zh-Hans".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
hide_gitignored_files: Some(false),
};
let json = serde_json::to_string(&settings).unwrap();
let parsed: Settings = serde_json::from_str(&json).unwrap();
@@ -279,6 +296,7 @@ mod tests {
ui_language: Some("zh-Hans".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
hide_gitignored_files: Some(false),
..Default::default()
});
assert_eq!(loaded.auto_pull_interval_minutes, Some(10));
@@ -291,6 +309,20 @@ mod tests {
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false));
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
assert_eq!(loaded.hide_gitignored_files, Some(false));
}
#[test]
fn test_gitignored_files_are_hidden_by_default() {
assert!(should_hide_gitignored_files(&Settings::default()));
assert!(should_hide_gitignored_files(&Settings {
hide_gitignored_files: Some(true),
..Default::default()
}));
assert!(!should_hide_gitignored_files(&Settings {
hide_gitignored_files: Some(false),
..Default::default()
}));
}
#[test]
@@ -348,6 +380,33 @@ mod tests {
assert!(loaded.default_ai_agent.is_none());
}
#[test]
fn test_opencode_default_ai_agent_is_preserved() {
let loaded = save_and_reload(Settings {
default_ai_agent: Some("opencode".to_string()),
..Default::default()
});
assert_eq!(loaded.default_ai_agent.as_deref(), Some("opencode"));
}
#[test]
fn test_pi_default_ai_agent_is_preserved() {
let loaded = save_and_reload(Settings {
default_ai_agent: Some("pi".to_string()),
..Default::default()
});
assert_eq!(loaded.default_ai_agent.as_deref(), Some("pi"));
}
#[test]
fn test_gemini_default_ai_agent_is_preserved() {
let loaded = save_and_reload(Settings {
default_ai_agent: Some("gemini".to_string()),
..Default::default()
});
assert_eq!(loaded.default_ai_agent.as_deref(), Some("gemini"));
}
#[test]
fn test_invalid_theme_mode_is_filtered() {
let loaded = save_and_reload(Settings {

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
use std::fs;
use std::io::{ErrorKind, Write};
use std::io::{Error, ErrorKind, Write};
use std::path::Path;
use std::time::UNIX_EPOCH;
@@ -25,18 +25,71 @@ fn invalid_utf8_text_error(path: &Path) -> String {
format!("File is not valid UTF-8 text: {}", path.display())
}
/// Read the content of a single note file.
pub fn get_note_content(path: &Path) -> Result<String, String> {
fn is_invalid_platform_path_error(error: &Error) -> bool {
error.kind() == ErrorKind::InvalidInput || error.raw_os_error() == Some(123)
}
fn read_existing_note_bytes(path: &Path) -> Result<Vec<u8>, String> {
if !path.exists() {
return Err(format!("File does not exist: {}", path.display()));
}
if !path.is_file() {
return Err(format!("Path is not a file: {}", path.display()));
}
let bytes = fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))
}
#[derive(Clone, Copy)]
enum NoteIoOperation {
Save,
Create,
}
#[derive(Clone, Copy)]
struct NotePathDisplay<'a> {
value: &'a str,
}
impl<'a> NotePathDisplay<'a> {
fn new(value: &'a str) -> Self {
Self { value }
}
}
impl NoteIoOperation {
fn verb(self) -> &'static str {
match self {
Self::Save => "save",
Self::Create => "create",
}
}
}
fn note_io_error(operation: NoteIoOperation, path: NotePathDisplay<'_>, error: &Error) -> String {
let verb = operation.verb();
if is_invalid_platform_path_error(error) {
let path = path.value;
format!(
"Failed to {verb} note: the path is invalid on this platform. Rename the note or move it to a valid folder, then try again. Path: {path}"
)
} else {
let path = path.value;
format!("Failed to {verb} {path}: {error}")
}
}
/// Read the content of a single note file.
pub fn get_note_content(path: &Path) -> Result<String, String> {
let bytes = read_existing_note_bytes(path)?;
String::from_utf8(bytes).map_err(|_| invalid_utf8_text_error(path))
}
/// Check whether a note still has the exact content the renderer cached.
pub fn note_content_matches(path: &Path, expected_content: &str) -> Result<bool, String> {
let bytes = read_existing_note_bytes(path)?;
Ok(bytes == expected_content.as_bytes())
}
fn validate_save_path(file_path: &Path, display_path: &str) -> Result<(), String> {
let parent_missing = file_path.parent().is_some_and(|p| !p.exists());
if parent_missing {
@@ -62,12 +115,14 @@ pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
let file_path = Path::new(path);
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
fs::create_dir_all(parent).map_err(|e| {
note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e)
})?;
}
}
validate_save_path(file_path, path)?;
fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e))
fs::write(file_path, content)
.map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e))
}
/// Create a new note file without overwriting any existing file.
@@ -75,8 +130,9 @@ pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
let file_path = Path::new(path);
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
fs::create_dir_all(parent).map_err(|e| {
note_io_error(NoteIoOperation::Create, NotePathDisplay::new(path), &e)
})?;
}
}
validate_save_path(file_path, path)?;
@@ -86,8 +142,37 @@ pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
.open(file_path)
.map_err(|e| match e.kind() {
ErrorKind::AlreadyExists => format!("File already exists: {}", path),
_ => format!("Failed to create {}: {}", path, e),
_ => note_io_error(NoteIoOperation::Create, NotePathDisplay::new(path), &e),
})?;
file.write_all(content.as_bytes())
.map_err(|e| format!("Failed to save {}: {}", path, e))
.map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_windows_invalid_path_syntax_as_recoverable_save_error() {
let path = r"C:\Users\@raflymln\notes\untitled-note-1777236475.md";
let message = note_io_error(
NoteIoOperation::Save,
NotePathDisplay::new(path),
&Error::from_raw_os_error(123),
);
assert!(message.contains("path is invalid on this platform"));
assert!(message.contains("Rename the note or move it to a valid folder"));
assert!(!message.contains("os error 123"));
}
#[test]
fn note_content_matches_detects_external_edits() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("note.md");
fs::write(&path, "# Fresh\n").unwrap();
assert!(note_content_matches(&path, "# Fresh\n").unwrap());
assert!(!note_content_matches(&path, "# Stale\n").unwrap());
}
}

View File

@@ -568,7 +568,7 @@ fn refresh_cloned_vault_config_files(vault_path: &Path) -> Result<(), String> {
return Ok(());
}
ensure_commit_identity(vault_path)?;
crate::git::ensure_author_config(vault_path)?;
crate::git::git_commit(
path_to_utf8(vault_path, "Vault path")?,
"Initialize Tolaria config files",
@@ -593,38 +593,6 @@ fn vault_has_pending_changes(vault_path: &Path) -> Result<bool, String> {
))
}
fn ensure_commit_identity(vault_path: &Path) -> Result<(), String> {
for (key, fallback) in [
("user.name", "Tolaria"),
("user.email", "vault@tolaria.app"),
] {
let output = crate::hidden_command("git")
.args(["config", key])
.current_dir(vault_path)
.output()
.map_err(|e| format!("Failed to inspect git config {key}: {e}"))?;
if output.status.success() && !String::from_utf8_lossy(&output.stdout).trim().is_empty() {
continue;
}
let set_output = crate::hidden_command("git")
.args(["config", key, fallback])
.current_dir(vault_path)
.output()
.map_err(|e| format!("Failed to set git config {key}: {e}"))?;
if !set_output.status.success() {
return Err(format!(
"git config {key} failed: {}",
String::from_utf8_lossy(&set_output.stderr).trim()
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -0,0 +1,300 @@
use super::{FolderNode, VaultEntry};
use std::collections::HashSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use walkdir::{DirEntry, WalkDir};
fn normalize_relative_path(path: &str) -> String {
path.replace('\\', "/")
.trim_start_matches("./")
.trim_matches('/')
.to_string()
}
fn relative_path(vault_path: &Path, path: &Path) -> Option<String> {
let relative = path.strip_prefix(vault_path).ok()?;
let normalized = normalize_relative_path(relative.to_string_lossy().as_ref());
(!normalized.is_empty()).then_some(normalized)
}
fn should_descend_for_gitignore(entry: &DirEntry) -> bool {
entry.depth() == 0 || entry.file_name().to_string_lossy() != ".git"
}
fn has_gitignore_file(vault_path: &Path) -> bool {
WalkDir::new(vault_path)
.follow_links(false)
.into_iter()
.filter_entry(should_descend_for_gitignore)
.filter_map(Result::ok)
.any(|entry| {
entry.file_type().is_file() && entry.file_name().to_string_lossy() == ".gitignore"
})
}
fn run_git_check_ignore(vault_path: &Path, relative_paths: &[String]) -> Option<String> {
let mut child = crate::hidden_command("git")
.args(["check-ignore", "--no-index", "--stdin"])
.current_dir(vault_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
{
let stdin = child.stdin.as_mut()?;
for path in relative_paths {
writeln!(stdin, "{path}").ok()?;
}
}
let output = child.wait_with_output().ok()?;
if output.status.success() || output.status.code() == Some(1) {
return Some(String::from_utf8_lossy(&output.stdout).to_string());
}
None
}
fn ignored_relative_paths(vault_path: &Path, relative_paths: &[String]) -> HashSet<String> {
if relative_paths.is_empty() || !has_gitignore_file(vault_path) {
return HashSet::new();
}
let mut candidates = relative_paths
.iter()
.map(|path| normalize_relative_path(path))
.filter(|path| !path.is_empty())
.collect::<Vec<_>>();
candidates.sort();
candidates.dedup();
run_git_check_ignore(vault_path, &candidates)
.unwrap_or_default()
.lines()
.map(normalize_relative_path)
.filter(|path| !path.is_empty())
.collect()
}
fn filter_gitignored_items<T>(
vault_path: &Path,
items: Vec<T>,
hide_enabled: bool,
relative_for: impl Fn(&T) -> Option<String>,
) -> Vec<T> {
if !hide_enabled || items.is_empty() {
return items;
}
let relative_paths = items.iter().filter_map(&relative_for).collect::<Vec<_>>();
let ignored = ignored_relative_paths(vault_path, &relative_paths);
if ignored.is_empty() {
return items;
}
items
.into_iter()
.filter(|item| {
relative_for(item)
.map(|relative| !ignored.contains(&relative))
.unwrap_or(true)
})
.collect()
}
pub fn filter_gitignored_paths(
vault_path: &Path,
paths: Vec<PathBuf>,
hide_enabled: bool,
) -> Vec<PathBuf> {
filter_gitignored_items(vault_path, paths, hide_enabled, |path| {
relative_path(vault_path, path)
})
}
pub fn filter_gitignored_entries(
vault_path: &Path,
entries: Vec<VaultEntry>,
hide_enabled: bool,
) -> Vec<VaultEntry> {
filter_gitignored_items(vault_path, entries, hide_enabled, |entry| {
relative_path(vault_path, Path::new(&entry.path))
})
}
fn collect_folder_queries(nodes: &[FolderNode], queries: &mut Vec<String>) {
for node in nodes {
let relative = normalize_relative_path(&node.path);
if !relative.is_empty() {
queries.push(relative.clone());
queries.push(format!("{relative}/"));
}
collect_folder_queries(&node.children, queries);
}
}
fn path_or_parent_is_ignored(relative_path: &str, ignored: &HashSet<String>) -> bool {
if ignored.contains(relative_path) {
return true;
}
let mut current = relative_path;
while let Some((parent, _)) = current.rsplit_once('/') {
if ignored.contains(parent) {
return true;
}
current = parent;
}
false
}
fn filter_folder_nodes(nodes: Vec<FolderNode>, ignored: &HashSet<String>) -> Vec<FolderNode> {
nodes
.into_iter()
.filter_map(|mut node| {
let relative = normalize_relative_path(&node.path);
if path_or_parent_is_ignored(&relative, ignored) {
return None;
}
node.children = filter_folder_nodes(node.children, ignored);
Some(node)
})
.collect()
}
pub fn filter_gitignored_folders(
vault_path: &Path,
folders: Vec<FolderNode>,
hide_enabled: bool,
) -> Vec<FolderNode> {
if !hide_enabled || folders.is_empty() {
return folders;
}
let mut queries = Vec::new();
collect_folder_queries(&folders, &mut queries);
let ignored = ignored_relative_paths(vault_path, &queries);
if ignored.is_empty() {
return folders;
}
filter_folder_nodes(folders, &ignored)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_file(root: &Path, relative: &str, content: &str) {
let path = root.join(relative);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
fn init_git_repo(root: &Path) {
crate::hidden_command("git")
.args(["init"])
.current_dir(root)
.output()
.unwrap();
}
fn entry(root: &Path, relative: &str) -> VaultEntry {
VaultEntry {
path: root.join(relative).to_string_lossy().to_string(),
filename: relative.rsplit('/').next().unwrap().to_string(),
title: relative.to_string(),
..VaultEntry::default()
}
}
fn entry_paths(root: &Path, entries: &[VaultEntry]) -> Vec<String> {
entries
.iter()
.map(|entry| relative_path(root, Path::new(&entry.path)).unwrap())
.collect()
}
#[test]
fn filters_ignored_entries_with_git_style_negation() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
write_file(dir.path(), ".gitignore", "ignored/*\n!ignored/keep.md\n");
write_file(dir.path(), "visible.md", "# Visible\n");
write_file(dir.path(), "ignored/hidden.md", "# Hidden\n");
write_file(dir.path(), "ignored/keep.md", "# Keep\n");
let filtered = filter_gitignored_entries(
dir.path(),
vec![
entry(dir.path(), "visible.md"),
entry(dir.path(), "ignored/hidden.md"),
entry(dir.path(), "ignored/keep.md"),
],
true,
);
assert_eq!(
entry_paths(dir.path(), &filtered),
vec!["visible.md", "ignored/keep.md"]
);
}
#[test]
fn keeps_ignored_entries_when_visibility_is_enabled() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
write_file(dir.path(), ".gitignore", "ignored/\n");
let entries = vec![entry(dir.path(), "ignored/hidden.md")];
let filtered = filter_gitignored_entries(dir.path(), entries, false);
assert_eq!(
entry_paths(dir.path(), &filtered),
vec!["ignored/hidden.md"]
);
}
#[test]
fn filters_ignored_folder_trees() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
write_file(dir.path(), ".gitignore", "generated/\n");
fs::create_dir_all(dir.path().join("generated/nested")).unwrap();
fs::create_dir_all(dir.path().join("notes")).unwrap();
let folders = vec![
FolderNode {
name: "generated".to_string(),
path: "generated".to_string(),
children: vec![FolderNode {
name: "nested".to_string(),
path: "generated/nested".to_string(),
children: vec![],
}],
},
FolderNode {
name: "notes".to_string(),
path: "notes".to_string(),
children: vec![],
},
];
let filtered = filter_gitignored_folders(dir.path(), folders, true);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].path, "notes");
}
#[test]
fn has_no_effect_without_gitignore_file() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
let entries = vec![entry(dir.path(), "notes/local.md")];
let filtered = filter_gitignored_entries(dir.path(), entries, true);
assert_eq!(entry_paths(dir.path(), &filtered), vec!["notes/local.md"]);
}
}

View File

@@ -6,6 +6,7 @@ pub(crate) mod filename_rules;
mod folders;
mod frontmatter;
mod getting_started;
mod ignored;
mod image;
mod migration;
mod parsing;
@@ -21,9 +22,10 @@ pub use config_seed::{
seed_config_files, AiGuidanceFileState, VaultAiGuidanceStatus,
};
pub use entry::{FolderNode, VaultEntry};
pub use file::{create_note_content, get_note_content, save_note_content};
pub use file::{create_note_content, get_note_content, note_content_matches, save_note_content};
pub use folders::{delete_folder, rename_folder, FolderRenameResult};
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
pub use ignored::{filter_gitignored_entries, filter_gitignored_folders, filter_gitignored_paths};
pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
pub use rename::{

View File

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

View File

@@ -0,0 +1,255 @@
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use serde::Serialize;
pub const VAULT_CHANGED_EVENT: &str = "vault-changed";
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct VaultChangedPayload {
vault_path: String,
paths: Vec<String>,
}
fn has_ignored_component(path: &Path) -> bool {
path.components().any(|part| {
let component = part.as_os_str();
component == OsStr::new(".git") || component == OsStr::new("node_modules")
})
}
fn is_temp_file_name(name: &OsStr) -> bool {
let Some(name) = name.to_str() else {
return false;
};
name == ".DS_Store"
|| name == ".tolaria-rename-txn"
|| name.starts_with(".#")
|| name.ends_with('~')
|| name.ends_with(".tmp")
|| name.ends_with(".swp")
|| name.ends_with(".swx")
}
fn is_watchable_path(path: &Path) -> bool {
if has_ignored_component(path) {
return false;
}
match path.file_name() {
Some(name) => !is_temp_file_name(name),
None => true,
}
}
#[cfg(desktop)]
mod desktop {
use std::sync::Mutex;
use notify::{
recommended_watcher, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher,
};
use tauri::Emitter;
use super::{is_watchable_path, Path, PathBuf, VaultChangedPayload, VAULT_CHANGED_EVENT};
struct ActiveVaultWatcher {
path: PathBuf,
_watcher: RecommendedWatcher,
}
pub struct VaultWatcherState {
active: Mutex<Option<ActiveVaultWatcher>>,
}
impl Default for VaultWatcherState {
fn default() -> Self {
Self::new()
}
}
impl VaultWatcherState {
pub fn new() -> Self {
Self {
active: Mutex::new(None),
}
}
}
fn validate_vault_path(vault_path: PathBuf) -> Result<PathBuf, String> {
if vault_path.as_os_str().is_empty() {
return Err("Vault path is required".to_string());
}
if !vault_path.is_dir() {
return Err(format!(
"Vault path is not a directory: {}",
vault_path.display()
));
}
Ok(vault_path)
}
fn should_emit_event(event: &Event) -> bool {
!matches!(event.kind, EventKind::Access(_))
}
fn changed_paths(event: Event) -> Vec<String> {
if !should_emit_event(&event) {
return Vec::new();
}
event
.paths
.into_iter()
.filter(|path| is_watchable_path(path))
.map(|path| path.to_string_lossy().to_string())
.collect()
}
fn emit_vault_change(app: &tauri::AppHandle, vault_path: &Path, event: Event) {
let paths = changed_paths(event);
if paths.is_empty() {
return;
}
let payload = VaultChangedPayload {
vault_path: vault_path.to_string_lossy().to_string(),
paths,
};
if let Err(err) = app.emit(VAULT_CHANGED_EVENT, payload) {
log::warn!("Failed to emit vault watcher event: {}", err);
}
}
pub fn start(
app: tauri::AppHandle,
state: tauri::State<'_, VaultWatcherState>,
path: PathBuf,
) -> Result<(), String> {
let vault_path = validate_vault_path(path)?;
let mut active = state
.active
.lock()
.map_err(|_| "Failed to lock vault watcher state".to_string())?;
if active
.as_ref()
.is_some_and(|watcher| watcher.path == vault_path)
{
return Ok(());
}
let event_vault_path = vault_path.clone();
let event_app = app.clone();
let mut watcher = recommended_watcher(move |event| match event {
Ok(event) => emit_vault_change(&event_app, &event_vault_path, event),
Err(err) => log::warn!("Vault watcher event failed: {}", err),
})
.map_err(|err| format!("Failed to create vault watcher: {err}"))?;
watcher
.watch(&vault_path, RecursiveMode::Recursive)
.map_err(|err| format!("Failed to watch {}: {err}", vault_path.display()))?;
*active = Some(ActiveVaultWatcher {
path: vault_path,
_watcher: watcher,
});
Ok(())
}
pub fn stop(state: tauri::State<'_, VaultWatcherState>) -> Result<(), String> {
let mut active = state
.active
.lock()
.map_err(|_| "Failed to lock vault watcher state".to_string())?;
*active = None;
Ok(())
}
}
#[cfg(not(desktop))]
mod mobile {
use super::PathBuf;
pub struct VaultWatcherState;
impl Default for VaultWatcherState {
fn default() -> Self {
Self::new()
}
}
impl VaultWatcherState {
pub fn new() -> Self {
Self
}
}
pub fn start(_path: PathBuf) -> Result<(), String> {
Ok(())
}
pub fn stop() -> Result<(), String> {
Ok(())
}
}
#[cfg(desktop)]
pub use desktop::VaultWatcherState;
#[cfg(not(desktop))]
pub use mobile::VaultWatcherState;
#[cfg(desktop)]
#[tauri::command]
pub fn start_vault_watcher(
app: tauri::AppHandle,
state: tauri::State<'_, VaultWatcherState>,
path: PathBuf,
) -> Result<(), String> {
desktop::start(app, state, path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn stop_vault_watcher(state: tauri::State<'_, VaultWatcherState>) -> Result<(), String> {
desktop::stop(state)
}
#[cfg(not(desktop))]
#[tauri::command]
pub fn start_vault_watcher(path: PathBuf) -> Result<(), String> {
mobile::start(path)
}
#[cfg(not(desktop))]
#[tauri::command]
pub fn stop_vault_watcher() -> Result<(), String> {
mobile::stop()
}
#[cfg(test)]
mod tests {
use super::is_watchable_path;
use std::path::Path;
#[test]
fn ignores_git_and_dependency_directory_changes() {
assert!(!is_watchable_path(Path::new(".git/index.lock")));
assert!(!is_watchable_path(Path::new(
"node_modules/package/index.js"
)));
}
#[test]
fn ignores_common_temporary_files() {
assert!(!is_watchable_path(Path::new("note.md.tmp")));
assert!(!is_watchable_path(Path::new("note.md.swp")));
assert!(!is_watchable_path(Path::new("draft.md~")));
assert!(!is_watchable_path(Path::new(".DS_Store")));
assert!(!is_watchable_path(Path::new(".tolaria-rename-txn")));
}
#[test]
fn keeps_notes_assets_and_saved_views_watchable() {
assert!(is_watchable_path(Path::new("notes/day.md")));
assert!(is_watchable_path(Path::new("attachments/image.png")));
assert!(is_watchable_path(Path::new(".laputa/views/work.yml")));
}
}

View File

@@ -0,0 +1,514 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{
App, AppHandle, LogicalPosition, LogicalSize, Manager, Position, RunEvent, Size, WebviewWindow,
WindowEvent,
};
const MAIN_WINDOW_LABEL: &str = "main";
const WINDOW_STATE_FILE: &str = "window-state.json";
const MIN_WINDOW_WIDTH: u32 = 480;
const MIN_WINDOW_HEIGHT: u32 = 400;
#[derive(Debug, Default)]
pub(crate) struct MainWindowFrameState(Mutex<Option<WindowFrame>>);
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
struct WindowFrame {
x: i32,
y: i32,
width: u32,
height: u32,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
struct ScreenArea {
x: i32,
y: i32,
width: u32,
height: u32,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct PersistedWindowState {
main: Option<WindowFrame>,
#[serde(default)]
coordinate_space: WindowFrameCoordinateSpace,
}
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum WindowFrameCoordinateSpace {
#[default]
Physical,
Logical,
}
pub(crate) fn restore_main_window_state(app: &mut App) {
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
return;
};
restore_main_window_frame(app.handle(), &window, "during setup");
}
pub(crate) fn handle_run_event(app_handle: &AppHandle, event: &RunEvent) {
match event {
event if restores_window_frame_after_runtime_ready(event) => {
restore_main_window_state_from_handle(app_handle)
}
RunEvent::WindowEvent {
label,
event:
WindowEvent::Moved(_) | WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. },
..
} if label == MAIN_WINDOW_LABEL => cache_current_normal_frame(app_handle),
RunEvent::WindowEvent {
label,
event: WindowEvent::CloseRequested { .. } | WindowEvent::Destroyed,
..
} if label == MAIN_WINDOW_LABEL => save_main_window_frame(app_handle),
RunEvent::Exit => save_main_window_frame(app_handle),
_ => {}
}
}
fn restores_window_frame_after_runtime_ready(event: &RunEvent) -> bool {
matches!(event, RunEvent::Ready)
}
fn restore_main_window_state_from_handle(app_handle: &AppHandle) {
let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) else {
return;
};
restore_main_window_frame(app_handle, &window, "after runtime ready");
}
fn restore_main_window_frame(app_handle: &AppHandle, window: &WebviewWindow, phase: &str) {
let Some(frame) = read_main_window_frame(window_scale_factor(window)) else {
return;
};
let areas = current_screen_areas(window);
let Some(restored_frame) = fit_frame_to_screens(frame, &areas) else {
return;
};
if let Err(err) = apply_window_frame(window, restored_frame) {
log::warn!("Failed to restore main window state {phase}: {err}");
return;
}
cache_frame(app_handle, restored_frame);
}
fn cache_current_normal_frame(app_handle: &AppHandle) {
if let Some(frame) = current_normal_main_window_frame(app_handle) {
cache_frame(app_handle, frame);
}
}
fn save_main_window_frame(app_handle: &AppHandle) {
let frame = current_normal_main_window_frame(app_handle).or_else(|| cached_frame(app_handle));
if let Some(frame) = frame {
if let Err(err) = write_main_window_frame(frame) {
log::warn!("Failed to save main window state: {err}");
}
}
}
fn current_normal_main_window_frame(app_handle: &AppHandle) -> Option<WindowFrame> {
let window = app_handle.get_webview_window(MAIN_WINDOW_LABEL)?;
if !is_normal_window(&window) {
return None;
}
read_window_frame(&window).filter(is_valid_saved_frame)
}
fn is_normal_window(window: &WebviewWindow) -> bool {
let is_fullscreen = window.is_fullscreen().unwrap_or(false);
let is_maximized = window.is_maximized().unwrap_or(false);
let is_minimized = window.is_minimized().unwrap_or(false);
!is_fullscreen && !is_maximized && !is_minimized
}
fn read_window_frame(window: &WebviewWindow) -> Option<WindowFrame> {
let scale_factor = window_scale_factor(window);
let position = window.outer_position().ok()?;
let size = window.inner_size().ok()?;
Some(WindowFrame::from_logical_geometry(
position.to_logical::<f64>(scale_factor),
size.to_logical::<f64>(scale_factor),
))
}
fn apply_window_frame(window: &WebviewWindow, frame: WindowFrame) -> tauri::Result<()> {
window.set_size(Size::Logical(LogicalSize::new(
frame.width as f64,
frame.height as f64,
)))?;
window.set_position(Position::Logical(LogicalPosition::new(
frame.x as f64,
frame.y as f64,
)))
}
fn current_screen_areas(window: &WebviewWindow) -> Vec<ScreenArea> {
let scale_factor = window_scale_factor(window);
window
.available_monitors()
.unwrap_or_default()
.into_iter()
.map(|monitor| {
let area = monitor.work_area();
let position = area.position.to_logical::<f64>(scale_factor);
let size = area.size.to_logical::<f64>(scale_factor);
ScreenArea {
x: rounded_i32(position.x),
y: rounded_i32(position.y),
width: rounded_u32(size.width),
height: rounded_u32(size.height),
}
})
.filter(ScreenArea::has_area)
.collect()
}
fn window_scale_factor(window: &WebviewWindow) -> f64 {
window.scale_factor().unwrap_or(1.0).max(1.0)
}
fn fit_frame_to_screens(frame: WindowFrame, screens: &[ScreenArea]) -> Option<WindowFrame> {
if frame_is_visible(frame, screens) {
return Some(frame);
}
let screen = best_screen_for_frame(frame, screens)?;
let width = clamp_dimension(frame.width, MIN_WINDOW_WIDTH, screen.width);
let height = clamp_dimension(frame.height, MIN_WINDOW_HEIGHT, screen.height);
Some(WindowFrame {
x: clamp_axis(frame.x, width, screen.x, screen.width),
y: clamp_axis(frame.y, height, screen.y, screen.height),
width,
height,
})
}
fn frame_is_visible(frame: WindowFrame, screens: &[ScreenArea]) -> bool {
frame_corners(frame)
.into_iter()
.all(|point| screens.iter().any(|screen| screen.contains(point)))
}
fn frame_corners(frame: WindowFrame) -> [(i32, i32); 4] {
let right = frame.right() - 1;
let bottom = frame.bottom() - 1;
[
(frame.x, frame.y),
(right, frame.y),
(frame.x, bottom),
(right, bottom),
]
}
fn best_screen_for_frame(frame: WindowFrame, screens: &[ScreenArea]) -> Option<ScreenArea> {
screens
.iter()
.copied()
.filter(ScreenArea::has_area)
.max_by_key(|screen| intersection_area(frame, *screen))
}
fn intersection_area(frame: WindowFrame, screen: ScreenArea) -> u64 {
let left = frame.x.max(screen.x);
let top = frame.y.max(screen.y);
let right = frame.right().min(screen.right());
let bottom = frame.bottom().min(screen.bottom());
if right <= left || bottom <= top {
return 0;
}
(right - left) as u64 * (bottom - top) as u64
}
fn clamp_dimension(value: u32, min: u32, max: u32) -> u32 {
if max < min {
max
} else {
value.clamp(min, max)
}
}
fn clamp_axis(value: i32, size: u32, area_start: i32, area_size: u32) -> i32 {
let max_start = area_start + area_size as i32 - size as i32;
if max_start < area_start {
return area_start;
}
value.clamp(area_start, max_start)
}
fn cache_frame(app_handle: &AppHandle, frame: WindowFrame) {
let state: tauri::State<'_, MainWindowFrameState> = app_handle.state();
if let Ok(mut cached_frame) = state.0.lock() {
*cached_frame = Some(frame);
};
}
fn cached_frame(app_handle: &AppHandle) -> Option<WindowFrame> {
let state: tauri::State<'_, MainWindowFrameState> = app_handle.state();
state.0.lock().ok().and_then(|cached_frame| *cached_frame)
}
fn window_state_path() -> Result<PathBuf, String> {
crate::settings::preferred_app_config_path(WINDOW_STATE_FILE)
}
fn read_main_window_frame(scale_factor: f64) -> Option<WindowFrame> {
let content = fs::read_to_string(window_state_path().ok()?).ok()?;
let persisted: PersistedWindowState = serde_json::from_str(&content).ok()?;
persisted
.main
.map(|frame| {
persisted
.coordinate_space
.to_logical_frame(frame, scale_factor)
})
.filter(is_valid_saved_frame)
}
fn write_main_window_frame(frame: WindowFrame) -> Result<(), String> {
let path = window_state_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create window state directory: {e}"))?;
}
let persisted = PersistedWindowState {
main: Some(frame),
coordinate_space: WindowFrameCoordinateSpace::Logical,
};
let json = serde_json::to_string_pretty(&persisted)
.map_err(|e| format!("Failed to serialize window state: {e}"))?;
fs::write(path, json).map_err(|e| format!("Failed to write window state: {e}"))
}
fn is_valid_saved_frame(frame: &WindowFrame) -> bool {
frame.width >= MIN_WINDOW_WIDTH && frame.height >= MIN_WINDOW_HEIGHT
}
fn rounded_i32(value: f64) -> i32 {
value.round() as i32
}
fn rounded_u32(value: f64) -> u32 {
value.round().max(0.0) as u32
}
impl WindowFrame {
fn from_logical_geometry(position: LogicalPosition<f64>, size: LogicalSize<f64>) -> Self {
Self {
x: rounded_i32(position.x),
y: rounded_i32(position.y),
width: rounded_u32(size.width),
height: rounded_u32(size.height),
}
}
fn to_logical(self, scale_factor: f64) -> Self {
let scale_factor = scale_factor.max(1.0);
Self {
x: rounded_i32(self.x as f64 / scale_factor),
y: rounded_i32(self.y as f64 / scale_factor),
width: rounded_u32(self.width as f64 / scale_factor),
height: rounded_u32(self.height as f64 / scale_factor),
}
}
fn right(self) -> i32 {
self.x + self.width as i32
}
fn bottom(self) -> i32 {
self.y + self.height as i32
}
}
impl WindowFrameCoordinateSpace {
fn to_logical_frame(self, frame: WindowFrame, scale_factor: f64) -> WindowFrame {
match self {
Self::Logical => frame,
Self::Physical => frame.to_logical(scale_factor),
}
}
}
impl ScreenArea {
fn right(self) -> i32 {
self.x + self.width as i32
}
fn bottom(self) -> i32 {
self.y + self.height as i32
}
fn has_area(&self) -> bool {
self.width > 0 && self.height > 0
}
fn contains(&self, point: (i32, i32)) -> bool {
let (x, y) = point;
x >= self.x && x < self.right() && y >= self.y && y < self.bottom()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(x: i32, y: i32, width: u32, height: u32) -> WindowFrame {
WindowFrame {
x,
y,
width,
height,
}
}
fn screen(x: i32, y: i32, width: u32, height: u32) -> ScreenArea {
ScreenArea {
x,
y,
width,
height,
}
}
#[test]
fn records_logical_window_geometry_for_persistence() {
let saved = WindowFrame::from_logical_geometry(
LogicalPosition::new(80.0, 120.0),
LogicalSize::new(1100.0, 700.0),
);
assert_eq!(saved, frame(80, 120, 1100, 700));
}
#[test]
fn migrates_legacy_physical_frames_to_logical_points() {
let saved = frame(160, 240, 2200, 1400);
assert_eq!(
WindowFrameCoordinateSpace::Physical.to_logical_frame(saved, 2.0),
frame(80, 120, 1100, 700)
);
}
#[test]
fn keeps_explicit_logical_frames_unscaled() {
let saved = frame(80, 120, 1100, 700);
assert_eq!(
WindowFrameCoordinateSpace::Logical.to_logical_frame(saved, 2.0),
saved
);
}
#[test]
fn keeps_valid_frame_unchanged() {
let saved = frame(120, 80, 1400, 900);
let screens = [screen(0, 0, 1920, 1080)];
assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved));
}
#[test]
fn clamps_oversized_frame_to_current_work_area() {
let saved = frame(-100, -80, 2600, 1800);
let screens = [screen(0, 0, 1440, 900)];
assert_eq!(
fit_frame_to_screens(saved, &screens),
Some(frame(0, 0, 1440, 900))
);
}
#[test]
fn moves_offscreen_frame_back_to_a_visible_screen() {
let saved = frame(3200, 1800, 900, 700);
let screens = [screen(0, 0, 1440, 900)];
assert_eq!(
fit_frame_to_screens(saved, &screens),
Some(frame(540, 200, 900, 700))
);
}
#[test]
fn picks_the_screen_with_the_largest_visible_overlap() {
let saved = frame(1700, 100, 900, 700);
let screens = [screen(0, 0, 1920, 1080), screen(1920, 0, 1440, 900)];
assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved));
}
#[test]
fn ignores_empty_screen_areas_when_restoring() {
let saved = frame(100, 100, 800, 600);
let screens = [screen(0, 0, 0, 900), screen(0, 0, 1440, 900)];
assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved));
}
#[test]
fn returns_none_when_no_usable_screens_exist() {
let saved = frame(100, 100, 800, 600);
assert_eq!(fit_frame_to_screens(saved, &[]), None);
assert_eq!(fit_frame_to_screens(saved, &[screen(0, 0, 0, 0)]), None);
}
#[test]
fn fits_to_tiny_work_area_when_it_is_smaller_than_minimum_size() {
let saved = frame(100, 100, 800, 600);
let screens = [screen(0, 0, 320, 240)];
assert_eq!(
fit_frame_to_screens(saved, &screens),
Some(frame(0, 0, 320, 240))
);
}
#[test]
fn reports_visibility_across_adjacent_screens() {
let screens = [screen(0, 0, 1920, 1080), screen(1920, 0, 1440, 900)];
assert!(frame_is_visible(frame(1700, 100, 900, 700), &screens));
assert!(!frame_is_visible(frame(1700, 850, 900, 300), &screens));
}
#[test]
fn computes_frame_and_screen_edges_for_overlap_checks() {
let saved = frame(10, 20, 800, 600);
let area = screen(0, 0, 500, 400);
assert_eq!(saved.right(), 810);
assert_eq!(saved.bottom(), 620);
assert_eq!(area.right(), 500);
assert_eq!(area.bottom(), 400);
assert_eq!(intersection_area(saved, area), 490 * 380);
assert_eq!(intersection_area(saved, screen(900, 900, 200, 200)), 0);
}
#[test]
fn rejects_corrupted_tiny_saved_frames() {
assert!(!is_valid_saved_frame(&frame(100, 100, 1, 900)));
assert!(!is_valid_saved_frame(&frame(100, 100, 1400, 1)));
}
#[test]
fn restores_again_after_runtime_ready() {
assert!(restores_window_frame_after_runtime_ready(&RunEvent::Ready));
assert!(!restores_window_frame_after_runtime_ready(
&RunEvent::Resumed
));
}
}

View File

@@ -23,7 +23,7 @@
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"backgroundColor": "#F7F6F3",
"dragDropEnabled": false
"dragDropEnabled": true
}
],
"security": {
@@ -37,7 +37,7 @@
"style-src-attr": "'unsafe-inline'",
"font-src": "'self' data: https://fonts.gstatic.com",
"media-src": "'self' data: blob: https:",
"object-src": "'none'"
"object-src": "'self' asset: http://asset.localhost"
},
"assetProtocol": {
"enable": true,

View File

@@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { DEFAULT_VAULTS } from './hooks/useVaultSwitcher'
import { formatShortcutDisplay } from './hooks/appCommandCatalog'
import { invoke } from '@tauri-apps/api/core'
import type { Settings, ViewDefinition, ViewFile } from './types'
// Provide a localStorage mock that supports all methods (jsdom's may be incomplete)
const localStorageMock = (() => {
@@ -147,6 +148,8 @@ const mockCommandResults: Record<string, unknown> = {
sync_vault_asset_scope_for_window: null,
get_file_history: [],
get_settings: { auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null },
is_git_repo: true,
init_git_repo: null,
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
save_settings: null,
check_vault_exists: true,
@@ -288,6 +291,8 @@ function resetMockCommandResults() {
anonymous_id: null,
release_channel: null,
},
is_git_repo: true,
init_git_repo: null,
save_settings: null,
check_vault_exists: true,
get_default_vault_path: expectedDefaultVaultPath,
@@ -312,10 +317,19 @@ vi.mock('./mock-tauri', () => ({
}))
// Mock ai-chat utilities
vi.mock('./utils/ai-chat', () => ({
buildSystemPrompt: vi.fn(() => ({ prompt: '', totalTokens: 0, truncated: false })),
checkClaudeCli: vi.fn(async () => ({ installed: false })),
streamClaudeChat: vi.fn(async () => 'mock-session'),
vi.mock('./utils/ai-chat', async () => {
const actual = await vi.importActual<typeof import('./utils/ai-chat')>('./utils/ai-chat')
return {
...actual,
buildSystemPrompt: vi.fn(() => ({ prompt: '', totalTokens: 0, truncated: false })),
checkClaudeCli: vi.fn(async () => ({ installed: false })),
streamClaudeChat: vi.fn(async () => 'mock-session'),
}
})
vi.mock('./utils/streamAiAgent', () => ({
streamAiAgent: vi.fn(async () => {}),
}))
vi.mock('./hooks/useUpdater', async () => {
@@ -418,6 +432,7 @@ vi.mock('./components/tolariaEditorFormatting', () => ({
import App from './App'
import { useUpdater } from './hooks/useUpdater'
import { isTauri } from './mock-tauri'
import { streamAiAgent } from './utils/streamAiAgent'
const AI_AGENTS_ONBOARDING_DISMISSED_KEY = 'tolaria:ai-agents-onboarding-dismissed'
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
@@ -453,6 +468,35 @@ describe('App', () => {
expect(await screen.findByText('All Notes', {}, { timeout: 5000 })).toBeInTheDocument()
})
it('creates custom views with a portable fallback filename for symbol-only names', async () => {
const savedViews: ViewFile[] = []
const saveView = vi.fn(({ filename, definition }: { filename: string; definition: ViewDefinition }) => {
if (filename === '.yml') throw new Error('Invalid view filename')
savedViews.push({ filename, definition })
return null
})
mockCommandResults.save_view_cmd = saveView
mockCommandResults.list_views = () => savedViews
mockCommandResults.reload_vault = mockEntries
render(<App />)
await screen.findByText('All Notes')
fireEvent.click(screen.getByRole('button', { name: 'Create view' }))
const dialog = await screen.findByRole('dialog')
fireEvent.change(within(dialog).getByPlaceholderText(/Active Projects|Reading List/i), {
target: { value: '🚀' },
})
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
await waitFor(() => {
expect(saveView).toHaveBeenCalledWith(expect.objectContaining({
filename: 'view.yml',
definition: expect.objectContaining({ name: '🚀' }),
}))
})
})
it('loads and displays vault entries in sidebar', async () => {
render(<App />)
await waitFor(() => {
@@ -462,6 +506,31 @@ describe('App', () => {
})
})
it('shows the app shell skeleton while the vault note scan is pending', async () => {
let resolveListVault: ((value: typeof mockEntries) => void) | null = null
const listVaultPromise = new Promise<typeof mockEntries>((resolve) => {
resolveListVault = resolve
})
mockCommandResults.list_vault = () => listVaultPromise
render(<App />)
await waitFor(() => {
expect(screen.getByTestId('vault-loading-skeleton')).toBeInTheDocument()
})
expect(screen.queryByText('Select a note to start editing')).not.toBeInTheDocument()
await act(async () => {
resolveListVault?.(mockEntries)
await Promise.resolve()
})
await waitFor(() => {
expect(screen.queryByTestId('vault-loading-skeleton')).not.toBeInTheDocument()
expect(screen.getAllByText('Test Project').length).toBeGreaterThan(0)
})
})
it('shows empty state in editor when no note is selected', async () => {
render(<App />)
await waitFor(() => {
@@ -564,6 +633,9 @@ describe('App', () => {
mockCommandResults.get_ai_agents_status = {
claude_code: { installed: true, version: '2.1.90' },
codex: { installed: true, version: '0.122.0-alpha.1' },
opencode: { installed: false, version: null },
pi: { installed: false, version: null },
gemini: { installed: false, version: null },
}
mockCommandResults.check_mcp_status = 'installed'
@@ -588,6 +660,100 @@ describe('App', () => {
expect(screen.queryByText('AI agents ready')).not.toBeInTheDocument()
})
it('routes right-panel AI chat messages to the selected default agent', async () => {
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
auto_advance_inbox_after_organize: null,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
default_ai_agent: 'codex',
}
mockCommandResults.get_ai_agents_status = {
claude_code: { installed: true, version: '2.1.90' },
codex: { installed: true, version: '0.122.0-alpha.1' },
opencode: { installed: false, version: null },
pi: { installed: false, version: null },
gemini: { installed: false, version: null },
}
render(<App />)
await screen.findByText('All Notes')
fireEvent.keyDown(window, { key: 'l', code: 'KeyL', metaKey: true, shiftKey: true })
const input = await screen.findByTestId('agent-input')
await waitFor(() => {
expect(input).toHaveAttribute('aria-placeholder', 'Ask Codex')
})
input.textContent = 'Summarize the active vault'
fireEvent.input(input)
fireEvent.click(screen.getByTestId('agent-send'))
await waitFor(() => {
expect(streamAiAgent).toHaveBeenCalledWith(expect.objectContaining({
agent: 'codex',
}))
})
})
it('waits for saved AI agent settings before sending right-panel messages', async () => {
let resolveSettings: ((settings: Settings) => void) | null = null
mockCommandResults.get_settings = () => new Promise((resolve) => {
resolveSettings = resolve
})
mockCommandResults.get_ai_agents_status = {
claude_code: { installed: true, version: '2.1.90' },
codex: { installed: true, version: '0.122.0-alpha.1' },
opencode: { installed: false, version: null },
pi: { installed: false, version: null },
gemini: { installed: false, version: null },
}
render(<App />)
await screen.findByText('All Notes')
fireEvent.keyDown(window, { key: 'l', code: 'KeyL', metaKey: true, shiftKey: true })
const input = await screen.findByTestId('agent-input')
input.textContent = 'Summarize the active vault'
fireEvent.input(input)
fireEvent.click(screen.getByTestId('agent-send'))
await act(async () => {
await Promise.resolve()
})
expect(streamAiAgent).not.toHaveBeenCalled()
await act(async () => {
resolveSettings?.({
auto_pull_interval_minutes: null,
auto_advance_inbox_after_organize: null,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
default_ai_agent: 'codex',
})
})
await waitFor(() => {
expect(input).toHaveAttribute('aria-placeholder', 'Ask Codex')
})
fireEvent.click(screen.getByTestId('agent-send'))
await waitFor(() => {
expect(streamAiAgent).toHaveBeenCalledWith(expect.objectContaining({
agent: 'codex',
}))
})
})
it('shows onboarding after telemetry consent when no active vault is configured', async () => {
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
@@ -669,7 +835,7 @@ describe('App', () => {
await Promise.resolve()
})
expect(screen.getByText('Loading…')).toBeInTheDocument()
expect(screen.getByTestId('vault-loading-skeleton')).toBeInTheDocument()
expect(screen.queryByText('Vault not found')).not.toBeInTheDocument()
await act(async () => {
@@ -986,6 +1152,32 @@ describe('App', () => {
})
})
it('clears the Git setup dialog when switching to a Git-enabled vault', async () => {
mockCommandResults.load_vault_list = {
vaults: [
{ label: 'Missing Git', path: '/work' },
{ label: 'Git Vault', path: '/vault-2' },
],
active_vault: '/work',
hidden_defaults: [],
}
mockCommandResults.is_git_repo = ({ vaultPath }: { vaultPath?: string } = {}) => vaultPath === '/vault-2'
render(<App />)
expect(await screen.findByText('Enable Git for this vault?')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('status-vault-trigger'))
fireEvent.click(screen.getByTestId('vault-menu-item-Git Vault'))
await waitFor(() => {
expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Git Vault')
})
await waitFor(() => {
expect(screen.queryByText('Enable Git for this vault?')).not.toBeInTheDocument()
})
})
it('Cmd+1 hides sidebar and note list (editor-only mode)', async () => {
render(<App />)
await waitFor(() => {

View File

@@ -16,12 +16,12 @@ import { StatusBar } from './components/StatusBar'
import { SettingsPanel } from './components/SettingsPanel'
import { CloneVaultModal } from './components/CloneVaultModal'
import { WelcomeScreen } from './components/WelcomeScreen'
import { AppLoadingSkeleton } from './components/AppLoadingSkeleton'
import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt'
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
import { FeedbackDialog } from './components/FeedbackDialog'
import { McpSetupDialog } from './components/McpSetupDialog'
import { NoteRetargetingDialogs } from './components/note-retargeting/NoteRetargetingDialogs'
import { NoteRetargetingProvider } from './components/note-retargeting/noteRetargetingContext'
import { useTelemetry } from './hooks/useTelemetry'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
@@ -29,10 +29,12 @@ import { useAiAgentsStatus } from './hooks/useAiAgentsStatus'
import { useVaultAiGuidanceStatus } from './hooks/useVaultAiGuidanceStatus'
import { useAutoGit } from './hooks/useAutoGit'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useRecentVaultWrites, useVaultWatcher } from './hooks/useVaultWatcher'
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'
@@ -65,11 +67,15 @@ import { useAiActivity } from './hooks/useAiActivity'
import { useBulkActions } from './hooks/useBulkActions'
import { useDeleteActions } from './hooks/useDeleteActions'
import { useFolderActions } from './hooks/useFolderActions'
import { useFileActions } from './hooks/useFileActions'
import { useLayoutPanels } from './hooks/useLayoutPanels'
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'
@@ -84,7 +90,7 @@ import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, type NoteWindowParams } from './utils/windowMode'
import { GitRequiredModal } from './components/GitRequiredModal'
import { GitSetupDialog } from './components/GitRequiredModal'
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents'
import type { NoteListMultiSelectionCommands } from './components/note-list/multiSelectionCommands'
@@ -319,25 +325,56 @@ function App() {
? onboarding.state.vaultPath
: vaultSwitcher.vaultPath
)
// Git repo check: 'checking' | 'required' | 'ready'
const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking')
// Git repo check: 'checking' | 'missing' | 'ready'
const [gitRepoState, setGitRepoState] = useState<'checking' | 'missing' | 'ready'>('checking')
const [showGitSetupDialog, setShowGitSetupDialog] = useState(false)
const dismissedGitSetupPathRef = useRef<string | null>(null)
useEffect(() => {
if (!resolvedPath) return
setGitRepoState('checking')
const check = isTauri()
? invoke<boolean>('is_git_repo', { vaultPath: resolvedPath })
: Promise.resolve(true) // browser mock: assume git
: mockInvoke<boolean>('is_git_repo', { vaultPath: resolvedPath })
check
.then(isGit => setGitRepoState(isGit ? 'ready' : 'required'))
.then(isGit => setGitRepoState(isGit ? 'ready' : 'missing'))
.catch(() => setGitRepoState('ready')) // fail open
}, [resolvedPath])
const handleInitGitRepo = useCallback(async () => {
if (isTauri()) await invoke('init_git_repo', { vaultPath: resolvedPath })
setGitRepoState('ready')
useEffect(() => {
if (noteWindowParams || gitRepoState !== 'missing' || !resolvedPath) return
if (dismissedGitSetupPathRef.current === resolvedPath) return
setShowGitSetupDialog(true)
}, [gitRepoState, noteWindowParams, resolvedPath])
useEffect(() => {
if (gitRepoState === 'missing') return
setShowGitSetupDialog(false)
}, [gitRepoState])
const openGitSetupDialog = useCallback(() => {
if (gitRepoState !== 'missing') return
setShowGitSetupDialog(true)
}, [gitRepoState])
const dismissGitSetupDialog = useCallback(() => {
dismissedGitSetupPathRef.current = resolvedPath
setShowGitSetupDialog(false)
}, [resolvedPath])
const handleInitGitRepo = useCallback(async () => {
if (isTauri()) {
await invoke('init_git_repo', { vaultPath: resolvedPath })
} else {
await mockInvoke('init_git_repo', { vaultPath: resolvedPath })
}
setGitRepoState('ready')
dismissedGitSetupPathRef.current = null
setShowGitSetupDialog(false)
setToastMessage('Git initialized for this vault')
}, [resolvedPath, setToastMessage])
const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath)
const recentVaultWrites = useRecentVaultWrites({ vaultPath: noteWindowParams ? '' : resolvedPath })
const {
status: vaultAiGuidanceStatus,
refresh: refreshVaultAiGuidance,
@@ -400,11 +437,16 @@ 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])
const aiAgentPreferences = useAiAgentPreferences({
settings,
settingsLoaded,
saveSettings,
aiAgentsStatus,
onToast: setToastMessage,
@@ -418,8 +460,25 @@ 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, appLocale)
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
const loadVaultModifiedFiles = vault.loadModifiedFiles
const refreshGitRemoteStatus = gitRemoteStatus.refreshRemoteStatus
useEffect(() => {
if (gitRepoState !== 'ready') return
void loadVaultModifiedFiles()
void refreshGitRemoteStatus()
}, [gitRepoState, loadVaultModifiedFiles, refreshGitRemoteStatus])
const openMcpSetupDialog = useCallback(() => {
setShowMcpSetupDialog(true)
@@ -450,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(() => {
@@ -499,8 +566,7 @@ function App() {
removeEntry: vault.removeEntry,
entries: vault.entries,
flushBeforeNoteSwitch: flushEditorStateBeforeAction,
flushBeforeFrontmatterChange: flushEditorStateBeforeAction,
flushBeforePathRename: flushEditorStateBeforeAction,
flushBeforeNoteMutation: flushEditorStateBeforeAction,
reloadVault: vault.reloadVault,
setToastMessage,
updateEntry: vault.updateEntry,
@@ -512,6 +578,7 @@ function App() {
unsavedPaths: vault.unsavedPaths,
markContentPending: (path, content) => appSave.contentChangeRef.current(path, content),
onNewNotePersisted: vault.loadModifiedFiles,
onTypeStateChanged: async () => { await vault.reloadVault() },
replaceEntry: vault.replaceEntry,
onFrontmatterPersisted: vault.loadModifiedFiles,
onPathRenamed: (oldPath, newPath) => appSave.trackRenamedPath(oldPath, newPath),
@@ -548,7 +615,13 @@ function App() {
vault.reloadViews,
vault.unsavedPaths,
])
useVaultWatcher({
vaultPath: noteWindowParams ? '' : resolvedPath,
onVaultChanged: handlePulledVaultUpdate,
filterChangedPaths: recentVaultWrites.filterExternalPaths,
})
const autoSync = useAutoSync({
enabled: gitRepoState === 'ready',
vaultPath: resolvedPath,
intervalMinutes: settings.auto_pull_interval_minutes,
onVaultUpdated: handlePulledVaultUpdate,
@@ -698,6 +771,8 @@ function App() {
handleRenameNote: notes.handleRenameNote, handleRenameFilename: notes.handleRenameFilename,
replaceEntry: vault.replaceEntry, resolvedPath,
initialH1AutoRenameEnabled: settings.initial_h1_auto_rename_enabled !== false,
onInternalVaultWrite: recentVaultWrites.markInternalWrite,
locale: appLocale,
})
const aiActivity = useAiActivity({
@@ -706,7 +781,7 @@ function App() {
onSetFilter: (filterType) => {
handleSetSelection({ kind: 'sectionGroup', type: filterType })
},
onVaultChanged: () => { vault.reloadVault() },
onVaultChanged: (path) => { void handlePulledVaultUpdate(path ? [path] : []) },
})
const handleInitializeProperties = useCallback(async (path: string) => {
@@ -784,6 +859,11 @@ function App() {
reloadFolders: vault.reloadFolders,
setToastMessage,
})
const fileActions = useFileActions({
selection: effectiveSelection,
setToastMessage,
vaultPath: resolvedPath,
})
const handleRemoveNoteIconCommand = useCallback(() => {
if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath)
@@ -858,7 +938,8 @@ function App() {
vaultPath: resolvedPath,
})
const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles])
const isGitVault = !vault.modifiedFilesError
const isGitVault = gitRepoState !== 'missing'
const shouldShowGitSetupDialog = !noteWindowParams && gitRepoState === 'missing' && showGitSetupDialog
const modifiedFilesSignature = useMemo(
() => vault.modifiedFiles.map((file) => `${file.relativePath}:${file.status}`).sort().join('|'),
[vault.modifiedFiles],
@@ -1002,17 +1083,26 @@ function App() {
const editing = dialogs.editingView
const filename = editing
? editing.filename
: definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml'
const nextDefinition = editing ? { ...editing.definition, ...definition } : definition
: createViewFilename(definition.name, vault.views.map((view) => view.filename))
const nextDefinition = editing
? { ...editing.definition, ...definition }
: { ...definition, order: nextViewOrder(vault.views) }
const target = isTauri() ? invoke : mockInvoke
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition })
trackEvent(editing ? 'view_updated' : 'view_created')
await vault.reloadViews()
await vault.reloadVault()
vault.reloadFolders()
setToastMessage(editing ? `View "${nextDefinition.name}" updated` : `View "${nextDefinition.name}" created`)
handleSetSelection({ kind: 'view', filename })
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView])
try {
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition })
trackEvent(editing ? 'view_updated' : 'view_created')
await vault.reloadViews()
await vault.reloadVault()
vault.reloadFolders()
setToastMessage(editing ? `View "${nextDefinition.name}" updated` : `View "${nextDefinition.name}" created`)
handleSetSelection({ kind: 'view', filename })
return true
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setToastMessage(`Could not save view: ${message}`)
return false
}
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView, setToastMessage])
const handleUpdateViewDefinition = useCallback(async (filename: string, patch: Partial<ViewDefinition>) => {
const existing = vault.views.find((view) => view.filename === filename)
@@ -1065,6 +1155,7 @@ function App() {
const rawToggleRef = useRef<() => void>(() => {})
// Diff-toggle ref: Editor registers its handleToggleDiff here so the command palette can call it
const diffToggleRef = useRef<() => void>(() => {})
const findInNoteRef = useRef<((options?: { replace?: boolean }) => void) | null>(null)
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode(noteWindowParams ? 'editor-only' : undefined)
const { noteLayout, toggleNoteLayout } = useNoteLayout()
@@ -1238,6 +1329,15 @@ 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,
locale: appLocale,
})
const activeNoteModified = useMemo(
() => vault.modifiedFiles.some((file) => file.path === notes.activeTabPath),
[notes.activeTabPath, vault.modifiedFiles],
@@ -1247,6 +1347,12 @@ function App() {
() => canToggleRichEditor ? () => rawToggleRef.current() : undefined,
[canToggleRichEditor],
)
const findInNoteCommand = useCallback(() => {
findInNoteRef.current?.({ replace: false })
}, [])
const replaceInNoteCommand = useCallback(() => {
findInNoteRef.current?.({ replace: true })
}, [])
const removeActiveVaultCommand = useCallback(() => {
vaultSwitcher.removeVault(vaultSwitcher.vaultPath)
}, [vaultSwitcher])
@@ -1296,6 +1402,12 @@ function App() {
() => activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
[activeDeletedFile, handleDiscardFile],
)
const reloadVaultForCommand = vault.reloadVault
const handleManualVaultReload = useCallback(async () => {
const entries = await reloadVaultForCommand()
setToastMessage(`Vault reloaded (${entries.length} ${entries.length === 1 ? 'entry' : 'entries'})`)
return entries
}, [reloadVaultForCommand, setToastMessage])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
@@ -1307,6 +1419,8 @@ function App() {
selection: effectiveSelection,
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
onSearch: dialogs.openSearch,
onFindInNote: findInNoteCommand,
onReplaceInNote: activeDeletedFile ? undefined : replaceInNoteCommand,
onCreateNote: notes.handleCreateNoteImmediate,
onCreateNoteOfType: notes.handleCreateNoteImmediate,
onSave: appSave.handleSave,
@@ -1315,6 +1429,8 @@ function App() {
onDeleteNote: deleteActions.handleDeleteNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: handleCommitPush,
isGitVault,
onInitializeGit: openGitSetupDialog,
onPull: autoSync.triggerSync,
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
onSetViewMode: handleSetViewMode,
@@ -1323,11 +1439,18 @@ 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,
onRenameFolder: folderActions.renameSelectedFolder,
onDeleteFolder: folderActions.deleteSelectedFolder,
onRevealSelectedFolder: fileActions.revealSelectedFolder,
onCopySelectedFolderPath: fileActions.copySelectedFolderPath,
showInbox: explicitOrganizationEnabled,
onReplaceActiveTab: notes.handleReplaceActiveTab,
onSelectNote: notes.handleSelectNote,
@@ -1346,6 +1469,7 @@ function App() {
systemLocale,
selectedUiLanguage,
onSetUiLanguage: handleSetUiLanguage,
onSetThemeMode: handleSetThemeMode,
mcpStatus,
onInstallMcp: openMcpSetupDialog,
onOpenAiAgents: dialogs.openSettings,
@@ -1356,7 +1480,7 @@ function App() {
onSetDefaultAiAgent: aiAgentPreferences.setDefaultAiAgent,
onCycleDefaultAiAgent: aiAgentPreferences.cycleDefaultAiAgent,
selectedAiAgentLabel: aiAgentPreferences.defaultAiAgentLabel,
onReloadVault: vault.reloadVault,
onReloadVault: handleManualVaultReload,
onRepairVault: handleRepairVault,
onSetNoteIcon: handleSetNoteIconCommand,
onRemoveNoteIcon: handleRemoveNoteIconCommand,
@@ -1367,6 +1491,9 @@ function App() {
noteListFilter,
onSetNoteListFilter: setNoteListFilter,
onOpenInNewWindow: handleOpenInNewWindow,
onRevealActiveFile: fileActions.revealFile,
onCopyActiveFilePath: fileActions.copyFilePath,
onOpenActiveFileExternal: fileActions.openExternalFile,
onToggleFavorite: entryActions.handleToggleFavorite,
onToggleOrganized: toggleOrganizedCommand,
onCustomizeNoteListColumns: handleCustomizeNoteListColumns,
@@ -1404,9 +1531,9 @@ function App() {
&& onboarding.state.vaultPath === vaultSwitcher.vaultPath
}, [onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
// Show loading spinner while checking vault (skip for note windows)
// Show loading skeleton while checking vault (skip for note windows)
if (!noteWindowParams && onboarding.state.status === 'loading') {
return <LoadingView />
return <AppLoadingSkeleton />
}
// Show telemetry consent dialog on first launch (skip for note windows).
@@ -1450,31 +1577,25 @@ function App() {
)
}
// Show git-required modal when vault has no git repo (skip for note windows)
if (!noteWindowParams && gitRepoState === 'required' && !showMcpSetupDialog) {
// Show loading skeleton while checking git status or scanning vault notes.
if (!noteWindowParams && onboarding.state.status === 'ready' && (gitRepoState === 'checking' || vault.isLoading)) {
return (
<div className="app-shell">
<GitRequiredModal
onCreateRepo={handleInitGitRepo}
onChooseVault={vaultSwitcher.handleOpenLocalFolder}
/>
</div>
<AppLoadingSkeleton
noteListWidth={layout.noteListWidth}
showNoteList={noteListVisible}
showSidebar={sidebarVisible}
sidebarWidth={layout.sidebarWidth}
/>
)
}
// Show loading spinner while checking git status
if (!noteWindowParams && gitRepoState === 'checking' && onboarding.state.status === 'ready') {
return <LoadingView />
}
return (
<NoteRetargetingProvider value={noteRetargetingUi.contextValue}>
<div className="app-shell">
<div className="app-shell">
<div className="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} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} />
<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} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -1483,7 +1604,7 @@ function App() {
<>
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} locale={appLocale} />
) : (
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
)}
@@ -1507,6 +1628,7 @@ function App() {
onToggleInspector={handleToggleInspector}
inspectorWidth={layout.inspectorWidth}
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
defaultAiAgentReadiness={aiAgentPreferences.defaultAiAgentReadiness}
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
onUnsupportedAiPaste={setToastMessage}
onInspectorResize={layout.handleInspectorResize}
@@ -1526,6 +1648,9 @@ function App() {
noteListFilter={aiNoteListFilter}
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : toggleOrganizedCommand}
onRevealFile={fileActions.revealFile}
onCopyFilePath={fileActions.copyFilePath}
onOpenExternalFile={fileActions.openExternalFile}
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
@@ -1535,6 +1660,7 @@ function App() {
noteLayout={noteLayout}
onToggleNoteLayout={toggleNoteLayout}
rawToggleRef={rawToggleRef}
findInNoteRef={findInNoteRef}
diffToggleRef={diffToggleRef}
canGoBack={canGoBack}
canGoForward={canGoForward}
@@ -1548,12 +1674,14 @@ function App() {
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
flushPendingRawContentRef={flushPendingRawContentRef}
locale={appLocale}
/>
</div>
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} locale={appLocale} />
<GitSetupDialog open={shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
@@ -1597,9 +1725,9 @@ function App() {
onCommit={conflictResolver.commitResolution}
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} isGitVault={isGitVault} onSave={saveSettings} onCopyMcpConfig={handleCopyMcpConfig} 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} locale={appLocale} 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
@@ -1621,8 +1749,7 @@ function App() {
onCancel={folderActions.cancelDeleteFolder}
/>
)}
</div>
</NoteRetargetingProvider>
</div>
)
}
@@ -1664,15 +1791,4 @@ function AiAgentsOnboardingView({
)
}
/** Loading spinner view - extracted from main App component */
function LoadingView() {
return (
<div className="app-shell">
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--sidebar)' }}>
<span style={{ color: 'var(--muted-foreground)', fontSize: 14 }}>Loading</span>
</div>
</div>
)
}
export default App

View File

@@ -12,6 +12,7 @@ import {
} from '../utils/propertyTypes'
import { StatusPill, StatusDropdown } from './StatusDropdown'
import { DISPLAY_MODE_OPTIONS, DISPLAY_MODE_ICONS } from '../utils/propertyTypes'
import { translate, type AppLocale } from '../lib/i18n'
function parseDateValue(value: string): Date | undefined {
const iso = toISODate(value)
@@ -39,7 +40,7 @@ function canSubmitProperty({ key, value, displayMode }: { key: string; value: st
return displayMode !== 'number' || isValidNumberValue(value)
}
function AddBooleanInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
function AddBooleanInput({ value, locale, onChange }: { value: string; locale: AppLocale; onChange: (v: string) => void }) {
const boolVal = value.toLowerCase() === 'true'
return (
<button
@@ -47,12 +48,12 @@ function AddBooleanInput({ value, onChange }: { value: string; onChange: (v: str
onClick={() => onChange(boolVal ? 'false' : 'true')}
data-testid="add-property-boolean-toggle"
>
{boolVal ? '\u2713 Yes' : '\u2717 No'}
{boolVal ? `\u2713 ${translate(locale, 'inspector.properties.yes')}` : `\u2717 ${translate(locale, 'inspector.properties.no')}`}
</button>
)
}
function AddDateInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
function AddDateInput({ value, locale, onChange }: { value: string; locale: AppLocale; onChange: (v: string) => void }) {
const selectedDate = value ? parseDateValue(value) : undefined
const formatted = value ? formatDateValue(value) : ''
return (
@@ -64,7 +65,7 @@ function AddDateInput({ value, onChange }: { value: string; onChange: (v: string
>
<CalendarIcon className="size-3 shrink-0 text-muted-foreground" />
<span className={`min-w-0 truncate${!formatted ? ' text-muted-foreground' : ' text-foreground'}`}>
{formatted || 'Pick a date\u2026'}
{formatted || translate(locale, 'inspector.properties.pickDate')}
</span>
</button>
</PopoverTrigger>
@@ -122,15 +123,16 @@ function AddNumberInput({ value, onChange, onKeyDown }: {
)
}
function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultStatuses }: {
function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultStatuses, locale }: {
displayMode: PropertyDisplayMode; value: string; onChange: (v: string) => void
onKeyDown: (e: React.KeyboardEvent) => void; vaultStatuses: string[]
locale: AppLocale
}) {
switch (displayMode) {
case 'number':
return <AddNumberInput value={value} onChange={onChange} onKeyDown={onKeyDown} />
case 'boolean': return <AddBooleanInput value={value} onChange={onChange} />
case 'date': return <AddDateInput value={value} onChange={onChange} />
case 'boolean': return <AddBooleanInput value={value} locale={locale} onChange={onChange} />
case 'date': return <AddDateInput value={value} locale={locale} onChange={onChange} />
case 'status': return <AddStatusInput value={value} onChange={onChange} vaultStatuses={vaultStatuses} />
case 'tags': return (
<Input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
@@ -138,16 +140,17 @@ function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultS
/>
)
default: return (
<Input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
<Input className={ADD_INPUT_CLASS} type="text" placeholder={translate(locale, 'inspector.properties.valuePlaceholder')} value={value}
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
/>
)
}
}
export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
export function AddPropertyForm({ onAdd, onCancel, vaultStatuses, locale = 'en' }: {
onAdd: (key: string, value: string, displayMode: PropertyDisplayMode) => void; onCancel: () => void
vaultStatuses: string[]
locale?: AppLocale
}) {
const [newKey, setNewKey] = useState('')
const [newValue, setNewValue] = useState('')
@@ -169,7 +172,7 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded px-1.5 py-1" data-testid="add-property-form">
<Input
className="h-[26px] w-20 shrink-0 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
type="text" placeholder="Property name" value={newKey}
type="text" placeholder={translate(locale, 'inspector.properties.propertyName')} value={newKey}
onChange={(e) => setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus
/>
<Select value={displayMode} onValueChange={(v) => handleModeChange(v as PropertyDisplayMode)}>
@@ -193,15 +196,15 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
})}
</SelectContent>
</Select>
<AddPropertyValueInput displayMode={displayMode} value={newValue} onChange={setNewValue} onKeyDown={handleKeyDown} vaultStatuses={vaultStatuses} />
<AddPropertyValueInput displayMode={displayMode} value={newValue} onChange={setNewValue} onKeyDown={handleKeyDown} vaultStatuses={vaultStatuses} locale={locale} />
<Button
size="icon-xs" onClick={() => onAdd(newKey, newValue, displayMode)}
disabled={!canSubmit} title="Add property"
disabled={!canSubmit} title={translate(locale, 'inspector.properties.addProperty')}
data-testid="add-property-confirm"
>
<Check className="size-3.5" />
</Button>
<Button size="icon-xs" variant="outline" onClick={onCancel} title="Cancel" data-testid="add-property-cancel">
<Button size="icon-xs" variant="outline" onClick={onCancel} title={translate(locale, 'common.cancel')} data-testid="add-property-cancel">
<X className="size-3.5" />
</Button>
</div>

View File

@@ -1,9 +1,30 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiAgentStatuses } from '../lib/aiAgents'
import { AiAgentsOnboardingPrompt } from './AiAgentsOnboardingPrompt'
const openExternalUrl = vi.fn()
const dragRegionMouseDown = vi.fn()
const missingStatuses: AiAgentStatuses = {
claude_code: { status: 'missing', version: null },
codex: { status: 'missing', version: null },
opencode: { status: 'missing', version: null },
pi: { status: 'missing', version: null },
gemini: { status: 'missing', version: null },
}
const missingAgentInstallTestIds = [
'ai-agents-onboarding-install-codex',
'ai-agents-onboarding-install-opencode',
'ai-agents-onboarding-install-pi',
'ai-agents-onboarding-install-gemini',
] as const
const installLinkTargets = [
['ai-agents-onboarding-install-claude_code', 'https://docs.anthropic.com/en/docs/claude-code'],
['ai-agents-onboarding-install-codex', 'https://developers.openai.com/codex/cli'],
['ai-agents-onboarding-install-opencode', 'https://opencode.ai/docs/'],
['ai-agents-onboarding-install-pi', 'https://pi.dev'],
['ai-agents-onboarding-install-gemini', 'https://google-gemini.github.io/gemini-cli/'],
] as const
vi.mock('../utils/url', () => ({
openExternalUrl: (...args: unknown[]) => openExternalUrl(...args),
@@ -12,74 +33,63 @@ vi.mock('../hooks/useDragRegion', () => ({
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
function renderPrompt(statuses: Partial<AiAgentStatuses> = {}) {
return render(
<AiAgentsOnboardingPrompt
statuses={{ ...missingStatuses, ...statuses }}
onContinue={vi.fn()}
/>,
)
}
function expectMissingAgentInstallLinks() {
missingAgentInstallTestIds.forEach(testId => {
expect(screen.getByTestId(testId)).toBeInTheDocument()
})
}
describe('AiAgentsOnboardingPrompt', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows the ready state when at least one agent is installed', () => {
render(
<AiAgentsOnboardingPrompt
statuses={{
claude_code: { status: 'installed', version: '1.0.20' },
codex: { status: 'missing', version: null },
}}
onContinue={vi.fn()}
/>,
)
renderPrompt({
claude_code: { status: 'installed', version: '1.0.20' },
})
expect(screen.getByText('AI agents ready')).toBeInTheDocument()
expect(screen.getByTestId('ai-agents-onboarding-install-codex')).toBeInTheDocument()
expectMissingAgentInstallLinks()
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue')
})
it('shows the missing state when no agents are installed', () => {
render(
<AiAgentsOnboardingPrompt
statuses={{
claude_code: { status: 'missing', version: null },
codex: { status: 'missing', version: null },
}}
onContinue={vi.fn()}
/>,
)
renderPrompt()
expect(screen.getByText('No AI agents detected')).toBeInTheDocument()
expect(screen.getByTestId('claude-onboarding-screen')).toBeInTheDocument()
expect(screen.getByText('Claude Code not detected')).toBeInTheDocument()
expect(screen.getByTestId('ai-agents-onboarding-install-claude_code')).toBeInTheDocument()
expect(screen.getByTestId('ai-agents-onboarding-install-codex')).toBeInTheDocument()
expectMissingAgentInstallLinks()
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue without it')
})
it('opens the agent install links', () => {
render(
<AiAgentsOnboardingPrompt
statuses={{
claude_code: { status: 'missing', version: null },
codex: { status: 'missing', version: null },
}}
onContinue={vi.fn()}
/>,
)
renderPrompt()
fireEvent.click(screen.getByTestId('ai-agents-onboarding-install-claude_code'))
fireEvent.click(screen.getByTestId('ai-agents-onboarding-install-codex'))
installLinkTargets.forEach(([testId]) => {
fireEvent.click(screen.getByTestId(testId))
})
expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
expect(openExternalUrl).toHaveBeenCalledWith('https://developers.openai.com/codex/cli')
installLinkTargets.forEach(([, url]) => {
expect(openExternalUrl).toHaveBeenCalledWith(url)
})
})
it('uses the surrounding surface as a drag region and excludes the card', () => {
render(
<AiAgentsOnboardingPrompt
statuses={{
claude_code: { status: 'installed', version: '1.0.20' },
codex: { status: 'missing', version: null },
}}
onContinue={vi.fn()}
/>,
)
renderPrompt({
claude_code: { status: 'installed', version: '1.0.20' },
})
const screenContainer = screen.getByTestId('ai-agents-onboarding-screen')
fireEvent.mouseDown(screenContainer)

View File

@@ -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())

View File

@@ -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, waitFor } 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,21 +53,130 @@ 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 AI Chat header', () => {
it('renders panel with the default CLI agent header', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('AI Chat')).toBeTruthy()
expect(screen.getByText('AI Agent')).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 per-mode tooltips', 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 limited to file, search, and edit tools.',
)
fireEvent.blur(safeMode)
fireEvent.focus(powerUserMode)
await waitFor(() => {
expect(screen.getByTestId('ai-permission-mode-tooltip')).toHaveTextContent(
'Power User also allows local shell commands for this vault.',
)
})
})
it('renders data-testid ai-panel', () => {
@@ -84,9 +201,15 @@ describe('AiPanel', () => {
expect(mockClearConversation).toHaveBeenCalledOnce()
})
it('keeps the MCP config action out of the AI panel header', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.queryByRole('button', { name: 'Copy MCP config' })).toBeNull()
})
it('renders empty state without context', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('Open a note, then ask the AI about it')).toBeTruthy()
expect(screen.getByText('Open a note, then ask Claude Code about it')).toBeTruthy()
})
it('renders contextual empty state when active entry is provided', () => {
@@ -94,7 +217,7 @@ describe('AiPanel', () => {
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
)
expect(screen.getByText('Ask about this note and its linked context')).toBeTruthy()
expect(screen.getByText('Ask Claude Code about this note and its linked context')).toBeTruthy()
})
it('shows context bar with active entry title', () => {
@@ -136,19 +259,46 @@ describe('AiPanel', () => {
expect((sendBtn as HTMLButtonElement).disabled).toBe(true)
})
it('shows contextual placeholder when active entry exists', () => {
it('shows active agent placeholder when active entry exists', () => {
const entry = makeEntry({ title: 'My Note' })
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
)
const input = screen.getByTestId('agent-input')
expect(input).toHaveAttribute('aria-placeholder', 'Ask about this note...')
expect(input).toHaveAttribute('aria-placeholder', 'Ask Claude Code')
})
it('shows generic placeholder when no active entry', () => {
it('shows active agent placeholder when no active entry', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
const input = screen.getByTestId('agent-input')
expect(input).toHaveAttribute('aria-placeholder', 'Ask the AI agent...')
expect(input).toHaveAttribute('aria-placeholder', 'Ask Claude Code')
})
it('uses the selected AI agent in the placeholder', () => {
render(
<AiPanel
onClose={vi.fn()}
vaultPath="/tmp/vault"
defaultAiAgent="codex"
defaultAiAgentReady
/>,
)
expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Ask Codex')
})
it('disables sending while the selected AI agent is still loading', () => {
render(
<AiPanel
onClose={vi.fn()}
vaultPath="/tmp/vault"
defaultAiAgent="codex"
defaultAiAgentReadiness="checking"
/>,
)
expect(screen.getByText('Checking availability')).toBeTruthy()
expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Checking AI agent availability...')
expect(screen.getByTestId('agent-send')).toBeDisabled()
})
it('auto-focuses input on mount', async () => {

View File

@@ -5,7 +5,13 @@ import {
AiPanelHeader,
AiPanelMessageHistory,
} from './AiPanelChrome'
import { DEFAULT_AI_AGENT, getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents'
import {
DEFAULT_AI_AGENT,
getAiAgentDefinition,
type AiAgentId,
type AiAgentReadiness,
} from '../lib/aiAgents'
import type { AppLocale } from '../lib/i18n'
import { type NoteListItem } from '../utils/ai-context'
import type { VaultEntry } from '../types'
import { useAiPanelController, type AiPanelController } from './useAiPanelController'
@@ -19,7 +25,9 @@ interface AiPanelProps {
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady?: boolean
locale?: AppLocale
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
@@ -39,24 +47,32 @@ interface AiPanelViewProps {
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady?: boolean
locale?: AppLocale
activeEntry?: VaultEntry | null
entries?: VaultEntry[]
}
function readinessFromReadyFlag(ready: boolean | undefined): AiAgentReadiness {
return (ready ?? true) ? 'ready' : 'missing'
}
export function AiPanelView({
controller,
onClose,
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
defaultAiAgentReady: providedDefaultAiAgentReady,
locale = 'en',
activeEntry,
entries,
}: AiPanelViewProps) {
const defaultAiAgent = providedDefaultAiAgent ?? DEFAULT_AI_AGENT
const defaultAiAgentReady = providedDefaultAiAgentReady ?? true
const useLegacyAiExperience = providedDefaultAiAgent === undefined && providedDefaultAiAgentReady === undefined
const defaultAiAgentReadiness = providedDefaultAiAgentReadiness
?? readinessFromReadyFlag(providedDefaultAiAgentReady)
const inputRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLElement>(null)
const agentLabel = getAiAgentDefinition(defaultAiAgent).label
@@ -67,8 +83,10 @@ export function AiPanelView({
linkedEntries,
hasContext,
isActive,
permissionMode,
handleSend,
handleNavigateWikilink,
handlePermissionModeChange,
handleNewChat,
} = controller
@@ -99,18 +117,21 @@ export function AiPanelView({
>
<AiPanelHeader
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
legacyCopy={useLegacyAiExperience}
agentReadiness={defaultAiAgentReadiness}
locale={locale}
permissionMode={permissionMode}
permissionModeDisabled={isActive}
onPermissionModeChange={handlePermissionModeChange}
onClose={onClose}
onNewChat={handleNewChat}
/>
{activeEntry && (
<AiPanelContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} />
<AiPanelContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} locale={locale} />
)}
<AiPanelMessageHistory
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
legacyCopy={useLegacyAiExperience}
agentReadiness={defaultAiAgentReadiness}
locale={locale}
messages={agent.messages}
isActive={isActive}
onOpenNote={onOpenNote}
@@ -120,12 +141,11 @@ export function AiPanelView({
<AiPanelComposer
entries={entries ?? []}
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
hasContext={hasContext}
agentReadiness={defaultAiAgentReadiness}
locale={locale}
input={input}
inputRef={inputRef}
isActive={isActive}
legacyCopy={useLegacyAiExperience}
onChange={setInput}
onSend={handleSend}
onUnsupportedAiPaste={onUnsupportedAiPaste}
@@ -139,7 +159,9 @@ export function AiPanel({
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
defaultAiAgentReady: providedDefaultAiAgentReady,
locale = 'en',
onFileCreated,
onFileModified,
onVaultChanged,
@@ -151,16 +173,20 @@ export function AiPanel({
noteList,
noteListFilter,
}: AiPanelProps) {
const defaultAiAgentReadiness = providedDefaultAiAgentReadiness
?? readinessFromReadyFlag(providedDefaultAiAgentReady)
const controller = useAiPanelController({
vaultPath,
defaultAiAgent: providedDefaultAiAgent ?? DEFAULT_AI_AGENT,
defaultAiAgentReady: providedDefaultAiAgentReady ?? true,
defaultAiAgentReadiness,
activeEntry,
activeNoteContent,
entries,
openTabs,
noteList,
noteListFilter,
locale,
onOpenNote,
onFileCreated,
onFileModified,
@@ -174,7 +200,9 @@ export function AiPanel({
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={providedDefaultAiAgent}
defaultAiAgentReadiness={defaultAiAgentReadiness}
defaultAiAgentReady={providedDefaultAiAgentReady}
locale={locale}
activeEntry={activeEntry}
entries={entries}
/>

View File

@@ -1,29 +1,42 @@
import { useEffect, useRef } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/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 {
aiAgentPermissionModeLabels,
type AiAgentPermissionMode,
} from '../lib/aiAgentPermissionMode'
import { createTranslator, type AppLocale } from '../lib/i18n'
import type { AiAgentMessage } from '../hooks/useCliAiAgent'
import type { AiAgentReadiness } from '../lib/aiAgents'
import type { NoteReference } from '../utils/ai-context'
import type { VaultEntry } from '../types'
interface AiPanelHeaderProps {
agentLabel: string
agentReady: boolean
legacyCopy: boolean
agentReadiness: AiAgentReadiness
locale?: AppLocale
permissionMode: AiAgentPermissionMode
permissionModeDisabled: boolean
onPermissionModeChange: (mode: AiAgentPermissionMode) => void
onClose: () => void
onNewChat: () => void
}
interface AiPanelContextBarProps {
activeEntry: VaultEntry
locale?: AppLocale
linkedCount: number
}
interface AiPanelMessageHistoryProps {
agentLabel: string
agentReady: boolean
legacyCopy: boolean
agentReadiness: AiAgentReadiness
locale?: AppLocale
messages: AiAgentMessage[]
isActive: boolean
onOpenNote?: (path: string) => void
@@ -34,12 +47,11 @@ interface AiPanelMessageHistoryProps {
interface AiPanelComposerProps {
entries: VaultEntry[]
agentLabel: string
agentReady: boolean
hasContext: boolean
agentReadiness: AiAgentReadiness
locale?: AppLocale
input: string
inputRef: React.RefObject<HTMLDivElement | null>
isActive: boolean
legacyCopy: boolean
onChange: (value: string) => void
onSend: (text: string, references: NoteReference[]) => void
onUnsupportedAiPaste?: (message: string) => void
@@ -47,28 +59,56 @@ interface AiPanelComposerProps {
function getComposerPlaceholder(
agentLabel: string,
agentReady: boolean,
legacyCopy: boolean,
hasContext: boolean,
agentReadiness: AiAgentReadiness,
t: ReturnType<typeof createTranslator>,
): string {
if (!agentReady) {
return `${agentLabel} is not installed. Open AI Agents in Settings.`
if (agentReadiness === 'checking') {
return t('ai.panel.placeholder.checking')
}
if (legacyCopy) {
return hasContext ? 'Ask about this note...' : 'Ask the AI agent...'
if (agentReadiness === 'missing') {
return t('ai.panel.placeholder.missing', { agent: agentLabel })
}
return hasContext ? `Ask ${agentLabel} about this note...` : `Ask ${agentLabel}...`
return t('ai.panel.placeholder.ready', { agent: agentLabel })
}
function permissionModeTooltip(
mode: AiAgentPermissionMode,
t: ReturnType<typeof createTranslator>,
): { label: string } {
return {
label: t(mode === 'power_user'
? 'ai.permission.powerUser.tooltip'
: 'ai.permission.safe.tooltip'),
}
}
function headerStatusText({
agentLabel,
agentReadiness,
modeLabel,
t,
}: {
agentLabel: string
agentReadiness: AiAgentReadiness
modeLabel: string
t: ReturnType<typeof createTranslator>
}): string {
if (agentReadiness === 'checking') return t('ai.panel.status.checking')
if (agentReadiness === 'missing') return t('ai.panel.status.missing', { agent: agentLabel })
return t('ai.panel.status.ready', { agent: agentLabel, mode: modeLabel })
}
function AiPanelEmptyState({
agentLabel,
agentReady,
agentReadiness,
hasContext,
legacyCopy,
}: Pick<AiPanelMessageHistoryProps, 'agentLabel' | 'agentReady' | 'hasContext' | 'legacyCopy'>) {
if (!agentReady) {
locale = 'en',
}: Pick<AiPanelMessageHistoryProps, 'agentLabel' | 'agentReadiness' | 'hasContext' | 'locale'>) {
const t = createTranslator(locale)
if (agentReadiness === 'checking') {
return (
<div
className="flex flex-col items-center justify-center text-center text-muted-foreground"
@@ -76,10 +116,27 @@ function AiPanelEmptyState({
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
{agentLabel} is not available on this machine
{t('ai.panel.empty.checkingTitle')}
</p>
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
Install it or switch the default AI agent in Settings
{t('ai.panel.empty.checkingDescription')}
</p>
</div>
)
}
if (agentReadiness === 'missing') {
return (
<div
className="flex flex-col items-center justify-center text-center text-muted-foreground"
style={{ paddingTop: 40 }}
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
{t('ai.panel.empty.missingTitle', { agent: agentLabel })}
</p>
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
{t('ai.panel.empty.missingDescription')}
</p>
</div>
)
@@ -93,14 +150,14 @@ function AiPanelEmptyState({
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
{hasContext
? legacyCopy ? 'Ask about this note and its linked context' : `Ask ${agentLabel} about this note and its linked context`
: legacyCopy ? 'Open a note, then ask the AI about it' : `Open a note, then ask ${agentLabel} about it`
? t('ai.panel.empty.withContextTitle', { agent: agentLabel })
: t('ai.panel.empty.noContextTitle', { agent: agentLabel })
}
</p>
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
{hasContext
? 'Summarize, find connections, expand ideas'
: 'The AI will use the active note as context'
? t('ai.panel.empty.withContextDescription')
: t('ai.panel.empty.noContextDescription')
}
</p>
</div>
@@ -109,48 +166,120 @@ function AiPanelEmptyState({
export function AiPanelHeader({
agentLabel,
agentReady,
legacyCopy,
agentReadiness,
locale = 'en',
permissionMode,
permissionModeDisabled,
onPermissionModeChange,
onClose,
onNewChat,
}: AiPanelHeaderProps) {
const t = createTranslator(locale)
const modeLabel = aiAgentPermissionModeLabels(permissionMode, locale).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 }}>
{legacyCopy ? 'AI Chat' : 'AI Agent'}
</span>
{!legacyCopy && (
<span className="truncate text-[11px] text-muted-foreground">
{agentLabel}
{!agentReady ? ' · not installed' : ''}
<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 }}>
{t('ai.panel.title')}
</span>
)}
<span className="truncate text-[11px] text-muted-foreground">
{headerStatusText({ agentLabel, agentReadiness, modeLabel, t })}
</span>
</div>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onNewChat}
aria-label={t('ai.panel.newChat')}
title={t('ai.panel.newChat')}
>
<Plus size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onClose}
aria-label={t('ai.panel.close')}
title={t('ai.panel.close')}
>
<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}
locale={locale}
disabled={permissionModeDisabled}
onChange={onPermissionModeChange}
/>
</div>
)
}
export function AiPanelContextBar({ activeEntry, linkedCount }: AiPanelContextBarProps) {
function AiPermissionModeToggle({
value,
locale = 'en',
disabled,
onChange,
}: {
value: AiAgentPermissionMode
locale?: AppLocale
disabled: boolean
onChange: (mode: AiAgentPermissionMode) => void
}) {
const t = createTranslator(locale)
return (
<TooltipProvider>
<div
className="inline-flex w-full rounded-md border border-border bg-muted p-1"
role="radiogroup"
aria-label={t('ai.permission.modeAria')}
data-testid="ai-permission-mode-toggle"
>
{(['safe', 'power_user'] as const).map((mode) => {
const selected = value === mode
return (
<ActionTooltip
key={mode}
copy={permissionModeTooltip(mode, t)}
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)}
>
{aiAgentPermissionModeLabels(mode, locale).control}
</Button>
</ActionTooltip>
)
})}
</div>
</TooltipProvider>
)
}
export function AiPanelContextBar({ activeEntry, linkedCount, locale = 'en' }: AiPanelContextBarProps) {
const t = createTranslator(locale)
return (
<div
className="flex shrink-0 items-center border-b border-border text-muted-foreground"
@@ -160,7 +289,7 @@ export function AiPanelContextBar({ activeEntry, linkedCount }: AiPanelContextBa
<Link size={12} className="shrink-0" />
<span className="truncate" style={{ fontWeight: 500 }}>{activeEntry.title}</span>
{linkedCount > 0 && (
<span style={{ opacity: 0.6 }}>+ {linkedCount} linked</span>
<span style={{ opacity: 0.6 }}>{t('ai.panel.linkedCount', { count: linkedCount })}</span>
)}
</div>
)
@@ -168,8 +297,8 @@ export function AiPanelContextBar({ activeEntry, linkedCount }: AiPanelContextBa
export function AiPanelMessageHistory({
agentLabel,
agentReady,
legacyCopy,
agentReadiness,
locale = 'en',
messages,
isActive,
onOpenNote,
@@ -187,8 +316,8 @@ export function AiPanelMessageHistory({
{messages.length === 0 && !isActive && (
<AiPanelEmptyState
agentLabel={agentLabel}
agentReady={agentReady}
legacyCopy={legacyCopy}
agentReadiness={agentReadiness}
locale={locale}
hasContext={hasContext}
/>
)}
@@ -208,19 +337,19 @@ export function AiPanelMessageHistory({
export function AiPanelComposer({
entries,
agentLabel,
agentReady,
hasContext,
agentReadiness,
locale = 'en',
input,
inputRef,
isActive,
legacyCopy,
onChange,
onSend,
onUnsupportedAiPaste,
}: AiPanelComposerProps) {
const composerDisabled = isActive || !agentReady
const t = createTranslator(locale)
const composerDisabled = isActive || agentReadiness !== 'ready'
const canSend = !composerDisabled && input.trim().length > 0
const placeholder = getComposerPlaceholder(agentLabel, agentReady, legacyCopy, hasContext)
const placeholder = getComposerPlaceholder(agentLabel, agentReadiness, t)
const sendButtonStyle = {
background: canSend ? 'var(--primary)' : 'var(--muted)',
color: canSend ? 'var(--primary-foreground)' : 'var(--muted-foreground)',
@@ -248,16 +377,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}
title="Send message"
aria-label={t('ai.panel.send')}
title={t('ai.panel.send')}
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</button>
</Button>
</div>
</div>
)

View File

@@ -0,0 +1,295 @@
import type { CSSProperties } from 'react'
import type { LucideIcon } from 'lucide-react'
import {
AlignJustify,
Archive,
CheckCircle2,
ChevronDown,
Code2,
FileText,
GitBranch,
Inbox,
Plus,
Search,
Settings,
SlidersHorizontal,
Sparkles,
Star,
Trash2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const SIDEBAR_GROUPS = [
{ rows: ['62%', '48%', '58%', '52%'], count: '24px' },
{ rows: ['72%'], count: '30px' },
{ rows: ['34%', '42%', '38%', '56%', '50%', '44%', '39%', '46%'], count: '26px' },
{ rows: ['38%', '52%', '32%'] },
]
const NOTE_ROWS = [
{ selected: false, title: '68%', snippet: '84%', chips: ['56px'] },
{ selected: true, title: '62%', snippet: '78%', chips: ['64px', '54px', '72px'] },
{ selected: false, title: '48%', snippet: '44%', chips: [] },
{ selected: false, title: '82%', snippet: '74%', chips: ['68px'] },
{ selected: false, title: '70%', snippet: '88%', chips: ['76px', '92px'] },
{ selected: false, title: '58%', snippet: '66%', chips: ['64px'] },
{ selected: false, title: '76%', snippet: '72%', chips: ['72px'] },
]
const EDITOR_ACTIONS = [Star, CheckCircle2, GitBranch, Code2, AlignJustify, Sparkles, Archive, Trash2, SlidersHorizontal]
const STATUS_LEFT = ['70px', '104px', '88px', '116px', '82px']
const STATUS_RIGHT = ['44px', '78px', '18px', '18px']
type AppLoadingSkeletonProps = {
noteListWidth?: number
showNoteList?: boolean
showSidebar?: boolean
sidebarWidth?: number
}
function SkeletonBar({ className, style }: { className?: string; style?: CSSProperties }) {
return (
<div
className={cn('animate-pulse rounded bg-muted', className)}
style={style}
/>
)
}
function SkeletonIcon({ icon: Icon, active = false }: { icon: LucideIcon; active?: boolean }) {
return (
<Icon
size={16}
strokeWidth={1.9}
className={cn(active ? 'text-primary' : 'text-muted-foreground', 'shrink-0 opacity-70')}
/>
)
}
function SidebarRow({ icon, width, active = false, countWidth }: {
icon: LucideIcon
width: string
active?: boolean
countWidth?: string
}) {
return (
<div
className={cn('flex items-center gap-2 rounded px-4 py-1.5', active && 'bg-primary/10')}
style={active ? { boxShadow: 'inset 3px 0 0 var(--primary)' } : undefined}
>
<SkeletonIcon icon={icon} active={active} />
<SkeletonBar className="h-3" style={{ width }} />
{countWidth && <SkeletonBar className="h-5 rounded-full" style={{ width: countWidth }} />}
</div>
)
}
function SidebarSkeletonGlyph() {
return <SkeletonBar className="h-4 w-4 shrink-0 rounded-[4px]" />
}
function SidebarStubRow({ width, countWidth }: {
width: string
countWidth?: string
}) {
return (
<div className="flex items-center gap-2 rounded px-4 py-1.5">
<SidebarSkeletonGlyph />
<SkeletonBar className="h-3" style={{ width }} />
{countWidth && <SkeletonBar className="h-5 rounded-full" style={{ width: countWidth }} />}
</div>
)
}
function SidebarGroupSkeleton({ rows, count }: { rows: string[]; count?: string }) {
return (
<div className="border-b border-border px-1.5 pb-2">
<div className="flex items-center gap-1 px-4 py-2">
<ChevronDown size={12} className="text-muted-foreground opacity-60" />
<SkeletonBar className="h-2.5" style={{ width: '54px' }} />
{count && <SkeletonBar className="ml-auto h-4 rounded-full" style={{ width: count }} />}
</div>
<div className="flex flex-col gap-1">
{rows.map((width, index) => (
<SidebarStubRow
key={`${width}-${index}`}
width={width}
countWidth={index > 3 ? '30px' : undefined}
/>
))}
</div>
</div>
)
}
function SidebarSkeleton() {
return (
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
<div className="h-[52px] shrink-0 border-b border-border" />
<nav className="flex-1 overflow-hidden py-1">
<div className="border-b border-border px-1.5 pb-1">
<SidebarRow icon={Inbox} width="48%" active countWidth="30px" />
<SidebarRow icon={FileText} width="54%" countWidth="38px" />
<SidebarRow icon={Archive} width="42%" countWidth="32px" />
</div>
{SIDEBAR_GROUPS.map((group, index) => (
<SidebarGroupSkeleton key={index} rows={group.rows} count={group.count} />
))}
</nav>
</aside>
)
}
function NoteRowSkeleton({ selected, title, snippet, chips }: {
selected: boolean
title: string
snippet: string
chips: string[]
}) {
return (
<div
className={cn('relative border-b border-border px-5 py-4', selected && 'bg-primary/5')}
style={selected ? { boxShadow: 'inset 3px 0 0 var(--accent-green)' } : undefined}
>
<SkeletonBar className="absolute right-4 top-4 h-3.5 w-3.5 rounded-sm" />
<SkeletonBar className="mb-3 h-3.5" style={{ width: title }} />
<div className="space-y-1.5">
<SkeletonBar className="h-2.5" style={{ width: snippet }} />
<SkeletonBar className="h-2.5" style={{ width: '58%' }} />
</div>
{chips.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{chips.map((width) => (
<SkeletonBar key={width} className="h-5 rounded" style={{ width }} />
))}
</div>
)}
<div className="mt-3 grid grid-cols-[1fr_auto] gap-3">
<SkeletonBar className="h-2.5" style={{ width: '38px' }} />
<SkeletonBar className="h-2.5" style={{ width: '72px' }} />
</div>
</div>
)
}
function NoteListSkeleton() {
return (
<section className="flex h-full flex-col overflow-hidden border-r border-border bg-card text-foreground">
<header className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4">
<SkeletonBar className="h-4" style={{ width: '78px' }} />
<div className="flex items-center gap-3 text-muted-foreground">
<SkeletonBar className="h-2.5" style={{ width: '58px' }} />
<Search size={16} />
<SlidersHorizontal size={16} />
<Plus size={16} />
</div>
</header>
<div className="flex-1 overflow-hidden">
{NOTE_ROWS.map((row, index) => (
<NoteRowSkeleton key={index} {...row} />
))}
</div>
</section>
)
}
function EditorSkeleton() {
return (
<main className="flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background text-foreground">
<header className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-5">
<div className="flex items-center gap-2">
<SkeletonBar className="h-3" style={{ width: '44px' }} />
<SkeletonBar className="h-3" style={{ width: '150px' }} />
</div>
<div className="flex items-center gap-5 text-muted-foreground">
{EDITOR_ACTIONS.map((Icon, index) => (
<Icon key={index} size={16} strokeWidth={1.8} className="opacity-65" />
))}
</div>
</header>
<article className="mx-auto flex w-full max-w-[760px] flex-1 flex-col px-10 py-16">
<SkeletonBar className="mb-7 h-9" style={{ width: '58%' }} />
<SkeletonBar className="mb-6 h-px w-full rounded-none" />
<div className="space-y-4">
<SkeletonBar className="h-4" style={{ width: '52%' }} />
<SkeletonBar className="h-4" style={{ width: '92%' }} />
<SkeletonBar className="h-4" style={{ width: '82%' }} />
<SkeletonBar className="h-4" style={{ width: '88%' }} />
<SkeletonBar className="h-4" style={{ width: '74%' }} />
</div>
<SkeletonBar className="my-10 h-px w-full rounded-none" />
<div className="space-y-4">
<SkeletonBar className="h-4" style={{ width: '22%' }} />
{[0, 1, 2].map((index) => (
<div key={index} className="flex items-center gap-4 pl-4">
<SkeletonBar className="h-2.5 w-2.5 rounded-full bg-primary/70" />
<SkeletonBar className="h-4" style={{ width: index === 1 ? '54%' : '44%' }} />
</div>
))}
</div>
</article>
</main>
)
}
function StatusSkeleton() {
return (
<footer
className="flex shrink-0 items-center justify-between border-t border-border bg-sidebar px-2 text-muted-foreground"
style={{ height: 30 }}
>
<div className="flex items-center gap-4">
{STATUS_LEFT.map((width, index) => (
<SkeletonBar key={`${width}-${index}`} className="h-3" style={{ width }} />
))}
</div>
<div className="flex items-center gap-4">
{STATUS_RIGHT.map((width, index) => (
<SkeletonBar key={`${width}-${index}`} className="h-3" style={{ width }} />
))}
<Settings size={14} className="opacity-60" />
</div>
</footer>
)
}
export function AppLoadingSkeleton({
noteListWidth = 350,
showNoteList = true,
showSidebar = true,
sidebarWidth = 250,
}: AppLoadingSkeletonProps) {
return (
<div
className="app-shell"
data-testid="vault-loading-skeleton"
role="status"
aria-busy="true"
aria-live="polite"
>
<span className="sr-only">Loading vault</span>
<div className="app" aria-hidden="true">
{showSidebar && (
<>
<div className="app__sidebar" style={{ width: sidebarWidth }}>
<SidebarSkeleton />
</div>
<div className="w-px shrink-0 bg-border" />
</>
)}
{showNoteList && (
<>
<div className="app__note-list" style={{ width: noteListWidth }}>
<NoteListSkeleton />
</div>
<div className="w-px shrink-0 bg-border" />
</>
)}
<div className="app__editor">
<EditorSkeleton />
</div>
</div>
<StatusSkeleton />
</div>
)
}

View File

@@ -1,10 +1,12 @@
import { Archive, ArrowUUpLeft } from '@phosphor-icons/react'
import { translate, type AppLocale } from '../lib/i18n'
interface ArchivedNoteBannerProps {
onUnarchive: () => void
locale?: AppLocale
}
export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
export function ArchivedNoteBanner({ onUnarchive, locale = 'en' }: ArchivedNoteBannerProps) {
return (
<div
data-testid="archived-note-banner"
@@ -21,7 +23,7 @@ export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
}}
>
<Archive size={13} weight="bold" />
<span>Archived</span>
<span>{translate(locale, 'editor.banner.archived')}</span>
<button
data-testid="unarchive-btn"
onClick={onUnarchive}
@@ -38,10 +40,10 @@ export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
color: 'var(--muted-foreground)',
cursor: 'pointer',
}}
title="Unarchive"
title={translate(locale, 'editor.banner.unarchive')}
>
<ArrowUUpLeft size={12} />
Unarchive
{translate(locale, 'editor.banner.unarchive')}
</button>
</div>
)

View File

@@ -135,6 +135,26 @@ describe('BreadcrumbBar — archive/unarchive', () => {
})
})
describe('BreadcrumbBar — file actions', () => {
it('reveals the current file from the breadcrumb toolbar', () => {
const onRevealFile = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRevealFile={onRevealFile} />)
fireEvent.click(screen.getByRole('button', { name: 'Reveal in Finder' }))
expect(onRevealFile).toHaveBeenCalledWith('/vault/note/test.md')
})
it('copies the current file path from the breadcrumb toolbar', () => {
const onCopyFilePath = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onCopyFilePath={onCopyFilePath} />)
fireEvent.click(screen.getByRole('button', { name: 'Copy file path' }))
expect(onCopyFilePath).toHaveBeenCalledWith('/vault/note/test.md')
})
})
describe('BreadcrumbBar — organized shortcut hint', () => {
it('shows Cmd+E on the organized toggle tooltip', async () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onToggleOrganized={vi.fn()} />)

View File

@@ -1,6 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
import type { NoteLayout, VaultEntry } from '../types'
import { cn } from '@/lib/utils'
import { translate, type AppLocale } from '../lib/i18n'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -14,6 +15,8 @@ import {
Trash,
Archive,
ArrowUUpLeft,
ClipboardText,
FolderOpen,
Star,
CheckCircle,
ArrowsClockwise,
@@ -41,6 +44,8 @@ interface BreadcrumbBarProps {
onToggleInspector?: () => void
onToggleFavorite?: () => void
onToggleOrganized?: () => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
onDelete?: () => void
onArchive?: () => void
onUnarchive?: () => void
@@ -49,6 +54,7 @@ interface BreadcrumbBarProps {
onToggleNoteLayout?: () => void
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
barRef?: React.Ref<HTMLDivElement>
locale?: AppLocale
}
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
@@ -146,6 +152,12 @@ interface ToggleIconActionProps {
shortcut: string
}
interface TranslatedToggleIconActionProps extends Omit<ToggleIconActionProps, 'activeLabel' | 'inactiveLabel'> {
activeLabelKey: Parameters<typeof translate>[1]
inactiveLabelKey: Parameters<typeof translate>[1]
locale?: AppLocale
}
function ToggleIconAction({
active,
activeClassName,
@@ -170,26 +182,88 @@ function ToggleIconAction({
)
}
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
function TranslatedToggleIconAction({
activeLabelKey,
inactiveLabelKey,
locale = 'en',
...props
}: TranslatedToggleIconActionProps) {
return (
<ToggleIconAction
active={!!rawMode}
activeClassName="text-foreground"
activeLabel="Return to the editor"
inactiveLabel="Open the raw editor"
onClick={onToggleRaw}
shortcut={formatShortcutDisplay({ display: '⌘\\' })}
>
<Code size={16} className={BREADCRUMB_ICON_CLASS} />
</ToggleIconAction>
{...props}
activeLabel={translate(locale, activeLabelKey)}
inactiveLabel={translate(locale, inactiveLabelKey)}
/>
)
}
const TOGGLE_ACTION_CONFIGS = {
raw: {
activeClassName: 'text-foreground',
activeLabelKey: 'editor.toolbar.rawReturn',
inactiveLabelKey: 'editor.toolbar.rawOpen',
shortcut: '⌘\\',
renderIcon: () => <Code size={16} className={BREADCRUMB_ICON_CLASS} />,
},
favorite: {
activeClassName: 'text-[var(--accent-yellow)]',
activeLabelKey: 'editor.toolbar.removeFavorite',
inactiveLabelKey: 'editor.toolbar.addFavorite',
shortcut: '⌘D',
renderIcon: (active: boolean) => <Star size={16} weight={active ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />,
},
organized: {
activeClassName: 'text-[var(--accent-green)]',
activeLabelKey: 'editor.toolbar.markUnorganized',
inactiveLabelKey: 'editor.toolbar.markOrganized',
shortcut: '⌘E',
renderIcon: (active: boolean) => <CheckCircle size={16} weight={active ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />,
},
} satisfies Record<string, {
activeClassName: string
activeLabelKey: Parameters<typeof translate>[1]
inactiveLabelKey: Parameters<typeof translate>[1]
shortcut: string
renderIcon: (active: boolean) => ReactNode
}>
function ConfiguredToggleAction({
active,
config,
locale = 'en',
onClick,
}: {
active: boolean
config: (typeof TOGGLE_ACTION_CONFIGS)[keyof typeof TOGGLE_ACTION_CONFIGS]
locale?: AppLocale
onClick?: () => void
}) {
return (
<TranslatedToggleIconAction
active={active}
activeClassName={config.activeClassName}
activeLabelKey={config.activeLabelKey}
inactiveLabelKey={config.inactiveLabelKey}
locale={locale}
onClick={onClick}
shortcut={formatShortcutDisplay({ display: config.shortcut })}
>
{config.renderIcon(active)}
</TranslatedToggleIconAction>
)
}
function RawToggleButton({ rawMode, locale = 'en', onToggleRaw }: { rawMode?: boolean; locale?: AppLocale; onToggleRaw?: () => void }) {
return <ConfiguredToggleAction active={!!rawMode} config={TOGGLE_ACTION_CONFIGS.raw} locale={locale} onClick={onToggleRaw} />
}
function NoteLayoutAction({
noteLayout = 'centered',
locale = 'en',
onToggleNoteLayout,
}: {
noteLayout?: NoteLayout
locale?: AppLocale
onToggleNoteLayout?: () => void
}) {
if (!onToggleNoteLayout) return null
@@ -197,7 +271,7 @@ function NoteLayoutAction({
const isLeftAligned = noteLayout === 'left'
return (
<IconActionButton
copy={{ label: isLeftAligned ? 'Switch to centered note layout' : 'Switch to left-aligned note layout' }}
copy={{ label: translate(locale, isLeftAligned ? 'editor.toolbar.centerLayout' : 'editor.toolbar.leftLayout') }}
onClick={onToggleNoteLayout}
className={cn(isLeftAligned ? 'text-foreground' : 'hover:text-foreground')}
>
@@ -208,60 +282,41 @@ function NoteLayoutAction({
)
}
function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onToggleFavorite?: () => void }) {
return (
<ToggleIconAction
active={favorite}
activeClassName="text-[var(--accent-yellow)]"
activeLabel="Remove from favorites"
inactiveLabel="Add to favorites"
onClick={onToggleFavorite}
shortcut={formatShortcutDisplay({ display: '⌘D' })}
>
<Star size={16} weight={favorite ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
</ToggleIconAction>
)
function FavoriteAction({ favorite, locale = 'en', onToggleFavorite }: { favorite: boolean; locale?: AppLocale; onToggleFavorite?: () => void }) {
return <ConfiguredToggleAction active={favorite} config={TOGGLE_ACTION_CONFIGS.favorite} locale={locale} onClick={onToggleFavorite} />
}
function OrganizedAction({
organized,
locale = 'en',
onToggleOrganized,
}: {
organized: boolean
locale?: AppLocale
onToggleOrganized?: () => void
}) {
if (!onToggleOrganized) return null
return (
<ToggleIconAction
active={organized}
activeClassName="text-[var(--accent-green)]"
activeLabel="Set note as not organized"
inactiveLabel="Set note as organized"
onClick={onToggleOrganized}
shortcut={formatShortcutDisplay({ display: '⌘E' })}
>
<CheckCircle size={16} weight={organized ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
</ToggleIconAction>
)
return <ConfiguredToggleAction active={organized} config={TOGGLE_ACTION_CONFIGS.organized} locale={locale} onClick={onToggleOrganized} />
}
function DiffAction({
showDiffToggle,
diffMode,
diffLoading,
locale = 'en',
onToggleDiff,
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading' | 'onToggleDiff'>) {
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading' | 'locale' | 'onToggleDiff'>) {
if (!showDiffToggle) {
return (
<IconActionButton copy={{ label: 'No diff is available yet' }} style={DISABLED_ICON_STYLE}>
<IconActionButton copy={{ label: translate(locale, 'editor.toolbar.noDiff') }} style={DISABLED_ICON_STYLE}>
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
const copy: ActionTooltipCopy = diffLoading
? { label: 'Loading the diff' }
: { label: diffMode ? 'Return to the editor' : 'Show the current diff' }
? { label: translate(locale, 'editor.toolbar.loadingDiff') }
: { label: translate(locale, diffMode ? 'editor.toolbar.rawReturn' : 'editor.toolbar.showDiff') }
return (
<IconActionButton
copy={copy}
@@ -273,13 +328,13 @@ function DiffAction({
)
}
function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'onToggleAIChat'>) {
function AIChatAction({ showAIChat, locale = 'en', onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'locale' | 'onToggleAIChat'>) {
return (
<ToggleIconAction
active={!!showAIChat}
activeClassName="text-primary"
activeLabel="Close the AI panel"
inactiveLabel="Open the AI panel"
activeLabel={translate(locale, 'editor.toolbar.closeAi')}
inactiveLabel={translate(locale, 'editor.toolbar.openAi')}
onClick={onToggleAIChat}
shortcut={formatShortcutDisplay({ display: '⌘⇧L' })}
>
@@ -290,29 +345,30 @@ function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, '
function ArchiveAction({
archived,
locale = 'en',
onArchive,
onUnarchive,
}: Pick<VaultEntry, 'archived'> & Pick<BreadcrumbBarProps, 'onArchive' | 'onUnarchive'>) {
}: Pick<VaultEntry, 'archived'> & Pick<BreadcrumbBarProps, 'locale' | 'onArchive' | 'onUnarchive'>) {
if (archived) {
return (
<IconActionButton copy={{ label: 'Restore this archived note' }} onClick={onUnarchive} className="hover:text-foreground">
<IconActionButton copy={{ label: translate(locale, 'editor.toolbar.restoreArchived') }} onClick={onUnarchive} className="hover:text-foreground">
<ArrowUUpLeft size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
return (
<IconActionButton copy={{ label: 'Archive this note' }} onClick={onArchive} className="hover:text-foreground">
<IconActionButton copy={{ label: translate(locale, 'editor.toolbar.archive') }} onClick={onArchive} className="hover:text-foreground">
<Archive size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
}
function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
function DeleteAction({ locale = 'en', onDelete }: Pick<BreadcrumbBarProps, 'locale' | 'onDelete'>) {
return (
<IconActionButton
copy={{
label: 'Delete this note',
label: translate(locale, 'editor.toolbar.delete'),
shortcut: formatShortcutDisplay({ display: '⌘⌫ / ⌘⌦' }),
}}
onClick={onDelete}
@@ -323,15 +379,48 @@ function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
)
}
function FilePathActions({
entry,
locale = 'en',
onRevealFile,
onCopyFilePath,
}: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onRevealFile' | 'onCopyFilePath'>) {
return (
<>
{onRevealFile && (
<IconActionButton
copy={{ label: translate(locale, 'editor.toolbar.revealFile') }}
onClick={() => onRevealFile(entry.path)}
className="hover:text-foreground"
testId="breadcrumb-reveal-file"
>
<FolderOpen size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)}
{onCopyFilePath && (
<IconActionButton
copy={{ label: translate(locale, 'editor.toolbar.copyFilePath') }}
onClick={() => onCopyFilePath(entry.path)}
className="hover:text-foreground"
testId="breadcrumb-copy-file-path"
>
<ClipboardText size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)}
</>
)
}
function InspectorAction({
inspectorCollapsed,
locale = 'en',
onToggleInspector,
}: Pick<BreadcrumbBarProps, 'inspectorCollapsed' | 'onToggleInspector'>) {
}: Pick<BreadcrumbBarProps, 'inspectorCollapsed' | 'locale' | 'onToggleInspector'>) {
if (!inspectorCollapsed) return null
return (
<IconActionButton
copy={{
label: 'Open the properties panel',
label: translate(locale, 'editor.toolbar.openProperties'),
shortcut: formatShortcutDisplay({ display: '⌘⇧I' }),
}}
onClick={onToggleInspector}
@@ -358,12 +447,14 @@ function deriveSyncStem(entry: VaultEntry): string | null {
function FilenameInput({
inputRef,
draftStem,
locale = 'en',
onDraftStemChange,
onBlur,
onKeyDown,
}: {
inputRef: React.RefObject<HTMLInputElement | null>
draftStem: string
locale?: AppLocale
onDraftStemChange: (nextValue: string) => void
onBlur: () => void
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
@@ -377,7 +468,7 @@ function FilenameInput({
onKeyDown={onKeyDown}
className="h-7 w-[180px] text-sm"
data-testid="breadcrumb-filename-input"
aria-label="Rename filename"
aria-label={translate(locale, 'editor.filename.rename')}
/>
)
}
@@ -385,10 +476,12 @@ function FilenameInput({
function FilenameTrigger({
entry,
filenameStem,
locale = 'en',
onStartEditing,
}: {
entry: VaultEntry
filenameStem: string
locale?: AppLocale
onStartEditing: () => void
}) {
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>) => {
@@ -406,7 +499,7 @@ function FilenameTrigger({
onDoubleClick={onStartEditing}
onKeyDown={handleKeyDown}
data-testid="breadcrumb-filename-trigger"
aria-label={`Filename ${filenameStem}. Press Enter to rename`}
aria-label={translate(locale, 'editor.filename.trigger', { filename: filenameStem })}
>
<NoteTitleIcon icon={entry.icon} size={15} testId="breadcrumb-note-icon" />
<span className="truncate">{filenameStem}</span>
@@ -417,15 +510,17 @@ function FilenameTrigger({
function SyncFilenameButton({
entryPath,
syncStem,
locale = 'en',
onRenameFilename,
}: {
entryPath: string
syncStem: string | null
locale?: AppLocale
onRenameFilename?: (path: string, newFilenameStem: string) => void
}) {
if (!syncStem || !onRenameFilename) return null
return (
<ActionTooltip copy={{ label: 'Rename the file to match the title' }} side="bottom">
<ActionTooltip copy={{ label: translate(locale, 'editor.filename.renameToTitle') }} side="bottom">
<Button
type="button"
variant="ghost"
@@ -433,7 +528,7 @@ function SyncFilenameButton({
className="text-muted-foreground hover:text-foreground"
onClick={() => onRenameFilename(entryPath, syncStem)}
data-testid="breadcrumb-sync-button"
aria-label="Rename the file to match the title"
aria-label={translate(locale, 'editor.filename.renameToTitle')}
>
<ArrowsClockwise size={14} />
</Button>
@@ -445,24 +540,26 @@ function FilenameDisplay({
entry,
filenameStem,
syncStem,
locale,
onRenameFilename,
onStartEditing,
}: {
entry: VaultEntry
filenameStem: string
syncStem: string | null
locale?: AppLocale
onRenameFilename?: (path: string, newFilenameStem: string) => void
onStartEditing: () => void
}) {
return (
<div className="flex min-w-0 items-center gap-1">
<FilenameTrigger entry={entry} filenameStem={filenameStem} onStartEditing={onStartEditing} />
<SyncFilenameButton entryPath={entry.path} syncStem={syncStem} onRenameFilename={onRenameFilename} />
<FilenameTrigger entry={entry} filenameStem={filenameStem} locale={locale} onStartEditing={onStartEditing} />
<SyncFilenameButton entryPath={entry.path} syncStem={syncStem} locale={locale} onRenameFilename={onRenameFilename} />
</div>
)
}
function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'entry' | 'onRenameFilename'>) {
function FilenameCrumb({ entry, locale = 'en', onRenameFilename }: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onRenameFilename'>) {
const filenameStem = useMemo(() => entry.filename.replace(/\.md$/, ''), [entry.filename])
const syncStem = useMemo(() => deriveSyncStem(entry), [entry])
const [isEditing, setIsEditing] = useState(false)
@@ -498,6 +595,7 @@ function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'en
<FilenameInput
inputRef={inputRef}
draftStem={draftStem}
locale={locale}
onDraftStemChange={setDraftStem}
onBlur={submitRename}
onKeyDown={handleInputKeyDown}
@@ -510,6 +608,7 @@ function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'en
entry={entry}
filenameStem={filenameStem}
syncStem={syncStem}
locale={locale}
onRenameFilename={onRenameFilename}
onStartEditing={startEditing}
/>
@@ -533,41 +632,47 @@ function BreadcrumbActions({
onToggleInspector,
onToggleFavorite,
onToggleOrganized,
onRevealFile,
onCopyFilePath,
onDelete,
onArchive,
onUnarchive,
locale = 'en',
}: Omit<BreadcrumbBarProps, 'wordCount' | 'barRef' | 'onRenameFilename'>) {
return (
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
<FavoriteAction favorite={entry.favorite} onToggleFavorite={onToggleFavorite} />
<OrganizedAction organized={entry.organized} onToggleOrganized={onToggleOrganized} />
<FavoriteAction favorite={entry.favorite} locale={locale} onToggleFavorite={onToggleFavorite} />
<OrganizedAction organized={entry.organized} locale={locale} onToggleOrganized={onToggleOrganized} />
<DiffAction
showDiffToggle={showDiffToggle}
diffMode={diffMode}
diffLoading={diffLoading}
onToggleDiff={onToggleDiff}
locale={locale}
/>
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
<NoteLayoutAction noteLayout={noteLayout} onToggleNoteLayout={onToggleNoteLayout} />
<AIChatAction showAIChat={showAIChat} onToggleAIChat={onToggleAIChat} />
<ArchiveAction archived={entry.archived} onArchive={onArchive} onUnarchive={onUnarchive} />
<DeleteAction onDelete={onDelete} />
<InspectorAction inspectorCollapsed={inspectorCollapsed} onToggleInspector={onToggleInspector} />
{!forceRawMode && <RawToggleButton rawMode={rawMode} locale={locale} onToggleRaw={onToggleRaw} />}
<NoteLayoutAction noteLayout={noteLayout} locale={locale} onToggleNoteLayout={onToggleNoteLayout} />
<AIChatAction showAIChat={showAIChat} locale={locale} onToggleAIChat={onToggleAIChat} />
<FilePathActions entry={entry} locale={locale} onRevealFile={onRevealFile} onCopyFilePath={onCopyFilePath} />
<ArchiveAction archived={entry.archived} locale={locale} onArchive={onArchive} onUnarchive={onUnarchive} />
<DeleteAction locale={locale} onDelete={onDelete} />
<InspectorAction inspectorCollapsed={inspectorCollapsed} locale={locale} onToggleInspector={onToggleInspector} />
</div>
)
}
function BreadcrumbTitle({
entry,
locale,
onRenameFilename,
}: Pick<BreadcrumbBarProps, 'entry' | 'onRenameFilename'>) {
}: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onRenameFilename'>) {
const typeLabel = entry.isA ?? 'Note'
return (
<div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
<span className="shrink-0">{typeLabel}</span>
<span className="shrink-0 text-border"></span>
<div className="flex min-w-0 items-center gap-1 truncate">
<FilenameCrumb entry={entry} onRenameFilename={onRenameFilename} />
<FilenameCrumb entry={entry} locale={locale} onRenameFilename={onRenameFilename} />
</div>
</div>
)
@@ -576,6 +681,7 @@ function BreadcrumbTitle({
export const BreadcrumbBar = memo(function BreadcrumbBar({
entry,
barRef,
locale = 'en',
onRenameFilename,
...actionProps
}: BreadcrumbBarProps) {
@@ -597,14 +703,14 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
}}
>
<div className="breadcrumb-bar__title min-w-0">
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
<BreadcrumbTitle entry={entry} locale={locale} onRenameFilename={onRenameFilename} />
</div>
<div
aria-hidden="true"
data-tauri-drag-region
className="breadcrumb-bar__drag-spacer min-w-0 flex-1"
/>
<BreadcrumbActions entry={entry} {...actionProps} />
<BreadcrumbActions entry={entry} locale={locale} {...actionProps} />
</div>
</TooltipProvider>
)

View File

@@ -1,14 +1,45 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
import type { VaultEntry } from '../types'
import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
import { readSelectionRange } from './inlineWikilinkDom'
import { CommandPalette } from './CommandPalette'
import type { CommandAction } from '../hooks/useCommandRegistry'
type NativeDropPayload = {
type: string
paths: string[]
position: { x: number; y: number }
}
type NativeDropHandler = (event: { payload: NativeDropPayload }) => void
const nativeDropState = vi.hoisted(() => ({
tauriMode: false,
handlers: {} as Record<string, NativeDropHandler[] | undefined>,
}))
// jsdom doesn't implement scrollIntoView
Element.prototype.scrollIntoView = vi.fn()
vi.mock('../mock-tauri', () => ({
isTauri: () => nativeDropState.tauriMode,
}))
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({
onDragDropEvent: vi.fn((handler: NativeDropHandler) => {
nativeDropState.handlers['native-drag-drop'] = [
...(nativeDropState.handlers['native-drag-drop'] ?? []),
handler,
]
return Promise.resolve(() => {
const handlers = nativeDropState.handlers['native-drag-drop']?.filter((candidate) => candidate !== handler) ?? []
if (handlers.length > 0) nativeDropState.handlers['native-drag-drop'] = handlers
else delete nativeDropState.handlers['native-drag-drop']
})
}),
}),
}))
vi.mock('../utils/aiPromptBridge', () => ({
queueAiPrompt: vi.fn(),
requestOpenAiChat: vi.fn(),
@@ -86,13 +117,60 @@ function updateAiInput(text: string) {
return editor
}
function resetNativeDropState() {
nativeDropState.tauriMode = false
for (const eventName of Object.keys(nativeDropState.handlers)) {
delete nativeDropState.handlers[eventName]
}
}
function mockElementRect(element: HTMLElement) {
Object.defineProperty(element, 'getBoundingClientRect', {
configurable: true,
value: () => ({
x: 0,
y: 0,
left: 0,
top: 0,
right: 400,
bottom: 48,
width: 400,
height: 48,
toJSON: () => ({}),
}),
})
}
function emitNativePathDrop(paths: string[]) {
const handlers = nativeDropState.handlers['native-drag-drop']
if (!handlers || handlers.length === 0) throw new Error('No native drop handler registered')
for (const handler of handlers) {
handler({
payload: {
type: 'drop',
paths,
position: { x: 20, y: 20 },
},
})
}
}
async function waitForNativePathDropListener() {
await waitFor(() => {
expect(nativeDropState.handlers['native-drag-drop']?.length).toBeGreaterThan(0)
})
}
describe('CommandPalette', () => {
const onClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
resetNativeDropState()
})
afterEach(resetNativeDropState)
it('renders nothing when closed', () => {
const { container } = render(
<CommandPalette open={false} commands={commands} onClose={onClose} />,
@@ -166,7 +244,7 @@ describe('CommandPalette', () => {
})
it('localizes command palette chrome', () => {
render(<CommandPalette open={true} commands={commands} locale="zh-Hans" onClose={onClose} />)
render(<CommandPalette open={true} commands={commands} locale="zh-CN" onClose={onClose} />)
const input = screen.getByPlaceholderText('输入命令...')
fireEvent.change(input, { target: { value: 'zzzzzzz' } })
@@ -284,6 +362,24 @@ describe('CommandPalette', () => {
expect(screen.queryByText('Search Notes')).not.toBeInTheDocument()
})
it('inserts Tauri native folder drops into the command query input', async () => {
nativeDropState.tauriMode = true
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
const input = screen.getByPlaceholderText('Type a command...') as HTMLInputElement
mockElementRect(input)
input.focus()
await waitForNativePathDropListener()
act(() => {
emitNativePathDrop(['/Users/test/Projects'])
})
await waitFor(() => {
expect(input).toHaveValue('/Users/test/Projects')
})
})
it('focuses the AI editor immediately when the leading space triggers AI mode', () => {
render(<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />)

View File

@@ -6,10 +6,13 @@ import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
import type { NoteReference } from '../utils/ai-context'
import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry'
import { groupSortKey } from '../hooks/useCommandRegistry'
import { localizeCommandGroup } from '../hooks/commands/localizeCommands'
import { rememberFeedbackDialogOpener } from '../lib/feedbackDialogOpener'
import { createTranslator, type AppLocale } from '../lib/i18n'
import { formatDroppedPathList } from './inlineWikilinkDropText'
import { CommandPaletteAiMode } from './CommandPaletteAiMode'
import { Input } from './ui/input'
import { useNativePathDrop } from './useNativePathDrop'
interface CommandPaletteProps {
open: boolean
@@ -117,6 +120,15 @@ function rememberCommandOpener(
rememberFeedbackDialogOpener(target instanceof HTMLElement ? target : null)
}
function inputSelectionRange(input: HTMLInputElement, fallbackIndex: number) {
const start = input.selectionStart ?? fallbackIndex
const end = input.selectionEnd ?? start
return {
start: Math.max(0, start),
end: Math.max(start, end),
}
}
function CommandPaletteInput({
inputRef,
query,
@@ -128,6 +140,27 @@ function CommandPaletteInput({
onChange: (value: string) => void
placeholder: string
}) {
const insertNativePathDrop = (paths: string[]) => {
const droppedPathText = formatDroppedPathList(paths)
const input = inputRef.current
if (!droppedPathText || !input) return
const { start, end } = inputSelectionRange(input, query.length)
const nextValue = `${query.slice(0, start)}${droppedPathText}${query.slice(end)}`
const nextCursor = start + droppedPathText.length
onChange(nextValue)
window.requestAnimationFrame(() => {
input.focus()
input.setSelectionRange(nextCursor, nextCursor)
})
}
useNativePathDrop({
targetRef: inputRef,
onPathDrop: insertNativePathDrop,
})
return (
<Input
ref={inputRef}
@@ -149,6 +182,7 @@ function CommandPaletteResults({
selectedIndex,
listRef,
emptyText,
locale,
onHover,
onSelect,
}: {
@@ -156,6 +190,7 @@ function CommandPaletteResults({
selectedIndex: number
listRef: React.RefObject<HTMLDivElement | null>
emptyText: string
locale: AppLocale
onHover: (index: number) => void
onSelect: (command: CommandAction) => void
}) {
@@ -189,7 +224,7 @@ function CommandPaletteResults({
return (
<div key={group}>
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
{group}
{localizeCommandGroup(group, locale)}
</div>
{items.map((command, index) => {
const globalIndex = startIndex + index
@@ -398,6 +433,7 @@ function OpenCommandPalette({
selectedIndex={selectedIndex}
listRef={listRef}
emptyText={t('command.noMatches')}
locale={locale}
onHover={setSelectedIndex}
onSelect={handleSelectCommand}
/>

View File

@@ -1,11 +1,13 @@
import { AlertTriangle } from 'lucide-react'
import { translate, type AppLocale } from '../lib/i18n'
interface ConflictNoteBannerProps {
onKeepMine: () => void
onKeepTheirs: () => void
locale?: AppLocale
}
export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBannerProps) {
export function ConflictNoteBanner({ onKeepMine, onKeepTheirs, locale = 'en' }: ConflictNoteBannerProps) {
return (
<div
data-testid="conflict-note-banner"
@@ -22,7 +24,7 @@ export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBan
}}
>
<AlertTriangle size={13} />
<span>This note has a merge conflict</span>
<span>{translate(locale, 'editor.banner.conflict')}</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
<button
data-testid="conflict-keep-mine-btn"
@@ -39,9 +41,9 @@ export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBan
color: 'var(--foreground)',
cursor: 'pointer',
}}
title="Keep my local version"
title={translate(locale, 'editor.banner.keepMineTooltip')}
>
Keep mine
{translate(locale, 'editor.banner.keepMine')}
</button>
<button
data-testid="conflict-keep-theirs-btn"
@@ -58,9 +60,9 @@ export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBan
color: 'var(--foreground)',
cursor: 'pointer',
}}
title="Keep the remote version"
title={translate(locale, 'editor.banner.keepTheirsTooltip')}
>
Keep theirs
{translate(locale, 'editor.banner.keepTheirs')}
</button>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { CreateViewDialog } from './CreateViewDialog'
import type { ViewDefinition } from '../types'
@@ -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,49 +29,33 @@ 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', () => {
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')!)
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ icon: '🗂️' })
)
await waitFor(() => {
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ icon: '🗂️', color: 'blue' })
)
})
})
it('passes selected emoji icon when creating a view', () => {
it('passes selected emoji icon when creating a view', async () => {
const onCreate = vi.fn()
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} />)
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
@@ -72,21 +67,37 @@ describe('CreateViewDialog', () => {
fireEvent.click(emojiButtons[0])
// Submit the form
fireEvent.click(screen.getByText('Create'))
expect(onCreate).toHaveBeenCalledTimes(1)
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1))
const definition = onCreate.mock.calls[0][0] as ViewDefinition
expect(definition.icon).not.toBeNull()
expect(typeof definition.icon).toBe('string')
expect(definition.icon!.length).toBeGreaterThan(0)
})
it('passes null icon when no emoji is selected', () => {
it('passes null icon when no emoji is selected', async () => {
const onCreate = vi.fn()
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} />)
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
fireEvent.change(input, { target: { value: 'No Icon View' } })
fireEvent.submit(screen.getByText('Create').closest('form')!)
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ icon: null })
)
await waitFor(() => {
expect(onCreate).toHaveBeenCalledWith(
expect.objectContaining({ icon: null })
)
})
})
it('keeps the dialog open when async save reports failure', async () => {
const onClose = vi.fn()
const onCreate = vi.fn(async () => false)
render(<CreateViewDialog {...defaultProps} onClose={onClose} onCreate={onCreate} />)
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
fireEvent.change(input, { target: { value: 'Unsaveable View' } })
fireEvent.click(screen.getByText('Create'))
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1))
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByText('Create View')).toBeInTheDocument()
})
})

View File

@@ -6,10 +6,14 @@ import { FilterBuilder } from './FilterBuilder'
import { EmojiPicker } from './EmojiPicker'
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
onClose: () => void
onCreate: (definition: ViewDefinition) => void
onCreate: SaveViewHandler
availableFields: string[]
/** When provided, the dialog operates in edit mode with pre-populated fields. */
editingView?: ViewDefinition | null
@@ -19,16 +23,18 @@ interface CreateViewDialogFormProps {
availableFields: string[]
initialName: string
initialIcon: string
initialColor: string | null
initialFilters: FilterGroup
isEditing: boolean
onClose: () => void
onCreate: (definition: ViewDefinition) => void
onCreate: SaveViewHandler
}
function CreateViewDialogForm({
availableFields,
initialName,
initialIcon,
initialColor,
initialFilters,
isEditing,
onClose,
@@ -38,6 +44,8 @@ function CreateViewDialogForm({
const [icon, setIcon] = useState(initialIcon)
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [filters, setFilters] = useState<FilterGroup>(initialFilters)
const [saveError, setSaveError] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
@@ -45,19 +53,35 @@ function CreateViewDialogForm({
return () => window.clearTimeout(timeoutId)
}, [])
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (isSaving) return
const trimmed = name.trim()
if (!trimmed) return
const definition: ViewDefinition = {
name: trimmed,
icon: icon || null,
color: null,
color: initialColor,
sort: null,
filters,
}
onCreate(definition)
onClose()
setSaveError(null)
setIsSaving(true)
let shouldClose = false
try {
const result = await onCreate(definition)
if (result === false) {
setSaveError('Could not save view.')
} else {
shouldClose = true
}
} catch {
setSaveError('Could not save view.')
}
setIsSaving(false)
if (shouldClose) onClose()
}
const handleSelectEmoji = useCallback((emoji: string) => {
@@ -76,14 +100,15 @@ function CreateViewDialogForm({
<div className="flex gap-2">
<div className="w-16 space-y-1.5 relative">
<label className="text-xs font-medium text-muted-foreground">Icon</label>
<button
<Button
type="button"
className="flex h-9 w-full items-center justify-center rounded-md border border-input bg-background text-xl cursor-pointer hover:bg-accent transition-colors"
variant="outline"
className="flex h-9 w-full p-0 text-xl hover:bg-accent"
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
title="Pick icon"
>
{icon || <span className="text-sm text-muted-foreground">📋</span>}
</button>
</Button>
{showEmojiPicker && (
<EmojiPicker onSelect={handleSelectEmoji} onClose={handleCloseEmojiPicker} />
)}
@@ -94,10 +119,16 @@ function CreateViewDialogForm({
ref={inputRef}
placeholder="e.g. Active Projects, Reading List..."
value={name}
onChange={(e) => setName(e.target.value)}
onChange={(e) => {
setName(e.target.value)
if (saveError) setSaveError(null)
}}
/>
</div>
</div>
{saveError && (
<p role="alert" className="text-xs text-destructive">{saveError}</p>
)}
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto">
<label className="text-xs font-medium text-muted-foreground">Filters</label>
<FilterBuilder
@@ -107,16 +138,34 @@ function CreateViewDialogForm({
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={isCreateDisabled}>{isEditing ? 'Save' : 'Create'}</Button>
<Button type="button" variant="outline" onClick={onClose} disabled={isSaving}>Cancel</Button>
<Button type="submit" disabled={isCreateDisabled || isSaving}>{isEditing ? 'Save' : 'Create'}</Button>
</DialogFooter>
</form>
)
}
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 (
@@ -125,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}

View File

@@ -205,6 +205,19 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByText('some-team')).toBeInTheDocument()
})
it('localizes UI actions without translating stored property names', () => {
renderPanel({
frontmatter: { Status: 'Active', 'Belongs to': 'some-team' },
onAddProperty,
locale: 'zh-CN',
})
expect(screen.getByText('Type')).toBeInTheDocument()
expect(screen.getByText('Status')).toBeInTheDocument()
expect(screen.getByText('Belongs to')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '添加属性' })).toBeInTheDocument()
})
it('hides custom field with wikilink value from Properties', () => {
renderPanel({ frontmatter: { Mentor: '[[person/luca]]' } })
// Mentor contains a wikilink → shown in Relationships, not Properties

View File

@@ -21,6 +21,7 @@ import {
PROPERTY_PANEL_ROW_STYLE,
} from './propertyPanelLayout'
import { humanizePropertyKey } from '../utils/propertyLabels'
import { translate, type AppLocale } from '../lib/i18n'
import { canonicalSystemMetadataKey, hasSystemMetadataKey } from '../utils/systemMetadata'
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
@@ -32,7 +33,7 @@ export function containsWikilinks(value: FrontmatterValue): boolean {
const PROPERTY_ROW_CLASS_NAME = 'group/prop grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary'
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, locale, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
propKey: string; value: FrontmatterValue; editingKey: string | null
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
vaultStatuses: string[]; vaultTags: string[]
@@ -40,6 +41,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
onSaveList: (key: string, items: string[]) => void
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
locale: AppLocale
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.target !== e.currentTarget) {
@@ -57,7 +59,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
<span className="min-w-0 flex-1 truncate">{humanizePropertyKey(propKey)}</span>
{onDelete && (
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title="Delete property">&times;</button>
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title={translate(locale, 'inspector.properties.deleteProperty')}>&times;</button>
)}
</span>
<div className="min-w-0">
@@ -67,7 +69,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
)
}
function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) {
function AddPropertyButton({ locale, onClick, disabled }: { locale: AppLocale; onClick: () => void; disabled: boolean }) {
return (
<Button
type="button"
@@ -86,7 +88,7 @@ function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disable
>
<Plus className="size-3.5" aria-hidden="true" />
</span>
<span className="min-w-0 truncate">Add property</span>
<span className="min-w-0 truncate">{translate(locale, 'inspector.properties.addProperty')}</span>
</span>
<span aria-hidden="true" className={PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME} />
</Button>
@@ -230,6 +232,7 @@ function useSuggestedPropertyActions({
export function DynamicPropertiesPanel({
entry, frontmatter, entries,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate, onCreateMissingType,
locale = 'en',
}: {
entry: VaultEntry
content?: string | null
@@ -240,6 +243,7 @@ export function DynamicPropertiesPanel({
onAddProperty?: (key: string, value: FrontmatterValue) => void
onNavigate?: (target: string) => void
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
locale?: AppLocale
}) {
const {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
@@ -279,6 +283,7 @@ export function DynamicPropertiesPanel({
onNavigate={onNavigate}
missingTypeName={missingTypeName}
onCreateMissingType={onCreateMissingType}
locale={locale}
/>
{propertyEntries.map(([key, value]) => (
<PropertyRow
@@ -290,6 +295,7 @@ export function DynamicPropertiesPanel({
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
onDelete={onDeleteProperty}
onDisplayModeChange={handleDisplayModeChange}
locale={locale}
/>
))}
{pendingSuggestedKey && editingKey === pendingSuggestedKey && (
@@ -308,6 +314,7 @@ export function DynamicPropertiesPanel({
onUpdate={undefined}
onDelete={undefined}
onDisplayModeChange={handleDisplayModeChange}
locale={locale}
/>
)}
{missingSuggested.map(({ key, label }) => (
@@ -320,6 +327,7 @@ export function DynamicPropertiesPanel({
))}
{!showAddDialog && (
<AddPropertyButton
locale={locale}
onClick={() => setShowAddDialog(true)}
disabled={!onAddProperty}
/>
@@ -330,6 +338,7 @@ export function DynamicPropertiesPanel({
onAdd={handleAdd}
onCancel={() => setShowAddDialog(false)}
vaultStatuses={vaultStatuses}
locale={locale}
/>
)}
</div>

View File

@@ -210,14 +210,8 @@
}
.raw-editor-codemirror {
max-width: var(--editor-max-width, 760px);
width: 100%;
margin: 0 auto;
}
.editor-content-layout--left .raw-editor-codemirror {
margin-left: clamp(16px, 6%, 96px);
margin-right: auto;
margin: 0;
}
/* --- Note Icon Area --- */
@@ -340,8 +334,7 @@
}
@container editor (max-width: 900px) {
.editor-content-layout--left .editor-content-wrapper,
.editor-content-layout--left .raw-editor-codemirror {
.editor-content-layout--left .editor-content-wrapper {
margin-left: auto;
margin-right: auto;
}

View File

@@ -18,6 +18,11 @@ Object.defineProperty(window, 'matchMedia', {
})),
})
vi.mock('@tauri-apps/api/core', () => ({
convertFileSrc: vi.fn((path: string) => `asset://localhost/${encodeURIComponent(path)}`),
invoke: vi.fn(),
}))
// Hoisted mock editor — available before vi.mock factory runs.
// Tests can reconfigure spies (e.g. mockTryParse.mockResolvedValue) before rendering.
const mockEditor = vi.hoisted(() => ({
@@ -255,6 +260,120 @@ describe('Editor', () => {
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
})
it('renders an in-app image preview for binary image tabs', () => {
const imageEntry: VaultEntry = {
...mockEntry,
path: '/vault/assets/photo.png',
filename: 'photo.png',
title: 'photo.png',
fileKind: 'binary',
}
renderEditor({
tabs: [{ entry: imageEntry, content: '' }],
activeTabPath: imageEntry.path,
entries: [imageEntry],
})
const preview = screen.getByTestId('file-preview')
expect(preview).toHaveAttribute('tabindex', '0')
expect(screen.getByRole('img', { name: 'photo.png' })).toHaveAttribute(
'src',
'asset://localhost/%2Fvault%2Fassets%2Fphoto.png',
)
expect(screen.queryByTestId('blocknote-view')).not.toBeInTheDocument()
})
it('renders an in-app PDF preview for binary PDF tabs', () => {
const pdfEntry: VaultEntry = {
...mockEntry,
path: '/vault/assets/report.pdf',
filename: 'report.pdf',
title: 'report.pdf',
fileKind: 'binary',
}
renderEditor({
tabs: [{ entry: pdfEntry, content: '' }],
activeTabPath: pdfEntry.path,
entries: [pdfEntry],
})
expect(screen.getByTestId('pdf-file-preview')).toHaveAttribute(
'data',
'asset://localhost/%2Fvault%2Fassets%2Freport.pdf',
)
expect(screen.queryByTestId('blocknote-view')).not.toBeInTheDocument()
})
it('shows a graceful fallback when an image preview fails to render', () => {
const imageEntry: VaultEntry = {
...mockEntry,
path: '/vault/assets/broken.png',
filename: 'broken.png',
title: 'broken.png',
fileKind: 'binary',
}
renderEditor({
tabs: [{ entry: imageEntry, content: '' }],
activeTabPath: imageEntry.path,
entries: [imageEntry],
})
fireEvent.error(screen.getByRole('img', { name: 'broken.png' }))
expect(screen.getByTestId('file-preview-fallback')).toHaveTextContent('Image preview failed')
expect(screen.getByRole('button', { name: 'Open in default app' })).toBeInTheDocument()
})
it('shows an explicit unsupported-file fallback for non-image binary tabs', () => {
const binaryEntry: VaultEntry = {
...mockEntry,
path: '/vault/assets/archive.zip',
filename: 'archive.zip',
title: 'archive.zip',
fileKind: 'binary',
}
renderEditor({
tabs: [{ entry: binaryEntry, content: '' }],
activeTabPath: binaryEntry.path,
entries: [binaryEntry],
})
expect(screen.getByTestId('file-preview-fallback')).toHaveTextContent('Preview unavailable')
expect(screen.getByText('ZIP file')).toBeInTheDocument()
})
it('moves focus back to the note list when Escape is pressed on the file preview', () => {
const imageEntry: VaultEntry = {
...mockEntry,
path: '/vault/assets/photo.png',
filename: 'photo.png',
title: 'photo.png',
fileKind: 'binary',
}
render(
<>
<div data-testid="note-list-container" tabIndex={0} />
<Editor
{...defaultProps}
tabs={[{ entry: imageEntry, content: '' }]}
activeTabPath={imageEntry.path}
entries={[imageEntry]}
/>
</>,
)
const preview = screen.getByTestId('file-preview')
preview.focus()
fireEvent.keyDown(preview, { key: 'Escape' })
expect(screen.getByTestId('note-list-container')).toHaveFocus()
})
it('passes the runtime CSP style nonce into BlockNote and TipTap', () => {
renderEditor({
tabs: [mockTab],

View File

@@ -1,10 +1,11 @@
import { useRef, useEffect, useCallback, memo } from 'react'
import { useRef, useEffect, useCallback, memo, useState } from 'react'
import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
import { useCreateBlockNote } from '@blocknote/react'
import '@blocknote/mantine/style.css'
import 'katex/dist/katex.min.css'
import { uploadImageFile } from '../hooks/useImageDrop'
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents'
import { translate, type AppLocale } from '../lib/i18n'
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
import type { VaultEntry, GitCommit, NoteLayout, NoteStatus } from '../types'
import type { NoteListItem } from '../utils/ai-context'
@@ -16,7 +17,9 @@ import { useDragRegion } from '../hooks/useDragRegion'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
import { EditorRightPanel } from './EditorRightPanel'
import { EditorContent } from './EditorContent'
import { FilePreview } from './FilePreview'
import { schema } from './editorSchema'
import type { RawEditorFindRequest } from './RawEditorFindBar'
import {
applyPendingRawExitContent,
resolvePendingRawExitContent,
@@ -24,6 +27,7 @@ import {
} from './editorRawModeSync'
import { useRawModeWithFlush } from './useRawModeWithFlush'
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
import { createMathInputExtension } from './mathInputExtension'
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
import './Editor.css'
import './EditorTheme.css'
@@ -49,6 +53,7 @@ interface EditorProps {
onToggleInspector: () => void
inspectorWidth: number
defaultAiAgent?: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady?: boolean
onInspectorResize: (delta: number) => void
inspectorEntry: VaultEntry | null
@@ -67,6 +72,9 @@ interface EditorProps {
noteListFilter?: { type: string | null; query: string }
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
onOpenExternalFile?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
@@ -83,6 +91,8 @@ interface EditorProps {
leftPanelsCollapsed?: boolean
/** Mutable ref that Editor registers its raw-mode toggle into, for command palette access. */
rawToggleRef?: React.MutableRefObject<() => void>
/** Mutable ref that Editor registers editor find commands into, for shortcuts and menus. */
findInNoteRef?: React.MutableRefObject<((options?: { replace?: boolean }) => void) | null>
/** Mutable ref that Editor registers its diff-mode toggle into, for command palette access. */
diffToggleRef?: React.MutableRefObject<() => void>
onFileCreated?: (relativePath: string) => void
@@ -96,6 +106,7 @@ interface EditorProps {
onKeepTheirs?: (path: string) => void
/** Registers a hook that flushes the raw editor buffer into app state before external actions. */
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
locale?: AppLocale
}
function useEditorModeExclusion({
@@ -129,7 +140,7 @@ function useEditorModeExclusion({
return { handleToggleDiffExclusive, handleToggleRawExclusive }
}
function EditorEmptyState() {
function EditorEmptyState({ locale = 'en' }: { locale?: AppLocale }) {
const breadcrumbBarHeight = 52
const { onMouseDown } = useDragRegion()
const quickOpenShortcut = formatShortcutDisplay({ display: '⌘P / ⌘O' })
@@ -146,8 +157,8 @@ function EditorEmptyState() {
style={{ height: breadcrumbBarHeight }}
/>
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<p className="m-0 text-[15px]">Select a note to start editing</p>
<span className="text-xs text-muted-foreground">{quickOpenShortcut} to search &middot; {newNoteShortcut} to create</span>
<p className="m-0 text-[15px]">{translate(locale, 'editor.empty.selectNote')}</p>
<span className="text-xs text-muted-foreground">{translate(locale, 'editor.empty.shortcuts', { quickOpen: quickOpenShortcut, newNote: newNoteShortcut })}</span>
</div>
</div>
)
@@ -179,7 +190,7 @@ function useEditorSetup({
schema,
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
_tiptapOptions: { injectNonce: RUNTIME_STYLE_NONCE },
extensions: [createArrowLigaturesExtension()],
extensions: [createArrowLigaturesExtension(), createMathInputExtension()],
})
useFilenameAutolinkGuard(editor)
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
@@ -272,6 +283,43 @@ function useRegisterRawContentFlush({
}, [flushPendingRawContent, flushPendingRawContentRef])
}
function useEditorFindCommand({
activeTab,
findInNoteRef,
handleToggleRawExclusive,
rawMode,
}: {
activeTab: Tab | null
findInNoteRef?: EditorProps['findInNoteRef']
handleToggleRawExclusive: () => void
rawMode: boolean
}): RawEditorFindRequest | null {
const [findRequest, setFindRequest] = useState<RawEditorFindRequest | null>(null)
const handleFindInNote = useCallback((options: { replace?: boolean } = {}) => {
if (!activeTab || activeTab.entry.fileKind === 'binary') return
if (!rawMode) handleToggleRawExclusive()
setFindRequest((current) => ({
id: (current?.id ?? 0) + 1,
path: activeTab.entry.path,
replace: options.replace === true,
}))
}, [activeTab, handleToggleRawExclusive, rawMode])
useEffect(() => {
if (!findInNoteRef) return
findInNoteRef.current = handleFindInNote
return () => {
if (findInNoteRef.current === handleFindInNote) {
findInNoteRef.current = null
}
}
}, [findInNoteRef, handleFindInNote])
return findRequest
}
function EditorLayout({
tabs,
activeTab,
@@ -296,11 +344,15 @@ function EditorLayout({
handleEditorChange,
onToggleFavorite,
onToggleOrganized,
onRevealFile,
onCopyFilePath,
onOpenExternalFile,
onDeleteNote,
onArchiveNote,
onUnarchiveNote,
vaultPath,
rawModeContent,
findRequest,
rawLatestContentRef,
onRenameFilename,
noteLayout,
@@ -311,6 +363,7 @@ function EditorLayout({
onInspectorResize,
inspectorWidth,
defaultAiAgent,
defaultAiAgentReadiness,
defaultAiAgentReady,
inspectorEntry,
inspectorContent,
@@ -328,6 +381,7 @@ function EditorLayout({
onFileModified,
onVaultChanged,
onUnsupportedAiPaste,
locale,
}: {
tabs: Tab[]
activeTab: Tab | null
@@ -352,11 +406,15 @@ function EditorLayout({
handleEditorChange: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
onOpenExternalFile?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
vaultPath?: string
rawModeContent: string | null
findRequest?: RawEditorFindRequest | null
rawLatestContentRef: React.MutableRefObject<string | null>
onRenameFilename?: (path: string, newFilenameStem: string) => void
noteLayout?: NoteLayout
@@ -367,6 +425,7 @@ function EditorLayout({
onInspectorResize: (delta: number) => void
inspectorWidth: number
defaultAiAgent: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady: boolean
inspectorEntry: VaultEntry | null
inspectorContent: string | null
@@ -384,13 +443,25 @@ function EditorLayout({
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
onUnsupportedAiPaste?: (message: string) => void
locale?: AppLocale
}) {
const activeBinaryTab = activeTab?.entry.fileKind === 'binary' ? activeTab : null
return (
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
<div className="flex flex-1 min-h-0">
{tabs.length === 0
? <EditorEmptyState />
: <EditorContent
? <EditorEmptyState locale={locale} />
: activeBinaryTab
? (
<FilePreview
entry={activeBinaryTab.entry}
onCopyFilePath={onCopyFilePath}
onOpenExternalFile={onOpenExternalFile}
onRevealFile={onRevealFile}
/>
)
: <EditorContent
activeTab={activeTab}
isLoadingNewTab={isLoadingNewTab}
entries={entries}
@@ -413,11 +484,14 @@ function EditorLayout({
onEditorChange={handleEditorChange}
onToggleFavorite={onToggleFavorite}
onToggleOrganized={onToggleOrganized}
onRevealFile={onRevealFile}
onCopyFilePath={onCopyFilePath}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
rawModeContent={rawModeContent}
findRequest={findRequest}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
noteLayout={noteLayout}
@@ -425,6 +499,7 @@ function EditorLayout({
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}
locale={locale}
/>
}
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
@@ -433,6 +508,7 @@ function EditorLayout({
inspectorCollapsed={inspectorCollapsed}
inspectorWidth={inspectorWidth}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReadiness={defaultAiAgentReadiness}
defaultAiAgentReady={defaultAiAgentReady}
onUnsupportedAiPaste={onUnsupportedAiPaste}
inspectorEntry={inspectorEntry}
@@ -457,6 +533,7 @@ function EditorLayout({
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
locale={locale}
/>
</div>
</div>
@@ -468,19 +545,21 @@ export const Editor = memo(function Editor(props: EditorProps) {
tabs, activeTabPath, entries, onNavigateWikilink,
getNoteStatus,
inspectorCollapsed, onToggleInspector, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReadiness, defaultAiAgentReady = true,
onUnsupportedAiPaste,
onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onRenameFilename,
noteLayout, onToggleNoteLayout,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
flushPendingRawContentRef,
flushPendingRawContentRef, findInNoteRef,
locale,
} = props
const {
@@ -498,6 +577,13 @@ export const Editor = memo(function Editor(props: EditorProps) {
getNoteStatus,
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
})
const findRequest = useEditorFindCommand({
activeTab,
findInNoteRef,
handleToggleRawExclusive,
rawMode,
})
useRegisterRawContentFlush({
activeTab,
rawLatestContentRef,
@@ -531,11 +617,15 @@ export const Editor = memo(function Editor(props: EditorProps) {
handleEditorChange={handleEditorChange}
onToggleFavorite={onToggleFavorite}
onToggleOrganized={onToggleOrganized}
onRevealFile={onRevealFile}
onCopyFilePath={onCopyFilePath}
onOpenExternalFile={onOpenExternalFile}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
rawModeContent={rawModeContent}
findRequest={findRequest}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
noteLayout={noteLayout}
@@ -546,6 +636,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
onInspectorResize={onInspectorResize}
inspectorWidth={inspectorWidth}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReadiness={defaultAiAgentReadiness}
defaultAiAgentReady={defaultAiAgentReady}
onUnsupportedAiPaste={onUnsupportedAiPaste}
inspectorEntry={inspectorEntry}
@@ -563,6 +654,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
locale={locale}
/>
)
})

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents'
import type { AppLocale } from '../lib/i18n'
import type { VaultEntry, GitCommit } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import { Inspector, type FrontmatterValue } from './Inspector'
@@ -12,6 +13,7 @@ interface EditorRightPanelProps {
inspectorCollapsed: boolean
inspectorWidth: number
defaultAiAgent?: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady?: boolean
onUnsupportedAiPaste?: (message: string) => void
inspectorEntry: VaultEntry | null
@@ -36,27 +38,31 @@ interface EditorRightPanelProps {
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
locale?: AppLocale
}
export function EditorRightPanel({
showAIChat, inspectorCollapsed, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReadiness, defaultAiAgentReady = true,
onUnsupportedAiPaste,
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
onFileCreated, onFileModified, onVaultChanged,
locale,
}: EditorRightPanelProps) {
const aiPanelController = useAiPanelController({
vaultPath,
defaultAiAgent,
defaultAiAgentReady,
defaultAiAgentReadiness,
activeEntry: inspectorEntry,
activeNoteContent: inspectorContent,
entries,
noteList,
noteListFilter,
locale,
onOpenNote,
onFileCreated,
onFileModified,
@@ -85,7 +91,9 @@ export function EditorRightPanel({
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReadiness={defaultAiAgentReadiness}
defaultAiAgentReady={defaultAiAgentReady}
locale={locale}
activeEntry={inspectorEntry}
entries={entries}
/>
@@ -117,6 +125,7 @@ export function EditorRightPanel({
onCreateAndOpenNote={onCreateAndOpenNote}
onInitializeProperties={onInitializeProperties}
onToggleRawEditor={onToggleRawEditor}
locale={locale}
/>
</div>
)

View File

@@ -29,6 +29,10 @@
gap: 0 !important;
}
.editor__blocknote-container .bn-side-menu [draggable="true"] * {
pointer-events: none;
}
/* More horizontal space between drag handle and content */
.editor__blocknote-container .bn-side-menu ~ * ,
.editor__blocknote-container [class*="bn-side-menu"] {
@@ -268,6 +272,110 @@
white-space: pre-wrap;
}
.editor__blocknote-container .mermaid-diagram {
position: relative;
width: 100%;
margin: 10px 0;
color: var(--colors-text);
}
.editor__blocknote-container .mermaid-diagram__viewport {
max-width: 100%;
overflow: auto;
padding: 14px;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--surface-editor);
}
.editor__blocknote-container .mermaid-diagram__expand-button {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
opacity: 0;
background: var(--surface-editor);
box-shadow: 0 6px 16px rgb(15 23 42 / 0.14);
}
.editor__blocknote-container .mermaid-diagram:is(:hover, :focus-within) .mermaid-diagram__expand-button {
opacity: 1;
}
.editor__blocknote-container .mermaid-diagram__viewport:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.editor__blocknote-container .mermaid-diagram__viewport svg {
display: block;
max-width: none;
min-width: min-content;
height: auto;
margin: 0 auto;
}
@media (hover: none) {
.editor__blocknote-container .mermaid-diagram__expand-button {
opacity: 1;
}
}
.mermaid-diagram__dialog {
width: calc(100vw - 32px);
max-width: calc(100vw - 32px);
height: calc(100dvh - 32px);
max-height: calc(100dvh - 32px);
overflow: hidden;
padding: 0;
}
.mermaid-diagram__dialog-viewport {
width: 100%;
height: 100%;
overflow: auto;
padding: 24px;
background: #fff;
}
.mermaid-diagram__dialog-viewport:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: -2px;
}
.mermaid-diagram__dialog-viewport svg {
display: block;
max-width: none;
min-width: min-content;
height: auto;
margin: auto;
}
.editor__blocknote-container .mermaid-diagram--error {
padding: 12px;
border: 1px solid var(--destructive);
border-radius: 8px;
background: color-mix(in srgb, var(--destructive) 8%, transparent);
}
.editor__blocknote-container .mermaid-diagram--error figcaption {
margin-bottom: 8px;
color: var(--destructive);
font-size: 0.85rem;
font-weight: 600;
}
.editor__blocknote-container .mermaid-diagram--error pre {
max-width: 100%;
overflow: auto;
margin: 0;
padding: 10px;
border-radius: 6px;
background: var(--inline-styles-code-background-color);
color: var(--inline-styles-code-color);
font-size: 0.85rem;
}
.editor__blocknote-container[data-follow-links] .bn-editor a,
.editor__blocknote-container[data-follow-links] .wikilink {
cursor: pointer;

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