Compare commits

...

49 Commits

Author SHA1 Message Date
lucaronin
ced89a42bd fix: render mermaid diagrams with lightbox 2026-04-28 10:32:37 +02:00
lucaronin
20a960551f fix: update fallback vault git email 2026-04-28 10:08:18 +02:00
lucaronin
2e331315e8 fix: refine Italian locale labels 2026-04-28 09:43:44 +02:00
lucaronin
d2c15b8469 feat: hide gitignored vault content 2026-04-28 09:07:21 +02:00
lucaronin
2fe6c5b580 fix: respect selected ai agent in right panel 2026-04-28 07:36:22 +02:00
lucaronin
2a224f78f8 refactor: simplify note mutation flushing 2026-04-28 06:25:39 +02:00
lucaronin
b85d54bb5d fix: preserve ai agent final result text 2026-04-28 06:17:33 +02:00
lucaronin
9c05e17d37 fix: sanitize custom view filenames 2026-04-28 05:39:41 +02:00
lucaronin
cf6ad71fff ci: de-duplicate coverage-backed test lane 2026-04-28 04:53:39 +02:00
lucaronin
db82aee172 refactor: consolidate ai agent session flow 2026-04-28 04:20:40 +02:00
lucaronin
c72d833624 feat: add pi ai agent support 2026-04-28 03:41:04 +02:00
lucaronin
a3ca78fced fix: guard git init parent folders 2026-04-28 02:38:32 +02:00
lucaronin
e912ab9a9f fix: use selected vault for mcp bridge 2026-04-28 02:26:14 +02:00
lucaronin
49717dc671 feat: add opencode ai agent option 2026-04-28 01:26:21 +02:00
lucaronin
d9a0a04ddb chore: ratchet codescene thresholds 2026-04-28 01:21:59 +02:00
lucaronin
0561fe68b1 fix: restore editor block handle reordering 2026-04-28 01:09:47 +02:00
lucaronin
2f078fab11 feat: watch active vault filesystem changes 2026-04-27 23:54:50 +02:00
lucaronin
98fde6571a feat: render mermaid note diagrams 2026-04-27 22:14:23 +02:00
lucaronin
a5c4b8fa20 fix: hide ai agent subprocess windows 2026-04-27 21:21:00 +02:00
lucaronin
1301653b81 fix: avoid tooltip dropdown popper nesting 2026-04-27 20:58:31 +02:00
lucaronin
ee90b05159 fix: guard inline input types 2026-04-27 20:36:59 +02:00
lucaronin
b6c11b1468 fix: close claude print-mode stdin 2026-04-27 19:25:53 +02:00
lucaronin
2fbdafa6f2 fix: set git identity before remote connect 2026-04-27 18:59:23 +02:00
lucaronin
39f44b86aa fix: copy fenced code blocks exactly 2026-04-27 18:31:51 +02:00
lucaronin
cb3274cdbc fix: harden windows mcp setup 2026-04-27 17:51:25 +02:00
lucaronin
76d8bba332 fix: remove hidden Claude permission bypass 2026-04-27 16:46:32 +02:00
lucaronin
86853cf337 fix: allow Codex to edit active vault 2026-04-27 16:07:05 +02:00
lucaronin
9a292d2476 fix: map releases to Sentry 2026-04-27 15:27:31 +02:00
lucaronin
4a22b02810 test: harden locale catalog validation 2026-04-27 14:48:49 +02:00
github-actions[bot]
e4270cda3b Merge branch 'main' into main 2026-04-27 12:45:03 +00:00
lucaronin
9cb0de37a5 fix: parse crlf frontmatter 2026-04-27 14:31:51 +02:00
github-actions[bot]
2a794cb373 Merge branch 'main' into main 2026-04-27 11:37:50 +00:00
lucaronin
324af6b271 fix: normalize folder filtering across path separators 2026-04-27 13:27:20 +02:00
Disheng Qiu
926db0eeb5 feat: add lara-powered app localization 2026-04-27 13:06:59 +02:00
lucaronin
b1a97834c9 fix: keep raw editor full width 2026-04-27 11:54:15 +02:00
lucaronin
da13cb38ac fix: satisfy editor find command contract 2026-04-27 10:57:58 +02:00
lucaronin
dbf8ba5f40 feat: add editor find and replace 2026-04-27 10:57:58 +02:00
lucaronin
3e8a6e5d7c fix: render inline math while typing 2026-04-27 10:44:35 +02:00
lucaronin
8141644729 fix: revert note width modes 2026-04-27 10:13:55 +02:00
lucaronin
0d88c75718 Merge branches 'main', 'main', 'main' and 'main' of https://github.com/refactoringhq/tolaria 2026-04-27 09:42:36 +02:00
lucaronin
f8721f2a1b feat: add note width modes 2026-04-27 05:34:11 +02:00
lucaronin
fb39c6679a fix: handle invalid Windows save paths 2026-04-27 04:04:04 +02:00
lucaronin
f1bed131bf fix: refresh type state after type changes 2026-04-27 03:25:24 +02:00
lucaronin
257cf6ed02 fix: keep theme tooltip inside window 2026-04-27 02:20:28 +02:00
lucaronin
908daafaa1 feat: add basic file actions 2026-04-27 01:45:16 +02:00
lucaronin
d15437face test: cover consecutive IME composer input 2026-04-27 01:05:34 +02:00
lucaronin
30281f879d chore: ratchet codescene thresholds 2026-04-27 00:49:39 +02:00
lucaronin
4ef6edfb10 feat: preview vault images in app 2026-04-27 00:36:24 +02:00
lucaronin
2704002973 fix: remove note drag and drop flows 2026-04-26 22:51:08 +02:00
258 changed files with 18673 additions and 3519 deletions

View File

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

View File

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

View File

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

View File

@@ -229,6 +229,7 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
@@ -320,6 +321,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
@@ -431,6 +433,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}

View File

@@ -288,6 +288,7 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
@@ -374,6 +375,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
@@ -485,6 +487,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}

1
.gitignore vendored
View File

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

View File

@@ -68,6 +68,8 @@ Git is a per-vault capability, not a prerequisite for the document model. A vaul
Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. Features that depend on Git must check this capability instead of assuming every vault has `.git`.
Git initialization is intentionally scoped to dedicated vault folders. When the current non-git folder looks like a broad personal root such as Documents, Desktop, or Downloads and does not already carry Tolaria-managed vault markers, `init_git_repo` refuses to run Git and asks the user to select or create a dedicated subfolder instead.
### VaultEntry
The core data type representing a single note, defined in Rust (`src-tauri/src/vault/mod.rs`) and TypeScript (`src/types.ts`).
@@ -143,9 +145,22 @@ interface VaultEntry {
trashed: boolean // Kept for backward compatibility (Trash system removed — delete is permanent)
trashedAt: number | null // Kept for backward compatibility (Trash system removed)
properties: Record<string, string> // Scalar frontmatter fields (custom properties)
fileKind?: 'markdown' | 'text' | 'binary' // Controls editor/raw/preview behavior
}
```
### File kinds and binary previews
`VaultEntry.fileKind` comes from the Rust vault scanner and intentionally stays coarse-grained:
| `fileKind` | Source files | UI behavior |
|---|---|---|
| `markdown` or absent | `.md`, `.markdown` | Full Tolaria note model: frontmatter, BlockNote, raw editor, relationships, title sync |
| `text` | UTF-8 editable formats such as `.yml`, `.json`, `.ts`, `.py`, `.sh` | Opens through the raw editor without Markdown note semantics |
| `binary` | Images, PDFs, archives, other non-text files | Stays a normal vault file; previewable images 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.
### Entity Types (isA / type)
Entity type is stored in the `type:` frontmatter field (e.g. `type: Quarter`). The legacy field name `Is A:` is still accepted as an alias for backwards compatibility but new notes use `type:`. The `VaultEntry.isA` property in TypeScript/Rust holds the resolved value.
@@ -253,6 +268,7 @@ Tolaria separates **display title** from the file identifier:
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions stage crash-safe note renames through a hidden `.tolaria-rename-txn/` transaction directory, recover unfinished renames on the next vault scan, update wikilinks across the vault, and surface any failed backlink rewrites instead of silently reporting partial success. The editor body remains the title editing surface.
- **Unicode-aware note stems** (`src/utils/noteSlug.ts`, `vault/rename.rs`): frontend and backend slugging preserve Unicode letters/digits in note filenames, untitled-rename detection, and fallback wikilink targets while still collapsing symbol-only titles to `untitled`.
- **Portable filename validation** (`vault/filename_rules.rs`): note filenames, folder names, and custom view filenames all reject Windows-reserved device names, invalid characters, and trailing dot/space suffixes so a vault created on macOS/Linux still clones and syncs cleanly on Windows.
- **Recoverable save failures** (`useEditorSave`, `vault/file.rs`): invalid platform path syntax is reported as a clear retryable save error, while the editor keeps the unsaved buffer intact for another attempt.
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
### Title Surface (UI)
@@ -281,7 +297,7 @@ type SidebarSelection =
`SidebarSelection.kind === 'folder'` is a first-class navigation target, not just a visual highlight.
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated.
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry.
- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands.
- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected.
@@ -307,25 +323,30 @@ type SidebarSelection =
`vault::scan_vault(path)` in `src-tauri/src/vault/mod.rs`:
1. Validates the path exists and is a directory
2. Scans root-level `.md` files (non-recursive)
3. Recursively scans protected folders: `type/`, legacy `config/`, `attachments/`
4. Files in non-protected subfolders are **not indexed** (flat vault enforcement)
5. For each `.md` file, calls `parse_md_file()`:
2. Recursively scans non-hidden files while skipping hidden directories such as `.git/`
3. For each `.md` file, calls `parse_md_file()`:
- Reads content with `fs::read_to_string()`
- Parses frontmatter with `gray_matter::Matter::<YAML>`
- Extracts title from first `#` heading
- Reads entity type from `type:` frontmatter field (`Is A:` accepted as legacy alias); type is never inferred from folder
- Parses dates as ISO 8601 to Unix timestamps
- Extracts relationships, outgoing links, custom properties, word count, snippet
4. For recognized non-markdown text and binary files, emits a minimal `VaultEntry` with `fileKind`
5. Sorts by `modified_at` descending
6. Skips unparseable files with a warning log
The folder tree hides only the dedicated `type/` directory, since note types already have their own sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders.
6. Sorts by `modified_at` descending
7. Skips unparseable files with a warning log
Command-facing vault content is filtered through `vault::filter_gitignored_entries`, `vault::filter_gitignored_folders`, and `vault::filter_gitignored_paths` when the app setting `hide_gitignored_files` is enabled. The cache still stores the complete scan; `list_vault`, `reload_vault`, `list_vault_folders`, and search apply the visibility filter at the boundary before React consumes entries. The filter batches paths through `git check-ignore --no-index --stdin`, so negated and specific `.gitignore` patterns follow Git semantics as closely as the app can reasonably support.
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands refresh the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder and external-open calls route through the Tauri opener plugin, while copy-path uses the browser clipboard API; none of those actions mutate vault contents or bypass the backend write boundary.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`.
### Vault Caching
`vault::scan_vault_cached(path)` wraps scanning with git-based caching:
@@ -428,6 +449,10 @@ interface PulseCommit {
- `pullAndPush()`: pulls then auto-pushes for divergence recovery
- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs)
### External Vault Refresh
External vault mutations are any disk writes Tolaria did not just perform through its own save path: Git pulls, AI-agent writes, filesystem watcher events, and edits from another app. These changes must route through `refreshPulledVaultState()` rather than calling `reloadVault()` in isolation. The shared refresh abstraction reloads entries, folders, and saved views together, preserves unsaved active-editor content, reopens a clean active note from disk, and closes the active tab if the file disappeared. `useVaultWatcher` supplies changed filesystem paths to this abstraction after debouncing and after filtering recent app-owned saves.
`useGitRemoteStatus` is the commit-time companion to `useAutoSync`:
- Re-checks `git_remote_status` when the Commit dialog opens and right before submit
- Converts `hasRemote: false` into a local-only commit path
@@ -493,6 +518,15 @@ Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and s
- `serializeMathAwareBlocks()` converts math nodes back to Markdown delimiters before save, raw-mode entry, and editor-position snapshots.
- Raw CodeMirror mode always shows the plain Markdown source, so imported technical notes stay editable outside Tolaria.
### Mermaid Diagrams
Defined in `src/utils/mermaidMarkdown.ts`, `src/components/MermaidDiagram.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
- Fenced `mermaid` blocks become `mermaidBlock` schema nodes before BlockNote sees the Markdown body.
- Each `mermaidBlock` stores the original fenced Markdown plus the diagram body, so raw-mode entry and saves can restore the canonical source instead of serializing generated SVG.
- The rich editor renders diagrams with the `mermaid` package and uses the original source as an inline fallback when rendering fails.
- `serializeMermaidAwareBlocks()` wraps the math-aware serializer so math, wikilinks, and diagrams share the same Markdown-first save path.
### Formatting Surface Policy
Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tolariaEditorFormattingConfig.ts`:
@@ -511,24 +545,25 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
```mermaid
flowchart LR
A["📄 Raw markdown\n(from disk)"] --> B["splitFrontmatter()\n→ yaml + body"]
B --> C["preProcessWikilinks(body)\n[[target]]token"]
C --> D["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
D --> E["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
E --> F["injectWikilinks + injectMathInBlocks\n tokens → schema nodes"]
F --> G["editor.replaceBlocks()\n→ rendered editor"]
B --> C["preProcessMermaidMarkdown(body)\nmermaid fence → token"]
C --> D["preProcessWikilinks(body)\n[[target]]token"]
D --> E["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
E --> F["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
F --> G["injectWikilinks + injectMathInBlocks + injectMermaidInBlocks\n tokens → schema nodes"]
G --> H["editor.replaceBlocks()\n→ rendered editor"]
style A fill:#f8f9fa,stroke:#6c757d,color:#000
style G fill:#d4edda,stroke:#28a745,color:#000
style H fill:#d4edda,stroke:#28a745,color:#000
```
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math placeholder tokens use ASCII sentinels with URI-encoded LaTeX payloads.
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math and Mermaid placeholder tokens use ASCII sentinels with URI-encoded payloads.
### BlockNote-to-Markdown Pipeline (Save)
```mermaid
flowchart LR
A["✏️ BlockNote blocks\n(editor state)"] --> B["blocksToMarkdownLossy()"]
B --> C["restoreWikilinks + serializeMathAwareBlocks()\nschema nodes → Markdown source"]
B --> C["restoreWikilinks + serializeMermaidAwareBlocks()\nschema nodes → Markdown source"]
C --> D["prepend frontmatter yaml"]
D --> E["invoke('save_note_content')\n→ disk write"]
@@ -550,6 +585,8 @@ Wikilink resolution (`resolveEntry` in `src/utils/wikilink.ts`) uses multi-pass
Toggle via Cmd+K → "Raw Editor" or breadcrumb bar button. Uses CodeMirror 6 (`useCodeMirror` hook) to edit the raw markdown + frontmatter directly. Changes saved via the same `save_note_content` command.
While the user types, `useEditorSaveWithLinks` derives a transient `VaultEntry` patch from parseable frontmatter so the Inspector, relationship chips, and note-list-visible metadata stay in sync with the raw editor before the next vault reload. Temporarily invalid or half-typed frontmatter is ignored until it becomes parseable again, which avoids clobbering the last known good derived state.
Current-note find/replace is intentionally backed by raw CodeMirror mode. `Cmd+F`, "Find in Note", and "Replace in Note" switch the active Markdown/text note to raw mode, show the compact find bar above CodeMirror, and operate on the current note only. Plain text matching is case-insensitive by default, `Aa` toggles case sensitivity, `.*` toggles JavaScript-regex matching, and regex replacement supports capture groups through JavaScript replacement syntax.
### Arrow Ligature Normalization
Typed ASCII arrow sequences are normalized consistently in both editor modes:
@@ -568,12 +605,13 @@ The app uses internal light and dark themes owned by Tolaria (see [ADR-0081](adr
## Localization
App UI strings are centralized in `src/lib/i18n.ts` (see [ADR-0084](adr/0084-app-localization-foundation.md)):
App UI strings are resolved through `src/lib/i18n.ts`, with flat JSON catalogs in `src/lib/locales/*.json` (see [ADR-0087](adr/0087-json-catalogs-and-lara-cli-localization.md)):
- `AppLocale`: currently `'en' | 'zh-Hans'`
- `AppLocale`: canonical locale tags such as `'en'`, `'zh-CN'`, `'fr-FR'`, `'es-419'`
- `UiLanguagePreference`: `'system' | AppLocale`; persisted settings serialize `system` as `null`
- `resolveEffectiveLocale()`: maps an explicit preference or system/browser language list to the effective supported locale
- `resolveEffectiveLocale()`: maps an explicit preference or system/browser language list to the effective supported locale, including legacy aliases
- `translate()` / `createTranslator()`: resolve keys with English fallback and simple `{name}` interpolation
- `scripts/validate-locales.mjs`: asserts every checked-in locale catalog matches the English keyset and stays flat-string-only
`App.tsx` owns the effective locale and passes it to localized app chrome through props. Settings and command-palette language commands call back into `saveSettings`, so UI language changes update the current session without touching vault content or reopening the vault.
@@ -597,7 +635,7 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
### Search
Keyword-based search scans all vault `.md` files using `walkdir`:
Keyword-based search scans all vault `.md` files using `walkdir` and applies the same Gitignored-content visibility filter as vault loading:
```typescript
interface SearchResult {
@@ -687,12 +725,13 @@ interface Settings {
anonymous_id: string | null
release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed
theme_mode: 'light' | 'dark' | null
ui_language: 'en' | 'zh-Hans' | null
default_ai_agent: 'claude_code' | 'codex' | null
ui_language: AppLocale | null
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | null
hide_gitignored_files: boolean | null // null = default true
}
```
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
## Telemetry
@@ -704,7 +743,7 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
- **`useTelemetry(settings, loaded)`** — Reactively initializes/tears down Sentry and PostHog based on settings. Called once in `App`.
### Libraries
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/key from `VITE_SENTRY_DSN` / `VITE_POSTHOG_KEY` env vars.
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/release/key from `VITE_SENTRY_DSN`, `VITE_SENTRY_RELEASE`, and `VITE_POSTHOG_KEY` env vars.
- **`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.
@@ -732,6 +771,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.
- **`.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.
- **`.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`.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -24,6 +24,7 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
| Pinned properties per type | API keys (OpenAI, Google) |
| Sidebar label overrides | Auto-sync interval |
| Property display order | Window size / position |
| Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files |
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.tolaria.app/settings.json` or localStorage.
@@ -32,7 +33,7 @@ Examples:
- ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties)
- ✅ Vault: `_icon: shapes` in a Type note (icon is part of the type's identity)
- ✅ App settings: `zoom: 1.3` (machine-specific preference)
- ✅ App settings: `ui_language: "zh-Hans"` (installation-specific UI language)
- ✅ App settings: `ui_language: "zh-CN"` (installation-specific UI language)
### No hardcoded exceptions
@@ -82,6 +83,11 @@ flowchart LR
3. **No orphan state updates**: Never call `updateEntry()` before the corresponding `handleUpdateFrontmatter()` or `handleDeleteProperty()` has resolved. The three functions in `useEntryActions` (`handleCustomizeType`, `handleRenameSection`, `handleToggleTypeVisibility`) follow this rule — disk write first, then state update.
4. **Recovery via reload**: If state ever diverges from disk (crash, external edit, race condition), `Reload Vault` (Cmd+K → "Reload Vault") invalidates the cache and does a full filesystem rescan via the `reload_vault` Tauri command, replacing all React state. The `reload_vault_entry` command can re-read a single file.
5. **Cache is disposable**: The `reload_vault` command deletes the cache file before rescanning, guaranteeing fresh data. The cache never contains data that doesn't exist on the filesystem.
6. **Visibility filters are command-boundary concerns**: Gitignored-content visibility is applied after scanning/caching, before entries, folders, or search results reach React. The cache remains complete so toggling the setting can show ignored content again without rebuilding a different cache shape.
#### External Change Detection
The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and the clean active editor all refresh under the ADR-0071 unsaved-edit rules. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads.
## Tech Stack
@@ -91,6 +97,7 @@ flowchart LR
| Frontend | React + TypeScript | React 19, TS 5.9 |
| Editor | BlockNote | 0.46.2 |
| Code block highlighting | @blocknote/code-block | 0.46.2 |
| Diagram rendering | Mermaid | 11.14.0 |
| Raw editor | CodeMirror 6 | - |
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
| UI primitives | Radix UI + shadcn/ui | - |
@@ -98,9 +105,10 @@ flowchart LR
| Build | Vite | 7.3.1 |
| Backend language | Rust (edition 2021) | 1.77.2 |
| Frontmatter parsing | gray_matter | 0.2 |
| AI (agent panel) | CLI agent adapters (Claude Code + Codex) | - |
| Filesystem watcher | notify | 6.1 |
| AI (agent panel) | CLI agent adapters (Claude Code + Codex + OpenCode + Pi) | - |
| Search | Keyword (walkdir-based file scan) | - |
| Localization | App-owned dictionary (`src/lib/i18n.ts`) | English fallback + `zh-Hans` |
| Localization | App-owned runtime + JSON catalogs (`src/lib/i18n.ts`, `src/lib/locales/*.json`, `lara.yaml`) | English fallback + Lara CLI sync |
| MCP | @modelcontextprotocol/sdk | 1.0 |
| Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - |
| Package manager | pnpm | - |
@@ -137,7 +145,7 @@ flowchart TD
end
subgraph EXT["External Services"]
CCLI["Claude CLI / Codex CLI\n(agent subprocesses)"]
CCLI["Claude / Codex / OpenCode / Pi CLI\n(agent subprocesses)"]
MCP["MCP Server\n(ws://9710, 9711)"]
GCLI["git CLI\n(system executable)"]
REMOTE["Git remotes\n(GitHub/GitLab/Gitea/etc.)"]
@@ -178,9 +186,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, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), 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.
- **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.
@@ -216,10 +224,10 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration.
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgents.ts`) — streaming UI with reasoning blocks, tool action cards, response display, onboarding, and default-agent selection
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`; Codex runs through `codex exec --json` with the CLI's normal approval / sandbox defaults
4. **MCP Integration** — Claude receives the generated MCP config file path, while Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides
3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, a file/search-only built-in tool list, hidden Windows subprocess launches, and closed stdin for print-mode subprocesses so Windows launches receive EOF; Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json`; OpenCode runs through `opencode run --format json`; Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`. OpenCode and Pi both launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
4. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, and Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs.
@@ -236,11 +244,11 @@ sequenceDiagram
U->>FE: sendMessage(text, references)
FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs)
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath})
R->>R: pick adapter for claude_code or codex
R->>R: pick adapter for claude_code, codex, opencode, or pi
R->>C: spawn agent with MCP-enabled config
loop Normalized stream
C-->>R: Claude NDJSON or Codex JSONL events
C-->>R: Claude NDJSON, Codex JSONL, OpenCode JSON, or Pi JSON events
R-->>FE: emit("ai-agent-stream", event)
alt TextDelta
FE->>FE: accumulate response (revealed on Done)
@@ -262,7 +270,7 @@ sequenceDiagram
#### File Operation Detection
When the agent writes or edits vault files, `useCliAiAgent` detects this from normalized tool inputs and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload.
When the agent writes or edits vault files, `aiAgentFileOperations.ts` detects this from normalized tool inputs and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload. Unrecognized write-like operations fall back to a full vault refresh.
### Context Building
@@ -282,7 +290,7 @@ Token budget: 60% of 180k context limit (~108k tokens max). Active note gets pri
### Authentication
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed. Tolaria does not store model-provider API keys in app settings.
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed; Pi surfaces a friendly prompt to run `pi /login` or configure a provider API key when needed. Tolaria does not store model-provider API keys in app settings.
## MCP Server
@@ -321,13 +329,13 @@ Tolaria can register itself as an MCP server in:
- `~/.cursor/mcp.json` (Cursor)
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`).
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), 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`.
### Architecture
```mermaid
flowchart TD
subgraph MCP["MCP Server (Node.js) — spawned by Tauri on startup"]
subgraph MCP["MCP Server (Node.js) — selected-vault scoped"]
IDX["index.js"]
VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"]
WSB["ws-bridge.js"]
@@ -339,7 +347,7 @@ flowchart TD
WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"]
end
TAURI["Tauri (mcp.rs)"] -->|"spawn on startup"| MCP
TAURI["Tauri bridge lifecycle"] -->|"start/stop/restart with active VAULT_PATH"| MCP
UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude.json\n~/.claude/mcp.json\n~/.cursor/mcp.json\n~/.config/mcp/mcp.json"]
```
@@ -367,11 +375,12 @@ flowchart LR
| Function | Purpose |
|----------|---------|
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code, Cursor, and generic MCP configs on explicit user request |
| `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 |
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed on app exit via the `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
## Search
@@ -420,7 +429,7 @@ The app uses internal app-owned light and dark themes (see [ADR-0081](adr/0081-i
## Localization
Tolaria's app chrome uses an app-owned localization layer in `src/lib/i18n.ts` (see [ADR-0084](adr/0084-app-localization-foundation.md)). English is the canonical fallback, and Simplified Chinese (`zh-Hans`) is the first additional locale. The installation-local `ui_language` setting stores an explicit locale when the user chooses one; `null` means "follow the system language when Tolaria supports it, otherwise English." Missing translation keys fall back to English so partially translated locales do not render broken placeholders.
Tolaria's app chrome uses an app-owned localization runtime in `src/lib/i18n.ts`, backed by flat JSON catalogs in `src/lib/locales/` and Lara CLI synchronization through `lara.yaml` (see [ADR-0087](adr/0087-json-catalogs-and-lara-cli-localization.md)). `en.json` is the canonical source catalog, locale files are one file per locale, and English remains the fallback for any missing locale file or key. The installation-local `ui_language` setting stores an explicit locale when the user chooses one; `null` means "follow the system language when Tolaria supports it, otherwise English." Legacy stored values such as `zh-Hans` are normalized to canonical locale codes like `zh-CN`.
`App.tsx` derives the effective locale from settings and browser/system language hints, then passes it down to localized surfaces. Settings exposes a keyboard-accessible shadcn `Select`, and the command palette includes actions to open language settings or switch directly to a supported language.
@@ -461,9 +470,9 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action.
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. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
When 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 and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
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.
`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.
@@ -587,7 +596,7 @@ flowchart TD
If the current vault is not a Git repository, Tolaria treats Git as disabled instead of degraded. The status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup. Command registration follows the same state: only `Initialize Git for Current Vault` is available in the Git group, while pull, commit, changes, conflict, and remote commands are hidden. `useAutoSync` is disabled for non-git vaults so the app does not run background Git commands against plain folders.
The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds.
The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command ensures the local author identity, adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds.
`useCommitFlow` also exposes `runAutomaticCheckpoint()`, a dialog-free commit path shared by AutoGit and the bottom-bar Commit button. `useAutoGit` watches the last editor activity plus app focus/visibility state, and when the vault is git-backed, all saves are flushed, and no unsaved edits remain, it triggers the same deterministic `Updated N note(s)` / `Updated N file(s)` commit message path after the configured idle or inactive thresholds. The bottom-bar quick action reuses that checkpoint flow after forcing a save first, so manual quick commits and scheduled AutoGit commits stay aligned on message generation and push behavior.
@@ -611,6 +620,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` |
| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow |
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
| `ignored.rs` | Gitignored-content visibility filtering via batched `git check-ignore` |
| `filename_rules.rs` | Cross-platform validation for note filenames, folder names, and custom view filenames |
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames |
@@ -625,9 +635,10 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `vault/` | Vault scanning, caching, parsing, rename, image, migration |
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) |
| `search.rs` | Keyword search — walkdir-based vault file scan |
| `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 |
| `pi_cli.rs`, `pi_config.rs`, `pi_discovery.rs`, `pi_events.rs` | Pi subprocess launch, transient MCP adapter config, discovery, and JSON stream parsing |
| `mcp.rs` | MCP server spawning + explicit config registration/removal |
| `commands/` | Tauri command handlers (split into submodules) |
| `settings.rs` | App settings persistence |
@@ -641,7 +652,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| Command | Description |
|---------|-------------|
| `list_vault` | Scan vault (cached) → `Vec<VaultEntry>` |
| `list_vault` | Scan vault (cached), then apply Gitignored-content visibility`Vec<VaultEntry>` |
| `get_note_content` | Read note file content |
| `save_note_content` | Write note content to disk |
| `delete_note` | Permanently delete note from disk (with confirm dialog) |
@@ -653,8 +664,9 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `sync_note_title` | Legacy helper: rewrite `title` frontmatter from filename → `bool` (modified); not used by the normal note-open flow |
| `batch_archive_notes` | Archive multiple notes |
| `batch_delete_notes` | Permanently delete notes from disk |
| `reload_vault` | Sync the active vault asset scope, invalidate cache, and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault` | Sync the active vault asset scope, invalidate cache, full rescan from filesystem, then apply Gitignored-content visibility`Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `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 |
@@ -709,13 +721,13 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| Command | Description |
|---------|-------------|
| `stream_claude_chat` | Claude CLI chat mode (streaming) |
| `stream_claude_agent` | Claude CLI agent mode (streaming + tools) |
| `check_claude_cli` | Check if Claude CLI is available |
| `get_ai_agents_status` | Check Claude Code + Codex availability |
| `stream_ai_agent` | Stream the selected CLI agent through the normalized event layer |
| `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 |
| `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.
@@ -773,13 +785,13 @@ 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 |
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline |
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
| `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints |
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings |
| `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 |
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
@@ -793,20 +805,23 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
| Cmd+P / Cmd+O | Open quick open palette |
| Cmd+N | Create new note |
| Cmd+S | Save current note |
| Cmd+F | Find in current note when the editor is focused; otherwise note-list search can claim it |
| Cmd+Shift+F | Find in vault |
| Cmd+[ / Cmd+] | Navigate back / forward (replaces tabs) |
| Cmd+Z / Cmd+Shift+Z | Undo / Redo |
| Cmd+19 | Switch to tab N |
| Cmd+[ / Cmd+] | Navigate back / forward |
| `[[` in editor | Open wikilink suggestion menu |
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Rename Folder" and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Shortcut routing is explicit:
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata
- `formatShortcutDisplay()` derives platform-accurate visible shortcut labels (`⌘` on macOS, `Ctrl` on Windows/Linux) from that same manifest so menus, tooltips, and command-palette copy stay aligned with real accelerators
- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs
- macOS browser-reserved chords such as `Cmd+O` and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same command IDs for native menu clicks, accelerators, and custom titlebar menu actions
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
- Deterministic QA uses two explicit proof paths from the shared manifest:
@@ -905,7 +920,7 @@ sequenceDiagram
App->>User: TelemetryConsentDialog
alt Accept
User->>Settings: telemetry_consent=true, anonymous_id=UUID
Settings->>Sentry: init(DSN, anonymous_id)
Settings->>Sentry: init(DSN, release, anonymous_id)
Settings->>PostHog: init(key, anonymous_id)
else Decline
User->>Settings: telemetry_consent=false
@@ -927,6 +942,7 @@ sequenceDiagram
**Architecture:**
- **Rust:** `sentry` crate initialized in `lib.rs::setup()` via `telemetry::init_sentry_from_settings()`
- **JS:** `@sentry/react` + `posthog-js` initialized lazily by `useTelemetry` hook; the React root also wires `onCaughtError`, `onUncaughtError`, and `onRecoverableError` through `Sentry.reactErrorHandler()` so production React invariants include component stack context when crash reporting is enabled.
- **Release grouping:** packaged release workflows pass `VITE_SENTRY_RELEASE` from the computed build version so frontend Sentry events group by the shipped alpha or stable version.
- **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct
- **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null`
@@ -981,7 +997,7 @@ Desktop-only modules gated at the crate level:
Desktop-only features gated at the function level in `commands/`:
- Git operations (commit, pull, push, status, history, diff, conflicts)
- Clone-by-URL via system git (`clone_repo`)
- CLI AI agent streaming (Claude, Codex)
- CLI AI agent streaming (Claude, Codex, OpenCode, Pi)
- MCP registration and status
- Menu state updates

View File

@@ -106,16 +106,15 @@ tolaria/
│ │ └── ui/ # shadcn/ui primitives
│ │ ├── button.tsx, dialog.tsx, input.tsx, ...
│ │
│ ├── hooks/ # Custom React hooks (~87 files)
│ ├── hooks/ # Custom React hooks (~86 files)
│ │ ├── useVaultLoader.ts # Loads vault entries + content
│ │ ├── useVaultSwitcher.ts # Multi-vault management
│ │ ├── useVaultConfig.ts # Per-vault UI settings
│ │ ├── useNoteActions.ts # Composes creation + rename + frontmatter
│ │ ├── useNoteCreation.ts # Note/type creation
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
│ │ ├── useAiAgent.ts # Legacy Claude-specific stream helpers reused by the shared agent hook
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized tool tracking
│ │ ├── useAiAgentsStatus.ts # Claude/Codex availability polling
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi availability polling
│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling
│ │ ├── useAiActivity.ts # MCP UI bridge listener
│ │ ├── useAutoSync.ts # Auto git pull/push
@@ -157,7 +156,8 @@ tolaria/
│ ├── lib/
│ │ ├── aiAgents.ts # Shared agent registry + status helpers
│ │ ├── appUpdater.ts # Frontend wrapper around channel-aware updater commands
│ │ ├── i18n.ts # App-owned localization dictionary and locale resolution
│ │ ├── i18n.ts # App-owned localization runtime and locale resolution
│ │ ├── locales/ # JSON locale catalogs (English source + translated locales)
│ │ ├── releaseChannel.ts # Alpha/stable normalization helpers
│ │ └── utils.ts # Tailwind merge + cn() helper
│ │
@@ -190,6 +190,7 @@ tolaria/
│ │ ├── 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
│ │ ├── mcp.rs # MCP server lifecycle + explicit config registration/removal
│ │ ├── app_updater.rs # Alpha/stable updater endpoint selection
│ │ ├── settings.rs # App settings persistence
@@ -212,6 +213,7 @@ tolaria/
├── scripts/ # Build/utility scripts
├── package.json # Frontend dependencies + scripts
├── lara.yaml # Lara CLI locale sync configuration
├── vite.config.ts # Vite bundler config
├── tsconfig.json # TypeScript config
├── playwright.config.ts # Full Playwright regression config
@@ -259,8 +261,9 @@ tolaria/
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse, add-remote). |
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, safe-default Codex adapter, and stream normalization. |
| `src-tauri/src/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/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. |
### Editor
@@ -280,7 +283,9 @@ tolaria/
| File | Why it matters |
|------|---------------|
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, and actions. |
| `src/hooks/useCliAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. |
| `src/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/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. |
@@ -340,6 +345,8 @@ type SidebarSelection =
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
Current-note find/replace is a surface-aware command: editor focus enables "Find in Note" / "Replace in Note" and routes Cmd+F into raw CodeMirror mode; note-list focus enables existing note-list search instead. When adding another focus-dependent command, mirror this pattern with an availability event consumed by `useMenuEvents.ts` and `update_menu_state`.
For automated shortcut QA, use the explicit proof path from `appCommandCatalog.ts`:
- `window.__laputaTest.triggerShortcutCommand()` for deterministic renderer shortcut-event coverage
@@ -413,5 +420,11 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
1. **Agent system prompt**: Edit `src/utils/ai-agent.ts` (inline system prompt string)
2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`)
5. **Shared agent adapters / Codex args**: Edit `src-tauri/src/ai_agents.rs` (keep Codex on the normal approval/sandbox path unless you are intentionally designing an advanced mode)
4. **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)
### 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
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -140,3 +140,5 @@ proposed → active → superseded
| [0082](0082-markdown-durable-math-notes.md) | Markdown-durable math in notes | active |
| [0083](0083-dual-architecture-macos-release-artifacts.md) | Dual-architecture macOS release artifacts | active |
| [0085](0085-non-git-vault-support.md) | Non-git vaults open with explicit later Git initialization | active |
| [0088](0088-markdown-durable-mermaid-diagrams.md) | Markdown-durable Mermaid diagrams in notes | active |
| [0089](0089-active-vault-filesystem-watcher.md) | Active vault filesystem watcher | active |

406
lara.lock Normal file
View File

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

30
lara.yaml Normal file
View File

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

View File

@@ -21,8 +21,9 @@ import {
} from '@modelcontextprotocol/sdk/types.js'
import WebSocket from 'ws'
import { searchNotes, getNote, vaultContext } from './vault.js'
import { requireVaultPath } from './vault-path.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const VAULT_PATH = requireVaultPath()
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`

View File

@@ -6,6 +6,7 @@ import os from 'node:os'
import {
findMarkdownFiles, getNote, searchNotes, vaultContext,
} from './vault.js'
import { requireVaultPath } from './vault-path.js'
import { evaluateBridgeRequest } from './ws-bridge.js'
let tmpDir
@@ -191,6 +192,22 @@ describe('evaluateBridgeRequest', () => {
})
})
describe('requireVaultPath', () => {
it('returns the explicit configured vault path', () => {
assert.equal(
requireVaultPath({ VAULT_PATH: '/tmp/Selected Vault' }),
'/tmp/Selected Vault',
)
})
it('rejects missing vault paths instead of falling back to ~/Laputa', () => {
assert.throws(
() => requireVaultPath({}),
/VAULT_PATH is required/,
)
})
})
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
const outsideNote = path.join(outsideDir, 'outside.md')

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

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

View File

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

View File

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

1767
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

83
src-tauri/Cargo.lock generated
View File

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

View File

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

View File

@@ -1,13 +1,15 @@
use serde::{Deserialize, Serialize};
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::process::Stdio;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AiAgentId {
ClaudeCode,
Codex,
Opencode,
Pi,
}
#[derive(Debug, Clone, Serialize)]
@@ -20,6 +22,8 @@ pub struct AiAgentAvailability {
pub struct AiAgentsStatus {
pub claude_code: AiAgentAvailability,
pub codex: AiAgentAvailability,
pub opencode: AiAgentAvailability,
pub pi: AiAgentAvailability,
}
#[derive(Debug, Clone, Serialize)]
@@ -63,6 +67,8 @@ 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(),
}
}
@@ -84,6 +90,22 @@ where
})
}
AiAgentId::Codex => run_codex_agent_stream(request, emit),
AiAgentId::Opencode => {
let mapped = crate::opencode_cli::AgentStreamRequest {
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
};
crate::opencode_cli::run_agent_stream(mapped, emit)
}
AiAgentId::Pi => {
let mapped = crate::pi_cli::AgentStreamRequest {
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
};
crate::pi_cli::run_agent_stream(mapped, emit)
}
}
}
@@ -112,8 +134,16 @@ fn availability_from_codex() -> AiAgentAvailability {
}
}
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> {
Command::new(binary)
crate::hidden_command(binary)
.arg("--version")
.output()
.ok()
@@ -138,7 +168,7 @@ fn find_codex_binary() -> Result<PathBuf, String> {
}
fn find_codex_binary_on_path() -> Option<PathBuf> {
Command::new("which")
crate::hidden_command("which")
.arg("codex")
.output()
.ok()
@@ -165,7 +195,7 @@ fn user_shell_candidates() -> Vec<PathBuf> {
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
Command::new(shell)
crate::hidden_command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
@@ -225,13 +255,7 @@ where
let args = build_codex_args(&request)?;
let prompt = build_codex_prompt(&request);
let mut command = Command::new(binary);
command
.args(args)
.arg(prompt)
.current_dir(&request.vault_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut command = build_codex_command(&binary, args, prompt, &request.vault_path);
let mut child = command
.spawn()
@@ -289,6 +313,22 @@ where
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
@@ -297,6 +337,10 @@ fn build_codex_args(request: &AiAgentStreamRequest) -> Result<Vec<String>, Strin
.to_string();
Ok(vec![
"--sandbox".into(),
"workspace-write".into(),
"--ask-for-approval".into(),
"never".into(),
"exec".into(),
"--json".into(),
"-C".into(),
@@ -390,6 +434,10 @@ fn format_codex_error(stderr_output: String, status: String) -> String {
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 {
@@ -403,6 +451,17 @@ fn is_codex_auth_error(lower: &str) -> bool {
.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 } => {
@@ -430,6 +489,9 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAge
Some(AiAgentStreamEvent::Error { message })
}
crate::claude_cli::ClaudeStreamEvent::Done => Some(AiAgentStreamEvent::Done),
crate::claude_cli::ClaudeStreamEvent::Result { text, .. } if !text.is_empty() => {
Some(AiAgentStreamEvent::TextDelta { text })
}
crate::claude_cli::ClaudeStreamEvent::Result { .. } => None,
}
}
@@ -437,12 +499,15 @@ 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_both_agents() {
fn normalize_status_contains_all_agents() {
let status = get_ai_agents_status();
assert!(matches!(status.claude_code.installed, true | false));
assert!(matches!(status.codex.installed, true | false));
assert!(matches!(status.opencode.installed, true | false));
assert!(matches!(status.pi.installed, true | false));
}
#[test]
@@ -466,12 +531,37 @@ mod tests {
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");
@@ -586,10 +676,35 @@ mod tests {
));
}
#[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"));
}
#[test]
fn map_claude_done_event_preserves_completion_signal() {
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Done);
assert!(matches!(mapped, Some(AiAgentStreamEvent::Done)));
}
#[test]
fn map_claude_result_event_preserves_final_text() {
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Result {
text: "Final answer from Claude".into(),
session_id: "session-1".into(),
});
assert!(matches!(
mapped,
Some(AiAgentStreamEvent::TextDelta { text }) if text == "Final answer from Claude"
));
}
}

View File

@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use std::process::{ExitStatus, Stdio};
/// Status returned by `check_claude_cli`.
#[derive(Debug, Serialize, Clone)]
@@ -50,7 +50,7 @@ pub struct ChatStreamRequest {
pub session_id: Option<String>,
}
/// Parameters accepted by `stream_claude_agent`.
/// Parameters accepted by Claude Code agent streams.
#[derive(Debug, Deserialize)]
pub struct AgentStreamRequest {
pub message: String,
@@ -79,7 +79,7 @@ pub(crate) fn find_claude_binary() -> Result<PathBuf, String> {
}
fn find_claude_binary_on_path() -> Option<PathBuf> {
Command::new(claude_path_lookup_command())
crate::hidden_command(claude_path_lookup_command())
.arg("claude")
.output()
.ok()
@@ -114,7 +114,7 @@ fn user_shell_candidates() -> Vec<PathBuf> {
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
Command::new(shell)
crate::hidden_command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
@@ -193,7 +193,7 @@ pub fn check_cli() -> ClaudeCliStatus {
}
};
let version = Command::new(&bin)
let version = crate::hidden_command(&bin)
.arg("--version")
.output()
.ok()
@@ -269,7 +269,11 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
"--include-partial-messages".into(),
"--mcp-config".into(),
mcp_config,
"--dangerously-skip-permissions".into(),
"--strict-mcp-config".into(),
"--permission-mode".into(),
"acceptEdits".into(),
"--tools".into(),
"Read,Edit,MultiEdit,Write,Glob,Grep,LS".into(),
"--no-session-persistence".into(),
];
@@ -323,14 +327,7 @@ fn run_claude_subprocess<F>(
where
F: FnMut(ClaudeStreamEvent),
{
let mut cmd = Command::new(bin);
cmd.args(args)
.env_remove("CLAUDECODE") // prevent "nested session" guard
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let mut cmd = build_claude_command(bin, args, cwd);
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
@@ -387,6 +384,23 @@ where
Ok(state.session_id)
}
fn build_claude_command(
bin: &PathBuf,
args: &[String],
cwd: Option<&str>,
) -> std::process::Command {
let mut cmd = crate::hidden_command(bin);
cmd.args(args)
.env_remove("CLAUDECODE") // prevent "nested session" guard
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
cmd
}
fn format_failed_claude_exit(stderr_output: &str, status: ExitStatus) -> String {
if is_claude_auth_error(stderr_output) {
return "Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into();
@@ -589,6 +603,9 @@ fn extract_tool_result_text(json: &serde_json::Value) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
use std::ffi::OsString;
use std::process::Command;
#[test]
fn check_cli_returns_status() {
@@ -902,6 +919,33 @@ mod tests {
// --- run_claude_subprocess with mock scripts ---
#[test]
fn build_claude_command_keeps_streaming_process_contract() {
let bin = PathBuf::from("claude");
let args = vec!["-p".to_string(), "hello".to_string()];
let command = build_claude_command(&bin, &args, Some("/tmp/vault"));
let actual_args: Vec<OsString> = command.get_args().map(OsStr::to_os_string).collect();
let claude_code_env = command
.get_envs()
.find(|(key, _)| *key == OsStr::new("CLAUDECODE"))
.map(|(_, value)| value.map(OsStr::to_os_string));
assert_eq!(
(
command.get_program().to_os_string(),
actual_args,
command.get_current_dir().map(Path::to_path_buf),
claude_code_env,
),
(
OsString::from("claude"),
vec![OsString::from("-p"), OsString::from("hello")],
Some(PathBuf::from("/tmp/vault")),
Some(None),
),
);
}
#[cfg(unix)]
fn run_mock_script(script: &str) -> (Result<String, String>, Vec<ClaudeStreamEvent>) {
run_mock_script_with_args(script, &[])
@@ -938,6 +982,99 @@ mod tests {
assert!(matches!(&events[3], ClaudeStreamEvent::Done));
}
#[test]
fn run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open() {
use std::io::Read;
use std::time::{Duration, Instant};
let mut child = Command::new(std::env::current_exe().unwrap())
.arg("stdin_probe_parent_child")
.arg("--ignored")
.arg("--nocapture")
.env("TOLARIA_STDIN_PROBE_CHILD", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let child_stdin = child.stdin.take().unwrap();
let mut stdout = child.stdout.take().unwrap();
let mut stderr = child.stderr.take().unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
if Instant::now() >= deadline {
child.kill().unwrap();
drop(child_stdin);
panic!("stdin probe child timed out");
}
std::thread::sleep(Duration::from_millis(10));
};
drop(child_stdin);
let mut stdout_text = String::new();
let mut stderr_text = String::new();
stdout.read_to_string(&mut stdout_text).unwrap();
stderr.read_to_string(&mut stderr_text).unwrap();
assert!(
status.success(),
"stdin probe child failed with {status}\nstdout:\n{stdout_text}\nstderr:\n{stderr_text}"
);
}
#[ignore = "spawned by run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open"]
#[test]
fn stdin_probe_parent_child() {
if std::env::var_os("TOLARIA_STDIN_PROBE_CHILD").is_none() {
return;
}
let fake_bin = std::env::current_exe().unwrap();
let args = vec![
"stdin_probe_mock_claude_child".to_string(),
"--ignored".to_string(),
"--nocapture".to_string(),
];
std::env::set_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD", "1");
let mut events = vec![];
let result = run_claude_subprocess(&fake_bin, &args, None, &mut |event| events.push(event));
std::env::remove_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD");
assert_eq!(result.unwrap(), "stdin-ok");
assert!(matches!(
events.first(),
Some(ClaudeStreamEvent::Result { text, session_id })
if text == "stdin closed" && session_id == "stdin-ok"
));
assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done)));
}
#[ignore = "spawned by stdin_probe_parent_child"]
#[test]
fn stdin_probe_mock_claude_child() {
if std::env::var_os("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD").is_none() {
return;
}
use std::io::Read;
let mut stdin = String::new();
std::io::stdin().read_to_string(&mut stdin).unwrap();
assert!(stdin.is_empty(), "stdin was not EOF");
println!(
"{}",
serde_json::json!({
"type": "result",
"result": "stdin closed",
"session_id": "stdin-ok"
})
);
}
#[cfg(unix)]
#[test]
fn run_subprocess_skips_blank_and_non_json_lines() {
@@ -1066,11 +1203,16 @@ mod tests {
assert!(args.contains(&"-p".to_string()));
assert!(args.contains(&"create note".to_string()));
assert!(args.contains(&"--mcp-config".to_string()));
assert!(args.contains(&"--dangerously-skip-permissions".to_string()));
assert!(args.contains(&"--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()));
// Native tools must NOT be disabled
assert!(!args.contains(&"--tools".to_string()));
}
}

View File

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

View File

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

View File

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

View File

@@ -165,6 +165,24 @@ fn ensure_missing_folder(folder_path: &Path, folder_name: &str) -> Result<(), St
Ok(())
}
fn scan_visible_vault_entries(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
let entries = vault::scan_vault_cached(vault_path)?;
Ok(vault::filter_gitignored_entries(
vault_path,
entries,
crate::settings::hide_gitignored_files_enabled(),
))
}
fn scan_visible_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String> {
let folders = vault::scan_vault_folders(vault_path)?;
Ok(vault::filter_gitignored_folders(
vault_path,
folders,
crate::settings::hide_gitignored_files_enabled(),
))
}
/// Sync the `title` frontmatter field with the filename on note open.
/// Returns `true` if the file was modified (title was absent or desynced).
#[tauri::command]
@@ -207,12 +225,12 @@ pub fn copy_image_to_vault(
#[tauri::command]
pub fn list_vault(path: PathBuf) -> Result<Vec<VaultEntry>, String> {
with_expanded_vault_root(path.as_path(), vault::scan_vault_cached)
with_expanded_vault_root(path.as_path(), scan_visible_vault_entries)
}
#[tauri::command]
pub fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
with_expanded_vault_root(path.as_path(), vault::scan_vault_folders)
with_expanded_vault_root(path.as_path(), scan_visible_vault_folders)
}
#[cfg(test)]
@@ -266,6 +284,28 @@ mod tests {
.contains("title: Command Note"));
}
#[test]
fn note_content_commands_accept_windows_sensitive_valid_segments() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
let note = root
.join("@raflymln")
.join("notes with spaces")
.join("résumé note.md");
save_note_content(
note.clone(),
"# Windows-Sensitive Path\n\nBody\n".to_string(),
Some(root.clone()),
)
.unwrap();
assert_eq!(
get_note_content(note, Some(root)).unwrap(),
"# Windows-Sensitive Path\n\nBody\n"
);
}
#[test]
fn folder_and_listing_commands_use_expanded_vault_root() {
let dir = TempDir::new().unwrap();

View File

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

View File

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

View File

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

View File

@@ -7,11 +7,20 @@ pub mod git;
pub mod mcp;
#[cfg(desktop)]
pub mod menu;
pub mod opencode_cli;
mod opencode_config;
mod opencode_discovery;
mod opencode_events;
pub mod pi_cli;
mod pi_config;
mod pi_discovery;
mod pi_events;
pub mod search;
pub mod settings;
pub mod telemetry;
pub mod vault;
pub mod vault_list;
pub mod vault_watcher;
#[cfg(desktop)]
mod window_state;
@@ -97,6 +106,78 @@ fn log_startup_result(label: &str, result: Result<usize, String>) {
}
}
#[cfg(desktop)]
fn selected_mcp_bridge_vault_path(vault_list: &vault_list::VaultList) -> Option<PathBuf> {
vault_list
.active_vault
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
.map(PathBuf::from)
}
#[cfg(desktop)]
fn validate_mcp_bridge_vault_path(vault_path: &Path) -> Result<PathBuf, String> {
let resolved = std::fs::canonicalize(vault_path).map_err(|e| {
format!(
"MCP bridge vault is not available: {} ({e})",
vault_path.display()
)
})?;
if !resolved.is_dir() {
return Err(format!(
"MCP bridge vault is not available: {} is not a directory",
vault_path.display()
));
}
Ok(resolved)
}
#[cfg(desktop)]
fn stop_ws_bridge_child(active_child: &mut Option<Child>) {
if let Some(mut child) = active_child.take() {
let _ = child.kill();
let _ = child.wait();
log::info!("ws-bridge child process stopped");
}
}
#[cfg(desktop)]
pub(crate) fn sync_ws_bridge_for_vault(
app_handle: &tauri::AppHandle,
vault_path: Option<&Path>,
) -> Result<&'static str, String> {
use tauri::Manager;
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
let mut active_child = state
.0
.lock()
.map_err(|_| "Failed to lock ws-bridge state".to_string())?;
let Some(vault_path) = vault_path else {
stop_ws_bridge_child(&mut active_child);
return Ok("stopped");
};
let resolved_vault_path = match validate_mcp_bridge_vault_path(vault_path) {
Ok(path) => path,
Err(e) => {
stop_ws_bridge_child(&mut active_child);
return Err(e);
}
};
stop_ws_bridge_child(&mut active_child);
let child = mcp::spawn_ws_bridge(&resolved_vault_path)?;
*active_child = Some(child);
Ok("started")
}
/// Run startup housekeeping on the default vault (migrate legacy frontmatter, seed configs).
#[cfg(desktop)]
fn run_startup_tasks() {
@@ -119,17 +200,21 @@ fn run_startup_tasks() {
#[cfg(desktop)]
fn spawn_ws_bridge(app: &mut tauri::App) {
use tauri::Manager;
let vault_path = dirs::home_dir()
.map(|h| h.join("Laputa"))
.unwrap_or_default();
let vp_str = vault_path.to_string_lossy().to_string();
match mcp::spawn_ws_bridge(&vp_str) {
Ok(child) => {
let state: tauri::State<'_, WsBridgeChild> = app.state();
*state.0.lock().unwrap() = Some(child);
let vault_path = match vault_list::load_vault_list() {
Ok(vault_list) => selected_mcp_bridge_vault_path(&vault_list),
Err(e) => {
log::warn!("Failed to load active vault for ws-bridge startup: {}", e);
None
}
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
};
let Some(vault_path) = vault_path else {
log::info!("ws-bridge not started: no active vault selected");
return;
};
if let Err(e) = sync_ws_bridge_for_vault(app.handle(), Some(&vault_path)) {
log::warn!("Failed to start ws-bridge: {}", e);
}
}
@@ -176,7 +261,7 @@ fn setup_linux_window_chrome(_app: &mut tauri::App) -> Result<(), Box<dyn std::e
}
#[cfg(any(test, all(desktop, target_os = "macos")))]
const MACOS_WEBVIEW_RESERVED_COMMAND_KEYS: &[&str] = &["O"];
const MACOS_WEBVIEW_RESERVED_COMMAND_KEYS: &[&str] = &["O", "F"];
#[cfg(any(test, all(desktop, target_os = "macos")))]
const MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS: &[&str] = &["L"];
@@ -316,7 +401,6 @@ macro_rules! app_invoke_handler {
commands::get_vault_ai_guidance_status,
commands::restore_vault_ai_guidance,
commands::stream_claude_chat,
commands::stream_claude_agent,
commands::stream_ai_agent,
commands::reload_vault,
commands::reload_vault_entry,
@@ -351,11 +435,14 @@ macro_rules! app_invoke_handler {
commands::register_mcp_tools,
commands::remove_mcp_tools,
commands::check_mcp_status,
commands::sync_mcp_bridge_vault,
commands::repair_vault,
commands::reinit_telemetry,
commands::list_views,
commands::save_view_cmd,
commands::delete_view_cmd
commands::delete_view_cmd,
vault_watcher::start_vault_watcher,
vault_watcher::stop_vault_watcher
]
};
}
@@ -389,7 +476,8 @@ pub fn run() {
let builder = builder
.manage(WsBridgeChild(Mutex::new(None)))
.manage(ActiveAssetScopeRoots(Mutex::new(Vec::new())))
.manage(window_state::MainWindowFrameState::default());
.manage(window_state::MainWindowFrameState::default())
.manage(vault_watcher::VaultWatcherState::new());
with_invoke_handler(builder)
.setup(setup_app)
@@ -405,13 +493,20 @@ pub fn run() {
mod tests {
use super::linux_appimage_startup_env_overrides_with;
use super::StartupEnvOverride;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
#[cfg(desktop)]
use super::{selected_mcp_bridge_vault_path, validate_mcp_bridge_vault_path};
#[cfg(desktop)]
use crate::vault_list::VaultList;
#[cfg(all(desktop, unix))]
use super::vault_asset_scope_roots;
#[test]
fn macos_webview_shortcut_prevention_includes_ai_panel_shortcut() {
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_KEYS, ["O", "F"]);
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]);
}
@@ -449,6 +544,48 @@ mod tests {
assert!(overrides.is_empty());
}
#[cfg(desktop)]
#[test]
fn selected_mcp_bridge_vault_path_uses_persisted_active_vault() {
let list = VaultList {
vaults: Vec::new(),
active_vault: Some("/tmp/Selected Vault".to_string()),
hidden_defaults: Vec::new(),
};
assert_eq!(
selected_mcp_bridge_vault_path(&list),
Some(std::path::PathBuf::from("/tmp/Selected Vault"))
);
}
#[cfg(desktop)]
#[test]
fn selected_mcp_bridge_vault_path_ignores_blank_active_vault() {
let list = VaultList {
vaults: Vec::new(),
active_vault: Some(" ".to_string()),
hidden_defaults: Vec::new(),
};
assert_eq!(selected_mcp_bridge_vault_path(&list), None);
}
#[cfg(desktop)]
#[test]
fn validate_mcp_bridge_vault_path_requires_existing_directory() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().join("Vault With Spaces");
std::fs::create_dir(&vault).unwrap();
let resolved = validate_mcp_bridge_vault_path(&vault).unwrap();
assert_eq!(resolved, vault.canonicalize().unwrap());
let missing = dir.path().join("Missing Vault");
let err = validate_mcp_bridge_vault_path(&missing).unwrap_err();
assert!(err.contains("MCP bridge vault is not available"));
}
#[cfg(all(desktop, unix))]
#[test]
fn vault_asset_scope_roots_include_requested_symlink_path() {

View File

@@ -21,19 +21,66 @@ pub(crate) fn find_node() -> Result<PathBuf, String> {
.output()
.map_err(|e| format!("Failed to locate node on PATH: {e}"))?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Ok(PathBuf::from(path));
if let Some(path) = first_node_lookup_path(&output.stdout) {
verify_node_version(&path)?;
return Ok(path);
}
}
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())
}
fn first_node_lookup_path(stdout: &[u8]) -> Option<PathBuf> {
String::from_utf8_lossy(stdout)
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(PathBuf::from)
}
fn verify_node_version(node: &Path) -> Result<(), String> {
let output = crate::hidden_command(node)
.arg("--version")
.output()
.map_err(|e| format!("Failed to run {} --version: {e}", node.display()))?;
if !output.status.success() {
return Err(format!(
"{} --version failed; install Node.js 18+ and make it available on PATH",
node.display()
));
}
let raw_version = String::from_utf8_lossy(&output.stdout);
let Some(major) = node_major_version(&raw_version) else {
return Err(format!(
"Cannot parse Node.js version from '{}'",
raw_version.trim()
));
};
if major < 18 {
return Err(format!(
"Node.js 18+ is required for Tolaria MCP tools; found {}",
raw_version.trim()
));
}
Ok(())
}
fn node_major_version(version: &str) -> Option<u32> {
version
.trim()
.trim_start_matches('v')
.split('.')
.next()
.and_then(|major| major.parse().ok())
}
fn node_lookup_command() -> Command {
#[cfg(windows)]
let mut command = crate::hidden_command("where.exe");
@@ -50,8 +97,30 @@ fn fallback_node_path() -> Option<PathBuf> {
PathBuf::from("/usr/local/bin/node"),
];
#[cfg(windows)]
{
if let Some(program_files) = std::env::var_os("ProgramFiles") {
candidates.push(PathBuf::from(program_files).join("nodejs").join("node.exe"));
}
if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") {
candidates.push(
PathBuf::from(program_files_x86)
.join("nodejs")
.join("node.exe"),
);
}
if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
candidates.push(
PathBuf::from(local_app_data)
.join("Programs")
.join("nodejs")
.join("node.exe"),
);
}
}
if let Some(home) = dirs::home_dir() {
candidates.push(home.join(".volta").join("bin").join("node"));
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) {
@@ -71,6 +140,14 @@ fn fallback_node_path() -> Option<PathBuf> {
candidates.into_iter().find(|path| path.is_file())
}
fn node_binary_name() -> &'static str {
if cfg!(windows) {
"node.exe"
} else {
"node"
}
}
/// Resolve the path to `mcp-server/`.
///
/// In dev mode, uses `CARGO_MANIFEST_DIR` (set at compile time).
@@ -79,34 +156,70 @@ pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("mcp-server");
if dev_path.join("ws-bridge.js").exists() {
return Ok(std::fs::canonicalize(&dev_path).unwrap_or(dev_path));
}
let exe = std::env::current_exe().map_err(|e| format!("Cannot find executable: {e}"))?;
// On macOS the exe lives at Contents/MacOS/<binary>.
// Resources are placed at Contents/Resources/ by Tauri.
let release_path = exe
.parent()
.and_then(|p| p.parent())
.map(|p| p.join("Resources").join("mcp-server"))
.ok_or_else(|| "Cannot resolve mcp-server directory".to_string())?;
if release_path.join("ws-bridge.js").exists() {
return Ok(release_path);
let appdir = std::env::var_os("APPDIR").map(PathBuf::from);
let candidates = mcp_server_dir_candidates(&dev_path, &exe, appdir.as_deref());
if let Some(path) = candidates
.iter()
.find(|path| mcp_server_dir_has_files(path))
{
return Ok(std::fs::canonicalize(path).unwrap_or_else(|_| path.clone()));
}
let searched = candidates
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ");
Err(format!(
"mcp-server not found at {} or {}",
dev_path.display(),
release_path.display()
"mcp-server not found. Searched these paths: {searched}"
))
}
fn mcp_server_dir_candidates(
dev_path: &Path,
exe_path: &Path,
appdir: Option<&Path>,
) -> Vec<PathBuf> {
let mut candidates = vec![dev_path.to_path_buf()];
if let Some(exe_dir) = exe_path.parent() {
candidates.push(exe_dir.join("mcp-server"));
if let Some(bundle_root) = exe_dir.parent() {
candidates.push(bundle_root.join("Resources").join("mcp-server"));
candidates.push(bundle_root.join("lib").join("tolaria").join("mcp-server"));
}
}
if let Some(appdir) = appdir {
candidates.push(
appdir
.join("usr")
.join("lib")
.join("tolaria")
.join("mcp-server"),
);
}
candidates.push(
PathBuf::from("/usr")
.join("lib")
.join("tolaria")
.join("mcp-server"),
);
candidates
}
fn mcp_server_dir_has_files(path: &Path) -> bool {
path.join("index.js").is_file() && path.join("ws-bridge.js").is_file()
}
/// Spawn the WebSocket bridge as a child process.
pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
pub fn spawn_ws_bridge(vault_path: impl AsRef<Path>) -> Result<Child, String> {
let node = find_node()?;
let server_dir = mcp_server_dir()?;
let script = server_dir.join("ws-bridge.js");
let vault_path = vault_path.as_ref();
let child = crate::hidden_command(node)
.arg(&script)
@@ -119,7 +232,11 @@ pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
.spawn()
.map_err(|e| format!("Failed to spawn ws-bridge: {e}"))?;
log::info!("ws-bridge spawned (pid: {})", child.id());
log::info!(
"ws-bridge spawned (pid: {}, vault: {})",
child.id(),
vault_path.display()
);
Ok(child)
}
@@ -160,6 +277,14 @@ fn entry_index_js_exists(entry: &serde_json::Value) -> bool {
.is_some_and(|index_js| Path::new(index_js).exists())
}
fn entry_uses_stdio(entry: &serde_json::Value) -> bool {
entry["type"].as_str() == Some("stdio")
}
fn entry_has_ui_port(entry: &serde_json::Value) -> bool {
entry["env"]["WS_UI_PORT"].as_str() == Some("9711")
}
fn entry_targets_vault(entry: &serde_json::Value, vault_path: &Path) -> bool {
let Some(entry_vault_path) = entry["env"]["VAULT_PATH"].as_str() else {
return false;
@@ -176,11 +301,15 @@ fn entry_targets_vault(entry: &serde_json::Value, vault_path: &Path) -> bool {
}
/// Build the MCP server entry JSON for a given vault path and index.js path.
fn build_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
fn build_mcp_entry(node_command: &str, index_js: &str, vault_path: &str) -> serde_json::Value {
serde_json::json!({
"command": "node",
"type": "stdio",
"command": node_command,
"args": [index_js],
"env": { "VAULT_PATH": vault_path }
"env": {
"VAULT_PATH": vault_path,
"WS_UI_PORT": "9711"
}
})
}
@@ -200,10 +329,14 @@ fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf])
/// Register Tolaria as an MCP server in external AI tool config files.
pub fn register_mcp(vault_path: &str) -> Result<String, String> {
let node = find_node().map_err(|e| {
format!("Node.js 18+ is required on PATH before Tolaria can register MCP tools: {e}")
})?;
let server_dir = mcp_server_dir()?;
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
let node_command = node.to_string_lossy().into_owned();
let entry = build_mcp_entry(&index_js, vault_path);
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
Ok(register_mcp_to_configs(&entry, &mcp_config_paths()))
}
@@ -317,7 +450,10 @@ pub fn check_mcp_status(vault_path: &str) -> McpStatus {
let active_vault_path = Path::new(vault_path);
if mcp_config_paths().into_iter().any(|config_path| {
read_registered_mcp_entry(&config_path).is_some_and(|entry| {
entry_index_js_exists(&entry) && entry_targets_vault(&entry, active_vault_path)
entry_uses_stdio(&entry)
&& entry_index_js_exists(&entry)
&& entry_has_ui_port(&entry)
&& entry_targets_vault(&entry, active_vault_path)
})
}) {
McpStatus::Installed
@@ -347,12 +483,17 @@ mod tests {
fn managed_server(index_js: &str, vault_path: &str) -> serde_json::Value {
serde_json::json!({
"type": "stdio",
"command": "node",
"args": [index_js],
"env": { "VAULT_PATH": vault_path }
"env": { "VAULT_PATH": vault_path, "WS_UI_PORT": "9711" }
})
}
fn test_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
build_mcp_entry("node", index_js, vault_path)
}
fn write_mcp_servers_config(config_path: &Path, servers: Vec<(&str, serde_json::Value)>) {
let servers = servers
.into_iter()
@@ -369,17 +510,56 @@ mod tests {
#[test]
fn build_mcp_entry_produces_correct_json() {
let entry = build_mcp_entry("/path/to/index.js", "/my/vault");
assert_eq!(entry["command"], "node");
let entry = build_mcp_entry("/usr/local/bin/node", "/path/to/index.js", "/my/vault");
assert_eq!(entry["type"], "stdio");
assert_eq!(entry["command"], "/usr/local/bin/node");
assert_eq!(entry["args"][0], "/path/to/index.js");
assert_eq!(entry["env"]["VAULT_PATH"], "/my/vault");
assert_eq!(entry["env"]["WS_UI_PORT"], "9711");
}
#[test]
fn first_node_lookup_path_uses_first_non_empty_line() {
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")
);
}
#[test]
fn node_major_version_accepts_current_node_output() {
assert_eq!(node_major_version("v24.13.1\n"), Some(24));
assert_eq!(node_major_version("18.19.0"), Some(18));
assert_eq!(node_major_version("not-node"), None);
}
#[test]
fn mcp_server_dir_candidates_prefer_exe_dir_before_macos_resources() {
let dev_path = Path::new("/repo/mcp-server");
let exe_path = Path::new("/Users/tester/AppData/Local/Tolaria/tolaria.exe");
let candidates = mcp_server_dir_candidates(dev_path, exe_path, None);
let windows_dir = PathBuf::from("/Users/tester/AppData/Local/Tolaria/mcp-server");
let macos_dir = PathBuf::from("/Users/tester/AppData/Local/Resources/mcp-server");
let windows_pos = candidates
.iter()
.position(|path| path == &windows_dir)
.unwrap();
let macos_pos = candidates
.iter()
.position(|path| path == &macos_dir)
.unwrap();
assert_eq!(candidates[0], dev_path);
assert!(windows_pos < macos_pos);
}
#[test]
fn upsert_creates_new_config() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/test/vault");
let entry = test_mcp_entry("/test/index.js", "/test/vault");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
assert!(!was_update);
@@ -400,10 +580,10 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let entry1 = build_mcp_entry("/test/index.js", "/vault/v1");
let entry1 = test_mcp_entry("/test/index.js", "/vault/v1");
upsert_mcp_config(&config_path, &entry1).unwrap();
let entry2 = build_mcp_entry("/test/index.js", "/vault/v2");
let entry2 = test_mcp_entry("/test/index.js", "/vault/v2");
let was_update = upsert_mcp_config(&config_path, &entry2).unwrap();
assert!(was_update);
@@ -430,7 +610,7 @@ mod tests {
});
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
assert!(was_update);
@@ -453,7 +633,7 @@ mod tests {
)],
);
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
upsert_mcp_config(&config_path, &entry).unwrap();
let raw = std::fs::read_to_string(&config_path).unwrap();
@@ -476,7 +656,7 @@ mod tests {
}),
);
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
upsert_mcp_config(&config_path, &entry).unwrap();
let config = read_config(&config_path);
@@ -490,7 +670,7 @@ mod tests {
fn upsert_creates_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("nested").join("dir").join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
upsert_mcp_config(&config_path, &entry).unwrap();
assert!(config_path.exists());
@@ -500,7 +680,7 @@ mod tests {
fn register_mcp_to_configs_returns_registered_for_new() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("claude").join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
let status = register_mcp_to_configs(&entry, &[config]);
assert_eq!(status, "registered");
@@ -510,7 +690,7 @@ mod tests {
fn register_mcp_to_configs_returns_updated_for_existing() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
// First call
register_mcp_to_configs(&entry, std::slice::from_ref(&config));
@@ -558,7 +738,7 @@ mod tests {
let claude_cfg = tmp.path().join("claude").join("mcp.json");
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
let generic_cfg = tmp.path().join(".config").join("mcp").join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
register_mcp_to_configs(
&entry,
@@ -603,14 +783,14 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
std::fs::write(&config_path, "not valid json{{{{").unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
let result = upsert_mcp_config(&config_path, &entry);
assert!(result.is_err());
}
#[test]
fn register_mcp_to_configs_handles_empty_list() {
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
// Empty config list — function should return "registered" (no existing)
let status = register_mcp_to_configs(&entry, &[]);
// With empty config list, there were no updates, so status should be "registered"
@@ -695,7 +875,7 @@ mod tests {
let config_path = tmp.path().join("mcp.json");
std::fs::write(&config_path, "[]").unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
let result = upsert_mcp_config(&config_path, &entry);
assert!(matches!(result, Err(ref error) if error.contains("Config is not a JSON object")));
}
@@ -709,7 +889,7 @@ mod tests {
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
let result = upsert_mcp_config(&config_path, &entry);
assert!(
matches!(result, Err(ref error) if error.contains("mcpServers is not a JSON object"))

View File

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

View File

@@ -0,0 +1,58 @@
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 fn check_cli() -> AiAgentAvailability {
crate::opencode_discovery::check_cli()
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut 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)
}

View File

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

View File

@@ -0,0 +1,159 @@
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: version_for_binary(&binary),
},
Err(_) => AiAgentAvailability {
installed: false,
version: None,
},
}
}
pub(crate) fn find_binary() -> Result<PathBuf, String> {
if let Some(binary) = find_binary_on_path() {
return Ok(binary);
}
if let Some(binary) = find_binary_in_user_shell() {
return Ok(binary);
}
if let Some(binary) = find_existing_binary(opencode_binary_candidates()) {
return Ok(binary);
}
Err("OpenCode CLI not found. Install it: https://opencode.ai/docs/".into())
}
fn 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")
.arg("opencode")
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn find_binary_in_user_shell() -> Option<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, "opencode"))
}
fn user_shell_candidates() -> Vec<PathBuf> {
let mut shells = Vec::new();
if let Some(shell) = std::env::var_os("SHELL") {
if !shell.is_empty() {
shells.push(PathBuf::from(shell));
}
}
shells.push(PathBuf::from("/bin/zsh"));
shells.push(PathBuf::from("/bin/bash"));
shells
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
crate::hidden_command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
if output.status.success() {
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
})
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
fn opencode_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| opencode_binary_candidates_for_home(&home))
.unwrap_or_default()
}
fn opencode_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".local/bin/opencode"),
home.join(".opencode/bin/opencode"),
home.join(".local/share/mise/shims/opencode"),
home.join(".asdf/shims/opencode"),
home.join(".npm-global/bin/opencode"),
home.join(".npm/bin/opencode"),
home.join(".bun/bin/opencode"),
PathBuf::from("/usr/local/bin/opencode"),
PathBuf::from("/opt/homebrew/bin/opencode"),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn binary_candidates_include_supported_local_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = opencode_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/opencode"),
home.join(".opencode/bin/opencode"),
home.join(".local/share/mise/shims/opencode"),
home.join(".asdf/shims/opencode"),
home.join(".npm-global/bin/opencode"),
home.join(".bun/bin/opencode"),
PathBuf::from("/opt/homebrew/bin/opencode"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing-opencode");
let opencode = dir.path().join("opencode");
std::fs::write(&opencode, "#!/bin/sh\n").unwrap();
let stdout = format!("\n{}\n{}\n", missing.display(), opencode.display());
assert_eq!(first_existing_path(&stdout), Some(opencode));
}
}

View File

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

View File

@@ -0,0 +1,63 @@
use super::*;
#[test]
fn dispatch_maps_session_reasoning_and_text() {
let mut events = Vec::new();
let started = serde_json::json!({ "type": "session", "sessionID": "ses_1" });
let reasoning = serde_json::json!({ "type": "reasoning", "text": "Checking links" });
let text = serde_json::json!({ "type": "message", "text": "Done" });
for event in [started, reasoning, text] {
dispatch_event(&event, &mut |event| events.push(event));
}
assert!(matches!(
&events[0],
AiAgentStreamEvent::Init { session_id } if session_id == "ses_1"
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ThinkingDelta { text } if text == "Checking links"
));
assert!(matches!(
&events[2],
AiAgentStreamEvent::TextDelta { text } if text == "Done"
));
}
#[test]
fn dispatch_maps_tool_events() {
let mut events = Vec::new();
let tool_start = serde_json::json!({
"type": "tool_use",
"id": "tool_1",
"name": "read",
"input": { "path": "Note.md" }
});
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));
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"}"#)
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ToolDone { tool_id, output }
if tool_id == "tool_1" && output.as_deref() == Some("ok")
));
}
#[test]
fn format_error_explains_missing_auth_or_provider_setup() {
let message = format_error(
"provider auth failed: please login".into(),
"exit status: 1".into(),
);
assert!(message.contains("OpenCode CLI is not authenticated"));
assert!(message.contains("opencode auth login"));
}

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

@@ -0,0 +1,62 @@
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 fn check_cli() -> AiAgentAvailability {
crate::pi_discovery::check_cli()
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = crate::pi_discovery::find_binary()?;
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)
}

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

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

View File

@@ -0,0 +1,159 @@
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: version_for_binary(&binary),
},
Err(_) => AiAgentAvailability {
installed: false,
version: None,
},
}
}
pub(crate) fn find_binary() -> Result<PathBuf, String> {
if let Some(binary) = find_binary_on_path() {
return Ok(binary);
}
if let Some(binary) = find_binary_in_user_shell() {
return Ok(binary);
}
if let Some(binary) = find_existing_binary(pi_binary_candidates()) {
return Ok(binary);
}
Err("Pi CLI not found. Install it: https://pi.dev".into())
}
fn 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")
.arg("pi")
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn find_binary_in_user_shell() -> Option<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, "pi"))
}
fn user_shell_candidates() -> Vec<PathBuf> {
let mut shells = Vec::new();
if let Some(shell) = std::env::var_os("SHELL") {
if !shell.is_empty() {
shells.push(PathBuf::from(shell));
}
}
shells.push(PathBuf::from("/bin/zsh"));
shells.push(PathBuf::from("/bin/bash"));
shells
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
crate::hidden_command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
if output.status.success() {
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
})
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
fn pi_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| pi_binary_candidates_for_home(&home))
.unwrap_or_default()
}
fn pi_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".local/bin/pi"),
home.join(".pi/bin/pi"),
home.join(".local/share/mise/shims/pi"),
home.join(".asdf/shims/pi"),
home.join(".npm-global/bin/pi"),
home.join(".npm/bin/pi"),
home.join(".bun/bin/pi"),
PathBuf::from("/usr/local/bin/pi"),
PathBuf::from("/opt/homebrew/bin/pi"),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn binary_candidates_include_supported_local_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = pi_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/pi"),
home.join(".pi/bin/pi"),
home.join(".local/share/mise/shims/pi"),
home.join(".asdf/shims/pi"),
home.join(".npm-global/bin/pi"),
home.join(".bun/bin/pi"),
PathBuf::from("/opt/homebrew/bin/pi"),
];
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-pi");
let pi = dir.path().join("pi");
std::fs::write(&pi, "#!/bin/sh\n").unwrap();
let stdout = format!("\n{}\n{}\n", missing.display(), pi.display());
assert_eq!(first_existing_path(&stdout), Some(pi));
}
}

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

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
use std::fs;
use std::io::{ErrorKind, Write};
use std::io::{Error, ErrorKind, Write};
use std::path::Path;
use std::time::UNIX_EPOCH;
@@ -25,6 +25,49 @@ fn invalid_utf8_text_error(path: &Path) -> String {
format!("File is not valid UTF-8 text: {}", path.display())
}
fn is_invalid_platform_path_error(error: &Error) -> bool {
error.kind() == ErrorKind::InvalidInput || error.raw_os_error() == Some(123)
}
#[derive(Clone, Copy)]
enum NoteIoOperation {
Save,
Create,
}
#[derive(Clone, Copy)]
struct NotePathDisplay<'a> {
value: &'a str,
}
impl<'a> NotePathDisplay<'a> {
fn new(value: &'a str) -> Self {
Self { value }
}
}
impl NoteIoOperation {
fn verb(self) -> &'static str {
match self {
Self::Save => "save",
Self::Create => "create",
}
}
}
fn note_io_error(operation: NoteIoOperation, path: NotePathDisplay<'_>, error: &Error) -> String {
let verb = operation.verb();
if is_invalid_platform_path_error(error) {
let path = path.value;
format!(
"Failed to {verb} note: the path is invalid on this platform. Rename the note or move it to a valid folder, then try again. Path: {path}"
)
} else {
let path = path.value;
format!("Failed to {verb} {path}: {error}")
}
}
/// Read the content of a single note file.
pub fn get_note_content(path: &Path) -> Result<String, String> {
if !path.exists() {
@@ -62,12 +105,14 @@ pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
let file_path = Path::new(path);
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
fs::create_dir_all(parent).map_err(|e| {
note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e)
})?;
}
}
validate_save_path(file_path, path)?;
fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e))
fs::write(file_path, content)
.map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e))
}
/// Create a new note file without overwriting any existing file.
@@ -75,8 +120,9 @@ pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
let file_path = Path::new(path);
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
fs::create_dir_all(parent).map_err(|e| {
note_io_error(NoteIoOperation::Create, NotePathDisplay::new(path), &e)
})?;
}
}
validate_save_path(file_path, path)?;
@@ -86,8 +132,27 @@ pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
.open(file_path)
.map_err(|e| match e.kind() {
ErrorKind::AlreadyExists => format!("File already exists: {}", path),
_ => format!("Failed to create {}: {}", path, e),
_ => note_io_error(NoteIoOperation::Create, NotePathDisplay::new(path), &e),
})?;
file.write_all(content.as_bytes())
.map_err(|e| format!("Failed to save {}: {}", path, e))
.map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_windows_invalid_path_syntax_as_recoverable_save_error() {
let path = r"C:\Users\@raflymln\notes\untitled-note-1777236475.md";
let message = note_io_error(
NoteIoOperation::Save,
NotePathDisplay::new(path),
&Error::from_raw_os_error(123),
);
assert!(message.contains("path is invalid on this platform"));
assert!(message.contains("Rename the note or move it to a valid folder"));
assert!(!message.contains("os error 123"));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { DEFAULT_VAULTS } from './hooks/useVaultSwitcher'
import { formatShortcutDisplay } from './hooks/appCommandCatalog'
import { invoke } from '@tauri-apps/api/core'
import type { Settings, ViewDefinition, ViewFile } from './types'
// Provide a localStorage mock that supports all methods (jsdom's may be incomplete)
const localStorageMock = (() => {
@@ -316,10 +317,19 @@ vi.mock('./mock-tauri', () => ({
}))
// Mock ai-chat utilities
vi.mock('./utils/ai-chat', () => ({
buildSystemPrompt: vi.fn(() => ({ prompt: '', totalTokens: 0, truncated: false })),
checkClaudeCli: vi.fn(async () => ({ installed: false })),
streamClaudeChat: vi.fn(async () => 'mock-session'),
vi.mock('./utils/ai-chat', async () => {
const actual = await vi.importActual<typeof import('./utils/ai-chat')>('./utils/ai-chat')
return {
...actual,
buildSystemPrompt: vi.fn(() => ({ prompt: '', totalTokens: 0, truncated: false })),
checkClaudeCli: vi.fn(async () => ({ installed: false })),
streamClaudeChat: vi.fn(async () => 'mock-session'),
}
})
vi.mock('./utils/streamAiAgent', () => ({
streamAiAgent: vi.fn(async () => {}),
}))
vi.mock('./hooks/useUpdater', async () => {
@@ -422,6 +432,7 @@ vi.mock('./components/tolariaEditorFormatting', () => ({
import App from './App'
import { useUpdater } from './hooks/useUpdater'
import { isTauri } from './mock-tauri'
import { streamAiAgent } from './utils/streamAiAgent'
const AI_AGENTS_ONBOARDING_DISMISSED_KEY = 'tolaria:ai-agents-onboarding-dismissed'
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
@@ -457,6 +468,35 @@ describe('App', () => {
expect(await screen.findByText('All Notes', {}, { timeout: 5000 })).toBeInTheDocument()
})
it('creates custom views with a portable fallback filename for symbol-only names', async () => {
const savedViews: ViewFile[] = []
const saveView = vi.fn(({ filename, definition }: { filename: string; definition: ViewDefinition }) => {
if (filename === '.yml') throw new Error('Invalid view filename')
savedViews.push({ filename, definition })
return null
})
mockCommandResults.save_view_cmd = saveView
mockCommandResults.list_views = () => savedViews
mockCommandResults.reload_vault = mockEntries
render(<App />)
await screen.findByText('All Notes')
fireEvent.click(screen.getByRole('button', { name: 'Create view' }))
const dialog = await screen.findByRole('dialog')
fireEvent.change(within(dialog).getByPlaceholderText(/Active Projects|Reading List/i), {
target: { value: '🚀' },
})
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
await waitFor(() => {
expect(saveView).toHaveBeenCalledWith(expect.objectContaining({
filename: 'view.yml',
definition: expect.objectContaining({ name: '🚀' }),
}))
})
})
it('loads and displays vault entries in sidebar', async () => {
render(<App />)
await waitFor(() => {
@@ -593,6 +633,8 @@ describe('App', () => {
mockCommandResults.get_ai_agents_status = {
claude_code: { installed: true, version: '2.1.90' },
codex: { installed: true, version: '0.122.0-alpha.1' },
opencode: { installed: false, version: null },
pi: { installed: false, version: null },
}
mockCommandResults.check_mcp_status = 'installed'
@@ -617,6 +659,98 @@ describe('App', () => {
expect(screen.queryByText('AI agents ready')).not.toBeInTheDocument()
})
it('routes right-panel AI chat messages to the selected default agent', async () => {
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,
auto_advance_inbox_after_organize: null,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
default_ai_agent: 'codex',
}
mockCommandResults.get_ai_agents_status = {
claude_code: { installed: true, version: '2.1.90' },
codex: { installed: true, version: '0.122.0-alpha.1' },
opencode: { installed: false, version: null },
pi: { installed: false, version: null },
}
render(<App />)
await screen.findByText('All Notes')
fireEvent.keyDown(window, { key: 'l', code: 'KeyL', metaKey: true, shiftKey: true })
const input = await screen.findByTestId('agent-input')
await waitFor(() => {
expect(input).toHaveAttribute('aria-placeholder', 'Ask Codex')
})
input.textContent = 'Summarize the active vault'
fireEvent.input(input)
fireEvent.click(screen.getByTestId('agent-send'))
await waitFor(() => {
expect(streamAiAgent).toHaveBeenCalledWith(expect.objectContaining({
agent: 'codex',
}))
})
})
it('waits for saved AI agent settings before sending right-panel messages', async () => {
let resolveSettings: ((settings: Settings) => void) | null = null
mockCommandResults.get_settings = () => new Promise((resolve) => {
resolveSettings = resolve
})
mockCommandResults.get_ai_agents_status = {
claude_code: { installed: true, version: '2.1.90' },
codex: { installed: true, version: '0.122.0-alpha.1' },
opencode: { installed: false, version: null },
pi: { installed: false, version: null },
}
render(<App />)
await screen.findByText('All Notes')
fireEvent.keyDown(window, { key: 'l', code: 'KeyL', metaKey: true, shiftKey: true })
const input = await screen.findByTestId('agent-input')
input.textContent = 'Summarize the active vault'
fireEvent.input(input)
fireEvent.click(screen.getByTestId('agent-send'))
await act(async () => {
await Promise.resolve()
})
expect(streamAiAgent).not.toHaveBeenCalled()
await act(async () => {
resolveSettings?.({
auto_pull_interval_minutes: null,
auto_advance_inbox_after_organize: null,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
default_ai_agent: 'codex',
})
})
await waitFor(() => {
expect(input).toHaveAttribute('aria-placeholder', 'Ask Codex')
})
fireEvent.click(screen.getByTestId('agent-send'))
await waitFor(() => {
expect(streamAiAgent).toHaveBeenCalledWith(expect.objectContaining({
agent: 'codex',
}))
})
})
it('shows onboarding after telemetry consent when no active vault is configured', async () => {
mockCommandResults.get_settings = {
auto_pull_interval_minutes: null,

View File

@@ -22,7 +22,6 @@ import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
import { FeedbackDialog } from './components/FeedbackDialog'
import { McpSetupDialog } from './components/McpSetupDialog'
import { NoteRetargetingDialogs } from './components/note-retargeting/NoteRetargetingDialogs'
import { NoteRetargetingProvider } from './components/note-retargeting/noteRetargetingContext'
import { useTelemetry } from './hooks/useTelemetry'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
@@ -30,6 +29,7 @@ import { useAiAgentsStatus } from './hooks/useAiAgentsStatus'
import { useVaultAiGuidanceStatus } from './hooks/useVaultAiGuidanceStatus'
import { useAutoGit } from './hooks/useAutoGit'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useRecentVaultWrites, useVaultWatcher } from './hooks/useVaultWatcher'
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
import { useSettings } from './hooks/useSettings'
import { useDocumentThemeMode } from './hooks/useDocumentThemeMode'
@@ -66,11 +66,13 @@ import { useAiActivity } from './hooks/useAiActivity'
import { useBulkActions } from './hooks/useBulkActions'
import { useDeleteActions } from './hooks/useDeleteActions'
import { useFolderActions } from './hooks/useFolderActions'
import { useFileActions } from './hooks/useFileActions'
import { useLayoutPanels } from './hooks/useLayoutPanels'
import { useConflictFlow } from './hooks/useConflictFlow'
import { useAppSave } from './hooks/useAppSave'
import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi'
import { useVaultBridge } from './hooks/useVaultBridge'
import { createViewFilename } from './utils/viewFilename'
import type { CommitDiffRequest } from './hooks/useDiffMode'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
@@ -369,6 +371,7 @@ function App() {
}, [resolvedPath, setToastMessage])
const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath)
const recentVaultWrites = useRecentVaultWrites({ vaultPath: noteWindowParams ? '' : resolvedPath })
const {
status: vaultAiGuidanceStatus,
refresh: refreshVaultAiGuidance,
@@ -436,6 +439,7 @@ function App() {
}, [saveSettings, settings])
const aiAgentPreferences = useAiAgentPreferences({
settings,
settingsLoaded,
saveSettings,
aiAgentsStatus,
onToast: setToastMessage,
@@ -538,8 +542,7 @@ function App() {
removeEntry: vault.removeEntry,
entries: vault.entries,
flushBeforeNoteSwitch: flushEditorStateBeforeAction,
flushBeforeFrontmatterChange: flushEditorStateBeforeAction,
flushBeforePathRename: flushEditorStateBeforeAction,
flushBeforeNoteMutation: flushEditorStateBeforeAction,
reloadVault: vault.reloadVault,
setToastMessage,
updateEntry: vault.updateEntry,
@@ -551,6 +554,7 @@ function App() {
unsavedPaths: vault.unsavedPaths,
markContentPending: (path, content) => appSave.contentChangeRef.current(path, content),
onNewNotePersisted: vault.loadModifiedFiles,
onTypeStateChanged: async () => { await vault.reloadVault() },
replaceEntry: vault.replaceEntry,
onFrontmatterPersisted: vault.loadModifiedFiles,
onPathRenamed: (oldPath, newPath) => appSave.trackRenamedPath(oldPath, newPath),
@@ -587,6 +591,11 @@ function App() {
vault.reloadViews,
vault.unsavedPaths,
])
useVaultWatcher({
vaultPath: noteWindowParams ? '' : resolvedPath,
onVaultChanged: handlePulledVaultUpdate,
filterChangedPaths: recentVaultWrites.filterExternalPaths,
})
const autoSync = useAutoSync({
enabled: gitRepoState === 'ready',
vaultPath: resolvedPath,
@@ -738,6 +747,7 @@ function App() {
handleRenameNote: notes.handleRenameNote, handleRenameFilename: notes.handleRenameFilename,
replaceEntry: vault.replaceEntry, resolvedPath,
initialH1AutoRenameEnabled: settings.initial_h1_auto_rename_enabled !== false,
onInternalVaultWrite: recentVaultWrites.markInternalWrite,
})
const aiActivity = useAiActivity({
@@ -746,7 +756,7 @@ function App() {
onSetFilter: (filterType) => {
handleSetSelection({ kind: 'sectionGroup', type: filterType })
},
onVaultChanged: () => { vault.reloadVault() },
onVaultChanged: (path) => { void handlePulledVaultUpdate(path ? [path] : []) },
})
const handleInitializeProperties = useCallback(async (path: string) => {
@@ -824,6 +834,11 @@ function App() {
reloadFolders: vault.reloadFolders,
setToastMessage,
})
const fileActions = useFileActions({
selection: effectiveSelection,
setToastMessage,
vaultPath: resolvedPath,
})
const handleRemoveNoteIconCommand = useCallback(() => {
if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath)
@@ -1043,17 +1058,24 @@ function App() {
const editing = dialogs.editingView
const filename = editing
? editing.filename
: definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml'
: createViewFilename(definition.name, vault.views.map((view) => view.filename))
const nextDefinition = editing ? { ...editing.definition, ...definition } : definition
const target = isTauri() ? invoke : mockInvoke
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition })
trackEvent(editing ? 'view_updated' : 'view_created')
await vault.reloadViews()
await vault.reloadVault()
vault.reloadFolders()
setToastMessage(editing ? `View "${nextDefinition.name}" updated` : `View "${nextDefinition.name}" created`)
handleSetSelection({ kind: 'view', filename })
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView])
try {
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition })
trackEvent(editing ? 'view_updated' : 'view_created')
await vault.reloadViews()
await vault.reloadVault()
vault.reloadFolders()
setToastMessage(editing ? `View "${nextDefinition.name}" updated` : `View "${nextDefinition.name}" created`)
handleSetSelection({ kind: 'view', filename })
return true
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setToastMessage(`Could not save view: ${message}`)
return false
}
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView, setToastMessage])
const handleUpdateViewDefinition = useCallback(async (filename: string, patch: Partial<ViewDefinition>) => {
const existing = vault.views.find((view) => view.filename === filename)
@@ -1106,6 +1128,7 @@ function App() {
const rawToggleRef = useRef<() => void>(() => {})
// Diff-toggle ref: Editor registers its handleToggleDiff here so the command palette can call it
const diffToggleRef = useRef<() => void>(() => {})
const findInNoteRef = useRef<((options?: { replace?: boolean }) => void) | null>(null)
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode(noteWindowParams ? 'editor-only' : undefined)
const { noteLayout, toggleNoteLayout } = useNoteLayout()
@@ -1288,6 +1311,12 @@ function App() {
() => canToggleRichEditor ? () => rawToggleRef.current() : undefined,
[canToggleRichEditor],
)
const findInNoteCommand = useCallback(() => {
findInNoteRef.current?.({ replace: false })
}, [])
const replaceInNoteCommand = useCallback(() => {
findInNoteRef.current?.({ replace: true })
}, [])
const removeActiveVaultCommand = useCallback(() => {
vaultSwitcher.removeVault(vaultSwitcher.vaultPath)
}, [vaultSwitcher])
@@ -1337,6 +1366,12 @@ function App() {
() => activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
[activeDeletedFile, handleDiscardFile],
)
const reloadVaultForCommand = vault.reloadVault
const handleManualVaultReload = useCallback(async () => {
const entries = await reloadVaultForCommand()
setToastMessage(`Vault reloaded (${entries.length} ${entries.length === 1 ? 'entry' : 'entries'})`)
return entries
}, [reloadVaultForCommand, setToastMessage])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
@@ -1348,6 +1383,8 @@ function App() {
selection: effectiveSelection,
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
onSearch: dialogs.openSearch,
onFindInNote: findInNoteCommand,
onReplaceInNote: activeDeletedFile ? undefined : replaceInNoteCommand,
onCreateNote: notes.handleCreateNoteImmediate,
onCreateNoteOfType: notes.handleCreateNoteImmediate,
onSave: appSave.handleSave,
@@ -1371,6 +1408,8 @@ function App() {
onSelect: handleSetSelection,
onRenameFolder: folderActions.renameSelectedFolder,
onDeleteFolder: folderActions.deleteSelectedFolder,
onRevealSelectedFolder: fileActions.revealSelectedFolder,
onCopySelectedFolderPath: fileActions.copySelectedFolderPath,
showInbox: explicitOrganizationEnabled,
onReplaceActiveTab: notes.handleReplaceActiveTab,
onSelectNote: notes.handleSelectNote,
@@ -1399,7 +1438,7 @@ function App() {
onSetDefaultAiAgent: aiAgentPreferences.setDefaultAiAgent,
onCycleDefaultAiAgent: aiAgentPreferences.cycleDefaultAiAgent,
selectedAiAgentLabel: aiAgentPreferences.defaultAiAgentLabel,
onReloadVault: vault.reloadVault,
onReloadVault: handleManualVaultReload,
onRepairVault: handleRepairVault,
onSetNoteIcon: handleSetNoteIconCommand,
onRemoveNoteIcon: handleRemoveNoteIconCommand,
@@ -1410,6 +1449,9 @@ function App() {
noteListFilter,
onSetNoteListFilter: setNoteListFilter,
onOpenInNewWindow: handleOpenInNewWindow,
onRevealActiveFile: fileActions.revealFile,
onCopyActiveFilePath: fileActions.copyFilePath,
onOpenActiveFileExternal: fileActions.openExternalFile,
onToggleFavorite: entryActions.handleToggleFavorite,
onToggleOrganized: toggleOrganizedCommand,
onCustomizeNoteListColumns: handleCustomizeNoteListColumns,
@@ -1506,13 +1548,12 @@ function App() {
}
return (
<NoteRetargetingProvider value={noteRetargetingUi.contextValue}>
<div className="app-shell">
<div className="app-shell">
<div className="app">
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} />
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -1545,6 +1586,7 @@ function App() {
onToggleInspector={handleToggleInspector}
inspectorWidth={layout.inspectorWidth}
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
defaultAiAgentReadiness={aiAgentPreferences.defaultAiAgentReadiness}
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
onUnsupportedAiPaste={setToastMessage}
onInspectorResize={layout.handleInspectorResize}
@@ -1564,6 +1606,9 @@ function App() {
noteListFilter={aiNoteListFilter}
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : toggleOrganizedCommand}
onRevealFile={fileActions.revealFile}
onCopyFilePath={fileActions.copyFilePath}
onOpenExternalFile={fileActions.openExternalFile}
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
@@ -1573,6 +1618,7 @@ function App() {
noteLayout={noteLayout}
onToggleNoteLayout={toggleNoteLayout}
rawToggleRef={rawToggleRef}
findInNoteRef={findInNoteRef}
diffToggleRef={diffToggleRef}
canGoBack={canGoBack}
canGoForward={canGoForward}
@@ -1592,7 +1638,7 @@ 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} 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} 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)} />
@@ -1661,8 +1707,7 @@ function App() {
onCancel={folderActions.cancelDeleteFolder}
/>
)}
</div>
</NoteRetargetingProvider>
</div>
)
}

View File

@@ -23,6 +23,8 @@ describe('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()}
/>,
@@ -30,6 +32,8 @@ describe('AiAgentsOnboardingPrompt', () => {
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()
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue')
})
@@ -39,6 +43,8 @@ describe('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()}
/>,
@@ -49,6 +55,8 @@ describe('AiAgentsOnboardingPrompt', () => {
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()
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue without it')
})
@@ -58,6 +66,8 @@ describe('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()}
/>,
@@ -65,9 +75,13 @@ describe('AiAgentsOnboardingPrompt', () => {
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'))
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')
})
it('uses the surrounding surface as a drag region and excludes the card', () => {
@@ -76,6 +90,8 @@ describe('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()}
/>,

View File

@@ -57,9 +57,10 @@ describe('AiPanel', () => {
mockClearConversation.mockReset()
})
it('renders panel with AI Chat header', () => {
it('renders panel with the default CLI agent header', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('AI Chat')).toBeTruthy()
expect(screen.getByText('AI Agent')).toBeTruthy()
expect(screen.getByText('Claude Code')).toBeTruthy()
})
it('renders data-testid ai-panel', () => {
@@ -86,7 +87,7 @@ describe('AiPanel', () => {
it('renders empty state without context', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('Open a note, then ask the AI about it')).toBeTruthy()
expect(screen.getByText('Open a note, then ask Claude Code about it')).toBeTruthy()
})
it('renders contextual empty state when active entry is provided', () => {
@@ -94,7 +95,7 @@ describe('AiPanel', () => {
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
)
expect(screen.getByText('Ask about this note and its linked context')).toBeTruthy()
expect(screen.getByText('Ask Claude Code about this note and its linked context')).toBeTruthy()
})
it('shows context bar with active entry title', () => {
@@ -163,6 +164,21 @@ describe('AiPanel', () => {
expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Ask Codex')
})
it('disables sending while the selected AI agent is still loading', () => {
render(
<AiPanel
onClose={vi.fn()}
vaultPath="/tmp/vault"
defaultAiAgent="codex"
defaultAiAgentReadiness="checking"
/>,
)
expect(screen.getByText('Checking availability')).toBeTruthy()
expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Checking AI agent availability...')
expect(screen.getByTestId('agent-send')).toBeDisabled()
})
it('auto-focuses input on mount', async () => {
vi.useFakeTimers()
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)

View File

@@ -5,7 +5,12 @@ import {
AiPanelHeader,
AiPanelMessageHistory,
} from './AiPanelChrome'
import { DEFAULT_AI_AGENT, getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents'
import {
DEFAULT_AI_AGENT,
getAiAgentDefinition,
type AiAgentId,
type AiAgentReadiness,
} from '../lib/aiAgents'
import { type NoteListItem } from '../utils/ai-context'
import type { VaultEntry } from '../types'
import { useAiPanelController, type AiPanelController } from './useAiPanelController'
@@ -19,6 +24,7 @@ interface AiPanelProps {
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady?: boolean
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
@@ -39,24 +45,30 @@ interface AiPanelViewProps {
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady?: boolean
activeEntry?: VaultEntry | null
entries?: VaultEntry[]
}
function readinessFromReadyFlag(ready: boolean | undefined): AiAgentReadiness {
return (ready ?? true) ? 'ready' : 'missing'
}
export function AiPanelView({
controller,
onClose,
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
defaultAiAgentReady: providedDefaultAiAgentReady,
activeEntry,
entries,
}: AiPanelViewProps) {
const defaultAiAgent = providedDefaultAiAgent ?? DEFAULT_AI_AGENT
const defaultAiAgentReady = providedDefaultAiAgentReady ?? true
const useLegacyAiExperience = providedDefaultAiAgent === undefined && providedDefaultAiAgentReady === undefined
const defaultAiAgentReadiness = providedDefaultAiAgentReadiness
?? readinessFromReadyFlag(providedDefaultAiAgentReady)
const inputRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLElement>(null)
const agentLabel = getAiAgentDefinition(defaultAiAgent).label
@@ -99,8 +111,7 @@ export function AiPanelView({
>
<AiPanelHeader
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
legacyCopy={useLegacyAiExperience}
agentReadiness={defaultAiAgentReadiness}
onClose={onClose}
onNewChat={handleNewChat}
/>
@@ -109,8 +120,7 @@ export function AiPanelView({
)}
<AiPanelMessageHistory
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
legacyCopy={useLegacyAiExperience}
agentReadiness={defaultAiAgentReadiness}
messages={agent.messages}
isActive={isActive}
onOpenNote={onOpenNote}
@@ -120,7 +130,7 @@ export function AiPanelView({
<AiPanelComposer
entries={entries ?? []}
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
agentReadiness={defaultAiAgentReadiness}
input={input}
inputRef={inputRef}
isActive={isActive}
@@ -137,6 +147,7 @@ export function AiPanel({
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
defaultAiAgentReady: providedDefaultAiAgentReady,
onFileCreated,
onFileModified,
@@ -149,10 +160,13 @@ export function AiPanel({
noteList,
noteListFilter,
}: AiPanelProps) {
const defaultAiAgentReadiness = providedDefaultAiAgentReadiness
?? readinessFromReadyFlag(providedDefaultAiAgentReady)
const controller = useAiPanelController({
vaultPath,
defaultAiAgent: providedDefaultAiAgent ?? DEFAULT_AI_AGENT,
defaultAiAgentReady: providedDefaultAiAgentReady ?? true,
defaultAiAgentReadiness,
activeEntry,
activeNoteContent,
entries,
@@ -172,6 +186,7 @@ export function AiPanel({
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={providedDefaultAiAgent}
defaultAiAgentReadiness={defaultAiAgentReadiness}
defaultAiAgentReady={providedDefaultAiAgentReady}
activeEntry={activeEntry}
entries={entries}

View File

@@ -4,13 +4,13 @@ import { AiMessage } from './AiMessage'
import { WikilinkChatInput } from './WikilinkChatInput'
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
import type { AiAgentMessage } from '../hooks/useCliAiAgent'
import type { AiAgentReadiness } from '../lib/aiAgents'
import type { NoteReference } from '../utils/ai-context'
import type { VaultEntry } from '../types'
interface AiPanelHeaderProps {
agentLabel: string
agentReady: boolean
legacyCopy: boolean
agentReadiness: AiAgentReadiness
onClose: () => void
onNewChat: () => void
}
@@ -22,8 +22,7 @@ interface AiPanelContextBarProps {
interface AiPanelMessageHistoryProps {
agentLabel: string
agentReady: boolean
legacyCopy: boolean
agentReadiness: AiAgentReadiness
messages: AiAgentMessage[]
isActive: boolean
onOpenNote?: (path: string) => void
@@ -34,7 +33,7 @@ interface AiPanelMessageHistoryProps {
interface AiPanelComposerProps {
entries: VaultEntry[]
agentLabel: string
agentReady: boolean
agentReadiness: AiAgentReadiness
input: string
inputRef: React.RefObject<HTMLDivElement | null>
isActive: boolean
@@ -45,9 +44,13 @@ interface AiPanelComposerProps {
function getComposerPlaceholder(
agentLabel: string,
agentReady: boolean,
agentReadiness: AiAgentReadiness,
): string {
if (!agentReady) {
if (agentReadiness === 'checking') {
return 'Checking AI agent availability...'
}
if (agentReadiness === 'missing') {
return `${agentLabel} is not installed. Open AI Agents in Settings.`
}
@@ -56,11 +59,27 @@ function getComposerPlaceholder(
function AiPanelEmptyState({
agentLabel,
agentReady,
agentReadiness,
hasContext,
legacyCopy,
}: Pick<AiPanelMessageHistoryProps, 'agentLabel' | 'agentReady' | 'hasContext' | 'legacyCopy'>) {
if (!agentReady) {
}: Pick<AiPanelMessageHistoryProps, 'agentLabel' | 'agentReadiness' | 'hasContext'>) {
if (agentReadiness === 'checking') {
return (
<div
className="flex flex-col items-center justify-center text-center text-muted-foreground"
style={{ paddingTop: 40 }}
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
Checking AI agent availability
</p>
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
Messages can be sent when the selected agent is ready
</p>
</div>
)
}
if (agentReadiness === 'missing') {
return (
<div
className="flex flex-col items-center justify-center text-center text-muted-foreground"
@@ -85,8 +104,8 @@ function AiPanelEmptyState({
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
{hasContext
? legacyCopy ? 'Ask about this note and its linked context' : `Ask ${agentLabel} about this note and its linked context`
: legacyCopy ? 'Open a note, then ask the AI about it' : `Open a note, then ask ${agentLabel} about it`
? `Ask ${agentLabel} about this note and its linked context`
: `Open a note, then ask ${agentLabel} about it`
}
</p>
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
@@ -101,8 +120,7 @@ function AiPanelEmptyState({
export function AiPanelHeader({
agentLabel,
agentReady,
legacyCopy,
agentReadiness,
onClose,
onNewChat,
}: AiPanelHeaderProps) {
@@ -114,14 +132,13 @@ export function AiPanelHeader({
<Robot size={16} className="shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden">
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
{legacyCopy ? 'AI Chat' : 'AI Agent'}
AI Agent
</span>
<span className="truncate text-[11px] text-muted-foreground">
{agentReadiness === 'checking'
? 'Checking availability'
: `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ''}`}
</span>
{!legacyCopy && (
<span className="truncate text-[11px] text-muted-foreground">
{agentLabel}
{!agentReady ? ' · not installed' : ''}
</span>
)}
</div>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
@@ -160,8 +177,7 @@ export function AiPanelContextBar({ activeEntry, linkedCount }: AiPanelContextBa
export function AiPanelMessageHistory({
agentLabel,
agentReady,
legacyCopy,
agentReadiness,
messages,
isActive,
onOpenNote,
@@ -179,8 +195,7 @@ export function AiPanelMessageHistory({
{messages.length === 0 && !isActive && (
<AiPanelEmptyState
agentLabel={agentLabel}
agentReady={agentReady}
legacyCopy={legacyCopy}
agentReadiness={agentReadiness}
hasContext={hasContext}
/>
)}
@@ -200,7 +215,7 @@ export function AiPanelMessageHistory({
export function AiPanelComposer({
entries,
agentLabel,
agentReady,
agentReadiness,
input,
inputRef,
isActive,
@@ -208,9 +223,9 @@ export function AiPanelComposer({
onSend,
onUnsupportedAiPaste,
}: AiPanelComposerProps) {
const composerDisabled = isActive || !agentReady
const composerDisabled = isActive || agentReadiness !== 'ready'
const canSend = !composerDisabled && input.trim().length > 0
const placeholder = getComposerPlaceholder(agentLabel, agentReady)
const placeholder = getComposerPlaceholder(agentLabel, agentReadiness)
const sendButtonStyle = {
background: canSend ? 'var(--primary)' : 'var(--muted)',
color: canSend ? 'var(--primary-foreground)' : 'var(--muted-foreground)',

View File

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

View File

@@ -15,6 +15,8 @@ import {
Trash,
Archive,
ArrowUUpLeft,
ClipboardText,
FolderOpen,
Star,
CheckCircle,
ArrowsClockwise,
@@ -42,6 +44,8 @@ interface BreadcrumbBarProps {
onToggleInspector?: () => void
onToggleFavorite?: () => void
onToggleOrganized?: () => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
onDelete?: () => void
onArchive?: () => void
onUnarchive?: () => void
@@ -375,6 +379,38 @@ function DeleteAction({ locale = 'en', onDelete }: Pick<BreadcrumbBarProps, 'loc
)
}
function FilePathActions({
entry,
locale = 'en',
onRevealFile,
onCopyFilePath,
}: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onRevealFile' | 'onCopyFilePath'>) {
return (
<>
{onRevealFile && (
<IconActionButton
copy={{ label: translate(locale, 'editor.toolbar.revealFile') }}
onClick={() => onRevealFile(entry.path)}
className="hover:text-foreground"
testId="breadcrumb-reveal-file"
>
<FolderOpen size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)}
{onCopyFilePath && (
<IconActionButton
copy={{ label: translate(locale, 'editor.toolbar.copyFilePath') }}
onClick={() => onCopyFilePath(entry.path)}
className="hover:text-foreground"
testId="breadcrumb-copy-file-path"
>
<ClipboardText size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)}
</>
)
}
function InspectorAction({
inspectorCollapsed,
locale = 'en',
@@ -596,6 +632,8 @@ function BreadcrumbActions({
onToggleInspector,
onToggleFavorite,
onToggleOrganized,
onRevealFile,
onCopyFilePath,
onDelete,
onArchive,
onUnarchive,
@@ -615,6 +653,7 @@ function BreadcrumbActions({
{!forceRawMode && <RawToggleButton rawMode={rawMode} locale={locale} onToggleRaw={onToggleRaw} />}
<NoteLayoutAction noteLayout={noteLayout} locale={locale} onToggleNoteLayout={onToggleNoteLayout} />
<AIChatAction showAIChat={showAIChat} locale={locale} onToggleAIChat={onToggleAIChat} />
<FilePathActions entry={entry} locale={locale} onRevealFile={onRevealFile} onCopyFilePath={onCopyFilePath} />
<ArchiveAction archived={entry.archived} locale={locale} onArchive={onArchive} onUnarchive={onUnarchive} />
<DeleteAction locale={locale} onDelete={onDelete} />
<InspectorAction inspectorCollapsed={inspectorCollapsed} locale={locale} onToggleInspector={onToggleInspector} />

View File

@@ -244,7 +244,7 @@ describe('CommandPalette', () => {
})
it('localizes command palette chrome', () => {
render(<CommandPalette open={true} commands={commands} locale="zh-Hans" onClose={onClose} />)
render(<CommandPalette open={true} commands={commands} locale="zh-CN" onClose={onClose} />)
const input = screen.getByPlaceholderText('输入命令...')
fireEvent.change(input, { target: { value: 'zzzzzzz' } })

View File

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

View File

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

View File

@@ -209,7 +209,7 @@ describe('DynamicPropertiesPanel', () => {
renderPanel({
frontmatter: { Status: 'Active', 'Belongs to': 'some-team' },
onAddProperty,
locale: 'zh-Hans',
locale: 'zh-CN',
})
expect(screen.getByText('Type')).toBeInTheDocument()

View File

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

View File

@@ -1,10 +1,10 @@
import { useRef, useEffect, useCallback, memo } from 'react'
import { useRef, useEffect, useCallback, memo, useState } from 'react'
import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
import { useCreateBlockNote } from '@blocknote/react'
import '@blocknote/mantine/style.css'
import 'katex/dist/katex.min.css'
import { uploadImageFile } from '../hooks/useImageDrop'
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents'
import { translate, type AppLocale } from '../lib/i18n'
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
import type { VaultEntry, GitCommit, NoteLayout, NoteStatus } from '../types'
@@ -17,7 +17,9 @@ import { useDragRegion } from '../hooks/useDragRegion'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
import { EditorRightPanel } from './EditorRightPanel'
import { EditorContent } from './EditorContent'
import { FilePreview } from './FilePreview'
import { schema } from './editorSchema'
import type { RawEditorFindRequest } from './RawEditorFindBar'
import {
applyPendingRawExitContent,
resolvePendingRawExitContent,
@@ -25,6 +27,7 @@ import {
} from './editorRawModeSync'
import { useRawModeWithFlush } from './useRawModeWithFlush'
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
import { createMathInputExtension } from './mathInputExtension'
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
import './Editor.css'
import './EditorTheme.css'
@@ -50,6 +53,7 @@ interface EditorProps {
onToggleInspector: () => void
inspectorWidth: number
defaultAiAgent?: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady?: boolean
onInspectorResize: (delta: number) => void
inspectorEntry: VaultEntry | null
@@ -68,6 +72,9 @@ interface EditorProps {
noteListFilter?: { type: string | null; query: string }
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
onOpenExternalFile?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
@@ -84,6 +91,8 @@ interface EditorProps {
leftPanelsCollapsed?: boolean
/** Mutable ref that Editor registers its raw-mode toggle into, for command palette access. */
rawToggleRef?: React.MutableRefObject<() => void>
/** Mutable ref that Editor registers editor find commands into, for shortcuts and menus. */
findInNoteRef?: React.MutableRefObject<((options?: { replace?: boolean }) => void) | null>
/** Mutable ref that Editor registers its diff-mode toggle into, for command palette access. */
diffToggleRef?: React.MutableRefObject<() => void>
onFileCreated?: (relativePath: string) => void
@@ -181,7 +190,7 @@ function useEditorSetup({
schema,
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
_tiptapOptions: { injectNonce: RUNTIME_STYLE_NONCE },
extensions: [createArrowLigaturesExtension()],
extensions: [createArrowLigaturesExtension(), createMathInputExtension()],
})
useFilenameAutolinkGuard(editor)
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
@@ -274,6 +283,43 @@ function useRegisterRawContentFlush({
}, [flushPendingRawContent, flushPendingRawContentRef])
}
function useEditorFindCommand({
activeTab,
findInNoteRef,
handleToggleRawExclusive,
rawMode,
}: {
activeTab: Tab | null
findInNoteRef?: EditorProps['findInNoteRef']
handleToggleRawExclusive: () => void
rawMode: boolean
}): RawEditorFindRequest | null {
const [findRequest, setFindRequest] = useState<RawEditorFindRequest | null>(null)
const handleFindInNote = useCallback((options: { replace?: boolean } = {}) => {
if (!activeTab || activeTab.entry.fileKind === 'binary') return
if (!rawMode) handleToggleRawExclusive()
setFindRequest((current) => ({
id: (current?.id ?? 0) + 1,
path: activeTab.entry.path,
replace: options.replace === true,
}))
}, [activeTab, handleToggleRawExclusive, rawMode])
useEffect(() => {
if (!findInNoteRef) return
findInNoteRef.current = handleFindInNote
return () => {
if (findInNoteRef.current === handleFindInNote) {
findInNoteRef.current = null
}
}
}, [findInNoteRef, handleFindInNote])
return findRequest
}
function EditorLayout({
tabs,
activeTab,
@@ -298,11 +344,15 @@ function EditorLayout({
handleEditorChange,
onToggleFavorite,
onToggleOrganized,
onRevealFile,
onCopyFilePath,
onOpenExternalFile,
onDeleteNote,
onArchiveNote,
onUnarchiveNote,
vaultPath,
rawModeContent,
findRequest,
rawLatestContentRef,
onRenameFilename,
noteLayout,
@@ -313,6 +363,7 @@ function EditorLayout({
onInspectorResize,
inspectorWidth,
defaultAiAgent,
defaultAiAgentReadiness,
defaultAiAgentReady,
inspectorEntry,
inspectorContent,
@@ -355,11 +406,15 @@ function EditorLayout({
handleEditorChange: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onRevealFile?: (path: string) => void
onCopyFilePath?: (path: string) => void
onOpenExternalFile?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
vaultPath?: string
rawModeContent: string | null
findRequest?: RawEditorFindRequest | null
rawLatestContentRef: React.MutableRefObject<string | null>
onRenameFilename?: (path: string, newFilenameStem: string) => void
noteLayout?: NoteLayout
@@ -370,6 +425,7 @@ function EditorLayout({
onInspectorResize: (delta: number) => void
inspectorWidth: number
defaultAiAgent: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady: boolean
inspectorEntry: VaultEntry | null
inspectorContent: string | null
@@ -389,12 +445,23 @@ function EditorLayout({
onUnsupportedAiPaste?: (message: string) => void
locale?: AppLocale
}) {
const activeBinaryTab = activeTab?.entry.fileKind === 'binary' ? activeTab : null
return (
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
<div className="flex flex-1 min-h-0">
{tabs.length === 0
? <EditorEmptyState locale={locale} />
: <EditorContent
: activeBinaryTab
? (
<FilePreview
entry={activeBinaryTab.entry}
onCopyFilePath={onCopyFilePath}
onOpenExternalFile={onOpenExternalFile}
onRevealFile={onRevealFile}
/>
)
: <EditorContent
activeTab={activeTab}
isLoadingNewTab={isLoadingNewTab}
entries={entries}
@@ -417,11 +484,14 @@ function EditorLayout({
onEditorChange={handleEditorChange}
onToggleFavorite={onToggleFavorite}
onToggleOrganized={onToggleOrganized}
onRevealFile={onRevealFile}
onCopyFilePath={onCopyFilePath}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
rawModeContent={rawModeContent}
findRequest={findRequest}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
noteLayout={noteLayout}
@@ -438,6 +508,7 @@ function EditorLayout({
inspectorCollapsed={inspectorCollapsed}
inspectorWidth={inspectorWidth}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReadiness={defaultAiAgentReadiness}
defaultAiAgentReady={defaultAiAgentReady}
onUnsupportedAiPaste={onUnsupportedAiPaste}
inspectorEntry={inspectorEntry}
@@ -474,19 +545,20 @@ export const Editor = memo(function Editor(props: EditorProps) {
tabs, activeTabPath, entries, onNavigateWikilink,
getNoteStatus,
inspectorCollapsed, onToggleInspector, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReadiness, defaultAiAgentReady = true,
onUnsupportedAiPaste,
onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onRenameFilename,
noteLayout, onToggleNoteLayout,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
flushPendingRawContentRef,
flushPendingRawContentRef, findInNoteRef,
locale,
} = props
@@ -505,6 +577,13 @@ export const Editor = memo(function Editor(props: EditorProps) {
getNoteStatus,
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
})
const findRequest = useEditorFindCommand({
activeTab,
findInNoteRef,
handleToggleRawExclusive,
rawMode,
})
useRegisterRawContentFlush({
activeTab,
rawLatestContentRef,
@@ -538,11 +617,15 @@ export const Editor = memo(function Editor(props: EditorProps) {
handleEditorChange={handleEditorChange}
onToggleFavorite={onToggleFavorite}
onToggleOrganized={onToggleOrganized}
onRevealFile={onRevealFile}
onCopyFilePath={onCopyFilePath}
onOpenExternalFile={onOpenExternalFile}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
rawModeContent={rawModeContent}
findRequest={findRequest}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
noteLayout={noteLayout}
@@ -553,6 +636,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
onInspectorResize={onInspectorResize}
inspectorWidth={inspectorWidth}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReadiness={defaultAiAgentReadiness}
defaultAiAgentReady={defaultAiAgentReady}
onUnsupportedAiPaste={onUnsupportedAiPaste}
inspectorEntry={inspectorEntry}

View File

@@ -1,5 +1,5 @@
import { useEffect } from 'react'
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents'
import type { AppLocale } from '../lib/i18n'
import type { VaultEntry, GitCommit } from '../types'
import type { NoteListItem } from '../utils/ai-context'
@@ -13,6 +13,7 @@ interface EditorRightPanelProps {
inspectorCollapsed: boolean
inspectorWidth: number
defaultAiAgent?: AiAgentId
defaultAiAgentReadiness?: AiAgentReadiness
defaultAiAgentReady?: boolean
onUnsupportedAiPaste?: (message: string) => void
inspectorEntry: VaultEntry | null
@@ -42,7 +43,7 @@ interface EditorRightPanelProps {
export function EditorRightPanel({
showAIChat, inspectorCollapsed, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReadiness, defaultAiAgentReady = true,
onUnsupportedAiPaste,
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
noteList, noteListFilter,
@@ -55,6 +56,7 @@ export function EditorRightPanel({
vaultPath,
defaultAiAgent,
defaultAiAgentReady,
defaultAiAgentReadiness,
activeEntry: inspectorEntry,
activeNoteContent: inspectorContent,
entries,
@@ -88,6 +90,7 @@ export function EditorRightPanel({
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReadiness={defaultAiAgentReadiness}
defaultAiAgentReady={defaultAiAgentReady}
activeEntry={inspectorEntry}
entries={entries}

View File

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

View File

@@ -0,0 +1,67 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FilePreview } from './FilePreview'
import type { VaultEntry } from '../types'
vi.mock('@tauri-apps/api/core', () => ({
convertFileSrc: (path: string) => `asset://${path}`,
}))
const imageEntry: VaultEntry = {
path: '/vault/Attachments/photo.png',
filename: 'photo.png',
title: 'photo.png',
isA: null,
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
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,
fileKind: 'binary',
}
describe('FilePreview', () => {
it('routes header file actions to the active file path', () => {
const onRevealFile = vi.fn()
const onCopyFilePath = vi.fn()
const onOpenExternalFile = vi.fn()
render(
<FilePreview
entry={imageEntry}
onRevealFile={onRevealFile}
onCopyFilePath={onCopyFilePath}
onOpenExternalFile={onOpenExternalFile}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Reveal' }))
fireEvent.click(screen.getByRole('button', { name: 'Copy path' }))
fireEvent.click(screen.getByRole('button', { name: 'Open' }))
expect(onRevealFile).toHaveBeenCalledWith('/vault/Attachments/photo.png')
expect(onCopyFilePath).toHaveBeenCalledWith('/vault/Attachments/photo.png')
expect(onOpenExternalFile).toHaveBeenCalledWith('/vault/Attachments/photo.png')
})
})

View File

@@ -0,0 +1,221 @@
import { useCallback, 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 type { VaultEntry } from '../types'
import { isImagePreviewEntry, previewFileTypeLabel } from '../utils/filePreview'
import { focusNoteListContainer } from '../utils/neighborhoodHistory'
import { openLocalFile } from '../utils/url'
import { Button } from './ui/button'
interface FilePreviewProps {
entry: VaultEntry
onCopyFilePath?: (path: string) => void
onOpenExternalFile?: (path: string) => void
onRevealFile?: (path: string) => void
}
interface FilePreviewFallbackProps {
icon: 'warning' | 'file'
title: string
description: string
onOpenExternal: () => void
}
function FilePreviewFallback({ icon, title, description, onOpenExternal }: FilePreviewFallbackProps) {
const Icon = icon === 'warning' ? WarningCircle : FileDashed
return (
<div
className="flex h-full min-h-[260px] flex-col items-center justify-center gap-4 px-8 text-center"
data-testid="file-preview-fallback"
>
<Icon size={34} className="text-muted-foreground" aria-hidden="true" />
<div className="space-y-1">
<h2 className="m-0 text-[15px] font-semibold text-foreground">{title}</h2>
<p className="m-0 max-w-md text-[13px] leading-6 text-muted-foreground">{description}</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={onOpenExternal}>
<ArrowSquareOut size={15} />
Open in default app
</Button>
</div>
)
}
function FilePreviewHeader({
entry,
isImage,
fileTypeLabel,
onOpenExternal,
onRevealFile,
onCopyFilePath,
}: {
entry: VaultEntry
isImage: boolean
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" />
<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>
</div>
</div>
<div className="flex items-center gap-1">
{onRevealFile && (
<Button type="button" variant="ghost" size="sm" onClick={onRevealFile}>
<FolderOpen size={15} />
Reveal
</Button>
)}
{onCopyFilePath && (
<Button type="button" variant="ghost" size="sm" onClick={onCopyFilePath}>
<ClipboardText size={15} />
Copy path
</Button>
)}
<Button type="button" variant="ghost" size="sm" onClick={onOpenExternal}>
<ArrowSquareOut size={15} />
Open
</Button>
</div>
</div>
)
}
function FilePreviewImage({
entry,
imageSrc,
onImageError,
}: {
entry: VaultEntry
imageSrc: string
onImageError: () => void
}) {
return (
<div className="flex h-full min-h-[260px] items-center justify-center p-6">
<img
src={imageSrc}
alt={entry.title}
className="max-h-full max-w-full object-contain"
data-testid="image-file-preview"
onError={onImageError}
/>
</div>
)
}
function shouldRenderImagePreview(isImage: boolean, imageSrc: string | null, imageFailed: boolean): imageSrc is string {
return isImage && imageSrc !== null && !imageFailed
}
function FilePreviewBody({
entry,
isImage,
imageSrc,
imageFailed,
onImageError,
onOpenExternal,
}: {
entry: VaultEntry
isImage: boolean
imageSrc: string | null
imageFailed: boolean
onImageError: () => void
onOpenExternal: () => void
}) {
if (shouldRenderImagePreview(isImage, imageSrc, imageFailed)) {
return <FilePreviewImage entry={entry} imageSrc={imageSrc} onImageError={onImageError} />
}
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.'
}
onOpenExternal={onOpenExternal}
/>
)
}
export function FilePreview({
entry,
onCopyFilePath,
onOpenExternalFile,
onRevealFile,
}: FilePreviewProps) {
const [imageFailed, setImageFailed] = useState(false)
const isImage = isImagePreviewEntry(entry)
const imageSrc = useMemo(() => (isImage ? convertFileSrc(entry.path) : null), [entry.path, isImage])
const fileTypeLabel = previewFileTypeLabel(entry)
const handleImageError = useCallback(() => setImageFailed(true), [])
const handleOpenExternal = useCallback(() => {
if (onOpenExternalFile) {
onOpenExternalFile(entry.path)
return
}
void openLocalFile(entry.path).catch((error) => {
console.warn('Failed to open file with default app:', error)
})
}, [entry.path, onOpenExternalFile])
const handleRevealFile = useCallback(() => {
onRevealFile?.(entry.path)
}, [entry.path, onRevealFile])
const handleCopyFilePath = useCallback(() => {
onCopyFilePath?.(entry.path)
}, [entry.path, onCopyFilePath])
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Escape') return
event.preventDefault()
focusNoteListContainer(document)
}, [])
return (
<section
className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"
data-testid="file-preview"
tabIndex={0}
role="group"
aria-label={`Preview ${entry.title}`}
onKeyDown={handleKeyDown}
>
<FilePreviewHeader
entry={entry}
isImage={isImage}
fileTypeLabel={fileTypeLabel}
onOpenExternal={handleOpenExternal}
onRevealFile={onRevealFile ? handleRevealFile : undefined}
onCopyFilePath={onCopyFilePath ? handleCopyFilePath : undefined}
/>
<div className="min-h-0 flex-1 overflow-auto bg-background">
<FilePreviewBody
entry={entry}
isImage={isImage}
imageSrc={imageSrc}
imageFailed={imageFailed}
onImageError={handleImageError}
onOpenExternal={handleOpenExternal}
/>
</div>
</section>
)
}

View File

@@ -223,4 +223,29 @@ describe('FolderTree', () => {
fireEvent.click(screen.getByTestId('delete-folder-menu-item'))
expect(onDeleteFolder).toHaveBeenCalledWith('projects')
})
it('opens folder file actions from the context menu', () => {
const onRevealFolder = vi.fn()
const onCopyFolderPath = vi.fn()
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={vi.fn()}
folderFileActions={{
copyFolderPath: onCopyFolderPath,
revealFolder: onRevealFolder,
}}
onStartRenameFolder={vi.fn()}
/>,
)
fireEvent.contextMenu(screen.getByText('projects'))
fireEvent.click(screen.getByTestId('reveal-folder-menu-item'))
expect(onRevealFolder).toHaveBeenCalledWith('projects')
fireEvent.contextMenu(screen.getByText('projects'))
fireEvent.click(screen.getByTestId('copy-folder-path-menu-item'))
expect(onCopyFolderPath).toHaveBeenCalledWith('projects')
})
})

View File

@@ -14,6 +14,7 @@ import { useFolderContextMenu } from './folder-tree/useFolderContextMenu'
import { useFolderTreeDisclosure } from './folder-tree/useFolderTreeDisclosure'
import { SidebarGroupHeader } from './sidebar/SidebarGroupHeader'
import { translate, type AppLocale } from '../lib/i18n'
import type { FolderFileActions } from '../hooks/useFileActions'
interface FolderTreeProps {
folders: FolderNode[]
@@ -22,6 +23,7 @@ interface FolderTreeProps {
onCreateFolder?: (name: string) => Promise<boolean> | boolean
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
onDeleteFolder?: (folderPath: string) => void
folderFileActions?: FolderFileActions
renamingFolderPath?: string | null
onStartRenameFolder?: (folderPath: string) => void
onCancelRenameFolder?: () => void
@@ -37,6 +39,7 @@ export const FolderTree = memo(function FolderTree({
onCreateFolder,
onRenameFolder,
onDeleteFolder,
folderFileActions,
renamingFolderPath,
onStartRenameFolder,
onCancelRenameFolder,
@@ -61,12 +64,15 @@ export const FolderTree = memo(function FolderTree({
const {
closeContextMenu,
contextMenu,
handleCopyPathFromMenu,
handleDeleteFromMenu,
handleOpenMenu,
handleRevealFromMenu,
handleRenameFromMenu,
menuRef,
} = useFolderContextMenu({
onDeleteFolder,
folderFileActions,
onStartRenameFolder,
})
@@ -82,28 +88,18 @@ export const FolderTree = memo(function FolderTree({
return created
}, [closeCreateForm, onCreateFolder])
const handleCreateFolderClick = useCallback(() => {
closeContextMenu()
openCreateForm()
}, [closeContextMenu, openCreateForm])
if (folders.length === 0 && !isCreating) return null
return (
<div className="border-b border-border" style={{ padding: '0 6px' }}>
<SidebarGroupHeader label={translate(locale, 'sidebar.group.folders')} collapsed={sectionCollapsed} onToggle={handleToggleSection}>
{onCreateFolder && (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-auto w-auto min-w-0 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
data-testid="create-folder-btn"
title={translate(locale, 'sidebar.action.createFolder')}
aria-label={translate(locale, 'sidebar.action.createFolder')}
onClick={(event) => {
event.stopPropagation()
closeContextMenu()
openCreateForm()
}}
>
<Plus size={12} className="text-muted-foreground hover:text-foreground" />
</Button>
<CreateFolderButton locale={locale} onCreate={handleCreateFolderClick} />
)}
</SidebarGroupHeader>
{!sectionCollapsed && (
@@ -145,9 +141,37 @@ export const FolderTree = memo(function FolderTree({
menu={contextMenu}
menuRef={menuRef}
onDelete={handleDeleteFromMenu}
onReveal={handleRevealFromMenu}
onCopyPath={handleCopyPathFromMenu}
onRename={handleRenameFromMenu}
locale={locale}
/>
</div>
)
})
function CreateFolderButton({
locale,
onCreate,
}: {
locale: AppLocale
onCreate: () => void
}) {
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-auto w-auto min-w-0 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
data-testid="create-folder-btn"
title={translate(locale, 'sidebar.action.createFolder')}
aria-label={translate(locale, 'sidebar.action.createFolder')}
onClick={(event) => {
event.stopPropagation()
onCreate()
}}
>
<Plus size={12} className="text-muted-foreground hover:text-foreground" />
</Button>
)
}

View File

@@ -41,6 +41,8 @@ const MENU_SECTIONS: ReadonlyArray<MenuSection> = [
{
label: 'Edit',
items: [
{ kind: 'command', label: 'Find in Note', commandId: APP_COMMAND_IDS.editFindInNote },
{ kind: 'command', label: 'Replace in Note', commandId: APP_COMMAND_IDS.editReplaceInNote },
{ kind: 'command', label: 'Find in Vault', commandId: APP_COMMAND_IDS.editFindInVault },
{ kind: 'command', label: 'Toggle Note List Search', commandId: 'edit-toggle-note-list-search' },
{ kind: 'command', label: 'Toggle Diff Mode', commandId: APP_COMMAND_IDS.editToggleDiff },

View File

@@ -17,6 +17,10 @@ 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.getByText('~/.claude/mcp.json')).toBeInTheDocument()
expect(screen.getAllByText('~/.config/mcp/mcp.json')).toHaveLength(2)

View File

@@ -66,6 +66,9 @@ export function McpSetupDialog({
</DialogHeader>
<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.
</p>
<p>
Confirming this action will write or update Tolaria&apos;s single <code className="rounded bg-muted px-1 py-0.5 text-xs">tolaria</code> MCP entry in:
</p>
@@ -75,6 +78,13 @@ export function McpSetupDialog({
<div>~/.cursor/mcp.json</div>
<div>~/.config/mcp/mcp.json</div>
</div>
<div className="rounded-md border border-border bg-background px-3 py-3 font-mono text-xs leading-5 text-foreground">
<div>type: stdio</div>
<div>command: node</div>
<div>args: &lt;Tolaria resources&gt;/mcp-server/index.js</div>
<div>VAULT_PATH: active vault</div>
<div>WS_UI_PORT: 9711</div>
</div>
<p>
Claude Code CLI reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.claude.json</code>, Cursor reads <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.cursor/mcp.json</code>, and the generic <code className="rounded bg-muted px-1 py-0.5 text-xs">~/.config/mcp/mcp.json</code> path is picked up by other MCP-compatible tools. Cancel leaves all files untouched, reconnect is idempotent, and disconnect removes Tolaria&apos;s entry again.
</p>

View File

@@ -0,0 +1,66 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MermaidDiagram } from './MermaidDiagram'
const mermaidMock = vi.hoisted(() => ({
initialize: vi.fn(),
render: vi.fn(),
}))
vi.mock('mermaid', () => ({
default: mermaidMock,
}))
describe('MermaidDiagram', () => {
beforeEach(() => {
vi.clearAllMocks()
mermaidMock.render.mockResolvedValue({
svg: '<svg aria-label="Rendered Mermaid"><g><text>A to B</text></g></svg>',
})
})
it('renders Mermaid SVG for valid source', async () => {
render(
<MermaidDiagram
diagram={'flowchart LR\nA --> B'}
source={'```mermaid\nflowchart LR\nA --> B\n```'}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('mermaid-diagram-viewport').querySelector('svg')).not.toBeNull()
})
expect(mermaidMock.render).toHaveBeenCalledWith(expect.stringMatching(/^tolaria-mermaid-/), 'flowchart LR\nA --> B')
expect(mermaidMock.initialize).toHaveBeenCalledWith(expect.objectContaining({ theme: 'default' }))
})
it('opens the rendered SVG in a lightbox', async () => {
render(
<MermaidDiagram
diagram={'flowchart LR\nA --> B'}
source={'```mermaid\nflowchart LR\nA --> B\n```'}
/>,
)
await waitFor(() => {
expect(screen.getByTestId('mermaid-diagram-viewport').querySelector('svg')).not.toBeNull()
})
fireEvent.click(screen.getByRole('button', { name: 'Open Mermaid diagram' }))
expect(screen.getByTestId('mermaid-diagram-dialog-viewport').querySelector('svg')).not.toBeNull()
})
it('falls back to the original source when Mermaid cannot render', async () => {
mermaidMock.render.mockRejectedValueOnce(new Error('parse error'))
render(
<MermaidDiagram
diagram={'flowchart LR\nA --'}
source={'```mermaid\nflowchart LR\nA --\n```'}
/>,
)
expect(await screen.findByText('Mermaid diagram unavailable')).toBeInTheDocument()
expect(screen.getByLabelText('Mermaid source')).toHaveTextContent('flowchart LR')
})
})

View File

@@ -0,0 +1,157 @@
import { useEffect, useId, useMemo, useState } from 'react'
import { Maximize2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
type MermaidApi = typeof import('mermaid')['default']
interface MermaidDiagramProps {
diagram: string
source: string
}
interface MermaidSvgViewportProps {
ariaLabel: string
className: string
svg: string
testId: string
}
interface RenderState {
diagram: string
svg: string
error: boolean
}
let initialized = false
let renderQueue = Promise.resolve()
function renderIdFromReactId(reactId: string): string {
const safeId = reactId.replace(/[^a-zA-Z0-9_-]/g, '')
return `tolaria-mermaid-${safeId || 'diagram'}`
}
function initializeMermaid(mermaid: MermaidApi) {
if (initialized) return
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
theme: 'default',
themeVariables: {
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
},
})
initialized = true
}
async function renderMermaidDiagram({
diagram,
renderId,
}: {
diagram: string
renderId: string
}): Promise<string> {
const render = async () => {
const mermaid = (await import('mermaid')).default
initializeMermaid(mermaid)
const result = await mermaid.render(renderId, diagram)
return result.svg
}
const nextRender = renderQueue.then(render, render)
renderQueue = nextRender.then(() => undefined, () => undefined)
return nextRender
}
function MermaidSvgViewport({ ariaLabel, className, svg, testId }: MermaidSvgViewportProps) {
return (
<div
aria-label={ariaLabel}
className={className}
data-testid={testId}
role="img"
tabIndex={0}
dangerouslySetInnerHTML={{ __html: svg }}
/>
)
}
function MermaidLightbox({ svg }: { svg: string }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button
aria-label="Open Mermaid diagram"
className="mermaid-diagram__expand-button"
size="icon-sm"
title="Open diagram"
type="button"
variant="outline"
>
<Maximize2 aria-hidden="true" />
</Button>
</DialogTrigger>
<DialogContent className="mermaid-diagram__dialog" showCloseButton>
<DialogTitle className="sr-only">Mermaid diagram</DialogTitle>
<DialogDescription className="sr-only">
Expanded view of the rendered Mermaid diagram.
</DialogDescription>
<MermaidSvgViewport
ariaLabel="Expanded Mermaid diagram"
className="mermaid-diagram__dialog-viewport"
svg={svg}
testId="mermaid-diagram-dialog-viewport"
/>
</DialogContent>
</Dialog>
)
}
export function MermaidDiagram({ diagram, source }: MermaidDiagramProps) {
const reactId = useId()
const renderId = useMemo(() => renderIdFromReactId(reactId), [reactId])
const [state, setState] = useState<RenderState>({ diagram: '', svg: '', error: false })
useEffect(() => {
let active = true
if (!diagram.trim()) return () => { active = false }
renderMermaidDiagram({ diagram, renderId })
.then((svg) => {
if (active) setState({ diagram, svg, error: false })
})
.catch(() => {
if (active) setState({ diagram, svg: '', error: true })
})
return () => { active = false }
}, [diagram, renderId])
const currentState = state.diagram === diagram ? state : { diagram, svg: '', error: false }
if (!diagram.trim() || currentState.error) {
return (
<figure className="mermaid-diagram mermaid-diagram--error" data-testid="mermaid-diagram-error">
<figcaption>Mermaid diagram unavailable</figcaption>
<pre aria-label="Mermaid source"><code>{source}</code></pre>
</figure>
)
}
return (
<figure className="mermaid-diagram" data-testid="mermaid-diagram">
<MermaidLightbox svg={currentState.svg} />
<MermaidSvgViewport
ariaLabel="Mermaid diagram"
className="mermaid-diagram__viewport"
svg={currentState.svg}
testId="mermaid-diagram-viewport"
/>
</figure>
)
}

View File

@@ -19,11 +19,11 @@ describe('NoteItem', () => {
openExternalUrl.mockClear()
})
it('renders binary files as non-clickable muted rows', () => {
it('renders unsupported binary files as non-clickable muted rows', () => {
const binaryEntry = makeEntry({
path: '/vault/photo.png',
filename: 'photo.png',
title: 'photo.png',
path: '/vault/archive.zip',
filename: 'archive.zip',
title: 'archive.zip',
fileKind: 'binary',
})
const onClickNote = vi.fn()
@@ -38,6 +38,26 @@ describe('NoteItem', () => {
expect(onClickNote).not.toHaveBeenCalled()
})
it('renders image files as clickable rows with an image file indicator', () => {
const imageEntry = makeEntry({
path: '/vault/photo.png',
filename: 'photo.png',
title: 'photo.png',
fileKind: 'binary',
})
const onClickNote = vi.fn()
render(<NoteItem entry={imageEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClickNote} />)
const item = screen.getByTestId('image-file-item')
expect(item.className).not.toContain('opacity-50')
expect(item).toHaveAttribute('title', 'Open image preview')
fireEvent.click(item)
expect(onClickNote).toHaveBeenCalledWith(imageEntry, expect.any(Object))
expect(screen.getByTestId('type-icon')).toHaveAttribute('data-file-preview-kind', 'image')
})
it('renders text files as clickable rows', () => {
const textEntry = makeEntry({
path: '/vault/config.yml',

View File

@@ -4,11 +4,12 @@ import { cn } from '@/lib/utils'
import {
Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, FileText, StackSimple,
File, FileDashed,
File, FileDashed, 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 { NoteTitleIcon } from './NoteTitleIcon'
import { PropertyChips } from './note-item/PropertyChips'
import { ChangeNoteContent } from './note-item/ChangeNoteContent'
@@ -61,7 +62,7 @@ function StateBadge({ archived }: { archived: boolean }) {
}
type NoteItemVisualState = {
isBinary: boolean
isUnavailableBinary: boolean
isSelected: boolean
isMultiSelected: boolean
isHighlighted: boolean
@@ -89,8 +90,8 @@ const NOTE_ITEM_ROW_CLASS_NAMES: Record<NoteItemRowState, string> = {
default: 'cursor-pointer hover:bg-muted',
}
function resolveNoteItemRowState({ isBinary, isSelected, isMultiSelected, isHighlighted }: NoteItemVisualState): NoteItemRowState {
if (isBinary) return 'binary'
function resolveNoteItemRowState({ isUnavailableBinary, isSelected, isMultiSelected, isHighlighted }: NoteItemVisualState): NoteItemRowState {
if (isUnavailableBinary) return 'binary'
if (isMultiSelected) return 'multiSelected'
if (isSelected) return 'selected'
if (isHighlighted) return 'highlighted'
@@ -104,11 +105,22 @@ function noteItemClassName(state: NoteItemVisualState) {
function NoteTypeIndicator({
TypeIcon,
typeColor,
filePreviewKind,
}: {
TypeIcon: ComponentType<SVGAttributes<SVGSVGElement>>
typeColor: string
filePreviewKind?: 'image'
}) {
return <TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
return (
<TypeIcon
width={14}
height={14}
className="absolute right-3 top-2.5"
style={{ color: typeColor }}
data-testid="type-icon"
data-file-preview-kind={filePreviewKind}
/>
)
}
function NoteSnippet({ snippet }: { snippet?: string | null }) {
@@ -190,6 +202,7 @@ function InteractiveNoteDetails({
}
function resolveNoteTypeIcon(entry: VaultEntry, customIcon?: string | null): ComponentType<SVGAttributes<SVGSVGElement>> {
if (isImagePreviewEntry(entry)) return ImageSquare
if (entry.fileKind && entry.fileKind !== 'markdown') return getFileKindIcon(entry.fileKind)
return getTypeIcon(entry.isA, customIcon)
}
@@ -197,6 +210,7 @@ function resolveNoteTypeIcon(entry: VaultEntry, customIcon?: string | null): Com
function StandardNoteContent({
entry,
isBinary,
isUnavailableBinary,
noteStatus,
isSelected,
typeColor,
@@ -207,6 +221,7 @@ function StandardNoteContent({
}: {
entry: VaultEntry
isBinary: boolean
isUnavailableBinary: boolean
noteStatus: NoteStatus
isSelected: boolean
typeColor: string
@@ -217,15 +232,16 @@ function StandardNoteContent({
}) {
const te = typeEntryMap[entry.isA ?? '']
const TypeIcon = resolveNoteTypeIcon(entry, te?.icon)
const filePreviewKind = isImagePreviewEntry(entry) ? 'image' : undefined
return (
<>
<NoteTypeIndicator TypeIcon={TypeIcon} typeColor={typeColor} />
<NoteTypeIndicator TypeIcon={TypeIcon} typeColor={typeColor} filePreviewKind={filePreviewKind} />
<div className="space-y-2" data-testid="note-content-stack">
{isBinary ? (
<NoteTitleRow
entry={entry}
isBinary={true}
isBinary={isUnavailableBinary}
isSelected={isSelected}
noteStatus={noteStatus}
/>
@@ -316,10 +332,10 @@ type NoteItemProps = {
function createNoteItemClickHandler(
entry: VaultEntry,
isBinary: boolean,
isUnavailableBinary: boolean,
onClickNote: NoteItemProps['onClickNote'],
) {
if (isBinary) {
if (isUnavailableBinary) {
return (event: ReactMouseEvent) => {
event.preventDefault()
event.stopPropagation()
@@ -328,9 +344,46 @@ function createNoteItemClickHandler(
return (event: ReactMouseEvent) => onClickNote(entry, event)
}
function resolveNoteItemSurfaceStyle({
isUnavailableBinary,
isSelected,
isMultiSelected,
typeColor,
typeLightColor,
}: Pick<NoteItemVisualState, 'isUnavailableBinary' | 'isSelected' | 'isMultiSelected'> & {
typeColor: string
typeLightColor: string
}) {
if (isUnavailableBinary) return BINARY_NOTE_STYLE
return noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor)
}
function resolveNoteItemTestId({
isMultiSelected,
isImagePreview,
isUnavailableBinary,
}: Pick<NoteItemVisualState, 'isMultiSelected' | 'isUnavailableBinary'> & {
isImagePreview: boolean
}) {
if (isMultiSelected) return 'multi-selected-item'
if (isImagePreview) return 'image-file-item'
return isUnavailableBinary ? 'binary-file-item' : undefined
}
function resolveNoteItemTitle({
isImagePreview,
isUnavailableBinary,
}: Pick<NoteItemVisualState, 'isUnavailableBinary'> & {
isImagePreview: boolean
}) {
if (isImagePreview) return 'Open image preview'
return isUnavailableBinary ? 'Cannot open this file type' : undefined
}
function resolveNoteItemSurfaceProps({
entry,
isBinary,
isUnavailableBinary,
isImagePreview,
isSelected,
isMultiSelected,
isHighlighted,
@@ -341,6 +394,7 @@ function resolveNoteItemSurfaceProps({
typeLightColor,
}: NoteItemVisualState & {
entry: VaultEntry
isImagePreview: boolean
onClickNote: NoteItemProps['onClickNote']
onPrefetch?: NoteItemProps['onPrefetch']
onContextMenu?: NoteItemProps['onContextMenu']
@@ -348,13 +402,13 @@ function resolveNoteItemSurfaceProps({
typeLightColor: string
}): NoteItemSurfaceProps {
return {
className: noteItemClassName({ isBinary, isSelected, isMultiSelected, isHighlighted }),
style: isBinary ? BINARY_NOTE_STYLE : noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor),
onClick: createNoteItemClickHandler(entry, isBinary, onClickNote),
className: noteItemClassName({ isUnavailableBinary, isSelected, isMultiSelected, isHighlighted }),
style: resolveNoteItemSurfaceStyle({ isUnavailableBinary, isSelected, isMultiSelected, typeColor, typeLightColor }),
onClick: createNoteItemClickHandler(entry, isUnavailableBinary, onClickNote),
onContextMenu: onContextMenu ? (event) => onContextMenu(entry, event) : undefined,
onMouseEnter: !isBinary && onPrefetch ? () => onPrefetch(entry.path) : undefined,
testId: isMultiSelected ? 'multi-selected-item' : isBinary ? 'binary-file-item' : undefined,
title: isBinary ? 'Cannot open this file type' : undefined,
onMouseEnter: entry.fileKind !== 'binary' && onPrefetch ? () => onPrefetch(entry.path) : undefined,
testId: resolveNoteItemTestId({ isMultiSelected, isImagePreview, isUnavailableBinary }),
title: resolveNoteItemTitle({ isImagePreview, isUnavailableBinary }),
}
}
@@ -392,6 +446,7 @@ function NoteItemRow({
function NoteItemContent({
entry,
isBinary,
isUnavailableBinary,
isSelected,
noteStatus,
changeStatus,
@@ -403,6 +458,7 @@ function NoteItemContent({
}: {
entry: VaultEntry
isBinary: boolean
isUnavailableBinary: boolean
isSelected: boolean
noteStatus: NoteStatus
changeStatus?: NoteItemProps['changeStatus']
@@ -427,6 +483,7 @@ function NoteItemContent({
<StandardNoteContent
entry={entry}
isBinary={isBinary}
isUnavailableBinary={isUnavailableBinary}
noteStatus={noteStatus}
isSelected={isSelected}
typeColor={typeColor}
@@ -440,13 +497,16 @@ 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 te = typeEntryMap[entry.isA ?? '']
const displayProps = resolveDisplayProps(entry, typeEntryMap, displayPropsOverride)
const typeColor = isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color)
const typeColor = isImagePreview ? '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,
isBinary,
isUnavailableBinary,
isImagePreview,
isSelected,
isMultiSelected,
isHighlighted,
@@ -467,6 +527,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
<NoteItemContent
entry={entry}
isBinary={isBinary}
isUnavailableBinary={isUnavailableBinary}
isSelected={isSelected}
noteStatus={noteStatus}
changeStatus={changeStatus}

View File

@@ -5,7 +5,7 @@ import { isTauri, mockInvoke } from '../mock-tauri'
import { useDragRegion } from '../hooks/useDragRegion'
import type { PulseCommit, PulseFile } from '../types'
import { relativeDate } from '../utils/noteListHelpers'
import { translate, type AppLocale } from '../lib/i18n'
import { getLocaleDateLocale, translate, type AppLocale } from '../lib/i18n'
import {
Plus, Minus, PencilSimple, GitCommit, ArrowSquareOut,
FileText, CaretDown, CaretRight, Pulse,
@@ -57,7 +57,7 @@ function formatDayLabel(dateKey: string, locale: AppLocale): string {
if (isYesterday(dateKey)) return translate(locale, 'pulse.yesterday')
const date = new Date(`${dateKey}T00:00:00`)
const dateLocale = locale === 'zh-Hans' ? 'zh-CN' : 'en-US'
const dateLocale = getLocaleDateLocale(locale)
return date.toLocaleDateString(dateLocale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
}

View File

@@ -0,0 +1,97 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import type { EditorView } from '@codemirror/view'
import { RawEditorFindBar } from './RawEditorFindBar'
function renderFindBar(overrides: Partial<React.ComponentProps<typeof RawEditorFindBar>> = {}) {
const view = {
dispatch: vi.fn(),
focus: vi.fn(),
} as unknown as EditorView
const props = {
doc: 'Alpha beta Alpha',
locale: 'en' as const,
onClose: vi.fn(),
onReplaceOpenChange: vi.fn(),
open: true,
path: '/vault/a.md',
replaceOpen: false,
request: { id: 1, path: '/vault/a.md', replace: false },
viewRef: { current: view },
...overrides,
}
render(<RawEditorFindBar {...props} />)
return { props, view }
}
describe('RawEditorFindBar', () => {
it('finds matches and moves the CodeMirror selection', async () => {
const { view } = renderFindBar()
const input = screen.getByTestId('raw-editor-find-input')
input.focus()
fireEvent.change(input, {
target: { value: 'Alpha' },
})
await waitFor(() => {
expect(screen.getByTestId('raw-editor-find-count')).toHaveTextContent('1 / 2')
expect(view.dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
selection: { anchor: 0, head: 5 },
}))
})
expect(input).toHaveFocus()
expect(view.focus).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Next match' }))
await waitFor(() => {
expect(screen.getByTestId('raw-editor-find-count')).toHaveTextContent('2 / 2')
expect(view.dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
selection: { anchor: 11, head: 16 },
}))
})
})
it('opens replace mode and dispatches regex replacement changes', async () => {
const onReplaceOpenChange = vi.fn()
const { view } = renderFindBar({
doc: 'foo-123 foo-456',
onReplaceOpenChange,
replaceOpen: true,
request: { id: 2, path: '/vault/a.md', replace: true },
})
fireEvent.click(screen.getByRole('button', { name: 'Use regular expression' }))
fireEvent.change(screen.getByTestId('raw-editor-find-input'), {
target: { value: 'foo-(\\d+)' },
})
fireEvent.change(screen.getByTestId('raw-editor-replace-input'), {
target: { value: 'bar-$1' },
})
await waitFor(() => {
expect(screen.getByTestId('raw-editor-find-count')).toHaveTextContent('1 / 2')
})
fireEvent.click(screen.getByRole('button', { name: 'Replace' }))
expect(view.dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
changes: { from: 0, insert: 'bar-123', to: 7 },
selection: { anchor: 0, head: 7 },
}))
expect(view.focus).toHaveBeenCalled()
expect(onReplaceOpenChange).toHaveBeenCalledWith(true)
})
it('closes on Escape', () => {
const onClose = vi.fn()
renderFindBar({ onClose })
fireEvent.keyDown(screen.getByTestId('raw-editor-find-bar'), { key: 'Escape' })
expect(onClose).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,528 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronRight, ChevronUp, X } from 'lucide-react'
import { EditorView } from '@codemirror/view'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { translate, type AppLocale } from '../lib/i18n'
import {
buildEditorFindReplacementChange,
buildEditorFindReplacementChanges,
clampEditorFindIndex,
findEditorMatches,
nextEditorFindIndex,
type EditorFindMatch,
type EditorFindOptions,
} from '../utils/editorFind'
export interface RawEditorFindRequest {
id: number
path: string
replace: boolean
}
interface RawEditorFindBarProps {
doc: string
locale?: AppLocale
onClose: () => void
onReplaceOpenChange: (open: boolean) => void
open: boolean
path: string
replaceOpen: boolean
request?: RawEditorFindRequest | null
viewRef: React.MutableRefObject<EditorView | null>
}
function selectMatch(view: EditorView, match: EditorFindMatch, focusEditor: boolean): void {
view.dispatch({
selection: { anchor: match.from, head: match.to },
effects: EditorView.scrollIntoView(match.from, { y: 'center' }),
})
if (focusEditor) view.focus()
}
function matchStatusText(
locale: AppLocale,
error: string | null,
activeIndex: number,
matchCount: number,
): string {
if (error === 'Invalid regex') return translate(locale, 'editor.find.invalidRegex')
if (error) return translate(locale, 'editor.find.regexMustMatchText')
if (matchCount === 0) return translate(locale, 'editor.find.noMatches')
return translate(locale, 'editor.find.matchCount', {
current: clampEditorFindIndex(activeIndex, matchCount) + 1,
total: matchCount,
})
}
function useRequestFocus({
inputRef,
onReplaceOpenChange,
open,
path,
request,
}: {
inputRef: React.RefObject<HTMLInputElement | null>
onReplaceOpenChange: (open: boolean) => void
open: boolean
path: string
request?: RawEditorFindRequest | null
}) {
useEffect(() => {
if (!open || !request || request.path !== path) return
if (request.replace) onReplaceOpenChange(true)
const frameId = requestAnimationFrame(() => {
inputRef.current?.focus()
inputRef.current?.select()
})
return () => cancelAnimationFrame(frameId)
}, [inputRef, onReplaceOpenChange, open, path, request])
}
function focusEditorOnNextFrame(viewRef: React.MutableRefObject<EditorView | null>): void {
requestAnimationFrame(() => viewRef.current?.focus())
}
function closeRawEditorFind(
onClose: () => void,
viewRef: React.MutableRefObject<EditorView | null>,
): void {
onClose()
focusEditorOnNextFrame(viewRef)
}
function handleRawEditorFindKeyDown(
event: React.KeyboardEvent<HTMLInputElement>,
close: () => void,
moveMatch: (direction: 1 | -1) => void,
): void {
if (event.key === 'Escape') {
event.preventDefault()
close()
return
}
if (event.key !== 'Enter') return
event.preventDefault()
moveMatch(event.shiftKey ? -1 : 1)
}
function handleRawEditorFindBarKeyDown(
event: React.KeyboardEvent<HTMLDivElement>,
close: () => void,
): void {
if (event.key !== 'Escape') return
event.preventDefault()
close()
}
function selectActiveEditorFindMatch(
viewRef: React.MutableRefObject<EditorView | null>,
open: boolean,
activeMatch?: EditorFindMatch,
): void {
const view = viewRef.current
if (!open || !view || !activeMatch) return
selectMatch(view, activeMatch, false)
}
function replaceCurrentEditorFindMatch({
activeMatch,
options,
query,
replacement,
viewRef,
}: {
activeMatch?: EditorFindMatch
options: EditorFindOptions
query: string
replacement: string
viewRef: React.MutableRefObject<EditorView | null>
}): void {
const view = viewRef.current
if (!view || !activeMatch) return
const change = buildEditorFindReplacementChange(activeMatch, query, replacement, options)
view.dispatch({
changes: change,
selection: { anchor: change.from, head: change.from + change.insert.length },
effects: EditorView.scrollIntoView(change.from, { y: 'center' }),
})
view.focus()
}
function replaceAllEditorFindMatches({
matches,
options,
query,
replacement,
viewRef,
}: {
matches: readonly EditorFindMatch[]
options: EditorFindOptions
query: string
replacement: string
viewRef: React.MutableRefObject<EditorView | null>
}): boolean {
const view = viewRef.current
if (!view || matches.length === 0) return false
const changes = buildEditorFindReplacementChanges(matches, query, replacement, options)
view.dispatch({ changes })
view.focus()
return true
}
interface RawEditorFindController {
caseSensitive: boolean
close: () => void
findInputRef: React.RefObject<HTMLInputElement | null>
handleBarKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
handleFindChange: (event: React.ChangeEvent<HTMLInputElement>) => void
handleFindKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
hasMatches: boolean
moveNext: () => void
movePrevious: () => void
query: string
regex: boolean
replaceAll: () => void
replaceCurrent: () => void
replacement: string
setReplacement: (value: string) => void
status: string
toggleCaseSensitive: () => void
toggleRegex: () => void
}
function useRawEditorFindController({
doc,
locale = 'en',
onClose,
onReplaceOpenChange,
open,
path,
request,
viewRef,
}: Omit<RawEditorFindBarProps, 'replaceOpen'>): RawEditorFindController {
const inputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState('')
const [replacement, setReplacement] = useState('')
const [regex, setRegex] = useState(false)
const [caseSensitive, setCaseSensitive] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const options = useMemo<EditorFindOptions>(() => ({ caseSensitive, regex }), [caseSensitive, regex])
const result = useMemo(() => findEditorMatches(doc, query, options), [doc, options, query])
const clampedActiveIndex = clampEditorFindIndex(activeIndex, result.matches.length)
const activeMatch = result.matches[clampedActiveIndex]
const status = matchStatusText(locale, result.error, clampedActiveIndex, result.matches.length)
const hasMatches = result.matches.length > 0 && !result.error
useRequestFocus({ inputRef, onReplaceOpenChange, open, path, request })
useEffect(() => {
selectActiveEditorFindMatch(viewRef, open, activeMatch)
}, [activeMatch, open, viewRef])
const moveMatch = useCallback((direction: 1 | -1) => {
setActiveIndex((current) => nextEditorFindIndex(current, result.matches.length, direction))
}, [result.matches.length])
const movePrevious = useCallback(() => moveMatch(-1), [moveMatch])
const moveNext = useCallback(() => moveMatch(1), [moveMatch])
const handleFindChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value)
setActiveIndex(0)
}, [])
const close = useCallback(() => closeRawEditorFind(onClose, viewRef), [onClose, viewRef])
const handleFindKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
handleRawEditorFindKeyDown(event, close, moveMatch)
}, [close, moveMatch])
const handleBarKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
handleRawEditorFindBarKeyDown(event, close)
}, [close])
const replaceCurrent = useCallback(() => {
replaceCurrentEditorFindMatch({ activeMatch, options, query, replacement, viewRef })
}, [activeMatch, options, query, replacement, viewRef])
const replaceAll = useCallback(() => {
if (replaceAllEditorFindMatches({
matches: result.matches,
options,
query,
replacement,
viewRef,
})) {
setActiveIndex(0)
}
}, [options, query, replacement, result.matches, viewRef])
return {
caseSensitive,
close,
findInputRef: inputRef,
handleBarKeyDown,
handleFindChange,
handleFindKeyDown,
hasMatches,
moveNext,
movePrevious,
query,
regex,
replaceAll,
replaceCurrent,
replacement,
setReplacement,
status,
toggleCaseSensitive: () => setCaseSensitive((value) => !value),
toggleRegex: () => setRegex((value) => !value),
}
}
type FindControlsProps = Pick<
RawEditorFindController,
| 'caseSensitive'
| 'close'
| 'findInputRef'
| 'handleFindChange'
| 'handleFindKeyDown'
| 'hasMatches'
| 'moveNext'
| 'movePrevious'
| 'query'
| 'regex'
| 'status'
| 'toggleCaseSensitive'
| 'toggleRegex'
> & {
locale: AppLocale
onReplaceOpenChange: (open: boolean) => void
replaceOpen: boolean
}
function FindControls({
caseSensitive,
close,
findInputRef,
handleFindChange,
handleFindKeyDown,
hasMatches,
locale,
moveNext,
movePrevious,
onReplaceOpenChange,
query,
regex,
replaceOpen,
status,
toggleCaseSensitive,
toggleRegex,
}: FindControlsProps) {
return (
<div className="flex min-w-0 items-center gap-1.5">
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={replaceOpen ? translate(locale, 'editor.find.hideReplace') : translate(locale, 'editor.find.showReplace')}
title={replaceOpen ? translate(locale, 'editor.find.hideReplace') : translate(locale, 'editor.find.showReplace')}
onClick={() => onReplaceOpenChange(!replaceOpen)}
>
<ChevronRight className={cn('transition-transform', replaceOpen && 'rotate-90')} />
</Button>
<Input
ref={findInputRef}
type="search"
aria-label={translate(locale, 'editor.find.findLabel')}
placeholder={translate(locale, 'editor.find.findPlaceholder')}
value={query}
onChange={handleFindChange}
onKeyDown={handleFindKeyDown}
className="h-7 min-w-[12rem] flex-1 rounded px-2 text-xs"
data-testid="raw-editor-find-input"
/>
<span
className="min-w-[4.75rem] text-right text-xs text-muted-foreground"
aria-live="polite"
data-testid="raw-editor-find-count"
>
{status}
</span>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(locale, 'editor.find.previousMatch')}
title={translate(locale, 'editor.find.previousMatch')}
disabled={!hasMatches}
onClick={movePrevious}
>
<ChevronUp />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(locale, 'editor.find.nextMatch')}
title={translate(locale, 'editor.find.nextMatch')}
disabled={!hasMatches}
onClick={moveNext}
>
<ChevronDown />
</Button>
<Button
type="button"
variant={regex ? 'secondary' : 'ghost'}
size="xs"
aria-label={translate(locale, 'editor.find.regex')}
aria-pressed={regex}
title={translate(locale, 'editor.find.regex')}
onClick={toggleRegex}
>
.*
</Button>
<Button
type="button"
variant={caseSensitive ? 'secondary' : 'ghost'}
size="xs"
aria-label={translate(locale, 'editor.find.matchCase')}
aria-pressed={caseSensitive}
title={translate(locale, 'editor.find.matchCase')}
onClick={toggleCaseSensitive}
>
Aa
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(locale, 'editor.find.close')}
title={translate(locale, 'editor.find.close')}
onClick={close}
>
<X />
</Button>
</div>
)
}
type ReplaceControlsProps = Pick<
RawEditorFindController,
'hasMatches' | 'replaceAll' | 'replaceCurrent' | 'replacement' | 'setReplacement'
> & {
locale: AppLocale
}
function ReplaceControls({
hasMatches,
locale,
replaceAll,
replaceCurrent,
replacement,
setReplacement,
}: ReplaceControlsProps) {
return (
<div className="ml-[1.875rem] flex min-w-0 items-center gap-1.5">
<Input
type="text"
aria-label={translate(locale, 'editor.find.replaceLabel')}
placeholder={translate(locale, 'editor.find.replacePlaceholder')}
value={replacement}
onChange={(event) => setReplacement(event.target.value)}
className="h-7 min-w-[12rem] flex-1 rounded px-2 text-xs"
data-testid="raw-editor-replace-input"
/>
<Button
type="button"
variant="outline"
size="xs"
disabled={!hasMatches}
onClick={replaceCurrent}
>
{translate(locale, 'editor.find.replace')}
</Button>
<Button
type="button"
variant="outline"
size="xs"
disabled={!hasMatches}
onClick={replaceAll}
>
{translate(locale, 'editor.find.replaceAll')}
</Button>
</div>
)
}
export function RawEditorFindBar(props: RawEditorFindBarProps) {
const { locale = 'en', onReplaceOpenChange, open, replaceOpen } = props
const controller = useRawEditorFindController(props)
const {
caseSensitive,
close,
findInputRef,
handleBarKeyDown,
handleFindChange,
handleFindKeyDown,
hasMatches,
moveNext,
movePrevious,
query,
regex,
replaceAll,
replaceCurrent,
replacement,
setReplacement,
status,
toggleCaseSensitive,
toggleRegex,
} = controller
if (!open) return null
return (
<div
className="flex shrink-0 flex-col gap-1.5 border-b px-3 py-2"
data-testid="raw-editor-find-bar"
onKeyDown={handleBarKeyDown}
style={{
background: 'var(--surface-editor)',
borderColor: 'var(--border-subtle)',
}}
>
<FindControls
caseSensitive={caseSensitive}
close={close}
findInputRef={findInputRef}
handleFindChange={handleFindChange}
handleFindKeyDown={handleFindKeyDown}
hasMatches={hasMatches}
locale={locale}
moveNext={moveNext}
movePrevious={movePrevious}
onReplaceOpenChange={onReplaceOpenChange}
query={query}
regex={regex}
replaceOpen={replaceOpen}
status={status}
toggleCaseSensitive={toggleCaseSensitive}
toggleRegex={toggleRegex}
/>
{replaceOpen && (
<ReplaceControls
hasMatches={hasMatches}
locale={locale}
replaceAll={replaceAll}
replaceCurrent={replaceCurrent}
replacement={replacement}
setReplacement={setReplacement}
/>
)}
</div>
)
}

View File

@@ -9,7 +9,6 @@ const {
detectYamlErrorMock,
extractWikilinkQueryMock,
getRawEditorDropdownPositionMock,
insertWikilinkAtCursorMock,
noteSearchListState,
replaceActiveWikilinkQueryMock,
trackEventMock,
@@ -22,7 +21,6 @@ const {
detectYamlErrorMock: vi.fn(),
extractWikilinkQueryMock: vi.fn(),
getRawEditorDropdownPositionMock: vi.fn(),
insertWikilinkAtCursorMock: vi.fn(),
noteSearchListState: { lastProps: null as null | Record<string, unknown> },
replaceActiveWikilinkQueryMock: vi.fn(),
trackEventMock: vi.fn(),
@@ -43,10 +41,6 @@ vi.mock('../utils/rawEditorUtils', () => ({
replaceActiveWikilinkQuery: replaceActiveWikilinkQueryMock,
}))
vi.mock('../utils/rawEditorInsertions', () => ({
insertWikilinkAtCursor: insertWikilinkAtCursorMock,
}))
vi.mock('../utils/typeColors', () => ({
buildTypeEntryMap: buildTypeEntryMapMock,
}))
@@ -124,30 +118,6 @@ function createMockView(docText = '[[Target') {
}
}
function createMockDataTransfer(seedData: Record<string, string>): DataTransfer {
const data = new Map(Object.entries(seedData))
const types = Array.from(data.keys())
return {
dropEffect: 'none',
effectAllowed: 'move',
setData(type: string, value: string) {
data.set(type, value)
if (!types.includes(type)) types.push(type)
},
getData(type: string) {
return data.get(type) ?? ''
},
clearData() {
data.clear()
types.splice(0, types.length)
},
get types() {
return types
},
} as DataTransfer
}
describe('RawEditorView behavior coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -169,10 +139,6 @@ describe('RawEditorView behavior coverage', () => {
text: '[[Inserted]]',
cursor: 12,
})
insertWikilinkAtCursorMock.mockReturnValue({
text: 'Before [[Projects/Alpha]]',
cursor: 'Before [[Projects/Alpha]]'.length,
})
})
afterEach(() => {
@@ -347,36 +313,4 @@ describe('RawEditorView behavior coverage', () => {
expect(callbacks.onEscape()).toBe(true)
})
it('inserts a canonical wikilink when a note is dropped onto the raw editor', () => {
const onContentChange = vi.fn()
const mockView = createMockView('Before ')
viewRefState.current = mockView
render(
<RawEditorView
content="Before "
path="/vault/a.md"
entries={[entry('Alpha')]}
onContentChange={onContentChange}
onSave={vi.fn()}
vaultPath="/vault"
/>,
)
fireEvent.drop(screen.getByTestId('raw-editor-codemirror'), {
dataTransfer: createMockDataTransfer({
'application/x-laputa-note-path': '/vault/Projects/Alpha.md',
'text/plain': '/vault/Projects/Alpha.md',
}),
})
expect(insertWikilinkAtCursorMock).toHaveBeenCalledWith('Before ', 'Before '.length, 'Projects/Alpha')
expect(mockView.dispatch).toHaveBeenCalledWith({
changes: { from: 0, to: 'Before '.length, insert: 'Before [[Projects/Alpha]]' },
selection: { anchor: 'Before [[Projects/Alpha]]'.length },
})
expect(onContentChange).toHaveBeenCalledWith('/vault/a.md', 'Before [[Projects/Alpha]]')
expect(trackEventMock).toHaveBeenCalledWith('wikilink_inserted')
expect(mockView.focus).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,8 +1,6 @@
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
import { trackEvent } from '../lib/telemetry'
import type { EditorView } from '@codemirror/view'
import { useNoteWikilinkDrop } from '../hooks/useNoteWikilinkDrop'
import { insertWikilinkAtCursor } from '../utils/rawEditorInsertions'
import { MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { buildTypeEntryMap } from '../utils/typeColors'
import { NoteSearchList } from './NoteSearchList'
@@ -18,6 +16,7 @@ import {
import { useCodeMirror } from '../hooks/useCodeMirror'
import type { VaultEntry } from '../types'
import { translate, type AppLocale } from '../lib/i18n'
import { RawEditorFindBar, type RawEditorFindRequest } from './RawEditorFindBar'
export interface RawEditorViewProps {
content: string
@@ -30,6 +29,7 @@ export interface RawEditorViewProps {
* Allows the parent to flush debounced content before unmount. */
latestContentRef?: React.MutableRefObject<string | null>
locale?: AppLocale
findRequest?: RawEditorFindRequest | null
}
const DEBOUNCE_MS = 500
@@ -331,48 +331,79 @@ function useRawEditorWikilinkInsertion({
applyWikilinkChange(view, replacement)
}, [applyWikilinkChange, viewRef])
const insertDroppedWikilink = useCallback((target: string) => {
const view = viewRef.current
if (!view) return
applyWikilinkChange(
view,
insertWikilinkAtCursor(view.state.doc.toString(), view.state.selection.main.head, target),
)
}, [applyWikilinkChange, viewRef])
useEffect(() => { insertWikilinkRef.current = insertAutocompleteWikilink }, [insertAutocompleteWikilink, insertWikilinkRef])
return { insertDroppedWikilink }
}
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath, locale = 'en' }: RawEditorViewProps) {
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath, locale = 'en', findRequest }: RawEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [rawDoc, setRawDoc] = useState(content)
const [findOpen, setFindOpen] = useState(false)
const [replaceOpen, setReplaceOpen] = useState(false)
const pendingChanges = useRawEditorPendingChanges({ content, latestContentRef, onContentChange, onSave, path })
const autocompleteController = useRawEditorAutocompleteController({ entries, vaultPath })
const {
autocomplete,
handleAutocompleteKey,
handleCursorActivity,
handleEscape: handleAutocompleteEscape,
handleItemHover,
insertWikilinkRef,
setAutocomplete,
} = useRawEditorAutocompleteController({ entries, vaultPath })
const handleDocChange = useCallback((doc: string) => {
setRawDoc(doc)
pendingChanges.handleDocChange(doc)
}, [pendingChanges])
const handleEscape = useCallback(() => {
if (handleAutocompleteEscape()) return true
if (!findOpen) return false
setFindOpen(false)
return true
}, [findOpen, handleAutocompleteEscape])
const viewRef = useCodeMirror(containerRef, content, {
onDocChange: pendingChanges.handleDocChange,
onCursorActivity: autocompleteController.handleCursorActivity,
onDocChange: handleDocChange,
onCursorActivity: handleCursorActivity,
onSave: pendingChanges.handleSave,
onEscape: autocompleteController.handleEscape,
onEscape: handleEscape,
})
const { insertDroppedWikilink } = useRawEditorWikilinkInsertion({
useRawEditorWikilinkInsertion({
debounceRef: pendingChanges.debounceRef,
insertWikilinkRef: autocompleteController.insertWikilinkRef,
insertWikilinkRef,
latestDocRef: pendingChanges.latestDocRef,
onContentChangeRef: pendingChanges.onContentChangeRef,
pathRef: pendingChanges.pathRef,
setAutocomplete: autocompleteController.setAutocomplete,
setAutocomplete,
viewRef,
})
useNoteWikilinkDrop({ containerRef, onInsertTarget: insertDroppedWikilink, vaultPath })
const dropdownPosition = getRawEditorDropdownPosition(autocompleteController.autocomplete, DROPDOWN_MAX_HEIGHT, window)
useEffect(() => {
setRawDoc(content)
}, [content])
useEffect(() => {
if (!findRequest || findRequest.path !== path) return
setAutocomplete(null)
setFindOpen(true)
setReplaceOpen(findRequest.replace)
}, [findRequest, path, setAutocomplete])
const dropdownPosition = getRawEditorDropdownPosition(autocomplete, DROPDOWN_MAX_HEIGHT, window)
return (
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }} onKeyDown={autocompleteController.handleAutocompleteKey} role="presentation">
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }} onKeyDown={handleAutocompleteKey} role="presentation">
<RawEditorYamlErrorBanner error={pendingChanges.yamlError} />
<RawEditorFindBar
doc={rawDoc}
locale={locale}
onClose={() => setFindOpen(false)}
onReplaceOpenChange={setReplaceOpen}
open={findOpen}
path={path}
replaceOpen={replaceOpen}
request={findRequest}
viewRef={viewRef}
/>
<div
ref={containerRef}
className="raw-editor-codemirror flex flex-1 min-h-0"
@@ -380,8 +411,8 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
aria-label={translate(locale, 'editor.raw.label')}
/>
<RawEditorAutocompleteDropdown
autocomplete={autocompleteController.autocomplete}
onItemHover={autocompleteController.handleItemHover}
autocomplete={autocomplete}
onItemHover={handleItemHover}
position={dropdownPosition}
/>
</div>

View File

@@ -17,6 +17,8 @@ const emptySettings: Settings = {
release_channel: null,
theme_mode: null,
ui_language: null,
default_ai_agent: null,
hide_gitignored_files: null,
}
function installPointerCapturePolyfill() {
@@ -78,7 +80,7 @@ describe('SettingsPanel', () => {
rerender(
<SettingsPanel
open={true}
settings={{ ...emptySettings, ui_language: 'zh-Hans' }}
settings={{ ...emptySettings, ui_language: 'zh-CN' }}
onSave={onSave}
onClose={onClose}
/>
@@ -102,6 +104,21 @@ describe('SettingsPanel', () => {
autogit_inactive_threshold_seconds: 30,
release_channel: null,
theme_mode: 'light',
hide_gitignored_files: true,
}))
expect(onClose).toHaveBeenCalled()
})
it('saves Gitignored content visibility immediately for keyboard close', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByTestId('settings-hide-gitignored-files'))
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Escape' })
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
hide_gitignored_files: false,
}))
expect(onClose).toHaveBeenCalled()
})
@@ -122,7 +139,7 @@ describe('SettingsPanel', () => {
open={true}
settings={emptySettings}
locale="en"
systemLocale="zh-Hans"
systemLocale="zh-CN"
onSave={onSave}
onClose={onClose}
/>
@@ -157,7 +174,7 @@ describe('SettingsPanel', () => {
fireEvent.click(screen.getByTestId('settings-save'))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
ui_language: 'zh-Hans',
ui_language: 'zh-CN',
}))
})

View File

@@ -18,6 +18,7 @@ import {
import { Moon, Sun, X } from '@phosphor-icons/react'
import type { Settings } from '../types'
import {
APP_LOCALES,
SYSTEM_UI_LANGUAGE,
createTranslator,
localeDisplayName,
@@ -32,6 +33,7 @@ import {
type ThemeMode,
} from '../lib/themeMode'
import { normalizeReleaseChannel, serializeReleaseChannel, type ReleaseChannel } from '../lib/releaseChannel'
import { shouldHideGitignoredFiles } from '../lib/gitignoredVisibility'
import { trackEvent } from '../lib/telemetry'
import { Button } from './ui/button'
import { Checkbox, type CheckedState } from './ui/checkbox'
@@ -69,6 +71,7 @@ interface SettingsDraft {
themeMode: ThemeMode
uiLanguage: UiLanguagePreference
initialH1AutoRename: boolean
hideGitignoredFiles: boolean
crashReporting: boolean
analytics: boolean
explicitOrganization: boolean
@@ -100,6 +103,8 @@ interface SettingsBodyProps {
systemLocale: AppLocale
initialH1AutoRename: boolean
setInitialH1AutoRename: (value: boolean) => void
hideGitignoredFiles: boolean
setHideGitignoredFiles: (value: boolean) => void
explicitOrganization: boolean
setExplicitOrganization: (value: boolean) => void
crashReporting: boolean
@@ -138,6 +143,7 @@ function createSettingsDraft(
themeMode: resolveSettingsDraftThemeMode(settings.theme_mode),
uiLanguage: settings.ui_language ?? SYSTEM_UI_LANGUAGE,
initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true,
hideGitignoredFiles: shouldHideGitignoredFiles(settings),
crashReporting: settings.crash_reporting_enabled ?? false,
analytics: settings.analytics_enabled ?? false,
explicitOrganization: explicitOrganizationEnabled,
@@ -179,6 +185,7 @@ function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Setti
ui_language: serializeUiLanguagePreference(draft.uiLanguage),
initial_h1_auto_rename_enabled: draft.initialH1AutoRename,
default_ai_agent: draft.defaultAiAgent,
hide_gitignored_files: draft.hideGitignoredFiles,
}
}
@@ -267,6 +274,11 @@ function SettingsPanelInner({
[],
)
const handleGitignoredVisibilityChange = useCallback((value: boolean) => {
updateDraft('hideGitignoredFiles', value)
onSave({ ...settings, hide_gitignored_files: value })
}, [onSave, settings, updateDraft])
const handleSave = useCallback(() => {
trackTelemetryConsentChange(settings.analytics_enabled === true, draft.analytics)
onSave(buildSettingsFromDraft(settings, draft))
@@ -337,6 +349,8 @@ function SettingsPanelInner({
setUiLanguage={(value) => updateDraft('uiLanguage', value)}
initialH1AutoRename={draft.initialH1AutoRename}
setInitialH1AutoRename={(value) => updateDraft('initialH1AutoRename', value)}
hideGitignoredFiles={draft.hideGitignoredFiles}
setHideGitignoredFiles={handleGitignoredVisibilityChange}
explicitOrganization={draft.explicitOrganization}
setExplicitOrganization={(value) => updateDraft('explicitOrganization', value)}
crashReporting={draft.crashReporting}
@@ -396,6 +410,8 @@ function SettingsBody({
setUiLanguage,
initialH1AutoRename,
setInitialH1AutoRename,
hideGitignoredFiles,
setHideGitignoredFiles,
explicitOrganization,
setExplicitOrganization,
crashReporting,
@@ -454,6 +470,14 @@ function SettingsBody({
/>
</SettingsSection>
<SettingsSection>
<VaultContentSettingsSection
t={t}
hideGitignoredFiles={hideGitignoredFiles}
setHideGitignoredFiles={setHideGitignoredFiles}
/>
</SettingsSection>
<SettingsSection>
<AiAgentSettingsSection
t={t}
@@ -673,8 +697,10 @@ function buildLanguageOptions(t: Translate, locale: AppLocale, systemLocale: App
language: localeDisplayName(systemLocale, locale),
}),
},
{ value: 'en', label: t('settings.language.en') },
{ value: 'zh-Hans', label: t('settings.language.zhHans') },
...APP_LOCALES.map((appLocale) => ({
value: appLocale,
label: localeDisplayName(appLocale, locale),
})),
]
}
@@ -730,6 +756,29 @@ function TitleSettingsSection({
)
}
function VaultContentSettingsSection({
t,
hideGitignoredFiles,
setHideGitignoredFiles,
}: Pick<SettingsBodyProps, 't' | 'hideGitignoredFiles' | 'setHideGitignoredFiles'>) {
return (
<>
<SectionHeading
title={t('settings.vaultContent.title')}
description={t('settings.vaultContent.description')}
/>
<SettingsSwitchRow
label={t('settings.vaultContent.hideGitignored')}
description={t('settings.vaultContent.hideGitignoredDescription')}
checked={hideGitignoredFiles}
onChange={setHideGitignoredFiles}
testId="settings-hide-gitignored-files"
/>
</>
)
}
function buildDefaultAiAgentOptions(aiAgentsStatus: AiAgentsStatus, t: Translate): Array<{ value: string; label: string }> {
return AI_AGENT_DEFINITIONS.map((definition) => {
const status = aiAgentsStatus[definition.id]

View File

@@ -23,6 +23,7 @@ import {
} from './sidebar/SidebarSections'
import { useSidebarTypeInteractions } from './sidebar/useSidebarTypeInteractions'
import type { AppLocale } from '../lib/i18n'
import type { FolderFileActions } from '../hooks/useFileActions'
interface SidebarProps {
entries: VaultEntry[]
@@ -46,6 +47,7 @@ interface SidebarProps {
onCreateFolder?: (name: string) => Promise<boolean> | boolean
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
onDeleteFolder?: (folderPath: string) => void
folderFileActions?: FolderFileActions
renamingFolderPath?: string | null
onStartRenameFolder?: (folderPath: string) => void
onCancelRenameFolder?: () => void
@@ -70,6 +72,7 @@ interface SidebarNavigationProps extends Pick<
| 'onCreateFolder'
| 'onRenameFolder'
| 'onDeleteFolder'
| 'folderFileActions'
| 'renamingFolderPath'
| 'onStartRenameFolder'
| 'onCancelRenameFolder'
@@ -107,6 +110,7 @@ function SidebarNavigation({
onCreateFolder,
onRenameFolder,
onDeleteFolder,
folderFileActions,
renamingFolderPath,
onStartRenameFolder,
onCancelRenameFolder,
@@ -194,6 +198,7 @@ function SidebarNavigation({
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
folderFileActions={folderFileActions}
renamingFolderPath={renamingFolderPath}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={onCancelRenameFolder}
@@ -205,6 +210,13 @@ function SidebarNavigation({
)
}
function useSidebarDndSensors() {
return useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
}
export const Sidebar = memo(function Sidebar({
entries,
selection,
@@ -224,6 +236,7 @@ export const Sidebar = memo(function Sidebar({
onCreateFolder,
onRenameFolder,
onDeleteFolder,
folderFileActions,
renamingFolderPath,
onStartRenameFolder,
onCancelRenameFolder,
@@ -247,10 +260,7 @@ export const Sidebar = memo(function Sidebar({
const isSectionVisible = useCallback((type: string) => typeEntryMap[type]?.visible !== false, [typeEntryMap])
const toggleVisibility = useCallback((type: string) => onToggleTypeVisibility?.(type), [onToggleTypeVisibility])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const sensors = useSidebarDndSensors()
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
@@ -288,6 +298,7 @@ export const Sidebar = memo(function Sidebar({
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
folderFileActions={folderFileActions}
renamingFolderPath={renamingFolderPath}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={onCancelRenameFolder}

View File

@@ -4,8 +4,6 @@ import type { ReactNode } from 'react'
import type { VaultEntry } from '../types'
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
const NOTE_DRAG_MIME = 'application/x-laputa-note-path'
const state = vi.hoisted(() => ({
capturedLinkToolbarProps: null as null | Record<string, unknown>,
capturedToolbarProps: null as null | Record<string, unknown>,
@@ -256,28 +254,48 @@ function createEditor() {
}
}
function createMockDataTransfer(seedData: Record<string, string>): DataTransfer {
const data = new Map(Object.entries(seedData))
const types = Array.from(data.keys())
function renderEditorHarness(editor = createEditor()) {
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
return {
dropEffect: 'none',
effectAllowed: 'move',
setData(type: string, value: string) {
data.set(type, value)
if (!types.includes(type)) types.push(type)
},
getData(type: string) {
return data.get(type) ?? ''
},
clearData() {
data.clear()
types.splice(0, types.length)
},
get types() {
return types
},
} as DataTransfer
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
expect(container).toBeTruthy()
return { container: container!, editor }
}
function createCodeBlockFixture(text: string) {
const codeBlock = document.createElement('div')
codeBlock.setAttribute('data-content-type', 'codeBlock')
const pre = document.createElement('pre')
const code = document.createElement('code')
code.textContent = text
pre.appendChild(code)
codeBlock.appendChild(pre)
return { codeBlock, code }
}
function selectNodeContents(node: Node) {
const range = document.createRange()
range.selectNodeContents(node)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
function appendToolbarButton(container: Element, className: string, text: string) {
const toolbar = document.createElement('div')
toolbar.className = className
const button = document.createElement('button')
button.type = 'button'
button.textContent = text
toolbar.appendChild(button)
container.appendChild(toolbar)
return button
}
describe('SingleEditorView', () => {
@@ -359,54 +377,6 @@ describe('SingleEditorView', () => {
)
})
it('inserts a canonical wikilink when a note is dropped onto the editor surface', () => {
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
vaultPath="/vault"
/>,
)
const noteDropData = createMockDataTransfer({
[NOTE_DRAG_MIME]: '/vault/Projects/Alpha.md',
'text/plain': '/vault/Projects/Alpha.md',
})
fireEvent.drop(screen.getByTestId('blocknote-view').parentElement as HTMLElement, {
dataTransfer: noteDropData,
})
expect(editor.insertInlineContent).toHaveBeenCalledWith([
{ type: 'wikilink', props: { target: 'Projects/Alpha' } },
' ',
], { updateSelection: true })
})
it('ignores dropped plain text that is not a markdown note path', () => {
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
vaultPath="/vault"
/>,
)
fireEvent.drop(screen.getByTestId('blocknote-view').parentElement as HTMLElement, {
dataTransfer: createMockDataTransfer({
'text/plain': 'Just some dragged text',
}),
})
expect(editor.insertInlineContent).not.toHaveBeenCalled()
})
it('wires the toolbar mouse guard and suggestion item click handlers', () => {
const editor = createEditor()
render(
@@ -522,6 +492,39 @@ describe('SingleEditorView', () => {
expect(onChange).toHaveBeenCalledTimes(1)
})
it('copies selected fenced code text without markdown escape backslashes', () => {
const json = '{\n "id": "Demo"\n}'
const { container } = renderEditorHarness()
const { codeBlock, code } = createCodeBlockFixture(json)
container.appendChild(codeBlock)
selectNodeContents(code)
const clipboardData = { setData: vi.fn() }
fireEvent.copy(code, { clipboardData })
expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', json)
})
it('does not override full-note copy selections that merely include a code block', () => {
const { container } = renderEditorHarness()
const paragraph = document.createElement('p')
paragraph.textContent = 'Before'
const { codeBlock, code } = createCodeBlockFixture('const value = 1')
container.append(paragraph, codeBlock)
const range = document.createRange()
range.setStartBefore(paragraph)
range.setEndAfter(codeBlock)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
const clipboardData = { setData: vi.fn() }
fireEvent.copy(code, { clipboardData })
expect(clipboardData.setData).not.toHaveBeenCalled()
})
it('routes clicks on the empty title wrapper back into the H1 block', async () => {
const editor = createEditor()
@@ -563,26 +566,8 @@ describe('SingleEditorView', () => {
})
it('ignores editor-container click handling for link toolbar interactions', () => {
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
expect(container).toBeTruthy()
const linkToolbar = document.createElement('div')
linkToolbar.className = 'bn-link-toolbar'
const linkAction = document.createElement('button')
linkAction.type = 'button'
linkAction.textContent = 'Open in a new tab'
linkToolbar.appendChild(linkAction)
container?.appendChild(linkToolbar)
const { container, editor } = renderEditorHarness()
const linkAction = appendToolbarButton(container, 'bn-link-toolbar', 'Open in a new tab')
fireEvent.click(linkAction)
@@ -591,26 +576,8 @@ describe('SingleEditorView', () => {
})
it('ignores editor-container click handling for BlockNote side-menu actions', () => {
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
expect(container).toBeTruthy()
const sideMenu = document.createElement('div')
sideMenu.className = 'bn-side-menu'
const action = document.createElement('button')
action.type = 'button'
action.textContent = 'Add block'
sideMenu.appendChild(action)
container?.appendChild(sideMenu)
const { container, editor } = renderEditorHarness()
const action = appendToolbarButton(container, 'bn-side-menu', 'Add block')
fireEvent.click(action)

View File

@@ -20,7 +20,6 @@ import { ExternalLink } from 'lucide-react'
import { useDocumentThemeMode } from '../hooks/useDocumentThemeMode'
import { useEditorTheme } from '../hooks/useTheme'
import { useImageDrop } from '../hooks/useImageDrop'
import { useNoteWikilinkDrop } from '../hooks/useNoteWikilinkDrop'
import { buildTypeEntryMap } from '../utils/typeColors'
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { filterPersonMentions, PERSON_MENTION_MIN_QUERY } from '../utils/personMentionSuggestions'
@@ -225,6 +224,62 @@ function isSelectionInsideElement(element: HTMLElement): boolean {
const TITLE_HEADING_SELECTOR = 'h1, [data-content-type="heading"][data-level="1"], [data-content-type="heading"]:not([data-level])'
const TITLE_HEADING_WRAPPER_SELECTOR = '.bn-block-outer, .bn-block'
const CODE_BLOCK_SELECTOR = '[data-content-type="codeBlock"]'
function nodeElement(node: Node | null): HTMLElement | null {
if (!node) return null
if (node instanceof HTMLElement) return node
return node.parentElement
}
function hasSingleActiveRange(selection: Selection | null): selection is Selection {
return Boolean(selection && selection.rangeCount === 1 && !selection.isCollapsed)
}
function closestCodeBlockInContainer(options: {
range: Range
container: HTMLElement
}): HTMLElement | null {
const { range, container } = options
const codeBlock = nodeElement(range.commonAncestorContainer)
?.closest<HTMLElement>(CODE_BLOCK_SELECTOR)
return codeBlock && container.contains(codeBlock) ? codeBlock : null
}
function nodeBelongsToElement(node: Node, element: HTMLElement): boolean {
const elementNode = nodeElement(node)
return Boolean(elementNode && element.contains(elementNode))
}
function rangeBelongsToElement(range: Range, element: HTMLElement): boolean {
return nodeBelongsToElement(range.startContainer, element)
&& nodeBelongsToElement(range.endContainer, element)
}
function selectedCodeBlockRange(options: {
selection: Selection | null
container: HTMLElement
}): Range | null {
const { selection, container } = options
if (!hasSingleActiveRange(selection)) return null
const range = selection.getRangeAt(0)
const codeBlock = closestCodeBlockInContainer({ range, container })
if (!codeBlock || !rangeBelongsToElement(range, codeBlock)) return null
return range
}
function selectedCodeBlockText(options: {
selection: Selection | null
container: HTMLElement
}): string | null {
const range = selectedCodeBlockRange(options)
if (!range) return null
return options.selection?.toString() || range.cloneContents().textContent || ''
}
function findTitleHeadingElement(target: HTMLElement): HTMLElement | null {
const directHeading = target.closest<HTMLElement>(TITLE_HEADING_SELECTOR)
@@ -335,6 +390,17 @@ function useCompositionAwareEditorChange(options: {
}, [])
}
function handleCodeBlockCopy(event: React.ClipboardEvent<HTMLDivElement>) {
const codeText = selectedCodeBlockText({
selection: window.getSelection(),
container: event.currentTarget,
})
if (codeText === null) return
event.clipboardData.setData('text/plain', codeText)
event.preventDefault()
}
function buildBaseSuggestionItems(entries: VaultEntry[]) {
return deduplicateByPath(entries.map(entry => ({
title: entry.title,
@@ -448,7 +514,6 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(() => buildBaseSuggestionItems(entries), [entries])
const insertWikilink = useInsertWikilink(editor)
useNoteWikilinkDrop({ containerRef, onInsertTarget: insertWikilink, vaultPath })
const {
getWikilinkItems,
getPersonMentionItems,
@@ -462,7 +527,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
})
return (
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties} onClick={handleContainerClick}>
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties} onClick={handleContainerClick} onCopyCapture={handleCodeBlockCopy}>
{isDragOver && (
<div className="editor__drop-overlay">
<div className="editor__drop-overlay-label">Drop image here</div>

View File

@@ -19,6 +19,8 @@ const vaults: VaultOption[] = [
const installedAiAgentsStatus = {
claude_code: { status: 'installed' as const, version: '1.0.20' },
codex: { status: 'installed' as const, version: '0.37.0' },
opencode: { status: 'installed' as const, version: '0.3.1' },
pi: { status: 'installed' as const, version: '0.70.2' },
}
const DEFAULT_WINDOW_WIDTH = 1280
@@ -85,6 +87,11 @@ describe('StatusBar', () => {
expect(screen.getByText('b?')).toBeInTheDocument()
})
it('shows the vault reload badge while a reload is active', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} isVaultReloading />)
expect(screen.getByTestId('status-vault-reloading')).toHaveAccessibleName('Reloading vault from disk')
})
it('calls onCheckForUpdates when clicking build number', () => {
const onCheckForUpdates = vi.fn()
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} buildNumber="b281" onCheckForUpdates={onCheckForUpdates} />)
@@ -131,6 +138,27 @@ describe('StatusBar', () => {
expect(screen.queryByLabelText('Notifications are coming soon')).not.toBeInTheDocument()
})
it('end-aligns the theme tooltip to keep it inside the right window edge', async () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
themeMode="light"
onToggleThemeMode={vi.fn()}
onOpenSettings={vi.fn()}
/>,
)
act(() => {
fireEvent.focus(screen.getByTestId('status-theme-mode'))
})
const tooltip = await screen.findByTestId('status-theme-mode-tooltip')
expect(tooltip).toHaveAttribute('data-align', 'end')
expect(tooltip).toHaveTextContent('Switch to dark mode')
})
it('calls onToggleThemeMode from the bottom bar', () => {
const onToggleThemeMode = vi.fn()
render(

View File

@@ -70,6 +70,7 @@ interface StatusBarProps {
onCommitPush?: () => void
onInitializeGit?: () => void
isOffline?: boolean
isVaultReloading?: boolean
isGitVault?: boolean
syncStatus?: SyncStatus
lastSyncTime?: number | null
@@ -117,6 +118,7 @@ function StatusBarPrimaryFromFooter({
onCommitPush,
onInitializeGit,
isOffline = false,
isVaultReloading = false,
isGitVault = true,
syncStatus = 'idle',
lastSyncTime = null,
@@ -156,6 +158,7 @@ function StatusBarPrimaryFromFooter({
onCommitPush={onCommitPush}
onInitializeGit={onInitializeGit}
isOffline={isOffline}
isVaultReloading={isVaultReloading}
isGitVault={isGitVault}
syncStatus={syncStatus}
lastSyncTime={lastSyncTime}

View File

@@ -50,7 +50,7 @@ function makeReadyStatus(overrides?: Partial<Extract<UpdateStatus, { state: 'rea
}
}
function renderBanner(status: UpdateStatus, actions = makeActions(), locale: 'en' | 'zh-Hans' = 'en') {
function renderBanner(status: UpdateStatus, actions = makeActions(), locale: 'en' | 'zh-CN' = 'en') {
const view = render(<UpdateBanner status={status} actions={actions} locale={locale} />)
return { ...view, actions }
}
@@ -87,7 +87,7 @@ describe('UpdateBanner', () => {
renderBanner(makeAvailableStatus({
version: '2026.4.16-alpha.3',
displayVersion: 'Alpha 2026.4.16.3',
}), makeActions(), 'zh-Hans')
}), makeActions(), 'zh-CN')
expect(screen.getByText(/Tolaria Alpha 2026\.4\.16\.3/)).toBeTruthy()
expect(screen.getByText(/可用/)).toBeTruthy()

View File

@@ -326,6 +326,34 @@ describe('WikilinkChatInput', () => {
})
})
it('does not duplicate syllables across consecutive IME compositions', async () => {
const onDraftChange = vi.fn()
render(<Controlled onDraftChange={onDraftChange} />)
const firstEditor = screen.getByTestId('agent-input') as HTMLDivElement
firstEditor.focus()
fireEvent.compositionStart(firstEditor)
firstEditor.appendChild(document.createTextNode('안'))
fireEvent.input(firstEditor)
fireEvent.compositionEnd(firstEditor)
await waitFor(() => {
expect(onDraftChange).toHaveBeenLastCalledWith('안')
})
const secondEditor = screen.getByTestId('agent-input') as HTMLDivElement
secondEditor.focus()
fireEvent.compositionStart(secondEditor)
secondEditor.appendChild(document.createTextNode('녕'))
fireEvent.input(secondEditor)
fireEvent.compositionEnd(secondEditor)
await waitFor(() => {
expect(onDraftChange).toHaveBeenLastCalledWith('안녕')
})
expect(screen.getByTestId('agent-input').textContent).toBe('안녕')
})
it('does not steal focus back if it was moved elsewhere during composition end', async () => {
const onDraftChange = vi.fn()
render(
@@ -521,6 +549,7 @@ describe('WikilinkChatInput', () => {
it('treats missing inputType as a non-insert beforeinput event', () => {
expect(() => isInsertBeforeInput({} as InputEvent)).not.toThrow()
expect(isInsertBeforeInput({} as InputEvent)).toBe(false)
expect(isInsertBeforeInput({ inputType: 42 })).toBe(false)
expect(isInsertBeforeInput({ inputType: 'insertFromPaste' } as InputEvent)).toBe(true)
})
@@ -539,6 +568,26 @@ describe('WikilinkChatInput', () => {
expect(editor.textContent).toContain('still works')
})
it('ignores beforeinput events with non-string inputType instead of calling startsWith', () => {
render(<Controlled />)
const editor = screen.getByTestId('agent-input')
const startsWith = vi.fn(() => true)
const beforeInputEvent = new Event('beforeinput', {
bubbles: true,
cancelable: true,
})
Object.defineProperty(beforeInputEvent, 'inputType', {
value: { startsWith },
})
expect(() => fireEvent(editor, beforeInputEvent)).not.toThrow()
expect(startsWith).not.toHaveBeenCalled()
updateEditorText('still works')
expect(editor.textContent).toContain('still works')
})
it('recovers if unsupported media lands in the editor DOM', async () => {
const onUnsupportedPaste = vi.fn()
render(<Controlled onUnsupportedPaste={onUnsupportedPaste} />)

View File

@@ -12,6 +12,43 @@ function setRect(element: HTMLElement, nextRect: DOMRect) {
element.getBoundingClientRect = () => nextRect
}
function blockNoteHoverFixture() {
const container = document.createElement('div')
const editor = document.createElement('div')
editor.className = 'bn-editor'
container.appendChild(editor)
document.body.appendChild(container)
const sideMenu = document.createElement('div')
sideMenu.className = 'bn-side-menu'
const sideMenuButton = document.createElement('button')
sideMenu.appendChild(sideMenuButton)
document.body.appendChild(sideMenu)
setRect(editor, rect(240, 90, 420, 32))
setRect(sideMenu, rect(190, 96, 32, 24))
return { container, sideMenuButton }
}
function expectHoverSuppression(options: {
eventTarget?: EventTarget
point: { x: number; y: number }
hasPressedButton?: boolean
expected: boolean
}) {
const { container, sideMenuButton } = blockNoteHoverFixture()
expect(
shouldSuppressBlockNoteHandleHoverUpdate({
eventTarget: options.eventTarget ?? sideMenuButton,
point: options.point,
container,
doc: document,
hasPressedButton: options.hasPressedButton,
}),
).toBe(options.expected)
}
describe('blockNoteSideMenuHoverGuard', () => {
it('treats the side-menu gutter as part of the hover bridge', () => {
expect(
@@ -34,76 +71,30 @@ describe('blockNoteSideMenuHoverGuard', () => {
})
it('suppresses hover updates when the pointer is already over the side menu', () => {
const container = document.createElement('div')
const editor = document.createElement('div')
editor.className = 'bn-editor'
container.appendChild(editor)
document.body.appendChild(container)
const sideMenu = document.createElement('div')
sideMenu.className = 'bn-side-menu'
const sideMenuButton = document.createElement('button')
sideMenu.appendChild(sideMenuButton)
document.body.appendChild(sideMenu)
setRect(editor, rect(240, 90, 420, 32))
setRect(sideMenu, rect(190, 96, 32, 24))
expect(
shouldSuppressBlockNoteHandleHoverUpdate({
eventTarget: sideMenuButton,
point: { x: 200, y: 110 },
container,
doc: document,
}),
).toBe(true)
expectHoverSuppression({ point: { x: 200, y: 110 }, expected: true })
})
it('suppresses hover updates while the pointer crosses the handle bridge', () => {
const container = document.createElement('div')
const editor = document.createElement('div')
editor.className = 'bn-editor'
container.appendChild(editor)
document.body.appendChild(container)
expectHoverSuppression({
eventTarget: document.body,
point: { x: 226, y: 116 },
expected: true,
})
})
const sideMenu = document.createElement('div')
sideMenu.className = 'bn-side-menu'
document.body.appendChild(sideMenu)
setRect(editor, rect(240, 90, 420, 32))
setRect(sideMenu, rect(190, 96, 32, 24))
expect(
shouldSuppressBlockNoteHandleHoverUpdate({
eventTarget: document.body,
point: { x: 226, y: 116 },
container,
doc: document,
}),
).toBe(true)
it('leaves block handle drag movement alone', () => {
expectHoverSuppression({
point: { x: 200, y: 110 },
hasPressedButton: true,
expected: false,
})
})
it('leaves unrelated pointer movement alone', () => {
const container = document.createElement('div')
const editor = document.createElement('div')
editor.className = 'bn-editor'
container.appendChild(editor)
document.body.appendChild(container)
const sideMenu = document.createElement('div')
sideMenu.className = 'bn-side-menu'
document.body.appendChild(sideMenu)
setRect(editor, rect(240, 90, 420, 32))
setRect(sideMenu, rect(190, 96, 32, 24))
expect(
shouldSuppressBlockNoteHandleHoverUpdate({
eventTarget: document.body,
point: { x: 360, y: 160 },
container,
doc: document,
}),
).toBe(false)
expectHoverSuppression({
eventTarget: document.body,
point: { x: 360, y: 160 },
expected: false,
})
})
})

View File

@@ -29,12 +29,15 @@ export function shouldSuppressBlockNoteHandleHoverUpdate({
point,
container,
doc,
hasPressedButton = false,
}: {
eventTarget: EventTarget | null
point: { x: number; y: number }
container: HTMLElement | null
doc: Document
hasPressedButton?: boolean
}) {
if (hasPressedButton) return false
if (!container) return false
const editor = container.querySelector('.bn-editor')
@@ -68,6 +71,7 @@ export function useBlockNoteSideMenuHoverGuard(
point: { x: event.clientX, y: event.clientY },
container: containerRef.current,
doc,
hasPressedButton: event.buttons !== 0,
})) {
return
}

View File

@@ -108,4 +108,18 @@ describe('EditorContentLayout', () => {
expect(container.firstElementChild).toHaveClass('editor-content-layout--left')
expect(screen.getByTestId('breadcrumb-bar')).toHaveAttribute('data-note-layout', 'left')
})
it('keeps raw mode out of the centered rich-editor content wrapper', () => {
render(<EditorContentLayout {...createModel({
effectiveRawMode: true,
showEditor: false,
noteLayout: 'centered',
})} />)
const rawEditor = screen.getByTestId('raw-editor-view')
const findScope = rawEditor.closest('[data-editor-find-scope="true"]')
expect(findScope).toHaveClass('editor-scroll-area')
expect(rawEditor.closest('.editor-content-wrapper')).toBeNull()
})
})

View File

@@ -1,6 +1,8 @@
import type React from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
import { translate, type AppLocale } from '../../lib/i18n'
import { dispatchEditorFindAvailability } from '../../utils/editorFindEvents'
import { DiffView } from '../DiffView'
import { BreadcrumbBar } from '../BreadcrumbBar'
import { ArchivedNoteBanner } from '../ArchivedNoteBanner'
@@ -26,6 +28,8 @@ type BreadcrumbActions = Pick<
| 'showDiffToggle'
| 'onToggleFavorite'
| 'onToggleOrganized'
| 'onRevealFile'
| 'onCopyFilePath'
| 'onDeleteNote'
| 'onArchiveNote'
| 'onUnarchiveNote'
@@ -67,6 +71,7 @@ function DiffModeView({ diffContent, locale = 'en', onToggleDiff }: { diffConten
function RawModeEditorSection({
activeTab,
entries,
findRequest,
rawMode,
rawModeContent,
onRawContentChange,
@@ -76,7 +81,7 @@ function RawModeEditorSection({
locale,
}: Pick<
EditorContentModel,
'activeTab' | 'entries' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'rawModeContent' | 'vaultPath'
'activeTab' | 'entries' | 'findRequest' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'rawModeContent' | 'vaultPath'
> & {
rawMode: boolean
locale?: AppLocale
@@ -84,17 +89,20 @@ function RawModeEditorSection({
if (!rawMode || !activeTab) return null
return (
<RawEditorView
key={activeTab.entry.path}
content={rawModeContent ?? activeTab.content}
path={activeTab.entry.path}
entries={entries}
onContentChange={onRawContentChange ?? (() => {})}
onSave={onSave ?? (() => {})}
latestContentRef={rawLatestContentRef}
vaultPath={vaultPath}
locale={locale}
/>
<EditorFindScope className="editor-scroll-area">
<RawEditorView
key={activeTab.entry.path}
content={rawModeContent ?? activeTab.content}
path={activeTab.entry.path}
entries={entries}
findRequest={findRequest}
onContentChange={onRawContentChange ?? (() => {})}
onSave={onSave ?? (() => {})}
latestContentRef={rawLatestContentRef}
vaultPath={vaultPath}
locale={locale}
/>
</EditorFindScope>
)
}
@@ -135,6 +143,8 @@ function ActiveTabBreadcrumb({
onToggleInspector={actions.onToggleInspector}
onToggleFavorite={bindPath(actions.onToggleFavorite, path)}
onToggleOrganized={bindPath(actions.onToggleOrganized, path)}
onRevealFile={actions.onRevealFile}
onCopyFilePath={actions.onCopyFilePath}
onDelete={bindPath(actions.onDeleteNote, path)}
onArchive={bindPath(actions.onArchiveNote, path)}
onUnarchive={bindPath(actions.onUnarchiveNote, path)}
@@ -201,7 +211,7 @@ function EditorCanvas({
if (!showEditor) return null
return (
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<EditorFindScope className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<div className="editor-content-wrapper">
<SingleEditorView
editor={editor}
@@ -212,6 +222,39 @@ function EditorCanvas({
editable={!isDeletedPreview}
/>
</div>
</EditorFindScope>
)
}
function EditorFindScope({
children,
className,
style,
}: {
children: React.ReactNode
className?: string
style?: React.CSSProperties
}) {
const scopeRef = useRef<HTMLDivElement | null>(null)
const syncAvailability = useCallback(() => {
const activeElement = document.activeElement
const enabled = activeElement instanceof Node
&& scopeRef.current?.contains(activeElement) === true
dispatchEditorFindAvailability(enabled)
}, [])
useEffect(() => () => dispatchEditorFindAvailability(false), [])
return (
<div
ref={scopeRef}
className={className}
data-editor-find-scope="true"
onFocusCapture={() => dispatchEditorFindAvailability(true)}
onBlurCapture={() => requestAnimationFrame(syncAvailability)}
style={style}
>
{children}
</div>
)
}
@@ -245,6 +288,7 @@ export function EditorContentLayout(model: EditorContentModel) {
rawLatestContentRef,
rawModeContent,
noteLayout,
findRequest,
locale,
} = model
const rootClassName = cn(
@@ -282,6 +326,8 @@ export function EditorContentLayout(model: EditorContentModel) {
showDiffToggle: model.showDiffToggle,
onToggleFavorite: model.onToggleFavorite,
onToggleOrganized: model.onToggleOrganized,
onRevealFile: model.onRevealFile,
onCopyFilePath: model.onCopyFilePath,
onDeleteNote: model.onDeleteNote,
onArchiveNote: model.onArchiveNote,
onUnarchiveNote: model.onUnarchiveNote,
@@ -305,6 +351,7 @@ export function EditorContentLayout(model: EditorContentModel) {
<RawModeEditorSection
activeTab={activeTab}
entries={entries}
findRequest={findRequest}
rawMode={effectiveRawMode}
rawModeContent={rawModeContent}
onRawContentChange={onRawContentChange}

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