Compare commits
99 Commits
site
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8754b1bc77 | ||
|
|
ec5751711f | ||
|
|
0995c90cc0 | ||
|
|
dede400296 | ||
|
|
65ff44eb50 | ||
|
|
2c06c8dcc2 | ||
|
|
bc56eec35e | ||
|
|
388d389ba4 | ||
|
|
a1a4cb50d0 | ||
|
|
a7ed9adaae | ||
|
|
f8f08f4160 | ||
|
|
c0afae2296 | ||
|
|
176f983dc9 | ||
|
|
01a87ed478 | ||
|
|
18b4f9d078 | ||
|
|
fd3afc2493 | ||
|
|
0ad207e318 | ||
|
|
2e40945c2d | ||
|
|
54d53b882f | ||
|
|
848d718373 | ||
|
|
ec83e9b839 | ||
|
|
ce859aa0e7 | ||
|
|
1f4889fe66 | ||
|
|
2a2ea1b8ef | ||
|
|
b2a50d9870 | ||
|
|
cca72a9d6a | ||
|
|
ef0e85b053 | ||
|
|
2624d32b50 | ||
|
|
9c42da5745 | ||
|
|
454acb5ded | ||
|
|
2a691f68db | ||
|
|
c4de154410 | ||
|
|
670aae9f2e | ||
|
|
f3b4e5e61d | ||
|
|
4c4f01af43 | ||
|
|
79d7eb9795 | ||
|
|
d1ed826714 | ||
|
|
2e8286d2b3 | ||
|
|
cb9ebaadad | ||
|
|
c295c9f2b5 | ||
|
|
a4b11089c0 | ||
|
|
6f29542528 | ||
|
|
dcd0d73848 | ||
|
|
0e8c8fb61a | ||
|
|
42bdd559ef | ||
|
|
3a6c97272d | ||
|
|
f53a3746d3 | ||
|
|
23145a05f5 | ||
|
|
5b86b0b50d | ||
|
|
dc1b1783b6 | ||
|
|
3dc520abea | ||
|
|
792b3009dd | ||
|
|
6ee7f219f5 | ||
|
|
3db1ed9dc5 | ||
|
|
7a7ef5d9ca | ||
|
|
4c42cf4d52 | ||
|
|
405b711e16 | ||
|
|
83705e26f5 | ||
|
|
434927160c | ||
|
|
ec2e04a464 | ||
|
|
a17ec689a3 | ||
|
|
eda4d380e0 | ||
|
|
f9b155ba6e | ||
|
|
4a62008b1f | ||
|
|
a08cee7965 | ||
|
|
a7c480af62 | ||
|
|
f4790d5763 | ||
|
|
ed52a9e800 | ||
|
|
2fcd808f09 | ||
|
|
9d15286f7d | ||
|
|
537bd0f3ec | ||
|
|
648241604f | ||
|
|
c6f3d9d945 | ||
|
|
65e54f108a | ||
|
|
58f8a774b5 | ||
|
|
0bf280d3ec | ||
|
|
d2d4746d5c | ||
|
|
91db7c0098 | ||
|
|
87908d982f | ||
|
|
40544fc551 | ||
|
|
f34fdb0289 | ||
|
|
e601a42f41 | ||
|
|
de627c2478 | ||
|
|
46d9aaed48 | ||
|
|
6b96b5caf6 | ||
|
|
595c48ca59 | ||
|
|
fd92ff5e9b | ||
|
|
a39b346a75 | ||
|
|
deac10caa2 | ||
|
|
0e1e0ff47a | ||
|
|
a645edbc19 | ||
|
|
48f1fb0b50 | ||
|
|
1aede09834 | ||
|
|
d51b76f992 | ||
|
|
8e66fb4c19 | ||
|
|
a2843a0cf3 | ||
|
|
38fb8a7370 | ||
|
|
be5a8bb300 | ||
|
|
ce040c75fd |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=10.0
|
||||
AVERAGE_THRESHOLD=9.89
|
||||
AVERAGE_THRESHOLD=9.91
|
||||
|
||||
11
.github/workflows/release-stable.yml
vendored
11
.github/workflows/release-stable.yml
vendored
@@ -274,7 +274,8 @@ jobs:
|
||||
wget \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file
|
||||
file \
|
||||
rpm
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -326,7 +327,7 @@ jobs:
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
|
||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
|
||||
|
||||
- name: Validate Linux bundles
|
||||
run: |
|
||||
@@ -334,6 +335,7 @@ jobs:
|
||||
installers=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
@@ -341,7 +343,7 @@ jobs:
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
)
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no AppImage or deb bundle."
|
||||
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
@@ -356,6 +358,7 @@ jobs:
|
||||
path: |
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
||||
@@ -664,12 +667,14 @@ jobs:
|
||||
updater-x86_64/*.app.tar.gz.sig
|
||||
linux-x86_64-bundles/*.deb
|
||||
linux-x86_64-bundles/*.deb.sig
|
||||
linux-x86_64-bundles/*.rpm
|
||||
linux-x86_64-bundles/*.AppImage
|
||||
linux-x86_64-bundles/*.AppImage.sig
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz.sig
|
||||
linux-x86_64-bundles/*/*.deb
|
||||
linux-x86_64-bundles/*/*.deb.sig
|
||||
linux-x86_64-bundles/*/*.rpm
|
||||
linux-x86_64-bundles/*/*.AppImage
|
||||
linux-x86_64-bundles/*/*.AppImage.sig
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz
|
||||
|
||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
@@ -328,7 +328,8 @@ jobs:
|
||||
wget \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file
|
||||
file \
|
||||
rpm
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -380,7 +381,7 @@ jobs:
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
|
||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
|
||||
|
||||
- name: Validate Linux bundles
|
||||
run: |
|
||||
@@ -388,6 +389,7 @@ jobs:
|
||||
installers=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
@@ -395,7 +397,7 @@ jobs:
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
)
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no AppImage or deb bundle."
|
||||
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
@@ -410,6 +412,7 @@ jobs:
|
||||
path: |
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
||||
@@ -715,12 +718,14 @@ jobs:
|
||||
updater-x86_64/*.app.tar.gz.sig
|
||||
linux-x86_64-bundles/*.deb
|
||||
linux-x86_64-bundles/*.deb.sig
|
||||
linux-x86_64-bundles/*.rpm
|
||||
linux-x86_64-bundles/*.AppImage
|
||||
linux-x86_64-bundles/*.AppImage.sig
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz.sig
|
||||
linux-x86_64-bundles/*/*.deb
|
||||
linux-x86_64-bundles/*/*.deb.sig
|
||||
linux-x86_64-bundles/*/*.rpm
|
||||
linux-x86_64-bundles/*/*.AppImage
|
||||
linux-x86_64-bundles/*/*.AppImage.sig
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz
|
||||
|
||||
31
AGENTS.md
31
AGENTS.md
@@ -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 20–30 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 (1–2 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"
|
||||
```
|
||||
|
||||
20
README.md
20
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
# 💧 Tolaria
|
||||
|
||||
Tolaria is a desktop app for Mac and Linux for managing **markdown knowledge bases**. People use it for a variety of use cases:
|
||||
Tolaria is a desktop app for macOS, Windows, and Linux for managing **markdown knowledge bases**. People use it for a variety of use cases:
|
||||
|
||||
* Operate second brains and personal knowledge
|
||||
* Organize company docs as context for AI
|
||||
@@ -27,13 +27,25 @@ 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
|
||||
## Installation
|
||||
|
||||
Download the [latest release here](https://github.com/refactoringhq/tolaria/releases/latest/download/Tolaria.app.tar.gz).
|
||||
### Homebrew
|
||||
|
||||
Install via Homebrew on macOS:
|
||||
|
||||
```batch
|
||||
brew install --cask tolaria
|
||||
```
|
||||
|
||||
### Download from releases
|
||||
|
||||
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/) for macOS, Windows, or Linux.
|
||||
|
||||
## Getting started
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ _icon: shapes # icon assigned to a type
|
||||
_color: blue # color assigned to a type
|
||||
_order: 10 # sort order in the sidebar
|
||||
_sidebar_label: Projects # override label in sidebar
|
||||
_width: wide # rich-editor width override for this note
|
||||
```
|
||||
|
||||
**This convention is universal** — apply it to all future system-level frontmatter fields. When a new feature needs to store configuration in a note's frontmatter (especially in Type notes), use `_field_name` to keep it hidden from normal user-facing surfaces while still stored on-disk as plain text.
|
||||
@@ -87,6 +88,7 @@ classDiagram
|
||||
+Record~string,string[]~ relationships
|
||||
+String[] outgoingLinks
|
||||
+String? status
|
||||
+String? noteWidth
|
||||
+Number? modifiedAt
|
||||
+Number? createdAt
|
||||
+Number wordCount
|
||||
@@ -135,6 +137,7 @@ interface VaultEntry {
|
||||
relationships: Record<string, string[]> // All frontmatter fields containing wikilinks
|
||||
outgoingLinks: string[] // All [[wikilinks]] found in note body
|
||||
status: string | null // Active, Done, Paused, Archived, Dropped
|
||||
noteWidth?: 'normal' | 'wide' | null // Rich-editor width mode from `_width`
|
||||
modifiedAt: number | null // Unix timestamp (seconds)
|
||||
// Note: owner and cadence are now in the generic `properties` map
|
||||
createdAt: number | null // Unix timestamp (seconds)
|
||||
@@ -157,9 +160,15 @@ interface VaultEntry {
|
||||
|---|---|---|
|
||||
| `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 open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
|
||||
| `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 |
|
||||
|
||||
Image previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
|
||||
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. Runtime asset access is accumulated only for vault roots Tolaria has loaded in the current app session, because Tauri directory forbids cannot be safely reversed after a vault switch. The "open in default app" action re-enters the active-vault command boundary through `open_vault_file_external` before delegating to the native opener. 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)
|
||||
|
||||
@@ -175,17 +184,19 @@ 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
|
||||
├── project.md ← type: Type (definition document)
|
||||
├── person.md ← type: Type (definition document)
|
||||
├── ...
|
||||
└── type/ ← type definition documents
|
||||
```
|
||||
|
||||
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. Moving a note into a user folder is a separate filesystem concern: the folder path changes, but the note keeps the same filename and `type:` value. The `type/` folder exists solely for type definition documents. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
|
||||
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. Moving a note into a user folder is a separate filesystem concern: the folder path changes, but the note keeps the same filename and `type:` value. Legacy `type/` and `types/` folders are still scanned like other non-hidden vault folders, so existing type documents in those folders continue to work, but new type documents created by Tolaria are written at the vault root. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
|
||||
|
||||
A `flatten_vault` migration command is available to move existing notes from type-based subfolders to the vault root.
|
||||
|
||||
### Types as Files
|
||||
|
||||
Each entity type can have a corresponding **type document** in the `type/` folder (e.g., `type/project.md`, `type/person.md`). Type documents:
|
||||
Each entity type can have a corresponding **type document**: any markdown note with `type: Type` in its frontmatter. Tolaria creates new type documents at the vault root (e.g., `project.md`, `person.md`) and still reads existing type documents from subfolders. Type documents:
|
||||
|
||||
- Have `type: Type` in their frontmatter (`Is A: Type` also accepted as legacy alias)
|
||||
- Define type metadata: icon, color, order, sidebar label, template, sort, view, visibility
|
||||
@@ -205,7 +216,7 @@ Each entity type can have a corresponding **type document** in the `type/` folde
|
||||
| `view` | string | Default view mode: "all", "editor-list", "editor-only" |
|
||||
| `visible` | bool | Whether type appears in sidebar (default: true) |
|
||||
|
||||
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[type/project]]`. This makes the type navigable from the Inspector panel.
|
||||
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[project]]`. This makes the type navigable from the Inspector panel while keeping location as an implementation detail.
|
||||
|
||||
**UI behavior**:
|
||||
- Clicking a section group header pins the type document at the top of the NoteList if it exists
|
||||
@@ -290,19 +301,25 @@ type SidebarFilter = 'all' | 'archived' | 'changes' | 'pulse'
|
||||
type SidebarSelection =
|
||||
| { kind: 'filter'; filter: SidebarFilter }
|
||||
| { kind: 'sectionGroup'; type: string } // e.g. type: 'Project'
|
||||
| { kind: 'folder'; path: string }
|
||||
| { kind: 'folder'; path: string; rootPath?: string }
|
||||
| { kind: 'entity'; entry: VaultEntry } // Neighborhood source note
|
||||
| { kind: 'view'; filename: string }
|
||||
```
|
||||
|
||||
`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. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
|
||||
- `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. The UI wraps backend folder nodes in a synthetic vault-root row with `path: ""` and `rootPath` set to the opened vault so root-level files can be listed without turning the vault root into a mutable folder. 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.
|
||||
@@ -335,17 +352,19 @@ type SidebarSelection =
|
||||
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.
|
||||
All Notes starts from Markdown notes and excludes Markdown files under `attachments/`. `src/utils/allNotesFileVisibility.ts` resolves the installation-local PDF, image, and unsupported-file toggles from app settings; `noteListHelpers` applies that policy only to All Notes filtering and counts. Folder/root browsing continues to show files from the selected folder independently of those All Notes toggles.
|
||||
|
||||
The folder tree hides the legacy `type/` directory, since those type documents already appear through the Types sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders under the synthetic vault-root row.
|
||||
|
||||
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.
|
||||
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 add the current vault root to 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 and external-open calls route through the Tauri opener plugin, while copy-path uses the browser clipboard API; none of those actions mutate vault contents or bypass the backend write boundary.
|
||||
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. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`.
|
||||
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
|
||||
|
||||
### Vault Caching
|
||||
|
||||
@@ -534,6 +553,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
|
||||
- `SingleEditorView` disables BlockNote's default formatting toolbar, `/` menu, and side menu, then mounts Tolaria-owned controllers so the visible formatting surface matches Tolaria's markdown round-trip guarantees.
|
||||
- The formatting toolbar only exposes inline controls that persist through `blocksToMarkdownLossy()` in Tolaria's save pipeline: bold, italic, strike, nesting, and link creation. Controls that BlockNote can render temporarily but Tolaria cannot faithfully persist, such as underline, color, alignment, and the block-type dropdown, are hidden instead of appearing to work and later disappearing.
|
||||
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
|
||||
- `useImageLightbox` listens for `dblclick` on the rich-editor container and opens `ImageLightbox` only when the event target resolves to a viewable BlockNote image. The target resolver handles media wrappers, ignores image captions/resize controls, missing sources, and tiny tracking-style images, preserving BlockNote's ordinary single-click image selection path.
|
||||
- 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.
|
||||
@@ -571,6 +591,8 @@ flowchart LR
|
||||
style E fill:#d4edda,stroke:#28a745,color:#000
|
||||
```
|
||||
|
||||
Rich-editor change events are coalesced before this serialization runs. `useEditorTabSwap` keeps the latest BlockNote state in the editor, schedules one Markdown serialization for a short idle window, and exposes an explicit flush hook for save, note switch, raw-mode entry, and destructive note actions. This keeps long notes from paying full-document Markdown serialization on every keystroke while preserving the disk-first save path.
|
||||
|
||||
### Wikilink Navigation
|
||||
|
||||
Two navigation mechanisms:
|
||||
@@ -587,6 +609,12 @@ While the user types, `useEditorSaveWithLinks` derives a transient `VaultEntry`
|
||||
|
||||
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.
|
||||
|
||||
### Rich Editor Width Modes
|
||||
|
||||
Rich Markdown editing supports `normal` and `wide` note widths. The effective mode is resolved in `App.tsx` from, in order, the current session's transient note-width cache, `VaultEntry.noteWidth` parsed from `_width`, and the installation-local `settings.note_width_mode` default. The breadcrumb toggle calls the same setter exposed through the command palette.
|
||||
|
||||
Per-note width is persisted as hidden `_width` frontmatter only when the note already has a valid or empty frontmatter block. Notes without frontmatter use the transient cache for the current session, so toggling width never creates frontmatter solely to store UI state. The width class is applied around `SingleEditorView` only; raw CodeMirror mode stays outside `.editor-content-wrapper` and remains full-width.
|
||||
|
||||
### Arrow Ligature Normalization
|
||||
|
||||
Typed ASCII arrow sequences are normalized consistently in both editor modes:
|
||||
@@ -602,6 +630,7 @@ The app uses internal light and dark themes owned by Tolaria (see [ADR-0081](adr
|
||||
1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states via `:root` / `[data-theme]`, bridged to Tailwind v4
|
||||
2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme`
|
||||
3. **Runtime theme bridge**: Applies `data-theme` and `.dark` for shadcn/ui, while CodeMirror and editor-specific consumers derive any non-CSS-variable values from the same semantic contract
|
||||
4. **Theme mode commands**: Command-palette actions for light and dark mode call the same `saveSettings` path as the Settings panel and persist only `settings.theme_mode`
|
||||
|
||||
## Localization
|
||||
|
||||
@@ -669,7 +698,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, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, AI agent permission mode (`safe` / `power_user`)
|
||||
- Missing, null, and unknown AI agent permission modes normalize to `safe`; the AI panel can switch modes per vault, preserving the transcript and applying the new mode only to the next agent run
|
||||
- One-time migration from localStorage (`configMigration.ts`)
|
||||
|
||||
### AI Guidance Files
|
||||
@@ -677,8 +707,9 @@ Per-vault settings stored locally and scoped by vault path:
|
||||
Tolaria tracks managed vault-level AI guidance separately from normal note content:
|
||||
- `AGENTS.md` is the canonical managed guidance file for Tolaria-aware coding agents
|
||||
- `CLAUDE.md` is a compatibility shim that points Claude Code back to `AGENTS.md`
|
||||
- `GEMINI.md` is an optional Gemini CLI compatibility shim that points Gemini back to `AGENTS.md`
|
||||
- `useVaultAiGuidanceStatus` reads `get_vault_ai_guidance_status` and normalizes the backend state into four UI cases: `managed`, `missing`, `broken`, and `custom`
|
||||
- `restore_vault_ai_guidance` repairs only Tolaria-managed files; user-authored custom `AGENTS.md` / `CLAUDE.md` files are surfaced as custom and left untouched
|
||||
- `restore_vault_ai_guidance` repairs only Tolaria-managed files and creates the optional Gemini shim on explicit request; user-authored custom `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` files are surfaced as custom and left untouched
|
||||
- The status bar AI badge and command palette consume that abstraction to expose restore actions only when the managed guidance is missing or broken
|
||||
|
||||
### Getting Started / Onboarding
|
||||
@@ -697,7 +728,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
|
||||
@@ -726,12 +757,16 @@ interface Settings {
|
||||
release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed
|
||||
theme_mode: 'light' | 'dark' | null
|
||||
ui_language: AppLocale | null
|
||||
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | null
|
||||
note_width_mode: 'normal' | 'wide' | null
|
||||
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | null
|
||||
hide_gitignored_files: boolean | null // null = default true
|
||||
all_notes_show_pdfs: boolean | null // null = default false
|
||||
all_notes_show_images: boolean | null // null = default false
|
||||
all_notes_show_unsupported: boolean | null // null = default false
|
||||
}
|
||||
```
|
||||
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette light/dark actions both update that same value. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `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 `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
|
||||
## Telemetry
|
||||
|
||||
@@ -743,9 +778,15 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
|
||||
- **`useTelemetry(settings, loaded)`** — Reactively initializes/tears down Sentry and PostHog based on settings. Called once in `App`.
|
||||
|
||||
### Libraries
|
||||
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/release/key from `VITE_SENTRY_DSN`, `VITE_SENTRY_RELEASE`, and `VITE_POSTHOG_KEY` env vars.
|
||||
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/key from `VITE_SENTRY_DSN` and `VITE_POSTHOG_KEY`; `VITE_SENTRY_RELEASE` is treated as the build version and only becomes Sentry's `release` for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds tag `tolaria.build_version` and `tolaria.release_kind` without creating normal Sentry Releases entries.
|
||||
- **`src/main.tsx`** — React root error callbacks (`onCaughtError`, `onUncaughtError`, `onRecoverableError`) forward component-stack context to `Sentry.reactErrorHandler()` for debuggable production React errors.
|
||||
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes. `reinit_sentry()` for runtime toggle.
|
||||
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes; stable calendar `CARGO_PKG_VERSION` values become Sentry releases, while alpha/prerelease/internal versions are kept as diagnostic tags only. `reinit_sentry()` for runtime toggle.
|
||||
|
||||
### Product Events
|
||||
- **File previews** — `file_preview_opened`, `file_preview_action`, and `file_preview_failed` report only preview/action categories such as `image`, `pdf`, `unsupported`, `open_external`, `copy_path`, and `reveal`.
|
||||
- **Inline image lightbox** — `inline_image_lightbox_opened` records that a rich-editor inline image was opened from double-click, without sending note paths, image URLs, alt text, or file names.
|
||||
- **AI agent sessions** — `ai_agent_message_sent`, `ai_agent_message_blocked`, `ai_agent_response_completed`, `ai_agent_response_failed`, and `ai_agent_permission_mode_changed` use only agent ids, permission modes, counts, and coarse status categories.
|
||||
- **All Notes visibility** — `all_notes_visibility_changed` records only the toggled category and enabled state.
|
||||
|
||||
### Tauri Commands
|
||||
- **`reinit_telemetry`** — Re-reads settings and toggles Rust Sentry on/off. Called from frontend when user changes crash reporting setting.
|
||||
@@ -755,7 +796,7 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
|
||||
## Updates & Feature Flags
|
||||
|
||||
### Hooks
|
||||
- **`useUpdater(releaseChannel)`** — Channel-aware updater state machine. Checks the selected feed, surfaces available/downloading/ready states, and delegates install work to Rust.
|
||||
- **`useUpdater(releaseChannel)`** — Channel-aware updater state machine. Checks the selected feed, surfaces checking/available/downloading/ready states, and delegates install work to Rust.
|
||||
- **`useFeatureFlag(flag)`** — Returns boolean for a named feature flag. Checks `localStorage` override (`ff_<name>`), then falls back to telemetry-backed evaluation. Type-safe via `FeatureFlagName` union.
|
||||
|
||||
### Frontend helpers
|
||||
@@ -771,6 +812,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
|
||||
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
|
||||
|
||||
### CI/CD
|
||||
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`.
|
||||
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`.
|
||||
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
|
||||
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
|
||||
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.
|
||||
|
||||
@@ -24,7 +24,9 @@ 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 |
|
||||
| Per-note `_width` rich-editor width override | Default rich-editor note width |
|
||||
| Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files |
|
||||
| Per-vault All Notes note-list column overrides | All Notes PDF/image/unsupported file visibility |
|
||||
| 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,8 +34,11 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
|
||||
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)
|
||||
- ✅ Vault: `_width: wide` in a note that already has frontmatter (per-note reading/editing preference)
|
||||
- ✅ App settings: `zoom: 1.3` (machine-specific preference)
|
||||
- ✅ App settings: `ui_language: "zh-CN"` (installation-specific UI language)
|
||||
- ✅ App settings: `note_width_mode: "wide"` (installation-specific default for notes without an override)
|
||||
- ✅ App settings: `all_notes_show_images: true` (installation-specific All Notes file-category visibility)
|
||||
|
||||
### No hardcoded exceptions
|
||||
|
||||
@@ -89,6 +94,18 @@ flowchart LR
|
||||
|
||||
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.
|
||||
|
||||
#### Progressive Vault Loading
|
||||
|
||||
Vault opening is allowed to render the main app shell while the full entry scan is still in flight. `useVaultLoader` keeps `isLoading` true until entries are ready, but folders and saved views load independently so the sidebar can become useful before the note index completes. The status bar uses the vault activity badge during this initial indexing state, while command-palette and editor-shell interactions remain mounted instead of being hidden behind the full app skeleton. The full skeleton is reserved for app-level capability checks such as the initial Git-state probe.
|
||||
|
||||
Large-vault reproduction and keyboard QA steps live in [LARGE-VAULT-LOADING-QA.md](./LARGE-VAULT-LOADING-QA.md).
|
||||
|
||||
#### 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
|
||||
|
||||
| Layer | Technology | Version |
|
||||
@@ -186,9 +203,9 @@ flowchart TD
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
|
||||
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image binaries get an image indicator and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Binary image files render through `FilePreview` as ordinary vault files using Tauri asset URLs, with explicit unsupported/broken fallback states and keyboard focus returning to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The title bar exposes Back/Forward buttons wired to the same note navigation history as the command palette, menu accelerators, and mouse navigation buttons. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; 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 on mutable folders, 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: Type` document; new type documents created by Tolaria are written at the vault root.
|
||||
- **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 rich-editor width 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 rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. 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.
|
||||
@@ -197,8 +214,8 @@ The main Tauri window derives its minimum width from the visible panes instead o
|
||||
|
||||
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.
|
||||
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 the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, while cross-platform custom items such as Check for Updates emit Tolaria command IDs and show visible updater feedback.
|
||||
When Tolaria is launched from a Linux AppImage, `run()` also applies AppImage-only WebKitGTK startup safeguards without changing native package installs. It injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` unless the user already set that variable, and on Wayland sessions it re-execs once with the first available system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER` before GTK/WebKit create the display.
|
||||
|
||||
## Multi-Window (Note Windows)
|
||||
|
||||
@@ -224,12 +241,13 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
|
||||
|
||||
Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration.
|
||||
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, and default-agent selection
|
||||
2. **Backend** (`ai_agents.rs`) — normalizes agent availability and streaming, dispatching to per-agent adapters
|
||||
3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, a file/search-only built-in tool list, hidden Windows subprocess launches, and closed stdin for print-mode subprocesses so Windows launches receive EOF; Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json`; OpenCode runs through `opencode run --format json`; Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`. OpenCode and Pi both launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
|
||||
4. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, and Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-agent selection, and the per-vault Safe / Power User permission mode shown in the panel header
|
||||
2. **Backend orchestration** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters
|
||||
3. **Shared runtime scaffold** (`cli_agent_runtime.rs`) — owns the common request shape, prompt wrapping, JSON-line subprocess lifecycle, normalized error/done handling, version probing, and Tolaria MCP server path resolution used by app-managed CLI agents
|
||||
4. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools, while Power User adds Bash without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs` and run through `codex --sandbox workspace-write --ask-for-approval never exec --json` in both modes. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config. 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
|
||||
|
||||
@@ -243,7 +261,7 @@ sequenceDiagram
|
||||
|
||||
U->>FE: sendMessage(text, references)
|
||||
FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs)
|
||||
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath})
|
||||
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath, permissionMode})
|
||||
R->>R: pick adapter for claude_code, codex, opencode, or pi
|
||||
R->>C: spawn agent with MCP-enabled config
|
||||
|
||||
@@ -294,7 +312,7 @@ Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existi
|
||||
|
||||
## MCP Server
|
||||
|
||||
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Cursor, or any MCP-compatible client).
|
||||
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Gemini CLI, Cursor, or any MCP-compatible client).
|
||||
|
||||
### Tool Surface (14 tools)
|
||||
|
||||
@@ -326,10 +344,11 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|
||||
|
||||
Tolaria can register itself as an MCP server in:
|
||||
- `~/.claude.json` and `~/.claude/mcp.json` (Claude Code compatibility across current CLI and legacy MCP-file setups)
|
||||
- `~/.gemini/settings.json` (Gemini CLI)
|
||||
- `~/.cursor/mcp.json` (Cursor)
|
||||
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
|
||||
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`). The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`.
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria 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
|
||||
|
||||
@@ -376,11 +395,12 @@ flowchart LR
|
||||
|----------|---------|
|
||||
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
|
||||
| `sync_mcp_bridge_vault(vault_path?)` | Starts, restarts, or stops the desktop WebSocket bridge as the selected vault changes |
|
||||
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Cursor, and generic MCP configs on user request |
|
||||
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Cursor, and generic MCP configs |
|
||||
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Gemini CLI, Cursor, and generic MCP configs on user request |
|
||||
| `mcp_config_snippet(vault_path)` | Builds the exact `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files |
|
||||
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Gemini CLI, Cursor, and generic MCP configs |
|
||||
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
|
||||
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed on app exit via the `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed plus waited on app exit via the `RunEvent::Exit` handler. The same desktop layer keeps Tauri asset protocol access limited to vault roots loaded during the current app session; command calls remain active-vault scoped for reads, writes, and external opens.
|
||||
|
||||
## Search
|
||||
|
||||
@@ -425,7 +445,7 @@ The app uses internal app-owned light and dark themes (see [ADR-0081](adr/0081-i
|
||||
|
||||
1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states. Bridged to Tailwind v4 via `@theme inline`.
|
||||
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`; editor colors resolve through the same semantic app variables.
|
||||
3. **Theme runtime**: Applies `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode is selected.
|
||||
3. **Theme runtime**: Applies `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode is selected. Settings and command-palette theme actions both write the same installation-local `settings.theme_mode` value.
|
||||
|
||||
## Localization
|
||||
|
||||
@@ -472,11 +492,11 @@ When an opened folder is not yet a git repo, Tolaria shows a dismissible Git set
|
||||
|
||||
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, and Pi are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
|
||||
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.
|
||||
|
||||
@@ -520,7 +540,7 @@ sequenceDiagram
|
||||
participant MCP as MCP Server
|
||||
participant U as User
|
||||
|
||||
T->>T: apply Linux AppImage WebKit env override<br/>(AppImage only)
|
||||
T->>T: apply Linux AppImage WebKit env/preload safeguards<br/>(AppImage only)
|
||||
T->>T: run_startup_tasks()<br/>(migrate + seed only)
|
||||
T->>MCP: spawn_ws_bridge() — ports 9710 + 9711
|
||||
T->>A: App mounts
|
||||
@@ -530,7 +550,7 @@ sequenceDiagram
|
||||
A-->>U: WelcomeScreen
|
||||
else Vault found
|
||||
A->>VL: useVaultLoader fires
|
||||
VL->>T: invoke('reload_vault') → sync active vault asset scope + scan_vault_cached()
|
||||
VL->>T: invoke('reload_vault') → allow requested vault roots in asset scope + scan_vault_cached()
|
||||
T-->>VL: VaultEntry[]
|
||||
VL->>T: invoke('get_modified_files')
|
||||
A->>T: useMcpStatus — check explicit MCP setup state
|
||||
@@ -625,7 +645,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
|
||||
| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames |
|
||||
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
|
||||
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
|
||||
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md`, `CLAUDE.md`, and optional `GEMINI.md` shims), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
|
||||
| `getting_started.rs` | Clones and normalizes the public Getting Started starter vault |
|
||||
|
||||
## Rust Backend Modules
|
||||
@@ -636,8 +656,9 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
|
||||
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) |
|
||||
| `search.rs` | Keyword search — walkdir-based vault file scan with Gitignored-content visibility filtering |
|
||||
| `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch |
|
||||
| `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing |
|
||||
| `ai_agents.rs` | CLI-agent request normalization and adapter dispatch |
|
||||
| `cli_agent_runtime.rs` | Shared CLI-agent request, prompt, subprocess, version, and MCP path helpers |
|
||||
| `claude_cli.rs`, `codex_cli.rs`, `opencode_cli.rs` | Claude Code, Codex, and OpenCode command/config/event adapters |
|
||||
| `pi_cli.rs`, `pi_config.rs`, `pi_discovery.rs`, `pi_events.rs` | Pi subprocess launch, transient MCP adapter config, discovery, and JSON stream parsing |
|
||||
| `mcp.rs` | MCP server spawning + explicit config registration/removal |
|
||||
| `commands/` | Tauri command handlers (split into submodules) |
|
||||
@@ -664,13 +685,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, full rescan from filesystem, then apply Gitignored-content visibility → `Vec<VaultEntry>` |
|
||||
| `reload_vault` | Allow the requested vault roots in the runtime 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
|
||||
@@ -724,9 +746,10 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `check_claude_cli` | Check if Claude CLI is available |
|
||||
| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi availability |
|
||||
| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, or Pi through the normalized agent event layer |
|
||||
| `register_mcp_tools` | Register MCP in Claude/Cursor/generic config for the active vault |
|
||||
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor/generic config |
|
||||
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor/generic config |
|
||||
| `register_mcp_tools` | Register MCP in Claude/Gemini/Cursor/generic config for the active vault |
|
||||
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Gemini/Cursor/generic config |
|
||||
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Gemini/Cursor/generic config |
|
||||
| `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault |
|
||||
| `sync_mcp_bridge_vault` | Sync the desktop WebSocket bridge process to the selected vault, or stop it when no vault is selected |
|
||||
|
||||
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.
|
||||
@@ -743,8 +766,8 @@ The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bri
|
||||
| `save_vault_config` | Save per-vault UI config |
|
||||
| `get_default_vault_path` | Get default vault path |
|
||||
| `get_build_number` | Get app build number |
|
||||
| `save_image` | Save base64 image to `attachments/` and refresh the active vault asset scope |
|
||||
| `copy_image_to_vault` | Copy image file to `attachments/` and refresh the active vault asset scope |
|
||||
| `save_image` | Save base64 image to `attachments/` and ensure the vault root is in the runtime asset scope |
|
||||
| `copy_image_to_vault` | Copy image file to `attachments/` and ensure the vault root is in the runtime asset scope |
|
||||
| `update_menu_state` | Update native menu checkmarks and enabled/disabled state for selection-dependent actions |
|
||||
| `trigger_menu_command` | Emit a native menu command ID for deterministic shortcut QA |
|
||||
| `update_current_window_min_size` | Update the active Tauri window's minimum size and optionally grow it to fit restored panes |
|
||||
@@ -785,14 +808,14 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |
|
||||
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
|
||||
| `useTheme` | Editor theme CSS vars and theme-mode bridge | Editor typography and app theme runtime |
|
||||
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline |
|
||||
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline and vault permission mode |
|
||||
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
|
||||
| `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints |
|
||||
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
|
||||
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
|
||||
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
|
||||
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
|
||||
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility, All Notes file 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`.
|
||||
@@ -938,11 +961,12 @@ sequenceDiagram
|
||||
- `anonymous_id` is a locally-generated UUID, never tied to identity
|
||||
- `send_default_pii: false` on both SDKs
|
||||
- PostHog: `autocapture: false`, `persistence: 'memory'`, no cookies
|
||||
- Product events use categorical metadata only: file preview kind/action, AI agent id/permission mode/counts/status, and All Notes visibility category/enabled state.
|
||||
|
||||
**Architecture:**
|
||||
- **Rust:** `sentry` crate initialized in `lib.rs::setup()` via `telemetry::init_sentry_from_settings()`
|
||||
- **JS:** `@sentry/react` + `posthog-js` initialized lazily by `useTelemetry` hook; the React root also wires `onCaughtError`, `onUncaughtError`, and `onRecoverableError` through `Sentry.reactErrorHandler()` so production React invariants include component stack context when crash reporting is enabled.
|
||||
- **Release grouping:** packaged release workflows pass `VITE_SENTRY_RELEASE` from the computed build version so frontend Sentry events group by the shipped alpha or stable version.
|
||||
- **Release grouping:** packaged release workflows pass `VITE_SENTRY_RELEASE` from the computed build version, but the app only assigns Sentry's `release` field for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds omit `release` so they do not create normal Sentry Releases entries, while both frontend and Rust Sentry scopes tag `tolaria.build_version` and `tolaria.release_kind` for diagnostics.
|
||||
- **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct
|
||||
- **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null`
|
||||
|
||||
|
||||
@@ -29,6 +29,22 @@ If you run the desktop app on Linux, install Tauri's WebKit2GTK 4.1 dependencies
|
||||
libappindicator-gtk3-devel librsvg2-devel
|
||||
```
|
||||
|
||||
### Linux AppImage Wayland troubleshooting
|
||||
|
||||
On some Wayland systems, the Linux AppImage may fail to launch with:
|
||||
|
||||
```text
|
||||
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
|
||||
```
|
||||
|
||||
Recent Tolaria AppImages automatically retry startup with the system Wayland client library when they detect this class of AppImage + Wayland environment. If you are running an older build, use this workaround:
|
||||
|
||||
```bash
|
||||
LD_PRELOAD=/usr/lib/libwayland-client.so ./Tolaria*.AppImage
|
||||
```
|
||||
|
||||
If your distribution stores the library elsewhere, use that path instead, for example `/usr/lib64/libwayland-client.so.0` or `/usr/lib/x86_64-linux-gnu/libwayland-client.so.0`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -79,7 +95,7 @@ tolaria/
|
||||
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
|
||||
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
|
||||
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
|
||||
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent)
|
||||
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent + per-vault permission mode)
|
||||
│ │ ├── AiMessage.tsx # Agent message display
|
||||
│ │ ├── AiActionCard.tsx # Agent tool action cards
|
||||
│ │ ├── AiAgentsOnboardingPrompt.tsx # First-launch AI agent installer prompt
|
||||
@@ -114,7 +130,8 @@ tolaria/
|
||||
│ │ ├── useNoteCreation.ts # Note/type creation
|
||||
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
|
||||
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline
|
||||
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi availability polling
|
||||
│ │ ├── 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
|
||||
@@ -188,9 +205,11 @@ tolaria/
|
||||
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
|
||||
│ │ ├── telemetry.rs # Sentry init + path scrubber
|
||||
│ │ ├── search.rs # Keyword search (walkdir-based)
|
||||
│ │ ├── ai_agents.rs # Shared CLI-agent detection + stream adapters
|
||||
│ │ ├── claude_cli.rs # Claude CLI subprocess management
|
||||
│ │ ├── pi_cli.rs # Pi CLI subprocess management
|
||||
│ │ ├── ai_agents.rs # CLI-agent request normalization + adapter dispatch
|
||||
│ │ ├── cli_agent_runtime.rs # Shared CLI-agent runtime process/prompt/MCP helpers
|
||||
│ │ ├── claude_cli.rs # Claude CLI adapter
|
||||
│ │ ├── codex_cli.rs # Codex CLI adapter
|
||||
│ │ ├── pi_cli.rs # Pi CLI adapter
|
||||
│ │ ├── mcp.rs # MCP server lifecycle + explicit config registration/removal
|
||||
│ │ ├── app_updater.rs # Alpha/stable updater endpoint selection
|
||||
│ │ ├── settings.rs # App settings persistence
|
||||
@@ -261,9 +280,9 @@ tolaria/
|
||||
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
|
||||
| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse, add-remote). |
|
||||
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
|
||||
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, adapter dispatch, and stream normalization. |
|
||||
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
|
||||
| `src-tauri/src/pi_cli.rs` | Pi subprocess spawning through JSON mode and transient MCP adapter config. |
|
||||
| `src-tauri/src/ai_agents.rs` | CLI-agent request normalization, availability aggregation, adapter dispatch, and Claude event mapping. |
|
||||
| `src-tauri/src/cli_agent_runtime.rs` | Shared CLI-agent request shape, prompt wrapping, JSON subprocess lifecycle, version probing, and MCP path helpers. |
|
||||
| `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs`, `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
|
||||
@@ -282,9 +301,10 @@ tolaria/
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, and actions. |
|
||||
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, actions, and per-vault permission mode. |
|
||||
| `src/hooks/useCliAiAgent.ts` | Thin React owner for the selected CLI agent session state. |
|
||||
| `src/lib/aiAgentSession.ts` | Single message/session lifecycle for prompt normalization, history, streaming, and reset behavior. |
|
||||
| `src/lib/aiAgentPermissionMode.ts` | Safe/Power User mode normalization, display labels, and local transcript marker text. |
|
||||
| `src/lib/aiAgentFileOperations.ts` | Detects agent-created or modified vault files from normalized tool inputs. |
|
||||
| `src/lib/aiAgents.ts` | Supported agent definitions, status normalization, and default-agent helpers. |
|
||||
| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. |
|
||||
@@ -304,7 +324,7 @@ tolaria/
|
||||
| `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). |
|
||||
| `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. |
|
||||
| `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow). |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow, AI permission mode). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, default AI agent, and the vault-level explicit organization toggle. |
|
||||
| `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. |
|
||||
|
||||
@@ -341,7 +361,7 @@ type SidebarSelection =
|
||||
|
||||
### Command Registry
|
||||
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the light/dark theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
|
||||
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
|
||||
|
||||
@@ -398,7 +418,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
|
||||
### Add a new entity type
|
||||
|
||||
1. Create a type document: `type/mytype.md` with `type: Type` frontmatter (icon, color, order, etc.)
|
||||
1. Create a type document at the vault root: `mytype.md` with `type: Type` frontmatter (icon, color, order, etc.)
|
||||
2. The sidebar section groups are auto-generated from type documents — no code change needed if `visible: true`
|
||||
3. Update `CreateNoteDialog.tsx` type options if users should be able to create it from the dialog
|
||||
4. Notes of this type are created at the vault root with `type: MyType` in frontmatter — no dedicated folder needed
|
||||
@@ -420,11 +440,14 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
1. **Agent system prompt**: Edit `src/utils/ai-agent.ts` (inline system prompt string)
|
||||
2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent
|
||||
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
|
||||
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`; keep app-managed launches on strict Tolaria MCP config, `acceptEdits`, and the scoped file/search tool list)
|
||||
5. **Shared agent adapters / Codex/Pi args**: Edit `src-tauri/src/ai_agents.rs` plus the per-agent adapter modules (keep Codex sandboxed with active-vault `workspace-write`, keep Pi on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode)
|
||||
4. **Permission-mode UI and request plumbing**: Edit `src/lib/aiAgentPermissionMode.ts`, `src/components/AiPanel*.tsx`, `src/hooks/useCliAiAgent.ts`, and `src/utils/streamAiAgent.ts`
|
||||
5. **Shared CLI runtime behavior**: Edit `src-tauri/src/cli_agent_runtime.rs` for process lifecycle, prompt wrapping, version probing, and common Tolaria MCP path handling.
|
||||
6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `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**: Edit `src-tauri/src/mcp.rs`; registration must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and write an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
|
||||
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx`; users should see the Node.js prerequisite and the manual config shape before Tolaria writes third-party config files
|
||||
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
|
||||
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files
|
||||
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes
|
||||
4. **Gemini CLI compatibility**: Keep `~/.gemini/settings.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; 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
|
||||
|
||||
53
docs/LARGE-VAULT-LOADING-QA.md
Normal file
53
docs/LARGE-VAULT-LOADING-QA.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Large Vault Loading QA
|
||||
|
||||
Use this when validating startup responsiveness for large vaults. The goal is to make the bottleneck reproducible without using a real user vault.
|
||||
|
||||
## Synthetic Vault
|
||||
|
||||
Create a disposable vault with many markdown files:
|
||||
|
||||
```bash
|
||||
VAULT="$(mktemp -d /tmp/tolaria-large-vault.XXXXXX)"
|
||||
mkdir -p "$VAULT/type" "$VAULT/archive" "$VAULT/assets"
|
||||
cat > "$VAULT/type/project.md" <<'EOF'
|
||||
---
|
||||
is_a: Type
|
||||
---
|
||||
# Project
|
||||
EOF
|
||||
|
||||
for i in $(seq -w 1 20000); do
|
||||
cat > "$VAULT/project-$i.md" <<EOF
|
||||
---
|
||||
is_a: Project
|
||||
status: Active
|
||||
related_to:
|
||||
- "[[project-00001]]"
|
||||
---
|
||||
# Project $i
|
||||
|
||||
Synthetic body $i with enough text to exercise parsing and snippets.
|
||||
EOF
|
||||
done
|
||||
|
||||
git -C "$VAULT" init
|
||||
git -C "$VAULT" config user.email qa@example.invalid
|
||||
git -C "$VAULT" config user.name "Tolaria QA"
|
||||
git -C "$VAULT" add .
|
||||
git -C "$VAULT" commit -m "seed large vault"
|
||||
echo "$VAULT"
|
||||
```
|
||||
|
||||
## Manual QA
|
||||
|
||||
1. Start Tolaria with `pnpm tauri dev`.
|
||||
2. Open the synthetic vault path printed above.
|
||||
3. Verify the main shell renders before the full note list finishes indexing.
|
||||
4. Confirm the status bar shows vault activity while indexing is still in progress.
|
||||
5. Use keyboard-only flows while indexing continues:
|
||||
- Cmd+K opens the command palette.
|
||||
- Cmd+P opens quick open; results may be partial or empty until indexing finishes.
|
||||
- Create a new note with Cmd+N and type in the editor.
|
||||
6. Wait for indexing to finish and verify the note list/search state is consistent.
|
||||
|
||||
The synthetic vault lives under `/tmp`; remove it after QA if it is no longer needed.
|
||||
35
docs/adr/0091-gemini-cli-external-ai-setup.md
Normal file
35
docs/adr/0091-gemini-cli-external-ai-setup.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0091"
|
||||
title: "Gemini CLI external AI setup"
|
||||
status: active
|
||||
date: 2026-04-28
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria already supports explicit MCP setup for external desktop AI tools. Users asked for Gemini CLI support so the same active-vault MCP server can be registered where Gemini reads tool configuration, with optional Gemini-specific vault guidance.
|
||||
|
||||
Gemini CLI reads MCP server definitions from `~/.gemini/settings.json` and can load project guidance from `GEMINI.md`. Tolaria must support those conventions without silently rewriting global user settings or overwriting user-authored vault instructions.
|
||||
|
||||
## Decision
|
||||
|
||||
Tolaria adds `~/.gemini/settings.json` to the explicit external AI setup path list. The existing MCP entry shape is reused: `mcpServers.tolaria` runs the packaged stdio server through Node.js, pins `VAULT_PATH` to the selected vault, and sets `WS_UI_PORT=9711`.
|
||||
|
||||
Vault guidance keeps `AGENTS.md` as the canonical shared source. `restore_vault_ai_guidance` can create or repair a managed `GEMINI.md` shim that imports `AGENTS.md`, while bootstrap and repair flows continue to seed only required Tolaria guidance (`AGENTS.md` and `CLAUDE.md`) plus type scaffolding. Custom `GEMINI.md` files are classified as custom and are not overwritten.
|
||||
|
||||
The setup dialog documents that Gemini CLI still needs its own install and sign-in. Tolaria does not store Gemini credentials or model-provider API keys.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Add Gemini to explicit MCP setup and optional guidance restore** (chosen): matches the existing consent boundary, preserves user settings, and gives Gemini the shared vault context.
|
||||
- **Generate `GEMINI.md` automatically for every vault**: simpler discovery, but turns an optional third-party compatibility file into startup side effect and dirties vaults for users who do not use Gemini.
|
||||
- **Document manual Gemini setup only**: avoids code changes, but leaves users to transpose paths and loses the active-vault safety already available for other MCP clients.
|
||||
- **Create a Gemini-specific MCP entry shape**: unnecessary because Gemini accepts the same `mcpServers` entry structure used by the existing Tolaria MCP server.
|
||||
|
||||
## Consequences
|
||||
|
||||
- External AI setup now writes/removes Tolaria's MCP entry in Claude, Gemini, Cursor, and generic MCP config files.
|
||||
- Gemini users can create a managed `GEMINI.md` shim without duplicating the canonical `AGENTS.md` guidance.
|
||||
- Existing vault bootstrap and repair remain non-invasive for users who do not use Gemini.
|
||||
- Native QA requires a local Gemini CLI install and authentication for an end-to-end Gemini prompt; otherwise the app can still verify the generated config and documentation paths.
|
||||
39
docs/adr/0092-vault-ai-agent-permission-modes.md
Normal file
39
docs/adr/0092-vault-ai-agent-permission-modes.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0092"
|
||||
title: "Vault-scoped AI agent permission modes"
|
||||
status: active
|
||||
date: 2026-04-28
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0074 established explicit setup and least-privilege defaults for desktop AI tools. The in-app AI panel now supports multiple local CLI agents, and users need a clear per-vault way to choose whether an agent should stay in the narrow vault-safe profile or use broader local-work tools for that vault.
|
||||
|
||||
The mode must be visible at the point of use, must not mutate global CLI settings, and must not silently restore dangerous bypass flags. Existing transcripts should remain intact when the mode changes because a change applies to the next agent run, not to a process that is already streaming.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria stores an `ai_agent_permission_mode` per vault with values `safe` and `power_user`, defaulting missing or null values to `safe`, and passes that normalized mode through the AI panel stream request into each CLI adapter.**
|
||||
|
||||
The AI panel header displays the current mode and offers a compact Vault Safe / Power User control that is disabled while an agent run is active. Changing the mode preserves the transcript and inserts a local transcript marker.
|
||||
|
||||
Adapter mappings remain conservative:
|
||||
- Claude Code Safe keeps `acceptEdits`, strict Tolaria MCP config, and file/search/edit tools only; Power User adds Bash to the allowed tool list without using `--dangerously-skip-permissions`.
|
||||
- Codex keeps the active-vault `workspace-write` sandbox and `--ask-for-approval never` in both modes.
|
||||
- OpenCode uses transient `OPENCODE_CONFIG_CONTENT`; Safe denies bash and external directories, while Power User allows bash but still denies external directories.
|
||||
- Pi receives the mode on the adapter request path; both modes currently use the same transient MCP adapter config.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Per-vault Safe / Power User modes** (chosen): makes the permission surface explicit where the agent is used and preserves least-privilege defaults for each vault.
|
||||
- **Global app setting**: simpler storage, but a single toggle can over-apply a power-user profile to unrelated vaults.
|
||||
- **Dangerous bypass mode**: maximizes CLI freedom, but violates ADR-0074's least-privilege boundary and needs a separate explicit security decision.
|
||||
- **Adapter-specific UI switches**: exposes too much implementation detail and makes cross-agent behavior harder to reason about.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Vault config normalization owns the safe default for old vaults and malformed values.
|
||||
- Agent requests now carry a permission mode through frontend and Rust boundaries, so new adapters must choose an explicit mapping.
|
||||
- Power User is intentionally not equivalent across agents; where an adapter lacks a safe broader local-work switch, both modes may map to the same conservative behavior and must document that with tests.
|
||||
- Any future dangerous mode requires a new ADR and separate UI language.
|
||||
35
docs/adr/0093-shared-cli-agent-runtime-adapters.md
Normal file
35
docs/adr/0093-shared-cli-agent-runtime-adapters.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0093"
|
||||
title: "Shared CLI agent runtime adapters"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria supports Claude Code, Codex, OpenCode, and Pi as local CLI agents in the AI panel. Each agent has different command-line arguments, configuration shape, and JSON event schema, but the Rust backend had grown repeated runtime plumbing around those differences: request shapes, prompt wrapping, subprocess launch, stdout JSON reading, stderr capture, exit handling, done events, version probing, and Tolaria MCP server path resolution.
|
||||
|
||||
That duplication made small runtime fixes expensive because they had to be repeated across several adapter files. It also kept Codex-specific command and event mapping inside `ai_agents.rs`, making the top-level module both an orchestrator and a bespoke adapter.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria uses `cli_agent_runtime.rs` as the shared runtime scaffold for app-managed CLI agents, while `ai_agents.rs` only normalizes and dispatches requests to per-agent adapter modules.**
|
||||
|
||||
The shared scaffold owns the common agent request shape, system/user prompt wrapping, JSON-line process lifecycle, normalized error/done handling for `AiAgentStreamEvent` adapters, version probing, and Tolaria MCP server path resolution. Per-agent modules keep the provider-specific pieces: binary discovery candidates, command arguments, transient config shape, authentication error wording, and JSON event mapping.
|
||||
|
||||
Codex now lives in `codex_cli.rs`, matching the Claude, OpenCode, and Pi adapter boundary. `ai_agents.rs` remains the Tauri-facing orchestrator that chooses an adapter and maps Claude's legacy event enum into the normalized event stream.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Shared runtime scaffold with thin adapters** (chosen): reduces repeated process lifecycle code without hiding provider-specific command/config/event behavior.
|
||||
- **One trait object per agent**: more uniform on paper, but adds indirection without removing much current complexity.
|
||||
- **Leave each adapter self-contained**: keeps local readability for a single file, but new process, prompt, and MCP fixes continue to land in parallel.
|
||||
- **Fully generic event mapping**: over-abstracts the JSON schemas and makes provider-specific edge cases harder to test.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Runtime lifecycle fixes should usually start in `cli_agent_runtime.rs`.
|
||||
- New agent adapters should use the shared request/prompt/process helpers and keep only command, config, discovery, and event mapping local.
|
||||
- `ai_agents.rs` should not grow provider-specific runtime code again; it should normalize the frontend request, dispatch to an adapter, and map any legacy event shape.
|
||||
- The shared scaffold deliberately does not erase provider differences. Authentication messages, permission semantics, and transient config formats remain adapter-owned and must stay covered by adapter tests.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0094"
|
||||
title: "Gitignored content visibility as a command-boundary filter"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's vault scanner now indexes more of the real filesystem so Folder views, search, and reload flows can reflect what is actually in the vault. In Git-backed vaults, that includes generated, local-only, or machine-specific content that users intentionally hide through `.gitignore`.
|
||||
|
||||
Always showing Gitignored files makes Folder lists and search noisy, especially in vaults that contain exports, build artifacts, or personal local scratch files. But removing those files during scanning would make visibility dependent on cache shape, complicate toggling, and blur the distinction between "what exists in the vault" and "what this installation chooses to surface."
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria keeps the vault scan and cache complete, then applies Gitignored-content visibility at the command boundary before entries, folders, or search results reach React.**
|
||||
|
||||
- `hide_gitignored_files` is an installation-local app setting and defaults to `true`.
|
||||
- Visibility checks use batched `git check-ignore --no-index --stdin` so Tolaria follows normal Git ignore and negation semantics as closely as practical.
|
||||
- `list_vault`, `reload_vault`, `list_vault_folders`, and keyword search all apply the same filter when the setting is enabled.
|
||||
- Toggling the setting reloads the current vault surfaces instead of rebuilding a different cache format.
|
||||
- If a vault has no `.gitignore`, or Gitignored visibility is turned off, Tolaria shows the full scanned result.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Complete scan/cache + boundary filter** (chosen): keeps the filesystem model authoritative, makes toggling cheap and consistent, and avoids cache divergence.
|
||||
- **Skip Gitignored content during scan/cache**: reduces later filtering work, but makes visibility part of the persisted cache shape and complicates instant toggling.
|
||||
- **Always show Gitignored content**: simplest implementation, but too noisy for real Git-backed vaults and undermines users' existing ignore rules.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Gitignored visibility is a per-installation comfort preference, not vault-authored shared metadata.
|
||||
- Search, folder lists, and note reloads stay aligned because they all consult the same boundary filter.
|
||||
- The cache can still support future visibility changes without a data migration.
|
||||
- Users can reveal ignored content again immediately by disabling the setting.
|
||||
- Future features that expose vault file lists should apply the same boundary filter unless they intentionally need raw filesystem output.
|
||||
35
docs/adr/0095-saved-view-order-field.md
Normal file
35
docs/adr/0095-saved-view-order-field.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0095"
|
||||
title: "Saved views use an explicit YAML order field"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Saved Views already persist as user-editable YAML files in the vault and sync through Git. Filename ordering was stable, but it forced users to rename files just to change sidebar order and gave Tolaria no durable way to support drag reordering, move actions, or keyboard-first ordering controls.
|
||||
|
||||
The ordering choice also needs to travel with the view definition itself. Saved Views are part of the vault's shared information architecture, not a machine-local preference.
|
||||
|
||||
## Decision
|
||||
|
||||
**Each Saved View may store an optional top-level `order` number in its YAML definition, and Tolaria sorts views by that value before falling back to filename.**
|
||||
|
||||
- Lower `order` values render earlier in the sidebar and other Saved View lists.
|
||||
- Views without `order` sort after ordered views and then fall back to filename ordering for stability.
|
||||
- Reordering actions rewrite affected view files with a dense sequence of order values instead of encoding position in filenames.
|
||||
- The same persisted order supports drag handles, explicit move buttons, and command-palette ordering actions.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Explicit `order` field in the view YAML** (chosen): portable, Git-syncable, easy to inspect by hand, and consistent with the existing file-first view model.
|
||||
- **Filename-based ordering only**: no schema change, but makes reordering clumsy and couples user-visible structure to file naming.
|
||||
- **App-local ordering state**: easy to prototype, but breaks cross-device consistency and separates ordering from the view artifact users already version.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Saved View ordering becomes part of the vault and syncs naturally through Git.
|
||||
- Existing views remain valid; unordered files keep a stable fallback sort until reordered.
|
||||
- Reordering can touch multiple view files in one action because Tolaria normalizes the sequence.
|
||||
- Future Saved View features should treat `order` as part of the shared YAML schema rather than introducing a parallel ordering store.
|
||||
37
docs/adr/0096-root-created-type-documents.md
Normal file
37
docs/adr/0096-root-created-type-documents.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0096"
|
||||
title: "Root-created type documents"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria identifies type definitions by markdown frontmatter (`type: Type`), not by filesystem location. Older documentation and UI creation flows still treated `type/` as the canonical destination for new type documents, with a compatibility fallback for vaults that already used `types/`.
|
||||
|
||||
That folder-based creation policy conflicted with the broader vault model: notes are scanned from all non-hidden folders, type identity comes from metadata, and root `type.md` / `note.md` definitions are already used by repair and bootstrap flows.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria creates new type documents at the vault root.**
|
||||
|
||||
- A type document is any markdown note with `type: Type` in frontmatter.
|
||||
- New UI-created type documents use `{vault}/{slug}.md`.
|
||||
- Existing type documents in `type/`, `types/`, or other scanned folders remain valid and continue to drive templates, icons, colors, visibility, sorting, and sidebar grouping.
|
||||
- Creation does not silently migrate or move existing type documents.
|
||||
- Root filename collisions are handled as file collisions; Tolaria must not overwrite an existing note when creating a type document.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Root-created type documents** (chosen): matches the metadata-first model, removes special casing from creation, and aligns new types with root-managed default type scaffolding.
|
||||
- **Canonical `type/` folder**: avoids root filename collisions, but makes path special even though type identity is already defined by frontmatter.
|
||||
- **Preserve existing folder convention dynamically**: minimizes change for plural `types/` vaults, but creates inconsistent behavior across vaults and leaves `types/` only partially supported.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Users can inspect and edit type documents as ordinary root notes by default.
|
||||
- Existing vaults with `type/` or `types/` type documents remain readable because vault scanning already includes non-hidden subdirectories.
|
||||
- If a root note with the same slug already exists, type creation fails with a collision message instead of writing into a fallback folder.
|
||||
- Legacy `type/` may remain hidden from the folder tree so old type documents do not duplicate the Types sidebar section.
|
||||
- Re-evaluate if users need a guided migration from folder-based type documents to root type documents.
|
||||
42
docs/adr/0097-gemini-cli-agent-adapter.md
Normal file
42
docs/adr/0097-gemini-cli-agent-adapter.md
Normal 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.
|
||||
29
docs/adr/0098-in-app-image-and-pdf-file-previews.md
Normal file
29
docs/adr/0098-in-app-image-and-pdf-file-previews.md
Normal 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.
|
||||
37
docs/adr/0099-cumulative-vault-asset-scope.md
Normal file
37
docs/adr/0099-cumulative-vault-asset-scope.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0099"
|
||||
title: "Cumulative vault asset scope for previews"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
supersedes: "0074 asset-protocol runtime scoping"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0074 moved the desktop asset protocol away from broad filesystem access and toward runtime vault scoping. The implementation tried to keep only the active vault in scope by calling Tauri's `forbid_directory` for vault roots that were no longer active.
|
||||
|
||||
Tauri's filesystem scope treats forbidden paths as permanent precedence rules: a forbidden path is denied even if it is later allowed again. After a user switched away from a vault and back, image and PDF previews could keep producing `403 Forbidden` responses for valid vault files until the app restarted.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria accumulates Tauri asset protocol access for vault roots loaded during the current app session and never forbids a previously loaded vault root at runtime.**
|
||||
|
||||
- `sync_vault_asset_scope` adds the canonical vault root and requested vault root when they are missing from the runtime asset scope.
|
||||
- The runtime asset scope remains narrower than global filesystem access because only vault roots that Tolaria has loaded are added.
|
||||
- Command paths still enforce the active vault boundary through the Rust command layer before reads, writes, external opens, and attachment imports.
|
||||
- Asset scope revocation is deferred to process exit, because Tauri does not expose a safe runtime unallow operation for directories.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Cumulative runtime vault scope** (chosen): keeps previews reliable after vault switches while preserving vault-only access in the current process.
|
||||
- **Continue forbidding previous vaults**: appears stricter, but Tauri forbids are not reversible and valid previews fail after switching back.
|
||||
- **Allow all filesystem paths**: avoids preview failures but returns to the broad asset protocol access that ADR-0074 intentionally removed.
|
||||
- **Replace `convertFileSrc` with a custom protocol**: could support exact active-vault revocation, but it would be a larger cross-cutting migration for editor images, file previews, and PDF rendering.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Images and PDFs from any vault loaded in the current session can keep rendering after vault switches.
|
||||
- The app process, not each vault switch, is the revocation boundary for asset URL access.
|
||||
- Active-vault command validation remains the primary guard for mutations and default-app opens.
|
||||
- Re-evaluate this if Tauri adds a public runtime unallow operation for asset protocol directories.
|
||||
35
docs/adr/0100-synthetic-vault-root-folder-row.md
Normal file
35
docs/adr/0100-synthetic-vault-root-folder-row.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0100"
|
||||
title: "Synthetic vault-root row in folder navigation"
|
||||
status: active
|
||||
date: 2026-04-30
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0033 introduced subfolder scanning and a collapsible folder tree backed by `list_vault_folders`, but the sidebar still had no first-class way to select the vault root itself. That left root-level files outside the folder-navigation model and pushed the UI toward one-off handling for the opened vault path.
|
||||
|
||||
The new sidebar behavior needs to show root-level files when the user clicks the vault name, while preserving the existing folder rename/delete model for real folders only.
|
||||
|
||||
## Decision
|
||||
|
||||
**Represent the vault root in the sidebar as a synthetic frontend-owned folder row rather than as a mutable backend folder.**
|
||||
|
||||
- `FolderTree` wraps backend folder nodes in a root row with `path: ""` and `rootPath` set to the opened vault path.
|
||||
- `SidebarSelection` keeps using `kind: 'folder'`, but root selection is encoded as the empty folder path plus `rootPath` metadata.
|
||||
- Root-level file filtering is handled in note-list helpers as a dedicated root case instead of pretending the vault root is an ordinary folder.
|
||||
- Rename/delete remain available only for real folders; the vault root row is navigable, not mutable.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Synthetic vault-root row in the renderer — keeps `list_vault_folders` focused on real folders, avoids backend schema churn, and reuses the existing folder-selection mental model.
|
||||
- **Option B**: Add a pseudo-folder to backend folder results — would couple presentation-only root behavior to command data and blur the distinction between the vault itself and mutable folders.
|
||||
- **Option C**: Keep root files outside folder navigation entirely — simpler, but leaves the sidebar with an incomplete navigation model and special cases elsewhere in the UI.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Folder navigation now has a single model for root and nested folder browsing.
|
||||
- Backend folder APIs stay unchanged: they describe actual folders, not UI-only rows.
|
||||
- Selection handling must treat `path: ""` as the vault-root case and use `rootPath` when computing direct-root file membership.
|
||||
- Re-evaluate if folder actions ever need to operate on the vault root itself, because that would likely require a separate command model instead of extending the synthetic row.
|
||||
35
docs/adr/0101-categorical-product-analytics-events.md
Normal file
35
docs/adr/0101-categorical-product-analytics-events.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0101"
|
||||
title: "Categorical product analytics events"
|
||||
status: active
|
||||
date: 2026-04-30
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0016 established opt-in PostHog analytics and Sentry crash reporting, but new feature telemetry now spans file previews, inline image lightbox opens, AI agent session lifecycle events, and All Notes visibility toggles. Instrumenting those flows ad hoc would make it easy to leak vault-specific data such as note paths, filenames, prompt text, or image URLs.
|
||||
|
||||
Tolaria needs richer product telemetry for feature behavior while preserving the privacy bar expected from a local-first notes app.
|
||||
|
||||
## Decision
|
||||
|
||||
**Route product analytics through dedicated helper functions that emit only coarse categorical metadata.**
|
||||
|
||||
- Product events live behind `src/lib/productAnalytics.ts` instead of scattering raw `trackEvent()` payloads across feature code.
|
||||
- Allowed payloads are constrained to categories and counts such as preview kind, action kind, AI agent id, permission mode, response/tool counts, status, and All Notes visibility category/enabled state.
|
||||
- Product events must not include vault content, note titles, file paths, filenames, image URLs, prompt text, or other user-authored data.
|
||||
- When a feature needs telemetry, it should add or extend a typed helper rather than sending free-form payloads inline.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Typed categorical wrappers for product events — preserves useful product signals while keeping privacy constraints explicit and reviewable in one place.
|
||||
- **Option B**: Let each feature call `trackEvent()` directly with whatever fields seem useful — faster short term, but inconsistent and too easy to let sensitive data leak into analytics.
|
||||
- **Option C**: Avoid new product events entirely — safest from a privacy standpoint, but leaves the team blind to whether preview, AI, and list-visibility features are actually being used or failing.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Telemetry reviews become easier because the allowed product-event surface is centralized.
|
||||
- Future product analytics work should start by defining categorical event helpers, not by attaching raw note/file context.
|
||||
- Some debugging detail is intentionally sacrificed; deep diagnosis should rely on consented crash reporting and local reproduction rather than richer analytics payloads.
|
||||
- Re-evaluate if Tolaria ever needs a broader telemetry taxonomy or stronger compile-time guarantees around event schemas.
|
||||
@@ -142,3 +142,15 @@ proposed → active → superseded
|
||||
| [0085](0085-non-git-vault-support.md) | Non-git vaults open with explicit later Git initialization | active |
|
||||
| [0088](0088-markdown-durable-mermaid-diagrams.md) | Markdown-durable Mermaid diagrams in notes | active |
|
||||
| [0089](0089-active-vault-filesystem-watcher.md) | Active vault filesystem watcher | active |
|
||||
| [0090](0090-pi-cli-agent-adapter.md) | Pi CLI agent adapter | active |
|
||||
| [0091](0091-gemini-cli-external-ai-setup.md) | Gemini CLI external AI setup | active |
|
||||
| [0092](0092-vault-ai-agent-permission-modes.md) | Vault-scoped AI agent permission modes | active |
|
||||
| [0093](0093-shared-cli-agent-runtime-adapters.md) | Shared CLI agent runtime adapters | active |
|
||||
| [0094](0094-gitignored-content-visibility-boundary-filter.md) | Gitignored content visibility as a command-boundary filter | active |
|
||||
| [0095](0095-saved-view-order-field.md) | Saved views use an explicit YAML order field | active |
|
||||
| [0096](0096-root-created-type-documents.md) | Root-created type documents | 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 |
|
||||
| [0099](0099-cumulative-vault-asset-scope.md) | Cumulative vault asset scope for previews | active |
|
||||
| [0100](0100-synthetic-vault-root-folder-row.md) | Synthetic vault-root row in folder navigation | active |
|
||||
| [0101](0101-categorical-product-analytics-events.md) | Categorical product analytics events | active |
|
||||
|
||||
122
lara.lock
122
lara.lock
@@ -11,9 +11,10 @@ files:
|
||||
command.openSettings: 638c05f4f159a403e04aebe94b46a2a8
|
||||
command.openSettings.keywords: ab6b5e03395f4db955ed9cbfd4de33b7
|
||||
command.openLanguageSettings: e5a425aa6222fab3df66c0e1e0ca4183
|
||||
command.openLanguageSettings.keywords: 68efcb7e847b1a9d472baa609824be80
|
||||
command.openLanguageSettings.keywords: 639afefdd1c238b381bc1f963288f389
|
||||
command.useSystemLanguage: b7f0315c47d4a59faa2a49571fcf1fca
|
||||
command.openH1Setting: 05b4f6edc0e98b29670e8ee902cdb9bf
|
||||
command.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03
|
||||
command.contribute: 9887a4451812854f0f1b6f669a874307
|
||||
command.checkUpdates: f77f38f684c8b974dd4a56bb321cfd5f
|
||||
command.group.navigation: 846495f9ceed11accf8879f555936a7d
|
||||
@@ -65,11 +66,19 @@ files:
|
||||
command.view.toggleProperties: 54f12756a363401243d82cbd0466117e
|
||||
command.view.toggleDiff: 148a10ef6f04f897e73289f529ecae86
|
||||
command.view.toggleRaw: 9b622257cd6345ceaf58e42b1410899b
|
||||
command.view.noteWidthNormal: d29ab88a55ae178bf4b5257201e51801
|
||||
command.view.noteWidthWide: 403f3913ad71451a90787c235f1684de
|
||||
command.view.defaultNoteWidthNormal: b045349e37329df23317db98cccd3263
|
||||
command.view.defaultNoteWidthWide: 41f326763e3e3954912dfd45d04bd87c
|
||||
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
|
||||
@@ -81,6 +90,9 @@ files:
|
||||
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
|
||||
@@ -115,6 +127,18 @@ files:
|
||||
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.allNotesVisibility.title: 0e07c3d48b91e1a4c42c1ff3e5751ec3
|
||||
settings.allNotesVisibility.description: bc9caa48296281401aad694fed65a2d9
|
||||
settings.allNotesVisibility.pdfs: 76663c34a88bd4225613a1a947127bcf
|
||||
settings.allNotesVisibility.pdfsDescription: 33bfa167c125ddd713f40af11d233121
|
||||
settings.allNotesVisibility.images: fff0d600f8a0b5e19e88bfb821dd1157
|
||||
settings.allNotesVisibility.imagesDescription: 54e1fe8f20b426de2fe457322862fb4b
|
||||
settings.allNotesVisibility.unsupported: 91de79ad2f20abd62e445f20584d3b8e
|
||||
settings.allNotesVisibility.unsupportedDescription: c1ecaa108af2de4680ab535585c475e8
|
||||
settings.aiAgents.title: 27f08387b26e1ceeb1b60bd9c97e7a47
|
||||
settings.aiAgents.description: a13222e67eb121676ff6dfc7b085f02d
|
||||
settings.aiAgents.default: 6af573559fd05d8c35bc57b41cc78e24
|
||||
@@ -138,10 +162,93 @@ files:
|
||||
settings.cancel: ea4788705e6873b424c65e91c2846b19
|
||||
settings.save: c9cc8cce247e49bae79f15173ce97354
|
||||
common.cancel: ea4788705e6873b424c65e91c2846b19
|
||||
common.create: 686e697538050e4664636337cc3b834f
|
||||
common.save: c9cc8cce247e49bae79f15173ce97354
|
||||
customize.color: cb5feb1b7314637725a2e73bdc9f7295
|
||||
customize.icon: 817434295a673aed431435658b65d9a7
|
||||
customize.searchIcons: 0688a8098567785a05cb4cf937cbb880
|
||||
customize.noIconsFound: 89c25bea716a589b11599f2bbcf34054
|
||||
customize.template: 278c491bdd8a53618c149c4ac790da34
|
||||
customize.templatePlaceholder: 44482755791f96f37118f3a3bc66f668
|
||||
customize.done: f92965e2c8a7afb3c1b9a5c09a263636
|
||||
viewDialog.title.create: 01e53f288ebe0b7bdda19c8151aa7710
|
||||
viewDialog.title.edit: 6369f1c063cb2c22efa584a19e98d97f
|
||||
viewDialog.description.create: bd77a64c89e304ee2c4ef4abc3636cfe
|
||||
viewDialog.description.edit: 7fa9ed9a54624ea91cdcdfc139090a24
|
||||
viewDialog.nameLabel: 49ee3087348e8d44e1feda1917443987
|
||||
viewDialog.namePlaceholder: dbb077d36957a33c4147eb24919192e1
|
||||
viewDialog.filtersLabel: f3f43e30c8c7d78c6ac0173515e57a00
|
||||
viewDialog.saveError: 80fe81f3ca09ea03af03fdb9ae809eda
|
||||
viewDialog.selectedIcon: 5f98b507ccec0bbc51ca7e8a5b270250
|
||||
viewDialog.selectedColor: 192ec1a9973f328c3620cd3fa670c745
|
||||
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.nav.back: 0557fa923dcee4d0f86b1409f5c2167f
|
||||
sidebar.nav.forward: 67d2f6740a8eaebf4d5c6f79be8da481
|
||||
sidebar.group.favorites: 953bf4d5e8a1807c15dec4e255384b1d
|
||||
sidebar.group.views: 8f56a72923a6ca8ff53462533d89e567
|
||||
sidebar.group.types: 6b20155008db90cf7589c07baa7334e9
|
||||
@@ -149,9 +256,14 @@ files:
|
||||
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.renameType: 327d51d97d4b0e3ddcb7afa39c4bf5fa
|
||||
sidebar.action.customizeIconColor: 9a2f685c74d261ab483ef00dee5314fe
|
||||
sidebar.action.deleteType: c52e15c44f6372c6ddb2758254a041ef
|
||||
sidebar.action.createType: 6cfb8b8aa3fdce38e675819691b48f5c
|
||||
sidebar.action.collapse: 00873bdddd457791609bff1243c40a6b
|
||||
sidebar.action.expand: 8f98e9696f6eed958573012f52710faa
|
||||
@@ -239,6 +351,8 @@ files:
|
||||
editor.find.replaceAll: b1c94ca2fbc3e78fc30069c8d0f01680
|
||||
editor.toolbar.rawReturn: 5ddcf5e0dc8b933b344bb6ca3d8e131d
|
||||
editor.toolbar.rawOpen: 89ad60735a649b60dacd2301cda0c4ec
|
||||
editor.toolbar.noteWidthNormal: 9bb55413ec536c4a8fa41c4c4464ab44
|
||||
editor.toolbar.noteWidthWide: 1a744f406c9150f76739bd167c3e47ec
|
||||
editor.toolbar.centerLayout: 3fabf7e9b285eeec90704d271f1049b7
|
||||
editor.toolbar.leftLayout: acab6c8612816e012ff9f4220a42e485
|
||||
editor.toolbar.removeFavorite: 7188286e36fc6c1892dd53aaa54237fb
|
||||
@@ -256,6 +370,7 @@ files:
|
||||
editor.toolbar.revealFile: 76f4ed52da9937ae432dcf5a108fabb4
|
||||
editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27
|
||||
editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e
|
||||
editor.imageLightbox.title: 2c5a15c875bcdc2478c4a5cad044e10c
|
||||
editor.filename.rename: c31c2b468229232ad6287e734fe67d96
|
||||
editor.filename.renameToTitle: bff34a6a2dd1eb87c59403946679237f
|
||||
editor.filename.trigger: 4e9739c2f937c828bbf5d1f935fe7fb9
|
||||
@@ -297,6 +412,7 @@ files:
|
||||
inspector.info.words: 6f15b8d4b7287d60a8ea3d1c5cbadc84
|
||||
inspector.info.size: 6f6cb72d544962fa333e2e34ce64f719
|
||||
update.available: 992477975168b876be8e77ce2975e390
|
||||
update.checking: a07813cc05571f23ce8dd7039583d7da
|
||||
update.releaseNotes: 5dd03e8d039863e563e049be198c3fd3
|
||||
update.updateNow: 99b1054c0f320be9f109c877a5efd0b2
|
||||
update.dismiss: c8a59e7135a20b362f9c768b09454fdb
|
||||
@@ -318,6 +434,8 @@ files:
|
||||
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
|
||||
@@ -402,5 +520,7 @@ files:
|
||||
locale.ptPT: 71bfc0d79772120b52c1c0782450ff84
|
||||
locale.es419: edbf64ac382b82a54795fb409beb54be
|
||||
locale.zhCN: 1e81fc32fea0e9f3a978661e4b5e484d
|
||||
locale.zhTW: d0bb8c23f6e4f8c8037b6774ba912036
|
||||
locale.jaJP: f32ced6a9ba164c4b3c047fd1d7c882e
|
||||
locale.koKR: d0bdb3cde477d82e766da05ebda50ccb
|
||||
locale.vi: 7b80fae85640c16cdb0261bef0c27636
|
||||
|
||||
@@ -30,27 +30,69 @@ const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
|
||||
// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js).
|
||||
// The bridge relays messages to all other clients (the React frontend).
|
||||
let uiSocket = null
|
||||
let reconnectTimer = null
|
||||
let shutdownStarted = false
|
||||
const RECONNECT_INTERVAL_MS = 3000
|
||||
|
||||
function connectUiBridge() {
|
||||
if (shutdownStarted) return
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(WS_UI_URL)
|
||||
uiSocket = ws
|
||||
ws.on('open', () => {
|
||||
uiSocket = ws
|
||||
if (shutdownStarted) {
|
||||
closeUiSocket()
|
||||
return
|
||||
}
|
||||
console.error(`[mcp] Connected to UI bridge at ${WS_UI_URL}`)
|
||||
})
|
||||
ws.on('close', () => {
|
||||
uiSocket = null
|
||||
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
if (uiSocket === ws) uiSocket = null
|
||||
scheduleUiReconnect()
|
||||
})
|
||||
ws.on('error', () => {
|
||||
// Silent — bridge may not be running yet, will retry
|
||||
})
|
||||
} catch {
|
||||
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
scheduleUiReconnect()
|
||||
}
|
||||
}
|
||||
connectUiBridge()
|
||||
|
||||
function scheduleUiReconnect() {
|
||||
if (shutdownStarted) return
|
||||
|
||||
clearUiReconnectTimer()
|
||||
reconnectTimer = setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
reconnectTimer.unref?.()
|
||||
}
|
||||
|
||||
function clearUiReconnectTimer() {
|
||||
if (!reconnectTimer) return
|
||||
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
function closeUiSocket() {
|
||||
const socket = uiSocket
|
||||
uiSocket = null
|
||||
if (!socket) return
|
||||
|
||||
socket.removeAllListeners()
|
||||
socket.on('error', () => {})
|
||||
if (socket.readyState === WebSocket.CONNECTING) {
|
||||
socket.terminate?.()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
socket.close()
|
||||
} catch {
|
||||
// Ignore close races during process teardown.
|
||||
}
|
||||
socket.terminate?.()
|
||||
}
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
if (!uiSocket || uiSocket.readyState !== WebSocket.OPEN) return
|
||||
@@ -193,10 +235,47 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
}
|
||||
})
|
||||
|
||||
async function shutdown(exitCode = 0) {
|
||||
if (shutdownStarted) return
|
||||
|
||||
shutdownStarted = true
|
||||
clearUiReconnectTimer()
|
||||
closeUiSocket()
|
||||
|
||||
try {
|
||||
await server.close()
|
||||
} catch (error) {
|
||||
console.error(`[mcp] Error while closing server: ${error.message}`)
|
||||
}
|
||||
|
||||
process.exitCode = exitCode
|
||||
setImmediate(() => process.exit(exitCode))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport()
|
||||
server.onclose = () => {
|
||||
void shutdown(0)
|
||||
}
|
||||
process.stdin.once('end', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
process.stdin.once('close', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
process.once('SIGINT', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
process.once('SIGTERM', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
|
||||
connectUiBridge()
|
||||
await server.connect(transport)
|
||||
console.error(`Tolaria MCP server running (vault: ${VAULT_PATH})`)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
void shutdown(1)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, it, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
findMarkdownFiles, getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
@@ -11,6 +13,7 @@ import { evaluateBridgeRequest } from './ws-bridge.js'
|
||||
|
||||
let tmpDir
|
||||
const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
|
||||
const MCP_SERVER_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
before(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
|
||||
@@ -208,6 +211,34 @@ describe('requireVaultPath', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('stdio process lifecycle', () => {
|
||||
it('exits when the MCP client closes stdin', async () => {
|
||||
const child = spawn(process.execPath, ['index.js'], {
|
||||
cwd: MCP_SERVER_DIR,
|
||||
env: { ...process.env, VAULT_PATH: tmpDir, WS_UI_PORT: '65534' },
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
})
|
||||
let stderr = ''
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += chunk
|
||||
})
|
||||
|
||||
await sleep(200)
|
||||
child.stdin.end()
|
||||
|
||||
const exit = await waitForExit(child, 1_500)
|
||||
if (!exit) {
|
||||
child.kill()
|
||||
await waitForExit(child, 1_000)
|
||||
assert.fail(`MCP server stayed alive after stdin closed.\n${stderr}`)
|
||||
}
|
||||
|
||||
assert.equal(exit.signal, null)
|
||||
assert.equal(exit.code, 0, stderr)
|
||||
})
|
||||
})
|
||||
|
||||
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
|
||||
const outsideNote = path.join(outsideDir, 'outside.md')
|
||||
@@ -222,3 +253,28 @@ async function assertRejectsOutsideVault(prefix, resolveNotePath) {
|
||||
await fs.rm(outsideDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function waitForExit(child, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve(null)
|
||||
}, timeoutMs)
|
||||
|
||||
child.once('exit', onExit)
|
||||
|
||||
function onExit(code, signal) {
|
||||
cleanup()
|
||||
resolve({ code, signal })
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(timer)
|
||||
child.off('exit', onExit)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/vault-loading-skeleton.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",
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
--- a/dist/defaultBlocks-DE5GNdJH.js
|
||||
+++ b/dist/defaultBlocks-DE5GNdJH.js
|
||||
@@ -2761,7 +2761,7 @@
|
||||
@@ -1120,6 +1120,8 @@
|
||||
), i.value = t.props.language || e.defaultLanguage || "text", n.isEditable) {
|
||||
const l = (u) => {
|
||||
const d = u.target.value;
|
||||
+ if (!n.getBlock(t.id))
|
||||
+ return;
|
||||
n.updateBlock(t.id, { props: { language: d } });
|
||||
};
|
||||
i.addEventListener("change", l), s = () => i.removeEventListener("change", l);
|
||||
@@ -2651,17 +2653,23 @@
|
||||
if (!a && !(o !== void 0 && r !== void 0) && !s)
|
||||
return;
|
||||
if (this.pluginState = s ? o : r, s || !this.editor.isEditable) {
|
||||
- this.state && (this.state.show = !1), this.emitUpdate(this.pluginState.triggerCharacter);
|
||||
+ if (!this.state)
|
||||
+ return;
|
||||
+ this.state.show = !1, this.emitUpdate(this.pluginState.triggerCharacter);
|
||||
return;
|
||||
}
|
||||
const c = (l = this.rootEl) == null ? void 0 : l.querySelector(
|
||||
`[data-decoration-id="${this.pluginState.decorationId}"]`
|
||||
);
|
||||
- this.editor.isEditable && c && (this.state = {
|
||||
+ if (!c) {
|
||||
+ this.state && (this.state.show = !1, this.emitUpdate(this.pluginState.triggerCharacter));
|
||||
+ return;
|
||||
+ }
|
||||
+ this.state = {
|
||||
show: !0,
|
||||
referencePos: c.getBoundingClientRect().toJSON(),
|
||||
query: this.pluginState.query
|
||||
- }, this.emitUpdate(this.pluginState.triggerCharacter));
|
||||
+ }, this.emitUpdate(this.pluginState.triggerCharacter);
|
||||
}
|
||||
destroy() {
|
||||
var t;
|
||||
@@ -2761,7 +2770,7 @@
|
||||
if (a === s) {
|
||||
const c = r.state.doc;
|
||||
for (const l of t) {
|
||||
@@ -9,13 +45,30 @@
|
||||
if (l === u)
|
||||
return r.dispatch(r.state.tr.insertText(i)), r.dispatch(
|
||||
r.state.tr.setMeta(B, {
|
||||
--- a/src/blocks/Code/block.ts
|
||||
+++ b/src/blocks/Code/block.ts
|
||||
@@ -132,7 +132,11 @@
|
||||
if (editor.isEditable) {
|
||||
const handleLanguageChange = (event: Event) => {
|
||||
const language = (event.target as HTMLSelectElement).value;
|
||||
-
|
||||
+
|
||||
+ if (!editor.getBlock(block.id)) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
editor.updateBlock(block.id, { props: { language } });
|
||||
};
|
||||
select.addEventListener("change", handleLanguageChange);
|
||||
--- a/src/editor/managers/ExtensionManager/extensions.ts
|
||||
+++ b/src/editor/managers/ExtensionManager/extensions.ts
|
||||
@@ -84,7 +84,8 @@
|
||||
@@ -84,7 +84,10 @@
|
||||
}).configure({
|
||||
defaultProtocol: DEFAULT_LINK_PROTOCOL,
|
||||
// only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450
|
||||
- protocols: LINKIFY_INITIALIZED ? [] : VALID_LINK_PROTOCOLS,
|
||||
+ // Tolaria routes editor link clicks through its guarded native opener.
|
||||
+ openOnClick: false,
|
||||
+ // Tolaria pre-registers BlockNote's non-native protocols before linkify initializes.
|
||||
+ protocols: [],
|
||||
}),
|
||||
@@ -34,11 +87,13 @@
|
||||
function mn(o, e) {
|
||||
const t = [
|
||||
I.ClipboardTextSerializer,
|
||||
@@ -2062,7 +2064,8 @@
|
||||
@@ -2062,7 +2064,10 @@
|
||||
}).configure({
|
||||
defaultProtocol: Ct,
|
||||
// only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450
|
||||
- protocols: fe ? [] : Bt
|
||||
+ // Tolaria routes editor link clicks through its guarded native opener.
|
||||
+ openOnClick: !1,
|
||||
+ // Tolaria pre-registers BlockNote's non-native protocols before linkify initializes.
|
||||
+ protocols: []
|
||||
}),
|
||||
@@ -53,6 +108,60 @@
|
||||
}
|
||||
function kn(o, e) {
|
||||
const t = [
|
||||
--- a/src/extensions/SuggestionMenu/SuggestionMenu.ts
|
||||
+++ b/src/extensions/SuggestionMenu/SuggestionMenu.ts
|
||||
@@ -83,29 +83,38 @@
|
||||
this.pluginState = stopped ? prev : next;
|
||||
|
||||
if (stopped || !this.editor.isEditable) {
|
||||
- if (this.state) {
|
||||
- this.state.show = false;
|
||||
- }
|
||||
+ if (!this.state) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ this.state.show = false;
|
||||
this.emitUpdate(this.pluginState!.triggerCharacter);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const decorationNode = this.rootEl?.querySelector(
|
||||
`[data-decoration-id="${this.pluginState!.decorationId}"]`,
|
||||
);
|
||||
|
||||
- if (this.editor.isEditable && decorationNode) {
|
||||
- this.state = {
|
||||
- show: true,
|
||||
- referencePos: decorationNode
|
||||
- .getBoundingClientRect()
|
||||
- .toJSON() as DOMRect,
|
||||
- query: this.pluginState!.query,
|
||||
- };
|
||||
-
|
||||
- this.emitUpdate(this.pluginState!.triggerCharacter!);
|
||||
+ if (!decorationNode) {
|
||||
+ if (this.state) {
|
||||
+ this.state.show = false;
|
||||
+ this.emitUpdate(this.pluginState!.triggerCharacter!);
|
||||
+ }
|
||||
+
|
||||
+ return;
|
||||
}
|
||||
+
|
||||
+ this.state = {
|
||||
+ show: true,
|
||||
+ referencePos: decorationNode
|
||||
+ .getBoundingClientRect()
|
||||
+ .toJSON() as DOMRect,
|
||||
+ query: this.pluginState!.query,
|
||||
+ };
|
||||
+
|
||||
+ this.emitUpdate(this.pluginState!.triggerCharacter!);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
--- a/src/extensions/TableHandles/TableHandles.ts
|
||||
+++ b/src/extensions/TableHandles/TableHandles.ts
|
||||
@@ -572,9 +572,14 @@
|
||||
|
||||
@@ -2,9 +2,21 @@ diff --git a/dist/blocknote-react.js b/dist/blocknote-react.js
|
||||
index 6271d12d2cd5152e924c5bfe78f3b8904844dbfe..12bb96bb36d445a77d51a18d0de446a3f9f0ea59 100644
|
||||
--- a/dist/blocknote-react.js
|
||||
+++ b/dist/blocknote-react.js
|
||||
@@ -157,6 +157,16 @@ function so(e) {
|
||||
const n = "getBoundingClientRect" in e ? () => e.getBoundingClientRect() : () => e.element.getBoundingClientRect();
|
||||
return () => "element" in e && (e.cacheMountedBoundingClientRect ?? !0) ? (e.element.isConnected && (t = n()), t) : n();
|
||||
@@ -155,8 +155,26 @@ var Rt = (e) => {
|
||||
function so(e) {
|
||||
let t = new DOMRect();
|
||||
- const n = "getBoundingClientRect" in e ? () => e.getBoundingClientRect() : () => e.element.getBoundingClientRect();
|
||||
- return () => "element" in e && (e.cacheMountedBoundingClientRect ?? !0) ? (e.element.isConnected && (t = n()), t) : n();
|
||||
+ const n = () => "getBoundingClientRect" in e ? e.getBoundingClientRect() : e.element instanceof Element ? e.element.getBoundingClientRect() : t;
|
||||
+ return () => {
|
||||
+ if (!("element" in e) || !(e.cacheMountedBoundingClientRect ?? !0))
|
||||
+ return n();
|
||||
+ if (!(e.element instanceof Element))
|
||||
+ return n();
|
||||
+ if (e.element.isConnected)
|
||||
+ t = n();
|
||||
+ return t;
|
||||
+ };
|
||||
}
|
||||
+function __bnSafeDomAtPos(e, t) {
|
||||
+ const n = e.prosemirrorView;
|
||||
@@ -19,7 +31,7 @@ index 6271d12d2cd5152e924c5bfe78f3b8904844dbfe..12bb96bb36d445a77d51a18d0de446a3
|
||||
const z = (e) => {
|
||||
var h, b, p;
|
||||
const { refs: t, floatingStyles: n, context: o } = Ze({
|
||||
@@ -216,9 +226,7 @@ const Lt = (e) => {
|
||||
@@ -216,9 +234,7 @@ const Lt = (e) => {
|
||||
const s = Ue(t, c.doc);
|
||||
if (!s)
|
||||
return;
|
||||
@@ -30,7 +42,7 @@ index 6271d12d2cd5152e924c5bfe78f3b8904844dbfe..12bb96bb36d445a77d51a18d0de446a3
|
||||
if (a instanceof Element)
|
||||
return {
|
||||
element: a
|
||||
@@ -3306,14 +3314,15 @@ const pi = (e) => {
|
||||
@@ -3306,14 +3322,15 @@ const pi = (e) => {
|
||||
);
|
||||
if (!m)
|
||||
return {};
|
||||
@@ -49,7 +61,7 @@ index 6271d12d2cd5152e924c5bfe78f3b8904844dbfe..12bb96bb36d445a77d51a18d0de446a3
|
||||
return p instanceof Element ? (d.cellReference = { element: p }, d.rowReference = {
|
||||
element: f,
|
||||
getBoundingClientRect: () => {
|
||||
@@ -4371,7 +4380,7 @@ const El = (e) => {
|
||||
@@ -4371,7 +4388,7 @@ const El = (e) => {
|
||||
const a = Ue(t, c.prosemirrorState.doc);
|
||||
if (!a)
|
||||
return;
|
||||
|
||||
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@@ -6,10 +6,10 @@ settings:
|
||||
|
||||
patchedDependencies:
|
||||
'@blocknote/core@0.46.2':
|
||||
hash: dc394d11ab419b36b8d306cbbb6ce39f0722e042b63b2b7d254c7d7f90782e94
|
||||
hash: 88fd4c0393dc9519af3a96dc4cdb35494424ea6201eff4839a383b41b40c85a0
|
||||
path: patches/@blocknote__core@0.46.2.patch
|
||||
'@blocknote/react@0.46.2':
|
||||
hash: e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97
|
||||
hash: e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76
|
||||
path: patches/@blocknote__react@0.46.2.patch
|
||||
'@tiptap/extension-link@3.19.0':
|
||||
hash: fbe59b1b8b798ba3fefa371c0729b5aa3458e1ea70a4b86cd5267f9f62529284
|
||||
@@ -27,16 +27,16 @@ importers:
|
||||
version: 0.78.0(zod@4.3.6)
|
||||
'@blocknote/code-block':
|
||||
specifier: ^0.46.2
|
||||
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=dc394d11ab419b36b8d306cbbb6ce39f0722e042b63b2b7d254c7d7f90782e94)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
|
||||
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=88fd4c0393dc9519af3a96dc4cdb35494424ea6201eff4839a383b41b40c85a0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
|
||||
'@blocknote/core':
|
||||
specifier: ^0.46.2
|
||||
version: 0.46.2(patch_hash=dc394d11ab419b36b8d306cbbb6ce39f0722e042b63b2b7d254c7d7f90782e94)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
version: 0.46.2(patch_hash=88fd4c0393dc9519af3a96dc4cdb35494424ea6201eff4839a383b41b40c85a0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@blocknote/mantine':
|
||||
specifier: ^0.46.2
|
||||
version: 0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@blocknote/react':
|
||||
specifier: ^0.46.2
|
||||
version: 0.46.2(patch_hash=e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
version: 0.46.2(patch_hash=e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@codemirror/commands':
|
||||
specifier: ^6.10.2
|
||||
version: 6.10.2
|
||||
@@ -5359,9 +5359,9 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=dc394d11ab419b36b8d306cbbb6ce39f0722e042b63b2b7d254c7d7f90782e94)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
|
||||
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=88fd4c0393dc9519af3a96dc4cdb35494424ea6201eff4839a383b41b40c85a0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
|
||||
dependencies:
|
||||
'@blocknote/core': 0.46.2(patch_hash=dc394d11ab419b36b8d306cbbb6ce39f0722e042b63b2b7d254c7d7f90782e94)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@blocknote/core': 0.46.2(patch_hash=88fd4c0393dc9519af3a96dc4cdb35494424ea6201eff4839a383b41b40c85a0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@shikijs/core': 3.23.0
|
||||
'@shikijs/engine-javascript': 3.23.0
|
||||
'@shikijs/langs': 3.23.0
|
||||
@@ -5369,7 +5369,7 @@ snapshots:
|
||||
'@shikijs/themes': 3.23.0
|
||||
'@shikijs/types': 3.22.0
|
||||
|
||||
'@blocknote/core@0.46.2(patch_hash=dc394d11ab419b36b8d306cbbb6ce39f0722e042b63b2b7d254c7d7f90782e94)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
|
||||
'@blocknote/core@0.46.2(patch_hash=88fd4c0393dc9519af3a96dc4cdb35494424ea6201eff4839a383b41b40c85a0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
|
||||
dependencies:
|
||||
'@emoji-mart/data': 1.2.1
|
||||
'@handlewithcare/prosemirror-inputrules': 0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
|
||||
@@ -5421,8 +5421,8 @@ snapshots:
|
||||
|
||||
'@blocknote/mantine@0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@blocknote/core': 0.46.2(patch_hash=dc394d11ab419b36b8d306cbbb6ce39f0722e042b63b2b7d254c7d7f90782e94)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@blocknote/react': 0.46.2(patch_hash=e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@blocknote/core': 0.46.2(patch_hash=88fd4c0393dc9519af3a96dc4cdb35494424ea6201eff4839a383b41b40c85a0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@blocknote/react': 0.46.2(patch_hash=e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@mantine/core': 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@mantine/hooks': 8.3.14(react@19.2.4)
|
||||
'@mantine/utils': 6.0.22(react@19.2.4)
|
||||
@@ -5441,9 +5441,9 @@ snapshots:
|
||||
- sugar-high
|
||||
- supports-color
|
||||
|
||||
'@blocknote/react@0.46.2(patch_hash=e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
'@blocknote/react@0.46.2(patch_hash=e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@blocknote/core': 0.46.2(patch_hash=dc394d11ab419b36b8d306cbbb6ce39f0722e042b63b2b7d254c7d7f90782e94)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@blocknote/core': 0.46.2(patch_hash=88fd4c0393dc9519af3a96dc4cdb35494424ea6201eff4839a383b41b40c85a0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@emoji-mart/data': 1.2.1
|
||||
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::BufRead;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -10,6 +7,15 @@ pub enum AiAgentId {
|
||||
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)]
|
||||
@@ -24,6 +30,7 @@ pub struct AiAgentsStatus {
|
||||
pub codex: AiAgentAvailability,
|
||||
pub opencode: AiAgentAvailability,
|
||||
pub pi: AiAgentAvailability,
|
||||
pub gemini: AiAgentAvailability,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -61,14 +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(),
|
||||
opencode: availability_from_opencode(),
|
||||
pi: availability_from_pi(),
|
||||
codex: crate::codex_cli::check_cli(),
|
||||
opencode: crate::opencode_cli::check_cli(),
|
||||
pi: crate::pi_cli::check_cli(),
|
||||
gemini: crate::gemini_cli::check_cli(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,12 +91,14 @@ pub fn run_ai_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Res
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let permission_mode = request.permission_mode();
|
||||
match request.agent {
|
||||
AiAgentId::ClaudeCode => {
|
||||
let mapped = crate::claude_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::claude_cli::run_agent_stream(mapped, |event| {
|
||||
if let Some(mapped_event) = map_claude_event(event) {
|
||||
@@ -89,12 +106,21 @@ where
|
||||
}
|
||||
})
|
||||
}
|
||||
AiAgentId::Codex => run_codex_agent_stream(request, emit),
|
||||
AiAgentId::Codex => {
|
||||
let mapped = crate::codex_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::codex_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
AiAgentId::Opencode => {
|
||||
let mapped = crate::opencode_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::opencode_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
@@ -103,9 +129,19 @@ where
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,351 +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 availability_from_opencode() -> AiAgentAvailability {
|
||||
crate::opencode_cli::check_cli()
|
||||
}
|
||||
|
||||
fn availability_from_pi() -> AiAgentAvailability {
|
||||
crate::pi_cli::check_cli()
|
||||
}
|
||||
|
||||
fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
crate::hidden_command(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn find_codex_binary() -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_codex_binary_on_path() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_codex_binary_in_user_shell() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_existing_binary(codex_binary_candidates()) {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
|
||||
}
|
||||
|
||||
fn find_codex_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command("which")
|
||||
.arg("codex")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.find_map(|shell| command_path_from_shell(&shell, "codex"))
|
||||
}
|
||||
|
||||
fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
let mut shells = Vec::new();
|
||||
if let Some(shell) = std::env::var_os("SHELL") {
|
||||
if !shell.is_empty() {
|
||||
shells.push(PathBuf::from(shell));
|
||||
}
|
||||
}
|
||||
shells.push(PathBuf::from("/bin/zsh"));
|
||||
shells.push(PathBuf::from("/bin/bash"));
|
||||
shells
|
||||
}
|
||||
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
|
||||
if output.status.success() {
|
||||
first_existing_path(&String::from_utf8_lossy(&output.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
stdout.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
fn codex_binary_candidates() -> Vec<PathBuf> {
|
||||
dirs::home_dir()
|
||||
.map(|home| codex_binary_candidates_for_home(&home))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".npm/bin/codex"),
|
||||
home.join(".bun/bin/codex"),
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
PathBuf::from("/opt/homebrew/bin/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
]
|
||||
}
|
||||
|
||||
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
candidates.into_iter().find(|candidate| candidate.exists())
|
||||
}
|
||||
|
||||
fn run_codex_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = find_codex_binary()?;
|
||||
let args = build_codex_args(&request)?;
|
||||
let prompt = build_codex_prompt(&request);
|
||||
|
||||
let mut command = build_codex_command(&binary, args, prompt, &request.vault_path);
|
||||
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Failed to spawn codex: {error}"))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
|
||||
let mut thread_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
Err(error) => {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format!("Read error: {error}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let json = match serde_json::from_str::<serde_json::Value>(&line) {
|
||||
Ok(json) => json,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(id) = json["thread_id"].as_str() {
|
||||
thread_id = id.to_string();
|
||||
}
|
||||
|
||||
dispatch_codex_event(&json, &mut emit);
|
||||
}
|
||||
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|stderr| std::io::read_to_string(stderr).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|error| format!("Wait failed: {error}"))?;
|
||||
if !status.success() {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format_codex_error(stderr_output, status.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
|
||||
Ok(thread_id)
|
||||
}
|
||||
|
||||
fn build_codex_command(
|
||||
binary: &Path,
|
||||
args: Vec<String>,
|
||||
prompt: String,
|
||||
vault_path: &str,
|
||||
) -> std::process::Command {
|
||||
let mut command = crate::hidden_command(binary);
|
||||
command
|
||||
.args(args)
|
||||
.arg(prompt)
|
||||
.current_dir(vault_path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
command
|
||||
}
|
||||
|
||||
fn build_codex_args(request: &AiAgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
|
||||
let mcp_server_path = mcp_server
|
||||
.to_str()
|
||||
.ok_or("Invalid MCP server path")?
|
||||
.to_string();
|
||||
|
||||
Ok(vec![
|
||||
"--sandbox".into(),
|
||||
"workspace-write".into(),
|
||||
"--ask-for-approval".into(),
|
||||
"never".into(),
|
||||
"exec".into(),
|
||||
"--json".into(),
|
||||
"-C".into(),
|
||||
request.vault_path.clone(),
|
||||
"-c".into(),
|
||||
r#"mcp_servers.tolaria.command="node""#.into(),
|
||||
"-c".into(),
|
||||
format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path),
|
||||
"-c".into(),
|
||||
format!(
|
||||
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#,
|
||||
request.vault_path
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn build_codex_prompt(request: &AiAgentStreamRequest) -> String {
|
||||
match request
|
||||
.system_prompt
|
||||
.as_ref()
|
||||
.map(|prompt| prompt.trim())
|
||||
.filter(|prompt| !prompt.is_empty())
|
||||
{
|
||||
Some(system_prompt) => format!(
|
||||
"System instructions:\n{system_prompt}\n\nUser request:\n{}",
|
||||
request.message
|
||||
),
|
||||
None => request.message.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_codex_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
match json["type"].as_str().unwrap_or_default() {
|
||||
"thread.started" => {
|
||||
if let Some(thread_id) = json["thread_id"].as_str() {
|
||||
emit(AiAgentStreamEvent::Init {
|
||||
session_id: thread_id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
"item.started" => emit_codex_item_event(json, false, emit),
|
||||
"item.completed" => emit_codex_item_event(json, true, emit),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_codex_item_event<F>(json: &serde_json::Value, completed: bool, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let item = &json["item"];
|
||||
let item_type = item["type"].as_str().unwrap_or_default();
|
||||
let item_id = item["id"].as_str().unwrap_or_default();
|
||||
|
||||
match item_type {
|
||||
"command_execution" => {
|
||||
if completed {
|
||||
emit(AiAgentStreamEvent::ToolDone {
|
||||
tool_id: item_id.to_string(),
|
||||
output: item["aggregated_output"]
|
||||
.as_str()
|
||||
.map(|output| output.to_string()),
|
||||
});
|
||||
} else {
|
||||
emit(AiAgentStreamEvent::ToolStart {
|
||||
tool_name: "Bash".into(),
|
||||
tool_id: item_id.to_string(),
|
||||
input: item["command"]
|
||||
.as_str()
|
||||
.map(|command| serde_json::json!({ "command": command }).to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
"agent_message" if completed => {
|
||||
if let Some(text) = item["text"].as_str() {
|
||||
emit(AiAgentStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_codex_error(stderr_output: String, status: String) -> String {
|
||||
let lower = stderr_output.to_ascii_lowercase();
|
||||
if is_codex_auth_error(&lower) {
|
||||
return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into();
|
||||
}
|
||||
|
||||
if is_codex_write_permission_error(&lower) {
|
||||
return "Codex could not write to the active vault. Tolaria starts Codex with a workspace-write sandbox, so verify the selected vault folder is writable and retry; writes outside the active vault remain blocked.".into();
|
||||
}
|
||||
|
||||
if stderr_output.trim().is_empty() {
|
||||
format!("codex exited with status {status}")
|
||||
} else {
|
||||
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_codex_auth_error(lower: &str) -> bool {
|
||||
["auth", "login", "sign in"]
|
||||
.iter()
|
||||
.any(|pattern| lower.contains(pattern))
|
||||
}
|
||||
|
||||
fn is_codex_write_permission_error(lower: &str) -> bool {
|
||||
[
|
||||
"read-only sandbox",
|
||||
"writing is blocked",
|
||||
"rejected by user approval",
|
||||
"rejected by the environment",
|
||||
]
|
||||
.iter()
|
||||
.any(|pattern| lower.contains(pattern))
|
||||
}
|
||||
|
||||
fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAgentStreamEvent> {
|
||||
match event {
|
||||
crate::claude_cli::ClaudeStreamEvent::Init { session_id } => {
|
||||
@@ -499,193 +190,21 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAge
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
#[test]
|
||||
fn normalize_status_contains_all_agents() {
|
||||
let status = get_ai_agents_status();
|
||||
assert!(matches!(status.claude_code.installed, true | false));
|
||||
assert!(matches!(status.codex.installed, true | false));
|
||||
assert!(matches!(status.opencode.installed, true | false));
|
||||
assert!(matches!(status.pi.installed, true | false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_codex_prompt(&AiAgentStreamRequest {
|
||||
agent: AiAgentId::Codex,
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: Some("Be concise".into()),
|
||||
vault_path: "/tmp/vault".into(),
|
||||
});
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_args_uses_safe_default_permissions() {
|
||||
if let Ok(args) = build_codex_args(&AiAgentStreamRequest {
|
||||
agent: AiAgentId::Codex,
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
}) {
|
||||
assert_eq!(args[0], "--sandbox");
|
||||
assert_eq!(args[1], "workspace-write");
|
||||
assert_eq!(args[2], "--ask-for-approval");
|
||||
assert_eq!(args[3], "never");
|
||||
assert_eq!(args[4], "exec");
|
||||
assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
|
||||
assert!(!args.contains(&"danger-full-access".to_string()));
|
||||
assert!(args.contains(&"--json".to_string()));
|
||||
assert!(args.contains(&"-C".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_command_keeps_agent_process_contract() {
|
||||
let binary = PathBuf::from("codex");
|
||||
let args = vec!["exec".to_string(), "--json".to_string()];
|
||||
let command = build_codex_command(&binary, args, "Summarize".into(), "/tmp/vault");
|
||||
let actual_args: Vec<&OsStr> = command.get_args().collect();
|
||||
|
||||
assert_eq!(command.get_program(), OsStr::new("codex"));
|
||||
assert_eq!(
|
||||
actual_args,
|
||||
vec![
|
||||
OsStr::new("exec"),
|
||||
OsStr::new("--json"),
|
||||
OsStr::new("Summarize")
|
||||
]
|
||||
);
|
||||
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_supported_macos_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = codex_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".bun/bin/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
let install_flags = [
|
||||
status.claude_code.installed,
|
||||
status.codex.installed,
|
||||
status.opencode.installed,
|
||||
status.pi.installed,
|
||||
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"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_codex_error_explains_vault_write_permission_failures() {
|
||||
let message = format_codex_error(
|
||||
"The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(),
|
||||
"exit status: 1".into(),
|
||||
);
|
||||
|
||||
assert!(message.contains("active vault"));
|
||||
assert!(message.contains("writable"));
|
||||
assert!(message.contains("outside"));
|
||||
assert!(install_flags
|
||||
.iter()
|
||||
.all(|installed| matches!(installed, true | false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
pub use crate::cli_agent_runtime::AgentStreamRequest;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::io::BufRead;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{ExitStatus, Stdio};
|
||||
|
||||
@@ -50,14 +51,6 @@ pub struct ChatStreamRequest {
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters accepted by Claude Code agent streams.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finding the `claude` binary
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -148,7 +141,7 @@ fn claude_binary_candidates() -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
fn claude_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
let mut candidates = vec![
|
||||
home.join(".local/bin/claude"),
|
||||
home.join(".local/bin/claude.exe"),
|
||||
home.join(".claude/local/claude"),
|
||||
@@ -170,7 +163,24 @@ fn claude_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
home.join("scoop/shims/claude.exe"),
|
||||
PathBuf::from("/opt/homebrew/bin/claude"),
|
||||
PathBuf::from("/usr/local/bin/claude"),
|
||||
]
|
||||
];
|
||||
candidates.extend(nvm_node_binary_candidates_for_home(home, "claude"));
|
||||
candidates
|
||||
}
|
||||
|
||||
fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut candidates = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir())
|
||||
.map(|path| path.join("bin").join(binary_name))
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort();
|
||||
candidates
|
||||
}
|
||||
|
||||
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
@@ -193,16 +203,9 @@ pub fn check_cli() -> ClaudeCliStatus {
|
||||
}
|
||||
};
|
||||
|
||||
let version = crate::hidden_command(&bin)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
|
||||
|
||||
ClaudeCliStatus {
|
||||
installed: true,
|
||||
version,
|
||||
version: crate::cli_agent_runtime::version_for_binary(&bin),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +276,7 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
"--permission-mode".into(),
|
||||
"acceptEdits".into(),
|
||||
"--tools".into(),
|
||||
"Read,Edit,MultiEdit,Write,Glob,Grep,LS".into(),
|
||||
agent_tools(req.permission_mode).into(),
|
||||
"--no-session-persistence".into(),
|
||||
];
|
||||
|
||||
@@ -287,19 +290,25 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn agent_tools(permission_mode: AiAgentPermissionMode) -> &'static str {
|
||||
match permission_mode {
|
||||
AiAgentPermissionMode::Safe => "Read,Edit,MultiEdit,Write,Glob,Grep,LS",
|
||||
AiAgentPermissionMode::PowerUser => "Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash",
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a temporary MCP config JSON string pointing to the vault MCP server.
|
||||
fn build_mcp_config(vault_path: &str) -> Result<String, String> {
|
||||
let server_dir = crate::mcp::mcp_server_dir()?;
|
||||
let index_js = server_dir.join("index.js");
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
let config = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"tolaria": {
|
||||
"command": "node",
|
||||
"args": [index_js.to_string_lossy()],
|
||||
"args": [mcp_server_path],
|
||||
"env": { "VAULT_PATH": vault_path }
|
||||
}
|
||||
}
|
||||
@@ -314,6 +323,8 @@ struct StreamState {
|
||||
tool_inputs: HashMap<String, String>,
|
||||
/// The tool_use id of the block currently being streamed.
|
||||
current_tool_id: Option<String>,
|
||||
/// Tracks whether response text has already been emitted for this run.
|
||||
emitted_text: bool,
|
||||
}
|
||||
|
||||
/// Core subprocess runner shared by chat and agent modes.
|
||||
@@ -327,55 +338,28 @@ fn run_claude_subprocess<F>(
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let mut cmd = build_claude_command(bin, args, cwd);
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
|
||||
let mut state = StreamState {
|
||||
session_id: String::new(),
|
||||
tool_inputs: HashMap::new(),
|
||||
current_tool_id: None,
|
||||
emitted_text: false,
|
||||
};
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
emit(ClaudeStreamEvent::Error {
|
||||
message: format!("Read error: {e}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
let cmd = build_claude_command(bin, args, cwd);
|
||||
let run = crate::cli_agent_runtime::run_json_line_process(
|
||||
cmd,
|
||||
"claude",
|
||||
emit,
|
||||
|message| ClaudeStreamEvent::Error { message },
|
||||
|json, emit, session_id| {
|
||||
dispatch_event(json, &mut state, emit);
|
||||
*session_id = state.session_id.clone();
|
||||
},
|
||||
)?;
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let json: serde_json::Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue, // skip non-JSON lines
|
||||
};
|
||||
|
||||
dispatch_event(&json, &mut state, emit);
|
||||
}
|
||||
|
||||
// Read stderr for potential error messages.
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|s| std::io::read_to_string(s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
|
||||
|
||||
if !status.success() && state.session_id.is_empty() {
|
||||
if !run.status.success() && state.session_id.is_empty() {
|
||||
emit(ClaudeStreamEvent::Error {
|
||||
message: format_failed_claude_exit(&stderr_output, status),
|
||||
message: format_failed_claude_exit(&run.stderr_output, run.status),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -473,7 +457,15 @@ where
|
||||
if !sid.is_empty() {
|
||||
state.session_id = sid.clone();
|
||||
}
|
||||
let text = json["result"].as_str().unwrap_or("").to_string();
|
||||
let text = if state.emitted_text {
|
||||
String::new()
|
||||
} else {
|
||||
let text = json["result"].as_str().unwrap_or("").to_string();
|
||||
if !text.is_empty() {
|
||||
state.emitted_text = true;
|
||||
}
|
||||
text
|
||||
};
|
||||
emit(ClaudeStreamEvent::Result {
|
||||
text,
|
||||
session_id: sid,
|
||||
@@ -483,19 +475,9 @@ where
|
||||
// --- Complete assistant message (fallback for text when no partials) ---
|
||||
"assistant" => {
|
||||
if let Some(content) = json["message"]["content"].as_array() {
|
||||
let emit_text = !state.emitted_text;
|
||||
for block in content {
|
||||
if block["type"].as_str() == Some("tool_use") {
|
||||
if let (Some(id), Some(name)) =
|
||||
(block["id"].as_str(), block["name"].as_str())
|
||||
{
|
||||
let input = format_tool_input(&block["input"], state, id);
|
||||
emit(ClaudeStreamEvent::ToolStart {
|
||||
tool_name: name.to_string(),
|
||||
tool_id: id.to_string(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
dispatch_assistant_content_block(block, emit_text, state, emit);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -518,9 +500,7 @@ where
|
||||
match delta["type"].as_str() {
|
||||
Some("text_delta") => {
|
||||
if let Some(text) = delta["text"].as_str() {
|
||||
emit(ClaudeStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
emit_text_delta(text, state, emit);
|
||||
}
|
||||
}
|
||||
Some("thinking_delta") => {
|
||||
@@ -565,6 +545,46 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_assistant_content_block<F>(
|
||||
block: &serde_json::Value,
|
||||
emit_text: bool,
|
||||
state: &mut StreamState,
|
||||
emit: &mut F,
|
||||
) where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
match block["type"].as_str() {
|
||||
Some("text") if emit_text => {
|
||||
if let Some(text) = block["text"].as_str() {
|
||||
emit_text_delta(text, state, emit);
|
||||
}
|
||||
}
|
||||
Some("tool_use") => {
|
||||
if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) {
|
||||
let input = format_tool_input(&block["input"], state, id);
|
||||
emit(ClaudeStreamEvent::ToolStart {
|
||||
tool_name: name.to_string(),
|
||||
tool_id: id.to_string(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_text_delta<F>(text: &str, state: &mut StreamState, emit: &mut F)
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
if !text.is_empty() {
|
||||
state.emitted_text = true;
|
||||
}
|
||||
emit(ClaudeStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Build the tool input string, preferring accumulated delta chunks over the
|
||||
/// block's `input` field (which may be empty at stream start).
|
||||
fn format_tool_input(
|
||||
@@ -607,6 +627,82 @@ mod tests {
|
||||
use std::ffi::OsString;
|
||||
use std::process::Command;
|
||||
|
||||
macro_rules! chat_request {
|
||||
($message:expr, None, None $(,)?) => {
|
||||
ChatStreamRequest {
|
||||
message: $message.into(),
|
||||
system_prompt: None,
|
||||
session_id: None,
|
||||
}
|
||||
};
|
||||
($message:expr, Some($system_prompt:expr), None $(,)?) => {
|
||||
ChatStreamRequest {
|
||||
message: $message.into(),
|
||||
system_prompt: Some($system_prompt.to_string()),
|
||||
session_id: None,
|
||||
}
|
||||
};
|
||||
($message:expr, None, Some($session_id:expr) $(,)?) => {
|
||||
ChatStreamRequest {
|
||||
message: $message.into(),
|
||||
system_prompt: None,
|
||||
session_id: Some($session_id.to_string()),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! agent_request {
|
||||
($message:expr, None, $permission_mode:expr $(,)?) => {
|
||||
AgentStreamRequest {
|
||||
message: $message.into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: $permission_mode,
|
||||
}
|
||||
};
|
||||
($message:expr, Some($system_prompt:expr), $permission_mode:expr $(,)?) => {
|
||||
AgentStreamRequest {
|
||||
message: $message.into(),
|
||||
system_prompt: Some($system_prompt.to_string()),
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: $permission_mode,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! assert_args_contain {
|
||||
($args:expr, [$($value:expr),+ $(,)?] $(,)?) => {
|
||||
$(
|
||||
assert!($args.contains(&$value.to_string()), "missing {}", $value);
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! assert_args_lack {
|
||||
($args:expr, [$($value:expr),+ $(,)?] $(,)?) => {
|
||||
$(
|
||||
assert!(!$args.contains(&$value.to_string()), "unexpected {}", $value);
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! assert_no_arg_contains {
|
||||
($args:expr, $fragment:expr $(,)?) => {
|
||||
assert!(!$args.iter().any(|arg| arg.contains($fragment)));
|
||||
};
|
||||
}
|
||||
|
||||
fn assert_binary_candidates_include(home: &Path, expected: &[PathBuf]) {
|
||||
let candidates = claude_binary_candidates_for_home(home);
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_cli_returns_status() {
|
||||
let status = check_cli();
|
||||
@@ -617,6 +713,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_binary_candidates_include_nvm_managed_node_installs() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let claude = home.path().join(".nvm/versions/node/v22.12.0/bin/claude");
|
||||
std::fs::create_dir_all(claude.parent().unwrap()).unwrap();
|
||||
std::fs::write(&claude, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let candidates = claude_binary_candidates_for_home(home.path());
|
||||
|
||||
assert!(candidates.contains(&claude), "missing {}", claude.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_args_use_safe_mode_without_bash_by_default() {
|
||||
let args = build_agent_args(&agent_request!(
|
||||
"Rename the note",
|
||||
None,
|
||||
AiAgentPermissionMode::Safe,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_args_contain!(
|
||||
args,
|
||||
["--strict-mcp-config", "--permission-mode", "acceptEdits"]
|
||||
);
|
||||
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]);
|
||||
assert_no_arg_contains!(args, "Bash");
|
||||
assert_args_lack!(args, ["--dangerously-skip-permissions"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_args_allow_bash_in_power_user_mode_without_dangerous_bypass() {
|
||||
let args = build_agent_args(&agent_request!(
|
||||
"Rename the note",
|
||||
None,
|
||||
AiAgentPermissionMode::PowerUser,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_args_contain!(args, ["--strict-mcp-config"]);
|
||||
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS,Bash"]);
|
||||
assert_args_lack!(args, ["--dangerously-skip-permissions"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_mcp_config_is_valid_json() {
|
||||
if let Ok(config_str) = build_mcp_config("/tmp/test-vault") {
|
||||
@@ -636,6 +776,7 @@ mod tests {
|
||||
session_id: String::new(),
|
||||
tool_inputs: HashMap::new(),
|
||||
current_tool_id: None,
|
||||
emitted_text: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -765,10 +906,35 @@ mod tests {
|
||||
{ "type": "tool_use", "id": "tu_1", "name": "search_notes", "input": {} }
|
||||
] }
|
||||
}));
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events.len(), 2);
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1")
|
||||
matches!(&events[0], ClaudeStreamEvent::TextDelta { text } if text == "Let me search.")
|
||||
);
|
||||
assert!(
|
||||
matches!(&events[1], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_result_after_text_delta_does_not_duplicate_response_text() {
|
||||
let (state, events) = run_dispatch_sequence(vec![
|
||||
serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_delta", "delta": { "type": "text_delta", "text": "Visible reply" } }
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "result",
|
||||
"session_id": "session-1",
|
||||
"result": "Visible reply"
|
||||
}),
|
||||
]);
|
||||
|
||||
assert_eq!(state.session_id, "session-1");
|
||||
assert!(matches!(&events[..],
|
||||
[
|
||||
ClaudeStreamEvent::TextDelta { text },
|
||||
ClaudeStreamEvent::Result { text: result_text, session_id },
|
||||
] if text == "Visible reply" && result_text.is_empty() && session_id == "session-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1142,52 +1308,31 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_basic() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hello".into(),
|
||||
system_prompt: None,
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"-p".to_string()));
|
||||
assert!(args.contains(&"hello".to_string()));
|
||||
assert!(args.contains(&"stream-json".to_string()));
|
||||
assert!(!args.contains(&"--system-prompt".to_string()));
|
||||
assert!(!args.contains(&"--resume".to_string()));
|
||||
let args = build_chat_args(&chat_request!("hello", None, None));
|
||||
|
||||
assert_args_contain!(args, ["-p", "hello", "stream-json"]);
|
||||
assert_args_lack!(args, ["--system-prompt", "--resume"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_with_system_prompt() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hi".into(),
|
||||
system_prompt: Some("You are helpful.".into()),
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"--system-prompt".to_string()));
|
||||
assert!(args.contains(&"You are helpful.".to_string()));
|
||||
let args = build_chat_args(&chat_request!("hi", Some("You are helpful."), None));
|
||||
|
||||
assert_args_contain!(args, ["--system-prompt", "You are helpful."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_empty_system_prompt_is_skipped() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hi".into(),
|
||||
system_prompt: Some(String::new()),
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
let args = build_chat_args(&chat_request!("hi", Some(""), None));
|
||||
|
||||
assert!(!args.contains(&"--system-prompt".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_with_session_id() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "continue".into(),
|
||||
system_prompt: None,
|
||||
session_id: Some("sess-abc".into()),
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"--resume".to_string()));
|
||||
assert!(args.contains(&"sess-abc".to_string()));
|
||||
let args = build_chat_args(&chat_request!("continue", None, Some("sess-abc")));
|
||||
|
||||
assert_args_contain!(args, ["--resume", "sess-abc"]);
|
||||
}
|
||||
|
||||
// --- build_agent_args ---
|
||||
@@ -1195,46 +1340,44 @@ mod tests {
|
||||
#[test]
|
||||
fn build_agent_args_basic() {
|
||||
// build_agent_args calls build_mcp_config which needs mcp_server_dir
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "create note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
}) {
|
||||
assert!(args.contains(&"-p".to_string()));
|
||||
assert!(args.contains(&"create note".to_string()));
|
||||
assert!(args.contains(&"--mcp-config".to_string()));
|
||||
assert!(args.contains(&"--strict-mcp-config".to_string()));
|
||||
assert!(args.contains(&"--permission-mode".to_string()));
|
||||
assert!(args.contains(&"acceptEdits".to_string()));
|
||||
assert!(args.contains(&"--tools".to_string()));
|
||||
assert!(args.contains(&"Read,Edit,MultiEdit,Write,Glob,Grep,LS".to_string()));
|
||||
assert!(!args.contains(&"--dangerously-skip-permissions".to_string()));
|
||||
assert!(!args.contains(&"bypassPermissions".to_string()));
|
||||
assert!(!args.contains(&"Bash".to_string()));
|
||||
assert!(args.contains(&"--no-session-persistence".to_string()));
|
||||
assert!(!args.contains(&"--append-system-prompt".to_string()));
|
||||
if let Ok(args) = build_agent_args(&agent_request!(
|
||||
"create note",
|
||||
None,
|
||||
AiAgentPermissionMode::Safe,
|
||||
)) {
|
||||
assert_args_contain!(args, ["-p", "create note", "--mcp-config"]);
|
||||
assert_args_contain!(args, ["--strict-mcp-config", "--permission-mode"]);
|
||||
assert_args_contain!(args, ["acceptEdits", "--tools"]);
|
||||
assert_args_contain!(args, ["Read,Edit,MultiEdit,Write,Glob,Grep,LS"]);
|
||||
assert_args_contain!(args, ["--no-session-persistence"]);
|
||||
assert_args_lack!(
|
||||
args,
|
||||
[
|
||||
"--dangerously-skip-permissions",
|
||||
"bypassPermissions",
|
||||
"--append-system-prompt",
|
||||
],
|
||||
);
|
||||
assert_no_arg_contains!(args, "Bash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_agent_args_with_system_prompt() {
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "do it".into(),
|
||||
system_prompt: Some("Act as expert.".into()),
|
||||
vault_path: "/tmp/v".into(),
|
||||
}) {
|
||||
assert!(args.contains(&"--append-system-prompt".to_string()));
|
||||
assert!(args.contains(&"Act as expert.".to_string()));
|
||||
if let Ok(args) = build_agent_args(&agent_request!(
|
||||
"do it",
|
||||
Some("Act as expert."),
|
||||
AiAgentPermissionMode::Safe,
|
||||
)) {
|
||||
assert_args_contain!(args, ["--append-system-prompt", "Act as expert."]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_agent_args_empty_system_prompt_is_skipped() {
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "x".into(),
|
||||
system_prompt: Some(String::new()),
|
||||
vault_path: "/tmp/v".into(),
|
||||
}) {
|
||||
if let Ok(args) =
|
||||
build_agent_args(&agent_request!("x", Some(""), AiAgentPermissionMode::Safe))
|
||||
{
|
||||
assert!(!args.contains(&"--append-system-prompt".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -1244,7 +1387,6 @@ mod tests {
|
||||
#[test]
|
||||
fn claude_binary_candidates_include_supported_local_and_toolchain_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = claude_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/claude"),
|
||||
home.join(".claude/local/claude"),
|
||||
@@ -1252,32 +1394,19 @@ mod tests {
|
||||
home.join(".npm-global/bin/claude"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
assert_binary_candidates_include(&home, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_binary_candidates_include_windows_exe_installs() {
|
||||
let home = PathBuf::from(r"C:\Users\alex");
|
||||
let candidates = claude_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/claude.exe"),
|
||||
home.join(".claude/local/claude.exe"),
|
||||
home.join("AppData/Roaming/npm/claude.cmd"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
assert_binary_candidates_include(&home, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1333,6 +1462,7 @@ mod tests {
|
||||
message: "test".into(),
|
||||
system_prompt: Some("sys".into()),
|
||||
vault_path: "/tmp/nonexistent".into(),
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
};
|
||||
let mut events = vec![];
|
||||
let result = run_agent_stream(req, |e| events.push(e));
|
||||
|
||||
186
src-tauri/src/cli_agent_runtime.rs
Normal file
186
src-tauri/src/cli_agent_runtime.rs
Normal file
@@ -0,0 +1,186 @@
|
||||
use crate::ai_agents::{AiAgentPermissionMode, AiAgentStreamEvent};
|
||||
use serde::Deserialize;
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, ExitStatus};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
pub permission_mode: AiAgentPermissionMode,
|
||||
}
|
||||
|
||||
pub(crate) struct JsonLineRun {
|
||||
pub session_id: String,
|
||||
pub stderr_output: String,
|
||||
pub status: ExitStatus,
|
||||
}
|
||||
|
||||
pub(crate) fn build_prompt(message: &str, system_prompt: Option<&str>) -> String {
|
||||
match system_prompt
|
||||
.map(str::trim)
|
||||
.filter(|prompt| !prompt.is_empty())
|
||||
{
|
||||
Some(system_prompt) => {
|
||||
format!("System instructions:\n{system_prompt}\n\nUser request:\n{message}")
|
||||
}
|
||||
None => message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_server_path_string() -> Result<String, String> {
|
||||
Ok(crate::mcp::mcp_server_dir()?
|
||||
.join("index.js")
|
||||
.to_str()
|
||||
.ok_or("Invalid MCP server path")?
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
crate::hidden_command(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_json_line(
|
||||
line: Result<String, std::io::Error>,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
let line = line.map_err(|error| format!("Read error: {error}"))?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(serde_json::from_str::<serde_json::Value>(trimmed).ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_ai_agent_json_line<F>(
|
||||
line: Result<String, std::io::Error>,
|
||||
emit: &mut F,
|
||||
) -> Option<serde_json::Value>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
match parse_json_line(line) {
|
||||
Ok(json) => json,
|
||||
Err(message) => {
|
||||
emit(AiAgentStreamEvent::Error { message });
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_json_line_process<Event, F, H>(
|
||||
mut command: Command,
|
||||
process_name: &'static str,
|
||||
emit: &mut F,
|
||||
error_event: impl Fn(String) -> Event,
|
||||
mut handle_json: H,
|
||||
) -> Result<JsonLineRun, String>
|
||||
where
|
||||
F: FnMut(Event),
|
||||
H: FnMut(&serde_json::Value, &mut F, &mut String),
|
||||
{
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Failed to spawn {process_name}: {error}"))?;
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
let mut session_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
match parse_json_line(line) {
|
||||
Ok(Some(json)) => handle_json(&json, emit, &mut session_id),
|
||||
Ok(None) => {}
|
||||
Err(message) => {
|
||||
emit(error_event(message));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|stderr| std::io::read_to_string(stderr).ok())
|
||||
.unwrap_or_default();
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|error| format!("Wait failed: {error}"))?;
|
||||
|
||||
Ok(JsonLineRun {
|
||||
session_id,
|
||||
stderr_output,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn run_ai_agent_json_stream<F>(
|
||||
command: Command,
|
||||
process_name: &'static str,
|
||||
mut emit: F,
|
||||
session_id: impl Fn(&serde_json::Value) -> Option<&str>,
|
||||
dispatch_event: impl Fn(&serde_json::Value, &mut F),
|
||||
format_error: impl Fn(String, String) -> String,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let run = run_json_line_process(
|
||||
command,
|
||||
process_name,
|
||||
&mut emit,
|
||||
|message| AiAgentStreamEvent::Error { message },
|
||||
|json, emit, active_session_id| {
|
||||
if let Some(id) = session_id(json) {
|
||||
*active_session_id = id.to_string();
|
||||
}
|
||||
dispatch_event(json, emit);
|
||||
},
|
||||
)?;
|
||||
|
||||
if !run.status.success() {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format_error(run.stderr_output, run.status.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(run.session_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_prompt("Rename the note", Some("Be concise"));
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_prompt_skips_blank_system_prompt() {
|
||||
assert_eq!(
|
||||
build_prompt("Rename the note", Some(" ")),
|
||||
"Rename the note"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_json_line_reports_read_errors_and_skips_blank_or_invalid_lines() {
|
||||
assert!(parse_json_line(Ok(" ".into())).unwrap().is_none());
|
||||
assert!(parse_json_line(Ok("not json".into())).unwrap().is_none());
|
||||
|
||||
let error = parse_json_line(Err(std::io::Error::other("broken pipe"))).unwrap_err();
|
||||
assert!(error.contains("broken pipe"));
|
||||
}
|
||||
}
|
||||
607
src-tauri/src/codex_cli.rs
Normal file
607
src-tauri/src/codex_cli.rs
Normal file
@@ -0,0 +1,607 @@
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
||||
pub use crate::cli_agent_runtime::AgentStreamRequest;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
|
||||
pub fn check_cli() -> AiAgentAvailability {
|
||||
let binary = match find_codex_binary() {
|
||||
Ok(binary) => binary,
|
||||
Err(_) => {
|
||||
return AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
AiAgentAvailability {
|
||||
installed: true,
|
||||
version: crate::cli_agent_runtime::version_for_binary(&binary),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = find_codex_binary()?;
|
||||
run_agent_stream_with_binary(&binary, request, emit)
|
||||
}
|
||||
|
||||
fn find_codex_binary() -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_codex_binary_on_path() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_codex_binary_in_user_shell() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_existing_binary(codex_binary_candidates()) {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
|
||||
}
|
||||
|
||||
fn find_codex_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command("which")
|
||||
.arg("codex")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.find_map(|shell| command_path_from_shell(&shell, "codex"))
|
||||
}
|
||||
|
||||
fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
let mut shells = Vec::new();
|
||||
if let Some(shell) = std::env::var_os("SHELL") {
|
||||
if !shell.is_empty() {
|
||||
shells.push(PathBuf::from(shell));
|
||||
}
|
||||
}
|
||||
shells.push(PathBuf::from("/bin/zsh"));
|
||||
shells.push(PathBuf::from("/bin/bash"));
|
||||
shells
|
||||
}
|
||||
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
|
||||
if output.status.success() {
|
||||
first_existing_path(&String::from_utf8_lossy(&output.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
stdout.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
fn codex_binary_candidates() -> Vec<PathBuf> {
|
||||
dirs::home_dir()
|
||||
.map(|home| codex_binary_candidates_for_home(&home))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".npm/bin/codex"),
|
||||
home.join(".bun/bin/codex"),
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
PathBuf::from("/opt/homebrew/bin/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
];
|
||||
candidates.extend(nvm_node_binary_candidates_for_home(home, "codex"));
|
||||
candidates
|
||||
}
|
||||
|
||||
fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut candidates = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir())
|
||||
.map(|path| path.join("bin").join(binary_name))
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort();
|
||||
candidates
|
||||
}
|
||||
|
||||
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
candidates.into_iter().find(|candidate| candidate.exists())
|
||||
}
|
||||
|
||||
fn run_agent_stream_with_binary<F>(
|
||||
binary: &Path,
|
||||
request: AgentStreamRequest,
|
||||
emit: F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let args = build_codex_args(&request)?;
|
||||
let prompt = build_codex_prompt(&request);
|
||||
let command = build_codex_command(binary, args, prompt, &request.vault_path);
|
||||
|
||||
crate::cli_agent_runtime::run_ai_agent_json_stream(
|
||||
command,
|
||||
"codex",
|
||||
emit,
|
||||
codex_session_id,
|
||||
dispatch_codex_event,
|
||||
format_codex_error,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_codex_command(
|
||||
binary: &Path,
|
||||
args: Vec<String>,
|
||||
prompt: String,
|
||||
vault_path: &str,
|
||||
) -> std::process::Command {
|
||||
let mut command = crate::hidden_command(binary);
|
||||
command
|
||||
.args(args)
|
||||
.arg(prompt)
|
||||
.current_dir(vault_path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
command
|
||||
}
|
||||
|
||||
fn build_codex_args(request: &AgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
|
||||
Ok(vec![
|
||||
"--sandbox".into(),
|
||||
"workspace-write".into(),
|
||||
"--ask-for-approval".into(),
|
||||
"never".into(),
|
||||
"exec".into(),
|
||||
"--json".into(),
|
||||
"-C".into(),
|
||||
request.vault_path.clone(),
|
||||
"-c".into(),
|
||||
r#"mcp_servers.tolaria.command="node""#.into(),
|
||||
"-c".into(),
|
||||
format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path),
|
||||
"-c".into(),
|
||||
format!(
|
||||
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#,
|
||||
request.vault_path
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn build_codex_prompt(request: &AgentStreamRequest) -> String {
|
||||
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
|
||||
}
|
||||
|
||||
fn codex_session_id(json: &serde_json::Value) -> Option<&str> {
|
||||
json["thread_id"].as_str()
|
||||
}
|
||||
|
||||
fn dispatch_codex_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
match json["type"].as_str().unwrap_or_default() {
|
||||
"thread.started" => {
|
||||
if let Some(thread_id) = json["thread_id"].as_str() {
|
||||
emit(AiAgentStreamEvent::Init {
|
||||
session_id: thread_id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
"item.started" => emit_codex_item_event(json, false, emit),
|
||||
"item.completed" => emit_codex_item_event(json, true, emit),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_codex_item_event<F>(json: &serde_json::Value, completed: bool, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let item = &json["item"];
|
||||
let item_type = item["type"].as_str().unwrap_or_default();
|
||||
let item_id = item["id"].as_str().unwrap_or_default();
|
||||
|
||||
match item_type {
|
||||
"command_execution" => {
|
||||
if completed {
|
||||
emit(AiAgentStreamEvent::ToolDone {
|
||||
tool_id: item_id.to_string(),
|
||||
output: item["aggregated_output"]
|
||||
.as_str()
|
||||
.map(|output| output.to_string()),
|
||||
});
|
||||
} else {
|
||||
emit(AiAgentStreamEvent::ToolStart {
|
||||
tool_name: "Bash".into(),
|
||||
tool_id: item_id.to_string(),
|
||||
input: item["command"]
|
||||
.as_str()
|
||||
.map(|command| serde_json::json!({ "command": command }).to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
"agent_message" if completed => {
|
||||
if let Some(text) = item["text"].as_str() {
|
||||
emit(AiAgentStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_codex_error(stderr_output: String, status: String) -> String {
|
||||
let lower = stderr_output.to_ascii_lowercase();
|
||||
if is_codex_auth_error(&lower) {
|
||||
return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into();
|
||||
}
|
||||
|
||||
if is_codex_write_permission_error(&lower) {
|
||||
return "Codex could not write to the active vault. Tolaria starts Codex with a workspace-write sandbox, so verify the selected vault folder is writable and retry; writes outside the active vault remain blocked.".into();
|
||||
}
|
||||
|
||||
if stderr_output.trim().is_empty() {
|
||||
format!("codex exited with status {status}")
|
||||
} else {
|
||||
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_codex_auth_error(lower: &str) -> bool {
|
||||
["auth", "login", "sign in"]
|
||||
.iter()
|
||||
.any(|pattern| lower.contains(pattern))
|
||||
}
|
||||
|
||||
fn is_codex_write_permission_error(lower: &str) -> bool {
|
||||
[
|
||||
"read-only sandbox",
|
||||
"writing is blocked",
|
||||
"rejected by user approval",
|
||||
"rejected by the environment",
|
||||
]
|
||||
.iter()
|
||||
.any(|pattern| lower.contains(pattern))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn executable_script(dir: &Path, name: &str, body: &str) -> PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let script = dir.join(name);
|
||||
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
script
|
||||
}
|
||||
|
||||
fn codex_request(
|
||||
vault_path: &Path,
|
||||
permission_mode: AiAgentPermissionMode,
|
||||
) -> AgentStreamRequest {
|
||||
AgentStreamRequest {
|
||||
message: "Summarize".into(),
|
||||
system_prompt: None,
|
||||
vault_path: vault_path.to_string_lossy().into_owned(),
|
||||
permission_mode,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_codex_workspace_write_contract(args: &[String]) {
|
||||
let prefix = [
|
||||
"--sandbox",
|
||||
"workspace-write",
|
||||
"--ask-for-approval",
|
||||
"never",
|
||||
];
|
||||
|
||||
assert_eq!(&args[..prefix.len()], prefix);
|
||||
assert!(!args.iter().any(|arg| arg == "danger-full-access"));
|
||||
assert!(!args
|
||||
.iter()
|
||||
.any(|arg| arg == "--dangerously-bypass-approvals-and-sandbox"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_codex_script(body: &str) -> (String, Vec<AiAgentStreamEvent>) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(dir.path(), "codex", body);
|
||||
let mut events = Vec::new();
|
||||
let thread_id = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
codex_request(vault.path(), AiAgentPermissionMode::Safe),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
(thread_id, events)
|
||||
}
|
||||
|
||||
fn assert_codex_text_flow(events: &[AiAgentStreamEvent], session: &str, text_delta: &str) {
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Init { session_id } if session_id == session
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == text_delta
|
||||
));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_codex_prompt(&AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: Some("Be concise".into()),
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
});
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_args_uses_safe_default_permissions() {
|
||||
if let Ok(args) = build_codex_args(&AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
}) {
|
||||
assert_eq!(args[4], "exec");
|
||||
assert_codex_workspace_write_contract(&args);
|
||||
assert!(args.contains(&"--json".to_string()));
|
||||
assert!(args.contains(&"-C".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_permission_modes_keep_workspace_write_without_dangerous_bypass() {
|
||||
for permission_mode in [
|
||||
AiAgentPermissionMode::Safe,
|
||||
AiAgentPermissionMode::PowerUser,
|
||||
] {
|
||||
if let Ok(args) = build_codex_args(&AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode,
|
||||
}) {
|
||||
assert_codex_workspace_write_contract(&args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_command_keeps_agent_process_contract() {
|
||||
let binary = PathBuf::from("codex");
|
||||
let args = vec!["exec".to_string(), "--json".to_string()];
|
||||
let command = build_codex_command(&binary, args, "Summarize".into(), "/tmp/vault");
|
||||
let actual_args: Vec<&OsStr> = command.get_args().collect();
|
||||
|
||||
assert_eq!(command.get_program(), OsStr::new("codex"));
|
||||
assert_eq!(
|
||||
actual_args,
|
||||
vec![
|
||||
OsStr::new("exec"),
|
||||
OsStr::new("--json"),
|
||||
OsStr::new("Summarize")
|
||||
]
|
||||
);
|
||||
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_codex_agent_stream_reads_ndjson_and_returns_thread_id() {
|
||||
let (thread_id, events) = run_codex_script(
|
||||
r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
|
||||
printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Done"}}'
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_eq!(thread_id, "thread_1");
|
||||
assert_codex_text_flow(&events, "thread_1", "Done");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_codex_agent_stream_reports_nonzero_exit_errors() {
|
||||
let (thread_id, events) = run_codex_script(
|
||||
r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
|
||||
printf '%s\n' 'login required' >&2
|
||||
exit 2
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_eq!(thread_id, "thread_1");
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AiAgentStreamEvent::Error { message } if message.contains("not authenticated")
|
||||
)));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_supported_macos_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = codex_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".bun/bin/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_nvm_managed_node_installs() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let codex = home.path().join(".nvm/versions/node/v22.12.0/bin/codex");
|
||||
std::fs::create_dir_all(codex.parent().unwrap()).unwrap();
|
||||
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let candidates = codex_binary_candidates_for_home(home.path());
|
||||
|
||||
assert!(candidates.contains(&codex), "missing {}", codex.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_path_skips_empty_and_missing_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("missing-codex");
|
||||
let codex = dir.path().join("codex");
|
||||
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let stdout = format!("\n{}\n{}\n", missing.display(), codex.display());
|
||||
|
||||
assert_eq!(first_existing_path(&stdout), Some(codex));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn command_path_from_shell_finds_codex_from_login_shell() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let codex = dir.path().join("codex");
|
||||
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let shell = dir.path().join("shell");
|
||||
std::fs::write(
|
||||
&shell,
|
||||
format!(
|
||||
"#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n",
|
||||
codex.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_codex_command_events_maps_to_bash_events() {
|
||||
let mut events = Vec::new();
|
||||
let started = serde_json::json!({
|
||||
"type": "item.started",
|
||||
"item": {
|
||||
"id": "item_1",
|
||||
"type": "command_execution",
|
||||
"command": "/bin/zsh -lc pwd"
|
||||
}
|
||||
});
|
||||
let completed = serde_json::json!({
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
"id": "item_1",
|
||||
"type": "command_execution",
|
||||
"aggregated_output": "/private/tmp\n"
|
||||
}
|
||||
});
|
||||
|
||||
dispatch_codex_event(&started, &mut |event| events.push(event));
|
||||
dispatch_codex_event(&completed, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ToolStart { tool_name, tool_id, .. }
|
||||
if tool_name == "Bash" && tool_id == "item_1"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ToolDone { tool_id, output }
|
||||
if tool_id == "item_1" && output.as_deref() == Some("/private/tmp\n")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_codex_agent_message_maps_to_text_delta() {
|
||||
let mut events = Vec::new();
|
||||
let completed = serde_json::json!({
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
"id": "item_2",
|
||||
"type": "agent_message",
|
||||
"text": "All set"
|
||||
}
|
||||
});
|
||||
|
||||
dispatch_codex_event(&completed, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "All set"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_codex_error_explains_vault_write_permission_failures() {
|
||||
let message = format_codex_error(
|
||||
"The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(),
|
||||
"exit status: 1".into(),
|
||||
);
|
||||
|
||||
assert!(message.contains("active vault"));
|
||||
assert!(message.contains("writable"));
|
||||
assert!(message.contains("outside"));
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,10 @@ pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
gemini: crate::ai_agents::AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,15 @@ pub async fn check_mcp_status(vault_path: String) -> Result<crate::mcp::McpStatu
|
||||
.map_err(|e| format!("MCP status check failed: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn get_mcp_config_snippet(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = super::expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || crate::mcp::mcp_config_snippet(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("MCP config task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn sync_mcp_bridge_vault(
|
||||
@@ -167,6 +176,12 @@ pub async fn check_mcp_status(_vault_path: String) -> Result<crate::mcp::McpStat
|
||||
Ok(crate::mcp::McpStatus::NotInstalled)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn get_mcp_config_snippet(_vault_path: String) -> Result<String, String> {
|
||||
Err("MCP is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn sync_mcp_bridge_vault(_vault_path: Option<String>) -> Result<String, String> {
|
||||
|
||||
@@ -53,6 +53,7 @@ mod tests {
|
||||
name: "Inbox".to_string(),
|
||||
icon: None,
|
||||
color: None,
|
||||
order: None,
|
||||
sort: None,
|
||||
list_properties_display: vec![],
|
||||
filters: crate::vault::FilterGroup::All(vec![]),
|
||||
|
||||
@@ -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,
|
||||
@@ -341,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(¬e, "# 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,15 +9,36 @@ use std::path::Path;
|
||||
pub use ops::update_frontmatter_content;
|
||||
pub use yaml::{format_yaml_key, FrontmatterValue};
|
||||
|
||||
fn is_markdown_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| {
|
||||
extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown")
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_frontmatter_path(path: &str, file_path: &Path) -> Result<(), String> {
|
||||
if !file_path.exists() {
|
||||
return Err(format!("File does not exist: {}", path));
|
||||
}
|
||||
|
||||
if !is_markdown_path(file_path) {
|
||||
return Err(format!(
|
||||
"Frontmatter can only be updated on Markdown notes: {}",
|
||||
path
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper to read a file, apply a frontmatter transformation, and write back.
|
||||
pub fn with_frontmatter<F>(path: &str, transform: F) -> Result<String, String>
|
||||
where
|
||||
F: FnOnce(&str) -> Result<String, String>,
|
||||
{
|
||||
let file_path = Path::new(path);
|
||||
if !file_path.exists() {
|
||||
return Err(format!("File does not exist: {}", path));
|
||||
}
|
||||
validate_frontmatter_path(path, file_path)?;
|
||||
|
||||
let content =
|
||||
fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))?;
|
||||
@@ -58,6 +79,25 @@ mod tests {
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_rejects_binary_attachment_before_utf8_read() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let attachment_dir = dir.path().join("attachments");
|
||||
fs::create_dir_all(&attachment_dir).unwrap();
|
||||
let attachment_path = attachment_dir.join("screenshot.png");
|
||||
fs::write(&attachment_path, [0xff, 0xfe, 0xfd]).unwrap();
|
||||
|
||||
let err = update_frontmatter(
|
||||
attachment_path.to_str().unwrap(),
|
||||
"Status",
|
||||
FrontmatterValue::String("Done".to_string()),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("Frontmatter can only be updated on Markdown notes"));
|
||||
assert!(err.contains("screenshot.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_update_string() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
|
||||
220
src-tauri/src/gemini_cli.rs
Normal file
220
src-tauri/src/gemini_cli.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
164
src-tauri/src/gemini_config.rs
Normal file
164
src-tauri/src/gemini_config.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
187
src-tauri/src/gemini_discovery.rs
Normal file
187
src-tauri/src/gemini_discovery.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
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)]
|
||||
@@ -27,6 +32,8 @@ mod window_state;
|
||||
use std::ffi::OsStr;
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
use std::os::unix::process::CommandExt;
|
||||
#[cfg(desktop)]
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(desktop)]
|
||||
@@ -56,7 +63,7 @@ fn suppress_windows_console(_command: &mut Command) {}
|
||||
struct WsBridgeChild(Mutex<Option<Child>>);
|
||||
|
||||
#[cfg(desktop)]
|
||||
struct ActiveAssetScopeRoots(Mutex<Vec<PathBuf>>);
|
||||
struct AllowedAssetScopeRoots(Mutex<Vec<PathBuf>>);
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -65,15 +72,67 @@ struct StartupEnvOverride {
|
||||
value: &'static str,
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
const WAYLAND_CLIENT_PRELOAD_CANDIDATES: [&str; 7] = [
|
||||
"/usr/lib/libwayland-client.so",
|
||||
"/usr/lib/libwayland-client.so.0",
|
||||
"/usr/lib64/libwayland-client.so",
|
||||
"/usr/lib64/libwayland-client.so.0",
|
||||
"/lib64/libwayland-client.so.0",
|
||||
"/lib/x86_64-linux-gnu/libwayland-client.so.0",
|
||||
"/usr/lib/x86_64-linux-gnu/libwayland-client.so.0",
|
||||
];
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
fn is_linux_appimage_launch<F>(mut get_var: F) -> bool
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
["APPIMAGE", "APPDIR"]
|
||||
.into_iter()
|
||||
.any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty()))
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
fn is_wayland_session<F>(mut get_var: F) -> bool
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
get_var("WAYLAND_DISPLAY").is_some_and(|value| !value.trim().is_empty())
|
||||
|| get_var("XDG_SESSION_TYPE")
|
||||
.is_some_and(|value| value.trim().eq_ignore_ascii_case("wayland"))
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
fn linux_appimage_wayland_client_preload_path_with<F, E>(
|
||||
mut get_var: F,
|
||||
mut file_exists: E,
|
||||
) -> Option<&'static str>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
E: FnMut(&str) -> bool,
|
||||
{
|
||||
if !is_linux_appimage_launch(&mut get_var) || !is_wayland_session(&mut get_var) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if get_var("LD_PRELOAD").is_some_and(|value| !value.trim().is_empty())
|
||||
|| get_var("TOLARIA_APPIMAGE_WAYLAND_PRELOAD_ATTEMPTED").is_some_and(|value| value == "1")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
WAYLAND_CLIENT_PRELOAD_CANDIDATES
|
||||
.into_iter()
|
||||
.find(|path| file_exists(path))
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
fn linux_appimage_startup_env_overrides_with<F>(mut get_var: F) -> Vec<StartupEnvOverride>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
let is_appimage = ["APPIMAGE", "APPDIR"]
|
||||
.into_iter()
|
||||
.any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty()));
|
||||
if !is_appimage {
|
||||
if !is_linux_appimage_launch(&mut get_var) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
@@ -89,11 +148,40 @@ where
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn apply_linux_appimage_startup_env_overrides() {
|
||||
apply_linux_appimage_wayland_client_preload();
|
||||
|
||||
for env_override in linux_appimage_startup_env_overrides_with(|key| std::env::var(key).ok()) {
|
||||
std::env::set_var(env_override.key, env_override.value);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn apply_linux_appimage_wayland_client_preload() {
|
||||
let Some(preload_path) = linux_appimage_wayland_client_preload_path_with(
|
||||
|key| std::env::var(key).ok(),
|
||||
|path| std::path::Path::new(path).is_file(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let exe = match std::env::current_exe() {
|
||||
Ok(exe) => exe,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Tolaria AppImage Wayland preload skipped: failed to resolve executable ({e})"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let error = Command::new(exe)
|
||||
.args(std::env::args_os().skip(1))
|
||||
.env("LD_PRELOAD", preload_path)
|
||||
.env("TOLARIA_APPIMAGE_WAYLAND_PRELOAD_ATTEMPTED", "1")
|
||||
.exec();
|
||||
eprintln!("Tolaria AppImage Wayland preload skipped: failed to re-exec ({error})");
|
||||
}
|
||||
|
||||
#[cfg(not(all(desktop, target_os = "linux")))]
|
||||
fn apply_linux_appimage_startup_env_overrides() {}
|
||||
|
||||
@@ -330,6 +418,18 @@ fn vault_asset_scope_roots(vault_path: &Path) -> Result<Vec<PathBuf>, String> {
|
||||
Ok(roots)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn missing_asset_scope_roots(
|
||||
allowed_roots: &[PathBuf],
|
||||
requested_roots: &[PathBuf],
|
||||
) -> Vec<PathBuf> {
|
||||
requested_roots
|
||||
.iter()
|
||||
.filter(|root| !allowed_roots.contains(root))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
pub(crate) fn sync_vault_asset_scope(
|
||||
app_handle: &tauri::AppHandle,
|
||||
@@ -337,27 +437,22 @@ pub(crate) fn sync_vault_asset_scope(
|
||||
) -> Result<(), String> {
|
||||
use tauri::Manager;
|
||||
|
||||
let next_roots = vault_asset_scope_roots(vault_path)?;
|
||||
let requested_roots = vault_asset_scope_roots(vault_path)?;
|
||||
let scope = app_handle.asset_protocol_scope();
|
||||
let state: tauri::State<'_, ActiveAssetScopeRoots> = app_handle.state();
|
||||
let mut active_roots = state
|
||||
let state: tauri::State<'_, AllowedAssetScopeRoots> = app_handle.state();
|
||||
let mut allowed_roots = state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock active asset scope state".to_string())?;
|
||||
.map_err(|_| "Failed to lock asset scope state".to_string())?;
|
||||
let roots_to_allow = missing_asset_scope_roots(&allowed_roots, &requested_roots);
|
||||
|
||||
for root in &next_roots {
|
||||
for root in &roots_to_allow {
|
||||
scope
|
||||
.allow_directory(root, true)
|
||||
.map_err(|e| format!("Failed to allow asset access for {}: {e}", root.display()))?;
|
||||
}
|
||||
|
||||
for previous_root in active_roots.iter() {
|
||||
if !next_roots.contains(previous_root) {
|
||||
let _ = scope.forbid_directory(previous_root, true);
|
||||
}
|
||||
}
|
||||
|
||||
*active_roots = next_roots;
|
||||
allowed_roots.extend(roots_to_allow);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -367,6 +462,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,
|
||||
@@ -405,6 +501,7 @@ macro_rules! app_invoke_handler {
|
||||
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,
|
||||
@@ -435,6 +532,7 @@ macro_rules! app_invoke_handler {
|
||||
commands::register_mcp_tools,
|
||||
commands::remove_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
commands::get_mcp_config_snippet,
|
||||
commands::sync_mcp_bridge_vault,
|
||||
commands::repair_vault,
|
||||
commands::reinit_telemetry,
|
||||
@@ -460,10 +558,7 @@ fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(ref mut child) = *guard {
|
||||
let _ = child.kill();
|
||||
log::info!("ws-bridge child process killed on exit");
|
||||
}
|
||||
stop_ws_bridge_child(&mut guard);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,7 +570,7 @@ pub fn run() {
|
||||
#[cfg(desktop)]
|
||||
let builder = builder
|
||||
.manage(WsBridgeChild(Mutex::new(None)))
|
||||
.manage(ActiveAssetScopeRoots(Mutex::new(Vec::new())))
|
||||
.manage(AllowedAssetScopeRoots(Mutex::new(Vec::new())))
|
||||
.manage(window_state::MainWindowFrameState::default())
|
||||
.manage(vault_watcher::VaultWatcherState::new());
|
||||
|
||||
@@ -492,14 +587,19 @@ pub fn run() {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::linux_appimage_startup_env_overrides_with;
|
||||
use super::linux_appimage_wayland_client_preload_path_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};
|
||||
use super::{
|
||||
missing_asset_scope_roots, selected_mcp_bridge_vault_path, validate_mcp_bridge_vault_path,
|
||||
};
|
||||
#[cfg(desktop)]
|
||||
use crate::vault_list::VaultList;
|
||||
#[cfg(desktop)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(all(desktop, unix))]
|
||||
use super::vault_asset_scope_roots;
|
||||
@@ -544,6 +644,52 @@ mod tests {
|
||||
assert!(overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_appimage_wayland_preload_uses_first_available_system_library() {
|
||||
let preload_path = linux_appimage_wayland_client_preload_path_with(
|
||||
|key| match key {
|
||||
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
|
||||
"XDG_SESSION_TYPE" => Some("wayland".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
|path| path == "/lib/x86_64-linux-gnu/libwayland-client.so.0",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
preload_path,
|
||||
Some("/lib/x86_64-linux-gnu/libwayland-client.so.0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_appimage_wayland_preload_preserves_explicit_ld_preload() {
|
||||
let preload_path = linux_appimage_wayland_client_preload_path_with(
|
||||
|key| match key {
|
||||
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
|
||||
"WAYLAND_DISPLAY" => Some("wayland-0".to_string()),
|
||||
"LD_PRELOAD" => Some("/custom/libwayland-client.so".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
|_| true,
|
||||
);
|
||||
|
||||
assert_eq!(preload_path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_appimage_wayland_preload_is_empty_for_x11_sessions() {
|
||||
let preload_path = linux_appimage_wayland_client_preload_path_with(
|
||||
|key| match key {
|
||||
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
|
||||
"XDG_SESSION_TYPE" => Some("x11".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
|_| true,
|
||||
);
|
||||
|
||||
assert_eq!(preload_path, None);
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn selected_mcp_bridge_vault_path_uses_persisted_active_vault() {
|
||||
@@ -600,4 +746,18 @@ mod tests {
|
||||
assert_eq!(roots[0], canonical_vault.canonicalize().unwrap());
|
||||
assert!(roots.contains(&symlinked_vault));
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn missing_asset_scope_roots_keeps_previously_allowed_vaults() {
|
||||
let vault_a = PathBuf::from("/vault-a");
|
||||
let vault_b = PathBuf::from("/vault-b");
|
||||
let allowed_roots = vec![vault_a.clone()];
|
||||
|
||||
assert_eq!(
|
||||
missing_asset_scope_roots(&allowed_roots, &[vault_b.clone()]),
|
||||
vec![vault_b]
|
||||
);
|
||||
assert!(missing_asset_scope_roots(&allowed_roots, &[vault_a]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,30 +17,88 @@ pub enum McpStatus {
|
||||
|
||||
/// Find the `node` binary path at runtime.
|
||||
pub(crate) fn find_node() -> Result<PathBuf, String> {
|
||||
let output = node_lookup_command()
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to locate node on PATH: {e}"))?;
|
||||
if output.status.success() {
|
||||
if let Some(path) = first_node_lookup_path(&output.stdout) {
|
||||
verify_node_version(&path)?;
|
||||
return Ok(path);
|
||||
let mut last_error = None;
|
||||
for path in node_binary_candidates() {
|
||||
match verify_node_version(&path) {
|
||||
Ok(()) => return Ok(path),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path) = fallback_node_path() {
|
||||
verify_node_version(&path)?;
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
Err("node not found in PATH or common install locations".into())
|
||||
Err(last_error.unwrap_or_else(|| "node not found in PATH or common install locations".into()))
|
||||
}
|
||||
|
||||
fn first_node_lookup_path(stdout: &[u8]) -> Option<PathBuf> {
|
||||
fn node_binary_candidates() -> Vec<PathBuf> {
|
||||
let mut candidates = find_node_on_path();
|
||||
candidates.extend(find_node_in_user_shell());
|
||||
candidates.extend(fallback_node_paths());
|
||||
candidates
|
||||
}
|
||||
|
||||
fn find_node_on_path() -> Vec<PathBuf> {
|
||||
node_lookup_command()
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| node_lookup_paths(&output.stdout))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn find_node_in_user_shell() -> Vec<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.filter_map(|shell| command_path_from_shell(&shell, "node"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn node_lookup_paths(stdout: &[u8]) -> Vec<PathBuf> {
|
||||
String::from_utf8_lossy(stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
let mut shells = Vec::new();
|
||||
if let Some(shell) = std::env::var_os("SHELL") {
|
||||
if !shell.is_empty() {
|
||||
shells.push(PathBuf::from(shell));
|
||||
}
|
||||
}
|
||||
shells.push(PathBuf::from("/bin/zsh"));
|
||||
shells.push(PathBuf::from("/bin/bash"));
|
||||
shells
|
||||
}
|
||||
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
crate::hidden_command(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
|
||||
if output.status.success() {
|
||||
first_existing_path(&String::from_utf8_lossy(&output.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
stdout.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_node_version(node: &Path) -> Result<(), String> {
|
||||
@@ -91,7 +149,7 @@ fn node_lookup_command() -> Command {
|
||||
command
|
||||
}
|
||||
|
||||
fn fallback_node_path() -> Option<PathBuf> {
|
||||
fn fallback_node_paths() -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
PathBuf::from("/opt/homebrew/bin/node"),
|
||||
PathBuf::from("/usr/local/bin/node"),
|
||||
@@ -120,24 +178,39 @@ fn fallback_node_path() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.push(home.join(".volta").join("bin").join(node_binary_name()));
|
||||
|
||||
let nvm_dir = home.join(".nvm").join("versions").join("node");
|
||||
if let Ok(entries) = std::fs::read_dir(nvm_dir) {
|
||||
let mut versions = entries
|
||||
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||
.collect::<Vec<_>>();
|
||||
versions.sort();
|
||||
versions.reverse();
|
||||
candidates.extend(
|
||||
versions
|
||||
.into_iter()
|
||||
.map(|version| version.join("bin").join("node")),
|
||||
);
|
||||
}
|
||||
candidates.extend(node_binary_candidates_for_home(&home));
|
||||
}
|
||||
|
||||
candidates.into_iter().find(|path| path.is_file())
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter(|path| path.is_file())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn node_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
home.join(".local/share/mise/shims")
|
||||
.join(node_binary_name()),
|
||||
home.join(".mise").join("shims").join(node_binary_name()),
|
||||
home.join(".asdf").join("shims").join(node_binary_name()),
|
||||
home.join(".volta").join("bin").join(node_binary_name()),
|
||||
];
|
||||
|
||||
let nvm_dir = home.join(".nvm").join("versions").join("node");
|
||||
if let Ok(entries) = std::fs::read_dir(nvm_dir) {
|
||||
let mut versions = entries
|
||||
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||
.collect::<Vec<_>>();
|
||||
versions.sort();
|
||||
versions.reverse();
|
||||
candidates.extend(
|
||||
versions
|
||||
.into_iter()
|
||||
.map(|version| version.join("bin").join("node")),
|
||||
);
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn node_binary_name() -> &'static str {
|
||||
@@ -250,6 +323,7 @@ fn mcp_config_paths_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".claude.json"),
|
||||
home.join(".claude").join("mcp.json"),
|
||||
home.join(".gemini").join("settings.json"),
|
||||
home.join(".cursor").join("mcp.json"),
|
||||
home.join(".config").join("mcp").join("mcp.json"),
|
||||
]
|
||||
@@ -313,6 +387,28 @@ fn build_mcp_entry(node_command: &str, index_js: &str, vault_path: &str) -> serd
|
||||
})
|
||||
}
|
||||
|
||||
fn build_mcp_config_snippet(entry: &serde_json::Value) -> Result<String, String> {
|
||||
let mut servers = serde_json::Map::new();
|
||||
servers.insert(MCP_SERVER_NAME.to_string(), entry.clone());
|
||||
let config = serde_json::json!({ "mcpServers": servers });
|
||||
|
||||
serde_json::to_string_pretty(&config)
|
||||
.map_err(|e| format!("Failed to serialize MCP config snippet: {e}"))
|
||||
}
|
||||
|
||||
/// Build the exact MCP config JSON users can copy into compatible tools.
|
||||
pub fn mcp_config_snippet(vault_path: &str) -> Result<String, String> {
|
||||
let node = find_node().map_err(|e| {
|
||||
format!("Node.js 18+ is required on PATH before Tolaria can build MCP config: {e}")
|
||||
})?;
|
||||
let server_dir = mcp_server_dir()?;
|
||||
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
|
||||
let node_command = node.to_string_lossy().into_owned();
|
||||
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
|
||||
|
||||
build_mcp_config_snippet(&entry)
|
||||
}
|
||||
|
||||
/// Write MCP registration to a list of config file paths.
|
||||
/// Returns "registered" on first registration, "updated" if already present.
|
||||
fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf]) -> String {
|
||||
@@ -502,6 +598,20 @@ mod tests {
|
||||
write_config_json(config_path, serde_json::json!({ "mcpServers": servers }));
|
||||
}
|
||||
|
||||
struct ExpectedMcpServer<'a> {
|
||||
index_js: &'a str,
|
||||
vault_path: &'a str,
|
||||
}
|
||||
|
||||
fn assert_registered_tolaria_server(
|
||||
config: &serde_json::Value,
|
||||
expected: ExpectedMcpServer<'_>,
|
||||
) {
|
||||
let server = &config["mcpServers"][MCP_SERVER_NAME];
|
||||
assert_eq!(server["args"][0], expected.index_js);
|
||||
assert_eq!(server["env"]["VAULT_PATH"], expected.vault_path);
|
||||
}
|
||||
|
||||
fn write_index_js(dir: &Path) -> PathBuf {
|
||||
let index_js = dir.join("index.js");
|
||||
std::fs::write(&index_js, "console.log('ok');").unwrap();
|
||||
@@ -519,14 +629,69 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_node_lookup_path_uses_first_non_empty_line() {
|
||||
fn build_mcp_config_snippet_wraps_tolaria_server_entry() {
|
||||
let entry = test_mcp_entry("/path/to/index.js", "/my/vault");
|
||||
let snippet = build_mcp_config_snippet(&entry).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&snippet).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
|
||||
"/path/to/index.js"
|
||||
);
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
|
||||
"/my/vault"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_lookup_paths_keep_non_empty_lines_in_order() {
|
||||
let stdout = b"\nC:\\Program Files\\nodejs\\node.exe\r\nC:\\Other\\node.exe\r\n";
|
||||
assert_eq!(
|
||||
first_node_lookup_path(stdout).unwrap(),
|
||||
PathBuf::from("C:\\Program Files\\nodejs\\node.exe")
|
||||
node_lookup_paths(stdout),
|
||||
vec![
|
||||
PathBuf::from("C:\\Program Files\\nodejs\\node.exe"),
|
||||
PathBuf::from("C:\\Other\\node.exe"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_path_skips_empty_and_missing_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("missing-node");
|
||||
let node = dir.path().join("node");
|
||||
std::fs::write(&node, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let stdout = format!("\n{}\n{}\n", missing.display(), node.display());
|
||||
|
||||
assert_eq!(first_existing_path(&stdout), Some(node));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn command_path_from_shell_finds_node_from_login_shell() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let node = dir.path().join("node");
|
||||
std::fs::write(&node, "#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let shell = dir.path().join("shell");
|
||||
std::fs::write(
|
||||
&shell,
|
||||
format!(
|
||||
"#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n",
|
||||
node.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
assert_eq!(command_path_from_shell(&shell, "node"), Some(node));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_major_version_accepts_current_node_output() {
|
||||
assert_eq!(node_major_version("v24.13.1\n"), Some(24));
|
||||
@@ -534,6 +699,25 @@ mod tests {
|
||||
assert_eq!(node_major_version("not-node"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_binary_candidates_include_shell_managed_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = node_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/share/mise/shims/node"),
|
||||
home.join(".asdf/shims/node"),
|
||||
home.join(".volta/bin/node"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_dir_candidates_prefer_exe_dir_before_macos_resources() {
|
||||
let dev_path = Path::new("/repo/mcp-server");
|
||||
@@ -565,13 +749,12 @@ mod tests {
|
||||
assert!(!was_update);
|
||||
|
||||
let config = read_config(&config_path);
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
|
||||
"/test/index.js"
|
||||
);
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
|
||||
"/test/vault"
|
||||
assert_registered_tolaria_server(
|
||||
&config,
|
||||
ExpectedMcpServer {
|
||||
index_js: "/test/index.js",
|
||||
vault_path: "/test/vault",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -666,6 +849,35 @@ mod tests {
|
||||
assert!(config["mcpServers"][MCP_SERVER_NAME].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_gemini_settings_json_fields() {
|
||||
let (_tmp, config_path) = temp_config_path("settings.json");
|
||||
write_config_json(
|
||||
&config_path,
|
||||
serde_json::json!({
|
||||
"theme": "GitHub",
|
||||
"mcpServers": {
|
||||
"other": { "command": "example" }
|
||||
}
|
||||
}),
|
||||
);
|
||||
let entry = test_mcp_entry("/gemini/index.js", "/gemini-vault");
|
||||
|
||||
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
let config = read_config(&config_path);
|
||||
|
||||
assert!(!was_update);
|
||||
assert_eq!(config["theme"], "GitHub");
|
||||
assert_eq!(config["mcpServers"]["other"]["command"], "example");
|
||||
assert_registered_tolaria_server(
|
||||
&config,
|
||||
ExpectedMcpServer {
|
||||
index_js: "/gemini/index.js",
|
||||
vault_path: "/gemini-vault",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_creates_parent_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -736,6 +948,7 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let claude_user_cfg = tmp.path().join(".claude.json");
|
||||
let claude_cfg = tmp.path().join("claude").join("mcp.json");
|
||||
let gemini_cfg = tmp.path().join(".gemini").join("settings.json");
|
||||
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
|
||||
let generic_cfg = tmp.path().join(".config").join("mcp").join("mcp.json");
|
||||
let entry = test_mcp_entry("/test/index.js", "/vault");
|
||||
@@ -745,6 +958,7 @@ mod tests {
|
||||
&[
|
||||
claude_user_cfg.clone(),
|
||||
claude_cfg.clone(),
|
||||
gemini_cfg.clone(),
|
||||
cursor_cfg.clone(),
|
||||
generic_cfg.clone(),
|
||||
],
|
||||
@@ -752,14 +966,18 @@ mod tests {
|
||||
|
||||
assert!(claude_user_cfg.exists());
|
||||
assert!(claude_cfg.exists());
|
||||
assert!(gemini_cfg.exists());
|
||||
assert!(cursor_cfg.exists());
|
||||
assert!(generic_cfg.exists());
|
||||
|
||||
let raw = std::fs::read_to_string(&claude_user_cfg).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
|
||||
"/test/index.js"
|
||||
assert_registered_tolaria_server(
|
||||
&config,
|
||||
ExpectedMcpServer {
|
||||
index_js: "/test/index.js",
|
||||
vault_path: "/vault",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -773,6 +991,7 @@ mod tests {
|
||||
vec![
|
||||
home.join(".claude.json"),
|
||||
home.join(".claude").join("mcp.json"),
|
||||
home.join(".gemini").join("settings.json"),
|
||||
home.join(".cursor").join("mcp.json"),
|
||||
home.join(".config").join("mcp").join("mcp.json"),
|
||||
]
|
||||
|
||||
@@ -136,6 +136,10 @@ const GIT_NO_REMOTE_DEPENDENT_IDS: &[&str] = &[VAULT_ADD_REMOTE];
|
||||
|
||||
type MenuResult = Result<Submenu<tauri::Wry>, Box<dyn std::error::Error>>;
|
||||
|
||||
fn app_menu_includes_services(target_os: &str) -> bool {
|
||||
target_os == "macos"
|
||||
}
|
||||
|
||||
fn build_app_menu(app: &App) -> MenuResult {
|
||||
let settings_item = MenuItemBuilder::new("Settings...")
|
||||
.id(APP_SETTINGS)
|
||||
@@ -145,21 +149,25 @@ fn build_app_menu(app: &App) -> MenuResult {
|
||||
.id(APP_CHECK_FOR_UPDATES)
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Tolaria")
|
||||
let mut builder = SubmenuBuilder::new(app, "Tolaria")
|
||||
.about(None)
|
||||
.separator()
|
||||
.item(&check_updates_item)
|
||||
.separator()
|
||||
.item(&settings_item)
|
||||
.separator()
|
||||
.services()
|
||||
.separator()
|
||||
.hide()
|
||||
.hide_others()
|
||||
.show_all()
|
||||
.separator()
|
||||
.quit()
|
||||
.build()?)
|
||||
.separator();
|
||||
|
||||
if app_menu_includes_services(std::env::consts::OS) {
|
||||
builder = builder
|
||||
.services()
|
||||
.separator()
|
||||
.hide()
|
||||
.hide_others()
|
||||
.show_all()
|
||||
.separator();
|
||||
}
|
||||
|
||||
Ok(builder.quit().build()?)
|
||||
}
|
||||
|
||||
fn build_file_menu(app: &App) -> MenuResult {
|
||||
@@ -628,4 +636,11 @@ mod tests {
|
||||
assert!(seen.insert(id), "duplicate custom ID: {id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_services_menu_is_macos_only() {
|
||||
assert!(app_menu_includes_services("macos"));
|
||||
assert!(!app_menu_includes_services("windows"));
|
||||
assert!(!app_menu_includes_services("linux"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +1,120 @@
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
||||
use std::io::BufRead;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
}
|
||||
pub use crate::cli_agent_runtime::AgentStreamRequest;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn check_cli() -> AiAgentAvailability {
|
||||
crate::opencode_discovery::check_cli()
|
||||
}
|
||||
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = crate::opencode_discovery::find_binary()?;
|
||||
let mut command = crate::opencode_config::build_command(&binary, &request)?;
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Failed to spawn opencode: {error}"))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
let mut session_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let json = match crate::opencode_events::parse_line(line, &mut emit) {
|
||||
Some(json) => json,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if let Some(id) = crate::opencode_events::session_id(&json) {
|
||||
session_id = id.to_string();
|
||||
}
|
||||
|
||||
crate::opencode_events::dispatch_event(&json, &mut emit);
|
||||
}
|
||||
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|stderr| std::io::read_to_string(stderr).ok())
|
||||
.unwrap_or_default();
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|error| format!("Wait failed: {error}"))?;
|
||||
if !status.success() {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: crate::opencode_events::format_error(stderr_output, status.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(session_id)
|
||||
run_agent_stream_with_binary(&binary, request, emit)
|
||||
}
|
||||
|
||||
fn run_agent_stream_with_binary<F>(
|
||||
binary: &Path,
|
||||
request: AgentStreamRequest,
|
||||
emit: F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let command = crate::opencode_config::build_command(binary, &request)?;
|
||||
crate::cli_agent_runtime::run_ai_agent_json_stream(
|
||||
command,
|
||||
"opencode",
|
||||
emit,
|
||||
crate::opencode_events::session_id,
|
||||
crate::opencode_events::dispatch_event,
|
||||
crate::opencode_events::format_error,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let script = dir.join("opencode");
|
||||
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
script
|
||||
}
|
||||
|
||||
fn request(vault_path: String) -> AgentStreamRequest {
|
||||
AgentStreamRequest {
|
||||
message: "Summarize".into(),
|
||||
system_prompt: None,
|
||||
vault_path,
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_agent_stream_maps_opencode_json_events() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
r#"printf '%s\n' '{"type":"session","sessionID":"open_1"}'
|
||||
printf '%s\n' '{"type":"message","text":"Done"}'
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut events = Vec::new();
|
||||
let session_id = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
request(vault.path().to_string_lossy().into_owned()),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session_id, "open_1");
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Init { session_id } if session_id == "open_1"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "Done"
|
||||
));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_agent_stream_reports_opencode_nonzero_exit_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
r#"printf '%s\n' '{"type":"session","sessionID":"open_1"}'
|
||||
printf '%s\n' 'provider login required' >&2
|
||||
exit 3
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut events = Vec::new();
|
||||
let session_id = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
request(vault.path().to_string_lossy().into_owned()),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session_id, "open_1");
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AiAgentStreamEvent::Error { message } if message.contains("provider configured")
|
||||
)));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
use crate::opencode_cli::AgentStreamRequest;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
@@ -12,7 +13,7 @@ pub(crate) fn build_command(
|
||||
.arg(build_prompt(request))
|
||||
.env(
|
||||
"OPENCODE_CONFIG_CONTENT",
|
||||
build_config(&request.vault_path)?,
|
||||
build_config(&request.vault_path, request.permission_mode)?,
|
||||
)
|
||||
.current_dir(&request.vault_path)
|
||||
.stdin(Stdio::null())
|
||||
@@ -26,30 +27,18 @@ fn build_args() -> Vec<String> {
|
||||
}
|
||||
|
||||
fn build_prompt(request: &AgentStreamRequest) -> String {
|
||||
match request
|
||||
.system_prompt
|
||||
.as_ref()
|
||||
.map(|prompt| prompt.trim())
|
||||
.filter(|prompt| !prompt.is_empty())
|
||||
{
|
||||
Some(system_prompt) => format!(
|
||||
"System instructions:\n{system_prompt}\n\nUser request:\n{}",
|
||||
request.message
|
||||
),
|
||||
None => request.message.clone(),
|
||||
}
|
||||
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
|
||||
}
|
||||
|
||||
fn build_config(vault_path: &str) -> Result<String, String> {
|
||||
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
|
||||
let mcp_server_path = mcp_server
|
||||
.to_str()
|
||||
.ok_or("Invalid MCP server path")?
|
||||
.to_string();
|
||||
fn build_config(
|
||||
vault_path: &str,
|
||||
permission_mode: AiAgentPermissionMode,
|
||||
) -> Result<String, String> {
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": permission_config(),
|
||||
"permission": permission_config(permission_mode),
|
||||
"mcp": {
|
||||
"tolaria": {
|
||||
"type": "local",
|
||||
@@ -62,7 +51,12 @@ fn build_config(vault_path: &str) -> Result<String, String> {
|
||||
.map_err(|error| format!("Failed to serialize opencode config: {error}"))
|
||||
}
|
||||
|
||||
fn permission_config() -> serde_json::Value {
|
||||
fn permission_config(permission_mode: AiAgentPermissionMode) -> serde_json::Value {
|
||||
let bash_permission = match permission_mode {
|
||||
AiAgentPermissionMode::Safe => "deny",
|
||||
AiAgentPermissionMode::PowerUser => "allow",
|
||||
};
|
||||
|
||||
serde_json::json!({
|
||||
"read": "allow",
|
||||
"edit": "allow",
|
||||
@@ -70,7 +64,7 @@ fn permission_config() -> serde_json::Value {
|
||||
"grep": "allow",
|
||||
"list": "allow",
|
||||
"external_directory": "deny",
|
||||
"bash": "deny"
|
||||
"bash": bash_permission
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,6 +79,7 @@ mod tests {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +113,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn config_includes_permissions_and_tolaria_mcp_server() {
|
||||
if let Ok(config) = build_config("/tmp/vault") {
|
||||
if let Ok(config) =
|
||||
build_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe)
|
||||
{
|
||||
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
|
||||
assert_eq!(json["permission"]["edit"], "allow");
|
||||
assert_eq!(json["permission"]["external_directory"], "deny");
|
||||
@@ -136,6 +133,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_user_config_allows_bash_but_keeps_external_directories_denied() {
|
||||
if let Ok(config) = build_config(
|
||||
"/tmp/vault",
|
||||
crate::ai_agents::AiAgentPermissionMode::PowerUser,
|
||||
) {
|
||||
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
|
||||
assert_eq!(json["permission"]["bash"], "allow");
|
||||
assert_eq!(json["permission"]["external_directory"], "deny");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_prompt(&AgentStreamRequest {
|
||||
|
||||
@@ -5,7 +5,7 @@ pub(crate) fn check_cli() -> AiAgentAvailability {
|
||||
match find_binary() {
|
||||
Ok(binary) => AiAgentAvailability {
|
||||
installed: true,
|
||||
version: version_for_binary(&binary),
|
||||
version: crate::cli_agent_runtime::version_for_binary(&binary),
|
||||
},
|
||||
Err(_) => AiAgentAvailability {
|
||||
installed: false,
|
||||
@@ -30,23 +30,22 @@ pub(crate) fn find_binary() -> Result<PathBuf, String> {
|
||||
Err("OpenCode CLI not found. Install it: https://opencode.ai/docs/".into())
|
||||
}
|
||||
|
||||
fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
crate::hidden_command(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn find_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command("which")
|
||||
crate::hidden_command(path_lookup_command())
|
||||
.arg("opencode")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_lookup_command() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"where"
|
||||
} else {
|
||||
"which"
|
||||
}
|
||||
}
|
||||
|
||||
fn find_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
@@ -107,12 +106,26 @@ fn opencode_binary_candidates() -> Vec<PathBuf> {
|
||||
fn opencode_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".local/bin/opencode"),
|
||||
home.join(".local/bin/opencode.exe"),
|
||||
home.join(".opencode/bin/opencode"),
|
||||
home.join(".opencode/bin/opencode.exe"),
|
||||
home.join(".local/share/mise/shims/opencode"),
|
||||
home.join(".local/share/mise/shims/opencode.exe"),
|
||||
home.join(".asdf/shims/opencode"),
|
||||
home.join(".asdf/shims/opencode.exe"),
|
||||
home.join(".npm-global/bin/opencode"),
|
||||
home.join(".npm-global/bin/opencode.cmd"),
|
||||
home.join(".npm-global/bin/opencode.exe"),
|
||||
home.join(".npm/bin/opencode"),
|
||||
home.join(".npm/bin/opencode.cmd"),
|
||||
home.join(".npm/bin/opencode.exe"),
|
||||
home.join(".bun/bin/opencode"),
|
||||
home.join(".bun/bin/opencode.exe"),
|
||||
home.join("AppData/Roaming/npm/opencode.cmd"),
|
||||
home.join("AppData/Roaming/npm/opencode.exe"),
|
||||
home.join("AppData/Local/pnpm/opencode.cmd"),
|
||||
home.join("AppData/Local/pnpm/opencode.exe"),
|
||||
home.join("scoop/shims/opencode.exe"),
|
||||
PathBuf::from("/usr/local/bin/opencode"),
|
||||
PathBuf::from("/opt/homebrew/bin/opencode"),
|
||||
]
|
||||
@@ -145,6 +158,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_windows_npm_and_toolchain_shims() {
|
||||
let home = PathBuf::from(r"C:\Users\alex");
|
||||
let candidates = opencode_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".npm-global/bin/opencode.cmd"),
|
||||
home.join(".npm-global/bin/opencode.exe"),
|
||||
home.join(".npm/bin/opencode.cmd"),
|
||||
home.join(".npm/bin/opencode.exe"),
|
||||
home.join("AppData/Roaming/npm/opencode.cmd"),
|
||||
home.join("AppData/Roaming/npm/opencode.exe"),
|
||||
home.join("AppData/Local/pnpm/opencode.cmd"),
|
||||
home.join("AppData/Local/pnpm/opencode.exe"),
|
||||
home.join("scoop/shims/opencode.exe"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_lookup_command_matches_current_platform() {
|
||||
let expected = if cfg!(windows) { "where" } else { "which" };
|
||||
|
||||
assert_eq!(path_lookup_command(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_path_skips_empty_and_missing_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::ai_agents::AiAgentStreamEvent;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_line<F>(
|
||||
line: Result<String, std::io::Error>,
|
||||
emit: &mut F,
|
||||
@@ -7,22 +8,7 @@ pub(crate) fn parse_line<F>(
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
Err(error) => {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format!("Read error: {error}"),
|
||||
});
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
serde_json::from_str::<serde_json::Value>(trimmed).ok()
|
||||
crate::cli_agent_runtime::parse_ai_agent_json_line(line, emit)
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
@@ -33,7 +19,7 @@ where
|
||||
emit_session_event(json, emit);
|
||||
}
|
||||
|
||||
match json["type"].as_str().unwrap_or_default() {
|
||||
match event_type(json) {
|
||||
"message" | "text" => emit_text(json, emit),
|
||||
"reasoning" => emit_reasoning(json, emit),
|
||||
"tool_use" | "tool" => emit_tool_start(json, emit),
|
||||
@@ -43,6 +29,15 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn event_type(json: &serde_json::Value) -> &str {
|
||||
let direct = json["type"].as_str().unwrap_or_default();
|
||||
match direct {
|
||||
"session" | "message" | "text" | "reasoning" | "tool_use" | "tool" | "tool_result"
|
||||
| "tool_done" | "error" => direct,
|
||||
_ => json["part"]["type"].as_str().unwrap_or(direct),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn session_id(json: &serde_json::Value) -> Option<&str> {
|
||||
json["sessionID"]
|
||||
.as_str()
|
||||
@@ -101,12 +96,8 @@ where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let tool_id = tool_id(json).unwrap_or("tool").to_string();
|
||||
let tool_name = json["name"]
|
||||
.as_str()
|
||||
.or_else(|| json["tool"].as_str())
|
||||
.unwrap_or("tool")
|
||||
.to_string();
|
||||
let input = json.get("input").map(|input| input.to_string());
|
||||
let tool_name = tool_name(json).unwrap_or("tool").to_string();
|
||||
let input = tool_input(json);
|
||||
|
||||
emit(AiAgentStreamEvent::ToolStart {
|
||||
tool_name,
|
||||
@@ -120,10 +111,7 @@ where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let tool_id = tool_id(json).unwrap_or("tool").to_string();
|
||||
let output = json["output"]
|
||||
.as_str()
|
||||
.map(|output| output.to_string())
|
||||
.or_else(|| json.get("result").map(|result| result.to_string()));
|
||||
let output = tool_output(json);
|
||||
|
||||
emit(AiAgentStreamEvent::ToolDone { tool_id, output });
|
||||
}
|
||||
@@ -140,24 +128,50 @@ where
|
||||
}
|
||||
|
||||
fn tool_id(json: &serde_json::Value) -> Option<&str> {
|
||||
json["id"]
|
||||
.as_str()
|
||||
.or_else(|| json["toolID"].as_str())
|
||||
.or_else(|| json["tool_id"].as_str())
|
||||
first_string_field(
|
||||
json,
|
||||
&["id", "toolID", "tool_id", "toolCallID", "toolCallId"],
|
||||
)
|
||||
}
|
||||
|
||||
fn text_value(json: &serde_json::Value) -> Option<&str> {
|
||||
json["text"]
|
||||
.as_str()
|
||||
.or_else(|| json["content"].as_str())
|
||||
.or_else(|| json["message"].as_str())
|
||||
first_string_field(json, &["text", "content", "message"])
|
||||
}
|
||||
|
||||
fn message_value(json: &serde_json::Value) -> Option<&str> {
|
||||
json["message"]
|
||||
first_string_field(json, &["message", "error", "text"])
|
||||
}
|
||||
|
||||
fn tool_name(json: &serde_json::Value) -> Option<&str> {
|
||||
first_string_field(json, &["name", "tool", "toolName"])
|
||||
}
|
||||
|
||||
fn tool_input(json: &serde_json::Value) -> Option<String> {
|
||||
first_json_field(json, &["input", "args"]).map(|input| input.to_string())
|
||||
}
|
||||
|
||||
fn tool_output(json: &serde_json::Value) -> Option<String> {
|
||||
first_json_field(json, &["output", "result"]).map(display_json_value)
|
||||
}
|
||||
|
||||
fn first_string_field<'a>(json: &'a serde_json::Value, keys: &[&str]) -> Option<&'a str> {
|
||||
keys.iter()
|
||||
.find_map(|key| json[*key].as_str().or_else(|| json["part"][*key].as_str()))
|
||||
}
|
||||
|
||||
fn first_json_field<'a>(
|
||||
json: &'a serde_json::Value,
|
||||
keys: &[&str],
|
||||
) -> Option<&'a serde_json::Value> {
|
||||
keys.iter()
|
||||
.find_map(|key| json.get(*key).or_else(|| json["part"].get(*key)))
|
||||
}
|
||||
|
||||
fn display_json_value(value: &serde_json::Value) -> String {
|
||||
value
|
||||
.as_str()
|
||||
.or_else(|| json["error"].as_str())
|
||||
.or_else(|| json["text"].as_str())
|
||||
.map(|output| output.to_string())
|
||||
.unwrap_or_else(|| value.to_string())
|
||||
}
|
||||
|
||||
fn is_auth_error(lower: &str) -> bool {
|
||||
|
||||
@@ -1,5 +1,61 @@
|
||||
use super::*;
|
||||
|
||||
fn dispatch_events(events: impl IntoIterator<Item = serde_json::Value>) -> Vec<AiAgentStreamEvent> {
|
||||
let mut mapped = Vec::new();
|
||||
|
||||
for event in events {
|
||||
dispatch_event(&event, &mut |event| mapped.push(event));
|
||||
}
|
||||
|
||||
mapped
|
||||
}
|
||||
|
||||
struct ToolExpectation<'a> {
|
||||
tool_id: &'a str,
|
||||
tool_name: &'a str,
|
||||
input: Option<&'a str>,
|
||||
output: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn assert_tool_pair(events: &[AiAgentStreamEvent], expected: ToolExpectation<'_>) {
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ToolStart {
|
||||
tool_name: actual_name,
|
||||
tool_id: actual_id,
|
||||
input: actual_input,
|
||||
} if actual_name == expected.tool_name
|
||||
&& actual_id == expected.tool_id
|
||||
&& actual_input.as_deref() == expected.input
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ToolDone {
|
||||
tool_id: actual_id,
|
||||
output: actual_output,
|
||||
} if actual_id == expected.tool_id && actual_output.as_deref() == expected.output
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_line_reports_read_errors_and_skips_blank_or_invalid_lines() {
|
||||
let mut events = Vec::new();
|
||||
|
||||
let read_error = parse_line(Err(std::io::Error::other("broken pipe")), &mut |event| {
|
||||
events.push(event)
|
||||
});
|
||||
let blank = parse_line(Ok(" ".into()), &mut |event| events.push(event));
|
||||
let invalid = parse_line(Ok("not json".into()), &mut |event| events.push(event));
|
||||
|
||||
assert!(read_error.is_none());
|
||||
assert!(blank.is_none());
|
||||
assert!(invalid.is_none());
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Error { message } if message.contains("broken pipe")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_session_reasoning_and_text() {
|
||||
let mut events = Vec::new();
|
||||
@@ -26,28 +82,137 @@ fn dispatch_maps_session_reasoning_and_text() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_tool_events() {
|
||||
fn dispatch_maps_part_backed_reasoning_and_text() {
|
||||
let mut events = Vec::new();
|
||||
let tool_start = serde_json::json!({
|
||||
"type": "tool_use",
|
||||
"id": "tool_1",
|
||||
"name": "read",
|
||||
"input": { "path": "Note.md" }
|
||||
let reasoning = serde_json::json!({
|
||||
"type": "reasoning",
|
||||
"part": { "type": "reasoning", "text": "Checking links" }
|
||||
});
|
||||
let text = serde_json::json!({
|
||||
"type": "text",
|
||||
"part": { "type": "text", "text": "Done from OpenCode" }
|
||||
});
|
||||
let tool_done = serde_json::json!({ "type": "tool_result", "id": "tool_1", "output": "ok" });
|
||||
|
||||
dispatch_event(&tool_start, &mut |event| events.push(event));
|
||||
dispatch_event(&tool_done, &mut |event| events.push(event));
|
||||
for event in [reasoning, text] {
|
||||
dispatch_event(&event, &mut |event| events.push(event));
|
||||
}
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ToolStart { tool_name, tool_id, input }
|
||||
if tool_name == "read" && tool_id == "tool_1" && input.as_deref() == Some(r#"{"path":"Note.md"}"#)
|
||||
AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ToolDone { tool_id, output }
|
||||
if tool_id == "tool_1" && output.as_deref() == Some("ok")
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "Done from OpenCode"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_final_text_after_tool_part_wrappers() {
|
||||
let events = dispatch_events([
|
||||
serde_json::json!({
|
||||
"type": "part",
|
||||
"part": {
|
||||
"id": "prt_tool_1",
|
||||
"type": "tool",
|
||||
"tool": "webfetch",
|
||||
"input": { "url": "https://example.com" }
|
||||
}
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "part",
|
||||
"part": {
|
||||
"id": "prt_text_1",
|
||||
"type": "text",
|
||||
"text": "Final answer after tool output."
|
||||
}
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "part",
|
||||
"part": {
|
||||
"id": "prt_finish_1",
|
||||
"type": "step-finish",
|
||||
"reason": "stop"
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ToolStart {
|
||||
tool_name,
|
||||
tool_id,
|
||||
input,
|
||||
} if tool_name == "webfetch"
|
||||
&& tool_id == "prt_tool_1"
|
||||
&& input.as_deref() == Some(r#"{"url":"https://example.com"}"#)
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "Final answer after tool output."
|
||||
));
|
||||
assert_eq!(events.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_tool_events() {
|
||||
let direct = dispatch_events([
|
||||
serde_json::json!({
|
||||
"type": "tool_use",
|
||||
"id": "tool_1",
|
||||
"name": "read",
|
||||
"input": { "path": "Note.md" }
|
||||
}),
|
||||
serde_json::json!({ "type": "tool_result", "id": "tool_1", "output": "ok" }),
|
||||
]);
|
||||
let part_backed = dispatch_events([
|
||||
serde_json::json!({
|
||||
"type": "tool_use",
|
||||
"part": {
|
||||
"id": "prt_tool_1",
|
||||
"tool": "read",
|
||||
"input": { "path": "Note.md" }
|
||||
}
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"part": {
|
||||
"id": "prt_tool_1",
|
||||
"output": "ok"
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
assert_tool_pair(
|
||||
&direct,
|
||||
ToolExpectation {
|
||||
tool_id: "tool_1",
|
||||
tool_name: "read",
|
||||
input: Some(r#"{"path":"Note.md"}"#),
|
||||
output: Some("ok"),
|
||||
},
|
||||
);
|
||||
assert_tool_pair(
|
||||
&part_backed,
|
||||
ToolExpectation {
|
||||
tool_id: "prt_tool_1",
|
||||
tool_name: "read",
|
||||
input: Some(r#"{"path":"Note.md"}"#),
|
||||
output: Some("ok"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_error_events() {
|
||||
let mut events = Vec::new();
|
||||
let error = serde_json::json!({ "type": "error", "message": "provider failed" });
|
||||
|
||||
dispatch_event(&error, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Error { message } if message == "provider failed"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -61,3 +226,12 @@ fn format_error_explains_missing_auth_or_provider_setup() {
|
||||
assert!(message.contains("OpenCode CLI is not authenticated"));
|
||||
assert!(message.contains("opencode auth login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_error_uses_status_or_first_stderr_lines() {
|
||||
let empty = format_error(String::new(), "exit status: 2".into());
|
||||
let truncated = format_error("line 1\nline 2\nline 3\nline 4".into(), "ignored".into());
|
||||
|
||||
assert_eq!(empty, "opencode exited with status exit status: 2");
|
||||
assert_eq!(truncated, "line 1\nline 2\nline 3");
|
||||
}
|
||||
|
||||
@@ -1,62 +1,124 @@
|
||||
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
||||
use std::io::BufRead;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
}
|
||||
pub use crate::cli_agent_runtime::AgentStreamRequest;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn check_cli() -> AiAgentAvailability {
|
||||
crate::pi_discovery::check_cli()
|
||||
}
|
||||
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
|
||||
pub fn run_agent_stream<F>(request: AgentStreamRequest, emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = crate::pi_discovery::find_binary()?;
|
||||
run_agent_stream_with_binary(&binary, request, emit)
|
||||
}
|
||||
|
||||
fn run_agent_stream_with_binary<F>(
|
||||
binary: &Path,
|
||||
request: AgentStreamRequest,
|
||||
emit: F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let agent_dir = tempfile::Builder::new()
|
||||
.prefix("tolaria-pi-agent-")
|
||||
.tempdir()
|
||||
.map_err(|error| format!("Failed to create Pi config directory: {error}"))?;
|
||||
let mut command = crate::pi_config::build_command(&binary, &request, agent_dir.path())?;
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Failed to spawn pi: {error}"))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
let mut session_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let json = match crate::pi_events::parse_line(line, &mut emit) {
|
||||
Some(json) => json,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if let Some(id) = crate::pi_events::session_id(&json) {
|
||||
session_id = id.to_string();
|
||||
}
|
||||
|
||||
crate::pi_events::dispatch_event(&json, &mut emit);
|
||||
}
|
||||
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|stderr| std::io::read_to_string(stderr).ok())
|
||||
.unwrap_or_default();
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|error| format!("Wait failed: {error}"))?;
|
||||
if !status.success() {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: crate::pi_events::format_error(stderr_output, status.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(session_id)
|
||||
let command = crate::pi_config::build_command(binary, &request, agent_dir.path())?;
|
||||
crate::cli_agent_runtime::run_ai_agent_json_stream(
|
||||
command,
|
||||
"pi",
|
||||
emit,
|
||||
crate::pi_events::session_id,
|
||||
crate::pi_events::dispatch_event,
|
||||
crate::pi_events::format_error,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let script = dir.join("pi");
|
||||
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
script
|
||||
}
|
||||
|
||||
fn request(vault_path: String) -> AgentStreamRequest {
|
||||
AgentStreamRequest {
|
||||
message: "Summarize".into(),
|
||||
system_prompt: None,
|
||||
vault_path,
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_agent_stream_maps_pi_json_events() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
r#"printf '%s\n' '{"type":"session","id":"pi_1"}'
|
||||
printf '%s\n' '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"Done"}}'
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut events = Vec::new();
|
||||
let session_id = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
request(vault.path().to_string_lossy().into_owned()),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session_id, "pi_1");
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Init { session_id } if session_id == "pi_1"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "Done"
|
||||
));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_agent_stream_reports_pi_nonzero_exit_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
r#"printf '%s\n' '{"type":"session","id":"pi_1"}'
|
||||
printf '%s\n' 'api key login required' >&2
|
||||
exit 4
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut events = Vec::new();
|
||||
let session_id = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
request(vault.path().to_string_lossy().into_owned()),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session_id, "pi_1");
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AiAgentStreamEvent::Error { message } if message.contains("not authenticated")
|
||||
)));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::ai_agents::AiAgentPermissionMode;
|
||||
use crate::pi_cli::AgentStreamRequest;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
@@ -7,7 +8,7 @@ pub(crate) fn build_command(
|
||||
request: &AgentStreamRequest,
|
||||
agent_dir: &Path,
|
||||
) -> Result<std::process::Command, String> {
|
||||
write_mcp_config(agent_dir, &request.vault_path)?;
|
||||
write_mcp_config(agent_dir, &request.vault_path, request.permission_mode)?;
|
||||
|
||||
let mut command = crate::hidden_command(binary);
|
||||
command
|
||||
@@ -32,34 +33,26 @@ fn build_args() -> Vec<String> {
|
||||
}
|
||||
|
||||
fn build_prompt(request: &AgentStreamRequest) -> String {
|
||||
match request
|
||||
.system_prompt
|
||||
.as_ref()
|
||||
.map(|prompt| prompt.trim())
|
||||
.filter(|prompt| !prompt.is_empty())
|
||||
{
|
||||
Some(system_prompt) => format!(
|
||||
"System instructions:\n{system_prompt}\n\nUser request:\n{}",
|
||||
request.message
|
||||
),
|
||||
None => request.message.clone(),
|
||||
}
|
||||
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref())
|
||||
}
|
||||
|
||||
fn write_mcp_config(agent_dir: &Path, vault_path: &str) -> Result<(), String> {
|
||||
fn write_mcp_config(
|
||||
agent_dir: &Path,
|
||||
vault_path: &str,
|
||||
permission_mode: AiAgentPermissionMode,
|
||||
) -> Result<(), String> {
|
||||
std::fs::create_dir_all(agent_dir)
|
||||
.map_err(|error| format!("Failed to create Pi agent directory: {error}"))?;
|
||||
let config = build_mcp_config(vault_path)?;
|
||||
let config = build_mcp_config(vault_path, permission_mode)?;
|
||||
std::fs::write(agent_dir.join("mcp.json"), config)
|
||||
.map_err(|error| format!("Failed to write Pi MCP config: {error}"))
|
||||
}
|
||||
|
||||
fn build_mcp_config(vault_path: &str) -> Result<String, String> {
|
||||
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
|
||||
let mcp_server_path = mcp_server
|
||||
.to_str()
|
||||
.ok_or("Invalid MCP server path")?
|
||||
.to_string();
|
||||
fn build_mcp_config(
|
||||
vault_path: &str,
|
||||
_permission_mode: AiAgentPermissionMode,
|
||||
) -> Result<String, String> {
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"settings": {
|
||||
@@ -93,6 +86,7 @@ mod tests {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: crate::ai_agents::AiAgentPermissionMode::Safe,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +123,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mcp_config_includes_tolaria_server_for_active_vault() {
|
||||
if let Ok(config) = build_mcp_config("/tmp/vault") {
|
||||
if let Ok(config) =
|
||||
build_mcp_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe)
|
||||
{
|
||||
let json: serde_json::Value = serde_json::from_str(&config).unwrap();
|
||||
assert_eq!(json["settings"]["toolPrefix"], "none");
|
||||
assert_eq!(json["mcpServers"]["tolaria"]["command"], "node");
|
||||
@@ -147,6 +143,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_user_mode_uses_the_same_pi_mcp_config_as_safe_mode() {
|
||||
let safe =
|
||||
build_mcp_config("/tmp/vault", crate::ai_agents::AiAgentPermissionMode::Safe).unwrap();
|
||||
let power = build_mcp_config(
|
||||
"/tmp/vault",
|
||||
crate::ai_agents::AiAgentPermissionMode::PowerUser,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(safe, power);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_prompt(&AgentStreamRequest {
|
||||
|
||||
@@ -5,7 +5,7 @@ pub(crate) fn check_cli() -> AiAgentAvailability {
|
||||
match find_binary() {
|
||||
Ok(binary) => AiAgentAvailability {
|
||||
installed: true,
|
||||
version: version_for_binary(&binary),
|
||||
version: crate::cli_agent_runtime::version_for_binary(&binary),
|
||||
},
|
||||
Err(_) => AiAgentAvailability {
|
||||
installed: false,
|
||||
@@ -30,23 +30,22 @@ pub(crate) fn find_binary() -> Result<PathBuf, String> {
|
||||
Err("Pi CLI not found. Install it: https://pi.dev".into())
|
||||
}
|
||||
|
||||
fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
crate::hidden_command(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn find_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command("which")
|
||||
crate::hidden_command(path_lookup_command())
|
||||
.arg("pi")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_lookup_command() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"where"
|
||||
} else {
|
||||
"which"
|
||||
}
|
||||
}
|
||||
|
||||
fn find_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
@@ -99,25 +98,97 @@ fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
fn pi_binary_candidates() -> Vec<PathBuf> {
|
||||
dirs::home_dir()
|
||||
.map(|home| pi_binary_candidates_for_home(&home))
|
||||
.unwrap_or_default()
|
||||
let mut candidates = pi_binary_candidates_from_env();
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.extend(pi_binary_candidates_for_home(&home));
|
||||
}
|
||||
|
||||
candidates.extend(pi_global_binary_candidates());
|
||||
candidates
|
||||
}
|
||||
|
||||
fn pi_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
let mut candidates = pi_nvm_binary_candidates_for_home(home);
|
||||
candidates.extend([
|
||||
home.join(".local/bin/pi"),
|
||||
home.join(".local/bin/pi.exe"),
|
||||
home.join(".pi/bin/pi"),
|
||||
home.join(".pi/bin/pi.exe"),
|
||||
home.join(".local/share/mise/shims/pi"),
|
||||
home.join(".local/share/mise/shims/pi.exe"),
|
||||
home.join(".asdf/shims/pi"),
|
||||
home.join(".asdf/shims/pi.exe"),
|
||||
home.join(".npm-global/bin/pi"),
|
||||
home.join(".npm-global/bin/pi.cmd"),
|
||||
home.join(".npm-global/bin/pi.exe"),
|
||||
home.join(".npm/bin/pi"),
|
||||
home.join(".npm/bin/pi.cmd"),
|
||||
home.join(".npm/bin/pi.exe"),
|
||||
home.join(".bun/bin/pi"),
|
||||
home.join(".bun/bin/pi.exe"),
|
||||
home.join("AppData/Roaming/npm/pi.cmd"),
|
||||
home.join("AppData/Roaming/npm/pi.exe"),
|
||||
home.join("AppData/Local/pnpm/pi.cmd"),
|
||||
home.join("AppData/Local/pnpm/pi.exe"),
|
||||
home.join("scoop/shims/pi.exe"),
|
||||
]);
|
||||
candidates
|
||||
}
|
||||
|
||||
fn pi_global_binary_candidates() -> Vec<PathBuf> {
|
||||
vec![
|
||||
PathBuf::from("/usr/local/bin/pi"),
|
||||
PathBuf::from("/opt/homebrew/bin/pi"),
|
||||
]
|
||||
}
|
||||
|
||||
fn pi_binary_candidates_from_env() -> Vec<PathBuf> {
|
||||
let nvm_bin = std::env::var_os("NVM_BIN").map(PathBuf::from);
|
||||
let npm_config_prefix = std::env::var_os("npm_config_prefix").map(PathBuf::from);
|
||||
let npm_config_prefix_upper = std::env::var_os("NPM_CONFIG_PREFIX").map(PathBuf::from);
|
||||
|
||||
pi_binary_candidates_from_prefixes(nvm_bin, npm_config_prefix, npm_config_prefix_upper)
|
||||
}
|
||||
|
||||
fn pi_binary_candidates_from_prefixes(
|
||||
nvm_bin: Option<PathBuf>,
|
||||
npm_config_prefix: Option<PathBuf>,
|
||||
npm_config_prefix_upper: Option<PathBuf>,
|
||||
) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if let Some(path) = nvm_bin.filter(|path| !path.as_os_str().is_empty()) {
|
||||
candidates.push(path.join("pi"));
|
||||
}
|
||||
if let Some(path) = npm_config_prefix.filter(|path| !path.as_os_str().is_empty()) {
|
||||
candidates.push(path.join("bin/pi"));
|
||||
}
|
||||
if let Some(path) = npm_config_prefix_upper.filter(|path| !path.as_os_str().is_empty()) {
|
||||
candidates.push(path.join("bin/pi"));
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn pi_nvm_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let versions_dir = home.join(".nvm/versions/node");
|
||||
let mut version_dirs = match std::fs::read_dir(versions_dir) {
|
||||
Ok(entries) => entries
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir())
|
||||
.collect::<Vec<_>>(),
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
version_dirs.sort_by(|left, right| right.file_name().cmp(&left.file_name()));
|
||||
version_dirs
|
||||
.into_iter()
|
||||
.map(|version_dir| version_dir.join("bin/pi"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -133,7 +204,6 @@ mod tests {
|
||||
home.join(".asdf/shims/pi"),
|
||||
home.join(".npm-global/bin/pi"),
|
||||
home.join(".bun/bin/pi"),
|
||||
PathBuf::from("/opt/homebrew/bin/pi"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
@@ -145,6 +215,85 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_windows_npm_and_toolchain_shims() {
|
||||
let home = PathBuf::from(r"C:\Users\alex");
|
||||
let candidates = pi_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".npm-global/bin/pi.cmd"),
|
||||
home.join(".npm-global/bin/pi.exe"),
|
||||
home.join(".npm/bin/pi.cmd"),
|
||||
home.join(".npm/bin/pi.exe"),
|
||||
home.join("AppData/Roaming/npm/pi.cmd"),
|
||||
home.join("AppData/Roaming/npm/pi.exe"),
|
||||
home.join("AppData/Local/pnpm/pi.cmd"),
|
||||
home.join("AppData/Local/pnpm/pi.exe"),
|
||||
home.join("scoop/shims/pi.exe"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_lookup_command_matches_current_platform() {
|
||||
let expected = if cfg!(windows) { "where" } else { "which" };
|
||||
|
||||
assert_eq!(path_lookup_command(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_nvm_node_version_installs() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let pi = home.path().join(".nvm/versions/node/v22.20.0/bin/pi");
|
||||
|
||||
std::fs::create_dir_all(pi.parent().unwrap()).unwrap();
|
||||
std::fs::write(&pi, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let candidates = pi_binary_candidates_for_home(home.path());
|
||||
|
||||
assert!(
|
||||
candidates.contains(&pi),
|
||||
"missing nvm candidate {}",
|
||||
pi.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_static_global_fallbacks() {
|
||||
let candidates = pi_global_binary_candidates();
|
||||
|
||||
assert!(candidates.contains(&PathBuf::from("/usr/local/bin/pi")));
|
||||
assert!(candidates.contains(&PathBuf::from("/opt/homebrew/bin/pi")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_candidates_include_env_provided_npm_and_nvm_prefixes() {
|
||||
let nvm_bin = PathBuf::from("/Users/alex/.nvm/versions/node/v22.20.0/bin");
|
||||
let npm_config_prefix = PathBuf::from("/Users/alex/.npm-global");
|
||||
let npm_config_prefix_upper = PathBuf::from("/Users/alex/.npm");
|
||||
|
||||
let candidates = pi_binary_candidates_from_prefixes(
|
||||
Some(nvm_bin.clone()),
|
||||
Some(npm_config_prefix.clone()),
|
||||
Some(npm_config_prefix_upper.clone()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
candidates,
|
||||
vec![
|
||||
nvm_bin.join("pi"),
|
||||
npm_config_prefix.join("bin/pi"),
|
||||
npm_config_prefix_upper.join("bin/pi"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_path_skips_empty_and_missing_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::ai_agents::AiAgentStreamEvent;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_line<F>(
|
||||
line: Result<String, std::io::Error>,
|
||||
emit: &mut F,
|
||||
@@ -7,22 +8,7 @@ pub(crate) fn parse_line<F>(
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
Err(error) => {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format!("Read error: {error}"),
|
||||
});
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
serde_json::from_str::<serde_json::Value>(trimmed).ok()
|
||||
crate::cli_agent_runtime::parse_ai_agent_json_line(line, emit)
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_line_reports_read_errors_and_skips_blank_or_invalid_lines() {
|
||||
let mut events = Vec::new();
|
||||
|
||||
let read_error = parse_line(Err(std::io::Error::other("broken pipe")), &mut |event| {
|
||||
events.push(event)
|
||||
});
|
||||
let blank = parse_line(Ok(" ".into()), &mut |event| events.push(event));
|
||||
let invalid = parse_line(Ok("not json".into()), &mut |event| events.push(event));
|
||||
|
||||
assert!(read_error.is_none());
|
||||
assert!(blank.is_none());
|
||||
assert!(invalid.is_none());
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Error { message } if message.contains("broken pipe")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_session_thinking_and_text() {
|
||||
let mut events = Vec::new();
|
||||
@@ -62,6 +81,19 @@ fn dispatch_maps_tool_events() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_maps_error_events() {
|
||||
let mut events = Vec::new();
|
||||
let error = serde_json::json!({ "type": "error", "message": "provider failed" });
|
||||
|
||||
dispatch_event(&error, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::Error { message } if message == "provider failed"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_error_explains_missing_auth_or_provider_setup() {
|
||||
let message = format_error(
|
||||
@@ -72,3 +104,12 @@ fn format_error_explains_missing_auth_or_provider_setup() {
|
||||
assert!(message.contains("Pi CLI is not authenticated"));
|
||||
assert!(message.contains("pi /login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_error_uses_status_or_first_stderr_lines() {
|
||||
let empty = format_error(String::new(), "exit status: 2".into());
|
||||
let truncated = format_error("line 1\nline 2\nline 3\nline 4".into(), "ignored".into());
|
||||
|
||||
assert_eq!(empty, "pi exited with status exit status: 2");
|
||||
assert_eq!(truncated, "line 1\nline 2\nline 3");
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ 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"];
|
||||
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] = &["claude_code", "codex", "opencode", "pi", "gemini"];
|
||||
pub const DEFAULT_HIDE_GITIGNORED_FILES: bool = true;
|
||||
const SUPPORTED_NOTE_WIDTH_MODES: &[&str] = &["normal", "wide"];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Settings {
|
||||
@@ -21,9 +22,13 @@ pub struct Settings {
|
||||
pub release_channel: Option<String>,
|
||||
pub theme_mode: Option<String>,
|
||||
pub ui_language: Option<String>,
|
||||
pub note_width_mode: Option<String>,
|
||||
pub initial_h1_auto_rename_enabled: Option<bool>,
|
||||
pub default_ai_agent: Option<String>,
|
||||
pub hide_gitignored_files: Option<bool>,
|
||||
pub all_notes_show_pdfs: Option<bool>,
|
||||
pub all_notes_show_images: Option<bool>,
|
||||
pub all_notes_show_unsupported: Option<bool>,
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
@@ -65,6 +70,13 @@ pub fn normalize_theme_mode(value: Option<&str>) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_note_width_mode(value: Option<&str>) -> Option<String> {
|
||||
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
|
||||
Some(mode) if SUPPORTED_NOTE_WIDTH_MODES.contains(&mode.as_str()) => Some(mode),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_hide_gitignored_files(settings: &Settings) -> bool {
|
||||
settings
|
||||
.hide_gitignored_files
|
||||
@@ -123,9 +135,13 @@ fn normalize_settings(settings: Settings) -> Settings {
|
||||
release_channel: normalize_release_channel(settings.release_channel.as_deref()),
|
||||
theme_mode: normalize_theme_mode(settings.theme_mode.as_deref()),
|
||||
ui_language: normalize_ui_language(settings.ui_language.as_deref()),
|
||||
note_width_mode: normalize_note_width_mode(settings.note_width_mode.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,
|
||||
all_notes_show_pdfs: settings.all_notes_show_pdfs,
|
||||
all_notes_show_images: settings.all_notes_show_images,
|
||||
all_notes_show_unsupported: settings.all_notes_show_unsupported,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,9 +282,13 @@ mod tests {
|
||||
release_channel: Some("alpha".to_string()),
|
||||
theme_mode: Some("dark".to_string()),
|
||||
ui_language: Some("zh-Hans".to_string()),
|
||||
note_width_mode: Some("wide".to_string()),
|
||||
initial_h1_auto_rename_enabled: Some(false),
|
||||
default_ai_agent: Some("codex".to_string()),
|
||||
hide_gitignored_files: Some(false),
|
||||
all_notes_show_pdfs: Some(true),
|
||||
all_notes_show_images: Some(true),
|
||||
all_notes_show_unsupported: Some(false),
|
||||
};
|
||||
let json = serde_json::to_string(&settings).unwrap();
|
||||
let parsed: Settings = serde_json::from_str(&json).unwrap();
|
||||
@@ -294,9 +314,13 @@ mod tests {
|
||||
release_channel: Some("alpha".to_string()),
|
||||
theme_mode: Some("dark".to_string()),
|
||||
ui_language: Some("zh-Hans".to_string()),
|
||||
note_width_mode: Some("wide".to_string()),
|
||||
initial_h1_auto_rename_enabled: Some(false),
|
||||
default_ai_agent: Some("codex".to_string()),
|
||||
hide_gitignored_files: Some(false),
|
||||
all_notes_show_pdfs: Some(true),
|
||||
all_notes_show_images: Some(false),
|
||||
all_notes_show_unsupported: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(loaded.auto_pull_interval_minutes, Some(10));
|
||||
@@ -307,9 +331,13 @@ mod tests {
|
||||
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
|
||||
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
|
||||
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
|
||||
assert_eq!(loaded.note_width_mode.as_deref(), Some("wide"));
|
||||
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));
|
||||
assert_eq!(loaded.all_notes_show_pdfs, Some(true));
|
||||
assert_eq!(loaded.all_notes_show_images, Some(false));
|
||||
assert_eq!(loaded.all_notes_show_unsupported, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -332,6 +360,7 @@ mod tests {
|
||||
release_channel: Some(" alpha ".to_string()),
|
||||
theme_mode: Some(" dark ".to_string()),
|
||||
ui_language: Some(" zh-cn ".to_string()),
|
||||
note_width_mode: Some(" WIDE ".to_string()),
|
||||
default_ai_agent: Some(" codex ".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
@@ -339,6 +368,7 @@ mod tests {
|
||||
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
|
||||
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
|
||||
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
|
||||
assert_eq!(loaded.note_width_mode.as_deref(), Some("wide"));
|
||||
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
|
||||
}
|
||||
|
||||
@@ -398,6 +428,15 @@ mod tests {
|
||||
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 {
|
||||
@@ -407,6 +446,15 @@ mod tests {
|
||||
assert!(loaded.theme_mode.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_note_width_mode_is_filtered() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
note_width_mode: Some("expanded".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(loaded.note_width_mode.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_ui_language_is_filtered() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::settings;
|
||||
use chrono::NaiveDate;
|
||||
use regex::Regex;
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Global Sentry guard — must live for the duration of the app.
|
||||
@@ -44,6 +46,41 @@ fn parse_embedded_sentry_dsn(raw: Option<&str>) -> Option<sentry::types::Dsn> {
|
||||
normalize_http_like_value(&normalized).parse().ok()
|
||||
}
|
||||
|
||||
fn is_stable_calendar_release(version: &str) -> bool {
|
||||
let mut parts = version.split('.');
|
||||
let (Some(year), Some(month), Some(day)) = (parts.next(), parts.next(), parts.next()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if parts.next().is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let (Ok(year), Ok(month), Ok(day)) = (
|
||||
year.parse::<i32>(),
|
||||
month.parse::<u32>(),
|
||||
day.parse::<u32>(),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
NaiveDate::from_ymd_opt(year, month, day).is_some()
|
||||
}
|
||||
|
||||
fn sentry_release_for_version(version: &'static str) -> Option<Cow<'static, str>> {
|
||||
is_stable_calendar_release(version).then(|| version.into())
|
||||
}
|
||||
|
||||
fn sentry_release_kind(version: &str) -> &'static str {
|
||||
if is_stable_calendar_release(version) {
|
||||
"stable"
|
||||
} else if version.contains('-') {
|
||||
"prerelease"
|
||||
} else {
|
||||
"internal"
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize Sentry if the user has opted in to crash reporting.
|
||||
/// Returns `true` if Sentry was initialized, `false` if skipped.
|
||||
pub fn init_sentry_from_settings() -> bool {
|
||||
@@ -61,9 +98,10 @@ pub fn init_sentry_from_settings() -> bool {
|
||||
};
|
||||
|
||||
let anonymous_id = settings.anonymous_id.unwrap_or_default();
|
||||
let build_version = env!("CARGO_PKG_VERSION");
|
||||
let guard = sentry::init(sentry::ClientOptions {
|
||||
dsn: Some(dsn),
|
||||
release: Some(env!("CARGO_PKG_VERSION").into()),
|
||||
release: sentry_release_for_version(build_version),
|
||||
send_default_pii: false,
|
||||
before_send: Some(std::sync::Arc::new(|mut event| {
|
||||
if let Some(ref mut msg) = event.message {
|
||||
@@ -79,6 +117,8 @@ pub fn init_sentry_from_settings() -> bool {
|
||||
id: Some(anonymous_id),
|
||||
..Default::default()
|
||||
}));
|
||||
scope.set_tag("tolaria.build_version", build_version);
|
||||
scope.set_tag("tolaria.release_kind", sentry_release_kind(build_version));
|
||||
});
|
||||
|
||||
*SENTRY_GUARD.lock().unwrap() = Some(guard);
|
||||
@@ -160,4 +200,26 @@ mod tests {
|
||||
// Without SENTRY_DSN env var set at compile time, init should return false
|
||||
assert!(!init_sentry_from_settings());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sentry_release_for_stable_calendar_version() {
|
||||
assert!(sentry_release_for_version("2026.4.28").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sentry_release_for_alpha_version_is_none() {
|
||||
assert!(sentry_release_for_version("2026.4.28-alpha.7").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sentry_release_for_local_dev_version_is_none() {
|
||||
assert!(sentry_release_for_version("0.1.0").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sentry_release_kind_classifies_versions() {
|
||||
assert_eq!(sentry_release_kind("2026.4.28"), "stable");
|
||||
assert_eq!(sentry_release_kind("2026.4.28-alpha.7"), "prerelease");
|
||||
assert_eq!(sentry_release_kind("0.1.0"), "internal");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,16 @@ _organized: true
|
||||
This file is only a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
|
||||
";
|
||||
|
||||
const GEMINI_MD_SHIM: &str = "---
|
||||
type: Note
|
||||
_organized: true
|
||||
---
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
This file is only a Gemini CLI compatibility shim. Keep shared agent instructions in `AGENTS.md`.
|
||||
";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiGuidanceFileState {
|
||||
@@ -71,9 +81,16 @@ pub enum AiGuidanceFileState {
|
||||
pub struct VaultAiGuidanceStatus {
|
||||
pub agents_state: AiGuidanceFileState,
|
||||
pub claude_state: AiGuidanceFileState,
|
||||
pub gemini_state: AiGuidanceFileState,
|
||||
pub can_restore: bool,
|
||||
}
|
||||
|
||||
struct GuidancePaths {
|
||||
agents: PathBuf,
|
||||
claude: PathBuf,
|
||||
gemini: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct LegacyAgentsMigrationOutcome {
|
||||
copied_to_root: bool,
|
||||
@@ -109,6 +126,10 @@ fn matches_claude_shim(content: &str) -> bool {
|
||||
|| trimmed == CLAUDE_MD_SHIM.trim()
|
||||
}
|
||||
|
||||
fn matches_gemini_shim(content: &str) -> bool {
|
||||
content.trim() == GEMINI_MD_SHIM.trim()
|
||||
}
|
||||
|
||||
fn claude_shim_can_be_replaced(path: &Path) -> bool {
|
||||
!path.exists() || {
|
||||
let content = read_file_or_empty(path);
|
||||
@@ -116,6 +137,13 @@ fn claude_shim_can_be_replaced(path: &Path) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn gemini_shim_can_be_replaced(path: &Path) -> bool {
|
||||
!path.exists() || {
|
||||
let content = read_file_or_empty(path);
|
||||
content.trim().is_empty() || matches_gemini_shim(&content)
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_managed_file(
|
||||
path: &Path,
|
||||
content: &str,
|
||||
@@ -150,9 +178,12 @@ fn classify_guidance_file(
|
||||
AiGuidanceFileState::Custom
|
||||
}
|
||||
|
||||
fn guidance_paths(vault_path: &Path) -> (PathBuf, PathBuf) {
|
||||
let vault = vault_path;
|
||||
(vault.join("AGENTS.md"), vault.join("CLAUDE.md"))
|
||||
fn guidance_paths(vault_path: &Path) -> GuidancePaths {
|
||||
GuidancePaths {
|
||||
agents: vault_path.join("AGENTS.md"),
|
||||
claude: vault_path.join("CLAUDE.md"),
|
||||
gemini: vault_path.join("GEMINI.md"),
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_agents_file(path: &Path) -> AiGuidanceFileState {
|
||||
@@ -167,6 +198,10 @@ fn classify_claude_file(path: &Path) -> AiGuidanceFileState {
|
||||
classify_guidance_file(path, matches_claude_shim, claude_shim_can_be_replaced)
|
||||
}
|
||||
|
||||
fn classify_gemini_file(path: &Path) -> AiGuidanceFileState {
|
||||
classify_guidance_file(path, matches_gemini_shim, gemini_shim_can_be_replaced)
|
||||
}
|
||||
|
||||
fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
|
||||
matches!(
|
||||
state,
|
||||
@@ -175,29 +210,43 @@ fn guidance_file_needs_restore(state: AiGuidanceFileState) -> bool {
|
||||
}
|
||||
|
||||
fn build_ai_guidance_status(vault_path: &Path) -> VaultAiGuidanceStatus {
|
||||
let (agents_path, claude_path) = guidance_paths(vault_path);
|
||||
let agents_state = classify_agents_file(&agents_path);
|
||||
let claude_state = classify_claude_file(&claude_path);
|
||||
let paths = guidance_paths(vault_path);
|
||||
let agents_state = classify_agents_file(&paths.agents);
|
||||
let claude_state = classify_claude_file(&paths.claude);
|
||||
let gemini_state = classify_gemini_file(&paths.gemini);
|
||||
|
||||
VaultAiGuidanceStatus {
|
||||
agents_state,
|
||||
claude_state,
|
||||
gemini_state,
|
||||
can_restore: guidance_file_needs_restore(agents_state)
|
||||
|| guidance_file_needs_restore(claude_state),
|
||||
|| guidance_file_needs_restore(claude_state)
|
||||
|| guidance_file_needs_restore(gemini_state),
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_claude_shim_file(vault_path: &Path) -> Result<bool, String> {
|
||||
let (_, claude_path) = guidance_paths(vault_path);
|
||||
sync_managed_file(&claude_path, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
|
||||
let paths = guidance_paths(vault_path);
|
||||
sync_managed_file(&paths.claude, CLAUDE_MD_SHIM, claude_shim_can_be_replaced)
|
||||
}
|
||||
|
||||
fn sync_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
|
||||
fn sync_gemini_shim_file(vault_path: &Path) -> Result<bool, String> {
|
||||
let paths = guidance_paths(vault_path);
|
||||
sync_managed_file(&paths.gemini, GEMINI_MD_SHIM, gemini_shim_can_be_replaced)
|
||||
}
|
||||
|
||||
fn sync_required_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
|
||||
let wrote_agents = sync_default_agents_file(vault_path)?;
|
||||
let wrote_claude = sync_claude_shim_file(vault_path)?;
|
||||
Ok(wrote_agents || wrote_claude)
|
||||
}
|
||||
|
||||
fn sync_all_ai_guidance_files(vault_path: &Path) -> Result<bool, String> {
|
||||
let wrote_required = sync_required_ai_guidance_files(vault_path)?;
|
||||
let wrote_gemini = sync_gemini_shim_file(vault_path)?;
|
||||
Ok(wrote_required || wrote_gemini)
|
||||
}
|
||||
|
||||
fn migrate_legacy_agents_file(
|
||||
root_agents: &Path,
|
||||
config_agents: &Path,
|
||||
@@ -241,8 +290,8 @@ fn cleanup_empty_config_dir(vault: &Path) -> Result<bool, String> {
|
||||
}
|
||||
|
||||
pub(super) fn sync_default_agents_file(vault_path: &Path) -> Result<bool, String> {
|
||||
let (agents_path, _) = guidance_paths(vault_path);
|
||||
sync_managed_file(&agents_path, AGENTS_MD, root_agents_can_be_replaced)
|
||||
let paths = guidance_paths(vault_path);
|
||||
sync_managed_file(&paths.agents, AGENTS_MD, root_agents_can_be_replaced)
|
||||
}
|
||||
|
||||
pub fn get_ai_guidance_status(
|
||||
@@ -255,7 +304,7 @@ pub fn restore_ai_guidance_files(
|
||||
vault_path: impl AsRef<str>,
|
||||
) -> Result<VaultAiGuidanceStatus, String> {
|
||||
let vault_path = Path::new(vault_path.as_ref());
|
||||
sync_ai_guidance_files(vault_path)?;
|
||||
sync_all_ai_guidance_files(vault_path)?;
|
||||
Ok(build_ai_guidance_status(vault_path))
|
||||
}
|
||||
|
||||
@@ -263,7 +312,7 @@ pub fn restore_ai_guidance_files(
|
||||
/// Also seeds Tolaria-managed root type definitions used by repair/bootstrap flows.
|
||||
pub fn seed_config_files(vault_path: impl AsRef<str>) {
|
||||
let vault_path = Path::new(vault_path.as_ref());
|
||||
if sync_ai_guidance_files(vault_path).unwrap_or(false) {
|
||||
if sync_required_ai_guidance_files(vault_path).unwrap_or(false) {
|
||||
log::info!("Seeded vault AI guidance files at vault root");
|
||||
}
|
||||
|
||||
@@ -307,7 +356,7 @@ pub fn migrate_agents_md(vault_path: impl AsRef<str>) {
|
||||
log::info!("Removed empty config/ directory");
|
||||
}
|
||||
|
||||
let _ = sync_ai_guidance_files(vault);
|
||||
let _ = sync_required_ai_guidance_files(vault);
|
||||
}
|
||||
|
||||
/// Repair config files: ensure `AGENTS.md` at vault root and root type definitions.
|
||||
@@ -320,7 +369,7 @@ pub fn repair_config_files(vault_path: impl AsRef<str>) -> Result<String, String
|
||||
|
||||
migrate_legacy_agents_file(&root_agents, &config_agents)?;
|
||||
let _ = cleanup_empty_config_dir(vault)?;
|
||||
sync_ai_guidance_files(vault)?;
|
||||
sync_required_ai_guidance_files(vault)?;
|
||||
|
||||
write_if_missing(&vault.join("type.md"), TYPE_TYPE_DEFINITION)?;
|
||||
write_if_missing(&vault.join("note.md"), NOTE_TYPE_DEFINITION)?;
|
||||
@@ -355,6 +404,10 @@ mod tests {
|
||||
fs::write(vault.join("CLAUDE.md"), content).unwrap();
|
||||
}
|
||||
|
||||
fn write_root_gemini(vault: &Path, content: &str) {
|
||||
fs::write(vault.join("GEMINI.md"), content).unwrap();
|
||||
}
|
||||
|
||||
fn write_legacy_agents(vault: &Path, content: &str) {
|
||||
fs::write(config_dir(vault).join("agents.md"), content).unwrap();
|
||||
}
|
||||
@@ -367,6 +420,10 @@ mod tests {
|
||||
fs::read_to_string(vault.join("CLAUDE.md")).unwrap()
|
||||
}
|
||||
|
||||
fn read_root_gemini(vault: &Path) -> String {
|
||||
fs::read_to_string(vault.join("GEMINI.md")).unwrap()
|
||||
}
|
||||
|
||||
type VaultOperation = fn(&Path);
|
||||
|
||||
fn run_seed(vault: &Path) {
|
||||
@@ -454,15 +511,52 @@ mod tests {
|
||||
assert!(content.contains(expected_root_text));
|
||||
}
|
||||
|
||||
fn assert_required_agents_file_seeded(vault: &Path) {
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
assert!(read_root_agents(vault).contains("Tolaria Vault"));
|
||||
}
|
||||
|
||||
fn assert_required_guidance_shims_seeded(vault: &Path) {
|
||||
assert_eq!(read_root_claude(vault), CLAUDE_MD_SHIM);
|
||||
assert!(!vault.join("GEMINI.md").exists());
|
||||
}
|
||||
|
||||
fn assert_required_guidance_files_seeded(vault: &Path) {
|
||||
assert_required_agents_file_seeded(vault);
|
||||
assert_required_guidance_shims_seeded(vault);
|
||||
}
|
||||
|
||||
fn assert_root_type_definitions_seeded(vault: &Path) {
|
||||
assert!(vault.join("type.md").exists());
|
||||
assert!(vault.join("note.md").exists());
|
||||
assert!(!vault.join("config").exists());
|
||||
}
|
||||
|
||||
fn assert_type_definition_content(vault: &Path) {
|
||||
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
|
||||
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
|
||||
assert_type_definition_body(&type_content);
|
||||
assert_note_definition_body(¬e_content);
|
||||
}
|
||||
|
||||
fn assert_type_definition_body(type_content: &str) {
|
||||
assert!(type_content.contains("type: Type"));
|
||||
assert!(type_content.contains("# Type"));
|
||||
assert!(type_content.contains("visible: false"));
|
||||
}
|
||||
|
||||
fn assert_note_definition_body(note_content: &str) {
|
||||
assert!(note_content.contains("type: Type"));
|
||||
assert!(note_content.contains("# Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_guidance_files_at_root() {
|
||||
let (_dir, vault) = create_vault();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
assert!(read_root_agents(&vault).contains("Tolaria Vault"));
|
||||
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
|
||||
assert_required_guidance_files_seeded(&vault);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -471,16 +565,8 @@ mod tests {
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("type.md").exists());
|
||||
assert!(vault.join("note.md").exists());
|
||||
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
|
||||
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
|
||||
assert!(type_content.contains("type: Type"));
|
||||
assert!(type_content.contains("# Type"));
|
||||
assert!(type_content.contains("visible: false"));
|
||||
assert!(note_content.contains("type: Type"));
|
||||
assert!(note_content.contains("# Note"));
|
||||
assert!(!vault.join("config").exists());
|
||||
assert_root_type_definitions_seeded(&vault);
|
||||
assert_type_definition_content(&vault);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -604,20 +690,12 @@ mod tests {
|
||||
let msg = repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(msg, "Config files repaired");
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
assert!(vault.join("CLAUDE.md").exists());
|
||||
assert!(vault.join("type.md").exists());
|
||||
assert!(vault.join("note.md").exists());
|
||||
assert!(!vault.join("config").exists());
|
||||
|
||||
let agents = read_root_agents(&vault);
|
||||
assert!(agents.contains("Tolaria Vault"));
|
||||
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
|
||||
assert!(type_content.contains("# Type"));
|
||||
assert!(type_content.contains("visible: false"));
|
||||
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
|
||||
assert!(note_content.contains("type: Type"));
|
||||
assert!(note_content.contains("general-purpose document"));
|
||||
assert_required_guidance_files_seeded(&vault);
|
||||
assert_root_type_definitions_seeded(&vault);
|
||||
assert_type_definition_content(&vault);
|
||||
assert!(fs::read_to_string(vault.join("note.md"))
|
||||
.unwrap()
|
||||
.contains("general-purpose document"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -656,9 +734,15 @@ mod tests {
|
||||
write_root_claude(&vault, "");
|
||||
|
||||
let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(status.agents_state, AiGuidanceFileState::Custom);
|
||||
assert_eq!(status.claude_state, AiGuidanceFileState::Broken);
|
||||
assert!(status.can_restore);
|
||||
assert_eq!(
|
||||
status,
|
||||
VaultAiGuidanceStatus {
|
||||
agents_state: AiGuidanceFileState::Custom,
|
||||
claude_state: AiGuidanceFileState::Broken,
|
||||
gemini_state: AiGuidanceFileState::Missing,
|
||||
can_restore: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -668,11 +752,18 @@ mod tests {
|
||||
write_root_claude(&vault, "");
|
||||
|
||||
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(status.agents_state, AiGuidanceFileState::Custom);
|
||||
assert_eq!(status.claude_state, AiGuidanceFileState::Managed);
|
||||
assert!(!status.can_restore);
|
||||
assert_eq!(
|
||||
status,
|
||||
VaultAiGuidanceStatus {
|
||||
agents_state: AiGuidanceFileState::Custom,
|
||||
claude_state: AiGuidanceFileState::Managed,
|
||||
gemini_state: AiGuidanceFileState::Managed,
|
||||
can_restore: false,
|
||||
}
|
||||
);
|
||||
assert!(read_root_agents(&vault).contains("Custom Agent Config"));
|
||||
assert_eq!(read_root_claude(&vault), CLAUDE_MD_SHIM);
|
||||
assert_eq!(read_root_gemini(&vault), GEMINI_MD_SHIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -681,8 +772,28 @@ mod tests {
|
||||
write_root_agents(&vault, "");
|
||||
|
||||
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(status.agents_state, AiGuidanceFileState::Managed);
|
||||
assert_eq!(status.claude_state, AiGuidanceFileState::Managed);
|
||||
assert_eq!(
|
||||
status,
|
||||
VaultAiGuidanceStatus {
|
||||
agents_state: AiGuidanceFileState::Managed,
|
||||
claude_state: AiGuidanceFileState::Managed,
|
||||
gemini_state: AiGuidanceFileState::Managed,
|
||||
can_restore: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_ai_guidance_files_preserves_custom_gemini() {
|
||||
let (_dir, vault) = create_vault();
|
||||
write_root_agents(&vault, AGENTS_MD);
|
||||
write_root_claude(&vault, CLAUDE_MD_SHIM);
|
||||
write_root_gemini(&vault, "# Custom Gemini instructions\nDo not overwrite\n");
|
||||
|
||||
let status = restore_ai_guidance_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(status.gemini_state, AiGuidanceFileState::Custom);
|
||||
assert!(!status.can_restore);
|
||||
assert!(read_root_gemini(&vault).contains("Custom Gemini instructions"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ pub struct VaultEntry {
|
||||
/// Default view mode for the note list when viewing instances of this Type.
|
||||
/// Stored as a string: "all", "editor-list", or "editor-only".
|
||||
pub view: Option<String>,
|
||||
/// Rich-editor width mode for this note. None means use the app default.
|
||||
#[serde(rename = "noteWidth")]
|
||||
pub note_width: Option<String>,
|
||||
/// Whether this Type is visible in the sidebar. Defaults to true when absent.
|
||||
pub visible: Option<bool>,
|
||||
/// Whether this note has been explicitly organized (removed from Inbox).
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::borrow::Cow;
|
||||
use std::fs;
|
||||
use std::io::{Error, ErrorKind, Write};
|
||||
use std::path::Path;
|
||||
@@ -29,6 +30,34 @@ 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()));
|
||||
}
|
||||
fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))
|
||||
}
|
||||
|
||||
struct RawNotePath<'a>(&'a str);
|
||||
|
||||
impl<'a> RawNotePath<'a> {
|
||||
fn is_windows_verbatim(&self) -> bool {
|
||||
self.0.starts_with(r"\\?\") || self.0.starts_with(r"\??\")
|
||||
}
|
||||
|
||||
fn normalized_for_file_io(&self) -> Cow<'a, str> {
|
||||
if !self.is_windows_verbatim() {
|
||||
return Cow::Borrowed(self.0);
|
||||
}
|
||||
if !self.0.contains('/') {
|
||||
return Cow::Borrowed(self.0);
|
||||
}
|
||||
Cow::Owned(self.0.replace('/', r"\"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum NoteIoOperation {
|
||||
Save,
|
||||
@@ -70,16 +99,16 @@ fn note_io_error(operation: NoteIoOperation, path: NotePathDisplay<'_>, error: &
|
||||
|
||||
/// Read the content of a single note file.
|
||||
pub fn get_note_content(path: &Path) -> Result<String, 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))?;
|
||||
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 {
|
||||
@@ -102,7 +131,8 @@ fn validate_save_path(file_path: &Path, display_path: &str) -> Result<(), String
|
||||
/// Write content to a note file. Creates parent directory if needed, validates path,
|
||||
/// then writes content to disk.
|
||||
pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
|
||||
let file_path = Path::new(path);
|
||||
let normalized_path = RawNotePath(path).normalized_for_file_io();
|
||||
let file_path = Path::new(normalized_path.as_ref());
|
||||
if let Some(parent) = file_path.parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
@@ -117,7 +147,8 @@ pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
|
||||
|
||||
/// Create a new note file without overwriting any existing file.
|
||||
pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
|
||||
let file_path = Path::new(path);
|
||||
let normalized_path = RawNotePath(path).normalized_for_file_io();
|
||||
let file_path = Path::new(normalized_path.as_ref());
|
||||
if let Some(parent) = file_path.parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
@@ -155,4 +186,24 @@ mod tests {
|
||||
assert!(message.contains("Rename the note or move it to a valid folder"));
|
||||
assert!(!message.contains("os error 123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_extended_windows_paths_before_file_io() {
|
||||
let path = r"\\?\C:\Users\alex\Documents\Tolaria/Getting Started/untitled-project.md";
|
||||
|
||||
assert_eq!(
|
||||
RawNotePath(path).normalized_for_file_io(),
|
||||
r"\\?\C:\Users\alex\Documents\Tolaria\Getting Started\untitled-project.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ pub(crate) struct Frontmatter {
|
||||
pub sort: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub view: Option<StringOrList>,
|
||||
#[serde(rename = "_width", alias = "width", default)]
|
||||
pub note_width: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub visible: Option<bool>,
|
||||
#[serde(
|
||||
@@ -203,6 +205,8 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
"_sort",
|
||||
"sort",
|
||||
"view",
|
||||
"_width",
|
||||
"width",
|
||||
"visible",
|
||||
"notion_id",
|
||||
"Status",
|
||||
@@ -238,6 +242,8 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"template",
|
||||
"sort",
|
||||
"view",
|
||||
"_width",
|
||||
"width",
|
||||
"visible",
|
||||
"status",
|
||||
"_organized",
|
||||
@@ -332,6 +338,16 @@ pub(crate) fn resolve_is_a(fm_is_a: Option<StringOrList>) -> Option<String> {
|
||||
fm_is_a.and_then(|a| a.into_vec().into_iter().next())
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_note_width(note_width: Option<StringOrList>) -> Option<String> {
|
||||
match note_width
|
||||
.and_then(StringOrList::into_scalar)
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
{
|
||||
Some(mode) if mode == "normal" || mode == "wide" => Some(mode),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert gray_matter::Pod to serde_json::Value
|
||||
fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
|
||||
match pod {
|
||||
|
||||
@@ -22,7 +22,7 @@ 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};
|
||||
@@ -41,7 +41,7 @@ pub use views::{
|
||||
};
|
||||
|
||||
use file::read_file_metadata;
|
||||
use frontmatter::{extract_fm_and_rels, resolve_is_a};
|
||||
use frontmatter::{extract_fm_and_rels, resolve_is_a, resolve_note_width};
|
||||
use parsing::{count_body_words, extract_outgoing_links, extract_snippet, extract_title};
|
||||
|
||||
use gray_matter::engine::YAML;
|
||||
@@ -151,6 +151,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
template: frontmatter.template.and_then(|v| v.into_scalar()),
|
||||
sort: frontmatter.sort.and_then(|v| v.into_scalar()),
|
||||
view: frontmatter.view.and_then(|v| v.into_scalar()),
|
||||
note_width: resolve_note_width(frontmatter.note_width),
|
||||
visible: frontmatter.visible,
|
||||
organized: frontmatter.organized.unwrap_or(false),
|
||||
favorite: frontmatter.favorite.unwrap_or(false),
|
||||
|
||||
@@ -71,3 +71,29 @@ fn ignores_unknown_underscore_keys_in_properties_and_relationships() {
|
||||
Some("Luca")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_note_width_without_property_leaks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"note.md",
|
||||
"---\ntype: Note\n_width: wide\n---\n# Note\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.note_width.as_deref(), Some("wide"));
|
||||
assert!(!entry.properties.contains_key("_width"));
|
||||
assert!(!entry.relationships.contains_key("_width"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_invalid_note_width_modes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"note.md",
|
||||
"---\ntype: Note\n_width: expanded\n---\n# Note\n",
|
||||
);
|
||||
|
||||
assert_eq!(entry.note_width, None);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -144,6 +144,7 @@ const mockCommandResults: Record<string, unknown> = {
|
||||
get_all_content: mockAllContent,
|
||||
get_modified_files: [],
|
||||
get_note_content: mockAllContent['/vault/project/test.md'] || '',
|
||||
save_note_content: null,
|
||||
reload_vault_entry: ({ path }: { path: string }) => mockEntries.find((entry) => entry.path === path) ?? null,
|
||||
sync_vault_asset_scope_for_window: null,
|
||||
get_file_history: [],
|
||||
@@ -279,6 +280,7 @@ function resetMockCommandResults() {
|
||||
get_all_content: mockAllContent,
|
||||
get_modified_files: [],
|
||||
get_note_content: mockAllContent['/vault/project/test.md'] || '',
|
||||
save_note_content: null,
|
||||
reload_vault_entry: ({ path }: { path: string }) => mockEntries.find((entry) => entry.path === path) ?? null,
|
||||
sync_vault_asset_scope_for_window: null,
|
||||
get_file_history: [],
|
||||
@@ -506,7 +508,7 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the app shell skeleton while the vault note scan is pending', async () => {
|
||||
it('keeps the app shell usable 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
|
||||
@@ -515,10 +517,22 @@ describe('App', () => {
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('vault-loading-skeleton')).toBeInTheDocument()
|
||||
})
|
||||
expect(await screen.findByTestId('sidebar-loading-favorites', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('vault-loading-skeleton')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('sidebar-top-nav')).toHaveTextContent('Inbox')
|
||||
expect(screen.getByTestId('sidebar-loading-views')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sidebar-loading-types')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sidebar-loading-folders')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('note-list-loading-skeleton')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('breadcrumb-title-skeleton')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('editor-content-skeleton')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Select a note to start editing')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-vault-reloading')).toHaveAccessibleName('Reloading vault from disk')
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: 'p', code: 'KeyP', metaKey: true })
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(within(screen.getByTestId('quick-open-palette')).getByText('Reloading vault...')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
resolveListVault?.(mockEntries)
|
||||
@@ -527,6 +541,10 @@ describe('App', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('vault-loading-skeleton')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('note-list-loading-skeleton')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('breadcrumb-title-skeleton')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('editor-content-skeleton')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('status-vault-reloading')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByText('Test Project').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -635,6 +653,7 @@ describe('App', () => {
|
||||
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'
|
||||
|
||||
@@ -675,6 +694,7 @@ describe('App', () => {
|
||||
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 />)
|
||||
@@ -708,6 +728,7 @@ describe('App', () => {
|
||||
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 />)
|
||||
@@ -1114,6 +1135,63 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the manually selected note after organizing finishes later', async () => {
|
||||
configureNeighborhoodVault()
|
||||
mockCommandResults.get_settings = {
|
||||
auto_pull_interval_minutes: null,
|
||||
auto_advance_inbox_after_organize: true,
|
||||
telemetry_consent: true,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
let resolveOrganizeSave!: () => void
|
||||
const organizeSave = new Promise<void>((resolve) => {
|
||||
resolveOrganizeSave = resolve
|
||||
})
|
||||
mockCommandResults.save_note_content = vi.fn(() => organizeSave)
|
||||
|
||||
render(<App />)
|
||||
|
||||
const noteListContainer = await screen.findByTestId('note-list-container')
|
||||
await waitFor(() => {
|
||||
expect(getHeaderForNoteList(noteListContainer)).toHaveTextContent('Inbox')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(noteListContainer).getByText('Alpha'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Set note as organized' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Set note as organized' }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(noteListContainer).getByText('Gamma'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(window.__laputaTest?.activeTabPath).toBe('/vault/gamma.md')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
resolveOrganizeSave()
|
||||
await organizeSave
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(window.__laputaTest?.activeTabPath).toBe('/vault/gamma.md')
|
||||
})
|
||||
|
||||
it('renders status bar', async () => {
|
||||
render(<App />)
|
||||
// StatusBar should be present
|
||||
|
||||
165
src/App.tsx
165
src/App.tsx
@@ -32,14 +32,15 @@ import { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useRecentVaultWrites, useVaultWatcher } from './hooks/useVaultWatcher'
|
||||
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useNoteWidthMode } from './hooks/useNoteWidthMode'
|
||||
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'
|
||||
import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
|
||||
import { useViewMode, type ViewMode } from './hooks/useViewMode'
|
||||
import { useNoteLayout } from './hooks/useNoteLayout'
|
||||
import { useEntryActions } from './hooks/useEntryActions'
|
||||
import { useAppCommands } from './hooks/useAppCommands'
|
||||
import { triggerCommitEntryAction } from './utils/commitEntryAction'
|
||||
@@ -72,7 +73,9 @@ import { useConflictFlow } from './hooks/useConflictFlow'
|
||||
import { useAppSave } from './hooks/useAppSave'
|
||||
import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi'
|
||||
import { useVaultBridge } from './hooks/useVaultBridge'
|
||||
import { useSavedViewOrdering } from './hooks/useSavedViewOrdering'
|
||||
import { createViewFilename } from './utils/viewFilename'
|
||||
import { nextViewOrder } from './utils/viewOrdering'
|
||||
import type { CommitDiffRequest } from './hooks/useDiffMode'
|
||||
import { ConflictResolverModal } from './components/ConflictResolverModal'
|
||||
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
|
||||
@@ -84,6 +87,7 @@ import type { SidebarSelection, InboxPeriod, VaultEntry, ViewDefinition } from '
|
||||
import type { NoteListItem } from './utils/ai-context'
|
||||
import { initializeNoteProperties } from './utils/initializeNoteProperties'
|
||||
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
|
||||
import { resolveAllNotesFileVisibility } from './utils/allNotesFileVisibility'
|
||||
import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
|
||||
import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, type NoteWindowParams } from './utils/windowMode'
|
||||
@@ -371,7 +375,10 @@ function App() {
|
||||
}, [resolvedPath, setToastMessage])
|
||||
|
||||
const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath)
|
||||
const recentVaultWrites = useRecentVaultWrites({ vaultPath: noteWindowParams ? '' : resolvedPath })
|
||||
const {
|
||||
markInternalWrite: markRecentVaultWrite,
|
||||
filterExternalPaths: filterExternalVaultPaths,
|
||||
} = useRecentVaultWrites({ vaultPath: noteWindowParams ? '' : resolvedPath })
|
||||
const {
|
||||
status: vaultAiGuidanceStatus,
|
||||
refresh: refreshVaultAiGuidance,
|
||||
@@ -424,6 +431,10 @@ function App() {
|
||||
() => resolveEffectiveLocale(settings.ui_language, [systemLocale]),
|
||||
[settings.ui_language, systemLocale],
|
||||
)
|
||||
const allNotesFileVisibility = useMemo(
|
||||
() => resolveAllNotesFileVisibility(settings),
|
||||
[settings],
|
||||
)
|
||||
const selectedUiLanguage = settings.ui_language ?? SYSTEM_UI_LANGUAGE
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = appLocale
|
||||
@@ -434,6 +445,10 @@ function App() {
|
||||
const theme_mode = documentThemeMode === 'dark' ? 'light' : 'dark'
|
||||
void saveSettings({ ...settings, theme_mode })
|
||||
}, [documentThemeMode, saveSettings, settings])
|
||||
const handleSetThemeMode = useCallback((theme_mode: ThemeMode) => {
|
||||
if (!settingsLoaded) return
|
||||
void saveSettings({ ...settings, theme_mode })
|
||||
}, [saveSettings, settings, settingsLoaded])
|
||||
const handleSetUiLanguage = useCallback((uiLanguage: UiLanguagePreference) => {
|
||||
void saveSettings({ ...settings, ui_language: serializeUiLanguagePreference(uiLanguage) })
|
||||
}, [saveSettings, settings])
|
||||
@@ -453,7 +468,16 @@ function App() {
|
||||
trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length })
|
||||
}
|
||||
}, [vault.entries.length, gitRepoState, resolvedPath])
|
||||
const { mcpStatus, connectMcp, disconnectMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
||||
const {
|
||||
mcpStatus,
|
||||
connectMcp,
|
||||
disconnectMcp,
|
||||
mcpConfigSnippet,
|
||||
mcpConfigLoading,
|
||||
mcpConfigError,
|
||||
loadMcpConfigSnippet,
|
||||
copyMcpConfig,
|
||||
} = useMcpStatus(resolvedPath, setToastMessage, appLocale)
|
||||
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
|
||||
const loadVaultModifiedFiles = vault.loadModifiedFiles
|
||||
const refreshGitRemoteStatus = gitRemoteStatus.refreshRemoteStatus
|
||||
@@ -493,6 +517,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(() => {
|
||||
@@ -531,11 +563,21 @@ function App() {
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
|
||||
})
|
||||
const flushPendingEditorContentRef = useRef<((path: string) => void) | null>(null)
|
||||
const flushPendingRawContentRef = useRef<((path: string) => void) | null>(null)
|
||||
const flushEditorStateBeforeAction = async (path: string) => {
|
||||
flushPendingEditorContentRef.current?.(path)
|
||||
flushPendingRawContentRef.current?.(path)
|
||||
await appSave.flushBeforeAction(path)
|
||||
}
|
||||
const handleCreatedVaultEntryPersisting = useCallback((path: string) => {
|
||||
markRecentVaultWrite(path)
|
||||
vault.addPendingSave(path)
|
||||
}, [markRecentVaultWrite, vault])
|
||||
const handleCreatedVaultEntryPersisted = useCallback((path: string) => {
|
||||
markRecentVaultWrite(path)
|
||||
vault.loadModifiedFiles()
|
||||
}, [markRecentVaultWrite, vault])
|
||||
|
||||
const notes = useNoteActions({
|
||||
addEntry: vault.addEntry,
|
||||
@@ -547,13 +589,13 @@ function App() {
|
||||
setToastMessage,
|
||||
updateEntry: vault.updateEntry,
|
||||
vaultPath: resolvedPath,
|
||||
addPendingSave: vault.addPendingSave,
|
||||
addPendingSave: handleCreatedVaultEntryPersisting,
|
||||
removePendingSave: vault.removePendingSave,
|
||||
trackUnsaved: vault.trackUnsaved,
|
||||
clearUnsaved: vault.clearUnsaved,
|
||||
unsavedPaths: vault.unsavedPaths,
|
||||
markContentPending: (path, content) => appSave.contentChangeRef.current(path, content),
|
||||
onNewNotePersisted: vault.loadModifiedFiles,
|
||||
onNewNotePersisted: handleCreatedVaultEntryPersisted,
|
||||
onTypeStateChanged: async () => { await vault.reloadVault() },
|
||||
replaceEntry: vault.replaceEntry,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
@@ -594,7 +636,7 @@ function App() {
|
||||
useVaultWatcher({
|
||||
vaultPath: noteWindowParams ? '' : resolvedPath,
|
||||
onVaultChanged: handlePulledVaultUpdate,
|
||||
filterChangedPaths: recentVaultWrites.filterExternalPaths,
|
||||
filterChangedPaths: filterExternalVaultPaths,
|
||||
})
|
||||
const autoSync = useAutoSync({
|
||||
enabled: gitRepoState === 'ready',
|
||||
@@ -747,7 +789,8 @@ function App() {
|
||||
handleRenameNote: notes.handleRenameNote, handleRenameFilename: notes.handleRenameFilename,
|
||||
replaceEntry: vault.replaceEntry, resolvedPath,
|
||||
initialH1AutoRenameEnabled: settings.initial_h1_auto_rename_enabled !== false,
|
||||
onInternalVaultWrite: recentVaultWrites.markInternalWrite,
|
||||
onInternalVaultWrite: markRecentVaultWrite,
|
||||
locale: appLocale,
|
||||
})
|
||||
|
||||
const aiActivity = useAiActivity({
|
||||
@@ -955,10 +998,14 @@ function App() {
|
||||
}, [handleAppContentChange, recordAutoGitActivity])
|
||||
|
||||
const handleTrackedSave = useCallback(async (...args: Parameters<typeof handleAppSave>) => {
|
||||
if (notes.activeTabPath) {
|
||||
flushPendingEditorContentRef.current?.(notes.activeTabPath)
|
||||
flushPendingRawContentRef.current?.(notes.activeTabPath)
|
||||
}
|
||||
const result = await handleAppSave(...args)
|
||||
recordAutoGitActivity()
|
||||
return result
|
||||
}, [handleAppSave, recordAutoGitActivity])
|
||||
}, [handleAppSave, notes.activeTabPath, recordAutoGitActivity])
|
||||
|
||||
const seedAutoGitSavedChange = useCallback(async () => {
|
||||
if (isTauri()) {
|
||||
@@ -1004,7 +1051,7 @@ function App() {
|
||||
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
|
||||
createTypeEntry: notes.createTypeEntrySilent,
|
||||
onBeforeAction: appSave.flushBeforeAction,
|
||||
onBeforeAction: flushEditorStateBeforeAction,
|
||||
})
|
||||
|
||||
const deleteActions = useDeleteActions({
|
||||
@@ -1016,6 +1063,14 @@ function App() {
|
||||
setToastMessage,
|
||||
})
|
||||
|
||||
const handleDeleteType = useCallback((typeName: string) => {
|
||||
const typeEntry = vault.entries.find((entry) => entry.isA === 'Type' && entry.title === typeName)
|
||||
if (!typeEntry) return
|
||||
|
||||
trackEvent('sidebar_type_delete_requested')
|
||||
deleteActions.handleDeleteNote(typeEntry.path)
|
||||
}, [deleteActions, vault.entries])
|
||||
|
||||
const shouldLoadGitHistory = !layout.inspectorCollapsed && !showAIChat
|
||||
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory, shouldLoadGitHistory)
|
||||
|
||||
@@ -1059,7 +1114,9 @@ function App() {
|
||||
const filename = editing
|
||||
? editing.filename
|
||||
: createViewFilename(definition.name, vault.views.map((view) => view.filename))
|
||||
const nextDefinition = editing ? { ...editing.definition, ...definition } : definition
|
||||
const nextDefinition = editing
|
||||
? { ...editing.definition, ...definition }
|
||||
: { ...definition, order: nextViewOrder(vault.views) }
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
try {
|
||||
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition })
|
||||
@@ -1131,7 +1188,6 @@ function App() {
|
||||
const findInNoteRef = useRef<((options?: { replace?: boolean }) => void) | null>(null)
|
||||
|
||||
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode(noteWindowParams ? 'editor-only' : undefined)
|
||||
const { noteLayout, toggleNoteLayout } = useNoteLayout()
|
||||
const zoom = useZoom()
|
||||
const buildNumber = useBuildNumber()
|
||||
|
||||
@@ -1302,6 +1358,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],
|
||||
@@ -1350,7 +1415,12 @@ function App() {
|
||||
|
||||
const organized = await entryActions.handleToggleOrganized(path)
|
||||
|
||||
if (organized && nextVisibleInboxEntry) {
|
||||
if (
|
||||
organized
|
||||
&& nextVisibleInboxEntry
|
||||
&& notes.activeTabPathRef.current === path
|
||||
&& notes.requestedActiveTabPathRef.current === path
|
||||
) {
|
||||
void notes.handleSelectNote(nextVisibleInboxEntry)
|
||||
}
|
||||
}, [effectiveSelection, entryActions, notes, settings.auto_advance_inbox_after_organize, vault.entries])
|
||||
@@ -1373,6 +1443,22 @@ function App() {
|
||||
return entries
|
||||
}, [reloadVaultForCommand, setToastMessage])
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
defaultNoteWidth,
|
||||
noteWidth: activeNoteWidth,
|
||||
setDefaultNoteWidth: handleSetDefaultNoteWidth,
|
||||
setNoteWidth: handleSetActiveNoteWidth,
|
||||
toggleNoteWidth: handleToggleNoteWidth,
|
||||
} = useNoteWidthMode({
|
||||
tabs: notes.tabs,
|
||||
activeTabPath: notes.activeTabPath,
|
||||
settings,
|
||||
saveSettings,
|
||||
updateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
setToastMessage,
|
||||
})
|
||||
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
entries: vault.entries,
|
||||
@@ -1401,8 +1487,15 @@ function App() {
|
||||
onToggleInspector: handleToggleInspector,
|
||||
onToggleDiff: toggleDiffCommand,
|
||||
onToggleRawEditor: toggleRawEditorCommand,
|
||||
noteLayout,
|
||||
onToggleNoteLayout: toggleNoteLayout,
|
||||
noteWidth: activeNoteWidth,
|
||||
defaultNoteWidth,
|
||||
onSetNoteWidth: handleSetActiveNoteWidth,
|
||||
onSetDefaultNoteWidth: handleSetDefaultNoteWidth,
|
||||
selectedViewName: viewOrdering.selectedViewName,
|
||||
onMoveSelectedViewUp: viewOrdering.onMoveSelectedViewUp,
|
||||
onMoveSelectedViewDown: viewOrdering.onMoveSelectedViewDown,
|
||||
canMoveSelectedViewUp: viewOrdering.canMoveSelectedViewUp,
|
||||
canMoveSelectedViewDown: viewOrdering.canMoveSelectedViewDown,
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
zoomLevel: zoom.zoomLevel,
|
||||
onSelect: handleSetSelection,
|
||||
@@ -1428,6 +1521,7 @@ function App() {
|
||||
systemLocale,
|
||||
selectedUiLanguage,
|
||||
onSetUiLanguage: handleSetUiLanguage,
|
||||
onSetThemeMode: handleSetThemeMode,
|
||||
mcpStatus,
|
||||
onInstallMcp: openMcpSetupDialog,
|
||||
onOpenAiAgents: dialogs.openSettings,
|
||||
@@ -1461,17 +1555,20 @@ function App() {
|
||||
canRestoreDeletedNote: !!activeDeletedFile,
|
||||
})
|
||||
|
||||
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
|
||||
|
||||
const inboxCount = useMemo(() => filterInboxEntries(vault.entries, inboxPeriod).length, [vault.entries, inboxPeriod])
|
||||
|
||||
const aiNoteList = useMemo<NoteListItem[]>(() => {
|
||||
const isInbox = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'inbox'
|
||||
const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, effectiveSelection, undefined, vault.views)
|
||||
const filtered = isInbox
|
||||
? filterInboxEntries(vault.entries, inboxPeriod)
|
||||
: filterEntries(vault.entries, effectiveSelection, {
|
||||
views: vault.views,
|
||||
allNotesFileVisibility,
|
||||
})
|
||||
return filtered.map(e => ({
|
||||
path: e.path, title: e.title, type: e.isA ?? 'Note',
|
||||
}))
|
||||
}, [vault.entries, vault.views, effectiveSelection, inboxPeriod])
|
||||
}, [allNotesFileVisibility, vault.entries, vault.views, effectiveSelection, inboxPeriod])
|
||||
|
||||
const aiNoteListFilter = useMemo(() => {
|
||||
if (effectiveSelection.kind === 'sectionGroup') return { type: effectiveSelection.type, query: '' }
|
||||
@@ -1535,17 +1632,7 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
// Show loading skeleton while checking git status or scanning vault notes.
|
||||
if (!noteWindowParams && onboarding.state.status === 'ready' && (gitRepoState === 'checking' || vault.isLoading)) {
|
||||
return (
|
||||
<AppLoadingSkeleton
|
||||
noteListWidth={layout.noteListWidth}
|
||||
showNoteList={noteListVisible}
|
||||
showSidebar={sidebarVisible}
|
||||
sidebarWidth={layout.sidebarWidth}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const isVaultContentLoading = !noteWindowParams && onboarding.state.status === 'ready' && vault.isLoading
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
@@ -1553,7 +1640,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} />
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onDeleteType={handleDeleteType} 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} allNotesFileVisibility={allNotesFileVisibility} locale={appLocale} loading={isVaultContentLoading} vaultRootPath={resolvedPath} canGoBack={canGoBack} canGoForward={canGoForward} onGoBack={handleGoBack} onGoForward={handleGoForward} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -1564,7 +1651,7 @@ function App() {
|
||||
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
|
||||
<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} />
|
||||
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} loading={isVaultContentLoading} 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} allNotesFileVisibility={allNotesFileVisibility} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -1574,6 +1661,7 @@ function App() {
|
||||
<Editor
|
||||
tabs={notes.tabs}
|
||||
activeTabPath={notes.activeTabPath}
|
||||
isVaultLoading={isVaultContentLoading}
|
||||
entries={vault.entries}
|
||||
onNavigateWikilink={notes.handleNavigateWikilink}
|
||||
onLoadDiff={vault.loadDiff}
|
||||
@@ -1615,8 +1703,8 @@ function App() {
|
||||
onContentChange={handleTrackedContentChange}
|
||||
onSave={handleTrackedSave}
|
||||
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
|
||||
noteLayout={noteLayout}
|
||||
onToggleNoteLayout={toggleNoteLayout}
|
||||
noteWidth={activeNoteWidth}
|
||||
onToggleNoteWidth={handleToggleNoteWidth}
|
||||
rawToggleRef={rawToggleRef}
|
||||
findInNoteRef={findInNoteRef}
|
||||
diffToggleRef={diffToggleRef}
|
||||
@@ -1631,6 +1719,7 @@ function App() {
|
||||
isConflicted={conflictFlow.isConflicted}
|
||||
onKeepMine={conflictFlow.handleKeepMine}
|
||||
onKeepTheirs={conflictFlow.handleKeepTheirs}
|
||||
flushPendingEditorContentRef={flushPendingEditorContentRef}
|
||||
flushPendingRawContentRef={flushPendingRawContentRef}
|
||||
locale={appLocale}
|
||||
/>
|
||||
@@ -1638,11 +1727,11 @@ function App() {
|
||||
</div>
|
||||
<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} 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} />
|
||||
<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 || vault.isLoading} 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} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} isLoading={vault.isLoading} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} locale={appLocale} />
|
||||
<CommandPalette
|
||||
open={dialogs.showCommandPalette}
|
||||
commands={commands}
|
||||
@@ -1663,7 +1752,7 @@ function App() {
|
||||
onSelectType={noteRetargetingUi.selectType}
|
||||
onSelectFolder={noteRetargetingUi.selectFolder}
|
||||
/>
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} locale={appLocale} editingView={dialogs.editingView?.definition ?? null} />
|
||||
<CommitDialog
|
||||
open={commitFlow.showCommitDialog}
|
||||
modifiedCount={vault.modifiedFiles.length}
|
||||
@@ -1683,9 +1772,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
|
||||
|
||||
@@ -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,90 +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 },
|
||||
opencode: { status: 'missing', version: null },
|
||||
pi: { 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()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-install-opencode')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-install-pi')).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 },
|
||||
opencode: { status: 'missing', version: null },
|
||||
pi: { 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()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-install-opencode')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-install-pi')).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 },
|
||||
opencode: { status: 'missing', version: null },
|
||||
pi: { 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'))
|
||||
fireEvent.click(screen.getByTestId('ai-agents-onboarding-install-opencode'))
|
||||
fireEvent.click(screen.getByTestId('ai-agents-onboarding-install-pi'))
|
||||
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')
|
||||
expect(openExternalUrl).toHaveBeenCalledWith('https://opencode.ai/docs/')
|
||||
expect(openExternalUrl).toHaveBeenCalledWith('https://pi.dev')
|
||||
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 },
|
||||
opencode: { status: 'missing', version: null },
|
||||
pi: { 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)
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface AiAction {
|
||||
export interface AiMessageProps {
|
||||
userMessage: string
|
||||
references?: NoteReference[]
|
||||
localMarker?: string
|
||||
reasoning?: string
|
||||
reasoningDone?: boolean
|
||||
actions: AiAction[]
|
||||
@@ -27,6 +28,18 @@ export interface AiMessageProps {
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
}
|
||||
|
||||
function LocalMarker({ text }: { text: string }) {
|
||||
return (
|
||||
<div
|
||||
className="mx-auto text-center text-muted-foreground"
|
||||
style={{ fontSize: 11, margin: '8px 0 16px', maxWidth: '85%' }}
|
||||
data-testid="ai-local-marker"
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReferencePill({ reference, onClick }: {
|
||||
reference: NoteReference
|
||||
onClick?: (path: string) => void
|
||||
@@ -176,7 +189,15 @@ function StreamingIndicator() {
|
||||
)
|
||||
}
|
||||
|
||||
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
|
||||
export function AiMessage(props: AiMessageProps) {
|
||||
if (props.localMarker) {
|
||||
return <LocalMarker text={props.localMarker} />
|
||||
}
|
||||
|
||||
return <ConversationMessage {...props} />
|
||||
}
|
||||
|
||||
function ConversationMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
|
||||
// Manual override: null = follow auto behavior, true/false = user forced
|
||||
const [userOverride, setUserOverride] = useState(false)
|
||||
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
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'
|
||||
|
||||
const { trackEventMock } = vi.hoisted(() => ({
|
||||
trackEventMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/telemetry', () => ({
|
||||
trackEvent: trackEventMock,
|
||||
}))
|
||||
|
||||
// Mock the hooks and utils to isolate component tests
|
||||
let mockMessages: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['messages'] = []
|
||||
let mockStatus: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['status'] = 'idle'
|
||||
const mockSendMessage = vi.fn()
|
||||
const mockClearConversation = vi.fn()
|
||||
const mockAddLocalMarker = vi.fn()
|
||||
const mockUseCliAiAgent = vi.fn()
|
||||
|
||||
vi.mock('../hooks/useCliAiAgent', () => ({
|
||||
useCliAiAgent: () => ({
|
||||
messages: mockMessages,
|
||||
status: mockStatus,
|
||||
sendMessage: mockSendMessage,
|
||||
clearConversation: mockClearConversation,
|
||||
}),
|
||||
useCliAiAgent: (...args: unknown[]) => {
|
||||
mockUseCliAiAgent(...args)
|
||||
return {
|
||||
messages: mockMessages,
|
||||
status: mockStatus,
|
||||
sendMessage: mockSendMessage,
|
||||
clearConversation: mockClearConversation,
|
||||
addLocalMarker: mockAddLocalMarker,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../utils/ai-chat', () => ({
|
||||
@@ -45,22 +61,135 @@ 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()
|
||||
trackEventMock.mockClear()
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore({
|
||||
zoom: null,
|
||||
view_mode: null,
|
||||
editor_mode: null,
|
||||
note_layout: null,
|
||||
tag_colors: null,
|
||||
status_colors: null,
|
||||
property_display_modes: null,
|
||||
inbox: null,
|
||||
allNotes: null,
|
||||
ai_agent_permission_mode: 'safe',
|
||||
}, vi.fn())
|
||||
})
|
||||
|
||||
it('renders panel with the default CLI agent header', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByText('AI Agent')).toBeTruthy()
|
||||
expect(screen.getByText('Claude Code')).toBeTruthy()
|
||||
expect(screen.getByText('Claude Code · Safe')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('passes the vault permission mode to the AI agent session', () => {
|
||||
bindVaultConfigStore({
|
||||
...getVaultConfig(),
|
||||
ai_agent_permission_mode: 'power_user',
|
||||
}, vi.fn())
|
||||
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
|
||||
expect(screen.getByText('Claude Code · Power User')).toBeTruthy()
|
||||
expect(mockUseCliAiAgent).toHaveBeenCalledWith(
|
||||
'/tmp/vault',
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ permissionMode: 'power_user' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('persists permission mode changes and records a local transcript marker', () => {
|
||||
const save = vi.fn()
|
||||
bindVaultConfigStore({
|
||||
...getVaultConfig(),
|
||||
ai_agent_permission_mode: 'safe',
|
||||
}, save)
|
||||
mockMessages = [{
|
||||
userMessage: 'Existing question',
|
||||
actions: [],
|
||||
response: 'Existing answer.',
|
||||
id: 'msg-existing',
|
||||
}]
|
||||
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Power User' }))
|
||||
|
||||
expect(getVaultConfig().ai_agent_permission_mode).toBe('power_user')
|
||||
expect(save).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
ai_agent_permission_mode: 'power_user',
|
||||
}))
|
||||
expect(mockAddLocalMarker).toHaveBeenCalledWith(
|
||||
'AI permission mode changed to Power User. It will apply to the next message.',
|
||||
)
|
||||
expect(trackEventMock).toHaveBeenCalledWith('ai_agent_permission_mode_changed', {
|
||||
agent: 'claude_code',
|
||||
permission_mode: 'power_user',
|
||||
})
|
||||
})
|
||||
|
||||
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', () => {
|
||||
@@ -85,6 +214,12 @@ 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 Claude Code about it')).toBeTruthy()
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
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'
|
||||
@@ -26,6 +27,7 @@ interface AiPanelProps {
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
defaultAiAgentReady?: boolean
|
||||
locale?: AppLocale
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
@@ -47,6 +49,7 @@ interface AiPanelViewProps {
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
defaultAiAgentReady?: boolean
|
||||
locale?: AppLocale
|
||||
activeEntry?: VaultEntry | null
|
||||
entries?: VaultEntry[]
|
||||
}
|
||||
@@ -63,6 +66,7 @@ export function AiPanelView({
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady,
|
||||
locale = 'en',
|
||||
activeEntry,
|
||||
entries,
|
||||
}: AiPanelViewProps) {
|
||||
@@ -79,8 +83,10 @@ export function AiPanelView({
|
||||
linkedEntries,
|
||||
hasContext,
|
||||
isActive,
|
||||
permissionMode,
|
||||
handleSend,
|
||||
handleNavigateWikilink,
|
||||
handlePermissionModeChange,
|
||||
handleNewChat,
|
||||
} = controller
|
||||
|
||||
@@ -112,15 +118,20 @@ export function AiPanelView({
|
||||
<AiPanelHeader
|
||||
agentLabel={agentLabel}
|
||||
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}
|
||||
agentReadiness={defaultAiAgentReadiness}
|
||||
locale={locale}
|
||||
messages={agent.messages}
|
||||
isActive={isActive}
|
||||
onOpenNote={onOpenNote}
|
||||
@@ -131,6 +142,7 @@ export function AiPanelView({
|
||||
entries={entries ?? []}
|
||||
agentLabel={agentLabel}
|
||||
agentReadiness={defaultAiAgentReadiness}
|
||||
locale={locale}
|
||||
input={input}
|
||||
inputRef={inputRef}
|
||||
isActive={isActive}
|
||||
@@ -149,6 +161,7 @@ export function AiPanel({
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady,
|
||||
locale = 'en',
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
@@ -173,6 +186,7 @@ export function AiPanel({
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
locale,
|
||||
onOpenNote,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
@@ -188,6 +202,7 @@ export function AiPanel({
|
||||
defaultAiAgent={providedDefaultAiAgent}
|
||||
defaultAiAgentReadiness={defaultAiAgentReadiness}
|
||||
defaultAiAgentReady={providedDefaultAiAgentReady}
|
||||
locale={locale}
|
||||
activeEntry={activeEntry}
|
||||
entries={entries}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
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'
|
||||
@@ -11,18 +19,24 @@ import type { VaultEntry } from '../types'
|
||||
interface AiPanelHeaderProps {
|
||||
agentLabel: string
|
||||
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
|
||||
agentReadiness: AiAgentReadiness
|
||||
locale?: AppLocale
|
||||
messages: AiAgentMessage[]
|
||||
isActive: boolean
|
||||
onOpenNote?: (path: string) => void
|
||||
@@ -34,6 +48,7 @@ interface AiPanelComposerProps {
|
||||
entries: VaultEntry[]
|
||||
agentLabel: string
|
||||
agentReadiness: AiAgentReadiness
|
||||
locale?: AppLocale
|
||||
input: string
|
||||
inputRef: React.RefObject<HTMLDivElement | null>
|
||||
isActive: boolean
|
||||
@@ -45,23 +60,54 @@ interface AiPanelComposerProps {
|
||||
function getComposerPlaceholder(
|
||||
agentLabel: string,
|
||||
agentReadiness: AiAgentReadiness,
|
||||
t: ReturnType<typeof createTranslator>,
|
||||
): string {
|
||||
if (agentReadiness === 'checking') {
|
||||
return 'Checking AI agent availability...'
|
||||
return t('ai.panel.placeholder.checking')
|
||||
}
|
||||
|
||||
if (agentReadiness === 'missing') {
|
||||
return `${agentLabel} is not installed. Open AI Agents in Settings.`
|
||||
return t('ai.panel.placeholder.missing', { agent: agentLabel })
|
||||
}
|
||||
|
||||
return `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,
|
||||
agentReadiness,
|
||||
hasContext,
|
||||
}: Pick<AiPanelMessageHistoryProps, 'agentLabel' | 'agentReadiness' | 'hasContext'>) {
|
||||
locale = 'en',
|
||||
}: Pick<AiPanelMessageHistoryProps, 'agentLabel' | 'agentReadiness' | 'hasContext' | 'locale'>) {
|
||||
const t = createTranslator(locale)
|
||||
|
||||
if (agentReadiness === 'checking') {
|
||||
return (
|
||||
<div
|
||||
@@ -70,10 +116,10 @@ function AiPanelEmptyState({
|
||||
>
|
||||
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
|
||||
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
|
||||
Checking AI agent availability
|
||||
{t('ai.panel.empty.checkingTitle')}
|
||||
</p>
|
||||
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
|
||||
Messages can be sent when the selected agent is ready
|
||||
{t('ai.panel.empty.checkingDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -87,10 +133,10 @@ 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.missingTitle', { agent: agentLabel })}
|
||||
</p>
|
||||
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
|
||||
Install it or switch the default AI agent in Settings
|
||||
{t('ai.panel.empty.missingDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -104,14 +150,14 @@ function AiPanelEmptyState({
|
||||
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
|
||||
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
|
||||
{hasContext
|
||||
? `Ask ${agentLabel} about this note and its linked context`
|
||||
: `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>
|
||||
@@ -121,45 +167,119 @@ function AiPanelEmptyState({
|
||||
export function AiPanelHeader({
|
||||
agentLabel,
|
||||
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 }}>
|
||||
AI Agent
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{agentReadiness === 'checking'
|
||||
? 'Checking availability'
|
||||
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ''}`}
|
||||
</span>
|
||||
<div className="flex items-center" style={{ gap: 8 }}>
|
||||
<Robot size={16} className="shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
{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"
|
||||
@@ -169,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>
|
||||
)
|
||||
@@ -178,6 +298,7 @@ export function AiPanelContextBar({ activeEntry, linkedCount }: AiPanelContextBa
|
||||
export function AiPanelMessageHistory({
|
||||
agentLabel,
|
||||
agentReadiness,
|
||||
locale = 'en',
|
||||
messages,
|
||||
isActive,
|
||||
onOpenNote,
|
||||
@@ -196,6 +317,7 @@ export function AiPanelMessageHistory({
|
||||
<AiPanelEmptyState
|
||||
agentLabel={agentLabel}
|
||||
agentReadiness={agentReadiness}
|
||||
locale={locale}
|
||||
hasContext={hasContext}
|
||||
/>
|
||||
)}
|
||||
@@ -216,6 +338,7 @@ export function AiPanelComposer({
|
||||
entries,
|
||||
agentLabel,
|
||||
agentReadiness,
|
||||
locale = 'en',
|
||||
input,
|
||||
inputRef,
|
||||
isActive,
|
||||
@@ -223,9 +346,10 @@ export function AiPanelComposer({
|
||||
onSend,
|
||||
onUnsupportedAiPaste,
|
||||
}: AiPanelComposerProps) {
|
||||
const t = createTranslator(locale)
|
||||
const composerDisabled = isActive || agentReadiness !== 'ready'
|
||||
const canSend = !composerDisabled && input.trim().length > 0
|
||||
const placeholder = getComposerPlaceholder(agentLabel, agentReadiness)
|
||||
const placeholder = getComposerPlaceholder(agentLabel, agentReadiness, t)
|
||||
const sendButtonStyle = {
|
||||
background: canSend ? 'var(--primary)' : 'var(--muted)',
|
||||
color: canSend ? 'var(--primary-foreground)' : 'var(--muted-foreground)',
|
||||
@@ -253,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>
|
||||
)
|
||||
|
||||
@@ -327,25 +327,25 @@ describe('BreadcrumbBar — raw editor toggle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — note layout toggle', () => {
|
||||
it('shows the left-align layout action while centered', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="centered" onToggleNoteLayout={vi.fn()} />)
|
||||
describe('BreadcrumbBar — note width toggle', () => {
|
||||
it('shows the wide width action while normal', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteWidth="normal" onToggleNoteWidth={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Switch to left-aligned note layout' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Switch to wide note width' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the centered layout action while left-aligned', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="left" onToggleNoteLayout={vi.fn()} />)
|
||||
it('shows the normal width action while wide', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteWidth="wide" onToggleNoteWidth={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Switch to centered note layout' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Switch to normal note width' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onToggleNoteLayout when the layout button is clicked', () => {
|
||||
const onToggleNoteLayout = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="centered" onToggleNoteLayout={onToggleNoteLayout} />)
|
||||
it('calls onToggleNoteWidth when the width button is clicked', () => {
|
||||
const onToggleNoteWidth = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteWidth="normal" onToggleNoteWidth={onToggleNoteWidth} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch to left-aligned note layout' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch to wide note width' }))
|
||||
|
||||
expect(onToggleNoteLayout).toHaveBeenCalledOnce()
|
||||
expect(onToggleNoteWidth).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import type { NoteLayout, VaultEntry } from '../types'
|
||||
import type { NoteWidthMode, VaultEntry } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
Star,
|
||||
CheckCircle,
|
||||
ArrowsClockwise,
|
||||
TextAlignCenter,
|
||||
TextAlignLeft,
|
||||
ArrowsInLineHorizontal,
|
||||
ArrowsOutLineHorizontal,
|
||||
} from '@phosphor-icons/react'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import { slugify } from '../hooks/useNoteCreation'
|
||||
@@ -50,11 +50,12 @@ interface BreadcrumbBarProps {
|
||||
onArchive?: () => void
|
||||
onUnarchive?: () => void
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
noteWidth?: NoteWidthMode
|
||||
onToggleNoteWidth?: () => void
|
||||
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
|
||||
barRef?: React.Ref<HTMLDivElement>
|
||||
locale?: AppLocale
|
||||
loadingTitle?: boolean
|
||||
}
|
||||
|
||||
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
@@ -257,27 +258,27 @@ function RawToggleButton({ rawMode, locale = 'en', onToggleRaw }: { rawMode?: bo
|
||||
return <ConfiguredToggleAction active={!!rawMode} config={TOGGLE_ACTION_CONFIGS.raw} locale={locale} onClick={onToggleRaw} />
|
||||
}
|
||||
|
||||
function NoteLayoutAction({
|
||||
noteLayout = 'centered',
|
||||
function NoteWidthAction({
|
||||
noteWidth = 'normal',
|
||||
locale = 'en',
|
||||
onToggleNoteLayout,
|
||||
onToggleNoteWidth,
|
||||
}: {
|
||||
noteLayout?: NoteLayout
|
||||
noteWidth?: NoteWidthMode
|
||||
locale?: AppLocale
|
||||
onToggleNoteLayout?: () => void
|
||||
onToggleNoteWidth?: () => void
|
||||
}) {
|
||||
if (!onToggleNoteLayout) return null
|
||||
if (!onToggleNoteWidth) return null
|
||||
|
||||
const isLeftAligned = noteLayout === 'left'
|
||||
const isWide = noteWidth === 'wide'
|
||||
return (
|
||||
<IconActionButton
|
||||
copy={{ label: translate(locale, isLeftAligned ? 'editor.toolbar.centerLayout' : 'editor.toolbar.leftLayout') }}
|
||||
onClick={onToggleNoteLayout}
|
||||
className={cn(isLeftAligned ? 'text-foreground' : 'hover:text-foreground')}
|
||||
copy={{ label: translate(locale, isWide ? 'editor.toolbar.noteWidthNormal' : 'editor.toolbar.noteWidthWide') }}
|
||||
onClick={onToggleNoteWidth}
|
||||
className={cn(isWide ? 'text-foreground' : 'hover:text-foreground')}
|
||||
>
|
||||
{isLeftAligned
|
||||
? <TextAlignLeft size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
: <TextAlignCenter size={16} className={BREADCRUMB_ICON_CLASS} />}
|
||||
{isWide
|
||||
? <ArrowsInLineHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
: <ArrowsOutLineHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />}
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
@@ -615,6 +616,16 @@ function FilenameCrumb({ entry, locale = 'en', onRenameFilename }: Pick<Breadcru
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbTitleSkeleton() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid="breadcrumb-title-skeleton"
|
||||
className="h-4 w-36 animate-pulse rounded bg-muted"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbActions({
|
||||
entry,
|
||||
showDiffToggle,
|
||||
@@ -624,8 +635,8 @@ function BreadcrumbActions({
|
||||
rawMode,
|
||||
onToggleRaw,
|
||||
forceRawMode,
|
||||
noteLayout,
|
||||
onToggleNoteLayout,
|
||||
noteWidth,
|
||||
onToggleNoteWidth,
|
||||
showAIChat,
|
||||
onToggleAIChat,
|
||||
inspectorCollapsed,
|
||||
@@ -651,7 +662,7 @@ function BreadcrumbActions({
|
||||
locale={locale}
|
||||
/>
|
||||
{!forceRawMode && <RawToggleButton rawMode={rawMode} locale={locale} onToggleRaw={onToggleRaw} />}
|
||||
<NoteLayoutAction noteLayout={noteLayout} locale={locale} onToggleNoteLayout={onToggleNoteLayout} />
|
||||
<NoteWidthAction noteWidth={noteWidth} locale={locale} onToggleNoteWidth={onToggleNoteWidth} />
|
||||
<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} />
|
||||
@@ -664,15 +675,18 @@ function BreadcrumbActions({
|
||||
function BreadcrumbTitle({
|
||||
entry,
|
||||
locale,
|
||||
loadingTitle,
|
||||
onRenameFilename,
|
||||
}: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onRenameFilename'>) {
|
||||
}: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'loadingTitle' | '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} locale={locale} onRenameFilename={onRenameFilename} />
|
||||
{loadingTitle
|
||||
? <BreadcrumbTitleSkeleton />
|
||||
: <FilenameCrumb entry={entry} locale={locale} onRenameFilename={onRenameFilename} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -682,6 +696,7 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
entry,
|
||||
barRef,
|
||||
locale = 'en',
|
||||
loadingTitle = false,
|
||||
onRenameFilename,
|
||||
...actionProps
|
||||
}: BreadcrumbBarProps) {
|
||||
@@ -703,7 +718,12 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
}}
|
||||
>
|
||||
<div className="breadcrumb-bar__title min-w-0">
|
||||
<BreadcrumbTitle entry={entry} locale={locale} onRenameFilename={onRenameFilename} />
|
||||
<BreadcrumbTitle
|
||||
entry={entry}
|
||||
locale={locale}
|
||||
loadingTitle={loadingTitle}
|
||||
onRenameFilename={onRenameFilename}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -11,6 +11,17 @@ describe('CreateViewDialog', () => {
|
||||
availableFields: ['type', 'status', 'title'],
|
||||
}
|
||||
|
||||
function makeEditingView(overrides: Partial<ViewDefinition> = {}): ViewDefinition {
|
||||
return {
|
||||
name: 'Active Projects',
|
||||
icon: 'rocket',
|
||||
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,70 +29,50 @@ describe('CreateViewDialog', () => {
|
||||
})
|
||||
|
||||
it('shows "Edit View" title when editingView is provided', () => {
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Active Projects',
|
||||
icon: '🚀',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
render(<CreateViewDialog {...defaultProps} editingView={editingView} />)
|
||||
render(<CreateViewDialog {...defaultProps} editingView={makeEditingView()} />)
|
||||
expect(screen.getByText('Edit View')).toBeInTheDocument()
|
||||
expect(screen.getByText('Save')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('pre-populates name field in edit mode', () => {
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Active Projects',
|
||||
icon: '🚀',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
render(<CreateViewDialog {...defaultProps} editingView={editingView} />)
|
||||
render(<CreateViewDialog {...defaultProps} editingView={makeEditingView()} />)
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
expect(input).toHaveValue('Active Projects')
|
||||
})
|
||||
|
||||
it('preserves emoji icon when editing a view', async () => {
|
||||
it('preserves existing icon and markdown-defined color when editing a view', async () => {
|
||||
const onCreate = vi.fn()
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Monday',
|
||||
icon: '🗂️',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
const editingView = makeEditingView({ name: 'Monday', icon: 'folder', color: 'blue' })
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} editingView={editingView} />)
|
||||
|
||||
// Submit the form without changing anything
|
||||
fireEvent.submit(screen.getByText('Save').closest('form')!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ icon: '🗂️' })
|
||||
expect.objectContaining({ icon: 'folder', color: 'blue' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('passes selected emoji icon when creating a view', async () => {
|
||||
it('passes selected icon and color when creating a view', async () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} />)
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
fireEvent.change(input, { target: { value: 'Test View' } })
|
||||
// Open emoji picker and select an emoji
|
||||
fireEvent.click(screen.getByTitle('Pick icon'))
|
||||
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
|
||||
const emojiButtons = screen.getAllByTestId('emoji-option')
|
||||
fireEvent.click(emojiButtons[0])
|
||||
// Submit the form
|
||||
fireEvent.change(screen.getByPlaceholderText('Search icons…'), { target: { value: 'book' } })
|
||||
fireEvent.click(screen.getByTitle('book'))
|
||||
fireEvent.click(screen.getByTitle('Blue'))
|
||||
|
||||
fireEvent.click(screen.getByText('Create'))
|
||||
|
||||
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)
|
||||
expect(definition.icon).toBe('book')
|
||||
expect(definition.color).toBe('blue')
|
||||
})
|
||||
|
||||
it('passes null icon when no emoji is selected', async () => {
|
||||
it('passes null icon and color when no appearance is selected', async () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} />)
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
@@ -89,7 +80,7 @@ describe('CreateViewDialog', () => {
|
||||
fireEvent.submit(screen.getByText('Create').closest('form')!)
|
||||
await waitFor(() => {
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ icon: null })
|
||||
expect.objectContaining({ icon: null, color: null })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import type { FilterGroup, ViewDefinition } from '../types'
|
||||
import { TypeCustomizePopover } from './TypeCustomizePopover'
|
||||
import { translate, type AppLocale, type TranslationKey } from '../lib/i18n'
|
||||
|
||||
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: SaveViewHandler
|
||||
availableFields: string[]
|
||||
locale?: AppLocale
|
||||
/** When provided, the dialog operates in edit mode with pre-populated fields. */
|
||||
editingView?: ViewDefinition | null
|
||||
}
|
||||
@@ -22,8 +25,10 @@ interface CreateViewDialogFormProps {
|
||||
availableFields: string[]
|
||||
initialName: string
|
||||
initialIcon: string
|
||||
initialColor: string | null
|
||||
initialFilters: FilterGroup
|
||||
isEditing: boolean
|
||||
locale: AppLocale
|
||||
onClose: () => void
|
||||
onCreate: SaveViewHandler
|
||||
}
|
||||
@@ -32,14 +37,16 @@ function CreateViewDialogForm({
|
||||
availableFields,
|
||||
initialName,
|
||||
initialIcon,
|
||||
initialColor,
|
||||
initialFilters,
|
||||
isEditing,
|
||||
locale,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: CreateViewDialogFormProps) {
|
||||
const [name, setName] = useState(initialName)
|
||||
const [icon, setIcon] = useState(initialIcon)
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const [color, setColor] = useState<string | null>(initialColor)
|
||||
const [filters, setFilters] = useState<FilterGroup>(initialFilters)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
@@ -58,7 +65,7 @@ function CreateViewDialogForm({
|
||||
const definition: ViewDefinition = {
|
||||
name: trimmed,
|
||||
icon: icon || null,
|
||||
color: null,
|
||||
color,
|
||||
sort: null,
|
||||
filters,
|
||||
}
|
||||
@@ -69,65 +76,56 @@ function CreateViewDialogForm({
|
||||
try {
|
||||
const result = await onCreate(definition)
|
||||
if (result === false) {
|
||||
setSaveError('Could not save view.')
|
||||
setSaveError(translate(locale, 'viewDialog.saveError'))
|
||||
} else {
|
||||
shouldClose = true
|
||||
}
|
||||
} catch {
|
||||
setSaveError('Could not save view.')
|
||||
setSaveError(translate(locale, 'viewDialog.saveError'))
|
||||
}
|
||||
|
||||
setIsSaving(false)
|
||||
if (shouldClose) onClose()
|
||||
}
|
||||
|
||||
const handleSelectEmoji = useCallback((emoji: string) => {
|
||||
setIcon(emoji)
|
||||
setShowEmojiPicker(false)
|
||||
}, [])
|
||||
|
||||
const handleCloseEmojiPicker = useCallback(() => {
|
||||
setShowEmojiPicker(false)
|
||||
}, [])
|
||||
|
||||
const isCreateDisabled = !name.trim()
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<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
|
||||
type="button"
|
||||
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>
|
||||
{showEmojiPicker && (
|
||||
<EmojiPicker onSelect={handleSelectEmoji} onClose={handleCloseEmojiPicker} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Name</label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="e.g. Active Projects, Reading List..."
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (saveError) setSaveError(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{translate(locale, 'viewDialog.nameLabel')}</label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={translate(locale, 'viewDialog.namePlaceholder')}
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (saveError) setSaveError(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<TypeCustomizePopover
|
||||
currentIcon={icon || null}
|
||||
currentColor={color}
|
||||
currentTemplate={null}
|
||||
onChangeIcon={setIcon}
|
||||
onChangeColor={setColor}
|
||||
onChangeTemplate={() => {}}
|
||||
onClose={() => {}}
|
||||
showTemplate={false}
|
||||
showDone={false}
|
||||
surface="inline"
|
||||
locale={locale}
|
||||
/>
|
||||
<div className="sr-only" aria-live="polite">
|
||||
{icon ? translate(locale, 'viewDialog.selectedIcon', { icon }) : ''}
|
||||
{color ? translate(locale, 'viewDialog.selectedColor', { color }) : ''}
|
||||
</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>
|
||||
<label className="text-xs font-medium text-muted-foreground">{translate(locale, 'viewDialog.filtersLabel')}</label>
|
||||
<FilterBuilder
|
||||
group={filters}
|
||||
onChange={setFilters}
|
||||
@@ -135,35 +133,59 @@ function CreateViewDialogForm({
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={isSaving}>Cancel</Button>
|
||||
<Button type="submit" disabled={isCreateDisabled || isSaving}>{isEditing ? 'Save' : 'Create'}</Button>
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={isSaving}>
|
||||
{translate(locale, 'common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreateDisabled || isSaving}>
|
||||
{translate(locale, isEditing ? 'common.save' : 'common.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function CreateViewDialog({ open, onClose, onCreate, availableFields, editingView }: CreateViewDialogProps) {
|
||||
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): TranslationKey {
|
||||
return isEditing
|
||||
? 'viewDialog.description.edit'
|
||||
: 'viewDialog.description.create'
|
||||
}
|
||||
|
||||
export function CreateViewDialog({ open, onClose, onCreate, availableFields, locale = 'en', 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 (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
|
||||
<DialogContent showCloseButton={false} className="flex max-h-[80vh] flex-col sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? 'Edit View' : 'Create View'}</DialogTitle>
|
||||
<DialogTitle>{translate(locale, isEditing ? 'viewDialog.title.edit' : 'viewDialog.title.create')}</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.'}
|
||||
{translate(locale, 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}
|
||||
locale={locale}
|
||||
onClose={onClose}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
|
||||
@@ -204,9 +204,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-content-layout--left .editor-content-wrapper {
|
||||
margin-left: clamp(16px, 6%, 96px);
|
||||
margin-right: auto;
|
||||
.editor-content-width--wide .editor-content-wrapper {
|
||||
max-width: none;
|
||||
padding-left: clamp(24px, 4vw, 72px);
|
||||
padding-right: clamp(24px, 4vw, 72px);
|
||||
}
|
||||
|
||||
.raw-editor-codemirror {
|
||||
@@ -332,10 +333,3 @@
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@container editor (max-width: 900px) {
|
||||
.editor-content-layout--left .editor-content-wrapper {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ const mockEditor = vi.hoisted(() => ({
|
||||
const blockNoteCreation = vi.hoisted(() => ({
|
||||
options: [] as unknown[],
|
||||
}))
|
||||
const blockNoteViewState = vi.hoisted(() => ({
|
||||
onChange: null as (() => void) | null,
|
||||
}))
|
||||
|
||||
// Mock BlockNote components
|
||||
vi.mock('@blocknote/core', () => ({
|
||||
@@ -81,16 +84,23 @@ vi.mock('@blocknote/react', () => ({
|
||||
ComponentsContext: {
|
||||
Provider: ({ children }: PropsWithChildren) => <>{children}</>,
|
||||
},
|
||||
BlockNoteViewRaw: ({ children, editable }: PropsWithChildren<{ editable?: boolean }>) => (
|
||||
<div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>
|
||||
<div
|
||||
contentEditable={editable !== false}
|
||||
data-testid="blocknote-editable"
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
BlockNoteViewRaw: ({
|
||||
children,
|
||||
editable,
|
||||
onChange,
|
||||
}: PropsWithChildren<{ editable?: boolean; onChange?: () => void }>) => {
|
||||
blockNoteViewState.onChange = onChange ?? null
|
||||
return (
|
||||
<div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>
|
||||
<div
|
||||
contentEditable={editable !== false}
|
||||
data-testid="blocknote-editable"
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
FormattingToolbarController: () => null,
|
||||
LinkToolbarController: () => null,
|
||||
EditLinkButton: () => null,
|
||||
@@ -224,6 +234,7 @@ function renderEditor(overrides: Partial<EditorComponentProps> = {}) {
|
||||
describe('Editor', () => {
|
||||
beforeEach(() => {
|
||||
blockNoteCreation.options = []
|
||||
blockNoteViewState.onChange = null
|
||||
})
|
||||
|
||||
it('shows empty state when no tabs are open', () => {
|
||||
@@ -284,6 +295,28 @@ describe('Editor', () => {
|
||||
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,
|
||||
@@ -365,6 +398,46 @@ describe('Editor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('registers a rich-editor flush hook for pending BlockNote changes', async () => {
|
||||
const onContentChange = vi.fn()
|
||||
const flushPendingEditorContentRef = { current: null as ((path: string) => void) | null }
|
||||
const originalMarkdownSerializer = mockEditor.blocksToMarkdownLossy.getMockImplementation()
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
|
||||
try {
|
||||
renderEditor({
|
||||
tabs: [mockTab],
|
||||
activeTabPath: mockEntry.path,
|
||||
onContentChange,
|
||||
flushPendingEditorContentRef,
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(blockNoteViewState.onChange).toEqual(expect.any(Function))
|
||||
expect(flushPendingEditorContentRef.current).toEqual(expect.any(Function))
|
||||
})
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) })
|
||||
|
||||
mockEditor.blocksToMarkdownLossy.mockReturnValueOnce('# Test Project\n\nEdited rich body.\n')
|
||||
|
||||
act(() => {
|
||||
blockNoteViewState.onChange?.()
|
||||
})
|
||||
expect(onContentChange).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
flushPendingEditorContentRef.current?.(mockEntry.path)
|
||||
})
|
||||
|
||||
expect(onContentChange).toHaveBeenCalledWith(
|
||||
mockEntry.path,
|
||||
expect.stringContaining('Edited rich body.'),
|
||||
)
|
||||
} finally {
|
||||
mockEditor.blocksToMarkdownLossy.mockImplementation(originalMarkdownSerializer)
|
||||
}
|
||||
})
|
||||
|
||||
it('disables native text assistance on the rich editor editable surface', () => {
|
||||
renderEditor({
|
||||
tabs: [mockTab],
|
||||
@@ -388,6 +461,26 @@ describe('Editor', () => {
|
||||
expect(screen.getByRole('button', { name: 'Delete this note' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps editor chrome visible while active note content is loading', () => {
|
||||
renderEditor({
|
||||
tabs: [],
|
||||
activeTabPath: mockEntry.path,
|
||||
entries: [mockEntry],
|
||||
inspectorCollapsed: false,
|
||||
inspectorEntry: mockEntry,
|
||||
inspectorContent: mockContent,
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('breadcrumb-filename-trigger')).toHaveTextContent('test')
|
||||
expect(screen.getAllByText('Properties').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('Select a note to start editing')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('blocknote-view')).not.toBeInTheDocument()
|
||||
|
||||
const skeleton = screen.getByTestId('editor-content-skeleton')
|
||||
expect(skeleton.closest('.editor-content-wrapper')).not.toBeNull()
|
||||
expect(skeleton.closest('.editor-scroll-area')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('hides the legacy title field for untitled draft notes', () => {
|
||||
const draftEntry: VaultEntry = {
|
||||
...mockEntry,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { uploadImageFile } from '../hooks/useImageDrop'
|
||||
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 { VaultEntry, GitCommit, NoteWidthMode, NoteStatus } from '../types'
|
||||
import type { NoteListItem } from '../utils/ai-context'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import { ResizeHandle } from './ResizeHandle'
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
resolvePendingRawExitContent,
|
||||
resolveRawModeContent,
|
||||
} from './editorRawModeSync'
|
||||
import { useRegisterEditorContentFlushes } from './editorContentFlushRegistration'
|
||||
import { useRawModeWithFlush } from './useRawModeWithFlush'
|
||||
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
|
||||
import { createMathInputExtension } from './mathInputExtension'
|
||||
@@ -40,6 +41,7 @@ interface Tab {
|
||||
interface EditorProps {
|
||||
tabs: Tab[]
|
||||
activeTabPath: string | null
|
||||
isVaultLoading?: boolean
|
||||
entries: VaultEntry[]
|
||||
onNavigateWikilink: (target: string) => void
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
@@ -82,8 +84,8 @@ interface EditorProps {
|
||||
onSave?: () => void
|
||||
/** Called when the user explicitly renames the filename from the breadcrumb. */
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
noteWidth?: NoteWidthMode
|
||||
onToggleNoteWidth?: () => void
|
||||
canGoBack?: boolean
|
||||
canGoForward?: boolean
|
||||
onGoBack?: () => void
|
||||
@@ -104,6 +106,8 @@ interface EditorProps {
|
||||
onKeepMine?: (path: string) => void
|
||||
/** Resolve conflict by keeping the remote version. */
|
||||
onKeepTheirs?: (path: string) => void
|
||||
/** Registers a hook that flushes pending rich-editor changes into app state before external actions. */
|
||||
flushPendingEditorContentRef?: React.MutableRefObject<((path: string) => void) | null>
|
||||
/** Registers a hook that flushes the raw editor buffer into app state before external actions. */
|
||||
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
|
||||
locale?: AppLocale
|
||||
@@ -219,7 +223,7 @@ function useEditorSetup({
|
||||
}))
|
||||
}, [activeTabPath, setPendingRawExitContent, tabs])
|
||||
|
||||
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({
|
||||
const { handleEditorChange, flushPendingEditorChange, editorMountedRef } = useEditorTabSwap({
|
||||
tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode, vaultPath,
|
||||
})
|
||||
useEditorFocus(editor, editorMountedRef)
|
||||
@@ -244,45 +248,11 @@ function useEditorSetup({
|
||||
editor, activeTab, rawLatestContentRef, rawModeContent,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, handleViewCommitDiff,
|
||||
handleEditorChange, flushPendingEditorChange, handleViewCommitDiff,
|
||||
isLoadingNewTab, activeStatus, showDiffToggle,
|
||||
}
|
||||
}
|
||||
|
||||
function useRegisterRawContentFlush({
|
||||
activeTab,
|
||||
rawLatestContentRef,
|
||||
rawMode,
|
||||
onContentChange,
|
||||
flushPendingRawContentRef,
|
||||
}: {
|
||||
activeTab: Tab | null
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
rawMode: boolean
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
|
||||
}) {
|
||||
const flushPendingRawContent = useCallback((path: string) => {
|
||||
if (!rawMode || !activeTab || activeTab.entry.path !== path) return
|
||||
|
||||
const latestContent = rawLatestContentRef.current
|
||||
if (latestContent === null || latestContent === activeTab.content) return
|
||||
|
||||
onContentChange?.(path, latestContent)
|
||||
}, [activeTab, onContentChange, rawLatestContentRef, rawMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!flushPendingRawContentRef) return
|
||||
|
||||
flushPendingRawContentRef.current = flushPendingRawContent
|
||||
return () => {
|
||||
if (flushPendingRawContentRef.current === flushPendingRawContent) {
|
||||
flushPendingRawContentRef.current = null
|
||||
}
|
||||
}
|
||||
}, [flushPendingRawContent, flushPendingRawContentRef])
|
||||
}
|
||||
|
||||
function useEditorFindCommand({
|
||||
activeTab,
|
||||
findInNoteRef,
|
||||
@@ -322,8 +292,10 @@ function useEditorFindCommand({
|
||||
|
||||
function EditorLayout({
|
||||
tabs,
|
||||
activeTabPath,
|
||||
activeTab,
|
||||
isLoadingNewTab,
|
||||
isVaultLoading,
|
||||
entries,
|
||||
editor,
|
||||
diffMode,
|
||||
@@ -355,8 +327,8 @@ function EditorLayout({
|
||||
findRequest,
|
||||
rawLatestContentRef,
|
||||
onRenameFilename,
|
||||
noteLayout,
|
||||
onToggleNoteLayout,
|
||||
noteWidth,
|
||||
onToggleNoteWidth,
|
||||
isConflicted,
|
||||
onKeepMine,
|
||||
onKeepTheirs,
|
||||
@@ -384,8 +356,10 @@ function EditorLayout({
|
||||
locale,
|
||||
}: {
|
||||
tabs: Tab[]
|
||||
activeTabPath: string | null
|
||||
activeTab: Tab | null
|
||||
isLoadingNewTab: boolean
|
||||
isVaultLoading?: boolean
|
||||
entries: VaultEntry[]
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
diffMode: boolean
|
||||
@@ -417,8 +391,8 @@ function EditorLayout({
|
||||
findRequest?: RawEditorFindRequest | null
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
noteWidth?: NoteWidthMode
|
||||
onToggleNoteWidth?: () => void
|
||||
isConflicted?: boolean
|
||||
onKeepMine?: (path: string) => void
|
||||
onKeepTheirs?: (path: string) => void
|
||||
@@ -446,11 +420,12 @@ function EditorLayout({
|
||||
locale?: AppLocale
|
||||
}) {
|
||||
const activeBinaryTab = activeTab?.entry.fileKind === 'binary' ? activeTab : null
|
||||
const showEmptyState = tabs.length === 0 && activeTabPath === null && !isVaultLoading
|
||||
|
||||
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
|
||||
{showEmptyState
|
||||
? <EditorEmptyState locale={locale} />
|
||||
: activeBinaryTab
|
||||
? (
|
||||
@@ -463,7 +438,9 @@ function EditorLayout({
|
||||
)
|
||||
: <EditorContent
|
||||
activeTab={activeTab}
|
||||
activeTabPath={activeTabPath}
|
||||
isLoadingNewTab={isLoadingNewTab}
|
||||
isVaultLoading={isVaultLoading}
|
||||
entries={entries}
|
||||
editor={editor}
|
||||
diffMode={diffMode}
|
||||
@@ -494,8 +471,8 @@ function EditorLayout({
|
||||
findRequest={findRequest}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onRenameFilename={onRenameFilename}
|
||||
noteLayout={noteLayout}
|
||||
onToggleNoteLayout={onToggleNoteLayout}
|
||||
noteWidth={noteWidth}
|
||||
onToggleNoteWidth={onToggleNoteWidth}
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
@@ -543,6 +520,7 @@ function EditorLayout({
|
||||
export const Editor = memo(function Editor(props: EditorProps) {
|
||||
const {
|
||||
tabs, activeTabPath, entries, onNavigateWikilink,
|
||||
isVaultLoading,
|
||||
getNoteStatus,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReadiness, defaultAiAgentReady = true,
|
||||
@@ -555,18 +533,17 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile,
|
||||
onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onRenameFilename,
|
||||
noteLayout, onToggleNoteLayout,
|
||||
noteWidth, onToggleNoteWidth,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
flushPendingRawContentRef, findInNoteRef,
|
||||
locale,
|
||||
flushPendingEditorContentRef, flushPendingRawContentRef, findInNoteRef, locale,
|
||||
} = props
|
||||
|
||||
const {
|
||||
editor, activeTab, rawLatestContentRef, rawModeContent,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, handleViewCommitDiff,
|
||||
handleEditorChange, flushPendingEditorChange, handleViewCommitDiff,
|
||||
isLoadingNewTab, activeStatus, showDiffToggle,
|
||||
} = useEditorSetup({
|
||||
tabs, activeTabPath, vaultPath, onContentChange,
|
||||
@@ -584,8 +561,10 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
rawMode,
|
||||
})
|
||||
|
||||
useRegisterRawContentFlush({
|
||||
useRegisterEditorContentFlushes({
|
||||
activeTab,
|
||||
flushPendingEditorChange,
|
||||
flushPendingEditorContentRef,
|
||||
rawLatestContentRef,
|
||||
rawMode,
|
||||
onContentChange,
|
||||
@@ -595,8 +574,10 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
return (
|
||||
<EditorLayout
|
||||
tabs={tabs}
|
||||
activeTabPath={props.activeTabPath}
|
||||
activeTab={activeTab}
|
||||
isLoadingNewTab={isLoadingNewTab}
|
||||
isVaultLoading={isVaultLoading}
|
||||
entries={entries}
|
||||
editor={editor}
|
||||
diffMode={diffMode}
|
||||
@@ -628,8 +609,8 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
findRequest={findRequest}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onRenameFilename={onRenameFilename}
|
||||
noteLayout={noteLayout}
|
||||
onToggleNoteLayout={onToggleNoteLayout}
|
||||
noteWidth={noteWidth}
|
||||
onToggleNoteWidth={onToggleNoteWidth}
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
|
||||
@@ -62,6 +62,7 @@ export function EditorRightPanel({
|
||||
entries,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
locale,
|
||||
onOpenNote,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
@@ -92,6 +93,7 @@ export function EditorRightPanel({
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiAgentReadiness={defaultAiAgentReadiness}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
locale={locale}
|
||||
activeEntry={inspectorEntry}
|
||||
entries={entries}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
caret-color: var(--colors-cursor);
|
||||
}
|
||||
|
||||
.editor-content-width--wide .editor__blocknote-container .bn-editor {
|
||||
max-width: none;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* Override BlockNote's default line-height on block-outer so our theme controls it */
|
||||
.editor__blocknote-container .bn-block-outer {
|
||||
line-height: var(--editor-line-height) !important;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FilePreview } from './FilePreview'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const { trackEventMock } = vi.hoisted(() => ({
|
||||
trackEventMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
convertFileSrc: (path: string) => `asset://${path}`,
|
||||
}))
|
||||
|
||||
vi.mock('../lib/telemetry', () => ({
|
||||
trackEvent: trackEventMock,
|
||||
}))
|
||||
|
||||
const imageEntry: VaultEntry = {
|
||||
path: '/vault/Attachments/photo.png',
|
||||
filename: 'photo.png',
|
||||
@@ -40,8 +48,18 @@ const imageEntry: VaultEntry = {
|
||||
hasH1: false,
|
||||
fileKind: 'binary',
|
||||
}
|
||||
const pdfEntry: VaultEntry = {
|
||||
...imageEntry,
|
||||
path: '/vault/Attachments/report.pdf',
|
||||
filename: 'report.pdf',
|
||||
title: 'report.pdf',
|
||||
}
|
||||
|
||||
describe('FilePreview', () => {
|
||||
beforeEach(() => {
|
||||
trackEventMock.mockClear()
|
||||
})
|
||||
|
||||
it('routes header file actions to the active file path', () => {
|
||||
const onRevealFile = vi.fn()
|
||||
const onCopyFilePath = vi.fn()
|
||||
@@ -56,6 +74,8 @@ describe('FilePreview', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(trackEventMock).toHaveBeenCalledWith('file_preview_opened', { preview_kind: 'image' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reveal' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy path' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open' }))
|
||||
@@ -63,5 +83,49 @@ describe('FilePreview', () => {
|
||||
expect(onRevealFile).toHaveBeenCalledWith('/vault/Attachments/photo.png')
|
||||
expect(onCopyFilePath).toHaveBeenCalledWith('/vault/Attachments/photo.png')
|
||||
expect(onOpenExternalFile).toHaveBeenCalledWith('/vault/Attachments/photo.png')
|
||||
expect(trackEventMock).toHaveBeenCalledWith('file_preview_action', {
|
||||
action: 'reveal',
|
||||
preview_kind: 'image',
|
||||
})
|
||||
expect(trackEventMock).toHaveBeenCalledWith('file_preview_action', {
|
||||
action: 'copy_path',
|
||||
preview_kind: 'image',
|
||||
})
|
||||
expect(trackEventMock).toHaveBeenCalledWith('file_preview_action', {
|
||||
action: 'open_external',
|
||||
preview_kind: 'image',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders supported PDF files through the asset preview path', () => {
|
||||
render(<FilePreview entry={pdfEntry} />)
|
||||
|
||||
expect(screen.getByTestId('pdf-file-preview')).toHaveAttribute('data', 'asset:///vault/Attachments/report.pdf')
|
||||
expect(screen.getByText('PDF file')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders supported PDFs when binary metadata is unavailable', () => {
|
||||
render(<FilePreview entry={{ ...pdfEntry, fileKind: undefined }} />)
|
||||
|
||||
expect(screen.getByTestId('pdf-file-preview')).toHaveAttribute('data', 'asset:///vault/Attachments/report.pdf')
|
||||
})
|
||||
|
||||
it('provides a graceful fallback when a PDF preview cannot render', () => {
|
||||
render(<FilePreview entry={pdfEntry} />)
|
||||
|
||||
expect(screen.getByTestId('file-preview-fallback')).toHaveTextContent('PDF preview failed')
|
||||
expect(screen.getByRole('button', { name: 'Open in default app' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tracks image preview failures without leaking the file path', () => {
|
||||
render(<FilePreview entry={imageEntry} />)
|
||||
|
||||
fireEvent.error(screen.getByTestId('image-file-preview'))
|
||||
|
||||
expect(trackEventMock).toHaveBeenCalledWith('file_preview_failed', { preview_kind: 'image' })
|
||||
expect(trackEventMock).not.toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ path: expect.any(String) }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useMemo, useState, type KeyboardEvent } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { ArrowSquareOut, ClipboardText, FileDashed, FolderOpen, ImageSquare, WarningCircle } from '@phosphor-icons/react'
|
||||
import { ArrowSquareOut, ClipboardText, FileDashed, FilePdf, FolderOpen, ImageSquare, WarningCircle } from '@phosphor-icons/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { isImagePreviewEntry, previewFileTypeLabel } from '../utils/filePreview'
|
||||
import { trackFilePreviewAction, trackFilePreviewFailed, trackFilePreviewOpened } from '../lib/productAnalytics'
|
||||
import { filePreviewKind, previewFileTypeLabel, type FilePreviewKind } from '../utils/filePreview'
|
||||
import { focusNoteListContainer } from '../utils/neighborhoodHistory'
|
||||
import { openLocalFile } from '../utils/url'
|
||||
import { Button } from './ui/button'
|
||||
@@ -21,6 +22,42 @@ interface FilePreviewFallbackProps {
|
||||
onOpenExternal: () => void
|
||||
}
|
||||
|
||||
function fallbackContentForPreviewKind(previewKind: FilePreviewKind | null): Omit<FilePreviewFallbackProps, 'onOpenExternal'> {
|
||||
if (previewKind === 'image') {
|
||||
return {
|
||||
icon: 'warning',
|
||||
title: 'Image preview failed',
|
||||
description: 'Tolaria could not render this image file in the preview.',
|
||||
}
|
||||
}
|
||||
|
||||
if (previewKind === 'pdf') {
|
||||
return {
|
||||
icon: 'warning',
|
||||
title: 'PDF preview failed',
|
||||
description: 'Tolaria could not render this PDF file in the preview.',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon: 'file',
|
||||
title: 'Preview unavailable',
|
||||
description: 'Tolaria does not have an in-app preview for this file type.',
|
||||
}
|
||||
}
|
||||
|
||||
function FilePreviewHeaderIcon({ previewKind }: { previewKind: FilePreviewKind | null }) {
|
||||
if (previewKind === 'image') {
|
||||
return <ImageSquare size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
}
|
||||
|
||||
if (previewKind === 'pdf') {
|
||||
return <FilePdf size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
}
|
||||
|
||||
return <FileDashed size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
}
|
||||
|
||||
function FilePreviewFallback({ icon, title, description, onOpenExternal }: FilePreviewFallbackProps) {
|
||||
const Icon = icon === 'warning' ? WarningCircle : FileDashed
|
||||
|
||||
@@ -44,28 +81,26 @@ function FilePreviewFallback({ icon, title, description, onOpenExternal }: FileP
|
||||
|
||||
function FilePreviewHeader({
|
||||
entry,
|
||||
isImage,
|
||||
previewKind,
|
||||
fileTypeLabel,
|
||||
onOpenExternal,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
isImage: boolean
|
||||
previewKind: FilePreviewKind | null
|
||||
fileTypeLabel: string
|
||||
onOpenExternal: () => void
|
||||
onRevealFile?: () => void
|
||||
onCopyFilePath?: () => void
|
||||
}) {
|
||||
const HeaderIcon = isImage ? ImageSquare : FileDashed
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<HeaderIcon size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<FilePreviewHeaderIcon previewKind={previewKind} />
|
||||
<div className="min-w-0">
|
||||
<h1 className="m-0 truncate text-[14px] font-semibold text-foreground">{entry.title}</h1>
|
||||
<p className="m-0 text-[11px] text-muted-foreground">{fileTypeLabel}</p>
|
||||
@@ -93,6 +128,35 @@ function FilePreviewHeader({
|
||||
)
|
||||
}
|
||||
|
||||
function FilePreviewPdf({
|
||||
entry,
|
||||
pdfSrc,
|
||||
onOpenExternal,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
pdfSrc: string
|
||||
onOpenExternal: () => void
|
||||
}) {
|
||||
const fallback = fallbackContentForPreviewKind('pdf')
|
||||
|
||||
return (
|
||||
<object
|
||||
data={pdfSrc}
|
||||
type="application/pdf"
|
||||
title={entry.title}
|
||||
className="h-full min-h-[320px] w-full bg-background"
|
||||
data-testid="pdf-file-preview"
|
||||
>
|
||||
<FilePreviewFallback
|
||||
icon={fallback.icon}
|
||||
title={fallback.title}
|
||||
description={fallback.description}
|
||||
onOpenExternal={onOpenExternal}
|
||||
/>
|
||||
</object>
|
||||
)
|
||||
}
|
||||
|
||||
function FilePreviewImage({
|
||||
entry,
|
||||
imageSrc,
|
||||
@@ -121,32 +185,34 @@ function shouldRenderImagePreview(isImage: boolean, imageSrc: string | null, ima
|
||||
|
||||
function FilePreviewBody({
|
||||
entry,
|
||||
isImage,
|
||||
imageSrc,
|
||||
previewKind,
|
||||
assetSrc,
|
||||
imageFailed,
|
||||
onImageError,
|
||||
onOpenExternal,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
isImage: boolean
|
||||
imageSrc: string | null
|
||||
previewKind: FilePreviewKind | null
|
||||
assetSrc: string | null
|
||||
imageFailed: boolean
|
||||
onImageError: () => void
|
||||
onOpenExternal: () => void
|
||||
}) {
|
||||
if (shouldRenderImagePreview(isImage, imageSrc, imageFailed)) {
|
||||
return <FilePreviewImage entry={entry} imageSrc={imageSrc} onImageError={onImageError} />
|
||||
if (shouldRenderImagePreview(previewKind === 'image', assetSrc, imageFailed)) {
|
||||
return <FilePreviewImage entry={entry} imageSrc={assetSrc} onImageError={onImageError} />
|
||||
}
|
||||
|
||||
if (previewKind === 'pdf' && assetSrc !== null) {
|
||||
return <FilePreviewPdf entry={entry} pdfSrc={assetSrc} onOpenExternal={onOpenExternal} />
|
||||
}
|
||||
|
||||
const fallback = fallbackContentForPreviewKind(previewKind)
|
||||
|
||||
return (
|
||||
<FilePreviewFallback
|
||||
icon={isImage ? 'warning' : 'file'}
|
||||
title={isImage ? 'Image preview failed' : 'Preview unavailable'}
|
||||
description={
|
||||
isImage
|
||||
? 'Tolaria could not render this image file in the preview.'
|
||||
: 'Tolaria does not have an in-app preview for this file type.'
|
||||
}
|
||||
icon={fallback.icon}
|
||||
title={fallback.title}
|
||||
description={fallback.description}
|
||||
onOpenExternal={onOpenExternal}
|
||||
/>
|
||||
)
|
||||
@@ -158,13 +224,22 @@ export function FilePreview({
|
||||
onOpenExternalFile,
|
||||
onRevealFile,
|
||||
}: FilePreviewProps) {
|
||||
const [imageFailed, setImageFailed] = useState(false)
|
||||
const isImage = isImagePreviewEntry(entry)
|
||||
const imageSrc = useMemo(() => (isImage ? convertFileSrc(entry.path) : null), [entry.path, isImage])
|
||||
const [failedImagePath, setFailedImagePath] = useState<string | null>(null)
|
||||
const previewKind = filePreviewKind(entry)
|
||||
const assetSrc = useMemo(() => (previewKind ? convertFileSrc(entry.path) : null), [entry.path, previewKind])
|
||||
const fileTypeLabel = previewFileTypeLabel(entry)
|
||||
const handleImageError = useCallback(() => setImageFailed(true), [])
|
||||
const imageFailed = failedImagePath === entry.path
|
||||
const handleImageError = useCallback(() => {
|
||||
setFailedImagePath(entry.path)
|
||||
trackFilePreviewFailed('image')
|
||||
}, [entry.path])
|
||||
|
||||
useEffect(() => {
|
||||
trackFilePreviewOpened(previewKind)
|
||||
}, [entry.path, previewKind])
|
||||
|
||||
const handleOpenExternal = useCallback(() => {
|
||||
trackFilePreviewAction('open_external', previewKind)
|
||||
if (onOpenExternalFile) {
|
||||
onOpenExternalFile(entry.path)
|
||||
return
|
||||
@@ -173,15 +248,17 @@ export function FilePreview({
|
||||
void openLocalFile(entry.path).catch((error) => {
|
||||
console.warn('Failed to open file with default app:', error)
|
||||
})
|
||||
}, [entry.path, onOpenExternalFile])
|
||||
}, [entry.path, onOpenExternalFile, previewKind])
|
||||
|
||||
const handleRevealFile = useCallback(() => {
|
||||
trackFilePreviewAction('reveal', previewKind)
|
||||
onRevealFile?.(entry.path)
|
||||
}, [entry.path, onRevealFile])
|
||||
}, [entry.path, onRevealFile, previewKind])
|
||||
|
||||
const handleCopyFilePath = useCallback(() => {
|
||||
trackFilePreviewAction('copy_path', previewKind)
|
||||
onCopyFilePath?.(entry.path)
|
||||
}, [entry.path, onCopyFilePath])
|
||||
}, [entry.path, onCopyFilePath, previewKind])
|
||||
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Escape') return
|
||||
@@ -200,7 +277,7 @@ export function FilePreview({
|
||||
>
|
||||
<FilePreviewHeader
|
||||
entry={entry}
|
||||
isImage={isImage}
|
||||
previewKind={previewKind}
|
||||
fileTypeLabel={fileTypeLabel}
|
||||
onOpenExternal={handleOpenExternal}
|
||||
onRevealFile={onRevealFile ? handleRevealFile : undefined}
|
||||
@@ -209,8 +286,8 @@ export function FilePreview({
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-background">
|
||||
<FilePreviewBody
|
||||
entry={entry}
|
||||
isImage={isImage}
|
||||
imageSrc={imageSrc}
|
||||
previewKind={previewKind}
|
||||
assetSrc={assetSrc}
|
||||
imageFailed={imageFailed}
|
||||
onImageError={handleImageError}
|
||||
onOpenExternal={handleOpenExternal}
|
||||
|
||||
@@ -19,6 +19,7 @@ const mockFolders: FolderNode[] = [
|
||||
]
|
||||
|
||||
const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
const vaultRootPath = '/Users/luca/Laputa'
|
||||
|
||||
describe('FolderTree', () => {
|
||||
it('renders nothing when folders is empty', () => {
|
||||
@@ -36,6 +37,53 @@ describe('FolderTree', () => {
|
||||
expect(screen.getByText('journal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the vault root as the top-level folder when a vault path is available', () => {
|
||||
render(
|
||||
<FolderTree
|
||||
folders={mockFolders}
|
||||
selection={defaultSelection}
|
||||
onSelect={vi.fn()}
|
||||
vaultRootPath={vaultRootPath}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Laputa')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Laputa' })).toBeInTheDocument()
|
||||
expect(screen.getByText('projects')).toBeInTheDocument()
|
||||
expect(screen.getByText('areas')).toBeInTheDocument()
|
||||
expect(screen.getByText('journal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the vault root even when the vault has no subfolders', () => {
|
||||
render(
|
||||
<FolderTree
|
||||
folders={[]}
|
||||
selection={defaultSelection}
|
||||
onSelect={vi.fn()}
|
||||
vaultRootPath={vaultRootPath}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Laputa')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets the vault root collapse and expand its nested folders', () => {
|
||||
render(
|
||||
<FolderTree
|
||||
folders={mockFolders}
|
||||
selection={defaultSelection}
|
||||
onSelect={vi.fn()}
|
||||
vaultRootPath={vaultRootPath}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Collapse Laputa'))
|
||||
expect(screen.queryByText('projects')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Expand Laputa'))
|
||||
expect(screen.getByText('projects')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands children when clicking the folder chevron', () => {
|
||||
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
|
||||
expect(screen.queryByText('laputa')).not.toBeInTheDocument()
|
||||
@@ -51,6 +99,21 @@ describe('FolderTree', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: 'projects' })
|
||||
})
|
||||
|
||||
it('selects the vault root with the root path attached', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(
|
||||
<FolderTree
|
||||
folders={mockFolders}
|
||||
selection={defaultSelection}
|
||||
onSelect={onSelect}
|
||||
vaultRootPath={vaultRootPath}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('folder-row:'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: '', rootPath: vaultRootPath })
|
||||
})
|
||||
|
||||
it('expands children when single-clicking a folder row with children', () => {
|
||||
vi.useFakeTimers()
|
||||
function FolderTreeHarness() {
|
||||
@@ -248,4 +311,33 @@ describe('FolderTree', () => {
|
||||
fireEvent.click(screen.getByTestId('copy-folder-path-menu-item'))
|
||||
expect(onCopyFolderPath).toHaveBeenCalledWith('projects')
|
||||
})
|
||||
|
||||
it('keeps destructive folder actions off the vault root row and menu', () => {
|
||||
render(
|
||||
<FolderTree
|
||||
folders={mockFolders}
|
||||
selection={defaultSelection}
|
||||
onSelect={vi.fn()}
|
||||
folderFileActions={{
|
||||
copyFolderPath: vi.fn(),
|
||||
revealFolder: vi.fn(),
|
||||
}}
|
||||
onDeleteFolder={vi.fn()}
|
||||
onRenameFolder={vi.fn().mockResolvedValue(true)}
|
||||
onStartRenameFolder={vi.fn()}
|
||||
onCancelRenameFolder={vi.fn()}
|
||||
vaultRootPath={vaultRootPath}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('rename-folder-btn:')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('delete-folder-btn:')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.contextMenu(screen.getByText('Laputa'))
|
||||
|
||||
expect(screen.getByTestId('reveal-folder-menu-item')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('copy-folder-path-menu-item')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Rename folder...')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('delete-folder-menu-item')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
memo,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import {
|
||||
Plus,
|
||||
@@ -30,6 +32,53 @@ interface FolderTreeProps {
|
||||
collapsed?: boolean
|
||||
locale?: AppLocale
|
||||
onToggle?: () => void
|
||||
vaultRootPath?: string
|
||||
}
|
||||
|
||||
interface FolderTreeBodyProps extends Pick<
|
||||
FolderTreeProps,
|
||||
| 'locale'
|
||||
| 'onCancelRenameFolder'
|
||||
| 'onDeleteFolder'
|
||||
| 'onRenameFolder'
|
||||
| 'onSelect'
|
||||
| 'onStartRenameFolder'
|
||||
| 'renamingFolderPath'
|
||||
| 'selection'
|
||||
> {
|
||||
displayedExpanded: Record<string, boolean>
|
||||
displayedFolders: FolderNode[]
|
||||
isCreating: boolean
|
||||
onCancelCreateFolder: () => void
|
||||
onCreateFolderSubmit: (value: string) => Promise<boolean>
|
||||
rootPath?: string
|
||||
sectionCollapsed: boolean
|
||||
toggleFolder: (path: string) => void
|
||||
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
function vaultRootLabel(vaultRootPath: string, locale: AppLocale): string {
|
||||
const trimmed = vaultRootPath.trim().replace(/[\\/]+$/g, '')
|
||||
return trimmed.split(/[\\/]/).filter(Boolean).pop() || translate(locale, 'status.vault.default')
|
||||
}
|
||||
|
||||
function buildRootNode(folders: FolderNode[], vaultRootPath: string | undefined, locale: AppLocale): FolderNode | null {
|
||||
if (!vaultRootPath?.trim()) return null
|
||||
return {
|
||||
name: vaultRootLabel(vaultRootPath, locale),
|
||||
path: '',
|
||||
children: folders,
|
||||
}
|
||||
}
|
||||
|
||||
function useDisplayedFolders(folders: FolderNode[], expanded: Record<string, boolean>, vaultRootPath: string | undefined, locale: AppLocale) {
|
||||
return useMemo(() => {
|
||||
const rootNode = buildRootNode(folders, vaultRootPath, locale)
|
||||
return {
|
||||
displayedExpanded: rootNode ? { '': true, ...expanded } : expanded,
|
||||
displayedFolders: rootNode ? [rootNode] : folders,
|
||||
}
|
||||
}, [expanded, folders, locale, vaultRootPath])
|
||||
}
|
||||
|
||||
export const FolderTree = memo(function FolderTree({
|
||||
@@ -46,6 +95,7 @@ export const FolderTree = memo(function FolderTree({
|
||||
collapsed: externalCollapsed,
|
||||
locale = 'en',
|
||||
onToggle,
|
||||
vaultRootPath,
|
||||
}: FolderTreeProps) {
|
||||
const {
|
||||
closeCreateForm,
|
||||
@@ -93,7 +143,9 @@ export const FolderTree = memo(function FolderTree({
|
||||
openCreateForm()
|
||||
}, [closeContextMenu, openCreateForm])
|
||||
|
||||
if (folders.length === 0 && !isCreating) return null
|
||||
const { displayedExpanded, displayedFolders } = useDisplayedFolders(folders, expanded, vaultRootPath, locale)
|
||||
|
||||
if (displayedFolders.length === 0 && !isCreating) return null
|
||||
|
||||
return (
|
||||
<div className="border-b border-border" style={{ padding: '0 6px' }}>
|
||||
@@ -102,41 +154,25 @@ export const FolderTree = memo(function FolderTree({
|
||||
<CreateFolderButton locale={locale} onCreate={handleCreateFolderClick} />
|
||||
)}
|
||||
</SidebarGroupHeader>
|
||||
{!sectionCollapsed && (
|
||||
<div className="flex flex-col gap-0.5 pb-2">
|
||||
{folders.map((node) => (
|
||||
<FolderTreeRow
|
||||
key={node.path}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
node={node}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onOpenMenu={handleOpenMenu}
|
||||
onRenameFolder={onRenameFolder}
|
||||
onSelect={onSelect}
|
||||
onStartRenameFolder={onStartRenameFolder}
|
||||
onToggle={toggleFolder}
|
||||
onCancelRenameFolder={onCancelRenameFolder}
|
||||
locale={locale}
|
||||
renamingFolderPath={renamingFolderPath}
|
||||
selection={selection}
|
||||
/>
|
||||
))}
|
||||
{isCreating && (
|
||||
<div style={{ paddingLeft: 8 }}>
|
||||
<FolderNameInput
|
||||
ariaLabel={translate(locale, 'sidebar.folder.newName')}
|
||||
initialValue=""
|
||||
placeholder={translate(locale, 'sidebar.folder.name')}
|
||||
submitOnBlur={true}
|
||||
testId="new-folder-input"
|
||||
onCancel={closeCreateForm}
|
||||
onSubmit={handleCreateFolderSubmit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<FolderTreeBody
|
||||
displayedExpanded={displayedExpanded}
|
||||
displayedFolders={displayedFolders}
|
||||
isCreating={isCreating}
|
||||
locale={locale}
|
||||
onCancelCreateFolder={closeCreateForm}
|
||||
onCancelRenameFolder={onCancelRenameFolder}
|
||||
onCreateFolderSubmit={handleCreateFolderSubmit}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onOpenMenu={handleOpenMenu}
|
||||
onRenameFolder={onRenameFolder}
|
||||
onSelect={onSelect}
|
||||
onStartRenameFolder={onStartRenameFolder}
|
||||
renamingFolderPath={renamingFolderPath}
|
||||
rootPath={vaultRootPath}
|
||||
sectionCollapsed={sectionCollapsed}
|
||||
selection={selection}
|
||||
toggleFolder={toggleFolder}
|
||||
/>
|
||||
<FolderContextMenu
|
||||
menu={contextMenu}
|
||||
menuRef={menuRef}
|
||||
@@ -150,6 +186,65 @@ export const FolderTree = memo(function FolderTree({
|
||||
)
|
||||
})
|
||||
|
||||
function FolderTreeBody({
|
||||
displayedExpanded,
|
||||
displayedFolders,
|
||||
isCreating,
|
||||
locale = 'en',
|
||||
onCancelCreateFolder,
|
||||
onCancelRenameFolder,
|
||||
onCreateFolderSubmit,
|
||||
onDeleteFolder,
|
||||
onOpenMenu,
|
||||
onRenameFolder,
|
||||
onSelect,
|
||||
onStartRenameFolder,
|
||||
renamingFolderPath,
|
||||
rootPath,
|
||||
sectionCollapsed,
|
||||
selection,
|
||||
toggleFolder,
|
||||
}: FolderTreeBodyProps) {
|
||||
if (sectionCollapsed) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 pb-2">
|
||||
{displayedFolders.map((node) => (
|
||||
<FolderTreeRow
|
||||
key={node.path}
|
||||
depth={0}
|
||||
expanded={displayedExpanded}
|
||||
node={node}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onOpenMenu={onOpenMenu}
|
||||
onRenameFolder={onRenameFolder}
|
||||
onSelect={onSelect}
|
||||
onStartRenameFolder={onStartRenameFolder}
|
||||
onToggle={toggleFolder}
|
||||
onCancelRenameFolder={onCancelRenameFolder}
|
||||
locale={locale}
|
||||
renamingFolderPath={renamingFolderPath}
|
||||
rootPath={rootPath}
|
||||
selection={selection}
|
||||
/>
|
||||
))}
|
||||
{isCreating && (
|
||||
<div style={{ paddingLeft: 8 }}>
|
||||
<FolderNameInput
|
||||
ariaLabel={translate(locale, 'sidebar.folder.newName')}
|
||||
initialValue=""
|
||||
placeholder={translate(locale, 'sidebar.folder.name')}
|
||||
submitOnBlur={true}
|
||||
testId="new-folder-input"
|
||||
onCancel={onCancelCreateFolder}
|
||||
onSubmit={onCreateFolderSubmit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateFolderButton({
|
||||
locale,
|
||||
onCreate,
|
||||
|
||||
44
src/components/ImageLightbox.test.tsx
Normal file
44
src/components/ImageLightbox.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ImageLightbox } from './ImageLightbox'
|
||||
|
||||
describe('ImageLightbox', () => {
|
||||
it('renders the selected image in a dialog', () => {
|
||||
render(
|
||||
<ImageLightbox
|
||||
image={{ src: 'https://example.com/photo.png', alt: 'A lake' }}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('image-lightbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: 'A lake' })).toHaveAttribute('src', 'https://example.com/photo.png')
|
||||
expect(screen.getByText('Image preview')).toHaveClass('sr-only')
|
||||
})
|
||||
|
||||
it('falls back to localized alt text when the image has no alt', () => {
|
||||
render(
|
||||
<ImageLightbox
|
||||
image={{ src: 'https://example.com/photo.png', alt: '' }}
|
||||
locale="zh-CN"
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('img', { name: '图像预览' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClose when the dialog closes', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<ImageLightbox
|
||||
image={{ src: 'https://example.com/photo.png', alt: 'A lake' }}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('image-lightbox'), { key: 'Escape' })
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
34
src/components/ImageLightbox.tsx
Normal file
34
src/components/ImageLightbox.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Dialog, DialogContent, DialogTitle } from './ui/dialog'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import type { ImageLightboxTarget } from '../utils/imageLightboxTarget'
|
||||
|
||||
type ImageLightboxProps = {
|
||||
image: ImageLightboxTarget | null
|
||||
locale?: AppLocale
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ImageLightbox({ image, locale = 'en', onClose }: ImageLightboxProps) {
|
||||
const title = translate(locale, 'editor.imageLightbox.title')
|
||||
const open = image !== null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose() }}>
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
data-testid="image-lightbox"
|
||||
className="flex max-h-[90vh] max-w-[90vw] items-center justify-center border-none bg-transparent p-0 shadow-none sm:max-w-[90vw]"
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
{image && (
|
||||
<img
|
||||
data-testid="image-lightbox-image"
|
||||
src={image.src}
|
||||
alt={image.alt || title}
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-md object-contain shadow-2xl"
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -82,6 +82,17 @@ function isSelectAllShortcut(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
return event.key.toLowerCase() === 'a' && (event.metaKey || event.ctrlKey)
|
||||
}
|
||||
|
||||
function isLineBreakShortcut(
|
||||
event: React.KeyboardEvent<HTMLDivElement>,
|
||||
isComposing: boolean,
|
||||
) {
|
||||
return event.key === 'Enter'
|
||||
&& event.shiftKey
|
||||
&& !isComposing
|
||||
&& !event.nativeEvent.isComposing
|
||||
&& event.keyCode !== 229
|
||||
}
|
||||
|
||||
export const UNSUPPORTED_INLINE_PASTE_MESSAGE = 'Only text paste is supported in the AI composer right now.'
|
||||
|
||||
function hasUnsupportedClipboardPayload(clipboardData: DataTransfer) {
|
||||
@@ -288,6 +299,12 @@ export function InlineWikilinkInput({
|
||||
|
||||
if (!isInsertBeforeInput(nativeEvent)) return
|
||||
|
||||
if (nativeEvent.inputType === 'insertLineBreak') {
|
||||
nativeEvent.preventDefault()
|
||||
insertTransferText('\n')
|
||||
return
|
||||
}
|
||||
|
||||
if (isPlainTextBeforeInput(nativeEvent)) {
|
||||
nativeEvent.preventDefault()
|
||||
insertTransferText(nativeEvent.data)
|
||||
@@ -422,6 +439,12 @@ export function InlineWikilinkInput({
|
||||
const submitValue = () =>
|
||||
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!disabled && isLineBreakShortcut(event, isComposingRef.current)) {
|
||||
event.preventDefault()
|
||||
insertTransferText('\n')
|
||||
return
|
||||
}
|
||||
|
||||
if (isSelectAllShortcut(event)) {
|
||||
event.preventDefault()
|
||||
selectAllContent()
|
||||
|
||||
@@ -203,7 +203,7 @@ export function InlineWikilinkEditorField({
|
||||
contentEditable={!disabled}
|
||||
suppressContentEditableWarning={true}
|
||||
role="textbox"
|
||||
aria-multiline="false"
|
||||
aria-multiline="true"
|
||||
aria-disabled={disabled || undefined}
|
||||
aria-placeholder={placeholder}
|
||||
data-testid={dataTestId}
|
||||
|
||||
@@ -634,6 +634,29 @@ Status: Active
|
||||
expect(onInit).toHaveBeenCalledWith('/vault/plain-note.md')
|
||||
})
|
||||
|
||||
it('does not offer frontmatter initialization for binary attachments', () => {
|
||||
const onInit = vi.fn()
|
||||
const attachmentEntry: VaultEntry = {
|
||||
...noFrontmatterEntry,
|
||||
path: '/vault/attachments/screenshot.png',
|
||||
filename: 'screenshot.png',
|
||||
title: 'screenshot.png',
|
||||
fileKind: 'binary',
|
||||
}
|
||||
|
||||
render(
|
||||
<Inspector
|
||||
{...defaultProps}
|
||||
entry={attachmentEntry}
|
||||
content=""
|
||||
onInitializeProperties={onInit}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Initialize properties')).not.toBeInTheDocument()
|
||||
expect(onInit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows invalid frontmatter notice with fix button', () => {
|
||||
render(
|
||||
<Inspector
|
||||
|
||||
@@ -48,6 +48,10 @@ function buildTypeEntryMap(entries: VaultEntry[]): Record<string, VaultEntry> {
|
||||
return map
|
||||
}
|
||||
|
||||
function supportsFrontmatter(entry: VaultEntry): boolean {
|
||||
return entry.fileKind === undefined || entry.fileKind === 'markdown'
|
||||
}
|
||||
|
||||
function ValidFrontmatterPanels({
|
||||
entry,
|
||||
entries,
|
||||
@@ -212,24 +216,26 @@ function InspectorBody({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PrimaryInspectorPanel
|
||||
entry={entry}
|
||||
frontmatterState={frontmatterState}
|
||||
frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
typeEntryMap={typeEntryMap}
|
||||
vaultPath={vaultPath}
|
||||
referencedBy={referencedBy}
|
||||
onNavigate={onNavigate}
|
||||
onToggleRawEditor={onToggleRawEditor}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
onCreateMissingType={onCreateMissingType ? handleCreateMissingType : undefined}
|
||||
locale={locale}
|
||||
/>
|
||||
{supportsFrontmatter(entry) && (
|
||||
<PrimaryInspectorPanel
|
||||
entry={entry}
|
||||
frontmatterState={frontmatterState}
|
||||
frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
typeEntryMap={typeEntryMap}
|
||||
vaultPath={vaultPath}
|
||||
referencedBy={referencedBy}
|
||||
onNavigate={onNavigate}
|
||||
onToggleRawEditor={onToggleRawEditor}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
onCreateMissingType={onCreateMissingType ? handleCreateMissingType : undefined}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
{backlinks.length > 0 && <Separator />}
|
||||
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
|
||||
<Separator />
|
||||
|
||||
@@ -2,6 +2,20 @@ import { describe, it, expect, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { McpSetupDialog } from './McpSetupDialog'
|
||||
|
||||
const MANUAL_CONFIG = JSON.stringify({
|
||||
mcpServers: {
|
||||
tolaria: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['/Applications/Tolaria.app/Contents/Resources/mcp-server/index.js'],
|
||||
env: {
|
||||
VAULT_PATH: '/Users/luca/Laputa',
|
||||
WS_UI_PORT: '9711',
|
||||
},
|
||||
},
|
||||
},
|
||||
}, null, 2)
|
||||
|
||||
describe('McpSetupDialog', () => {
|
||||
it('renders the explicit setup flow without mutating config by default', () => {
|
||||
render(
|
||||
@@ -9,6 +23,7 @@ describe('McpSetupDialog', () => {
|
||||
open={true}
|
||||
status="not_installed"
|
||||
busyAction={null}
|
||||
manualConfigSnippet={MANUAL_CONFIG}
|
||||
onClose={vi.fn()}
|
||||
onConnect={vi.fn()}
|
||||
onDisconnect={vi.fn()}
|
||||
@@ -18,13 +33,17 @@ describe('McpSetupDialog', () => {
|
||||
expect(screen.getByText('Set Up External AI Tools')).toBeInTheDocument()
|
||||
expect(screen.getByText(/will not touch third-party config files until you confirm here/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/requires Node.js 18\+ on PATH/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/type: stdio/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/VAULT_PATH/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/WS_UI_PORT/i)).toBeInTheDocument()
|
||||
expect(screen.getAllByText('~/.claude.json')).toHaveLength(2)
|
||||
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"type": "stdio"')
|
||||
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"VAULT_PATH": "/Users/luca/Laputa"')
|
||||
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"WS_UI_PORT": "9711"')
|
||||
expect(screen.getByText('~/.claude.json')).toBeInTheDocument()
|
||||
expect(screen.getByText('~/.claude/mcp.json')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('~/.config/mcp/mcp.json')).toHaveLength(2)
|
||||
expect(screen.getByText('~/.gemini/settings.json')).toBeInTheDocument()
|
||||
expect(screen.getByText('~/.config/mcp/mcp.json')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Claude Code CLI reads ~\/\.claude\.json/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/picked up by other MCP-compatible tools/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Gemini CLI needs its own install and sign-in/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/GEMINI\.md/)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('mcp-setup-connect')).toHaveTextContent('Connect External AI Tools')
|
||||
expect(screen.queryByTestId('mcp-setup-disconnect')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -49,6 +68,7 @@ describe('McpSetupDialog', () => {
|
||||
it('routes actions through the dialog buttons', () => {
|
||||
const onClose = vi.fn()
|
||||
const onConnect = vi.fn()
|
||||
const onCopyManualConfig = vi.fn()
|
||||
const onDisconnect = vi.fn()
|
||||
|
||||
render(
|
||||
@@ -58,16 +78,37 @@ describe('McpSetupDialog', () => {
|
||||
busyAction={null}
|
||||
onClose={onClose}
|
||||
onConnect={onConnect}
|
||||
onCopyManualConfig={onCopyManualConfig}
|
||||
onDisconnect={onDisconnect}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
fireEvent.click(screen.getByTestId('mcp-copy-config'))
|
||||
fireEvent.click(screen.getByTestId('mcp-setup-connect'))
|
||||
fireEvent.click(screen.getByTestId('mcp-setup-disconnect'))
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
expect(onCopyManualConfig).toHaveBeenCalledOnce()
|
||||
expect(onConnect).toHaveBeenCalledOnce()
|
||||
expect(onDisconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('loads exact manual config when opened', () => {
|
||||
const onLoadManualConfig = vi.fn()
|
||||
|
||||
render(
|
||||
<McpSetupDialog
|
||||
open={true}
|
||||
status="not_installed"
|
||||
busyAction={null}
|
||||
onClose={vi.fn()}
|
||||
onConnect={vi.fn()}
|
||||
onDisconnect={vi.fn()}
|
||||
onLoadManualConfig={onLoadManualConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(onLoadManualConfig).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import { Copy, ShieldCheck } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -9,11 +10,38 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import type { McpStatus } from '../hooks/useMcpStatus'
|
||||
import { createTranslator, type AppLocale } from '../lib/i18n'
|
||||
|
||||
interface McpSetupDialogProps {
|
||||
open: boolean
|
||||
status: McpStatus
|
||||
busyAction: 'connect' | 'disconnect' | null
|
||||
manualConfigError?: string | null
|
||||
manualConfigLoading?: boolean
|
||||
manualConfigSnippet?: string | null
|
||||
locale?: AppLocale
|
||||
onClose: () => void
|
||||
onConnect: () => void
|
||||
onCopyManualConfig?: () => void
|
||||
onDisconnect: () => void
|
||||
onLoadManualConfig?: () => void
|
||||
}
|
||||
|
||||
interface ManualMcpConfigSectionProps {
|
||||
error?: string | null
|
||||
loading: boolean
|
||||
locale?: AppLocale
|
||||
onCopy?: () => void
|
||||
snippet?: string | null
|
||||
}
|
||||
|
||||
interface McpSetupActionsProps {
|
||||
buttonsDisabled: boolean
|
||||
connectBusy: boolean
|
||||
disconnectBusy: boolean
|
||||
locale?: AppLocale
|
||||
primaryLabel: string
|
||||
secondaryLabel: string | null
|
||||
onClose: () => void
|
||||
onConnect: () => void
|
||||
onDisconnect: () => void
|
||||
@@ -23,37 +51,128 @@ function isConnected(status: McpStatus): boolean {
|
||||
return status === 'installed'
|
||||
}
|
||||
|
||||
function actionCopy(status: McpStatus) {
|
||||
function actionCopy(status: McpStatus, t: ReturnType<typeof createTranslator>) {
|
||||
if (isConnected(status)) {
|
||||
return {
|
||||
description: 'Tolaria is already connected to external AI tools for this vault. Reconnect to refresh the configuration, or disconnect to remove Tolaria from those third-party config files.',
|
||||
primaryLabel: 'Reconnect External AI Tools',
|
||||
secondaryLabel: 'Disconnect',
|
||||
title: 'Manage External AI Tools',
|
||||
description: t('mcp.setup.connectedDescription'),
|
||||
primaryLabel: t('mcp.setup.reconnect'),
|
||||
secondaryLabel: t('mcp.setup.disconnect'),
|
||||
title: t('mcp.setup.manageTitle'),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
description: 'Tolaria can add its MCP server to external AI tools for this vault, but it will not touch third-party config files until you confirm here.',
|
||||
primaryLabel: 'Connect External AI Tools',
|
||||
description: t('mcp.setup.setupDescription'),
|
||||
primaryLabel: t('mcp.setup.connect'),
|
||||
secondaryLabel: null,
|
||||
title: 'Set Up External AI Tools',
|
||||
title: t('mcp.setup.setupTitle'),
|
||||
}
|
||||
}
|
||||
|
||||
function manualConfigText(
|
||||
{ error, loading, snippet }: ManualMcpConfigSectionProps,
|
||||
t: ReturnType<typeof createTranslator>,
|
||||
): string {
|
||||
if (loading) return t('mcp.setup.manual.loading')
|
||||
return error ?? snippet ?? t('mcp.setup.manual.unavailable')
|
||||
}
|
||||
|
||||
function ManualMcpConfigSection(props: ManualMcpConfigSectionProps) {
|
||||
const t = createTranslator(props.locale)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="m-0 text-sm font-medium text-foreground">{t('mcp.setup.manual.title')}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={props.onCopy}
|
||||
disabled={!props.onCopy || props.loading}
|
||||
data-testid="mcp-copy-config"
|
||||
>
|
||||
<Copy size={14} />
|
||||
{t('mcp.setup.manual.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
<pre
|
||||
tabIndex={0}
|
||||
className="max-h-48 overflow-auto rounded-md border border-border bg-background px-3 py-3 font-mono text-xs leading-5 text-foreground"
|
||||
data-testid="mcp-config-snippet"
|
||||
>
|
||||
{manualConfigText(props, t)}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function McpSetupActions({
|
||||
buttonsDisabled,
|
||||
connectBusy,
|
||||
disconnectBusy,
|
||||
locale = 'en',
|
||||
primaryLabel,
|
||||
secondaryLabel,
|
||||
onClose,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
}: McpSetupActionsProps) {
|
||||
const t = createTranslator(locale)
|
||||
|
||||
return (
|
||||
<DialogFooter className="flex-row items-center justify-end gap-2 sm:justify-end">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={buttonsDisabled}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
{secondaryLabel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={onDisconnect}
|
||||
disabled={buttonsDisabled}
|
||||
data-testid="mcp-setup-disconnect"
|
||||
>
|
||||
{disconnectBusy ? t('mcp.setup.disconnecting') : secondaryLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
autoFocus
|
||||
onClick={onConnect}
|
||||
disabled={buttonsDisabled}
|
||||
data-testid="mcp-setup-connect"
|
||||
>
|
||||
{connectBusy ? t('mcp.setup.connecting') : primaryLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)
|
||||
}
|
||||
|
||||
export function McpSetupDialog({
|
||||
open,
|
||||
status,
|
||||
busyAction,
|
||||
manualConfigError,
|
||||
manualConfigLoading = false,
|
||||
manualConfigSnippet,
|
||||
locale = 'en',
|
||||
onClose,
|
||||
onConnect,
|
||||
onCopyManualConfig,
|
||||
onDisconnect,
|
||||
onLoadManualConfig,
|
||||
}: McpSetupDialogProps) {
|
||||
const copy = actionCopy(status)
|
||||
const t = createTranslator(locale)
|
||||
const copy = actionCopy(status, t)
|
||||
const connectBusy = busyAction === 'connect'
|
||||
const disconnectBusy = busyAction === 'disconnect'
|
||||
const buttonsDisabled = busyAction !== null || status === 'checking'
|
||||
|
||||
useEffect(() => {
|
||||
if (open) onLoadManualConfig?.()
|
||||
}, [open, onLoadManualConfig])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-[520px]" data-testid="mcp-setup-dialog">
|
||||
@@ -67,54 +186,44 @@ export function McpSetupDialog({
|
||||
|
||||
<div className="space-y-3 text-sm leading-6 text-muted-foreground">
|
||||
<p>
|
||||
This setup requires Node.js 18+ on PATH and an MCP-compatible desktop tool. Tolaria checks Node.js before writing config so the tool is not left pointing at a broken command.
|
||||
{t('mcp.setup.nodeRequirement')}
|
||||
</p>
|
||||
<p>
|
||||
Confirming this action will write or update Tolaria's single <code className="rounded bg-muted px-1 py-0.5 text-xs">tolaria</code> MCP entry in:
|
||||
{t('mcp.setup.writeEntryDescription', { entry: 'tolaria' })}
|
||||
</p>
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-3 font-mono text-xs text-foreground">
|
||||
<div>~/.claude.json</div>
|
||||
<div>~/.claude/mcp.json</div>
|
||||
<div>~/.gemini/settings.json</div>
|
||||
<div>~/.cursor/mcp.json</div>
|
||||
<div>~/.config/mcp/mcp.json</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-background px-3 py-3 font-mono text-xs leading-5 text-foreground">
|
||||
<div>type: stdio</div>
|
||||
<div>command: node</div>
|
||||
<div>args: <Tolaria resources>/mcp-server/index.js</div>
|
||||
<div>VAULT_PATH: active vault</div>
|
||||
<div>WS_UI_PORT: 9711</div>
|
||||
</div>
|
||||
<ManualMcpConfigSection
|
||||
error={manualConfigError}
|
||||
loading={manualConfigLoading}
|
||||
locale={locale}
|
||||
onCopy={onCopyManualConfig}
|
||||
snippet={manualConfigSnippet}
|
||||
/>
|
||||
<p>
|
||||
Claude Code CLI reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.claude.json</code>, Cursor reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.cursor/mcp.json</code>, and the generic <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.config/mcp/mcp.json</code> path is picked up by other MCP-compatible tools. Cancel leaves all files untouched, reconnect is idempotent, and disconnect removes Tolaria's entry again.
|
||||
{t('mcp.setup.clientPathsDescription')}
|
||||
</p>
|
||||
<p>
|
||||
{t('mcp.setup.geminiGuidanceDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-end gap-2 sm:justify-end">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={buttonsDisabled}>
|
||||
Cancel
|
||||
</Button>
|
||||
{copy.secondaryLabel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={onDisconnect}
|
||||
disabled={buttonsDisabled}
|
||||
data-testid="mcp-setup-disconnect"
|
||||
>
|
||||
{disconnectBusy ? 'Disconnecting…' : copy.secondaryLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
autoFocus
|
||||
onClick={onConnect}
|
||||
disabled={buttonsDisabled}
|
||||
data-testid="mcp-setup-connect"
|
||||
>
|
||||
{connectBusy ? 'Connecting…' : copy.primaryLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<McpSetupActions
|
||||
buttonsDisabled={buttonsDisabled}
|
||||
connectBusy={connectBusy}
|
||||
disconnectBusy={disconnectBusy}
|
||||
locale={locale}
|
||||
primaryLabel={copy.primaryLabel}
|
||||
secondaryLabel={copy.secondaryLabel}
|
||||
onClose={onClose}
|
||||
onConnect={onConnect}
|
||||
onDisconnect={onDisconnect}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -58,6 +58,26 @@ describe('NoteItem', () => {
|
||||
expect(screen.getByTestId('type-icon')).toHaveAttribute('data-file-preview-kind', 'image')
|
||||
})
|
||||
|
||||
it('renders PDF files as clickable rows with a PDF file indicator', () => {
|
||||
const pdfEntry = makeEntry({
|
||||
path: '/vault/reports/brief.pdf',
|
||||
filename: 'brief.pdf',
|
||||
title: 'brief.pdf',
|
||||
fileKind: 'binary',
|
||||
})
|
||||
const onClickNote = vi.fn()
|
||||
|
||||
render(<NoteItem entry={pdfEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClickNote} />)
|
||||
|
||||
const item = screen.getByTestId('pdf-file-item')
|
||||
expect(item.className).not.toContain('opacity-50')
|
||||
expect(item).toHaveAttribute('title', 'Open PDF preview')
|
||||
|
||||
fireEvent.click(item)
|
||||
expect(onClickNote).toHaveBeenCalledWith(pdfEntry, expect.any(Object))
|
||||
expect(screen.getByTestId('type-icon')).toHaveAttribute('data-file-preview-kind', 'pdf')
|
||||
})
|
||||
|
||||
it('renders text files as clickable rows', () => {
|
||||
const textEntry = makeEntry({
|
||||
path: '/vault/config.yml',
|
||||
|
||||
@@ -4,12 +4,12 @@ import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, FileText, StackSimple,
|
||||
File, FileDashed, ImageSquare,
|
||||
File, FileDashed, FilePdf, ImageSquare,
|
||||
} from '@phosphor-icons/react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
|
||||
import { isImagePreviewEntry } from '../utils/filePreview'
|
||||
import { filePreviewKind, type FilePreviewKind } from '../utils/filePreview'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import { PropertyChips } from './note-item/PropertyChips'
|
||||
import { ChangeNoteContent } from './note-item/ChangeNoteContent'
|
||||
@@ -109,7 +109,7 @@ function NoteTypeIndicator({
|
||||
}: {
|
||||
TypeIcon: ComponentType<SVGAttributes<SVGSVGElement>>
|
||||
typeColor: string
|
||||
filePreviewKind?: 'image'
|
||||
filePreviewKind?: FilePreviewKind
|
||||
}) {
|
||||
return (
|
||||
<TypeIcon
|
||||
@@ -202,7 +202,9 @@ function InteractiveNoteDetails({
|
||||
}
|
||||
|
||||
function resolveNoteTypeIcon(entry: VaultEntry, customIcon?: string | null): ComponentType<SVGAttributes<SVGSVGElement>> {
|
||||
if (isImagePreviewEntry(entry)) return ImageSquare
|
||||
const previewKind = filePreviewKind(entry)
|
||||
if (previewKind === 'image') return ImageSquare
|
||||
if (previewKind === 'pdf') return FilePdf
|
||||
if (entry.fileKind && entry.fileKind !== 'markdown') return getFileKindIcon(entry.fileKind)
|
||||
return getTypeIcon(entry.isA, customIcon)
|
||||
}
|
||||
@@ -232,11 +234,11 @@ function StandardNoteContent({
|
||||
}) {
|
||||
const te = typeEntryMap[entry.isA ?? '']
|
||||
const TypeIcon = resolveNoteTypeIcon(entry, te?.icon)
|
||||
const filePreviewKind = isImagePreviewEntry(entry) ? 'image' : undefined
|
||||
const previewKind = filePreviewKind(entry) ?? undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<NoteTypeIndicator TypeIcon={TypeIcon} typeColor={typeColor} filePreviewKind={filePreviewKind} />
|
||||
<NoteTypeIndicator TypeIcon={TypeIcon} typeColor={typeColor} filePreviewKind={previewKind} />
|
||||
<div className="space-y-2" data-testid="note-content-stack">
|
||||
{isBinary ? (
|
||||
<NoteTitleRow
|
||||
@@ -360,30 +362,31 @@ function resolveNoteItemSurfaceStyle({
|
||||
|
||||
function resolveNoteItemTestId({
|
||||
isMultiSelected,
|
||||
isImagePreview,
|
||||
previewKind,
|
||||
isUnavailableBinary,
|
||||
}: Pick<NoteItemVisualState, 'isMultiSelected' | 'isUnavailableBinary'> & {
|
||||
isImagePreview: boolean
|
||||
previewKind: FilePreviewKind | null
|
||||
}) {
|
||||
if (isMultiSelected) return 'multi-selected-item'
|
||||
if (isImagePreview) return 'image-file-item'
|
||||
if (previewKind) return `${previewKind}-file-item`
|
||||
return isUnavailableBinary ? 'binary-file-item' : undefined
|
||||
}
|
||||
|
||||
function resolveNoteItemTitle({
|
||||
isImagePreview,
|
||||
previewKind,
|
||||
isUnavailableBinary,
|
||||
}: Pick<NoteItemVisualState, 'isUnavailableBinary'> & {
|
||||
isImagePreview: boolean
|
||||
previewKind: FilePreviewKind | null
|
||||
}) {
|
||||
if (isImagePreview) return 'Open image preview'
|
||||
if (previewKind === 'image') return 'Open image preview'
|
||||
if (previewKind === 'pdf') return 'Open PDF preview'
|
||||
return isUnavailableBinary ? 'Cannot open this file type' : undefined
|
||||
}
|
||||
|
||||
function resolveNoteItemSurfaceProps({
|
||||
entry,
|
||||
isUnavailableBinary,
|
||||
isImagePreview,
|
||||
previewKind,
|
||||
isSelected,
|
||||
isMultiSelected,
|
||||
isHighlighted,
|
||||
@@ -394,7 +397,7 @@ function resolveNoteItemSurfaceProps({
|
||||
typeLightColor,
|
||||
}: NoteItemVisualState & {
|
||||
entry: VaultEntry
|
||||
isImagePreview: boolean
|
||||
previewKind: FilePreviewKind | null
|
||||
onClickNote: NoteItemProps['onClickNote']
|
||||
onPrefetch?: NoteItemProps['onPrefetch']
|
||||
onContextMenu?: NoteItemProps['onContextMenu']
|
||||
@@ -407,8 +410,8 @@ function resolveNoteItemSurfaceProps({
|
||||
onClick: createNoteItemClickHandler(entry, isUnavailableBinary, onClickNote),
|
||||
onContextMenu: onContextMenu ? (event) => onContextMenu(entry, event) : undefined,
|
||||
onMouseEnter: entry.fileKind !== 'binary' && onPrefetch ? () => onPrefetch(entry.path) : undefined,
|
||||
testId: resolveNoteItemTestId({ isMultiSelected, isImagePreview, isUnavailableBinary }),
|
||||
title: resolveNoteItemTitle({ isImagePreview, isUnavailableBinary }),
|
||||
testId: resolveNoteItemTestId({ isMultiSelected, previewKind, isUnavailableBinary }),
|
||||
title: resolveNoteItemTitle({ previewKind, isUnavailableBinary }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,16 +500,17 @@ function NoteItemContent({
|
||||
|
||||
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', changeStatus, typeEntryMap, allEntries, displayPropsOverride, onClickNote, onPrefetch, onContextMenu }: NoteItemProps) {
|
||||
const isBinary = entry.fileKind === 'binary'
|
||||
const isImagePreview = isImagePreviewEntry(entry)
|
||||
const isUnavailableBinary = isBinary && !isImagePreview
|
||||
const previewKind = filePreviewKind(entry)
|
||||
const isPreviewableFile = previewKind !== null
|
||||
const isUnavailableBinary = isBinary && !isPreviewableFile
|
||||
const te = typeEntryMap[entry.isA ?? '']
|
||||
const displayProps = resolveDisplayProps(entry, typeEntryMap, displayPropsOverride)
|
||||
const typeColor = isImagePreview ? 'var(--accent-blue)' : isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color)
|
||||
const typeColor = isPreviewableFile ? 'var(--accent-blue)' : isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color)
|
||||
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
|
||||
const surfaceProps = resolveNoteItemSurfaceProps({
|
||||
entry,
|
||||
isUnavailableBinary,
|
||||
isImagePreview,
|
||||
previewKind,
|
||||
isSelected,
|
||||
isMultiSelected,
|
||||
isHighlighted,
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('NoteList virtualized datasets', () => {
|
||||
expect(screen.getByText('Note 499')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters large datasets by search query', async () => {
|
||||
it('filters large datasets by search query', { timeout: 15000 }, async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const entries = [
|
||||
|
||||
@@ -2,15 +2,19 @@ import { useState, useRef, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { NoteSearchList } from './NoteSearchList'
|
||||
import { useNoteSearch } from '../hooks/useNoteSearch'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface QuickOpenPaletteProps {
|
||||
open: boolean
|
||||
entries: VaultEntry[]
|
||||
isLoading?: boolean
|
||||
onSelect: (entry: VaultEntry) => void
|
||||
onClose: () => void
|
||||
locale?: AppLocale
|
||||
}
|
||||
|
||||
export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpenPaletteProps) {
|
||||
export function QuickOpenPalette({ open, entries, isLoading = false, onSelect, onClose, locale = 'en' }: QuickOpenPaletteProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const { results, selectedIndex, setSelectedIndex, handleKeyDown } = useNoteSearch(entries, query)
|
||||
@@ -56,11 +60,11 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
|
||||
className="flex w-[500px] max-w-[90vw] max-h-[400px] flex-col self-start overflow-hidden rounded-xl border border-[var(--border-dialog)] bg-popover shadow-[0_8px_32px_var(--shadow-dialog)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
className="h-auto rounded-none border-0 border-b border-border px-4 py-3 text-[15px] shadow-none focus-visible:ring-0"
|
||||
type="text"
|
||||
placeholder="Search notes..."
|
||||
placeholder={translate(locale, 'noteList.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
@@ -73,7 +77,7 @@ export function QuickOpenPalette({ open, entries, onSelect, onClose }: QuickOpen
|
||||
onClose()
|
||||
}}
|
||||
onItemHover={(i) => setSelectedIndex(i)}
|
||||
emptyMessage="No matching notes"
|
||||
emptyMessage={isLoading ? translate(locale, 'status.vault.reloading') : translate(locale, 'noteList.empty.noMatching')}
|
||||
className="flex-1 overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,14 @@ import { SettingsPanel } from './SettingsPanel'
|
||||
import type { Settings } from '../types'
|
||||
import { THEME_MODE_STORAGE_KEY } from '../lib/themeMode'
|
||||
|
||||
const { trackEventMock } = vi.hoisted(() => ({
|
||||
trackEventMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/telemetry', () => ({
|
||||
trackEvent: trackEventMock,
|
||||
}))
|
||||
|
||||
const emptySettings: Settings = {
|
||||
auto_pull_interval_minutes: null,
|
||||
autogit_enabled: null,
|
||||
@@ -19,6 +27,9 @@ const emptySettings: Settings = {
|
||||
ui_language: null,
|
||||
default_ai_agent: null,
|
||||
hide_gitignored_files: null,
|
||||
all_notes_show_pdfs: null,
|
||||
all_notes_show_images: null,
|
||||
all_notes_show_unsupported: null,
|
||||
}
|
||||
|
||||
function installPointerCapturePolyfill() {
|
||||
@@ -52,8 +63,11 @@ describe('SettingsPanel', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
trackEventMock.mockClear()
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true })
|
||||
window.localStorage.clear()
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
document.documentElement.classList.remove('dark')
|
||||
installPointerCapturePolyfill()
|
||||
})
|
||||
|
||||
@@ -105,6 +119,9 @@ describe('SettingsPanel', () => {
|
||||
release_channel: null,
|
||||
theme_mode: 'light',
|
||||
hide_gitignored_files: true,
|
||||
all_notes_show_pdfs: false,
|
||||
all_notes_show_images: false,
|
||||
all_notes_show_unsupported: false,
|
||||
}))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
@@ -123,6 +140,73 @@ describe('SettingsPanel', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders All Notes file visibility checkboxes off by default', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('All Notes visibility')).toBeInTheDocument()
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-unsupported')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('preserves saved All Notes file visibility checkboxes', () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
open={true}
|
||||
settings={{
|
||||
...emptySettings,
|
||||
all_notes_show_pdfs: true,
|
||||
all_notes_show_images: true,
|
||||
all_notes_show_unsupported: false,
|
||||
}}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(within(screen.getByTestId('settings-all-notes-show-unsupported')).getByRole('checkbox')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('saves All Notes file visibility from keyboard toggles before Escape close', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
const pdfCheckbox = within(screen.getByTestId('settings-all-notes-show-pdfs')).getByRole('checkbox')
|
||||
pdfCheckbox.focus()
|
||||
fireEvent.keyDown(pdfCheckbox, { key: ' ', code: 'Space' })
|
||||
fireEvent.keyUp(pdfCheckbox, { key: ' ', code: 'Space' })
|
||||
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Escape' })
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
all_notes_show_pdfs: true,
|
||||
all_notes_show_images: false,
|
||||
all_notes_show_unsupported: false,
|
||||
}))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks All Notes visibility toggles with categorical metadata only', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
fireEvent.click(within(screen.getByTestId('settings-all-notes-show-images')).getByRole('checkbox'))
|
||||
|
||||
expect(trackEventMock).toHaveBeenCalledWith('all_notes_visibility_changed', {
|
||||
category: 'images',
|
||||
enabled: 1,
|
||||
})
|
||||
expect(trackEventMock).not.toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ path: expect.any(String) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults the color mode control to light', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
@@ -201,6 +285,21 @@ describe('SettingsPanel', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('applies the selected dark color mode immediately while settings stays open', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Dark' }))
|
||||
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'dark')
|
||||
expect(document.documentElement).toHaveClass('dark')
|
||||
expect(window.localStorage.getItem(THEME_MODE_STORAGE_KEY)).toBe('dark')
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
theme_mode: 'dark',
|
||||
}))
|
||||
})
|
||||
|
||||
it('preserves a saved dark color mode until changed', () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
@@ -441,6 +540,23 @@ describe('SettingsPanel', () => {
|
||||
expect(screen.getByText(/to open settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies the MCP config from the AI Agents section', () => {
|
||||
const onCopyMcpConfig = vi.fn()
|
||||
render(
|
||||
<SettingsPanel
|
||||
open={true}
|
||||
settings={emptySettings}
|
||||
onSave={onSave}
|
||||
onCopyMcpConfig={onCopyMcpConfig}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy MCP config' }))
|
||||
|
||||
expect(onCopyMcpConfig).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
describe('Privacy & Telemetry section', () => {
|
||||
it('renders crash reporting and analytics toggles', () => {
|
||||
render(
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from 'react'
|
||||
import { Moon, Sun, X } from '@phosphor-icons/react'
|
||||
import { Copy } from 'lucide-react'
|
||||
import type { Settings } from '../types'
|
||||
import {
|
||||
APP_LOCALES,
|
||||
@@ -28,13 +30,21 @@ import {
|
||||
type UiLanguagePreference,
|
||||
} from '../lib/i18n'
|
||||
import {
|
||||
applyThemeModeToDocument,
|
||||
DEFAULT_THEME_MODE,
|
||||
readStoredThemeMode,
|
||||
type ThemeMode,
|
||||
writeStoredThemeMode,
|
||||
} from '../lib/themeMode'
|
||||
import { normalizeReleaseChannel, serializeReleaseChannel, type ReleaseChannel } from '../lib/releaseChannel'
|
||||
import { shouldHideGitignoredFiles } from '../lib/gitignoredVisibility'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { trackAllNotesVisibilityChanged } from '../lib/productAnalytics'
|
||||
import {
|
||||
resolveAllNotesFileVisibility,
|
||||
settingsWithAllNotesFileVisibility,
|
||||
type AllNotesFileVisibility,
|
||||
} from '../utils/allNotesFileVisibility'
|
||||
import { Button } from './ui/button'
|
||||
import { Checkbox, type CheckedState } from './ui/checkbox'
|
||||
import { Input } from './ui/input'
|
||||
@@ -54,6 +64,7 @@ interface SettingsPanelProps {
|
||||
locale?: AppLocale
|
||||
systemLocale?: AppLocale
|
||||
onSave: (settings: Settings) => void
|
||||
onCopyMcpConfig?: () => void
|
||||
isGitVault?: boolean
|
||||
explicitOrganizationEnabled?: boolean
|
||||
onSaveExplicitOrganization?: (enabled: boolean) => void
|
||||
@@ -72,6 +83,7 @@ interface SettingsDraft {
|
||||
uiLanguage: UiLanguagePreference
|
||||
initialH1AutoRename: boolean
|
||||
hideGitignoredFiles: boolean
|
||||
allNotesFileVisibility: AllNotesFileVisibility
|
||||
crashReporting: boolean
|
||||
analytics: boolean
|
||||
explicitOrganization: boolean
|
||||
@@ -93,6 +105,7 @@ interface SettingsBodyProps {
|
||||
aiAgentsStatus: AiAgentsStatus
|
||||
defaultAiAgent: AiAgentId
|
||||
setDefaultAiAgent: (value: AiAgentId) => void
|
||||
onCopyMcpConfig?: () => void
|
||||
releaseChannel: ReleaseChannel
|
||||
setReleaseChannel: (value: ReleaseChannel) => void
|
||||
themeMode: ThemeMode
|
||||
@@ -105,6 +118,8 @@ interface SettingsBodyProps {
|
||||
setInitialH1AutoRename: (value: boolean) => void
|
||||
hideGitignoredFiles: boolean
|
||||
setHideGitignoredFiles: (value: boolean) => void
|
||||
allNotesFileVisibility: AllNotesFileVisibility
|
||||
setAllNotesFileVisibility: (value: AllNotesFileVisibility) => void
|
||||
explicitOrganization: boolean
|
||||
setExplicitOrganization: (value: boolean) => void
|
||||
crashReporting: boolean
|
||||
@@ -144,6 +159,7 @@ function createSettingsDraft(
|
||||
uiLanguage: settings.ui_language ?? SYSTEM_UI_LANGUAGE,
|
||||
initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true,
|
||||
hideGitignoredFiles: shouldHideGitignoredFiles(settings),
|
||||
allNotesFileVisibility: resolveAllNotesFileVisibility(settings),
|
||||
crashReporting: settings.crash_reporting_enabled ?? false,
|
||||
analytics: settings.analytics_enabled ?? false,
|
||||
explicitOrganization: explicitOrganizationEnabled,
|
||||
@@ -170,7 +186,7 @@ function resolveAnonymousId(settings: Settings, draft: SettingsDraft): string |
|
||||
}
|
||||
|
||||
function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Settings {
|
||||
return {
|
||||
const nextSettings = {
|
||||
auto_pull_interval_minutes: draft.pullInterval,
|
||||
autogit_enabled: draft.autoGitEnabled,
|
||||
autogit_idle_threshold_seconds: draft.autoGitIdleThresholdSeconds,
|
||||
@@ -187,6 +203,7 @@ function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Setti
|
||||
default_ai_agent: draft.defaultAiAgent,
|
||||
hide_gitignored_files: draft.hideGitignoredFiles,
|
||||
}
|
||||
return settingsWithAllNotesFileVisibility(nextSettings, draft.allNotesFileVisibility)
|
||||
}
|
||||
|
||||
function trackTelemetryConsentChange(previousAnalytics: boolean, nextAnalytics: boolean): void {
|
||||
@@ -203,6 +220,21 @@ function sanitizePositiveInteger(value: number | null | undefined, fallback: num
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
function applyThemeModeSelection(value: ThemeMode): void {
|
||||
if (typeof document !== 'undefined') applyThemeModeToDocument(document, value)
|
||||
if (typeof window !== 'undefined') writeStoredThemeMode(window.localStorage, value)
|
||||
}
|
||||
|
||||
function useSettingsPanelAutofocus(panelRef: RefObject<HTMLDivElement | null>): void {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
const focusTarget = panelRef.current?.querySelector<HTMLElement>('[data-settings-autofocus="true"]')
|
||||
focusTarget?.focus()
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
}, [panelRef])
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
open,
|
||||
settings,
|
||||
@@ -210,6 +242,7 @@ export function SettingsPanel({
|
||||
locale = 'en',
|
||||
systemLocale = locale,
|
||||
onSave,
|
||||
onCopyMcpConfig,
|
||||
isGitVault = true,
|
||||
explicitOrganizationEnabled = true,
|
||||
onSaveExplicitOrganization,
|
||||
@@ -224,6 +257,7 @@ export function SettingsPanel({
|
||||
locale={locale}
|
||||
systemLocale={systemLocale}
|
||||
onSave={onSave}
|
||||
onCopyMcpConfig={onCopyMcpConfig}
|
||||
isGitVault={isGitVault}
|
||||
explicitOrganizationEnabled={explicitOrganizationEnabled}
|
||||
onSaveExplicitOrganization={onSaveExplicitOrganization}
|
||||
@@ -245,6 +279,7 @@ function SettingsPanelInner({
|
||||
aiAgentsStatus,
|
||||
systemLocale,
|
||||
onSave,
|
||||
onCopyMcpConfig,
|
||||
isGitVault,
|
||||
explicitOrganizationEnabled,
|
||||
onSaveExplicitOrganization,
|
||||
@@ -259,13 +294,7 @@ function SettingsPanelInner({
|
||||
setDraft(createSettingsDraft(settings, explicitOrganizationEnabled))
|
||||
}, [explicitOrganizationEnabled, settings])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
const focusTarget = panelRef.current?.querySelector<HTMLElement>('[data-settings-autofocus="true"]')
|
||||
focusTarget?.focus()
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
useSettingsPanelAutofocus(panelRef)
|
||||
|
||||
const updateDraft = useCallback(
|
||||
<Key extends keyof SettingsDraft>(key: Key, value: SettingsDraft[Key]) => {
|
||||
@@ -279,6 +308,18 @@ function SettingsPanelInner({
|
||||
onSave({ ...settings, hide_gitignored_files: value })
|
||||
}, [onSave, settings, updateDraft])
|
||||
|
||||
const handleAllNotesFileVisibilityChange = useCallback((value: AllNotesFileVisibility) => {
|
||||
trackAllNotesVisibilityChanged(draft.allNotesFileVisibility, value)
|
||||
updateDraft('allNotesFileVisibility', value)
|
||||
onSave(settingsWithAllNotesFileVisibility(settings, value))
|
||||
}, [draft.allNotesFileVisibility, onSave, settings, updateDraft])
|
||||
|
||||
const handleThemeModeChange = useCallback((value: ThemeMode) => {
|
||||
updateDraft('themeMode', value)
|
||||
applyThemeModeSelection(value)
|
||||
onSave({ ...settings, theme_mode: value })
|
||||
}, [onSave, settings, updateDraft])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
trackTelemetryConsentChange(settings.analytics_enabled === true, draft.analytics)
|
||||
onSave(buildSettingsFromDraft(settings, draft))
|
||||
@@ -286,12 +327,9 @@ function SettingsPanelInner({
|
||||
onClose()
|
||||
}, [draft, onClose, onSave, onSaveExplicitOrganization, settings])
|
||||
|
||||
const handleBackdropClick = useCallback(
|
||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) onClose()
|
||||
},
|
||||
[onClose],
|
||||
)
|
||||
const handleBackdropClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: ReactKeyboardEvent) => {
|
||||
@@ -341,16 +379,19 @@ function SettingsPanelInner({
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
defaultAiAgent={draft.defaultAiAgent}
|
||||
setDefaultAiAgent={(value) => updateDraft('defaultAiAgent', value)}
|
||||
onCopyMcpConfig={onCopyMcpConfig}
|
||||
releaseChannel={draft.releaseChannel}
|
||||
setReleaseChannel={(value) => updateDraft('releaseChannel', value)}
|
||||
themeMode={draft.themeMode}
|
||||
setThemeMode={(value) => updateDraft('themeMode', value)}
|
||||
setThemeMode={handleThemeModeChange}
|
||||
uiLanguage={draft.uiLanguage}
|
||||
setUiLanguage={(value) => updateDraft('uiLanguage', value)}
|
||||
initialH1AutoRename={draft.initialH1AutoRename}
|
||||
setInitialH1AutoRename={(value) => updateDraft('initialH1AutoRename', value)}
|
||||
hideGitignoredFiles={draft.hideGitignoredFiles}
|
||||
setHideGitignoredFiles={handleGitignoredVisibilityChange}
|
||||
allNotesFileVisibility={draft.allNotesFileVisibility}
|
||||
setAllNotesFileVisibility={handleAllNotesFileVisibilityChange}
|
||||
explicitOrganization={draft.explicitOrganization}
|
||||
setExplicitOrganization={(value) => updateDraft('explicitOrganization', value)}
|
||||
crashReporting={draft.crashReporting}
|
||||
@@ -384,7 +425,17 @@ function SettingsHeader({ onClose, t }: { onClose: () => void; t: Translate }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsBody({
|
||||
function SettingsBody(props: SettingsBodyProps) {
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 0, overflow: 'auto' }}>
|
||||
<SettingsSyncAndAppearanceSections {...props} />
|
||||
<SettingsContentSections {...props} />
|
||||
<SettingsAgentWorkflowSections {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsSyncAndAppearanceSections({
|
||||
t,
|
||||
locale,
|
||||
systemLocale,
|
||||
@@ -397,30 +448,15 @@ function SettingsBody({
|
||||
setAutoGitIdleThresholdSeconds,
|
||||
autoGitInactiveThresholdSeconds,
|
||||
setAutoGitInactiveThresholdSeconds,
|
||||
autoAdvanceInboxAfterOrganize,
|
||||
setAutoAdvanceInboxAfterOrganize,
|
||||
aiAgentsStatus,
|
||||
defaultAiAgent,
|
||||
setDefaultAiAgent,
|
||||
releaseChannel,
|
||||
setReleaseChannel,
|
||||
themeMode,
|
||||
setThemeMode,
|
||||
uiLanguage,
|
||||
setUiLanguage,
|
||||
initialH1AutoRename,
|
||||
setInitialH1AutoRename,
|
||||
hideGitignoredFiles,
|
||||
setHideGitignoredFiles,
|
||||
explicitOrganization,
|
||||
setExplicitOrganization,
|
||||
crashReporting,
|
||||
setCrashReporting,
|
||||
analytics,
|
||||
setAnalytics,
|
||||
}: SettingsBodyProps) {
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 0, overflow: 'auto' }}>
|
||||
<>
|
||||
<SettingsSection showDivider={false}>
|
||||
<SyncAndUpdatesSection
|
||||
t={t}
|
||||
@@ -430,7 +466,6 @@ function SettingsBody({
|
||||
setReleaseChannel={setReleaseChannel}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<AutoGitSettingsSection
|
||||
t={t}
|
||||
@@ -461,7 +496,21 @@ function SettingsBody({
|
||||
setUiLanguage={setUiLanguage}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsContentSections({
|
||||
t,
|
||||
initialH1AutoRename,
|
||||
setInitialH1AutoRename,
|
||||
hideGitignoredFiles,
|
||||
setHideGitignoredFiles,
|
||||
allNotesFileVisibility,
|
||||
setAllNotesFileVisibility,
|
||||
}: SettingsBodyProps) {
|
||||
return (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<TitleSettingsSection
|
||||
t={t}
|
||||
@@ -475,15 +524,38 @@ function SettingsBody({
|
||||
t={t}
|
||||
hideGitignoredFiles={hideGitignoredFiles}
|
||||
setHideGitignoredFiles={setHideGitignoredFiles}
|
||||
allNotesFileVisibility={allNotesFileVisibility}
|
||||
setAllNotesFileVisibility={setAllNotesFileVisibility}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsAgentWorkflowSections({
|
||||
t,
|
||||
autoAdvanceInboxAfterOrganize,
|
||||
setAutoAdvanceInboxAfterOrganize,
|
||||
aiAgentsStatus,
|
||||
defaultAiAgent,
|
||||
setDefaultAiAgent,
|
||||
onCopyMcpConfig,
|
||||
explicitOrganization,
|
||||
setExplicitOrganization,
|
||||
crashReporting,
|
||||
setCrashReporting,
|
||||
analytics,
|
||||
setAnalytics,
|
||||
}: SettingsBodyProps) {
|
||||
return (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<AiAgentSettingsSection
|
||||
t={t}
|
||||
aiAgentsStatus={aiAgentsStatus}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
setDefaultAiAgent={setDefaultAiAgent}
|
||||
onCopyMcpConfig={onCopyMcpConfig}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -506,7 +578,7 @@ function SettingsBody({
|
||||
setAnalytics={setAnalytics}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -760,7 +832,16 @@ function VaultContentSettingsSection({
|
||||
t,
|
||||
hideGitignoredFiles,
|
||||
setHideGitignoredFiles,
|
||||
}: Pick<SettingsBodyProps, 't' | 'hideGitignoredFiles' | 'setHideGitignoredFiles'>) {
|
||||
allNotesFileVisibility,
|
||||
setAllNotesFileVisibility,
|
||||
}: Pick<
|
||||
SettingsBodyProps,
|
||||
't' | 'hideGitignoredFiles' | 'setHideGitignoredFiles' | 'allNotesFileVisibility' | 'setAllNotesFileVisibility'
|
||||
>) {
|
||||
const updateAllNotesFileVisibility = (patch: Partial<AllNotesFileVisibility>) => {
|
||||
setAllNotesFileVisibility({ ...allNotesFileVisibility, ...patch })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeading
|
||||
@@ -775,6 +856,35 @@ function VaultContentSettingsSection({
|
||||
onChange={setHideGitignoredFiles}
|
||||
testId="settings-hide-gitignored-files"
|
||||
/>
|
||||
|
||||
<div className="space-y-3 pt-1">
|
||||
<SectionHeading
|
||||
title={t('settings.allNotesVisibility.title')}
|
||||
description={t('settings.allNotesVisibility.description')}
|
||||
/>
|
||||
|
||||
<SettingsCheckboxRow
|
||||
label={t('settings.allNotesVisibility.pdfs')}
|
||||
description={t('settings.allNotesVisibility.pdfsDescription')}
|
||||
checked={allNotesFileVisibility.pdfs}
|
||||
onChange={(checked) => updateAllNotesFileVisibility({ pdfs: checked })}
|
||||
testId="settings-all-notes-show-pdfs"
|
||||
/>
|
||||
<SettingsCheckboxRow
|
||||
label={t('settings.allNotesVisibility.images')}
|
||||
description={t('settings.allNotesVisibility.imagesDescription')}
|
||||
checked={allNotesFileVisibility.images}
|
||||
onChange={(checked) => updateAllNotesFileVisibility({ images: checked })}
|
||||
testId="settings-all-notes-show-images"
|
||||
/>
|
||||
<SettingsCheckboxRow
|
||||
label={t('settings.allNotesVisibility.unsupported')}
|
||||
description={t('settings.allNotesVisibility.unsupportedDescription')}
|
||||
checked={allNotesFileVisibility.unsupported}
|
||||
onChange={(checked) => updateAllNotesFileVisibility({ unsupported: checked })}
|
||||
testId="settings-all-notes-show-unsupported"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -797,7 +907,8 @@ function AiAgentSettingsSection({
|
||||
aiAgentsStatus,
|
||||
defaultAiAgent,
|
||||
setDefaultAiAgent,
|
||||
}: Pick<SettingsBodyProps, 't' | 'aiAgentsStatus' | 'defaultAiAgent' | 'setDefaultAiAgent'>) {
|
||||
onCopyMcpConfig,
|
||||
}: Pick<SettingsBodyProps, 't' | 'aiAgentsStatus' | 'defaultAiAgent' | 'setDefaultAiAgent' | 'onCopyMcpConfig'>) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeading
|
||||
@@ -816,6 +927,21 @@ function AiAgentSettingsSection({
|
||||
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
{renderDefaultAiAgentSummary(defaultAiAgent, aiAgentsStatus, t)}
|
||||
</div>
|
||||
|
||||
{onCopyMcpConfig ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCopyMcpConfig}
|
||||
className="w-fit gap-2"
|
||||
aria-label={t('ai.panel.copyMcpConfig')}
|
||||
data-testid="settings-copy-mcp-config"
|
||||
>
|
||||
<Copy size={15} />
|
||||
{t('ai.panel.copyMcpConfig')}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1049,6 +1175,40 @@ function SettingsSwitchRow({
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsCheckboxRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
testId,
|
||||
}: {
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
testId: string
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-start gap-3" style={{ cursor: 'pointer' }} data-testid={testId}>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => onChange(isChecked(value))}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== ' ' && event.key !== 'Spacebar') return
|
||||
event.preventDefault()
|
||||
onChange(!checked)
|
||||
}}
|
||||
aria-label={label}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="space-y-1">
|
||||
<span className="block" style={{ fontSize: 13, fontWeight: 500, color: 'var(--foreground)' }}>{label}</span>
|
||||
<span className="block" style={{ fontSize: 11, color: 'var(--muted-foreground)' }}>{description}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function TelemetryToggle({
|
||||
label,
|
||||
description,
|
||||
|
||||
@@ -828,63 +828,6 @@ describe('Sidebar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename section via context menu', () => {
|
||||
it('shows Rename section option in context menu on right-click', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
expect(screen.getByText('Rename section…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Customize icon option in context menu on right-click', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
expect(screen.getByText('Customize icon & color…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows inline input when Rename section is clicked', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
fireEvent.click(screen.getByText('Rename section…'))
|
||||
expect(screen.getByRole('textbox', { name: 'Section name' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('inline input is pre-filled with current label', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
fireEvent.click(screen.getByText('Rename section…'))
|
||||
const input = screen.getByRole('textbox', { name: 'Section name' }) as HTMLInputElement
|
||||
expect(input.value).toBe('Projects')
|
||||
})
|
||||
|
||||
it('calls onRenameSection with new name on Enter', () => {
|
||||
const onRenameSection = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onRenameSection={onRenameSection} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
fireEvent.click(screen.getByText('Rename section…'))
|
||||
const input = screen.getByRole('textbox', { name: 'Section name' })
|
||||
fireEvent.change(input, { target: { value: 'My Projects' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onRenameSection).toHaveBeenCalledWith('Project', 'My Projects')
|
||||
})
|
||||
|
||||
it('cancels rename on Escape and hides input', () => {
|
||||
const onRenameSection = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onRenameSection={onRenameSection} />)
|
||||
const projectHeader = screen.getByText('Projects').closest('div')!
|
||||
fireEvent.contextMenu(projectHeader)
|
||||
fireEvent.click(screen.getByText('Rename section…'))
|
||||
const input = screen.getByRole('textbox', { name: 'Section name' })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(onRenameSection).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('textbox', { name: 'Section name' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Note type in sidebar', () => {
|
||||
const noteEntries: VaultEntry[] = [
|
||||
...mockEntries,
|
||||
@@ -1384,6 +1327,22 @@ describe('Sidebar', () => {
|
||||
},
|
||||
]
|
||||
|
||||
it('does not add visible reorder controls to saved view rows', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
entries={mockEntries}
|
||||
selection={defaultSelection}
|
||||
onSelect={() => {}}
|
||||
views={mockViews}
|
||||
onReorderViews={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.queryByTitle('Move view up')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Move view down')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Reorder view')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows note count chip for each view matching the filter results', () => {
|
||||
render(
|
||||
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} />
|
||||
@@ -1465,6 +1424,69 @@ describe('Sidebar', () => {
|
||||
expect(screen.queryByText('rocket')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses saved view color metadata for sidebar accents', () => {
|
||||
const coloredViews = [{
|
||||
filename: 'active-projects.yml',
|
||||
definition: {
|
||||
name: 'Active Projects',
|
||||
icon: 'rocket',
|
||||
color: 'blue',
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Project' }] },
|
||||
},
|
||||
}]
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
entries={mockEntries}
|
||||
selection={{ kind: 'view', filename: 'active-projects.yml' }}
|
||||
onSelect={() => {}}
|
||||
views={coloredViews}
|
||||
/>
|
||||
)
|
||||
|
||||
const navItem = screen.getByText('Active Projects').closest('[class*="cursor-pointer"]') as HTMLElement
|
||||
const icon = navItem.querySelector('svg')
|
||||
const countChip = within(navItem).getByTestId('view-count-chip')
|
||||
|
||||
expect(navItem).toHaveStyle({
|
||||
background: 'var(--accent-blue-light)',
|
||||
color: 'var(--accent-blue)',
|
||||
})
|
||||
expect(icon).toHaveStyle({ color: 'var(--accent-blue)' })
|
||||
expect(countChip).toHaveStyle({
|
||||
background: 'var(--accent-blue)',
|
||||
color: 'var(--text-inverse)',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the default active styling when a saved view color is invalid', () => {
|
||||
const invalidColorViews = [{
|
||||
filename: 'active-projects.yml',
|
||||
definition: {
|
||||
name: 'Active Projects',
|
||||
icon: 'rocket',
|
||||
color: 'not-a-token',
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Project' }] },
|
||||
},
|
||||
}]
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
entries={mockEntries}
|
||||
selection={{ kind: 'view', filename: 'active-projects.yml' }}
|
||||
onSelect={() => {}}
|
||||
views={invalidColorViews}
|
||||
/>
|
||||
)
|
||||
|
||||
const navItem = screen.getByText('Active Projects').closest('[class*="cursor-pointer"]') as HTMLElement
|
||||
|
||||
expect(navItem.className).toContain('text-primary')
|
||||
expect(navItem.getAttribute('style')).not.toContain('--accent-')
|
||||
})
|
||||
|
||||
it('does not show count chip for views with 0 matching notes', () => {
|
||||
const emptyView = [{
|
||||
filename: 'empty.yml',
|
||||
|
||||
@@ -21,9 +21,15 @@ import {
|
||||
TypesSection,
|
||||
ViewsSection,
|
||||
} from './sidebar/SidebarSections'
|
||||
import {
|
||||
SidebarCreatableLoadingSection,
|
||||
SidebarFavoritesLoadingSection,
|
||||
SidebarTypesLoadingSection,
|
||||
} from './sidebar/SidebarLoadingSections'
|
||||
import { useSidebarTypeInteractions } from './sidebar/useSidebarTypeInteractions'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
import type { FolderFileActions } from '../hooks/useFileActions'
|
||||
import type { AllNotesFileVisibility } from '../utils/allNotesFileVisibility'
|
||||
|
||||
interface SidebarProps {
|
||||
entries: VaultEntry[]
|
||||
@@ -36,6 +42,7 @@ interface SidebarProps {
|
||||
onUpdateTypeTemplate?: (typeName: string, template: string) => void
|
||||
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
|
||||
onRenameSection?: (typeName: string, label: string) => void
|
||||
onDeleteType?: (typeName: string) => void
|
||||
onToggleTypeVisibility?: (typeName: string) => void
|
||||
onSelectFavorite?: (entry: VaultEntry) => void
|
||||
onReorderFavorites?: (orderedPaths: string[]) => void
|
||||
@@ -43,6 +50,7 @@ interface SidebarProps {
|
||||
onCreateView?: () => void
|
||||
onEditView?: (filename: string) => void
|
||||
onDeleteView?: (filename: string) => void
|
||||
onReorderViews?: (orderedFilenames: string[]) => void
|
||||
folders?: FolderNode[]
|
||||
onCreateFolder?: (name: string) => Promise<boolean> | boolean
|
||||
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
|
||||
@@ -51,10 +59,17 @@ interface SidebarProps {
|
||||
renamingFolderPath?: string | null
|
||||
onStartRenameFolder?: (folderPath: string) => void
|
||||
onCancelRenameFolder?: () => void
|
||||
vaultRootPath?: string
|
||||
showInbox?: boolean
|
||||
inboxCount?: number
|
||||
allNotesFileVisibility?: AllNotesFileVisibility
|
||||
locale?: AppLocale
|
||||
onCollapse?: () => void
|
||||
loading?: boolean
|
||||
canGoBack?: boolean
|
||||
canGoForward?: boolean
|
||||
onGoBack?: () => void
|
||||
onGoForward?: () => void
|
||||
}
|
||||
|
||||
interface SidebarNavigationProps extends Pick<
|
||||
@@ -68,6 +83,7 @@ interface SidebarNavigationProps extends Pick<
|
||||
| 'onCreateView'
|
||||
| 'onEditView'
|
||||
| 'onDeleteView'
|
||||
| 'onReorderViews'
|
||||
| 'folders'
|
||||
| 'onCreateFolder'
|
||||
| 'onRenameFolder'
|
||||
@@ -76,10 +92,12 @@ interface SidebarNavigationProps extends Pick<
|
||||
| 'renamingFolderPath'
|
||||
| 'onStartRenameFolder'
|
||||
| 'onCancelRenameFolder'
|
||||
| 'vaultRootPath'
|
||||
| 'showInbox'
|
||||
| 'inboxCount'
|
||||
| 'onCreateNewType'
|
||||
| 'locale'
|
||||
| 'loading'
|
||||
> {
|
||||
activeCount: number
|
||||
archivedCount: number
|
||||
@@ -96,6 +114,251 @@ interface SidebarNavigationProps extends Pick<
|
||||
toggleVisibility: (type: string) => void
|
||||
}
|
||||
|
||||
type SidebarFavoritesNavigationProps = Pick<
|
||||
SidebarNavigationProps,
|
||||
| 'loading'
|
||||
| 'entries'
|
||||
| 'selection'
|
||||
| 'onSelect'
|
||||
| 'onSelectFavorite'
|
||||
| 'onReorderFavorites'
|
||||
| 'groupCollapsed'
|
||||
| 'toggleGroup'
|
||||
| 'locale'
|
||||
>
|
||||
|
||||
type SidebarViewsNavigationProps = Pick<
|
||||
SidebarNavigationProps,
|
||||
| 'loading'
|
||||
| 'views'
|
||||
| 'selection'
|
||||
| 'onSelect'
|
||||
| 'onCreateView'
|
||||
| 'onEditView'
|
||||
| 'onDeleteView'
|
||||
| 'onReorderViews'
|
||||
| 'groupCollapsed'
|
||||
| 'toggleGroup'
|
||||
| 'sensors'
|
||||
| 'entries'
|
||||
| 'locale'
|
||||
>
|
||||
|
||||
type SidebarTypesNavigationProps = Pick<
|
||||
SidebarNavigationProps,
|
||||
| 'loading'
|
||||
| 'visibleSections'
|
||||
| 'allSectionGroups'
|
||||
| 'sectionIds'
|
||||
| 'sensors'
|
||||
| 'handleDragEnd'
|
||||
| 'sectionProps'
|
||||
| 'groupCollapsed'
|
||||
| 'toggleGroup'
|
||||
| 'typeInteractions'
|
||||
| 'isSectionVisible'
|
||||
| 'toggleVisibility'
|
||||
| 'onCreateNewType'
|
||||
| 'locale'
|
||||
>
|
||||
|
||||
type SidebarFoldersNavigationProps = Pick<
|
||||
SidebarNavigationProps,
|
||||
| 'loading'
|
||||
| 'folders'
|
||||
| 'selection'
|
||||
| 'onSelect'
|
||||
| 'onCreateFolder'
|
||||
| 'onRenameFolder'
|
||||
| 'onDeleteFolder'
|
||||
| 'folderFileActions'
|
||||
| 'renamingFolderPath'
|
||||
| 'onStartRenameFolder'
|
||||
| 'onCancelRenameFolder'
|
||||
| 'vaultRootPath'
|
||||
| 'groupCollapsed'
|
||||
| 'toggleGroup'
|
||||
| 'locale'
|
||||
>
|
||||
|
||||
function SidebarFavoritesNavigation({
|
||||
loading,
|
||||
entries,
|
||||
selection,
|
||||
onSelect,
|
||||
onSelectFavorite,
|
||||
onReorderFavorites,
|
||||
groupCollapsed,
|
||||
toggleGroup,
|
||||
locale,
|
||||
}: SidebarFavoritesNavigationProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<SidebarFavoritesLoadingSection
|
||||
collapsed={groupCollapsed.favorites}
|
||||
locale={locale}
|
||||
onToggle={() => toggleGroup('favorites')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<FavoritesSection
|
||||
entries={entries}
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
onSelectNote={onSelectFavorite}
|
||||
onReorder={onReorderFavorites}
|
||||
collapsed={groupCollapsed.favorites}
|
||||
locale={locale}
|
||||
onToggle={() => toggleGroup('favorites')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarViewsNavigation({
|
||||
loading,
|
||||
views,
|
||||
selection,
|
||||
onSelect,
|
||||
onCreateView,
|
||||
onEditView,
|
||||
onDeleteView,
|
||||
onReorderViews,
|
||||
groupCollapsed,
|
||||
toggleGroup,
|
||||
sensors,
|
||||
entries,
|
||||
locale,
|
||||
}: SidebarViewsNavigationProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<SidebarCreatableLoadingSection
|
||||
collapsed={groupCollapsed.views}
|
||||
kind="views"
|
||||
locale={locale}
|
||||
onCreate={onCreateView}
|
||||
onToggle={() => toggleGroup('views')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ViewsSection
|
||||
views={views ?? []}
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
collapsed={groupCollapsed.views}
|
||||
onToggle={() => toggleGroup('views')}
|
||||
onCreateView={onCreateView}
|
||||
onEditView={onEditView}
|
||||
onDeleteView={onDeleteView}
|
||||
onReorderViews={onReorderViews}
|
||||
sensors={sensors}
|
||||
entries={entries}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTypesNavigation({
|
||||
loading,
|
||||
visibleSections,
|
||||
allSectionGroups,
|
||||
sectionIds,
|
||||
sensors,
|
||||
handleDragEnd,
|
||||
sectionProps,
|
||||
groupCollapsed,
|
||||
toggleGroup,
|
||||
typeInteractions,
|
||||
isSectionVisible,
|
||||
toggleVisibility,
|
||||
onCreateNewType,
|
||||
locale,
|
||||
}: SidebarTypesNavigationProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<SidebarTypesLoadingSection
|
||||
collapsed={groupCollapsed.sections}
|
||||
locale={locale}
|
||||
onCreateNewType={onCreateNewType}
|
||||
onToggle={() => toggleGroup('sections')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TypesSection
|
||||
visibleSections={visibleSections}
|
||||
allSectionGroups={allSectionGroups}
|
||||
sectionIds={sectionIds}
|
||||
sensors={sensors}
|
||||
handleDragEnd={handleDragEnd}
|
||||
sectionProps={sectionProps}
|
||||
collapsed={groupCollapsed.sections}
|
||||
onToggle={() => toggleGroup('sections')}
|
||||
showCustomize={typeInteractions.showCustomize}
|
||||
setShowCustomize={typeInteractions.setShowCustomize}
|
||||
isSectionVisible={isSectionVisible}
|
||||
toggleVisibility={toggleVisibility}
|
||||
onCreateNewType={onCreateNewType}
|
||||
customizeRef={typeInteractions.customizeRef}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFoldersNavigation({
|
||||
loading,
|
||||
folders,
|
||||
selection,
|
||||
onSelect,
|
||||
onCreateFolder,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
folderFileActions,
|
||||
renamingFolderPath,
|
||||
onStartRenameFolder,
|
||||
onCancelRenameFolder,
|
||||
vaultRootPath,
|
||||
groupCollapsed,
|
||||
toggleGroup,
|
||||
locale,
|
||||
}: SidebarFoldersNavigationProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<SidebarCreatableLoadingSection
|
||||
collapsed={groupCollapsed.folders}
|
||||
kind="folders"
|
||||
locale={locale}
|
||||
onToggle={() => toggleGroup('folders')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FolderTree
|
||||
folders={folders ?? []}
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
onCreateFolder={onCreateFolder}
|
||||
onRenameFolder={onRenameFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
folderFileActions={folderFileActions}
|
||||
renamingFolderPath={renamingFolderPath}
|
||||
onStartRenameFolder={onStartRenameFolder}
|
||||
onCancelRenameFolder={onCancelRenameFolder}
|
||||
collapsed={groupCollapsed.folders}
|
||||
locale={locale}
|
||||
onToggle={() => toggleGroup('folders')}
|
||||
vaultRootPath={vaultRootPath}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarNavigation({
|
||||
entries,
|
||||
selection,
|
||||
@@ -106,6 +369,7 @@ function SidebarNavigation({
|
||||
onCreateView,
|
||||
onEditView,
|
||||
onDeleteView,
|
||||
onReorderViews,
|
||||
folders = [],
|
||||
onCreateFolder,
|
||||
onRenameFolder,
|
||||
@@ -114,9 +378,11 @@ function SidebarNavigation({
|
||||
renamingFolderPath,
|
||||
onStartRenameFolder,
|
||||
onCancelRenameFolder,
|
||||
vaultRootPath,
|
||||
showInbox = true,
|
||||
inboxCount = 0,
|
||||
locale = 'en',
|
||||
loading = false,
|
||||
onCreateNewType,
|
||||
activeCount,
|
||||
archivedCount,
|
||||
@@ -132,8 +398,8 @@ function SidebarNavigation({
|
||||
isSectionVisible,
|
||||
toggleVisibility,
|
||||
}: SidebarNavigationProps) {
|
||||
const hasFavorites = entries.some((entry) => entry.favorite && !entry.archived)
|
||||
const hasViews = views.length > 0 || !!onCreateView
|
||||
const hasFavorites = loading || entries.some((entry) => entry.favorite && !entry.archived)
|
||||
const hasViews = loading || views.length > 0 || !!onCreateView
|
||||
|
||||
return (
|
||||
<nav className="flex-1 overflow-y-auto">
|
||||
@@ -145,53 +411,56 @@ function SidebarNavigation({
|
||||
activeCount={activeCount}
|
||||
archivedCount={archivedCount}
|
||||
locale={locale}
|
||||
loading={loading}
|
||||
/>
|
||||
{hasFavorites && (
|
||||
<div className="border-b border-border">
|
||||
<FavoritesSection
|
||||
entries={entries}
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
onSelectNote={onSelectFavorite}
|
||||
onReorder={onReorderFavorites}
|
||||
collapsed={groupCollapsed.favorites}
|
||||
locale={locale}
|
||||
onToggle={() => toggleGroup('favorites')}
|
||||
/>
|
||||
</div>
|
||||
<SidebarFavoritesNavigation
|
||||
loading={loading}
|
||||
entries={entries}
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
onSelectFavorite={onSelectFavorite}
|
||||
onReorderFavorites={onReorderFavorites}
|
||||
groupCollapsed={groupCollapsed}
|
||||
toggleGroup={toggleGroup}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
{hasViews && (
|
||||
<ViewsSection
|
||||
<SidebarViewsNavigation
|
||||
loading={loading}
|
||||
views={views}
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
collapsed={groupCollapsed.views}
|
||||
onToggle={() => toggleGroup('views')}
|
||||
onCreateView={onCreateView}
|
||||
onEditView={onEditView}
|
||||
onDeleteView={onDeleteView}
|
||||
onReorderViews={onReorderViews}
|
||||
groupCollapsed={groupCollapsed}
|
||||
toggleGroup={toggleGroup}
|
||||
sensors={sensors}
|
||||
entries={entries}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
<TypesSection
|
||||
<SidebarTypesNavigation
|
||||
loading={loading}
|
||||
visibleSections={visibleSections}
|
||||
allSectionGroups={allSectionGroups}
|
||||
sectionIds={sectionIds}
|
||||
sensors={sensors}
|
||||
handleDragEnd={handleDragEnd}
|
||||
sectionProps={sectionProps}
|
||||
collapsed={groupCollapsed.sections}
|
||||
onToggle={() => toggleGroup('sections')}
|
||||
showCustomize={typeInteractions.showCustomize}
|
||||
setShowCustomize={typeInteractions.setShowCustomize}
|
||||
groupCollapsed={groupCollapsed}
|
||||
toggleGroup={toggleGroup}
|
||||
typeInteractions={typeInteractions}
|
||||
isSectionVisible={isSectionVisible}
|
||||
toggleVisibility={toggleVisibility}
|
||||
onCreateNewType={onCreateNewType}
|
||||
customizeRef={typeInteractions.customizeRef}
|
||||
locale={locale}
|
||||
/>
|
||||
<FolderTree
|
||||
<SidebarFoldersNavigation
|
||||
loading={loading}
|
||||
folders={folders}
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
@@ -202,9 +471,10 @@ function SidebarNavigation({
|
||||
renamingFolderPath={renamingFolderPath}
|
||||
onStartRenameFolder={onStartRenameFolder}
|
||||
onCancelRenameFolder={onCancelRenameFolder}
|
||||
collapsed={groupCollapsed.folders}
|
||||
vaultRootPath={vaultRootPath}
|
||||
groupCollapsed={groupCollapsed}
|
||||
toggleGroup={toggleGroup}
|
||||
locale={locale}
|
||||
onToggle={() => toggleGroup('folders')}
|
||||
/>
|
||||
</nav>
|
||||
)
|
||||
@@ -217,37 +487,22 @@ function useSidebarDndSensors() {
|
||||
)
|
||||
}
|
||||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
function useSidebarRuntime({
|
||||
entries,
|
||||
selection,
|
||||
onSelect,
|
||||
onSelectNote,
|
||||
onCustomizeType,
|
||||
onUpdateTypeTemplate,
|
||||
onReorderSections,
|
||||
onRenameSection,
|
||||
onDeleteType,
|
||||
onToggleTypeVisibility,
|
||||
onSelectFavorite,
|
||||
onReorderFavorites,
|
||||
views = [],
|
||||
onCreateView,
|
||||
onEditView,
|
||||
onDeleteView,
|
||||
folders = [],
|
||||
onCreateFolder,
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
folderFileActions,
|
||||
renamingFolderPath,
|
||||
onStartRenameFolder,
|
||||
onCancelRenameFolder,
|
||||
showInbox = true,
|
||||
inboxCount = 0,
|
||||
allNotesFileVisibility,
|
||||
locale = 'en',
|
||||
onCollapse,
|
||||
onCreateNewType,
|
||||
}: SidebarProps) {
|
||||
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries)
|
||||
const { activeCount, archivedCount } = useEntryCounts(entries)
|
||||
const { activeCount, archivedCount } = useEntryCounts(entries, allNotesFileVisibility)
|
||||
const { collapsed: groupCollapsed, toggle: toggleGroup } = useSidebarCollapsed()
|
||||
const typeInteractions = useSidebarTypeInteractions({
|
||||
allSectionGroups,
|
||||
@@ -255,10 +510,15 @@ export const Sidebar = memo(function Sidebar({
|
||||
onCustomizeType,
|
||||
onUpdateTypeTemplate,
|
||||
onRenameSection,
|
||||
onDeleteType,
|
||||
})
|
||||
|
||||
const isSectionVisible = useCallback((type: string) => typeEntryMap[type]?.visible !== false, [typeEntryMap])
|
||||
const toggleVisibility = useCallback((type: string) => onToggleTypeVisibility?.(type), [onToggleTypeVisibility])
|
||||
const selectTypeNote = useCallback((type: string) => {
|
||||
const typeEntry = typeEntryMap[type] ?? typeEntryMap[type.toLowerCase()]
|
||||
if (typeEntry) onSelectNote?.(typeEntry)
|
||||
}, [onSelectNote, typeEntryMap])
|
||||
|
||||
const sensors = useSidebarDndSensors()
|
||||
|
||||
@@ -279,63 +539,125 @@ export const Sidebar = memo(function Sidebar({
|
||||
renameInitialValue: typeInteractions.renameInitialValue,
|
||||
onRenameSubmit: typeInteractions.handleRenameSubmit,
|
||||
onRenameCancel: typeInteractions.cancelRename,
|
||||
onStartRename: typeInteractions.handleStartRename,
|
||||
onSelectTypeNote: selectTypeNote,
|
||||
}
|
||||
|
||||
return {
|
||||
activeCount,
|
||||
allSectionGroups,
|
||||
archivedCount,
|
||||
groupCollapsed,
|
||||
handleDragEnd,
|
||||
isSectionVisible,
|
||||
sectionIds,
|
||||
sectionProps,
|
||||
sensors,
|
||||
toggleGroup,
|
||||
toggleVisibility,
|
||||
typeEntryMap,
|
||||
typeInteractions,
|
||||
visibleSections,
|
||||
}
|
||||
}
|
||||
|
||||
function SidebarRuntimeNavigation({
|
||||
props,
|
||||
runtime,
|
||||
}: {
|
||||
props: SidebarProps
|
||||
runtime: ReturnType<typeof useSidebarRuntime>
|
||||
}) {
|
||||
return (
|
||||
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
|
||||
<SidebarTitleBar locale={locale} onCollapse={onCollapse} />
|
||||
<SidebarNavigation
|
||||
entries={entries}
|
||||
selection={selection}
|
||||
onSelect={onSelect}
|
||||
onSelectFavorite={onSelectFavorite}
|
||||
onReorderFavorites={onReorderFavorites}
|
||||
views={views}
|
||||
onCreateView={onCreateView}
|
||||
onEditView={onEditView}
|
||||
onDeleteView={onDeleteView}
|
||||
folders={folders}
|
||||
onCreateFolder={onCreateFolder}
|
||||
onRenameFolder={onRenameFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
folderFileActions={folderFileActions}
|
||||
renamingFolderPath={renamingFolderPath}
|
||||
onStartRenameFolder={onStartRenameFolder}
|
||||
onCancelRenameFolder={onCancelRenameFolder}
|
||||
showInbox={showInbox}
|
||||
inboxCount={inboxCount}
|
||||
locale={locale}
|
||||
onCreateNewType={onCreateNewType}
|
||||
activeCount={activeCount}
|
||||
archivedCount={archivedCount}
|
||||
groupCollapsed={groupCollapsed}
|
||||
toggleGroup={toggleGroup}
|
||||
visibleSections={visibleSections}
|
||||
allSectionGroups={allSectionGroups}
|
||||
sectionIds={sectionIds}
|
||||
sensors={sensors}
|
||||
handleDragEnd={handleDragEnd}
|
||||
sectionProps={sectionProps}
|
||||
typeInteractions={typeInteractions}
|
||||
isSectionVisible={isSectionVisible}
|
||||
toggleVisibility={toggleVisibility}
|
||||
/>
|
||||
<SidebarNavigation
|
||||
entries={props.entries}
|
||||
selection={props.selection}
|
||||
onSelect={props.onSelect}
|
||||
onSelectFavorite={props.onSelectFavorite}
|
||||
onReorderFavorites={props.onReorderFavorites}
|
||||
views={props.views}
|
||||
onCreateView={props.onCreateView}
|
||||
onEditView={props.onEditView}
|
||||
onDeleteView={props.onDeleteView}
|
||||
onReorderViews={props.onReorderViews}
|
||||
folders={props.folders}
|
||||
onCreateFolder={props.onCreateFolder}
|
||||
onRenameFolder={props.onRenameFolder}
|
||||
onDeleteFolder={props.onDeleteFolder}
|
||||
folderFileActions={props.folderFileActions}
|
||||
renamingFolderPath={props.renamingFolderPath}
|
||||
onStartRenameFolder={props.onStartRenameFolder}
|
||||
onCancelRenameFolder={props.onCancelRenameFolder}
|
||||
vaultRootPath={props.vaultRootPath}
|
||||
showInbox={props.showInbox}
|
||||
inboxCount={props.inboxCount}
|
||||
locale={props.locale}
|
||||
loading={props.loading}
|
||||
onCreateNewType={props.onCreateNewType}
|
||||
activeCount={runtime.activeCount}
|
||||
archivedCount={runtime.archivedCount}
|
||||
groupCollapsed={runtime.groupCollapsed}
|
||||
toggleGroup={runtime.toggleGroup}
|
||||
visibleSections={runtime.visibleSections}
|
||||
allSectionGroups={runtime.allSectionGroups}
|
||||
sectionIds={runtime.sectionIds}
|
||||
sensors={runtime.sensors}
|
||||
handleDragEnd={runtime.handleDragEnd}
|
||||
sectionProps={runtime.sectionProps}
|
||||
typeInteractions={runtime.typeInteractions}
|
||||
isSectionVisible={runtime.isSectionVisible}
|
||||
toggleVisibility={runtime.toggleVisibility}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInteractionOverlays({
|
||||
locale,
|
||||
runtime,
|
||||
}: {
|
||||
locale: AppLocale
|
||||
runtime: ReturnType<typeof useSidebarRuntime>
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ContextMenuOverlay
|
||||
pos={typeInteractions.contextMenuPos}
|
||||
type={typeInteractions.contextMenuType}
|
||||
innerRef={typeInteractions.contextMenuRef}
|
||||
onOpenCustomize={typeInteractions.openCustomizeTarget}
|
||||
onStartRename={typeInteractions.handleStartRename}
|
||||
pos={runtime.typeInteractions.contextMenuPos}
|
||||
type={runtime.typeInteractions.contextMenuType}
|
||||
innerRef={runtime.typeInteractions.contextMenuRef}
|
||||
onOpenCustomize={runtime.typeInteractions.openCustomizeTarget}
|
||||
onStartRename={runtime.typeInteractions.handleStartRename}
|
||||
onDelete={runtime.typeInteractions.handleDeleteType}
|
||||
locale={locale}
|
||||
/>
|
||||
<CustomizeOverlay
|
||||
target={typeInteractions.customizeTarget}
|
||||
typeEntryMap={typeEntryMap}
|
||||
innerRef={typeInteractions.popoverRef}
|
||||
onCustomize={typeInteractions.handleCustomize}
|
||||
onChangeTemplate={typeInteractions.handleChangeTemplate}
|
||||
onClose={typeInteractions.closeCustomizeTarget}
|
||||
target={runtime.typeInteractions.customizeTarget}
|
||||
typeEntryMap={runtime.typeEntryMap}
|
||||
innerRef={runtime.typeInteractions.popoverRef}
|
||||
onCustomize={runtime.typeInteractions.handleCustomize}
|
||||
onChangeTemplate={runtime.typeInteractions.handleChangeTemplate}
|
||||
onClose={runtime.typeInteractions.closeCustomizeTarget}
|
||||
locale={locale}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Sidebar = memo(function Sidebar(props: SidebarProps) {
|
||||
const locale = props.locale ?? 'en'
|
||||
const runtime = useSidebarRuntime(props)
|
||||
|
||||
return (
|
||||
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
|
||||
<SidebarTitleBar
|
||||
locale={locale}
|
||||
onCollapse={props.onCollapse}
|
||||
canGoBack={props.canGoBack}
|
||||
canGoForward={props.canGoForward}
|
||||
onGoBack={props.onGoBack}
|
||||
onGoForward={props.onGoForward}
|
||||
/>
|
||||
<SidebarRuntimeNavigation props={props} runtime={runtime} />
|
||||
<SidebarInteractionOverlays locale={locale} runtime={runtime} />
|
||||
</aside>
|
||||
)
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user